diff --git a/ee/app/models/ai/duo_workflows/workflow_definition.rb b/ee/app/models/ai/duo_workflows/workflow_definition.rb index 7fd0ddab38bc46d6467d3902bcb6284d0b14e50b..c228f71e48dd8fe64f40880be72c1a00d8123eed 100644 --- a/ee/app/models/ai/duo_workflows/workflow_definition.rb +++ b/ee/app/models/ai/duo_workflows/workflow_definition.rb @@ -17,7 +17,8 @@ class WorkflowDefinition ::Ai::DuoWorkflows::Workflow::AgentPrivileges::READ_WRITE_GITLAB, ::Ai::DuoWorkflows::Workflow::AgentPrivileges::RUN_COMMANDS, ::Ai::DuoWorkflows::Workflow::AgentPrivileges::USE_GIT - ] + ], + triggers: [] }, { id: 2, @@ -31,7 +32,8 @@ class WorkflowDefinition ::Ai::DuoWorkflows::Workflow::AgentPrivileges::RUN_COMMANDS, ::Ai::DuoWorkflows::Workflow::AgentPrivileges::USE_GIT ], - environment: "web" + environment: "web", + triggers: [] }, { id: 3, @@ -45,7 +47,39 @@ class WorkflowDefinition ::Ai::DuoWorkflows::Workflow::AgentPrivileges::RUN_COMMANDS, ::Ai::DuoWorkflows::Workflow::AgentPrivileges::USE_GIT ], - environment: "web" + environment: "web", + triggers: [] + }, + { + id: 4, + name: "developer/v1", + foundational_flow_reference: "developer/v1", + description: "GitLab Duo developer", + pre_approved_agent_privileges: [ + ::Ai::DuoWorkflows::Workflow::AgentPrivileges::READ_WRITE_FILES, + ::Ai::DuoWorkflows::Workflow::AgentPrivileges::READ_ONLY_GITLAB, + ::Ai::DuoWorkflows::Workflow::AgentPrivileges::READ_WRITE_GITLAB, + ::Ai::DuoWorkflows::Workflow::AgentPrivileges::USE_GIT + ], + environment: "web", + triggers: [::Ai::FlowTrigger::EVENT_TYPES[:assign]], + avatar: "gitlab-duo-flow.png" + }, + { + id: 5, + name: "fix_pipeline/v1", + foundational_flow_reference: "fix_pipeline/v1", + description: "GitLab pipeline troubleshooter", + pre_approved_agent_privileges: [ + ::Ai::DuoWorkflows::Workflow::AgentPrivileges::READ_WRITE_FILES, + ::Ai::DuoWorkflows::Workflow::AgentPrivileges::READ_ONLY_GITLAB, + ::Ai::DuoWorkflows::Workflow::AgentPrivileges::READ_WRITE_GITLAB, + ::Ai::DuoWorkflows::Workflow::AgentPrivileges::RUN_COMMANDS, + ::Ai::DuoWorkflows::Workflow::AgentPrivileges::USE_GIT + ], + environment: "web", + triggers: [], + avatar: "fix-pipeline-flow.png" } ].freeze @@ -57,6 +91,8 @@ class WorkflowDefinition attribute :environment, :string, default: "ambient" attribute :foundational_flow_reference, :string attribute :description, :string + attribute :triggers, default: [] + attribute :avatar, :string validates :name, :ai_feature, presence: true diff --git a/ee/app/services/ai/catalog/flows/sync_foundational_flows_service.rb b/ee/app/services/ai/catalog/flows/sync_foundational_flows_service.rb index 55a0c937988014f94127ed5e92a0d6974c6a6767..4589bedae5528058f8d8e17452e588fb59487390 100644 --- a/ee/app/services/ai/catalog/flows/sync_foundational_flows_service.rb +++ b/ee/app/services/ai/catalog/flows/sync_foundational_flows_service.rb @@ -52,20 +52,33 @@ def sync_flows end def create_consumer_for_catalog_item(item) - params = { item: item } + return unless should_create_consumer?(item) + return unless authorized_to_create_consumer?(item) - if container.is_a?(Project) && item.flow? - parent_consumer = container.root_ancestor.configured_ai_catalog_items.for_item(item.id).first + result = create_or_find_consumer(item) + consumer = extract_consumer_from_result(result, item) - return if parent_consumer.nil? + create_trigger_if_needed(consumer, item) if consumer - params[:parent_item_consumer] = parent_consumer - end + result + end - if current_user - return unless Ability.allowed?(current_user, :admin_ai_catalog_item_consumer, container) - return unless Ability.allowed?(current_user, :read_ai_catalog_item, item) - end + def should_create_consumer?(item) + return true unless container.is_a?(Project) + + parent_consumer = find_existing_consumer(item, container.root_ancestor) + parent_consumer.present? + end + + def authorized_to_create_consumer?(item) + return false unless current_user + + Ability.allowed?(current_user, :admin_ai_catalog_item_consumer, container) && + Ability.allowed?(current_user, :read_ai_catalog_item, item) + end + + def create_or_find_consumer(item) + params = build_consumer_params(item) ::Ai::Catalog::ItemConsumers::CreateService.new( container: container, @@ -74,6 +87,72 @@ def create_consumer_for_catalog_item(item) ).execute end + def build_consumer_params(item) + params = { item: item } + + if container.is_a?(Project) && item.flow? + parent_consumer = find_existing_consumer(item, container.root_ancestor) + + params[:parent_item_consumer] = parent_consumer if parent_consumer + end + + params + end + + def extract_consumer_from_result(result, item) + return result.payload[:item_consumer] if result.success? + return find_existing_consumer(item, container) if item_already_configured?(result) + + nil + end + + def item_already_configured?(result) + result.error? && result.message.include?("Item already configured") + end + + def find_existing_consumer(item, container) + container.configured_ai_catalog_items.find { |c| c.ai_catalog_item_id == item.id } + end + + def create_trigger_if_needed(consumer, item) + return unless container.is_a?(Project) + + create_trigger_for_consumer(consumer, item) + end + + def create_trigger_for_consumer(consumer, item) + service_account = extract_service_account(consumer) + return unless service_account + + trigger_params = build_trigger_params(service_account, consumer, item) + return unless trigger_params.present? + + ::Ai::FlowTriggers::CreateService.new( + project: container, + current_user: current_user + ).execute(trigger_params) + end + + def extract_service_account(consumer) + if consumer.project.present? + consumer.parent_item_consumer&.service_account + else + consumer.service_account + end + end + + def build_trigger_params(service_account, consumer, item) + event_types = fetch_event_type_for_flow(item.foundational_flow_reference, service_account) + return if event_types.empty? + + { + user_id: service_account.id, + description: "Foundational flow trigger for #{item.name}", + ai_catalog_item_consumer_id: consumer.id, + event_types: event_types + } + end + def remove_consumers_not_in(catalog_item_ids) ids_to_remove = foundational_flow_ids - catalog_item_ids @@ -87,6 +166,20 @@ def remove_all_flows def foundational_flow_ids Item.foundational_flow_ids end + + def fetch_event_type_for_flow(foundational_flow_reference, service_account) + flow_definition = ::Ai::DuoWorkflows::WorkflowDefinition[foundational_flow_reference] + return [] unless flow_definition.present? && flow_definition.triggers.present? + + flow_definition.triggers.reject { |event| trigger_exists?(service_account, event) } + end + + def trigger_exists?(service_account, event) + event_type = ::Ai::FlowTrigger::EVENT_TYPES.key(event) + return false unless event_type + + container.ai_flow_triggers.triggered_on(event_type).by_users([service_account]).exists? + end end end end diff --git a/ee/spec/services/ai/catalog/flows/sync_foundational_flows_service_spec.rb b/ee/spec/services/ai/catalog/flows/sync_foundational_flows_service_spec.rb index c110270663a4bf7b91e691e79e886be8bb56ac61..e219d984617f4888bcd977254757119b68757001 100644 --- a/ee/spec/services/ai/catalog/flows/sync_foundational_flows_service_spec.rb +++ b/ee/spec/services/ai/catalog/flows/sync_foundational_flows_service_spec.rb @@ -77,9 +77,11 @@ it 'creates consumers with parent consumer for flows' do parent_consumer = create(:ai_catalog_item_consumer, group: group, item: flow1) allow(container).to receive(:enabled_flow_catalog_item_ids).and_return([flow1.id]) + allow(group).to receive(:configured_ai_catalog_items).and_return([parent_consumer]) create_service = instance_double(Ai::Catalog::ItemConsumers::CreateService) - allow(create_service).to receive(:execute).and_return(ServiceResponse.success) + allow(create_service).to receive(:execute).and_return( + ServiceResponse.success(payload: { item_consumer: parent_consumer })) expect(Ai::Catalog::ItemConsumers::CreateService).to receive(:new) .with( @@ -102,6 +104,106 @@ end end + context 'when trigger creation' do + let(:container) { project } + let(:create_service) { instance_double(Ai::Catalog::ItemConsumers::CreateService) } + let(:flow) do + create(:ai_catalog_item, foundational_flow_reference: 'developer/v1', public: true, + organization: group.organization) + end + + before do + container.project_setting.update!(duo_foundational_flows_enabled: true) + allow(container).to receive(:enabled_flow_catalog_item_ids).and_return([flow.id]) + allow(Ability).to receive(:allowed?).with(user, :admin_ai_catalog_item_consumer, container).and_return(true) + allow(Ability).to receive(:allowed?).with(user, :read_ai_catalog_item, flow).and_return(true) + allow(Ai::Catalog::ItemConsumers::CreateService).to receive(:new).and_return(create_service) + end + + context 'when consumer is group-level' do + before do + group_consumer = build(:ai_catalog_item_consumer, group: group, item: flow) + allow(create_service).to receive(:execute).and_return( + ServiceResponse.success(payload: { item_consumer: group_consumer }) + ) + end + + it 'does not create triggers' do + expect(Ai::FlowTriggers::CreateService).not_to receive(:new) + + described_class.new(container, current_user: user).execute + end + end + + context 'when parent consumer has no service account' do + before do + parent_consumer = create(:ai_catalog_item_consumer, group: group, item: flow) + allow(parent_consumer).to receive(:service_account).and_return(nil) + + project_consumer = build(:ai_catalog_item_consumer, + project: container, + item: flow1, + parent_item_consumer: parent_consumer + ) + + allow(create_service).to receive(:execute).and_return( + ServiceResponse.success(payload: { item_consumer: project_consumer }) + ) + end + + it 'does not create triggers' do + expect(Ai::FlowTriggers::CreateService).not_to receive(:new) + + described_class.new(container, current_user: user).execute + end + end + + context 'when parent consumer has service account' do + let(:service_account) do + create(:user, :service_account, composite_identity_enforced: true, provisioned_by_group: group) + end + + let(:parent_consumer) { create(:ai_catalog_item_consumer, group: group, item: flow) } + let(:project_consumer) do + create(:ai_catalog_item_consumer, project: container, item: flow1, parent_item_consumer: parent_consumer) + end + + before do + allow(parent_consumer).to receive(:service_account).and_return(service_account) + allow(create_service).to receive(:execute).and_return( + ServiceResponse.success(payload: { item_consumer: project_consumer }) + ) + allow(Ability).to receive(:allowed?).with(user, :admin_service_accounts, group).and_return(true) + allow(group).to receive(:configured_ai_catalog_items).and_return([parent_consumer]) + end + + it 'creates triggers' do + expect_next_instance_of(::Ai::FlowTriggers::CreateService) do |instance| + expect(instance).to receive(:execute).with( + hash_including(user_id: service_account.id, ai_catalog_item_consumer_id: project_consumer.id) + ).and_call_original + end + described_class.new(container, current_user: user).execute + end + + context 'when trigger already exists' do + before do + create(:ai_flow_trigger, + project: container, + user: service_account, + event_types: [::Ai::FlowTrigger::EVENT_TYPES[:assign]] + ) + end + + it 'does not create a duplicate trigger' do + expect(Ai::FlowTriggers::CreateService).not_to receive(:new) + + described_class.new(container, current_user: user).execute + end + end + end + end + context 'when user does not have permission' do before do container.namespace_settings.update!(duo_foundational_flows_enabled: true) @@ -126,16 +228,28 @@ let(:current_user) { nil } - it 'creates consumers without permission checks' do + it 'does not create consumers' do allow(container).to receive(:enabled_flow_catalog_item_ids).and_return([flow1.id]) + expect(Ai::Catalog::ItemConsumers::CreateService).not_to receive(:new) + service.execute + end + end + context 'when item is already configured' do + before do + container.namespace_settings.update!(duo_foundational_flows_enabled: true) + allow(container).to receive(:enabled_flow_catalog_item_ids).and_return([flow1.id]) + allow(Ability).to receive(:allowed?).with(user, :admin_ai_catalog_item_consumer, container).and_return(true) + allow(Ability).to receive(:allowed?).with(user, :read_ai_catalog_item, flow1).and_return(true) + end + + it 'handles the error gracefully and continues' do create_service = instance_double(Ai::Catalog::ItemConsumers::CreateService) - allow(create_service).to receive(:execute).and_return(ServiceResponse.success) + error_result = ServiceResponse.error(message: "Item already configured for container") + allow(create_service).to receive(:execute).and_return(error_result) allow(Ai::Catalog::ItemConsumers::CreateService).to receive(:new).and_return(create_service) - service.execute - - expect(Ai::Catalog::ItemConsumers::CreateService).to have_received(:new) + expect { service.execute }.not_to raise_error end end @@ -255,6 +369,7 @@ create_service = instance_double(Ai::Catalog::ItemConsumers::CreateService) allow(create_service).to receive(:execute).and_return(ServiceResponse.success) + allow(group).to receive(:configured_ai_catalog_items).and_return([parent_consumer]) expect(Ai::Catalog::ItemConsumers::CreateService).to receive(:new) .with(