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