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