diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md
index 3065ff9af3212ae3ba10eb5c8eecd767ac5c5010..6d0543088bc50b8cb674c471a76757776ae51e61 100644
--- a/doc/api/graphql/reference/_index.md
+++ b/doc/api/graphql/reference/_index.md
@@ -39417,6 +39417,30 @@ Returns [`AiCatalogItem`](#aicatalogitem).
| ---- | ---- | ----------- |
| `id` | [`AiCatalogItemID!`](#aicatalogitemid) | Global ID of the catalog item to find. |
+##### `Project.aiCatalogItems`
+
+{{< details >}}
+**Introduced** in GitLab 18.6.
+**Status**: Experiment.
+{{< /details >}}
+
+AI Catalog items of the project. This field can only be resolved for one project in any single request.
+
+Returns [`AiCatalogItemConnection!`](#aicatalogitemconnection).
+
+This field returns a [connection](#connections). It accepts the
+four standard [pagination arguments](#pagination-arguments):
+`before: String`, `after: String`, `first: Int`, and `last: Int`.
+
+###### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| `allAvailable` | [`Boolean`](#boolean) | Include public items from the AI Catalog. |
+| `enabled` | [`Boolean`](#boolean) | Filter items that are enabled or disabled for the project. |
+| `itemTypes` | [`[AiCatalogItemType!]`](#aicatalogitemtype) | Types of items to retrieve. |
+| `search` | [`String`](#string) | Search items by name and description. |
+
##### `Project.aiFlowTriggers`
{{< details >}}
diff --git a/ee/app/finders/ai/catalog/project_items_finder.rb b/ee/app/finders/ai/catalog/project_items_finder.rb
new file mode 100644
index 0000000000000000000000000000000000000000..4d74c22d3469f038aeae67e17c9832ff91c97a27
--- /dev/null
+++ b/ee/app/finders/ai/catalog/project_items_finder.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+module Ai
+ module Catalog
+ class ProjectItemsFinder
+ def initialize(current_user, project, params: {})
+ @current_user = current_user
+ @project = project
+ @params = params
+ end
+
+ def execute
+ return Item.none unless Feature.enabled?(:global_ai_catalog, current_user)
+ return Item.none unless Ability.allowed?(current_user, :developer_access, project)
+
+ items = init_collection
+ items = by_item_type(items)
+ items = by_enabled_state(items)
+ by_search(items)
+ end
+
+ private
+
+ attr_reader :current_user, :project, :params
+
+ def init_collection
+ items = Item.not_deleted.for_project(project)
+ items = items.or(Item.not_deleted.for_organization(project.organization).public_only) if params[:all_available]
+ items.order_by_id_desc
+ end
+
+ def by_item_type(items)
+ return items if params[:item_types].blank?
+
+ items.with_item_type(params[:item_types])
+ end
+
+ def by_enabled_state(items)
+ return items if params[:enabled].nil?
+
+ enabled_items = project.configured_ai_catalog_items.by_enabled(true)
+
+ if params[:enabled]
+ items.id_in(enabled_items.select(:ai_catalog_item_id))
+ else
+ items.id_not_in(enabled_items.select(:ai_catalog_item_id))
+ end
+ end
+
+ def by_search(items)
+ return items if params[:search].blank?
+
+ items.search(params[:search])
+ end
+ end
+ end
+end
diff --git a/ee/app/graphql/ee/types/project_type.rb b/ee/app/graphql/ee/types/project_type.rb
index 3252b4449194e3586c3babe805ffd3d9007c83a6..5c20a470ac6e904c3a8fdb2e0308cbbd39b7c289 100644
--- a/ee/app/graphql/ee/types/project_type.rb
+++ b/ee/app/graphql/ee/types/project_type.rb
@@ -328,6 +328,16 @@ module ProjectType
description: 'Global ID of the catalog item to find.'
end
+ field :ai_catalog_items,
+ ::Types::Ai::Catalog::ItemInterface.connection_type,
+ null: false,
+ resolver: ::Resolvers::Ai::Catalog::ProjectItemsResolver,
+ description: 'AI Catalog items of the project. ' \
+ 'This field can only be resolved for one project in any single request.',
+ experiment: { milestone: '18.6' } do
+ extension(::Gitlab::Graphql::Limit::FieldCallCount, limit: 1)
+ end
+
field :ai_metrics,
::Types::Analytics::AiMetrics::NamespaceMetricsType,
null: true,
diff --git a/ee/app/graphql/resolvers/ai/catalog/project_items_resolver.rb b/ee/app/graphql/resolvers/ai/catalog/project_items_resolver.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e76927fbed9af52c13dc21f3af9fb3076749a061
--- /dev/null
+++ b/ee/app/graphql/resolvers/ai/catalog/project_items_resolver.rb
@@ -0,0 +1,39 @@
+# frozen_string_literal: true
+
+module Resolvers
+ module Ai
+ module Catalog
+ class ProjectItemsResolver < BaseResolver
+ description 'AI Catalog items for a project.'
+
+ type ::Types::Ai::Catalog::ItemInterface.connection_type, null: false
+
+ argument :item_types, [::Types::Ai::Catalog::ItemTypeEnum],
+ required: false,
+ description: 'Types of items to retrieve.'
+
+ argument :enabled,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: 'Filter items that are enabled or disabled for the project.'
+
+ argument :all_available,
+ GraphQL::Types::Boolean,
+ required: false,
+ description: 'Include public items from the AI Catalog.'
+
+ argument :search, GraphQL::Types::String,
+ required: false,
+ description: 'Search items by name and description.'
+
+ def resolve(**args)
+ ::Ai::Catalog::ProjectItemsFinder.new(
+ current_user,
+ object,
+ params: args
+ ).execute
+ end
+ end
+ end
+ end
+end
diff --git a/ee/app/models/ai/catalog/item_consumer.rb b/ee/app/models/ai/catalog/item_consumer.rb
index b1721134d73dacb6ec435a7a6a68a21462af8dc7..888a2ba308bd9e7e148ece6d52b773f7680242d6 100644
--- a/ee/app/models/ai/catalog/item_consumer.rb
+++ b/ee/app/models/ai/catalog/item_consumer.rb
@@ -27,6 +27,7 @@ class ItemConsumer < ApplicationRecord
belongs_to :group
belongs_to :project
+ scope :by_enabled, ->(enabled) { where(enabled: enabled) }
scope :not_for_projects, ->(project) { where.not(project: project) }
scope :for_item, ->(item_id) { where(ai_catalog_item_id: item_id) }
scope :with_item_type, ->(item_type) { joins(:item).where(item: { item_type: item_type }) }
diff --git a/ee/spec/factories/ai/catalog/items.rb b/ee/spec/factories/ai/catalog/items.rb
index 271182e646c8988d4ff01de7ae2262c3d40200d0..d5490ef1956ad242bf23f9374a5a9e41d497866a 100644
--- a/ee/spec/factories/ai/catalog/items.rb
+++ b/ee/spec/factories/ai/catalog/items.rb
@@ -22,6 +22,18 @@
item_type { 'third_party_flow' }
end
+ trait :public do
+ public { true }
+ end
+
+ trait :private do
+ public { false }
+ end
+
+ trait :soft_deleted do
+ deleted_at { Time.zone.now }
+ end
+
versions do |item|
version_factory = "ai_catalog_#{item.item_type}_version"
build_list(version_factory, 1, item: nil)
diff --git a/ee/spec/finders/ai/catalog/project_items_finder_spec.rb b/ee/spec/finders/ai/catalog/project_items_finder_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d1cbf5c1bc20a50224c0b7cd2baf3356de37f09a
--- /dev/null
+++ b/ee/spec/finders/ai/catalog/project_items_finder_spec.rb
@@ -0,0 +1,113 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Ai::Catalog::ProjectItemsFinder, feature_category: :workflow_catalog do
+ include Ai::Catalog::TestHelpers
+
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project, developers: user) }
+ let_it_be(:other_project) { create(:project) }
+
+ let_it_be(:public_flow) { create(:ai_catalog_flow, :public, project: project) }
+ let_it_be(:private_agent) { create(:ai_catalog_agent, :private, project: project) }
+ let_it_be(:deleted_flow) { create(:ai_catalog_flow, :soft_deleted, project: project) }
+ let_it_be(:private_flow_of_other_project) { create(:ai_catalog_flow, :private, project: other_project) }
+ let_it_be(:public_flow_of_other_project) { create(:ai_catalog_flow, :public, project: other_project) }
+ let_it_be(:public_flow_of_other_org) { create(:ai_catalog_flow, :public, organization: create(:organization)) }
+ let_it_be(:deleted_public_flow_of_other_project) do
+ create(:ai_catalog_flow, :public, :soft_deleted, project: other_project)
+ end
+
+ let(:params) { {} }
+
+ subject(:results) { described_class.new(user, project, params: params).execute }
+
+ before do
+ enable_ai_catalog
+ end
+
+ it 'returns items that are not deleted and belong to the project' do
+ is_expected.to contain_exactly(public_flow, private_agent)
+ end
+
+ it 'returns items ordered by id desc' do
+ is_expected.to eq([private_agent, public_flow])
+ end
+
+ context 'when user does not have permission to read the project' do
+ let_it_be(:user) { create(:user) }
+
+ it 'returns no items' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when the `global_ai_catalog` flag is disabled' do
+ before do
+ stub_feature_flags(global_ai_catalog: false)
+ end
+
+ it 'returns no items' do
+ is_expected.to be_empty
+ end
+ end
+
+ context 'when filtering by `item_types`' do
+ let(:params) { { item_types: ['agent'] } }
+
+ it 'returns the matching items' do
+ is_expected.to contain_exactly(private_agent)
+ end
+ end
+
+ context 'when filtering by `search`' do
+ let_it_be(:agent_with_name_match) { create(:ai_catalog_agent, name: 'Autotriager', project: project) }
+ let_it_be(:flow_with_description_match) do
+ create(:ai_catalog_flow, project: project, description: 'Flow to triage issues')
+ end
+
+ let(:params) { { search: 'triage' } }
+
+ it 'returns items that partial match on the name or description' do
+ is_expected.to contain_exactly(
+ agent_with_name_match,
+ flow_with_description_match
+ )
+ end
+ end
+
+ context 'when `all_available` param is `true`' do
+ let(:params) { { all_available: true } }
+
+ it 'also returns public items of other projects within the same organization' do
+ is_expected.to contain_exactly(public_flow, private_agent, public_flow_of_other_project)
+ end
+ end
+
+ context 'when filtering by `enabled`' do
+ let(:params) { { enabled: enabled } }
+
+ before_all do
+ create(:ai_catalog_item_consumer, item: private_agent, project: project)
+ create(:ai_catalog_item_consumer, item: public_flow, project: other_project)
+ create(:ai_catalog_item_consumer, item: public_flow_of_other_project, project: other_project)
+ end
+
+ context 'when `enabled` is `true`' do
+ let(:enabled) { true }
+
+ it 'returns items owned by the project and enabled for the project' do
+ is_expected.to contain_exactly(private_agent)
+ end
+ end
+
+ context 'when `enabled` is `false`' do
+ let(:enabled) { false }
+
+ it 'returns only items that have not been enabled for the project' do
+ is_expected.to contain_exactly(public_flow)
+ end
+ end
+ end
+end
diff --git a/ee/spec/requests/api/graphql/ai/catalog/project_items_spec.rb b/ee/spec/requests/api/graphql/ai/catalog/project_items_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..142a260f2b4a6e802ec42745fdb5122be045db9b
--- /dev/null
+++ b/ee/spec/requests/api/graphql/ai/catalog/project_items_spec.rb
@@ -0,0 +1,133 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'Project.aiCatalogItems', :with_current_organization, feature_category: :workflow_catalog do
+ include Ai::Catalog::TestHelpers
+ include GraphqlHelpers
+
+ let_it_be(:guest_user) { create(:user) }
+ let_it_be(:developer_user) { create(:user) }
+ let_it_be(:project) { create(:project, developers: developer_user, guests: guest_user) }
+ let_it_be(:other_project) { create(:project) }
+
+ let_it_be(:flow) { create(:ai_catalog_flow, project: project) }
+ let_it_be(:agent) { create(:ai_catalog_agent, project: project) }
+
+ let_it_be(:private_agent_of_other_project) { create(:ai_catalog_agent, :private, project: other_project) }
+ let_it_be(:public_agent_of_other_project) { create(:ai_catalog_agent, :public, project: other_project) }
+
+ let(:nodes) { graphql_data_at(:project, :ai_catalog_items, :nodes) }
+ let(:args) { {} }
+
+ let(:current_user) { developer_user }
+
+ let(:query) do
+ graphql_query_for(
+ :project,
+ { full_path: project.full_path },
+ query_graphql_field(:ai_catalog_items, attributes_to_graphql(args).to_s,
+ query_graphql_field(:nodes, {}, all_graphql_fields_for('AiCatalogItem'))
+ )
+ )
+ end
+
+ before do
+ enable_ai_catalog
+ end
+
+ it 'returns items belonging to the project' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(nodes).to contain_exactly(
+ a_graphql_entity_for(flow),
+ a_graphql_entity_for(agent)
+ )
+ end
+
+ context 'when user is not a developer+ of the project' do
+ let(:current_user) { guest_user }
+
+ it 'returns no items' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(nodes).to be_empty
+ end
+ end
+
+ context 'when the `global_ai_catalog` flag is disabled' do
+ before do
+ stub_feature_flags(global_ai_catalog: false)
+ end
+
+ it 'returns no items' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(nodes).to be_empty
+ end
+ end
+
+ context 'when filtering by `item_types`' do
+ let(:args) { super().merge(item_types: [:AGENT]) }
+
+ it 'returns the matching items' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(nodes).to contain_exactly(
+ a_graphql_entity_for(agent)
+ )
+ end
+ end
+
+ context 'when filtering by `enabled`' do
+ let(:args) { super().merge(enabled: true) }
+
+ before_all do
+ create(:ai_catalog_item_consumer, item: flow, project: project)
+ end
+
+ it 'returns the matching items' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(nodes).to contain_exactly(
+ a_graphql_entity_for(flow)
+ )
+ end
+ end
+
+ context 'when filtering by `all_available`' do
+ let(:args) { super().merge(all_available: true) }
+
+ it 'returns all available items' do
+ post_graphql(query, current_user: current_user)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(nodes).to contain_exactly(
+ a_graphql_entity_for(agent),
+ a_graphql_entity_for(flow),
+ a_graphql_entity_for(public_agent_of_other_project)
+ )
+ end
+ end
+
+ context 'when filtering by `search`' do
+ let(:args) { super().merge(search: 'triage') }
+
+ let_it_be(:issue_label_agent) { create(:ai_catalog_agent, name: 'Autotriager', project: project) }
+ let_it_be(:mr_review_flow) { create(:ai_catalog_flow, project: project, description: 'Flow to triage issues') }
+
+ it 'returns items that partial match on the name or description' do
+ post_graphql(query, current_user: current_user)
+
+ expect(nodes).to contain_exactly(
+ a_graphql_entity_for(issue_label_agent),
+ a_graphql_entity_for(mr_review_flow)
+ )
+ end
+ end
+end