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