diff options
Diffstat (limited to 'app/models')
160 files changed, 1774 insertions, 788 deletions
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index d0e4163dcdb..f40d0cd2fa4 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -9,24 +9,12 @@ module AlertManagement include ShaAttribute include Sortable include Noteable + include Mentionable include Gitlab::SQL::Pattern include Presentable include Gitlab::Utils::StrongMemoize include Referable - - STATUSES = { - triggered: 0, - acknowledged: 1, - resolved: 2, - ignored: 3 - }.freeze - - STATUS_DESCRIPTIONS = { - triggered: 'Investigation has not started', - acknowledged: 'Someone is actively investigating the problem', - resolved: 'No further work is required', - ignored: 'No action will be taken on the alert' - }.freeze + include ::IncidentManagement::Escalatable belongs_to :project belongs_to :issue, optional: true @@ -44,6 +32,9 @@ module AlertManagement sha_attribute :fingerprint + # Allow :ended_at to be managed by Escalatable + alias_attribute :resolved_at, :ended_at + TITLE_MAX_LENGTH = 200 DESCRIPTION_MAX_LENGTH = 1_000 SERVICE_MAX_LENGTH = 100 @@ -57,7 +48,6 @@ module AlertManagement validates :project, presence: true validates :events, presence: true validates :severity, presence: true - validates :status, presence: true validates :started_at, presence: true validates :fingerprint, allow_blank: true, uniqueness: { scope: :project, @@ -80,52 +70,10 @@ module AlertManagement threat_monitoring: 1 } - state_machine :status, initial: :triggered do - state :triggered, value: STATUSES[:triggered] - - state :acknowledged, value: STATUSES[:acknowledged] - - state :resolved, value: STATUSES[:resolved] do - validates :ended_at, presence: true - end - - state :ignored, value: STATUSES[:ignored] - - state :triggered, :acknowledged, :ignored do - validates :ended_at, absence: true - end - - event :trigger do - transition any => :triggered - end - - event :acknowledge do - transition any => :acknowledged - end - - event :resolve do - transition any => :resolved - end - - event :ignore do - transition any => :ignored - end - - before_transition to: [:triggered, :acknowledged, :ignored] do |alert, _transition| - alert.ended_at = nil - end - - before_transition to: :resolved do |alert, transition| - ended_at = transition.args.first - alert.ended_at = ended_at || Time.current - end - end - delegate :iid, to: :issue, prefix: true, allow_nil: true delegate :details_url, to: :present scope :for_iid, -> (iid) { where(iid: iid) } - scope :for_status, -> (status) { with_status(status) } scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) } scope :for_environment, -> (environment) { where(environment: environment) } scope :for_assignee_username, -> (assignee_username) { joins(:assignees).merge(User.by_username(assignee_username)) } @@ -146,36 +94,14 @@ module AlertManagement scope :order_severity, -> (sort_order) { order(severity: sort_order == :asc ? :desc : :asc) } scope :order_severity_with_open_prometheus_alert, -> { open.with_prometheus_alert.order(severity: :asc, started_at: :desc) } - # Ascending sort order sorts statuses: Ignored > Resolved > Acknowledged > Triggered - # Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored - # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior - scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) } - scope :counts_by_project_id, -> { group(:project_id).count } alias_method :state, :status_name - def self.state_machine_statuses - @state_machine_statuses ||= state_machines[:status].states.to_h { |s| [s.name, s.value] } - end - private_class_method :state_machine_statuses - - def self.status_value(name) - state_machine_statuses[name] - end - - def self.status_name(raw_status) - state_machine_statuses.key(raw_status) - end - def self.counts_by_status group(:status).count.transform_keys { |k| status_name(k) } end - def self.status_names - @status_names ||= state_machine_statuses.keys - end - def self.sort_by_attribute(method) case method.to_s when 'started_at_asc' then order_start_time(:asc) @@ -229,15 +155,6 @@ module AlertManagement self.class.open_status?(status_name) end - def status_event_for(status) - self.class.state_machines[:status].events.transitions_for(self, to: status.to_s.to_sym).first&.event - end - - def change_status_to(new_status) - event = status_event_for(new_status) - event && fire_status_event(event) - end - def prometheus? monitoring_tool == Gitlab::AlertManagement::Payload::MONITORING_TOOLS[:prometheus] end diff --git a/app/models/analytics/cycle_analytics/stage_event_hash.rb b/app/models/analytics/cycle_analytics/stage_event_hash.rb new file mode 100644 index 00000000000..0e1e9b3ef67 --- /dev/null +++ b/app/models/analytics/cycle_analytics/stage_event_hash.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class StageEventHash < ApplicationRecord + has_many :cycle_analytics_project_stages, class_name: 'Analytics::CycleAnalytics::ProjectStage', inverse_of: :stage_event_hash + + validates :hash_sha256, presence: true + + # Creates or queries the id of the corresponding stage event hash code + def self.record_id_by_hash_sha256(hash) + casted_hash_code = Arel::Nodes.build_quoted(hash, Analytics::CycleAnalytics::StageEventHash.arel_table[:hash_sha256]).to_sql + + # Atomic, safe insert without retrying + query = <<~SQL + WITH insert_cte AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} ( + INSERT INTO #{quoted_table_name} (hash_sha256) VALUES (#{casted_hash_code}) ON CONFLICT DO NOTHING RETURNING ID + ) + SELECT ids.id FROM ( + (SELECT id FROM #{quoted_table_name} WHERE hash_sha256=#{casted_hash_code} LIMIT 1) + UNION ALL + (SELECT id FROM insert_cte LIMIT 1) + ) AS ids LIMIT 1 + SQL + + connection.execute(query).first['id'] + end + + def self.cleanup_if_unused(id) + unused_hashes_for(id) + .where(id: id) + .delete_all + end + + def self.unused_hashes_for(id) + exists_query = Analytics::CycleAnalytics::ProjectStage.where(stage_event_hash_id: id).select('1').limit(1) + where.not('EXISTS (?)', exists_query) + end + end + end +end +Analytics::CycleAnalytics::StageEventHash.prepend_mod_with('Analytics::CycleAnalytics::StageEventHash') diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 527b67712ee..d9375b55e89 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -63,11 +63,27 @@ class ApplicationRecord < ActiveRecord::Base end def self.safe_find_or_create_by(*args, &block) + return optimized_safe_find_or_create_by(*args, &block) if Feature.enabled?(:optimize_safe_find_or_create_by, default_enabled: :yaml) + safe_ensure_unique(retries: 1) do find_or_create_by(*args, &block) end end + def self.optimized_safe_find_or_create_by(*args, &block) + record = find_by(*args) + return record if record.present? + + # We need to use `all.create` to make this implementation follow `find_or_create_by` which delegates this in + # https://github.com/rails/rails/blob/v6.1.3.2/activerecord/lib/active_record/querying.rb#L22 + # + # When calling this method on an association, just calling `self.create` would call `ActiveRecord::Persistence.create` + # and that skips some code that adds the newly created record to the association. + transaction(requires_new: true) { all.create(*args, &block) } + rescue ActiveRecord::RecordNotUnique + find_by(*args) + end + def create_or_load_association(association_name) association(association_name).create unless association(association_name).loaded? rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation @@ -87,6 +103,23 @@ class ApplicationRecord < ActiveRecord::Base enum(enum_mod.key => values) end + def self.transaction(**options, &block) + if options[:requires_new] && track_subtransactions? + ::Gitlab::Database::Metrics.subtransactions_increment(self.name) + end + + super(**options, &block) + end + + def self.track_subtransactions? + ::Feature.enabled?(:active_record_subtransactions_counter, type: :ops, default_enabled: :yaml) && + connection.transaction_open? + end + + def self.cached_column_list + self.column_names.map { |column_name| self.arel_table[column_name] } + end + def readable_by?(user) Ability.allowed?(user, "read_#{to_ability_name}".to_sym, self) end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index a7140cc0718..c4b6bcb9395 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -5,6 +5,11 @@ class ApplicationSetting < ApplicationRecord include CacheMarkdownField include TokenAuthenticatable include ChronicDurationAttribute + include IgnorableColumns + + ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22' + ignore_column :seat_link_enabled, remove_with: '14.4', remove_after: '2021-09-22' + ignore_column :cloud_license_enabled, remove_with: '14.4', remove_after: '2021-09-22' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -30,7 +35,7 @@ class ApplicationSetting < ApplicationRecord def self.kroki_formats_attributes { blockdiag: { - label: 'BlockDiag (includes BlockDiag, SeqDiag, ActDiag, NwDiag, PacketDiag and RackDiag)' + label: 'BlockDiag (includes BlockDiag, SeqDiag, ActDiag, NwDiag, PacketDiag, and RackDiag)' }, bpmn: { label: 'BPMN' @@ -451,6 +456,9 @@ class ApplicationSetting < ApplicationRecord validates :ci_jwt_signing_key, rsa_key: true, allow_nil: true + validates :customers_dot_jwt_signing_key, + rsa_key: true, allow_nil: true + validates :rate_limiting_response_text, length: { maximum: 255, message: _('is too long (maximum is %{count} characters)') }, allow_blank: true @@ -554,6 +562,7 @@ class ApplicationSetting < ApplicationRecord attr_encrypted :slack_app_secret, encryption_options_base_32_aes_256_gcm attr_encrypted :slack_app_verification_token, encryption_options_base_32_aes_256_gcm attr_encrypted :ci_jwt_signing_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :customers_dot_jwt_signing_key, encryption_options_base_32_aes_256_gcm attr_encrypted :secret_detection_token_revocation_token, encryption_options_base_32_aes_256_gcm attr_encrypted :cloud_license_auth_token, encryption_options_base_32_aes_256_gcm attr_encrypted :external_pipeline_validation_service_token, encryption_options_base_32_aes_256_gcm @@ -564,6 +573,7 @@ class ApplicationSetting < ApplicationRecord before_validation :ensure_uuid! before_validation :coerce_repository_storages_weighted, if: :repository_storages_weighted_changed? + before_validation :sanitize_default_branch_name before_save :ensure_runners_registration_token before_save :ensure_health_check_access_token @@ -593,6 +603,14 @@ class ApplicationSetting < ApplicationRecord !!(sourcegraph_url =~ %r{\Ahttps://(www\.)?sourcegraph\.com}) end + def sanitize_default_branch_name + self.default_branch_name = if default_branch_name.blank? + nil + else + Sanitize.fragment(self.default_branch_name) + end + end + def instance_review_permitted? users_count = Rails.cache.fetch('limited_users_count', expires_in: 1.day) do ::User.limit(INSTANCE_REVIEW_MIN_USERS + 1).count(:all) @@ -627,7 +645,7 @@ class ApplicationSetting < ApplicationRecord # prevent this from happening, we do a sanity check that the # primary key constraint is present before inserting a new entry. def self.check_schema! - return if ActiveRecord::Base.connection.primary_key(self.table_name).present? + return if connection.primary_key(self.table_name).present? raise "The `#{self.table_name}` table is missing a primary key constraint in the database schema" end diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index d7a594af84c..060c831a11b 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -219,11 +219,11 @@ module ApplicationSettingImplementation end def home_page_url_column_exists? - ::Gitlab::Database.cached_column_exists?(:application_settings, :home_page_url) + ::Gitlab::Database.main.cached_column_exists?(:application_settings, :home_page_url) end def help_page_support_url_column_exists? - ::Gitlab::Database.cached_column_exists?(:application_settings, :help_page_support_url) + ::Gitlab::Database.main.cached_column_exists?(:application_settings, :help_page_support_url) end def disabled_oauth_sign_in_sources=(sources) diff --git a/app/models/ci/application_record.rb b/app/models/ci/application_record.rb new file mode 100644 index 00000000000..9d4a8f0648e --- /dev/null +++ b/app/models/ci/application_record.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Ci + class ApplicationRecord < ::ApplicationRecord + self.abstract_class = true + + def self.table_name_prefix + 'ci_' + end + + def self.model_name + @model_name ||= ActiveModel::Name.new(self, nil, self.name.demodulize) + end + end +end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 4328f3f7a4b..1ca291a659b 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -39,7 +39,6 @@ module Ci has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id has_one :runtime_metadata, class_name: 'Ci::RunningBuild', foreign_key: :build_id - has_many :trace_sections, class_name: 'Ci::BuildTraceSection' has_many :trace_chunks, class_name: 'Ci::BuildTraceChunk', foreign_key: :build_id, inverse_of: :build has_many :report_results, class_name: 'Ci::BuildReportResult', inverse_of: :build @@ -54,6 +53,7 @@ module Ci end has_one :runner_session, class_name: 'Ci::BuildRunnerSession', validate: true, inverse_of: :build + has_one :trace_metadata, class_name: 'Ci::BuildTraceMetadata', inverse_of: :build accepts_nested_attributes_for :runner_session, update_only: true accepts_nested_attributes_for :job_variables @@ -103,7 +103,6 @@ module Ci end scope :unstarted, -> { where(runner_id: nil) } - scope :ignore_failures, -> { where(allow_failure: false) } scope :with_downloadable_artifacts, -> do where('EXISTS (?)', Ci::JobArtifact.select(1) @@ -120,10 +119,6 @@ module Ci where('EXISTS (?)', ::Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').merge(query)) end - scope :with_archived_trace, -> do - with_existing_job_artifacts(Ci::JobArtifact.trace) - end - scope :without_archived_trace, -> do where('NOT EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').trace) end @@ -134,7 +129,6 @@ module Ci end scope :eager_load_job_artifacts, -> { includes(:job_artifacts) } - scope :eager_load_job_artifacts_archive, -> { includes(:job_artifacts_archive) } scope :eager_load_tags, -> { includes(:tags) } scope :eager_load_everything, -> do @@ -158,7 +152,7 @@ module Ci scope :with_project_and_metadata, -> do if Feature.enabled?(:non_public_artifacts, type: :development) - joins(:metadata).includes(:project, :metadata) + joins(:metadata).includes(:metadata).preload(:project) end end @@ -466,13 +460,9 @@ module Ci end def retryable? - if Feature.enabled?(:prevent_retry_of_retried_jobs, project, default_enabled: :yaml) - return false if retried? || archived? + return false if retried? || archived? - success? || failed? || canceled? - else - !archived? && (success? || failed? || canceled?) - end + success? || failed? || canceled? end def retries_count @@ -559,6 +549,7 @@ module Ci .concat(persisted_variables) .concat(dependency_proxy_variables) .concat(job_jwt_variables) + .concat(kubernetes_variables) .concat(scoped_variables) .concat(job_variables) .concat(persisted_environment_variables) @@ -648,12 +639,6 @@ module Ci update(coverage: coverage) if coverage.present? end - # rubocop: disable CodeReuse/ServiceClass - def parse_trace_sections! - ExtractSectionsFromBuildTraceService.new(project, user).execute(self) - end - # rubocop: enable CodeReuse/ServiceClass - def trace Gitlab::Ci::Trace.new(self) end @@ -907,7 +892,7 @@ module Ci end def valid_dependency? - return false if artifacts_expired? + return false if artifacts_expired? && !pipeline.artifacts_locked? return false if erased? true @@ -1183,6 +1168,10 @@ module Ci end end + def kubernetes_variables + [] # Overridden in EE + end + def conditionally_allow_failure!(exit_code) return unless exit_code diff --git a/app/models/ci/build_metadata.rb b/app/models/ci/build_metadata.rb index 50775f578f0..90237a4be52 100644 --- a/app/models/ci/build_metadata.rb +++ b/app/models/ci/build_metadata.rb @@ -3,10 +3,9 @@ module Ci # The purpose of this class is to store Build related data that can be disposed. # Data that should be persisted forever, should be stored with Ci::Build model. - class BuildMetadata < ApplicationRecord + class BuildMetadata < Ci::ApplicationRecord BuildTimeout = Struct.new(:value, :source) - extend Gitlab::Ci::Model include Presentable include ChronicDurationAttribute include Gitlab::Utils::StrongMemoize diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 4a59c25cbb0..003659570b3 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class BuildNeed < ApplicationRecord - extend Gitlab::Ci::Model - + class BuildNeed < Ci::ApplicationRecord include BulkInsertSafe include IgnorableColumns diff --git a/app/models/ci/build_pending_state.rb b/app/models/ci/build_pending_state.rb index 299c67f441d..53cf0697e2e 100644 --- a/app/models/ci/build_pending_state.rb +++ b/app/models/ci/build_pending_state.rb @@ -1,8 +1,6 @@ # frozen_string_literal: true -class Ci::BuildPendingState < ApplicationRecord - extend Gitlab::Ci::Model - +class Ci::BuildPendingState < Ci::ApplicationRecord belongs_to :build, class_name: 'Ci::Build', foreign_key: :build_id enum state: Ci::Stage.statuses diff --git a/app/models/ci/build_report_result.rb b/app/models/ci/build_report_result.rb index eb6a0700006..2c08fc4c8bf 100644 --- a/app/models/ci/build_report_result.rb +++ b/app/models/ci/build_report_result.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class BuildReportResult < ApplicationRecord - extend Gitlab::Ci::Model - + class BuildReportResult < Ci::ApplicationRecord self.primary_key = :build_id belongs_to :build, class_name: "Ci::Build", inverse_of: :report_results diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index 2aa856dbc64..45de47116cd 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -3,8 +3,7 @@ module Ci # The purpose of this class is to store Build related runner session. # Data will be removed after transitioning from running to any state. - class BuildRunnerSession < ApplicationRecord - extend Gitlab::Ci::Model + class BuildRunnerSession < Ci::ApplicationRecord include IgnorableColumns ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 3fa9a484b0c..7a15d7ba940 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class BuildTraceChunk < ApplicationRecord - extend ::Gitlab::Ci::Model + class BuildTraceChunk < Ci::ApplicationRecord include ::Comparable include ::FastDestroyAll include ::Checksummable diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb new file mode 100644 index 00000000000..05bdb3d8b7b --- /dev/null +++ b/app/models/ci/build_trace_metadata.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + class BuildTraceMetadata < Ci::ApplicationRecord + self.table_name = 'ci_build_trace_metadata' + self.primary_key = :build_id + + belongs_to :build, class_name: 'Ci::Build' + belongs_to :trace_artifact, class_name: 'Ci::JobArtifact' + + validates :build, presence: true + end +end diff --git a/app/models/ci/build_trace_section.rb b/app/models/ci/build_trace_section.rb deleted file mode 100644 index 036f611a61c..00000000000 --- a/app/models/ci/build_trace_section.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Ci - class BuildTraceSection < ApplicationRecord - extend SuppressCompositePrimaryKeyWarning - extend Gitlab::Ci::Model - include IgnorableColumns - - belongs_to :build, class_name: 'Ci::Build' - belongs_to :project - belongs_to :section_name, class_name: 'Ci::BuildTraceSectionName' - - validates :section_name, :build, :project, presence: true, allow_blank: false - - ignore_column :build_id_convert_to_bigint, remove_with: '14.2', remove_after: '2021-08-22' - end -end diff --git a/app/models/ci/build_trace_section_name.rb b/app/models/ci/build_trace_section_name.rb deleted file mode 100644 index c065cfea14e..00000000000 --- a/app/models/ci/build_trace_section_name.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module Ci - class BuildTraceSectionName < ApplicationRecord - extend Gitlab::Ci::Model - - belongs_to :project - has_many :trace_sections, class_name: 'Ci::BuildTraceSection', foreign_key: :section_name_id - - validates :name, :project, presence: true, allow_blank: false - validates :name, uniqueness: { scope: :project_id } - end -end diff --git a/app/models/ci/base_model.rb b/app/models/ci/ci_database_record.rb index 8fb752ead1d..e2b832a28e7 100644 --- a/app/models/ci/base_model.rb +++ b/app/models/ci/ci_database_record.rb @@ -7,7 +7,7 @@ module Ci # This class is part of a migration to move all CI classes to a new separate database. # Initially we are only going to be moving the `Ci::InstanceVariable` model and it will be duplicated in the main and CI tables # Do not extend this class in any other models. - class BaseModel < ::ApplicationRecord + class CiDatabaseRecord < Ci::ApplicationRecord self.abstract_class = true if Gitlab::Database.has_config?(:ci) diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb index b46d32474c6..598d1456a48 100644 --- a/app/models/ci/daily_build_group_report_result.rb +++ b/app/models/ci/daily_build_group_report_result.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class DailyBuildGroupReportResult < ApplicationRecord - extend Gitlab::Ci::Model - + class DailyBuildGroupReportResult < Ci::ApplicationRecord PARAM_TYPES = %w[coverage].freeze belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb index b2a949c9bb5..aba7b73aba9 100644 --- a/app/models/ci/deleted_object.rb +++ b/app/models/ci/deleted_object.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class DeletedObject < ApplicationRecord - extend Gitlab::Ci::Model - + class DeletedObject < Ci::ApplicationRecord mount_uploader :file, DeletedObjectUploader scope :ready_for_destruction, ->(limit) do diff --git a/app/models/ci/freeze_period.rb b/app/models/ci/freeze_period.rb index d215372bb45..da0bbbacddd 100644 --- a/app/models/ci/freeze_period.rb +++ b/app/models/ci/freeze_period.rb @@ -1,15 +1,15 @@ # frozen_string_literal: true module Ci - class FreezePeriod < ApplicationRecord + class FreezePeriod < Ci::ApplicationRecord include StripAttribute - self.table_name = 'ci_freeze_periods' + include Ci::NamespacedModelName default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope belongs_to :project, inverse_of: :freeze_periods - strip_attributes :freeze_start, :freeze_end + strip_attributes! :freeze_start, :freeze_end validates :freeze_start, cron: true, presence: true validates :freeze_end, cron: true, presence: true diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb index 2928ce801ad..165bee5c54d 100644 --- a/app/models/ci/group_variable.rb +++ b/app/models/ci/group_variable.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class GroupVariable < ApplicationRecord - extend Gitlab::Ci::Model + class GroupVariable < Ci::ApplicationRecord include Ci::HasVariable include Presentable include Ci::Maskable diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb index 5aee4c924af..f4aa935b983 100644 --- a/app/models/ci/instance_variable.rb +++ b/app/models/ci/instance_variable.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class InstanceVariable < ::Ci::BaseModel - extend Gitlab::Ci::Model + class InstanceVariable < Ci::CiDatabaseRecord extend Gitlab::ProcessMemoryCache::Helper include Ci::NewHasVariable include Ci::Maskable diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 46c976d5616..1f0da4345f2 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module Ci - class JobArtifact < ApplicationRecord + class JobArtifact < Ci::ApplicationRecord include AfterCommitQueue include ObjectStorage::BackgroundMove include UpdateProjectStatistics @@ -10,7 +10,6 @@ module Ci include Artifactable include FileStoreMounter include EachBatch - extend Gitlab::Ci::Model TEST_REPORT_FILE_TYPES = %w[junit].freeze COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze diff --git a/app/models/ci/job_variable.rb b/app/models/ci/job_variable.rb index 7eea8a37150..44bd3fe8901 100644 --- a/app/models/ci/job_variable.rb +++ b/app/models/ci/job_variable.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class JobVariable < ApplicationRecord - extend Gitlab::Ci::Model + class JobVariable < Ci::ApplicationRecord include Ci::NewHasVariable include BulkInsertSafe diff --git a/app/models/ci/pending_build.rb b/app/models/ci/pending_build.rb index 0663052f51d..7cf3a387516 100644 --- a/app/models/ci/pending_build.rb +++ b/app/models/ci/pending_build.rb @@ -1,14 +1,16 @@ # frozen_string_literal: true module Ci - class PendingBuild < ApplicationRecord - extend Gitlab::Ci::Model - + class PendingBuild < Ci::ApplicationRecord belongs_to :project belongs_to :build, class_name: 'Ci::Build' + belongs_to :namespace, inverse_of: :pending_builds, class_name: 'Namespace' + + validates :namespace, presence: true scope :ref_protected, -> { where(protected: true) } scope :queued_before, ->(time) { where(arel_table[:created_at].lt(time)) } + scope :with_instance_runners, -> { where(instance_runners_enabled: true) } def self.upsert_from_build!(build) entry = self.new(args_from_build(build)) @@ -22,7 +24,8 @@ module Ci args = { build: build, project: build.project, - protected: build.protected? + protected: build.protected?, + namespace: build.project.namespace } if Feature.enabled?(:ci_pending_builds_maintain_shared_runners_data, type: :development, default_enabled: :yaml) @@ -56,3 +59,5 @@ module Ci private_class_method :builds_access_level? end end + +Ci::PendingBuild.prepend_mod_with('Ci::PendingBuild') diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 5d079f57267..70e67953e31 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Pipeline < ApplicationRecord - extend Gitlab::Ci::Model + class Pipeline < Ci::ApplicationRecord include Ci::HasStatus include Importable include AfterCommitQueue @@ -319,6 +318,7 @@ module Ci scope :created_before_id, -> (id) { where('ci_pipelines.id < ?', id) } scope :before_pipeline, -> (pipeline) { created_before_id(pipeline.id).outside_pipeline_family(pipeline) } scope :eager_load_project, -> { eager_load(project: [:route, { namespace: :route }]) } + scope :with_pipeline_source, -> (source) { where(source: source)} scope :outside_pipeline_family, ->(pipeline) do where.not(id: pipeline.same_family_pipeline_ids) @@ -378,11 +378,15 @@ module Ci end def self.latest_successful_for_refs(refs) - relation = newest_first(ref: refs).success + return Ci::Pipeline.none if refs.empty? - relation.each_with_object({}) do |pipeline, hash| - hash[pipeline.ref] ||= pipeline - end + refs_values = refs.map { |ref| "(#{connection.quote(ref)})" }.join(",") + join_query = success.where("refs_values.ref = ci_pipelines.ref").order(id: :desc).limit(1) + + Ci::Pipeline + .from("(VALUES #{refs_values}) refs_values (ref)") + .joins("INNER JOIN LATERAL (#{join_query.to_sql}) #{Ci::Pipeline.table_name} ON TRUE") + .index_by(&:ref) end def self.latest_running_for_ref(ref) @@ -393,6 +397,10 @@ module Ci newest_first(ref: ref).failed.take end + def self.jobs_count_in_alive_pipelines + created_after(24.hours.ago).alive.joins(:builds).count + end + # Returns a Hash containing the latest pipeline for every given # commit. # diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb index 889c5d094a7..2284a05bcc9 100644 --- a/app/models/ci/pipeline_artifact.rb +++ b/app/models/ci/pipeline_artifact.rb @@ -3,8 +3,7 @@ # This class is being used to persist additional artifacts after a pipeline completes, which is a great place to cache a computed result in object storage module Ci - class PipelineArtifact < ApplicationRecord - extend Gitlab::Ci::Model + class PipelineArtifact < Ci::ApplicationRecord include UpdateProjectStatistics include Artifactable include FileStoreMounter diff --git a/app/models/ci/pipeline_chat_data.rb b/app/models/ci/pipeline_chat_data.rb index 65466a8c6f8..ba20c993e36 100644 --- a/app/models/ci/pipeline_chat_data.rb +++ b/app/models/ci/pipeline_chat_data.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true module Ci - class PipelineChatData < ApplicationRecord + class PipelineChatData < Ci::ApplicationRecord + include Ci::NamespacedModelName + self.table_name = 'ci_pipeline_chat_data' belongs_to :chat_name diff --git a/app/models/ci/pipeline_config.rb b/app/models/ci/pipeline_config.rb index d5a8da2bc1e..e2dcad653d7 100644 --- a/app/models/ci/pipeline_config.rb +++ b/app/models/ci/pipeline_config.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class PipelineConfig < ApplicationRecord - extend Gitlab::Ci::Model - + class PipelineConfig < Ci::ApplicationRecord self.table_name = 'ci_pipelines_config' self.primary_key = :pipeline_id diff --git a/app/models/ci/pipeline_message.rb b/app/models/ci/pipeline_message.rb index a47ec554462..5668da915e6 100644 --- a/app/models/ci/pipeline_message.rb +++ b/app/models/ci/pipeline_message.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class PipelineMessage < ApplicationRecord - extend Gitlab::Ci::Model - + class PipelineMessage < Ci::ApplicationRecord MAX_CONTENT_LENGTH = 10_000 belongs_to :pipeline diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index effe2d95a99..b915495ac38 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class PipelineSchedule < ApplicationRecord - extend Gitlab::Ci::Model + class PipelineSchedule < Ci::ApplicationRecord extend ::Gitlab::Utils::Override include Importable include StripAttribute @@ -25,7 +24,7 @@ module Ci validates :description, presence: true validates :variables, nested_attributes_duplicates: true - strip_attributes :cron + strip_attributes! :cron scope :active, -> { where(active: true) } scope :inactive, -> { where(active: false) } diff --git a/app/models/ci/pipeline_schedule_variable.rb b/app/models/ci/pipeline_schedule_variable.rb index adef9911ae1..84a24609cc7 100644 --- a/app/models/ci/pipeline_schedule_variable.rb +++ b/app/models/ci/pipeline_schedule_variable.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class PipelineScheduleVariable < ApplicationRecord - extend Gitlab::Ci::Model + class PipelineScheduleVariable < Ci::ApplicationRecord include Ci::HasVariable belongs_to :pipeline_schedule diff --git a/app/models/ci/pipeline_variable.rb b/app/models/ci/pipeline_variable.rb index 84ca4833cd7..a0e8886414b 100644 --- a/app/models/ci/pipeline_variable.rb +++ b/app/models/ci/pipeline_variable.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class PipelineVariable < ApplicationRecord - extend Gitlab::Ci::Model + class PipelineVariable < Ci::ApplicationRecord include Ci::HasVariable belongs_to :pipeline diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index e2f257eab25..30d335fd7d5 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -169,11 +169,7 @@ module Ci end def all_dependencies - if Feature.enabled?(:preload_associations_jobs_request_api_endpoint, project, default_enabled: :yaml) - strong_memoize(:all_dependencies) do - dependencies.all - end - else + strong_memoize(:all_dependencies) do dependencies.all end end diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb index 3d71a5f2c96..af5fdabff6e 100644 --- a/app/models/ci/ref.rb +++ b/app/models/ci/ref.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Ref < ApplicationRecord - extend Gitlab::Ci::Model + class Ref < Ci::ApplicationRecord include AfterCommitQueue include Gitlab::OptimisticLocking diff --git a/app/models/ci/resource.rb b/app/models/ci/resource.rb index e0e1fab642d..ee094fa2007 100644 --- a/app/models/ci/resource.rb +++ b/app/models/ci/resource.rb @@ -1,13 +1,26 @@ # frozen_string_literal: true module Ci - class Resource < ApplicationRecord - extend Gitlab::Ci::Model - + class Resource < Ci::ApplicationRecord belongs_to :resource_group, class_name: 'Ci::ResourceGroup', inverse_of: :resources belongs_to :processable, class_name: 'Ci::Processable', foreign_key: 'build_id', inverse_of: :resource scope :free, -> { where(processable: nil) } + scope :retained, -> { where.not(processable: nil) } scope :retained_by, -> (processable) { where(processable: processable) } + + class << self + # In some cases, state machine hooks in `Ci::Build` are skipped + # even if the job status transitions to a complete state. + # For example, `Ci::Build#doom!` (a.k.a `data_integrity_failure`) doesn't execute state machine hooks. + # To handle these edge cases, we check the staleness of the jobs that currently + # assigned to the resources, and release if it's stale. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/335537#note_632925914 for more information. + def stale_processables + Ci::Processable.where(id: retained.select(:build_id)) + .complete + .updated_at_before(5.minutes.ago) + end + end end end diff --git a/app/models/ci/resource_group.rb b/app/models/ci/resource_group.rb index 85fbe03e1c9..8a7456041e6 100644 --- a/app/models/ci/resource_group.rb +++ b/app/models/ci/resource_group.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class ResourceGroup < ApplicationRecord - extend Gitlab::Ci::Model - + class ResourceGroup < Ci::ApplicationRecord belongs_to :project, inverse_of: :resource_groups has_many :resources, class_name: 'Ci::Resource', inverse_of: :resource_group diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index a541dca47de..432c3a408a9 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Runner < ApplicationRecord - extend Gitlab::Ci::Model + class Runner < Ci::ApplicationRecord include Gitlab::SQL::Pattern include RedisCacheable include ChronicDurationAttribute @@ -12,6 +11,7 @@ module Ci include FeatureGate include Gitlab::Utils::StrongMemoize include TaggableQueries + include Presentable add_authentication_token_field :token, encrypted: :optional @@ -61,13 +61,7 @@ module Ci scope :paused, -> { where(active: false) } scope :online, -> { where('contacted_at > ?', online_contact_time_deadline) } scope :recent, -> { where('ci_runners.created_at > :date OR ci_runners.contacted_at > :date', date: 3.months.ago) } - # The following query using negation is cheaper than using `contacted_at <= ?` - # because there are less runners online than have been created. The - # resulting query is quickly finding online ones and then uses the regular - # indexed search and rejects the ones that are in the previous set. If we - # did `contacted_at <= ?` the query would effectively have to do a seq - # scan. - scope :offline, -> { where.not(id: online) } + scope :offline, -> { where(arel_table[:contacted_at].lteq(online_contact_time_deadline)) } scope :not_connected, -> { where(contacted_at: nil) } scope :ordered, -> { order(id: :desc) } diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb index 41a4c9012ff..d1353b97ed9 100644 --- a/app/models/ci/runner_namespace.rb +++ b/app/models/ci/runner_namespace.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true module Ci - class RunnerNamespace < ApplicationRecord - extend Gitlab::Ci::Model + class RunnerNamespace < Ci::ApplicationRecord include Limitable self.limit_name = 'ci_registered_group_runners' self.limit_scope = :group self.limit_relation = :recent_runners self.limit_feature_flag = :ci_runner_limits + self.limit_feature_flag_for_override = :ci_runner_limits_override belongs_to :runner, inverse_of: :runner_namespaces belongs_to :namespace, inverse_of: :runner_namespaces, class_name: '::Namespace' diff --git a/app/models/ci/runner_project.rb b/app/models/ci/runner_project.rb index af2595ce4af..e1c435e9b1f 100644 --- a/app/models/ci/runner_project.rb +++ b/app/models/ci/runner_project.rb @@ -1,14 +1,14 @@ # frozen_string_literal: true module Ci - class RunnerProject < ApplicationRecord - extend Gitlab::Ci::Model + class RunnerProject < Ci::ApplicationRecord include Limitable self.limit_name = 'ci_registered_project_runners' self.limit_scope = :project self.limit_relation = :recent_runners self.limit_feature_flag = :ci_runner_limits + self.limit_feature_flag_for_override = :ci_runner_limits_override belongs_to :runner, inverse_of: :runner_projects belongs_to :project, inverse_of: :runner_projects diff --git a/app/models/ci/running_build.rb b/app/models/ci/running_build.rb index 9446cfa05da..ae38d54862d 100644 --- a/app/models/ci/running_build.rb +++ b/app/models/ci/running_build.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class RunningBuild < ApplicationRecord - extend Gitlab::Ci::Model - + class RunningBuild < Ci::ApplicationRecord belongs_to :project belongs_to :build, class_name: 'Ci::Build' belongs_to :runner, class_name: 'Ci::Runner' diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index f19aac213be..f78caf710a6 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -2,7 +2,9 @@ module Ci module Sources - class Pipeline < ApplicationRecord + class Pipeline < Ci::ApplicationRecord + include Ci::NamespacedModelName + self.table_name = "ci_sources_pipelines" belongs_to :project, class_name: "Project" diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index d00066b778d..39e26bf2785 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Stage < ApplicationRecord - extend Gitlab::Ci::Model + class Stage < Ci::ApplicationRecord include Importable include Ci::HasStatus include Gitlab::OptimisticLocking diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 6e27abb9f5b..595315f14ab 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Trigger < ApplicationRecord - extend Gitlab::Ci::Model + class Trigger < Ci::ApplicationRecord include Presentable belongs_to :project diff --git a/app/models/ci/trigger_request.rb b/app/models/ci/trigger_request.rb index 5daf3dd192d..b645f7ee2bb 100644 --- a/app/models/ci/trigger_request.rb +++ b/app/models/ci/trigger_request.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class TriggerRequest < ApplicationRecord - extend Gitlab::Ci::Model - + class TriggerRequest < Ci::ApplicationRecord belongs_to :trigger belongs_to :pipeline, foreign_key: :commit_id has_many :builds diff --git a/app/models/ci/unit_test.rb b/app/models/ci/unit_test.rb index 9fddd9c6002..96b701840ea 100644 --- a/app/models/ci/unit_test.rb +++ b/app/models/ci/unit_test.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class UnitTest < ApplicationRecord - extend Gitlab::Ci::Model - + class UnitTest < Ci::ApplicationRecord MAX_NAME_SIZE = 255 MAX_SUITE_NAME_SIZE = 255 diff --git a/app/models/ci/unit_test_failure.rb b/app/models/ci/unit_test_failure.rb index 480f9cefb8e..a5aa3b70e37 100644 --- a/app/models/ci/unit_test_failure.rb +++ b/app/models/ci/unit_test_failure.rb @@ -1,9 +1,7 @@ # frozen_string_literal: true module Ci - class UnitTestFailure < ApplicationRecord - extend Gitlab::Ci::Model - + class UnitTestFailure < Ci::ApplicationRecord REPORT_WINDOW = 14.days validates :unit_test, :build, :failed_at, presence: true diff --git a/app/models/ci/variable.rb b/app/models/ci/variable.rb index 84505befc5c..1e91f248fc4 100644 --- a/app/models/ci/variable.rb +++ b/app/models/ci/variable.rb @@ -1,8 +1,7 @@ # frozen_string_literal: true module Ci - class Variable < ApplicationRecord - extend Gitlab::Ci::Model + class Variable < Ci::ApplicationRecord include Ci::HasVariable include Presentable include Ci::Maskable diff --git a/app/models/ci_platform_metric.rb b/app/models/ci_platform_metric.rb index ac4ab391bbf..db6b73b43f7 100644 --- a/app/models/ci_platform_metric.rb +++ b/app/models/ci_platform_metric.rb @@ -1,8 +1,10 @@ # frozen_string_literal: true -class CiPlatformMetric < ApplicationRecord +class CiPlatformMetric < Ci::ApplicationRecord include BulkInsertSafe + self.table_name = 'ci_platform_metrics' + PLATFORM_TARGET_MAX_LENGTH = 255 validates :recorded_at, presence: true diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index 3785023c9af..993ccb33655 100644 --- a/app/models/clusters/applications/runner.rb +++ b/app/models/clusters/applications/runner.rb @@ -3,7 +3,7 @@ module Clusters module Applications class Runner < ApplicationRecord - VERSION = '0.30.0' + VERSION = '0.31.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/commit.rb b/app/models/commit.rb index 8e7f526c512..6c8b4ae1139 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -3,6 +3,7 @@ class Commit extend ActiveModel::Naming extend Gitlab::Cache::RequestCache + extend Gitlab::Utils::Override include ActiveModel::Conversion include Noteable @@ -327,7 +328,7 @@ class Commit end def user_mentions - CommitUserMention.where(commit_id: self.id) + user_mention_class.where(commit_id: self.id) end def discussion_notes @@ -554,6 +555,19 @@ class Commit Ability.allowed?(user, :read_commit, self) end + override :user_mention_class + def user_mention_class + CommitUserMention + end + + override :user_mention_identifier + def user_mention_identifier + { + commit_id: id, + note_id: nil + } + end + private def expire_note_etag_cache_for_related_mrs diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index cf23cd3be67..b34d64de101 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class CommitStatus < ApplicationRecord +class CommitStatus < Ci::ApplicationRecord include Ci::HasStatus include Importable include AfterCommitQueue @@ -58,6 +58,7 @@ class CommitStatus < ApplicationRecord scope :in_pipelines, ->(pipelines) { where(pipeline: pipelines) } scope :eager_load_pipeline, -> { eager_load(:pipeline, project: { namespace: :route }) } scope :with_pipeline, -> { joins(:pipeline) } + scope :updated_at_before, ->(date) { where('updated_at < ?', date) } scope :updated_before, ->(lookback:, timeout:) { where('(ci_builds.created_at BETWEEN ? AND ?) AND (ci_builds.updated_at BETWEEN ? AND ?)', lookback, timeout, lookback, timeout) } diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index 2a0274f5706..7bb6004ca83 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -10,6 +10,7 @@ module Analytics included do belongs_to :start_event_label, class_name: 'GroupLabel', optional: true belongs_to :end_event_label, class_name: 'GroupLabel', optional: true + belongs_to :stage_event_hash, class_name: 'Analytics::CycleAnalytics::StageEventHash', foreign_key: :stage_event_hash_id, optional: true validates :name, presence: true validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom? @@ -28,6 +29,9 @@ module Analytics scope :ordered, -> { order(:relative_position, :id) } scope :for_list, -> { includes(:start_event_label, :end_event_label).ordered } scope :by_value_stream, -> (value_stream) { where(value_stream_id: value_stream.id) } + + before_save :ensure_stage_event_hash_id + after_commit :cleanup_old_stage_event_hash end def parent=(_) @@ -133,6 +137,20 @@ module Analytics .id_in(label_id) .exists? end + + def ensure_stage_event_hash_id + previous_stage_event_hash = stage_event_hash&.hash_sha256 + + if previous_stage_event_hash.blank? || events_hash_code != previous_stage_event_hash + self.stage_event_hash_id = Analytics::CycleAnalytics::StageEventHash.record_id_by_hash_sha256(events_hash_code) + end + end + + def cleanup_old_stage_event_hash + if stage_event_hash_id_previously_changed? && stage_event_hash_id_previously_was + Analytics::CycleAnalytics::StageEventHash.cleanup_if_unused(stage_event_hash_id_previously_was) + end + end end end end diff --git a/app/models/concerns/any_field_validation.rb b/app/models/concerns/any_field_validation.rb deleted file mode 100644 index 987c4e7800e..00000000000 --- a/app/models/concerns/any_field_validation.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -# This module enables a record to be valid if any field is present -# -# Overwrite one_of_required_fields to set one of which fields must be present -module AnyFieldValidation - extend ActiveSupport::Concern - - included do - validate :any_field_present - end - - private - - def any_field_present - return unless one_of_required_fields.all? { |field| self[field].blank? } - - errors.add(:base, _("At least one field of %{one_of_required_fields} must be present") % - { one_of_required_fields: one_of_required_fields }) - end - - def one_of_required_fields - raise NotImplementedError - end -end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 79b622c8dad..44d9beff27e 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -160,6 +160,8 @@ module CacheMarkdownField # We can only store mentions if the mentionable is a database object return unless self.is_a?(ApplicationRecord) + return store_mentions_without_subtransaction! if Feature.enabled?(:store_mentions_without_subtransaction, default_enabled: :yaml) + refs = all_references(self.author) references = {} @@ -190,6 +192,29 @@ module CacheMarkdownField true end + def store_mentions_without_subtransaction! + identifier = user_mention_identifier + + # this may happen due to notes polymorphism, so noteable_id may point to a record + # that no longer exists as we cannot have FK on noteable_id + return if identifier.blank? + + refs = all_references(self.author) + + references = {} + references[:mentioned_users_ids] = refs.mentioned_user_ids.presence + references[:mentioned_groups_ids] = refs.mentioned_group_ids.presence + references[:mentioned_projects_ids] = refs.mentioned_project_ids.presence + + if references.compact.any? + user_mention_class.upsert(references.merge(identifier), unique_by: identifier.compact.keys) + else + user_mention_class.delete_by(identifier) + end + + true + end + def mentionable_attributes_changed?(changes = saved_changes) return false unless is_a?(Mentionable) diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb index 5d24e15d518..e58e5ddc966 100644 --- a/app/models/concerns/cascading_namespace_setting_attribute.rb +++ b/app/models/concerns/cascading_namespace_setting_attribute.rb @@ -127,7 +127,7 @@ module CascadingNamespaceSettingAttribute end def alias_boolean(attribute) - return unless Gitlab::Database.exists? && type_for_attribute(attribute).type == :boolean + return unless Gitlab::Database.main.exists? && type_for_attribute(attribute).type == :boolean alias_method :"#{attribute}?", attribute end diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index f3c254053b5..c1299e3d468 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -93,6 +93,7 @@ module Ci scope :running_or_pending, -> { with_status(:running, :pending) } scope :finished, -> { with_status(:success, :failed, :canceled) } scope :failed_or_canceled, -> { with_status(:failed, :canceled) } + scope :complete, -> { with_status(completed_statuses) } scope :incomplete, -> { without_statuses(completed_statuses) } scope :cancelable, -> do diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 114435d5a21..ec86746ae54 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -76,14 +76,8 @@ module Ci end def write_metadata_attribute(legacy_key, metadata_key, value) - # save to metadata or this model depending on the state of feature flag - if Feature.enabled?(:ci_build_metadata_config, project, default_enabled: :yaml) - ensure_metadata.write_attribute(metadata_key, value) - write_attribute(legacy_key, nil) - else - write_attribute(legacy_key, value) - metadata&.write_attribute(metadata_key, nil) - end + ensure_metadata.write_attribute(metadata_key, value) + write_attribute(legacy_key, nil) end end end diff --git a/app/models/concerns/ci/namespaced_model_name.rb b/app/models/concerns/ci/namespaced_model_name.rb new file mode 100644 index 00000000000..e941a3a7a0c --- /dev/null +++ b/app/models/concerns/ci/namespaced_model_name.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ci + module NamespacedModelName + extend ActiveSupport::Concern + + class_methods do + def model_name + @model_name ||= ActiveModel::Name.new(self, Ci) + end + end + end +end diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb index 829b2a6ef21..4bfeba338d2 100644 --- a/app/models/concerns/counter_attribute.rb +++ b/app/models/concerns/counter_attribute.rb @@ -128,8 +128,7 @@ module CounterAttribute end def counter_attribute_enabled?(attribute) - Feature.enabled?(:efficient_counter_attribute, project) && - self.class.counter_attributes.include?(attribute) + self.class.counter_attributes.include?(attribute) end private diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb index a59f00d73ec..443e1ab53b4 100644 --- a/app/models/concerns/each_batch.rb +++ b/app/models/concerns/each_batch.rb @@ -91,7 +91,11 @@ module EachBatch # Any ORDER BYs are useless for this relation and can lead to less # efficient UPDATE queries, hence we get rid of it. - yield relation.except(:order), index + relation = relation.except(:order) + + # Using unscoped is necessary to prevent leaking the current scope used by + # ActiveRecord to chain `each_batch` method. + unscoped { yield relation, index } break unless stop end diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index c42b046592f..94d11c871ca 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -37,7 +37,9 @@ module Enums merge_request_event: 10, external_pull_request_event: 11, parent_pipeline: 12, - ondemand_dast_scan: 13 + ondemand_dast_scan: 13, + ondemand_dast_validation: 14, + security_orchestration_policy: 15 } end @@ -48,8 +50,10 @@ module Enums # parent pipeline. It's up to the parent to affect the ref CI status # - when an ondemand_dast_scan pipeline runs it is for testing purpose and should # not affect the ref CI status. + # - when an ondemand_dast_validation pipeline runs it is for validating a DAST site + # profile and should not affect the ref CI status. def self.dangling_sources - sources.slice(:webide, :parent_pipeline, :ondemand_dast_scan) + sources.slice(:webide, :parent_pipeline, :ondemand_dast_scan, :ondemand_dast_validation, :security_orchestration_policy) end # CI sources are those pipeline events that affect the CI status of the ref diff --git a/app/models/concerns/expirable.rb b/app/models/concerns/expirable.rb index 512822089ba..e029ada84f0 100644 --- a/app/models/concerns/expirable.rb +++ b/app/models/concerns/expirable.rb @@ -13,6 +13,9 @@ module Expirable expires? && expires_at <= Time.current end + # Used in subclasses that override expired? + alias_method :expired_original?, :expired? + def expires? expires_at.present? end diff --git a/app/models/concerns/has_integrations.rb b/app/models/concerns/has_integrations.rb index 25650ae56ad..76e03d68600 100644 --- a/app/models/concerns/has_integrations.rb +++ b/app/models/concerns/has_integrations.rb @@ -4,18 +4,6 @@ module HasIntegrations extend ActiveSupport::Concern class_methods do - def with_custom_integration_for(integration, page = nil, per = nil) - custom_integration_project_ids = Integration - .select(:project_id) - .where(type: integration.type) - .where(inherit_from_id: nil) - .where.not(project_id: nil) - .page(page) - .per(per) - - Project.where(id: custom_integration_project_ids) - end - def without_integration(integration) integrations = Integration .select('1') diff --git a/app/models/concerns/incident_management/escalatable.rb b/app/models/concerns/incident_management/escalatable.rb new file mode 100644 index 00000000000..78dce63f59e --- /dev/null +++ b/app/models/concerns/incident_management/escalatable.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module IncidentManagement + # Shared functionality for a `#status` field, representing + # whether action is required. In EE, this corresponds + # to paging functionality with EscalationPolicies. + # + # This module is only responsible for setting the status and + # possible status-related timestamps (EX triggered_at/resolved_at) + # for the implementing class. The relationships between these + # values and other related timestamps/logic should be managed from + # the object class itself. (EX Alert#ended_at = Alert#resolved_at) + module Escalatable + extend ActiveSupport::Concern + + STATUSES = { + triggered: 0, + acknowledged: 1, + resolved: 2, + ignored: 3 + }.freeze + + STATUS_DESCRIPTIONS = { + triggered: 'Investigation has not started', + acknowledged: 'Someone is actively investigating the problem', + resolved: 'The problem has been addressed', + ignored: 'No action will be taken' + }.freeze + + included do + validates :status, presence: true + + # Ascending sort order sorts statuses: Ignored > Resolved > Acknowledged > Triggered + # Descending sort order sorts statuses: Triggered > Acknowledged > Resolved > Ignored + # https://gitlab.com/gitlab-org/gitlab/-/issues/221242#what-is-the-expected-correct-behavior + scope :order_status, -> (sort_order) { order(status: sort_order == :asc ? :desc : :asc) } + + state_machine :status, initial: :triggered do + state :triggered, value: STATUSES[:triggered] + + state :acknowledged, value: STATUSES[:acknowledged] + + state :resolved, value: STATUSES[:resolved] do + validates :resolved_at, presence: true + end + + state :ignored, value: STATUSES[:ignored] + + state :triggered, :acknowledged, :ignored do + validates :resolved_at, absence: true + end + + event :trigger do + transition any => :triggered + end + + event :acknowledge do + transition any => :acknowledged + end + + event :resolve do + transition any => :resolved + end + + event :ignore do + transition any => :ignored + end + + before_transition to: [:triggered, :acknowledged, :ignored] do |escalatable, _transition| + escalatable.resolved_at = nil + end + + before_transition to: :resolved do |escalatable, transition| + resolved_at = transition.args.first + escalatable.resolved_at = resolved_at || Time.current + end + end + + class << self + def status_value(name) + state_machine_statuses[name] + end + + def status_name(raw_status) + state_machine_statuses.key(raw_status) + end + + def status_names + @status_names ||= state_machine_statuses.keys + end + + private + + def state_machine_statuses + @state_machine_statuses ||= state_machines[:status].states.to_h { |s| [s.name, s.value] } + end + end + + def status_event_for(status) + self.class.state_machines[:status].events.transitions_for(self, to: status.to_s.to_sym).first&.event + end + end + end +end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index d5e2e63402f..8d0f8b01d64 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -152,7 +152,7 @@ module Issuable participant :notes_with_associations participant :assignees - strip_attributes :title + strip_attributes! :title class << self def labels_hash @@ -374,6 +374,8 @@ module Issuable grouping_columns << milestone_table[:due_date] elsif %w(merged_at_desc merged_at_asc).include?(sort) grouping_columns << MergeRequest::Metrics.arel_table[:merged_at] + elsif %w(closed_at_desc closed_at_asc).include?(sort) + grouping_columns << MergeRequest::Metrics.arel_table[:closed_at] end grouping_columns diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb index 41efea65c5a..fab1aa21634 100644 --- a/app/models/concerns/limitable.rb +++ b/app/models/concerns/limitable.rb @@ -9,6 +9,7 @@ module Limitable class_attribute :limit_relation class_attribute :limit_name class_attribute :limit_feature_flag + class_attribute :limit_feature_flag_for_override # Allows selectively disabling by actor (as per https://docs.gitlab.com/ee/development/feature_flags/#selectively-disable-by-actor) self.limit_name = self.name.demodulize.tableize validate :validate_plan_limit_not_exceeded, on: :create @@ -28,6 +29,7 @@ module Limitable scope_relation = self.public_send(limit_scope) # rubocop:disable GitlabSecurity/PublicSend return unless scope_relation return if limit_feature_flag && ::Feature.disabled?(limit_feature_flag, scope_relation, default_enabled: :yaml) + return if limit_feature_flag_for_override && ::Feature.enabled?(limit_feature_flag_for_override, scope_relation, default_enabled: :yaml) relation = limit_relation ? self.public_send(limit_relation) : self.class.where(limit_scope => scope_relation) # rubocop:disable GitlabSecurity/PublicSend limits = scope_relation.actual_limits diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index f1baa923ec5..4df9e32d8ec 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -161,6 +161,21 @@ module Mentionable create_cross_references!(author) end + def user_mention_class + user_mention_association.klass + end + + # Identifier for the user mention that is parsed from model description rather then its related notes. + # Models that have a description attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. + # Other mentionable models like DesignManagement::Design, will never have such record as those do not have + # a description attribute. + def user_mention_identifier + { + user_mention_association.foreign_key => id, + note_id: nil + } + end + private def extracted_mentionables(refs) @@ -199,6 +214,10 @@ module Mentionable {} end + def user_mention_association + association(:user_mentions).reflection + end + # User mention that is parsed from model description rather then its related notes. # Models that have a description attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention. # Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb index 159f0044c82..196bec04be6 100644 --- a/app/models/concerns/packages/debian/distribution.rb +++ b/app/models/concerns/packages/debian/distribution.rb @@ -77,23 +77,16 @@ module Packages validates container_type, presence: true validates :file_store, presence: true - - validates :file_signature, absence: true - validates :signing_keys, absence: true + validates :signed_file_store, presence: true scope :with_container, ->(subject) { where(container_type => subject) } scope :with_codename, ->(codename) { where(codename: codename) } scope :with_suite, ->(suite) { where(suite: suite) } scope :with_codename_or_suite, ->(codename_or_suite) { with_codename(codename_or_suite).or(with_suite(codename_or_suite)) } - attr_encrypted :signing_keys, - mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_32, - algorithm: 'aes-256-gcm', - encode: false, - encode_iv: false - mount_file_store_uploader Packages::Debian::DistributionReleaseFileUploader + mount_uploader :signed_file, Packages::Debian::DistributionReleaseFileUploader + after_save :update_signed_file_store, if: :saved_change_to_signed_file? def component_names components.pluck(:name).sort @@ -131,6 +124,12 @@ module Packages self.class.with_container(container).with_codename(suite).exists? end + + def update_signed_file_store + # The signed_file.object_store is set during `uploader.store!` + # which happens after object is inserted/updated + self.update_column(:signed_file_store, signed_file.object_store) + end end end end diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 484c91e0833..0cab874a240 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -90,6 +90,13 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:container_registry_access_level, value) end + # TODO: Remove this method after we drop support for project create/edit APIs to set the + # container_registry_enabled attribute. They can instead set the container_registry_access_level + # attribute. + def container_registry_enabled=(value) + write_feature_attribute_boolean(:container_registry_access_level, value) + end + private def write_feature_attribute_boolean(field, value) diff --git a/app/models/concerns/restricted_signup.rb b/app/models/concerns/restricted_signup.rb new file mode 100644 index 00000000000..587f8c35ff7 --- /dev/null +++ b/app/models/concerns/restricted_signup.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true +module RestrictedSignup + extend ActiveSupport::Concern + + private + + def validate_admin_signup_restrictions(email) + return if allowed_domain?(email) + + if allowlist_present? + return _('domain is not authorized for sign-up.') + elsif denied_domain?(email) + return _('is not from an allowed domain.') + elsif restricted_email?(email) + return _('is not allowed. Try again with a different email address, or contact your GitLab admin.') + end + + nil + end + + def denied_domain?(email) + return false unless Gitlab::CurrentSettings.domain_denylist_enabled? + + denied_domains = Gitlab::CurrentSettings.domain_denylist + denied_domains.present? && domain_matches?(denied_domains, email) + end + + def allowlist_present? + Gitlab::CurrentSettings.domain_allowlist.present? + end + + def allowed_domain?(email) + allowed_domains = Gitlab::CurrentSettings.domain_allowlist + allowlist_present? && domain_matches?(allowed_domains, email) + end + + def restricted_email?(email) + return false unless Gitlab::CurrentSettings.email_restrictions_enabled? + + restrictions = Gitlab::CurrentSettings.email_restrictions + restrictions.present? && Gitlab::UntrustedRegexp.new(restrictions).match?(email) + end + + def domain_matches?(email_domains, email) + signup_domain = Mail::Address.new(email).domain + email_domains.any? do |domain| + escaped = Regexp.escape(domain).gsub('\*', '.*?') + regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE + signup_domain =~ regexp + end + end +end diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb index 4fae36f7b8d..49342e30db6 100644 --- a/app/models/concerns/select_for_project_authorization.rb +++ b/app/models/concerns/select_for_project_authorization.rb @@ -5,7 +5,7 @@ module SelectForProjectAuthorization class_methods do def select_for_project_authorization - select("projects.id AS project_id, members.access_level") + select("projects.id AS project_id", "members.access_level") end def select_as_maintainer_for_project_authorization diff --git a/app/models/concerns/sha256_attribute.rb b/app/models/concerns/sha256_attribute.rb index 4921f7f1a7e..17fda6c806c 100644 --- a/app/models/concerns/sha256_attribute.rb +++ b/app/models/concerns/sha256_attribute.rb @@ -39,7 +39,7 @@ module Sha256Attribute end def database_exists? - Gitlab::Database.exists? + Gitlab::Database.main.exists? end end end diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index f6f5dbce4b6..27277bc5296 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -32,7 +32,7 @@ module ShaAttribute end def database_exists? - Gitlab::Database.exists? + Gitlab::Database.main.exists? end end end diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb index 2daea388939..4901cd832ff 100644 --- a/app/models/concerns/spammable.rb +++ b/app/models/concerns/spammable.rb @@ -111,7 +111,7 @@ module Spammable end # Override in Spammable if further checks are necessary - def check_for_spam? + def check_for_spam?(user:) true end diff --git a/app/models/concerns/strip_attribute.rb b/app/models/concerns/strip_attribute.rb index 8f6a6244dd3..1c433a3275e 100644 --- a/app/models/concerns/strip_attribute.rb +++ b/app/models/concerns/strip_attribute.rb @@ -7,7 +7,7 @@ # Usage: # # class Milestone < ApplicationRecord -# strip_attributes :title +# strip_attributes! :title # end # # @@ -15,7 +15,7 @@ module StripAttribute extend ActiveSupport::Concern class_methods do - def strip_attributes(*attrs) + def strip_attributes!(*attrs) strip_attrs.concat(attrs) end @@ -25,10 +25,10 @@ module StripAttribute end included do - before_validation :strip_attributes + before_validation :strip_attributes! end - def strip_attributes + def strip_attributes! self.class.strip_attrs.each do |attr| self[attr].strip! if self[attr] && self[attr].respond_to?(:strip!) end diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb index 89b42eec727..54fe9eac2bc 100644 --- a/app/models/concerns/time_trackable.rb +++ b/app/models/concerns/time_trackable.rb @@ -11,7 +11,7 @@ module TimeTrackable extend ActiveSupport::Concern included do - attr_reader :time_spent, :time_spent_user, :spent_at + attr_reader :time_spent, :time_spent_user, :spent_at, :summary alias_method :time_spent?, :time_spent @@ -20,7 +20,7 @@ module TimeTrackable validates :time_estimate, numericality: { message: 'has an invalid format' }, allow_nil: false validate :check_negative_time_spent - has_many :timelogs, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :timelogs, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent end # rubocop:disable Gitlab/ModuleWithInstanceVariables @@ -29,6 +29,7 @@ module TimeTrackable @time_spent_note_id = options[:note_id] @time_spent_user = User.find(options[:user_id]) @spent_at = options[:spent_at] + @summary = options[:summary] @original_total_time_spent = nil return if @time_spent == 0 @@ -78,7 +79,8 @@ module TimeTrackable time_spent: time_spent, note_id: @time_spent_note_id, user: @time_spent_user, - spent_at: @spent_at + spent_at: @spent_at, + summary: @summary ) end # rubocop:enable Gitlab/ModuleWithInstanceVariables diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 8dc58f8dca1..79cbe225e5a 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -106,7 +106,7 @@ module Timebox .where('due_date is NULL or due_date >= ?', start_date) end - strip_attributes :title + strip_attributes! :title alias_attribute :name, :title end diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index f0e5e010e70..a656856487d 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -2,6 +2,35 @@ module VulnerabilityFindingHelpers extend ActiveSupport::Concern -end + def matches_signatures(other_signatures, other_uuid) + other_signature_types = other_signatures.index_by(&:algorithm_type) + + # highest first + match_result = nil + signatures.sort_by(&:priority).reverse_each do |signature| + matching_other_signature = other_signature_types[signature.algorithm_type] + next if matching_other_signature.nil? + + match_result = matching_other_signature == signature + break + end -VulnerabilityFindingHelpers.prepend_mod_with('VulnerabilityFindingHelpers') + if match_result.nil? + [uuid, *signature_uuids].include?(other_uuid) + else + match_result + end + end + + def signature_uuids + signatures.map do |signature| + hex_sha = signature.signature_hex + ::Security::VulnerabilityUUID.generate( + report_type: report_type, + location_fingerprint: hex_sha, + primary_identifier_fingerprint: primary_identifier&.fingerprint, + project_id: project_id + ) + end + end +end diff --git a/app/models/concerns/vulnerability_finding_signature_helpers.rb b/app/models/concerns/vulnerability_finding_signature_helpers.rb index f98c1e93aaf..71a12b4077b 100644 --- a/app/models/concerns/vulnerability_finding_signature_helpers.rb +++ b/app/models/concerns/vulnerability_finding_signature_helpers.rb @@ -2,6 +2,30 @@ module VulnerabilityFindingSignatureHelpers extend ActiveSupport::Concern -end + # If the location object describes a physical location within a file + # (filename + line numbers), the 'location' algorithm_type should be used + # If the location object describes arbitrary data, then the 'hash' + # algorithm_type should be used. + + ALGORITHM_TYPES = { hash: 1, location: 2, scope_offset: 3 }.with_indifferent_access.freeze + + class_methods do + def priority(algorithm_type) + raise ArgumentError, "No priority for #{algorithm_type.inspect}" unless ALGORITHM_TYPES.key?(algorithm_type) + + ALGORITHM_TYPES[algorithm_type] + end -VulnerabilityFindingSignatureHelpers.prepend_mod_with('VulnerabilityFindingSignatureHelpers') + def algorithm_types + ALGORITHM_TYPES + end + end + + def priority + self.class.priority(algorithm_type) + end + + def algorithm_types + self.class.algorithm_types + end +end diff --git a/app/models/concerns/x509_serial_number_attribute.rb b/app/models/concerns/x509_serial_number_attribute.rb index dbba80eff53..dfb1e151b41 100644 --- a/app/models/concerns/x509_serial_number_attribute.rb +++ b/app/models/concerns/x509_serial_number_attribute.rb @@ -39,7 +39,7 @@ module X509SerialNumberAttribute end def database_exists? - Gitlab::Database.exists? + Gitlab::Database.main.exists? end end end diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb new file mode 100644 index 00000000000..caf1cd68cc5 --- /dev/null +++ b/app/models/customer_relations/organization.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +class CustomerRelations::Organization < ApplicationRecord + self.table_name = "customer_relations_organizations" + + belongs_to :group, -> { where(type: 'Group') }, foreign_key: 'group_id' + + before_validation :strip_whitespace! + + enum state: { + inactive: 0, + active: 1 + } + + validates :group, presence: true + validates :name, presence: true + validates :name, uniqueness: { case_sensitive: false, scope: [:group_id] } + validates :name, length: { maximum: 255 } + validates :description, length: { maximum: 1024 } + + def self.find_by_name(group_id, name) + where(group: group_id) + .where('LOWER(name) = LOWER(?)', name) + end + + private + + def strip_whitespace! + name&.strip! + end +end diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb index 5fa9f2ef9f9..326d3fb8470 100644 --- a/app/models/deploy_token.rb +++ b/app/models/deploy_token.rb @@ -10,6 +10,7 @@ class DeployToken < ApplicationRecord AVAILABLE_SCOPES = %i(read_repository read_registry write_registry read_package_registry write_package_registry).freeze GITLAB_DEPLOY_TOKEN_NAME = 'gitlab-deploy-token' + REQUIRED_DEPENDENCY_PROXY_SCOPES = %i[read_registry write_registry].freeze default_value_for(:expires_at) { Forever.date } @@ -46,6 +47,12 @@ class DeployToken < ApplicationRecord active.find_by(name: GITLAB_DEPLOY_TOKEN_NAME) end + def valid_for_dependency_proxy? + group_type? && + active? && + REQUIRED_DEPENDENCY_PROXY_SCOPES.all? { |scope| scope.in?(scopes) } + end + def revoke! update!(revoked: true) end @@ -73,6 +80,14 @@ class DeployToken < ApplicationRecord holder.has_access_to?(requested_project) end + def has_access_to_group?(requested_group) + return false unless active? + return false unless group_type? + return false unless holder + + holder.has_access_to_group?(requested_group) + end + # This is temporal. Currently we limit DeployToken # to a single project or group, later we're going to # extend that to be for multiple projects and namespaces. diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 313aeb1eda7..4a690ccc67e 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -289,7 +289,7 @@ class Deployment < ApplicationRecord "#{id} as deployment_id", "#{environment_id} as environment_id").to_sql - # We don't use `Gitlab::Database.bulk_insert` here so that we don't need to + # We don't use `Gitlab::Database.main.bulk_insert` here so that we don't need to # first pluck lots of IDs into memory. # # We also ignore any duplicates so this method can be called multiple times diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb index ca65cf38f0d..6cda03557d1 100644 --- a/app/models/design_management/version.rb +++ b/app/models/design_management/version.rb @@ -88,7 +88,7 @@ module DesignManagement rows = design_actions.map { |action| action.row_attrs(version) } - Gitlab::Database.bulk_insert(::DesignManagement::Action.table_name, rows) # rubocop:disable Gitlab/BulkInsert + Gitlab::Database.main.bulk_insert(::DesignManagement::Action.table_name, rows) # rubocop:disable Gitlab/BulkInsert version.designs.reset version.validate! design_actions.each(&:performed) diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 642e93f7912..f4d665cf279 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -43,9 +43,13 @@ class DiffDiscussion < Discussion end def cache_key + positions_json = diff_note_positions.map { |dnp| dnp.position.to_json } + positions_sha = Digest::SHA1.hexdigest(positions_json.join(':')) if positions_json.any? + [ super, - Digest::SHA1.hexdigest(position.to_json) + Digest::SHA1.hexdigest(position.to_json), + positions_sha ].join(':') end diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 076d8cc280c..203e14f1227 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -163,16 +163,15 @@ class Discussion end def cache_key - # Need this so cache will be invalidated when note within a discussion - # has been deleted. - notes_sha = Digest::SHA1.hexdigest(notes.map(&:id).join(':')) + # Need to use the notes' cache key so cache will be invalidated when note + # within a discussion has been deleted or has different data after post + # processing of content. + notes_sha = Digest::SHA1.hexdigest(notes.map(&:post_processed_cache_key).join(':')) [ CACHE_VERSION, - notes.last.latest_cached_markdown_version, id, notes_sha, - notes.max_by(&:updated_at).updated_at, resolved_at ].join(':') end diff --git a/app/models/environment.rb b/app/models/environment.rb index 558963c98c4..963249c018a 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -5,6 +5,7 @@ class Environment < ApplicationRecord include ReactiveCaching include FastDestroyAll::Helpers include Presentable + include NullifyIfBlank self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 55.seconds @@ -14,6 +15,7 @@ class Environment < ApplicationRecord belongs_to :project, required: true use_fast_destroy :all_deployments + nullify_if_blank :external_url has_many :all_deployments, class_name: 'Deployment' has_many :deployments, -> { visible } @@ -33,7 +35,6 @@ class Environment < ApplicationRecord has_one :upcoming_deployment, -> { running.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment has_one :latest_opened_most_severe_alert, -> { order_severity_with_open_prometheus_alert }, class_name: 'AlertManagement::Alert', inverse_of: :environment - before_validation :nullify_external_url before_validation :generate_slug, if: ->(env) { env.slug.blank? } before_save :set_environment_type @@ -77,6 +78,7 @@ class Environment < ApplicationRecord scope :for_name, -> (name) { where(name: name) } scope :preload_cluster, -> { preload(last_deployment: :cluster) } scope :auto_stoppable, -> (limit) { available.where('auto_stop_at < ?', Time.zone.now).limit(limit) } + scope :auto_deletable, -> (limit) { stopped.where('auto_delete_at < ?', Time.zone.now).limit(limit) } ## # Search environments which have names like the given query. @@ -230,10 +232,6 @@ class Environment < ApplicationRecord ref.to_s == last_deployment.try(:ref) end - def nullify_external_url - self.external_url = nil if self.external_url.blank? - end - def set_environment_type names = name.split('/') diff --git a/app/models/error_tracking/client_key.rb b/app/models/error_tracking/client_key.rb new file mode 100644 index 00000000000..9d12c0ed6f1 --- /dev/null +++ b/app/models/error_tracking/client_key.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class ErrorTracking::ClientKey < ApplicationRecord + belongs_to :project + + validates :project, presence: true + validates :public_key, presence: true, length: { maximum: 255 } + + scope :active, -> { where(active: true) } + + after_initialize :generate_key + + def self.find_by_public_key(key) + find_by(public_key: key) + end + + private + + def generate_key + self.public_key = "glet_#{SecureRandom.hex}" + end +end diff --git a/app/models/error_tracking/error.rb b/app/models/error_tracking/error.rb index 012dcc4418f..32932c4d045 100644 --- a/app/models/error_tracking/error.rb +++ b/app/models/error_tracking/error.rb @@ -5,10 +5,19 @@ class ErrorTracking::Error < ApplicationRecord has_many :events, class_name: 'ErrorTracking::ErrorEvent' + scope :for_status, -> (status) { where(status: status) } + validates :project, presence: true validates :name, presence: true validates :description, presence: true validates :actor, presence: true + validates :status, presence: true + + enum status: { + unresolved: 0, + resolved: 1, + ignored: 2 + } def self.report_error(name:, description:, actor:, platform:, timestamp:) safe_find_or_create_by( @@ -20,4 +29,64 @@ class ErrorTracking::Error < ApplicationRecord error.update!(last_seen_at: timestamp) end end + + def title + if description.present? + "#{name} #{description}" + else + name + end + end + + def title_truncated + title.truncate(64) + end + + # For compatibility with sentry integration + def to_sentry_error + Gitlab::ErrorTracking::Error.new( + id: id, + title: title_truncated, + message: description, + culprit: actor, + first_seen: first_seen_at, + last_seen: last_seen_at, + status: status, + count: events_count + ) + end + + # For compatibility with sentry integration + def to_sentry_detailed_error + Gitlab::ErrorTracking::DetailedError.new( + id: id, + title: title_truncated, + message: description, + culprit: actor, + first_seen: first_seen_at.to_s, + last_seen: last_seen_at.to_s, + count: events_count, + user_count: 0, # we don't support user count yet. + project_id: project.id, + status: status, + tags: { level: nil, logger: nil }, + external_url: external_url, + external_base_url: external_base_url + ) + end + + private + + # For compatibility with sentry integration + def external_url + Gitlab::Routing.url_helpers.details_namespace_project_error_tracking_index_url( + namespace_id: project.namespace, + project_id: project, + issue_id: id) + end + + # For compatibility with sentry integration + def external_base_url + Gitlab::Routing.url_helpers.root_url + end end diff --git a/app/models/error_tracking/error_event.rb b/app/models/error_tracking/error_event.rb index ed14a1bce41..4de13de7e2e 100644 --- a/app/models/error_tracking/error_event.rb +++ b/app/models/error_tracking/error_event.rb @@ -8,4 +8,69 @@ class ErrorTracking::ErrorEvent < ApplicationRecord validates :error, presence: true validates :description, presence: true validates :occurred_at, presence: true + + def stacktrace + @stacktrace ||= build_stacktrace + end + + # For compatibility with sentry integration + def to_sentry_error_event + Gitlab::ErrorTracking::ErrorEvent.new( + issue_id: error_id, + date_received: occurred_at, + stack_trace_entries: stacktrace + ) + end + + private + + def build_stacktrace + raw_stacktrace = find_stacktrace_from_payload + + return [] unless raw_stacktrace + + raw_stacktrace.map do |entry| + { + 'lineNo' => entry['lineno'], + 'context' => build_stacktrace_context(entry), + 'filename' => entry['filename'], + 'function' => entry['function'], + 'colNo' => 0 # we don't support colNo yet. + } + end + end + + def find_stacktrace_from_payload + exception_entry = payload.dig('exception') + + if exception_entry + exception_values = exception_entry.dig('values') + stack_trace_entry = exception_values&.detect { |h| h['stacktrace'].present? } + stack_trace_entry&.dig('stacktrace', 'frames') + end + end + + def build_stacktrace_context(entry) + context = [] + error_line = entry['context_line'] + error_line_no = entry['lineno'] + pre_context = entry['pre_context'] + post_context = entry['post_context'] + + context += lines_with_position(pre_context, error_line_no - pre_context.size) + context += lines_with_position([error_line], error_line_no) + context += lines_with_position(post_context, error_line_no + 1) + + context.reject(&:blank?) + end + + def lines_with_position(lines, position) + return [] if lines.blank? + + lines.map.with_index do |line, index| + next unless line + + [position + index, line] + end + end end diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index c729b002852..c5a77427588 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -31,12 +31,13 @@ module ErrorTracking validates :api_url, length: { maximum: 255 }, public_url: { enforce_sanitization: true, ascii_only: true }, allow_nil: true validates :enabled, inclusion: { in: [true, false] } + validates :integrated, inclusion: { in: [true, false] } - validates :api_url, presence: { message: 'is a required field' }, if: :enabled - - validate :validate_api_url_path, if: :enabled - - validates :token, presence: { message: 'is a required field' }, if: :enabled + with_options if: :sentry_enabled do + validates :api_url, presence: { message: 'is a required field' } + validates :token, presence: { message: 'is a required field' } + validate :validate_api_url_path + end attr_encrypted :token, mode: :per_attribute_iv, @@ -45,6 +46,14 @@ module ErrorTracking after_save :clear_reactive_cache! + def sentry_enabled + enabled && !integrated_client? + end + + def integrated_client? + integrated && ::Feature.enabled?(:integrated_error_tracking, project) + end + def api_url=(value) super clear_memoization(:api_url_slugs) @@ -79,7 +88,7 @@ module ErrorTracking def sentry_client strong_memoize(:sentry_client) do - ErrorTracking::SentryClient.new(api_url, token) + ::ErrorTracking::SentryClient.new(api_url, token) end end diff --git a/app/models/event.rb b/app/models/event.rb index 14d20b0d6c4..f6174589a84 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -434,9 +434,9 @@ class Event < ApplicationRecord def design_action_names { - created: _('uploaded'), - updated: _('revised'), - destroyed: _('deleted') + created: _('added'), + updated: _('updated'), + destroyed: _('removed') } end diff --git a/app/models/group.rb b/app/models/group.rb index 1e7308499a0..f6b45a755e4 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -80,7 +80,7 @@ class Group < Namespace # debian_distributions and associated component_files must be destroyed by ruby code in order to properly remove carrierwave uploads has_many :debian_distributions, class_name: 'Packages::Debian::GroupDistribution', dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent - delegate :prevent_sharing_groups_outside_hierarchy, to: :namespace_settings + delegate :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, to: :namespace_settings accepts_nested_attributes_for :variables, allow_destroy: true @@ -158,7 +158,7 @@ class Group < Namespace if current_scope.joins_values.include?(:shared_projects) joins('INNER JOIN namespaces project_namespace ON project_namespace.id = projects.namespace_id') .where(project_namespace: { share_with_group_lock: false }) - .select("projects.id AS project_id, LEAST(project_group_links.group_access, members.access_level) AS access_level") + .select("projects.id AS project_id", "LEAST(project_group_links.group_access, members.access_level) AS access_level") else super end @@ -296,7 +296,7 @@ class Group < Namespace end def add_users(users, access_level, current_user: nil, expires_at: nil) - Members::Groups::CreatorService.add_users( # rubocop:todo CodeReuse/ServiceClass + Members::Groups::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass self, users, access_level, @@ -306,7 +306,7 @@ class Group < Namespace end def add_user(user, access_level, current_user: nil, expires_at: nil, ldap: false) - Members::Groups::CreatorService.new(self, # rubocop:todo CodeReuse/ServiceClass + Members::Groups::CreatorService.new(self, # rubocop:disable CodeReuse/ServiceClass user, access_level, current_user: current_user, @@ -463,7 +463,7 @@ class Group < Namespace id end - group_hierarchy_members = GroupMember.where(source_id: source_ids) + group_hierarchy_members = GroupMember.where(source_id: source_ids).select(*GroupMember.cached_column_list) GroupMember.from_union([group_hierarchy_members, members_from_self_and_ancestor_group_shares]).authorizable @@ -481,6 +481,7 @@ class Group < Namespace group_hierarchy_members = GroupMember.active_without_invites_and_requests .non_minimal_access .where(source_id: source_ids) + .select(*GroupMember.cached_column_list) GroupMember.from_union([group_hierarchy_members, members_from_self_and_ancestor_group_shares]) @@ -729,6 +730,10 @@ class Group < Namespace end # rubocop: enable CodeReuse/ServiceClass + def timelogs + Timelog.in_group(self) + end + private def max_member_access(user_ids) diff --git a/app/models/group_deploy_token.rb b/app/models/group_deploy_token.rb index 084a8672460..d9667e7c74d 100644 --- a/app/models/group_deploy_token.rb +++ b/app/models/group_deploy_token.rb @@ -11,9 +11,14 @@ class GroupDeployToken < ApplicationRecord def has_access_to?(requested_project) requested_project_group = requested_project&.group return false unless requested_project_group - return true if requested_project_group.id == group_id - requested_project_group + has_access_to_group?(requested_project_group) + end + + def has_access_to_group?(requested_group) + return true if requested_group.id == group_id + + requested_group .ancestors .where(id: group_id) .exists? diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index 5f8fa4bca0a..9a78fe3971c 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -69,21 +69,26 @@ class WebHook < ApplicationRecord end def disable! - update!(recent_failures: FAILURE_THRESHOLD + 1) + update_attribute(:recent_failures, FAILURE_THRESHOLD + 1) end def enable! return if recent_failures == 0 && disabled_until.nil? && backoff_count == 0 - update!(recent_failures: 0, disabled_until: nil, backoff_count: 0) + assign_attributes(recent_failures: 0, disabled_until: nil, backoff_count: 0) + save(validate: false) end def backoff! - update!(disabled_until: next_backoff.from_now, backoff_count: backoff_count.succ.clamp(0, MAX_FAILURES)) + assign_attributes(disabled_until: next_backoff.from_now, backoff_count: backoff_count.succ.clamp(0, MAX_FAILURES)) + save(validate: false) end def failed! - update!(recent_failures: recent_failures + 1) if recent_failures < MAX_FAILURES + return unless recent_failures < MAX_FAILURES + + assign_attributes(recent_failures: recent_failures + 1) + save(validate: false) end # Overridden in ProjectHook and GroupHook, other webhooks are not rate-limited. diff --git a/app/models/incident_management/issuable_escalation_status.rb b/app/models/incident_management/issuable_escalation_status.rb new file mode 100644 index 00000000000..88aef104d88 --- /dev/null +++ b/app/models/incident_management/issuable_escalation_status.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module IncidentManagement + class IssuableEscalationStatus < ApplicationRecord + include ::IncidentManagement::Escalatable + + self.table_name = 'incident_management_issuable_escalation_statuses' + + belongs_to :issue + + validates :issue, presence: true, uniqueness: true + end +end + +IncidentManagement::IssuableEscalationStatus.prepend_mod_with('IncidentManagement::IssuableEscalationStatus') diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb index f401c23e453..09a60e9dd10 100644 --- a/app/models/instance_configuration.rb +++ b/app/models/instance_configuration.rb @@ -13,7 +13,9 @@ class InstanceConfiguration { ssh_algorithms_hashes: ssh_algorithms_hashes, host: host, gitlab_pages: gitlab_pages, - gitlab_ci: gitlab_ci }.deep_symbolize_keys + gitlab_ci: gitlab_ci, + package_file_size_limits: package_file_size_limits, + rate_limits: rate_limits }.deep_symbolize_keys end end @@ -43,6 +45,66 @@ class InstanceConfiguration default: 100.megabytes }) end + def package_file_size_limits + Plan.all.to_h { |plan| [plan.name.capitalize, plan_file_size_limits(plan)] } + end + + def plan_file_size_limits(plan) + { + conan: plan.actual_limits[:conan_max_file_size], + maven: plan.actual_limits[:maven_max_file_size], + npm: plan.actual_limits[:npm_max_file_size], + nuget: plan.actual_limits[:nuget_max_file_size], + pypi: plan.actual_limits[:pypi_max_file_size], + terraform_module: plan.actual_limits[:terraform_module_max_file_size], + generic: plan.actual_limits[:generic_packages_max_file_size] + } + end + + def rate_limits + { + unauthenticated: { + enabled: application_settings[:throttle_unauthenticated_enabled], + requests_per_period: application_settings[:throttle_unauthenticated_requests_per_period], + period_in_seconds: application_settings[:throttle_unauthenticated_period_in_seconds] + }, + authenticated_api: { + enabled: application_settings[:throttle_authenticated_api_enabled], + requests_per_period: application_settings[:throttle_authenticated_api_requests_per_period], + period_in_seconds: application_settings[:throttle_authenticated_api_period_in_seconds] + }, + authenticated_web: { + enabled: application_settings[:throttle_authenticated_web_enabled], + requests_per_period: application_settings[:throttle_authenticated_web_requests_per_period], + period_in_seconds: application_settings[:throttle_authenticated_web_period_in_seconds] + }, + protected_paths: { + enabled: application_settings[:throttle_protected_paths_enabled], + requests_per_period: application_settings[:throttle_protected_paths_requests_per_period], + period_in_seconds: application_settings[:throttle_protected_paths_period_in_seconds] + }, + unauthenticated_packages_api: { + enabled: application_settings[:throttle_unauthenticated_packages_api_enabled], + requests_per_period: application_settings[:throttle_unauthenticated_packages_api_requests_per_period], + period_in_seconds: application_settings[:throttle_unauthenticated_packages_api_period_in_seconds] + }, + authenticated_packages_api: { + enabled: application_settings[:throttle_authenticated_packages_api_enabled], + requests_per_period: application_settings[:throttle_authenticated_packages_api_requests_per_period], + period_in_seconds: application_settings[:throttle_authenticated_packages_api_period_in_seconds] + }, + issue_creation: application_setting_limit_per_minute(:issues_create_limit), + note_creation: application_setting_limit_per_minute(:notes_create_limit), + project_export: application_setting_limit_per_minute(:project_export_limit), + project_export_download: application_setting_limit_per_minute(:project_download_export_limit), + project_import: application_setting_limit_per_minute(:project_import_limit), + group_export: application_setting_limit_per_minute(:group_export_limit), + group_export_download: application_setting_limit_per_minute(:group_download_export_limit), + group_import: application_setting_limit_per_minute(:group_import_limit), + raw_blob: application_setting_limit_per_minute(:raw_blob_request_limit) + } + end + def ssh_algorithm_file(algorithm) File.join(SSH_ALGORITHMS_PATH, "ssh_host_#{algorithm.downcase}_key.pub") end @@ -70,4 +132,16 @@ class InstanceConfiguration def ssh_algorithm_sha256(ssh_file_content) Gitlab::SSHPublicKey.new(ssh_file_content).fingerprint('SHA256') end + + def application_settings + Gitlab::CurrentSettings.current_application_settings + end + + def application_setting_limit_per_minute(setting) + { + enabled: application_settings[setting] > 0, + requests_per_period: application_settings[setting], + period_in_seconds: 1.minute + } + end end diff --git a/app/models/integration.rb b/app/models/integration.rb index ea1e3840f6c..a9c865569d0 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -62,15 +62,13 @@ class Integration < ApplicationRecord belongs_to :group, inverse_of: :integrations has_one :service_hook, inverse_of: :integration, foreign_key: :service_id - validates :project_id, presence: true, unless: -> { template? || instance_level? || group_level? } - validates :group_id, presence: true, unless: -> { template? || instance_level? || project_level? } - validates :project_id, :group_id, absence: true, if: -> { template? || instance_level? } + validates :project_id, presence: true, unless: -> { instance_level? || group_level? } + validates :group_id, presence: true, unless: -> { instance_level? || project_level? } + validates :project_id, :group_id, absence: true, if: -> { instance_level? } validates :type, presence: true, exclusion: BASE_CLASSES - validates :type, uniqueness: { scope: :template }, if: :template? validates :type, uniqueness: { scope: :instance }, if: :instance_level? validates :type, uniqueness: { scope: :project_id }, if: :project_level? validates :type, uniqueness: { scope: :group_id }, if: :group_level? - validate :validate_is_instance_or_template validate :validate_belongs_to_project_or_group scope :external_issue_trackers, -> { where(category: 'issue_tracker').active } @@ -79,9 +77,9 @@ class Integration < ApplicationRecord scope :by_type, -> (type) { where(type: type) } scope :by_active_flag, -> (flag) { where(active: flag) } scope :inherit_from_id, -> (id) { where(inherit_from_id: id) } - scope :inherit, -> { where.not(inherit_from_id: nil) } + scope :with_default_settings, -> { where.not(inherit_from_id: nil) } + scope :with_custom_settings, -> { where(inherit_from_id: nil) } scope :for_group, -> (group) { where(group_id: group, type: available_integration_types(include_project_specific: false)) } - scope :for_template, -> { where(template: true, type: available_integration_types(include_project_specific: false)) } scope :for_instance, -> { where(instance: true, type: available_integration_types(include_project_specific: false)) } scope :push_hooks, -> { where(push_events: true, active: true) } @@ -169,25 +167,10 @@ class Integration < ApplicationRecord 'push' end - def self.find_or_create_templates - create_nonexistent_templates - for_template + def self.event_description(event) + IntegrationsHelper.integration_event_description(event) end - def self.create_nonexistent_templates - nonexistent_integrations = build_nonexistent_integrations_for(for_template) - return if nonexistent_integrations.empty? - - # Create within a transaction to perform the lowest possible SQL queries. - transaction do - nonexistent_integrations.each do |integration| - integration.template = true - integration.save - end - end - end - private_class_method :create_nonexistent_templates - def self.find_or_initialize_non_project_specific_integration(name, instance: false, group_id: nil) return unless name.in?(available_integration_names(include_project_specific: false)) @@ -275,7 +258,6 @@ class Integration < ApplicationRecord data_fields.integration = new_integration end - new_integration.template = false new_integration.instance = false new_integration.project_id = project_id new_integration.group_id = group_id @@ -292,7 +274,7 @@ class Integration < ApplicationRecord end def self.closest_group_integration(type, scope) - group_ids = scope.ancestors.select(:id) + group_ids = scope.ancestors(hierarchy_order: :asc).select(:id) array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]' where(type: type, group_id: group_ids, inherit_from_id: nil) @@ -306,12 +288,11 @@ class Integration < ApplicationRecord end private_class_method :instance_level_integration - def self.create_from_active_default_integrations(scope, association, with_templates: false) + def self.create_from_active_default_integrations(scope, association) group_ids = sorted_ancestors(scope).select(:id) array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]' from_union([ - with_templates ? active.where(template: true) : none, active.where(instance: true), active.where(group_id: group_ids, inherit_from_id: nil) ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")).group_by(&:type).each do |type, records| @@ -384,7 +365,7 @@ class Integration < ApplicationRecord end def to_integration_hash - as_json(methods: :type, except: %w[id template instance project_id group_id]) + as_json(methods: :type, except: %w[id instance project_id group_id]) end def to_data_fields_hash @@ -503,10 +484,6 @@ class Integration < ApplicationRecord end end - def validate_is_instance_or_template - errors.add(:template, 'The service should be a service template or instance-level integration') if template? && instance_level? - 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? end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 590be52151c..1a7cbaa34c7 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -18,7 +18,7 @@ module Integrations attr_accessor :response - before_update :reset_password + before_validation :reset_password def reset_password if bamboo_url_changed? && !password_touched? diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index 27c2fcf266b..5516e6bc2c0 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -2,6 +2,7 @@ module Integrations class Datadog < Integration + include ActionView::Helpers::UrlHelper include HasWebHook extend Gitlab::Utils::Override @@ -47,11 +48,12 @@ module Integrations end def description - 'Trace your GitLab pipelines with Datadog' + s_('DatadogIntegration|Trace your GitLab pipelines with Datadog.') end def help - nil + docs_link = link_to s_('DatadogIntegration|How do I set up this integration?'), Rails.application.routes.url_helpers.help_page_url('integration/datadog'), target: '_blank', rel: 'noopener noreferrer' + s_('DatadogIntegration|Send CI/CD pipeline information to Datadog to monitor for job failures and troubleshoot performance issues. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end def self.to_param @@ -64,14 +66,19 @@ module Integrations type: 'text', name: 'datadog_site', placeholder: DEFAULT_DOMAIN, - help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site', + help: ERB::Util.html_escape( + s_('DatadogIntegration|The Datadog site to send data to. To send data to the EU site, use %{codeOpen}datadoghq.eu%{codeClose}.') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe + }, required: false }, { type: 'text', name: 'api_url', - title: 'API URL', - help: '(Advanced) Define the full URL for your Datadog site directly', + title: s_('DatadogIntegration|API URL'), + help: s_('DatadogIntegration|(Advanced) The full URL for your Datadog site.'), required: false }, { @@ -80,21 +87,34 @@ module Integrations title: _('API key'), non_empty_password_title: s_('ProjectService|Enter new API key'), non_empty_password_help: s_('ProjectService|Leave blank to use your current API key'), - help: "<a href=\"#{api_keys_url}\" target=\"_blank\">API key</a> used for authentication with Datadog", + help: ERB::Util.html_escape( + s_('DatadogIntegration|%{linkOpen}API key%{linkClose} used for authentication with Datadog.') + ) % { + linkOpen: '<a href="%s" target="_blank" rel="noopener noreferrer">'.html_safe % api_keys_url, + linkClose: '</a>'.html_safe + }, required: true }, { type: 'text', name: 'datadog_service', - title: 'Service', + title: s_('DatadogIntegration|Service'), placeholder: 'gitlab-ci', - help: 'Name of this GitLab instance that all data will be tagged with' + help: s_('DatadogIntegration|Tag all data from this GitLab instance in Datadog. Useful when managing several self-managed deployments.') }, { type: 'text', name: 'datadog_env', - title: 'Env', - help: 'The environment tag that traces will be tagged with' + title: s_('DatadogIntegration|Environment'), + placeholder: 'ci', + help: ERB::Util.html_escape( + s_('DatadogIntegration|For self-managed deployments, set the %{codeOpen}env%{codeClose} tag for all the data sent to Datadog. %{linkOpen}How do I use tags?%{linkClose}') + ) % { + codeOpen: '<code>'.html_safe, + codeClose: '</code>'.html_safe, + linkOpen: '<a href="https://docs.datadoghq.com/getting_started/tagging/#using-tags" target="_blank" rel="noopener noreferrer">'.html_safe, + linkClose: '</a>'.html_safe + } } ] end @@ -123,18 +143,18 @@ module Integrations object_kind = 'job' if object_kind == 'build' return unless supported_events.include?(object_kind) + data = data.with_retried_builds if data.respond_to?(:with_retried_builds) + execute_web_hook!(data, "#{object_kind} hook") end def test(data) - begin - result = execute(data) - return { success: false, result: result[:message] } if result[:http_status] != 200 - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result[:message] } + result = execute(data) + + { + success: (200..299).cover?(result[:http_status]), + result: result[:message] + } end private diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb index 7048dd641ea..cea4aa2038d 100644 --- a/app/models/integrations/irker.rb +++ b/app/models/integrations/irker.rb @@ -4,6 +4,8 @@ require 'uri' module Integrations class Irker < Integration + include ActionView::Helpers::UrlHelper + prop_accessor :server_host, :server_port, :default_irc_uri prop_accessor :recipients, :channels boolean_accessor :colorize_messages @@ -12,11 +14,11 @@ module Integrations before_validation :get_channels def title - 'Irker (IRC gateway)' + s_('IrkerService|irker (IRC gateway)') end def description - 'Send IRC messages.' + s_('IrkerService|Send update messages to an irker server.') end def self.to_param @@ -42,33 +44,25 @@ module Integrations end def fields + recipients_docs_link = link_to s_('IrkerService|How to enter channels or users?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'enter-irker-recipients'), target: '_blank', rel: 'noopener noreferrer' [ - { type: 'text', name: 'server_host', placeholder: 'localhost', - help: 'Irker daemon hostname (defaults to localhost)' }, - { type: 'text', name: 'server_port', placeholder: 6659, - help: 'Irker daemon port (defaults to 6659)' }, - { type: 'text', name: 'default_irc_uri', title: 'Default IRC URI', - help: 'A default IRC URI to prepend before each recipient (optional)', + { type: 'text', name: 'server_host', placeholder: 'localhost', title: s_('IrkerService|Server host (optional)'), + help: s_('IrkerService|irker daemon hostname (defaults to localhost).') }, + { type: 'text', name: 'server_port', placeholder: 6659, title: s_('IrkerService|Server port (optional)'), + help: s_('IrkerService|irker daemon port (defaults to 6659).') }, + { type: 'text', name: 'default_irc_uri', title: s_('IrkerService|Default IRC URI (optional)'), + help: s_('IrkerService|URI to add before each recipient.'), placeholder: 'irc://irc.network.net:6697/' }, - { type: 'textarea', name: 'recipients', - placeholder: 'Recipients/channels separated by whitespaces', required: true, - help: 'Recipients have to be specified with a full URI: '\ - 'irc[s]://irc.network.net[:port]/#channel. Special cases: if '\ - 'you want the channel to be a nickname instead, append ",isnick" to ' \ - 'the channel name; if the channel is protected by a secret password, ' \ - ' append "?key=secretpassword" to the URI (Note that due to a bug, if you ' \ - ' want to use a password, you have to omit the "#" on the channel). If you ' \ - ' specify a default IRC URI to prepend before each recipient, you can just ' \ - ' give a channel name.' }, - { type: 'checkbox', name: 'colorize_messages' } + { type: 'textarea', name: 'recipients', title: s_('IrkerService|Recipients'), + placeholder: 'irc[s]://irc.network.net[:port]/#channel', required: true, + help: s_('IrkerService|Channels and users separated by whitespaces. %{recipients_docs_link}').html_safe % { recipients_docs_link: recipients_docs_link.html_safe } }, + { type: 'checkbox', name: 'colorize_messages', title: _('Colorize messages') } ] end def help - ' NOTE: Irker does NOT have built-in authentication, which makes it' \ - ' vulnerable to spamming IRC channels if it is hosted outside of a ' \ - ' firewall. Please make sure you run the daemon within a secured network ' \ - ' to prevent abuse. For more details, read: http://www.catb.org/~esr/irker/security.html.' + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'set-up-an-irker-daemon'), target: '_blank', rel: 'noopener noreferrer' + s_('IrkerService|Send update messages to an irker server. Before you can use this, you need to set up the irker daemon. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end private diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb index 55fc60990f3..e5c1d5ad0d7 100644 --- a/app/models/integrations/jenkins.rb +++ b/app/models/integrations/jenkins.rb @@ -8,7 +8,7 @@ module Integrations prop_accessor :jenkins_url, :project_name, :username, :password - before_update :reset_password + before_validation :reset_password validates :jenkins_url, presence: true, addressable_url: true, if: :activated? validates :project_name, presence: true, if: :activated? diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index 1dc5c0db9e3..ec6adc87bf4 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -33,7 +33,7 @@ module Integrations data_field :username, :password, :url, :api_url, :jira_issue_transition_automatic, :jira_issue_transition_id, :project_key, :issues_enabled, :vulnerabilities_enabled, :vulnerabilities_issuetype - before_update :reset_password + before_validation :reset_password after_commit :update_deployment_type, on: [:create, :update], if: :update_deployment_type? enum comment_detail: { @@ -65,7 +65,10 @@ module Integrations end def reset_password - data_fields.password = nil if reset_password? + return unless reset_password? + + data_fields.password = nil + properties.delete('password') if properties end def set_default_data @@ -536,8 +539,7 @@ module Integrations end def update_deployment_type? - (api_url_changed? || url_changed? || username_changed? || password_changed?) && - testable? + api_url_changed? || url_changed? || username_changed? || password_changed? end def update_deployment_type diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb index 91e6800f03c..5aad25e8ddc 100644 --- a/app/models/integrations/microsoft_teams.rb +++ b/app/models/integrations/microsoft_teams.rb @@ -15,7 +15,7 @@ module Integrations end def help - '<p>Use this service to send notifications about events in GitLab projects to your Microsoft Teams channels. <a href="https://docs.gitlab.com/ee/user/project/integrations/microsoft_teams.html">How do I configure this integration?</a></p>' + '<p>Use this service to send notifications about events in GitLab projects to your Microsoft Teams channels. <a href="https://docs.gitlab.com/ee/user/project/integrations/microsoft_teams.html" target="_blank" rel="noopener noreferrer">How do I configure this integration?</a></p>' end def webhook_placeholder diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb index fb0917db02b..f616bc5faf2 100644 --- a/app/models/integrations/packagist.rb +++ b/app/models/integrations/packagist.rb @@ -18,7 +18,7 @@ module Integrations end def description - s_('Integrations|Update your Packagist projects.') + s_('Integrations|Keep your PHP dependencies updated on Packagist.') end def self.to_param @@ -27,9 +27,30 @@ module Integrations def fields [ - { type: 'text', name: 'username', placeholder: '', required: true }, - { type: 'text', name: 'token', placeholder: '', required: true }, - { type: 'text', name: 'server', placeholder: 'https://packagist.org', required: false } + { + type: 'text', + name: 'username', + title: _('Username'), + help: s_('Enter your Packagist username.'), + placeholder: '', + required: true + }, + { + type: 'text', + name: 'token', + title: _('Token'), + help: s_('Enter your Packagist token.'), + placeholder: '', + required: true + }, + { + type: 'text', + name: 'server', + title: _('Server (optional)'), + help: s_('Enter your Packagist server. Defaults to https://packagist.org.'), + placeholder: 'https://packagist.org', + required: false + } ] end diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb index b0cadc7ef4e..db39a4c68bd 100644 --- a/app/models/integrations/pushover.rb +++ b/app/models/integrations/pushover.rb @@ -21,18 +21,46 @@ module Integrations def fields [ - { type: 'text', name: 'api_key', title: _('API key'), placeholder: s_('PushoverService|Your application key'), required: true }, - { type: 'text', name: 'user_key', placeholder: s_('PushoverService|Your user key'), required: true }, - { type: 'text', name: 'device', placeholder: s_('PushoverService|Leave blank for all active devices') }, - { type: 'select', name: 'priority', required: true, choices: + { + type: 'text', + name: 'api_key', + title: _('API key'), + help: s_('PushoverService|Enter your application key.'), + placeholder: '', + required: true + }, + { + type: 'text', + name: 'user_key', + title: _('User key'), + help: s_('PushoverService|Enter your user key.'), + placeholder: '', + required: true + }, + { + type: 'text', + name: 'device', + title: _('Devices (optional)'), + help: s_('PushoverService|Leave blank for all active devices.'), + placeholder: '' + }, + { + type: 'select', + name: 'priority', + required: true, + choices: [ - [s_('PushoverService|Lowest Priority'), -2], - [s_('PushoverService|Low Priority'), -1], - [s_('PushoverService|Normal Priority'), 0], - [s_('PushoverService|High Priority'), 1] + [s_('PushoverService|Lowest priority'), -2], + [s_('PushoverService|Low priority'), -1], + [s_('PushoverService|Normal priority'), 0], + [s_('PushoverService|High priority'), 1] ], - default_choice: 0 }, - { type: 'select', name: 'sound', choices: + default_choice: 0 + }, + { + type: 'select', + name: 'sound', + choices: [ ['Device default sound', nil], ['Pushover (default)', 'pushover'], @@ -57,7 +85,8 @@ module Integrations ['Pushover Echo (long)', 'echo'], ['Up Down (long)', 'updown'], ['None (silent)', 'none'] - ] } + ] + } ] end diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index 135c304b57e..3f868b57597 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -18,7 +18,7 @@ module Integrations attr_accessor :response - before_update :reset_password + before_validation :reset_password class << self def to_param diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index 834222834e9..ad6a9164d00 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -18,7 +18,7 @@ module Integrations 'This service sends notifications about projects events to a Unify Circuit conversation.<br /> To set up this service: <ol> - <li><a href="https://www.circuit.com/unifyportalfaqdetail?articleId=164448">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li> + <li><a href="https://www.circuit.com/unifyportalfaqdetail?articleId=164448" target="_blank" rel="noopener noreferrer">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li> <li>Paste the <strong>Webhook URL</strong> into the field below.</li> <li>Select events below to enable notifications.</li> </ol>' diff --git a/app/models/internal_id.rb b/app/models/internal_id.rb index f114094d69c..a54de3c82d1 100644 --- a/app/models/internal_id.rb +++ b/app/models/internal_id.rb @@ -83,7 +83,7 @@ class InternalId < ApplicationRecord self.internal_id_transactions_total.increment( operation: operation, usage: usage.to_s, - in_transaction: ActiveRecord::Base.connection.transaction_open?.to_s + in_transaction: ActiveRecord::Base.connection.transaction_open?.to_s # rubocop: disable Database/MultipleDatabases ) end @@ -317,7 +317,7 @@ class InternalId < ApplicationRecord stmt.set(arel_table[:last_value] => new_value) stmt.wheres = InternalId.filter_by(scope, usage).arel.constraints - ActiveRecord::Base.connection.insert(stmt, 'Update InternalId', 'last_value') + ActiveRecord::Base.connection.insert(stmt, 'Update InternalId', 'last_value') # rubocop: disable Database/MultipleDatabases end def create_record!(subject, scope, usage, init) diff --git a/app/models/issuable_severity.rb b/app/models/issuable_severity.rb index 35d03a544bd..928301e1da6 100644 --- a/app/models/issuable_severity.rb +++ b/app/models/issuable_severity.rb @@ -10,6 +10,14 @@ class IssuableSeverity < ApplicationRecord critical: 'Critical - S1' }.freeze + SEVERITY_QUICK_ACTION_PARAMS = { + unknown: %w(Unknown 0), + low: %w(Low S4 4), + medium: %w(Medium S3 3), + high: %w(High S2 2), + critical: %w(Critical S1 1) + }.freeze + belongs_to :issue validates :issue, presence: true, uniqueness: true diff --git a/app/models/issue.rb b/app/models/issue.rb index d91d72e1fba..48e3fdd51e9 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -48,6 +48,7 @@ class Issue < ApplicationRecord belongs_to :duplicated_to, class_name: 'Issue' belongs_to :closed_by, class_name: 'User' belongs_to :iteration, foreign_key: 'sprint_id' + belongs_to :work_item_type, class_name: 'WorkItem::Type', inverse_of: :work_items belongs_to :moved_to, class_name: 'Issue' has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id @@ -76,6 +77,7 @@ class Issue < ApplicationRecord has_one :issuable_severity has_one :sentry_issue has_one :alert_management_alert, class_name: 'AlertManagement::Alert' + has_one :incident_management_issuable_escalation_status, class_name: 'IncidentManagement::IssuableEscalationStatus' has_and_belongs_to_many :self_managed_prometheus_alert_events, join_table: :issues_self_managed_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany has_and_belongs_to_many :prometheus_alert_events, join_table: :issues_prometheus_alert_events # rubocop: disable Rails/HasAndBelongsToMany has_many :prometheus_alerts, through: :prometheus_alert_events @@ -86,12 +88,7 @@ class Issue < ApplicationRecord validates :project, presence: true validates :issue_type, presence: true - enum issue_type: { - issue: 0, - incident: 1, - test_case: 2, ## EE-only - requirement: 3 ## EE-only - } + enum issue_type: WorkItem::Type.base_types alias_method :issuing_parent, :project @@ -134,6 +131,15 @@ class Issue < ApplicationRecord scope :public_only, -> { where(confidential: false) } scope :confidential_only, -> { where(confidential: true) } + scope :without_hidden, -> { + if Feature.enabled?(:ban_user_feature_flag) + where(id: joins('LEFT JOIN banned_users ON banned_users.user_id = issues.author_id WHERE banned_users.user_id IS NULL') + .select('issues.id')) + else + all + end + } + scope :counts_by_state, -> { reorder(nil).group(:state_id).count } scope :service_desk, -> { where(author: ::User.support_bot) } @@ -317,6 +323,21 @@ class Issue < ApplicationRecord ) end + def self.to_branch_name(*args) + branch_name = args.map(&:to_s).each_with_index.map do |arg, i| + arg.parameterize(preserve_case: i == 0).presence + end.compact.join('-') + + if branch_name.length > 100 + truncated_string = branch_name[0, 100] + # Delete everything dangling after the last hyphen so as not to risk + # existence of unintended words in the branch name due to mid-word split. + branch_name = truncated_string.sub(/-[^-]*\Z/, '') + end + + branch_name + end + # Temporary disable moving null elements because of performance problems # For more information check https://gitlab.com/gitlab-com/gl-infra/production/-/issues/4321 def check_repositioning_allowed! @@ -384,16 +405,7 @@ class Issue < ApplicationRecord if self.confidential? "#{iid}-confidential-issue" else - branch_name = "#{iid}-#{title.parameterize}" - - if branch_name.length > 100 - truncated_string = branch_name[0, 100] - # Delete everything dangling after the last hyphen so as not to risk - # existence of unintended words in the branch name due to mid-word split. - branch_name = truncated_string[0, truncated_string.rindex("-")] - end - - branch_name + self.class.to_branch_name(iid, title) end end @@ -437,10 +449,10 @@ class Issue < ApplicationRecord user, project.external_authorization_classification_label) end - def check_for_spam? + def check_for_spam?(user:) # content created via support bots is always checked for spam, EVEN if # the issue is not publicly visible and/or confidential - return true if author.support_bot? && spammable_attribute_changed? + return true if user.support_bot? && spammable_attribute_changed? # Only check for spam on issues which are publicly visible (and thus indexed in search engines) return false unless publicly_visible? @@ -549,6 +561,8 @@ class Issue < ApplicationRecord true elsif confidential? && !assignee_or_author?(user) project.team.member?(user, Gitlab::Access::REPORTER) + elsif hidden? + false else project.public? || project.internal? && !user.external? || @@ -556,6 +570,10 @@ class Issue < ApplicationRecord end end + def hidden? + author&.banned? + end + private def spammable_attribute_changed? @@ -583,7 +601,7 @@ class Issue < ApplicationRecord # Returns `true` if this Issue is visible to everybody. def publicly_visible? - project.public? && !confidential? && !::Gitlab::ExternalAuthorization.enabled? + project.public? && !confidential? && !hidden? && !::Gitlab::ExternalAuthorization.enabled? end def expire_etag_cache diff --git a/app/models/jira_connect_installation.rb b/app/models/jira_connect_installation.rb index 7480800abc3..759d44fb29e 100644 --- a/app/models/jira_connect_installation.rb +++ b/app/models/jira_connect_installation.rb @@ -11,6 +11,7 @@ class JiraConnectInstallation < ApplicationRecord validates :client_key, presence: true, uniqueness: true validates :shared_secret, presence: true validates :base_url, presence: true, public_url: true + validates :instance_url, public_url: true, allow_blank: true scope :for_project, -> (project) { distinct diff --git a/app/models/label.rb b/app/models/label.rb index 1a07620f944..a46d6bc5c0f 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -9,10 +9,6 @@ class Label < ApplicationRecord include Sortable include FromUnion include Presentable - include IgnorableColumns - - # TODO: Project#create_labels can remove column exception when this column is dropped from all envs - ignore_column :remove_on_close, remove_with: '14.1', remove_after: '2021-06-22' cache_markdown_field :description, pipeline: :single_line diff --git a/app/models/member.rb b/app/models/member.rb index 14c886e3ab8..397e60be3a8 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -12,6 +12,7 @@ class Member < ApplicationRecord include Gitlab::Utils::StrongMemoize include FromUnion include UpdateHighestRole + include RestrictedSignup AVATAR_SIZE = 40 ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT = 10 @@ -42,6 +43,7 @@ class Member < ApplicationRecord scope: [:source_type, :source_id], allow_nil: true } + validate :signup_email_valid?, on: :create, if: ->(member) { member.invite_email.present? } validates :user_id, uniqueness: { message: _('project bots cannot be added to other groups / projects') @@ -166,7 +168,7 @@ class Member < ApplicationRecord scope :on_project_and_ancestors, ->(project) { where(source: [project] + project.ancestors) } - before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } + before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? && !member.invite_accepted_at? } after_create :send_invite, if: :invite?, unless: :importing? after_create :send_request, if: :request?, unless: :importing? @@ -175,7 +177,9 @@ class Member < ApplicationRecord 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_commit :refresh_member_authorized_projects + after_save :log_invitation_token_cleanup + + after_commit :refresh_member_authorized_projects, unless: :importing? default_value_for :notification_level, NotificationSetting.levels[:global] @@ -391,11 +395,6 @@ class Member < ApplicationRecord # error or not doing any meaningful work. # rubocop: disable CodeReuse/ServiceClass def refresh_member_authorized_projects - # If user/source is being destroyed, project access are going to be - # destroyed eventually because of DB foreign keys, so we shouldn't bother - # with refreshing after each member is destroyed through association - return if destroyed_by_association.present? - UserProjectAccessChangedService.new(user_id).execute end # rubocop: enable CodeReuse/ServiceClass @@ -436,6 +435,12 @@ class Member < ApplicationRecord end end + def signup_email_valid? + error = validate_admin_signup_restrictions(invite_email) + + errors.add(:user, error) if error + end + def update_highest_role? return unless user_id.present? @@ -449,6 +454,13 @@ class Member < ApplicationRecord def project_bot? user&.project_bot? 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 end Member.prepend_mod_with('Member') diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index cf5906a4cbf..a13133c90e9 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class GroupMember < Member + extend ::Gitlab::Utils::Override include FromUnion include CreatedAtFilterable @@ -28,8 +29,6 @@ class GroupMember < Member attr_accessor :last_owner, :last_blocked_owner - self.enumerate_columns_in_select_statements = true - def self.access_level_roles Gitlab::Access.options_with_owner end @@ -51,6 +50,19 @@ class GroupMember < Member { group: group } end + override :refresh_member_authorized_projects + def refresh_member_authorized_projects + # Here, `destroyed_by_association` will be present if the + # GroupMember is being destroyed due to the `dependent: :destroy` + # callback on Group. In this case, there is no need to refresh the + # authorizations, because whenever a Group is being destroyed, + # its projects are also destroyed, so the removal of project_authorizations + # will happen behind the scenes via DB foreign keys anyway. + return if destroyed_by_association.present? + + super + end + private def access_level_inclusion diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 5040879e177..b45c0b6a0cc 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true class ProjectMember < Member + extend ::Gitlab::Utils::Override SOURCE_TYPE = 'Project' belongs_to :project, foreign_key: 'source_id' @@ -19,11 +20,6 @@ class ProjectMember < Member .where(projects: { namespace_id: groups.select(:id) }) end - scope :without_project_bots, -> do - left_join_users - .merge(User.without_project_bot) - end - class << self # Add users to projects with passed access option # @@ -48,7 +44,7 @@ class ProjectMember < Member project_ids.each do |project_id| project = Project.find(project_id) - Members::Projects::CreatorService.add_users( # rubocop:todo CodeReuse/ServiceClass + Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass project, users, access_level, @@ -94,6 +90,22 @@ class ProjectMember < Member { project: project } end + override :refresh_member_authorized_projects + def refresh_member_authorized_projects + return super unless Feature.enabled?(:specialized_service_for_project_member_auth_refresh) + return unless user + + # rubocop:disable CodeReuse/ServiceClass + AuthorizedProjectUpdate::ProjectRecalculatePerUserService.new(project, user).execute + + # Until we compare the inconsistency rates of the new, specialized service and + # the old approach, we still run AuthorizedProjectsWorker + # but with some delay and lower urgency as a safety net. + UserProjectAccessChangedService.new(user_id) + .execute(blocking: false, priority: UserProjectAccessChangedService::LOW_PRIORITY) + # rubocop:enable CodeReuse/ServiceClass + end + private def send_invite diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 7ca83d1d68c..a090ac87cc9 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -329,16 +329,16 @@ class MergeRequest < ApplicationRecord where("target_branch LIKE ?", ApplicationRecord.sanitize_sql_like(wildcard_branch_name).tr('*', '%')) end scope :by_target_branch, ->(branch_name) { where(target_branch: branch_name) } - scope :order_merged_at, ->(direction) do + scope :order_by_metric, ->(metric, direction) do reverse_direction = { 'ASC' => 'DESC', 'DESC' => 'ASC' } reversed_direction = reverse_direction[direction] || raise("Unknown sort direction was given: #{direction}") order = Gitlab::Pagination::Keyset::Order.build([ Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: 'merge_request_metrics_merged_at', - column_expression: MergeRequest::Metrics.arel_table[:merged_at], - order_expression: Gitlab::Database.nulls_last_order('merge_request_metrics.merged_at', direction), - reversed_order_expression: Gitlab::Database.nulls_first_order('merge_request_metrics.merged_at', reversed_direction), + attribute_name: "merge_request_metrics_#{metric}", + column_expression: MergeRequest::Metrics.arel_table[metric], + order_expression: Gitlab::Database.nulls_last_order("merge_request_metrics.#{metric}", direction), + reversed_order_expression: Gitlab::Database.nulls_first_order("merge_request_metrics.#{metric}", reversed_direction), order_direction: direction, nullable: :nulls_last, distinct: false, @@ -353,8 +353,10 @@ class MergeRequest < ApplicationRecord order.apply_cursor_conditions(join_metrics).order(order) end - scope :order_merged_at_asc, -> { order_merged_at('ASC') } - scope :order_merged_at_desc, -> { order_merged_at('DESC') } + scope :order_merged_at_asc, -> { order_by_metric(:merged_at, 'ASC') } + scope :order_merged_at_desc, -> { order_by_metric(:merged_at, 'DESC') } + scope :order_closed_at_asc, -> { order_by_metric(:latest_closed_at, 'ASC') } + scope :order_closed_at_desc, -> { order_by_metric(:latest_closed_at, 'DESC') } scope :preload_source_project, -> { preload(:source_project) } scope :preload_target_project, -> { preload(:target_project) } scope :preload_routables, -> do @@ -452,7 +454,9 @@ class MergeRequest < ApplicationRecord def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'merged_at', 'merged_at_asc' then order_merged_at_asc + when 'closed_at', 'closed_at_asc' then order_closed_at_asc when 'merged_at_desc' then order_merged_at_desc + when 'closed_at_desc' then order_closed_at_desc else super end diff --git a/app/models/merge_request_context_commit.rb b/app/models/merge_request_context_commit.rb index 0f2a7515462..09824ed4468 100644 --- a/app/models/merge_request_context_commit.rb +++ b/app/models/merge_request_context_commit.rb @@ -26,7 +26,7 @@ class MergeRequestContextCommit < ApplicationRecord # create MergeRequestContextCommit by given commit sha and it's diff file record def self.bulk_insert(rows, **args) - Gitlab::Database.bulk_insert('merge_request_context_commits', rows, **args) # rubocop:disable Gitlab/BulkInsert + Gitlab::Database.main.bulk_insert('merge_request_context_commits', rows, **args) # rubocop:disable Gitlab/BulkInsert end def to_commit diff --git a/app/models/merge_request_context_commit_diff_file.rb b/app/models/merge_request_context_commit_diff_file.rb index 8abedd26b06..b9efebe3af2 100644 --- a/app/models/merge_request_context_commit_diff_file.rb +++ b/app/models/merge_request_context_commit_diff_file.rb @@ -14,7 +14,7 @@ class MergeRequestContextCommitDiffFile < ApplicationRecord # create MergeRequestContextCommitDiffFile by given diff file record(s) def self.bulk_insert(*args) - Gitlab::Database.bulk_insert('merge_request_context_commit_diff_files', *args) # rubocop:disable Gitlab/BulkInsert + Gitlab::Database.main.bulk_insert('merge_request_context_commit_diff_files', *args) # rubocop:disable Gitlab/BulkInsert end def path diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index d2ea663551d..bea75927b2c 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -395,10 +395,10 @@ class MergeRequestDiff < ApplicationRecord if comparison if diff_options[:paths].blank? && !without_files? # Return the empty MergeRequestDiffBatch for an out of bound batch request - break diffs_batch if diffs_batch.diff_file_paths.blank? + break diffs_batch if diffs_batch.diff_paths.blank? diff_options.merge!( - paths: diffs_batch.diff_file_paths, + paths: diffs_batch.diff_paths, pagination_data: diffs_batch.pagination_data ) end @@ -515,7 +515,7 @@ class MergeRequestDiff < ApplicationRecord transaction do MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all - Gitlab::Database.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert + Gitlab::Database.main.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert save! end @@ -535,7 +535,7 @@ class MergeRequestDiff < ApplicationRecord transaction do MergeRequestDiffFile.where(merge_request_diff_id: id).delete_all - Gitlab::Database.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert + Gitlab::Database.main.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert update!(stored_externally: false) end @@ -595,7 +595,7 @@ class MergeRequestDiff < ApplicationRecord rows = build_external_merge_request_diff_files(rows) if use_external_diff? # Faster inserts - Gitlab::Database.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert + Gitlab::Database.main.bulk_insert('merge_request_diff_files', rows) # rubocop:disable Gitlab/BulkInsert end def build_external_diff_tempfile(rows) diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index 466d28301c0..d9a1784cdda 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -63,7 +63,7 @@ class MergeRequestDiffCommit < ApplicationRecord ) end - Gitlab::Database.bulk_insert(self.table_name, rows) # rubocop:disable Gitlab/BulkInsert + Gitlab::Database.main.bulk_insert(self.table_name, rows) # rubocop:disable Gitlab/BulkInsert end def self.prepare_commits_for_bulk_insert(commits) diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 2168d57693e..0e2842c3c11 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -61,10 +61,38 @@ class Milestone < ApplicationRecord end def self.reference_pattern + if Feature.enabled?(:milestone_reference_pattern, default_enabled: :yaml) + new_reference_pattern + else + old_reference_pattern + end + end + + def self.new_reference_pattern + # NOTE: The iid pattern only matches when all characters on the expression + # are digits, so it will match %2 but not %2.1 because that's probably a + # milestone name and we want it to be matched as such. + @new_reference_pattern ||= %r{ + (#{Project.reference_pattern})? + #{Regexp.escape(reference_prefix)} + (?: + (?<milestone_iid> + \d+(?!\S\w)\b # Integer-based milestone iid, or + ) | + (?<milestone_name> + [^"\s\<]+\b | # String-based single-word milestone title, or + "[^"]+" # String-based multi-word milestone surrounded in quotes + ) + ) + }x + end + + # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/336268 + def self.old_reference_pattern # NOTE: The iid pattern only matches when all characters on the expression # are digits, so it will match %2 but not %2.1 because that's probably a # milestone name and we want it to be matched as such. - @reference_pattern ||= %r{ + @old_reference_pattern ||= %r{ (#{Project.reference_pattern})? #{Regexp.escape(reference_prefix)} (?: diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 5524fec5324..261639a4ec1 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -24,6 +24,7 @@ class Namespace < ApplicationRecord NUMBER_OF_ANCESTORS_ALLOWED = 20 SHARED_RUNNERS_SETTINGS = %w[disabled_and_unoverridable disabled_with_override enabled].freeze + URL_MAX_LENGTH = 255 cache_markdown_field :description, pipeline: :description @@ -33,6 +34,7 @@ class Namespace < ApplicationRecord 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' has_one :onboarding_progress # This should _not_ be `inverse_of: :namespace`, because that would also set @@ -58,7 +60,7 @@ class Namespace < ApplicationRecord validates :description, length: { maximum: 255 } validates :path, presence: true, - length: { maximum: 255 }, + length: { maximum: URL_MAX_LENGTH }, namespace_path: true # Introduce minimal path length of 2 characters. @@ -464,10 +466,34 @@ class Namespace < ApplicationRecord end def refresh_access_of_projects_invited_groups - Group - .joins(project_group_links: :project) - .where(projects: { namespace_id: id }) - .find_each(&:refresh_members_authorized_projects) + if Feature.enabled?(:specialized_worker_for_group_lock_update_auth_recalculation) + Project + .where(namespace_id: id) + .joins(:project_group_links) + .distinct + .find_each do |project| + AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id) + end + + # Until we compare the inconsistency rates of the new specialized worker and + # the old approach, we still run AuthorizedProjectsWorker + # but with some delay and lower urgency as a safety net. + Group + .joins(project_group_links: :project) + .where(projects: { namespace_id: id }) + .distinct + .find_each do |group| + group.refresh_members_authorized_projects( + blocking: false, + priority: UserProjectAccessChangedService::LOW_PRIORITY + ) + end + else + Group + .joins(project_group_links: :project) + .where(projects: { namespace_id: id }) + .find_each(&:refresh_members_authorized_projects) + end end def nesting_level_allowed @@ -503,7 +529,7 @@ class Namespace < ApplicationRecord def write_projects_repository_config all_projects.find_each do |project| - project.write_repository_config + project.set_full_path project.track_project_repository end end diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index fc890bf687c..4a39bfebda0 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -11,6 +11,9 @@ class NamespaceSetting < ApplicationRecord validate :allow_mfa_for_group validate :allow_resource_access_token_creation_for_group + before_save :set_prevent_sharing_groups_outside_hierarchy, if: -> { user_cap_enabled? } + after_save :disable_project_sharing!, if: -> { user_cap_enabled? } + before_validation :normalize_default_branch_name NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal, @@ -19,10 +22,20 @@ class NamespaceSetting < ApplicationRecord self.primary_key = :namespace_id + def prevent_sharing_groups_outside_hierarchy + return super if namespace.root? + + namespace.root_ancestor.prevent_sharing_groups_outside_hierarchy + end + private def normalize_default_branch_name - self.default_branch_name = nil if default_branch_name.blank? + self.default_branch_name = if default_branch_name.blank? + nil + else + Sanitize.fragment(self.default_branch_name) + end end def default_branch_name_content @@ -44,6 +57,18 @@ class NamespaceSetting < ApplicationRecord errors.add(:resource_access_token_creation_allowed, _('is not allowed since the group is not top-level group.')) end end + + def set_prevent_sharing_groups_outside_hierarchy + self.prevent_sharing_groups_outside_hierarchy = true + end + + def disable_project_sharing! + namespace.update_attribute(:share_with_group_lock, true) + end + + def user_cap_enabled? + new_user_signups_cap.present? && namespace.root? + end end NamespaceSetting.prepend_mod_with('NamespaceSetting') diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 3d78f384634..33e8c3e5172 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -37,6 +37,7 @@ module Namespaces module Traversal module Linear extend ActiveSupport::Concern + include LinearScopes UnboundedSearch = Class.new(StandardError) @@ -44,14 +45,6 @@ module Namespaces before_update :lock_both_roots, if: -> { sync_traversal_ids? && parent_id_changed? } after_create :sync_traversal_ids, if: -> { sync_traversal_ids? } after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? } - - scope :traversal_ids_contains, ->(ids) { where("traversal_ids @> (?)", ids) } - # When filtering namespaces by the traversal_ids column to compile a - # list of namespace IDs, it's much faster to reference the ID in - # traversal_ids than the primary key ID column. - # WARNING This scope must be used behind a linear query feature flag - # such as `use_traversal_ids`. - scope :as_ids, -> { select('traversal_ids[array_length(traversal_ids, 1)] AS id') } end def sync_traversal_ids? @@ -59,7 +52,7 @@ module Namespaces end def use_traversal_ids? - return false unless Feature.enabled?(:use_traversal_ids, root_ancestor, default_enabled: :yaml) + return false unless Feature.enabled?(:use_traversal_ids, default_enabled: :yaml) traversal_ids.present? end @@ -164,20 +157,14 @@ module Namespaces Namespace.lock.select(:id).where(id: roots).order(id: :asc).load end - # Make sure we drop the STI `type = 'Group'` condition for better performance. - # Logically equivalent so long as hierarchies remain homogeneous. - def without_sti_condition - self.class.unscope(where: :type) - end - # Search this namespace's lineage. Bound inclusively by top node. def lineage(top: nil, bottom: nil, hierarchy_order: nil) raise UnboundedSearch, 'Must bound search by either top or bottom' unless top || bottom - skope = without_sti_condition + skope = self.class.without_sti_condition if top - skope = skope.traversal_ids_contains("{#{top.id}}") + skope = skope.where("traversal_ids @> ('{?}')", top.id) end if bottom @@ -190,7 +177,13 @@ module Namespaces if hierarchy_order depth_sql = "ABS(#{traversal_ids.count} - array_length(traversal_ids, 1))" skope = skope.select(skope.arel_table[Arel.star], "#{depth_sql} as depth") - .order(depth: hierarchy_order) + # The SELECT includes an extra depth attribute. We wrap the SQL in a + # standard SELECT to avoid mismatched attribute errors when trying to + # chain future ActiveRelation commands, and retain the ordering. + skope = self.class + .without_sti_condition + .from(skope, self.class.table_name) + .order(depth: hierarchy_order) end skope diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb new file mode 100644 index 00000000000..90fae8ef35d --- /dev/null +++ b/app/models/namespaces/traversal/linear_scopes.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module Namespaces + module Traversal + module LinearScopes + extend ActiveSupport::Concern + + class_methods do + # When filtering namespaces by the traversal_ids column to compile a + # list of namespace IDs, it can be faster to reference the ID in + # traversal_ids than the primary key ID column. + def as_ids + return super unless use_traversal_ids? + + select('namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id') + end + + def self_and_descendants(include_self: true) + return super unless use_traversal_ids? + + records = self_and_descendants_with_duplicates(include_self: include_self) + + distinct = records.select('DISTINCT on(namespaces.id) namespaces.*') + + # Produce a query of the form: SELECT * FROM namespaces; + # + # When we have queries that break this SELECT * format we can run in to errors. + # For example `SELECT DISTINCT on(...)` will fail when we chain a `.count` c + unscoped.without_sti_condition.from(distinct, :namespaces) + end + + def self_and_descendant_ids(include_self: true) + return super unless use_traversal_ids? + + self_and_descendants_with_duplicates(include_self: include_self) + .select('DISTINCT namespaces.id') + end + + # Make sure we drop the STI `type = 'Group'` condition for better performance. + # Logically equivalent so long as hierarchies remain homogeneous. + def without_sti_condition + unscope(where: :type) + end + + private + + def use_traversal_ids? + Feature.enabled?(:use_traversal_ids, default_enabled: :yaml) + end + + def self_and_descendants_with_duplicates(include_self: true) + base_ids = select(:id) + + records = unscoped + .without_sti_condition + .from("namespaces, (#{base_ids.to_sql}) base") + .where('namespaces.traversal_ids @> ARRAY[base.id]') + + if include_self + records + else + records.where('namespaces.id <> base.id') + end + end + end + end + end +end diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb index d9e8743aa50..c1ada715d6d 100644 --- a/app/models/namespaces/traversal/recursive.rb +++ b/app/models/namespaces/traversal/recursive.rb @@ -4,6 +4,7 @@ module Namespaces module Traversal module Recursive extend ActiveSupport::Concern + include RecursiveScopes def root_ancestor return self if parent.nil? diff --git a/app/models/namespaces/traversal/recursive_scopes.rb b/app/models/namespaces/traversal/recursive_scopes.rb new file mode 100644 index 00000000000..be49d5d9d55 --- /dev/null +++ b/app/models/namespaces/traversal/recursive_scopes.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Namespaces + module Traversal + module RecursiveScopes + extend ActiveSupport::Concern + + class_methods do + def as_ids + select('id') + end + + def descendant_ids + recursive_descendants.as_ids + end + alias_method :recursive_descendant_ids, :descendant_ids + + def self_and_descendants(include_self: true) + base = if include_self + unscoped.where(id: all.as_ids) + else + unscoped.where(parent_id: all.as_ids) + end + + Gitlab::ObjectHierarchy.new(base).base_and_descendants + end + alias_method :recursive_self_and_descendants, :self_and_descendants + + def self_and_descendant_ids(include_self: true) + self_and_descendants(include_self: include_self).as_ids + end + alias_method :recursive_self_and_descendant_ids, :self_and_descendant_ids + end + end + end +end diff --git a/app/models/note.rb b/app/models/note.rb index 2ad6df85e5f..34ffd7c91af 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -5,6 +5,8 @@ # A note of this type is never resolvable. class Note < ApplicationRecord extend ActiveModel::Naming + extend Gitlab::Utils::Override + include Gitlab::Utils::StrongMemoize include Participable include Mentionable @@ -576,6 +578,29 @@ class Note < ApplicationRecord review.present? || !author.can_trigger_notifications? end + def post_processed_cache_key + cache_key_items = [cache_key, author.cache_key] + cache_key_items << Digest::SHA1.hexdigest(redacted_note_html) if redacted_note_html.present? + + cache_key_items.join(':') + end + + override :user_mention_class + def user_mention_class + return if noteable.blank? + + noteable.user_mention_class + end + + override :user_mention_identifier + def user_mention_identifier + return if noteable.blank? + + noteable.user_mention_identifier.merge({ + note_id: id + }) + end + private # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 4323f89865a..2e45753c182 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -16,7 +16,7 @@ class NotificationSetting < ApplicationRecord validates :user_id, uniqueness: { scope: [:source_type, :source_id], message: "already exists in source", allow_nil: true } - validate :owns_notification_email, if: :notification_email_changed? + validate :notification_email_verified, if: :notification_email_changed? scope :for_groups, -> { where(source_type: 'Namespace') } @@ -110,11 +110,11 @@ class NotificationSetting < ApplicationRecord has_attribute?(event) && !!read_attribute(event) end - def owns_notification_email + def notification_email_verified return if user.temp_oauth_email? return if notification_email.empty? - errors.add(:notification_email, _("is not an email you own")) unless user.verified_emails.include?(notification_email) + errors.add(:notification_email, _("must be an email you have verified")) unless user.verified_emails.include?(notification_email) end end diff --git a/app/models/operations/feature_flags/strategy.rb b/app/models/operations/feature_flags/strategy.rb index c70e10c72d5..ed9400dde8f 100644 --- a/app/models/operations/feature_flags/strategy.rb +++ b/app/models/operations/feature_flags/strategy.rb @@ -16,7 +16,7 @@ module Operations STRATEGY_USERWITHID => ['userIds'].freeze }.freeze USERID_MAX_LENGTH = 256 - STICKINESS_SETTINGS = %w[DEFAULT USERID SESSIONID RANDOM].freeze + STICKINESS_SETTINGS = %w[default userId sessionId random].freeze self.table_name = 'operations_strategies' diff --git a/app/models/packages/debian.rb b/app/models/packages/debian.rb index e20f1b8244a..2daafe0ebcf 100644 --- a/app/models/packages/debian.rb +++ b/app/models/packages/debian.rb @@ -6,6 +6,8 @@ module Packages COMPONENT_REGEX = DISTRIBUTION_REGEX.freeze ARCHITECTURE_REGEX = %r{[a-z0-9][-a-z0-9]*}.freeze + LETTER_REGEX = %r{(lib)?[a-z0-9]}.freeze + def self.table_name_prefix 'packages_debian_' end diff --git a/app/models/packages/event.rb b/app/models/packages/event.rb index a1eb7120117..bb2c33594e5 100644 --- a/app/models/packages/event.rb +++ b/app/models/packages/event.rb @@ -4,7 +4,7 @@ class Packages::Event < ApplicationRecord belongs_to :package, optional: true UNIQUE_EVENTS_ALLOWED = %i[push_package delete_package pull_package pull_symbol_package push_symbol_package].freeze - EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001).freeze + EVENT_SCOPES = ::Packages::Package.package_types.merge(container: 1000, tag: 1001, dependency_proxy: 1002).freeze EVENT_PREFIX = "i_package" @@ -23,7 +23,11 @@ class Packages::Event < ApplicationRecord list_tags: 9, cli_metadata: 10, pull_symbol_package: 11, - push_symbol_package: 12 + push_symbol_package: 12, + pull_manifest: 13, + pull_manifest_from_cache: 14, + pull_blob: 15, + pull_blob_from_cache: 16 } enum originator_type: { user: 0, deploy_token: 1, guest: 2 } diff --git a/app/models/packages/npm.rb b/app/models/packages/npm.rb new file mode 100644 index 00000000000..e49199d911c --- /dev/null +++ b/app/models/packages/npm.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true +module Packages + module Npm + # from "@scope/package-name" return "scope" or nil + def self.scope_of(package_name) + return unless package_name + return unless package_name.starts_with?('@') + return unless package_name.include?('/') + + package_name.match(Gitlab::Regex.npm_package_name_regex)&.captures&.first + end + end +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index d2e4f46898c..4ea127fc222 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -62,7 +62,7 @@ class Packages::Package < ApplicationRecord validate :valid_conan_package_recipe, if: :conan? validate :valid_composer_global_name, if: :composer? - validate :package_already_taken, if: :npm? + validate :npm_package_already_taken, if: :npm? validates :name, format: { with: Gitlab::Regex.conan_recipe_component_regex }, if: :conan? validates :name, format: { with: Gitlab::Regex.generic_package_name_regex }, if: :generic? validates :name, format: { with: Gitlab::Regex.helm_package_regex }, if: :helm? @@ -320,14 +320,22 @@ class Packages::Package < ApplicationRecord end end - def package_already_taken + def npm_package_already_taken return unless project + return unless follows_npm_naming_convention? - if project.package_already_taken?(name) + if project.package_already_taken?(name, version, package_type: :npm) errors.add(:base, _('Package already exists')) end end + # https://docs.gitlab.com/ee/user/packages/npm_registry/#package-naming-convention + def follows_npm_naming_convention? + return false unless project&.root_namespace&.path + + project.root_namespace.path == ::Packages::Npm.scope_of(name) + end + def unique_debian_package_name return unless debian_publication&.distribution diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index 799242a639a..8aa19397086 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -5,11 +5,14 @@ class Packages::PackageFile < ApplicationRecord delegate :project, :project_id, to: :package delegate :conan_file_type, to: :conan_file_metadatum - delegate :file_type, :component, :architecture, :fields, to: :debian_file_metadatum, prefix: :debian + delegate :file_type, :dsc?, :component, :architecture, :fields, to: :debian_file_metadatum, prefix: :debian delegate :channel, :metadata, to: :helm_file_metadatum, prefix: :helm belongs_to :package + # used to move the linked file within object storage + attribute :new_file_path, default: nil + has_one :conan_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Conan::FileMetadatum' has_many :package_file_build_infos, inverse_of: :package_file, class_name: 'Packages::PackageFileBuildInfo' has_many :pipelines, through: :package_file_build_infos @@ -33,6 +36,8 @@ class Packages::PackageFile < ApplicationRecord scope :with_file_name_like, ->(file_name) { where(arel_table[:file_name].matches(file_name)) } scope :with_files_stored_locally, -> { where(file_store: ::Packages::PackageFileUploader::Store::LOCAL) } scope :with_format, ->(format) { where(::Packages::PackageFile.arel_table[:file_name].matches("%.#{format}")) } + + scope :preload_package, -> { preload(:package) } scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) } scope :preload_debian_file_metadata, -> { preload(:debian_file_metadatum) } scope :preload_helm_file_metadata, -> { preload(:helm_file_metadatum) } @@ -78,6 +83,12 @@ class Packages::PackageFile < ApplicationRecord before_save :update_size_from_file + # if a new_file_path is provided, we need + # * disable the remove_previously_stored_file callback so that carrierwave doesn't take care of the file + # * enable a new after_commit callback that will move the file in object storage + skip_callback :commit, :after, :remove_previously_stored_file, if: :execute_move_in_object_storage? + after_commit :move_in_object_storage, if: :execute_move_in_object_storage? + def download_path Gitlab::Routing.url_helpers.download_project_package_file_path(project, self) end @@ -87,6 +98,17 @@ class Packages::PackageFile < ApplicationRecord def update_size_from_file self.size ||= file.size end + + def execute_move_in_object_storage? + !file.file_storage? && new_file_path? + end + + def move_in_object_storage + carrierwave_file = file.file + + carrierwave_file.copy_to(new_file_path) + carrierwave_file.delete + end end Packages::PackageFile.prepend_mod_with('Packages::PackageFile') diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 732ed0b7bb3..1778e927dd1 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -47,6 +47,10 @@ class PersonalAccessToken < ApplicationRecord !revoked? && !expired? end + def expired_but_not_enforced? + false + end + def self.redis_getdel(user_id) Gitlab::Redis::SharedState.with do |redis| redis_key = redis_shared_state_key(user_id) diff --git a/app/models/postgresql/detached_partition.rb b/app/models/postgresql/detached_partition.rb new file mode 100644 index 00000000000..76b299ff9d4 --- /dev/null +++ b/app/models/postgresql/detached_partition.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module Postgresql + class DetachedPartition < ApplicationRecord + scope :ready_to_drop, -> { where('drop_after < ?', Time.current) } + end +end diff --git a/app/models/postgresql/replication_slot.rb b/app/models/postgresql/replication_slot.rb index 77b42c34ad9..1a4d3bd5794 100644 --- a/app/models/postgresql/replication_slot.rb +++ b/app/models/postgresql/replication_slot.rb @@ -39,5 +39,55 @@ module Postgresql false end end + + def self.count + connection + .execute("SELECT COUNT(*) FROM pg_replication_slots;") + .first + .fetch('count') + .to_i + end + + def self.unused_slots_count + connection + .execute("SELECT COUNT(*) FROM pg_replication_slots WHERE active = 'f';") + .first + .fetch('count') + .to_i + end + + def self.used_slots_count + connection + .execute("SELECT COUNT(*) FROM pg_replication_slots WHERE active = 't';") + .first + .fetch('count') + .to_i + end + + # array of slots and the retained_bytes + # https://www.skillslogic.com/blog/databases/checking-postgres-replication-lag + # http://bdr-project.org/docs/stable/monitoring-peers.html + def self.slots_retained_bytes + connection.execute(<<-SQL.squish).to_a + SELECT slot_name, database, + active, pg_wal_lsn_diff(pg_current_wal_insert_lsn(), restart_lsn) + AS retained_bytes + FROM pg_replication_slots; + SQL + end + + # returns the max number WAL space (in bytes) being used across the replication slots + def self.max_retained_wal + connection.execute(<<-SQL.squish).first.fetch('coalesce').to_i + SELECT COALESCE(MAX(pg_wal_lsn_diff(pg_current_wal_insert_lsn(), restart_lsn)), 0) + FROM pg_replication_slots; + SQL + end + + def self.max_replication_slots + connection.execute(<<-SQL.squish).first&.fetch('setting').to_i + SELECT setting FROM pg_settings WHERE name = 'max_replication_slots'; + SQL + end end end diff --git a/app/models/preloaders/user_max_access_level_in_projects_preloader.rb b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb index c0ed56057ae..3764e9dcb16 100644 --- a/app/models/preloaders/user_max_access_level_in_projects_preloader.rb +++ b/app/models/preloaders/user_max_access_level_in_projects_preloader.rb @@ -10,9 +10,13 @@ module Preloaders end def execute + # Use reselect to override the existing select to prevent + # the error `subquery has too many columns` + # NotificationsController passes in an Array so we need to check the type + project_ids = @projects.is_a?(ActiveRecord::Relation) ? @projects.reselect(:id) : @projects access_levels = @user .project_authorizations - .where(project_id: @projects) + .where(project_id: project_ids) .group(:project_id) .maximum(:access_level) diff --git a/app/models/project.rb b/app/models/project.rb index c5522737b87..81b04e1316c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -43,8 +43,13 @@ class Project < ApplicationRecord extend Gitlab::ConfigHelper + ignore_columns :container_registry_enabled, remove_after: '2021-09-22', remove_with: '14.4' + BoardLimitExceeded = Class.new(StandardError) + ignore_columns :mirror_last_update_at, :mirror_last_successful_update_at, remove_after: '2021-09-22', remove_with: '14.4' + ignore_columns :pull_mirror_branch_prefix, remove_after: '2021-09-22', remove_with: '14.4' + STATISTICS_ATTRIBUTE = 'repositories_count' UNKNOWN_IMPORT_URL = 'http://unknown.git' # Hashed Storage versions handle rolling out new storage to project and dependents models: @@ -73,7 +78,6 @@ class Project < ApplicationRecord default_value_for :packages_enabled, true default_value_for :archived, false default_value_for :resolve_outdated_diff_discussions, false - default_value_for :container_registry_enabled, gitlab_config_features.container_registry default_value_for(:repository_storage) do Repository.pick_storage_shard end @@ -95,9 +99,6 @@ class Project < ApplicationRecord before_save :ensure_runners_token - # https://api.rubyonrails.org/v6.0.3.4/classes/ActiveRecord/AttributeMethods/Dirty.html#method-i-will_save_change_to_attribute-3F - before_update :set_container_registry_access_level, if: :will_save_change_to_container_registry_enabled? - after_save :update_project_statistics, if: :saved_change_to_namespace_id? after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? } @@ -318,7 +319,6 @@ class Project < ApplicationRecord # still using `dependent: :destroy` here. has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :processables, class_name: 'Ci::Processable', inverse_of: :project - has_many :build_trace_section_names, class_name: 'Ci::BuildTraceSectionName' has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project has_many :job_artifacts, class_name: 'Ci::JobArtifact' @@ -378,6 +378,7 @@ class Project < ApplicationRecord has_many :operations_feature_flags_user_lists, class_name: 'Operations::FeatureFlags::UserList' has_many :error_tracking_errors, inverse_of: :project, class_name: 'ErrorTracking::Error' + has_many :error_tracking_client_keys, inverse_of: :project, class_name: 'ErrorTracking::ClientKey' has_many :timelogs @@ -436,7 +437,7 @@ class Project < ApplicationRecord delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, - :allow_merge_on_skipped_pipeline=, :has_confluence?, :allow_editing_commit_messages?, + :allow_merge_on_skipped_pipeline=, :has_confluence?, to: :project_setting delegate :active?, to: :prometheus_integration, allow_nil: true, prefix: true @@ -538,10 +539,8 @@ class Project < ApplicationRecord scope :visible_to_user_and_access_level, ->(user, access_level) { where(id: user.authorized_projects.where('project_authorizations.access_level >= ?', access_level).select(:id).reorder(nil)) } scope :archived, -> { where(archived: true) } scope :non_archived, -> { where(archived: false) } - scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct } scope :with_push, -> { joins(:events).merge(Event.pushed_action) } scope :with_project_feature, -> { joins('LEFT JOIN project_features ON projects.id = project_features.project_id') } - scope :with_active_jira_integrations, -> { joins(:integrations).merge(::Integrations::Jira.active) } scope :with_jira_dvcs_cloud, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: true)) } scope :with_jira_dvcs_server, -> { joins(:feature_usage).merge(ProjectFeatureUsage.with_jira_dvcs_integration_enabled(cloud: false)) } scope :inc_routes, -> { includes(:route, namespace: :route) } @@ -549,7 +548,9 @@ class Project < ApplicationRecord scope :with_namespace, -> { includes(:namespace) } scope :with_import_state, -> { includes(:import_state) } scope :include_project_feature, -> { includes(:project_feature) } - scope :with_integration, ->(integration) { joins(integration).eager_load(integration) } + scope :include_integration, -> (integration_association_name) { includes(integration_association_name) } + scope :with_integration, -> (integration_class) { joins(:integrations).merge(integration_class.all) } + scope :with_active_integration, -> (integration_class) { with_integration(integration_class).merge(integration_class.active) } scope :with_shared_runners, -> { where(shared_runners_enabled: true) } scope :inside_path, ->(path) do # We need routes alias rs for JOIN so it does not conflict with @@ -913,7 +914,13 @@ class Project < ApplicationRecord .base_and_ancestors(upto: top, hierarchy_order: hierarchy_order) end - alias_method :ancestors, :ancestors_upto + def ancestors(hierarchy_order: nil) + if Feature.enabled?(:linear_project_ancestors, self, default_enabled: :yaml) + group&.self_and_ancestors(hierarchy_order: hierarchy_order) || Group.none + else + ancestors_upto(hierarchy_order: hierarchy_order) + end + end def ancestors_upto_ids(...) ancestors_upto(...).pluck(:id) @@ -1180,6 +1187,15 @@ class Project < ApplicationRecord import_type == 'gitea' end + def github_import? + import_type == 'github' + end + + def github_enterprise_import? + github_import? && + URI.parse(import_url).host != URI.parse(Octokit::Default::API_ENDPOINT).host + end + def has_remote_mirror? remote_mirror_available? && remote_mirrors.enabled.exists? end @@ -1411,14 +1427,13 @@ class Project < ApplicationRecord def find_or_initialize_integration(name) return if disabled_integrations.include?(name) - find_integration(integrations, name) || build_from_instance_or_template(name) || build_integration(name) + find_integration(integrations, name) || build_from_instance(name) || build_integration(name) end # rubocop: disable CodeReuse/ServiceClass def create_labels Label.templates.each do |label| - # TODO: remove_on_close exception can be removed after the column is dropped from all envs - params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type', 'remove_on_close') + params = label.attributes.except('id', 'template', 'created_at', 'updated_at', 'type') Labels::FindOrCreateService.new(nil, self, params).execute(skip_authorization: true) end end @@ -1876,11 +1891,11 @@ class Project < ApplicationRecord .update_all(deployed: deployment.present?, pages_deployment_id: deployment&.id) end - def write_repository_config(gl_full_path: full_path) + def set_full_path(gl_full_path: full_path) # We'd need to keep track of project full path otherwise directory tree # created with hashed storage enabled cannot be usefully imported using # the import rake task. - repository.raw_repository.write_config(full_path: gl_full_path) + repository.raw_repository.set_full_path(full_path: gl_full_path) rescue Gitlab::Git::Repository::NoRepository => e Gitlab::AppLogger.error("Error writing to .git/config for project #{full_path} (#{id}): #{e.message}.") nil @@ -1892,6 +1907,7 @@ class Project < ApplicationRecord DetectRepositoryLanguagesWorker.perform_async(id) ProjectCacheWorker.perform_async(self.id, [], [:repository_size]) + AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(id) # The import assigns iid values on its own, e.g. by re-using GitHub ids. # Flush existing InternalId records for this project for consistency reasons. @@ -1904,7 +1920,7 @@ class Project < ApplicationRecord after_create_default_branch join_pool_repository refresh_markdown_cache! - write_repository_config + set_full_path end def update_project_counter_caches @@ -2030,6 +2046,7 @@ class Project < ApplicationRecord .append(key: 'CI_PROJECT_URL', value: web_url) .append(key: 'CI_PROJECT_VISIBILITY', value: Gitlab::VisibilityLevel.string_level(visibility_level)) .append(key: 'CI_PROJECT_REPOSITORY_LANGUAGES', value: repository_languages.map(&:name).join(',').downcase) + .append(key: 'CI_PROJECT_CLASSIFICATION_LABEL', value: external_authorization_classification_label) .append(key: 'CI_DEFAULT_BRANCH', value: default_branch) .append(key: 'CI_CONFIG_PATH', value: ci_config_path_or_default) end @@ -2557,12 +2574,15 @@ class Project < ApplicationRecord [project&.id, root_group&.id] end - def package_already_taken?(package_name) - namespace.root_ancestor.all_projects - .joins(:packages) - .where.not(id: id) - .merge(Packages::Package.default_scoped.with_name(package_name)) - .exists? + def package_already_taken?(package_name, package_version, package_type:) + Packages::Package.with_name(package_name) + .with_version(package_version) + .with_package_type(package_type) + .for_projects( + root_ancestor.all_projects + .id_not_in(id) + .select(:id) + ).exists? end def default_branch_or_main @@ -2651,40 +2671,22 @@ class Project < ApplicationRecord private - def set_container_registry_access_level - # changes_to_save = { 'container_registry_enabled' => [value_before_update, value_after_update] } - value = changes_to_save['container_registry_enabled'][1] - - access_level = - if value - ProjectFeature::ENABLED - else - ProjectFeature::DISABLED - end - - project_feature.update!(container_registry_access_level: access_level) - end - def find_integration(integrations, name) integrations.find { _1.to_param == name } end - def build_from_instance_or_template(name) + def build_from_instance(name) instance = find_integration(integration_instances, name) - return Integration.build_from_integration(instance, project_id: id) if instance - template = find_integration(integration_templates, name) - return Integration.build_from_integration(template, project_id: id) if template + return unless instance + + Integration.build_from_integration(instance, project_id: id) end def build_integration(name) Integration.integration_name_to_model(name).new(project_id: id) end - def integration_templates - @integration_templates ||= Integration.for_template - end - def integration_instances @integration_instances ||= Integration.for_instance end diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index f6e889396c6..aea8abecd74 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -2,6 +2,7 @@ class ProjectFeature < ApplicationRecord include Featurable + extend Gitlab::ConfigHelper # When updating this array, make sure to update rubocop/cop/gitlab/feature_available_usage.rb as well. FEATURES = %i[ @@ -48,12 +49,7 @@ class ProjectFeature < ApplicationRecord end end - before_create :set_container_registry_access_level - - # Default scopes force us to unscope here since a service may need to check - # permissions for a project in pending_delete - # http://stackoverflow.com/questions/1540645/how-to-disable-default-scope-for-a-belongs-to - belongs_to :project, -> { unscope(where: :pending_delete) } + belongs_to :project validates :project, presence: true @@ -80,6 +76,14 @@ class ProjectFeature < ApplicationRecord end end + default_value_for(:container_registry_access_level) do |feature| + if gitlab_config_features.container_registry + ENABLED + else + DISABLED + end + end + def public_pages? return true unless Gitlab.config.pages.access_control @@ -94,15 +98,6 @@ class ProjectFeature < ApplicationRecord private - def set_container_registry_access_level - self.container_registry_access_level = - if project&.read_attribute(:container_registry_enabled) - ENABLED - else - DISABLED - end - end - # Validates builds and merge requests access level # which cannot be higher than repository access level def repository_children_level diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 24d892290a6..b2559636f32 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -1,6 +1,10 @@ # frozen_string_literal: true class ProjectSetting < ApplicationRecord + include IgnorableColumns + + ignore_column :allow_editing_commit_messages, remove_with: '14.4', remove_after: '2021-09-10' + belongs_to :project, inverse_of: :project_setting enum squash_option: { diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 4586aa2b4b4..4ae3bc01a01 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -42,7 +42,7 @@ class ProjectTeam end def add_users(users, access_level, current_user: nil, expires_at: nil) - Members::Projects::CreatorService.add_users( # rubocop:todo CodeReuse/ServiceClass + Members::Projects::CreatorService.add_users( # rubocop:disable CodeReuse/ServiceClass project, users, access_level, @@ -52,7 +52,7 @@ class ProjectTeam end def add_user(user, access_level, current_user: nil, expires_at: nil) - Members::Projects::CreatorService.new(project, # rubocop:todo CodeReuse/ServiceClass + Members::Projects::CreatorService.new(project, # rubocop:disable CodeReuse/ServiceClass user, access_level, current_user: current_user, @@ -78,6 +78,10 @@ class ProjectTeam members.where(id: member_user_ids) end + def members_with_access_levels(access_levels = []) + fetch_members(access_levels) + end + def guests @guests ||= fetch_members(Gitlab::Access::GUEST) end diff --git a/app/models/projects/ci_feature_usage.rb b/app/models/projects/ci_feature_usage.rb new file mode 100644 index 00000000000..a10426e50c9 --- /dev/null +++ b/app/models/projects/ci_feature_usage.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Projects + class CiFeatureUsage < ApplicationRecord + self.table_name = 'project_ci_feature_usages' + + belongs_to :project + + validates :project, :feature, presence: true + + enum feature: { + code_coverage: 1, + security_report: 2 + } + + def self.insert_usage(project_id:, feature:, default_branch:) + insert( + { + project_id: project_id, + feature: feature, + default_branch: default_branch + }, + unique_by: 'index_project_ci_feature_usages_unique_columns' + ) + end + end +end diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb index 84e0a43670b..17a9ad7db66 100644 --- a/app/models/release_highlight.rb +++ b/app/models/release_highlight.rb @@ -49,8 +49,12 @@ class ReleaseHighlight end def self.file_paths - @file_paths ||= Rails.cache.fetch(self.cache_key('file_paths'), expires_in: CACHE_DURATION) do - Dir.glob(FILES_PATH).sort.reverse + @file_paths ||= self.relative_file_paths.map { |path| path.prepend(Rails.root.to_s) } + end + + def self.relative_file_paths + Rails.cache.fetch(self.cache_key('file_paths'), expires_in: CACHE_DURATION) do + Dir.glob(FILES_PATH).sort.reverse.map { |path| path.delete_prefix(Rails.root.to_s) } end end diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index a700f104150..7f41f0907d5 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -22,14 +22,9 @@ class RemoteMirror < ApplicationRecord validates :url, presence: true, public_url: { schemes: %w(ssh git http https), allow_blank: true, enforce_user: true } - before_save :set_new_remote_name, if: :mirror_url_changed? - after_save :set_override_remote_mirror_available, unless: -> { Gitlab::CurrentSettings.current_application_settings.mirror_available } - after_save :refresh_remote, if: :saved_change_to_mirror_url? after_update :reset_fields, if: :saved_change_to_mirror_url? - after_commit :remove_remote, on: :destroy - before_validation :store_credentials scope :enabled, -> { where(enabled: true) } @@ -88,10 +83,6 @@ class RemoteMirror < ApplicationRecord end end - def remote_name - super || fallback_remote_name - end - def update_failed? update_status == 'failed' end @@ -100,11 +91,10 @@ class RemoteMirror < ApplicationRecord update_status == 'started' end - def update_repository(inmemory_remote:) + def update_repository Gitlab::Git::RemoteMirror.new( project.repository.raw, - remote_name, - inmemory_remote ? remote_url : nil, + remote_url, **options_for_update ).update end @@ -227,15 +217,6 @@ class RemoteMirror < ApplicationRecord Gitlab::UrlSanitizer.new(read_attribute(:url)).full_url end - def ensure_remote! - return unless project - return unless remote_name && remote_url - - # If this fails or the remote already exists, we won't know due to - # https://gitlab.com/gitlab-org/gitaly/issues/1317 - project.repository.add_remote(remote_name, remote_url) - end - def after_sent_notification update_column(:error_notification_sent, true) end @@ -280,12 +261,6 @@ class RemoteMirror < ApplicationRecord super end - def fallback_remote_name - return unless id - - "remote_mirror_#{id}" - end - def recently_scheduled? return false unless self.last_update_started_at @@ -308,29 +283,6 @@ class RemoteMirror < ApplicationRecord project.update(remote_mirror_available_overridden: enabled) end - def set_new_remote_name - self.remote_name = "remote_mirror_#{SecureRandom.hex}" - end - - def refresh_remote - return unless project - - # Before adding a new remote we have to delete the data from - # the previous remote name - prev_remote_name = remote_name_before_last_save || fallback_remote_name - run_after_commit do - project.repository.async_remove_remote(prev_remote_name) - end - - project.repository.add_remote(remote_name, remote_url) - end - - def remove_remote - return unless project # could be pending to delete so don't need to touch the git repository - - project.repository.async_remove_remote(remote_name) - end - def mirror_url_changed? url_changed? || attribute_changed?(:credentials) end diff --git a/app/models/repository.rb b/app/models/repository.rb index a77aaf02e06..0164d6fed93 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -168,8 +168,8 @@ class Repository end # Returns a list of commits that are not present in any reference - def new_commits(newrev) - commits = raw.new_commits(newrev) + def new_commits(newrev, allow_quarantine: false) + commits = raw.new_commits(newrev, allow_quarantine: allow_quarantine) ::Commit.decorate(commits, container) end @@ -502,8 +502,8 @@ class Repository end end - def blob_at(sha, path) - Blob.decorate(raw_repository.blob_at(sha, path), container) + def blob_at(sha, path, limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE) + Blob.decorate(raw_repository.blob_at(sha, path, limit: limit), container) rescue Gitlab::Git::Repository::NoRepository nil end @@ -656,7 +656,7 @@ class Repository end end - def tree(sha = :head, path = nil, recursive: false) + def tree(sha = :head, path = nil, recursive: false, pagination_params: nil) if sha == :head return unless head_commit @@ -667,7 +667,7 @@ class Repository end end - Tree.new(self, sha, path, recursive: recursive) + Tree.new(self, sha, path, recursive: recursive, pagination_params: pagination_params) end def blob_at_branch(branch_name, path) @@ -938,33 +938,8 @@ class Repository end end - def fetch_as_mirror(url, forced: false, refmap: :all_refs, remote_name: nil, prune: true) - return fetch_remote(remote_name, url: url, refmap: refmap, forced: forced, prune: prune) if Feature.enabled?(:fetch_remote_params, project, default_enabled: :yaml) - - unless remote_name - remote_name = "tmp-#{SecureRandom.hex}" - tmp_remote_name = true - end - - add_remote(remote_name, url, mirror_refmap: refmap) - fetch_remote(remote_name, forced: forced, prune: prune) - ensure - async_remove_remote(remote_name) if tmp_remote_name - end - - def async_remove_remote(remote_name) - return unless remote_name - return unless project - - job_id = RepositoryRemoveRemoteWorker.perform_async(project.id, remote_name) - - if job_id - Gitlab::AppLogger.info("Remove remote job scheduled for #{project.id} with remote name: #{remote_name} job ID #{job_id}.") - else - Gitlab::AppLogger.info("Remove remote job failed to create for #{project.id} with remote name #{remote_name}.") - end - - job_id + def fetch_as_mirror(url, forced: false, refmap: :all_refs, prune: true, http_authorization_header: "") + fetch_remote(url, refmap: refmap, forced: forced, prune: prune, http_authorization_header: http_authorization_header) end def fetch_source_branch!(source_repository, source_branch, local_ref) diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 68957dd6b22..dd76f2c3c84 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -246,7 +246,7 @@ class Snippet < ApplicationRecord notes.includes(:author) end - def check_for_spam? + def check_for_spam?(user:) visibility_level_changed?(to: Snippet::PUBLIC) || (public? && (title_changed? || content_changed?)) end diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index 8aeeae1330c..8c3b85ac4c3 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -20,7 +20,6 @@ module Terraform foreign_key: :terraform_state_id, inverse_of: :terraform_state - scope :versioning_not_enabled, -> { where(versioning_enabled: false) } scope :ordered_by_name, -> { order(:name) } scope :with_name, -> (name) { where(name: name) } diff --git a/app/models/timelog.rb b/app/models/timelog.rb index 3f0e827cf61..7c394736560 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -19,6 +19,14 @@ class Timelog < ApplicationRecord joins(:project).where(projects: { namespace: group.self_and_descendants }) end + scope :in_project, -> (project) do + where(project: project) + end + + scope :for_user, -> (user) do + where(user: user) + end + scope :at_or_after, -> (start_time) do where('spent_at >= ?', start_time) end diff --git a/app/models/tree.rb b/app/models/tree.rb index cd385872171..fd416ebdedc 100644 --- a/app/models/tree.rb +++ b/app/models/tree.rb @@ -4,9 +4,9 @@ class Tree include Gitlab::MarkupHelper include Gitlab::Utils::StrongMemoize - attr_accessor :repository, :sha, :path, :entries + attr_accessor :repository, :sha, :path, :entries, :cursor - def initialize(repository, sha, path = '/', recursive: false) + def initialize(repository, sha, path = '/', recursive: false, pagination_params: nil) path = '/' if path.blank? @repository = repository @@ -14,7 +14,7 @@ class Tree @path = path git_repo = @repository.raw_repository - @entries = Gitlab::Git::Tree.where(git_repo, @sha, @path, recursive) + @entries, @cursor = Gitlab::Git::Tree.where(git_repo, @sha, @path, recursive, pagination_params) end def readme_path diff --git a/app/models/user.rb b/app/models/user.rb index 80b8c9173d1..cb0f15c04cb 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -26,6 +26,7 @@ class User < ApplicationRecord include UpdateHighestRole include HasUserType include Gitlab::Auth::Otp::Fortinet + include RestrictedSignup DEFAULT_NOTIFICATION_LEVEL = :participating @@ -205,11 +206,14 @@ class User < ApplicationRecord has_one :user_canonical_email has_one :credit_card_validation, class_name: '::Users::CreditCardValidation' has_one :atlassian_identity, class_name: 'Atlassian::Identity' + has_one :banned_user, class_name: '::Users::BannedUser' has_many :reviews, foreign_key: :author_id, inverse_of: :author has_many :in_product_marketing_emails, class_name: '::Users::InProductMarketingEmail' + has_many :timelogs + # # Validations # @@ -220,7 +224,7 @@ class User < ApplicationRecord validates :email, confirmation: true validates :notification_email, presence: true validates :notification_email, devise_email: true, if: ->(user) { user.notification_email != user.email } - validates :public_email, presence: true, uniqueness: true, devise_email: true, allow_blank: true + validates :public_email, uniqueness: true, devise_email: true, allow_blank: true validates :commit_email, devise_email: true, allow_nil: true, if: ->(user) { user.commit_email != user.email } validates :projects_limit, presence: true, @@ -231,11 +235,10 @@ class User < ApplicationRecord validate :namespace_move_dir_allowed, if: :username_changed? validate :unique_email, if: :email_changed? - validate :owns_notification_email, if: :notification_email_changed? - validate :owns_public_email, if: :public_email_changed? - validate :owns_commit_email, if: :commit_email_changed? - validate :signup_domain_valid?, on: :create, if: ->(user) { !user.created_by_id } - validate :check_email_restrictions, on: :create, if: ->(user) { !user.created_by_id } + validate :notification_email_verified, if: :notification_email_changed? + validate :public_email_verified, if: :public_email_changed? + validate :commit_email_verified, if: :commit_email_changed? + validate :signup_email_valid?, on: :create, if: ->(user) { !user.created_by_id } validate :check_username_format, if: :username_changed? validates :theme_id, allow_nil: true, inclusion: { in: Gitlab::Themes.valid_ids, @@ -245,7 +248,6 @@ class User < ApplicationRecord message: _("%{placeholder} is not a valid color scheme") % { placeholder: '%{value}' } } before_validation :sanitize_attrs - before_validation :set_notification_email, if: :new_record? before_validation :set_public_email, if: :public_email_changed? before_validation :set_commit_email, if: :commit_email_changed? before_save :default_private_profile_to_false @@ -270,11 +272,6 @@ class User < ApplicationRecord update_emails_with_primary_email(previous_confirmed_at, previous_email) update_invalid_gpg_signatures - - if previous_email == notification_email - self.notification_email = email - save - end end end @@ -315,6 +312,7 @@ class User < ApplicationRecord delegate :bio, :bio=, :bio_html, to: :user_detail, allow_nil: true delegate :webauthn_xid, :webauthn_xid=, to: :user_detail, allow_nil: true delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true + delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true @@ -326,7 +324,6 @@ class User < ApplicationRecord transition deactivated: :blocked transition ldap_blocked: :blocked transition blocked_pending_approval: :blocked - transition banned: :blocked end event :ldap_block do @@ -380,6 +377,14 @@ class User < ApplicationRecord NotificationService.new.user_deactivated(user.name, user.notification_email) end # rubocop: enable CodeReuse/ServiceClass + + after_transition active: :banned do |user| + user.create_banned_user + end + + after_transition banned: :active do |user| + user.banned_user&.destroy + end end # Scopes @@ -917,22 +922,22 @@ class User < ApplicationRecord end end - def owns_notification_email - return if new_record? || temp_oauth_email? + def notification_email_verified + return if read_attribute(:notification_email).blank? || temp_oauth_email? - errors.add(:notification_email, _("is not an email you own")) unless verified_emails.include?(notification_email) + errors.add(:notification_email, _("must be an email you have verified")) unless verified_emails.include?(notification_email) end - def owns_public_email + def public_email_verified return if public_email.blank? - errors.add(:public_email, _("is not an email you own")) unless verified_emails.include?(public_email) + errors.add(:public_email, _("must be an email you have verified")) unless verified_emails.include?(public_email) end - def owns_commit_email + def commit_email_verified return if read_attribute(:commit_email).blank? - errors.add(:commit_email, _("is not an email you own")) unless verified_emails.include?(commit_email) + errors.add(:commit_email, _("must be an email you have verified")) unless verified_emails.include?(commit_email) end # Define commit_email-related attribute methods explicitly instead of relying @@ -959,6 +964,11 @@ class User < ApplicationRecord has_attribute?(:commit_email) && super end + def notification_email + # The notification email is the same as the primary email if undefined + super.presence || self.email + end + def private_commit_email Gitlab::PrivateCommitEmail.for_user(self) end @@ -1005,6 +1015,8 @@ class User < ApplicationRecord # Returns a relation of groups the user has access to, including their parent # and child groups (recursively). def all_expanded_groups + return groups if groups.empty? + Gitlab::ObjectHierarchy.new(groups).all_objects end @@ -1576,10 +1588,11 @@ class User < ApplicationRecord .order('routes.path') end - def namespaces - namespace_ids = groups.pluck(:id) - namespace_ids.push(namespace.id) - Namespace.where(id: namespace_ids) + def namespaces(owned_only: false) + user_groups = owned_only ? owned_groups : groups + personal_namespace = Namespace.where(id: namespace.id) + + Namespace.from_union([user_groups, personal_namespace]) end def oauth_authorized_tokens @@ -2008,8 +2021,8 @@ class User < ApplicationRecord def authorized_groups_without_shared_membership Group.from_union([ - groups, - authorized_projects.joins(:namespace).select('namespaces.*') + groups.select(Namespace.arel_table[Arel.star]), + authorized_projects.joins(:namespace).select(Namespace.arel_table[Arel.star]) ]) end @@ -2058,51 +2071,10 @@ class User < ApplicationRecord end end - def signup_domain_valid? - valid = true - error = nil - - if Gitlab::CurrentSettings.domain_denylist_enabled? - blocked_domains = Gitlab::CurrentSettings.domain_denylist - if domain_matches?(blocked_domains, email) - error = 'is not from an allowed domain.' - valid = false - end - end - - allowed_domains = Gitlab::CurrentSettings.domain_allowlist - unless allowed_domains.blank? - if domain_matches?(allowed_domains, email) - valid = true - else - error = "domain is not authorized for sign-up" - valid = false - end - end - - errors.add(:email, error) unless valid - - valid - end - - def domain_matches?(email_domains, email) - signup_domain = Mail::Address.new(email).domain - email_domains.any? do |domain| - escaped = Regexp.escape(domain).gsub('\*', '.*?') - regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE - signup_domain =~ regexp - end - end + def signup_email_valid? + error = validate_admin_signup_restrictions(email) - def check_email_restrictions - return unless Gitlab::CurrentSettings.email_restrictions_enabled? - - restrictions = Gitlab::CurrentSettings.email_restrictions - return if restrictions.blank? - - if Gitlab::UntrustedRegexp.new(restrictions).match?(email) - errors.add(:email, _('is not allowed. Try again with a different email address, or contact your GitLab admin.')) - end + errors.add(:email, error) if error end def check_username_format diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 854992dcd1e..1172b2ee5e8 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -16,7 +16,6 @@ class UserCallout < ApplicationRecord tabs_position_highlight: 10, threat_monitoring_info: 11, # EE-only account_recovery_regular_check: 12, # EE-only - service_templates_deprecated_callout: 14, web_ide_alert_dismissed: 16, # no longer in use active_user_count_threshold: 18, # EE-only buy_pipeline_minutes_notification_dot: 19, # EE-only @@ -35,7 +34,9 @@ class UserCallout < ApplicationRecord cloud_licensing_subscription_activation_banner: 33, # EE-only trial_status_reminder_d14: 34, # EE-only trial_status_reminder_d3: 35, # EE-only - security_configuration_devops_alert: 36 # EE-only + security_configuration_devops_alert: 36, # EE-only + profile_personal_access_token_expiry: 37, # EE-only + terraform_notification_dismissed: 38 } validates :user, presence: true diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 47537e5885f..b3cca1e0cc0 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -7,6 +7,7 @@ class UserDetail < ApplicationRecord belongs_to :user validates :pronouns, length: { maximum: 50 } + validates :pronunciation, length: { maximum: 255 } validates :job_title, length: { maximum: 200 } validates :bio, length: { maximum: 255 }, allow_blank: true diff --git a/app/models/user_interacted_project.rb b/app/models/user_interacted_project.rb index 4c8cc5fc83a..1c7515894fe 100644 --- a/app/models/user_interacted_project.rb +++ b/app/models/user_interacted_project.rb @@ -24,16 +24,8 @@ class UserInteractedProject < ApplicationRecord } cached_exists?(**attributes) do - transaction(requires_new: true) do - where(attributes).select(1).first || create!(attributes) - true # not caching the whole record here for now - rescue ActiveRecord::RecordNotUnique - # Note, above queries are not atomic and prone - # to race conditions (similar like #find_or_create!). - # In the case where we hit this, the record we want - # already exists - shortcut and return. - true - end + where(attributes).exists? || UserInteractedProject.insert_all([attributes], unique_by: %w(project_id user_id)) + true end end diff --git a/app/models/users/banned_user.rb b/app/models/users/banned_user.rb new file mode 100644 index 00000000000..c52b6d4b728 --- /dev/null +++ b/app/models/users/banned_user.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Users + class BannedUser < ApplicationRecord + self.primary_key = :user_id + + belongs_to :user + + validates :user, presence: true + validates :user_id, uniqueness: { message: _("banned user already exists") } + end +end diff --git a/app/models/users/in_product_marketing_email.rb b/app/models/users/in_product_marketing_email.rb index 3e5e7b259d8..8fe52ac7ecc 100644 --- a/app/models/users/in_product_marketing_email.rb +++ b/app/models/users/in_product_marketing_email.rb @@ -19,7 +19,10 @@ module Users verify: 1, trial: 2, team: 3, - experience: 4 + experience: 4, + team_short: 5, + trial_short: 6, + admin_verify: 7 }, _suffix: true scope :without_track_and_series, -> (track, series) do diff --git a/app/models/work_item/type.rb b/app/models/work_item/type.rb new file mode 100644 index 00000000000..16cb7a8be45 --- /dev/null +++ b/app/models/work_item/type.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +# Note: initial thinking behind `icon_name` is for it to do triple duty: +# 1. one of our svg icon names, such as `external-link` or a new one `bug` +# 2. if it's an absolute url, then url to a user uploaded icon/image +# 3. an emoji, with the format of `:smile:` +class WorkItem::Type < ApplicationRecord + self.table_name = 'work_item_types' + + include CacheMarkdownField + + cache_markdown_field :description, pipeline: :single_line + + enum base_type: { + issue: 0, + incident: 1, + test_case: 2, ## EE-only + requirement: 3 ## EE-only + } + + belongs_to :namespace, optional: true + has_many :work_items, class_name: 'Issue', foreign_key: :work_item_type_id, inverse_of: :work_item_type + + before_validation :strip_whitespace + + # TODO: review validation rules + # https://gitlab.com/gitlab-org/gitlab/-/issues/336919 + validates :name, presence: true + validates :name, uniqueness: { case_sensitive: false, scope: [:namespace_id] } + validates :name, length: { maximum: 255 } + validates :icon_name, length: { maximum: 255 } + + private + + def strip_whitespace + name&.strip! + end +end |