diff --git a/db/docs/ai_catalog_item_consumers/user_id.yml b/db/docs/ai_catalog_item_consumers/user_id.yml new file mode 100644 index 0000000000000000000000000000000000000000..e14ee31a0ff9758919c31fd6b9e759441a4b27c7 --- /dev/null +++ b/db/docs/ai_catalog_item_consumers/user_id.yml @@ -0,0 +1,8 @@ +--- +table_name: ai_catalog_item_consumers +column_name: user_id +feature_categories: +- workflow_catalog +description: Service account user associated with this catalog item consumer. Used to represent the flow when it's added to a group. +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/TBD +milestone: '18.5' diff --git a/db/migrate/20251017160050_add_user_id_to_ai_catalog_item_consumers.rb b/db/migrate/20251017160050_add_user_id_to_ai_catalog_item_consumers.rb new file mode 100644 index 0000000000000000000000000000000000000000..cfc6b522079c984675d771430380d1b3cd1b6024 --- /dev/null +++ b/db/migrate/20251017160050_add_user_id_to_ai_catalog_item_consumers.rb @@ -0,0 +1,12 @@ +# 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 AddUserIdToAiCatalogItemConsumers < Gitlab::Database::Migration[2.3] + milestone '18.5' + + def change + add_column :ai_catalog_item_consumers, :user_id, :bigint + end +end diff --git a/db/migrate/20251017163232_add_index_to_ai_catalog_item_consumers_user_id.rb b/db/migrate/20251017163232_add_index_to_ai_catalog_item_consumers_user_id.rb new file mode 100644 index 0000000000000000000000000000000000000000..940b292161c47d258d871d121062e0801ac986cf --- /dev/null +++ b/db/migrate/20251017163232_add_index_to_ai_catalog_item_consumers_user_id.rb @@ -0,0 +1,20 @@ +# 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 AddIndexToAiCatalogItemConsumersUserId < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + + milestone '18.5' + + INDEX_NAME = 'index_ai_catalog_item_consumers_on_user_id' + + def up + add_concurrent_index :ai_catalog_item_consumers, :user_id, name: INDEX_NAME + end + + def down + remove_concurrent_index_by_name :ai_catalog_item_consumers, INDEX_NAME + end +end diff --git a/db/migrate/20251017163303_add_user_id_fk_to_ai_catalog_item_consumers.rb b/db/migrate/20251017163303_add_user_id_fk_to_ai_catalog_item_consumers.rb new file mode 100644 index 0000000000000000000000000000000000000000..4962a20046b0b534eb217e32bef3378f22d7ef6a --- /dev/null +++ b/db/migrate/20251017163303_add_user_id_fk_to_ai_catalog_item_consumers.rb @@ -0,0 +1,20 @@ +# 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 AddUserIdFkToAiCatalogItemConsumers < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + + milestone '18.5' + + def up + add_concurrent_foreign_key :ai_catalog_item_consumers, :users, column: :user_id, on_delete: :nullify + end + + def down + with_lock_retries do + remove_foreign_key :ai_catalog_item_consumers, column: :user_id + end + end +end diff --git a/db/schema_migrations/20251017160050 b/db/schema_migrations/20251017160050 new file mode 100644 index 0000000000000000000000000000000000000000..2798f67534892b1b45dace65d16361128c17ff83 --- /dev/null +++ b/db/schema_migrations/20251017160050 @@ -0,0 +1 @@ +71b5135e066a8e7fee3e4f431f42102f53ce912ee2672ae0b34c46905c0ca8ae \ No newline at end of file diff --git a/db/schema_migrations/20251017163232 b/db/schema_migrations/20251017163232 new file mode 100644 index 0000000000000000000000000000000000000000..853ce8ef86e2598ca26f321b2f5638eb1ea4b56b --- /dev/null +++ b/db/schema_migrations/20251017163232 @@ -0,0 +1 @@ +22730dd827f124084f1f9d6a135b6fab4090c4de737ffbbacd9dfcf74c27c47c \ No newline at end of file diff --git a/db/schema_migrations/20251017163303 b/db/schema_migrations/20251017163303 new file mode 100644 index 0000000000000000000000000000000000000000..e2d62a4d5ab57ce2f9b0d7c4ec4519d2f8651c6d --- /dev/null +++ b/db/schema_migrations/20251017163303 @@ -0,0 +1 @@ +4012d81a68fc0779a20a5f961f77b3ef4b378b2931a251bed7e48847b8b9a957 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 666516d90ef217690e2df715842ffb86f9571902..75969089aed6bef34f673a8bb13ccf6a06ea6315 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -4678,6 +4678,36 @@ RETURN NULL; END $$; +CREATE TABLE ai_code_suggestion_events ( + id bigint NOT NULL, + "timestamp" timestamp with time zone NOT NULL, + user_id bigint NOT NULL, + organization_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + event smallint NOT NULL, + namespace_path text, + payload jsonb, + CONSTRAINT check_ba9ae3f258 CHECK ((char_length(namespace_path) <= 255)) +) +PARTITION BY RANGE ("timestamp"); + +CREATE TABLE ai_duo_chat_events ( + id bigint NOT NULL, + "timestamp" timestamp with time zone NOT NULL, + user_id bigint NOT NULL, + personal_namespace_id bigint, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + event smallint NOT NULL, + namespace_path text, + payload jsonb, + organization_id bigint, + CONSTRAINT check_628cdfbf3f CHECK ((char_length(namespace_path) <= 255)), + CONSTRAINT check_f759f45177 CHECK ((organization_id IS NOT NULL)) +) +PARTITION BY RANGE ("timestamp"); + CREATE TABLE ai_events_counts ( id bigint NOT NULL, events_date date NOT NULL, @@ -4689,6 +4719,21 @@ CREATE TABLE ai_events_counts ( ) PARTITION BY RANGE (events_date); +CREATE TABLE ai_troubleshoot_job_events ( + id bigint NOT NULL, + "timestamp" timestamp with time zone NOT NULL, + user_id bigint NOT NULL, + job_id bigint NOT NULL, + project_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + event smallint NOT NULL, + namespace_path text, + payload jsonb, + CONSTRAINT check_29d6dbc329 CHECK ((char_length(namespace_path) <= 255)) +) +PARTITION BY RANGE ("timestamp"); + CREATE TABLE ai_usage_events ( id bigint NOT NULL, "timestamp" timestamp with time zone NOT NULL, @@ -5538,7 +5583,7 @@ PARTITION BY LIST (partition_id); CREATE TABLE p_ci_finished_build_ch_sync_events ( build_id bigint NOT NULL, - partition bigint DEFAULT 1 NOT NULL, + partition bigint DEFAULT 21 NOT NULL, build_finished_at timestamp without time zone NOT NULL, processed boolean DEFAULT false NOT NULL, project_id bigint NOT NULL @@ -5548,7 +5593,7 @@ PARTITION BY LIST (partition); CREATE TABLE p_ci_finished_pipeline_ch_sync_events ( pipeline_id bigint NOT NULL, project_namespace_id bigint NOT NULL, - partition bigint DEFAULT 1 NOT NULL, + partition bigint DEFAULT 21 NOT NULL, pipeline_finished_at timestamp without time zone NOT NULL, processed boolean DEFAULT false NOT NULL ) @@ -10054,6 +10099,7 @@ CREATE TABLE ai_catalog_item_consumers ( enabled boolean DEFAULT false NOT NULL, locked boolean DEFAULT true NOT NULL, pinned_version_prefix text, + user_id bigint, CONSTRAINT check_55026cf703 CHECK ((num_nonnulls(group_id, organization_id, project_id) = 1)), CONSTRAINT check_a788d1fdfa CHECK ((char_length(pinned_version_prefix) <= 50)) ); @@ -10132,20 +10178,6 @@ CREATE SEQUENCE ai_catalog_items_id_seq ALTER SEQUENCE ai_catalog_items_id_seq OWNED BY ai_catalog_items.id; -CREATE TABLE ai_code_suggestion_events ( - id bigint NOT NULL, - "timestamp" timestamp with time zone NOT NULL, - user_id bigint NOT NULL, - organization_id bigint NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - event smallint NOT NULL, - namespace_path text, - payload jsonb, - CONSTRAINT check_ba9ae3f258 CHECK ((char_length(namespace_path) <= 255)) -) -PARTITION BY RANGE ("timestamp"); - CREATE SEQUENCE ai_code_suggestion_events_id_seq START WITH 1 INCREMENT BY 1 @@ -10204,22 +10236,6 @@ CREATE SEQUENCE ai_conversation_threads_id_seq ALTER SEQUENCE ai_conversation_threads_id_seq OWNED BY ai_conversation_threads.id; -CREATE TABLE ai_duo_chat_events ( - id bigint NOT NULL, - "timestamp" timestamp with time zone NOT NULL, - user_id bigint NOT NULL, - personal_namespace_id bigint, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - event smallint NOT NULL, - namespace_path text, - payload jsonb, - organization_id bigint, - CONSTRAINT check_628cdfbf3f CHECK ((char_length(namespace_path) <= 255)), - CONSTRAINT check_f759f45177 CHECK ((organization_id IS NOT NULL)) -) -PARTITION BY RANGE ("timestamp"); - CREATE SEQUENCE ai_duo_chat_events_id_seq START WITH 1 INCREMENT BY 1 @@ -10265,6 +10281,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, ai_catalog_item_consumer_id bigint, CONSTRAINT check_87b77d9d54 CHECK ((char_length(description) <= 255)), CONSTRAINT check_f3a5b0bd6e CHECK ((char_length(config_path) <= 255)) @@ -10364,21 +10382,6 @@ CREATE TABLE ai_testing_terms_acceptances ( CONSTRAINT check_5efe98894e CHECK ((char_length(user_email) <= 255)) ); -CREATE TABLE ai_troubleshoot_job_events ( - id bigint NOT NULL, - "timestamp" timestamp with time zone NOT NULL, - user_id bigint NOT NULL, - job_id bigint NOT NULL, - project_id bigint NOT NULL, - created_at timestamp with time zone NOT NULL, - updated_at timestamp with time zone NOT NULL, - event smallint NOT NULL, - namespace_path text, - payload jsonb, - CONSTRAINT check_29d6dbc329 CHECK ((char_length(namespace_path) <= 255)) -) -PARTITION BY RANGE ("timestamp"); - CREATE SEQUENCE ai_troubleshoot_job_events_id_seq START WITH 1 INCREMENT BY 1 @@ -11463,20 +11466,20 @@ CREATE TABLE application_settings ( secret_push_protection_available boolean DEFAULT false, vscode_extension_marketplace jsonb DEFAULT '{}'::jsonb NOT NULL, token_prefixes jsonb DEFAULT '{}'::jsonb NOT NULL, - ci_cd_settings jsonb DEFAULT '{}'::jsonb NOT NULL, database_reindexing jsonb DEFAULT '{}'::jsonb NOT NULL, duo_chat jsonb DEFAULT '{}'::jsonb NOT NULL, + ci_cd_settings jsonb DEFAULT '{}'::jsonb NOT NULL, group_settings jsonb DEFAULT '{}'::jsonb NOT NULL, + web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, + lock_web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, model_prompt_cache_enabled boolean DEFAULT true NOT NULL, lock_model_prompt_cache_enabled boolean DEFAULT false NOT NULL, response_limits jsonb DEFAULT '{}'::jsonb NOT NULL, - web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, - lock_web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, tmp_asset_proxy_secret_key jsonb, editor_extensions jsonb DEFAULT '{}'::jsonb NOT NULL, security_and_compliance_settings jsonb DEFAULT '{}'::jsonb NOT NULL, - sdrs_url text, default_profile_preferences jsonb DEFAULT '{}'::jsonb NOT NULL, + sdrs_url text, sdrs_enabled boolean DEFAULT false NOT NULL, sdrs_jwt_signing_key jsonb, resource_access_tokens_settings jsonb DEFAULT '{}'::jsonb NOT NULL, @@ -16308,6 +16311,47 @@ CREATE SEQUENCE draft_notes_id_seq ALTER SEQUENCE draft_notes_id_seq OWNED BY draft_notes.id; +CREATE TABLE duo_catalog_server_versions ( + id bigint NOT NULL, + version_string character varying(255) NOT NULL, + release_date timestamp with time zone, + is_latest boolean DEFAULT false, + definition text, + duo_catalog_server_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL +); + +CREATE SEQUENCE duo_catalog_server_versions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE duo_catalog_server_versions_id_seq OWNED BY duo_catalog_server_versions.id; + +CREATE TABLE duo_catalog_servers ( + id bigint NOT NULL, + server_name character varying(255) NOT NULL, + description text, + repository character varying(255) NOT NULL, + server_type character varying(50) NOT NULL, + organization_id bigint NOT NULL, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + server_id character varying(255) +); + +CREATE SEQUENCE duo_catalog_servers_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE duo_catalog_servers_id_seq OWNED BY duo_catalog_servers.id; + CREATE TABLE duo_workflows_checkpoint_writes ( id bigint NOT NULL, workflow_id bigint NOT NULL, @@ -20481,15 +20525,15 @@ CREATE TABLE namespace_settings ( job_token_policies_enabled boolean DEFAULT false NOT NULL, security_policies jsonb DEFAULT '{}'::jsonb NOT NULL, duo_nano_features_enabled boolean, + web_based_commit_signing_enabled boolean, + lock_web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, model_prompt_cache_enabled boolean, lock_model_prompt_cache_enabled boolean DEFAULT false NOT NULL, disable_invite_members boolean DEFAULT false NOT NULL, - web_based_commit_signing_enabled boolean, - lock_web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, allow_enterprise_bypass_placeholder_confirmation boolean DEFAULT false NOT NULL, enterprise_bypass_expires_at timestamp with time zone, - hide_email_on_profile boolean DEFAULT false NOT NULL, allow_personal_snippets boolean DEFAULT true NOT NULL, + hide_email_on_profile boolean DEFAULT false NOT NULL, auto_duo_code_review_enabled boolean, lock_auto_duo_code_review_enabled boolean DEFAULT false NOT NULL, step_up_auth_required_oauth_provider text, @@ -23698,6 +23742,27 @@ CREATE VIEW project_design_management_routes_view AS JOIN projects p ON ((dr.project_id = p.id))) JOIN routes r ON (((p.id = r.source_id) AND ((r.source_type)::text = 'Project'::text)))); +CREATE TABLE project_duo_catalog_resources ( + id bigint NOT NULL, + project_id bigint NOT NULL, + duo_catalog_server_version_id bigint NOT NULL, + configuration text, + is_active boolean DEFAULT true, + last_used_at timestamp with time zone, + created_at timestamp with time zone NOT NULL, + updated_at timestamp with time zone NOT NULL, + CONSTRAINT check_5bdc5c3629 CHECK ((char_length(configuration) <= 10000)) +); + +CREATE SEQUENCE project_duo_catalog_resources_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE project_duo_catalog_resources_id_seq OWNED BY project_duo_catalog_resources.id; + CREATE TABLE project_error_tracking_settings ( project_id bigint NOT NULL, enabled boolean DEFAULT false NOT NULL, @@ -23774,6 +23839,26 @@ CREATE SEQUENCE project_features_id_seq ALTER SEQUENCE project_features_id_seq OWNED BY project_features.id; +CREATE TABLE project_flow_triggers ( + id bigint NOT NULL, + created_at timestamp(6) without time zone NOT NULL, + updated_at timestamp(6) without time zone NOT NULL, + project_id bigint NOT NULL, + name character varying(255) NOT NULL, + event integer NOT NULL, + owner_id bigint NOT NULL, + pipeline_definition_file text NOT NULL +); + +CREATE SEQUENCE project_flow_triggers_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + +ALTER SEQUENCE project_flow_triggers_id_seq OWNED BY project_flow_triggers.id; + CREATE TABLE project_group_links ( id bigint NOT NULL, project_id bigint NOT NULL, @@ -24163,8 +24248,8 @@ CREATE TABLE project_settings ( merge_request_title_regex text, protect_merge_request_pipelines boolean DEFAULT true NOT NULL, auto_duo_code_review_enabled boolean, - model_prompt_cache_enabled boolean, web_based_commit_signing_enabled boolean DEFAULT false NOT NULL, + model_prompt_cache_enabled boolean, duo_context_exclusion_settings jsonb DEFAULT '{}'::jsonb NOT NULL, merge_request_title_regex_description text, duo_remote_flows_enabled boolean, @@ -27527,14 +27612,14 @@ CREATE TABLE user_preferences ( dpop_enabled boolean DEFAULT false NOT NULL, use_work_items_view boolean DEFAULT false NOT NULL, text_editor_type smallint DEFAULT 2 NOT NULL, - merge_request_dashboard_list_type smallint DEFAULT 0 NOT NULL, extensions_marketplace_opt_in_url text, + merge_request_dashboard_list_type smallint DEFAULT 0 NOT NULL, dark_color_scheme_id smallint, work_items_display_settings jsonb DEFAULT '{}'::jsonb NOT NULL, - default_duo_add_on_assignment_id bigint, markdown_maintain_indentation boolean DEFAULT false NOT NULL, - project_studio_enabled boolean DEFAULT false NOT NULL, + default_duo_add_on_assignment_id bigint, merge_request_dashboard_show_drafts boolean DEFAULT true NOT NULL, + project_studio_enabled boolean DEFAULT false NOT NULL, duo_default_namespace_id bigint, policy_advanced_editor boolean DEFAULT false NOT NULL, CONSTRAINT check_1d670edc68 CHECK ((time_display_relative IS NOT NULL)), @@ -28497,8 +28582,8 @@ CREATE TABLE vulnerability_occurrences ( initial_pipeline_id bigint, latest_pipeline_id bigint, security_project_tracked_context_id bigint, - detected_at timestamp with time zone, new_uuid uuid, + detected_at timestamp with time zone, CONSTRAINT check_4a3a60f2ba CHECK ((char_length(solution) <= 7000)), CONSTRAINT check_ade261da6b CHECK ((char_length(description) <= 15000)), CONSTRAINT check_f602da68dd CHECK ((char_length(cve) <= 48400)) @@ -30776,6 +30861,10 @@ ALTER TABLE ONLY dora_performance_scores ALTER COLUMN id SET DEFAULT nextval('do ALTER TABLE ONLY draft_notes ALTER COLUMN id SET DEFAULT nextval('draft_notes_id_seq'::regclass); +ALTER TABLE ONLY duo_catalog_server_versions ALTER COLUMN id SET DEFAULT nextval('duo_catalog_server_versions_id_seq'::regclass); + +ALTER TABLE ONLY duo_catalog_servers ALTER COLUMN id SET DEFAULT nextval('duo_catalog_servers_id_seq'::regclass); + ALTER TABLE ONLY duo_workflows_checkpoint_writes ALTER COLUMN id SET DEFAULT nextval('duo_workflows_checkpoint_writes_id_seq'::regclass); ALTER TABLE ONLY duo_workflows_events ALTER COLUMN id SET DEFAULT nextval('duo_workflows_events_id_seq'::regclass); @@ -31330,10 +31419,14 @@ ALTER TABLE ONLY project_data_transfers ALTER COLUMN id SET DEFAULT nextval('pro ALTER TABLE ONLY project_deploy_tokens ALTER COLUMN id SET DEFAULT nextval('project_deploy_tokens_id_seq'::regclass); +ALTER TABLE ONLY project_duo_catalog_resources ALTER COLUMN id SET DEFAULT nextval('project_duo_catalog_resources_id_seq'::regclass); + ALTER TABLE ONLY project_export_jobs ALTER COLUMN id SET DEFAULT nextval('project_export_jobs_id_seq'::regclass); ALTER TABLE ONLY project_features ALTER COLUMN id SET DEFAULT nextval('project_features_id_seq'::regclass); +ALTER TABLE ONLY project_flow_triggers ALTER COLUMN id SET DEFAULT nextval('project_flow_triggers_id_seq'::regclass); + ALTER TABLE ONLY project_group_links ALTER COLUMN id SET DEFAULT nextval('project_group_links_id_seq'::regclass); ALTER TABLE ONLY project_import_data ALTER COLUMN id SET DEFAULT nextval('project_import_data_id_seq'::regclass); @@ -33639,6 +33732,12 @@ ALTER TABLE ONLY dora_performance_scores ALTER TABLE ONLY draft_notes ADD CONSTRAINT draft_notes_pkey PRIMARY KEY (id); +ALTER TABLE ONLY duo_catalog_server_versions + ADD CONSTRAINT duo_catalog_server_versions_pkey PRIMARY KEY (id); + +ALTER TABLE ONLY duo_catalog_servers + ADD CONSTRAINT duo_catalog_servers_pkey PRIMARY KEY (id); + ALTER TABLE ONLY duo_workflows_checkpoint_writes ADD CONSTRAINT duo_workflows_checkpoint_writes_pkey PRIMARY KEY (id); @@ -34716,6 +34815,9 @@ ALTER TABLE ONLY project_data_transfers ALTER TABLE ONLY project_deploy_tokens ADD CONSTRAINT project_deploy_tokens_pkey PRIMARY KEY (id); +ALTER TABLE ONLY project_duo_catalog_resources + ADD CONSTRAINT project_duo_catalog_resources_pkey PRIMARY KEY (id); + ALTER TABLE ONLY project_error_tracking_settings ADD CONSTRAINT project_error_tracking_settings_pkey PRIMARY KEY (project_id); @@ -34728,6 +34830,9 @@ ALTER TABLE ONLY project_feature_usages ALTER TABLE ONLY project_features ADD CONSTRAINT project_features_pkey PRIMARY KEY (id); +ALTER TABLE ONLY project_flow_triggers + ADD CONSTRAINT project_flow_triggers_pkey PRIMARY KEY (id); + ALTER TABLE ONLY project_group_links ADD CONSTRAINT project_group_links_pkey PRIMARY KEY (id); @@ -37660,6 +37765,8 @@ CREATE INDEX idx_build_artifacts_size_refreshes_state_updated_at ON project_buil CREATE INDEX idx_catalog_resource_cpmt_last_usages_on_cpmt_project_id ON catalog_resource_component_last_usages USING btree (component_project_id); +CREATE UNIQUE INDEX idx_catalog_versions_on_version_and_server_id ON duo_catalog_server_versions USING btree (version_string, duo_catalog_server_id); + CREATE UNIQUE INDEX idx_ci_job_token_authorizations_on_accessed_and_origin_project ON ci_job_token_authorizations USING btree (accessed_project_id, origin_project_id); CREATE INDEX index_ci_runner_taggings_on_runner_id_and_runner_type ON ONLY ci_runner_taggings USING btree (runner_id, runner_type); @@ -37994,6 +38101,8 @@ CREATE INDEX idx_policy_violations_on_project_id_policy_rule_id_and_id ON scan_r CREATE UNIQUE INDEX idx_proj_comp_viol_issues_on_viol_id_issue_id ON project_compliance_violations_issues USING btree (project_compliance_violation_id, issue_id); +CREATE INDEX idx_proj_duo_cat_resources_on_duo_cat_server_version_id ON project_duo_catalog_resources USING btree (duo_catalog_server_version_id); + CREATE INDEX idx_proj_feat_usg_on_jira_dvcs_cloud_last_sync_at_and_proj_id ON project_feature_usages USING btree (jira_dvcs_cloud_last_sync_at, project_id) WHERE (jira_dvcs_cloud_last_sync_at IS NOT NULL); CREATE INDEX idx_proj_feat_usg_on_jira_dvcs_server_last_sync_at_and_proj_id ON project_feature_usages USING btree (jira_dvcs_server_last_sync_at, project_id) WHERE (jira_dvcs_server_last_sync_at IS NOT NULL); @@ -38016,6 +38125,8 @@ CREATE INDEX idx_project_control_statuses_on_project_id ON project_control_compl CREATE INDEX idx_project_control_statuses_on_requirement_id ON project_control_compliance_statuses USING btree (compliance_requirement_id); +CREATE UNIQUE INDEX idx_project_duo_cat_resources_project_and_version ON project_duo_catalog_resources USING btree (project_id, duo_catalog_server_version_id); + CREATE INDEX idx_project_namespace_id_from_namespace_path_timestamp_and_id ON ONLY ai_code_suggestion_events USING btree ((("substring"(namespace_path, '([0-9]+)[^0-9]*$'::text))::bigint), "timestamp", id); CREATE INDEX idx_project_repository_check_partial ON projects USING btree (repository_storage, created_at) WHERE (last_repository_check_at IS NULL); @@ -38380,6 +38491,8 @@ CREATE INDEX index_ai_catalog_item_consumers_on_organization_id ON ai_catalog_it CREATE INDEX index_ai_catalog_item_consumers_on_project_id ON ai_catalog_item_consumers USING btree (project_id); +CREATE INDEX index_ai_catalog_item_consumers_on_user_id ON ai_catalog_item_consumers USING btree (user_id); + CREATE INDEX index_ai_catalog_item_version_dependencies_on_dependency_id ON ai_catalog_item_version_dependencies USING btree (dependency_id); CREATE INDEX index_ai_catalog_item_version_dependencies_on_organization_id ON ai_catalog_item_version_dependencies USING btree (organization_id); @@ -38438,6 +38551,10 @@ CREATE UNIQUE INDEX index_ai_feature_settings_on_feature ON ai_feature_settings CREATE INDEX index_ai_flow_triggers_on_ai_catalog_item_consumer_id ON ai_flow_triggers USING btree (ai_catalog_item_consumer_id); +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); @@ -39612,6 +39729,14 @@ CREATE INDEX index_dts_on_expiring_at_sixty_days_notification_sent_at ON deploy_ CREATE INDEX index_dts_on_expiring_at_thirty_days_notification_sent_at ON deploy_tokens USING btree (expires_at, id) WHERE ((revoked = false) AND (thirty_days_notification_sent_at IS NULL)); +CREATE INDEX index_duo_catalog_server_versions_on_duo_catalog_server_id ON duo_catalog_server_versions USING btree (duo_catalog_server_id); + +CREATE INDEX index_duo_catalog_servers_on_organization_id ON duo_catalog_servers USING btree (organization_id); + +CREATE UNIQUE INDEX index_duo_catalog_servers_on_server_id ON duo_catalog_servers USING btree (server_id); + +CREATE UNIQUE INDEX index_duo_catalog_servers_on_server_name_and_organization_id ON duo_catalog_servers USING btree (server_name, organization_id); + CREATE INDEX index_duo_workflows_checkpoint_writes_on_namespace_id ON duo_workflows_checkpoint_writes USING btree (namespace_id); CREATE INDEX index_duo_workflows_checkpoint_writes_on_project_id ON duo_workflows_checkpoint_writes USING btree (project_id); @@ -41580,6 +41705,8 @@ CREATE INDEX index_project_deploy_tokens_on_deploy_token_id ON project_deploy_to CREATE UNIQUE INDEX index_project_deploy_tokens_on_project_id_and_deploy_token_id ON project_deploy_tokens USING btree (project_id, deploy_token_id); +CREATE INDEX index_project_duo_catalog_resources_on_project_id ON project_duo_catalog_resources USING btree (project_id); + CREATE UNIQUE INDEX index_project_export_job_relation ON project_relation_exports USING btree (project_export_job_id, relation); CREATE UNIQUE INDEX index_project_export_jobs_on_jid ON project_export_jobs USING btree (jid); @@ -41608,6 +41735,14 @@ CREATE INDEX index_project_features_on_project_id_on_public_package_registry ON CREATE INDEX index_project_features_on_project_id_ral_20 ON project_features USING btree (project_id) WHERE (repository_access_level = 20); +CREATE INDEX index_project_flow_triggers_on_event ON project_flow_triggers USING btree (event); + +CREATE INDEX index_project_flow_triggers_on_owner_id ON project_flow_triggers USING btree (owner_id); + +CREATE INDEX index_project_flow_triggers_on_project_id ON project_flow_triggers USING btree (project_id); + +CREATE UNIQUE INDEX index_project_flow_triggers_on_project_id_and_name ON project_flow_triggers USING btree (project_id, name); + CREATE INDEX index_project_group_links_on_member_role_id ON project_group_links USING btree (member_role_id); CREATE INDEX index_project_group_links_on_project_id ON project_group_links USING btree (project_id); @@ -48746,6 +48881,9 @@ ALTER TABLE ONLY deploy_tokens ALTER TABLE ONLY cluster_agent_migrations ADD CONSTRAINT fk_9b274efd3a FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE SET NULL; +ALTER TABLE ONLY ai_catalog_item_consumers + ADD CONSTRAINT fk_9b2a7b553a FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL; + ALTER TABLE ONLY work_item_transitions ADD CONSTRAINT fk_9ba5313b4f FOREIGN KEY (duplicated_to_id) REFERENCES issues(id) ON DELETE SET NULL; @@ -50204,6 +50342,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; @@ -50726,6 +50867,9 @@ ALTER TABLE ONLY vulnerability_management_policy_rules ALTER TABLE ONLY ml_model_version_metadata ADD CONSTRAINT fk_rails_6b8fcb2af1 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY duo_catalog_servers + ADD CONSTRAINT fk_rails_6d4d500b4d FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; + ALTER TABLE ONLY term_agreements ADD CONSTRAINT fk_rails_6ea6520e4a FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; @@ -50819,6 +50963,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; @@ -51008,6 +51155,9 @@ ALTER TABLE ONLY approval_merge_request_rules_approved_approvers ALTER TABLE ONLY work_item_dates_sources ADD CONSTRAINT fk_rails_8dcefa21a5 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE; +ALTER TABLE ONLY project_flow_triggers + ADD CONSTRAINT fk_rails_8dd94ee204 FOREIGN KEY (owner_id) REFERENCES users(id); + ALTER TABLE ONLY design_user_mentions ADD CONSTRAINT fk_rails_8de8c6d632 FOREIGN KEY (note_id) REFERENCES notes(id) ON DELETE CASCADE; @@ -51059,6 +51209,9 @@ ALTER TABLE ONLY packages_debian_project_distributions ALTER TABLE ONLY personal_access_token_last_used_ips ADD CONSTRAINT fk_rails_95388fbaeb FOREIGN KEY (organization_id) REFERENCES organizations(id) ON DELETE CASCADE; +ALTER TABLE ONLY project_duo_catalog_resources + ADD CONSTRAINT fk_rails_9572f5155f FOREIGN KEY (duo_catalog_server_version_id) REFERENCES duo_catalog_server_versions(id) ON DELETE CASCADE; + ALTER TABLE ONLY packages_rubygems_metadata ADD CONSTRAINT fk_rails_95a3f5ce78 FOREIGN KEY (package_id) REFERENCES packages_packages(id) ON DELETE CASCADE; @@ -51233,6 +51386,9 @@ ALTER TABLE ONLY dast_profiles_tags ALTER TABLE ONLY resource_iteration_events ADD CONSTRAINT fk_rails_abf5d4affa FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE; +ALTER TABLE ONLY project_flow_triggers + ADD CONSTRAINT fk_rails_ac0891e7cf FOREIGN KEY (project_id) REFERENCES projects(id); + ALTER TABLE ONLY container_registry_protection_rules ADD CONSTRAINT fk_rails_ac331fcba9 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; @@ -51254,6 +51410,9 @@ ALTER TABLE ONLY duo_workflows_events ALTER TABLE ONLY analytics_cycle_analytics_group_stages ADD CONSTRAINT fk_rails_ae5da3409b FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; +ALTER TABLE ONLY project_duo_catalog_resources + ADD CONSTRAINT fk_rails_ae5dcb2941 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; + ALTER TABLE p_ci_build_trace_metadata ADD CONSTRAINT fk_rails_aebc78111f_p FOREIGN KEY (partition_id, build_id) REFERENCES p_ci_builds(partition_id, id) ON UPDATE CASCADE ON DELETE CASCADE; @@ -51539,6 +51698,9 @@ ALTER TABLE ONLY requirements_management_test_reports ALTER TABLE ONLY design_management_repository_states ADD CONSTRAINT fk_rails_d2a258cc5a FOREIGN KEY (design_management_repository_id) REFERENCES design_management_repositories(id) ON DELETE CASCADE; +ALTER TABLE ONLY duo_catalog_server_versions + ADD CONSTRAINT fk_rails_d32ad78e00 FOREIGN KEY (duo_catalog_server_id) REFERENCES duo_catalog_servers(id) ON DELETE CASCADE; + ALTER TABLE ONLY web_hooks ADD CONSTRAINT fk_rails_d35697648e FOREIGN KEY (group_id) REFERENCES namespaces(id) ON DELETE CASCADE; diff --git a/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_add_project_flow_modal.vue b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_add_project_flow_modal.vue new file mode 100644 index 0000000000000000000000000000000000000000..75168a9c69b6ce55a1e6dbb28bf2f3f37eff67a8 --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_add_project_flow_modal.vue @@ -0,0 +1,248 @@ + + + diff --git a/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_item_consumer_modal.vue b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_item_consumer_modal.vue index 94c8f7d11df9f55f49b067f4677a21a7f94c13b2..b7f00cafd153266c5d0a33fb4a00a25bce41c7be 100644 --- a/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_item_consumer_modal.vue +++ b/ee/app/assets/javascripts/ai/catalog/components/ai_catalog_item_consumer_modal.vue @@ -1,19 +1,34 @@ + + diff --git a/ee/app/assets/javascripts/ai/catalog/components/form_group_dropdown.vue b/ee/app/assets/javascripts/ai/catalog/components/form_group_dropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..0f630519b14250ba64f042c91599f666dc53b72d --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/components/form_group_dropdown.vue @@ -0,0 +1,187 @@ + + + diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_item_consumer.fragment.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_item_consumer.fragment.graphql index 9a110ecd76a6f590736390dfd88e92b227277188..7b509110a8db40f05e76d8453f361912dbbd65b1 100644 --- a/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_item_consumer.fragment.graphql +++ b/ee/app/assets/javascripts/ai/catalog/graphql/fragments/ai_catalog_item_consumer.fragment.graphql @@ -3,6 +3,12 @@ fragment BaseAiCatalogItemConsumer on AiCatalogItemConsumer { id pinnedVersionPrefix + project { + id + } + group { + id + } item { ...BaseAiCatalogItem } diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/mutations/create_ai_catalog_item_consumer.mutation.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/mutations/create_ai_catalog_item_consumer.mutation.graphql index 28c578ec4fc4c7347fdd0dab7e9b1f59cb08dca7..66bb4800469b6f1fa63b1ccb924443c164da3251 100644 --- a/ee/app/assets/javascripts/ai/catalog/graphql/mutations/create_ai_catalog_item_consumer.mutation.graphql +++ b/ee/app/assets/javascripts/ai/catalog/graphql/mutations/create_ai_catalog_item_consumer.mutation.graphql @@ -7,6 +7,20 @@ mutation createAiCatalogItemConsumer($input: AiCatalogItemConsumerCreateInput!) id name } + group { + id + name + } + user { + id + username + name + } + } + serviceAccount { + id + username + name } } } diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/queries/ai_catalog_configured_items.query.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/queries/ai_catalog_configured_items.query.graphql index dad71ab432f112f21c0b0a925c1c73f6a2ba63d9..692ef381716fcac166a5997658afa1bff038ce6f 100644 --- a/ee/app/assets/javascripts/ai/catalog/graphql/queries/ai_catalog_configured_items.query.graphql +++ b/ee/app/assets/javascripts/ai/catalog/graphql/queries/ai_catalog_configured_items.query.graphql @@ -3,7 +3,9 @@ query aiCatalogConfiguredItems( $itemType: AiCatalogItemType - $projectId: ProjectID! + $projectId: ProjectID + $groupId: GroupID + $includeInherited: Boolean $before: String $after: String $first: Int @@ -12,6 +14,8 @@ query aiCatalogConfiguredItems( aiCatalogConfiguredItems( itemType: $itemType projectId: $projectId + groupId: $groupId + includeInherited: $includeInherited before: $before after: $after first: $first diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/queries/ai_catalog_group_flows_for_project.query.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/queries/ai_catalog_group_flows_for_project.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..548330410c486c27d9ba43672d2c321a77c502dc --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/graphql/queries/ai_catalog_group_flows_for_project.query.graphql @@ -0,0 +1,24 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getGroupFlowsForProject($groupId: GroupID!, $itemType: AiCatalogItemType, $first: Int, $last: Int, $after: String, $before: String) { + aiCatalogConfiguredItems(groupId: $groupId, itemType: $itemType, first: $first, last: $last, after: $after, before: $before, includeInherited: true) { + nodes { + id + item { + id + name + description + itemType + public + createdAt + updatedAt + userPermissions { + adminAiCatalogItem + } + } + } + pageInfo { + ...PageInfo + } + } +} diff --git a/ee/app/assets/javascripts/ai/catalog/graphql/queries/get_user_groups.query.graphql b/ee/app/assets/javascripts/ai/catalog/graphql/queries/get_user_groups.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..5cea121009395bd38c355f414e83b205b540bc60 --- /dev/null +++ b/ee/app/assets/javascripts/ai/catalog/graphql/queries/get_user_groups.query.graphql @@ -0,0 +1,27 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" + +query getUserGroups( + $search: String + $after: String + $first: Int! + $sort: String +) { + groups( + search: $search + after: $after + first: $first + sort: $sort + allAvailable: false + ) { + nodes { + id + name + fullName + fullPath + avatarUrl + } + pageInfo { + ...PageInfo + } + } +} diff --git a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue index 5400b39880a7db2f7fa477e4023357fb18b067a0..9a01d839a1b93f57d3918f03678bc829bec94035 100644 --- a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue +++ b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_agents.vue @@ -85,7 +85,7 @@ export default { const items = [ { - text: s__('AICatalog|Add to project'), + text: s__('AICatalog|Add to project or group'), action: () => this.setAiCatalogAgentToBeAdded(item), icon: 'plus', }, @@ -181,14 +181,21 @@ export default { setAiCatalogAgentToBeAdded(agent) { this.aiCatalogAgentToBeAdded = agent; }, - async addAgentToTarget(target) { + async addAgentToTarget(payload) { const agent = this.aiCatalogAgentToBeAdded; const input = { itemId: agent.id, - target, + target: payload.target, }; + if (payload.createServiceAccount) { + input.createServiceAccount = true; + if (payload.serviceAccountName) { + input.serviceAccountName = payload.serviceAccountName; + } + } + this.setAiCatalogAgentToBeAdded(null); try { @@ -200,9 +207,8 @@ export default { }); if (data) { - const { errors } = data.aiCatalogItemConsumerCreate; + const { errors, itemConsumer, serviceAccount } = data.aiCatalogItemConsumerCreate; if (errors.length > 0) { - // TODO: Once we have a project selector, we could add the project name in this message. this.errors = [ sprintf(s__('AICatalog|Agent could not be added: %{agentName}'), { agentName: agent.name, @@ -212,17 +218,25 @@ export default { return; } - const name = data.aiCatalogItemConsumerCreate.itemConsumer.project?.name || ''; + const targetName = itemConsumer.project?.name || itemConsumer.group?.name || ''; + let successMessage = sprintf(s__('AICatalog|Agent added successfully to %{name}.'), { + name: targetName, + }); + + if (serviceAccount) { + const serviceAccountMessage = sprintf(s__('AICatalog|Service account %{username} created.'), { + username: serviceAccount.username, + }); + successMessage = `${successMessage} ${serviceAccountMessage}`; + } - this.$toast.show( - sprintf(s__('AICatalog|Agent added successfully to %{name}.'), { name }), - ); + this.$toast.show(successMessage); } } catch (error) { this.errors = [ sprintf( s__( - 'AICatalog|The agent could not be added to the project. Check that the project meets the %{link_start}prerequisites%{link_end} and try again.', + 'AICatalog|The agent could not be added. Check that the target meets the %{link_start}prerequisites%{link_end} and try again.', ), { link_start: ` this.setAiCatalogFlowToBeAdded(item), icon: 'plus', }, @@ -157,14 +157,21 @@ export default { Sentry.captureException(error); } }, - async addFlowToTarget(target) { + async addFlowToTarget(payload) { const flow = this.aiCatalogFlowToBeAdded; const input = { itemId: flow.id, - target, + target: payload.target, }; + if (payload.createServiceAccount) { + input.createServiceAccount = true; + if (payload.serviceAccountName) { + input.serviceAccountName = payload.serviceAccountName; + } + } + this.setAiCatalogFlowToBeAdded(null); try { @@ -176,9 +183,8 @@ export default { }); if (data) { - const { errors } = data.aiCatalogItemConsumerCreate; + const { errors, itemConsumer, serviceAccount } = data.aiCatalogItemConsumerCreate; if (errors.length > 0) { - // TODO: Once we have a project selector, we could add the project name in this message. this.errors = [ sprintf(s__('AICatalog|Flow could not be added: %{flowName}'), { flowName: flow.name, @@ -188,14 +194,24 @@ export default { return; } - const name = data.aiCatalogItemConsumerCreate.itemConsumer.project?.name || ''; + const targetName = itemConsumer.project?.name || itemConsumer.group?.name || ''; + let successMessage = sprintf(s__('AICatalog|Flow added successfully to %{name}.'), { + name: targetName, + }); + + if (serviceAccount) { + const serviceAccountMessage = sprintf(s__('AICatalog|Service account %{username} created.'), { + username: serviceAccount.username, + }); + successMessage = `${successMessage} ${serviceAccountMessage}`; + } - this.$toast.show(sprintf(s__('AICatalog|Flow added successfully to %{name}.'), { name })); + this.$toast.show(successMessage); } } catch (error) { this.errors = [ sprintf( - s__('AICatalog|The flow could not be added to the project. Try again. %{error}'), + s__('AICatalog|The flow could not be added. Try again. %{error}'), { error }, ), ]; diff --git a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_show.vue b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_show.vue index 2420e200aa120d3a4c1b917d9839e270aa70a2d0..ad8a9ac433e45361361dfb6b6af7a16de41cf08b 100644 --- a/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_show.vue +++ b/ee/app/assets/javascripts/ai/catalog/pages/ai_catalog_flows_show.vue @@ -47,12 +47,19 @@ export default { }); }, methods: { - async addToProject(target) { + async addToProject(payload) { const input = { itemId: this.aiCatalogFlow.id, - target, + target: payload.target, }; + if (payload.createServiceAccount) { + input.createServiceAccount = true; + if (payload.serviceAccountName) { + input.serviceAccountName = payload.serviceAccountName; + } + } + try { const { data } = await this.$apollo.mutate({ mutation: createAiCatalogItemConsumer, @@ -62,7 +69,7 @@ export default { }); if (data) { - const { errors } = data.aiCatalogItemConsumerCreate; + const { errors, itemConsumer, serviceAccount } = data.aiCatalogItemConsumerCreate; if (errors.length > 0) { this.errorTitle = sprintf(s__('AICatalog|Flow could not be added: %{flowName}'), { flowName: this.aiCatalogFlow.name, @@ -71,14 +78,24 @@ export default { return; } - const name = data.aiCatalogItemConsumerCreate.itemConsumer.project?.name || ''; + const targetName = itemConsumer.project?.name || itemConsumer.group?.name || ''; + let successMessage = sprintf(s__('AICatalog|Flow added successfully to %{name}.'), { + name: targetName, + }); + + if (serviceAccount) { + const serviceAccountMessage = sprintf(s__('AICatalog|Service account %{username} created.'), { + username: serviceAccount.username, + }); + successMessage = `${successMessage} ${serviceAccountMessage}`; + } - this.$toast.show(sprintf(s__('AICatalog|Flow added successfully to %{name}.'), { name })); + this.$toast.show(successMessage); } } catch (error) { this.errors = [ sprintf( - s__('AICatalog|The flow could not be added to the project. Try again. %{error}'), + s__('AICatalog|The flow could not be added. Try again. %{error}'), { error }, ), ]; diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/constants.js b/ee/app/assets/javascripts/ai/duo_agents_platform/constants.js index 80a7550fabe5a01ed803478e2ab7320758a3aea0..c201edd006e9ad3ff546c089941a577d88a41769 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/constants.js +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/constants.js @@ -7,6 +7,7 @@ export const TOOL_MESSAGE_TYPE = 'tool'; export const AGENT_PLATFORM_INDEX_COMPONENT_NAME = 'DuoAgentPlatformIndex'; export const AGENT_PLATFORM_PROJECT_PAGE = 'project'; +export const AGENT_PLATFORM_GROUP_PAGE = 'group'; export const AGENT_PLATFORM_USER_PAGE = 'user'; export const AGENT_PLATFORM_STATUS_ICON = { diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_group_agent_flows.query.graphql b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_group_agent_flows.query.graphql new file mode 100644 index 0000000000000000000000000000000000000000..751d90c60c7aab8ede92ac426575307cd945a98b --- /dev/null +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_group_agent_flows.query.graphql @@ -0,0 +1,26 @@ +#import "../fragments/flow.fragment.graphql" + +query getGroupAgentFlows($groupPath: ID!, $after: String, $before: String, $first: Int, $last: Int) { + group(fullPath: $groupPath) { + id + duoWorkflowWorkflows( + namespacePath: $groupPath + first: $first + after: $after + last: $last + before: $before + ) { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + edges { + node { + ...FlowFragment + } + } + } + } +} diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/group/group_agents_platform_index.vue b/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/group/group_agents_platform_index.vue new file mode 100644 index 0000000000000000000000000000000000000000..7eaa40035446e4d2adabe3476cb1a937bf26124b --- /dev/null +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/group/group_agents_platform_index.vue @@ -0,0 +1,55 @@ + + diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/group/index.js b/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/group/index.js new file mode 100644 index 0000000000000000000000000000000000000000..c97c003362eab323c33e1e5c5f2a31c4e37aa0b7 --- /dev/null +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/group/index.js @@ -0,0 +1,9 @@ +import { initDuoAgentsPlatformPage } from '../../index'; +import { AGENT_PLATFORM_GROUP_PAGE } from '../../constants'; + +export const initDuoAgentsPlatformGroupPage = () => { + initDuoAgentsPlatformPage({ + namespace: AGENT_PLATFORM_GROUP_PAGE, + namespaceDatasetProperties: ['groupPath', 'groupId'], + }); +}; diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/project/index.js b/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/project/index.js index e949e27f57f2604d774fbf5d93c799f3c5fdb7e5..380409632380cfdfa6f5d1611d04b4d96e80166f 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/project/index.js +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/namespace/project/index.js @@ -4,6 +4,6 @@ import { AGENT_PLATFORM_PROJECT_PAGE } from '../../constants'; export const initDuoAgentsPlatformProjectPage = () => { initDuoAgentsPlatformPage({ namespace: AGENT_PLATFORM_PROJECT_PAGE, - namespaceDatasetProperties: ['projectPath', 'projectId'], + namespaceDatasetProperties: ['projectPath', 'projectId', 'groupId', 'flowTriggersEventTypeOptions'], }); }; diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/flow_triggers_edit.vue b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/flow_triggers_edit.vue index ed6d7a9216da065e84f6e3c2440f35a8a9626cf7..3008ac7f14f5338d5d929f74919ebb32ed87789d 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/flow_triggers_edit.vue +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/flow_triggers_edit.vue @@ -69,6 +69,13 @@ export default { isNotFound() { return this.flowTrigger && Object.keys(this.flowTrigger).length === 0; }, + parsedEventTypeOptions() { + try { + return JSON.parse(this.flowTriggersEventTypeOptions || '[]'); + } catch (e) { + return []; + } + }, }, methods: { async updateAiFlowTrigger(input) { @@ -131,7 +138,7 @@ export default { /> diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flows/ai_flows.vue b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flows/ai_flows.vue index c94ec4bb236c012b76d65df02f4b8c9897f2115e..0d8ffbb1019261cccf74ef0e6efbef3d7ff71503 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flows/ai_flows.vue +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flows/ai_flows.vue @@ -10,12 +10,14 @@ import PageHeading from '~/vue_shared/components/page_heading.vue'; import ResourceListsEmptyState from '~/vue_shared/components/resource_lists/empty_state.vue'; import AiCatalogList from 'ee/ai/catalog/components/ai_catalog_list.vue'; import AiCatalogItemDrawer from 'ee/ai/catalog/components/ai_catalog_item_drawer.vue'; +import AiCatalogAddProjectFlowModal from 'ee/ai/catalog/components/ai_catalog_add_project_flow_modal.vue'; import aiCatalogConfiguredItemsQuery from 'ee/ai/catalog/graphql/queries/ai_catalog_configured_items.query.graphql'; import aiCatalogProjectUserPermissionsQuery from 'ee/ai/catalog/graphql/queries/ai_catalog_project_user_permissions.query.graphql'; import aiCatalogFlowQuery from 'ee/ai/catalog/graphql/queries/ai_catalog_flow.query.graphql'; import deleteAiCatalogItemConsumer from 'ee/ai/catalog/graphql/mutations/delete_ai_catalog_item_consumer.mutation.graphql'; +import createAiCatalogItemConsumer from 'ee/ai/catalog/graphql/mutations/create_ai_catalog_item_consumer.mutation.graphql'; import { AI_CATALOG_TYPE_FLOW, PAGE_SIZE } from 'ee/ai/catalog/constants'; -import { TYPENAME_PROJECT } from '~/graphql_shared/constants'; +import { TYPENAME_PROJECT, TYPENAME_GROUP } from '~/graphql_shared/constants'; import { TYPENAME_AI_CATALOG_ITEM } from 'ee/graphql_shared/constants'; import { AI_CATALOG_FLOWS_EDIT_ROUTE, @@ -33,6 +35,7 @@ export default { ErrorsAlert, AiCatalogList, AiCatalogItemDrawer, + AiCatalogAddProjectFlowModal, }, inject: { projectId: { @@ -41,6 +44,12 @@ export default { projectPath: { default: null, }, + groupId: { + default: null, + }, + groupPath: { + default: null, + }, exploreAiCatalogPath: { default: '', }, @@ -49,14 +58,23 @@ export default { aiFlows: { query: aiCatalogConfiguredItemsQuery, variables() { - return { + const vars = { itemType: AI_CATALOG_TYPE_FLOW, - projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId), before: null, after: null, first: PAGE_SIZE, last: null, }; + + if (this.projectId) { + vars.projectId = convertToGraphQLId(TYPENAME_PROJECT, this.projectId); + // At project level, only show project-level flows, not inherited group flows + vars.includeInherited = false; + } else if (this.groupId) { + vars.groupId = convertToGraphQLId(TYPENAME_GROUP, this.groupId); + } + + return vars; }, fetchPolicy: fetchPolicies.CACHE_AND_NETWORK, update: (data) => data.aiCatalogConfiguredItems.nodes, @@ -66,6 +84,9 @@ export default { }, userPermissions: { query: aiCatalogProjectUserPermissionsQuery, + skip() { + return !this.projectPath; + }, variables() { return { fullPath: this.projectPath, @@ -102,9 +123,16 @@ export default { userPermissions: {}, errors: [], pageInfo: {}, + showAddFlowModal: false, }; }, computed: { + isProjectLevel() { + return Boolean(this.projectId); + }, + isGroupLevel() { + return Boolean(this.groupPath) && !this.projectId; + }, isLoading() { return this.$apollo.queries.aiFlows.loading; }, @@ -145,7 +173,24 @@ export default { ]; }, deleteActionItem: { - showActionItem: () => this.userPermissions?.adminAiCatalogItemConsumer || false, + showActionItem: (item) => { + // At project level, check project permissions and only show for project-level flows + if (this.isProjectLevel) { + if (!this.userPermissions?.adminAiCatalogItemConsumer) { + return false; + } + // Only show delete for project-level flows when viewing at project level + // Don't allow deleting group-level flows from the project view + return Boolean(item.itemConsumer?.project); + } + + // At group level, check item permissions + if (this.isGroupLevel) { + return item.userPermissions?.adminAiCatalogItem || false; + } + + return false; + }, text: __('Remove'), }, }; @@ -162,6 +207,17 @@ export default { ); return fromList?.item || { iid }; }, + deleteConfirmTitle() { + return this.isProjectLevel + ? s__('AICatalog|Remove flow from this project') + : s__('AICatalog|Remove flow from this group'); + }, + deleteConfirmMessage() { + if (this.isGroupLevel) { + return s__('AICatalog|Are you sure you want to remove flow %{name}? This will also remove it from all projects under this group and delete the associated service account.'); + } + return s__('AICatalog|Are you sure you want to remove flow %{name}?'); + }, }, methods: { formatId(id) { @@ -196,7 +252,10 @@ export default { return; } - this.$toast.show(s__('AICatalog|Flow removed successfully from this project.')); + const successMessage = this.isProjectLevel + ? s__('AICatalog|Flow removed successfully from this project.') + : s__('AICatalog|Flow removed successfully from this group.'); + this.$toast.show(successMessage); } catch (error) { this.errors = [sprintf(s__('AICatalog|Failed to remove flow. %{error}'), { error })]; Sentry.captureException(error); @@ -220,6 +279,43 @@ export default { last: PAGE_SIZE, }); }, + openAddFlowModal() { + this.showAddFlowModal = true; + }, + async handleAddFlow({ flowId, createFlowTriggers, triggerTypes }) { + const input = { + itemId: flowId, + target: { projectId: convertToGraphQLId(TYPENAME_PROJECT, this.projectId) }, + createFlowTriggers, + triggerTypes: triggerTypes || [], + }; + + try { + const { data } = await this.$apollo.mutate({ + mutation: createAiCatalogItemConsumer, + variables: { input }, + refetchQueries: [aiCatalogConfiguredItemsQuery], + }); + + if (data) { + const { errors, itemConsumer } = data.aiCatalogItemConsumerCreate; + if (errors.length > 0) { + this.errors = [sprintf(s__('AICatalog|Flow could not be added. %{error}'), { error: errors[0] })]; + return; + } + + let successMessage = s__('AICatalog|Flow added successfully to this project.'); + if (createFlowTriggers && triggerTypes && triggerTypes.length > 0) { + successMessage += ' ' + sprintf(s__('AICatalog|%{count} flow trigger(s) have been created automatically.'), { count: triggerTypes.length }); + } + + this.$toast.show(successMessage); + } + } catch (error) { + this.errors = [sprintf(s__('AICatalog|Failed to add flow. %{error}'), { error })]; + Sentry.captureException(error); + } + }, }, editRoute: AI_CATALOG_FLOWS_EDIT_ROUTE, EMPTY_SVG_URL, @@ -235,6 +331,14 @@ export default { + @@ -242,8 +346,8 @@ export default { :is-loading="isLoading" :items="items" :item-type-config="itemTypeConfig" - :delete-confirm-title="s__('AICatalog|Remove flow from this project')" - :delete-confirm-message="s__('AICatalog|Are you sure you want to remove flow %{name}?')" + :delete-confirm-title="deleteConfirmTitle" + :delete-confirm-message="deleteConfirmMessage" :delete-fn="deleteFlow" :page-info="pageInfo" @next-page="handleNextPage" @@ -251,13 +355,16 @@ export default { > diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js b/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js index dc2909c1768dd76e568061e9a9c173bd131f11d2..32d72ee7cb4d5e09e75b64f17726e7105b2c3298 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/router/utils.js @@ -1,5 +1,6 @@ -import { AGENT_PLATFORM_PROJECT_PAGE, AGENT_PLATFORM_USER_PAGE } from '../constants'; +import { AGENT_PLATFORM_PROJECT_PAGE, AGENT_PLATFORM_GROUP_PAGE, AGENT_PLATFORM_USER_PAGE } from '../constants'; import ProjectAgentsPlatformIndex from '../namespace/project/project_agents_platform_index.vue'; +import GroupAgentsPlatformIndex from '../namespace/group/group_agents_platform_index.vue'; import userAgentsPlatformIndex from '../namespace/user/user_agents_platform_index.vue'; export const getNamespaceIndexComponent = (namespace) => { @@ -9,6 +10,7 @@ export const getNamespaceIndexComponent = (namespace) => { const componentMappings = { [AGENT_PLATFORM_PROJECT_PAGE]: ProjectAgentsPlatformIndex, + [AGENT_PLATFORM_GROUP_PAGE]: GroupAgentsPlatformIndex, [AGENT_PLATFORM_USER_PAGE]: userAgentsPlatformIndex, }; diff --git a/ee/app/assets/javascripts/pages/groups/duo_agents_platform/index.js b/ee/app/assets/javascripts/pages/groups/duo_agents_platform/index.js new file mode 100644 index 0000000000000000000000000000000000000000..aee7957805deec57eaceb6e32305e02aacb86358 --- /dev/null +++ b/ee/app/assets/javascripts/pages/groups/duo_agents_platform/index.js @@ -0,0 +1,3 @@ +import { initDuoAgentsPlatformGroupPage } from 'ee/ai/duo_agents_platform/namespace/group'; + +initDuoAgentsPlatformGroupPage(); diff --git a/ee/app/controllers/groups/duo_agents_platform_controller.rb b/ee/app/controllers/groups/duo_agents_platform_controller.rb new file mode 100644 index 0000000000000000000000000000000000000000..0bf9b7a7aebdf4fa154ebed958ff9d3d8a952e8a --- /dev/null +++ b/ee/app/controllers/groups/duo_agents_platform_controller.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Groups + class DuoAgentsPlatformController < Groups::ApplicationController + include ::Projects::AiFlowTriggersHelper + + feature_category :agent_foundations + before_action :check_access + before_action do + push_frontend_feature_flag(:ai_catalog_flows, current_user) + push_frontend_feature_flag(:ai_catalog_item_project_curation, current_user) + end + + def show; end + + helper_method :duo_agents_platform_data + + def duo_agents_platform_data(group) + { + agents_platform_base_route: group_automate_path(group), + group_id: group.id, + group_path: group.full_path, + explore_ai_catalog_path: explore_ai_catalog_path, + flow_triggers_event_type_options: ai_flow_triggers_event_type_options + } + end + + private + + def check_access + return render_404 unless group&.duo_features_enabled && current_user.can?(:duo_workflow, group) + + if specific_vueroute? + render_404 unless authorized_for_route? + return + end + + return render_404 unless group&.duo_remote_flows_enabled + + render_404 unless ::Feature.enabled?(:duo_workflow_in_ci, current_user) && ::Ai::DuoWorkflow.enabled? + end + + def specific_vueroute? + %w[agents flows flow-triggers].include?(duo_agents_platform_params[:vueroute]) + end + + def authorized_for_route? + case duo_agents_platform_params[:vueroute] + when 'agents' + Feature.enabled?(:global_ai_catalog, current_user) + when 'flow-triggers' + current_user.can?(:manage_ai_flow_triggers, group) + when 'flows' + Feature.enabled?(:global_ai_catalog, current_user) && + Feature.enabled?(:ai_catalog_flows, current_user) + end + end + + def duo_agents_platform_params + params.permit(:vueroute) + end + end +end diff --git a/ee/app/graphql/mutations/ai/catalog/item_consumer/create.rb b/ee/app/graphql/mutations/ai/catalog/item_consumer/create.rb index 6b377022fdcba6112cb1f7b3d848ae8d25ddf85a..260e74e44a690077d0bb8fe7c20acf35aad4c547 100644 --- a/ee/app/graphql/mutations/ai/catalog/item_consumer/create.rb +++ b/ee/app/graphql/mutations/ai/catalog/item_consumer/create.rb @@ -12,6 +12,11 @@ class Create < BaseMutation null: true, description: 'Item configuration created.' + field :service_account, + ::Types::UserType, + null: true, + description: 'Service account created for this catalog item consumer.' + argument :item_id, ::Types::GlobalIDType[::Ai::Catalog::Item], required: true, loads: ::Types::Ai::Catalog::ItemInterface, @@ -25,6 +30,24 @@ class Create < BaseMutation required: true, description: 'Target in which the catalog item is configured.' + argument :create_service_account, GraphQL::Types::Boolean, + required: false, + default_value: false, + description: 'Whether to create a service account for this catalog item consumer.' + + argument :service_account_name, GraphQL::Types::String, + required: false, + description: 'Custom name for the service account. If not provided, defaults to flow_name_group_name.' + + argument :create_flow_triggers, GraphQL::Types::Boolean, + required: false, + default_value: false, + description: 'Whether to automatically create flow triggers for this catalog item consumer.' + + argument :trigger_types, [GraphQL::Types::String], + required: false, + description: 'List of event types to create flow triggers for (e.g., push, merge_request, issue, pipeline).' + authorize :admin_ai_catalog_item_consumer def resolve(item:, target:, **args) @@ -35,13 +58,27 @@ def resolve(item:, target:, **args) raise_resource_not_available_error! unless allowed?(item) - result = ::Ai::Catalog::ItemConsumers::CreateService.new( + service_class = if args[:create_flow_triggers] + ::Ai::Catalog::ItemConsumers::CreateWithTriggersService + elsif args[:create_service_account] + ::Ai::Catalog::ItemConsumers::CreateWithServiceAccountService + else + ::Ai::Catalog::ItemConsumers::CreateService + end + + Rails.logger.info("AiCatalogItemConsumerCreate mutation: service_class=#{service_class}, create_flow_triggers=#{args[:create_flow_triggers]}, trigger_types=#{args[:trigger_types]}") + + result = service_class.new( container: group || project, current_user: current_user, params: service_args(item, args) ).execute - { item_consumer: result.payload&.dig(:item_consumer), errors: result.errors } + { + item_consumer: result.payload&.dig(:item_consumer), + service_account: result.payload&.dig(:service_account), + errors: result.errors + } end private diff --git a/ee/app/graphql/types/ai/catalog/item_consumer_type.rb b/ee/app/graphql/types/ai/catalog/item_consumer_type.rb index ced05aca9d089fae940ce8b5101ea7f7c1c28476..0e8889eecc6542ebd0bbb06e2af790936f95a0e6 100644 --- a/ee/app/graphql/types/ai/catalog/item_consumer_type.rb +++ b/ee/app/graphql/types/ai/catalog/item_consumer_type.rb @@ -28,6 +28,9 @@ class ItemConsumerType < ::Types::BaseObject field :project, ::Types::ProjectType, null: true, description: 'Project in which the catalog item is configured.' + field :user, ::Types::UserType, + null: true, + description: 'Service account associated with this catalog item consumer.' def item ::Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ai::Catalog::Item, object.ai_catalog_item_id).find diff --git a/ee/app/helpers/ee/projects/duo_agents_platform_helper.rb b/ee/app/helpers/ee/projects/duo_agents_platform_helper.rb index d1efca2cd3165cc9788ae0ffa57e06c3914a9397..2963008e8323f07fbd34ef112be77db7cc40c4f4 100644 --- a/ee/app/helpers/ee/projects/duo_agents_platform_helper.rb +++ b/ee/app/helpers/ee/projects/duo_agents_platform_helper.rb @@ -4,10 +4,14 @@ module EE module Projects module DuoAgentsPlatformHelper def duo_agents_platform_data(project) + # Get the group if the namespace is a Group (not a User namespace) + group_id = project.namespace.is_a?(Group) ? project.namespace.id : nil + { agents_platform_base_route: project_automate_path(project), project_id: project.id, project_path: project.full_path, + group_id: group_id, explore_ai_catalog_path: explore_ai_catalog_path, flow_triggers_event_type_options: ai_flow_triggers_event_type_options } diff --git a/ee/app/helpers/projects/ai_flow_triggers_helper.rb b/ee/app/helpers/projects/ai_flow_triggers_helper.rb index 5df566363cc2c12b887806bc1c4455c6a047e0aa..3422bce3a5343866e644612af8d1aea41a4ff8d1 100644 --- a/ee/app/helpers/projects/ai_flow_triggers_helper.rb +++ b/ee/app/helpers/projects/ai_flow_triggers_helper.rb @@ -4,7 +4,7 @@ module Projects module AiFlowTriggersHelper def ai_flow_triggers_event_type_options ::Ai::FlowTrigger::EVENT_TYPES.map do |key, value| - { text: key.to_s.humanize, value: value } + { text: key.to_s.humanize, value: key.to_s } end.to_json end end diff --git a/ee/app/models/ai/catalog/item_consumer.rb b/ee/app/models/ai/catalog/item_consumer.rb index b1721134d73dacb6ec435a7a6a68a21462af8dc7..7d928f6f35ce3a4f37e121988d808f236c6baad9 100644 --- a/ee/app/models/ai/catalog/item_consumer.rb +++ b/ee/app/models/ai/catalog/item_consumer.rb @@ -26,6 +26,7 @@ class ItemConsumer < ApplicationRecord belongs_to :organization, class_name: 'Organizations::Organization' belongs_to :group belongs_to :project + belongs_to :user, optional: true scope :not_for_projects, ->(project) { where.not(project: project) } scope :for_item, ->(item_id) { where(ai_catalog_item_id: item_id) } diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb index 0c764c4c26ad85176121e4c88fd3146e9a626eb1..fdb890cb47538a5e7e9cffb847133466b18aa00c 100644 --- a/ee/app/policies/ee/group_policy.rb +++ b/ee/app/policies/ee/group_policy.rb @@ -1041,6 +1041,10 @@ module GroupPolicy prevent :create_group_link end + condition(:duo_workflow_enabled) do + ::Feature.enabled?(:duo_workflow, @user) + end + with_scope :subject condition(:duo_workflow_available) do @subject.duo_features_enabled && @@ -1048,6 +1052,10 @@ module GroupPolicy @user&.allowed_to_use?(:duo_agent_platform) end + rule { duo_workflow_enabled & duo_workflow_available & can?(:developer_access) }.policy do + enable :duo_workflow + end + rule { duo_workflow_available & can?(:admin_group) }.policy do enable :admin_duo_workflow end diff --git a/ee/app/services/ai/catalog/item_consumers/create_service.rb b/ee/app/services/ai/catalog/item_consumers/create_service.rb index d3260a8eb347887abf5d29ddc0f57aeb54396dc9..a28cd68fff03201dc5f3af11a632d2587926a1f6 100644 --- a/ee/app/services/ai/catalog/item_consumers/create_service.rb +++ b/ee/app/services/ai/catalog/item_consumers/create_service.rb @@ -9,11 +9,13 @@ class CreateService < ::BaseContainerService def execute return error_no_permissions unless allowed? - params.merge!(project: project, group: group) + # Filter out service-level parameters that are not ItemConsumer attributes + item_consumer_params = params.except(:create_service_account, :service_account_name, :create_flow_triggers, :trigger_types) + item_consumer_params.merge!(project: project, group: group) # The enabled setting is not currently used, so always set new records as enabled. # https://gitlab.com/gitlab-org/gitlab/-/issues/553912#note_2706802395 - params[:enabled] = true - item_consumer = ::Ai::Catalog::ItemConsumer.new(params) + item_consumer_params[:enabled] = true + item_consumer = ::Ai::Catalog::ItemConsumer.new(item_consumer_params) if item_consumer.save track_item_consumer_event(item_consumer, 'create_ai_catalog_item_consumer') diff --git a/ee/app/services/ai/catalog/item_consumers/create_with_service_account_service.rb b/ee/app/services/ai/catalog/item_consumers/create_with_service_account_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..afc1b0da27bf991f7c0024746555cf903f9489a0 --- /dev/null +++ b/ee/app/services/ai/catalog/item_consumers/create_with_service_account_service.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module Ai + module Catalog + module ItemConsumers + class CreateWithServiceAccountService < CreateService + def execute + return error_no_permissions unless allowed? + return error('Group is required for service account creation') unless group + + ActiveRecord::Base.transaction do + service_account = create_service_account_if_requested + return service_account unless service_account.success? + + params[:user_id] = service_account.payload[:user]&.id if service_account.payload[:user] + + result = create_item_consumer + return result unless result.success? + + ServiceResponse.success( + payload: { + item_consumer: result.payload[:item_consumer], + service_account: service_account.payload[:user] + } + ) + end + rescue StandardError => e + ServiceResponse.error(message: e.message) + end + + private + + def create_item_consumer + # Filter out service-level parameters that are not ItemConsumer attributes + item_consumer_params = params.except(:create_service_account, :service_account_name, :create_flow_triggers, :trigger_types) + item_consumer_params.merge!(project: project, group: group) + item_consumer_params[:enabled] = true + item_consumer = ::Ai::Catalog::ItemConsumer.new(item_consumer_params) + + if item_consumer.save + track_item_consumer_event(item_consumer, 'create_ai_catalog_item_consumer') + ServiceResponse.success(payload: { item_consumer: item_consumer }) + else + error_creating(item_consumer) + end + end + + def create_service_account_if_requested + return ServiceResponse.success(payload: {}) unless params[:create_service_account] + + service_account_username = build_service_account_username + service_account_display_name = item.name # Use the flow name as the display name + + service_account_params = { + namespace_id: group.id, + name: service_account_display_name, + username: service_account_username, + organization_id: group.organization_id + } + + service_account_result = ::Namespaces::ServiceAccounts::CreateService.new( + current_user, + service_account_params + ).execute + + return service_account_result unless service_account_result.success? + + ServiceResponse.success(payload: { user: service_account_result.payload[:user] }) + end + + def build_service_account_username + return params[:service_account_username] if params[:service_account_username].present? + + flow_name = sanitize_name(item.name) + group_name = sanitize_name(group.name) + unique_id = SecureRandom.hex(4) # 8 character unique identifier + "service_account_ai_#{flow_name}_#{group_name}_#{unique_id}" + end + + def sanitize_name(name) + # Convert to lowercase, replace spaces and special characters with underscores + # Keep only alphanumeric characters and underscores (usernames require underscores, not hyphens) + name.to_s.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_+|_+$/, '') + end + + def item + params[:item] + end + end + end + end +end diff --git a/ee/app/services/ai/catalog/item_consumers/create_with_triggers_service.rb b/ee/app/services/ai/catalog/item_consumers/create_with_triggers_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..8954159d0c3cf9e629e5d9989edc6e5f8fe9bc06 --- /dev/null +++ b/ee/app/services/ai/catalog/item_consumers/create_with_triggers_service.rb @@ -0,0 +1,186 @@ +# frozen_string_literal: true + +module Ai + module Catalog + module ItemConsumers + class CreateWithTriggersService < CreateWithServiceAccountService + def execute + Rails.logger.info("CreateWithTriggersService#execute called with params: create_flow_triggers=#{params[:create_flow_triggers]}, trigger_types=#{params[:trigger_types]}") + return error_no_permissions unless allowed? + + # Flow triggers require a service account, so enable service account creation + if params[:create_flow_triggers] + params[:create_service_account] = true + end + + return error('Group is required for service account creation') unless effective_group if params[:create_service_account] + return error('Project is required for flow trigger creation') unless project if params[:create_flow_triggers] + + ActiveRecord::Base.transaction do + service_account = create_service_account_if_requested + return service_account unless service_account.success? + + params[:user_id] = service_account.payload[:user]&.id if service_account.payload[:user] + + result = create_item_consumer + return result unless result.success? + + # Add service account as project member if we're creating at project level + if project && service_account.payload[:user] + add_service_account_to_project(service_account.payload[:user]) + end + + trigger_result = create_flow_triggers_if_requested(result.payload[:item_consumer]) + return trigger_result unless trigger_result.success? + + ServiceResponse.success( + payload: { + item_consumer: result.payload[:item_consumer], + service_account: service_account.payload[:user], + flow_triggers: trigger_result.payload[:flow_triggers] + } + ) + end + rescue StandardError => e + ServiceResponse.error(message: e.message) + end + + private + + # When creating triggers at project level, we need the project's parent group for service account creation + def effective_group + group || project&.namespace + end + + def create_service_account_if_requested + return ServiceResponse.success(payload: {}) unless params[:create_service_account] + + service_account_username = build_service_account_username + service_account_display_name = item.name # Use the flow name as the display name + + Rails.logger.info("Creating service account with username: #{service_account_username}, name: #{service_account_display_name}") + + service_account_params = { + namespace_id: effective_group.id, + name: service_account_display_name, + username: service_account_username, + organization_id: effective_group.organization_id + } + + Rails.logger.info("Service account params: #{service_account_params.inspect}") + + service_account_result = ::Namespaces::ServiceAccounts::CreateService.new( + current_user, + service_account_params + ).execute + + Rails.logger.info("Service account result: #{service_account_result.inspect}") + + return service_account_result unless service_account_result.success? + + ServiceResponse.success(payload: { user: service_account_result.payload[:user] }) + end + + def build_service_account_username + return params[:service_account_username] if params[:service_account_username].present? + + flow_name = sanitize_name(item.name) + group_name = sanitize_name(effective_group.name) + unique_id = SecureRandom.hex(4) # 8 character unique identifier + "service_account_ai_#{flow_name}_#{group_name}_#{unique_id}" + end + + def sanitize_name(name) + # Convert to lowercase, replace spaces and special characters with underscores + # Keep only alphanumeric characters and underscores (usernames require underscores, not hyphens) + name.to_s.downcase.gsub(/[^a-z0-9]+/, '_').gsub(/^_+|_+$/, '') + end + + def create_item_consumer + # Filter out service-level parameters that are not ItemConsumer attributes + item_consumer_params = params.except(:create_service_account, :service_account_name, :create_flow_triggers, :trigger_types) + Rails.logger.info("CreateWithTriggersService#create_item_consumer called with filtered params: #{item_consumer_params.keys}") + item_consumer_params.merge!(project: project, group: group) + item_consumer_params[:enabled] = true + item_consumer = ::Ai::Catalog::ItemConsumer.new(item_consumer_params) + + if item_consumer.save + track_item_consumer_event(item_consumer, 'create_ai_catalog_item_consumer') + ServiceResponse.success(payload: { item_consumer: item_consumer }) + else + error_creating(item_consumer) + end + end + + def create_flow_triggers_if_requested(item_consumer) + Rails.logger.info("create_flow_triggers_if_requested: create_flow_triggers=#{params[:create_flow_triggers]}, item_type=#{item.item_type}, trigger_types=#{params[:trigger_types]}") + return ServiceResponse.success(payload: { flow_triggers: [] }) unless params[:create_flow_triggers] + return ServiceResponse.success(payload: { flow_triggers: [] }) unless item.item_type == 'flow' + + trigger_types = params[:trigger_types] || [] + Rails.logger.info("trigger_types after assignment: #{trigger_types}") + return ServiceResponse.success(payload: { flow_triggers: [] }) if trigger_types.empty? + + # Create flow triggers for the selected event types + triggers = create_flow_triggers_for_types(item_consumer, trigger_types) + Rails.logger.info("Triggers created: #{triggers.inspect}") + + ServiceResponse.success(payload: { flow_triggers: triggers }) + end + + def create_flow_triggers_for_types(item_consumer, trigger_types) + triggers = [] + + # Convert string event types to integer values + event_type_values = trigger_types.map do |type_str| + ::Ai::FlowTrigger::EVENT_TYPES[type_str.to_sym] + end.compact + + return triggers if event_type_values.empty? + + # Create a single flow trigger with all selected event types + trigger = create_flow_trigger(item_consumer, event_type_values) + triggers << trigger if trigger + + triggers + end + + def create_flow_trigger(item_consumer, event_type_values) + # Get the service account user from the item_consumer or create a default one + service_account_user = item_consumer.user || current_user + + trigger = ::Ai::FlowTrigger.new( + project: project, + user: service_account_user, + ai_catalog_item_consumer_id: item_consumer.id, + description: "Auto-created triggers for #{item.name}", + event_types: event_type_values + ) + + Rails.logger.info("Attempting to save flow trigger: #{trigger.inspect}") + if trigger.save + Rails.logger.info("Flow trigger saved successfully: #{trigger.id}") + trigger + else + Rails.logger.error("Flow trigger save failed: #{trigger.errors.full_messages}") + nil + end + end + + def add_service_account_to_project(service_account_user) + # Add the service account as a Developer member to the project + # Developer access level (30) allows the service account to execute flows + Rails.logger.info("Adding service account #{service_account_user.username} to project #{project.full_path}") + + member = project.add_developer(service_account_user) + + if member.persisted? + Rails.logger.info("Service account successfully added to project as Developer") + else + Rails.logger.error("Failed to add service account to project: #{member.errors.full_messages}") + end + end + end + end + end +end diff --git a/ee/app/services/ai/catalog/item_consumers/destroy_service.rb b/ee/app/services/ai/catalog/item_consumers/destroy_service.rb index 0dd890bceeb5d891f62f3bf721a0e55785f8cf4f..a8289444d55b7a65f67b8b734ed0f5e9ca4a99e4 100644 --- a/ee/app/services/ai/catalog/item_consumers/destroy_service.rb +++ b/ee/app/services/ai/catalog/item_consumers/destroy_service.rb @@ -14,18 +14,65 @@ def initialize(item_consumer, current_user) def execute return error_no_permissions unless allowed? - if item_consumer.destroy - track_item_consumer_event(item_consumer, 'delete_ai_catalog_item_consumer', additional_properties: nil) - ServiceResponse.success(payload: { item_consumer: item_consumer }) - else - error(item_consumer.errors.full_messages) + service_account_user = nil + + ActiveRecord::Base.transaction do + # If this is a group-level item consumer, clean up related project-level resources + if item_consumer.group_id.present? + cleanup_project_level_resources + # Store the service account user for deletion after transaction + service_account_user = item_consumer.user if item_consumer.user&.service_account? + end + + if item_consumer.destroy + track_item_consumer_event(item_consumer, 'delete_ai_catalog_item_consumer', additional_properties: nil) + else + return error(item_consumer.errors.full_messages) + end end + + # Delete service account after transaction commits (avoids enqueuing jobs in transaction) + delete_service_account(service_account_user) if service_account_user + + ServiceResponse.success(payload: { item_consumer: item_consumer }) + rescue StandardError => e + ServiceResponse.error(message: e.message) end private attr_reader :current_user, :item_consumer + def cleanup_project_level_resources + # Find all project-level item consumers for the same item in child projects of this group + group = item_consumer.group + return unless group + + # Get all projects under this group (including subgroups) + project_ids = group.all_projects.pluck(:id) + + # Find all project-level item consumers for the same catalog item + project_item_consumers = ::Ai::Catalog::ItemConsumer + .where(project_id: project_ids, ai_catalog_item_id: item_consumer.ai_catalog_item_id) + + # Delete associated flow triggers first + project_item_consumers.each do |project_consumer| + ::Ai::FlowTrigger.where(ai_catalog_item_consumer_id: project_consumer.id).destroy_all + end + + # Delete the project-level item consumers + project_item_consumers.destroy_all + end + + def delete_service_account(user) + # Delete the service account user using the appropriate service + # This must be called outside the transaction since it enqueues background jobs + ::Namespaces::ServiceAccounts::DeleteService.new( + current_user, + user + ).execute(hard_delete: true) + end + def allowed? Ability.allowed?(current_user, :admin_ai_catalog_item_consumer, item_consumer) end diff --git a/ee/app/views/groups/duo_agents_platform/show.html.haml b/ee/app/views/groups/duo_agents_platform/show.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..0ee8f084e3ce534e660c805978fcfe33237a8ed6 --- /dev/null +++ b/ee/app/views/groups/duo_agents_platform/show.html.haml @@ -0,0 +1,3 @@ +- page_title _('Automate') + +#js-duo-agents-platform-page{ data: duo_agents_platform_data(@group) } diff --git a/ee/config/routes/group.rb b/ee/config/routes/group.rb index 7d691833026f58f10104e75f9fac49b5876249de..afd0c94e6788e197a682ab7bfe446c76e71e0287 100644 --- a/ee/config/routes/group.rb +++ b/ee/config/routes/group.rb @@ -282,6 +282,14 @@ end end + scope :automate do + get '/(*vueroute)' => 'duo_agents_platform#show', as: :automate, format: false + get 'agent-sessions', to: 'duo_agents_platform#show', as: :automate_agent_sessions, format: false + get 'agents', to: 'duo_agents_platform#show', as: :automate_agents, format: false + get 'flow-triggers', to: 'duo_agents_platform#show', as: :automate_flow_triggers, format: false + get 'flows', to: 'duo_agents_platform#show', as: :automate_flows, format: false + end + draw :virtual_registries end end diff --git a/ee/lib/ee/sidebars/groups/super_sidebar_panel.rb b/ee/lib/ee/sidebars/groups/super_sidebar_panel.rb new file mode 100644 index 0000000000000000000000000000000000000000..66af3888933776b3a9493c50302ad3b5a141219b --- /dev/null +++ b/ee/lib/ee/sidebars/groups/super_sidebar_panel.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module EE + module Sidebars + module Groups + module SuperSidebarPanel + extend ::Gitlab::Utils::Override + + override :configure_menus + def configure_menus + super + + insert_menu_after( + ::Sidebars::Groups::SuperSidebarMenus::PlanMenu, + ::Sidebars::Groups::SuperSidebarMenus::DuoAgentsMenu.new(context) + ) + end + end + end + end +end diff --git a/ee/lib/sidebars/groups/super_sidebar_menus/duo_agents_menu.rb b/ee/lib/sidebars/groups/super_sidebar_menus/duo_agents_menu.rb new file mode 100644 index 0000000000000000000000000000000000000000..13c06a930a35e16eb1c608c20fe6f632650714a1 --- /dev/null +++ b/ee/lib/sidebars/groups/super_sidebar_menus/duo_agents_menu.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Sidebars + module Groups + module SuperSidebarMenus + class DuoAgentsMenu < ::Sidebars::Menu + override :configure_menu_items + def configure_menu_items + return false unless current_user&.can?(:duo_workflow, context.group) && + context.group.duo_features_enabled && + (show_agents_runs_menu_items? || show_flow_triggers_menu_items? || show_flows_menu_item?) + + add_item(duo_agents_runs_menu_item) if show_agents_runs_menu_items? + add_item(ai_catalog_agents_menu_item) if show_agents_menu_item? + add_item(duo_flow_triggers_menu_item) if show_flow_triggers_menu_items? + add_item(ai_catalog_flows_menu_item) if show_flows_menu_item? + + true + end + + override :title + def title + s_('DuoAgentsPlatform|Automate') + end + + override :sprite_icon + def sprite_icon + 'tanuki-ai' + end + + override :active_routes + def active_routes + { controller: :duo_agents_platform } + end + + private + + def show_agents_runs_menu_items? + context.group.duo_remote_flows_enabled && Feature.enabled?(:duo_workflow_in_ci, context.current_user) + end + + def show_flow_triggers_menu_items? + context.current_user&.can?(:manage_ai_flow_triggers, context.group) + end + + def show_agents_menu_item? + Feature.enabled?(:global_ai_catalog, context.current_user) + end + + def show_flows_menu_item? + Feature.enabled?(:global_ai_catalog, context.current_user) && + Feature.enabled?(:ai_catalog_flows, context.current_user) + end + + def duo_agents_runs_menu_item + ::Sidebars::MenuItem.new( + title: s_('DuoAgentsPlatform|Agent sessions'), + link: group_automate_agent_sessions_path(context.group), + active_routes: nil, + item_id: :agents_runs + ) + end + + def duo_flow_triggers_menu_item + ::Sidebars::MenuItem.new( + title: s_('DuoAgentsPlatform|Flow triggers'), + link: group_automate_flow_triggers_path(context.group), + active_routes: nil, + item_id: :ai_flow_triggers + ) + end + + def ai_catalog_agents_menu_item + ::Sidebars::MenuItem.new( + title: s_('AICatalog|Agents'), + link: group_automate_agents_path(context.group), + active_routes: nil, + item_id: :ai_catalog_agents + ) + end + + def ai_catalog_flows_menu_item + ::Sidebars::MenuItem.new( + title: s_('AICatalog|Flows'), + link: group_automate_flows_path(context.group), + active_routes: nil, + item_id: :ai_flows + ) + end + end + end + end +end diff --git a/lib/sidebars/groups/super_sidebar_panel.rb b/lib/sidebars/groups/super_sidebar_panel.rb index bf4cc5e846e15268f3b6daca8c411f277cf45851..54bb30c5563e283004ef2eca7cf66e0396c26993 100644 --- a/lib/sidebars/groups/super_sidebar_panel.rb +++ b/lib/sidebars/groups/super_sidebar_panel.rb @@ -43,3 +43,5 @@ def super_sidebar_context_header end end end + +Sidebars::Groups::SuperSidebarPanel.prepend_mod_with('Sidebars::Groups::SuperSidebarPanel')