diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 1d361c9aab4c8c842527d525c053d3de1be2c08a..9f80c7223699831205867710527c9edec3bc81a1 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -2204,6 +2204,33 @@ Input type: `AiAgentUpdateInput` | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. | +### `Mutation.aiCatalogItemsCreate` + +{{< details >}} +**Introduced** in GitLab 18.2. +**Status**: Experiment. +{{< /details >}} + +Input type: `AiCatalogItemsCreateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `description` | [`String!`](#string) | Description for the item. | +| `itemType` | [`AiCatalogItemType!`](#aicatalogitemtype) | Type of the item. | +| `name` | [`String!`](#string) | Name for the item. | +| `projectId` | [`ProjectID!`](#projectid) | Project for the item. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| `errors` | [`[String!]!`](#string) | Errors encountered during the mutation. | +| `item` | [`AiCatalogItem`](#aicatalogitem) | Item created. | + ### `Mutation.aiDuoWorkflowCreate` {{< details >}} @@ -21626,6 +21653,7 @@ An AI catalog item. | `id` | [`ID!`](#id) | ID of the item. | | `itemType` | [`AiCatalogItemType!`](#aicatalogitemtype) | Type of the item. | | `name` | [`String!`](#string) | Name of the item. | +| `project` | [`Project`](#project) | Project for the item. | ### `AiConversationsThread` diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index d6adc01483127ceaed685683455ef6cab494ec51..238277c1ec32774a9f1d651c7bf3cc2e4b4f1ef8 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -10,6 +10,7 @@ def self.authorization_scopes super + [:ai_features] end + mount_mutation ::Mutations::Ai::Catalog::Items::Create, experiment: { milestone: '18.2' } mount_mutation ::Mutations::Ci::Catalog::VerifiedNamespace::Create mount_mutation ::Mutations::Ci::ProjectSubscriptions::Create mount_mutation ::Mutations::Ci::ProjectSubscriptions::Delete diff --git a/ee/app/graphql/mutations/ai/catalog/items/create.rb b/ee/app/graphql/mutations/ai/catalog/items/create.rb new file mode 100644 index 0000000000000000000000000000000000000000..55aa425f753d88f0f5a59e92b8201f09e4a07094 --- /dev/null +++ b/ee/app/graphql/mutations/ai/catalog/items/create.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Mutations + module Ai + module Catalog + module Items + class Create < BaseMutation + graphql_name 'AiCatalogItemsCreate' + + include Gitlab::Graphql::Authorize::AuthorizeResource + + field :item, + ::Types::Ai::Catalog::ItemType, + null: true, + description: 'Item created.' + + argument :description, GraphQL::Types::String, + required: true, + description: 'Description for the item.' + + argument :item_type, ::Types::Ai::Catalog::ItemTypeEnum, + required: true, + description: 'Type of the item.' + + argument :name, GraphQL::Types::String, + required: true, + description: 'Name for the item.' + + argument :project_id, ::Types::GlobalIDType[::Project], + required: true, + description: 'Project for the item.' + + authorize :admin_ai_catalog_item + + def resolve(args) + project = authorized_find!(id: args[:project_id]) + + result = ::Ai::Catalog::Items::CreateService.new( + project: project, + current_user: current_user, + params: args + ).execute + + { item: result.payload, errors: result.errors } + end + end + end + end + end +end diff --git a/ee/app/graphql/types/ai/catalog/item_type.rb b/ee/app/graphql/types/ai/catalog/item_type.rb index 78cfa7097264066cab4e39a91729d98c0c4921b7..4cff36142b4154163a78cb859763fd28ba0d96a5 100644 --- a/ee/app/graphql/types/ai/catalog/item_type.rb +++ b/ee/app/graphql/types/ai/catalog/item_type.rb @@ -18,6 +18,7 @@ class ItemType < ::Types::BaseObject null: false, description: 'Type of the item.' field :name, GraphQL::Types::String, null: false, description: 'Name of the item.' + field :project, ::Types::ProjectType, null: true, description: 'Project for the item.' end # rubocop: enable Graphql/AuthorizeTypes end diff --git a/ee/app/policies/ee/archived_abilities.rb b/ee/app/policies/ee/archived_abilities.rb index f5105272b077990d524b0fb2af3f3aa0996dfd45..7aea75d592eab1c6a79f920c3c45e4dd2a7780d3 100644 --- a/ee/app/policies/ee/archived_abilities.rb +++ b/ee/app/policies/ee/archived_abilities.rb @@ -7,6 +7,7 @@ module ArchivedAbilities ARCHIVED_ABILITIES_EE = %i[ admin_software_license_policy create_test_case + admin_ai_catalog_item ].freeze ARCHIVED_FEATURES_EE = %i[ diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index ada04fbeec59ffbae7830fceb6a58530f6fd252f..2e7cb1c737efa291a302d80b3eafaa31ef5df978 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -330,6 +330,10 @@ module ProjectPolicy (merge_requests_is_a_private_feature? && custom_role_enables_admin_merge_request?)) end + condition(:ai_catalog_enabled, scope: :user) do + ::Feature.enabled?(:global_ai_catalog, @user) + end + rule { custom_role_enables_admin_cicd_variables }.policy do enable :admin_cicd_variables end @@ -694,6 +698,10 @@ module ProjectPolicy enable :admin_vulnerability end + rule { ai_catalog_enabled & can?(:duo_workflow) & can?(:maintainer_access) }.policy do + enable :admin_ai_catalog_item + end + rule { ~runner_performance_insights_available }.prevent :read_runner_usage rule { ~clickhouse_main_database_available }.prevent :read_runner_usage diff --git a/ee/app/services/ai/catalog/items/base_service.rb b/ee/app/services/ai/catalog/items/base_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..8921c66dcf0e347697617fb8e2c53aa2c536c3c3 --- /dev/null +++ b/ee/app/services/ai/catalog/items/base_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Ai + module Catalog + module Items + class BaseService < ::BaseContainerService + def initialize(project:, current_user:, params: {}) + super(container: project, current_user: current_user, params: params) + end + + private + + def allowed? + current_user.can?(:admin_ai_catalog_item, project) + end + + def error(message) + ServiceResponse.error(message: Array(message)) + end + end + end + end +end diff --git a/ee/app/services/ai/catalog/items/create_service.rb b/ee/app/services/ai/catalog/items/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..c5ae896d0d8e3606f81a5030ec0d62d468332f9e --- /dev/null +++ b/ee/app/services/ai/catalog/items/create_service.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Ai + module Catalog + module Items + class CreateService < BaseService + def execute + return error_no_permissions unless allowed? + + params.merge!(project_id: @project.id, organization_id: @project.organization_id) + item = Ai::Catalog::Item.create(params) + + return error_creating(item) unless item.persisted? + + ServiceResponse.success(payload: item) + end + + private + + def error_no_permissions + error('You have insufficient permissions to create catalog items for this project') + end + + def error_creating(item) + error(item.errors.full_messages || 'Failed to create catalog item') + end + end + end + end +end diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb index 0640419c2142b9c3b08d5662d1c56f56cef7a681..fba8df669eb1af4e92a7f8697dd8a1e5ac4412f2 100644 --- a/ee/spec/policies/project_policy_spec.rb +++ b/ee/spec/policies/project_policy_spec.rb @@ -5168,4 +5168,36 @@ def create_member_role(member, abilities = member_role_abilities) end end end + + describe 'admin_ai_catalog_item' do + let(:current_user) { maintainer } + let(:duo_workflow) { true } + + before do + allow(subject).to receive(:allowed?).and_call_original + allow(subject).to receive(:allowed?).with(:duo_workflow).and_return(duo_workflow) + end + + it { is_expected.to be_allowed(:admin_ai_catalog_item) } + + context 'when developer' do + let(:current_user) { developer } + + it { is_expected.to be_disallowed(:admin_ai_catalog_item) } + end + + context 'when global_ai_catalog feature flag is disabled' do + before do + stub_feature_flags(global_ai_catalog: false) + end + + it { is_expected.to be_disallowed(:admin_ai_catalog_item) } + end + + context 'when can?(:duo_workflow) false' do + let(:duo_workflow) { false } + + it { is_expected.to be_disallowed(:admin_ai_catalog_item) } + end + end end diff --git a/ee/spec/requests/api/graphql/mutations/ai/catalog/items/create_spec.rb b/ee/spec/requests/api/graphql/mutations/ai/catalog/items/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7e3bc82d9f99bfce43a0f277b43f46c62d70fa9c --- /dev/null +++ b/ee/spec/requests/api/graphql/mutations/ai/catalog/items/create_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Ai::Catalog::Items::Create, feature_category: :workflow_catalog do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:project) { create(:project, :public) } + + let(:mutation) { graphql_mutation(:ai_catalog_items_create, params) } + let(:name) { 'Name' } + let(:description) { 'Description' } + let(:allowed) { true } + let(:params) do + { + item_type: 'AGENT', + project_id: project.to_global_id, + name: name, + description: description + } + end + + subject(:execute) { post_graphql_mutation(mutation, current_user: current_user) } + + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(current_user, :admin_ai_catalog_item, project).and_return(allowed) + end + + context 'when the user does not have permission' do + let(:allowed) { false } + + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not create a catalog item' do + expect { execute }.not_to change { Ai::Catalog::Item.count } + end + end + + context 'when the params are invalid' do + let(:name) { nil } + + it 'returns the validation error' do + execute + + expect(graphql_errors.to_s).to include('provided invalid value for name (Expected value to not be null)') + end + end + + it 'creates a item' do + expect { execute }.to change { Ai::Catalog::Item.count }.by(1) + end + + it 'returns the new item' do + execute + + expect(graphql_data_at(:ai_catalog_items_create, :item)).to match a_hash_including( + 'name' => name, + 'project' => a_hash_including('id' => project.to_global_id.to_s), + 'description' => description + ) + end +end diff --git a/ee/spec/services/ai/catalog/items/create_service_spec.rb b/ee/spec/services/ai/catalog/items/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e746b9920d2429b065312a78e4a462396d5c1d5b --- /dev/null +++ b/ee/spec/services/ai/catalog/items/create_service_spec.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Ai::Catalog::Items::CreateService, feature_category: :workflow_catalog do + let_it_be(:user) { create(:user) } + let_it_be(:project) { create(:project) } + + let(:allowed) { true } + let(:params) { attributes_for(:ai_catalog_item) } + + subject(:response) { described_class.new(project: project, current_user: user, params: params).execute } + + before do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(user, :admin_ai_catalog_item, project).and_return(allowed) + end + + describe '#execute' do + it 'returns success' do + expect(response).to be_success + end + + it 'creates a catalog item' do + expect { response }.to change { Ai::Catalog::Item.count }.by(1) + end + + context 'when there is a validation issue' do + it 'returns the relevant error' do + params[:name] = nil + + expect { response }.not_to change { Ai::Catalog::Item.count } + expect(response).to be_error + expect(response.message).to match_array(["Name can't be blank"]) + end + end + + context 'when user does not have permission' do + let(:allowed) { false } + + it 'returns an error' do + expect { response }.not_to change { Ai::Catalog::Item.count } + expect(response).to be_error + expect(response.message).to match_array( + ['You have insufficient permissions to create catalog items for this project']) + end + end + end +end