diff --git a/db/migrate/20250919154932_add_ai_catalog_references_to_flow_triggers.rb b/db/migrate/20250919154932_add_ai_catalog_references_to_flow_triggers.rb new file mode 100644 index 0000000000000000000000000000000000000000..d38831355686013756ec6300a0bd67bc6ae09371 --- /dev/null +++ b/db/migrate/20250919154932_add_ai_catalog_references_to_flow_triggers.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# See https://docs.gitlab.com/ee/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddAiCatalogReferencesToFlowTriggers < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + + milestone '18.5' + + def up + add_column :ai_flow_triggers, :ai_catalog_item_id, :bigint + add_column :ai_flow_triggers, :ai_catalog_item_version_id, :bigint + + add_concurrent_index :ai_flow_triggers, :ai_catalog_item_id + add_concurrent_index :ai_flow_triggers, :ai_catalog_item_version_id + + add_concurrent_foreign_key :ai_flow_triggers, :ai_catalog_items, column: :ai_catalog_item_id, on_delete: :cascade + add_concurrent_foreign_key :ai_flow_triggers, :ai_catalog_item_versions, column: :ai_catalog_item_version_id, + on_delete: :cascade + end + + def down + remove_concurrent_foreign_key :ai_flow_triggers, :ai_catalog_items, column: :ai_catalog_item_id + remove_concurrent_foreign_key :ai_flow_triggers, :ai_catalog_item_versions, column: :ai_catalog_item_version_id + + remove_concurrent_index_by_name :ai_flow_triggers, 'index_ai_flow_triggers_on_ai_catalog_item_id' + remove_concurrent_index_by_name :ai_flow_triggers, 'index_ai_flow_triggers_on_ai_catalog_item_version_id' + + remove_column :ai_flow_triggers, :ai_catalog_item_id + remove_column :ai_flow_triggers, :ai_catalog_item_version_id + end +end diff --git a/db/schema_migrations/20250919154932 b/db/schema_migrations/20250919154932 new file mode 100644 index 0000000000000000000000000000000000000000..9652ea270682b4028c92fded210c7dde5eefa686 --- /dev/null +++ b/db/schema_migrations/20250919154932 @@ -0,0 +1 @@ +14889d08ac64debe0a4dee3d0d8a2a541227a91ca19ac86bbebcb7db0acb48d7 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index b86d63cff2aac0a2df9ec7908aa13aa6bc21aa1d..2d08ff4f4de14a7207de8c108e8a9dd08aa33b8d 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9553,6 +9553,8 @@ CREATE TABLE ai_flow_triggers ( event_types smallint[] DEFAULT '{}'::smallint[] NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, + ai_catalog_item_id bigint, + ai_catalog_item_version_id bigint, CONSTRAINT check_87b77d9d54 CHECK ((char_length(description) <= 255)), CONSTRAINT check_f3a5b0bd6e CHECK ((char_length(config_path) <= 255)) ); @@ -37165,6 +37167,10 @@ CREATE INDEX index_ai_feature_settings_on_ai_self_hosted_model_id ON ai_feature_ CREATE UNIQUE INDEX index_ai_feature_settings_on_feature ON ai_feature_settings USING btree (feature); +CREATE INDEX index_ai_flow_triggers_on_ai_catalog_item_id ON ai_flow_triggers USING btree (ai_catalog_item_id); + +CREATE INDEX index_ai_flow_triggers_on_ai_catalog_item_version_id ON ai_flow_triggers USING btree (ai_catalog_item_version_id); + CREATE INDEX index_ai_flow_triggers_on_project_id ON ai_flow_triggers USING btree (project_id); CREATE INDEX index_ai_flow_triggers_on_user_id ON ai_flow_triggers USING btree (user_id); @@ -48581,6 +48587,9 @@ ALTER TABLE ONLY container_repositories ALTER TABLE ONLY ai_agents ADD CONSTRAINT fk_rails_3328b05449 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY ai_flow_triggers + ADD CONSTRAINT fk_rails_337106921f FOREIGN KEY (ai_catalog_item_id) REFERENCES ai_catalog_items(id) ON DELETE CASCADE; + ALTER TABLE ONLY alert_management_alert_metric_images ADD CONSTRAINT fk_rails_338e55b408 FOREIGN KEY (alert_id) REFERENCES alert_management_alerts(id) ON DELETE CASCADE; @@ -49184,6 +49193,9 @@ ALTER TABLE ONLY packages_debian_group_distribution_keys ALTER TABLE ONLY group_scim_identities ADD CONSTRAINT fk_rails_77cb698c8d FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY ai_flow_triggers + ADD CONSTRAINT fk_rails_7848f871e5 FOREIGN KEY (ai_catalog_item_version_id) REFERENCES ai_catalog_item_versions(id) ON DELETE CASCADE; + ALTER TABLE ONLY terraform_states ADD CONSTRAINT fk_rails_78f54ca485 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 2a776e60b2ab1d7337705a13a9b47d66239b2705..aca8bcab23b1a08a9a336c273edee819504f65c2 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -2859,6 +2859,8 @@ Input type: `AiFlowTriggerCreateInput` | Name | Type | Description | | ---- | ---- | ----------- | +| `aiCatalogItemId` | [`AiCatalogItemID`](#aicatalogitemid) | AI catalog item to use instead of config_path. | +| `aiCatalogItemVersionId` | [`AiCatalogItemVersionID`](#aicatalogitemversionid) | Specific version of the AI catalog item to use. | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `configPath` | [`String`](#string) | Path to the configuration file for the AI flow trigger. | | `description` | [`String!`](#string) | Description of the AI flow trigger. | @@ -2910,6 +2912,8 @@ Input type: `AiFlowTriggerUpdateInput` | Name | Type | Description | | ---- | ---- | ----------- | +| `aiCatalogItemId` | [`AiCatalogItemID`](#aicatalogitemid) | AI catalog item to use instead of config_path. | +| `aiCatalogItemVersionId` | [`AiCatalogItemVersionID`](#aicatalogitemversionid) | Specific version of the AI catalog item to use. | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `configPath` | [`String`](#string) | Path to the configuration file for the AI flow trigger. | | `description` | [`String`](#string) | Description of the AI flow trigger. | @@ -23450,6 +23454,8 @@ Represents an AI flow trigger. | Name | Type | Description | | ---- | ---- | ----------- | +| `aiCatalogItem` | [`AiCatalogItem`](#aicatalogitem) | AI catalog item associated with the trigger. | +| `aiCatalogItemVersion` | [`AiCatalogItemVersion`](#aicatalogitemversion) | Specific version of the AI catalog item. | | `configPath` | [`String`](#string) | Path to the configuration file for the trigger. | | `configUrl` | [`String`](#string) | Web URL to the configuration file for the trigger. | | `createdAt` | [`Time!`](#time) | Timestamp of when the flow trigger was created. | diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..86666e623087ea1cc44ae6b5ff603af045d46277 --- /dev/null +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql @@ -0,0 +1,13 @@ +#import "ee/ai/catalog/graphql/fragments/ai_catalog_item.fragment.graphql" + +query getAiCatalogFlowsForTriggers($first: Int = 50) { + aiCatalogItems(itemType: FLOW, first: $first) { + nodes { + ...BaseAiCatalogItem + project { + id + nameWithNamespace + } + } + } +} \ No newline at end of file diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_item_versions.query.graphql b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_item_versions.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..73c6a98f8e5bf97200eba95f97f5060341f6e1cb --- /dev/null +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_item_versions.query.graphql @@ -0,0 +1,16 @@ +#import "ee/ai/catalog/graphql/fragments/ai_catalog_item_version.fragment.graphql" + +query getAiCatalogItemVersions($id: AiCatalogItemID!, $first: Int = 50) { + aiCatalogItem(id: $id) { + id + name + latestVersion { + ...BaseAiCatalogItemVersion + } + versions(first: $first) { + nodes { + ...BaseAiCatalogItemVersion + } + } + } +} \ No newline at end of file diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue index e59b45f0e461b670d754b69fcd98bd7e683ac953..e6a1308c0908b43cb97d2d1210200504dfa9fdea 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue @@ -6,12 +6,16 @@ import { GlForm, GlFormGroup, GlFormInput, + GlFormRadio, + GlFormRadioGroup, GlFormTextarea, } from '@gitlab/ui'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import projectServiceAccountsQuery from '../../../graphql/queries/get_project_service_accounts.query.graphql'; +import getAiCatalogFlowsForTriggersQuery from '../../../graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql'; +import getAiCatalogItemVersionsQuery from '../../../graphql/queries/get_ai_catalog_item_versions.query.graphql'; import { FLOW_TRIGGERS_INDEX_ROUTE } from '../../../router/constants'; import AiLegalDisclaimer from '../../../components/common/ai_legal_disclaimer.vue'; @@ -27,6 +31,8 @@ export default { GlForm, GlFormGroup, GlFormInput, + GlFormRadio, + GlFormRadioGroup, GlFormTextarea, UserSelect, AiLegalDisclaimer, @@ -57,6 +63,8 @@ export default { eventTypes: [], configPath: '', user: null, + aiCatalogItemId: null, + aiCatalogItemVersionId: null, }; }, }, @@ -75,6 +83,13 @@ export default { eventTypes: this.initialValues.eventTypes, configPath: this.initialValues.configPath, selectedUsers: this.initialValues.user ? [{ ...this.initialValues.user }] : [], + configMode: this.initialValues.aiCatalogItemId ? 'catalog' : 'manual', + aiCatalogItemId: this.initialValues.aiCatalogItemId, + aiCatalogItemVersionId: this.initialValues.aiCatalogItemVersionId, + catalogFlows: [], + catalogVersions: [], + loadingFlows: false, + loadingVersions: false, }; }, computed: { @@ -99,6 +114,57 @@ export default { ? this.selectedUsers[0].name : s__('DuoAgentsPlatform|Select user'); }, + configModeOptions() { + return [ + { + text: s__('DuoAgentsPlatform|Manual configuration'), + value: 'manual', + }, + { + text: s__('DuoAgentsPlatform|AI Catalog flow'), + value: 'catalog', + }, + ]; + }, + catalogFlowOptions() { + return this.catalogFlows.map(flow => ({ + text: `${flow.name} (${flow.project?.nameWithNamespace})`, + value: flow.id, + })); + }, + catalogVersionOptions() { + if (!this.catalogVersions.id) return []; + + const options = []; + + // Add latest version option first if it exists + if (this.catalogVersions.latestVersion) { + options.push({ + text: `Latest (${this.catalogVersions.latestVersion.humanVersionName || this.catalogVersions.latestVersion.versionName})`, + value: this.catalogVersions.latestVersion.id, + }); + } + + // Add other versions + this.catalogVersions.versions?.nodes?.forEach(version => { + if (version.id !== this.catalogVersions.latestVersion?.id) { + options.push({ + text: version.humanVersionName || version.versionName, + value: version.id, + }); + } + }); + + return options; + }, + selectedCatalogItemText() { + const selectedItem = this.catalogFlowOptions.find(item => item.value === this.aiCatalogItemId); + return selectedItem?.text || s__('DuoAgentsPlatform|Select a flow'); + }, + selectedCatalogVersionText() { + const selectedVersion = this.catalogVersionOptions.find(version => version.value === this.aiCatalogItemVersionId); + return selectedVersion?.text || s__('DuoAgentsPlatform|Select version'); + }, }, watch: { async errorMessages(newValue) { @@ -111,6 +177,22 @@ export default { behavior: 'smooth', }); }, + configMode(newMode) { + if (newMode === 'catalog' && this.catalogFlows.length === 0) { + this.fetchCatalogFlows(); + } + }, + }, + async mounted() { + // If we're in catalog mode or editing an existing trigger with catalog item, fetch the data + if (this.configMode === 'catalog') { + await this.fetchCatalogFlows(); + + // If we already have a selected catalog item, fetch its versions + if (this.aiCatalogItemId) { + await this.fetchCatalogItemVersions(this.aiCatalogItemId); + } + } }, methods: { setEventType(eventTypesValue) { @@ -124,19 +206,81 @@ export default { }, onSubmit() { const formValues = { - configPath: this.configPath.trim(), description: this.description.trim(), eventTypes: this.eventTypes, userId: this.selectedUsers.length > 0 ? this.selectedUsers[0].id : null, }; + + if (this.configMode === 'catalog') { + formValues.aiCatalogItemId = this.aiCatalogItemId; + formValues.aiCatalogItemVersionId = this.aiCatalogItemVersionId; + formValues.configPath = null; + } else { + formValues.configPath = this.configPath.trim(); + formValues.aiCatalogItemId = null; + formValues.aiCatalogItemVersionId = null; + } + this.$emit('submit', formValues); }, usersProcessor(data) { return data.project?.projectMembers?.nodes?.map(({ user }) => user) || []; }, + onCatalogItemSelect(itemId) { + this.aiCatalogItemId = itemId; + // Reset version selection when item changes + this.aiCatalogItemVersionId = null; + // Fetch versions for the selected item + this.fetchCatalogItemVersions(itemId); + }, + onCatalogVersionSelect(versionId) { + this.aiCatalogItemVersionId = versionId; + }, + async fetchCatalogFlows() { + this.loadingFlows = true; + try { + const { data } = await this.$apollo.query({ + query: getAiCatalogFlowsForTriggersQuery, + fetchPolicy: 'cache-first', + }); + + this.catalogFlows = data.aiCatalogItems?.nodes || []; + } catch (error) { + console.error('Error fetching catalog flows:', error); + createAlert({ + message: `${s__('DuoAgentsPlatform|An error occurred while fetching catalog flows.')}: ${error.message}` + }); + } finally { + this.loadingFlows = false; + } + }, + async fetchCatalogItemVersions(itemId) { + if (!itemId) return; + + this.loadingVersions = true; + this.catalogVersions = []; + + try { + const { data } = await this.$apollo.query({ + query: getAiCatalogItemVersionsQuery, + variables: { id: itemId }, + fetchPolicy: 'cache-first', + }); + + this.catalogVersions = data.aiCatalogItem || {}; + } catch (error) { + createAlert({ + message: s__('DuoAgentsPlatform|An error occurred while fetching catalog item versions.') + }); + } finally { + this.loadingVersions = false; + } + }, }, indexRoute: FLOW_TRIGGERS_INDEX_ROUTE, projectServiceAccountsQuery, + getAiCatalogFlowsForTriggersQuery, + getAiCatalogItemVersionsQuery, }; @@ -211,7 +355,15 @@ export default { /> - + + + + + + +
(global_id, _ctx) { global_id.model_id.to_i }, + required: false, + description: 'AI catalog item to use instead of config_path.' + + argument :ai_catalog_item_version_id, ::Types::GlobalIDType[::Ai::Catalog::ItemVersion], + prepare: ->(global_id, _ctx) { global_id.model_id.to_i }, + required: false, + description: 'Specific version of the AI catalog item to use.' + field :ai_flow_trigger, Types::Ai::FlowTriggerType, description: 'Created AI flow trigger.' diff --git a/ee/app/graphql/mutations/ai/flow_triggers/update.rb b/ee/app/graphql/mutations/ai/flow_triggers/update.rb index 77400a6a3ae714d5b74a0fd6260eb1231537cce0..99f7201ceda07dc81dc88e259bf2e45c42fbf2cd 100644 --- a/ee/app/graphql/mutations/ai/flow_triggers/update.rb +++ b/ee/app/graphql/mutations/ai/flow_triggers/update.rb @@ -29,6 +29,16 @@ class Update < BaseMutation required: false, description: 'Path to the configuration file for the AI flow trigger.' + argument :ai_catalog_item_id, ::Types::GlobalIDType[::Ai::Catalog::Item], + prepare: ->(global_id, _ctx) { global_id.model_id.to_i }, + required: false, + description: 'AI catalog item to use instead of config_path.' + + argument :ai_catalog_item_version_id, ::Types::GlobalIDType[::Ai::Catalog::ItemVersion], + prepare: ->(global_id, _ctx) { global_id.model_id.to_i }, + required: false, + description: 'Specific version of the AI catalog item to use.' + field :ai_flow_trigger, Types::Ai::FlowTriggerType, description: 'Updated AI flow trigger.' diff --git a/ee/app/graphql/types/ai/flow_trigger_type.rb b/ee/app/graphql/types/ai/flow_trigger_type.rb index c84dc6bfbdfcbdeeb818ec3109e3bb984b30d7d0..9d37fa052d6b334d14ae182f5b9c551423894e76 100644 --- a/ee/app/graphql/types/ai/flow_trigger_type.rb +++ b/ee/app/graphql/types/ai/flow_trigger_type.rb @@ -31,6 +31,14 @@ class FlowTriggerType < BaseObject null: true, description: 'Web URL to the configuration file for the trigger.', calls_gitaly: true + + field :ai_catalog_item, Types::Ai::Catalog::ItemInterface, + null: true, + description: 'AI catalog item associated with the trigger.' + + field :ai_catalog_item_version, Types::Ai::Catalog::VersionInterface, + null: true, + description: 'Specific version of the AI catalog item.' # rubocop:enable GraphQL/ExtractType field :project, ::Types::ProjectType, diff --git a/ee/app/models/ai/catalog/item.rb b/ee/app/models/ai/catalog/item.rb index a676431376f1c12560a847c58a4c00bd93db8920..0f84286797a4a3f72924eb54ddf717d8a11aa63b 100644 --- a/ee/app/models/ai/catalog/item.rb +++ b/ee/app/models/ai/catalog/item.rb @@ -24,6 +24,8 @@ class Item < ApplicationRecord has_many :versions, class_name: 'Ai::Catalog::ItemVersion', foreign_key: :ai_catalog_item_id, inverse_of: :item has_many :consumers, class_name: 'Ai::Catalog::ItemConsumer', foreign_key: :ai_catalog_item_id, inverse_of: :item + has_many :flow_triggers, class_name: 'Ai::FlowTrigger', foreign_key: :ai_catalog_item_id, + inverse_of: :ai_catalog_item has_many( :dependents, diff --git a/ee/app/models/ai/flow_trigger.rb b/ee/app/models/ai/flow_trigger.rb index 1f283229c69982f1f0b6ce6d83e6ee9edbad76a9..4b356e0e27e516b4cc1b85a66b8bfa67931549ce 100644 --- a/ee/app/models/ai/flow_trigger.rb +++ b/ee/app/models/ai/flow_trigger.rb @@ -11,6 +11,8 @@ class FlowTrigger < ApplicationRecord belongs_to :project belongs_to :user + belongs_to :ai_catalog_item, class_name: 'Ai::Catalog::Item', optional: true + belongs_to :ai_catalog_item_version, class_name: 'Ai::Catalog::ItemVersion', optional: true scope :triggered_on, ->(event_type) { where("event_types @> ('{?}')", EVENT_TYPES[event_type]) } scope :by_users, ->(users) { where(user: users) } @@ -20,10 +22,13 @@ class FlowTrigger < ApplicationRecord validates :event_types, presence: true validates :description, length: { maximum: 255 }, presence: true - validates :config_path, length: { maximum: 255 }, presence: true + validates :config_path, length: { maximum: 255 }, presence: true, unless: :ai_catalog_item_id? + validates :ai_catalog_item, presence: true, unless: :config_path? validate :event_types_are_valid validate :user_is_service_account, if: :user + validate :catalog_item_version_matches_item, if: :ai_catalog_item_version_id? + validate :exactly_one_config_source scope :with_ids, ->(ids) { where(id: ids) } @@ -43,5 +48,24 @@ def user_is_service_account errors.add(:user, 'user must be a service account') end + + def catalog_item_version_matches_item + return unless ai_catalog_item_id && ai_catalog_item_version_id + + return if ai_catalog_item_version.ai_catalog_item_id == ai_catalog_item_id + + errors.add(:ai_catalog_item_version, 'must belong to the selected catalog item') + end + + def exactly_one_config_source + has_config_path = config_path.present? + has_catalog_item = ai_catalog_item_id.present? + + if has_config_path && has_catalog_item + errors.add(:base, 'cannot have both config_path and ai_catalog_item') + elsif !has_config_path && !has_catalog_item + errors.add(:base, 'must have either config_path or ai_catalog_item') + end + end end end diff --git a/ee/app/services/ai/flow_triggers/create_note_service.rb b/ee/app/services/ai/flow_triggers/create_note_service.rb index f524f1018a2ac1b06f169c6df946aa1d2e0d0302..155b4ded12f70f3268399b5944921e5005177ed8 100644 --- a/ee/app/services/ai/flow_triggers/create_note_service.rb +++ b/ee/app/services/ai/flow_triggers/create_note_service.rb @@ -15,9 +15,9 @@ def initialize(project:, resource:, author:, discussion: nil) def execute(params) note = create_note - response = yield(params.merge(discussion_id: note.discussion_id)) + response, workflow = yield(params.merge(discussion_id: note.discussion_id)) - update_note(note, response) + update_note(note, response, workflow) response end @@ -36,11 +36,11 @@ def create_note ).execute end - def update_note(note, response) + def update_note(note, response, workflow) updated_message = if response.success? link_start = format(''.html_safe, - url: response.payload.logs_url) + url: workflow.last_executor_logs_url) format(s_( "AiFlowTriggers|✅ Agent has started. You can view the progress %{link_start}here%{link_end}." ), link_start: link_start, link_end: ''.html_safe) diff --git a/ee/app/services/ai/flow_triggers/create_service.rb b/ee/app/services/ai/flow_triggers/create_service.rb index f0061eee00291e3f2c8a1cb2f3166fb32bbfcf3c..1c445cfbbf49f2c14364e34347047645d098d68a 100644 --- a/ee/app/services/ai/flow_triggers/create_service.rb +++ b/ee/app/services/ai/flow_triggers/create_service.rb @@ -10,9 +10,37 @@ def initialize(project:, current_user:) def execute(params) super do - project.ai_flow_triggers.create(params) + # Handle catalog item parameters + processed_params = process_catalog_params(params) + project.ai_flow_triggers.create(processed_params) end end + + private + + def process_catalog_params(params) + processed = params.dup + + # If ai_catalog_item_id is provided, ensure the item exists and is a flow type + if processed[:ai_catalog_item_id].present? + catalog_item = find_catalog_item(processed[:ai_catalog_item_id]) + + raise ArgumentError, 'Selected catalog item must be a flow type' unless catalog_item&.flow? + + # If no specific version is provided, use the latest version + if processed[:ai_catalog_item_version_id].blank? + processed[:ai_catalog_item_version_id] = catalog_item.latest_version_id + end + end + + processed + end + + def find_catalog_item(item_id) + # rubocop:disable CodeReuse/ActiveRecord, Gitlab/FinderWithFindBy -- ItemsFinder doesn't include FinderMethods + ::Ai::Catalog::ItemsFinder.new(current_user).execute.find_by(id: item_id) + # rubocop:enable CodeReuse/ActiveRecord, Gitlab/FinderWithFindBy + end end end end diff --git a/ee/app/services/ai/flow_triggers/run_service.rb b/ee/app/services/ai/flow_triggers/run_service.rb index c00f3a006d806d9fd2513f87840fb972804eab2e..02b5b0f77926c17463cef0f52d8857520be67b88 100644 --- a/ee/app/services/ai/flow_triggers/run_service.rb +++ b/ee/app/services/ai/flow_triggers/run_service.rb @@ -14,28 +14,16 @@ def initialize(project:, current_user:, resource:, flow_trigger:) end def execute(params) - # Create Duo Workflow Header - wf_create_result = ::Ai::DuoWorkflows::CreateWorkflowService.new( - container: project, - current_user: current_user, - params: { - workflow_definition: "Trigger - #{flow_trigger.description}", - status: :running, - goal: params[:input], - environment: :web - } - ).execute - - return ServiceResponse.error(message: wf_create_result[:message]) if wf_create_result.error? - - workflow = wf_create_result[:workflow] - params[:flow_id] = workflow.id note_service = ::Ai::FlowTriggers::CreateNoteService.new( project: project, resource: resource, author: flow_trigger_user, discussion: params[:discussion] ) note_service.execute(params) do |updated_params| - run_workload(updated_params, workflow) + if flow_trigger.ai_catalog_item.present? + start_catalog_workflow + else + run_workload(updated_params) + end end end @@ -43,7 +31,23 @@ def execute(params) attr_reader :project, :current_user, :resource, :flow_trigger, :flow_trigger_user - def run_workload(params, workflow) + def run_workload(params) + workflow_params = { + workflow_definition: "Trigger - #{flow_trigger.description}", + status: :running, + goal: params[:input], + environment: :web + } + + wf_create_result = ::Ai::DuoWorkflows::CreateWorkflowService.new( + container: project, + current_user: current_user, + params: workflow_params + ).execute + + return ServiceResponse.error(message: wf_create_result[:message]) if wf_create_result.error? + + workflow = wf_create_result[:workflow] flow_definition = fetch_flow_definition return ServiceResponse.error(message: 'invalid or missing flow definition') unless flow_definition @@ -75,9 +79,31 @@ def run_workload(params, workflow) workflow: workflow, status_event: status_event, current_user: current_user ).execute - workflow.workflows_workloads.create(project_id: project.id, workload_id: result.payload.id) + workflow.workflows_workloads.create(project_id: project.id, workload_id: result.payload.id) if result.success? + + [result, workflow] + end + + def start_catalog_workflow + response = ::Ai::Catalog::Flows::ExecuteService.new( + project: project, + current_user: current_user, + params: { + flow: flow_trigger.ai_catalog_item, + flow_version: flow_trigger.ai_catalog_item_version, + execute_workflow: true + } + ).execute + + status_event = response.success? ? "start" : "drop" + + workflow = response.payload[:workflow] - result + ::Ai::DuoWorkflows::UpdateWorkflowStatusService.new( + workflow: workflow, status_event: status_event, current_user: current_user + ).execute + + [response, workflow] end def fetch_flow_definition @@ -100,7 +126,6 @@ def build_variables(params) AI_FLOW_DISCUSSION_ID: params[:discussion_id], AI_FLOW_EVENT: params[:event].to_s, AI_FLOW_GITLAB_TOKEN: composite_identity_token, - AI_FLOW_ID: params[:flow_id], AI_FLOW_INPUT: params[:input] } @@ -131,15 +156,21 @@ def branch_args def composite_identity_token return unless can_use_composite_identity? + token = create_composite_oauth_token + token&.plaintext_token + end + + def create_composite_oauth_token composite_oauth_token_result = ::Ai::DuoWorkflows::CreateCompositeOauthAccessTokenService.new( current_user: current_user, organization: project.organization, scopes: ['api'], service_account: flow_trigger_user ).execute + return if composite_oauth_token_result.error? - composite_oauth_token_result[:oauth_access_token].plaintext_token + composite_oauth_token_result[:oauth_access_token] end def can_use_composite_identity? diff --git a/ee/app/services/ai/flow_triggers/update_service.rb b/ee/app/services/ai/flow_triggers/update_service.rb index 28a4a3413c936902814db841390ce8562cb4f77f..cac17f78c4a3f235fb53a005e3668281ceefe422 100644 --- a/ee/app/services/ai/flow_triggers/update_service.rb +++ b/ee/app/services/ai/flow_triggers/update_service.rb @@ -11,10 +11,38 @@ def initialize(project:, current_user:, trigger:) def execute(params) super do - @trigger.update(params) + # Handle catalog item parameters + processed_params = process_catalog_params(params) + @trigger.update(processed_params) @trigger end end + + private + + def process_catalog_params(params) + processed = params.dup + + # If ai_catalog_item_id is provided, ensure the item exists and is a flow type + if processed[:ai_catalog_item_id].present? + catalog_item = find_catalog_item(processed[:ai_catalog_item_id]) + + raise ArgumentError, 'Selected catalog item must be a flow type' unless catalog_item&.flow? + + # If no specific version is provided, use the latest version + if processed[:ai_catalog_item_version_id].blank? + processed[:ai_catalog_item_version_id] = catalog_item.latest_version_id + end + end + + processed + end + + def find_catalog_item(item_id) + # rubocop:disable CodeReuse/ActiveRecord, Gitlab/FinderWithFindBy -- ItemsFinder doesn't include FinderMethods + ::Ai::Catalog::ItemsFinder.new(current_user).execute.find_by(id: item_id) + # rubocop:enable CodeReuse/ActiveRecord, Gitlab/FinderWithFindBy + end end end end diff --git a/ee/spec/frontend/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form_spec.js b/ee/spec/frontend/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form_spec.js index 4271f775dcf81f4e29b1219266d4dc46bfaf82db..11cf395f9bfe49ef4a23aa54f9e09e89f5f8a82d 100644 --- a/ee/spec/frontend/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form_spec.js +++ b/ee/spec/frontend/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form_spec.js @@ -1,117 +1,272 @@ +import { GlAlert, GlButton, GlCollapsibleListbox, GlFormRadioGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert, GlForm, GlFormInput, GlFormTextarea, GlCollapsibleListbox } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + import FlowTriggerForm from 'ee/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue'; -import { mockTrigger, eventTypeOptions } from '../mocks'; +import getAiCatalogFlowsForTriggersQuery from 'ee/ai/duo_agents_platform/graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql'; +import getAiCatalogItemVersionsQuery from 'ee/ai/duo_agents_platform/graphql/queries/get_ai_catalog_item_versions.query.graphql'; Vue.use(VueApollo); describe('FlowTriggerForm', () => { let wrapper; - - const findErrorAlert = () => wrapper.findComponent(GlAlert); - const findForm = () => wrapper.findComponent(GlForm); - const findDescription = () => wrapper.findComponent(GlFormTextarea); - const findConfigPath = () => wrapper.findComponent(GlFormInput); - const findEventTypes = () => wrapper.findComponent(GlCollapsibleListbox); - const findUserSelect = () => wrapper.findComponent(UserSelect); + let mockApollo; const defaultProps = { mode: 'create', - isLoading: false, errorMessages: [], - eventTypeOptions: [], - projectPath: 'myProject', + eventTypeOptions: [ + { text: 'Mention', value: 0 }, + { text: 'Assign', value: 1 }, + ], + initialValues: { + description: '', + eventTypes: [], + configPath: '', + user: null, + aiCatalogItemId: null, + aiCatalogItemVersionId: null, + }, + isLoading: false, + projectPath: 'group/project', + }; + + const mockCatalogFlowsData = { + aiCatalogItems: { + nodes: [ + { + id: 'gid://gitlab/Ai::Catalog::Item/1', + name: 'Test Flow 1', + project: { nameWithNamespace: 'Group/Project1' }, + }, + { + id: 'gid://gitlab/Ai::Catalog::Item/2', + name: 'Test Flow 2', + project: { nameWithNamespace: 'Group/Project2' }, + }, + ], + }, }; - const createWrapper = () => { - wrapper = shallowMountExtended(FlowTriggerForm, { - apolloProvider: createMockApollo(), - propsData: defaultProps, - stubs: { - UserSelect, + const mockCatalogVersionsData = { + aiCatalogItem: { + id: 'gid://gitlab/Ai::Catalog::Item/1', + name: 'Test Flow 1', + latestVersion: { + id: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + humanVersionName: 'v1.2.0', + versionName: '1.2.0', + }, + versions: { + nodes: [ + { + id: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + humanVersionName: 'v1.2.0', + versionName: '1.2.0', + }, + { + id: 'gid://gitlab/Ai::Catalog::ItemVersion/2', + humanVersionName: 'v1.1.0', + versionName: '1.1.0', + }, + ], + }, + }, + }; + + const createMockApolloProvider = ({ + catalogFlowsResolver = jest.fn().mockResolvedValue({ data: mockCatalogFlowsData }), + catalogVersionsResolver = jest.fn().mockResolvedValue({ data: mockCatalogVersionsData }), + } = {}) => { + return createMockApollo([ + [getAiCatalogFlowsForTriggersQuery, catalogFlowsResolver], + [getAiCatalogItemVersionsQuery, catalogVersionsResolver], + ]); + }; + + const createComponent = (props = {}, apolloOptions = {}) => { + mockApollo = createMockApolloProvider(apolloOptions); + + wrapper = shallowMount(FlowTriggerForm, { + apolloProvider: mockApollo, + propsData: { + ...defaultProps, + ...props, }, }); }; - describe('Rendering', () => { - it('does not render error alert', () => { - createWrapper(); + const findAlert = () => wrapper.findComponent(GlAlert); + const findConfigModeRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + const findCatalogFlowListbox = () => wrapper.findAllComponents(GlCollapsibleListbox).at(1); + const findCatalogVersionListbox = () => wrapper.findAllComponents(GlCollapsibleListbox).at(2); - expect(findErrorAlert().exists()).toBe(false); + describe('configuration mode selection', () => { + beforeEach(() => { + createComponent(); }); - }); - describe('with error messages', () => { - const mockErrorMessage = 'The flow could not be created'; + it('renders configuration mode radio group', () => { + const radioGroup = findConfigModeRadioGroup(); + expect(radioGroup.exists()).toBe(true); + expect(radioGroup.props('options')).toEqual([ + { text: 'Manual configuration', value: 'manual' }, + { text: 'AI Catalog flow', value: 'catalog' }, + ]); + }); + + it('defaults to manual mode when no catalog item is selected', () => { + expect(wrapper.vm.configMode).toBe('manual'); + }); - beforeEach(async () => { - window.scrollTo = jest.fn(); - createWrapper(); - await wrapper.setProps({ - errorMessages: [mockErrorMessage], + it('defaults to catalog mode when catalog item is provided in initial values', () => { + createComponent({ + initialValues: { + ...defaultProps.initialValues, + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + }, }); + + expect(wrapper.vm.configMode).toBe('catalog'); + }); + }); + + describe('catalog configuration mode', () => { + let catalogFlowsResolver; + let catalogVersionsResolver; + + beforeEach(() => { + catalogFlowsResolver = jest.fn().mockResolvedValue({ data: mockCatalogFlowsData }); + catalogVersionsResolver = jest.fn().mockResolvedValue({ data: mockCatalogVersionsData }); + + createComponent( + { + initialValues: { + ...defaultProps.initialValues, + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + }, + }, + { catalogFlowsResolver, catalogVersionsResolver } + ); + }); + + it('fetches catalog flows on mount when in catalog mode', async () => { + await waitForPromises(); + expect(catalogFlowsResolver).toHaveBeenCalled(); }); - it('renders error alert', () => { - expect(findErrorAlert().find('li').text()).toBe(mockErrorMessage); + it('displays catalog flow options correctly', async () => { + await waitForPromises(); + + const catalogFlowOptions = wrapper.vm.catalogFlowOptions; + expect(catalogFlowOptions).toEqual([ + { text: 'Test Flow 1 (Group/Project1)', value: 'gid://gitlab/Ai::Catalog::Item/1' }, + { text: 'Test Flow 2 (Group/Project2)', value: 'gid://gitlab/Ai::Catalog::Item/2' }, + ]); }); - it('scrolls to the top', () => { - expect(window.scrollTo).toHaveBeenCalledWith({ - top: 0, - left: 0, - behavior: 'smooth', + it('fetches versions when a catalog item is selected', async () => { + await wrapper.setData({ configMode: 'catalog' }); + await wrapper.vm.onCatalogItemSelect('gid://gitlab/Ai::Catalog::Item/1'); + await waitForPromises(); + + expect(catalogVersionsResolver).toHaveBeenCalledWith({ + id: 'gid://gitlab/Ai::Catalog::Item/1', }); }); - it('customSearchUsersProcessor handles project service account response mapping', () => { - const user1 = { id: 1, name: 'a' }; - const user2 = { id: 2, name: 'b' }; - const data = { project: { projectMembers: { nodes: [{ user: user1 }, { user: user2 }] } } }; + it('resets version selection when catalog item changes', async () => { + await wrapper.setData({ + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + aiCatalogItemVersionId: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + }); + + await wrapper.vm.onCatalogItemSelect('gid://gitlab/Ai::Catalog::Item/2'); + + expect(wrapper.vm.aiCatalogItemId).toBe('gid://gitlab/Ai::Catalog::Item/2'); + expect(wrapper.vm.aiCatalogItemVersionId).toBeNull(); + }); + }); - expect(findUserSelect().props('customSearchUsersProcessor')(data)).toContain(user1, user2); + describe('form submission', () => { + beforeEach(() => { + createComponent(); }); - it('renders error alert with list for multiple errors', async () => { - await wrapper.setProps({ - errorMessages: ['error1', 'error2'], + it('emits correct data for manual configuration', async () => { + await wrapper.setData({ + configMode: 'manual', + description: 'Test description', + eventTypes: [0, 1], + configPath: 'path/to/config.yml', + selectedUsers: [{ id: 123, name: 'Test User' }], }); - expect(findErrorAlert().findAll('li')).toHaveLength(2); + await wrapper.vm.onSubmit(); + + expect(wrapper.emitted('submit')[0][0]).toEqual({ + description: 'Test description', + eventTypes: [0, 1], + userId: 123, + configPath: 'path/to/config.yml', + aiCatalogItemId: null, + aiCatalogItemVersionId: null, + }); }); - it('emits dismiss-errors event', () => { - findErrorAlert().vm.$emit('dismiss'); + it('emits correct data for catalog configuration', async () => { + await wrapper.setData({ + configMode: 'catalog', + description: 'Test description', + eventTypes: [0], + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + aiCatalogItemVersionId: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + selectedUsers: [{ id: 123, name: 'Test User' }], + }); + + await wrapper.vm.onSubmit(); - expect(wrapper.emitted('dismiss-errors')).toHaveLength(1); + expect(wrapper.emitted('submit')[0][0]).toEqual({ + description: 'Test description', + eventTypes: [0], + userId: 123, + configPath: null, + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + aiCatalogItemVersionId: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + }); }); }); - describe('Form Submit', () => { - beforeEach(() => { - createWrapper(); + describe('error handling', () => { + it('displays error messages', () => { + createComponent({ + errorMessages: ['Error 1', 'Error 2'], + }); + + const alert = findAlert(); + expect(alert.exists()).toBe(true); + expect(alert.props('variant')).toBe('danger'); }); - describe('when using the submit button', () => { - it('submits the form', async () => { - const description = 'My description'; - const configPath = 'my/config/path'; - const eventTypes = [eventTypeOptions[0].value]; - await findDescription().vm.$emit('input', description); - await findConfigPath().vm.$emit('input', configPath); - await findEventTypes().vm.$emit('select', eventTypes); - await findUserSelect().vm.$emit('input', [mockTrigger.user]); - - findForm().vm.$emit('submit', { preventDefault: () => {} }); - - expect(wrapper.emitted('submit')).toEqual([ - [{ configPath, description, eventTypes, userId: 'gid://gitlab/User/1' }], - ]); - }); + it('handles catalog flows fetch error gracefully', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const catalogFlowsResolver = jest.fn().mockRejectedValue(new Error('Network error')); + + createComponent( + { + initialValues: { + ...defaultProps.initialValues, + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + }, + }, + { catalogFlowsResolver } + ); + + await waitForPromises(); + expect(catalogFlowsResolver).toHaveBeenCalled(); }); }); -}); +}); \ No newline at end of file diff --git a/ee/spec/graphql/mutations/ai/flow_triggers/create_spec.rb b/ee/spec/graphql/mutations/ai/flow_triggers/create_spec.rb index fbf8d8ed403a28a226f09a7289637aec37a2e1de..667c473b0bcf51c34d181069b306bbc1db66f08d 100644 --- a/ee/spec/graphql/mutations/ai/flow_triggers/create_spec.rb +++ b/ee/spec/graphql/mutations/ai/flow_triggers/create_spec.rb @@ -20,6 +20,8 @@ :description, :event_types, :config_path, + :ai_catalog_item_id, + :ai_catalog_item_version_id, :client_mutation_id ) end diff --git a/ee/spec/graphql/mutations/ai/flow_triggers/update_spec.rb b/ee/spec/graphql/mutations/ai/flow_triggers/update_spec.rb index 0c7bb51beb0132e37c726586faac0d0886224100..63ab5eeff05bcde03f678e94355755dd2b4aa4f8 100644 --- a/ee/spec/graphql/mutations/ai/flow_triggers/update_spec.rb +++ b/ee/spec/graphql/mutations/ai/flow_triggers/update_spec.rb @@ -20,6 +20,8 @@ :description, :event_types, :config_path, + :ai_catalog_item_id, + :ai_catalog_item_version_id, :client_mutation_id ) end diff --git a/ee/spec/graphql/types/ai/flow_trigger_type_spec.rb b/ee/spec/graphql/types/ai/flow_trigger_type_spec.rb index ee6a4c231566d7fff57ac8aab3aaa48ec0b2ce72..98e35abe39c1faf1a77b3dad57b0ec5f9f5a5315 100644 --- a/ee/spec/graphql/types/ai/flow_trigger_type_spec.rb +++ b/ee/spec/graphql/types/ai/flow_trigger_type_spec.rb @@ -16,6 +16,8 @@ config_url project user + ai_catalog_item + ai_catalog_item_version created_at updated_at ] diff --git a/ee/spec/models/ai/catalog/item_spec.rb b/ee/spec/models/ai/catalog/item_spec.rb index 8b05bed10df6a4660aad2023fbb84a586657efbc..ef1a4c298cb5ae854627b002fa9edb6537e9104a 100644 --- a/ee/spec/models/ai/catalog/item_spec.rb +++ b/ee/spec/models/ai/catalog/item_spec.rb @@ -11,6 +11,7 @@ it { is_expected.to have_many(:versions) } it { is_expected.to have_many(:consumers) } it { is_expected.to have_many(:dependents) } + it { is_expected.to have_many(:flow_triggers).class_name('Ai::FlowTrigger') } end describe 'validations' do @@ -388,4 +389,50 @@ expect(item.latest_version).to eq(item.versions.last) end end + + describe '#flow_triggers association' do + let_it_be(:organization) { create(:organization) } + let_it_be(:project) { create(:project, organization: organization) } + let_it_be(:user) { create(:service_account) } + let_it_be(:catalog_item) { create(:ai_catalog_item, :flow, organization: organization) } + let_it_be(:catalog_version) { create(:ai_catalog_item_version, :for_flow, item: catalog_item) } + + before do + catalog_item.flow_triggers.delete_all + catalog_item.association(:flow_triggers).reload + end + + it 'associates flow triggers with the catalog item' do + flow_trigger = create(:ai_flow_trigger, + project: project, + user: user, + config_path: nil, + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + + catalog_item.association(:flow_triggers).reload + expect(catalog_item.flow_triggers).to include(flow_trigger) + end + + it 'allows multiple flow triggers for the same catalog item' do + project2 = create(:project, organization: organization) + + flow_trigger1 = create(:ai_flow_trigger, + project: project, + user: user, + config_path: nil, + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + + flow_trigger2 = create(:ai_flow_trigger, + project: project2, + user: user, + config_path: nil, + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + + catalog_item.association(:flow_triggers).reload + expect(catalog_item.flow_triggers).to contain_exactly(flow_trigger1, flow_trigger2) + end + end end diff --git a/ee/spec/models/ai/flow_trigger_spec.rb b/ee/spec/models/ai/flow_trigger_spec.rb index 5394de47db9fd609eb545d2aa0548135d08c8ebb..e16e83cf11c871946d20db517911d522d04630a4 100644 --- a/ee/spec/models/ai/flow_trigger_spec.rb +++ b/ee/spec/models/ai/flow_trigger_spec.rb @@ -11,6 +11,8 @@ describe 'associations' do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:ai_catalog_item).class_name('Ai::Catalog::Item').optional } + it { is_expected.to belong_to(:ai_catalog_item_version).class_name('Ai::Catalog::ItemVersion').optional } end describe 'validations' do @@ -18,7 +20,6 @@ it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:event_types) } it { is_expected.to validate_presence_of(:description) } - it { is_expected.to validate_presence_of(:config_path) } it { is_expected.to validate_length_of(:description).is_at_most(255) } it { is_expected.to validate_length_of(:config_path).is_at_most(255) } @@ -50,6 +51,73 @@ expect(flow_trigger.errors[:user]).to include('user must be a service account') end end + + describe 'configuration source validation' do + let_it_be(:organization) { create(:organization) } + let_it_be(:catalog_item) { create(:ai_catalog_item, :flow, organization: organization) } + let_it_be(:catalog_version) { create(:ai_catalog_item_version, :for_flow, item: catalog_item) } + + context 'when using config_path only' do + it 'is valid' do + flow_trigger = build(:ai_flow_trigger, config_path: 'path/to/config.yml') + expect(flow_trigger).to be_valid + end + end + + context 'when using ai_catalog_item only' do + it 'is valid' do + flow_trigger = build(:ai_flow_trigger, + config_path: nil, + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + expect(flow_trigger).to be_valid + end + end + + context 'when using both config_path and ai_catalog_item' do + it 'is invalid' do + flow_trigger = build(:ai_flow_trigger, + config_path: 'path/to/config.yml', + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + expect(flow_trigger).not_to be_valid + expect(flow_trigger.errors[:base]).to include('cannot have both config_path and ai_catalog_item') + end + end + + context 'when using neither config_path nor ai_catalog_item' do + it 'is invalid' do + flow_trigger = build(:ai_flow_trigger, config_path: nil) + expect(flow_trigger).not_to be_valid + expect(flow_trigger.errors[:base]).to include('must have either config_path or ai_catalog_item') + end + end + end + + describe 'catalog_item_version_matches_item validation' do + let_it_be(:organization) { create(:organization) } + let_it_be(:catalog_item1) { create(:ai_catalog_item, :flow, organization: organization) } + let_it_be(:catalog_item2) { create(:ai_catalog_item, :flow, organization: organization) } + let_it_be(:version1) { create(:ai_catalog_item_version, :for_flow, item: catalog_item1) } + let_it_be(:version2) { create(:ai_catalog_item_version, :for_flow, item: catalog_item2) } + + it 'is valid when version belongs to the selected item' do + flow_trigger = build(:ai_flow_trigger, + config_path: nil, + ai_catalog_item: catalog_item1, + ai_catalog_item_version: version1) + expect(flow_trigger).to be_valid + end + + it 'is invalid when version belongs to a different item' do + flow_trigger = build(:ai_flow_trigger, + config_path: nil, + ai_catalog_item: catalog_item1, + ai_catalog_item_version: version2) + expect(flow_trigger).not_to be_valid + expect(flow_trigger.errors[:ai_catalog_item_version]).to include('must belong to the selected catalog item') + end + end end describe 'database constraints' do @@ -57,14 +125,14 @@ expect(described_class.table_name).to eq('ai_flow_triggers') end - context 'with loose foreign key on users.id' do + context 'when using loose foreign key on users.id' do it_behaves_like 'cleanup by a loose foreign key' do let!(:model) { create(:ai_flow_trigger) } let!(:parent) { model.user } end end - context 'with loose foreign key on projects.id' do + context 'when using loose foreign key on projects.id' do it_behaves_like 'cleanup by a loose foreign key' do let!(:model) { create(:ai_flow_trigger) } let!(:parent) { model.project } diff --git a/ee/spec/services/ai/flow_triggers/create_service_spec.rb b/ee/spec/services/ai/flow_triggers/create_service_spec.rb index a95def74840be52082a9789f31e9ed0aa9f44539..c197d07f2ea8a262f5b2176cc8fba877efe1beb7 100644 --- a/ee/spec/services/ai/flow_triggers/create_service_spec.rb +++ b/ee/spec/services/ai/flow_triggers/create_service_spec.rb @@ -53,7 +53,7 @@ end end - context 'with invalid params' do + context 'when using invalid params' do let(:event_types) { [99] } it 'returns the error' do @@ -80,5 +80,69 @@ expect(response).not_to be_success end end + + context 'when using catalog item configuration' do + let_it_be(:organization) { create(:organization) } + let_it_be(:catalog_version) { create(:ai_catalog_item_version, :for_flow, item: catalog_item) } + let(:catalog_params) do + { + user_id: service_account.id, + event_types: event_types, + description: "catalog flow trigger", + ai_catalog_item_id: catalog_item.id, + ai_catalog_item_version_id: catalog_version.id + } + end + + let_it_be(:catalog_item) { create(:ai_catalog_item, :flow, organization: organization, public: true) } + + before do + human_user.update!(organization: organization) + stub_feature_flags(global_ai_catalog: true) + end + + it 'creates a flow trigger with catalog item' do + response = service.execute(catalog_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger).to be_persisted + expect(flow_trigger.ai_catalog_item).to eq(catalog_item) + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_version) + expect(flow_trigger.config_path).to be_nil + end + + it 'uses latest version when no version is specified' do + catalog_params_without_version = catalog_params.except(:ai_catalog_item_version_id) + + response = service.execute(catalog_params_without_version) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_item.latest_version) + end + + context 'when catalog item is not a flow type' do + let_it_be(:agent_item) { create(:ai_catalog_item, :agent, organization: organization) } + + it 'raises an error' do + invalid_params = catalog_params.merge(ai_catalog_item_id: agent_item.id) + + expect { service.execute(invalid_params) }.to raise_error( + ArgumentError, 'Selected catalog item must be a flow type' + ) + end + end + + context 'when catalog item does not exist' do + it 'raises an error for non-existent item' do + invalid_params = catalog_params.merge(ai_catalog_item_id: 99999) + + expect { service.execute(invalid_params) }.to raise_error( + ArgumentError, 'Selected catalog item must be a flow type' + ) + end + end + end end end diff --git a/ee/spec/services/ai/flow_triggers/run_service_spec.rb b/ee/spec/services/ai/flow_triggers/run_service_spec.rb index 1064cbe343b3a37ffb66159d4f22a15a5a180d7f..44ff75ef4277b26ef4220ccb89fbf77f12f8ad45 100644 --- a/ee/spec/services/ai/flow_triggers/run_service_spec.rb +++ b/ee/spec/services/ai/flow_triggers/run_service_spec.rb @@ -231,7 +231,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present expect(variables[:AI_FLOW_AI_GATEWAY_TOKEN]).to eq('test-token-123') expect(variables[:AI_FLOW_AI_GATEWAY_HEADERS]).to eq( @@ -372,7 +371,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present # These should not be present expect(variables).not_to have_key(:AI_FLOW_AI_GATEWAY_TOKEN) @@ -418,7 +416,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present # These should not be present expect(variables).not_to have_key(:AI_FLOW_AI_GATEWAY_TOKEN) @@ -451,7 +448,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present expect(variables[:AI_FLOW_GITLAB_TOKEN]).to be_nil original_method.call(**kwargs) end @@ -484,7 +480,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present expect(variables[:AI_FLOW_GITLAB_TOKEN]).to be_nil original_method.call(**kwargs) end @@ -507,7 +502,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present expect(variables[:AI_FLOW_GITLAB_TOKEN]).to be_present original_method.call(**kwargs) end diff --git a/ee/spec/services/ai/flow_triggers/update_service_spec.rb b/ee/spec/services/ai/flow_triggers/update_service_spec.rb index ca0f7c721ce9bf169105a5bc047f92ed58798710..39cdfb35acd160a93486301cdef9b5590dbc48d4 100644 --- a/ee/spec/services/ai/flow_triggers/update_service_spec.rb +++ b/ee/spec/services/ai/flow_triggers/update_service_spec.rb @@ -50,7 +50,7 @@ end end - context 'with invalid params' do + context 'when using invalid params' do let(:event_types) { [99] } it 'returns the error' do @@ -77,5 +77,110 @@ expect(response).not_to be_success end end + + context 'when updating catalog item configuration' do + let_it_be(:organization) { create(:organization) } + let_it_be(:catalog_item1) { create(:ai_catalog_item, :flow, organization: organization, public: true) } + let_it_be(:catalog_item2) { create(:ai_catalog_item, :flow, organization: organization, public: true) } + let_it_be(:catalog_version1) { create(:ai_catalog_item_version, :for_flow, item: catalog_item1) } + let_it_be(:catalog_version2) { create(:ai_catalog_item_version, :for_flow, item: catalog_item2) } + + before do + human_user.update!(organization: organization) + stub_feature_flags(global_ai_catalog: true) + end + + context 'when switching from config_path to catalog item' do + let(:trigger) do + create(:ai_flow_trigger, project: project, user: service_account, config_path: 'path/config.yml') + end + + it 'updates to use catalog item' do + catalog_params = { + user_id: service_account.id, + ai_catalog_item_id: catalog_item1.id, + ai_catalog_item_version_id: catalog_version1.id, + config_path: nil + } + + response = service.execute(catalog_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.ai_catalog_item).to eq(catalog_item1) + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_version1) + expect(flow_trigger.config_path).to be_nil + end + end + + context 'when switching from catalog item to config_path' do + let(:trigger) do + create(:ai_flow_trigger, + project: project, + user: service_account, + config_path: nil, + ai_catalog_item: catalog_item1, + ai_catalog_item_version: catalog_version1) + end + + it 'updates to use config_path' do + config_params = { + user_id: service_account.id, + config_path: 'new/path/config.yml', + ai_catalog_item_id: nil, + ai_catalog_item_version_id: nil + } + + response = service.execute(config_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.config_path).to eq('new/path/config.yml') + expect(flow_trigger.ai_catalog_item).to be_nil + expect(flow_trigger.ai_catalog_item_version).to be_nil + end + end + + context 'when updating catalog item and version' do + let(:trigger) do + create(:ai_flow_trigger, + project: project, + user: service_account, + config_path: nil, + ai_catalog_item: catalog_item1, + ai_catalog_item_version: catalog_version1) + end + + it 'updates to different catalog item' do + update_params = { + user_id: service_account.id, + ai_catalog_item_id: catalog_item2.id, + ai_catalog_item_version_id: catalog_version2.id + } + + response = service.execute(update_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.ai_catalog_item).to eq(catalog_item2) + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_version2) + end + + it 'uses latest version when no version specified' do + update_params = { + user_id: service_account.id, + ai_catalog_item_id: catalog_item2.id, + ai_catalog_item_version_id: nil + } + + response = service.execute(update_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.ai_catalog_item).to eq(catalog_item2) + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_item2.latest_version) + end + end + end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a0c2a978c59e122a57ecc34d59cf7fb9e1168921..c567deafbd6883ce0c8dd9288c7dca8896aa36c1 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -24518,6 +24518,12 @@ msgstr "" msgid "DuoAgenticChat|GitLab Duo Agentic Chat" msgstr "" +msgid "DuoAgentsPlatform|AI Catalog Flow" +msgstr "" + +msgid "DuoAgentsPlatform|AI Catalog flow" +msgstr "" + msgid "DuoAgentsPlatform|Action" msgstr "" @@ -24530,6 +24536,12 @@ msgstr "" msgid "DuoAgentsPlatform|Agent sessions" msgstr "" +msgid "DuoAgentsPlatform|An error occurred while fetching catalog flows." +msgstr "" + +msgid "DuoAgentsPlatform|An error occurred while fetching catalog item versions." +msgstr "" + msgid "DuoAgentsPlatform|An error occurred while fetching users." msgstr "" @@ -24548,6 +24560,9 @@ msgstr "" msgid "DuoAgentsPlatform|Config path" msgstr "" +msgid "DuoAgentsPlatform|Configuration source" +msgstr "" + msgid "DuoAgentsPlatform|Convert Jenkins to CI" msgstr "" @@ -24659,6 +24674,9 @@ msgstr "" msgid "DuoAgentsPlatform|Manage automated flows within your project." msgstr "" +msgid "DuoAgentsPlatform|Manual configuration" +msgstr "" + msgid "DuoAgentsPlatform|New" msgstr "" @@ -24713,12 +24731,18 @@ msgstr "" msgid "DuoAgentsPlatform|Select a service account user" msgstr "" +msgid "DuoAgentsPlatform|Select an AI Catalog flow" +msgstr "" + msgid "DuoAgentsPlatform|Select one or multiple event types" msgstr "" msgid "DuoAgentsPlatform|Select user" msgstr "" +msgid "DuoAgentsPlatform|Select version" +msgstr "" + msgid "DuoAgentsPlatform|Service account user" msgstr "" @@ -24746,6 +24770,9 @@ msgstr "" msgid "DuoAgentsPlatform|Unknown" msgstr "" +msgid "DuoAgentsPlatform|Version" +msgstr "" + msgid "DuoAgentsPlatform|Write file" msgstr ""