diff options
Diffstat (limited to 'app/models')
111 files changed, 1149 insertions, 720 deletions
diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 19dc0e40564..e19a75a68e8 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -205,11 +205,12 @@ class AbuseReport < ApplicationRecord return if links_to_spam.blank? links_to_spam.each do |link| - Gitlab::UrlBlocker.validate!( + Gitlab::HTTP_V2::UrlBlocker.validate!( link, schemes: %w[http https], allow_localhost: true, - dns_rebind_protection: true + dns_rebind_protection: true, + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) next unless link.length > MAX_CHAR_LIMIT_URL diff --git a/app/models/ai/service_access_token.rb b/app/models/ai/service_access_token.rb index 46dfbe9078c..d2d64079c74 100644 --- a/app/models/ai/service_access_token.rb +++ b/app/models/ai/service_access_token.rb @@ -2,11 +2,8 @@ module Ai class ServiceAccessToken < ApplicationRecord - include IgnorableColumns self.table_name = 'service_access_tokens' - ignore_column :category, remove_with: '16.8', remove_after: '2024-01-22' - scope :expired, -> { where('expires_at < :now', now: Time.current) } scope :active, -> { where('expires_at > :now', now: Time.current) } diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb index 0f8e184933e..5ac5437a442 100644 --- a/app/models/analytics/cycle_analytics/aggregation.rb +++ b/app/models/analytics/cycle_analytics/aggregation.rb @@ -59,19 +59,26 @@ class Analytics::CycleAnalytics::Aggregation < ApplicationRecord estimation < 1 ? nil : estimation.from_now end - def self.safe_create_for_namespace(group_or_project_namespace) + def self.safe_create_for_namespace(target_namespace) # Namespaces::ProjectNamespace has no root_ancestor # Related: https://gitlab.com/gitlab-org/gitlab/-/issues/386124 - group = group_or_project_namespace.is_a?(Group) ? group_or_project_namespace : group_or_project_namespace.parent - top_level_group = group.root_ancestor - aggregation = find_by(group_id: top_level_group.id) + namespace = if target_namespace.is_a?(Group) || target_namespace.is_a?(Namespaces::UserNamespace) + target_namespace + else + target_namespace.parent + end + # personal namespace projects and associated ProjectNamespace respond to `namespace` + # and this is close enough to "root ancestor" + top_level_namespace = + target_namespace.respond_to?(:root_ancestor) ? namespace.root_ancestor : namespace.namespace + aggregation = find_by(group_id: top_level_namespace.id) return aggregation if aggregation&.enabled? # At this point we're sure that the group is licensed, we can always enable the aggregation. # This re-enables the aggregation in case the group downgraded and later upgraded the license. - upsert({ group_id: top_level_group.id, enabled: true }) + upsert({ group_id: top_level_namespace.id, enabled: true }) - find(top_level_group.id) + find(top_level_namespace.id) end private diff --git a/app/models/analytics/cycle_analytics/stage.rb b/app/models/analytics/cycle_analytics/stage.rb index 6f152e7749e..4686dc3aedd 100644 --- a/app/models/analytics/cycle_analytics/stage.rb +++ b/app/models/analytics/cycle_analytics/stage.rb @@ -7,7 +7,6 @@ module Analytics self.table_name = :analytics_cycle_analytics_group_stages - include DatabaseEventTracking include Analytics::CycleAnalytics::Stageable include Analytics::CycleAnalytics::Parentable @@ -38,22 +37,6 @@ module Analytics .select("DISTINCT ON(stage_event_hash_id) #{quoted_table_name}.*") end - SNOWPLOW_ATTRIBUTES = %i[ - id - created_at - updated_at - relative_position - start_event_identifier - end_event_identifier - group_id - start_event_label_id - end_event_label_id - hidden - custom - name - group_value_stream_id - ].freeze - private def max_stages_count diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index cb533a5e99d..35d4722b711 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -99,7 +99,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :default_branch_protection_defaults, json_schema: { filename: 'default_branch_protection_defaults' } validates :default_branch_protection_defaults, bytesize: { maximum: -> { DEFAULT_BRANCH_PROTECTIONS_DEFAULT_MAX_SIZE } } - validates :failed_login_attempts_unlock_period_in_minutes, + validates :external_pipeline_validation_service_timeout, + :failed_login_attempts_unlock_period_in_minutes, + :max_login_attempts, allow_nil: true, numericality: { only_integer: true, greater_than: 0 } @@ -118,10 +120,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord allow_nil: false, qualified_domain_array: true - validates :session_expire_delay, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :minimum_password_length, presence: true, numericality: { @@ -222,38 +220,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord hostname: true, length: { maximum: 255 } - validates :max_attachment_size, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - - validates :max_artifacts_size, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - - validates :max_export_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_import_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_import_remote_file_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :bulk_import_max_download_file_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_decompressed_archive_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_login_attempts, - allow_nil: true, - numericality: { only_integer: true, greater_than: 0 } - validates :max_pages_size, presence: true, numericality: { @@ -261,31 +227,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord less_than: ::Gitlab::Pages::MAX_SIZE / 1.megabyte } - validates :max_pages_custom_domains_per_project, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :jobs_per_stage_page_size, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :max_terraform_state_size_bytes, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :default_artifacts_expire_in, presence: true, duration: true validates :container_expiration_policies_enable_historic_entries, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :container_registry_token_expire_delay, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - - validates :decompress_archive_file_timeout, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validate :check_repository_storages_weighted validates :auto_devops_domain, @@ -300,14 +246,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord presence: { message: 'Domain denylist cannot be empty if denylist is enabled.' }, if: :domain_denylist_enabled? - validates :housekeeping_optimize_repository_period, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - - validates :terminal_max_session_time, - presence: true, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :polling_interval_multiplier, presence: true, numericality: { greater_than_or_equal_to: 0 } @@ -413,59 +351,26 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, allow_nil: false - validates :push_event_hooks_limit, - numericality: { greater_than_or_equal_to: 0 } - validates :push_event_activities_limit, + :push_event_hooks_limit, numericality: { greater_than_or_equal_to: 0 } - validates :snippet_size_limit, numericality: { only_integer: true, greater_than: 0 } validates :wiki_page_max_content_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 1.kilobytes } validates :wiki_asciidoc_allow_uri_includes, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :max_yaml_size_bytes, numericality: { only_integer: true, greater_than: 0 }, presence: true - validates :max_yaml_depth, numericality: { only_integer: true, greater_than: 0 }, presence: true - - validates :ci_max_total_yaml_size_bytes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true - - validates :ci_max_includes, numericality: { only_integer: true, greater_than_or_equal_to: 0 }, presence: true validates :email_restrictions, untrusted_regexp: true validates :hashed_storage_enabled, inclusion: { in: [true], message: N_("Hashed storage can't be disabled anymore for new projects") } - validates :container_registry_delete_tags_service_timeout, - :container_registry_cleanup_tags_service_max_list_size, - :container_registry_data_repair_detail_worker_max_concurrency, - :container_registry_expiration_policies_worker_capacity, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :container_registry_expiration_policies_caching, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :container_registry_import_max_tags_count, - :container_registry_import_max_retries, - :container_registry_import_start_max_retries, - :container_registry_import_max_step_duration, - :container_registry_pre_import_timeout, - :container_registry_import_timeout, - allow_nil: false, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :container_registry_pre_import_tags_rate, allow_nil: false, numericality: { greater_than_or_equal_to: 0 } validates :container_registry_import_target_plan, presence: true validates :container_registry_import_created_before, presence: true - validates :dependency_proxy_ttl_group_policy_worker_capacity, - allow_nil: false, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :packages_cleanup_package_file_worker_capacity, - :package_registry_cleanup_policies_worker_capacity, - allow_nil: false, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :invisible_captcha_enabled, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -584,15 +489,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord length: { maximum: 255 }, allow_blank: true - validates :issues_create_limit, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :raw_blob_request_limit, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :pipeline_limit_per_project_user_sha, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :ci_jwt_signing_key, rsa_key: true, allow_nil: true @@ -619,41 +515,90 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :slack_app_verification_token end - with_options(presence: true, numericality: { only_integer: true, greater_than: 0 }) do - validates :throttle_unauthenticated_api_requests_per_period - validates :throttle_unauthenticated_api_period_in_seconds - validates :throttle_unauthenticated_requests_per_period - validates :throttle_unauthenticated_period_in_seconds - validates :throttle_unauthenticated_packages_api_requests_per_period - validates :throttle_unauthenticated_packages_api_period_in_seconds - validates :throttle_unauthenticated_files_api_requests_per_period - validates :throttle_unauthenticated_files_api_period_in_seconds - validates :throttle_unauthenticated_deprecated_api_requests_per_period - validates :throttle_unauthenticated_deprecated_api_period_in_seconds - validates :throttle_authenticated_api_requests_per_period - validates :throttle_authenticated_api_period_in_seconds - validates :throttle_authenticated_git_lfs_requests_per_period - validates :throttle_authenticated_git_lfs_period_in_seconds - validates :throttle_authenticated_web_requests_per_period - validates :throttle_authenticated_web_period_in_seconds - validates :throttle_authenticated_packages_api_requests_per_period - validates :throttle_authenticated_packages_api_period_in_seconds - validates :throttle_authenticated_files_api_requests_per_period - validates :throttle_authenticated_files_api_period_in_seconds - validates :throttle_authenticated_deprecated_api_requests_per_period - validates :throttle_authenticated_deprecated_api_period_in_seconds - validates :throttle_protected_paths_requests_per_period - validates :throttle_protected_paths_period_in_seconds - validates :project_jobs_api_rate_limit + with_options(numericality: { only_integer: true, greater_than: 0 }) do + validates :bulk_import_concurrent_pipeline_batch_limit, + :container_registry_token_expire_delay, + :housekeeping_optimize_repository_period, + :inactive_projects_delete_after_months, + :max_artifacts_size, + :max_attachment_size, + :max_yaml_depth, + :max_yaml_size_bytes, + :namespace_aggregation_schedule_lease_duration_in_seconds, + :project_jobs_api_rate_limit, + :snippet_size_limit, + :throttle_authenticated_api_period_in_seconds, + :throttle_authenticated_api_requests_per_period, + :throttle_authenticated_deprecated_api_period_in_seconds, + :throttle_authenticated_deprecated_api_requests_per_period, + :throttle_authenticated_files_api_period_in_seconds, + :throttle_authenticated_files_api_requests_per_period, + :throttle_authenticated_git_lfs_period_in_seconds, + :throttle_authenticated_git_lfs_requests_per_period, + :throttle_authenticated_packages_api_period_in_seconds, + :throttle_authenticated_packages_api_requests_per_period, + :throttle_authenticated_web_period_in_seconds, + :throttle_authenticated_web_requests_per_period, + :throttle_protected_paths_period_in_seconds, + :throttle_protected_paths_requests_per_period, + :throttle_unauthenticated_api_period_in_seconds, + :throttle_unauthenticated_api_requests_per_period, + :throttle_unauthenticated_deprecated_api_period_in_seconds, + :throttle_unauthenticated_deprecated_api_requests_per_period, + :throttle_unauthenticated_files_api_period_in_seconds, + :throttle_unauthenticated_files_api_requests_per_period, + :throttle_unauthenticated_packages_api_period_in_seconds, + :throttle_unauthenticated_packages_api_requests_per_period, + :throttle_unauthenticated_period_in_seconds, + :throttle_unauthenticated_requests_per_period end with_options(numericality: { only_integer: true, greater_than_or_equal_to: 0 }) do - validates :notes_create_limit - validates :search_rate_limit - validates :search_rate_limit_unauthenticated - validates :projects_api_rate_limit_unauthenticated - validates :gitlab_shell_operation_limit - end + validates :bulk_import_max_download_file_size, + :ci_max_includes, + :ci_max_total_yaml_size_bytes, + :container_registry_cleanup_tags_service_max_list_size, + :container_registry_data_repair_detail_worker_max_concurrency, + :container_registry_delete_tags_service_timeout, + :container_registry_expiration_policies_worker_capacity, + :container_registry_import_max_retries, + :container_registry_import_max_step_duration, + :container_registry_import_max_tags_count, + :container_registry_import_start_max_retries, + :container_registry_import_timeout, + :container_registry_pre_import_timeout, + :decompress_archive_file_timeout, + :dependency_proxy_ttl_group_policy_worker_capacity, + :gitlab_shell_operation_limit, + :inactive_projects_min_size_mb, + :issues_create_limit, + :jobs_per_stage_page_size, + :max_decompressed_archive_size, + :max_export_size, + :max_import_remote_file_size, + :max_import_size, + :max_pages_custom_domains_per_project, + :max_terraform_state_size_bytes, + :members_delete_limit, + :notes_create_limit, + :package_registry_cleanup_policies_worker_capacity, + :packages_cleanup_package_file_worker_capacity, + :pipeline_limit_per_project_user_sha, + :projects_api_rate_limit_unauthenticated, + :raw_blob_request_limit, + :search_rate_limit, + :search_rate_limit_unauthenticated, + :session_expire_delay, + :sidekiq_job_limiter_compression_threshold_bytes, + :sidekiq_job_limiter_limit_bytes, + :terminal_max_session_time, + :users_get_by_id_limit + end + + jsonb_accessor :rate_limits, + members_delete_limit: [:integer, { default: 60 }] + + validates :rate_limits, json_schema: { filename: "application_setting_rate_limits" } validates :search_rate_limit_allowlist, length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, @@ -669,10 +614,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :external_pipeline_validation_service_url, addressable_url: ADDRESSABLE_URL_VALIDATION_OPTIONS, allow_blank: true - validates :external_pipeline_validation_service_timeout, - allow_nil: true, - numericality: { only_integer: true, greater_than: 0 } - validates :whats_new_variant, inclusion: { in: ApplicationSetting.whats_new_variants.keys } @@ -686,10 +627,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :sidekiq_job_limiter_mode, inclusion: { in: self.sidekiq_job_limiter_modes } - validates :sidekiq_job_limiter_compression_threshold_bytes, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :sidekiq_job_limiter_limit_bytes, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :sentry_enabled, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -711,8 +648,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord length: { maximum: 255 }, if: :error_tracking_enabled? - validates :users_get_by_id_limit, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } validates :users_get_by_id_limit_allowlist, length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') }, allow_nil: false @@ -724,20 +659,11 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord presence: true, if: :update_runner_versions_enabled? - validates :inactive_projects_min_size_mb, - numericality: { only_integer: true, greater_than_or_equal_to: 0 } - - validates :inactive_projects_delete_after_months, - numericality: { only_integer: true, greater_than: 0 } - validates :inactive_projects_send_warning_email_after_months, numericality: { only_integer: true, greater_than: 0, less_than: :inactive_projects_delete_after_months } validates :prometheus_alert_db_indicators_settings, json_schema: { filename: 'application_setting_prometheus_alert_db_indicators_settings' }, allow_nil: true - validates :namespace_aggregation_schedule_lease_duration_in_seconds, - numericality: { only_integer: true, greater_than: 0 } - validates :sentry_clientside_traces_sample_rate, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1, message: N_('must be a value between 0 and 1') } @@ -815,10 +741,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :bulk_import_concurrent_pipeline_batch_limit, - presence: true, - numericality: { only_integer: true, greater_than: 0 } - validates :allow_runner_registration_token, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } @@ -835,6 +757,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :math_rendering_limits_enabled, inclusion: { in: [true, false], message: N_('must be a boolean value') } + validates :require_admin_two_factor_authentication, + inclusion: { in: [true, false], message: N_('must be a boolean value') } + before_validation :ensure_uuid! before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed? before_validation :normalize_default_branch_name @@ -982,7 +907,10 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord end def parsed_kroki_url - @parsed_kroki_url ||= Gitlab::UrlBlocker.validate!(kroki_url, schemes: %w[http https], enforce_sanitization: true)[0] + @parsed_kroki_url ||= Gitlab::HTTP_V2::UrlBlocker.validate!( + kroki_url, schemes: %w[http https], + enforce_sanitization: true, + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed?)[0] rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e self.errors.add( :kroki_url, diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 851b65055d0..d1899b18a4f 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -79,6 +79,7 @@ module ApplicationSettingImplementation ecdsa_sk_key_restriction: default_min_key_size(:ecdsa_sk), ed25519_key_restriction: default_min_key_size(:ed25519), ed25519_sk_key_restriction: default_min_key_size(:ed25519_sk), + require_admin_two_factor_authentication: false, eks_access_key_id: nil, eks_account_id: nil, eks_integration_enabled: false, @@ -136,6 +137,7 @@ module ApplicationSettingImplementation mirror_available: true, notes_create_limit: 300, notes_create_limit_allowlist: [], + members_delete_limit: 60, notify_on_unknown_sign_in: true, outbound_local_requests_whitelist: [], password_authentication_enabled_for_git: true, @@ -275,7 +277,8 @@ module ApplicationSettingImplementation allow_account_deletion: true, gitlab_shell_operation_limit: 600, project_jobs_api_rate_limit: 600, - security_txt_content: nil + security_txt_content: nil, + allow_project_creation_for_guest_and_below: true }.tap do |hsh| hsh.merge!(non_production_defaults) unless Rails.env.production? end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 894e28dd88a..a6969ce6f76 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -150,9 +150,9 @@ class BulkImports::Entity < ApplicationRecord File.join(base_resource_path, 'export_relations') end - def export_relations_url_path(batched: false) - if batched && bulk_import.supports_batched_export? - Gitlab::Utils.add_url_parameters(export_relations_url_path_base, batched: batched) + def export_relations_url_path + if bulk_import.supports_batched_export? + Gitlab::Utils.add_url_parameters(export_relations_url_path_base, batched: true) else export_relations_url_path_base end diff --git a/app/models/bulk_imports/failure.rb b/app/models/bulk_imports/failure.rb index 8a6077b523c..e23e49c6396 100644 --- a/app/models/bulk_imports/failure.rb +++ b/app/models/bulk_imports/failure.rb @@ -19,6 +19,14 @@ class BulkImports::Failure < ApplicationRecord super(::Projects::ImportErrorFilter.filter_message(message).truncate(255)) end + def source_title=(title) + super(title&.truncate(255, omission: '')) + end + + def source_url=(url) + super(url&.truncate(255, omission: '')) + end + private def pipeline_relation diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index e56f3d2536c..d4c70a294ff 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -27,6 +27,7 @@ module Ci foreign_key: :commit_id, partition_foreign_key: :partition_id, inverse_of: :builds + belongs_to :project_mirror, primary_key: :project_id, foreign_key: :project_id, inverse_of: :builds RUNNER_FEATURES = { upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? }, @@ -42,6 +43,8 @@ module Ci DEPLOYMENT_NAMES = %w[deploy release rollout].freeze + TOKEN_PREFIX = 'glcbt-' + has_one :pending_state, class_name: 'Ci::BuildPendingState', foreign_key: :build_id, inverse_of: :build has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id, inverse_of: :build has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id, inverse_of: :build @@ -98,6 +101,7 @@ module Ci delegate :harbor_integration, to: :project delegate :apple_app_store_integration, to: :project delegate :google_play_integration, to: :project + delegate :diffblue_cover_integration, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true delegate :ensure_persistent_ref, to: :pipeline delegate :enable_debug_trace!, to: :metadata @@ -188,6 +192,10 @@ module Ci # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123131 scope :with_runner_type, -> (runner_type) { joins(:runner).where(runner: { runner_type: runner_type }) } + scope :belonging_to_runner_manager, -> (runner_machine_id) { + joins(:runner_manager_build).where(p_ci_runner_machine_builds: { runner_machine_id: runner_machine_id }) + } + scope :with_secure_reports_from_config_options, -> (job_types) do joins(:metadata).where("#{Ci::BuildMetadata.quoted_table_name}.config_options -> 'artifacts' -> 'reports' ?| array[:job_types]", job_types: job_types) end @@ -204,7 +212,7 @@ module Ci add_authentication_token_field :token, encrypted: :required, - format_with_prefix: :partition_id_prefix_in_16_bit_encode + format_with_prefix: :prefix_and_partition_for_token after_save :stick_build_if_status_changed @@ -516,6 +524,7 @@ module Ci .concat(harbor_variables) .concat(apple_app_store_variables) .concat(google_play_variables) + .concat(diffblue_cover_variables) end end @@ -568,6 +577,12 @@ module Ci Gitlab::Ci::Variables::Collection.new(google_play_integration.ci_variables(protected_ref: pipeline.protected_ref?)) end + def diffblue_cover_variables + return [] unless diffblue_cover_integration.try(:activated?) + + Gitlab::Ci::Variables::Collection.new(diffblue_cover_integration.ci_variables) + end + def features { trace_sections: true, @@ -1232,6 +1247,14 @@ module Ci def partition_id_prefix_in_16_bit_encode "#{partition_id.to_s(16)}_" end + + def prefix_and_partition_for_token + if Feature.enabled?(:prefix_ci_build_tokens, project, type: :beta) + TOKEN_PREFIX + partition_id_prefix_in_16_bit_encode + else + partition_id_prefix_in_16_bit_encode + end + end end end diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb index 4273c4515bc..0ea2735b030 100644 --- a/app/models/ci/catalog/resources/version.rb +++ b/app/models/ci/catalog/resources/version.rb @@ -19,6 +19,7 @@ module Ci scope :for_catalog_resources, ->(catalog_resources) { where(catalog_resource_id: catalog_resources) } scope :preloaded, -> { includes(:catalog_resource, project: [:route, { namespace: :route }], release: :author) } + scope :by_name, ->(name) { joins(:release).merge(Release.where(tag: name)) } scope :order_by_created_at_asc, -> { reorder(created_at: :asc) } scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } @@ -122,6 +123,14 @@ module Ci project.commit_by(oid: sha) end + def path + Gitlab::Routing.url_helpers.project_tag_path(project, name) + end + + def readme + project.repository.tree(sha).readme + end + private def update_catalog_resource diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb index 179befb8469..6a2fb1132c0 100644 --- a/app/models/ci/instance_variable.rb +++ b/app/models/ci/instance_variable.rb @@ -13,6 +13,7 @@ module Ci alias_attribute :secret_value, :value + validates :description, length: { maximum: 255 }, allow_blank: true validates :key, uniqueness: { message: -> (object, data) { _("(%{value}) has already been taken") } } diff --git a/app/models/ci/namespace_mirror.rb b/app/models/ci/namespace_mirror.rb index ff7e681217a..5f55713b436 100644 --- a/app/models/ci/namespace_mirror.rb +++ b/app/models/ci/namespace_mirror.rb @@ -7,6 +7,7 @@ module Ci include FromUnion belongs_to :namespace + has_many :project_mirrors, primary_key: :namespace_id, foreign_key: :namespace_id, inverse_of: :namespace_mirror scope :by_group_and_descendants, -> (id) do where('traversal_ids @> ARRAY[?]::int[]', id) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 9d5b2e5a0b1..1bf4d585e1c 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -20,7 +20,6 @@ module Ci include IgnorableColumns ignore_column :id_convert_to_bigint, remove_with: '16.3', remove_after: '2023-08-22' - ignore_column :auto_canceled_by_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22' MAX_OPEN_MERGE_REQUESTS_REFS = 4 @@ -439,7 +438,7 @@ module Ci where_exists(Ci::Build.latest.scoped_pipeline.with_artifacts(reports_scope)) end - scope :with_only_interruptible_builds, -> do + scope :conservative_interruptible, -> do where_not_exists( Ci::Build.scoped_pipeline.with_status(STARTED_STATUSES).not_interruptible ) @@ -621,7 +620,7 @@ module Ci end def valid_commit_sha - if self.sha == Gitlab::Git::BLANK_SHA + if self.sha == Gitlab::Git::SHA1_BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") end end @@ -675,7 +674,7 @@ module Ci end def before_sha - super || Gitlab::Git::BLANK_SHA + super || Gitlab::Git::SHA1_BLANK_SHA end def short_sha @@ -1394,6 +1393,10 @@ module Ci merge_request.merge_request_diff_for(merge_request_diff_sha) end + def auto_cancel_on_new_commit + pipeline_metadata&.auto_cancel_on_new_commit || 'conservative' + end + private def add_message(severity, content) diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb index 6d22a875aab..e0e6906f211 100644 --- a/app/models/ci/pipeline_artifact.rb +++ b/app/models/ci/pipeline_artifact.rb @@ -4,6 +4,7 @@ module Ci class PipelineArtifact < Ci::ApplicationRecord + include Ci::Partitionable include UpdateProjectStatistics include Artifactable include FileStoreMounter @@ -31,6 +32,8 @@ module Ci validates :size, presence: true, numericality: { less_than_or_equal_to: FILE_SIZE_LIMIT } validates :file_type, presence: true + partitionable scope: :pipeline + mount_file_store_uploader Ci::PipelineArtifactUploader update_project_statistics project_statistics_name: :pipeline_artifacts_size diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb index ba20c993e36..1a2bc37d17d 100644 --- a/app/models/ci/pipeline_chat_data.rb +++ b/app/models/ci/pipeline_chat_data.rb @@ -2,14 +2,21 @@ module Ci class PipelineChatData < Ci::ApplicationRecord + include Ci::Partitionable include Ci::NamespacedModelName + include SafelyChangeColumnDefault + + columns_changing_default :partition_id self.table_name = 'ci_pipeline_chat_data' belongs_to :chat_name + belongs_to :pipeline validates :pipeline_id, presence: true validates :chat_name_id, presence: true validates :response_url, presence: true + + partitionable scope: :pipeline end end diff --git a/app/models/ci/pipeline_config.rb b/app/models/ci/pipeline_config.rb index e2dcad653d7..11decd3fc66 100644 --- a/app/models/ci/pipeline_config.rb +++ b/app/models/ci/pipeline_config.rb @@ -2,11 +2,15 @@ module Ci class PipelineConfig < Ci::ApplicationRecord + include Ci::Partitionable + self.table_name = 'ci_pipelines_config' self.primary_key = :pipeline_id belongs_to :pipeline, class_name: "Ci::Pipeline", inverse_of: :pipeline_config validates :pipeline, presence: true validates :content, presence: true + + partitionable scope: :pipeline end end diff --git a/app/models/ci/pipeline_metadata.rb b/app/models/ci/pipeline_metadata.rb index 37fa3e32ad8..21d102374f0 100644 --- a/app/models/ci/pipeline_metadata.rb +++ b/app/models/ci/pipeline_metadata.rb @@ -2,12 +2,15 @@ module Ci class PipelineMetadata < Ci::ApplicationRecord + include Ci::Partitionable + include Importable + self.primary_key = :pipeline_id enum auto_cancel_on_new_commit: { conservative: 0, interruptible: 1, - disabled: 2 + none: 2 }, _prefix: true enum auto_cancel_on_job_failure: { @@ -21,5 +24,7 @@ module Ci validates :pipeline, presence: true validates :project, presence: true validates :name, length: { minimum: 1, maximum: 255 }, allow_nil: true + + partitionable scope: :pipeline end end diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index b1831e365b1..4fddb3e053e 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -5,9 +5,6 @@ module Ci include Ci::Partitionable include Ci::HasVariable include Ci::RawVariable - include IgnorableColumns - - ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.5', remove_after: '2023-10-22' belongs_to :pipeline diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 414d36da7c3..989d6337ab7 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -33,6 +33,10 @@ module Ci where('NOT EXISTS (?)', needs) end + scope :interruptible, -> do + joins(:metadata).merge(Ci::BuildMetadata.with_interruptible) + end + scope :not_interruptible, -> do joins(:metadata).where.not( Ci::BuildMetadata.table_name => { id: Ci::BuildMetadata.scoped_build.with_interruptible.select(:id) } diff --git a/app/models/ci/project_mirror.rb b/app/models/ci/project_mirror.rb index 23cd5d92730..c6828f827b5 100644 --- a/app/models/ci/project_mirror.rb +++ b/app/models/ci/project_mirror.rb @@ -7,6 +7,8 @@ module Ci include FromUnion belongs_to :project + belongs_to :namespace_mirror, primary_key: :namespace_id, foreign_key: :namespace_id, inverse_of: :project_mirrors + has_many :builds, primary_key: :project_id, foreign_key: :project_id, inverse_of: :project_mirror scope :by_namespace_id, -> (namespace_id) { where(namespace_id: namespace_id) } scope :by_project_id, -> (project_id) { where(project_id: project_id) } diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 9c30beeeb59..5fb982ee21e 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -14,6 +14,7 @@ module Ci include Presentable include EachBatch include Ci::HasRunnerExecutor + include Ci::HasRunnerStatus extend ::Gitlab::Utils::Override @@ -85,22 +86,22 @@ module Ci before_save :ensure_token - scope :active, -> (value = true) { where(active: value) } + scope :active, ->(value = true) { where(active: value) } scope :paused, -> { active(false) } - scope :online, -> { where(arel_table[:contacted_at].gt(online_contact_time_deadline)) } scope :recent, -> do timestamp = stale_deadline where(arel_table[:created_at].gteq(timestamp).or(arel_table[:contacted_at].gteq(timestamp))) end scope :stale, -> do - timestamp = stale_deadline + stale_timestamp = stale_deadline + + created_before_stale_deadline = arel_table[:created_at].lteq(stale_timestamp) + contacted_before_stale_deadline = arel_table[:contacted_at].lteq(stale_timestamp) + never_contacted = arel_table[:contacted_at].eq(nil) - where(arel_table[:created_at].lteq(timestamp)) - .where(arel_table[:contacted_at].eq(nil).or(arel_table[:contacted_at].lteq(timestamp))) + where(created_before_stale_deadline).where(never_contacted.or(contacted_before_stale_deadline)) end - scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) } - scope :never_contacted, -> { where(contacted_at: nil) } scope :ordered, -> { order(id: :desc) } scope :with_recent_runner_queue, -> { where(arel_table[:contacted_at].gt(recent_queue_deadline)) } @@ -220,6 +221,11 @@ module Ci validate :exactly_one_group, if: :group_type? scope :with_version_prefix, ->(value) { joins(:runner_managers).merge(RunnerManager.with_version_prefix(value)) } + scope :with_runner_type, ->(runner_type) do + return all if AVAILABLE_TYPES.exclude?(runner_type.to_s) + + where(runner_type: runner_type) + end acts_as_taggable @@ -348,23 +354,6 @@ module Ci description end - def online? - contacted_at && contacted_at > self.class.online_contact_time_deadline - end - - def stale? - return false unless created_at - - [created_at, contacted_at].compact.max <= self.class.stale_deadline - end - - def status - return :stale if stale? - return :never_contacted unless contacted_at - - online? ? :online : :offline - end - # DEPRECATED # TODO Remove in v5 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648 def deprecated_rest_status @@ -475,6 +464,21 @@ module Ci end end + def clear_heartbeat + cleared_attributes = { + version: nil, + revision: nil, + platform: nil, + architecture: nil, + ip_address: nil, + executor_type: nil, + config: {}, + contacted_at: nil + } + merge_cache_attributes(cleared_attributes) + update_columns(cleared_attributes) + end + def pick_build!(build) tick_runner_queue if matches_build?(build) end diff --git a/app/models/ci/runner_manager.rb b/app/models/ci/runner_manager.rb index e6576859827..44fe1bdd67d 100644 --- a/app/models/ci/runner_manager.rb +++ b/app/models/ci/runner_manager.rb @@ -5,10 +5,13 @@ module Ci include FromUnion include RedisCacheable include Ci::HasRunnerExecutor + include Ci::HasRunnerStatus # For legacy reasons, the table name is ci_runner_machines in the database self.table_name = 'ci_runner_machines' + AVAILABLE_STATUSES = %w[online offline never_contacted stale].freeze + # The `UPDATE_CONTACT_COLUMN_EVERY` defines how often the Runner Machine DB entry can be updated UPDATE_CONTACT_COLUMN_EVERY = (40.minutes)..(55.minutes) @@ -36,19 +39,26 @@ module Ci STALE_TIMEOUT = 7.days scope :stale, -> do - created_some_time_ago = arel_table[:created_at].lteq(STALE_TIMEOUT.ago) - contacted_some_time_ago = arel_table[:contacted_at].lteq(STALE_TIMEOUT.ago) + stale_timestamp = stale_deadline + + created_before_stale_deadline = arel_table[:created_at].lteq(stale_timestamp) + contacted_before_stale_deadline = arel_table[:contacted_at].lteq(stale_timestamp) from_union( - where(contacted_at: nil), - where(contacted_some_time_ago), - remove_duplicates: false).where(created_some_time_ago) + never_contacted, + where(contacted_before_stale_deadline), + remove_duplicates: false + ).where(created_before_stale_deadline) end scope :for_runner, ->(runner_id) do where(runner_id: runner_id) end + scope :with_system_xid, ->(system_xid) do + where(system_xid: system_xid) + end + scope :with_running_builds, -> do where('EXISTS(?)', Ci::Build.select(1) @@ -114,25 +124,8 @@ module Ci end end - def status - return :stale if stale? - return :never_contacted unless contacted_at - - online? ? :online : :offline - end - private - def online? - contacted_at && contacted_at > self.class.online_contact_time_deadline - end - - def stale? - return false unless created_at - - [created_at, contacted_at].compact.max <= self.class.stale_deadline - end - def persist_cached_data? # Use a random threshold to prevent beating DB updates. contacted_at_max_age = Random.rand(UPDATE_CONTACT_COLUMN_EVERY) diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index becb8f204bf..ba1a0a46247 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -7,9 +7,6 @@ module Ci include Ci::HasStatus include Gitlab::OptimisticLocking include Presentable - include IgnorableColumns - - ignore_column :pipeline_id_convert_to_bigint, remove_with: '16.6', remove_after: '2023-10-22' partitionable scope: :pipeline diff --git a/app/models/commit.rb b/app/models/commit.rb index 886e6e9fbd7..9c8d7604031 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -359,7 +359,7 @@ class Commit def diff_refs Gitlab::Diff::DiffRefs.new( - base_sha: self.parent_id || Gitlab::Git::BLANK_SHA, + base_sha: self.parent_id || Gitlab::Git::SHA1_BLANK_SHA, head_sha: self.sha ) end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index f1aeb7e528f..3a9b1465682 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -86,7 +86,7 @@ class CommitStatus < Ci::ApplicationRecord scope :for_project_paths, -> (paths) do # Pluck is used to split this query. Splitting the query is required for database decomposition for `ci_*` tables. # https://docs.gitlab.com/ee/development/database/transaction_guidelines.html#database-decomposition-and-sharding - project_ids = Project.where_full_path_in(Array(paths), use_includes: false).pluck(:id) + project_ids = Project.where_full_path_in(Array(paths), preload_routes: false).pluck(:id) for_project(project_ids) end diff --git a/app/models/compare.rb b/app/models/compare.rb index 58279cb58aa..d80f3f72ca7 100644 --- a/app/models/compare.rb +++ b/app/models/compare.rb @@ -1,12 +1,12 @@ # frozen_string_literal: true -require 'set' +require 'set' # rubocop:disable Lint/RedundantRequireStatement -- Ruby 3.1 and earlier needs this. Drop this line after Ruby 3.2+ is only supported. class Compare include Gitlab::Utils::StrongMemoize include ActsAsPaginatedDiff - delegate :same, :head, :base, to: :@compare + delegate :same, :head, :base, :generated_files, to: :@compare attr_reader :project diff --git a/app/models/concerns/analytics/cycle_analytics/parentable.rb b/app/models/concerns/analytics/cycle_analytics/parentable.rb index 785f6eea6bf..90a38e3c58c 100644 --- a/app/models/concerns/analytics/cycle_analytics/parentable.rb +++ b/app/models/concerns/analytics/cycle_analytics/parentable.rb @@ -6,16 +6,7 @@ module Analytics extend ActiveSupport::Concern included do - belongs_to :namespace, class_name: 'Namespace', foreign_key: :group_id, optional: false # rubocop: disable Rails/InverseOf - - validate :ensure_namespace_type - - def ensure_namespace_type - return if namespace.nil? - return if namespace.is_a?(::Namespaces::ProjectNamespace) || namespace.is_a?(::Group) - - errors.add(:namespace, s_('CycleAnalytics|the assigned object is not supported')) - end + belongs_to :namespace, class_name: 'Namespace', foreign_key: :group_id, optional: false # rubocop: disable Rails/InverseOf -- this relation is not present on Namespace end end end diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index ec4ee7985fe..f51b0967968 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -219,8 +219,8 @@ module AtomicInternalId ::AtomicInternalId.scope_usage(self.class) end - def self.scope_usage(including_class) - including_class.table_name.to_sym + def self.scope_usage(klass) + klass.respond_to?(:internal_id_scope_usage) ? klass.internal_id_scope_usage : klass.table_name.to_sym end def self.project_init(klass, column_name = :iid) diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 6a855198697..7c7fd882228 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -40,8 +40,6 @@ module CacheMarkdownField # Banzai is less strict about authors, so don't always have an author key context[:author] = self.author if self.respond_to?(:author) - context[:markdown_engine] = Banzai::Filter::MarkdownFilter::DEFAULT_ENGINE - if Feature.enabled?(:personal_snippet_reference_filters, context[:author]) context[:user] = self.parent_user end diff --git a/app/models/concerns/ci/has_runner_status.rb b/app/models/concerns/ci/has_runner_status.rb new file mode 100644 index 00000000000..f6fb9940b44 --- /dev/null +++ b/app/models/concerns/ci/has_runner_status.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Ci + module HasRunnerStatus + extend ActiveSupport::Concern + + included do + scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) } + scope :never_contacted, -> { where(contacted_at: nil) } + scope :online, -> { where(arel_table[:contacted_at].gt(online_contact_time_deadline)) } + + scope :with_status, ->(status) do + return all if available_statuses.exclude?(status.to_s) + + public_send(status) # rubocop:disable GitlabSecurity/PublicSend -- safe to call + end + end + + class_methods do + def available_statuses + self::AVAILABLE_STATUSES + end + + def online_contact_time_deadline + raise NotImplementedError + end + + def stale_deadline + raise NotImplementedError + end + end + + def status + return :stale if stale? + return :never_contacted unless contacted_at + + online? ? :online : :offline + end + + def online? + contacted_at && contacted_at > self.class.online_contact_time_deadline + end + + def stale? + return false unless created_at + + [created_at, contacted_at].compact.max <= self.class.stale_deadline + end + end +end diff --git a/app/models/concerns/ci/partitionable/testing.rb b/app/models/concerns/ci/partitionable/testing.rb index b961d72db94..9f0d55329ad 100644 --- a/app/models/concerns/ci/partitionable/testing.rb +++ b/app/models/concerns/ci/partitionable/testing.rb @@ -21,6 +21,10 @@ module Ci Ci::PendingBuild Ci::RunningBuild Ci::RunnerManagerBuild + Ci::PipelineArtifact + Ci::PipelineChatData + Ci::PipelineConfig + Ci::PipelineMetadata Ci::PipelineVariable Ci::Sources::Pipeline Ci::Stage diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb index 201994cb321..12e4a5a0ee0 100644 --- a/app/models/concerns/commit_signature.rb +++ b/app/models/concerns/commit_signature.rb @@ -9,17 +9,7 @@ module CommitSignature sha_attribute :commit_sha - enum verification_status: { - unverified: 0, - verified: 1, - same_user_different_email: 2, - other_user: 3, - unverified_key: 4, - unknown_key: 5, - multiple_signatures: 6, - revoked_key: 7, - verified_system: 8 - } + enum verification_status: Enums::CommitSignature.verification_statuses belongs_to :project, class_name: 'Project', foreign_key: 'project_id', optional: false diff --git a/app/models/concerns/database_event_tracking.rb b/app/models/concerns/database_event_tracking.rb deleted file mode 100644 index 7e2f445189e..00000000000 --- a/app/models/concerns/database_event_tracking.rb +++ /dev/null @@ -1,52 +0,0 @@ -# frozen_string_literal: true - -module DatabaseEventTracking - extend ActiveSupport::Concern - - included do - after_create_commit :publish_database_create_event - after_destroy_commit :publish_database_destroy_event - after_update_commit :publish_database_update_event - end - - def publish_database_create_event - publish_database_event('create') - end - - def publish_database_destroy_event - publish_database_event('destroy') - end - - def publish_database_update_event - publish_database_event('update') - end - - def publish_database_event(name) - # Gitlab::Tracking#event is triggering Snowplow event - # Snowplow events are sent with usage of - # https://snowplow.github.io/snowplow-ruby-tracker/SnowplowTracker/AsyncEmitter.html - # that reports data asynchronously and does not impact performance nor carries a risk of - # rollback in case of error - - Gitlab::Tracking.database_event( - self.class.to_s, - "database_event_#{name}", - label: self.class.table_name, - project: try(:project), - namespace: (try(:group) || try(:namespace)) || try(:project)&.namespace, - property: name, - **filtered_record_attributes - ) - rescue StandardError => err - # this rescue should be a dead code due to utilization of AsyncEmitter, however - # since this concern is expected to be included in every model, it is better to - # prevent against any unexpected outcome - Gitlab::ErrorTracking.track_and_raise_for_dev_exception(err) - end - - def filtered_record_attributes - attributes - .with_indifferent_access - .slice(*self.class::SNOWPLOW_ATTRIBUTES) - end -end diff --git a/app/models/concerns/enums/commit_signature.rb b/app/models/concerns/enums/commit_signature.rb new file mode 100644 index 00000000000..92625af58ef --- /dev/null +++ b/app/models/concerns/enums/commit_signature.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Enums + class CommitSignature + VERIFICATION_STATUSES = { + unverified: 0, + verified: 1, + same_user_different_email: 2, + other_user: 3, + unverified_key: 4, + unknown_key: 5, + multiple_signatures: 6, + revoked_key: 7, + verified_system: 8 + # EE adds more values in ee/app/models/concerns/ee/enums/commit_signature.rb + }.freeze + + def self.verification_statuses + VERIFICATION_STATUSES + end + end +end + +Enums::CommitSignature.prepend_mod diff --git a/app/models/concerns/integrations/enable_ssl_verification.rb b/app/models/concerns/integrations/enable_ssl_verification.rb index cb20955488a..1dffe183475 100644 --- a/app/models/concerns/integrations/enable_ssl_verification.rb +++ b/app/models/concerns/integrations/enable_ssl_verification.rb @@ -9,7 +9,8 @@ module Integrations type: :checkbox, title: -> { s_('Integrations|SSL verification') }, checkbox_label: -> { s_('Integrations|Enable SSL verification') }, - help: -> { s_('Integrations|Clear if using a self-signed certificate.') } + help: -> { s_('Integrations|Clear if using a self-signed certificate.') }, + description: -> { s_('Enable SSL verification. Defaults to `true` (enabled).') } end def initialize_properties diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb index 223191fb963..3ce1dd36a5e 100644 --- a/app/models/concerns/integrations/has_issue_tracker_fields.rb +++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb @@ -10,16 +10,16 @@ module Integrations field :project_url, required: true, title: -> { _('Project URL') }, - help: -> do - s_('IssueTracker|The URL to the project in the external issue tracker.') - end + description: -> { s_('URL of the project.') }, + help: -> { s_('IssueTracker|URL of the project in the external issue tracker.') } field :issues_url, required: true, title: -> { s_('IssueTracker|Issue URL') }, + description: -> { s_('URL of the issue.') }, help: -> do ERB::Util.html_escape( - s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') + s_('IssueTracker|URL to view an issue in the external issue tracker. Must contain %{colon_id}.') ) % { colon_id: '<code>:id</code>'.html_safe } @@ -28,9 +28,8 @@ module Integrations field :new_issue_url, required: true, title: -> { s_('IssueTracker|New issue URL') }, - help: -> do - s_('IssueTracker|The URL to create an issue in the external issue tracker.') - end + description: -> { s_('URL of the new issue.') }, + help: -> { s_('IssueTracker|URL to create an issue in the external issue tracker.') } end end end diff --git a/app/models/concerns/integrations/slack_mattermost_fields.rb b/app/models/concerns/integrations/slack_mattermost_fields.rb index a8e63c4e405..08f86813cc1 100644 --- a/app/models/concerns/integrations/slack_mattermost_fields.rb +++ b/app/models/concerns/integrations/slack_mattermost_fields.rb @@ -7,26 +7,40 @@ module Integrations included do field :webhook, help: -> { webhook_help }, + description: -> do + Kernel.format(_("%{title} webhook (for example, `%{example}`)."), title: title, example: webhook_help) + end, required: true, if: -> { requires_webhook? } field :username, placeholder: 'GitLab-integration', + description: -> { Kernel.format(_("%{title} username."), title: title) }, if: -> { requires_webhook? } + field :channel, + description: -> { _('Default channel to use if no other channel is configured.') }, + api_only: true + field :notify_only_broken_pipelines, type: :checkbox, section: Integration::SECTION_TYPE_CONFIGURATION, + description: -> { _('Send notifications for broken pipelines.') }, help: 'Do not send notifications for successful pipelines.' field :branches_to_be_notified, type: :select, section: Integration::SECTION_TYPE_CONFIGURATION, title: -> { s_('Integration|Branches for which notifications are to be sent') }, + description: -> { + _('Branches to send notifications for. Valid options are `all`, `default`, `protected`, ' \ + 'and `default_and_protected`. The default value is `default`.') + }, choices: -> { branch_choices } field :labels_to_be_notified, section: Integration::SECTION_TYPE_CONFIGURATION, + description: -> { _('Labels to send notifications for. Leave blank to receive notifications for all events.') }, placeholder: '~backend,~frontend', help: 'Send notifications for issue, merge request, and comment events with the listed labels only. ' \ 'Leave blank to receive notifications for all events.' @@ -34,6 +48,10 @@ module Integrations field :labels_to_be_notified_behavior, type: :select, section: Integration::SECTION_TYPE_CONFIGURATION, + description: -> { + _('Labels to be notified for. Valid options are `match_any` and `match_all`. ' \ + 'The default value is `match_any`.') + }, choices: [ ['Match any of the labels', Integrations::BaseChatNotification::MATCH_ANY_LABEL], ['Match all of the labels', Integrations::BaseChatNotification::MATCH_ALL_LABELS] diff --git a/app/models/concerns/partitioned_table.rb b/app/models/concerns/partitioned_table.rb index c322a736e79..8feb162207d 100644 --- a/app/models/concerns/partitioned_table.rb +++ b/app/models/concerns/partitioned_table.rb @@ -9,7 +9,8 @@ module PartitionedTable PARTITIONING_STRATEGIES = { monthly: Gitlab::Database::Partitioning::MonthlyStrategy, sliding_list: Gitlab::Database::Partitioning::SlidingListStrategy, - ci_sliding_list: Gitlab::Database::Partitioning::CiSlidingListStrategy + ci_sliding_list: Gitlab::Database::Partitioning::CiSlidingListStrategy, + int_range: Gitlab::Database::Partitioning::IntRangeStrategy }.freeze def partitioned_by(partitioning_key, strategy:, **kwargs) diff --git a/app/models/concerns/restricted_signup.rb b/app/models/concerns/restricted_signup.rb index 87b62214529..8fcf0532151 100644 --- a/app/models/concerns/restricted_signup.rb +++ b/app/models/concerns/restricted_signup.rb @@ -31,10 +31,10 @@ module RestrictedSignup def error_message { admin: { - allowlist: html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Allowed domains for sign-ups'.")).html_safe, - denylist: html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check the 'Domain denylist'.")).html_safe, - restricted: html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Email restrictions for sign-ups'.")).html_safe, - group_setting: html_escape_once(_("Go to the group’s 'Settings > General' page, and check 'Restrict membership by email domain'.")).html_safe + allowlist: ERB::Util.html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Allowed domains for sign-ups'.")).html_safe, + denylist: ERB::Util.html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check the 'Domain denylist'.")).html_safe, + restricted: ERB::Util.html_escape_once(_("Go to the 'Admin area > Sign-up restrictions', and check 'Email restrictions for sign-ups'.")).html_safe, + group_setting: ERB::Util.html_escape_once(_("Go to the group’s 'Settings > General' page, and check 'Restrict membership by email domain'.")).html_safe }, nonadmin: { allowlist: error_nonadmin, diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 242194be440..43874d0211c 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -87,37 +87,27 @@ module Routable # Klass.where_full_path_in(%w{gitlab-org/gitlab-foss gitlab-org/gitlab}) # # Returns an ActiveRecord::Relation. - def where_full_path_in(paths, use_includes: true) + def where_full_path_in(paths, preload_routes: true) return none if paths.empty? - wheres = paths.map do |path| + path_condition = paths.map do |path| "(LOWER(routes.path) = LOWER(#{connection.quote(path)}))" - end + end.join(' OR ') - if Feature.enabled?(:optimize_where_full_path_in, Feature.current_request) - route_scope = all - source_type_condition = { source_type: route_scope.klass.base_class } + route_scope = all + source_type_condition = { source_type: route_scope.klass.base_class } - routes_matching_condition = Route.where(source_type_condition).where(wheres.join(' OR ')) + routes_matching_condition = Route + .where(source_type_condition) + .where(path_condition) - result = route_scope.where(id: routes_matching_condition.pluck(:source_id)) + source_ids = routes_matching_condition.pluck(:source_id) + result = route_scope.where(id: source_ids) - if use_includes - result.preload(:route) - else - result - end + if preload_routes + result.preload(:route) else - route = - if use_includes - includes(:route).references(:routes) - else - joins(:route) - end - - route - .where(wheres.join(' OR ')) - .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") + result end end end diff --git a/app/models/container_registry/protection/rule.rb b/app/models/container_registry/protection/rule.rb index a7324b3b3b8..34d00bdef2f 100644 --- a/app/models/container_registry/protection/rule.rb +++ b/app/models/container_registry/protection/rule.rb @@ -19,6 +19,23 @@ module ContainerRegistry validates :repository_path_pattern, presence: true, uniqueness: { scope: :project_id }, length: { maximum: 255 } validates :delete_protected_up_to_access_level, presence: true validates :push_protected_up_to_access_level, presence: true + + scope :for_repository_path, ->(repository_path) do + return none if repository_path.blank? + + where( + ":repository_path ILIKE #{::Gitlab::SQL::Glob.to_like('repository_path_pattern')}", + repository_path: repository_path + ) + end + + def self.for_push_exists?(access_level:, repository_path:) + return false if access_level.blank? || repository_path.blank? + + where(push_protected_up_to_access_level: access_level..) + .for_repository_path(repository_path) + .exists? + end end end end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 6bcfd23e69c..3b1c10c0259 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -482,7 +482,7 @@ class ContainerRepository < ApplicationRecord raise 'too many pages requested' if page_count >= MAX_TAGS_PAGES end - def tags_page(before: nil, last: nil, sort: nil, name: nil, page_size: 100) + def tags_page(before: nil, last: nil, sort: nil, name: nil, page_size: 100, referrers: nil) raise ArgumentError, 'not a migrated repository' unless migrated? page = gitlab_api_client.tags( @@ -491,7 +491,8 @@ class ContainerRepository < ApplicationRecord before: before, last: last, sort: sort, - name: name + name: name, + referrers: referrers ) { @@ -618,12 +619,11 @@ class ContainerRepository < ApplicationRecord self.new(project: path.repository_project, name: path.repository_name) end - def self.find_or_create_from_path(path) - repository = safe_find_or_create_by( - project: path.repository_project, + def self.find_or_create_from_path!(path) + ContainerRepository.upsert({ + project_id: path.repository_project.id, name: path.repository_name - ) - return repository if repository.persisted? + }, unique_by: %i[project_id name]) find_by_path!(path) end @@ -657,6 +657,8 @@ class ContainerRepository < ApplicationRecord tag.total_size = raw_tag['size_bytes'] tag.manifest_digest = raw_tag['digest'] tag.revision = raw_tag['config_digest'].to_s.split(':')[1] || '' + tag.referrers = raw_tag['referrers'] + tag.published_at = raw_tag['published_at'] tag end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 36f4a0ef426..1fff089451d 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -9,6 +9,7 @@ class Deployment < ApplicationRecord include Gitlab::Utils::StrongMemoize include FastDestroyAll include IgnorableColumns + include EachBatch StatusUpdateError = Class.new(StandardError) StatusSyncError = Class.new(StandardError) @@ -230,7 +231,7 @@ class Deployment < ApplicationRecord ## # FastDestroyAll concerns def begin_fast_destroy - preload(:project).find_each.map do |deployment| + preload(:project, :environment).find_each.map do |deployment| [deployment.project, deployment.ref_path] end end diff --git a/app/models/group.rb b/app/models/group.rb index ac843f392fd..bbf34ce21c0 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -37,8 +37,8 @@ class Group < Namespace has_many :all_group_members, -> { non_request }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent has_many :all_owner_members, -> { non_request.all_owners }, as: :source, class_name: 'GroupMember' - has_many :group_members, -> { non_request.where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }) }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent - has_many :namespace_members, -> { non_request.where.not(members: { access_level: Gitlab::Access::MINIMAL_ACCESS }).unscope(where: %i[source_id source_type]) }, + has_many :group_members, -> { non_request.non_minimal_access }, dependent: :destroy, as: :source # rubocop:disable Cop/ActiveRecordDependent + has_many :namespace_members, -> { non_request.non_minimal_access.unscope(where: %i[source_id source_type]) }, foreign_key: :member_namespace_id, inverse_of: :group, class_name: 'GroupMember' alias_method :members, :group_members @@ -338,6 +338,18 @@ class Group < Namespace by_ids_or_paths(ids, paths).pluck(:id) end + def descendant_groups_counts + left_joins(:children).group(:id).count(:children_namespaces) + end + + def projects_counts + left_joins(:non_archived_projects).group(:id).count(:projects) + end + + def group_members_counts + left_joins(:group_members).group(:id).count(:members) + end + private def public_to_user_arel(user) @@ -434,7 +446,9 @@ class Group < Namespace end def owned_by?(user) - owners.include?(user) + return false unless user + + all_owner_members.non_invite.exists?(user: user) end def add_members(users, access_level, current_user: nil, expires_at: nil) @@ -593,6 +607,14 @@ class Group < Namespace end end + # Only for direct and not requested members with higher access level than MIMIMAL_ACCESS + # It returns true for non-active users + def has_user?(user) + return false unless user + + group_members.non_invite.exists?(user: user) + end + def direct_members GroupMember.active_without_invites_and_requests .non_minimal_access @@ -685,7 +707,11 @@ class Group < Namespace end def highest_group_member(user) - GroupMember.where(source_id: self_and_ancestors_ids, user_id: user.id).order(:access_level).last + GroupMember + .where(source_id: self_and_ancestors_ids, user_id: user.id) + .non_request + .order(:access_level) + .last end def bots diff --git a/app/models/integration.rb b/app/models/integration.rb index 618f9f986e8..8ebf24b1663 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -19,8 +19,8 @@ class Integration < ApplicationRecord self.inheritance_column = :type_new INTEGRATION_NAMES = %w[ - asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker datadog discord - drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira + asana assembla bamboo bugzilla buildkite campfire clickup confluence custom_issue_tracker + datadog diffblue_cover discord drone_ci emails_on_push ewm external_wiki hangouts_chat harbor irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email pivotaltracker prometheus pumble pushover redmine slack slack_slash_commands squash_tm teamcity telegram unify_circuit webex_teams youtrack zentao @@ -638,7 +638,9 @@ class Integration < ApplicationRecord end def validate_belongs_to_project_or_group - errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_level? && group_level? + return unless project_level? && group_level? + + errors.add(:project_id, 'The integration cannot belong to both a project and a group') end def validate_recipients? diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb index a248a1aa561..152bcf934ae 100644 --- a/app/models/integrations/apple_app_store.rb +++ b/app/models/integrations/apple_app_store.rb @@ -45,7 +45,7 @@ module Integrations section: SECTION_TYPE_CONFIGURATION, title: -> { s_('AppleAppStore|Protected branches and tags only') }, description: -> { s_('AppleAppStore|Set variables on protected branches and tags only.') }, - checkbox_label: -> { s_('AppleAppStore|Set variables on protected branches and tags only.') } + checkbox_label: -> { s_('AppleAppStore|Set variables on protected branches and tags only') } def self.title 'Apple App Store Connect' diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 9fe73f86be3..1c68d09aa2f 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -8,12 +8,14 @@ module Integrations field :bamboo_url, title: -> { s_('BambooService|Bamboo URL') }, placeholder: -> { s_('https://bamboo.example.com') }, - help: -> { s_('BambooService|Bamboo service root URL.') }, + help: -> { s_('BambooService|Bamboo root URL.') }, + description: -> { s_('Bamboo root URL (for example, `https://bamboo.example.com`).') }, exposes_secrets: true, required: true field :build_key, help: -> { s_('BambooService|Bamboo build plan key.') }, + description: -> { s_('Bamboo build plan key (for example, `KEY`).') }, non_empty_password_title: -> { s_('BambooService|Enter new build key') }, non_empty_password_help: -> { s_('BambooService|Leave blank to use your current build key.') }, placeholder: -> { _('KEY') }, @@ -21,12 +23,16 @@ module Integrations is_secret: true field :username, - help: -> { s_('BambooService|The user with API access to the Bamboo server.') } + help: -> { s_('BambooService|User with API access to the Bamboo server.') }, + description: -> { s_('User with API access to the Bamboo server.') }, + required: true field :password, type: :password, non_empty_password_title: -> { s_('ProjectService|Enter new password') }, - non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') } + non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current password') }, + description: -> { s_('Password of the user.') }, + required: true with_options if: :activated? do validates :bamboo_url, presence: true, public_url: true diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb index 18268ed18f4..783311ca18d 100644 --- a/app/models/integrations/campfire.rb +++ b/app/models/integrations/campfire.rb @@ -15,6 +15,9 @@ module Integrations field :token, type: :password, title: -> { _('Campfire token') }, + description: -> do + _('API authentication token from Campfire. To get the token, sign in to Campfire and select **My info**.') + end, help: -> { s_('CampfireService|API authentication token from Campfire.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, @@ -23,18 +26,22 @@ module Integrations field :subdomain, title: -> { _('Campfire subdomain (optional)') }, + description: -> do + _("`.campfirenow.com` subdomain when you're signed in.") + end, placeholder: '', exposes_secrets: true, help: -> do format(ERB::Util.html_escape( - s_('CampfireService|The %{code_open}.campfirenow.com%{code_close} subdomain.') + s_('CampfireService|%{code_open}.campfirenow.com%{code_close} subdomain.') ), code_open: '<code>'.html_safe, code_close: '</code>'.html_safe) end field :room, title: -> { _('Campfire room ID (optional)') }, + description: -> { _("ID portion of the Campfire room URL.") }, placeholder: '123456', - help: -> { s_('CampfireService|From the end of the room URL.') } + help: -> { s_('CampfireService|ID portion of the Campfire room URL.') } def self.title 'Campfire' diff --git a/app/models/integrations/clickup.rb b/app/models/integrations/clickup.rb index 25287b53300..1737aa7ff61 100644 --- a/app/models/integrations/clickup.rb +++ b/app/models/integrations/clickup.rb @@ -32,8 +32,8 @@ module Integrations 'clickup' end - def fields - super.select { _1.name.in?(%w[project_url issues_url]) } + def self.fields + super.select { %w[project_url issues_url].include?(_1.name) } end end end diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb index f97f1fd25c9..fcdc908ca67 100644 --- a/app/models/integrations/confluence.rb +++ b/app/models/integrations/confluence.rb @@ -10,7 +10,8 @@ module Integrations validate :validate_confluence_url_is_cloud, if: :activated? field :confluence_url, - title: -> { _('Confluence Cloud Workspace URL') }, + title: -> { _('Confluence Workspace URL') }, + description: -> { _("URL of the Confluence Workspace hosted on `atlassian.net`.") }, placeholder: 'https://example.atlassian.net/wiki', required: true diff --git a/app/models/integrations/diffblue_cover.rb b/app/models/integrations/diffblue_cover.rb new file mode 100644 index 00000000000..c0e0cae2b33 --- /dev/null +++ b/app/models/integrations/diffblue_cover.rb @@ -0,0 +1,126 @@ +# frozen_string_literal: true + +module Integrations + class DiffblueCover < Integration + field :diffblue_license_key, + section: SECTION_TYPE_CONNECTION, + type: :password, + title: -> { s_('DiffblueCover|License key') }, + description: -> { s_('DiffblueCover|Diffblue Cover license key.') }, + non_empty_password_title: -> { s_('DiffblueCover|License key') }, + non_empty_password_help: -> { + s_( + 'DiffblueCover|Leave blank to use your current license key.' + ) + }, + exposes_secrets: true, + required: true, + is_secret: true, + placeholder: 'XXXX-XXXX-XXXX-XXXX', + help: -> { + format( + s_( + 'DiffblueCover|Enter your Diffblue Cover license key or ' \ + 'go to %{diffblue_link} to obtain a free trial license.' + ), + diffblue_link: diffblue_link + ) + } + + field :diffblue_access_token_name, + section: SECTION_TYPE_CONFIGURATION, + title: -> { s_('DiffblueCover|Name') }, + description: -> { s_('DiffblueCover|Access token name used by Diffblue Cover in pipelines.') }, + required: true, + placeholder: -> { s_('DiffblueCover|My token name') } + + field :diffblue_access_token_secret, + section: SECTION_TYPE_CONFIGURATION, + type: :password, + title: -> { s_('DiffblueCover|Secret') }, + description: -> { s_('DiffblueCover|Access token secret used by Diffblue Cover in pipelines.') }, + non_empty_password_title: -> { s_('DiffblueCover|Secret') }, + non_empty_password_help: -> { s_('DiffblueCover|Leave blank to use your current secret value.') }, + required: true, + is_secret: true, + placeholder: 'glpat-XXXXXXXXXXXXXXXXXXXX' # gitleaks:allow + + with_options if: :activated? do + validates :diffblue_license_key, presence: true + validates :diffblue_access_token_name, presence: true + validates :diffblue_access_token_secret, presence: true + end + + def self.title + 'Diffblue Cover' + end + + def self.description + s_('DiffblueCover|Automatically write comprehensive, human-like Java unit tests.') + end + + def self.to_param + 'diffblue_cover' + end + + def self.help + s_('DiffblueCover|Automatically write comprehensive, human-like Java unit tests.') + end + + def avatar_url + ActionController::Base.helpers.image_path('illustrations/third-party-logos/integrations-logos/diffblue.svg') + end + + def self.supported_events + [] + end + + def sections + [ + { + type: SECTION_TYPE_CONNECTION, + title: s_('DiffblueCover|Integration details'), + description: + s_( + 'DiffblueCover|Diffblue Cover is a generative AI platform that automatically ' \ + 'writes comprehensive, human-like Java unit tests. Integrate Diffblue ' \ + 'Cover into your CI/CD workflow for fully autonomous operation.' + ) + }, + { + type: SECTION_TYPE_CONFIGURATION, + title: s_('DiffblueCover|Access token'), + description: + 'You must have a GitLab access token for Diffblue Cover to access your project. ' \ + 'Use a GitLab access token with at least the Developer role and ' \ + 'the <code>api</code> and <code>write_repository</code> permissions.' + } + ] + end + + def execute(_data) end + + def ci_variables + return [] unless activated? + + [ + { key: 'DIFFBLUE_LICENSE_KEY', value: diffblue_license_key, public: false, masked: true }, + { key: 'DIFFBLUE_ACCESS_TOKEN_NAME', value: diffblue_access_token_name, public: false, masked: true }, + { key: 'DIFFBLUE_ACCESS_TOKEN', value: diffblue_access_token_secret, public: false, masked: true } + ] + end + + def testable? + false + end + + def self.diffblue_link + ActionController::Base.helpers.link_to( + s_('DiffblueCover|Try Diffblue Cover'), + 'https://www.diffblue.com/try-cover/gitlab/', + target: '_blank', + rel: 'noopener noreferrer' + ) + end + end +end diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 7ce597389f0..f36170f91d0 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -8,17 +8,20 @@ module Integrations field :webhook, section: SECTION_TYPE_CONNECTION, + description: -> { _('Discord webhook (for example, `https://discord.com/api/webhooks/…`).') }, help: 'e.g. https://discord.com/api/webhooks/…', required: true field :notify_only_broken_pipelines, type: :checkbox, - section: SECTION_TYPE_CONFIGURATION + section: SECTION_TYPE_CONFIGURATION, + description: -> { _('Send notifications for broken pipelines.') } field :branches_to_be_notified, type: :select, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('Integrations|Branches for which notifications are to be sent') }, + description: -> { _('Branches to send notifications for. Valid options are `all`, `default`, `protected`, and `default_and_protected`. The default value is `default`.') }, choices: -> { branch_choices } def self.title diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index 7408f86d231..e5360e58426 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -7,6 +7,7 @@ module Integrations field :external_wiki_url, section: SECTION_TYPE_CONNECTION, title: -> { s_('ExternalWikiService|External wiki URL') }, + description: -> { s_('ExternalWikiService|URL of the external wiki.') }, placeholder: -> { s_('ExternalWikiService|https://example.com/xxx/wiki/...') }, help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') }, required: true diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb index 746f68fdc4c..1d6d563e37f 100644 --- a/app/models/integrations/google_play.rb +++ b/app/models/integrations/google_play.rb @@ -18,19 +18,25 @@ module Integrations field :package_name, section: SECTION_TYPE_CONNECTION, placeholder: 'com.example.myapp', + description: -> { _('Package name of the app in Google Play.') }, required: true field :service_account_key_file_name, section: SECTION_TYPE_CONNECTION, - required: true + required: true, + description: -> { _('File name of the Google Play service account key.') } - field :service_account_key, api_only: true + field :service_account_key, + required: true, + description: -> { _('Google Play service account key.') }, + api_only: true field :google_play_protected_refs, type: :checkbox, section: SECTION_TYPE_CONFIGURATION, title: -> { s_('GooglePlayStore|Protected branches and tags only') }, - checkbox_label: -> { s_('GooglePlayStore|Only set variables on protected branches and tags') } + description: -> { _('Set variables on protected branches and tags only.') }, + checkbox_label: -> { s_('GooglePlayStore|Set variables on protected branches and tags only') } def self.title s_('GooglePlay|Google Play') @@ -48,10 +54,10 @@ module Integrations # rubocop:disable Layout/LineLength texts = [ - s_("Use the Google Play integration to connect to Google Play with fastlane in CI/CD pipelines."), - s_("After you enable the integration, the following protected variable is created for CI/CD use:"), + s_("Use this integration to connect to Google Play with fastlane in CI/CD pipelines."), + s_("After you enable the integration, the following protected variables are created for CI/CD use:"), variable_list.join('<br>'), - s_(format("To generate a Google Play service account key and use this integration, see the <a href='%{url}' target='_blank'>integration documentation</a>.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/google_play'))).html_safe + s_(format("For more information, see the <a href='%{url}' target='_blank'>documentation</a>.", url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/google_play'))).html_safe ] # rubocop:enable Layout/LineLength diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index cc570e49e36..a1621588cd6 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -10,6 +10,7 @@ module Integrations field :url, title: -> { s_('HarborIntegration|Harbor URL') }, + description: -> { _('The base URL to the Harbor instance linked to the GitLab project. For example, `https://demo.goharbor.io`.') }, placeholder: 'https://demo.goharbor.io', help: -> { s_('HarborIntegration|Base URL of the Harbor instance.') }, exposes_secrets: true, @@ -17,16 +18,19 @@ module Integrations field :project_name, title: -> { s_('HarborIntegration|Harbor project name') }, + description: -> { s_('HarborIntegration|The name of the project in the Harbor instance. For example, `testproject`.') }, help: -> { s_('HarborIntegration|The name of the project in Harbor.') }, required: true field :username, title: -> { s_('HarborIntegration|Harbor username') }, + description: -> { s_('HarborIntegration|The username created in the Harbor interface.') }, required: true field :password, type: :password, title: -> { s_('HarborIntegration|Harbor password') }, + description: -> { s_('HarborIntegration|The password of the user.') }, help: -> { s_('HarborIntegration|Password for your Harbor username.') }, non_empty_password_title: -> { s_('HarborIntegration|Enter new Harbor password') }, non_empty_password_help: -> { s_('HarborIntegration|Leave blank to use your current password.') }, diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb index 361ff4afce8..e7be2b2a454 100644 --- a/app/models/integrations/mattermost.rb +++ b/app/models/integrations/mattermost.rb @@ -27,7 +27,7 @@ module Integrations end def self.webhook_help - 'http://mattermost.example.com/hooks/' + 'http://mattermost.example.com/hooks/...' end override :configurable_channels? diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb index 29ed563a902..dcbda8d1ed0 100644 --- a/app/models/integrations/mattermost_slash_commands.rb +++ b/app/models/integrations/mattermost_slash_commands.rb @@ -8,8 +8,10 @@ module Integrations field :token, type: :password, + description: -> { _('The Mattermost token.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, + required: true, placeholder: '' def testable? diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb index 9f9614a84fd..0c1fd34fccf 100644 --- a/app/models/integrations/slack.rb +++ b/app/models/integrations/slack.rb @@ -18,7 +18,7 @@ module Integrations end def self.webhook_help - 'https://hooks.slack.com/services/…' + 'https://hooks.slack.com/services/...' end private diff --git a/app/models/integrations/squash_tm.rb b/app/models/integrations/squash_tm.rb index 1b4ab152b1d..7aaef0c22cc 100644 --- a/app/models/integrations/squash_tm.rb +++ b/app/models/integrations/squash_tm.rb @@ -7,12 +7,14 @@ module Integrations field :url, placeholder: 'https://your-instance.squashcloud.io/squash/plugin/xsquash4gitlab/webhook/issue', title: -> { s_('SquashTmIntegration|Squash TM webhook URL') }, + description: -> { s_('URL of the Squash TM webhook.') }, exposes_secrets: true, required: true field :token, type: :password, title: -> { s_('SquashTmIntegration|Secret token (optional)') }, + description: -> { s_('Secret token.') }, non_empty_password_title: -> { s_('ProjectService|Enter new token') }, non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, required: false diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index 932e588a829..4d825adb961 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -31,8 +31,8 @@ module Integrations 'youtrack' end - def fields - super.select { _1.name.in?(%w[project_url issues_url]) } + def self.fields + super.select { %w[project_url issues_url].include?(_1.name) } end end end diff --git a/app/models/issue_email_participant.rb b/app/models/issue_email_participant.rb index 9d7e2afa1d9..bb03b3d72e6 100644 --- a/app/models/issue_email_participant.rb +++ b/app/models/issue_email_participant.rb @@ -3,6 +3,7 @@ class IssueEmailParticipant < ApplicationRecord include BulkInsertSafe include Presentable + include CaseSensitivity belongs_to :issue @@ -10,6 +11,8 @@ class IssueEmailParticipant < ApplicationRecord validates :issue, presence: true validate :validate_email_format + scope :with_emails, ->(emails) { iwhere(email: emails) } + def validate_email_format self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email) end diff --git a/app/models/jira_connect_subscription.rb b/app/models/jira_connect_subscription.rb index c74f75b2d8e..8ff89560f09 100644 --- a/app/models/jira_connect_subscription.rb +++ b/app/models/jira_connect_subscription.rb @@ -8,5 +8,5 @@ class JiraConnectSubscription < ApplicationRecord validates :namespace, presence: true, uniqueness: { scope: :jira_connect_installation_id, message: 'has already been added' } scope :preload_namespace_route, -> { preload(namespace: :route) } - scope :for_project, -> (project) { where(namespace_id: project.namespace.self_and_ancestors) } + scope :for_project, -> (project) { where(namespace_id: project.namespace.self_and_ancestor_ids) } end diff --git a/app/models/label.rb b/app/models/label.rb index d0d278b68fd..8fff42abd58 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -46,7 +46,6 @@ class Label < ApplicationRecord scope :with_lists_and_board, -> { joins(lists: :board).merge(List.movable) } scope :with_lock_on_merge, -> { where(lock_on_merge: true) } scope :on_project_boards, ->(project_id) { with_lists_and_board.where(boards: { project_id: project_id }) } - scope :on_board, ->(board_id) { with_lists_and_board.where(boards: { id: board_id }) } scope :order_name_asc, -> { reorder(title: :asc) } scope :order_name_desc, -> { reorder(title: :desc) } scope :subscribed_by, ->(user_id) { joins(:subscriptions).where(subscriptions: { user_id: user_id, subscribed: true }) } @@ -152,10 +151,6 @@ class Label < ApplicationRecord nil end - def self.ids_on_board(board_id) - on_board(board_id).pluck(:label_id) - end - # Searches for labels with a matching title or description. # # This method uses ILIKE on PostgreSQL. diff --git a/app/models/member.rb b/app/models/member.rb index 25dae518406..8bec64932b3 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -276,9 +276,11 @@ class Member < ApplicationRecord after_create :send_invite, if: :invite?, unless: :importing? after_create :create_notification_setting, unless: [:pending?, :importing?] after_create :post_create_hook, unless: [:pending?, :importing?], if: :hook_prerequisites_met? + after_create :update_two_factor_requirement, unless: :invite? after_update :post_update_hook, unless: [:pending?, :importing?], if: :hook_prerequisites_met? after_destroy :destroy_notification_setting after_destroy :post_destroy_hook, unless: :pending?, if: :hook_prerequisites_met? + after_destroy :update_two_factor_requirement, unless: :invite? after_save :log_invitation_token_cleanup after_commit :send_request, if: :request?, unless: :importing?, on: [:create] @@ -286,6 +288,14 @@ class Member < ApplicationRecord refresh_member_authorized_projects end + after_create if: :update_organization_user? do + Organizations::OrganizationUser.upsert( + { organization_id: source.organization_id, user_id: user_id, access_level: :default }, + unique_by: [:organization_id, :user_id], + on_duplicate: :skip # Do not change access_level, could make :owner :default + ) + end + attribute :notification_level, default: -> { NotificationSetting.levels[:global] } class << self @@ -486,7 +496,10 @@ class Member < ApplicationRecord strong_memoize(:highest_group_member) do next unless user_id && source&.ancestors&.any? - GroupMember.where(source: source.ancestors, user_id: user_id).order(:access_level).last + GroupMember + .where(source: source.ancestors, user_id: user_id) + .non_request + .order(:access_level).last end end @@ -498,6 +511,17 @@ class Member < ApplicationRecord created_by&.name end + def update_two_factor_requirement + return unless source.is_a?(Group) + return unless user + + Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( + %w[users user_details user_preferences], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424288' + ) do + user.update_two_factor_requirement + end + end + private # TODO: https://gitlab.com/groups/gitlab-org/-/epics/7054 @@ -513,7 +537,7 @@ class Member < ApplicationRecord end def send_invite - # override in subclass + run_after_commit_or_now { notification_service.invite_member(self, @raw_invite_token) } end def send_request @@ -522,10 +546,26 @@ class Member < ApplicationRecord end def post_create_hook + # The creator of a personal project gets added as a `ProjectMember` + # with `OWNER` access during creation of a personal project, + # but we do not want to trigger notifications to the same person who created the personal project. + unless source.is_a?(Project) && source.personal_namespace_holder?(user) + event_service.join_source(source, user) + run_after_commit_or_now { notification_service.new_member(self) } + end + system_hook_service.execute_hooks_for(self, :create) end def post_update_hook + if saved_change_to_access_level? + run_after_commit { notification_service.updated_member_access_level(self) } + end + + if saved_change_to_expires_at? + run_after_commit { notification_service.updated_member_expiration(self) } + end + system_hook_service.execute_hooks_for(self, :update) end @@ -548,6 +588,12 @@ class Member < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def after_accept_invite + run_after_commit_or_now do + notification_service.accept_invite(self) + end + + update_two_factor_requirement + post_create_hook end @@ -578,7 +624,12 @@ class Member < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def notifiable_options - {} + case source + when Group + { group: source } + when Project + { project: source } + end end def higher_access_level_than_group @@ -617,12 +668,22 @@ class Member < ApplicationRecord user&.project_bot? end + def update_organization_user? + return false unless Feature.enabled?(:update_organization_users, source.root_ancestor, type: :gitlab_com_derisk) + + !invite? && source.organization.present? + end + def log_invitation_token_cleanup return true unless Gitlab.com? && invite? && invite_accepted_at? error = StandardError.new("Invitation token is present but invite was already accepted!") Gitlab::ErrorTracking.track_exception(error, attributes.slice(%w["invite_accepted_at created_at source_type source_id user_id id"])) end + + def event_service + EventCreateService.new # rubocop:todo CodeReuse/ServiceClass -- Legacy, convert to value object eventually + end end Member.prepend_mod_with('Member') diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index e3ead1b04d0..b04fb1f6768 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -18,25 +18,12 @@ class GroupMember < Member default_scope { where(source_type: SOURCE_TYPE) } # rubocop:disable Cop/DefaultScope - scope :of_groups, ->(groups) { where(source_id: groups&.select(:id)) } + scope :of_groups, ->(groups) { where(source_id: groups) } scope :of_ldap_type, -> { where(ldap: true) } scope :count_users_by_group_id, -> { group(:source_id).count } - after_create :update_two_factor_requirement, unless: :invite? - after_destroy :update_two_factor_requirement, unless: :invite? - attr_accessor :last_owner - def update_two_factor_requirement - return unless user - - Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.temporary_ignore_tables_in_transaction( - %w[users user_details user_preferences], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424288' - ) do - user.update_two_factor_requirement - end - end - # For those who get to see a modal with a role dropdown, here are the options presented def self.permissible_access_level_roles(_, _) # This method is a stopgap in preparation for https://gitlab.com/gitlab-org/gitlab/-/issues/364087 @@ -56,10 +43,6 @@ class GroupMember < Member Group.sti_name end - def notifiable_options - { group: group } - end - def last_owner_of_the_group? return false unless access_level == Gitlab::Access::OWNER return last_owner unless last_owner.nil? @@ -87,40 +70,6 @@ class GroupMember < Member super end - - def send_invite - run_after_commit_or_now { notification_service.invite_group_member(self, @raw_invite_token) } - - super - end - - def post_create_hook - run_after_commit_or_now { notification_service.new_group_member(self) } - - super - end - - def post_update_hook - if saved_change_to_access_level? - run_after_commit { notification_service.update_group_member(self) } - end - - if saved_change_to_expires_at? - run_after_commit { notification_service.updated_group_member_expiration(self) } - end - - super - end - - def after_accept_invite - run_after_commit_or_now do - notification_service.accept_group_invite(self) - end - - update_two_factor_requirement - - super - end end GroupMember.prepend_mod_with('GroupMember') diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index f52fef9e247..a2927238e54 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -72,10 +72,6 @@ class ProjectMember < Member source end - def notifiable_options - { project: project } - end - def holder_of_the_personal_namespace? project.personal_namespace_holder?(user) end @@ -116,32 +112,6 @@ class ProjectMember < Member self.member_namespace_id = project&.project_namespace_id end - def send_invite - run_after_commit_or_now { notification_service.invite_project_member(self, @raw_invite_token) } - - super - end - - def post_create_hook - # The creator of a personal project gets added as a `ProjectMember` - # with `OWNER` access during creation of a personal project, - # but we do not want to trigger notifications to the same person who created the personal project. - unless project.personal_namespace_holder?(user) - event_service.join_project(self.project, self.user) - run_after_commit_or_now { notification_service.new_project_member(self) } - end - - super - end - - def post_update_hook - if saved_change_to_access_level? - run_after_commit { notification_service.update_project_member(self) } - end - - super - end - def post_destroy_hook if expired? event_service.expired_leave_project(self.project, self.user) @@ -151,20 +121,6 @@ class ProjectMember < Member super end - - def after_accept_invite - run_after_commit_or_now do - notification_service.accept_project_invite(self) - end - - super - end - - # rubocop: disable CodeReuse/ServiceClass - def event_service - EventCreateService.new - end - # rubocop: enable CodeReuse/ServiceClass end ProjectMember.prepend_mod_with('ProjectMember') diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index f9af342f47f..ae68a36c8d2 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1716,8 +1716,6 @@ class MergeRequest < ApplicationRecord actual_head_pipeline&.complete_and_has_reports?(Ci::JobArtifact.of_report_type(:test)) end - # rubocop: disable Metrics/AbcSize - # Delete a rubocop annotation once FF truncate_ci_merge_request_description is cleaned up def predefined_variables Gitlab::Ci::Variables::Collection.new.tap do |variables| variables.append(key: 'CI_MERGE_REQUEST_ID', value: id.to_s) @@ -1730,14 +1728,9 @@ class MergeRequest < ApplicationRecord variables.append(key: 'CI_MERGE_REQUEST_TARGET_BRANCH_PROTECTED', value: ProtectedBranch.protected?(target_project, target_branch).to_s) variables.append(key: 'CI_MERGE_REQUEST_TITLE', value: title) - if ::Feature.enabled?(:truncate_ci_merge_request_description) - mr_description, mr_description_truncated = truncate_mr_description - variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: mr_description) - variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION_IS_TRUNCATED', value: mr_description_truncated) - else - variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: description) - end - + mr_description, mr_description_truncated = truncate_mr_description + variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION', value: mr_description) + variables.append(key: 'CI_MERGE_REQUEST_DESCRIPTION_IS_TRUNCATED', value: mr_description_truncated) variables.append(key: 'CI_MERGE_REQUEST_ASSIGNEES', value: assignee_username_list) if assignees.present? variables.append(key: 'CI_MERGE_REQUEST_MILESTONE', value: milestone.title) if milestone variables.append(key: 'CI_MERGE_REQUEST_LABELS', value: label_names.join(',')) if labels.present? @@ -1745,8 +1738,6 @@ class MergeRequest < ApplicationRecord variables.concat(source_project_variables) end end - # rubocop: enable Metrics/AbcSize - # Delete a rubocop annotation once FF truncate_ci_merge_request_description is cleaned up def compare_test_reports unless has_test_reports? @@ -2102,8 +2093,12 @@ class MergeRequest < ApplicationRecord true end + def allows_multiple_assignees? + project.allows_multiple_merge_request_assignees? + end + def allows_multiple_reviewers? - false + project.allows_multiple_merge_request_reviewers? end def supports_assignee? @@ -2198,6 +2193,8 @@ class MergeRequest < ApplicationRecord attr_accessor :skip_fetch_ref def merge_base_pipelines + return ::Ci::Pipeline.none unless actual_head_pipeline&.target_sha + target_branch_pipelines_for(sha: actual_head_pipeline.target_sha) end diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 3c592c0008f..6d6c0ee07af 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true class MergeRequest::Metrics < ApplicationRecord - include DatabaseEventTracking - belongs_to :merge_request, inverse_of: :metrics belongs_to :pipeline, class_name: 'Ci::Pipeline', foreign_key: :pipeline_id belongs_to :latest_closed_by, class_name: 'User' @@ -33,8 +31,7 @@ class MergeRequest::Metrics < ApplicationRecord RETURNING id, #{inserted_columns.join(', ')} SQL - result = connection.execute(sql).first - new(result).publish_database_create_event + connection.execute(sql) end end @@ -48,31 +45,6 @@ class MergeRequest::Metrics < ApplicationRecord with_valid_time_to_merge .pick(time_to_merge_expression) end - - SNOWPLOW_ATTRIBUTES = %i[ - id - merge_request_id - latest_build_started_at - latest_build_finished_at - first_deployed_to_production_at - merged_at - created_at - updated_at - pipeline_id - merged_by_id - latest_closed_by_id - latest_closed_at - first_comment_at - first_commit_at - last_commit_at - diff_size - modified_paths_size - commits_count - first_approved_at - first_reassigned_at - added_lines - removed_lines - ].freeze end MergeRequest::Metrics.prepend_mod_with('MergeRequest::Metrics') diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index 0b183131a47..47102418152 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -196,6 +196,7 @@ class MergeRequestDiff < ApplicationRecord # It allows you to override variables like head_commit_sha before getting diff. after_create :save_git_content, unless: :importing? after_create_commit :set_as_latest_diff, unless: :importing? + after_create_commit :trigger_diff_generated_subscription, unless: :importing? after_save :update_external_diff_store after_save :set_count_columns @@ -258,6 +259,12 @@ class MergeRequestDiff < ApplicationRecord .update_all(latest_merge_request_diff_id: self.id) end + def trigger_diff_generated_subscription + return unless Feature.enabled?(:merge_request_diff_generated_subscription, merge_request.project) + + GraphqlTriggers.merge_request_diff_generated(merge_request) + end + def ensure_commit_shas self.start_commit_sha ||= merge_request.target_branch_sha @@ -439,6 +446,8 @@ class MergeRequestDiff < ApplicationRecord ) end + diff_options[:generated_files] = comparison.generated_files if diff_options[:collapse_generated] + Gitlab::Metrics.measure(:diffs_comparison) do comparison.diffs(diff_options) end @@ -452,18 +461,25 @@ class MergeRequestDiff < ApplicationRecord fetching_repository_diffs({}) do |comparison| reorder_diff_files! + collapse_generated = Feature.enabled?(:collapse_generated_diff_files, project) + diff_options = { collapse_generated: collapse_generated } + collection = Gitlab::Diff::FileCollection::PaginatedMergeRequestDiff.new( self, page, - per_page + per_page, + diff_options ) if comparison + diff_options[:generated_files] = comparison.generated_files if collapse_generated + comparison.diffs( - paths: collection.diff_paths, - page: collection.current_page, - per_page: collection.limit_value, - count: collection.total_count + diff_options.merge( + paths: collection.diff_paths, + page: collection.current_page, + per_page: collection.limit_value, + count: collection.total_count) ) else collection diff --git a/app/models/ml/experiment.rb b/app/models/ml/experiment.rb index ad6c6b7b3bf..456c23df0e0 100644 --- a/app/models/ml/experiment.rb +++ b/app/models/ml/experiment.rb @@ -3,6 +3,7 @@ module Ml class Experiment < ApplicationRecord include AtomicInternalId + include Sortable PACKAGE_PREFIX = 'ml_experiment_' @@ -15,6 +16,8 @@ module Ml has_many :candidates, class_name: 'Ml::Candidate' has_many :metadata, class_name: 'Ml::ExperimentMetadata' + scope :including_project, -> { includes(:project) } + scope :by_project, ->(project) { where(project: project) } scope :with_candidate_count, -> { left_outer_joins(:candidates) .select("ml_experiments.*, count(ml_candidates.id) as candidate_count") diff --git a/app/models/ml/model_metadata.rb b/app/models/ml/model_metadata.rb index 9c4273c629c..9695621e47d 100644 --- a/app/models/ml/model_metadata.rb +++ b/app/models/ml/model_metadata.rb @@ -3,7 +3,7 @@ module Ml class ModelMetadata < ApplicationRecord validates :name, - length: { maximum: 250 }, + length: { maximum: 255 }, presence: true, uniqueness: { scope: :model, message: ->(metadata, _) { "'#{metadata.name}' already taken" } } validates :value, length: { maximum: 5000 }, presence: true diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb index 58da57f27d6..1b3313c803a 100644 --- a/app/models/ml/model_version.rb +++ b/app/models/ml/model_version.rb @@ -21,12 +21,25 @@ module Ml belongs_to :project belongs_to :package, class_name: 'Packages::MlModel::Package', optional: true has_one :candidate, class_name: 'Ml::Candidate' + has_many :metadata, class_name: 'Ml::ModelVersionMetadata' delegate :name, to: :model scope :order_by_model_id_id_desc, -> { order('model_id, id DESC') } scope :latest_by_model, -> { order_by_model_id_id_desc.select('DISTINCT ON (model_id) *') } + def add_metadata(metadata_key_value) + return unless metadata_key_value.present? + + metadata_key_value.each do |entry| + metadata.create!( + project_id: project_id, + name: entry[:key], + value: entry[:value] + ) + end + end + class << self def find_or_create!(model, version, package, description) create_with(package: package, description: description) diff --git a/app/models/ml/model_version_metadata.rb b/app/models/ml/model_version_metadata.rb new file mode 100644 index 00000000000..61810786091 --- /dev/null +++ b/app/models/ml/model_version_metadata.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ml + class ModelVersionMetadata < ApplicationRecord + validates :name, + length: { maximum: 255 }, + presence: true, + uniqueness: { scope: :model_version, message: ->(metadata, _) { "'#{metadata.name}' already taken" } } + validates :value, length: { maximum: 5000 }, presence: true + + belongs_to :project, optional: false + belongs_to :model_version, class_name: 'Ml::ModelVersion', optional: false + end +end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index c665c2278a5..238556f0cf0 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -12,6 +12,7 @@ class Namespace < ApplicationRecord include Gitlab::Utils::StrongMemoize include Namespaces::Traversal::Recursive include Namespaces::Traversal::Linear + include Namespaces::Traversal::Cached include EachBatch include BlocksUnsafeSerialization include Ci::NamespaceSettings @@ -45,6 +46,7 @@ class Namespace < ApplicationRecord cache_markdown_field :description, pipeline: :description has_many :projects, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :non_archived_projects, -> { where.not(archived: true) }, class_name: 'Project' has_many :project_statistics has_one :namespace_settings, inverse_of: :namespace, class_name: 'NamespaceSetting', autosave: true has_one :ci_cd_settings, inverse_of: :namespace, class_name: 'NamespaceCiCdSetting', autosave: true @@ -55,6 +57,9 @@ class Namespace < ApplicationRecord has_one :namespace_ldap_settings, inverse_of: :namespace, class_name: 'Namespaces::LdapSetting', autosave: true + has_one :namespace_descendants, class_name: 'Namespaces::Descendants' + accepts_nested_attributes_for :namespace_descendants, allow_destroy: true + has_many :runner_namespaces, inverse_of: :namespace, class_name: 'Ci::RunnerNamespace' has_many :runners, through: :runner_namespaces, source: :runner, class_name: 'Ci::Runner' has_many :pending_builds, class_name: 'Ci::PendingBuild' @@ -263,6 +268,28 @@ class Namespace < ApplicationRecord end end + # This should be kept in sync with the frontend filtering in + # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L68 and + # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L1053 + def gfm_autocomplete_search(query) + without_project_namespaces + .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/420046") + .joins(:route) + .where( + "REPLACE(routes.name, ' ', '') ILIKE :pattern OR routes.path ILIKE :pattern", + pattern: "%#{sanitize_sql_like(query)}%" + ) + .order( + Arel.sql(sanitize_sql( + [ + "CASE WHEN starts_with(REPLACE(routes.name, ' ', ''), :pattern) OR starts_with(routes.path, :pattern) THEN 1 ELSE 2 END", + { pattern: query } + ] + )), + 'routes.path' + ) + end + def clean_path(path, limited_to: Namespace.all) slug = Gitlab::Slug::Path.new(path).generate path = Namespaces::RandomizedSuffixPath.new(slug) diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb index a5a393ad8a2..5f5bef4409c 100644 --- a/app/models/namespace/package_setting.rb +++ b/app/models/namespace/package_setting.rb @@ -12,7 +12,7 @@ class Namespace::PackageSetting < ApplicationRecord PackageSettingNotImplemented = Class.new(StandardError) - PACKAGES_WITH_SETTINGS = %w[maven generic nuget].freeze + PACKAGES_WITH_SETTINGS = %w[maven generic nuget terraform_module].freeze belongs_to :namespace, inverse_of: :package_setting_relation @@ -24,6 +24,14 @@ class Namespace::PackageSetting < ApplicationRecord validates :nuget_duplicates_allowed, inclusion: { in: [true, false] } validates :nuget_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } validates :nuget_symbol_server_enabled, inclusion: { in: [true, false] } + validates :terraform_module_duplicates_allowed, inclusion: { in: [true, false] } + validates :terraform_module_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } + + scope :namespace_id_in, ->(namespace_ids) { where(namespace_id: namespace_ids) } + scope :with_terraform_module_duplicates_allowed_or_exception_regex, -> do + where(terraform_module_duplicates_allowed: true) + .or(where.not(terraform_module_duplicate_exception_regex: '')) + end class << self def duplicates_allowed?(package) diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 0263942116d..e61e5a7f37e 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -4,9 +4,13 @@ class NamespaceSetting < ApplicationRecord include CascadingNamespaceSettingAttribute include Sanitizable include ChronicDurationAttribute + include IgnorableColumns + + ignore_column :project_import_level, remove_with: '16.10', remove_after: '2024-02-22' cascading_attr :delayed_project_removal cascading_attr :toggle_security_policy_custom_ci + cascading_attr :toggle_security_policies_policy_scope belongs_to :namespace, inverse_of: :namespace_settings diff --git a/app/models/namespaces/descendants.rb b/app/models/namespaces/descendants.rb new file mode 100644 index 00000000000..8444cea9848 --- /dev/null +++ b/app/models/namespaces/descendants.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Namespaces + class Descendants < ApplicationRecord + self.table_name = :namespace_descendants + + belongs_to :namespace + + validates :namespace_id, uniqueness: true + + def self.expire_for(namespace_ids) + # Union: + # - Look up all parent ids including the given ids via traversal_ids + # - Include the given ids to handle the case when the namespaces records are already deleted + sql = <<~SQL + WITH namespace_ids AS MATERIALIZED ( + ( + SELECT ids.id + FROM namespaces, UNNEST(traversal_ids) ids(id) + WHERE namespaces.id IN (?) + ) UNION + (SELECT UNNEST(ARRAY[?]) AS id) + ) + UPDATE namespace_descendants SET outdated_at = ? FROM namespace_ids WHERE namespace_descendants.namespace_id = namespace_ids.id + SQL + + connection.execute(sanitize_sql_array([sql, namespace_ids, namespace_ids, Time.current])) + end + end +end diff --git a/app/models/namespaces/traversal/cached.rb b/app/models/namespaces/traversal/cached.rb new file mode 100644 index 00000000000..55eaaa4667e --- /dev/null +++ b/app/models/namespaces/traversal/cached.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Namespaces + module Traversal + module Cached + extend ActiveSupport::Concern + extend Gitlab::Utils::Override + + included do + after_destroy :invalidate_descendants_cache + end + + private + + override :sync_traversal_ids + def sync_traversal_ids + super + return if is_a?(Namespaces::UserNamespace) + return unless Feature.enabled?(:namespace_descendants_cache_expiration, self, type: :gitlab_com_derisk) + + ids = [id] + ids.concat((saved_changes[:parent_id] - [parent_id]).compact) if saved_changes[:parent_id] + Namespaces::Descendants.expire_for(ids) + end + + def invalidate_descendants_cache + return if is_a?(Namespaces::UserNamespace) + return unless Feature.enabled?(:namespace_descendants_cache_expiration, self, type: :gitlab_com_derisk) + + Namespaces::Descendants.expire_for([parent_id, id].compact) + end + end + end +end diff --git a/app/models/onboarding/completion.rb b/app/models/onboarding/completion.rb index afbd671f82e..53781e112ae 100644 --- a/app/models/onboarding/completion.rb +++ b/app/models/onboarding/completion.rb @@ -3,7 +3,6 @@ module Onboarding class Completion include Gitlab::Utils::StrongMemoize - include Gitlab::Experiment::Dsl ACTION_PATHS = [ :pipeline_created, @@ -12,6 +11,7 @@ module Onboarding :code_owners_enabled, :issue_created, :git_write, + :code_added, :merge_request_created, :user_added, :license_scanning_run, @@ -35,20 +35,11 @@ module Onboarding end def completed?(column) - if column == :code_added - repository.commit_count > 1 || repository.branch_count > 1 - else - attributes[column].present? - end + attributes[column].present? end private - def repository - project.repository - end - strong_memoize_attr :repository - def attributes onboarding_progress.attributes.symbolize_keys end @@ -60,8 +51,7 @@ module Onboarding strong_memoize_attr :onboarding_progress def action_columns - [:code_added] + - ACTION_PATHS.map { |action_key| ::Onboarding::Progress.column_name(action_key) } + ACTION_PATHS.map { |action_key| ::Onboarding::Progress.column_name(action_key) } end strong_memoize_attr :action_columns diff --git a/app/models/onboarding/progress.rb b/app/models/onboarding/progress.rb index 83030732c6a..b6628843821 100644 --- a/app/models/onboarding/progress.rb +++ b/app/models/onboarding/progress.rb @@ -32,7 +32,8 @@ module Onboarding :secure_api_fuzzing_run, :secure_cluster_image_scanning_run, :license_scanning_run, - :promote_ultimate_features + :promote_ultimate_features, + :code_added ].freeze scope :incomplete_actions, ->(actions) do diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index 764378a5d19..df6f0109d57 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Organizations - class Organization < ApplicationRecord + class Organization < MainClusterwide::ApplicationRecord DEFAULT_ORGANIZATION_ID = 1 scope :without_default, -> { where.not(id: DEFAULT_ORGANIZATION_ID) } @@ -16,6 +16,8 @@ module Organizations has_one :organization_detail, inverse_of: :organization, autosave: true has_many :organization_users, inverse_of: :organization + # if considering disable_joins on the below see: + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/140343#note_1705047949 has_many :users, through: :organization_users, inverse_of: :organizations validates :name, @@ -28,7 +30,7 @@ module Organizations 'organizations/path': true, length: { minimum: 2, maximum: 255 } - delegate :description, :avatar, :avatar_url, to: :organization_detail + delegate :description, :description_html, :avatar, :avatar_url, :remove_avatar!, to: :organization_detail accepts_nested_attributes_for :organization_detail @@ -52,6 +54,10 @@ module Organizations organization_users.exists?(user: user) end + def owner?(user) + organization_users.owners.exists?(user: user) + end + def web_url(only_path: nil) Gitlab::UrlBuilder.build(self, only_path: only_path) end diff --git a/app/models/organizations/organization_detail.rb b/app/models/organizations/organization_detail.rb index b69ec5eae76..018e7579c5b 100644 --- a/app/models/organizations/organization_detail.rb +++ b/app/models/organizations/organization_detail.rb @@ -6,7 +6,7 @@ module Organizations include Avatarable include WithUploads - cache_markdown_field :description + cache_markdown_field :description, pipeline: :description belongs_to :organization, inverse_of: :organization_detail diff --git a/app/models/organizations/organization_user.rb b/app/models/organizations/organization_user.rb index 5aa1133b017..9e06870dcc6 100644 --- a/app/models/organizations/organization_user.rb +++ b/app/models/organizations/organization_user.rb @@ -4,5 +4,17 @@ module Organizations class OrganizationUser < ApplicationRecord belongs_to :organization, inverse_of: :organization_users, optional: false belongs_to :user, inverse_of: :organization_users, optional: false + + validates :user, uniqueness: { scope: :organization_id } + validates :access_level, presence: true + + enum access_level: { + # Until we develop more access_levels, we really don't know if the default access_level will be what we think of + # as a guest. For now, we'll set to same value as guest, but call it default to denote the current ambivalence. + default: Gitlab::Access::GUEST, + owner: Gitlab::Access::OWNER + } + + scope :owners, -> { where(access_level: Gitlab::Access::OWNER) } end end diff --git a/app/models/pages/project_settings.rb b/app/models/pages/project_settings.rb new file mode 100644 index 00000000000..96e5bb8e98e --- /dev/null +++ b/app/models/pages/project_settings.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Pages + class ProjectSettings + def initialize(project) + @project = project + end + + def url = url_builder.pages_url(with_unique_domain: true) + + def deployments = project.pages_deployments.active + + def unique_domain_enabled? = project.project_setting.pages_unique_domain_enabled? + + def force_https? = project.pages_https_only? + + private + + attr_reader :project + + def url_builder + @url_builder ||= ::Gitlab::Pages::UrlBuilder.new(project) + end + end +end diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index e8b186234af..a360b705805 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -68,6 +68,14 @@ class PagesDeployment < ApplicationRecord update(deleted_at: Time.now.utc) end + def url + base_url = ::Gitlab::Pages::UrlBuilder + .new(project) + .pages_url(with_unique_domain: true) + + File.join(base_url.to_s, path_prefix.to_s) + end + private def set_size diff --git a/app/models/project.rb b/app/models/project.rb index 7b996457c0d..8f82a947ba6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -208,6 +208,7 @@ class Project < ApplicationRecord has_one :custom_issue_tracker_integration, class_name: 'Integrations::CustomIssueTracker' has_one :datadog_integration, class_name: 'Integrations::Datadog' has_one :container_registry_data_repair_detail, class_name: 'ContainerRegistry::DataRepairDetail' + has_one :diffblue_cover_integration, class_name: 'Integrations::DiffblueCover' has_one :discord_integration, class_name: 'Integrations::Discord' has_one :drone_ci_integration, class_name: 'Integrations::DroneCi' has_one :emails_on_push_integration, class_name: 'Integrations::EmailsOnPush' @@ -334,7 +335,7 @@ class Project < ApplicationRecord has_many :authorized_users, -> { allow_cross_joins_across_databases(url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/422045') }, through: :project_authorizations, source: :user, class_name: 'User' - has_many :project_members, -> { where(requested_at: nil) }, + has_many :project_members, -> { non_request }, as: :source, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent alias_method :members, :project_members has_many :namespace_members, ->(project) { where(requested_at: nil).unscope(where: %i[source_id source_type]) }, @@ -508,6 +509,7 @@ class Project < ApplicationRecord delegate :members, prefix: true delegate :add_member, :add_members, :member? delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role + delegate :has_user? end with_options to: :namespace do @@ -749,6 +751,7 @@ class Project < ApplicationRecord preload(:project_feature, :route, namespace: [:route, :owner]) } + scope :with_name, -> (name) { where(name: name) } scope :created_by, -> (user) { where(creator: user) } scope :imported_from, -> (type) { where(import_type: type) } scope :imported, -> { where.not(import_type: nil) } @@ -3205,6 +3208,21 @@ class Project < ApplicationRecord end strong_memoize_attr :code_suggestions_enabled? + # Overridden in EE + def allows_multiple_merge_request_assignees? + false + end + + # Overridden in EE + def allows_multiple_merge_request_reviewers? + false + end + + # Overridden in EE + def on_demand_dast_available? + false + end + private # overridden in EE @@ -3226,8 +3244,11 @@ class Project < ApplicationRecord if @topic_list != self.topic_list self.topics.delete_all - self.topics = @topic_list.map do |topic| - Projects::Topic.where('lower(name) = ?', topic.downcase).order(total_projects_count: :desc).first_or_create(name: topic, title: topic) + self.topics = @topic_list.map do |topic_name| + Projects::Topic + .where('lower(name) = ?', topic_name.downcase) + .order(total_projects_count: :desc) + .first_or_create(name: topic_name, title: topic_name, slug: Gitlab::Slug::Path.new(topic_name).generate) end end @@ -3438,7 +3459,7 @@ class Project < ApplicationRecord def check_project_export_limit! return if Gitlab::CurrentSettings.current_application_settings.max_export_size == 0 - if self.statistics.storage_size > Gitlab::CurrentSettings.current_application_settings.max_export_size.megabytes + if self.statistics.export_size > Gitlab::CurrentSettings.current_application_settings.max_export_size.megabytes raise ExportLimitExceeded, _('The project size exceeds the export limit.') end end diff --git a/app/models/project_authorizations/changes.rb b/app/models/project_authorizations/changes.rb index ac52bdfdb07..26f5366ad5e 100644 --- a/app/models/project_authorizations/changes.rb +++ b/app/models/project_authorizations/changes.rb @@ -21,6 +21,7 @@ module ProjectAuthorizations @authorizations_to_add = [] @affected_project_ids = Set.new @removed_user_ids = Set.new + @added_user_ids = Set.new yield self end @@ -61,6 +62,7 @@ module ProjectAuthorizations def add_authorizations insert_all_in_batches(authorizations_to_add) @affected_project_ids += authorizations_to_add.pluck(:project_id) + @added_user_ids += authorizations_to_add.pluck(:user_id) end def delete_authorizations_for_user @@ -139,23 +141,51 @@ module ProjectAuthorizations end def publish_events + publish_changed_event + publish_removed_event + publish_added_event + end + + def publish_changed_event + # This event is used to add policy approvers to approval rules by re-syncing all project policies which is costly. + # If the feature flag below is enabled, the policies won't be re-synced and + # the approvers will be added via `AuthorizationsAddedEvent`. + return if ::Feature.enabled?(:add_policy_approvers_to_rules) + @affected_project_ids.each do |project_id| ::Gitlab::EventStore.publish( ::ProjectAuthorizations::AuthorizationsChangedEvent.new(data: { project_id: project_id }) ) end - return if ::Feature.disabled?(:user_approval_rules_removal) || @removed_user_ids.blank? + end - @affected_project_ids.each do |project_id| - @removed_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).each do |user_ids_batch| - ::Gitlab::EventStore.publish( - ::ProjectAuthorizations::AuthorizationsRemovedEvent.new(data: { - project_id: project_id, - user_ids: user_ids_batch - }) - ) + def publish_removed_event + return if @removed_user_ids.none? + + events = @affected_project_ids.flat_map do |project_id| + @removed_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).map do |user_ids_batch| + ::ProjectAuthorizations::AuthorizationsRemovedEvent.new(data: { + project_id: project_id, + user_ids: user_ids_batch + }) + end + end + ::Gitlab::EventStore.publish_group(events) + end + + def publish_added_event + return if ::Feature.disabled?(:add_policy_approvers_to_rules) + return if @added_user_ids.none? + + events = @affected_project_ids.flat_map do |project_id| + @added_user_ids.to_a.each_slice(EVENT_USER_BATCH_SIZE).map do |user_ids_batch| + ::ProjectAuthorizations::AuthorizationsAddedEvent.new(data: { + project_id: project_id, + user_ids: user_ids_batch + }) end end + ::Gitlab::EventStore.publish_group(events) end end end diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 942f20f6e5e..f89894b77a8 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -145,6 +145,11 @@ class ProjectStatistics < ApplicationRecord bulk_increment_counter(key, increments) end + # Build artifacts & packages are not included in the project export + def export_size + storage_size - build_artifacts_size - packages_size + end + private def incrementable_attribute?(key) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 5078642ea3a..3af9f946243 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -172,6 +172,13 @@ class ProjectTeam max_member_access(user.id) >= min_access_level end + # Only for direct and not invited members + def has_user?(user) + return false unless user + + project.project_members.non_invite.exists?(user: user) + end + def human_max_access(user_id) Gitlab::Access.human_access(max_member_access(user_id)) end diff --git a/app/models/projects/project_topic.rb b/app/models/projects/project_topic.rb index 7021a48646a..7833c1ebf24 100644 --- a/app/models/projects/project_topic.rb +++ b/app/models/projects/project_topic.rb @@ -4,5 +4,7 @@ module Projects class ProjectTopic < ApplicationRecord belongs_to :project belongs_to :topic, counter_cache: :total_projects_count + + validates :topic_id, uniqueness: { scope: [:project_id] } end end diff --git a/app/models/projects/repository_storage_move.rb b/app/models/projects/repository_storage_move.rb index ae815bf366d..95fd78e8941 100644 --- a/app/models/projects/repository_storage_move.rb +++ b/app/models/projects/repository_storage_move.rb @@ -17,11 +17,7 @@ module Projects override :schedule_repository_storage_update_worker def schedule_repository_storage_update_worker - Projects::UpdateRepositoryStorageWorker.perform_async( - project_id, - destination_storage_name, - id - ) + Projects::UpdateRepositoryStorageWorker.perform_async(id) end private diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb index 347d65841ed..a3622150351 100644 --- a/app/models/projects/topic.rb +++ b/app/models/projects/topic.rb @@ -7,9 +7,18 @@ module Projects include Avatarable include Gitlab::SQL::Pattern + SLUG_ALLOWED_REGEX = %r{\A[a-zA-Z0-9_\-.]+\z} + validates :name, presence: true, length: { maximum: 255 } validates :name, uniqueness: { case_sensitive: false }, if: :name_changed? validate :validate_name_format, if: :name_changed? + + validates :slug, + length: { maximum: 255 }, + uniqueness: { case_sensitive: false }, + format: { with: SLUG_ALLOWED_REGEX, message: "can contain only letters, digits, '_', '-', '.'" }, + if: :slug_changed? + validates :title, presence: true, length: { maximum: 255 }, on: :create validates :description, length: { maximum: 1024 } diff --git a/app/models/release.rb b/app/models/release.rb index 1cd623e1254..7bacc69f038 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -54,6 +54,7 @@ class Release < ApplicationRecord scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) } scope :released_within_2hrs, -> { where(released_at: Time.zone.now - 1.hour..Time.zone.now + 1.hour) } + scope :unpublished, -> { where(release_published_at: nil) } scope :for_projects, ->(projects) { where(project_id: projects) } scope :by_tag, ->(tag) { where(tag: tag) } @@ -66,6 +67,7 @@ class Release < ApplicationRecord delegate :repository, to: :project MAX_NUMBER_TO_DISPLAY = 3 + MAX_NUMBER_TO_PUBLISH = 5000 class << self # In the future, we should support `order_by=semver`; @@ -97,6 +99,10 @@ class Release < ApplicationRecord .from("(VALUES #{project_ids_list}) projects (id)") .joins("INNER JOIN LATERAL (#{join_query.to_sql}) #{Release.table_name} ON TRUE") end + + def waiting_for_publish_event + unpublished.released_within_2hrs.joins(:project).merge(Project.with_feature_enabled(:releases)).limit(MAX_NUMBER_TO_PUBLISH) + end end def to_param diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index ad1ce740c89..e912e57f39e 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -45,7 +45,7 @@ class ResourceLabelEvent < ResourceEvent end def group - issuable.group if issuable.respond_to?(:group) + issuable.resource_parent if issuable.resource_parent.is_a?(Group) end def outdated_markdown? @@ -93,7 +93,9 @@ class ResourceLabelEvent < ResourceEvent end def label_url_method - issuable.is_a?(MergeRequest) ? :project_merge_requests_url : :project_issues_url + return :project_merge_requests_url if issuable.is_a?(MergeRequest) + + issuable.project_id.nil? ? :group_work_items_url : :project_issues_url end def broadcast_notes_changed diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb index d305a4ace51..2b93334f721 100644 --- a/app/models/resource_milestone_event.rb +++ b/app/models/resource_milestone_event.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ResourceMilestoneEvent < ResourceTimeboxEvent + include EachBatch + belongs_to :milestone scope :include_relations, -> { includes(:user, milestone: [:project, :group]) } diff --git a/app/models/route.rb b/app/models/route.rb index 652c33a673c..1fa0005ffb4 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -3,6 +3,7 @@ class Route < MainClusterwide::ApplicationRecord include CaseSensitivity include Gitlab::SQL::Pattern + include EachBatch belongs_to :source, polymorphic: true, inverse_of: :route # rubocop:disable Cop/PolymorphicAssociations belongs_to :namespace, inverse_of: :namespace_route @@ -26,30 +27,39 @@ class Route < MainClusterwide::ApplicationRecord def rename_descendants return unless saved_change_to_path? || saved_change_to_name? - descendant_routes = self.class.inside_path(path_before_last_save) + if Feature.disabled?(:batch_route_updates, Feature.current_request, type: :gitlab_com_derisk) + descendant_routes = self.class.inside_path(path_before_last_save) - descendant_routes.each do |route| - attributes = {} + descendant_routes.each do |route| + attributes = {} - if saved_change_to_path? && route.path.present? - attributes[:path] = route.path.sub(path_before_last_save, path) - end + if saved_change_to_path? && route.path.present? + attributes[:path] = route.path.sub(path_before_last_save, path) + end - if saved_change_to_name? && name_before_last_save.present? && route.name.present? - attributes[:name] = route.name.sub(name_before_last_save, name) - end + if saved_change_to_name? && name_before_last_save.present? && route.name.present? + attributes[:name] = route.name.sub(name_before_last_save, name) + end - next if attributes.empty? + next if attributes.empty? - old_path = route.path + old_path = route.path - # Callbacks must be run manually - route.update_columns(attributes.merge(updated_at: Time.current)) + # Callbacks must be run manually + route.update_columns(attributes.merge(updated_at: Time.current)) + + # We are not calling route.delete_conflicting_redirects here, in hopes + # of avoiding deadlocks. The parent (self, in this method) already + # called it, which deletes conflicts for all descendants. + route.create_redirect(old_path) if attributes[:path] + end + else + changes = { + path: { saved: saved_change_to_path?, old_value: path_before_last_save }, + name: { saved: saved_change_to_name?, old_value: name_before_last_save } + } - # We are not calling route.delete_conflicting_redirects here, in hopes - # of avoiding deadlocks. The parent (self, in this method) already - # called it, which deletes conflicts for all descendants. - route.create_redirect(old_path) if attributes[:path] + Routes::RenameDescendantsService.new(self).execute(changes) # rubocop: disable CodeReuse/ServiceClass -- Need a service class to encapsulate all the logic. end end diff --git a/app/models/service_desk/custom_email_credential.rb b/app/models/service_desk/custom_email_credential.rb index 7ae44ac6aa1..6955f178bea 100644 --- a/app/models/service_desk/custom_email_credential.rb +++ b/app/models/service_desk/custom_email_credential.rb @@ -61,12 +61,13 @@ module ServiceDesk def validate_smtp_address # Addressable::URI always needs a scheme otherwise it interprets the host as the path - Gitlab::UrlBlocker.validate!("smtp://#{smtp_address}", + Gitlab::HTTP_V2::UrlBlocker.validate!("smtp://#{smtp_address}", schemes: %w[smtp], ascii_only: true, enforce_sanitization: true, allow_localhost: false, - allow_local_network: !::Gitlab.com? # rubocop:disable Gitlab/AvoidGitlabInstanceChecks -- self-managed may also use local network + allow_local_network: !::Gitlab.com?, # rubocop:disable Gitlab/AvoidGitlabInstanceChecks -- self-managed may also use local network + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) rescue Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError => e errors.add(:smtp_address, e) diff --git a/app/models/snippets/repository_storage_move.rb b/app/models/snippets/repository_storage_move.rb index 9db25ef4fc5..794caefb77d 100644 --- a/app/models/snippets/repository_storage_move.rb +++ b/app/models/snippets/repository_storage_move.rb @@ -16,11 +16,7 @@ module Snippets override :schedule_repository_storage_update_worker def schedule_repository_storage_update_worker - Snippets::UpdateRepositoryStorageWorker.perform_async( - snippet_id, - destination_storage_name, - id - ) + Snippets::UpdateRepositoryStorageWorker.perform_async(id) end private diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index 672a6d64127..f0855fc9f1c 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -137,12 +137,13 @@ class SshHostKey end def normalize_url(url) - url, real_hostname = Gitlab::UrlBlocker.validate!( + url, real_hostname = Gitlab::HTTP_V2::UrlBlocker.validate!( url, schemes: %w[ssh], allow_localhost: allow_local_requests?, allow_local_network: allow_local_requests?, - dns_rebind_protection: Gitlab::CurrentSettings.dns_rebinding_protection_enabled? + dns_rebind_protection: Gitlab::CurrentSettings.dns_rebinding_protection_enabled?, + deny_all_requests_except_allowed: Gitlab::CurrentSettings.deny_all_requests_except_allowed? ) # When DNS rebinding protection is required, the hostname is replaced by the diff --git a/app/models/time_tracking/timelog_category.rb b/app/models/time_tracking/timelog_category.rb index 67565039acd..295304f6e99 100644 --- a/app/models/time_tracking/timelog_category.rb +++ b/app/models/time_tracking/timelog_category.rb @@ -9,6 +9,8 @@ module TimeTracking belongs_to :namespace, foreign_key: 'namespace_id' + has_many :timelogs + strip_attributes! :name validates :namespace, presence: true diff --git a/app/models/timelog.rb b/app/models/timelog.rb index 0ae7790eef9..ffb88b7ebea 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -20,6 +20,7 @@ class Timelog < ApplicationRecord belongs_to :project belongs_to :user belongs_to :note + belongs_to :timelog_category, optional: true, class_name: 'TimeTracking::TimelogCategory' scope :in_group, -> (group) do joins(:project).where(projects: { namespace: group.self_and_descendants }) diff --git a/app/models/tree.rb b/app/models/tree.rb index 030e7d9e85f..d62e5c1b368 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -18,8 +18,15 @@ class Tree ref = ExtractsRef::RefExtractor.qualify_ref(@sha, ref_type) - @entries, @cursor = Gitlab::Git::Tree.where(git_repo, ref, @path, recursive, skip_flat_paths, rescue_not_found, - pagination_params) + @entries, @cursor = Gitlab::Git::Tree.tree_entries( + repository: git_repo, + sha: ref, + path: @path, + recursive: recursive, + skip_flat_paths: skip_flat_paths, + rescue_not_found: rescue_not_found, + pagination_params: pagination_params + ) @entries.each do |entry| entry.ref_type = self.ref_type diff --git a/app/models/user.rb b/app/models/user.rb index c36898aaf70..c9873975cc9 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -151,7 +151,7 @@ class User < MainClusterwide::ApplicationRecord # Namespace for personal projects has_one :namespace, -> { where(type: Namespaces::UserNamespace.sti_name) }, - required: true, + required: false, dependent: :destroy, # rubocop:disable Cop/ActiveRecordDependent foreign_key: :owner_id, inverse_of: :owner, @@ -270,7 +270,8 @@ class User < MainClusterwide::ApplicationRecord belongs_to :accepted_term, class_name: 'ApplicationSetting::Term' has_many :organization_users, class_name: 'Organizations::OrganizationUser', inverse_of: :user - has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users + has_many :organizations, through: :organization_users, class_name: 'Organizations::Organization', inverse_of: :users, + disable_joins: true has_one :status, class_name: 'UserStatus' has_one :user_preference @@ -284,8 +285,6 @@ class User < MainClusterwide::ApplicationRecord has_many :reviews, foreign_key: :author_id, inverse_of: :author - has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail' - has_many :timelogs has_many :resource_label_events, dependent: :nullify # rubocop:disable Cop/ActiveRecordDependent @@ -304,6 +303,10 @@ class User < MainClusterwide::ApplicationRecord # Validations # # Note: devise :validatable above adds validations for :email and :password + validates :username, + presence: true, + exclusion: { in: Gitlab::PathRegex::TOP_LEVEL_ROUTES, message: N_('%{value} is a reserved name') } + validates :username, uniqueness: true, unless: :namespace validates :name, presence: true, length: { maximum: 255 } validates :first_name, length: { maximum: 127 } validates :last_name, length: { maximum: 127 } @@ -314,10 +317,9 @@ class User < MainClusterwide::ApplicationRecord validates :projects_limit, presence: true, numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: Gitlab::Database::MAX_INT_VALUE } - validates :username, presence: true validate :check_password_weakness, if: :encrypted_password_changed? - validates :namespace, presence: true + validates :namespace, presence: true, unless: :optional_namespace? validate :namespace_move_dir_allowed, if: :username_changed?, unless: :new_record? validate :unique_email, if: :email_changed? @@ -591,6 +593,8 @@ class User < MainClusterwide::ApplicationRecord scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) } scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) } scope :order_oldest_last_activity, -> { reorder(arel_table[:last_activity_on].asc.nulls_first, arel_table[:id].desc) } + scope :ordered_by_id_desc, -> { reorder(arel_table[:id].desc) } + scope :dormant, -> { with_state(:active).human_or_service_user.where('last_activity_on <= ?', Gitlab::CurrentSettings.deactivate_dormant_users_period.day.ago.to_date) } scope :with_no_activity, -> { with_state(:active).human_or_service_user.where(last_activity_on: nil).where('created_at <= ?', MINIMUM_DAYS_CREATED.day.ago.to_date) } scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } @@ -847,6 +851,25 @@ class User < MainClusterwide::ApplicationRecord scope.reorder(order) end + # This should be kept in sync with the frontend filtering in + # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L68 and + # https://gitlab.com/gitlab-org/gitlab/-/blob/5d34e3488faa3982d30d7207773991c1e0b6368a/app/assets/javascripts/gfm_auto_complete.js#L1053 + def gfm_autocomplete_search(query) + where( + "REPLACE(users.name, ' ', '') ILIKE :pattern OR users.username ILIKE :pattern", + pattern: "%#{sanitize_sql_like(query)}%" + ).order( + Arel.sql(sanitize_sql( + [ + "CASE WHEN starts_with(REPLACE(users.name, ' ', ''), :pattern) OR starts_with(users.username, :pattern) THEN 1 ELSE 2 END", + { pattern: query } + ] + )), + :username, + :id + ) + end + # Limits the result set to users _not_ in the given query/list of IDs. # # users - The list of users to ignore. This can be an @@ -1302,7 +1325,13 @@ class User < MainClusterwide::ApplicationRecord end def can_create_project? - projects_limit_left > 0 + projects_limit_left > 0 && allow_user_to_create_group_and_project? + end + + def allow_user_to_create_group_and_project? + return true if Gitlab::CurrentSettings.allow_project_creation_for_guest_and_below + + highest_role > Gitlab::Access::GUEST end def can_create_group? @@ -1596,12 +1625,6 @@ class User < MainClusterwide::ApplicationRecord if namespace namespace.path = username if username_changed? namespace.name = name if name_changed? - elsif Feature.disabled?(:create_personal_ns_outside_model, Feature.current_request) - # TODO: we should no longer need the `type` parameter once we can make the - # the `has_one :namespace` association use the correct class. - # issue https://gitlab.com/gitlab-org/gitlab/-/issues/341070 - namespace = build_namespace(path: username, name: name, type: ::Namespaces::UserNamespace.sti_name) - namespace.build_namespace_settings end end @@ -1623,6 +1646,9 @@ class User < MainClusterwide::ApplicationRecord self.errors.add(:base, :username_exists_as_a_different_namespace) else namespace_path_errors.each do |msg| + # Already handled by username validation. + next if msg.ends_with?('is a reserved name') + self.errors.add(:username, msg) end end @@ -2300,6 +2326,10 @@ class User < MainClusterwide::ApplicationRecord private + def optional_namespace? + Feature.enabled?(:optional_personal_namespace, self) + end + def block_or_ban user_scores = Abuse::UserTrustScore.new(self) if user_scores.spammer? && account_age_in_days < 7 diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index c32414be312..8d330e4eb6e 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -79,7 +79,9 @@ module Users vulnerability_report_grouping: 77, # EE-only new_nav_for_everyone_callout: 78, code_suggestions_ga_non_owner_alert: 79, # EE-only - duo_chat_callout: 80 # EE-only + duo_chat_callout: 80, # EE-only + code_suggestions_ga_owner_alert: 81, # EE-only + product_analytics_dashboard_feedback: 82 # EE-only } validates :feature_name, diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index 6d0a22c8b0a..33e7ba72d5a 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -8,7 +8,7 @@ module Users self.table_name = 'user_credit_card_validations' - ignore_columns %i[last_digits network holder_name expiration_date], remove_with: '16.8', remove_after: '2023-12-22' + ignore_columns %i[last_digits network holder_name expiration_date], remove_with: '16.9', remove_after: '2024-01-22' attr_accessor :last_digits, :network, :holder_name, :expiration_date diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb deleted file mode 100644 index 5362a726ff5..00000000000 --- a/app/models/users/in_product_marketing_email.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -module Users - class InProductMarketingEmail < ApplicationRecord - include BulkInsertSafe - - belongs_to :user - - validates :user, presence: true - validates :track, presence: true - validates :series, presence: true - - validates :user_id, uniqueness: { - scope: [:track, :series], - message: 'track series email has already been sent' - }, if: -> { track.present? } - - enum track: { - create: 0, - verify: 1, - trial: 2, - team: 3, - experience: 4, - team_short: 5, - trial_short: 6, - admin_verify: 7, - invite_team: 8 - }, _suffix: true - - # Tracks we don't send emails for (e.g. unsuccessful experiment). These - # are kept since we already have DB records that use the enum value. - INACTIVE_TRACK_NAMES = %w[invite_team experience].freeze - ACTIVE_TRACKS = tracks.except(*INACTIVE_TRACK_NAMES) - - scope :for_user_with_track_and_series, ->(user, track, series) do - where(user: user, track: track, series: series) - end - - scope :without_track_and_series, ->(track, series) do - join_condition = for_user.and(for_track_and_series(track, series)) - users_without_records(join_condition) - end - - def self.users_table - User.arel_table - end - - def self.distinct_users_sql - name = users_table.name - Arel.sql("DISTINCT ON(#{name}.id) #{name}.*") - end - - def self.users_without_records(condition) - arel_join = users_table.join(arel_table, Arel::Nodes::OuterJoin).on(condition) - joins(arel_join.join_sources) - .where(in_product_marketing_emails: { id: nil }) - .select(distinct_users_sql) - end - - def self.for_user - arel_table[:user_id].eq(users_table[:id]) - end - - def self.for_track_and_series(track, series) - arel_table[:track].eq(ACTIVE_TRACKS[track]) - .and(arel_table[:series]).eq(series) - end - - def self.save_cta_click(user, track, series) - email = for_user_with_track_and_series(user, track, series).take - - email.update(cta_clicked_at: Time.zone.now) if email && email.cta_clicked_at.blank? - end - end -end diff --git a/app/models/users/phone_number_validation.rb b/app/models/users/phone_number_validation.rb index 072b75a1c90..ffb8d3a95a2 100644 --- a/app/models/users/phone_number_validation.rb +++ b/app/models/users/phone_number_validation.rb @@ -4,12 +4,17 @@ module Users class PhoneNumberValidation < ApplicationRecord include IgnorableColumns + # SMS send attempts subsequent to the first one will have wait times of 1 + # min, 3 min, 5 min after each one respectively. Wait time between the fifth + # attempt and so on will be 10 minutes. + SMS_SEND_WAIT_TIMES = [1.minute, 3.minutes, 5.minutes, 10.minutes].freeze + self.primary_key = :user_id self.table_name = 'user_phone_number_validations' ignore_column :verification_attempts, remove_with: '16.7', remove_after: '2023-11-17' - belongs_to :user, foreign_key: :user_id + belongs_to :user belongs_to :banned_user, class_name: '::Users::BannedUser', foreign_key: :user_id validates :country, presence: true, length: { maximum: 3 } @@ -26,13 +31,24 @@ module Users presence: true, format: { with: /\A\d+\Z/, - message: -> (object, data) { _('can contain only digits') } + message: ->(_object, _data) { _('can contain only digits') } }, length: { maximum: 12 } validates :telesign_reference_xid, length: { maximum: 255 } - scope :for_user, -> (user_id) { where(user_id: user_id) } + scope :for_user, ->(user_id) { where(user_id: user_id) } + + scope :similar_to, ->(phone_number_validation) do + where( + international_dial_code: phone_number_validation.international_dial_code, + phone_number: phone_number_validation.phone_number + ) + end + + def similar_records + self.class.similar_to(self).includes(:user) + end def self.related_to_banned_user?(international_dial_code, phone_number) joins(:banned_user) @@ -51,5 +67,18 @@ module Users def validated? validated_at.present? end + + def sms_send_allowed_after + return unless Feature.enabled?(:sms_send_wait_time, user) + + # first send is allowed anytime + return if sms_send_count < 1 + return unless sms_sent_at + + max_wait_time = SMS_SEND_WAIT_TIMES.last + wait_time = SMS_SEND_WAIT_TIMES.fetch(sms_send_count - 1, max_wait_time) + + sms_sent_at + wait_time + end end end diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 77f684e3578..f1d007e8167 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -5,7 +5,7 @@ class WorkItem < Issue COMMON_QUICK_ACTIONS_COMMANDS = [ :title, :reopen, :close, :cc, :tableflip, :shrug, :type, :promote_to, :checkin_reminder, - :subscribe, :unsubscribe, :confidential, :award + :subscribe, :unsubscribe, :confidential, :award, :react ].freeze self.table_name = 'issues' diff --git a/app/models/work_items/hierarchy_restriction.rb b/app/models/work_items/hierarchy_restriction.rb index a253447a8db..f74f2f037b1 100644 --- a/app/models/work_items/hierarchy_restriction.rb +++ b/app/models/work_items/hierarchy_restriction.rb @@ -7,8 +7,17 @@ module WorkItems belongs_to :parent_type, class_name: 'WorkItems::Type' belongs_to :child_type, class_name: 'WorkItems::Type' + after_destroy :clear_parent_type_cache! + after_save :clear_parent_type_cache! + validates :parent_type, presence: true validates :child_type, presence: true validates :child_type, uniqueness: { scope: :parent_type_id } + + private + + def clear_parent_type_cache! + parent_type.clear_reactive_cache! + end end end diff --git a/app/models/work_items/widget_definition.rb b/app/models/work_items/widget_definition.rb index f25c951406f..2637a7c8185 100644 --- a/app/models/work_items/widget_definition.rb +++ b/app/models/work_items/widget_definition.rb @@ -32,7 +32,9 @@ module WorkItems notifications: 14, current_user_todos: 15, award_emoji: 16, - linked_items: 17 + linked_items: 17, + color: 18, # EE-only + rolledup_dates: 19 # EE-only } def self.available_widgets diff --git a/app/models/work_items/widgets/notes.rb b/app/models/work_items/widgets/notes.rb index bde94ea8f43..67ee19f4947 100644 --- a/app/models/work_items/widgets/notes.rb +++ b/app/models/work_items/widgets/notes.rb @@ -4,8 +4,18 @@ module WorkItems module Widgets class Notes < Base delegate :notes, to: :work_item + delegate :discussion_locked, to: :work_item + delegate_missing_to :work_item + def self.quick_action_commands + [:lock, :unlock] + end + + def self.quick_action_params + [:discussion_locked] + end + def declarative_policy_delegate work_item end |