diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 5577fa57ae3c2b211f27bc0074522c1846904b2b..7f25ad580793f09e8eceeafeb9edf90d67a1eff2 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -199,6 +199,7 @@ four standard [pagination arguments](#pagination-arguments): | `trending` | [`Boolean`](#boolean) | Return only projects that are trending. | | `visibilityLevel` | [`VisibilityLevelsEnum`](#visibilitylevelsenum) | Filter projects by visibility level. | | `withCodeEmbeddingsIndexed` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.2. **Status**: Experiment. Include projects with indexed code embeddings. Requires `ids` to be sent. Applies only if the feature flag `allow_with_code_embeddings_indexed_projects_filter` is enabled. | +| `withDuoEligible` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.6. **Status**: Experiment. Include only projects that are eligible for GitLab Duo and have Duo features enabled. | | `withIssuesEnabled` | [`Boolean`](#boolean) | Return only projects with issues enabled. | | `withMergeRequestsEnabled` | [`Boolean`](#boolean) | Return only projects with merge requests enabled. | @@ -1566,6 +1567,7 @@ four standard [pagination arguments](#pagination-arguments): | `trending` | [`Boolean`](#boolean) | Return only projects that are trending. | | `visibilityLevel` | [`VisibilityLevelsEnum`](#visibilitylevelsenum) | Filter projects by visibility level. | | `withCodeEmbeddingsIndexed` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.2. **Status**: Experiment. Include projects with indexed code embeddings. Requires `ids` to be sent. Applies only if the feature flag `allow_with_code_embeddings_indexed_projects_filter` is enabled. | +| `withDuoEligible` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.6. **Status**: Experiment. Include only projects that are eligible for GitLab Duo and have Duo features enabled. | | `withIssuesEnabled` | [`Boolean`](#boolean) | Return only projects with issues enabled. | | `withMergeRequestsEnabled` | [`Boolean`](#boolean) | Return only projects with merge requests enabled. | @@ -38118,6 +38120,7 @@ four standard [pagination arguments](#pagination-arguments): | `trending` | [`Boolean`](#boolean) | Return only projects that are trending. | | `visibilityLevel` | [`VisibilityLevelsEnum`](#visibilitylevelsenum) | Filter projects by visibility level. | | `withCodeEmbeddingsIndexed` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.2. **Status**: Experiment. Include projects with indexed code embeddings. Requires `ids` to be sent. Applies only if the feature flag `allow_with_code_embeddings_indexed_projects_filter` is enabled. | +| `withDuoEligible` {{< icon name="warning-solid" >}} | [`Boolean`](#boolean) | **Introduced** in GitLab 18.6. **Status**: Experiment. Include only projects that are eligible for GitLab Duo and have Duo features enabled. | | `withIssuesEnabled` | [`Boolean`](#boolean) | Return only projects with issues enabled. | | `withMergeRequestsEnabled` | [`Boolean`](#boolean) | Return only projects with merge requests enabled. | diff --git a/ee/app/finders/ee/projects_finder.rb b/ee/app/finders/ee/projects_finder.rb index 4df4428d4505a015d3b3193a6e6d125fcbe2059a..9625d3529637fcadeaebd48e24f83f7d7c31e48e 100644 --- a/ee/app/finders/ee/projects_finder.rb +++ b/ee/app/finders/ee/projects_finder.rb @@ -12,11 +12,10 @@ module EE # aimed_for_deletion: Symbol # include_hidden: boolean # filter_expired_saml_session_projects: boolean - # active: boolean - Whether to include projects that are neither archived or marked - # for deletion. - # with_code_embeddings_indexed: boolean - Whether to include projects that have - # indexed embeddings for their code. This requires the `project_ids_relation` parameter, - # passed in as an integer array + # active: boolean - Whether to include projects that are neither archived or marked for deletion. + # with_code_embeddings_indexed: boolean - Whether to include projects that have indexed embeddings for their + # code. This requires the `project_ids_relation` parameter, passed in as an integer array + # with_duo_eligible: boolean - Whether to include projects that are eligible for Duo features. module ProjectsFinder include Gitlab::Auth::Saml::SsoSessionFilterable extend ::Gitlab::Utils::Override @@ -30,6 +29,7 @@ def filter_projects(collection) collection = by_feature_available(collection) collection = by_hidden(collection) collection = by_code_embeddings_indexed(collection) + collection = by_duo_eligible(collection) by_saml_sso_session(collection) end @@ -75,5 +75,13 @@ def by_hidden(items) def by_saml_sso_session(collection) filter_by_saml_sso_session(collection, :filter_expired_saml_session_projects) end + + def by_duo_eligible(items) + return items unless ::Feature.enabled?(:with_duo_eligible_projects_filter, current_user) + + return items unless ::Gitlab::Utils.to_boolean(params[:with_duo_eligible]) + + items.duo_eligible + end end end diff --git a/ee/app/graphql/ee/resolvers/projects_resolver.rb b/ee/app/graphql/ee/resolvers/projects_resolver.rb index 646db69a4dc87676540da3045998d940356e62c6..07081f75a941c6e0094b5eede372525f8ff5ca8d 100644 --- a/ee/app/graphql/ee/resolvers/projects_resolver.rb +++ b/ee/app/graphql/ee/resolvers/projects_resolver.rb @@ -18,6 +18,11 @@ module ProjectsResolver "Requires `ids` to be sent. Applies only if the feature flag " \ "`allow_with_code_embeddings_indexed_projects_filter` is enabled." + argument :with_duo_eligible, GraphQL::Types::Boolean, + required: false, + experiment: { milestone: '18.6' }, + description: "Include only projects that are eligible for GitLab Duo and have Duo features enabled." + before_connection_authorization do |projects, current_user| ::Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute ::Preloaders::UserMemberRolesInProjectsPreloader.new(projects: projects, user: current_user).execute @@ -29,7 +34,13 @@ module ProjectsResolver override :finder_params def finder_params(args) super(args) - .merge(args.slice(:include_hidden, :with_code_embeddings_indexed)) + .merge( + args.slice( + :include_hidden, + :with_code_embeddings_indexed, + :with_duo_eligible + ) + ) .merge(filter_expired_saml_session_projects: true) end diff --git a/ee/app/models/ee/project.rb b/ee/app/models/ee/project.rb index dc8c5541fceae75906713cadacd2380316633a8c..06f860110949f600a7a8d409937155bc668d4ff7 100644 --- a/ee/app/models/ee/project.rb +++ b/ee/app/models/ee/project.rb @@ -503,6 +503,46 @@ def lock_for_confirmation!(id) order(Arel.sql(order_sql)) end + scope :with_duo_features_enabled, -> do + joins(:project_setting).merge(ProjectSetting.duo_features_set(true)) + end + + # A project has active duo entitlement if its root namespace either: + # - has an active add-on for Duo Enterprise, OR + # - has an active add-on for Code Suggestions (Duo Pro), OR + # - has an active add-on for Duo Amazon Q, OR + # - has Duo Core features enabled in namespace settings + scope :with_active_duo_entitlement, -> do + duo_add_on_ids_sql = GitlabSubscriptions::AddOn + .where(name: %w[code_suggestions duo_enterprise duo_amazon_q]) + .select(:id) + .to_sql + + joins('INNER JOIN namespaces ON namespaces.id = projects.namespace_id') + .joins(<<~SQL) + LEFT JOIN namespace_settings + ON namespace_settings.namespace_id = namespaces.traversal_ids[1] + LEFT JOIN subscription_add_on_purchases add_on_purchases + ON add_on_purchases.namespace_id = namespaces.traversal_ids[1] + LEFT JOIN subscription_add_ons add_ons + ON add_ons.id = add_on_purchases.subscription_add_on_id + SQL + .where(<<~SQL.squish) + ( + ( + add_on_purchases.subscription_add_on_id IN (#{duo_add_on_ids_sql}) + AND (add_on_purchases.started_at IS NULL OR add_on_purchases.started_at <= CURRENT_DATE) + AND (add_on_purchases.expires_on IS NULL OR CURRENT_DATE < add_on_purchases.expires_on) + ) + OR namespace_settings.duo_nano_features_enabled = TRUE + ) + SQL + end + + scope :duo_eligible, -> do + with_duo_features_enabled.with_active_duo_entitlement.distinct + end + delegate :shared_runners_seconds, to: :statistics, allow_nil: true delegate :ci_minutes_usage, to: :shared_runners_limit_namespace diff --git a/ee/config/feature_flags/experiment/with_duo_eligible_projects_filter.yml b/ee/config/feature_flags/experiment/with_duo_eligible_projects_filter.yml new file mode 100644 index 0000000000000000000000000000000000000000..fad24af6cc33cace941f485ebacf0ad5e972642c --- /dev/null +++ b/ee/config/feature_flags/experiment/with_duo_eligible_projects_filter.yml @@ -0,0 +1,10 @@ +--- +name: with_duo_eligible_projects_filter +description: +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/576635 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/209581 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/578113 +milestone: '18.6' +group: group::code creation +type: experiment +default_enabled: false diff --git a/ee/spec/finders/ee/projects_finder_spec.rb b/ee/spec/finders/ee/projects_finder_spec.rb index 60b8354131e6d43bdde22a2503c2dce53dff12e3..a153eaf0b6e4522acefbd0fb97c72103c85b90ac 100644 --- a/ee/spec/finders/ee/projects_finder_spec.rb +++ b/ee/spec/finders/ee/projects_finder_spec.rb @@ -200,6 +200,66 @@ end end + context 'filter by with_duo_eligible' do + let_it_be(:params) { { with_duo_eligible: true } } + + let_it_be(:user) { create(:user) } + let_it_be_with_reload(:root_ns_core) { create(:group) } + let_it_be_with_reload(:root_ns_pro) { create(:group) } + let_it_be_with_reload(:root_ns_ent) { create(:group) } + let_it_be_with_reload(:root_ns_amazon_q) { create(:group) } + let_it_be_with_reload(:root_ns_none) { create(:group) } + + let_it_be_with_reload(:core_project) { create(:project, namespace: root_ns_core) } + let_it_be_with_reload(:pro_project) { create(:project, namespace: root_ns_pro) } + let_it_be_with_reload(:ent_project) { create(:project, namespace: root_ns_ent) } + let_it_be_with_reload(:amazon_q_project) { create(:project, namespace: root_ns_amazon_q) } + let_it_be_with_reload(:other_project) { create(:project, namespace: root_ns_none) } + + before do + core_project.project_setting.update!(duo_features_enabled: true) + pro_project.project_setting.update!(duo_features_enabled: true) + ent_project.project_setting.update!(duo_features_enabled: true) + amazon_q_project.project_setting.update!(duo_features_enabled: true) + other_project.project_setting.update!(duo_features_enabled: true) + + # Simulate add-on / settings on root namespaces + root_ns_core.namespace_settings.update!(duo_nano_features_enabled: true) + create(:gitlab_subscription_add_on_purchase, :duo_pro, namespace: root_ns_pro) + create(:gitlab_subscription_add_on_purchase, :duo_amazon_q, namespace: root_ns_amazon_q) + create(:gitlab_subscription_add_on_purchase, :duo_enterprise, namespace: root_ns_ent) + + # User can see all four + root_ns_core.add_developer(user) + root_ns_pro.add_developer(user) + root_ns_amazon_q.add_developer(user) + root_ns_ent.add_developer(user) + root_ns_none.add_developer(user) + + stub_feature_flags(with_duo_eligible_projects_filter: true) + end + + it { is_expected.to contain_exactly(core_project, pro_project, ent_project, amazon_q_project) } + + context 'when duo features disabled at project' do + before do + pro_project.project_setting.update!(duo_features_enabled: false) + end + + it { is_expected.to contain_exactly(core_project, ent_project, amazon_q_project) } + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(with_duo_eligible_projects_filter: false) + end + + it do + is_expected.to include(core_project, pro_project, ent_project, amazon_q_project, other_project) + end + end + end + private def create_project(plan, visibility = :public) diff --git a/ee/spec/models/ee/project_spec.rb b/ee/spec/models/ee/project_spec.rb index fc5e4126b8901f55edb87522a1a21e547980f5b0..623d1e187ef568a751ad91f9d9b734d766d86258 100644 --- a/ee/spec/models/ee/project_spec.rb +++ b/ee/spec/models/ee/project_spec.rb @@ -5719,4 +5719,122 @@ def stub_default_url_options(host) describe '#duo_remote_flows_enabled' do it_behaves_like 'cascading project setting', settings_attribute: :duo_remote_flows_enabled end + + describe '#duo_eligible' do + let_it_be_with_reload(:root_ns_core) { create(:group) } + let_it_be_with_reload(:root_ns_pro) { create(:group) } + let_it_be_with_reload(:root_ns_ent) { create(:group) } + let_it_be_with_reload(:root_ns_q) { create(:group) } + let_it_be_with_reload(:root_ns_none) { create(:group) } + + let_it_be_with_reload(:core_project) { create(:project, namespace: root_ns_core) } + let_it_be_with_reload(:pro_project) { create(:project, namespace: root_ns_pro) } + let_it_be_with_reload(:ent_project) { create(:project, namespace: root_ns_ent) } + let_it_be_with_reload(:q_project) { create(:project, namespace: root_ns_q) } + let_it_be_with_reload(:other_project) { create(:project, namespace: root_ns_none) } + + before do + # Project-level switch must be ON for eligibility + [core_project, pro_project, ent_project, q_project, other_project].each do |p| + p.project_setting.update!(duo_features_enabled: true) + end + + # Baseline: no Core flag / no purchases at any root + root_ns_core.namespace_settings.update!(duo_nano_features_enabled: false) + root_ns_pro.namespace_settings.update!(duo_nano_features_enabled: false) + root_ns_ent.namespace_settings.update!(duo_nano_features_enabled: false) + root_ns_q.namespace_settings.update!(duo_nano_features_enabled: false) + root_ns_none.namespace_settings.update!(duo_nano_features_enabled: false) + end + + it 'is empty when no root entitlement exists' do + expect(described_class.duo_eligible).to be_empty + end + + context 'Core (namespace setting)' do + before do + root_ns_core.namespace_settings.update!(duo_nano_features_enabled: true) + end + + it 'includes only the project under a root with Core enabled' do + expect(described_class.duo_eligible).to contain_exactly(core_project) + end + + it 'excludes the project when the project-level switch is OFF' do + core_project.project_setting.update!(duo_features_enabled: false) + expect(described_class.duo_eligible).to be_empty + end + end + + context 'Pro (code_suggestions add-on)' do + it 'includes when purchase is active' do + create(:gitlab_subscription_add_on_purchase, :duo_pro, :active, namespace: root_ns_pro) + expect(described_class.duo_eligible).to contain_exactly(pro_project) + end + + it 'excludes when purchase is future-dated' do + create(:gitlab_subscription_add_on_purchase, :duo_pro, :future_dated, namespace: root_ns_pro) + expect(described_class.duo_eligible).to be_empty + end + + it 'excludes when purchase is expired' do + create(:gitlab_subscription_add_on_purchase, :duo_pro, :expired, namespace: root_ns_pro) + expect(described_class.duo_eligible).to be_empty + end + + it 'excludes when project-level switch is OFF even if active' do + create(:gitlab_subscription_add_on_purchase, :duo_pro, :active, namespace: root_ns_pro) + pro_project.project_setting.update!(duo_features_enabled: false) + expect(described_class.duo_eligible).to be_empty + end + end + + context 'Enterprise (duo_enterprise add-on)' do + it 'includes when purchase is active' do + create(:gitlab_subscription_add_on_purchase, :duo_enterprise, :active, namespace: root_ns_ent) + expect(described_class.duo_eligible).to contain_exactly(ent_project) + end + end + + context 'Amazon Q (duo_amazon_q add-on)' do + it 'includes when purchase is active' do + create(:gitlab_subscription_add_on_purchase, :duo_amazon_q, :active, namespace: root_ns_q) + expect(described_class.duo_eligible).to contain_exactly(q_project) + end + end + + context 'multiple purchases on same root' do + it 'returns each eligible project only once (distinct)' do + create(:gitlab_subscription_add_on_purchase, :duo_pro, :active, namespace: root_ns_pro) + create(:gitlab_subscription_add_on_purchase, :duo_enterprise, :active, namespace: root_ns_pro) + expect(described_class.duo_eligible).to contain_exactly(pro_project) + end + end + + context 'mixture across roots' do + it 'returns union of all eligible projects' do + root_ns_core.namespace_settings.update!(duo_nano_features_enabled: true) + create(:gitlab_subscription_add_on_purchase, :duo_pro, :active, namespace: root_ns_pro) + create(:gitlab_subscription_add_on_purchase, :duo_enterprise, :active, namespace: root_ns_ent) + create(:gitlab_subscription_add_on_purchase, :duo_amazon_q, :active, namespace: root_ns_q) + + expect(described_class.duo_eligible) + .to contain_exactly(core_project, pro_project, ent_project, q_project) + end + end + end + + describe '#with_duo_features_enabled' do + let_it_be(:p_on) { create(:project) } + let_it_be(:p_off) { create(:project) } + + before do + p_on.project_setting.update!(duo_features_enabled: true) + p_off.project_setting.update!(duo_features_enabled: false) + end + + it 'filters by project_settings.duo_features_enabled = true' do + expect(described_class.with_duo_features_enabled).to contain_exactly(p_on) + end + end end