diff options
Diffstat (limited to 'app/models')
331 files changed, 3785 insertions, 3630 deletions
diff --git a/app/models/ability.rb b/app/models/ability.rb index ba46a98b951..c18bd21d754 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -1,7 +1,5 @@ # frozen_string_literal: true -require_dependency 'declarative_policy' - class Ability class << self # Given a list of users and a project this method returns the users that can diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb index 7090d9f4ea1..156111ffaf3 100644 --- a/app/models/alert_management/alert.rb +++ b/app/models/alert_management/alert.rb @@ -20,7 +20,13 @@ module AlertManagement resolved: 2, ignored: 3 }.freeze - private_constant :STATUSES + + 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 belongs_to :project belongs_to :issue, optional: true @@ -271,4 +277,4 @@ module AlertManagement end end -AlertManagement::Alert.prepend_if_ee('EE::AlertManagement::Alert') +AlertManagement::Alert.prepend_mod_with('AlertManagement::Alert') diff --git a/app/models/alert_management/http_integration.rb b/app/models/alert_management/http_integration.rb index e98c770c364..2caa9a18445 100644 --- a/app/models/alert_management/http_integration.rb +++ b/app/models/alert_management/http_integration.rb @@ -10,7 +10,7 @@ module AlertManagement attr_encrypted :token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' default_value_for(:endpoint_identifier, allows_nil: false) { SecureRandom.hex(8) } diff --git a/app/models/alerting/project_alerting_setting.rb b/app/models/alerting/project_alerting_setting.rb index 8f8c38f11e4..34fa27eb29b 100644 --- a/app/models/alerting/project_alerting_setting.rb +++ b/app/models/alerting/project_alerting_setting.rb @@ -10,7 +10,7 @@ module Alerting attr_encrypted :token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' before_validation :ensure_token diff --git a/app/models/analytics/cycle_analytics/project_stage.rb b/app/models/analytics/cycle_analytics/project_stage.rb index b2c16444a2a..e8b03fa066a 100644 --- a/app/models/analytics/cycle_analytics/project_stage.rb +++ b/app/models/analytics/cycle_analytics/project_stage.rb @@ -7,10 +7,13 @@ module Analytics validates :project, presence: true belongs_to :project + belongs_to :value_stream, class_name: 'Analytics::CycleAnalytics::ProjectValueStream', foreign_key: :project_value_stream_id alias_attribute :parent, :project alias_attribute :parent_id, :project_id + alias_attribute :value_stream_id, :project_value_stream_id + delegate :group, to: :project validate :validate_project_group_for_label_events, if: -> { start_event_label_based? || end_event_label_based? } diff --git a/app/models/analytics/cycle_analytics/project_value_stream.rb b/app/models/analytics/cycle_analytics/project_value_stream.rb new file mode 100644 index 00000000000..3eba7e87b17 --- /dev/null +++ b/app/models/analytics/cycle_analytics/project_value_stream.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class Analytics::CycleAnalytics::ProjectValueStream < ApplicationRecord + belongs_to :project + + has_many :stages, class_name: 'Analytics::CycleAnalytics::ProjectStage' + + validates :project, :name, presence: true + validates :name, length: { minimum: 3, maximum: 100, allow_nil: false }, uniqueness: { scope: :project_id } + + def custom? + false + end + + def stages + [] + end + + def self.build_default_value_stream(project) + new(name: Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME, project: project) + end +end diff --git a/app/models/analytics/usage_trends/measurement.rb b/app/models/analytics/usage_trends/measurement.rb index ad0272699c2..46c5d56d210 100644 --- a/app/models/analytics/usage_trends/measurement.rb +++ b/app/models/analytics/usage_trends/measurement.rb @@ -58,4 +58,4 @@ module Analytics end end -Analytics::UsageTrends::Measurement.prepend_if_ee('EE::Analytics::UsageTrends::Measurement') +Analytics::UsageTrends::Measurement.prepend_mod_with('Analytics::UsageTrends::Measurement') diff --git a/app/models/application_record.rb b/app/models/application_record.rb index 1bbace791ed..5e5bc00458e 100644 --- a/app/models/application_record.rb +++ b/app/models/application_record.rb @@ -66,6 +66,12 @@ class ApplicationRecord < ActiveRecord::Base end end + def create_or_load_association(association_name) + association(association_name).create unless association(association_name).loaded? + rescue ActiveRecord::RecordNotUnique, PG::UniqueViolation + association(association_name).reader + end + def self.underscore Gitlab::SafeRequestStore.fetch("model:#{self}:underscore") { self.to_s.underscore } end @@ -80,4 +86,4 @@ class ApplicationRecord < ActiveRecord::Base end end -ApplicationRecord.prepend_if_ee('EE::ApplicationRecordHelpers') +ApplicationRecord.prepend_mod_with('ApplicationRecordHelpers') diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index f405f5ca5d3..65800e40d6c 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -13,6 +13,8 @@ class ApplicationSetting < ApplicationRecord KROKI_URL_ERROR_MESSAGE = 'Please check your Kroki URL setting in ' \ 'Admin Area > Settings > General > Kroki' + enum whats_new_variant: { all_tiers: 0, current_tier: 1, disabled: 2 }, _prefix: true + add_authentication_token_field :runners_registration_token, encrypted: -> { Feature.enabled?(:application_settings_tokens_optional_encryption) ? :optional : :required } add_authentication_token_field :health_check_access_token add_authentication_token_field :static_objects_external_storage_auth_token @@ -132,6 +134,14 @@ class ApplicationSetting < ApplicationRecord presence: true, if: :akismet_enabled + validates :spam_check_api_key, + length: { maximum: 2000, message: _('is too long (maximum is %{count} characters)') }, + allow_blank: true + + validates :spam_check_api_key, + presence: true, + if: :spam_check_endpoint_enabled + validates :unique_ips_limit_per_user, numericality: { greater_than_or_equal_to: 1 }, presence: true, @@ -365,7 +375,7 @@ class ApplicationSetting < ApplicationRecord if: :external_authorization_service_enabled validates :spam_check_endpoint_url, - addressable_url: true, allow_blank: true + addressable_url: { schemes: %w(grpc) }, allow_blank: true validates :spam_check_endpoint_url, presence: true, @@ -434,6 +444,14 @@ class ApplicationSetting < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :throttle_unauthenticated_packages_api_requests_per_period, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + + validates :throttle_unauthenticated_packages_api_period_in_seconds, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + validates :throttle_authenticated_api_requests_per_period, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -450,6 +468,14 @@ class ApplicationSetting < ApplicationRecord presence: true, numericality: { only_integer: true, greater_than: 0 } + validates :throttle_authenticated_packages_api_requests_per_period, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + + validates :throttle_authenticated_packages_api_period_in_seconds, + presence: true, + numericality: { only_integer: true, greater_than: 0 } + validates :throttle_protected_paths_requests_per_period, presence: true, numericality: { only_integer: true, greater_than: 0 } @@ -475,35 +501,43 @@ class ApplicationSetting < ApplicationRecord allow_nil: true, numericality: { only_integer: true, greater_than: 0 } + validates :whats_new_variant, + inclusion: { in: ApplicationSetting.whats_new_variants.keys } + + validates :floc_enabled, + inclusion: { in: [true, false], message: _('must be a boolean value') } + attr_encrypted :asset_proxy_secret_key, mode: :per_attribute_iv, key: Settings.attr_encrypted_db_key_base_truncated, algorithm: 'aes-256-cbc', insecure_mode: true - private_class_method def self.encryption_options_base_truncated_aes_256_gcm + private_class_method def self.encryption_options_base_32_aes_256_gcm { mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: true } end - attr_encrypted :external_auth_client_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :external_auth_client_key_pass, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :lets_encrypt_private_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :eks_secret_access_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :akismet_api_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :elasticsearch_aws_secret_access_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :recaptcha_private_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :recaptcha_site_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :slack_app_secret, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :slack_app_verification_token, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :ci_jwt_signing_key, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :secret_detection_token_revocation_token, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :cloud_license_auth_token, encryption_options_base_truncated_aes_256_gcm - attr_encrypted :external_pipeline_validation_service_token, encryption_options_base_truncated_aes_256_gcm + attr_encrypted :external_auth_client_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :external_auth_client_key_pass, encryption_options_base_32_aes_256_gcm + attr_encrypted :lets_encrypt_private_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :eks_secret_access_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :akismet_api_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :spam_check_api_key, encryption_options_base_32_aes_256_gcm.merge(encode: false) + attr_encrypted :elasticsearch_aws_secret_access_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :elasticsearch_password, encryption_options_base_32_aes_256_gcm.merge(encode: false) + attr_encrypted :recaptcha_private_key, encryption_options_base_32_aes_256_gcm + attr_encrypted :recaptcha_site_key, encryption_options_base_32_aes_256_gcm + 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 :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 validates :disable_feed_token, inclusion: { in: [true, false], message: _('must be a boolean value') } @@ -634,4 +668,4 @@ class ApplicationSetting < ApplicationRecord end end -ApplicationSetting.prepend_if_ee('EE::ApplicationSetting') +ApplicationSetting.prepend_mod_with('ApplicationSetting') diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 66a8d1f8105..5ff1c653f9e 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -38,6 +38,7 @@ module ApplicationSettingImplementation admin_mode: false, after_sign_up_text: nil, akismet_enabled: false, + akismet_api_key: nil, allow_local_requests_from_system_hooks: true, allow_local_requests_from_web_hooks_and_services: false, asset_proxy_enabled: false, @@ -76,6 +77,7 @@ module ApplicationSettingImplementation external_pipeline_validation_service_token: nil, external_pipeline_validation_service_url: nil, first_day_of_week: 0, + floc_enabled: false, gitaly_timeout_default: 55, gitaly_timeout_fast: 10, gitaly_timeout_medium: 30, @@ -149,6 +151,7 @@ module ApplicationSettingImplementation sourcegraph_url: nil, spam_check_endpoint_enabled: false, spam_check_endpoint_url: nil, + spam_check_api_key: nil, terminal_max_session_time: 0, throttle_authenticated_api_enabled: false, throttle_authenticated_api_period_in_seconds: 3600, @@ -156,6 +159,9 @@ module ApplicationSettingImplementation throttle_authenticated_web_enabled: false, throttle_authenticated_web_period_in_seconds: 3600, throttle_authenticated_web_requests_per_period: 7200, + throttle_authenticated_packages_api_enabled: false, + throttle_authenticated_packages_api_period_in_seconds: 15, + throttle_authenticated_packages_api_requests_per_period: 1000, throttle_incident_management_notification_enabled: false, throttle_incident_management_notification_per_period: 3600, throttle_incident_management_notification_period_in_seconds: 3600, @@ -165,6 +171,9 @@ module ApplicationSettingImplementation throttle_unauthenticated_enabled: false, throttle_unauthenticated_period_in_seconds: 3600, throttle_unauthenticated_requests_per_period: 3600, + throttle_unauthenticated_packages_api_enabled: false, + throttle_unauthenticated_packages_api_period_in_seconds: 15, + throttle_unauthenticated_packages_api_requests_per_period: 800, time_tracking_limit_to_hours: false, two_factor_grace_period: 48, unique_ips_limit_enabled: false, @@ -181,7 +190,8 @@ module ApplicationSettingImplementation kroki_enabled: false, kroki_url: nil, kroki_formats: { blockdiag: false, bpmn: false, excalidraw: false }, - rate_limiting_response_text: nil + rate_limiting_response_text: nil, + whats_new_variant: 0 } end diff --git a/app/models/atlassian/identity.rb b/app/models/atlassian/identity.rb index 906f2be0fbf..02bbe007e1b 100644 --- a/app/models/atlassian/identity.rb +++ b/app/models/atlassian/identity.rb @@ -11,14 +11,14 @@ module Atlassian attr_encrypted :token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: false, encode_iv: false attr_encrypted :refresh_token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: false, encode_iv: false diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index 32c9d44f836..aff7eef4622 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -92,4 +92,4 @@ class AuditEvent < ApplicationRecord end end -AuditEvent.prepend_if_ee('EE::AuditEvent') +AuditEvent.prepend_mod_with('AuditEvent') diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb index a851f22bfcd..a3801025cd7 100644 --- a/app/models/blob_viewer/dependency_manager.rb +++ b/app/models/blob_viewer/dependency_manager.rb @@ -33,7 +33,7 @@ module BlobViewer @json_data ||= begin prepare! Gitlab::Json.parse(blob.data) - rescue + rescue StandardError {} end end diff --git a/app/models/board.rb b/app/models/board.rb index b26a9461ffc..7938819b6e4 100644 --- a/app/models/board.rb +++ b/app/models/board.rb @@ -45,6 +45,12 @@ class Board < ApplicationRecord def to_type self.class.to_type end + + def disabled_for?(current_user) + namespace = group_board? ? resource_parent.root_ancestor : resource_parent.root_namespace + + namespace.issue_repositioning_disabled? || !Ability.allowed?(current_user, :create_non_backlog_issues, self) + end end -Board.prepend_if_ee('EE::Board') +Board.prepend_mod_with('Board') diff --git a/app/models/board_group_recent_visit.rb b/app/models/board_group_recent_visit.rb index 979f0e1ab92..dc273e256a8 100644 --- a/app/models/board_group_recent_visit.rb +++ b/app/models/board_group_recent_visit.rb @@ -2,27 +2,19 @@ # Tracks which boards in a specific group a user has visited class BoardGroupRecentVisit < ApplicationRecord + include BoardRecentVisit + belongs_to :user belongs_to :group belongs_to :board - validates :user, presence: true + validates :user, presence: true validates :group, presence: true validates :board, presence: true - scope :by_user_group, -> (user, group) { where(user: user, group: group) } - - def self.visited!(user, board) - visit = find_or_create_by(user: user, group: board.group, board: board) - visit.touch if visit.updated_at < Time.current - rescue ActiveRecord::RecordNotUnique - retry - end - - def self.latest(user, group, count: nil) - visits = by_user_group(user, group).order(updated_at: :desc) - visits = visits.preload(:board) if count && count > 1 + scope :by_user_parent, -> (user, group) { where(user: user, group: group) } - visits.first(count) + def self.board_parent_relation + :group end end diff --git a/app/models/board_project_recent_visit.rb b/app/models/board_project_recent_visit.rb index 509c8f97b83..723afd6feab 100644 --- a/app/models/board_project_recent_visit.rb +++ b/app/models/board_project_recent_visit.rb @@ -2,27 +2,19 @@ # Tracks which boards in a specific project a user has visited class BoardProjectRecentVisit < ApplicationRecord + include BoardRecentVisit + belongs_to :user belongs_to :project belongs_to :board - validates :user, presence: true + validates :user, presence: true validates :project, presence: true validates :board, presence: true - scope :by_user_project, -> (user, project) { where(user: user, project: project) } - - def self.visited!(user, board) - visit = find_or_create_by(user: user, project: board.project, board: board) - visit.touch if visit.updated_at < Time.current - rescue ActiveRecord::RecordNotUnique - retry - end - - def self.latest(user, project, count: nil) - visits = by_user_project(user, project).order(updated_at: :desc) - visits = visits.preload(:board) if count && count > 1 + scope :by_user_parent, -> (user, project) { where(user: user, project: project) } - visits.first(count) + def self.board_parent_relation + :project end end diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb index a8325e98095..1ee5c081840 100644 --- a/app/models/broadcast_message.rb +++ b/app/models/broadcast_message.rb @@ -106,6 +106,14 @@ class BroadcastMessage < ApplicationRecord return false if current_path.blank? && target_path.present? return true if current_path.blank? || target_path.blank? + # Ensure paths are consistent across callers. + # This fixes a mismatch between requests in the GUI and CLI + # + # This has to be reassigned due to frozen strings being provided. + unless current_path.start_with?("/") + current_path = "/#{current_path}" + end + escaped = Regexp.escape(target_path).gsub('\\*', '.*') regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE @@ -119,4 +127,4 @@ class BroadcastMessage < ApplicationRecord end end -BroadcastMessage.prepend_if_ee('EE::BroadcastMessage') +BroadcastMessage.prepend_mod_with('BroadcastMessage') diff --git a/app/models/bulk_imports/configuration.rb b/app/models/bulk_imports/configuration.rb index 4c6f745c268..6d9f598583e 100644 --- a/app/models/bulk_imports/configuration.rb +++ b/app/models/bulk_imports/configuration.rb @@ -12,11 +12,11 @@ class BulkImports::Configuration < ApplicationRecord allow_nil: true attr_encrypted :url, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, mode: :per_attribute_iv, algorithm: 'aes-256-gcm' attr_encrypted :access_token, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, mode: :per_attribute_iv, algorithm: 'aes-256-gcm' end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 04af1145769..bb543b39a79 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -68,6 +68,10 @@ class BulkImports::Entity < ApplicationRecord end end + def encoded_source_full_path + ERB::Util.url_encode(source_full_path) + end + private def validate_parent_is_a_group diff --git a/app/models/bulk_imports/export.rb b/app/models/bulk_imports/export.rb new file mode 100644 index 00000000000..59ca4dbfec6 --- /dev/null +++ b/app/models/bulk_imports/export.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module BulkImports + class Export < ApplicationRecord + include Gitlab::Utils::StrongMemoize + + self.table_name = 'bulk_import_exports' + + belongs_to :project, optional: true + belongs_to :group, optional: true + + has_one :upload, class_name: 'BulkImports::ExportUpload' + + validates :project, presence: true, unless: :group + validates :group, presence: true, unless: :project + validates :relation, :status, presence: true + + validate :portable_relation? + + state_machine :status, initial: :started do + state :started, value: 0 + state :finished, value: 1 + state :failed, value: -1 + + event :start do + transition any => :started + end + + event :finish do + transition started: :finished + transition failed: :failed + end + + event :fail_op do + transition any => :failed + end + end + + def portable_relation? + return unless portable + + errors.add(:relation, 'Unsupported portable relation') unless config.portable_relations.include?(relation) + end + + def portable + strong_memoize(:portable) do + project || group + end + end + + def relation_definition + config.portable_tree[:include].find { |include| include[relation.to_sym] } + end + + def config + strong_memoize(:config) do + FileTransfer.config_for(portable) + end + end + end +end diff --git a/app/models/bulk_imports/export_upload.rb b/app/models/bulk_imports/export_upload.rb new file mode 100644 index 00000000000..a9cba5119af --- /dev/null +++ b/app/models/bulk_imports/export_upload.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module BulkImports + class ExportUpload < ApplicationRecord + include WithUploads + include ObjectStorage::BackgroundMove + + self.table_name = 'bulk_import_export_uploads' + + belongs_to :export, class_name: 'BulkImports::Export' + + mount_uploader :export_file, ExportUploader + + def retrieve_upload(_identifier, paths) + Upload.find_by(model: self, path: paths) + end + end +end diff --git a/app/models/bulk_imports/file_transfer.rb b/app/models/bulk_imports/file_transfer.rb new file mode 100644 index 00000000000..5be954b98da --- /dev/null +++ b/app/models/bulk_imports/file_transfer.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module BulkImports + module FileTransfer + extend self + + UnsupportedObjectType = Class.new(StandardError) + + def config_for(portable) + case portable + when ::Project + FileTransfer::ProjectConfig.new(portable) + when ::Group + FileTransfer::GroupConfig.new(portable) + else + raise(UnsupportedObjectType, "Unsupported object type: #{portable.class}") + end + end + end +end diff --git a/app/models/bulk_imports/file_transfer/base_config.rb b/app/models/bulk_imports/file_transfer/base_config.rb new file mode 100644 index 00000000000..bb04e84ad72 --- /dev/null +++ b/app/models/bulk_imports/file_transfer/base_config.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module BulkImports + module FileTransfer + class BaseConfig + include Gitlab::Utils::StrongMemoize + + def initialize(portable) + @portable = portable + end + + def portable_tree + attributes_finder.find_root(portable_class_sym) + end + + def export_path + strong_memoize(:export_path) do + relative_path = File.join(base_export_path, SecureRandom.hex) + + ::Gitlab::ImportExport.export_path(relative_path: relative_path) + end + end + + def portable_relations + import_export_config.dig(:tree, portable_class_sym).keys.map(&:to_s) + end + + private + + attr_reader :portable + + def attributes_finder + strong_memoize(:attributes_finder) do + ::Gitlab::ImportExport::AttributesFinder.new(config: import_export_config) + end + end + + def import_export_config + ::Gitlab::ImportExport::Config.new(config: import_export_yaml).to_h + end + + def portable_class + @portable_class ||= portable.class + end + + def portable_class_sym + @portable_class_sym ||= portable_class.to_s.demodulize.underscore.to_sym + end + + def import_export_yaml + raise NotImplementedError + end + + def base_export_path + raise NotImplementedError + end + end + end +end diff --git a/app/models/bulk_imports/file_transfer/group_config.rb b/app/models/bulk_imports/file_transfer/group_config.rb new file mode 100644 index 00000000000..1f845b387b8 --- /dev/null +++ b/app/models/bulk_imports/file_transfer/group_config.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module BulkImports + module FileTransfer + class GroupConfig < BaseConfig + def base_export_path + portable.full_path + end + + def import_export_yaml + ::Gitlab::ImportExport.group_config_file + end + end + end +end diff --git a/app/models/bulk_imports/file_transfer/project_config.rb b/app/models/bulk_imports/file_transfer/project_config.rb new file mode 100644 index 00000000000..e42b5bfce3d --- /dev/null +++ b/app/models/bulk_imports/file_transfer/project_config.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module BulkImports + module FileTransfer + class ProjectConfig < BaseConfig + def base_export_path + portable.disk_path + end + + def import_export_yaml + ::Gitlab::ImportExport.config_file + end + end + end +end diff --git a/app/models/bulk_imports/stage.rb b/app/models/bulk_imports/stage.rb deleted file mode 100644 index 050c2c76ce8..00000000000 --- a/app/models/bulk_imports/stage.rb +++ /dev/null @@ -1,65 +0,0 @@ -# frozen_string_literal: true - -module BulkImports - class Stage - include Singleton - - CONFIG = { - group: { - pipeline: BulkImports::Groups::Pipelines::GroupPipeline, - stage: 0 - }, - subgroups: { - pipeline: BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, - stage: 1 - }, - members: { - pipeline: BulkImports::Groups::Pipelines::MembersPipeline, - stage: 1 - }, - labels: { - pipeline: BulkImports::Groups::Pipelines::LabelsPipeline, - stage: 1 - }, - milestones: { - pipeline: BulkImports::Groups::Pipelines::MilestonesPipeline, - stage: 1 - }, - badges: { - pipeline: BulkImports::Groups::Pipelines::BadgesPipeline, - stage: 1 - }, - finisher: { - pipeline: BulkImports::Groups::Pipelines::EntityFinisher, - stage: 2 - } - }.freeze - - def self.pipelines - instance.pipelines - end - - def self.pipeline_exists?(name) - pipelines.any? do |(_, pipeline)| - pipeline.to_s == name.to_s - end - end - - def pipelines - @pipelines ||= config - .values - .sort_by { |entry| entry[:stage] } - .map do |entry| - [entry[:stage], entry[:pipeline]] - end - end - - private - - def config - @config ||= CONFIG - end - end -end - -::BulkImports::Stage.prepend_if_ee('::EE::BulkImports::Stage') diff --git a/app/models/bulk_imports/tracker.rb b/app/models/bulk_imports/tracker.rb index 282ba9e19ac..1b108d5c042 100644 --- a/app/models/bulk_imports/tracker.rb +++ b/app/models/bulk_imports/tracker.rb @@ -35,7 +35,7 @@ class BulkImports::Tracker < ApplicationRecord def pipeline_class unless BulkImports::Stage.pipeline_exists?(pipeline_name) - raise NameError.new("'#{pipeline_name}' is not a valid BulkImport Pipeline") + raise NameError, "'#{pipeline_name}' is not a valid BulkImport Pipeline" end pipeline_name.constantize diff --git a/app/models/chat_name.rb b/app/models/chat_name.rb index 0041595baba..ff3f2663b73 100644 --- a/app/models/chat_name.rb +++ b/app/models/chat_name.rb @@ -3,11 +3,11 @@ class ChatName < ApplicationRecord LAST_USED_AT_INTERVAL = 1.hour - belongs_to :service + belongs_to :integration, foreign_key: :service_id belongs_to :user validates :user, presence: true - validates :service, presence: true + validates :integration, presence: true validates :team_id, presence: true validates :chat_id, presence: true diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index ca400cebe4e..352229c64da 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -163,6 +163,9 @@ module Ci def expanded_environment_name end + def instantized_environment + end + def execute_hooks raise NotImplementedError end @@ -248,4 +251,4 @@ module Ci end end -::Ci::Bridge.prepend_if_ee('::EE::Ci::Bridge') +::Ci::Bridge.prepend_mod_with('Ci::Bridge') diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 3d8e9f4c126..46fc87a6ea8 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -62,6 +62,9 @@ module Ci delegate :gitlab_deploy_token, to: :project delegate :trigger_short_token, to: :trigger_request, allow_nil: true + ignore_columns :id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' + ignore_columns :stage_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' + ## # Since Gitlab 11.5, deployments records started being created right after # `ci_builds` creation. We can look up a relevant `environment` through @@ -85,6 +88,16 @@ module Ci end end + # Initializing an object instead of fetching `persisted_environment` for avoiding unnecessary queries. + # We're planning to introduce a direct relationship between build and environment + # in https://gitlab.com/gitlab-org/gitlab/-/issues/326445 to let us to preload + # in batch. + def instantized_environment + return unless has_environment? + + ::Environment.new(project: self.project, name: self.expanded_environment_name) + end + serialize :options # rubocop:disable Cop/ActiveRecordSerialize serialize :yaml_variables, Gitlab::Serializer::Ci::Variables # rubocop:disable Cop/ActiveRecordSerialize @@ -330,7 +343,7 @@ module Ci begin build.deployment.drop! - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, build_id: build.id) end @@ -1047,7 +1060,7 @@ module Ci end def build_data - @build_data ||= Gitlab::DataBuilder::Build.build(self) + strong_memoize(:build_data) { Gitlab::DataBuilder::Build.build(self) } end def successful_deployment_status @@ -1141,4 +1154,4 @@ module Ci end end -Ci::Build.prepend_if_ee('EE::Ci::Build') +Ci::Build.prepend_mod_with('Ci::Build') diff --git a/app/models/ci/build_dependencies.rb b/app/models/ci/build_dependencies.rb index 8ae921f1416..716d919487d 100644 --- a/app/models/ci/build_dependencies.rb +++ b/app/models/ci/build_dependencies.rb @@ -14,14 +14,33 @@ module Ci (local + cross_pipeline + cross_project).uniq end + def invalid_local + local.reject(&:valid_dependency?) + end + + def valid? + valid_local? && valid_cross_pipeline? && valid_cross_project? + end + + private + + # Dependencies can only be of Ci::Build type because only builds + # can create artifacts + def model_class + ::Ci::Build + end + # Dependencies local to the given pipeline def local - return [] if no_local_dependencies_specified? - - deps = model_class.where(pipeline_id: processable.pipeline_id).latest - deps = from_previous_stages(deps) - deps = from_needs(deps) - from_dependencies(deps) + strong_memoize(:local) do + next [] if no_local_dependencies_specified? + next [] unless processable.pipeline_id # we don't have any dependency when creating the pipeline + + deps = model_class.where(pipeline_id: processable.pipeline_id).latest + deps = from_previous_stages(deps) + deps = from_needs(deps) + from_dependencies(deps).to_a + end end # Dependencies from the same parent-pipeline hierarchy excluding @@ -37,22 +56,6 @@ module Ci [] end - def invalid_local - local.reject(&:valid_dependency?) - end - - def valid? - valid_local? && valid_cross_pipeline? && valid_cross_project? - end - - private - - # Dependencies can only be of Ci::Build type because only builds - # can create artifacts - def model_class - ::Ci::Build - end - def fetch_dependencies_in_hierarchy deps_specifications = specified_cross_pipeline_dependencies return [] if deps_specifications.empty? @@ -102,8 +105,6 @@ module Ci end def valid_local? - return true unless Gitlab::Ci::Features.validate_build_dependencies?(project) - local.all?(&:valid_dependency?) end @@ -154,4 +155,4 @@ module Ci end end -Ci::BuildDependencies.prepend_if_ee('EE::Ci::BuildDependencies') +Ci::BuildDependencies.prepend_mod_with('Ci::BuildDependencies') diff --git a/app/models/ci/build_need.rb b/app/models/ci/build_need.rb index 7bc70f9f1e1..4a59c25cbb0 100644 --- a/app/models/ci/build_need.rb +++ b/app/models/ci/build_need.rb @@ -5,6 +5,9 @@ module Ci extend Gitlab::Ci::Model include BulkInsertSafe + include IgnorableColumns + + ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' belongs_to :build, class_name: "Ci::Processable", foreign_key: :build_id, inverse_of: :needs @@ -14,5 +17,12 @@ module Ci scope :scoped_build, -> { where('ci_builds.id=ci_build_needs.build_id') } scope :artifacts, -> { where(artifacts: true) } + + # TODO: Remove once build_id_convert_to_bigint is not an "ignored" column anymore (see .ignore_columns above) + # There is a database-side trigger to populate this column. This is unexpected in the context + # of cloning an instance, e.g. when retrying the job. Hence we exclude the ignored column explicitly here. + def attributes + super.except('build_id_convert_to_bigint') + end end end diff --git a/app/models/ci/build_runner_session.rb b/app/models/ci/build_runner_session.rb index b6196048ca1..2aa856dbc64 100644 --- a/app/models/ci/build_runner_session.rb +++ b/app/models/ci/build_runner_session.rb @@ -5,6 +5,9 @@ module Ci # Data will be removed after transitioning from running to any state. class BuildRunnerSession < ApplicationRecord extend Gitlab::Ci::Model + include IgnorableColumns + + ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' TERMINAL_SUBPROTOCOL = 'terminal.gitlab.com' DEFAULT_SERVICE_NAME = 'build' diff --git a/app/models/ci/build_trace_chunk.rb b/app/models/ci/build_trace_chunk.rb index 7e03d709f24..719511bbb8a 100644 --- a/app/models/ci/build_trace_chunk.rb +++ b/app/models/ci/build_trace_chunk.rb @@ -8,6 +8,9 @@ module Ci include ::Checksummable include ::Gitlab::ExclusiveLeaseHelpers include ::Gitlab::OptimisticLocking + include IgnorableColumns + + ignore_columns :build_id_convert_to_bigint, remove_with: '14.1', remove_after: '2021-07-22' belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id diff --git a/app/models/ci/commit_with_pipeline.rb b/app/models/ci/commit_with_pipeline.rb index 7f952fb77a0..dde4b534aaa 100644 --- a/app/models/ci/commit_with_pipeline.rb +++ b/app/models/ci/commit_with_pipeline.rb @@ -18,9 +18,25 @@ class Ci::CommitWithPipeline < SimpleDelegator end end + def lazy_latest_pipeline + BatchLoader.for(sha).batch do |shas, loader| + preload_pipelines = project.ci_pipelines.latest_pipeline_per_commit(shas.compact) + + shas.each do |sha| + pipeline = preload_pipelines[sha] + + loader.call(sha, pipeline) + end + end + end + def latest_pipeline(ref = nil) @latest_pipelines.fetch(ref) do |ref| - @latest_pipelines[ref] = latest_pipeline_for_project(ref, project) + @latest_pipelines[ref] = if ref + latest_pipeline_for_project(ref, project) + else + lazy_latest_pipeline&.itself + end end end diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb index 5dcf575abd7..b46d32474c6 100644 --- a/app/models/ci/daily_build_group_report_result.rb +++ b/app/models/ci/daily_build_group_report_result.rb @@ -30,4 +30,4 @@ module Ci end end -Ci::DailyBuildGroupReportResult.prepend_if_ee('EE::Ci::DailyBuildGroupReportResult') +Ci::DailyBuildGroupReportResult.prepend_mod_with('Ci::DailyBuildGroupReportResult') diff --git a/app/models/ci/deleted_object.rb b/app/models/ci/deleted_object.rb index 2942a153e05..b2a949c9bb5 100644 --- a/app/models/ci/deleted_object.rb +++ b/app/models/ci/deleted_object.rb @@ -29,7 +29,7 @@ module Ci def delete_file_from_storage file.remove! true - rescue => exception + rescue StandardError => exception Gitlab::ErrorTracking.track_exception(exception) false end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 50e21a1c323..5248a80f710 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -261,6 +261,22 @@ module Ci self.where(project: project).sum(:size) end + ## + # FastDestroyAll concerns + # rubocop: disable CodeReuse/ServiceClass + def self.begin_fast_destroy + service = ::Ci::JobArtifacts::DestroyAssociationsService.new(self) + service.destroy_records + service + end + # rubocop: enable CodeReuse/ServiceClass + + ## + # FastDestroyAll concerns + def self.finalize_fast_destroy(service) + service.update_statistics + end + def local_store? [nil, ::JobArtifactUploader::Store::LOCAL].include?(self.file_store) end @@ -331,4 +347,4 @@ module Ci end end -Ci::JobArtifact.prepend_if_ee('EE::Ci::JobArtifact') +Ci::JobArtifact.prepend_mod_with('Ci::JobArtifact') diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb index 91163c85a9e..57aa1962bd2 100644 --- a/app/models/ci/persistent_ref.rb +++ b/app/models/ci/persistent_ref.rb @@ -15,13 +15,13 @@ module Ci def exist? ref_exists?(path) - rescue + rescue StandardError false end def create create_ref(sha, path) - rescue => e + rescue StandardError => e Gitlab::ErrorTracking .track_exception(e, pipeline_id: pipeline.id) end @@ -30,7 +30,7 @@ module Ci delete_refs(path) rescue Gitlab::Git::Repository::NoRepository # no-op - rescue => e + rescue StandardError => e Gitlab::ErrorTracking .track_exception(e, pipeline_id: pipeline.id) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index c9ab69317e1..f0a2c074584 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -17,6 +17,7 @@ module Ci include FromUnion include UpdatedAtFilterable include EachBatch + include FastDestroyAll::Helpers MAX_OPEN_MERGE_REQUESTS_REFS = 4 @@ -70,7 +71,9 @@ module Ci has_many :deployments, through: :builds has_many :environments, -> { distinct }, through: :deployments has_many :latest_builds, -> { latest.with_project_and_metadata }, foreign_key: :commit_id, inverse_of: :pipeline, class_name: 'Ci::Build' - has_many :downloadable_artifacts, -> { not_expired.downloadable.with_job }, through: :latest_builds, source: :job_artifacts + has_many :downloadable_artifacts, -> do + not_expired.or(where_exists(::Ci::Pipeline.artifacts_locked.where('ci_pipelines.id = ci_builds.commit_id'))).downloadable.with_job + end, through: :latest_builds, source: :job_artifacts has_many :messages, class_name: 'Ci::PipelineMessage', inverse_of: :pipeline @@ -124,6 +127,8 @@ module Ci after_create :keep_around_commits, unless: :importing? + use_fast_destroy :job_artifacts + # We use `Enums::Ci::Pipeline.sources` here so that EE can more easily extend # this `Hash` with new values. enum_with_nil source: Enums::Ci::Pipeline.sources @@ -908,7 +913,7 @@ module Ci def same_family_pipeline_ids ::Gitlab::Ci::PipelineObjectHierarchy.new( - self.class.where(id: root_ancestor), options: { same_project: true } + self.class.default_scoped.where(id: root_ancestor), options: { same_project: true } ).base_and_descendants.select(:id) end @@ -1093,6 +1098,8 @@ module Ci merge_request.modified_paths elsif branch_updated? push_details.modified_paths + elsif external_pull_request? && ::Feature.enabled?(:ci_modified_paths_of_external_prs, project, default_enabled: :yaml) + external_pull_request.modified_paths end end end @@ -1117,6 +1124,10 @@ module Ci merge_request_id.present? end + def external_pull_request? + external_pull_request_id.present? + end + def detached_merge_request_pipeline? merge_request? && target_sha.nil? end @@ -1210,11 +1221,18 @@ module Ci # We need `base_and_ancestors` in a specific order to "break" when needed. # If we use `find_each`, then the order is broken. # rubocop:disable Rails/FindEach - def reset_ancestor_bridges! - base_and_ancestors.includes(:source_bridge).each do |pipeline| - break unless pipeline.bridge_waiting? + def reset_source_bridge!(current_user) + if ::Feature.enabled?(:ci_reset_bridge_with_subsequent_jobs, project, default_enabled: :yaml) + return unless bridge_waiting? - pipeline.source_bridge.pending! + source_bridge.pending! + Ci::AfterRequeueJobService.new(project, current_user).execute(source_bridge) # rubocop:disable CodeReuse/ServiceClass + else + base_and_ancestors.includes(:source_bridge).each do |pipeline| + break unless pipeline.bridge_waiting? + + pipeline.source_bridge.pending! + end end end # rubocop:enable Rails/FindEach @@ -1237,8 +1255,6 @@ module Ci private def add_message(severity, content) - return unless Gitlab::Ci::Features.store_pipeline_messages?(project) - messages.build(severity: severity, content: content) end @@ -1294,4 +1310,4 @@ module Ci end end -Ci::Pipeline.prepend_if_ee('EE::Ci::Pipeline') +Ci::Pipeline.prepend_mod_with('Ci::Pipeline') diff --git a/app/models/ci/pipeline_artifact.rb b/app/models/ci/pipeline_artifact.rb index 9dfe4252e95..889c5d094a7 100644 --- a/app/models/ci/pipeline_artifact.rb +++ b/app/models/ci/pipeline_artifact.rb @@ -40,6 +40,8 @@ module Ci code_quality_mr_diff: 2 } + scope :unlocked, -> { joins(:pipeline).merge(::Ci::Pipeline.unlocked) } + class << self def report_exists?(file_type) return false unless REPORT_TYPES.key?(file_type) @@ -58,4 +60,4 @@ module Ci end end -Ci::PipelineArtifact.prepend_ee_mod +Ci::PipelineArtifact.prepend_mod diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb index 3c17246bc34..9e5d517c1fe 100644 --- a/app/models/ci/pipeline_schedule.rb +++ b/app/models/ci/pipeline_schedule.rb @@ -5,7 +5,7 @@ module Ci extend Gitlab::Ci::Model include Importable include StripAttribute - include Schedulable + include CronSchedulable include Limitable include EachBatch @@ -51,38 +51,16 @@ module Ci update_attribute(:active, false) end - ## - # The `next_run_at` column is set to the actual execution date of `PipelineScheduleWorker`. - # This way, a schedule like `*/1 * * * *` won't be triggered in a short interval - # when PipelineScheduleWorker runs irregularly by Sidekiq Memory Killer. - def set_next_run_at - now = Time.zone.now - ideal_next_run = ideal_next_run_from(now) - - self.next_run_at = if ideal_next_run == cron_worker_next_run_from(now) - ideal_next_run - else - cron_worker_next_run_from(ideal_next_run) - end - end - def job_variables variables&.map(&:to_runner_variable) || [] end private - def ideal_next_run_from(start_time) - Gitlab::Ci::CronParser.new(cron, cron_timezone) - .next_time_from(start_time) - end - - def cron_worker_next_run_from(start_time) - Gitlab::Ci::CronParser.new(Settings.cron_jobs['pipeline_schedule_worker']['cron'], - Time.zone.name) - .next_time_from(start_time) + def worker_cron_expression + Settings.cron_jobs['pipeline_schedule_worker']['cron'] end end end -Ci::PipelineSchedule.prepend_if_ee('EE::Ci::PipelineSchedule') +Ci::PipelineSchedule.prepend_mod_with('Ci::PipelineSchedule') diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb index 3b61840805a..15c57550159 100644 --- a/app/models/ci/processable.rb +++ b/app/models/ci/processable.rb @@ -120,6 +120,10 @@ module Ci raise NotImplementedError end + def instantized_environment + raise NotImplementedError + end + override :all_met_to_become_pending? def all_met_to_become_pending? super && !with_resource_group? diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 05126853e0f..8c877c2b818 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -39,16 +39,16 @@ module Ci AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze AVAILABLE_TYPES = runner_types.keys.freeze - AVAILABLE_STATUSES = %w[active paused online offline].freeze + AVAILABLE_STATUSES = %w[active paused online offline not_connected].freeze AVAILABLE_SCOPES = (AVAILABLE_TYPES_LEGACY + AVAILABLE_TYPES + AVAILABLE_STATUSES).freeze FORM_EDITABLE = %i[description tag_list active run_untagged locked access_level maximum_timeout_human_readable].freeze MINUTES_COST_FACTOR_FIELDS = %i[public_projects_minutes_cost_factor private_projects_minutes_cost_factor].freeze has_many :builds - has_many :runner_projects, inverse_of: :runner, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :runner_projects, inverse_of: :runner, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :projects, through: :runner_projects - has_many :runner_namespaces, inverse_of: :runner + has_many :runner_namespaces, inverse_of: :runner, autosave: true has_many :groups, through: :runner_namespaces has_one :last_build, -> { order('id DESC') }, class_name: 'Ci::Build' @@ -65,6 +65,7 @@ module Ci # did `contacted_at <= ?` the query would effectively have to do a seq # scan. scope :offline, -> { where.not(id: online) } + scope :not_connected, -> { where(contacted_at: nil) } scope :ordered, -> { order(id: :desc) } scope :with_recent_runner_queue, -> { where('contacted_at > ?', recent_queue_deadline) } @@ -405,4 +406,4 @@ module Ci end end -Ci::Runner.prepend_if_ee('EE::Ci::Runner') +Ci::Runner.prepend_mod_with('Ci::Runner') diff --git a/app/models/ci/runner_namespace.rb b/app/models/ci/runner_namespace.rb index e6c1899c89d..f819dda207d 100644 --- a/app/models/ci/runner_namespace.rb +++ b/app/models/ci/runner_namespace.rb @@ -3,6 +3,11 @@ module Ci class RunnerNamespace < ApplicationRecord extend Gitlab::Ci::Model + include Limitable + + self.limit_name = 'ci_registered_group_runners' + self.limit_scope = :group + self.limit_feature_flag = :ci_runner_limits 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 f5bd50dc5a3..c26b8183b52 100644 --- a/app/models/ci/runner_project.rb +++ b/app/models/ci/runner_project.rb @@ -3,6 +3,11 @@ module Ci class RunnerProject < ApplicationRecord extend Gitlab::Ci::Model + include Limitable + + self.limit_name = 'ci_registered_project_runners' + self.limit_scope = :project + self.limit_feature_flag = :ci_runner_limits belongs_to :runner, inverse_of: :runner_projects belongs_to :project, inverse_of: :runner_projects diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 5ae97dcd495..ef920b2d589 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -41,7 +41,7 @@ module Ci self.position = statuses.select(:stage_idx) .where.not(stage_idx: nil) .group(:stage_idx) - .order('COUNT(*) DESC') + .order('COUNT(id) DESC') .first&.stage_idx.to_i end diff --git a/app/models/ci/trigger.rb b/app/models/ci/trigger.rb index 85cb3f5b46a..6e27abb9f5b 100644 --- a/app/models/ci/trigger.rb +++ b/app/models/ci/trigger.rb @@ -37,4 +37,4 @@ module Ci end end -Ci::Trigger.prepend_if_ee('EE::Ci::Trigger') +Ci::Trigger.prepend_mod_with('Ci::Trigger') diff --git a/app/models/ci/unit_test.rb b/app/models/ci/unit_test.rb index 81623b4f6ad..9fddd9c6002 100644 --- a/app/models/ci/unit_test.rb +++ b/app/models/ci/unit_test.rb @@ -14,6 +14,7 @@ module Ci belongs_to :project scope :by_project_and_keys, -> (project, keys) { where(project_id: project.id, key_hash: keys) } + scope :deletable, -> { where('NOT EXISTS (?)', Ci::UnitTestFailure.select(1).where("#{Ci::UnitTestFailure.table_name}.unit_test_id = #{table_name}.id")) } class << self def find_or_create_by_batch(project, unit_test_attrs) diff --git a/app/models/ci/unit_test_failure.rb b/app/models/ci/unit_test_failure.rb index 653a56bd2b3..480f9cefb8e 100644 --- a/app/models/ci/unit_test_failure.rb +++ b/app/models/ci/unit_test_failure.rb @@ -11,6 +11,8 @@ module Ci belongs_to :unit_test, class_name: "Ci::UnitTest", foreign_key: :unit_test_id belongs_to :build, class_name: "Ci::Build", foreign_key: :build_id + scope :deletable, -> { where('failed_at < ?', REPORT_WINDOW.ago) } + def self.recent_failures_count(project:, unit_test_keys:, date_range: REPORT_WINDOW.ago..Time.current) joins(:unit_test) .where( diff --git a/app/models/clusters/agent.rb b/app/models/clusters/agent.rb index c5b9dddb1da..9fb8cd024c5 100644 --- a/app/models/clusters/agent.rb +++ b/app/models/clusters/agent.rb @@ -8,6 +8,7 @@ module Clusters belongs_to :project, class_name: '::Project' # Otherwise, it will load ::Clusters::Project has_many :agent_tokens, class_name: 'Clusters::AgentToken' + has_many :last_used_agent_tokens, -> { order_last_used_at_desc }, class_name: 'Clusters::AgentToken', inverse_of: :agent scope :ordered_by_name, -> { order(:name) } scope :with_name, -> (name) { where(name: name) } diff --git a/app/models/clusters/agent_token.rb b/app/models/clusters/agent_token.rb index d42279502c5..27a3cd8d13d 100644 --- a/app/models/clusters/agent_token.rb +++ b/app/models/clusters/agent_token.rb @@ -6,7 +6,7 @@ module Clusters include TokenAuthenticatable add_authentication_token_field :token, encrypted: :required, token_generator: -> { Devise.friendly_token(50) } - cached_attr_reader :last_contacted_at + cached_attr_reader :last_used_at self.table_name = 'cluster_agent_tokens' @@ -21,6 +21,8 @@ module Clusters validates :description, length: { maximum: 1024 } validates :name, presence: true, length: { maximum: 255 } + scope :order_last_used_at_desc, -> { order(::Gitlab::Database.nulls_last_order('last_used_at', 'DESC')) } + def track_usage track_values = { last_used_at: Time.current.utc } diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb index db18a29ec84..73c731aab1a 100644 --- a/app/models/clusters/applications/elastic_stack.rb +++ b/app/models/clusters/applications/elastic_stack.rb @@ -3,9 +3,9 @@ module Clusters module Applications class ElasticStack < ApplicationRecord - VERSION = '3.0.0' + include ::Clusters::Concerns::ElasticsearchClient - ELASTICSEARCH_PORT = 9200 + VERSION = '3.0.0' self.table_name = 'clusters_applications_elastic_stacks' @@ -13,10 +13,23 @@ module Clusters include ::Clusters::Concerns::ApplicationStatus include ::Clusters::Concerns::ApplicationVersion include ::Clusters::Concerns::ApplicationData - include ::Gitlab::Utils::StrongMemoize default_value_for :version, VERSION + after_destroy do + cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil) + end + + state_machine :status do + after_transition any => [:installed] do |application| + application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: true, chart_version: application.version) + end + + after_transition any => [:uninstalled] do |application| + application.cluster&.find_or_build_integration_elastic_stack&.update(enabled: false, chart_version: nil) + end + end + def chart 'elastic-stack/elastic-stack' end @@ -51,31 +64,6 @@ module Clusters super.merge('wait-for-elasticsearch.sh': File.read("#{Rails.root}/vendor/elastic_stack/wait-for-elasticsearch.sh")) end - def elasticsearch_client(timeout: nil) - strong_memoize(:elasticsearch_client) do - next unless kube_client - - proxy_url = kube_client.proxy_url('service', service_name, ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE) - - Elasticsearch::Client.new(url: proxy_url) do |faraday| - # ensures headers containing auth data are appended to original client options - faraday.headers.merge!(kube_client.headers) - # ensure TLS certs are properly verified - faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl] - faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store] - faraday.options.timeout = timeout unless timeout.nil? - end - - rescue Kubeclient::HttpError => error - # If users have mistakenly set parameters or removed the depended clusters, - # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. - # We check for a nil client in downstream use and behaviour is equivalent to an empty state - log_exception(error, :failed_to_create_elasticsearch_client) - - nil - end - end - def chart_above_v2? Gem::Version.new(version) >= Gem::Version.new('2.0.0') end @@ -106,10 +94,6 @@ module Clusters ] end - def kube_client - cluster&.kubeclient&.core_client - end - def migrate_to_3_script return [] if !updating? || chart_above_v3? diff --git a/app/models/clusters/applications/prometheus.rb b/app/models/clusters/applications/prometheus.rb index b9c136abab4..21f7e410843 100644 --- a/app/models/clusters/applications/prometheus.rb +++ b/app/models/clusters/applications/prometheus.rb @@ -22,21 +22,18 @@ module Clusters attr_encrypted :alert_manager_token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' + default_value_for(:alert_manager_token) { SecureRandom.hex } + after_destroy do - run_after_commit do - disable_prometheus_integration - end + cluster.find_or_build_integration_prometheus.destroy end state_machine :status do after_transition any => [:installed, :externally_installed] do |application| - application.run_after_commit do - Clusters::Applications::ActivateServiceWorker - .perform_async(application.cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass - end + application.cluster.find_or_build_integration_prometheus.update(enabled: true, alert_manager_token: application.alert_manager_token) end after_transition any => :updating do |application| @@ -44,6 +41,10 @@ module Clusters end end + def managed_prometheus? + !externally_installed? && !uninstalled? + end + def updated_since?(timestamp) last_update_started_at && last_update_started_at > timestamp && @@ -70,6 +71,7 @@ module Clusters ) end + # Deprecated, to be removed in %14.0 as part of https://gitlab.com/groups/gitlab-org/-/epics/4280 def patch_command(values) helm_command_module::PatchCommand.new( name: name, @@ -98,23 +100,8 @@ module Clusters files.merge('values.yaml': replaced_values) end - def generate_alert_manager_token! - unless alert_manager_token.present? - update!(alert_manager_token: generate_token) - end - end - private - def generate_token - SecureRandom.hex - end - - def disable_prometheus_integration - ::Clusters::Applications::DeactivateServiceWorker - .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass - end - def install_knative_metrics return [] unless cluster.application_knative_available? diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb index bc80bcd0b06..e8d56072b89 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.27.0' + VERSION = '0.28.0' self.table_name = 'clusters_applications_runners' diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb index a1e2aa194a0..4877ced795c 100644 --- a/app/models/clusters/cluster.rb +++ b/app/models/clusters/cluster.rb @@ -52,6 +52,7 @@ module Clusters has_one :platform_kubernetes, class_name: 'Clusters::Platforms::Kubernetes', inverse_of: :cluster, autosave: true has_one :integration_prometheus, class_name: 'Clusters::Integrations::Prometheus', inverse_of: :cluster + has_one :integration_elastic_stack, class_name: 'Clusters::Integrations::ElasticStack', inverse_of: :cluster def self.has_one_cluster_application(name) # rubocop:disable Naming/PredicateName application = APPLICATIONS[name.to_s] @@ -104,6 +105,7 @@ module Clusters delegate :available?, to: :application_ingress, prefix: true, allow_nil: true delegate :available?, to: :application_knative, prefix: true, allow_nil: true delegate :available?, to: :application_elastic_stack, prefix: true, allow_nil: true + delegate :available?, to: :integration_elastic_stack, prefix: true, allow_nil: true delegate :external_ip, to: :application_ingress, prefix: true, allow_nil: true delegate :external_hostname, to: :application_ingress, prefix: true, allow_nil: true @@ -284,6 +286,10 @@ module Clusters integration_prometheus || build_integration_prometheus end + def find_or_build_integration_elastic_stack + integration_elastic_stack || build_integration_elastic_stack + end + def provider if gcp? provider_gcp @@ -318,6 +324,22 @@ module Clusters platform_kubernetes.kubeclient if kubernetes? end + def elastic_stack_adapter + application_elastic_stack || integration_elastic_stack + end + + def elasticsearch_client + elastic_stack_adapter&.elasticsearch_client + end + + def elastic_stack_available? + if application_elastic_stack_available? || integration_elastic_stack_available? + true + else + false + end + end + def kubernetes_namespace_for(environment, deployable: environment.last_deployable) if deployable && environment.project_id != deployable.project_id raise ArgumentError, 'environment.project_id must match deployable.project_id' @@ -470,4 +492,4 @@ module Clusters end end -Clusters::Cluster.prepend_if_ee('EE::Clusters::Cluster') +Clusters::Cluster.prepend_mod_with('Clusters::Cluster') diff --git a/app/models/clusters/concerns/application_core.rb b/app/models/clusters/concerns/application_core.rb index ad6699daa78..2e40689a650 100644 --- a/app/models/clusters/concerns/application_core.rb +++ b/app/models/clusters/concerns/application_core.rb @@ -6,6 +6,8 @@ module Clusters extend ActiveSupport::Concern included do + include ::Clusters::Concerns::KubernetesLogger + belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id validates :cluster, presence: true @@ -79,27 +81,9 @@ module Clusters # Override if your application needs any action after # being uninstalled by Helm end - - def logger - @logger ||= Gitlab::Kubernetes::Logger.build - end - - def log_exception(error, event) - logger.error({ - exception: error.class.name, - status_code: error.error_code, - cluster_id: cluster&.id, - application_id: id, - class_name: self.class.name, - event: event, - message: error.message - }) - - Gitlab::ErrorTracking.track_exception(error, cluster_id: cluster&.id, application_id: id) - end end end end end -Clusters::Concerns::ApplicationCore.prepend_if_ee('EE::Clusters::Concerns::ApplicationCore') +Clusters::Concerns::ApplicationCore.prepend_mod_with('Clusters::Concerns::ApplicationCore') diff --git a/app/models/clusters/concerns/elasticsearch_client.rb b/app/models/clusters/concerns/elasticsearch_client.rb new file mode 100644 index 00000000000..7b0b6bdae02 --- /dev/null +++ b/app/models/clusters/concerns/elasticsearch_client.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Clusters + module Concerns + module ElasticsearchClient + include ::Gitlab::Utils::StrongMemoize + + ELASTICSEARCH_PORT = 9200 + ELASTICSEARCH_NAMESPACE = 'gitlab-managed-apps' + + def elasticsearch_client(timeout: nil) + strong_memoize(:elasticsearch_client) do + kube_client = cluster&.kubeclient&.core_client + next unless kube_client + + proxy_url = kube_client.proxy_url('service', service_name, ELASTICSEARCH_PORT, ELASTICSEARCH_NAMESPACE) + + Elasticsearch::Client.new(url: proxy_url) do |faraday| + # ensures headers containing auth data are appended to original client options + faraday.headers.merge!(kube_client.headers) + # ensure TLS certs are properly verified + faraday.ssl[:verify] = kube_client.ssl_options[:verify_ssl] + faraday.ssl[:cert_store] = kube_client.ssl_options[:cert_store] + faraday.options.timeout = timeout unless timeout.nil? + end + + rescue Kubeclient::HttpError => error + # If users have mistakenly set parameters or removed the depended clusters, + # `proxy_url` could raise an exception because gitlab can not communicate with the cluster. + # We check for a nil client in downstream use and behaviour is equivalent to an empty state + log_exception(error, :failed_to_create_elasticsearch_client) + + nil + end + end + end + end +end diff --git a/app/models/clusters/concerns/kubernetes_logger.rb b/app/models/clusters/concerns/kubernetes_logger.rb new file mode 100644 index 00000000000..2eca33a7610 --- /dev/null +++ b/app/models/clusters/concerns/kubernetes_logger.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Clusters + module Concerns + module KubernetesLogger + def logger + @logger ||= Gitlab::Kubernetes::Logger.build + end + + def log_exception(error, event) + logger.error( + { + exception: error.class.name, + status_code: error.error_code, + cluster_id: cluster&.id, + application_id: id, + class_name: self.class.name, + event: event, + message: error.message + } + ) + + Gitlab::ErrorTracking.track_exception(error, cluster_id: cluster&.id, application_id: id) + end + end + end +end diff --git a/app/models/clusters/integrations/elastic_stack.rb b/app/models/clusters/integrations/elastic_stack.rb new file mode 100644 index 00000000000..565d268259a --- /dev/null +++ b/app/models/clusters/integrations/elastic_stack.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Clusters + module Integrations + class ElasticStack < ApplicationRecord + include ::Clusters::Concerns::ElasticsearchClient + include ::Clusters::Concerns::KubernetesLogger + + self.table_name = 'clusters_integration_elasticstack' + self.primary_key = :cluster_id + + belongs_to :cluster, class_name: 'Clusters::Cluster', foreign_key: :cluster_id + + validates :cluster, presence: true + validates :enabled, inclusion: { in: [true, false] } + + def available? + enabled + end + + def service_name + chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client' + end + + def chart_above_v2? + return true if chart_version.nil? + + Gem::Version.new(chart_version) >= Gem::Version.new('2.0.0') + end + + def chart_above_v3? + return true if chart_version.nil? + + Gem::Version.new(chart_version) >= Gem::Version.new('3.0.0') + end + end + end +end diff --git a/app/models/clusters/integrations/prometheus.rb b/app/models/clusters/integrations/prometheus.rb index 1496d8ff1dd..0a01ac5d1ce 100644 --- a/app/models/clusters/integrations/prometheus.rb +++ b/app/models/clusters/integrations/prometheus.rb @@ -4,6 +4,7 @@ module Clusters module Integrations class Prometheus < ApplicationRecord include ::Clusters::Concerns::PrometheusClient + include AfterCommitQueue self.table_name = 'clusters_integration_prometheus' self.primary_key = :cluster_id @@ -13,9 +14,46 @@ module Clusters validates :cluster, presence: true validates :enabled, inclusion: { in: [true, false] } + attr_encrypted :alert_manager_token, + mode: :per_attribute_iv, + key: Settings.attr_encrypted_db_key_base_32, + algorithm: 'aes-256-gcm' + + default_value_for(:alert_manager_token) { SecureRandom.hex } + + after_destroy do + run_after_commit do + deactivate_project_services + end + end + + after_save do + next unless enabled_before_last_save != enabled + + run_after_commit do + if enabled + activate_project_services + else + deactivate_project_services + end + end + end + def available? enabled? end + + private + + def activate_project_services + ::Clusters::Applications::ActivateServiceWorker + .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass + end + + def deactivate_project_services + ::Clusters::Applications::DeactivateServiceWorker + .perform_async(cluster_id, ::PrometheusService.to_param) # rubocop:disable CodeReuse/ServiceClass + end end end end diff --git a/app/models/clusters/providers/aws.rb b/app/models/clusters/providers/aws.rb index bfd01775620..af2eba42721 100644 --- a/app/models/clusters/providers/aws.rb +++ b/app/models/clusters/providers/aws.rb @@ -18,7 +18,7 @@ module Clusters attr_encrypted :secret_access_key, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' validates :role_arn, diff --git a/app/models/commit.rb b/app/models/commit.rb index 5c3e3685c64..09e43bb8f20 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -142,6 +142,7 @@ class Commit delegate \ :pipelines, :last_pipeline, + :lazy_latest_pipeline, :latest_pipeline, :latest_pipeline_for_project, :set_latest_pipeline_for_ref, diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index e989129209a..c5ba19438cd 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -214,8 +214,14 @@ class CommitStatus < ApplicationRecord allow_failure? && (failed? || canceled?) end + # Time spent running. def duration - calculate_duration + calculate_duration(started_at, finished_at) + end + + # Time spent in the pending state. + def queued_duration + calculate_duration(queued_at, started_at) end def latest? @@ -286,4 +292,4 @@ class CommitStatus < ApplicationRecord end end -CommitStatus.prepend_if_ee('::EE::CommitStatus') +CommitStatus.prepend_mod_with('CommitStatus') diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb index f1c39dda49d..90d48aa81d0 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage.rb @@ -27,6 +27,7 @@ module Analytics scope :default_stages, -> { where(custom: false) } 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) } end def parent=(_) diff --git a/app/models/concerns/atomic_internal_id.rb b/app/models/concerns/atomic_internal_id.rb index bbf9ecbcfe9..80cf6260b0b 100644 --- a/app/models/concerns/atomic_internal_id.rb +++ b/app/models/concerns/atomic_internal_id.rb @@ -214,9 +214,9 @@ module AtomicInternalId def self.project_init(klass, column_name = :iid) ->(instance, scope) do if instance - klass.where(project_id: instance.project_id).maximum(column_name) + klass.default_scoped.where(project_id: instance.project_id).maximum(column_name) elsif scope.present? - klass.where(**scope).maximum(column_name) + klass.default_scoped.where(**scope).maximum(column_name) end end end diff --git a/app/models/concerns/board_recent_visit.rb b/app/models/concerns/board_recent_visit.rb new file mode 100644 index 00000000000..fd4d574ac58 --- /dev/null +++ b/app/models/concerns/board_recent_visit.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module BoardRecentVisit + extend ActiveSupport::Concern + + class_methods do + def visited!(user, board) + find_or_create_by( + "user" => user, + board_parent_relation => board.resource_parent, + board_relation => board + ).tap do |visit| + visit.touch + end + rescue ActiveRecord::RecordNotUnique + retry + end + + def latest(user, parent, count: nil) + visits = by_user_parent(user, parent).order(updated_at: :desc) + visits = visits.preload(board_relation) + + visits.first(count) + end + + def board_relation + :board + end + + def board_parent_relation + raise NotImplementedError + end + end +end diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 34c1b6d25a4..a5cf947ba07 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -26,7 +26,7 @@ module CacheMarkdownField # Returns the default Banzai render context for the cached markdown field. def banzai_render_context(field) - raise ArgumentError.new("Unknown field: #{field.inspect}") unless + raise ArgumentError, "Unknown field: #{field.inspect}" unless cached_markdown_fields.markdown_fields.include?(field) # Always include a project key, or Banzai complains @@ -99,7 +99,7 @@ module CacheMarkdownField end def cached_html_for(markdown_field) - raise ArgumentError.new("Unknown field: #{markdown_field}") unless + raise ArgumentError, "Unknown field: #{markdown_field}" unless cached_markdown_fields.markdown_fields.include?(markdown_field) __send__(cached_markdown_fields.html_field(markdown_field)) # rubocop:disable GitlabSecurity/PublicSend diff --git a/app/models/concerns/cacheable_attributes.rb b/app/models/concerns/cacheable_attributes.rb index ee56322cce7..f3b47047c55 100644 --- a/app/models/concerns/cacheable_attributes.rb +++ b/app/models/concerns/cacheable_attributes.rb @@ -53,7 +53,7 @@ module CacheableAttributes return cached_record if cached_record.present? current_without_cache.tap { |current_record| current_record&.cache! } - rescue => e + rescue StandardError => e if Rails.env.production? Gitlab::AppLogger.warn("Cached record for #{name} couldn't be loaded, falling back to uncached record: #{e}") else @@ -66,7 +66,7 @@ module CacheableAttributes def expire Gitlab::SafeRequestStore.delete(request_store_cache_key) cache_backend.delete(cache_key) - rescue + rescue StandardError # Gracefully handle when Redis is not available. For example, # omnibus may fail here during gitlab:assets:compile. end diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb index 2b4a108a9a0..9efd90756b1 100644 --- a/app/models/concerns/cascading_namespace_setting_attribute.rb +++ b/app/models/concerns/cascading_namespace_setting_attribute.rb @@ -55,6 +55,7 @@ module CascadingNamespaceSettingAttribute # public methods define_attr_reader(attribute) define_attr_writer(attribute) + define_lock_attr_writer(attribute) define_lock_methods(attribute) alias_boolean(attribute) @@ -84,7 +85,7 @@ module CascadingNamespaceSettingAttribute next self[attribute] unless self.class.cascading_settings_feature_enabled? next self[attribute] if will_save_change_to_attribute?(attribute) - next locked_value(attribute) if cascading_attribute_locked?(attribute) + next locked_value(attribute) if cascading_attribute_locked?(attribute, include_self: false) next self[attribute] unless self[attribute].nil? cascaded_value = cascaded_ancestor_value(attribute) @@ -97,15 +98,25 @@ module CascadingNamespaceSettingAttribute def define_attr_writer(attribute) define_method("#{attribute}=") do |value| + return value if value == cascaded_ancestor_value(attribute) + clear_memoization(attribute) + super(value) + end + end + + def define_lock_attr_writer(attribute) + define_method("lock_#{attribute}=") do |value| + attr_value = public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend + write_attribute(attribute, attr_value) if self[attribute].nil? super(value) end end def define_lock_methods(attribute) - define_method("#{attribute}_locked?") do - cascading_attribute_locked?(attribute) + define_method("#{attribute}_locked?") do |include_self: false| + cascading_attribute_locked?(attribute, include_self: include_self) end define_method("#{attribute}_locked_by_ancestor?") do @@ -133,7 +144,7 @@ module CascadingNamespaceSettingAttribute def define_validator_methods(attribute) define_method("#{attribute}_changeable?") do return unless cascading_attribute_changed?(attribute) - return unless cascading_attribute_locked?(attribute) + return unless cascading_attribute_locked?(attribute, include_self: false) errors.add(attribute, s_('CascadingSettings|cannot be changed because it is locked by an ancestor')) end @@ -141,7 +152,7 @@ module CascadingNamespaceSettingAttribute define_method("lock_#{attribute}_changeable?") do return unless cascading_attribute_changed?("lock_#{attribute}") - if cascading_attribute_locked?(attribute) + if cascading_attribute_locked?(attribute, include_self: false) return errors.add(:"lock_#{attribute}", s_('CascadingSettings|cannot be changed because it is locked by an ancestor')) end @@ -202,8 +213,9 @@ module CascadingNamespaceSettingAttribute Gitlab::CurrentSettings.public_send("lock_#{attribute}") # rubocop:disable GitlabSecurity/PublicSend end - def cascading_attribute_locked?(attribute) - locked_by_ancestor?(attribute) || locked_by_application_setting?(attribute) + def cascading_attribute_locked?(attribute, include_self:) + locked_by_self = include_self ? public_send("lock_#{attribute}?") : false # rubocop:disable GitlabSecurity/PublicSend + locked_by_self || locked_by_ancestor?(attribute) || locked_by_application_setting?(attribute) end def cascading_attribute_changed?(attribute) diff --git a/app/models/concerns/ci/artifactable.rb b/app/models/concerns/ci/artifactable.rb index 0d29955268f..27040a677ff 100644 --- a/app/models/concerns/ci/artifactable.rb +++ b/app/models/concerns/ci/artifactable.rb @@ -43,4 +43,4 @@ module Ci end end -Ci::Artifactable.prepend_ee_mod +Ci::Artifactable.prepend_mod diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index c990da5873a..f3c254053b5 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -122,12 +122,10 @@ module Ci private - def calculate_duration - if started_at && finished_at - finished_at - started_at - elsif started_at - Time.current - started_at - end + def calculate_duration(start_time, end_time) + return unless start_time + + (end_time || Time.current) - start_time end end end diff --git a/app/models/concerns/ci/maskable.rb b/app/models/concerns/ci/maskable.rb index 4e0ee72f18f..e1ef4531845 100644 --- a/app/models/concerns/ci/maskable.rb +++ b/app/models/concerns/ci/maskable.rb @@ -9,9 +9,9 @@ module Ci # * No variables # * No spaces # * Minimal length of 8 characters - # * Characters must be from the Base64 alphabet (RFC4648) with the addition of '@', ':' and '.' + # * Characters must be from the Base64 alphabet (RFC4648) with the addition of '@', ':', '.', and '~' # * Absolutely no fun is allowed - REGEX = /\A[a-zA-Z0-9_+=\/@:.-]{8,}\z/.freeze + REGEX = /\A[a-zA-Z0-9_+=\/@:.~-]{8,}\z/.freeze included do validates :masked, inclusion: { in: [true, false] } diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index 26e644646b4..601637ea32a 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -88,4 +88,4 @@ module Ci end end -Ci::Metadatable.prepend_if_ee('EE::Ci::Metadatable') +Ci::Metadatable.prepend_mod_with('Ci::Metadatable') diff --git a/app/models/concerns/cron_schedulable.rb b/app/models/concerns/cron_schedulable.rb new file mode 100644 index 00000000000..beb3a09c119 --- /dev/null +++ b/app/models/concerns/cron_schedulable.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module CronSchedulable + extend ActiveSupport::Concern + include Schedulable + + ## + # The `next_run_at` column is set to the actual execution date of worker that + # triggers the schedule. This way, a schedule like `*/1 * * * *` won't be triggered + # in a short interval when the worker runs irregularly by Sidekiq Memory Killer. + def set_next_run_at + now = Time.zone.now + ideal_next_run = ideal_next_run_from(now) + + self.next_run_at = if ideal_next_run == cron_worker_next_run_from(now) + ideal_next_run + else + cron_worker_next_run_from(ideal_next_run) + end + end + + private + + def ideal_next_run_from(start_time) + next_time_from(start_time, cron, cron_timezone) + end + + def cron_worker_next_run_from(start_time) + next_time_from(start_time, worker_cron_expression, Time.zone.name) + end + + def next_time_from(start_time, cron, cron_timezone) + Gitlab::Ci::CronParser + .new(cron, cron_timezone) + .next_time_from(start_time) + end + + def worker_cron_expression + raise NotImplementedError + end +end diff --git a/app/models/concerns/enums/ci/commit_status.rb b/app/models/concerns/enums/ci/commit_status.rb index de17f50cd29..2e368b12cb7 100644 --- a/app/models/concerns/enums/ci/commit_status.rb +++ b/app/models/concerns/enums/ci/commit_status.rb @@ -22,6 +22,8 @@ module Enums forward_deployment_failure: 13, user_blocked: 14, project_deleted: 15, + ci_quota_exceeded: 16, + pipeline_loop_detected: 17, insufficient_bridge_permissions: 1_001, downstream_bridge_project_not_found: 1_002, invalid_bridge_trigger: 1_003, @@ -35,4 +37,4 @@ module Enums end end -Enums::Ci::CommitStatus.prepend_if_ee('EE::Enums::Ci::CommitStatus') +Enums::Ci::CommitStatus.prepend_mod_with('Enums::Ci::CommitStatus') diff --git a/app/models/concerns/enums/ci/pipeline.rb b/app/models/concerns/enums/ci/pipeline.rb index fdc48d09db2..c42b046592f 100644 --- a/app/models/concerns/enums/ci/pipeline.rb +++ b/app/models/concerns/enums/ci/pipeline.rb @@ -10,6 +10,7 @@ module Enums unknown_failure: 0, config_error: 1, external_validation_failure: 2, + user_not_verified: 3, activity_limit_exceeded: 20, size_limit_exceeded: 21, job_activity_limit_exceeded: 22, diff --git a/app/models/concerns/enums/internal_id.rb b/app/models/concerns/enums/internal_id.rb index b08c05b1934..71c86bab136 100644 --- a/app/models/concerns/enums/internal_id.rb +++ b/app/models/concerns/enums/internal_id.rb @@ -22,4 +22,4 @@ module Enums end end -Enums::InternalId.prepend_if_ee('EE::Enums::InternalId') +Enums::InternalId.prepend_mod_with('Enums::InternalId') diff --git a/app/models/concerns/enums/vulnerability.rb b/app/models/concerns/enums/vulnerability.rb index 4b2e9e9e0b2..55360eb92e6 100644 --- a/app/models/concerns/enums/vulnerability.rb +++ b/app/models/concerns/enums/vulnerability.rb @@ -43,4 +43,4 @@ module Enums end end -Enums::Vulnerability.prepend_if_ee('EE::Enums::Vulnerability') +Enums::Vulnerability.prepend_mod_with('Enums::Vulnerability') diff --git a/app/models/concerns/from_set_operator.rb b/app/models/concerns/from_set_operator.rb index 593fd251c5c..c6d63631c84 100644 --- a/app/models/concerns/from_set_operator.rb +++ b/app/models/concerns/from_set_operator.rb @@ -10,8 +10,8 @@ module FromSetOperator raise "Trying to redefine method '#{method(method_name)}'" if methods.include?(method_name) - define_method(method_name) do |members, remove_duplicates: true, alias_as: table_name| - operator_sql = operator.new(members, remove_duplicates: remove_duplicates).to_sql + define_method(method_name) do |members, remove_duplicates: true, remove_order: true, alias_as: table_name| + operator_sql = operator.new(members, remove_duplicates: remove_duplicates, remove_order: remove_order).to_sql from(Arel.sql("(#{operator_sql}) #{alias_as}")) end diff --git a/app/models/concerns/group_descendant.rb b/app/models/concerns/group_descendant.rb index 67953105bed..b376537a418 100644 --- a/app/models/concerns/group_descendant.rb +++ b/app/models/concerns/group_descendant.rb @@ -22,7 +22,7 @@ module GroupDescendant return [] if descendants.empty? unless descendants.all? { |hierarchy| hierarchy.is_a?(GroupDescendant) } - raise ArgumentError.new(_('element is not a hierarchy')) + raise ArgumentError, _('element is not a hierarchy') end all_hierarchies = descendants.map do |descendant| @@ -56,7 +56,7 @@ module GroupDescendant end if parent.nil? && hierarchy_top.present? - raise ArgumentError.new(_('specified top is not part of the tree')) + raise ArgumentError, _('specified top is not part of the tree') end if parent && parent != hierarchy_top diff --git a/app/models/concerns/integration.rb b/app/models/concerns/has_integrations.rb index 5e53f13be95..b2775f4cbb2 100644 --- a/app/models/concerns/integration.rb +++ b/app/models/concerns/has_integrations.rb @@ -1,11 +1,11 @@ # frozen_string_literal: true -module Integration +module HasIntegrations extend ActiveSupport::Concern class_methods do def with_custom_integration_for(integration, page = nil, per = nil) - custom_integration_project_ids = Service + custom_integration_project_ids = Integration .select(:project_id) .where(type: integration.type) .where(inherit_from_id: nil) @@ -17,13 +17,13 @@ module Integration end def without_integration(integration) - services = Service + integrations = Integration .select('1') .where('services.project_id = projects.id') .where(type: integration.type) Project - .where('NOT EXISTS (?)', services) + .where('NOT EXISTS (?)', integrations) .where(pending_delete: false) .where(archived: false) end diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb index 774cda2c3e8..33f6904bc91 100644 --- a/app/models/concerns/has_repository.rb +++ b/app/models/concerns/has_repository.rb @@ -17,7 +17,7 @@ module HasRepository def valid_repo? repository.exists? - rescue + rescue StandardError errors.add(:base, _('Invalid repository path')) false end @@ -25,7 +25,7 @@ module HasRepository def repo_exists? strong_memoize(:repo_exists) do repository.exists? - rescue + rescue StandardError false end end diff --git a/app/models/concerns/has_timelogs_report.rb b/app/models/concerns/has_timelogs_report.rb index 90f9876de95..3af063438bf 100644 --- a/app/models/concerns/has_timelogs_report.rb +++ b/app/models/concerns/has_timelogs_report.rb @@ -15,6 +15,6 @@ module HasTimelogsReport private def timelogs_for(start_time, end_time) - Timelog.between_times(start_time, end_time).for_issues_in_group(self) + Timelog.between_times(start_time, end_time).in_group(self) end end diff --git a/app/models/concerns/has_wiki_page_meta_attributes.rb b/app/models/concerns/has_wiki_page_meta_attributes.rb index 136f2d00ce3..55681bc91a5 100644 --- a/app/models/concerns/has_wiki_page_meta_attributes.rb +++ b/app/models/concerns/has_wiki_page_meta_attributes.rb @@ -59,7 +59,7 @@ module HasWikiPageMetaAttributes if conflict.present? meta.errors.add(:canonical_slug, 'Duplicate value found') - raise CanonicalSlugConflictError.new(meta) + raise CanonicalSlugConflictError, meta end meta diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 1e44321e148..f5c70f10dc5 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -63,7 +63,7 @@ module Issuable has_many :note_authors, -> { distinct }, through: :notes, source: :author - has_many :label_links, as: :target, dependent: :destroy, inverse_of: :target # rubocop:disable Cop/ActiveRecordDependent + has_many :label_links, as: :target, inverse_of: :target has_many :labels, through: :label_links has_many :todos, as: :target @@ -103,7 +103,7 @@ module Issuable end scope :assigned_to, ->(u) do assignees_table = Arel::Table.new("#{to_ability_name}_assignees") - sql = assignees_table.project('true').where(assignees_table[:user_id].in(u)).where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) + sql = assignees_table.project('true').where(assignees_table[:user_id].in(u.id)).where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id")) where("EXISTS (#{sql.to_sql})") end # rubocop:enable GitlabSecurity/SqlInjection @@ -564,4 +564,4 @@ module Issuable end end -Issuable.prepend_if_ee('EE::Issuable') +Issuable.prepend_mod_with('Issuable') diff --git a/app/models/concerns/issue_available_features.rb b/app/models/concerns/issue_available_features.rb index a5ffa959174..28d12a033a6 100644 --- a/app/models/concerns/issue_available_features.rb +++ b/app/models/concerns/issue_available_features.rb @@ -29,5 +29,5 @@ module IssueAvailableFeatures end end -IssueAvailableFeatures.prepend_if_ee('EE::IssueAvailableFeatures') -IssueAvailableFeatures::ClassMethods.prepend_if_ee('EE::IssueAvailableFeatures::ClassMethods') +IssueAvailableFeatures.prepend_mod_with('IssueAvailableFeatures') +IssueAvailableFeatures::ClassMethods.prepend_mod_with('IssueAvailableFeatures::ClassMethods') diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb index 3cb0bd85936..672bcdbbb1b 100644 --- a/app/models/concerns/limitable.rb +++ b/app/models/concerns/limitable.rb @@ -7,6 +7,7 @@ module Limitable included do class_attribute :limit_scope class_attribute :limit_name + class_attribute :limit_feature_flag self.limit_name = self.name.demodulize.tableize validate :validate_plan_limit_not_exceeded, on: :create @@ -25,6 +26,7 @@ module Limitable def validate_scoped_plan_limit_not_exceeded 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) relation = self.class.where(limit_scope => scope_relation) limits = scope_relation.actual_limits diff --git a/app/models/concerns/loaded_in_group_list.rb b/app/models/concerns/loaded_in_group_list.rb index 59e0ed75d2d..848ef63f1c2 100644 --- a/app/models/concerns/loaded_in_group_list.rb +++ b/app/models/concerns/loaded_in_group_list.rb @@ -79,4 +79,4 @@ module LoadedInGroupList end end -LoadedInGroupList::ClassMethods.prepend_if_ee('EE::LoadedInGroupList::ClassMethods') +LoadedInGroupList::ClassMethods.prepend_mod_with('LoadedInGroupList::ClassMethods') diff --git a/app/models/concerns/mentionable.rb b/app/models/concerns/mentionable.rb index 5db077c178d..f1baa923ec5 100644 --- a/app/models/concerns/mentionable.rb +++ b/app/models/concerns/mentionable.rb @@ -211,4 +211,4 @@ module Mentionable end end -Mentionable.prepend_if_ee('EE::Mentionable') +Mentionable.prepend_mod_with('Mentionable') diff --git a/app/models/concerns/mentionable/reference_regexes.rb b/app/models/concerns/mentionable/reference_regexes.rb index 5a5ce1809d0..e33b6db0103 100644 --- a/app/models/concerns/mentionable/reference_regexes.rb +++ b/app/models/concerns/mentionable/reference_regexes.rb @@ -37,4 +37,4 @@ module Mentionable end end -Mentionable::ReferenceRegexes.prepend_if_ee('EE::Mentionable::ReferenceRegexes') +Mentionable::ReferenceRegexes.prepend_mod_with('Mentionable::ReferenceRegexes') diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index d42417bb6c1..c4f810ab9b1 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -28,7 +28,7 @@ module Milestoneable scope :without_release, -> do joins("LEFT OUTER JOIN milestone_releases ON #{table_name}.milestone_id = milestone_releases.milestone_id") - .where('milestone_releases.release_id IS NULL') + .where(milestone_releases: { release_id: nil }) end scope :joins_milestone_releases, -> do @@ -57,4 +57,4 @@ module Milestoneable end end -Milestoneable.prepend_if_ee('EE::Milestoneable') +Milestoneable.prepend_mod_with('Milestoneable') diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index f3cc68e4b85..f6d4e5bd27b 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -183,5 +183,5 @@ end Noteable.extend(Noteable::ClassMethods) -Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods') -Noteable.prepend_if_ee('EE::Noteable') +Noteable::ClassMethods.prepend_mod_with('Noteable::ClassMethods') +Noteable.prepend_mod_with('Noteable') diff --git a/app/models/concerns/optimized_issuable_label_filter.rb b/app/models/concerns/optimized_issuable_label_filter.rb index c7af841e450..19d2ac620f3 100644 --- a/app/models/concerns/optimized_issuable_label_filter.rb +++ b/app/models/concerns/optimized_issuable_label_filter.rb @@ -28,7 +28,6 @@ module OptimizedIssuableLabelFilter # Taken from IssuableFinder def count_by_state - return super if root_namespace.nil? return super if Feature.disabled?(:optimized_issuable_label_filter, default_enabled: :yaml) count_params = params.merge(state: nil, sort: nil, force_cte: true) @@ -40,7 +39,11 @@ module OptimizedIssuableLabelFilter .group(:state_id) .count - counts = state_counts.transform_keys { |key| count_key(key) } + counts = Hash.new(0) + + state_counts.each do |key, value| + counts[count_key(key)] += value + end counts[:all] = counts.values.sum counts.with_indifferent_access diff --git a/app/models/concerns/packages/debian/architecture.rb b/app/models/concerns/packages/debian/architecture.rb index 760ebb49980..e2fa0ceb0f6 100644 --- a/app/models/concerns/packages/debian/architecture.rb +++ b/app/models/concerns/packages/debian/architecture.rb @@ -23,6 +23,7 @@ module Packages uniqueness: { scope: %i[distribution_id] }, format: { with: Gitlab::Regex.debian_architecture_regex } + scope :ordered_by_name, -> { order(:name) } scope :with_distribution, ->(distribution) { where(distribution: distribution) } scope :with_name, ->(name) { where(name: name) } end diff --git a/app/models/concerns/packages/debian/component.rb b/app/models/concerns/packages/debian/component.rb index 7b342c7b684..5ea686faec2 100644 --- a/app/models/concerns/packages/debian/component.rb +++ b/app/models/concerns/packages/debian/component.rb @@ -23,6 +23,7 @@ module Packages uniqueness: { scope: %i[distribution_id] }, format: { with: Gitlab::Regex.debian_component_regex } + scope :ordered_by_name, -> { order(:name) } scope :with_distribution, ->(distribution) { where(distribution: distribution) } scope :with_name, ->(name) { where(name: name) } end diff --git a/app/models/concerns/packages/debian/component_file.rb b/app/models/concerns/packages/debian/component_file.rb index 3cc2c291e96..c41635a0d16 100644 --- a/app/models/concerns/packages/debian/component_file.rb +++ b/app/models/concerns/packages/debian/component_file.rb @@ -60,6 +60,8 @@ module Packages scope :preload_distribution, -> { includes(component: :distribution) } + scope :created_before, ->(reference) { where("#{table_name}.created_at < ?", reference) } + mount_file_store_uploader Packages::Debian::ComponentFileUploader before_validation :update_size_from_file diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb index 08fb9ccf3ea..267c7a4d201 100644 --- a/app/models/concerns/packages/debian/distribution.rb +++ b/app/models/concerns/packages/debian/distribution.rb @@ -84,7 +84,7 @@ module Packages attr_encrypted :signing_keys, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: false, encode_iv: false diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index acd654bd229..25410a859e9 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -135,4 +135,4 @@ module Participable end end -Participable.prepend_if_ee('EE::Participable') +Participable.prepend_mod_with('Participable') diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 7c774d8bad7..484c91e0833 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -109,4 +109,4 @@ module ProjectFeaturesCompatibility end end -ProjectFeaturesCompatibility.prepend_if_ee('EE::ProjectFeaturesCompatibility') +ProjectFeaturesCompatibility.prepend_mod_with('ProjectFeaturesCompatibility') diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb index 55c2bf96a94..afebc426762 100644 --- a/app/models/concerns/prometheus_adapter.rb +++ b/app/models/concerns/prometheus_adapter.rb @@ -26,9 +26,14 @@ module PrometheusAdapter } end + # Overridden in app/models/clusters/applications/prometheus.rb + def managed_prometheus? + false + end + # This is a light-weight check if a prometheus client is properly configured. def configured? - raise NotImplemented + raise NotImplementedError end # This is a heavy-weight check if a prometheus is properly configured and accessible from GitLab. diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index 2828ae4a3a9..ec56f4a32af 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -78,4 +78,4 @@ end # since these are defined in a ClassMethods constant. As such, we prepend the # module directly into ProtectedRef::ClassMethods, instead of prepending it into # ProtectedRef. -ProtectedRef::ClassMethods.prepend_if_ee('EE::ProtectedRef') +ProtectedRef::ClassMethods.prepend_mod_with('ProtectedRef') diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index 5e38ce7cad8..618ad96905d 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -53,12 +53,12 @@ module ProtectedRefAccess end end -ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes') -ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess') +ProtectedRefAccess.include_mod_with('ProtectedRefAccess::Scopes') +ProtectedRefAccess.prepend_mod_with('ProtectedRefAccess') # When using `prepend` (or `include` for that matter), the `ClassMethods` # constants are not merged. This means that `class_methods` in # `EE::ProtectedRefAccess` would be ignored. # # To work around this, we prepend the `ClassMethods` constant manually. -ProtectedRefAccess::ClassMethods.prepend_if_ee('EE::ProtectedRefAccess::ClassMethods') +ProtectedRefAccess::ClassMethods.prepend_mod_with('ProtectedRefAccess::ClassMethods') diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb index dbc70ac2218..9ed2070d11c 100644 --- a/app/models/concerns/reactive_caching.rb +++ b/app/models/concerns/reactive_caching.rb @@ -168,7 +168,7 @@ module ReactiveCaching data_deep_size = Gitlab::Utils::DeepSize.new(data, max_size: self.class.reactive_cache_hard_limit) - raise ExceededReactiveCacheLimit.new unless data_deep_size.valid? + raise ExceededReactiveCacheLimit unless data_deep_size.valid? end end end diff --git a/app/models/concerns/relative_positioning.rb b/app/models/concerns/relative_positioning.rb index 7f559f0a7ed..75dfed6d58f 100644 --- a/app/models/concerns/relative_positioning.rb +++ b/app/models/concerns/relative_positioning.rb @@ -53,13 +53,13 @@ module RelativePositioning return [size, starting_from] if size >= MIN_GAP + terminus = context.at_position(starting_from) + if at_end - terminus = context.max_sibling terminus.shift_left max_relative_position = terminus.relative_position [[(MAX_POSITION - max_relative_position) / gaps, IDEAL_DISTANCE].min, max_relative_position] else - terminus = context.min_sibling terminus.shift_right min_relative_position = terminus.relative_position [[(min_relative_position - MIN_POSITION) / gaps, IDEAL_DISTANCE].min, min_relative_position] @@ -79,6 +79,8 @@ module RelativePositioning objects = objects.reject(&:relative_position) return 0 if objects.empty? + objects.first.check_repositioning_allowed! + number_of_gaps = objects.size # 1 to the nearest neighbour, and one between each representative = RelativePositioning.mover.context(objects.first) @@ -123,6 +125,12 @@ module RelativePositioning ::Gitlab::RelativePositioning::Mover.new(START_POSITION, (MIN_POSITION..MAX_POSITION)) end + # To be overriden on child classes whenever + # blocking position updates is necessary. + def check_repositioning_allowed! + nil + end + def move_between(before, after) before, after = [before, after].sort_by(&:relative_position) if before && after diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb index 8607f0d94f4..1dd8eebeff3 100644 --- a/app/models/concerns/repository_storage_movable.rb +++ b/app/models/concerns/repository_storage_movable.rb @@ -50,7 +50,7 @@ module RepositoryStorageMovable begin storage_move.container.set_repository_read_only!(skip_git_transfer_check: true) - rescue => err + rescue StandardError => err storage_move.add_error(err.message) next false end @@ -114,7 +114,7 @@ module RepositoryStorageMovable private def container_repository_writable - add_error(_('is read only')) if container&.repository_read_only? + add_error(_('is read-only')) if container&.repository_read_only? end def error_key diff --git a/app/models/concerns/routable.rb b/app/models/concerns/routable.rb index 71d8e06de76..847abdc1b6d 100644 --- a/app/models/concerns/routable.rb +++ b/app/models/concerns/routable.rb @@ -96,11 +96,49 @@ module Routable end def full_name - route&.name || build_full_name + # We have to test for persistence as the cache key uses #updated_at + return (route&.name || build_full_name) unless persisted? && Feature.enabled?(:cached_route_lookups, self, type: :ops, default_enabled: :yaml) + + # Return the name as-is if the parent is missing + return name if route.nil? && parent.nil? && name.present? + + # If the route is already preloaded, return directly, preventing an extra load + return route.name if route_loaded? && route.present? + + # Similarly, we can allow the build if the parent is loaded + return build_full_name if parent_loaded? + + Gitlab::Cache.fetch_once([cache_key, :full_name]) do + route&.name || build_full_name + end end def full_path - route&.path || build_full_path + # We have to test for persistence as the cache key uses #updated_at + return (route&.path || build_full_path) unless persisted? && Feature.enabled?(:cached_route_lookups, self, type: :ops, default_enabled: :yaml) + + # Return the path as-is if the parent is missing + return path if route.nil? && parent.nil? && path.present? + + # If the route is already preloaded, return directly, preventing an extra load + return route.path if route_loaded? && route.present? + + # Similarly, we can allow the build if the parent is loaded + return build_full_path if parent_loaded? + + Gitlab::Cache.fetch_once([cache_key, :full_path]) do + route&.path || build_full_path + end + end + + # Overriden in the Project model + # parent_id condition prevents issues with parent reassignment + def parent_loaded? + association(:parent).loaded? + end + + def route_loaded? + association(:route).loaded? end def full_path_components @@ -124,7 +162,9 @@ module Routable def set_path_errors route_path_errors = self.errors.delete(:"route.path") - self.errors[:path].concat(route_path_errors) if route_path_errors + route_path_errors&.each do |msg| + self.errors.add(:path, msg) + end end def full_name_changed? diff --git a/app/models/concerns/services/data_fields.rb b/app/models/concerns/services/data_fields.rb index 10963e4e7d8..fd56af449bc 100644 --- a/app/models/concerns/services/data_fields.rb +++ b/app/models/concerns/services/data_fields.rb @@ -5,11 +5,11 @@ module Services extend ActiveSupport::Concern included do - belongs_to :service + belongs_to :integration, inverse_of: self.name.underscore.to_sym, foreign_key: :service_id - delegate :activated?, to: :service, allow_nil: true + delegate :activated?, to: :integration, allow_nil: true - validates :service, presence: true + validates :integration, presence: true end class_methods do diff --git a/app/models/concerns/sha256_attribute.rb b/app/models/concerns/sha256_attribute.rb index 9dfe1b77829..4921f7f1a7e 100644 --- a/app/models/concerns/sha256_attribute.rb +++ b/app/models/concerns/sha256_attribute.rb @@ -31,9 +31,9 @@ module Sha256Attribute end unless column.type == :binary - raise ArgumentError.new("sha256_attribute #{name.inspect} is invalid since the column type is not :binary") + raise ArgumentError, "sha256_attribute #{name.inspect} is invalid since the column type is not :binary" end - rescue => error + rescue StandardError => error Gitlab::AppLogger.error "Sha256Attribute initialization: #{error.message}" raise end diff --git a/app/models/concerns/sha_attribute.rb b/app/models/concerns/sha_attribute.rb index cbac6a210c7..f6f5dbce4b6 100644 --- a/app/models/concerns/sha_attribute.rb +++ b/app/models/concerns/sha_attribute.rb @@ -24,9 +24,9 @@ module ShaAttribute return unless column unless column.type == :binary - raise ArgumentError.new("sha_attribute #{name.inspect} is invalid since the column type is not :binary") + raise ArgumentError, "sha_attribute #{name.inspect} is invalid since the column type is not :binary" end - rescue => error + rescue StandardError => error Gitlab::AppLogger.error "ShaAttribute initialization: #{error.message}" raise end @@ -37,4 +37,4 @@ module ShaAttribute end end -ShaAttribute::ClassMethods.prepend_if_ee('EE::ShaAttribute') +ShaAttribute::ClassMethods.prepend_mod_with('ShaAttribute') diff --git a/app/models/concerns/sidebars/container_with_html_options.rb b/app/models/concerns/sidebars/container_with_html_options.rb deleted file mode 100644 index 12ea366c66a..00000000000 --- a/app/models/concerns/sidebars/container_with_html_options.rb +++ /dev/null @@ -1,42 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module ContainerWithHtmlOptions - # The attributes returned from this method - # will be applied to helper methods like - # `link_to` or the div containing the container. - def container_html_options - { - aria: { label: title } - }.merge(extra_container_html_options) - end - - # Classes will override mostly this method - # and not `container_html_options`. - def extra_container_html_options - {} - end - - # Attributes to pass to the html_options attribute - # in the helper method that sets the active class - # on each element. - def nav_link_html_options - {} - end - - def title - raise NotImplementedError - end - - # The attributes returned from this method - # will be applied right next to the title, - # for example in the span that renders the title. - def title_html_options - {} - end - - def link - raise NotImplementedError - end - end -end diff --git a/app/models/concerns/sidebars/has_active_routes.rb b/app/models/concerns/sidebars/has_active_routes.rb deleted file mode 100644 index e7a153f067a..00000000000 --- a/app/models/concerns/sidebars/has_active_routes.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module HasActiveRoutes - # This method will indicate for which paths or - # controllers, the menu or menu item should - # be set as active. - # - # The returned values are passed to the `nav_link` helper method, - # so the params can be either `path`, `page`, `controller`. - # Param 'action' is not supported. - def active_routes - {} - end - end -end diff --git a/app/models/concerns/sidebars/has_hint.rb b/app/models/concerns/sidebars/has_hint.rb deleted file mode 100644 index 21dca39dca0..00000000000 --- a/app/models/concerns/sidebars/has_hint.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -# This module has the necessary methods to store -# hints for menus. Hints are elements displayed -# when the user hover the menu item. -module Sidebars - module HasHint - def show_hint? - false - end - - def hint_html_options - {} - end - end -end diff --git a/app/models/concerns/sidebars/has_icon.rb b/app/models/concerns/sidebars/has_icon.rb deleted file mode 100644 index d1a87918285..00000000000 --- a/app/models/concerns/sidebars/has_icon.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -# This module has the necessary methods to show -# sprites or images next to the menu item. -module Sidebars - module HasIcon - def sprite_icon - nil - end - - def sprite_icon_html_options - {} - end - - def image_path - nil - end - - def image_html_options - {} - end - - def icon_or_image? - sprite_icon || image_path - end - end -end diff --git a/app/models/concerns/sidebars/has_pill.rb b/app/models/concerns/sidebars/has_pill.rb deleted file mode 100644 index ad7064fe63d..00000000000 --- a/app/models/concerns/sidebars/has_pill.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# This module introduces the logic to show the "pill" element -# next to the menu item, indicating the a count. -module Sidebars - module HasPill - def has_pill? - false - end - - # In this method we will need to provide the query - # to retrieve the elements count - def pill_count - raise NotImplementedError - end - - def pill_html_options - {} - end - end -end diff --git a/app/models/concerns/sidebars/positionable_list.rb b/app/models/concerns/sidebars/positionable_list.rb deleted file mode 100644 index 30830d547f3..00000000000 --- a/app/models/concerns/sidebars/positionable_list.rb +++ /dev/null @@ -1,37 +0,0 @@ -# frozen_string_literal: true - -# This module handles elements in a list. All elements -# must have a different class -module Sidebars - module PositionableList - def add_element(list, element) - list << element - end - - def insert_element_before(list, before_element, new_element) - index = index_of(list, before_element) - - if index - list.insert(index, new_element) - else - list.unshift(new_element) - end - end - - def insert_element_after(list, after_element, new_element) - index = index_of(list, after_element) - - if index - list.insert(index + 1, new_element) - else - add_element(list, new_element) - end - end - - private - - def index_of(list, element) - list.index { |e| e.is_a?(element) } - end - end -end diff --git a/app/models/concerns/sidebars/renderable.rb b/app/models/concerns/sidebars/renderable.rb deleted file mode 100644 index a3976af8515..00000000000 --- a/app/models/concerns/sidebars/renderable.rb +++ /dev/null @@ -1,12 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Renderable - # This method will control whether the menu or menu_item - # should be rendered. It will be overriden by specific - # classes. - def render? - true - end - end -end diff --git a/app/models/concerns/storage/legacy_namespace.rb b/app/models/concerns/storage/legacy_namespace.rb index a82cf338039..948190dfadf 100644 --- a/app/models/concerns/storage/legacy_namespace.rb +++ b/app/models/concerns/storage/legacy_namespace.rb @@ -10,7 +10,7 @@ module Storage proj_with_tags = first_project_with_container_registry_tags if proj_with_tags - raise Gitlab::UpdatePathError.new("Namespace #{name} (#{id}) cannot be moved because at least one project (e.g. #{proj_with_tags.name} (#{proj_with_tags.id})) has tags in container registry") + raise Gitlab::UpdatePathError, "Namespace #{name} (#{id}) cannot be moved because at least one project (e.g. #{proj_with_tags.name} (#{proj_with_tags.id})) has tags in container registry" end parent_was = if saved_change_to_parent? && parent_id_before_last_save.present? @@ -48,7 +48,7 @@ module Storage begin send_update_instructions write_projects_repository_config - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_and_raise_for_dev_exception(e, full_path_before_last_save: full_path_before_last_save, full_path: full_path, @@ -83,7 +83,7 @@ module Storage # if we cannot move namespace directory we should rollback # db changes in order to prevent out of sync between db and fs - raise Gitlab::UpdatePathError.new('namespace directory cannot be moved') + raise Gitlab::UpdatePathError, 'namespace directory cannot be moved' end end end diff --git a/app/models/concerns/taskable.rb b/app/models/concerns/taskable.rb index d8867177059..4d1c1d44af7 100644 --- a/app/models/concerns/taskable.rb +++ b/app/models/concerns/taskable.rb @@ -12,7 +12,7 @@ module Taskable COMPLETED = 'completed' INCOMPLETE = 'incomplete' COMPLETE_PATTERN = /(\[[xX]\])/.freeze - INCOMPLETE_PATTERN = /(\[[\s]\])/.freeze + INCOMPLETE_PATTERN = /(\[\s\])/.freeze ITEM_PATTERN = %r{ ^ (?:(?:>\s{0,4})*) # optional blockquote characters diff --git a/app/models/concerns/throttled_touch.rb b/app/models/concerns/throttled_touch.rb index 797c46f6cc5..b5682abb229 100644 --- a/app/models/concerns/throttled_touch.rb +++ b/app/models/concerns/throttled_touch.rb @@ -6,7 +6,7 @@ module ThrottledTouch # The amount of time to wait before "touch" can update a record again. TOUCH_INTERVAL = 1.minute - def touch(*args) + def touch(*args, **kwargs) super if (Time.zone.now - updated_at) > TOUCH_INTERVAL end end diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb index 8273059b30c..fb9a8cd312d 100644 --- a/app/models/concerns/timebox.rb +++ b/app/models/concerns/timebox.rb @@ -72,11 +72,7 @@ module Timebox groups = groups.compact if groups.is_a? Array groups = [] if groups.nil? - if Feature.enabled?(:optimized_timebox_queries, default_enabled: true) - from_union([where(project_id: projects), where(group_id: groups)], remove_duplicates: false) - else - where(project_id: projects).or(where(group_id: groups)) - end + from_union([where(project_id: projects), where(group_id: groups)], remove_duplicates: false) end # A timebox is within the timeframe (start_date, end_date) if it overlaps diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index 535cf25eb9d..34c8630bb90 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -12,7 +12,7 @@ module TokenAuthenticatable def add_authentication_token_field(token_field, options = {}) if token_authenticatable_fields.include?(token_field) - raise ArgumentError.new("#{token_field} already configured via add_authentication_token_field") + raise ArgumentError, "#{token_field} already configured via add_authentication_token_field" end token_authenticatable_fields.push(token_field) diff --git a/app/models/concerns/triggerable_hooks.rb b/app/models/concerns/triggerable_hooks.rb index db5df6c2c9f..8fe34632430 100644 --- a/app/models/concerns/triggerable_hooks.rb +++ b/app/models/concerns/triggerable_hooks.rb @@ -29,11 +29,11 @@ module TriggerableHooks callable_scopes = triggers.keys + [:all] return none unless callable_scopes.include?(trigger) - public_send(trigger) # rubocop:disable GitlabSecurity/PublicSend + executable.public_send(trigger) # rubocop:disable GitlabSecurity/PublicSend end def select_active(hooks_scope, data) - select do |hook| + executable.select do |hook| ActiveHookFilter.new(hook).matches?(hooks_scope, data) end end diff --git a/app/models/concerns/vulnerability_finding_helpers.rb b/app/models/concerns/vulnerability_finding_helpers.rb index cf50305faab..f0e5e010e70 100644 --- a/app/models/concerns/vulnerability_finding_helpers.rb +++ b/app/models/concerns/vulnerability_finding_helpers.rb @@ -4,4 +4,4 @@ module VulnerabilityFindingHelpers extend ActiveSupport::Concern end -VulnerabilityFindingHelpers.prepend_if_ee('EE::VulnerabilityFindingHelpers') +VulnerabilityFindingHelpers.prepend_mod_with('VulnerabilityFindingHelpers') diff --git a/app/models/concerns/vulnerability_finding_signature_helpers.rb b/app/models/concerns/vulnerability_finding_signature_helpers.rb index f57e3cb0bfb..f98c1e93aaf 100644 --- a/app/models/concerns/vulnerability_finding_signature_helpers.rb +++ b/app/models/concerns/vulnerability_finding_signature_helpers.rb @@ -4,4 +4,4 @@ module VulnerabilityFindingSignatureHelpers extend ActiveSupport::Concern end -VulnerabilityFindingSignatureHelpers.prepend_if_ee('EE::VulnerabilityFindingSignatureHelpers') +VulnerabilityFindingSignatureHelpers.prepend_mod_with('VulnerabilityFindingSignatureHelpers') diff --git a/app/models/concerns/x509_serial_number_attribute.rb b/app/models/concerns/x509_serial_number_attribute.rb index d2a5c736604..dbba80eff53 100644 --- a/app/models/concerns/x509_serial_number_attribute.rb +++ b/app/models/concerns/x509_serial_number_attribute.rb @@ -31,9 +31,9 @@ module X509SerialNumberAttribute end unless column.type == :binary - raise ArgumentError.new("x509_serial_number_attribute #{name.inspect} is invalid since the column type is not :binary") + raise ArgumentError, "x509_serial_number_attribute #{name.inspect} is invalid since the column type is not :binary" end - rescue => error + rescue StandardError => error Gitlab::AppLogger.error "X509SerialNumberAttribute initialization: #{error.message}" raise end diff --git a/app/models/container_registry/event.rb b/app/models/container_registry/event.rb index 109fda675a2..c1b865ae578 100644 --- a/app/models/container_registry/event.rb +++ b/app/models/container_registry/event.rb @@ -66,4 +66,4 @@ module ContainerRegistry end end -::ContainerRegistry::Event.prepend_if_ee('EE::ContainerRegistry::Event') +::ContainerRegistry::Event.prepend_mod_with('ContainerRegistry::Event') diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index e2bdf8ffce2..6e0d0e347c9 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -7,6 +7,7 @@ class ContainerRepository < ApplicationRecord include Sortable WAITING_CLEANUP_STATUSES = %i[cleanup_scheduled cleanup_unfinished].freeze + REQUIRING_CLEANUP_STATUSES = %i[cleanup_unscheduled cleanup_scheduled].freeze belongs_to :project @@ -31,6 +32,7 @@ class ContainerRepository < ApplicationRecord scope :for_project_id, ->(project_id) { where(project_id: project_id) } scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) } scope :waiting_for_cleanup, -> { where(expiration_policy_cleanup_status: WAITING_CLEANUP_STATUSES) } + scope :expiration_policy_started_at_nil_or_before, ->(timestamp) { where('expiration_policy_started_at < ? OR expiration_policy_started_at IS NULL', timestamp) } def self.exists_by_path?(path) where( @@ -39,6 +41,23 @@ class ContainerRepository < ApplicationRecord ).exists? end + def self.with_enabled_policy + joins("INNER JOIN container_expiration_policies ON container_repositories.project_id = container_expiration_policies.project_id") + .where(container_expiration_policies: { enabled: true }) + end + + def self.requiring_cleanup + where( + container_repositories: { expiration_policy_cleanup_status: REQUIRING_CLEANUP_STATUSES }, + project_id: ::ContainerExpirationPolicy.runnable_schedules + .select(:project_id) + ) + end + + def self.with_unfinished_cleanup + with_enabled_policy.cleanup_unfinished + end + # rubocop: disable CodeReuse/ServiceClass def registry @registry ||= begin @@ -140,4 +159,4 @@ class ContainerRepository < ApplicationRecord end end -ContainerRepository.prepend_if_ee('EE::ContainerRepository') +ContainerRepository.prepend_mod_with('ContainerRepository') diff --git a/app/models/context_commits_diff.rb b/app/models/context_commits_diff.rb new file mode 100644 index 00000000000..fe1a72b79f2 --- /dev/null +++ b/app/models/context_commits_diff.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +class ContextCommitsDiff + include ActsAsPaginatedDiff + + attr_reader :merge_request + + def initialize(merge_request) + @merge_request = merge_request + end + + def empty? + commits.empty? + end + + def commits_count + merge_request.context_commits_count + end + + def diffs(diff_options = nil) + Gitlab::Diff::FileCollection::Compare.new( + self, + project: merge_request.project, + diff_options: diff_options, + diff_refs: diff_refs + ) + end + + def raw_diffs(options = {}) + compare.diffs(options.merge(paths: paths)) + end + + def diff_refs + Gitlab::Diff::DiffRefs.new( + base_sha: commits.last&.diff_refs&.base_sha, + head_sha: commits.first&.diff_refs&.head_sha + ) + end + + private + + def compare + @compare ||= + Gitlab::Git::Compare.new( + merge_request.project.repository.raw_repository, + commits.last&.diff_refs&.base_sha, + commits.first&.diff_refs&.head_sha + ) + end + + def commits + @commits ||= merge_request.project.repository.commits_by(oids: merge_request.recent_context_commits.map(&:id)) + end + + def paths + merge_request.merge_request_context_commit_diff_files.map(&:path) + end +end diff --git a/app/models/cycle_analytics/project_level_stage_adapter.rb b/app/models/cycle_analytics/project_level_stage_adapter.rb index dd4afa9b809..5538e93a39e 100644 --- a/app/models/cycle_analytics/project_level_stage_adapter.rb +++ b/app/models/cycle_analytics/project_level_stage_adapter.rb @@ -4,6 +4,8 @@ # compatible with the old value stream controller actions. module CycleAnalytics class ProjectLevelStageAdapter + ProjectLevelStage = Struct.new(:title, :description, :legend, :name, :project_median, keyword_init: true ) + def initialize(stage, options) @stage = stage @options = options @@ -13,7 +15,7 @@ module CycleAnalytics def as_json(serializer: AnalyticsStageSerializer) presenter = Analytics::CycleAnalytics::StagePresenter.new(stage) - serializer.new.represent(OpenStruct.new( + serializer.new.represent(ProjectLevelStage.new( title: presenter.title, description: presenter.description, legend: presenter.legend, diff --git a/app/models/deployment.rb b/app/models/deployment.rb index d3280403bfd..e2b25690323 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -32,8 +32,9 @@ class Deployment < ApplicationRecord delegate :kubernetes_namespace, to: :deployment_cluster, allow_nil: true scope :for_environment, -> (environment) { where(environment_id: environment) } - scope :for_environment_name, -> (name) do - joins(:environment).where(environments: { name: name }) + scope :for_environment_name, -> (project, name) do + where('deployments.environment_id = (?)', + Environment.select(:id).where(project: project, name: name).limit(1)) end scope :for_status, -> (status) { where(status: status) } @@ -87,7 +88,7 @@ class Deployment < ApplicationRecord after_transition any => :running do |deployment| deployment.run_after_commit do - Deployments::ExecuteHooksWorker.perform_async(id) + Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current) end end @@ -100,7 +101,7 @@ class Deployment < ApplicationRecord after_transition any => FINISHED_STATUSES do |deployment| deployment.run_after_commit do - Deployments::ExecuteHooksWorker.perform_async(id) + Deployments::HooksWorker.perform_async(deployment_id: id, status_changed_at: Time.current) end end @@ -182,8 +183,8 @@ class Deployment < ApplicationRecord Commit.truncate_sha(sha) end - def execute_hooks - deployment_data = Gitlab::DataBuilder::Deployment.build(self) + def execute_hooks(status_changed_at) + deployment_data = Gitlab::DataBuilder::Deployment.build(self, status_changed_at) project.execute_hooks(deployment_data, :deployment_hooks) project.execute_services(deployment_data, :deployment_hooks) end @@ -347,4 +348,4 @@ class Deployment < ApplicationRecord end end -Deployment.prepend_if_ee('EE::Deployment') +Deployment.prepend_mod_with('Deployment') diff --git a/app/models/deployment_merge_request.rb b/app/models/deployment_merge_request.rb index 7949bd81605..b91785eeb57 100644 --- a/app/models/deployment_merge_request.rb +++ b/app/models/deployment_merge_request.rb @@ -12,7 +12,7 @@ class DeploymentMergeRequest < ApplicationRecord end def self.by_deployment_id(id) - where('deployments.id = ?', id) + where(deployments: { id: id }) end def self.deployed_to(name) @@ -20,7 +20,7 @@ class DeploymentMergeRequest < ApplicationRecord # (project_id, name), instead of using the index on # (name varchar_pattern_ops). This results in better performance on # GitLab.com. - where('environments.name = ?', name) + where(environments: { name: name }) .where('environments.project_id = merge_requests.target_project_id') end diff --git a/app/models/description_version.rb b/app/models/description_version.rb index f69564f4893..96c8553c101 100644 --- a/app/models/description_version.rb +++ b/app/models/description_version.rb @@ -29,4 +29,4 @@ class DescriptionVersion < ApplicationRecord end end -DescriptionVersion.prepend_if_ee('EE::DescriptionVersion') +DescriptionVersion.prepend_mod_with('DescriptionVersion') diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb index 5cfd8f3ec8e..ca65cf38f0d 100644 --- a/app/models/design_management/version.rb +++ b/app/models/design_management/version.rb @@ -58,6 +58,7 @@ module DesignManagement scope :ordered, -> { order(id: :desc) } scope :for_issue, -> (issue) { where(issue: issue) } scope :by_sha, -> (sha) { where(sha: sha) } + scope :with_author, -> { includes(:author) } # This is the one true way to create a Version. # @@ -94,7 +95,7 @@ module DesignManagement version end - rescue + rescue StandardError raise CouldNotCreateVersion.new(sha, issue_id, design_actions) end diff --git a/app/models/discussion_note.rb b/app/models/discussion_note.rb index 5049107da2c..6621b30b645 100644 --- a/app/models/discussion_note.rb +++ b/app/models/discussion_note.rb @@ -5,7 +5,7 @@ # A note of this type can be resolvable. class DiscussionNote < Note # This prepend must stay here because the `validates` below depends on it. - prepend_if_ee('EE::DiscussionNote') # rubocop: disable Cop/InjectEnterpriseEditionModule + prepend_mod_with('DiscussionNote') # rubocop: disable Cop/InjectEnterpriseEditionModule # Names of all implementers of `Noteable` that support discussions. def self.noteable_types diff --git a/app/models/email.rb b/app/models/email.rb index c5154267ff0..0140f784842 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -22,7 +22,7 @@ class Email < ApplicationRecord self.reconfirmable = false # currently email can't be changed, no need to reconfirm - delegate :username, :can?, to: :user + delegate :username, :can?, :pending_invitations, :accept_pending_invitations!, to: :user def email=(value) write_attribute(:email, value.downcase.strip) @@ -32,10 +32,6 @@ class Email < ApplicationRecord self.errors.add(:email, 'has already been taken') if User.exists?(email: self.email) end - def accept_pending_invitations! - user.accept_pending_invitations! - end - def validate_email_format self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email) end diff --git a/app/models/environment.rb b/app/models/environment.rb index 4ee93b0ba4a..2e677a3d177 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -24,13 +24,13 @@ class Environment < ApplicationRecord has_many :self_managed_prometheus_alert_events, inverse_of: :environment has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :environment - has_one :last_deployment, -> { success.order('deployments.id DESC') }, class_name: 'Deployment', inverse_of: :environment + has_one :last_deployment, -> { success.distinct_on_environment }, class_name: 'Deployment', inverse_of: :environment has_one :last_deployable, through: :last_deployment, source: 'deployable', source_type: 'CommitStatus' has_one :last_pipeline, through: :last_deployable, source: 'pipeline' has_one :last_visible_deployment, -> { visible.distinct_on_environment }, inverse_of: :environment, class_name: 'Deployment' has_one :last_visible_deployable, through: :last_visible_deployment, source: 'deployable', source_type: 'CommitStatus' has_one :last_visible_pipeline, through: :last_visible_deployable, source: 'pipeline' - has_one :upcoming_deployment, -> { running.order('deployments.id DESC') }, class_name: 'Deployment', inverse_of: :environment + 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 @@ -269,7 +269,7 @@ class Environment < ApplicationRecord Gitlab::OptimisticLocking.retry_lock(deployment.deployable, name: 'environment_cancel_deployment_jobs') do |deployable| deployable.cancel! if deployable&.cancelable? end - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, environment_id: id, deployment_id: deployment.id) end end @@ -406,7 +406,7 @@ class Environment < ApplicationRecord end def elastic_stack_available? - !!deployment_platform&.cluster&.application_elastic_stack_available? + !!deployment_platform&.cluster&.elastic_stack_available? end def rollout_status @@ -471,4 +471,4 @@ class Environment < ApplicationRecord end end -Environment.prepend_if_ee('EE::Environment') +Environment.prepend_mod_with('Environment') diff --git a/app/models/epic.rb b/app/models/epic.rb index 93f286f97d3..81cd342576f 100644 --- a/app/models/epic.rb +++ b/app/models/epic.rb @@ -18,4 +18,4 @@ class Epic < ApplicationRecord end end -Epic.prepend_if_ee('EE::Epic') +Epic.prepend_mod_with('Epic') diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb index 9a9fbc6a801..956b5d6470f 100644 --- a/app/models/error_tracking/project_error_tracking_setting.rb +++ b/app/models/error_tracking/project_error_tracking_setting.rb @@ -38,7 +38,7 @@ module ErrorTracking attr_encrypted :token, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' after_save :clear_reactive_cache! diff --git a/app/models/event.rb b/app/models/event.rb index 401dfc4cb02..5b755736f47 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -448,4 +448,4 @@ class Event < ApplicationRecord end end -Event.prepend_if_ee('EE::Event') +Event.prepend_mod_with('Event') diff --git a/app/models/external_pull_request.rb b/app/models/external_pull_request.rb index 1487a6387f0..3fc166203e7 100644 --- a/app/models/external_pull_request.rb +++ b/app/models/external_pull_request.rb @@ -72,6 +72,10 @@ class ExternalPullRequest < ApplicationRecord end end + def modified_paths + project.repository.diff_stats(target_sha, source_sha).paths + end + private def actual_source_branch_sha diff --git a/app/models/gpg_key.rb b/app/models/gpg_key.rb index 330815ab8c1..0cb3662368c 100644 --- a/app/models/gpg_key.rb +++ b/app/models/gpg_key.rb @@ -128,4 +128,4 @@ class GpgKey < ApplicationRecord end end -GpgKey.prepend_if_ee('EE::GpgKey') +GpgKey.prepend_mod_with('GpgKey') diff --git a/app/models/group.rb b/app/models/group.rb index 2967c1ffc1d..da795651c63 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -34,7 +34,7 @@ class Group < Namespace has_many :members_and_requesters, as: :source, class_name: 'GroupMember' has_many :milestones - has_many :services + has_many :integrations has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink' has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink' has_many :shared_groups, through: :shared_group_links, source: :shared_group @@ -67,6 +67,8 @@ class Group < Namespace has_one :import_state, class_name: 'GroupImportState', inverse_of: :group + has_many :bulk_import_exports, class_name: 'BulkImports::Export', inverse_of: :group + has_many :group_deploy_keys_groups, inverse_of: :group has_many :group_deploy_keys, through: :group_deploy_keys_groups has_many :group_deploy_tokens @@ -105,21 +107,21 @@ class Group < Namespace scope :with_users, -> { includes(:users) } + scope :with_onboarding_progress, -> { joins(:onboarding_progress) } + scope :by_id, ->(groups) { where(id: groups) } scope :for_authorized_group_members, -> (user_ids) do joins(:group_members) - .where("members.user_id IN (?)", user_ids) + .where(members: { user_id: user_ids }) .where("access_level >= ?", Gitlab::Access::GUEST) end scope :for_authorized_project_members, -> (user_ids) do joins(projects: :project_authorizations) - .where("project_authorizations.user_id IN (?)", user_ids) + .where(project_authorizations: { user_id: user_ids }) end - delegate :default_branch_name, to: :namespace_settings - class << self def sort_by_attribute(method) if method == 'storage_size_desc' @@ -155,7 +157,7 @@ class Group < Namespace def select_for_project_authorization 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) + .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") else super @@ -163,12 +165,12 @@ class Group < Namespace end def without_integration(integration) - services = Service + integrations = Integration .select('1') .where('services.group_id = namespaces.id') .where(type: integration.type) - where('NOT EXISTS (?)', services) + where('NOT EXISTS (?)', integrations) end # This method can be used only if all groups have the same top-level @@ -448,6 +450,20 @@ class Group < Namespace .where(source_id: id) end + def authorizable_members_with_parents + source_ids = + if has_parent? + self_and_ancestors.reorder(nil).select(:id) + else + id + end + + group_hierarchy_members = GroupMember.where(source_id: source_ids) + + GroupMember.from_union([group_hierarchy_members, + members_from_self_and_ancestor_group_shares]).authorizable + end + def members_with_parents # Avoids an unnecessary SELECT when the group has no parents source_ids = @@ -553,11 +569,22 @@ class Group < Namespace def max_member_access_for_user(user, only_concrete_membership: false) return GroupMember::NO_ACCESS unless user return GroupMember::OWNER if user.can_admin_all_resources? && !only_concrete_membership + # Use the preloaded value that exists instead of performing the db query again(cached or not). + # Groups::GroupMembersController#preload_max_access makes use of this by + # calling Group#max_member_access. This helps when we have a process + # that may query this multiple times from the outside through a policy query + # like the GroupPolicy#lookup_access_level! does as a condition for any role + return user.max_access_for_group[id] if user.max_access_for_group[id] + + max_member_access(user) + end - max_member_access = members_with_parents.where(user_id: user) - .reorder(access_level: :desc) - .first - &.access_level + def max_member_access(user) + max_member_access = members_with_parents + .where(user_id: user) + .reorder(access_level: :desc) + .first + &.access_level max_member_access || GroupMember::NO_ACCESS end @@ -622,7 +649,7 @@ class Group < Namespace end def access_request_approvers_to_be_notified - members.owners.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + members.owners.connected_to_user.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) end def supports_events? @@ -693,6 +720,14 @@ class Group < Namespace Gitlab::ServiceDesk.supported? && all_projects.service_desk_enabled.exists? end + def to_ability_name + model_name.singular + end + + def activity_path + Gitlab::Routing.url_helpers.activity_group_path(self) + end + private def update_two_factor_requirement @@ -820,7 +855,12 @@ class Group < Namespace end def uncached_ci_variables_for(ref, project, environment: nil) - list_of_ids = [self] + ancestors + list_of_ids = if root_ancestor.use_traversal_ids? + [self] + ancestors(hierarchy_order: :asc) + else + [self] + ancestors + end + variables = Ci::GroupVariable.where(group: list_of_ids) variables = variables.unprotected unless project.protected_for?(ref) @@ -835,4 +875,4 @@ class Group < Namespace end end -Group.prepend_if_ee('EE::Group') +Group.prepend_mod_with('Group') diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb index b625a70b444..a28b97e63e5 100644 --- a/app/models/hooks/project_hook.rb +++ b/app/models/hooks/project_hook.rb @@ -4,6 +4,7 @@ class ProjectHook < WebHook include TriggerableHooks include Presentable include Limitable + extend ::Gitlab::Utils::Override self.limit_scope = :project @@ -29,6 +30,15 @@ class ProjectHook < WebHook def pluralized_name _('Webhooks') end + + def web_hooks_disable_failed? + Feature.enabled?(:web_hooks_disable_failed, project) + end + + override :rate_limit + def rate_limit + project.actual_limits.limit_for(:web_hook_calls) + end end -ProjectHook.prepend_if_ee('EE::ProjectHook') +ProjectHook.prepend_mod_with('ProjectHook') diff --git a/app/models/hooks/service_hook.rb b/app/models/hooks/service_hook.rb index 4caa45a13d4..1a466b333a5 100644 --- a/app/models/hooks/service_hook.rb +++ b/app/models/hooks/service_hook.rb @@ -3,12 +3,10 @@ class ServiceHook < WebHook include Presentable - belongs_to :service - validates :service, presence: true + belongs_to :integration, foreign_key: :service_id + validates :integration, presence: true - # rubocop: disable CodeReuse/ServiceClass def execute(data, hook_name = 'service_hook') - WebHookService.new(self, data, hook_name).execute + super(data, hook_name) end - # rubocop: enable CodeReuse/ServiceClass end diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb index dbd5a1b032a..02b4feb4ccc 100644 --- a/app/models/hooks/web_hook.rb +++ b/app/models/hooks/web_hook.rb @@ -3,6 +3,11 @@ class WebHook < ApplicationRecord include Sortable + FAILURE_THRESHOLD = 3 # three strikes + INITIAL_BACKOFF = 10.minutes + MAX_BACKOFF = 1.day + BACKOFF_GROWTH_FACTOR = 2.0 + attr_encrypted :token, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', @@ -21,15 +26,27 @@ class WebHook < ApplicationRecord validates :token, format: { without: /\n/ } validates :push_events_branch_filter, branch_filter: true + scope :executable, -> do + next all unless Feature.enabled?(:web_hooks_disable_failed) + + where('recent_failures <= ? AND (disabled_until IS NULL OR disabled_until < ?)', FAILURE_THRESHOLD, Time.current) + end + + def executable? + return true unless web_hooks_disable_failed? + + recent_failures <= FAILURE_THRESHOLD && (disabled_until.nil? || disabled_until < Time.current) + end + # rubocop: disable CodeReuse/ServiceClass def execute(data, hook_name) - WebHookService.new(self, data, hook_name).execute + WebHookService.new(self, data, hook_name).execute if executable? end # rubocop: enable CodeReuse/ServiceClass # rubocop: disable CodeReuse/ServiceClass def async_execute(data, hook_name) - WebHookService.new(self, data, hook_name).async_execute + WebHookService.new(self, data, hook_name).async_execute if executable? end # rubocop: enable CodeReuse/ServiceClass @@ -41,4 +58,31 @@ class WebHook < ApplicationRecord def help_path 'user/project/integrations/webhooks' end + + def next_backoff + return MAX_BACKOFF if backoff_count >= 8 # optimization to prevent expensive exponentiation and possible overflows + + (INITIAL_BACKOFF * (BACKOFF_GROWTH_FACTOR**backoff_count)) + .clamp(INITIAL_BACKOFF, MAX_BACKOFF) + .seconds + end + + def disable! + update!(recent_failures: FAILURE_THRESHOLD + 1) + end + + def enable! + update!(recent_failures: 0, disabled_until: nil, backoff_count: 0) + end + + # Overridden in ProjectHook and GroupHook, other webhooks are not rate-limited. + def rate_limit + nil + end + + private + + def web_hooks_disable_failed? + Feature.enabled?(:web_hooks_disable_failed) + end end diff --git a/app/models/hooks/web_hook_log.rb b/app/models/hooks/web_hook_log.rb index e2230a2d644..0c96d5d4b6d 100644 --- a/app/models/hooks/web_hook_log.rb +++ b/app/models/hooks/web_hook_log.rb @@ -5,9 +5,12 @@ class WebHookLog < ApplicationRecord include Presentable include DeleteWithLimit include CreatedAtFilterable + include PartitionedTable self.primary_key = :id + partitioned_by :created_at, strategy: :monthly + belongs_to :web_hook serialize :request_headers, Hash # rubocop:disable Cop/ActiveRecordSerialize diff --git a/app/models/hooks/web_hook_log_archived.rb b/app/models/hooks/web_hook_log_archived.rb new file mode 100644 index 00000000000..a1c8a44f5ba --- /dev/null +++ b/app/models/hooks/web_hook_log_archived.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# This model is not intended to be used. +# It is a temporary reference to the old non-partitioned +# web_hook_logs table. +# Please refer to https://gitlab.com/groups/gitlab-org/-/epics/5558 +# for details. +# rubocop:disable Gitlab/NamespacedClass: This is a temporary class with no relevant namespace +# WebHook, WebHookLog and all hooks are defined outside of a namespace +class WebHookLogArchived < ApplicationRecord + self.table_name = 'web_hook_logs_archived' +end diff --git a/app/models/hooks/web_hook_log_partitioned.rb b/app/models/hooks/web_hook_log_partitioned.rb deleted file mode 100644 index b4b150afb6a..00000000000 --- a/app/models/hooks/web_hook_log_partitioned.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -# This model is not yet intended to be used. -# It is in a transitioning phase while we are partitioning -# the web_hook_logs table on the database-side. -# Please refer to https://gitlab.com/groups/gitlab-org/-/epics/5558 -# for details. -# rubocop:disable Gitlab/NamespacedClass: This is a temporary class with no relevant namespace -# WebHook, WebHookLog and all hooks are defined outside of a namespace -class WebHookLogPartitioned < ApplicationRecord - include PartitionedTable - - self.table_name = 'web_hook_logs_part_0c5294f417' - self.primary_key = :id - - partitioned_by :created_at, strategy: :monthly -end diff --git a/app/models/identity.rb b/app/models/identity.rb index fc97c68b756..df1185f330d 100644 --- a/app/models/identity.rb +++ b/app/models/identity.rb @@ -51,4 +51,4 @@ class Identity < ApplicationRecord end end -Identity.prepend_if_ee('EE::Identity') +Identity.prepend_mod_with('Identity') diff --git a/app/models/identity/uniqueness_scopes.rb b/app/models/identity/uniqueness_scopes.rb index c1890865a1c..b41b4572e82 100644 --- a/app/models/identity/uniqueness_scopes.rb +++ b/app/models/identity/uniqueness_scopes.rb @@ -10,4 +10,4 @@ class Identity < ApplicationRecord end end -Identity::UniquenessScopes.prepend_if_ee('EE::Identity::UniquenessScopes') +Identity::UniquenessScopes.prepend_mod_with('Identity::UniquenessScopes') diff --git a/app/models/incident_management/project_incident_management_setting.rb b/app/models/incident_management/project_incident_management_setting.rb index 4887265be88..b6da93508c2 100644 --- a/app/models/incident_management/project_incident_management_setting.rb +++ b/app/models/incident_management/project_incident_management_setting.rb @@ -12,7 +12,7 @@ module IncidentManagement attr_encrypted :pagerduty_token, mode: :per_attribute_iv, - key: ::Settings.attr_encrypted_db_key_base_truncated, + key: ::Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: false, # No need to encode for binary column https://github.com/attr-encrypted/attr_encrypted#the-encode-encode_iv-encode_salt-and-default_encoding-options encode_iv: false @@ -52,4 +52,4 @@ module IncidentManagement end end -IncidentManagement::ProjectIncidentManagementSetting.prepend_if_ee('EE::IncidentManagement::ProjectIncidentManagementSetting') +IncidentManagement::ProjectIncidentManagementSetting.prepend_mod_with('IncidentManagement::ProjectIncidentManagementSetting') diff --git a/app/models/instance_metadata.rb b/app/models/instance_metadata.rb index 96622d0b1b3..6cac78178e0 100644 --- a/app/models/instance_metadata.rb +++ b/app/models/instance_metadata.rb @@ -1,10 +1,11 @@ # frozen_string_literal: true class InstanceMetadata - attr_reader :version, :revision + attr_reader :version, :revision, :kas def initialize(version: Gitlab::VERSION, revision: Gitlab.revision) @version = version @revision = revision + @kas = ::InstanceMetadata::Kas.new end end diff --git a/app/models/instance_metadata/kas.rb b/app/models/instance_metadata/kas.rb new file mode 100644 index 00000000000..7d2d71120b5 --- /dev/null +++ b/app/models/instance_metadata/kas.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class InstanceMetadata::Kas + attr_reader :enabled, :version, :external_url + + def initialize + @enabled = Gitlab::Kas.enabled? + @version = Gitlab::Kas.version if @enabled + @external_url = Gitlab::Kas.external_url if @enabled + end + + def self.declarative_policy_class + "InstanceMetadataPolicy" + end +end diff --git a/app/models/service.rb b/app/models/integration.rb index aadc75ae710..13203cd4e95 100644 --- a/app/models/service.rb +++ b/app/models/integration.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -# To add new service you should build a class inherited from Service +# To add new integration you should build a class inherited from Integration # and implement a set of methods -class Service < ApplicationRecord +class Integration < ApplicationRecord include Sortable include Importable include ProjectServicesLoggable @@ -10,24 +10,29 @@ class Service < ApplicationRecord include FromUnion include EachBatch - SERVICE_NAMES = %w[ + # TODO Rename the table: https://gitlab.com/gitlab-org/gitlab/-/issues/201856 + self.table_name = 'services' + + INTEGRATION_NAMES = %w[ asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker discord drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack ].freeze - PROJECT_SPECIFIC_SERVICE_NAMES = %w[ + PROJECT_SPECIFIC_INTEGRATION_NAMES = %w[ datadog jenkins ].freeze - # Fake services to help with local development. - DEV_SERVICE_NAMES = %w[ + # Fake integrations to help with local development. + DEV_INTEGRATION_NAMES = %w[ mock_ci mock_monitoring ].freeze serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize + attribute :type, Gitlab::Integrations::StiType.new + default_value_for :active, false default_value_for :alert_events, true default_value_for :category, 'common' @@ -47,18 +52,18 @@ class Service < ApplicationRecord after_commit :reset_updated_properties - belongs_to :project, inverse_of: :services - belongs_to :group, inverse_of: :services - has_one :service_hook + belongs_to :project, inverse_of: :integrations + 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? || group_id } - validates :group_id, presence: true, unless: -> { template? || instance? || project_id } - validates :project_id, :group_id, absence: true, if: -> { template? || instance? } + 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 :type, presence: true validates :type, uniqueness: { scope: :template }, if: :template? - validates :type, uniqueness: { scope: :instance }, if: :instance? - validates :type, uniqueness: { scope: :project_id }, if: :project_id? - validates :type, uniqueness: { scope: :group_id }, if: :group_id? + 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 @@ -164,22 +169,23 @@ class Service < ApplicationRecord end def self.create_nonexistent_templates - nonexistent_services = list_nonexistent_services_for(for_template) + nonexistent_services = build_nonexistent_services_for(for_template) return if nonexistent_services.empty? # Create within a transaction to perform the lowest possible SQL queries. transaction do - nonexistent_services.each do |service_type| - service_type.constantize.create(template: true) + nonexistent_services.each do |service| + service.template = true + service.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) - if name.in?(available_services_names(include_project_specific: false)) - "#{name}_service".camelize.constantize.find_or_initialize_by(instance: instance, group_id: group_id) - end + return unless name.in?(available_services_names(include_project_specific: false)) + + service_name_to_model(name).find_or_initialize_by(instance: instance, group_id: group_id) end def self.find_or_initialize_all_non_project_specific(scope) @@ -187,19 +193,23 @@ class Service < ApplicationRecord end def self.build_nonexistent_services_for(scope) - list_nonexistent_services_for(scope).map do |service_type| - service_type.constantize.new + nonexistent_services_types_for(scope).map do |service_type| + service_type_to_model(service_type).new end end private_class_method :build_nonexistent_services_for - def self.list_nonexistent_services_for(scope) + # Returns a list of service types that do not exist in the given scope. + # Example: ["AsanaService", ...] + def self.nonexistent_services_types_for(scope) # Using #map instead of #pluck to save one query count. This is because # ActiveRecord loaded the object here, so we don't need to query again later. available_services_types(include_project_specific: false) - scope.map(&:type) end - private_class_method :list_nonexistent_services_for + private_class_method :nonexistent_services_types_for + # Returns a list of available service names. + # Example: ["asana", ...] def self.available_services_names(include_project_specific: true, include_dev: true) service_names = services_names service_names += project_specific_services_names if include_project_specific @@ -209,40 +219,61 @@ class Service < ApplicationRecord end def self.services_names - SERVICE_NAMES + INTEGRATION_NAMES end def self.dev_services_names return [] unless Rails.env.development? - DEV_SERVICE_NAMES + DEV_INTEGRATION_NAMES end def self.project_specific_services_names - PROJECT_SPECIFIC_SERVICE_NAMES + PROJECT_SPECIFIC_INTEGRATION_NAMES end + # Returns a list of available service types. + # Example: ["AsanaService", ...] def self.available_services_types(include_project_specific: true, include_dev: true) available_services_names(include_project_specific: include_project_specific, include_dev: include_dev).map do |service_name| - "#{service_name}_service".camelize + service_name_to_type(service_name) end end + # Returns the model for the given service name. + # Example: "asana" => Integrations::Asana + def self.service_name_to_model(name) + type = service_name_to_type(name) + service_type_to_model(type) + end + + # Returns the STI type for the given service name. + # Example: "asana" => "AsanaService" + def self.service_name_to_type(name) + "#{name}_service".camelize + end + + # Returns the model for the given STI type. + # Example: "AsanaService" => Integrations::Asana + def self.service_type_to_model(type) + Gitlab::Integrations::StiType.new.cast(type).constantize + end + private_class_method :service_type_to_model + def self.build_from_integration(integration, project_id: nil, group_id: nil) - service = integration.dup + new_integration = integration.dup if integration.supports_data_fields? data_fields = integration.data_fields.dup - data_fields.service = service + data_fields.integration = new_integration end - service.template = false - service.instance = false - service.project_id = project_id - service.group_id = group_id - service.inherit_from_id = integration.id if integration.instance? || integration.group - service.active = false if service.invalid? - service + new_integration.template = false + new_integration.instance = false + new_integration.project_id = project_id + new_integration.group_id = group_id + new_integration.inherit_from_id = integration.id if integration.instance_level? || integration.group_level? + new_integration end def self.instance_exists_for?(type) @@ -269,7 +300,7 @@ class Service < ApplicationRecord private_class_method :instance_level_integration def self.create_from_active_default_integrations(scope, association, with_templates: false) - group_ids = scope.ancestors.select(:id) + group_ids = sorted_ancestors(scope).select(:id) array = group_ids.to_sql.present? ? "array(#{group_ids.to_sql})" : 'ARRAY[]' from_union([ @@ -340,7 +371,7 @@ class Service < ApplicationRecord # Expose a list of fields in the JSON endpoint. # - # This list is used in `Service#as_json(only: json_fields)`. + # This list is used in `Integration#as_json(only: json_fields)`. def json_fields %w[active] end @@ -407,16 +438,24 @@ class Service < ApplicationRecord { success: result.present?, result: result } end - # Disable test for instance-level and group-level services. + # Disable test for instance-level and group-level integrations. # https://gitlab.com/gitlab-org/gitlab/-/issues/213138 def can_test? - !instance? && !group_id + !(instance_level? || group_level?) end def project_level? project_id.present? end + def group_level? + group_id.present? + end + + def instance_level? + instance? + end + def parent project || group end @@ -424,7 +463,7 @@ class Service < ApplicationRecord # Returns a hash of the properties that have been assigned a new value since last save, # indicating their original values (attr => original value). # ActiveRecord does not provide a mechanism to track changes in serialized keys, - # so we need a specific implementation for service properties. + # so we need a specific implementation for integration properties. # This allows to track changes to properties set with the accessor methods, # but not direct manipulation of properties hash. def updated_properties @@ -452,12 +491,21 @@ class Service < ApplicationRecord private + # Ancestors sorted by hierarchy depth in bottom-top order. + def self.sorted_ancestors(scope) + if scope.root_ancestor.use_traversal_ids? + Namespace.from(scope.ancestors(hierarchy_order: :asc)) + else + scope.ancestors + 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? + 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_id && group_id + errors.add(:project_id, 'The service cannot belong to both a project and a group') if project_level? && group_level? end def validate_recipients? @@ -465,4 +513,4 @@ class Service < ApplicationRecord end end -Service.prepend_if_ee('EE::Service') +Integration.prepend_mod_with('Integration') diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb new file mode 100644 index 00000000000..7949563a1dc --- /dev/null +++ b/app/models/integrations/asana.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true + +require 'asana' + +module Integrations + class Asana < Integration + include ActionView::Helpers::UrlHelper + + prop_accessor :api_key, :restrict_to_branch + validates :api_key, presence: true, if: :activated? + + def title + 'Asana' + end + + def description + s_('AsanaService|Add commit messages as comments to Asana tasks.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer' + s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'asana' + end + + def fields + [ + { + type: 'text', + name: 'api_key', + title: 'API key', + help: s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.'), + # Example Personal Access Token from Asana docs + placeholder: '0/68a9e79b868c6789e79a124c30b0', + required: true + }, + { + type: 'text', + name: 'restrict_to_branch', + title: 'Restrict to branch (optional)', + help: s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') + } + ] + end + + def self.supported_events + %w(push) + end + + def client + @_client ||= begin + ::Asana::Client.new do |c| + c.authentication :access_token, api_key + end + end + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + # check the branch restriction is poplulated and branch is not included + branch = Gitlab::Git.ref_name(data[:ref]) + branch_restriction = restrict_to_branch.to_s + if branch_restriction.present? && branch_restriction.index(branch).nil? + return + end + + user = data[:user_name] + project_name = project.full_name + + data[:commits].each do |commit| + push_msg = s_("AsanaService|%{user} pushed to branch %{branch} of %{project_name} ( %{commit_url} ):") % { user: user, branch: branch, project_name: project_name, commit_url: commit[:url] } + check_commit(commit[:message], push_msg) + end + end + + def check_commit(message, push_msg) + # matches either: + # - #1234 + # - https://app.asana.com/0/{project_gid}/{task_gid} + # optionally preceded with: + # - fix/ed/es/ing + # - close/s/d + # - closing + issue_finder = %r{(fix\w*|clos[ei]\w*+)?\W*(?:https://app\.asana\.com/\d+/\w+/(\w+)|#(\w+))}i + + message.scan(issue_finder).each do |tuple| + # tuple will be + # [ 'fix', 'id_from_url', 'id_from_pound' ] + taskid = tuple[2] || tuple[1] + + begin + task = ::Asana::Resources::Task.find_by_id(client, taskid) + task.add_comment(text: "#{push_msg} #{message}") + + if tuple[0] + task.update(completed: true) + end + rescue StandardError => e + log_error(e.message) + next + end + end + end + end +end diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb new file mode 100644 index 00000000000..6a36045330a --- /dev/null +++ b/app/models/integrations/assembla.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Integrations + class Assembla < Integration + prop_accessor :token, :subdomain + validates :token, presence: true, if: :activated? + + def title + 'Assembla' + end + + def description + _('Manage projects.') + end + + def self.to_param + 'assembla' + end + + def fields + [ + { type: 'text', name: 'token', placeholder: '', required: true }, + { type: 'text', name: 'subdomain', placeholder: '' } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + url = "https://atlas.assembla.com/spaces/#{subdomain}/github_tool?secret_key=#{token}" + Gitlab::HTTP.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' }) + end + end +end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb new file mode 100644 index 00000000000..82111c7322e --- /dev/null +++ b/app/models/integrations/bamboo.rb @@ -0,0 +1,183 @@ +# frozen_string_literal: true + +module Integrations + class Bamboo < CiService + include ActionView::Helpers::UrlHelper + include ReactiveService + + prop_accessor :bamboo_url, :build_key, :username, :password + + validates :bamboo_url, presence: true, public_url: true, if: :activated? + validates :build_key, presence: true, if: :activated? + validates :username, + presence: true, + if: ->(service) { service.activated? && service.password } + validates :password, + presence: true, + if: ->(service) { service.activated? && service.username } + + attr_accessor :response + + after_save :compose_service_hook, if: :activated? + before_update :reset_password + + def compose_service_hook + hook = service_hook || build_service_hook + hook.save + end + + def reset_password + if bamboo_url_changed? && !password_touched? + self.password = nil + end + end + + def title + s_('BambooService|Atlassian Bamboo') + end + + def description + s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer' + s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo. You must set up automatic revision labeling and a repository trigger in Bamboo. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + end + + def self.to_param + 'bamboo' + end + + def fields + [ + { + type: 'text', + name: 'bamboo_url', + title: s_('BambooService|Bamboo URL'), + placeholder: s_('https://bamboo.example.com'), + help: s_('BambooService|Bamboo service root URL.'), + required: true + }, + { + type: 'text', + name: 'build_key', + placeholder: s_('KEY'), + help: s_('BambooService|Bamboo build plan key.'), + required: true + }, + { + type: 'text', + name: 'username', + help: s_('BambooService|The user with API access to the Bamboo server.') + }, + { + type: 'password', + name: 'password', + non_empty_password_title: s_('ProjectService|Enter new password'), + non_empty_password_help: s_('ProjectService|Leave blank to use your current password') + } + ] + end + + def build_page(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:build_page] } + end + + def commit_status(sha, ref) + with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + get_path("updateAndBuild.action", { buildKey: build_key }) + end + + def calculate_reactive_cache(sha, ref) + response = try_get_path("rest/api/latest/result/byChangeset/#{sha}") + + { build_page: read_build_page(response), commit_status: read_commit_status(response) } + end + + private + + def get_build_result(response) + return if response&.code != 200 + + # May be nil if no result, a single result hash, or an array if multiple results for a given changeset. + result = response.dig('results', 'results', 'result') + + # In case of multiple results, arbitrarily assume the last one is the most relevant. + return result.last if result.is_a?(Array) + + result + end + + def read_build_page(response) + result = get_build_result(response) + key = + if result.blank? + # If actual build link can't be determined, send user to build summary page. + build_key + else + # If actual build link is available, go to build result page. + result.dig('planResultKey', 'key') + end + + build_url("browse/#{key}") + end + + def read_commit_status(response) + return :error unless response && (response.code == 200 || response.code == 404) + + result = get_build_result(response) + status = + if result.blank? + 'Pending' + else + result.dig('buildState') + end + + return :error unless status.present? + + if status.include?('Success') + 'success' + elsif status.include?('Failed') + 'failed' + elsif status.include?('Pending') + 'pending' + else + :error + end + end + + def try_get_path(path, query_params = {}) + params = build_get_params(query_params) + params[:extra_log_info] = { project_id: project_id } + + Gitlab::HTTP.try_get(build_url(path), params) + end + + def get_path(path, query_params = {}) + Gitlab::HTTP.get(build_url(path), build_get_params(query_params)) + end + + def build_url(path) + Gitlab::Utils.append_path(bamboo_url, path) + end + + def build_get_params(query_params) + params = { verify: false, query: query_params } + return params if username.blank? && password.blank? + + query_params[:os_authType] = 'basic' + params[:basic_auth] = basic_auth + params + end + + def basic_auth + { username: username, password: password } + end + end +end diff --git a/app/models/integrations/builds_email.rb b/app/models/integrations/builds_email.rb new file mode 100644 index 00000000000..2628848667e --- /dev/null +++ b/app/models/integrations/builds_email.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +# This class is to be removed with 9.1 +# We should also by then remove BuildsEmailService from database +# https://gitlab.com/gitlab-org/gitlab/-/issues/331064 +module Integrations + class BuildsEmail < Integration + def self.to_param + 'builds_email' + end + + def self.supported_events + %w[] + end + end +end diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb new file mode 100644 index 00000000000..eede3d00307 --- /dev/null +++ b/app/models/integrations/campfire.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +module Integrations + class Campfire < Integration + prop_accessor :token, :subdomain, :room + validates :token, presence: true, if: :activated? + + def title + 'Campfire' + end + + def description + 'Send notifications about push events to Campfire chat rooms.' + end + + def self.to_param + 'campfire' + end + + def fields + [ + { type: 'text', name: 'token', placeholder: '', required: true }, + { type: 'text', name: 'subdomain', placeholder: '' }, + { type: 'text', name: 'room', placeholder: '' } + ] + end + + def self.supported_events + %w(push) + end + + def execute(data) + return unless supported_events.include?(data[:object_kind]) + + message = build_message(data) + speak(self.room, message, auth) + end + + private + + def base_uri + @base_uri ||= "https://#{subdomain}.campfirenow.com" + end + + def auth + # use a dummy password, as explained in the Campfire API doc: + # https://github.com/basecamp/campfire-api#authentication + @auth ||= { + basic_auth: { + username: token, + password: 'X' + } + } + end + + # Post a message into a room, returns the message Hash in case of success. + # Returns nil otherwise. + # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message + def speak(room_name, message, auth) + room = rooms(auth).find { |r| r["name"] == room_name } + return unless room + + path = "/room/#{room["id"]}/speak.json" + body = { + body: { + message: { + type: 'TextMessage', + body: message + } + } + } + res = Gitlab::HTTP.post(path, base_uri: base_uri, **auth.merge(body)) + res.code == 201 ? res : nil + end + + # Returns a list of rooms, or []. + # https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms + def rooms(auth) + res = Gitlab::HTTP.get("/rooms.json", base_uri: base_uri, **auth) + res.code == 200 ? res["rooms"] : [] + end + + def build_message(push) + ref = Gitlab::Git.ref_name(push[:ref]) + before = push[:before] + after = push[:after] + + message = [] + message << "[#{project.full_name}] " + message << "#{push[:user_name]} " + + if Gitlab::Git.blank_ref?(before) + message << "pushed new branch #{ref} \n" + elsif Gitlab::Git.blank_ref?(after) + message << "removed branch #{ref} \n" + else + message << "pushed #{push[:total_commits_count]} commits to #{ref}. " + message << "#{project.web_url}/compare/#{before}...#{after}" + end + + message.join + end + end +end diff --git a/app/models/integrations/chat_message/alert_message.rb b/app/models/integrations/chat_message/alert_message.rb new file mode 100644 index 00000000000..ef0579124fe --- /dev/null +++ b/app/models/integrations/chat_message/alert_message.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class AlertMessage < BaseMessage + attr_reader :title + attr_reader :alert_url + attr_reader :severity + attr_reader :events + attr_reader :status + attr_reader :started_at + + def initialize(params) + @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) + @project_url = params.dig(:project, :web_url) || params[:project_url] + @title = params.dig(:object_attributes, :title) + @alert_url = params.dig(:object_attributes, :url) + @severity = params.dig(:object_attributes, :severity) + @events = params.dig(:object_attributes, :events) + @status = params.dig(:object_attributes, :status) + @started_at = params.dig(:object_attributes, :started_at) + end + + def attachments + [{ + title: title, + title_link: alert_url, + color: attachment_color, + fields: attachment_fields + }] + end + + def message + "Alert firing in #{project_name}" + end + + private + + def attachment_color + "#C95823" + end + + def attachment_fields + [ + { + title: "Severity", + value: severity.to_s.humanize, + short: true + }, + { + title: "Events", + value: events, + short: true + }, + { + title: "Status", + value: status.to_s.humanize, + short: true + }, + { + title: "Start time", + value: format_time(started_at), + short: true + } + ] + end + + # This formats time into the following format + # April 23rd, 2020 1:06AM UTC + def format_time(time) + time = Time.zone.parse(time.to_s) + time.strftime("%B #{time.day.ordinalize}, %Y %l:%M%p %Z") + end + end + end +end diff --git a/app/models/integrations/chat_message/base_message.rb b/app/models/integrations/chat_message/base_message.rb new file mode 100644 index 00000000000..2f70384d3b9 --- /dev/null +++ b/app/models/integrations/chat_message/base_message.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class BaseMessage + RELATIVE_LINK_REGEX = %r{!\[[^\]]*\]\((/uploads/[^\)]*)\)}.freeze + + attr_reader :markdown + attr_reader :user_full_name + attr_reader :user_name + attr_reader :user_avatar + attr_reader :project_name + attr_reader :project_url + + def initialize(params) + @markdown = params[:markdown] || false + @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) + @project_url = params.dig(:project, :web_url) || params[:project_url] + @user_full_name = params.dig(:user, :name) || params[:user_full_name] + @user_name = params.dig(:user, :username) || params[:user_name] + @user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar] + end + + def user_combined_name + if user_full_name.present? + "#{user_full_name} (#{user_name})" + else + user_name + end + end + + def summary + return message if markdown + + format(message) + end + + def pretext + summary + end + + def fallback + format(message) + end + + def attachments + raise NotImplementedError + end + + def activity + raise NotImplementedError + end + + private + + def message + raise NotImplementedError + end + + def format(string) + Slack::Messenger::Util::LinkFormatter.format(format_relative_links(string)) + end + + def format_relative_links(string) + string.gsub(RELATIVE_LINK_REGEX, "#{project_url}\\1") + end + + def attachment_color + '#345' + end + + def link(text, url) + "[#{text}](#{url})" + end + + def pretty_duration(seconds) + parse_string = + if duration < 1.hour + '%M:%S' + else + '%H:%M:%S' + end + + Time.at(seconds).utc.strftime(parse_string) + end + end + end +end diff --git a/app/models/integrations/chat_message/deployment_message.rb b/app/models/integrations/chat_message/deployment_message.rb new file mode 100644 index 00000000000..c4f3bf9610d --- /dev/null +++ b/app/models/integrations/chat_message/deployment_message.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class DeploymentMessage < BaseMessage + attr_reader :commit_title + attr_reader :commit_url + attr_reader :deployable_id + attr_reader :deployable_url + attr_reader :environment + attr_reader :short_sha + attr_reader :status + attr_reader :user_url + + def initialize(data) + super + + @commit_title = data[:commit_title] + @commit_url = data[:commit_url] + @deployable_id = data[:deployable_id] + @deployable_url = data[:deployable_url] + @environment = data[:environment] + @short_sha = data[:short_sha] + @status = data[:status] + @user_url = data[:user_url] + end + + def attachments + [{ + text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{commit_title}", + color: color + }] + end + + def activity + {} + end + + private + + def message + if running? + "Starting deploy to #{environment}" + else + "Deploy to #{environment} #{humanized_status}" + end + end + + def color + case status + when 'success' + 'good' + when 'canceled' + 'warning' + when 'failed' + 'danger' + else + '#334455' + end + end + + def project_link + link(project_name, project_url) + end + + def deployment_link + link("##{deployable_id}", deployable_url) + end + + def user_link + link(user_combined_name, user_url) + end + + def commit_link + link(short_sha, commit_url) + end + + def humanized_status + status == 'success' ? 'succeeded' : status + end + + def running? + status == 'running' + end + end + end +end diff --git a/app/models/integrations/chat_message/issue_message.rb b/app/models/integrations/chat_message/issue_message.rb new file mode 100644 index 00000000000..5fa6bd4090f --- /dev/null +++ b/app/models/integrations/chat_message/issue_message.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class IssueMessage < BaseMessage + attr_reader :title + attr_reader :issue_iid + attr_reader :issue_url + attr_reader :action + attr_reader :state + attr_reader :description + + def initialize(params) + super + + obj_attr = params[:object_attributes] + obj_attr = HashWithIndifferentAccess.new(obj_attr) + @title = obj_attr[:title] + @issue_iid = obj_attr[:iid] + @issue_url = obj_attr[:url] + @action = obj_attr[:action] + @state = obj_attr[:state] + @description = obj_attr[:description] || '' + end + + def attachments + return [] unless opened_issue? + return description if markdown + + description_message + end + + def activity + { + title: "Issue #{state} by #{user_combined_name}", + subtitle: "in #{project_link}", + text: issue_link, + image: user_avatar + } + end + + private + + def message + "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}" + end + + def opened_issue? + action == 'open' + end + + def description_message + [{ + title: issue_title, + title_link: issue_url, + text: format(description), + color: '#C95823' + }] + end + + def project_link + link(project_name, project_url) + end + + def issue_link + link(issue_title, issue_url) + end + + def issue_title + "#{Issue.reference_prefix}#{issue_iid} #{title}" + end + end + end +end diff --git a/app/models/integrations/chat_message/merge_message.rb b/app/models/integrations/chat_message/merge_message.rb new file mode 100644 index 00000000000..d2f48699f50 --- /dev/null +++ b/app/models/integrations/chat_message/merge_message.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class MergeMessage < BaseMessage + attr_reader :merge_request_iid + attr_reader :source_branch + attr_reader :target_branch + attr_reader :action + attr_reader :state + attr_reader :title + + def initialize(params) + super + + obj_attr = params[:object_attributes] + obj_attr = HashWithIndifferentAccess.new(obj_attr) + @merge_request_iid = obj_attr[:iid] + @source_branch = obj_attr[:source_branch] + @target_branch = obj_attr[:target_branch] + @action = obj_attr[:action] + @state = obj_attr[:state] + @title = format_title(obj_attr[:title]) + end + + def attachments + [] + end + + def activity + { + title: "Merge request #{state_or_action_text} by #{user_combined_name}", + subtitle: "in #{project_link}", + text: merge_request_link, + image: user_avatar + } + end + + private + + def format_title(title) + '*' + title.lines.first.chomp + '*' + end + + def message + merge_request_message + end + + def project_link + link(project_name, project_url) + end + + def merge_request_message + "#{user_combined_name} #{state_or_action_text} merge request #{merge_request_link} in #{project_link}" + end + + def merge_request_link + link(merge_request_title, merge_request_url) + end + + def merge_request_title + "#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}" + end + + def merge_request_url + "#{project_url}/-/merge_requests/#{merge_request_iid}" + end + + def state_or_action_text + case action + when 'approved', 'unapproved' + action + when 'approval' + 'added their approval to' + when 'unapproval' + 'removed their approval from' + else + state + end + end + end + end +end diff --git a/app/models/integrations/chat_message/note_message.rb b/app/models/integrations/chat_message/note_message.rb new file mode 100644 index 00000000000..96675d2b27c --- /dev/null +++ b/app/models/integrations/chat_message/note_message.rb @@ -0,0 +1,86 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class NoteMessage < BaseMessage + attr_reader :note + attr_reader :note_url + attr_reader :title + attr_reader :target + + def initialize(params) + super + + params = HashWithIndifferentAccess.new(params) + obj_attr = params[:object_attributes] + @note = obj_attr[:note] + @note_url = obj_attr[:url] + @target, @title = case obj_attr[:noteable_type] + when "Commit" + create_commit_note(params[:commit]) + when "Issue" + create_issue_note(params[:issue]) + when "MergeRequest" + create_merge_note(params[:merge_request]) + when "Snippet" + create_snippet_note(params[:snippet]) + end + end + + def attachments + return note if markdown + + description_message + end + + def activity + { + title: "#{user_combined_name} #{link('commented on ' + target, note_url)}", + subtitle: "in #{project_link}", + text: formatted_title, + image: user_avatar + } + end + + private + + def message + "#{user_combined_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*" + end + + def format_title(title) + title.lines.first.chomp + end + + def formatted_title + format_title(title) + end + + def create_issue_note(issue) + ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]] + end + + def create_commit_note(commit) + commit_sha = Commit.truncate_sha(commit[:id]) + + ["commit #{commit_sha}", commit[:message]] + end + + def create_merge_note(merge_request) + ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]] + end + + def create_snippet_note(snippet) + ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]] + end + + def description_message + [{ text: format(note), color: attachment_color }] + end + + def project_link + link(project_name, project_url) + end + end + end +end diff --git a/app/models/integrations/chat_message/pipeline_message.rb b/app/models/integrations/chat_message/pipeline_message.rb new file mode 100644 index 00000000000..a0f6f582e4c --- /dev/null +++ b/app/models/integrations/chat_message/pipeline_message.rb @@ -0,0 +1,267 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class PipelineMessage < BaseMessage + MAX_VISIBLE_JOBS = 10 + + attr_reader :user + attr_reader :ref_type + attr_reader :ref + attr_reader :status + attr_reader :detailed_status + attr_reader :duration + attr_reader :finished_at + attr_reader :pipeline_id + attr_reader :failed_stages + attr_reader :failed_jobs + + attr_reader :project + attr_reader :commit + attr_reader :committer + attr_reader :pipeline + + def initialize(data) + super + + @user = data[:user] + @user_name = data.dig(:user, :username) || 'API' + + pipeline_attributes = data[:object_attributes] + @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' + @ref = pipeline_attributes[:ref] + @status = pipeline_attributes[:status] + @detailed_status = pipeline_attributes[:detailed_status] + @duration = pipeline_attributes[:duration].to_i + @finished_at = pipeline_attributes[:finished_at] ? Time.parse(pipeline_attributes[:finished_at]).to_i : nil + @pipeline_id = pipeline_attributes[:id] + + # Get list of jobs that have actually failed (after exhausting all retries) + @failed_jobs = actually_failed_jobs(Array(data[:builds])) + @failed_stages = @failed_jobs.map { |j| j[:stage] }.uniq + + @project = Project.find(data[:project][:id]) + @commit = project.commit_by(oid: data[:commit][:id]) + @committer = commit.committer + @pipeline = Ci::Pipeline.find(pipeline_id) + end + + def pretext + '' + end + + def attachments + return message if markdown + + [{ + fallback: format(message), + color: attachment_color, + author_name: user_combined_name, + author_icon: user_avatar, + author_link: author_url, + title: s_("ChatMessage|Pipeline #%{pipeline_id} %{humanized_status} in %{duration}") % + { + pipeline_id: pipeline_id, + humanized_status: humanized_status, + duration: pretty_duration(duration) + }, + title_link: pipeline_url, + fields: attachments_fields, + footer: project.name, + footer_icon: project.avatar_url(only_path: false), + ts: finished_at + }] + end + + def activity + { + title: s_("ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status}") % + { + pipeline_link: pipeline_link, + ref_type: ref_type, + ref_link: ref_link, + user_combined_name: user_combined_name, + humanized_status: humanized_status + }, + subtitle: s_("ChatMessage|in %{project_link}") % { project_link: project_link }, + text: s_("ChatMessage|in %{duration}") % { duration: pretty_duration(duration) }, + image: user_avatar || '' + } + end + + private + + def actually_failed_jobs(builds) + succeeded_job_names = builds.map { |b| b[:name] if b[:status] == 'success' }.compact.uniq + + failed_jobs = builds.select do |build| + # Select jobs which doesn't have a successful retry + build[:status] == 'failed' && !succeeded_job_names.include?(build[:name]) + end + + failed_jobs.uniq { |job| job[:name] }.reverse + end + + def failed_stages_field + { + title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length), + value: Slack::Messenger::Util::LinkFormatter.format(failed_stages_links), + short: true + } + end + + def failed_jobs_field + { + title: s_("ChatMessage|Failed job").pluralize(failed_jobs.length), + value: Slack::Messenger::Util::LinkFormatter.format(failed_jobs_links), + short: true + } + end + + def yaml_error_field + { + title: s_("ChatMessage|Invalid CI config YAML file"), + value: pipeline.yaml_errors, + short: false + } + end + + def attachments_fields + fields = [ + { + title: ref_type == "tag" ? s_("ChatMessage|Tag") : s_("ChatMessage|Branch"), + value: Slack::Messenger::Util::LinkFormatter.format(ref_link), + short: true + }, + { + title: s_("ChatMessage|Commit"), + value: Slack::Messenger::Util::LinkFormatter.format(commit_link), + short: true + } + ] + + fields << failed_stages_field if failed_stages.any? + fields << failed_jobs_field if failed_jobs.any? + fields << yaml_error_field if pipeline.has_yaml_errors? + + fields + end + + def message + s_("ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status} in %{duration}") % + { + project_link: project_link, + pipeline_link: pipeline_link, + ref_type: ref_type, + ref_link: ref_link, + user_combined_name: user_combined_name, + humanized_status: humanized_status, + duration: pretty_duration(duration) + } + end + + def humanized_status + case status + when 'success' + detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed") + when 'failed' + s_("ChatMessage|has failed") + else + status + end + end + + def attachment_color + case status + when 'success' + detailed_status == 'passed with warnings' ? 'warning' : 'good' + else + 'danger' + end + end + + def ref_url + if ref_type == 'tag' + "#{project_url}/-/tags/#{ref}" + else + "#{project_url}/-/commits/#{ref}" + end + end + + def ref_link + "[#{ref}](#{ref_url})" + end + + def project_url + project.web_url + end + + def project_link + "[#{project.name}](#{project_url})" + end + + def pipeline_failed_jobs_url + "#{project_url}/-/pipelines/#{pipeline_id}/failures" + end + + def pipeline_url + if failed_jobs.any? + pipeline_failed_jobs_url + else + "#{project_url}/-/pipelines/#{pipeline_id}" + end + end + + def pipeline_link + "[##{pipeline_id}](#{pipeline_url})" + end + + def job_url(job) + "#{project_url}/-/jobs/#{job[:id]}" + end + + def job_link(job) + "[#{job[:name]}](#{job_url(job)})" + end + + def failed_jobs_links + failed = failed_jobs.slice(0, MAX_VISIBLE_JOBS) + truncated = failed_jobs.slice(MAX_VISIBLE_JOBS, failed_jobs.size) + + failed_links = failed.map { |job| job_link(job) } + + unless truncated.blank? + failed_links << s_("ChatMessage|and [%{count} more](%{pipeline_failed_jobs_url})") % { + count: truncated.size, + pipeline_failed_jobs_url: pipeline_failed_jobs_url + } + end + + failed_links.join(I18n.t(:'support.array.words_connector')) + end + + def stage_link(stage) + # All stages link to the pipeline page + "[#{stage}](#{pipeline_url})" + end + + def failed_stages_links + failed_stages.map { |s| stage_link(s) }.join(I18n.t(:'support.array.words_connector')) + end + + def commit_url + Gitlab::UrlBuilder.build(commit) + end + + def commit_link + "[#{commit.title}](#{commit_url})" + end + + def author_url + return unless user && committer + + Gitlab::UrlBuilder.build(committer) + end + end + end +end diff --git a/app/models/integrations/chat_message/push_message.rb b/app/models/integrations/chat_message/push_message.rb new file mode 100644 index 00000000000..0952986e923 --- /dev/null +++ b/app/models/integrations/chat_message/push_message.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class PushMessage < BaseMessage + attr_reader :after + attr_reader :before + attr_reader :commits + attr_reader :ref + attr_reader :ref_type + + def initialize(params) + super + + @after = params[:after] + @before = params[:before] + @commits = params.fetch(:commits, []) + @ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch' + @ref = Gitlab::Git.ref_name(params[:ref]) + end + + def attachments + return [] if new_branch? || removed_branch? + return commit_messages if markdown + + commit_message_attachments + end + + def activity + { + title: humanized_action(short: true), + subtitle: "in #{project_link}", + text: compare_link, + image: user_avatar + } + end + + private + + def humanized_action(short: false) + action, ref_link, target_link = compose_action_details + text = [user_combined_name, action, ref_type, ref_link] + text << target_link unless short + text.join(' ') + end + + def message + humanized_action + end + + def format(string) + Slack::Messenger::Util::LinkFormatter.format(string) + end + + def commit_messages + commits.map { |commit| compose_commit_message(commit) }.join("\n\n") + end + + def commit_message_attachments + [{ text: format(commit_messages), color: attachment_color }] + end + + def compose_commit_message(commit) + author = commit[:author][:name] + id = Commit.truncate_sha(commit[:id]) + title = commit[:title] + + url = commit[:url] + + "[#{id}](#{url}): #{title} - #{author}" + end + + def new_branch? + Gitlab::Git.blank_ref?(before) + end + + def removed_branch? + Gitlab::Git.blank_ref?(after) + end + + def ref_url + if ref_type == 'tag' + "#{project_url}/-/tags/#{ref}" + else + "#{project_url}/commits/#{ref}" + end + end + + def compare_url + "#{project_url}/compare/#{before}...#{after}" + end + + def ref_link + "[#{ref}](#{ref_url})" + end + + def project_link + "[#{project_name}](#{project_url})" + end + + def compare_link + "[Compare changes](#{compare_url})" + end + + def compose_action_details + if new_branch? + ['pushed new', ref_link, "to #{project_link}"] + elsif removed_branch? + ['removed', ref, "from #{project_link}"] + else + ['pushed to', ref_link, "of #{project_link} (#{compare_link})"] + end + end + + def attachment_color + '#345' + end + end + end +end diff --git a/app/models/integrations/chat_message/wiki_page_message.rb b/app/models/integrations/chat_message/wiki_page_message.rb new file mode 100644 index 00000000000..9b5275b8c03 --- /dev/null +++ b/app/models/integrations/chat_message/wiki_page_message.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Integrations + module ChatMessage + class WikiPageMessage < BaseMessage + attr_reader :title + attr_reader :wiki_page_url + attr_reader :action + attr_reader :description + + def initialize(params) + super + + obj_attr = params[:object_attributes] + obj_attr = HashWithIndifferentAccess.new(obj_attr) + @title = obj_attr[:title] + @wiki_page_url = obj_attr[:url] + @description = obj_attr[:message] + + @action = + case obj_attr[:action] + when "create" + "created" + when "update" + "edited" + end + end + + def attachments + return description if markdown + + description_message + end + + def activity + { + title: "#{user_combined_name} #{action} #{wiki_page_link}", + subtitle: "in #{project_link}", + text: title, + image: user_avatar + } + end + + private + + def message + "#{user_combined_name} #{action} #{wiki_page_link} in #{project_link}: *#{title}*" + end + + def description_message + [{ text: format(@description), color: attachment_color }] + end + + def project_link + "[#{project_name}](#{project_url})" + end + + def wiki_page_link + "[wiki page](#{wiki_page_url})" + end + end + end +end diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb new file mode 100644 index 00000000000..30f73496993 --- /dev/null +++ b/app/models/integrations/confluence.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Integrations + class Confluence < Integration + include ActionView::Helpers::UrlHelper + + VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze + VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze + VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze + + prop_accessor :confluence_url + + validates :confluence_url, presence: true, if: :activated? + validate :validate_confluence_url_is_cloud, if: :activated? + + after_commit :cache_project_has_confluence + + def self.to_param + 'confluence' + end + + def self.supported_events + %w() + end + + def title + s_('ConfluenceService|Confluence Workspace') + end + + def description + s_('ConfluenceService|Link to a Confluence Workspace from the sidebar.') + end + + def help + return unless project&.wiki_enabled? + + if activated? + wiki_url = project.wiki.web_url + + s_( + 'ConfluenceService|Your GitLab wiki is still available at %{wiki_link}. To re-enable the link to the GitLab wiki, disable this integration.' % + { wiki_link: link_to(wiki_url, wiki_url) } + ).html_safe + else + s_('ConfluenceService|Link to a Confluence Workspace from the sidebar. Enabling this integration replaces the "Wiki" sidebar link with a link to the Confluence Workspace. The GitLab wiki is still available at the original URL.').html_safe + end + end + + def fields + [ + { + type: 'text', + name: 'confluence_url', + title: s_('Confluence Cloud Workspace URL'), + placeholder: 'https://example.atlassian.net/wiki', + required: true + } + ] + end + + def can_test? + false + end + + private + + def validate_confluence_url_is_cloud + unless confluence_uri_valid? + errors.add(:confluence_url, 'URL must be to a Confluence Cloud Workspace hosted on atlassian.net') + end + end + + def confluence_uri_valid? + return false unless confluence_url + + uri = URI.parse(confluence_url) + + (uri.scheme&.match(VALID_SCHEME_MATCH) && + uri.host&.match(VALID_HOST_MATCH) && + uri.path&.match(VALID_PATH_MATCH)).present? + + rescue URI::InvalidURIError + false + end + + def cache_project_has_confluence + return unless project && !project.destroyed? + + project.project_setting.save! unless project.project_setting.persisted? + project.project_setting.update_column(:has_confluence, active?) + end + end +end diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb new file mode 100644 index 00000000000..dd4b0664d52 --- /dev/null +++ b/app/models/integrations/datadog.rb @@ -0,0 +1,143 @@ +# frozen_string_literal: true + +module Integrations + class Datadog < Integration + DEFAULT_SITE = 'datadoghq.com' + URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_site}/v1/input/' + URL_TEMPLATE_API_KEYS = 'https://app.%{datadog_site}/account/settings#api' + URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_SITE}/account_management/api-app-keys/" + + SUPPORTED_EVENTS = %w[ + pipeline job + ].freeze + + prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env + + with_options if: :activated? do + validates :api_key, presence: true, format: { with: /\A\w+\z/ } + validates :datadog_site, format: { with: /\A[\w\.]+\z/, allow_blank: true } + validates :api_url, public_url: { allow_blank: true } + validates :datadog_site, presence: true, unless: -> (obj) { obj.api_url.present? } + validates :api_url, presence: true, unless: -> (obj) { obj.datadog_site.present? } + end + + after_save :compose_service_hook, if: :activated? + + def initialize_properties + super + + self.datadog_site ||= DEFAULT_SITE + end + + def self.supported_events + SUPPORTED_EVENTS + end + + def self.default_test_event + 'pipeline' + end + + def configurable_events + [] # do not allow to opt out of required hooks + end + + def title + 'Datadog' + end + + def description + 'Trace your GitLab pipelines with Datadog' + end + + def help + nil + end + + def self.to_param + 'datadog' + end + + def fields + [ + { + type: 'text', + name: 'datadog_site', + placeholder: DEFAULT_SITE, + help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site', + required: false + }, + { + type: 'text', + name: 'api_url', + title: 'API URL', + help: '(Advanced) Define the full URL for your Datadog site directly', + required: false + }, + { + type: 'password', + name: 'api_key', + 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", + required: true + }, + { + type: 'text', + name: 'datadog_service', + title: 'Service', + placeholder: 'gitlab-ci', + help: 'Name of this GitLab instance that all data will be tagged with' + }, + { + type: 'text', + name: 'datadog_env', + title: 'Env', + help: 'The environment tag that traces will be tagged with' + } + ] + end + + def compose_service_hook + hook = service_hook || build_service_hook + hook.url = hook_url + hook.save + end + + def hook_url + url = api_url.presence || sprintf(URL_TEMPLATE, datadog_site: datadog_site) + url = URI.parse(url) + url.path = File.join(url.path || '/', api_key) + query = { service: datadog_service.presence, env: datadog_env.presence }.compact + url.query = query.to_query unless query.empty? + url.to_s + end + + def api_keys_url + return URL_API_KEYS_DOCS unless datadog_site.presence + + sprintf(URL_TEMPLATE_API_KEYS, datadog_site: datadog_site) + end + + def execute(data) + return if project.disabled_services.include?(to_param) + + object_kind = data[:object_kind] + object_kind = 'job' if object_kind == 'build' + return unless supported_events.include?(object_kind) + + service_hook.execute(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] } + end + end +end diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb new file mode 100644 index 00000000000..e277633664f --- /dev/null +++ b/app/models/integrations/emails_on_push.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +module Integrations + class EmailsOnPush < Integration + include NotificationBranchSelection + + RECIPIENTS_LIMIT = 750 + + boolean_accessor :send_from_committer_email + boolean_accessor :disable_diffs + prop_accessor :recipients, :branches_to_be_notified + validates :recipients, presence: true, if: :validate_recipients? + validate :number_of_recipients_within_limit, if: :validate_recipients? + + def self.valid_recipients(recipients) + recipients.split.select do |recipient| + recipient.include?('@') + end.uniq(&:downcase) + end + + def title + s_('EmailsOnPushService|Emails on push') + end + + def description + s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.') + end + + def self.to_param + 'emails_on_push' + end + + def self.supported_events + %w(push tag_push) + end + + def initialize_properties + super + + self.branches_to_be_notified = 'all' if branches_to_be_notified.nil? + end + + def execute(push_data) + return unless supported_events.include?(push_data[:object_kind]) + return if project.emails_disabled? + return unless notify_for_ref?(push_data) + + EmailsOnPushWorker.perform_async( + project_id, + recipients, + push_data, + send_from_committer_email: send_from_committer_email?, + disable_diffs: disable_diffs? + ) + end + + def notify_for_ref?(push_data) + return true if push_data[:object_kind] == 'tag_push' + return true if push_data.dig(:object_attributes, :tag) + + notify_for_branch?(push_data) + end + + def send_from_committer_email? + Gitlab::Utils.to_boolean(self.send_from_committer_email) + end + + def disable_diffs? + Gitlab::Utils.to_boolean(self.disable_diffs) + end + + def fields + domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ") + [ + { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"), + help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } }, + { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), + help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, + { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }, + { + type: 'textarea', + name: 'recipients', + placeholder: s_('EmailsOnPushService|tanuki@example.com gitlab@example.com'), + help: s_('EmailsOnPushService|Emails separated by whitespace.') + } + ] + end + + private + + def number_of_recipients_within_limit + return if recipients.blank? + + if self.class.valid_recipients(recipients).size > RECIPIENTS_LIMIT + errors.add(:recipients, s_("EmailsOnPushService|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT }) + end + end + end +end diff --git a/app/models/issue.rb b/app/models/issue.rb index af78466e6a9..2077f9bfdbb 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -87,7 +87,8 @@ class Issue < ApplicationRecord enum issue_type: { issue: 0, incident: 1, - test_case: 2 ## EE-only + test_case: 2, ## EE-only + requirement: 3 ## EE-only } alias_method :issuing_parent, :project @@ -108,6 +109,7 @@ class Issue < ApplicationRecord scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) } scope :order_closest_future_date, -> { reorder(Arel.sql('CASE WHEN issues.due_date >= CURRENT_DATE THEN 0 ELSE 1 END ASC, ABS(CURRENT_DATE - issues.due_date) ASC')) } scope :order_relative_position_asc, -> { reorder(::Gitlab::Database.nulls_last_order('relative_position', 'ASC')) } + scope :order_relative_position_desc, -> { reorder(::Gitlab::Database.nulls_first_order('relative_position', 'DESC')) } scope :order_closed_date_desc, -> { reorder(closed_at: :desc) } scope :order_created_at_desc, -> { reorder(created_at: :desc) } scope :order_severity_asc, -> { includes(:issuable_severity).order('issuable_severities.severity ASC NULLS FIRST') } @@ -121,7 +123,7 @@ class Issue < ApplicationRecord scope :with_prometheus_alert_events, -> { joins(:issues_prometheus_alert_events) } scope :with_self_managed_prometheus_alert_events, -> { joins(:issues_self_managed_prometheus_alert_events) } scope :with_api_entity_associations, -> { - preload(:timelogs, :closed_by, :assignees, :author, :notes, :labels, + preload(:timelogs, :closed_by, :assignees, :author, :labels, milestone: { project: [:route, { namespace: :route }] }, project: [:route, { namespace: :route }]) } @@ -174,8 +176,16 @@ class Issue < ApplicationRecord state :opened, value: Issue.available_states[:opened] state :closed, value: Issue.available_states[:closed] - before_transition any => :closed do |issue| + before_transition any => :closed do |issue, transition| + args = transition.args + issue.closed_at = issue.system_note_timestamp + + next if args.empty? + + next unless args.first.is_a?(User) + + issue.closed_by = args.first end before_transition closed: :opened do |issue| @@ -262,6 +272,18 @@ class Issue < ApplicationRecord "id DESC") 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! + if blocked_for_repositioning? + raise ::Gitlab::RelativePositioning::IssuePositioningDisabled, "Issue relative position changes temporarily disabled." + end + end + + def blocked_for_repositioning? + resource_parent.root_namespace&.issue_repositioning_disabled? + end + def hook_attrs Gitlab::HookData::IssueBuilder.new(self).build end @@ -506,4 +528,4 @@ class Issue < ApplicationRecord end end -Issue.prepend_if_ee('EE::Issue') +Issue.prepend_mod_with('Issue') diff --git a/app/models/issue/metrics.rb b/app/models/issue/metrics.rb index a5e1957c096..86523bbd023 100644 --- a/app/models/issue/metrics.rb +++ b/app/models/issue/metrics.rb @@ -24,6 +24,10 @@ class Issue::Metrics < ApplicationRecord private def issue_assigned_to_list_label? - issue.labels.any? { |label| label.lists.present? } + # Avoid another DB lookup when issue.labels are empty by adding a guard clause here + # We can't use issue.labels.empty? because that will cause a `Label Exists?` DB lookup + return false if issue.labels.length == 0 # rubocop:disable Style/ZeroLengthPredicate + + issue.labels.includes(:lists).any? { |label| label.lists.present? } end end diff --git a/app/models/issue_assignee.rb b/app/models/issue_assignee.rb index d62f0eb170c..d8fbd49d313 100644 --- a/app/models/issue_assignee.rb +++ b/app/models/issue_assignee.rb @@ -8,9 +8,9 @@ class IssueAssignee < ApplicationRecord validates :assignee, uniqueness: { scope: :issue_id } - scope :in_projects, ->(project_ids) { joins(:issue).where("issues.project_id in (?)", project_ids) } + scope :in_projects, ->(project_ids) { joins(:issue).where(issues: { project_id: project_ids }) } scope :on_issues, ->(issue_ids) { where(issue_id: issue_ids) } scope :for_assignee, ->(user) { where(assignee: user) } end -IssueAssignee.prepend_if_ee('EE::IssueAssignee') +IssueAssignee.prepend_mod_with('IssueAssignee') diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb index ba97874ed39..920586cc1ba 100644 --- a/app/models/issue_link.rb +++ b/app/models/issue_link.rb @@ -46,4 +46,4 @@ class IssueLink < ApplicationRecord end end -IssueLink.prepend_if_ee('EE::IssueLink') +IssueLink.prepend_mod_with('IssueLink') diff --git a/app/models/iteration.rb b/app/models/iteration.rb index 7483d04aab8..71ecbcf1c1a 100644 --- a/app/models/iteration.rb +++ b/app/models/iteration.rb @@ -13,4 +13,4 @@ class Iteration < ApplicationRecord end end -Iteration.prepend_if_ee('::EE::Iteration') +Iteration.prepend_mod_with('Iteration') diff --git a/app/models/key.rb b/app/models/key.rb index 131416d1bee..15b3c460b52 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -147,4 +147,4 @@ class Key < ApplicationRecord end end -Key.prepend_if_ee('EE::Key') +Key.prepend_mod_with('Key') diff --git a/app/models/label.rb b/app/models/label.rb index 26faaa90df3..a46d6bc5c0f 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -290,4 +290,4 @@ class Label < ApplicationRecord end end -Label.prepend_if_ee('EE::Label') +Label.prepend_mod_with('Label') diff --git a/app/models/label_link.rb b/app/models/label_link.rb index 5ae1e88e14e..a466fe69300 100644 --- a/app/models/label_link.rb +++ b/app/models/label_link.rb @@ -9,4 +9,7 @@ class LabelLink < ApplicationRecord validates :target, presence: true, unless: :importing? validates :label, presence: true, unless: :importing? + + scope :for_target, -> (target_id, target_type) { where(target_id: target_id, target_type: target_type) } + scope :with_remove_on_close_labels, -> { joins(:label).where(labels: { remove_on_close: true }) } end diff --git a/app/models/label_note.rb b/app/models/label_note.rb index e90028ce835..19dede36abd 100644 --- a/app/models/label_note.rb +++ b/app/models/label_note.rb @@ -79,4 +79,4 @@ class LabelNote < SyntheticNote end end -LabelNote.prepend_if_ee('EE::LabelNote') +LabelNote.prepend_mod_with('LabelNote') diff --git a/app/models/legacy_diff_note.rb b/app/models/legacy_diff_note.rb index df1ad8ea281..25e90036a53 100644 --- a/app/models/legacy_diff_note.rb +++ b/app/models/legacy_diff_note.rb @@ -112,4 +112,4 @@ class LegacyDiffNote < Note end end -LegacyDiffNote.prepend_if_ee('EE::LegacyDiffNote') +LegacyDiffNote.prepend_mod_with('LegacyDiffNote') diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index d60baa299cb..b837b902e2d 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -50,4 +50,4 @@ class LfsObject < ApplicationRecord end end -LfsObject.prepend_if_ee('EE::LfsObject') +LfsObject.prepend_mod_with('LfsObject') diff --git a/app/models/list.rb b/app/models/list.rb index d72afbaee69..fba0e51bdf8 100644 --- a/app/models/list.rb +++ b/app/models/list.rb @@ -49,4 +49,4 @@ class List < ApplicationRecord end end -List.prepend_if_ee('::EE::List') +List.prepend_mod_with('List') diff --git a/app/models/member.rb b/app/models/member.rb index e978552592d..044b662e10f 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -84,15 +84,25 @@ class Member < ApplicationRecord is_external_invite = arel_table[:user_id].eq(nil).and(arel_table[:invite_token].not_eq(nil)) user_is_blocked = User.arel_table[:state].eq(:blocked) - user_ok = Arel::Nodes::Grouping.new(is_external_invite).or(user_is_blocked) - left_join_users - .where(user_ok) + .where(user_is_blocked) + .where.not(is_external_invite) .non_request .non_minimal_access .reorder(nil) end + scope :connected_to_user, -> { where.not(user_id: nil) } + + # This scope is exclusively used to get the members + # that can possibly have project_authorization records + # to projects/groups. + scope :authorizable, -> do + connected_to_user + .non_request + .non_minimal_access + end + # Like active, but without invites. For when a User is required. scope :active_without_invites_and_requests, -> do left_join_users @@ -140,7 +150,8 @@ class Member < ApplicationRecord scope :distinct_on_user_with_max_access_level, -> do distinct_members = select('DISTINCT ON (user_id, invite_email) *') .order('user_id, invite_email, access_level DESC, expires_at DESC, created_at ASC') - Member.from(distinct_members, :members) + + from(distinct_members, :members) end scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) } @@ -560,4 +571,4 @@ class Member < ApplicationRecord end end -Member.prepend_if_ee('EE::Member') +Member.prepend_mod_with('Member') diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 0f9fdd230ff..b22a4fa9ef6 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -101,4 +101,4 @@ class GroupMember < Member end end -GroupMember.prepend_if_ee('EE::GroupMember') +GroupMember.prepend_mod_with('GroupMember') diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 9a86b3a3fd9..41ecc4cbf01 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -16,7 +16,7 @@ class ProjectMember < Member scope :in_project, ->(project) { where(source_id: project.id) } scope :in_namespaces, ->(groups) do joins('INNER JOIN projects ON projects.id = members.source_id') - .where('projects.namespace_id in (?)', groups.select(:id)) + .where(projects: { namespace_id: groups.select(:id) }) end scope :without_project_bots, -> do @@ -69,7 +69,7 @@ class ProjectMember < Member end true - rescue + rescue StandardError false end @@ -154,4 +154,4 @@ class ProjectMember < Member # rubocop: enable CodeReuse/ServiceClass end -ProjectMember.prepend_if_ee('EE::ProjectMember') +ProjectMember.prepend_mod_with('ProjectMember') diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb index 88db7f63bd9..ba7e4b39989 100644 --- a/app/models/members_preloader.rb +++ b/app/models/members_preloader.rb @@ -10,10 +10,11 @@ class MembersPreloader def preload_all ActiveRecord::Associations::Preloader.new.preload(members, :user) ActiveRecord::Associations::Preloader.new.preload(members, :source) - ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :status) - ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :u2f_registrations) - ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :webauthn_registrations) + ActiveRecord::Associations::Preloader.new.preload(members, :created_by) + ActiveRecord::Associations::Preloader.new.preload(members, user: :status) + ActiveRecord::Associations::Preloader.new.preload(members, user: :u2f_registrations) + ActiveRecord::Associations::Preloader.new.preload(members, user: :webauthn_registrations) if Feature.enabled?(:webauthn) end end -MembersPreloader.prepend_if_ee('EE::MembersPreloader') +MembersPreloader.prepend_mod_with('MembersPreloader') diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index e7f3762b9a3..aaef56418d2 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -37,6 +37,7 @@ class MergeRequest < ApplicationRecord SORTING_PREFERENCE_FIELD = :merge_requests_sort ALLOWED_TO_USE_MERGE_BASE_PIPELINE_FOR_COMPARISON = { + 'Ci::CompareMetricsReportsService' => ->(project) { ::Gitlab::Ci::Features.merge_base_pipeline_for_metrics_comparison?(project) }, 'Ci::CompareCodequalityReportsService' => ->(project) { true } }.freeze @@ -381,7 +382,7 @@ class MergeRequest < ApplicationRecord scope :review_requested_to, ->(user) do where( reviewers_subquery - .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user)) + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user.id)) .exists ) end @@ -389,7 +390,7 @@ class MergeRequest < ApplicationRecord scope :no_review_requested_to, ->(user) do where( reviewers_subquery - .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user)) + .where(Arel::Table.new("#{to_ability_name}_reviewers")[:user_id].eq(user.id)) .exists .not ) @@ -1367,11 +1368,11 @@ class MergeRequest < ApplicationRecord def environments_for(current_user, latest: false) return [] unless diff_head_commit - envs = EnvironmentsByDeploymentsFinder.new(target_project, current_user, + envs = Environments::EnvironmentsByDeploymentsFinder.new(target_project, current_user, ref: target_branch, commit: diff_head_commit, with_tags: true, find_latest: latest).execute if source_project - envs.concat EnvironmentsByDeploymentsFinder.new(source_project, current_user, + envs.concat Environments::EnvironmentsByDeploymentsFinder.new(source_project, current_user, ref: source_branch, commit: diff_head_commit, find_latest: latest).execute end @@ -1741,7 +1742,7 @@ class MergeRequest < ApplicationRecord if project.resolve_outdated_diff_discussions? MergeRequests::ResolvedDiscussionNotificationService - .new(project, current_user) + .new(project: project, current_user: current_user) .execute(self) end end @@ -1899,6 +1900,12 @@ class MergeRequest < ApplicationRecord diff_stats.map(&:path).include?(project.ci_config_path_or_default) end + def context_commits_diff + strong_memoize(:context_commits_diff) do + ContextCommitsDiff.new(self) + end + end + private def missing_report_error(report_type) @@ -1948,4 +1955,4 @@ class MergeRequest < ApplicationRecord end end -MergeRequest.prepend_if_ee('::EE::MergeRequest') +MergeRequest.prepend_mod_with('MergeRequest') diff --git a/app/models/merge_request/metrics.rb b/app/models/merge_request/metrics.rb index 5c611da0684..b9460afa8e7 100644 --- a/app/models/merge_request/metrics.rb +++ b/app/models/merge_request/metrics.rb @@ -31,4 +31,4 @@ class MergeRequest::Metrics < ApplicationRecord end end -MergeRequest::Metrics.prepend_if_ee('EE::MergeRequest::Metrics') +MergeRequest::Metrics.prepend_mod_with('MergeRequest::Metrics') diff --git a/app/models/merge_request_assignee.rb b/app/models/merge_request_assignee.rb index 73f8fe77b04..86bf950ae19 100644 --- a/app/models/merge_request_assignee.rb +++ b/app/models/merge_request_assignee.rb @@ -6,5 +6,5 @@ class MergeRequestAssignee < ApplicationRecord validates :assignee, uniqueness: { scope: :merge_request_id } - scope :in_projects, ->(project_ids) { joins(:merge_request).where("merge_requests.target_project_id in (?)", project_ids) } + scope :in_projects, ->(project_ids) { joins(:merge_request).where(merge_requests: { target_project_id: project_ids }) } end diff --git a/app/models/merge_request_context_commit_diff_file.rb b/app/models/merge_request_context_commit_diff_file.rb index 6f15df1b70f..8abedd26b06 100644 --- a/app/models/merge_request_context_commit_diff_file.rb +++ b/app/models/merge_request_context_commit_diff_file.rb @@ -16,4 +16,8 @@ class MergeRequestContextCommitDiffFile < ApplicationRecord def self.bulk_insert(*args) Gitlab::Database.bulk_insert('merge_request_context_commit_diff_files', *args) # rubocop:disable Gitlab/BulkInsert end + + def path + new_path.presence || old_path + end end diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index fb873ddbbab..2dc6796732f 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -113,14 +113,29 @@ class MergeRequestDiff < ApplicationRecord joins(merge_request: :metrics).where(condition) end + # This scope uses LATERAL JOIN to find the most recent MR diff association for the given merge requests. + # To avoid joining the merge_requests table, we build an in memory table using the merge request ids. + # Example: + # SELECT ... + # FROM (VALUES (MR_ID_1),(MR_ID_2)) merge_requests (id) + # INNER JOIN LATERAL (...) scope :latest_diff_for_merge_requests, -> (merge_requests) do - inner_select = MergeRequestDiff - .default_scoped - .distinct - .select("FIRST_VALUE(id) OVER (PARTITION BY merge_request_id ORDER BY created_at DESC) as id") - .where(merge_request: merge_requests) + mrs = Array(merge_requests) + return MergeRequestDiff.none if mrs.empty? - joins("INNER JOIN (#{inner_select.to_sql}) latest_diffs ON latest_diffs.id = merge_request_diffs.id") + merge_request_table = MergeRequest.arel_table + merge_request_diff_table = MergeRequestDiff.arel_table + + join_query = MergeRequestDiff + .where(merge_request_table[:id].eq(merge_request_diff_table[:merge_request_id])) + .order(created_at: :desc) + .limit(1) + + mr_id_list = mrs.map { |mr| "(#{Integer(mr.id)})" }.join(",") + + MergeRequestDiff + .from("(VALUES #{mr_id_list}) merge_requests (id)") + .joins("INNER JOIN LATERAL (#{join_query.to_sql}) #{MergeRequestDiff.table_name} ON TRUE") .includes(:merge_request_diff_commits) end @@ -665,10 +680,6 @@ class MergeRequestDiff < ApplicationRecord opening_external_diff do collection = merge_request_diff_files - if options[:include_context_commits] - collection += merge_request.merge_request_context_commit_diff_files - end - if paths = options[:paths] collection = collection.where('old_path IN (?) OR new_path IN (?)', paths, paths) end @@ -743,7 +754,6 @@ class MergeRequestDiff < ApplicationRecord end def reorder_diff_files! - return unless sort_diffs? return if sorted? || merge_request_diff_files.empty? diff_files = sort_diffs(merge_request_diff_files) @@ -762,14 +772,8 @@ class MergeRequestDiff < ApplicationRecord end def sort_diffs(diffs) - return diffs unless sort_diffs? - Gitlab::Diff::FileCollectionSorter.new(diffs).sort end - - def sort_diffs? - Feature.enabled?(:sort_diffs, project, default_enabled: :yaml) - end end -MergeRequestDiff.prepend_if_ee('EE::MergeRequestDiff') +MergeRequestDiff.prepend_mod_with('MergeRequestDiff') diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 4cf0e423a15..16090f0ebfa 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -7,7 +7,7 @@ class Milestone < ApplicationRecord include FromUnion include Importable - prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule + prepend_mod_with('Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule class Predefined ALL = [::Timebox::None, ::Timebox::Any, ::Timebox::Started, ::Timebox::Upcoming].freeze @@ -94,7 +94,7 @@ class Milestone < ApplicationRecord end def participants - User.joins(assigned_issues: :milestone).where("milestones.id = ?", id).distinct + User.joins(assigned_issues: :milestone).where(milestones: { id: id }).distinct end def self.sort_by_attribute(method) diff --git a/app/models/milestone_release.rb b/app/models/milestone_release.rb index c6b5a967af9..93ad961ca51 100644 --- a/app/models/milestone_release.rb +++ b/app/models/milestone_release.rb @@ -19,4 +19,4 @@ class MilestoneRelease < ApplicationRecord end end -MilestoneRelease.prepend_if_ee('EE::MilestoneRelease') +MilestoneRelease.prepend_mod_with('MilestoneRelease') diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 455429608b4..8f03c6145cb 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -14,6 +14,7 @@ class Namespace < ApplicationRecord include IgnorableColumns include Namespaces::Traversal::Recursive include Namespaces::Traversal::Linear + include EachBatch ignore_column :delayed_project_removal, remove_with: '14.1', remove_after: '2021-05-22' @@ -88,8 +89,12 @@ class Namespace < ApplicationRecord after_update :move_dir, if: :saved_change_to_path_or_parent? before_destroy(prepend: true) { prepare_for_destroy } after_destroy :rm_dir + after_commit :expire_child_caches, on: :update, if: -> { + Feature.enabled?(:cached_route_lookups, self, type: :ops, default_enabled: :yaml) && + saved_change_to_name? || saved_change_to_path? || saved_change_to_parent_id? + } - scope :for_user, -> { where('type IS NULL') } + scope :for_user, -> { where(type: nil) } scope :sort_by_type, -> { order(Gitlab::Database.nulls_first_order(:type)) } scope :include_route, -> { includes(:route) } scope :by_parent, -> (parent) { where(parent_id: parent) } @@ -198,7 +203,7 @@ class Namespace < ApplicationRecord end def any_project_has_container_registry_tags? - all_projects.any?(&:has_container_registry_tags?) + all_projects.includes(:container_repositories).any?(&:has_container_registry_tags?) end def first_project_with_container_registry_tags @@ -420,8 +425,22 @@ class Namespace < ApplicationRecord created_at >= 90.days.ago end + def issue_repositioning_disabled? + Feature.enabled?(:block_issue_repositioning, self, type: :ops, default_enabled: :yaml) + end + private + def expire_child_caches + Namespace.where(id: descendants).each_batch do |namespaces| + namespaces.touch_all + end + + all_projects.each_batch do |projects| + projects.touch_all + end + end + def all_projects_with_pages if all_projects.pages_metadata_not_migrated.exists? Gitlab::BackgroundMigration::MigratePagesMetadata.new.perform_on_relation( @@ -490,4 +509,4 @@ class Namespace < ApplicationRecord end end -Namespace.prepend_if_ee('EE::Namespace') +Namespace.prepend_mod_with('Namespace') diff --git a/app/models/namespace/package_setting.rb b/app/models/namespace/package_setting.rb index a2064e020b3..881b2f3acb3 100644 --- a/app/models/namespace/package_setting.rb +++ b/app/models/namespace/package_setting.rb @@ -6,13 +6,15 @@ class Namespace::PackageSetting < ApplicationRecord PackageSettingNotImplemented = Class.new(StandardError) - PACKAGES_WITH_SETTINGS = %w[maven].freeze + PACKAGES_WITH_SETTINGS = %w[maven generic].freeze belongs_to :namespace, inverse_of: :package_setting_relation validates :namespace, presence: true validates :maven_duplicates_allowed, inclusion: { in: [true, false] } validates :maven_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } + validates :generic_duplicates_allowed, inclusion: { in: [true, false] } + validates :generic_duplicate_exception_regex, untrusted_regexp: true, length: { maximum: 255 } class << self def duplicates_allowed?(package) @@ -22,7 +24,7 @@ class Namespace::PackageSetting < ApplicationRecord duplicates_allowed = package.package_settings["#{package.package_type}_duplicates_allowed"] regex = ::Gitlab::UntrustedRegexp.new("\\A#{package.package_settings["#{package.package_type}_duplicate_exception_regex"]}\\z") - duplicates_allowed || regex.match?(package.name) + duplicates_allowed || regex.match?(package.name) || regex.match?(package.version) end end end diff --git a/app/models/namespace/root_storage_statistics.rb b/app/models/namespace/root_storage_statistics.rb index 0c91ae760b2..73061b78637 100644 --- a/app/models/namespace/root_storage_statistics.rb +++ b/app/models/namespace/root_storage_statistics.rb @@ -70,4 +70,4 @@ class Namespace::RootStorageStatistics < ApplicationRecord end end -Namespace::RootStorageStatistics.prepend_if_ee('EE::Namespace::RootStorageStatistics') +Namespace::RootStorageStatistics.prepend_mod_with('Namespace::RootStorageStatistics') diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb index 28cf55f7486..093b7dae246 100644 --- a/app/models/namespace/traversal_hierarchy.rb +++ b/app/models/namespace/traversal_hierarchy.rb @@ -20,7 +20,7 @@ class Namespace end def initialize(root) - raise StandardError.new('Must specify a root node') if root.parent_id + raise StandardError, 'Must specify a root node' if root.parent_id @root = root end @@ -34,20 +34,23 @@ class Namespace sql = """ UPDATE namespaces SET traversal_ids = cte.traversal_ids - FROM (#{recursive_traversal_ids(lock: true)}) as cte + FROM (#{recursive_traversal_ids}) as cte WHERE namespaces.id = cte.id AND namespaces.traversal_ids <> cte.traversal_ids """ - Namespace.connection.exec_query(sql) + Namespace.transaction do + @root.lock! + Namespace.connection.exec_query(sql) + end rescue ActiveRecord::Deadlocked db_deadlock_counter.increment(source: 'Namespace#sync_traversal_ids!') raise end # Identify all incorrect traversal_ids in the current namespace hierarchy. - def incorrect_traversal_ids(lock: false) + def incorrect_traversal_ids Namespace - .joins("INNER JOIN (#{recursive_traversal_ids(lock: lock)}) as cte ON namespaces.id = cte.id") + .joins("INNER JOIN (#{recursive_traversal_ids}) as cte ON namespaces.id = cte.id") .where('namespaces.traversal_ids <> cte.traversal_ids') end @@ -58,13 +61,10 @@ class Namespace # # Note that the traversal_ids represent a calculated traversal path for the # namespace and not the value stored within the traversal_ids attribute. - # - # Optionally locked with FOR UPDATE to ensure isolation between concurrent - # updates of the heirarchy. - def recursive_traversal_ids(lock: false) + def recursive_traversal_ids root_id = Integer(@root.id) - sql = <<~SQL + <<~SQL WITH RECURSIVE cte(id, traversal_ids, cycle) AS ( VALUES(#{root_id}, ARRAY[#{root_id}], false) UNION ALL @@ -74,10 +74,6 @@ class Namespace ) SELECT id, traversal_ids FROM cte SQL - - sql += ' FOR UPDATE' if lock - - sql end # This is essentially Namespace#root_ancestor which will soon be rewritten diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index d21f9632e18..75b8169b58e 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -45,4 +45,4 @@ class NamespaceSetting < ApplicationRecord end end -NamespaceSetting.prepend_if_ee('EE::NamespaceSetting') +NamespaceSetting.prepend_mod_with('NamespaceSetting') diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb index 294ef83b9b4..a1711bc5ee0 100644 --- a/app/models/namespaces/traversal/linear.rb +++ b/app/models/namespaces/traversal/linear.rb @@ -41,6 +41,7 @@ module Namespaces UnboundedSearch = Class.new(StandardError) included do + 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? } @@ -52,15 +53,30 @@ module Namespaces end def use_traversal_ids? - Feature.enabled?(:use_traversal_ids, root_ancestor, default_enabled: :yaml) + return false unless Feature.enabled?(:use_traversal_ids, root_ancestor, default_enabled: :yaml) + + traversal_ids.present? end def self_and_descendants - if use_traversal_ids? - lineage(self) - else - super - end + return super unless use_traversal_ids? + + lineage(top: self) + end + + def descendants + return super unless use_traversal_ids? + + self_and_descendants.where.not(id: id) + end + + def ancestors(hierarchy_order: nil) + return super() unless use_traversal_ids? + return super() unless Feature.enabled?(:use_traversal_ids_for_ancestors, root_ancestor, default_enabled: :yaml) + + return self.class.none if parent_id.blank? + + lineage(bottom: parent, hierarchy_order: hierarchy_order) end private @@ -75,6 +91,23 @@ module Namespaces Namespace::TraversalHierarchy.for_namespace(root_ancestor).sync_traversal_ids! end + # Lock the root of the hierarchy we just left, and lock the root of the hierarchy + # we just joined. In most cases the two hierarchies will be the same. + def lock_both_roots + parent_ids = [ + parent_id_was || self.id, + parent_id || self.id + ].compact + + roots = Gitlab::ObjectHierarchy + .new(Namespace.where(id: parent_ids)) + .base_and_ancestors + .reorder(nil) + .where(parent_id: nil) + + 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 @@ -82,29 +115,29 @@ module Namespaces end # Search this namespace's lineage. Bound inclusively by top node. - def lineage(top) - raise UnboundedSearch.new('Must bound search by a top') unless top + def lineage(top: nil, bottom: nil, hierarchy_order: nil) + raise UnboundedSearch, 'Must bound search by either top or bottom' unless top || bottom - without_sti_condition - .traversal_ids_contains(latest_traversal_ids(top)) - end + skope = without_sti_condition - # traversal_ids are a cached value. - # - # The traversal_ids value in a loaded object can become stale when compared - # to the database value. For example, if you load a hierarchy and then move - # a group, any previously loaded descendant objects will have out of date - # traversal_ids. - # - # To solve this problem, we never depend on the object's traversal_ids - # value. We always query the database first with a sub-select for the - # latest traversal_ids. - # - # Note that ActiveRecord will cache query results. You can avoid this by - # using `Model.uncached { ... }` - def latest_traversal_ids(namespace = self) - without_sti_condition.where('id = (?)', namespace) - .select('traversal_ids as latest_traversal_ids') + if top + skope = skope.traversal_ids_contains("{#{top.id}}") + end + + if bottom + skope = skope.where(id: bottom.traversal_ids[0..-1]) + end + + # The original `with_depth` attribute in ObjectHierarchy increments as you + # walk away from the "base" namespace. This direction changes depending on + # if you are walking up the ancestors or down the descendants. + 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) + end + + skope end end end diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 9da454125eb..560ff861105 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -27,7 +27,7 @@ module Network @project .notes - .where('noteable_type = ?', 'Commit') + .where(noteable_type: 'Commit') .group('notes.commit_id') .select('notes.commit_id, count(notes.id) as note_count') .each do |item| diff --git a/app/models/note.rb b/app/models/note.rb index 3e560a09fbd..ae4a8859d4d 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -107,6 +107,7 @@ class Note < ApplicationRecord scope :fresh, -> { order_created_asc.with_order_id_asc } scope :updated_after, ->(time) { where('updated_at > ?', time) } scope :with_updated_at, ->(time) { where(updated_at: time) } + scope :with_suggestions, -> { joins(:suggestions) } scope :inc_author_project, -> { includes(:project, :author) } scope :inc_author, -> { includes(:author) } scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) } @@ -319,7 +320,7 @@ class Note < ApplicationRecord return commit if for_commit? super - rescue + rescue StandardError # Temp fix to prevent app crash # if note commit id doesn't exist nil @@ -495,7 +496,7 @@ class Note < ApplicationRecord noteable&.expire_note_etag_cache end - def touch(*args) + def touch(*args, **kwargs) # We're not using an explicit transaction here because this would in all # cases result in all future queries going to the primary, even if no writes # are performed. @@ -638,4 +639,4 @@ class Note < ApplicationRecord end end -Note.prepend_if_ee('EE::Note') +Note.prepend_mod_with('Note') diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 3d049336d44..4323f89865a 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -118,4 +118,4 @@ class NotificationSetting < ApplicationRecord end end -NotificationSetting.prepend_if_ee('EE::NotificationSetting') +NotificationSetting.prepend_mod_with('NotificationSetting') diff --git a/app/models/operations/feature_flag.rb b/app/models/operations/feature_flag.rb index be3f719ddb3..537543a7ff0 100644 --- a/app/models/operations/feature_flag.rb +++ b/app/models/operations/feature_flag.rb @@ -97,7 +97,7 @@ module Operations issues = ::Issue .select('issues.*, operations_feature_flags_issues.id AS link_id') .joins(:feature_flag_issues) - .where('operations_feature_flags_issues.feature_flag_id = ?', id) + .where(operations_feature_flags_issues: { feature_flag_id: id }) .order('operations_feature_flags_issues.id ASC') .includes(preload) diff --git a/app/models/packages.rb b/app/models/packages.rb index e14c9290093..19490d23ce4 100644 --- a/app/models/packages.rb +++ b/app/models/packages.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true module Packages + DuplicatePackageError = Class.new(StandardError) + def self.table_name_prefix 'packages_' end diff --git a/app/models/packages/debian/group_distribution.rb b/app/models/packages/debian/group_distribution.rb index eea7acacc96..50c1ec9f163 100644 --- a/app/models/packages/debian/group_distribution.rb +++ b/app/models/packages/debian/group_distribution.rb @@ -6,4 +6,14 @@ class Packages::Debian::GroupDistribution < ApplicationRecord end include Packages::Debian::Distribution + + def packages + Packages::Package + .for_projects(group.all_projects.public_only) + .with_debian_codename(codename) + end + + def package_files + ::Packages::PackageFile.for_package_ids(packages.select(:id)) + end end diff --git a/app/models/packages/debian/project_distribution.rb b/app/models/packages/debian/project_distribution.rb index 22f1008b3b5..5ac60d789b3 100644 --- a/app/models/packages/debian/project_distribution.rb +++ b/app/models/packages/debian/project_distribution.rb @@ -5,8 +5,9 @@ class Packages::Debian::ProjectDistribution < ApplicationRecord :project end + include Packages::Debian::Distribution + has_many :publications, class_name: 'Packages::Debian::Publication', inverse_of: :distribution, foreign_key: :distribution_id has_many :packages, class_name: 'Packages::Package', through: :publications - - include Packages::Debian::Distribution + has_many :package_files, class_name: 'Packages::PackageFile', through: :packages end diff --git a/app/models/packages/go/module.rb b/app/models/packages/go/module.rb index b38b691ed6c..00d51c21881 100644 --- a/app/models/packages/go/module.rb +++ b/app/models/packages/go/module.rb @@ -18,8 +18,8 @@ module Packages end def version_by(ref: nil, commit: nil) - raise ArgumentError.new 'no filter specified' unless ref || commit - raise ArgumentError.new 'ref and commit are mutually exclusive' if ref && commit + raise ArgumentError, 'no filter specified' unless ref || commit + raise ArgumentError, 'ref and commit are mutually exclusive' if ref && commit if commit return version_by_sha(commit) if commit.is_a? String diff --git a/app/models/packages/go/module_version.rb b/app/models/packages/go/module_version.rb index fd575e6c96c..c442b2416f1 100644 --- a/app/models/packages/go/module_version.rb +++ b/app/models/packages/go/module_version.rb @@ -17,15 +17,15 @@ module Packages delegate :build, to: :@semver, allow_nil: true def initialize(mod, type, commit, name: nil, semver: nil, ref: nil) - raise ArgumentError.new("invalid type '#{type}'") unless VALID_TYPES.include? type - raise ArgumentError.new("mod is required") unless mod - raise ArgumentError.new("commit is required") unless commit + raise ArgumentError, "invalid type '#{type}'" unless VALID_TYPES.include? type + raise ArgumentError, "mod is required" unless mod + raise ArgumentError, "commit is required" unless commit if type == :ref - raise ArgumentError.new("ref is required") unless ref + raise ArgumentError, "ref is required" unless ref elsif type == :pseudo - raise ArgumentError.new("name is required") unless name - raise ArgumentError.new("semver is required") unless semver + raise ArgumentError, "name is required" unless name + raise ArgumentError, "semver is required" unless semver end @mod = mod diff --git a/app/models/packages/helm.rb b/app/models/packages/helm.rb new file mode 100644 index 00000000000..e021b997bf5 --- /dev/null +++ b/app/models/packages/helm.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Packages + module Helm + def self.table_name_prefix + 'packages_helm_' + end + end +end diff --git a/app/models/packages/helm/file_metadatum.rb b/app/models/packages/helm/file_metadatum.rb new file mode 100644 index 00000000000..1771003d1f9 --- /dev/null +++ b/app/models/packages/helm/file_metadatum.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Packages + module Helm + class FileMetadatum < ApplicationRecord + self.primary_key = :package_file_id + + belongs_to :package_file, inverse_of: :helm_file_metadatum + + validates :package_file, presence: true + validate :valid_helm_package_type + + validates :channel, + presence: true, + length: { maximum: 63 }, + format: { with: Gitlab::Regex.helm_channel_regex } + + validates :metadata, + json_schema: { filename: "helm_metadata" } + + private + + def valid_helm_package_type + return if package_file&.package&.helm? + + errors.add(:package_file, _('Package type must be Helm')) + end + end + end +end diff --git a/app/models/packages/package.rb b/app/models/packages/package.rb index e510432be8f..36edf646658 100644 --- a/app/models/packages/package.rb +++ b/app/models/packages/package.rb @@ -6,6 +6,7 @@ class Packages::Package < ApplicationRecord include Gitlab::Utils::StrongMemoize DISPLAYABLE_STATUSES = [:default, :error].freeze + INSTALLABLE_STATUSES = [:default].freeze belongs_to :project belongs_to :creator, class_name: 'User' @@ -47,8 +48,10 @@ class Packages::Package < ApplicationRecord validate :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? validates :name, format: { with: Gitlab::Regex.npm_package_name_regex }, if: :npm? validates :name, format: { with: Gitlab::Regex.nuget_package_name_regex }, if: :nuget? + validates :name, format: { with: Gitlab::Regex.terraform_module_package_name_regex }, if: :terraform_module? validates :name, format: { with: Gitlab::Regex.debian_package_name_regex }, if: :debian_package? validates :name, inclusion: { in: %w[incoming] }, if: :debian_incoming? validates :version, format: { with: Gitlab::Regex.nuget_version_regex }, if: :nuget? @@ -56,7 +59,8 @@ class Packages::Package < ApplicationRecord validates :version, format: { with: Gitlab::Regex.maven_version_regex }, if: -> { version? && maven? } validates :version, format: { with: Gitlab::Regex.pypi_version_regex }, if: :pypi? validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :golang? - validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? } + validates :version, format: { with: Gitlab::Regex.prefixed_semver_regex }, if: :helm? + validates :version, format: { with: Gitlab::Regex.semver_regex }, if: -> { composer_tag_version? || npm? || terraform_module? } validates :version, presence: true, @@ -70,10 +74,11 @@ class Packages::Package < ApplicationRecord enum package_type: { maven: 1, npm: 2, conan: 3, nuget: 4, pypi: 5, composer: 6, generic: 7, golang: 8, debian: 9, - rubygems: 10 } + rubygems: 10, helm: 11, terraform_module: 12 } enum status: { default: 0, hidden: 1, processing: 2, error: 3 } + scope :for_projects, ->(project_ids) { where(project_id: project_ids) } scope :with_name, ->(name) { where(name: name) } scope :with_name_like, ->(name) { where(arel_table[:name].matches(name)) } scope :with_normalized_pypi_name, ->(name) { where("LOWER(regexp_replace(name, '[-_.]+', '-', 'g')) = ?", name.downcase) } @@ -81,8 +86,10 @@ class Packages::Package < ApplicationRecord scope :with_version, ->(version) { where(version: version) } scope :without_version_like, -> (version) { where.not(arel_table[:version].matches(version)) } scope :with_package_type, ->(package_type) { where(package_type: package_type) } + scope :without_package_type, ->(package_type) { where.not(package_type: package_type) } scope :with_status, ->(status) { where(status: status) } scope :displayable, -> { with_status(DISPLAYABLE_STATUSES) } + scope :installable, -> { with_status(INSTALLABLE_STATUSES) } scope :including_build_info, -> { includes(pipelines: :user) } scope :including_project_route, -> { includes(project: { namespace: :route }) } scope :including_tags, -> { includes(:tags) } @@ -110,25 +117,20 @@ class Packages::Package < ApplicationRecord scope :without_nuget_temporary_name, -> { where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) } scope :has_version, -> { where.not(version: nil) } - scope :processed, -> do - where.not(package_type: :nuget).or( - where.not(name: Packages::Nuget::TEMPORARY_PACKAGE_NAME) - ) - end scope :preload_files, -> { preload(:package_files) } scope :last_of_each_version, -> { where(id: all.select('MAX(id) AS id').group(:version)) } scope :limit_recent, ->(limit) { order_created_desc.limit(limit) } scope :select_distinct_name, -> { select(:name).distinct } # Sorting - scope :order_created, -> { reorder('created_at ASC') } - scope :order_created_desc, -> { reorder('created_at DESC') } - scope :order_name, -> { reorder('name ASC') } - scope :order_name_desc, -> { reorder('name DESC') } - scope :order_version, -> { reorder('version ASC') } - scope :order_version_desc, -> { reorder('version DESC') } - scope :order_type, -> { reorder('package_type ASC') } - scope :order_type_desc, -> { reorder('package_type DESC') } + scope :order_created, -> { reorder(created_at: :asc) } + scope :order_created_desc, -> { reorder(created_at: :desc) } + scope :order_name, -> { reorder(name: :asc) } + scope :order_name_desc, -> { reorder(name: :desc) } + scope :order_version, -> { reorder(version: :asc) } + scope :order_version_desc, -> { reorder(version: :desc) } + scope :order_type, -> { reorder(package_type: :asc) } + scope :order_type_desc, -> { reorder(package_type: :desc) } scope :order_project_name, -> { joins(:project).reorder('projects.name ASC') } scope :order_project_name_desc, -> { joins(:project).reorder('projects.name DESC') } scope :order_project_path, -> { joins(:project).reorder('projects.path ASC, id ASC') } @@ -137,14 +139,6 @@ class Packages::Package < ApplicationRecord after_commit :update_composer_cache, on: :destroy, if: -> { composer? } - def self.for_projects(projects) - unless Feature.enabled?(:maven_packages_group_level_improvements, default_enabled: :yaml) - return none unless projects.any? - end - - where(project_id: projects) - end - def self.only_maven_packages_with_path(path, use_cte: false) if use_cte && Feature.enabled?(:maven_metadata_by_path_with_optimization_fence, default_enabled: :yaml) # This is an optimization fence which assumes that looking up the Metadatum record by path (globally) diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb index 23a7144e2bb..3d8641ca2fa 100644 --- a/app/models/packages/package_file.rb +++ b/app/models/packages/package_file.rb @@ -5,7 +5,8 @@ class Packages::PackageFile < ApplicationRecord delegate :project, :project_id, to: :package delegate :conan_file_type, to: :conan_file_metadatum - delegate :file_type, :architecture, :fields, to: :debian_file_metadatum, prefix: :debian + delegate :file_type, :component, :architecture, :fields, to: :debian_file_metadatum, prefix: :debian + delegate :channel, :metadata, to: :helm_file_metadatum, prefix: :helm belongs_to :package @@ -13,9 +14,11 @@ class Packages::PackageFile < ApplicationRecord has_many :package_file_build_infos, inverse_of: :package_file, class_name: 'Packages::PackageFileBuildInfo' has_many :pipelines, through: :package_file_build_infos has_one :debian_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Debian::FileMetadatum' + has_one :helm_file_metadatum, inverse_of: :package_file, class_name: 'Packages::Helm::FileMetadatum' accepts_nested_attributes_for :conan_file_metadatum accepts_nested_attributes_for :debian_file_metadatum + accepts_nested_attributes_for :helm_file_metadatum validates :package, presence: true validates :file, presence: true @@ -24,6 +27,7 @@ class Packages::PackageFile < ApplicationRecord validates :file_name, uniqueness: { scope: :package }, if: -> { package&.pypi? } scope :recent, -> { order(id: :desc) } + scope :for_package_ids, ->(ids) { where(package_id: ids) } scope :with_file_name, ->(file_name) { where(file_name: file_name) } 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) } @@ -41,7 +45,17 @@ class Packages::PackageFile < ApplicationRecord scope :with_debian_file_type, ->(file_type) do joins(:debian_file_metadatum) - .where(packages_debian_file_metadata: { debian_file_type: ::Packages::Debian::FileMetadatum.debian_file_types[file_type] }) + .where(packages_debian_file_metadata: { file_type: ::Packages::Debian::FileMetadatum.file_types[file_type] }) + end + + scope :with_debian_component_name, ->(component_name) do + joins(:debian_file_metadatum) + .where(packages_debian_file_metadata: { component: component_name }) + end + + scope :with_debian_architecture_name, ->(architecture_name) do + joins(:debian_file_metadatum) + .where(packages_debian_file_metadata: { architecture: architecture_name }) end scope :with_conan_package_reference, ->(conan_package_reference) do @@ -66,4 +80,4 @@ class Packages::PackageFile < ApplicationRecord end end -Packages::PackageFile.prepend_if_ee('EE::Packages::PackageFile') +Packages::PackageFile.prepend_mod_with('Packages::PackageFile') diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 3285a1f7f4c..17131cd736d 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -50,8 +50,6 @@ module Pages def zip_source return unless deployment&.file - return if deployment.file.file_storage? && !Feature.enabled?(:pages_serve_with_zip_file_protocol, project, default_enabled: :yaml) - global_id = ::Gitlab::GlobalId.build(deployment, id: deployment.id).to_s { @@ -64,17 +62,16 @@ module Pages } end + # TODO: remove support for legacy storage in 14.3 https://gitlab.com/gitlab-org/gitlab/-/issues/328712 + # we support this till 14.3 to allow people to still use legacy storage if something goes very wrong + # on self-hosted installations, and we'll need some time to fix it def legacy_source - raise LegacyStorageDisabledError unless Feature.enabled?(:pages_serve_from_legacy_storage, default_enabled: true) + return unless ::Settings.pages.local_store.enabled { type: 'file', path: File.join(project.full_path, 'public/') } - rescue LegacyStorageDisabledError => e - Gitlab::ErrorTracking.track_exception(e, project_id: project.id) - - nil end end end diff --git a/app/models/pages/virtual_domain.rb b/app/models/pages/virtual_domain.rb index 90cb8253b52..497f67993ae 100644 --- a/app/models/pages/virtual_domain.rb +++ b/app/models/pages/virtual_domain.rb @@ -21,9 +21,7 @@ module Pages project.pages_lookup_path(trim_prefix: trim_prefix, domain: domain) end - # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/297524 - # source can only be nil if pages_serve_from_legacy_storage FF is disabled - # we can remove this filtering once we remove legacy storage + # TODO: remove in https://gitlab.com/gitlab-org/gitlab/-/issues/328715 paths = paths.select(&:source) paths.sort_by(&:prefix).reverse diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index 4d60489e599..4668fc265a0 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -311,4 +311,4 @@ class PagesDomain < ApplicationRecord end end -PagesDomain.prepend_if_ee('::EE::PagesDomain') +PagesDomain.prepend_mod_with('PagesDomain') diff --git a/app/models/pages_domain_acme_order.rb b/app/models/pages_domain_acme_order.rb index 411456cc237..8427176fa72 100644 --- a/app/models/pages_domain_acme_order.rb +++ b/app/models/pages_domain_acme_order.rb @@ -14,7 +14,7 @@ class PagesDomainAcmeOrder < ApplicationRecord attr_encrypted :private_key, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm', encode: true diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index ad2f4525171..732ed0b7bb3 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -55,7 +55,7 @@ class PersonalAccessToken < ApplicationRecord begin Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token) - rescue => ex + rescue StandardError => ex logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{ex.class}" encrypted_token end @@ -110,4 +110,4 @@ class PersonalAccessToken < ApplicationRecord end end -PersonalAccessToken.prepend_if_ee('EE::PersonalAccessToken') +PersonalAccessToken.prepend_mod_with('PersonalAccessToken') diff --git a/app/models/plan.rb b/app/models/plan.rb index 6a7f32a5d5f..f3ef04315f8 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -39,4 +39,4 @@ class Plan < ApplicationRecord end end -Plan.prepend_if_ee('EE::Plan') +Plan.prepend_mod_with('Plan') diff --git a/app/models/pool_repository.rb b/app/models/pool_repository.rb index 94992adfd1e..78cddaa1302 100644 --- a/app/models/pool_repository.rb +++ b/app/models/pool_repository.rb @@ -115,4 +115,4 @@ class PoolRepository < ApplicationRecord end end -PoolRepository.prepend_if_ee('EE::PoolRepository') +PoolRepository.prepend_mod_with('PoolRepository') diff --git a/app/models/preloaders/labels_preloader.rb b/app/models/preloaders/labels_preloader.rb index 427f2869aac..bb3206f5399 100644 --- a/app/models/preloaders/labels_preloader.rb +++ b/app/models/preloaders/labels_preloader.rb @@ -31,4 +31,4 @@ module Preloaders end end -Preloaders::LabelsPreloader.prepend_if_ee('EE::Preloaders::LabelsPreloader') +Preloaders::LabelsPreloader.prepend_mod_with('Preloaders::LabelsPreloader') diff --git a/app/models/project.rb b/app/models/project.rb index f03e5293b58..9d572b7e2f8 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -19,6 +19,7 @@ class Project < ApplicationRecord include Presentable include HasRepository include HasWiki + include HasIntegrations include CanMoveRepositoryStorage include Routable include GroupDescendant @@ -33,7 +34,6 @@ class Project < ApplicationRecord include OptionallySearch include FromUnion include IgnorableColumns - include Integration include Repositories::CanHousekeepRepository include EachBatch include GitlabRoutingHelper @@ -104,16 +104,13 @@ class Project < ApplicationRecord after_save :create_import_state, if: ->(project) { project.import? && project.import_state.nil? } - after_create :create_project_feature, unless: :project_feature + after_create -> { create_or_load_association(:project_feature) } - after_create :create_ci_cd_settings, - unless: :ci_cd_settings + after_create -> { create_or_load_association(:ci_cd_settings) } - after_create :create_container_expiration_policy, - unless: :container_expiration_policy + after_create -> { create_or_load_association(:container_expiration_policy) } - after_create :create_pages_metadatum, - unless: :pages_metadatum + after_create -> { create_or_load_association(:pages_metadatum) } after_create :set_timestamps_for_create after_update :update_forks_visibility_level @@ -131,7 +128,41 @@ class Project < ApplicationRecord after_initialize :use_hashed_storage after_create :check_repository_absence! - acts_as_ordered_taggable + acts_as_ordered_taggable_on :topics + # The 'tag_list' alias and the 'has_many' associations are required during the 'tags -> topics' migration + # TODO: eliminate 'tag_list', 'topic_taggings' and 'tags' in the further process of the migration + # https://gitlab.com/gitlab-org/gitlab/-/issues/331081 + alias_attribute :tag_list, :topic_list + has_many :topic_taggings, -> { includes(:tag).order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, + as: :taggable, + class_name: 'ActsAsTaggableOn::Tagging', + after_add: :dirtify_tag_list, + after_remove: :dirtify_tag_list + has_many :topics, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, + class_name: 'ActsAsTaggableOn::Tag', + through: :topic_taggings, + source: :tag + has_many :tags, -> { order("#{ActsAsTaggableOn::Tagging.table_name}.id") }, + class_name: 'ActsAsTaggableOn::Tag', + through: :topic_taggings, + source: :tag + + # Overwriting 'topic_list' and 'topic_list=' is necessary to ensure functionality during the background migration [1]. + # [1] https://gitlab.com/gitlab-org/gitlab/-/merge_requests/61237 + # TODO: remove 'topic_list' and 'topic_list=' once the background migration is complete + # https://gitlab.com/gitlab-org/gitlab/-/issues/331081 + def topic_list + # Return both old topics (context 'tags') and new topics (context 'topics') + tag_list_on('tags') + tag_list_on('topics') + end + + def topic_list=(new_tags) + # Old topics with context 'tags' are added as new topics with context 'topics' + super(new_tags) + + # Remove old topics with context 'tags' + set_tag_list_on('tags', '') + end attr_accessor :old_path_with_namespace attr_accessor :template_name @@ -151,26 +182,26 @@ class Project < ApplicationRecord has_one :last_event, -> {order 'events.created_at DESC'}, class_name: 'Event' has_many :boards - # Project services - has_one :campfire_service - has_one :datadog_service + # Project integrations + has_one :asana_service, class_name: 'Integrations::Asana' + has_one :assembla_service, class_name: 'Integrations::Assembla' + has_one :bamboo_service, class_name: 'Integrations::Bamboo' + has_one :campfire_service, class_name: 'Integrations::Campfire' + has_one :confluence_service, class_name: 'Integrations::Confluence' + has_one :datadog_service, class_name: 'Integrations::Datadog' + has_one :emails_on_push_service, class_name: 'Integrations::EmailsOnPush' has_one :discord_service has_one :drone_ci_service - has_one :emails_on_push_service has_one :ewm_service has_one :pipelines_email_service has_one :irker_service has_one :pivotaltracker_service - has_one :hipchat_service has_one :flowdock_service - has_one :assembla_service - has_one :asana_service has_one :mattermost_slash_commands_service has_one :mattermost_service has_one :slack_slash_commands_service has_one :slack_service has_one :buildkite_service - has_one :bamboo_service has_one :teamcity_service has_one :pushover_service has_one :jenkins_service @@ -179,7 +210,6 @@ class Project < ApplicationRecord has_one :youtrack_service has_one :custom_issue_tracker_service has_one :bugzilla_service - has_one :confluence_service has_one :external_wiki_service has_one :prometheus_service, inverse_of: :project has_one :mock_ci_service @@ -227,7 +257,7 @@ class Project < ApplicationRecord has_many :source_of_merge_requests, foreign_key: 'source_project_id', class_name: 'MergeRequest' has_many :issues has_many :labels, class_name: 'ProjectLabel' - has_many :services + has_many :integrations has_many :events has_many :milestones has_many :iterations @@ -338,7 +368,8 @@ class Project < ApplicationRecord has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting', inverse_of: :project, autosave: true, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent has_many :remote_mirrors, inverse_of: :project - has_many :cycle_analytics_stages, class_name: 'Analytics::CycleAnalytics::ProjectStage' + has_many :cycle_analytics_stages, class_name: 'Analytics::CycleAnalytics::ProjectStage', inverse_of: :project + has_many :value_streams, class_name: 'Analytics::CycleAnalytics::ProjectValueStream', inverse_of: :project has_many :external_pull_requests, inverse_of: :project @@ -371,6 +402,8 @@ class Project < ApplicationRecord has_one :operations_feature_flags_client, class_name: 'Operations::FeatureFlagsClient' has_many :operations_feature_flags_user_lists, class_name: 'Operations::FeatureFlags::UserList' + has_many :timelogs + accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :project_setting, update_only: true @@ -528,7 +561,7 @@ class Project < ApplicationRecord 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_services, -> { joins(:services).merge(::JiraService.active) } # rubocop:disable CodeReuse/ServiceClass + scope :with_active_jira_services, -> { joins(:integrations).merge(::JiraService.active) } # rubocop:disable CodeReuse/ServiceClass 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) } @@ -619,7 +652,7 @@ class Project < ApplicationRecord mount_uploader :bfg_object_map, AttachmentUploader def self.with_api_entity_associations - preload(:project_feature, :route, :tags, :group, namespace: [:route, :owner]) + preload(:project_feature, :route, :tags, :group, :timelogs, namespace: [:route, :owner]) end def self.with_web_entity_associations @@ -832,6 +865,10 @@ class Project < ApplicationRecord super end + def parent_loaded? + association(:namespace).loaded? + end + def project_setting super.presence || build_project_setting end @@ -1005,7 +1042,7 @@ class Project < ApplicationRecord end def latest_successful_build_for_ref!(job_name, ref = default_branch) - latest_successful_build_for_ref(job_name, ref) || raise(ActiveRecord::RecordNotFound.new("Couldn't find job #{job_name}")) + latest_successful_build_for_ref(job_name, ref) || raise(ActiveRecord::RecordNotFound, "Couldn't find job #{job_name}") end def latest_pipeline(ref = default_branch, sha = nil) @@ -1098,7 +1135,7 @@ class Project < ApplicationRecord else super end - rescue + rescue StandardError super end @@ -1342,7 +1379,7 @@ class Project < ApplicationRecord return unless has_external_issue_tracker? - @external_issue_tracker ||= services.external_issue_trackers.first + @external_issue_tracker ||= integrations.external_issue_trackers.first end def external_references_supported? @@ -1358,11 +1395,11 @@ class Project < ApplicationRecord return unless has_external_wiki? - @external_wiki ||= services.external_wikis.first + @external_wiki ||= integrations.external_wikis.first end def find_or_initialize_services - available_services_names = Service.available_services_names - disabled_services + available_services_names = Integration.available_services_names - disabled_services available_services_names.map do |service_name| find_or_initialize_service(service_name) @@ -1378,7 +1415,7 @@ class Project < ApplicationRecord def find_or_initialize_service(name) return if disabled_services.include?(name) - find_service(services, name) || build_from_instance_or_template(name) || build_service(name) + find_service(integrations, name) || build_from_instance_or_template(name) || build_service(name) end # rubocop: disable CodeReuse/ServiceClass @@ -1391,7 +1428,7 @@ class Project < ApplicationRecord # rubocop: enable CodeReuse/ServiceClass def ci_services - services.where(category: :ci) + integrations.where(category: :ci) end def ci_service @@ -1399,7 +1436,7 @@ class Project < ApplicationRecord end def monitoring_services - services.where(category: :monitoring) + integrations.where(category: :monitoring) end def monitoring_service @@ -1477,8 +1514,8 @@ class Project < ApplicationRecord def execute_services(data, hooks_scope = :push_hooks) # Call only service hooks that are active for this scope run_after_commit_or_now do - services.public_send(hooks_scope).each do |service| # rubocop:disable GitlabSecurity/PublicSend - service.async_execute(data) + integrations.public_send(hooks_scope).each do |integration| # rubocop:disable GitlabSecurity/PublicSend + integration.async_execute(data) end end end @@ -1488,7 +1525,7 @@ class Project < ApplicationRecord end def has_active_services?(hooks_scope = :push_hooks) - services.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend + integrations.public_send(hooks_scope).any? # rubocop:disable GitlabSecurity/PublicSend end def feature_usage @@ -1560,7 +1597,7 @@ class Project < ApplicationRecord repository.after_create true - rescue => err + rescue StandardError => err Gitlab::ErrorTracking.track_exception(err, project: { id: id, full_path: full_path, disk_path: disk_path }) errors.add(:base, _('Failed to create repository')) false @@ -2417,7 +2454,7 @@ class Project < ApplicationRecord end def access_request_approvers_to_be_notified - members.maintainers.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) + members.maintainers.connected_to_user.order_recent_sign_in.limit(ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) end def pages_lookup_path(trim_prefix: nil, domain: nil) @@ -2529,12 +2566,14 @@ class Project < ApplicationRecord namespace.root_ancestor.all_projects .joins(:packages) .where.not(id: id) - .merge(Packages::Package.with_name(package_name)) + .merge(Packages::Package.default_scoped.with_name(package_name)) .exists? end - def default_branch_or_master - default_branch || 'master' + def default_branch_or_main + return default_branch if default_branch + + Gitlab::DefaultBranch.value(object: self) end def ci_config_path_or_default @@ -2569,6 +2608,16 @@ class Project < ApplicationRecord Feature.enabled?(:inherited_issuable_templates, self, default_enabled: :yaml) end + def activity_path + Gitlab::Routing.url_helpers.activity_project_path(self) + end + + def increment_statistic_value(statistic, delta) + return if pending_delete? + + ProjectStatistics.increment_statistic(self, statistic, delta) + end + private def set_container_registry_access_level @@ -2591,22 +2640,22 @@ class Project < ApplicationRecord def build_from_instance_or_template(name) instance = find_service(services_instances, name) - return Service.build_from_integration(instance, project_id: id) if instance + return Integration.build_from_integration(instance, project_id: id) if instance template = find_service(services_templates, name) - return Service.build_from_integration(template, project_id: id) if template + return Integration.build_from_integration(template, project_id: id) if template end def build_service(name) - "#{name}_service".classify.constantize.new(project_id: id) + Integration.service_name_to_model(name).new(project_id: id) end def services_templates - @services_templates ||= Service.for_template + @services_templates ||= Integration.for_template end def services_instances - @services_instances ||= Service.for_instance + @services_instances ||= Integration.for_instance end def closest_namespace_setting(name) @@ -2664,7 +2713,7 @@ class Project < ApplicationRecord def cross_namespace_reference?(from) case from when Project - namespace != from.namespace + namespace_id != from.namespace_id when Namespace namespace != from when User @@ -2743,11 +2792,11 @@ class Project < ApplicationRecord end def cache_has_external_wiki - update_column(:has_external_wiki, services.external_wikis.any?) if Gitlab::Database.read_write? + update_column(:has_external_wiki, integrations.external_wikis.any?) if Gitlab::Database.read_write? end def cache_has_external_issue_tracker - update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) if Gitlab::Database.read_write? + update_column(:has_external_issue_tracker, integrations.external_issue_trackers.any?) if Gitlab::Database.read_write? end def active_runners_with_tags @@ -2759,4 +2808,4 @@ class Project < ApplicationRecord end end -Project.prepend_if_ee('EE::Project') +Project.prepend_mod_with('Project') diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb index 2c3f70654f8..1fed166e4d0 100644 --- a/app/models/project_authorization.rb +++ b/app/models/project_authorization.rb @@ -31,4 +31,4 @@ class ProjectAuthorization < ApplicationRecord end end -ProjectAuthorization.prepend_if_ee('::EE::ProjectAuthorization') +ProjectAuthorization.prepend_mod_with('ProjectAuthorization') diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index 31be0759cd0..c0c2ea42d46 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -33,4 +33,4 @@ class ProjectCiCdSetting < ApplicationRecord end end -ProjectCiCdSetting.prepend_if_ee('EE::ProjectCiCdSetting') +ProjectCiCdSetting.prepend_mod_with('ProjectCiCdSetting') diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb index 15f6bedfc2e..eb4ad327438 100644 --- a/app/models/project_feature.rb +++ b/app/models/project_feature.rb @@ -145,4 +145,4 @@ class ProjectFeature < ApplicationRecord end end -ProjectFeature.prepend_if_ee('EE::ProjectFeature') +ProjectFeature.prepend_mod_with('ProjectFeature') diff --git a/app/models/project_feature_usage.rb b/app/models/project_feature_usage.rb index 02051310af7..d993db860c3 100644 --- a/app/models/project_feature_usage.rb +++ b/app/models/project_feature_usage.rb @@ -45,4 +45,4 @@ class ProjectFeatureUsage < ApplicationRecord end end -ProjectFeatureUsage.prepend_if_ee('EE::ProjectFeatureUsage') +ProjectFeatureUsage.prepend_mod_with('ProjectFeatureUsage') diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb index f065246e8af..d704f4c2c87 100644 --- a/app/models/project_group_link.rb +++ b/app/models/project_group_link.rb @@ -2,6 +2,7 @@ class ProjectGroupLink < ApplicationRecord include Expirable + include EachBatch belongs_to :project belongs_to :group @@ -49,4 +50,4 @@ class ProjectGroupLink < ApplicationRecord end end -ProjectGroupLink.prepend_if_ee('EE::ProjectGroupLink') +ProjectGroupLink.prepend_mod_with('ProjectGroupLink') diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb index 87ac6d38787..d374ee120d1 100644 --- a/app/models/project_import_data.rb +++ b/app/models/project_import_data.rb @@ -3,7 +3,7 @@ require 'carrierwave/orm/activerecord' class ProjectImportData < ApplicationRecord - prepend_if_ee('::EE::ProjectImportData') # rubocop: disable Cop/InjectEnterpriseEditionModule + prepend_mod_with('ProjectImportData') # rubocop: disable Cop/InjectEnterpriseEditionModule belongs_to :project, inverse_of: :import_data attr_encrypted :credentials, diff --git a/app/models/project_import_state.rb b/app/models/project_import_state.rb index 4bd3ffbea2f..633e669b5fc 100644 --- a/app/models/project_import_state.rb +++ b/app/models/project_import_state.rb @@ -105,4 +105,4 @@ class ProjectImportState < ApplicationRecord end end -ProjectImportState.prepend_if_ee('EE::ProjectImportState') +ProjectImportState.prepend_mod_with('ProjectImportState') diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb deleted file mode 100644 index f31bf931a41..00000000000 --- a/app/models/project_services/asana_service.rb +++ /dev/null @@ -1,107 +0,0 @@ -# frozen_string_literal: true - -require 'asana' - -class AsanaService < Service - include ActionView::Helpers::UrlHelper - - prop_accessor :api_key, :restrict_to_branch - validates :api_key, presence: true, if: :activated? - - def title - 'Asana' - end - - def description - s_('AsanaService|Add commit messages as comments to Asana tasks') - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer' - s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'asana' - end - - def fields - [ - { - type: 'text', - name: 'api_key', - title: 'API key', - help: s_('AsanaService|User Personal Access Token. User must have access to the task. All comments are attributed to this user.'), - # Example Personal Access Token from Asana docs - placeholder: '0/68a9e79b868c6789e79a124c30b0', - required: true - }, - { - type: 'text', - name: 'restrict_to_branch', - title: 'Restrict to branch (optional)', - help: s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') - } - ] - end - - def self.supported_events - %w(push) - end - - def client - @_client ||= begin - Asana::Client.new do |c| - c.authentication :access_token, api_key - end - end - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - # check the branch restriction is poplulated and branch is not included - branch = Gitlab::Git.ref_name(data[:ref]) - branch_restriction = restrict_to_branch.to_s - if branch_restriction.present? && branch_restriction.index(branch).nil? - return - end - - user = data[:user_name] - project_name = project.full_name - - data[:commits].each do |commit| - push_msg = s_("AsanaService|%{user} pushed to branch %{branch} of %{project_name} ( %{commit_url} ):") % { user: user, branch: branch, project_name: project_name, commit_url: commit[:url] } - check_commit(commit[:message], push_msg) - end - end - - def check_commit(message, push_msg) - # matches either: - # - #1234 - # - https://app.asana.com/0/{project_gid}/{task_gid} - # optionally preceded with: - # - fix/ed/es/ing - # - close/s/d - # - closing - issue_finder = %r{(fix\w*|clos[ei]\w*+)?\W*(?:https://app\.asana\.com/\d+/\w+/(\w+)|#(\w+))}i - - message.scan(issue_finder).each do |tuple| - # tuple will be - # [ 'fix', 'id_from_url', 'id_from_pound' ] - taskid = tuple[2] || tuple[1] - - begin - task = Asana::Resources::Task.find_by_id(client, taskid) - task.add_comment(text: "#{push_msg} #{message}") - - if tuple[0] - task.update(completed: true) - end - rescue => e - log_error(e.message) - next - end - end - end -end diff --git a/app/models/project_services/assembla_service.rb b/app/models/project_services/assembla_service.rb deleted file mode 100644 index 8845fb99605..00000000000 --- a/app/models/project_services/assembla_service.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -class AssemblaService < Service - prop_accessor :token, :subdomain - validates :token, presence: true, if: :activated? - - def title - 'Assembla' - end - - def description - _('Manage projects.') - end - - def self.to_param - 'assembla' - end - - def fields - [ - { type: 'text', name: 'token', placeholder: '', required: true }, - { type: 'text', name: 'subdomain', placeholder: '' } - ] - end - - def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - url = "https://atlas.assembla.com/spaces/#{subdomain}/github_tool?secret_key=#{token}" - Gitlab::HTTP.post(url, body: { payload: data }.to_json, headers: { 'Content-Type' => 'application/json' }) - end -end diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb deleted file mode 100644 index a892d1a4314..00000000000 --- a/app/models/project_services/bamboo_service.rb +++ /dev/null @@ -1,181 +0,0 @@ -# frozen_string_literal: true - -class BambooService < CiService - include ActionView::Helpers::UrlHelper - include ReactiveService - - prop_accessor :bamboo_url, :build_key, :username, :password - - validates :bamboo_url, presence: true, public_url: true, if: :activated? - validates :build_key, presence: true, if: :activated? - validates :username, - presence: true, - if: ->(service) { service.activated? && service.password } - validates :password, - presence: true, - if: ->(service) { service.activated? && service.username } - - attr_accessor :response - - after_save :compose_service_hook, if: :activated? - before_update :reset_password - - def compose_service_hook - hook = service_hook || build_service_hook - hook.save - end - - def reset_password - if bamboo_url_changed? && !password_touched? - self.password = nil - end - end - - def title - s_('BambooService|Atlassian Bamboo') - end - - def description - s_('BambooService|Use the Atlassian Bamboo CI/CD server with GitLab.') - end - - def help - docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), target: '_blank', rel: 'noopener noreferrer' - s_('BambooService|Use Atlassian Bamboo to run CI/CD pipelines. You must set up automatic revision labeling and a repository trigger in Bamboo. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } - end - - def self.to_param - 'bamboo' - end - - def fields - [ - { - type: 'text', - name: 'bamboo_url', - title: s_('BambooService|Bamboo URL'), - placeholder: s_('https://bamboo.example.com'), - help: s_('BambooService|Bamboo service root URL.'), - required: true - }, - { - type: 'text', - name: 'build_key', - placeholder: s_('KEY'), - help: s_('BambooService|Bamboo build plan key.'), - required: true - }, - { - type: 'text', - name: 'username', - help: s_('BambooService|The user with API access to the Bamboo server.') - }, - { - type: 'password', - name: 'password', - non_empty_password_title: s_('ProjectService|Enter new password'), - non_empty_password_help: s_('ProjectService|Leave blank to use your current password') - } - ] - end - - def build_page(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:build_page] } - end - - def commit_status(sha, ref) - with_reactive_cache(sha, ref) {|cached| cached[:commit_status] } - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - get_path("updateAndBuild.action", { buildKey: build_key }) - end - - def calculate_reactive_cache(sha, ref) - response = try_get_path("rest/api/latest/result/byChangeset/#{sha}") - - { build_page: read_build_page(response), commit_status: read_commit_status(response) } - end - - private - - def get_build_result(response) - return if response&.code != 200 - - # May be nil if no result, a single result hash, or an array if multiple results for a given changeset. - result = response.dig('results', 'results', 'result') - - # In case of multiple results, arbitrarily assume the last one is the most relevant. - return result.last if result.is_a?(Array) - - result - end - - def read_build_page(response) - result = get_build_result(response) - key = - if result.blank? - # If actual build link can't be determined, send user to build summary page. - build_key - else - # If actual build link is available, go to build result page. - result.dig('planResultKey', 'key') - end - - build_url("browse/#{key}") - end - - def read_commit_status(response) - return :error unless response && (response.code == 200 || response.code == 404) - - result = get_build_result(response) - status = - if result.blank? - 'Pending' - else - result.dig('buildState') - end - - return :error unless status.present? - - if status.include?('Success') - 'success' - elsif status.include?('Failed') - 'failed' - elsif status.include?('Pending') - 'pending' - else - :error - end - end - - def try_get_path(path, query_params = {}) - params = build_get_params(query_params) - params[:extra_log_info] = { project_id: project_id } - - Gitlab::HTTP.try_get(build_url(path), params) - end - - def get_path(path, query_params = {}) - Gitlab::HTTP.get(build_url(path), build_get_params(query_params)) - end - - def build_url(path) - Gitlab::Utils.append_path(bamboo_url, path) - end - - def build_get_params(query_params) - params = { verify: false, query: query_params } - return params if username.blank? && password.blank? - - query_params[:os_authType] = 'basic' - params[:basic_auth] = basic_auth - params - end - - def basic_auth - { username: username, password: password } - end -end diff --git a/app/models/project_services/bugzilla_service.rb b/app/models/project_services/bugzilla_service.rb index 4332db3e961..d1c56d2a4d5 100644 --- a/app/models/project_services/bugzilla_service.rb +++ b/app/models/project_services/bugzilla_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class BugzillaService < IssueTrackerService + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? def title @@ -8,7 +10,12 @@ class BugzillaService < IssueTrackerService end def description - s_('IssueTracker|Bugzilla issue tracker') + s_("IssueTracker|Use Bugzilla as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use Bugzilla as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end def self.to_param diff --git a/app/models/project_services/buildkite_service.rb b/app/models/project_services/buildkite_service.rb index 53bb7b47b41..f2ea5066e37 100644 --- a/app/models/project_services/buildkite_service.rb +++ b/app/models/project_services/buildkite_service.rb @@ -68,7 +68,7 @@ class BuildkiteService < CiService end def description - 'Buildkite is a platform for running fast, secure, and scalable continuous integration pipelines on your own infrastructure' + 'Run CI/CD pipelines with Buildkite.' end def self.to_param diff --git a/app/models/project_services/builds_email_service.rb b/app/models/project_services/builds_email_service.rb deleted file mode 100644 index f2295a95b60..00000000000 --- a/app/models/project_services/builds_email_service.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -# This class is to be removed with 9.1 -# We should also by then remove BuildsEmailService from database -class BuildsEmailService < Service - def self.to_param - 'builds_email' - end - - def self.supported_events - %w[] - end -end diff --git a/app/models/project_services/campfire_service.rb b/app/models/project_services/campfire_service.rb deleted file mode 100644 index ad26e42a21b..00000000000 --- a/app/models/project_services/campfire_service.rb +++ /dev/null @@ -1,102 +0,0 @@ -# frozen_string_literal: true - -class CampfireService < Service - prop_accessor :token, :subdomain, :room - validates :token, presence: true, if: :activated? - - def title - 'Campfire' - end - - def description - 'Simple web-based real-time group chat' - end - - def self.to_param - 'campfire' - end - - def fields - [ - { type: 'text', name: 'token', placeholder: '', required: true }, - { type: 'text', name: 'subdomain', placeholder: '' }, - { type: 'text', name: 'room', placeholder: '' } - ] - end - - def self.supported_events - %w(push) - end - - def execute(data) - return unless supported_events.include?(data[:object_kind]) - - message = build_message(data) - speak(self.room, message, auth) - end - - private - - def base_uri - @base_uri ||= "https://#{subdomain}.campfirenow.com" - end - - def auth - # use a dummy password, as explained in the Campfire API doc: - # https://github.com/basecamp/campfire-api#authentication - @auth ||= { - basic_auth: { - username: token, - password: 'X' - } - } - end - - # Post a message into a room, returns the message Hash in case of success. - # Returns nil otherwise. - # https://github.com/basecamp/campfire-api/blob/master/sections/messages.md#create-message - def speak(room_name, message, auth) - room = rooms(auth).find { |r| r["name"] == room_name } - return unless room - - path = "/room/#{room["id"]}/speak.json" - body = { - body: { - message: { - type: 'TextMessage', - body: message - } - } - } - res = Gitlab::HTTP.post(path, base_uri: base_uri, **auth.merge(body)) - res.code == 201 ? res : nil - end - - # Returns a list of rooms, or []. - # https://github.com/basecamp/campfire-api/blob/master/sections/rooms.md#get-rooms - def rooms(auth) - res = Gitlab::HTTP.get("/rooms.json", base_uri: base_uri, **auth) - res.code == 200 ? res["rooms"] : [] - end - - def build_message(push) - ref = Gitlab::Git.ref_name(push[:ref]) - before = push[:before] - after = push[:after] - - message = [] - message << "[#{project.full_name}] " - message << "#{push[:user_name]} " - - if Gitlab::Git.blank_ref?(before) - message << "pushed new branch #{ref} \n" - elsif Gitlab::Git.blank_ref?(after) - message << "removed branch #{ref} \n" - else - message << "pushed #{push[:total_commits_count]} commits to #{ref}. " - message << "#{project.web_url}/compare/#{before}...#{after}" - end - - message.join - end -end diff --git a/app/models/project_services/chat_message/alert_message.rb b/app/models/project_services/chat_message/alert_message.rb deleted file mode 100644 index c8913775843..00000000000 --- a/app/models/project_services/chat_message/alert_message.rb +++ /dev/null @@ -1,74 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class AlertMessage < BaseMessage - attr_reader :title - attr_reader :alert_url - attr_reader :severity - attr_reader :events - attr_reader :status - attr_reader :started_at - - def initialize(params) - @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) - @project_url = params.dig(:project, :web_url) || params[:project_url] - @title = params.dig(:object_attributes, :title) - @alert_url = params.dig(:object_attributes, :url) - @severity = params.dig(:object_attributes, :severity) - @events = params.dig(:object_attributes, :events) - @status = params.dig(:object_attributes, :status) - @started_at = params.dig(:object_attributes, :started_at) - end - - def attachments - [{ - title: title, - title_link: alert_url, - color: attachment_color, - fields: attachment_fields - }] - end - - def message - "Alert firing in #{project_name}" - end - - private - - def attachment_color - "#C95823" - end - - def attachment_fields - [ - { - title: "Severity", - value: severity.to_s.humanize, - short: true - }, - { - title: "Events", - value: events, - short: true - }, - { - title: "Status", - value: status.to_s.humanize, - short: true - }, - { - title: "Start time", - value: format_time(started_at), - short: true - } - ] - end - - # This formats time into the following format - # April 23rd, 2020 1:06AM UTC - def format_time(time) - time = Time.zone.parse(time.to_s) - time.strftime("%B #{time.day.ordinalize}, %Y %l:%M%p %Z") - end - end -end diff --git a/app/models/project_services/chat_message/base_message.rb b/app/models/project_services/chat_message/base_message.rb deleted file mode 100644 index bdd77a919e3..00000000000 --- a/app/models/project_services/chat_message/base_message.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class BaseMessage - RELATIVE_LINK_REGEX = /!\[[^\]]*\]\((\/uploads\/[^\)]*)\)/.freeze - - attr_reader :markdown - attr_reader :user_full_name - attr_reader :user_name - attr_reader :user_avatar - attr_reader :project_name - attr_reader :project_url - - def initialize(params) - @markdown = params[:markdown] || false - @project_name = params[:project_name] || params.dig(:project, :path_with_namespace) - @project_url = params.dig(:project, :web_url) || params[:project_url] - @user_full_name = params.dig(:user, :name) || params[:user_full_name] - @user_name = params.dig(:user, :username) || params[:user_name] - @user_avatar = params.dig(:user, :avatar_url) || params[:user_avatar] - end - - def user_combined_name - if user_full_name.present? - "#{user_full_name} (#{user_name})" - else - user_name - end - end - - def summary - return message if markdown - - format(message) - end - - def pretext - summary - end - - def fallback - format(message) - end - - def attachments - raise NotImplementedError - end - - def activity - raise NotImplementedError - end - - private - - def message - raise NotImplementedError - end - - def format(string) - Slack::Messenger::Util::LinkFormatter.format(format_relative_links(string)) - end - - def format_relative_links(string) - string.gsub(RELATIVE_LINK_REGEX, "#{project_url}\\1") - end - - def attachment_color - '#345' - end - - def link(text, url) - "[#{text}](#{url})" - end - - def pretty_duration(seconds) - parse_string = - if duration < 1.hour - '%M:%S' - else - '%H:%M:%S' - end - - Time.at(seconds).utc.strftime(parse_string) - end - end -end diff --git a/app/models/project_services/chat_message/deployment_message.rb b/app/models/project_services/chat_message/deployment_message.rb deleted file mode 100644 index 5deb757e60f..00000000000 --- a/app/models/project_services/chat_message/deployment_message.rb +++ /dev/null @@ -1,85 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class DeploymentMessage < BaseMessage - attr_reader :commit_title - attr_reader :commit_url - attr_reader :deployable_id - attr_reader :deployable_url - attr_reader :environment - attr_reader :short_sha - attr_reader :status - attr_reader :user_url - - def initialize(data) - super - - @commit_title = data[:commit_title] - @commit_url = data[:commit_url] - @deployable_id = data[:deployable_id] - @deployable_url = data[:deployable_url] - @environment = data[:environment] - @short_sha = data[:short_sha] - @status = data[:status] - @user_url = data[:user_url] - end - - def attachments - [{ - text: "#{project_link} with job #{deployment_link} by #{user_link}\n#{commit_link}: #{commit_title}", - color: color - }] - end - - def activity - {} - end - - private - - def message - if running? - "Starting deploy to #{environment}" - else - "Deploy to #{environment} #{humanized_status}" - end - end - - def color - case status - when 'success' - 'good' - when 'canceled' - 'warning' - when 'failed' - 'danger' - else - '#334455' - end - end - - def project_link - link(project_name, project_url) - end - - def deployment_link - link("##{deployable_id}", deployable_url) - end - - def user_link - link(user_combined_name, user_url) - end - - def commit_link - link(short_sha, commit_url) - end - - def humanized_status - status == 'success' ? 'succeeded' : status - end - - def running? - status == 'running' - end - end -end diff --git a/app/models/project_services/chat_message/issue_message.rb b/app/models/project_services/chat_message/issue_message.rb deleted file mode 100644 index c8e90b66bae..00000000000 --- a/app/models/project_services/chat_message/issue_message.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class IssueMessage < BaseMessage - attr_reader :title - attr_reader :issue_iid - attr_reader :issue_url - attr_reader :action - attr_reader :state - attr_reader :description - - def initialize(params) - super - - obj_attr = params[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - @title = obj_attr[:title] - @issue_iid = obj_attr[:iid] - @issue_url = obj_attr[:url] - @action = obj_attr[:action] - @state = obj_attr[:state] - @description = obj_attr[:description] || '' - end - - def attachments - return [] unless opened_issue? - return description if markdown - - description_message - end - - def activity - { - title: "Issue #{state} by #{user_combined_name}", - subtitle: "in #{project_link}", - text: issue_link, - image: user_avatar - } - end - - private - - def message - "[#{project_link}] Issue #{issue_link} #{state} by #{user_combined_name}" - end - - def opened_issue? - action == 'open' - end - - def description_message - [{ - title: issue_title, - title_link: issue_url, - text: format(description), - color: '#C95823' - }] - end - - def project_link - link(project_name, project_url) - end - - def issue_link - link(issue_title, issue_url) - end - - def issue_title - "#{Issue.reference_prefix}#{issue_iid} #{title}" - end - end -end diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb deleted file mode 100644 index e45bb9b8ce1..00000000000 --- a/app/models/project_services/chat_message/merge_message.rb +++ /dev/null @@ -1,81 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class MergeMessage < BaseMessage - attr_reader :merge_request_iid - attr_reader :source_branch - attr_reader :target_branch - attr_reader :action - attr_reader :state - attr_reader :title - - def initialize(params) - super - - obj_attr = params[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - @merge_request_iid = obj_attr[:iid] - @source_branch = obj_attr[:source_branch] - @target_branch = obj_attr[:target_branch] - @action = obj_attr[:action] - @state = obj_attr[:state] - @title = format_title(obj_attr[:title]) - end - - def attachments - [] - end - - def activity - { - title: "Merge request #{state_or_action_text} by #{user_combined_name}", - subtitle: "in #{project_link}", - text: merge_request_link, - image: user_avatar - } - end - - private - - def format_title(title) - '*' + title.lines.first.chomp + '*' - end - - def message - merge_request_message - end - - def project_link - link(project_name, project_url) - end - - def merge_request_message - "#{user_combined_name} #{state_or_action_text} merge request #{merge_request_link} in #{project_link}" - end - - def merge_request_link - link(merge_request_title, merge_request_url) - end - - def merge_request_title - "#{MergeRequest.reference_prefix}#{merge_request_iid} #{title}" - end - - def merge_request_url - "#{project_url}/-/merge_requests/#{merge_request_iid}" - end - - def state_or_action_text - case action - when 'approved', 'unapproved' - action - when 'approval' - 'added their approval to' - when 'unapproval' - 'removed their approval from' - else - state - end - end - end -end diff --git a/app/models/project_services/chat_message/note_message.rb b/app/models/project_services/chat_message/note_message.rb deleted file mode 100644 index 741474fb27b..00000000000 --- a/app/models/project_services/chat_message/note_message.rb +++ /dev/null @@ -1,84 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class NoteMessage < BaseMessage - attr_reader :note - attr_reader :note_url - attr_reader :title - attr_reader :target - - def initialize(params) - super - - params = HashWithIndifferentAccess.new(params) - obj_attr = params[:object_attributes] - @note = obj_attr[:note] - @note_url = obj_attr[:url] - @target, @title = case obj_attr[:noteable_type] - when "Commit" - create_commit_note(params[:commit]) - when "Issue" - create_issue_note(params[:issue]) - when "MergeRequest" - create_merge_note(params[:merge_request]) - when "Snippet" - create_snippet_note(params[:snippet]) - end - end - - def attachments - return note if markdown - - description_message - end - - def activity - { - title: "#{user_combined_name} #{link('commented on ' + target, note_url)}", - subtitle: "in #{project_link}", - text: formatted_title, - image: user_avatar - } - end - - private - - def message - "#{user_combined_name} #{link('commented on ' + target, note_url)} in #{project_link}: *#{formatted_title}*" - end - - def format_title(title) - title.lines.first.chomp - end - - def formatted_title - format_title(title) - end - - def create_issue_note(issue) - ["issue #{Issue.reference_prefix}#{issue[:iid]}", issue[:title]] - end - - def create_commit_note(commit) - commit_sha = Commit.truncate_sha(commit[:id]) - - ["commit #{commit_sha}", commit[:message]] - end - - def create_merge_note(merge_request) - ["merge request #{MergeRequest.reference_prefix}#{merge_request[:iid]}", merge_request[:title]] - end - - def create_snippet_note(snippet) - ["snippet #{Snippet.reference_prefix}#{snippet[:id]}", snippet[:title]] - end - - def description_message - [{ text: format(note), color: attachment_color }] - end - - def project_link - link(project_name, project_url) - end - end -end diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb deleted file mode 100644 index f4c6938fa78..00000000000 --- a/app/models/project_services/chat_message/pipeline_message.rb +++ /dev/null @@ -1,265 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class PipelineMessage < BaseMessage - MAX_VISIBLE_JOBS = 10 - - attr_reader :user - attr_reader :ref_type - attr_reader :ref - attr_reader :status - attr_reader :detailed_status - attr_reader :duration - attr_reader :finished_at - attr_reader :pipeline_id - attr_reader :failed_stages - attr_reader :failed_jobs - - attr_reader :project - attr_reader :commit - attr_reader :committer - attr_reader :pipeline - - def initialize(data) - super - - @user = data[:user] - @user_name = data.dig(:user, :username) || 'API' - - pipeline_attributes = data[:object_attributes] - @ref_type = pipeline_attributes[:tag] ? 'tag' : 'branch' - @ref = pipeline_attributes[:ref] - @status = pipeline_attributes[:status] - @detailed_status = pipeline_attributes[:detailed_status] - @duration = pipeline_attributes[:duration].to_i - @finished_at = pipeline_attributes[:finished_at] ? Time.parse(pipeline_attributes[:finished_at]).to_i : nil - @pipeline_id = pipeline_attributes[:id] - - # Get list of jobs that have actually failed (after exhausting all retries) - @failed_jobs = actually_failed_jobs(Array(data[:builds])) - @failed_stages = @failed_jobs.map { |j| j[:stage] }.uniq - - @project = Project.find(data[:project][:id]) - @commit = project.commit_by(oid: data[:commit][:id]) - @committer = commit.committer - @pipeline = Ci::Pipeline.find(pipeline_id) - end - - def pretext - '' - end - - def attachments - return message if markdown - - [{ - fallback: format(message), - color: attachment_color, - author_name: user_combined_name, - author_icon: user_avatar, - author_link: author_url, - title: s_("ChatMessage|Pipeline #%{pipeline_id} %{humanized_status} in %{duration}") % - { - pipeline_id: pipeline_id, - humanized_status: humanized_status, - duration: pretty_duration(duration) - }, - title_link: pipeline_url, - fields: attachments_fields, - footer: project.name, - footer_icon: project.avatar_url(only_path: false), - ts: finished_at - }] - end - - def activity - { - title: s_("ChatMessage|Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status}") % - { - pipeline_link: pipeline_link, - ref_type: ref_type, - ref_link: ref_link, - user_combined_name: user_combined_name, - humanized_status: humanized_status - }, - subtitle: s_("ChatMessage|in %{project_link}") % { project_link: project_link }, - text: s_("ChatMessage|in %{duration}") % { duration: pretty_duration(duration) }, - image: user_avatar || '' - } - end - - private - - def actually_failed_jobs(builds) - succeeded_job_names = builds.map { |b| b[:name] if b[:status] == 'success' }.compact.uniq - - failed_jobs = builds.select do |build| - # Select jobs which doesn't have a successful retry - build[:status] == 'failed' && !succeeded_job_names.include?(build[:name]) - end - - failed_jobs.uniq { |job| job[:name] }.reverse - end - - def failed_stages_field - { - title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length), - value: Slack::Messenger::Util::LinkFormatter.format(failed_stages_links), - short: true - } - end - - def failed_jobs_field - { - title: s_("ChatMessage|Failed job").pluralize(failed_jobs.length), - value: Slack::Messenger::Util::LinkFormatter.format(failed_jobs_links), - short: true - } - end - - def yaml_error_field - { - title: s_("ChatMessage|Invalid CI config YAML file"), - value: pipeline.yaml_errors, - short: false - } - end - - def attachments_fields - fields = [ - { - title: ref_type == "tag" ? s_("ChatMessage|Tag") : s_("ChatMessage|Branch"), - value: Slack::Messenger::Util::LinkFormatter.format(ref_link), - short: true - }, - { - title: s_("ChatMessage|Commit"), - value: Slack::Messenger::Util::LinkFormatter.format(commit_link), - short: true - } - ] - - fields << failed_stages_field if failed_stages.any? - fields << failed_jobs_field if failed_jobs.any? - fields << yaml_error_field if pipeline.has_yaml_errors? - - fields - end - - def message - s_("ChatMessage|%{project_link}: Pipeline %{pipeline_link} of %{ref_type} %{ref_link} by %{user_combined_name} %{humanized_status} in %{duration}") % - { - project_link: project_link, - pipeline_link: pipeline_link, - ref_type: ref_type, - ref_link: ref_link, - user_combined_name: user_combined_name, - humanized_status: humanized_status, - duration: pretty_duration(duration) - } - end - - def humanized_status - case status - when 'success' - detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed") - when 'failed' - s_("ChatMessage|has failed") - else - status - end - end - - def attachment_color - case status - when 'success' - detailed_status == 'passed with warnings' ? 'warning' : 'good' - else - 'danger' - end - end - - def ref_url - if ref_type == 'tag' - "#{project_url}/-/tags/#{ref}" - else - "#{project_url}/-/commits/#{ref}" - end - end - - def ref_link - "[#{ref}](#{ref_url})" - end - - def project_url - project.web_url - end - - def project_link - "[#{project.name}](#{project_url})" - end - - def pipeline_failed_jobs_url - "#{project_url}/-/pipelines/#{pipeline_id}/failures" - end - - def pipeline_url - if failed_jobs.any? - pipeline_failed_jobs_url - else - "#{project_url}/-/pipelines/#{pipeline_id}" - end - end - - def pipeline_link - "[##{pipeline_id}](#{pipeline_url})" - end - - def job_url(job) - "#{project_url}/-/jobs/#{job[:id]}" - end - - def job_link(job) - "[#{job[:name]}](#{job_url(job)})" - end - - def failed_jobs_links - failed = failed_jobs.slice(0, MAX_VISIBLE_JOBS) - truncated = failed_jobs.slice(MAX_VISIBLE_JOBS, failed_jobs.size) - - failed_links = failed.map { |job| job_link(job) } - - unless truncated.blank? - failed_links << s_("ChatMessage|and [%{count} more](%{pipeline_failed_jobs_url})") % { - count: truncated.size, - pipeline_failed_jobs_url: pipeline_failed_jobs_url - } - end - - failed_links.join(I18n.translate(:'support.array.words_connector')) - end - - def stage_link(stage) - # All stages link to the pipeline page - "[#{stage}](#{pipeline_url})" - end - - def failed_stages_links - failed_stages.map { |s| stage_link(s) }.join(I18n.translate(:'support.array.words_connector')) - end - - def commit_url - Gitlab::UrlBuilder.build(commit) - end - - def commit_link - "[#{commit.title}](#{commit_url})" - end - - def author_url - return unless user && committer - - Gitlab::UrlBuilder.build(committer) - end - end -end diff --git a/app/models/project_services/chat_message/push_message.rb b/app/models/project_services/chat_message/push_message.rb deleted file mode 100644 index c8e70a69c88..00000000000 --- a/app/models/project_services/chat_message/push_message.rb +++ /dev/null @@ -1,118 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class PushMessage < BaseMessage - attr_reader :after - attr_reader :before - attr_reader :commits - attr_reader :ref - attr_reader :ref_type - - def initialize(params) - super - - @after = params[:after] - @before = params[:before] - @commits = params.fetch(:commits, []) - @ref_type = Gitlab::Git.tag_ref?(params[:ref]) ? 'tag' : 'branch' - @ref = Gitlab::Git.ref_name(params[:ref]) - end - - def attachments - return [] if new_branch? || removed_branch? - return commit_messages if markdown - - commit_message_attachments - end - - def activity - { - title: humanized_action(short: true), - subtitle: "in #{project_link}", - text: compare_link, - image: user_avatar - } - end - - private - - def humanized_action(short: false) - action, ref_link, target_link = compose_action_details - text = [user_combined_name, action, ref_type, ref_link] - text << target_link unless short - text.join(' ') - end - - def message - humanized_action - end - - def format(string) - Slack::Messenger::Util::LinkFormatter.format(string) - end - - def commit_messages - commits.map { |commit| compose_commit_message(commit) }.join("\n\n") - end - - def commit_message_attachments - [{ text: format(commit_messages), color: attachment_color }] - end - - def compose_commit_message(commit) - author = commit[:author][:name] - id = Commit.truncate_sha(commit[:id]) - title = commit[:title] - - url = commit[:url] - - "[#{id}](#{url}): #{title} - #{author}" - end - - def new_branch? - Gitlab::Git.blank_ref?(before) - end - - def removed_branch? - Gitlab::Git.blank_ref?(after) - end - - def ref_url - if ref_type == 'tag' - "#{project_url}/-/tags/#{ref}" - else - "#{project_url}/commits/#{ref}" - end - end - - def compare_url - "#{project_url}/compare/#{before}...#{after}" - end - - def ref_link - "[#{ref}](#{ref_url})" - end - - def project_link - "[#{project_name}](#{project_url})" - end - - def compare_link - "[Compare changes](#{compare_url})" - end - - def compose_action_details - if new_branch? - ['pushed new', ref_link, "to #{project_link}"] - elsif removed_branch? - ['removed', ref, "from #{project_link}"] - else - ['pushed to', ref_link, "of #{project_link} (#{compare_link})"] - end - end - - def attachment_color - '#345' - end - end -end diff --git a/app/models/project_services/chat_message/wiki_page_message.rb b/app/models/project_services/chat_message/wiki_page_message.rb deleted file mode 100644 index ebe7abb379f..00000000000 --- a/app/models/project_services/chat_message/wiki_page_message.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -module ChatMessage - class WikiPageMessage < BaseMessage - attr_reader :title - attr_reader :wiki_page_url - attr_reader :action - attr_reader :description - - def initialize(params) - super - - obj_attr = params[:object_attributes] - obj_attr = HashWithIndifferentAccess.new(obj_attr) - @title = obj_attr[:title] - @wiki_page_url = obj_attr[:url] - @description = obj_attr[:message] - - @action = - case obj_attr[:action] - when "create" - "created" - when "update" - "edited" - end - end - - def attachments - return description if markdown - - description_message - end - - def activity - { - title: "#{user_combined_name} #{action} #{wiki_page_link}", - subtitle: "in #{project_link}", - text: title, - image: user_avatar - } - end - - private - - def message - "#{user_combined_name} #{action} #{wiki_page_link} in #{project_link}: *#{title}*" - end - - def description_message - [{ text: format(@description), color: attachment_color }] - end - - def project_link - "[#{project_name}](#{project_url})" - end - - def wiki_page_link - "[wiki page](#{wiki_page_url})" - end - end -end diff --git a/app/models/project_services/chat_notification_service.rb b/app/models/project_services/chat_notification_service.rb index 4a99842b4d5..2f841bf903e 100644 --- a/app/models/project_services/chat_notification_service.rb +++ b/app/models/project_services/chat_notification_service.rb @@ -2,7 +2,7 @@ # Base class for Chat notifications services # This class is not meant to be used directly, but only to inherit from. -class ChatNotificationService < Service +class ChatNotificationService < Integration include ChatMessage include NotificationBranchSelection @@ -15,9 +15,14 @@ class ChatNotificationService < Service EVENT_CHANNEL = proc { |event| "#{event}_channel" } + LABEL_NOTIFICATION_BEHAVIOURS = [ + MATCH_ANY_LABEL = 'match_any', + MATCH_ALL_LABELS = 'match_all' + ].freeze + default_value_for :category, 'chat' - prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified + prop_accessor :webhook, :username, :channel, :branches_to_be_notified, :labels_to_be_notified, :labels_to_be_notified_behavior # Custom serialized properties initialization prop_accessor(*SUPPORTED_EVENTS.map { |event| EVENT_CHANNEL[event] }) @@ -25,12 +30,14 @@ class ChatNotificationService < Service boolean_accessor :notify_only_broken_pipelines, :notify_only_default_branch validates :webhook, presence: true, public_url: true, if: :activated? + validates :labels_to_be_notified_behavior, inclusion: { in: LABEL_NOTIFICATION_BEHAVIOURS }, allow_blank: true def initialize_properties if properties.nil? self.properties = {} self.notify_only_broken_pipelines = true self.branches_to_be_notified = "default" + self.labels_to_be_notified_behavior = MATCH_ANY_LABEL elsif !self.notify_only_default_branch.nil? # In older versions, there was only a boolean property named # `notify_only_default_branch`. Now we have a string property named @@ -65,7 +72,20 @@ class ChatNotificationService < Service { type: 'text', name: 'username', placeholder: 'GitLab-integration' }.freeze, { type: 'checkbox', name: 'notify_only_broken_pipelines', help: 'Do not send notifications for successful pipelines.' }.freeze, { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }.freeze, - { type: 'text', name: 'labels_to_be_notified', placeholder: '~backend,~frontend', help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.' }.freeze + { + type: 'text', + name: 'labels_to_be_notified', + placeholder: '~backend,~frontend', + help: 'Send notifications for issue, merge request, and comment events with the listed labels only. Leave blank to receive notifications for all events.' + }.freeze, + { + type: 'select', + name: 'labels_to_be_notified_behavior', + choices: [ + ['Match any of the labels', MATCH_ANY_LABEL], + ['Match all of the labels', MATCH_ALL_LABELS] + ] + }.freeze ].freeze end @@ -136,11 +156,17 @@ class ChatNotificationService < Service def notify_label?(data) return true unless SUPPORTED_EVENTS_FOR_LABEL_FILTER.include?(data[:object_kind]) && labels_to_be_notified.present? - issue_labels = data.dig(:issue, :labels) || [] - merge_request_labels = data.dig(:merge_request, :labels) || [] - label_titles = (issue_labels + merge_request_labels).pluck(:title) + labels = data.dig(:issue, :labels) || data.dig(:merge_request, :labels) + + return false if labels.nil? - (labels_to_be_notified_list & label_titles).any? + matching_labels = labels_to_be_notified_list & labels.pluck(:title) + + if labels_to_be_notified_behavior == MATCH_ALL_LABELS + labels_to_be_notified_list.difference(matching_labels).empty? + else + matching_labels.any? + end end def user_id_from_hook_data(data) @@ -159,19 +185,19 @@ class ChatNotificationService < Service def get_message(object_kind, data) case object_kind when "push", "tag_push" - ChatMessage::PushMessage.new(data) if notify_for_ref?(data) + Integrations::ChatMessage::PushMessage.new(data) if notify_for_ref?(data) when "issue" - ChatMessage::IssueMessage.new(data) unless update?(data) + Integrations::ChatMessage::IssueMessage.new(data) unless update?(data) when "merge_request" - ChatMessage::MergeMessage.new(data) unless update?(data) + Integrations::ChatMessage::MergeMessage.new(data) unless update?(data) when "note" - ChatMessage::NoteMessage.new(data) + Integrations::ChatMessage::NoteMessage.new(data) when "pipeline" - ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) + Integrations::ChatMessage::PipelineMessage.new(data) if should_pipeline_be_notified?(data) when "wiki_page" - ChatMessage::WikiPageMessage.new(data) + Integrations::ChatMessage::WikiPageMessage.new(data) when "deployment" - ChatMessage::DeploymentMessage.new(data) + Integrations::ChatMessage::DeploymentMessage.new(data) end end diff --git a/app/models/project_services/ci_service.rb b/app/models/project_services/ci_service.rb index 29edb9ec16f..0733da761d5 100644 --- a/app/models/project_services/ci_service.rb +++ b/app/models/project_services/ci_service.rb @@ -3,7 +3,7 @@ # Base class for CI services # List methods you need to implement to get your CI service # working with GitLab merge requests -class CiService < Service +class CiService < Integration default_value_for :category, 'ci' def valid_token?(token) diff --git a/app/models/project_services/confluence_service.rb b/app/models/project_services/confluence_service.rb deleted file mode 100644 index 8a6f4de540c..00000000000 --- a/app/models/project_services/confluence_service.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -class ConfluenceService < Service - include ActionView::Helpers::UrlHelper - - VALID_SCHEME_MATCH = %r{\Ahttps?\Z}.freeze - VALID_HOST_MATCH = %r{\A.+\.atlassian\.net\Z}.freeze - VALID_PATH_MATCH = %r{\A/wiki(/|\Z)}.freeze - - prop_accessor :confluence_url - - validates :confluence_url, presence: true, if: :activated? - validate :validate_confluence_url_is_cloud, if: :activated? - - after_commit :cache_project_has_confluence - - def self.to_param - 'confluence' - end - - def self.supported_events - %w() - end - - def title - s_('ConfluenceService|Confluence Workspace') - end - - def description - s_('ConfluenceService|Connect a Confluence Cloud Workspace to GitLab') - end - - def help - return unless project&.wiki_enabled? - - if activated? - wiki_url = project.wiki.web_url - - s_( - 'ConfluenceService|Your GitLab Wiki can be accessed here: %{wiki_link}. To re-enable your GitLab Wiki, disable this integration' % - { wiki_link: link_to(wiki_url, wiki_url) } - ).html_safe - else - s_('ConfluenceService|Enabling the Confluence Workspace will disable the default GitLab Wiki. Your GitLab Wiki data will be saved and you can always re-enable it later by turning off this integration').html_safe - end - end - - def fields - [ - { - type: 'text', - name: 'confluence_url', - title: 'Confluence Cloud Workspace URL', - placeholder: s_('ConfluenceService|The URL of the Confluence Workspace'), - required: true - } - ] - end - - def can_test? - false - end - - private - - def validate_confluence_url_is_cloud - unless confluence_uri_valid? - errors.add(:confluence_url, 'URL must be to a Confluence Cloud Workspace hosted on atlassian.net') - end - end - - def confluence_uri_valid? - return false unless confluence_url - - uri = URI.parse(confluence_url) - - (uri.scheme&.match(VALID_SCHEME_MATCH) && - uri.host&.match(VALID_HOST_MATCH) && - uri.path&.match(VALID_PATH_MATCH)).present? - - rescue URI::InvalidURIError - false - end - - def cache_project_has_confluence - return unless project && !project.destroyed? - - project.project_setting.save! unless project.project_setting.persisted? - project.project_setting.update_column(:has_confluence, active?) - end -end diff --git a/app/models/project_services/custom_issue_tracker_service.rb b/app/models/project_services/custom_issue_tracker_service.rb index aab8661ec55..6f99d104904 100644 --- a/app/models/project_services/custom_issue_tracker_service.rb +++ b/app/models/project_services/custom_issue_tracker_service.rb @@ -1,25 +1,23 @@ # frozen_string_literal: true class CustomIssueTrackerService < IssueTrackerService + include ActionView::Helpers::UrlHelper validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? def title - 'Custom Issue Tracker' + s_('IssueTracker|Custom issue tracker') end def description - s_('IssueTracker|Custom issue tracker') + s_("IssueTracker|Use a custom issue tracker as this project's issue tracker.") end - def self.to_param - 'custom_issue_tracker' + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer' + s_('IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end - def fields - [ - { type: 'text', name: 'project_url', title: _('Project URL'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), required: true }, - { type: 'text', name: 'new_issue_url', title: s_('ProjectService|New issue URL'), required: true } - ] + def self.to_param + 'custom_issue_tracker' end end diff --git a/app/models/project_services/data_fields.rb b/app/models/project_services/data_fields.rb index 12ebf260e08..ca4dc0375fb 100644 --- a/app/models/project_services/data_fields.rb +++ b/app/models/project_services/data_fields.rb @@ -42,9 +42,9 @@ module DataFields end included do - has_one :issue_tracker_data, autosave: true - has_one :jira_tracker_data, autosave: true - has_one :open_project_tracker_data, autosave: true + has_one :issue_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id + has_one :jira_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id + has_one :open_project_tracker_data, autosave: true, inverse_of: :integration, foreign_key: :service_id def data_fields raise NotImplementedError diff --git a/app/models/project_services/datadog_service.rb b/app/models/project_services/datadog_service.rb deleted file mode 100644 index 9a2d99c46c9..00000000000 --- a/app/models/project_services/datadog_service.rb +++ /dev/null @@ -1,144 +0,0 @@ -# frozen_string_literal: true - -class DatadogService < Service - DEFAULT_SITE = 'datadoghq.com' - URL_TEMPLATE = 'https://webhooks-http-intake.logs.%{datadog_site}/v1/input/' - URL_TEMPLATE_API_KEYS = 'https://app.%{datadog_site}/account/settings#api' - URL_API_KEYS_DOCS = "https://docs.#{DEFAULT_SITE}/account_management/api-app-keys/" - - SUPPORTED_EVENTS = %w[ - pipeline job - ].freeze - - prop_accessor :datadog_site, :api_url, :api_key, :datadog_service, :datadog_env - - with_options if: :activated? do - validates :api_key, presence: true, format: { with: /\A\w+\z/ } - validates :datadog_site, format: { with: /\A[\w\.]+\z/, allow_blank: true } - validates :api_url, public_url: { allow_blank: true } - validates :datadog_site, presence: true, unless: -> (obj) { obj.api_url.present? } - validates :api_url, presence: true, unless: -> (obj) { obj.datadog_site.present? } - end - - after_save :compose_service_hook, if: :activated? - - def initialize_properties - super - - self.datadog_site ||= DEFAULT_SITE - end - - def self.supported_events - SUPPORTED_EVENTS - end - - def self.default_test_event - 'pipeline' - end - - def configurable_events - [] # do not allow to opt out of required hooks - end - - def title - 'Datadog' - end - - def description - 'Trace your GitLab pipelines with Datadog' - end - - def help - nil - # Maybe adding something in the future - # We could link to static help pages as well - # [More information](#{Gitlab::Routing.url_helpers.help_page_url('integration/datadog')})" - end - - def self.to_param - 'datadog' - end - - def fields - [ - { - type: 'text', - name: 'datadog_site', - placeholder: DEFAULT_SITE, - help: 'Choose the Datadog site to send data to. Set to "datadoghq.eu" to send data to the EU site', - required: false - }, - { - type: 'text', - name: 'api_url', - title: 'API URL', - help: '(Advanced) Define the full URL for your Datadog site directly', - required: false - }, - { - type: 'password', - name: 'api_key', - 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", - required: true - }, - { - type: 'text', - name: 'datadog_service', - title: 'Service', - placeholder: 'gitlab-ci', - help: 'Name of this GitLab instance that all data will be tagged with' - }, - { - type: 'text', - name: 'datadog_env', - title: 'Env', - help: 'The environment tag that traces will be tagged with' - } - ] - end - - def compose_service_hook - hook = service_hook || build_service_hook - hook.url = hook_url - hook.save - end - - def hook_url - url = api_url.presence || sprintf(URL_TEMPLATE, datadog_site: datadog_site) - url = URI.parse(url) - url.path = File.join(url.path || '/', api_key) - query = { service: datadog_service.presence, env: datadog_env.presence }.compact - url.query = query.to_query unless query.empty? - url.to_s - end - - def api_keys_url - return URL_API_KEYS_DOCS unless datadog_site.presence - - sprintf(URL_TEMPLATE_API_KEYS, datadog_site: datadog_site) - end - - def execute(data) - return if project.disabled_services.include?(to_param) - - object_kind = data[:object_kind] - object_kind = 'job' if object_kind == 'build' - return unless supported_events.include?(object_kind) - - service_hook.execute(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] } - end -end diff --git a/app/models/project_services/emails_on_push_service.rb b/app/models/project_services/emails_on_push_service.rb deleted file mode 100644 index cdb69684d16..00000000000 --- a/app/models/project_services/emails_on_push_service.rb +++ /dev/null @@ -1,97 +0,0 @@ -# frozen_string_literal: true - -class EmailsOnPushService < Service - include NotificationBranchSelection - - RECIPIENTS_LIMIT = 750 - - boolean_accessor :send_from_committer_email - boolean_accessor :disable_diffs - prop_accessor :recipients, :branches_to_be_notified - validates :recipients, presence: true, if: :validate_recipients? - validate :number_of_recipients_within_limit, if: :validate_recipients? - - def self.valid_recipients(recipients) - recipients.split.select do |recipient| - recipient.include?('@') - end.uniq(&:downcase) - end - - def title - s_('EmailsOnPushService|Emails on push') - end - - def description - s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.') - end - - def self.to_param - 'emails_on_push' - end - - def self.supported_events - %w(push tag_push) - end - - def initialize_properties - super - - self.branches_to_be_notified = 'all' if branches_to_be_notified.nil? - end - - def execute(push_data) - return unless supported_events.include?(push_data[:object_kind]) - return if project.emails_disabled? - return unless notify_for_ref?(push_data) - - EmailsOnPushWorker.perform_async( - project_id, - recipients, - push_data, - send_from_committer_email: send_from_committer_email?, - disable_diffs: disable_diffs? - ) - end - - def notify_for_ref?(push_data) - return true if push_data[:object_kind] == 'tag_push' - return true if push_data.dig(:object_attributes, :tag) - - notify_for_branch?(push_data) - end - - def send_from_committer_email? - Gitlab::Utils.to_boolean(self.send_from_committer_email) - end - - def disable_diffs? - Gitlab::Utils.to_boolean(self.disable_diffs) - end - - def fields - domains = Notify.allowed_email_domains.map { |domain| "user@#{domain}" }.join(", ") - [ - { type: 'checkbox', name: 'send_from_committer_email', title: s_("EmailsOnPushService|Send from committer"), - help: s_("EmailsOnPushService|Send notifications from the committer's email address if the domain matches the domain used by your GitLab instance (such as %{domains}).") % { domains: domains } }, - { type: 'checkbox', name: 'disable_diffs', title: s_("EmailsOnPushService|Disable code diffs"), - help: s_("EmailsOnPushService|Don't include possibly sensitive code diffs in notification body.") }, - { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }, - { - type: 'textarea', - name: 'recipients', - placeholder: s_('EmailsOnPushService|tanuki@example.com gitlab@example.com'), - help: s_('EmailsOnPushService|Emails separated by whitespace.') - } - ] - end - - private - - def number_of_recipients_within_limit - return if recipients.blank? - - if self.class.valid_recipients(recipients).size > RECIPIENTS_LIMIT - errors.add(:recipients, s_("EmailsOnPushService|can't exceed %{recipients_limit}") % { recipients_limit: RECIPIENTS_LIMIT }) - end - end -end diff --git a/app/models/project_services/ewm_service.rb b/app/models/project_services/ewm_service.rb index af402e50292..90fcbb10d2b 100644 --- a/app/models/project_services/ewm_service.rb +++ b/app/models/project_services/ewm_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class EwmService < IssueTrackerService + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? def self.reference_pattern(only_long: true) @@ -12,7 +14,12 @@ class EwmService < IssueTrackerService end def description - s_('IssueTracker|EWM work items tracker') + s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end def self.to_param diff --git a/app/models/project_services/external_wiki_service.rb b/app/models/project_services/external_wiki_service.rb index c41783d1af4..f49b008533d 100644 --- a/app/models/project_services/external_wiki_service.rb +++ b/app/models/project_services/external_wiki_service.rb @@ -1,7 +1,8 @@ # frozen_string_literal: true -class ExternalWikiService < Service +class ExternalWikiService < Integration include ActionView::Helpers::UrlHelper + prop_accessor :external_wiki_url validates :external_wiki_url, presence: true, public_url: true, if: :activated? @@ -39,7 +40,7 @@ class ExternalWikiService < Service def execute(_data) response = Gitlab::HTTP.get(properties['external_wiki_url'], verify: true) response.body if response.code == 200 - rescue + rescue StandardError nil end diff --git a/app/models/project_services/flowdock_service.rb b/app/models/project_services/flowdock_service.rb index e721fded1d9..7aae5af7454 100644 --- a/app/models/project_services/flowdock_service.rb +++ b/app/models/project_services/flowdock_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true -class FlowdockService < Service +class FlowdockService < Integration + include ActionView::Helpers::UrlHelper + prop_accessor :token validates :token, presence: true, if: :activated? @@ -9,7 +11,12 @@ class FlowdockService < Service end def description - s_('FlowdockService|Flowdock is a collaboration web app for technical teams.') + s_('FlowdockService|Send event notifications from GitLab to Flowdock flows.') + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'flowdock'), target: '_blank', rel: 'noopener noreferrer' + s_('FlowdockService|Send event notifications from GitLab to Flowdock flows. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end def self.to_param @@ -18,7 +25,7 @@ class FlowdockService < Service def fields [ - { type: 'text', name: 'token', placeholder: s_('FlowdockService|Flowdock Git source token'), required: true } + { type: 'text', name: 'token', placeholder: s_('FlowdockService|1b609b52537...'), required: true, help: 'Enter your Flowdock token.' } ] end diff --git a/app/models/project_services/hangouts_chat_service.rb b/app/models/project_services/hangouts_chat_service.rb index 299a306add7..6e7708a169f 100644 --- a/app/models/project_services/hangouts_chat_service.rb +++ b/app/models/project_services/hangouts_chat_service.rb @@ -3,12 +3,14 @@ require 'hangouts_chat' class HangoutsChatService < ChatNotificationService + include ActionView::Helpers::UrlHelper + def title - 'Hangouts Chat' + 'Google Chat' end def description - 'Receive event notifications in Google Hangouts Chat' + 'Send notifications from GitLab to a room in Google Chat.' end def self.to_param @@ -16,13 +18,8 @@ class HangoutsChatService < ChatNotificationService end def help - 'This service sends notifications about projects events to Google Hangouts Chat room.<br /> - To set up this service: - <ol> - <li><a href="https://developers.google.com/hangouts/chat/how-tos/webhooks">Set up an incoming webhook for your room</a>. All notifications will come to this room.</li> - <li>Paste the <strong>Webhook URL</strong> into the field below.</li> - <li>Select events below to enable notifications.</li> - </ol>' + docs_link = link_to _('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer' + s_('Before enabling this integration, create a webhook for the room in Google Chat where you want to receive notifications from this project. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end def event_field(event) @@ -42,7 +39,7 @@ class HangoutsChatService < ChatNotificationService def default_fields [ - { type: 'text', name: 'webhook', placeholder: "e.g. #{webhook_placeholder}" }, + { type: 'text', name: 'webhook', placeholder: "#{webhook_placeholder}" }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] diff --git a/app/models/project_services/hipchat_service.rb b/app/models/project_services/hipchat_service.rb index cd49c6d253d..71d8e7bfac4 100644 --- a/app/models/project_services/hipchat_service.rb +++ b/app/models/project_services/hipchat_service.rb @@ -1,54 +1,17 @@ # frozen_string_literal: true -class HipchatService < Service - include ActionView::Helpers::SanitizeHelper - - MAX_COMMITS = 3 - HIPCHAT_ALLOWED_TAGS = %w[ - a b i strong em br img pre code - table th tr td caption colgroup col thead tbody tfoot - ul ol li dl dt dd - ].freeze - - prop_accessor :token, :room, :server, :color, :api_version - boolean_accessor :notify_only_broken_pipelines, :notify - validates :token, presence: true, if: :activated? - - def initialize_properties - if properties.nil? - self.properties = {} - self.notify_only_broken_pipelines = true - end - end - - def title - 'HipChat' - end - - def description - 'Private group chat and IM' - end +# This service is scheduled for removal. All records must +# be deleted before the class can be removed. +# https://gitlab.com/gitlab-org/gitlab/-/issues/27954 +class HipchatService < Integration + before_save :prevent_save def self.to_param 'hipchat' end - def fields - [ - { type: 'text', name: 'token', placeholder: 'Room token', required: true }, - { type: 'text', name: 'room', placeholder: 'Room name or ID' }, - { type: 'checkbox', name: 'notify' }, - { type: 'select', name: 'color', choices: %w(yellow red green purple gray random) }, - { type: 'text', name: 'api_version', title: _('API version'), - placeholder: 'Leave blank for default (v2)' }, - { type: 'text', name: 'server', - placeholder: 'Leave blank for default. https://hipchat.example.com' }, - { type: 'checkbox', name: 'notify_only_broken_pipelines' } - ] - end - def self.supported_events - %w(push issue confidential_issue merge_request note confidential_note tag_push pipeline) + [] end def execute(data) @@ -56,96 +19,14 @@ class HipchatService < Service # HipChat is unusable anyway, so do nothing in this method end - def test(data) - begin - result = execute(data) - rescue StandardError => error - return { success: false, result: error } - end - - { success: true, result: result } - end - private - def message_options(data = nil) - { notify: notify.present? && Gitlab::Utils.to_boolean(notify), color: message_color(data) } - end - - def render_line(text) - markdown(text.lines.first.chomp, pipeline: :single_line) if text - end - - def markdown(text, options = {}) - return "" unless text - - context = { - project: project, - pipeline: :email - } - - Banzai.render(text, context) - - context.merge!(options) - - html = Banzai.render_and_post_process(text, context) - sanitized_html = sanitize(html, tags: HIPCHAT_ALLOWED_TAGS, attributes: %w[href title alt]) - - sanitized_html.truncate(200, separator: ' ', omission: '...') - end - - def format_title(title) - "<b>#{render_line(title)}</b>" - end - - def message_color(data) - pipeline_status_color(data) || color || 'yellow' - end - - def pipeline_status_color(data) - return unless data && data[:object_kind] == 'pipeline' - - case data[:object_attributes][:status] - when 'success' - 'green' - else - 'red' - end - end - - def project_name - project.full_name.gsub(/\s/, '') - end - - def project_url - project.web_url - end - - def project_link - "<a href=\"#{project_url}\">#{project_name}</a>" - end - - def update?(data) - data[:object_attributes][:action] == 'update' - end - - def humanized_status(status) - case status - when 'success' - 'passed' - else - status - end - end + def prevent_save + errors.add(:base, _('HipChat endpoint is deprecated and should not be created or modified.')) - def should_pipeline_be_notified?(data) - case data[:object_attributes][:status] - when 'success' - !notify_only_broken_pipelines? - when 'failed' - true - else - false - end + # Stops execution of callbacks and database operation while + # preserving expectations of #save (will not raise) & #save! (raises) + # https://guides.rubyonrails.org/active_record_callbacks.html#halting-execution + throw :abort # rubocop:disable Cop/BanCatchThrow end end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index 4f1ce16ebb2..5cca620c659 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -2,7 +2,7 @@ require 'uri' -class IrkerService < Service +class IrkerService < Integration prop_accessor :server_host, :server_port, :default_irc_uri prop_accessor :recipients, :channels boolean_accessor :colorize_messages @@ -15,8 +15,7 @@ class IrkerService < Service end def description - 'Send IRC messages, on update, to a list of recipients through an Irker '\ - 'gateway.' + 'Send IRC messages.' end def self.to_param @@ -103,7 +102,7 @@ class IrkerService < Service begin new_recipient = URI.join(default_irc_uri, '/', recipient).to_s uri = consider_uri(URI.parse(new_recipient)) - rescue + rescue StandardError log_error("Unable to create a valid URL", default_irc_uri: default_irc_uri, recipient: recipient) end end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 19a5b4a74bb..099e3c336dd 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class IssueTrackerService < Service +class IssueTrackerService < Integration validate :one_issue_tracker, if: :activated?, on: :manual_change # TODO: we can probably just delegate as part of @@ -73,9 +73,9 @@ class IssueTrackerService < Service def fields [ - { type: 'text', name: 'project_url', title: _('Project URL'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), required: true }, - { type: 'text', name: 'new_issue_url', title: s_('ProjectService|New issue URL'), required: true } + { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in the external issue tracker.'), required: true }, + { type: 'text', name: 'issues_url', title: s_('IssueTracker|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true }, + { type: 'text', name: 'new_issue_url', title: s_('IssueTracker|New issue URL'), help: s_('IssueTracker|The URL to create an issue in the external issue tracker.'), required: true } ] end @@ -143,10 +143,10 @@ class IssueTrackerService < Service return if template? || instance? return if project.blank? - if project.services.external_issue_trackers.where.not(id: id).any? + if project.integrations.external_issue_trackers.where.not(id: id).any? errors.add(:base, _('Another issue tracker is already in use. Only one issue tracker service can be active at a time')) end end end -IssueTrackerService.prepend_if_ee('EE::IssueTrackerService') +IssueTrackerService.prepend_mod_with('IssueTrackerService') diff --git a/app/models/project_services/jenkins_service.rb b/app/models/project_services/jenkins_service.rb index 6a123517b84..990a35cd617 100644 --- a/app/models/project_services/jenkins_service.rb +++ b/app/models/project_services/jenkins_service.rb @@ -64,12 +64,12 @@ class JenkinsService < CiService end def description - s_('An extendable open source CI/CD server.') + s_('Run CI/CD pipelines with Jenkins.') end def help docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer' - s_('Trigger Jenkins builds when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } + s_('Run CI/CD pipelines with Jenkins when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end def self.to_param diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 3e14bf44c12..5cd6e79eb1d 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -106,9 +106,8 @@ class JiraService < IssueTrackerService end def help - "You need to configure Jira before enabling this service. For more details - read the - [Jira service documentation](#{help_page_url('user/project/integrations/jira')})." + jira_doc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_url('integration/jira/index.html') } + s_("JiraService|You need to configure Jira before enabling this integration. For more details, read the %{jira_doc_link_start}Jira integration documentation%{link_end}.") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe } end def title @@ -116,7 +115,7 @@ class JiraService < IssueTrackerService end def description - s_('JiraService|Track issues in Jira') + s_("JiraService|Use Jira as this project's issue tracker.") end def self.to_param @@ -305,7 +304,7 @@ class JiraService < IssueTrackerService ) true - rescue => error + rescue StandardError => error log_error( "Issue transition failed", error: { @@ -490,7 +489,7 @@ class JiraService < IssueTrackerService # Handle errors when doing Jira API calls def jira_request yield - rescue => error + rescue StandardError => error @error = error log_error("Error sending message", client_url: client_url, error: @error.message) nil @@ -539,4 +538,4 @@ class JiraService < IssueTrackerService end end -JiraService.prepend_if_ee('EE::JiraService') +JiraService.prepend_mod_with('JiraService') diff --git a/app/models/project_services/microsoft_teams_service.rb b/app/models/project_services/microsoft_teams_service.rb index 803c1255195..1d2067067da 100644 --- a/app/models/project_services/microsoft_teams_service.rb +++ b/app/models/project_services/microsoft_teams_service.rb @@ -6,7 +6,7 @@ class MicrosoftTeamsService < ChatNotificationService end def description - 'Receive event notifications in Microsoft Teams' + 'Send notifications about project events to Microsoft Teams.' end def self.to_param diff --git a/app/models/project_services/monitoring_service.rb b/app/models/project_services/monitoring_service.rb index 1b530a8247b..ea65a200027 100644 --- a/app/models/project_services/monitoring_service.rb +++ b/app/models/project_services/monitoring_service.rb @@ -4,7 +4,7 @@ # # These services integrate with a deployment solution like Prometheus # to provide additional features for environments. -class MonitoringService < Service +class MonitoringService < Integration default_value_for :category, 'monitoring' def self.supported_events diff --git a/app/models/project_services/packagist_service.rb b/app/models/project_services/packagist_service.rb index 21f0a2b2463..f3ea8c64302 100644 --- a/app/models/project_services/packagist_service.rb +++ b/app/models/project_services/packagist_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PackagistService < Service +class PackagistService < Integration prop_accessor :username, :token, :server validates :username, presence: true, if: :activated? @@ -16,7 +16,7 @@ class PackagistService < Service end def description - s_('Integrations|Update your projects on Packagist, the main Composer repository') + s_('Integrations|Update your Packagist projects.') end def self.to_param diff --git a/app/models/project_services/pipelines_email_service.rb b/app/models/project_services/pipelines_email_service.rb index 0a0a41c525c..4603193ac8e 100644 --- a/app/models/project_services/pipelines_email_service.rb +++ b/app/models/project_services/pipelines_email_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PipelinesEmailService < Service +class PipelinesEmailService < Integration include NotificationBranchSelection prop_accessor :recipients, :branches_to_be_notified diff --git a/app/models/project_services/pivotaltracker_service.rb b/app/models/project_services/pivotaltracker_service.rb index d3fff100964..6e67984591d 100644 --- a/app/models/project_services/pivotaltracker_service.rb +++ b/app/models/project_services/pivotaltracker_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PivotaltrackerService < Service +class PivotaltrackerService < Integration API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits' prop_accessor :token, :restrict_to_branch @@ -11,7 +11,7 @@ class PivotaltrackerService < Service end def description - s_('PivotalTrackerService|Project Management Software (Source Commits Endpoint)') + s_('PivotalTrackerService|Add commit messages as comments to PivotalTracker stories.') end def self.to_param diff --git a/app/models/project_services/pushover_service.rb b/app/models/project_services/pushover_service.rb index 1781ec7456d..89765fbdf41 100644 --- a/app/models/project_services/pushover_service.rb +++ b/app/models/project_services/pushover_service.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class PushoverService < Service +class PushoverService < Integration BASE_URI = 'https://api.pushover.net/1' prop_accessor :api_key, :user_key, :device, :priority, :sound @@ -11,7 +11,7 @@ class PushoverService < Service end def description - s_('PushoverService|Pushover makes it easy to get real-time notifications on your Android device, iPhone, iPad, and Desktop.') + s_('PushoverService|Get real-time notifications on your device.') end def self.to_param diff --git a/app/models/project_services/redmine_service.rb b/app/models/project_services/redmine_service.rb index 26a6cf86bf4..7a0f500209c 100644 --- a/app/models/project_services/redmine_service.rb +++ b/app/models/project_services/redmine_service.rb @@ -9,7 +9,7 @@ class RedmineService < IssueTrackerService end def description - s_('IssueTracker|Use Redmine as the issue tracker.') + s_("IssueTracker|Use Redmine as this project's issue tracker.") end def help diff --git a/app/models/project_services/slack_service.rb b/app/models/project_services/slack_service.rb index 7badcc24870..92a46f8d01f 100644 --- a/app/models/project_services/slack_service.rb +++ b/app/models/project_services/slack_service.rb @@ -39,7 +39,7 @@ class SlackService < ChatNotificationService end def get_message(object_kind, data) - return ChatMessage::AlertMessage.new(data) if object_kind == 'alert' + return Integrations::ChatMessage::AlertMessage.new(data) if object_kind == 'alert' super end diff --git a/app/models/project_services/slash_commands_service.rb b/app/models/project_services/slash_commands_service.rb index d436176a52c..37d16737052 100644 --- a/app/models/project_services/slash_commands_service.rb +++ b/app/models/project_services/slash_commands_service.rb @@ -2,7 +2,7 @@ # Base class for Chat services # This class is not meant to be used directly, but only to inherrit from. -class SlashCommandsService < Service +class SlashCommandsService < Integration default_value_for :category, 'chat' prop_accessor :token diff --git a/app/models/project_services/unify_circuit_service.rb b/app/models/project_services/unify_circuit_service.rb index 1a0eebe7d64..5f43388e1c9 100644 --- a/app/models/project_services/unify_circuit_service.rb +++ b/app/models/project_services/unify_circuit_service.rb @@ -6,7 +6,7 @@ class UnifyCircuitService < ChatNotificationService end def description - 'Receive event notifications in Unify Circuit' + s_('Integrations|Send notifications about project events to Unify Circuit.') end def self.to_param diff --git a/app/models/project_services/webex_teams_service.rb b/app/models/project_services/webex_teams_service.rb index 4e8281f4e81..3d92d3bb85e 100644 --- a/app/models/project_services/webex_teams_service.rb +++ b/app/models/project_services/webex_teams_service.rb @@ -1,12 +1,14 @@ # frozen_string_literal: true class WebexTeamsService < ChatNotificationService + include ActionView::Helpers::UrlHelper + def title - 'Webex Teams' + s_("WebexTeamsService|Webex Teams") end def description - 'Receive event notifications in Webex Teams' + s_("WebexTeamsService|Send notifications about project events to Webex Teams.") end def self.to_param @@ -14,13 +16,8 @@ class WebexTeamsService < ChatNotificationService end def help - 'This service sends notifications about projects events to a Webex Teams conversation.<br /> - To set up this service: - <ol> - <li><a href="https://apphub.webex.com/teams/applications/incoming-webhooks-cisco-systems">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>' + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' + s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } end def event_field(event) @@ -36,7 +33,7 @@ class WebexTeamsService < ChatNotificationService def default_fields [ - { type: 'text', name: 'webhook', placeholder: "e.g. https://api.ciscospark.com/v1/webhooks/incoming/…", required: true }, + { type: 'text', name: 'webhook', placeholder: "https://api.ciscospark.com/v1/webhooks/incoming/...", required: true }, { type: 'checkbox', name: 'notify_only_broken_pipelines' }, { type: 'select', name: 'branches_to_be_notified', choices: branch_choices } ] diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb index 30abd0159b3..9760a22a872 100644 --- a/app/models/project_services/youtrack_service.rb +++ b/app/models/project_services/youtrack_service.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class YoutrackService < IssueTrackerService + include ActionView::Helpers::UrlHelper + validates :project_url, :issues_url, presence: true, public_url: true, if: :activated? # {PROJECT-KEY}-{NUMBER} Examples: YT-1, PRJ-1, gl-030 @@ -17,7 +19,12 @@ class YoutrackService < IssueTrackerService end def description - s_('IssueTracker|YouTrack issue tracker') + s_("IssueTracker|Use YouTrack as this project's issue tracker.") + end + + def help + docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' + s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end def self.to_param @@ -26,8 +33,8 @@ class YoutrackService < IssueTrackerService def fields [ - { type: 'text', name: 'project_url', title: _('Project URL'), required: true }, - { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), required: true } + { type: 'text', name: 'project_url', title: _('Project URL'), help: s_('IssueTracker|The URL to the project in YouTrack.'), required: true }, + { type: 'text', name: 'issues_url', title: s_('ProjectService|Issue URL'), help: s_('IssueTracker|The URL to view an issue in the YouTrack project. Must contain %{colon_id}.') % { colon_id: '<code>:id</code>'.html_safe }, required: true } ] end end diff --git a/app/models/project_setting.rb b/app/models/project_setting.rb index 83ff0702b88..24d892290a6 100644 --- a/app/models/project_setting.rb +++ b/app/models/project_setting.rb @@ -21,4 +21,4 @@ class ProjectSetting < ApplicationRecord end end -ProjectSetting.prepend_ee_mod +ProjectSetting.prepend_mod diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb index 8c3dcaa7c0f..37ddd2d030d 100644 --- a/app/models/project_statistics.rb +++ b/app/models/project_statistics.rb @@ -159,4 +159,4 @@ class ProjectStatistics < ApplicationRecord end end -ProjectStatistics.prepend_if_ee('EE::ProjectStatistics') +ProjectStatistics.prepend_mod_with('ProjectStatistics') diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 1a3f362e6a1..a85afada901 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -130,7 +130,7 @@ class ProjectTeam end true - rescue + rescue StandardError false end @@ -234,4 +234,4 @@ class ProjectTeam end end -ProjectTeam.prepend_if_ee('EE::ProjectTeam') +ProjectTeam.prepend_mod_with('ProjectTeam') diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index 91fb3d4e4ba..ffffa803011 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -32,4 +32,4 @@ end # TODO: Remove this once we implement ES support for group wikis. # https://gitlab.com/gitlab-org/gitlab/-/issues/207889 -ProjectWiki.prepend_if_ee('EE::ProjectWiki') +ProjectWiki.prepend_mod_with('ProjectWiki') diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index 963a6b7774a..889eaed138d 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -63,4 +63,4 @@ class ProtectedBranch < ApplicationRecord end end -ProtectedBranch.prepend_if_ee('EE::ProtectedBranch') +ProtectedBranch.prepend_mod_with('ProtectedBranch') diff --git a/app/models/push_event_payload.rb b/app/models/push_event_payload.rb index 2786ecb641a..8358be35470 100644 --- a/app/models/push_event_payload.rb +++ b/app/models/push_event_payload.rb @@ -25,4 +25,4 @@ class PushEventPayload < ApplicationRecord } end -PushEventPayload.prepend_if_ee('EE::PushEventPayload') +PushEventPayload.prepend_mod_with('PushEventPayload') diff --git a/app/models/release.rb b/app/models/release.rb index 5ca8f537baa..1889a0707b4 100644 --- a/app/models/release.rb +++ b/app/models/release.rb @@ -13,6 +13,7 @@ class Release < ApplicationRecord belongs_to :author, class_name: 'User' has_many :links, class_name: 'Releases::Link' + has_many :sorted_links, -> { sorted }, class_name: 'Releases::Link', inverse_of: :release has_many :milestone_releases has_many :milestones, through: :milestone_releases @@ -23,11 +24,15 @@ class Release < ApplicationRecord before_create :set_released_at validates :project, :tag, presence: true + validates :description, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }, if: :description_changed? validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") } validates :links, nested_attributes_duplicates: { scope: :release, child_attributes: %i[name url filepath] } scope :sorted, -> { order(released_at: :desc) } - scope :preloaded, -> { includes(:evidences, :milestones, project: [:project_feature, :route, { namespace: :route }]) } + scope :preloaded, -> { + includes(:author, :evidences, :milestones, :links, :sorted_links, + project: [:project_feature, :route, { namespace: :route }]) + } scope :with_project_and_namespace, -> { includes(project: :namespace) } scope :recent, -> { sorted.limit(MAX_NUMBER_TO_DISPLAY) } scope :without_evidence, -> { left_joins(:evidences).where(::Releases::Evidence.arel_table[:id].eq(nil)) } @@ -58,8 +63,8 @@ class Release < ApplicationRecord end def assets_count(except: []) - links_count = links.count - sources_count = except.include?(:sources) ? 0 : sources.count + links_count = links.size + sources_count = except.include?(:sources) ? 0 : sources.size links_count + sources_count end @@ -123,4 +128,4 @@ class Release < ApplicationRecord end end -Release.prepend_if_ee('EE::Release') +Release.prepend_mod_with('Release') diff --git a/app/models/release_highlight.rb b/app/models/release_highlight.rb index 98d9899a349..9c30d0611e6 100644 --- a/app/models/release_highlight.rb +++ b/app/models/release_highlight.rb @@ -4,6 +4,10 @@ class ReleaseHighlight CACHE_DURATION = 1.hour FILES_PATH = Rails.root.join('data', 'whats_new', '*.yml') + FREE_PACKAGE = 'Free' + PREMIUM_PACKAGE = 'Premium' + ULTIMATE_PACKAGE = 'Ultimate' + def self.paginated(page: 1) key = self.cache_key("items:page-#{page}") @@ -25,14 +29,12 @@ class ReleaseHighlight file = File.read(file_path) items = YAML.safe_load(file, permitted_classes: [Date]) - platform = Gitlab.com? ? 'gitlab-com' : 'self-managed' - items&.map! do |item| - next unless item[platform] + next unless include_item?(item) begin item.tap {|i| i['body'] = Kramdown::Document.new(i['body']).to_html } - rescue => e + rescue StandardError => e Gitlab::ErrorTracking.track_exception(e, file_path: file_path) next @@ -53,7 +55,8 @@ class ReleaseHighlight end def self.cache_key(key) - ['release_highlight', key, Gitlab.revision].join(':') + variant = Gitlab::CurrentSettings.current_application_settings.whats_new_variant + ['release_highlight', variant, key, Gitlab.revision].join(':') end def self.next_page(current_page: 1) @@ -88,4 +91,27 @@ class ReleaseHighlight delegate :each, to: :items end + + def self.current_package + return FREE_PACKAGE unless defined?(License) + + case License.current&.plan&.downcase + when License::PREMIUM_PLAN + PREMIUM_PACKAGE + when License::ULTIMATE_PLAN + ULTIMATE_PACKAGE + else + FREE_PACKAGE + end + end + + def self.include_item?(item) + platform = Gitlab.com? ? 'gitlab-com' : 'self-managed' + + return false unless item[platform] + + return true unless Gitlab::CurrentSettings.current_application_settings.whats_new_variant_current_tier? + + item['packages']&.include?(current_package) + end end diff --git a/app/models/releases/evidence.rb b/app/models/releases/evidence.rb index 7c428f5ad03..5fe91b0fef5 100644 --- a/app/models/releases/evidence.rb +++ b/app/models/releases/evidence.rb @@ -5,7 +5,7 @@ module Releases include ShaAttribute include Presentable - belongs_to :release, inverse_of: :evidences + belongs_to :release, inverse_of: :evidences, touch: true default_scope { order(created_at: :asc) } # rubocop:disable Cop/DefaultScope diff --git a/app/models/releases/link.rb b/app/models/releases/link.rb index fc2fa639f56..acc56d3980a 100644 --- a/app/models/releases/link.rb +++ b/app/models/releases/link.rb @@ -4,7 +4,7 @@ module Releases class Link < ApplicationRecord self.table_name = 'release_links' - belongs_to :release + belongs_to :release, touch: true # See https://gitlab.com/gitlab-org/gitlab/-/issues/218753 # Regex modified to prevent catastrophic backtracking diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb index c7387d2197d..c3ca90ca0ad 100644 --- a/app/models/remote_mirror.rb +++ b/app/models/remote_mirror.rb @@ -214,7 +214,7 @@ class RemoteMirror < ApplicationRecord if super Gitlab::UrlSanitizer.new(super, credentials: credentials).full_url end - rescue + rescue StandardError super end @@ -275,7 +275,7 @@ class RemoteMirror < ApplicationRecord return url unless ssh_key_auth? && password.present? Gitlab::UrlSanitizer.new(read_attribute(:url), credentials: { user: user }).full_url - rescue + rescue StandardError super end @@ -339,4 +339,4 @@ class RemoteMirror < ApplicationRecord end end -RemoteMirror.prepend_if_ee('EE::RemoteMirror') +RemoteMirror.prepend_mod_with('RemoteMirror') diff --git a/app/models/repository.rb b/app/models/repository.rb index b2efc9b480b..7dca8e52403 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -995,7 +995,13 @@ class Repository def search_files_by_wildcard_path(path, ref = 'HEAD') # We need to use RE2 to match Gitaly's regexp engine - regexp_string = RE2::Regexp.escape(path).gsub('\*', '.*?') + regexp_string = RE2::Regexp.escape(path) + + anything = '.*?' + anything_but_not_slash = '([^\/])*?' + regexp_string.gsub!('\*\*', anything) + regexp_string.gsub!('\*', anything_but_not_slash) + raw_repository.search_files_by_regexp("^#{regexp_string}$", ref) end @@ -1165,17 +1171,13 @@ class Repository end def tags_sorted_by_committed_date - tags.sort_by do |tag| - # Annotated tags can point to any object (e.g. a blob), but generally - # tags point to a commit. If we don't have a commit, then just default - # to putting the tag at the end of the list. - target = tag.dereferenced_target + # Annotated tags can point to any object (e.g. a blob), but generally + # tags point to a commit. If we don't have a commit, then just default + # to putting the tag at the end of the list. + default = Time.current - if target - target.committed_date - else - Time.current - end + tags.sort_by do |tag| + tag.dereferenced_target&.committed_date || default end end @@ -1191,4 +1193,4 @@ class Repository end end -Repository.prepend_if_ee('EE::Repository') +Repository.prepend_mod_with('Repository') diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index 57a3b568c53..68f0ab06bea 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -115,4 +115,4 @@ class ResourceLabelEvent < ResourceEvent end end -ResourceLabelEvent.prepend_if_ee('EE::ResourceLabelEvent') +ResourceLabelEvent.prepend_mod_with('ResourceLabelEvent') diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb index 73eb4987143..689a9d8a8ae 100644 --- a/app/models/resource_state_event.rb +++ b/app/models/resource_state_event.rb @@ -45,4 +45,4 @@ class ResourceStateEvent < ResourceEvent end end -ResourceStateEvent.prepend_if_ee('EE::ResourceStateEvent') +ResourceStateEvent.prepend_mod_with('ResourceStateEvent') diff --git a/app/models/resource_timebox_event.rb b/app/models/resource_timebox_event.rb index 71077758b69..db87ff09159 100644 --- a/app/models/resource_timebox_event.rb +++ b/app/models/resource_timebox_event.rb @@ -41,4 +41,4 @@ class ResourceTimeboxEvent < ResourceEvent end end -ResourceTimeboxEvent.prepend_if_ee('EE::ResourceTimeboxEvent') +ResourceTimeboxEvent.prepend_mod_with('ResourceTimeboxEvent') diff --git a/app/models/serverless/domain_cluster.rb b/app/models/serverless/domain_cluster.rb index 9f914d5c3f8..0d54a97370e 100644 --- a/app/models/serverless/domain_cluster.rb +++ b/app/models/serverless/domain_cluster.rb @@ -12,7 +12,7 @@ module Serverless attr_encrypted :key, mode: :per_attribute_iv, - key: Settings.attr_encrypted_db_key_base_truncated, + key: Settings.attr_encrypted_db_key_base_32, algorithm: 'aes-256-gcm' validates :pages_domain, :knative, presence: true diff --git a/app/models/service_list.rb b/app/models/service_list.rb index 5eca5f2bda1..8a52539d128 100644 --- a/app/models/service_list.rb +++ b/app/models/service_list.rb @@ -8,7 +8,7 @@ class ServiceList end def to_array - [Service, columns, values] + [Integration, columns, values] end private diff --git a/app/models/sidebars/context.rb b/app/models/sidebars/context.rb deleted file mode 100644 index d9ac2705aaf..00000000000 --- a/app/models/sidebars/context.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -# This class stores all the information needed to display and -# render the sidebar and menus. -# It usually stores information regarding the context and calculated -# values where the logic is in helpers. -module Sidebars - class Context - attr_reader :current_user, :container - - def initialize(current_user:, container:, **args) - @current_user = current_user - @container = container - - args.each do |key, value| - singleton_class.public_send(:attr_reader, key) # rubocop:disable GitlabSecurity/PublicSend - instance_variable_set("@#{key}", value) - end - end - end -end diff --git a/app/models/sidebars/menu.rb b/app/models/sidebars/menu.rb deleted file mode 100644 index a5c8be2bb31..00000000000 --- a/app/models/sidebars/menu.rb +++ /dev/null @@ -1,82 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - class Menu - extend ::Gitlab::Utils::Override - include ::Gitlab::Routing - include GitlabRoutingHelper - include Gitlab::Allowable - include ::Sidebars::HasPill - include ::Sidebars::HasIcon - include ::Sidebars::PositionableList - include ::Sidebars::Renderable - include ::Sidebars::ContainerWithHtmlOptions - include ::Sidebars::HasActiveRoutes - - attr_reader :context - delegate :current_user, :container, to: :@context - - def initialize(context) - @context = context - @items = [] - - configure_menu_items - end - - def configure_menu_items - # No-op - end - - override :render? - def render? - @items.empty? || renderable_items.any? - end - - # Menus might have or not a link - override :link - def link - nil - end - - # This method normalizes the information retrieved from the submenus and this menu - # Value from menus is something like: [{ path: 'foo', path: 'bar', controller: :foo }] - # This method filters the information and returns: { path: ['foo', 'bar'], controller: :foo } - def all_active_routes - @all_active_routes ||= begin - ([active_routes] + renderable_items.map(&:active_routes)).flatten.each_with_object({}) do |pairs, hash| - pairs.each do |k, v| - hash[k] ||= [] - hash[k] += Array(v) - hash[k].uniq! - end - - hash - end - end - end - - def has_items? - @items.any? - end - - def add_item(item) - add_element(@items, item) - end - - def insert_item_before(before_item, new_item) - insert_element_before(@items, before_item, new_item) - end - - def insert_item_after(after_item, new_item) - insert_element_after(@items, after_item, new_item) - end - - def has_renderable_items? - renderable_items.any? - end - - def renderable_items - @renderable_items ||= @items.select(&:render?) - end - end -end diff --git a/app/models/sidebars/menu_item.rb b/app/models/sidebars/menu_item.rb deleted file mode 100644 index 7466b31898e..00000000000 --- a/app/models/sidebars/menu_item.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - class MenuItem - extend ::Gitlab::Utils::Override - include ::Gitlab::Routing - include GitlabRoutingHelper - include Gitlab::Allowable - include ::Sidebars::HasIcon - include ::Sidebars::HasHint - include ::Sidebars::Renderable - include ::Sidebars::ContainerWithHtmlOptions - include ::Sidebars::HasActiveRoutes - - attr_reader :context - - def initialize(context) - @context = context - end - end -end diff --git a/app/models/sidebars/panel.rb b/app/models/sidebars/panel.rb deleted file mode 100644 index 5c8191ebda3..00000000000 --- a/app/models/sidebars/panel.rb +++ /dev/null @@ -1,75 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - class Panel - extend ::Gitlab::Utils::Override - include ::Sidebars::PositionableList - - attr_reader :context, :scope_menu, :hidden_menu - - def initialize(context) - @context = context - @scope_menu = nil - @hidden_menu = nil - @menus = [] - - configure_menus - end - - def configure_menus - # No-op - end - - def add_menu(menu) - add_element(@menus, menu) - end - - def insert_menu_before(before_menu, new_menu) - insert_element_before(@menus, before_menu, new_menu) - end - - def insert_menu_after(after_menu, new_menu) - insert_element_after(@menus, after_menu, new_menu) - end - - def set_scope_menu(scope_menu) - @scope_menu = scope_menu - end - - def set_hidden_menu(hidden_menu) - @hidden_menu = hidden_menu - end - - def aria_label - raise NotImplementedError - end - - def has_renderable_menus? - renderable_menus.any? - end - - def renderable_menus - @renderable_menus ||= @menus.select(&:render?) - end - - def container - context.container - end - - # Auxiliar method that helps with the migration from - # regular views to the new logic - def render_raw_scope_menu_partial - # No-op - end - - # Auxiliar method that helps with the migration from - # regular views to the new logic. - # - # Any menu inside this partial will be added after - # all the menus added in the `configure_menus` - # method. - def render_raw_menus_partial - # No-op - end - end -end diff --git a/app/models/sidebars/projects/context.rb b/app/models/sidebars/projects/context.rb deleted file mode 100644 index 4c82309035d..00000000000 --- a/app/models/sidebars/projects/context.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - class Context < ::Sidebars::Context - def initialize(current_user:, container:, **args) - super(current_user: current_user, container: container, project: container, **args) - end - end - end -end diff --git a/app/models/sidebars/projects/menus/learn_gitlab/menu.rb b/app/models/sidebars/projects/menus/learn_gitlab/menu.rb deleted file mode 100644 index 4b572846d1a..00000000000 --- a/app/models/sidebars/projects/menus/learn_gitlab/menu.rb +++ /dev/null @@ -1,41 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module LearnGitlab - class Menu < ::Sidebars::Menu - override :link - def link - project_learn_gitlab_path(context.project) - end - - override :active_routes - def active_routes - { controller: :learn_gitlab } - end - - override :title - def title - _('Learn GitLab') - end - - override :extra_container_html_options - def nav_link_html_options - { class: 'home' } - end - - override :sprite_icon - def sprite_icon - 'home' - end - - override :render? - def render? - context.learn_gitlab_experiment_enabled - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/project_overview/menu.rb b/app/models/sidebars/projects/menus/project_overview/menu.rb deleted file mode 100644 index e6aa8ed159f..00000000000 --- a/app/models/sidebars/projects/menus/project_overview/menu.rb +++ /dev/null @@ -1,45 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module ProjectOverview - class Menu < ::Sidebars::Menu - override :configure_menu_items - def configure_menu_items - add_item(MenuItems::Details.new(context)) - add_item(MenuItems::Activity.new(context)) - add_item(MenuItems::Releases.new(context)) - end - - override :link - def link - project_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - class: 'shortcuts-project rspec-project-link' - } - end - - override :extra_container_html_options - def nav_link_html_options - { class: 'home' } - end - - override :title - def title - _('Project overview') - end - - override :sprite_icon - def sprite_icon - 'home' - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb b/app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb deleted file mode 100644 index 46d0f0bc43b..00000000000 --- a/app/models/sidebars/projects/menus/project_overview/menu_items/activity.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module ProjectOverview - module MenuItems - class Activity < ::Sidebars::MenuItem - override :link - def link - activity_project_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - class: 'shortcuts-project-activity' - } - end - - override :active_routes - def active_routes - { path: 'projects#activity' } - end - - override :title - def title - _('Activity') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/project_overview/menu_items/details.rb b/app/models/sidebars/projects/menus/project_overview/menu_items/details.rb deleted file mode 100644 index c40c2ed8fa2..00000000000 --- a/app/models/sidebars/projects/menus/project_overview/menu_items/details.rb +++ /dev/null @@ -1,36 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module ProjectOverview - module MenuItems - class Details < ::Sidebars::MenuItem - override :link - def link - project_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - aria: { label: _('Project details') }, - class: 'shortcuts-project' - } - end - - override :active_routes - def active_routes - { path: 'projects#show' } - end - - override :title - def title - _('Details') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb b/app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb deleted file mode 100644 index 5e8348f4398..00000000000 --- a/app/models/sidebars/projects/menus/project_overview/menu_items/releases.rb +++ /dev/null @@ -1,40 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module ProjectOverview - module MenuItems - class Releases < ::Sidebars::MenuItem - override :link - def link - project_releases_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - class: 'shortcuts-project-releases' - } - end - - override :render? - def render? - can?(context.current_user, :read_release, context.project) && !context.project.empty_repo? - end - - override :active_routes - def active_routes - { controller: :releases } - end - - override :title - def title - _('Releases') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu.rb b/app/models/sidebars/projects/menus/repository/menu.rb deleted file mode 100644 index f49a0479521..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu.rb +++ /dev/null @@ -1,59 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - class Menu < ::Sidebars::Menu - override :configure_menu_items - def configure_menu_items - add_item(MenuItems::Files.new(context)) - add_item(MenuItems::Commits.new(context)) - add_item(MenuItems::Branches.new(context)) - add_item(MenuItems::Tags.new(context)) - add_item(MenuItems::Contributors.new(context)) - add_item(MenuItems::Graphs.new(context)) - add_item(MenuItems::Compare.new(context)) - end - - override :link - def link - project_tree_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - class: 'shortcuts-tree' - } - end - - override :title - def title - _('Repository') - end - - override :title_html_options - def title_html_options - { - id: 'js-onboarding-repo-link' - } - end - - override :sprite_icon - def sprite_icon - 'doc-text' - end - - override :render? - def render? - can?(context.current_user, :download_code, context.project) && - !context.project.empty_repo? - end - end - end - end - end -end - -Sidebars::Projects::Menus::Repository::Menu.prepend_if_ee('EE::Sidebars::Projects::Menus::Repository::Menu') diff --git a/app/models/sidebars/projects/menus/repository/menu_items/branches.rb b/app/models/sidebars/projects/menus/repository/menu_items/branches.rb deleted file mode 100644 index 4a62803dd2b..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/branches.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Branches < ::Sidebars::MenuItem - override :link - def link - project_branches_path(context.project) - end - - override :extra_container_html_options - def extra_container_html_options - { - id: 'js-onboarding-branches-link' - } - end - - override :active_routes - def active_routes - { controller: :branches } - end - - override :title - def title - _('Branches') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/commits.rb b/app/models/sidebars/projects/menus/repository/menu_items/commits.rb deleted file mode 100644 index 647cf89133e..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/commits.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Commits < ::Sidebars::MenuItem - override :link - def link - project_commits_path(context.project, context.current_ref) - end - - override :extra_container_html_options - def extra_container_html_options - { - id: 'js-onboarding-commits-link' - } - end - - override :active_routes - def active_routes - { controller: %w(commit commits) } - end - - override :title - def title - _('Commits') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/compare.rb b/app/models/sidebars/projects/menus/repository/menu_items/compare.rb deleted file mode 100644 index 4812636b63f..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/compare.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Compare < ::Sidebars::MenuItem - override :link - def link - project_compare_index_path(context.project, from: context.project.repository.root_ref, to: context.current_ref) - end - - override :active_routes - def active_routes - { controller: :compare } - end - - override :title - def title - _('Compare') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/contributors.rb b/app/models/sidebars/projects/menus/repository/menu_items/contributors.rb deleted file mode 100644 index d60fd05bb64..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/contributors.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Contributors < ::Sidebars::MenuItem - override :link - def link - project_graph_path(context.project, context.current_ref) - end - - override :active_routes - def active_routes - { path: 'graphs#show' } - end - - override :title - def title - _('Contributors') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/files.rb b/app/models/sidebars/projects/menus/repository/menu_items/files.rb deleted file mode 100644 index 4989efe9fa5..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/files.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Files < ::Sidebars::MenuItem - override :link - def link - project_tree_path(context.project, context.current_ref) - end - - override :active_routes - def active_routes - { controller: %w[tree blob blame edit_tree new_tree find_file] } - end - - override :title - def title - _('Files') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/graphs.rb b/app/models/sidebars/projects/menus/repository/menu_items/graphs.rb deleted file mode 100644 index a57021be4d0..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/graphs.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Graphs < ::Sidebars::MenuItem - override :link - def link - project_network_path(context.project, context.current_ref) - end - - override :active_routes - def active_routes - { controller: :network } - end - - override :title - def title - _('Graph') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/repository/menu_items/tags.rb b/app/models/sidebars/projects/menus/repository/menu_items/tags.rb deleted file mode 100644 index d84bc89b93c..00000000000 --- a/app/models/sidebars/projects/menus/repository/menu_items/tags.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Repository - module MenuItems - class Tags < ::Sidebars::MenuItem - override :link - def link - project_tags_path(context.project) - end - - override :active_routes - def active_routes - { controller: :tags } - end - - override :title - def title - _('Tags') - end - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/menus/scope/menu.rb b/app/models/sidebars/projects/menus/scope/menu.rb deleted file mode 100644 index 3b699083f75..00000000000 --- a/app/models/sidebars/projects/menus/scope/menu.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - module Menus - module Scope - class Menu < ::Sidebars::Menu - override :link - def link - project_path(context.project) - end - - override :title - def title - context.project.name - end - end - end - end - end -end diff --git a/app/models/sidebars/projects/panel.rb b/app/models/sidebars/projects/panel.rb deleted file mode 100644 index ec4fac53a40..00000000000 --- a/app/models/sidebars/projects/panel.rb +++ /dev/null @@ -1,26 +0,0 @@ -# frozen_string_literal: true - -module Sidebars - module Projects - class Panel < ::Sidebars::Panel - override :configure_menus - def configure_menus - set_scope_menu(Sidebars::Projects::Menus::Scope::Menu.new(context)) - - add_menu(Sidebars::Projects::Menus::ProjectOverview::Menu.new(context)) - add_menu(Sidebars::Projects::Menus::LearnGitlab::Menu.new(context)) - add_menu(Sidebars::Projects::Menus::Repository::Menu.new(context)) - end - - override :render_raw_menus_partial - def render_raw_menus_partial - 'layouts/nav/sidebar/project_menus' - end - - override :aria_label - def aria_label - _('Project navigation') - end - end - end -end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 5fdd4551982..68957dd6b22 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -20,7 +20,6 @@ class Snippet < ApplicationRecord extend ::Gitlab::Utils::Override MAX_FILE_COUNT = 10 - MASTER_BRANCH = 'master' cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description @@ -118,7 +117,7 @@ class Snippet < ApplicationRecord def self.only_include_projects_visible_to(current_user = nil) levels = Gitlab::VisibilityLevel.levels_for_user(current_user) - joins(:project).where('projects.visibility_level IN (?)', levels) + joins(:project).where(projects: { visibility_level: levels }) end def self.only_include_projects_with_snippets_enabled(include_private: false) @@ -316,19 +315,19 @@ class Snippet < ApplicationRecord override :default_branch def default_branch - super || MASTER_BRANCH + super || Gitlab::DefaultBranch.value(object: project) end def repository_storage snippet_repository&.shard_name || Repository.pick_storage_shard end - # Repositories are created by default with the `master` branch. + # Repositories are created with a default branch. This branch + # can be different from the default branch set in the platform. # This method changes the `HEAD` file to point to the existing - # default branch in case it's not master. + # default branch in case it's different. def change_head_to_default_branch return unless repository.exists? - return if default_branch == MASTER_BRANCH # All snippets must have at least 1 file. Therefore, if # `HEAD` is empty is because it's pointing to the wrong # default branch @@ -391,4 +390,4 @@ class Snippet < ApplicationRecord end end -Snippet.prepend_if_ee('EE::Snippet') +Snippet.prepend_mod_with('Snippet') diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index 54dbc579d54..92405a0d943 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -133,4 +133,4 @@ class SnippetRepository < ApplicationRecord end end -SnippetRepository.prepend_if_ee('EE::SnippetRepository') +SnippetRepository.prepend_mod_with('SnippetRepository') diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb index 7e34988c7a0..bb928118edf 100644 --- a/app/models/ssh_host_key.rb +++ b/app/models/ssh_host_key.rb @@ -128,10 +128,10 @@ class SshHostKey def normalize_url(url) full_url = ::Addressable::URI.parse(url) - raise ArgumentError.new("Invalid URL") unless full_url&.scheme == 'ssh' + raise ArgumentError, "Invalid URL" unless full_url&.scheme == 'ssh' Addressable::URI.parse("ssh://#{full_url.host}:#{full_url.inferred_port}") rescue Addressable::URI::InvalidURIError - raise ArgumentError.new("Invalid URL") + raise ArgumentError, "Invalid URL" end end diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb index f643d52587e..092e5249a3e 100644 --- a/app/models/storage/legacy_project.rb +++ b/app/models/storage/legacy_project.rb @@ -34,7 +34,7 @@ module Storage begin gitlab_shell.mv_repository(repository_storage, "#{old_full_path}.wiki", "#{new_full_path}.wiki") return true - rescue => e + rescue StandardError => e Gitlab::AppLogger.error("Exception renaming #{old_full_path} -> #{new_full_path}: #{e}") # Returning false does not rollback after_* transaction but gives # us information about failing some of tasks diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index 20107147b4f..749b9dce97c 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -41,4 +41,4 @@ class SystemNoteMetadata < ApplicationRecord end end -SystemNoteMetadata.prepend_if_ee('EE::SystemNoteMetadata') +SystemNoteMetadata.prepend_mod_with('SystemNoteMetadata') diff --git a/app/models/terraform/state.rb b/app/models/terraform/state.rb index eb7d465d585..8aeeae1330c 100644 --- a/app/models/terraform/state.rb +++ b/app/models/terraform/state.rb @@ -104,3 +104,5 @@ module Terraform end end end + +Terraform::State.prepend_mod diff --git a/app/models/terraform/state_version.rb b/app/models/terraform/state_version.rb index 432ac5b6422..31ff7e4c27d 100644 --- a/app/models/terraform/state_version.rb +++ b/app/models/terraform/state_version.rb @@ -20,4 +20,4 @@ module Terraform end end -Terraform::StateVersion.prepend_if_ee('EE::Terraform::StateVersion') +Terraform::StateVersion.prepend_mod_with('Terraform::StateVersion') diff --git a/app/models/timelog.rb b/app/models/timelog.rb index c1aa84cbbcd..bd543526685 100644 --- a/app/models/timelog.rb +++ b/app/models/timelog.rb @@ -3,20 +3,19 @@ class Timelog < ApplicationRecord include Importable + before_save :set_project + validates :time_spent, :user, presence: true validate :issuable_id_is_present, unless: :importing? belongs_to :issue, touch: true belongs_to :merge_request, touch: true + belongs_to :project belongs_to :user belongs_to :note - scope :for_issues_in_group, -> (group) do - joins(:issue).where( - 'EXISTS (?)', - Project.select(1).where(namespace: group.self_and_descendants) - .where('issues.project_id = projects.id') - ) + scope :in_group, -> (group) do + joins(:project).where(projects: { namespace: group.self_and_descendants }) end scope :between_times, -> (start_time, end_time) do @@ -37,6 +36,10 @@ class Timelog < ApplicationRecord end end + def set_project + self.project_id = issuable.project_id + end + # Rails5 defaults to :touch_later, overwrite for normal touch def belongs_to_touch_method :touch diff --git a/app/models/todo.rb b/app/models/todo.rb index c8138587d83..23685fb68e0 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -149,8 +149,8 @@ class Todo < ApplicationRecord .order('todos.created_at') end - def pluck_user_id - pluck(:user_id) + def distinct_user_ids + distinct.pluck(:user_id) end # Count todos grouped by user_id and state, using an UNION query @@ -252,4 +252,4 @@ class Todo < ApplicationRecord end end -Todo.prepend_if_ee('EE::Todo') +Todo.prepend_mod_with('Todo') diff --git a/app/models/upload.rb b/app/models/upload.rb index 46ae924bf8c..0a4acdfc7e3 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -163,4 +163,4 @@ class Upload < ApplicationRecord end end -Upload.prepend_if_ee('EE::Upload') +Upload.prepend_mod_with('Upload') diff --git a/app/models/user.rb b/app/models/user.rb index 507e8cc2cf5..0eb58baae11 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -33,6 +33,8 @@ class User < ApplicationRecord BLOCKED_PENDING_APPROVAL_STATE = 'blocked_pending_approval' + COUNT_CACHE_VALIDITY_PERIOD = 24.hours + add_authentication_token_field :incoming_email_token, token_generator: -> { SecureRandom.hex.to_i(16).to_s(36) } add_authentication_token_field :feed_token add_authentication_token_field :static_object_token @@ -94,6 +96,12 @@ class User < ApplicationRecord # Virtual attribute for impersonator attr_accessor :impersonator + attr_writer :max_access_for_group + + def max_access_for_group + @max_access_for_group ||= {} + end + # # Relations # @@ -197,6 +205,7 @@ class User < ApplicationRecord has_one :user_detail has_one :user_highest_role has_one :user_canonical_email + has_one :credit_card_validation, class_name: '::Users::CreditCardValidation' has_one :atlassian_identity, class_name: 'Atlassian::Identity' has_many :reviews, foreign_key: :author_id, inverse_of: :author @@ -309,6 +318,7 @@ class User < ApplicationRecord accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true + accepts_nested_attributes_for :credit_card_validation, update_only: true state_machine :state, initial: :active do event :block do @@ -316,6 +326,7 @@ class User < ApplicationRecord transition deactivated: :blocked transition ldap_blocked: :blocked transition blocked_pending_approval: :blocked + transition banned: :blocked end event :ldap_block do @@ -328,17 +339,24 @@ class User < ApplicationRecord transition blocked: :active transition ldap_blocked: :active transition blocked_pending_approval: :active + transition banned: :active end event :block_pending_approval do transition active: :blocked_pending_approval end + event :ban do + transition active: :banned + end + event :deactivate do + # Any additional changes to this event should be also + # reflected in app/workers/users/deactivate_dormant_users_worker.rb transition active: :deactivated end - state :blocked, :ldap_blocked, :blocked_pending_approval do + state :blocked, :ldap_blocked, :blocked_pending_approval, :banned do def blocked? true end @@ -365,6 +383,7 @@ class User < ApplicationRecord scope :instance_access_request_approvers_to_be_notified, -> { admins.active.order_recent_sign_in.limit(INSTANCE_ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT) } scope :blocked, -> { with_states(:blocked, :ldap_blocked) } scope :blocked_pending_approval, -> { with_states(:blocked_pending_approval) } + scope :banned, -> { with_states(:banned) } scope :external, -> { where(external: true) } scope :non_external, -> { where(external: false) } scope :confirmed, -> { where.not(confirmed_at: nil) } @@ -376,7 +395,7 @@ class User < ApplicationRecord scope :by_name, -> (names) { iwhere(name: Array(names)) } scope :by_user_email, -> (emails) { iwhere(email: Array(emails)) } scope :by_emails, -> (emails) { joins(:emails).where(emails: { email: Array(emails).map(&:downcase) }) } - scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) } + scope :for_todos, -> (todos) { where(id: todos.select(:user_id).distinct) } scope :with_emails, -> { preload(:emails) } scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) } scope :with_public_profile, -> { where(private_profile: false) } @@ -416,10 +435,12 @@ class User < ApplicationRecord scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) } scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) } scope :by_id_and_login, ->(id, login) { where(id: id).where('username = LOWER(:login) OR email = LOWER(:login)', login: login) } + scope :dormant, -> { active.where('last_activity_on <= ?', MINIMUM_INACTIVE_DAYS.day.ago.to_date) } + scope :with_no_activity, -> { active.where(last_activity_on: nil) } def preferred_language read_attribute('preferred_language') || - I18n.default_locale.to_s.presence_in(Gitlab::I18n::AVAILABLE_LANGUAGES.keys) || + I18n.default_locale.to_s.presence_in(Gitlab::I18n.available_locales) || 'en' end @@ -584,6 +605,8 @@ class User < ApplicationRecord blocked when 'blocked_pending_approval' blocked_pending_approval + when 'banned' + banned when 'two_factor_disabled' without_two_factor when 'two_factor_enabled' @@ -1098,6 +1121,11 @@ class User < ApplicationRecord Gitlab::CurrentSettings.password_authentication_enabled_for_git? && !password_based_omniauth_user? end + # method overriden in EE + def password_based_login_forbidden? + false + end + def can_change_username? gitlab_config.username_changing_enabled end @@ -1211,6 +1239,10 @@ class User < ApplicationRecord user_highest_role&.highest_access_level || Gitlab::Access::NO_ACCESS end + def credit_card_validated_at + credit_card_validation&.credit_card_validated_at + end + def accessible_deploy_keys DeployKey.from_union([ DeployKey.where(id: project_deploy_keys.select(:deploy_key_id)), @@ -1414,7 +1446,9 @@ class User < ApplicationRecord if namespace_path_errors.include?('has already been taken') && !User.exists?(username: username) self.errors.add(:base, :username_exists_as_a_different_namespace) else - self.errors[:username].concat(namespace_path_errors) + namespace_path_errors.each do |msg| + self.errors.add(:username, msg) + end end end @@ -1619,40 +1653,32 @@ class User < ApplicationRecord @global_notification_setting end - def count_cache_validity_period - if Feature.enabled?(:longer_count_cache_validity, self, default_enabled: :yaml) - 24.hours - else - 20.minutes - end - end - def assigned_open_merge_requests_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'assigned_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do MergeRequestsFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count end end def review_requested_open_merge_requests_count(force: false) - Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'review_requested_open_merge_requests_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do MergeRequestsFinder.new(self, reviewer_id: id, state: 'opened', non_archived: true).execute.count end end def assigned_open_issues_count(force: false) - Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'assigned_open_issues_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count end end def todos_done_count(force: false) - Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do TodosFinder.new(self, state: :done).execute.count end end def todos_pending_count(force: false) - Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: count_cache_validity_period) do + Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do TodosFinder.new(self, state: :pending).execute.count end end @@ -1677,6 +1703,12 @@ class User < ApplicationRecord def invalidate_issue_cache_counts Rails.cache.delete(['users', id, 'assigned_open_issues_count']) + + if Feature.enabled?(:assigned_open_issues_cache, default_enabled: :yaml) + run_after_commit do + Users::UpdateOpenIssueCountWorker.perform_async(self.id) + end + end end def invalidate_merge_request_cache_counts @@ -2061,4 +2093,4 @@ class User < ApplicationRecord end end -User.prepend_if_ee('EE::User') +User.prepend_mod_with('User') diff --git a/app/models/user_callout.rb b/app/models/user_callout.rb index 0a4db707be6..8fc9efddac9 100644 --- a/app/models/user_callout.rb +++ b/app/models/user_callout.rb @@ -30,7 +30,9 @@ class UserCallout < ApplicationRecord new_user_signups_cap_reached: 26, # EE-only unfinished_tag_cleanup_callout: 27, eoa_bronze_plan_banner: 28, # EE-only - pipeline_needs_banner: 29 + pipeline_needs_banner: 29, + pipeline_needs_hover_tip: 30, + web_ide_ci_environments_guidance: 31 } validates :user, presence: true diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 6b64f583927..458764632ed 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -32,4 +32,4 @@ class UserDetail < ApplicationRecord end end -UserDetail.prepend_if_ee('EE::UserDetail') +UserDetail.prepend_mod_with('UserDetail') diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 0bf8c8f901d..2735e169b5f 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -71,4 +71,4 @@ class UserPreference < ApplicationRecord end end -UserPreference.prepend_if_ee('EE::UserPreference') +UserPreference.prepend_mod_with('UserPreference') diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb new file mode 100644 index 00000000000..5e255acd882 --- /dev/null +++ b/app/models/users/credit_card_validation.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Users + class CreditCardValidation < ApplicationRecord + RELEASE_DAY = Date.new(2021, 5, 17) + + self.table_name = 'user_credit_card_validations' + + belongs_to :user + end +end diff --git a/app/models/users/merge_request_interaction.rb b/app/models/users/merge_request_interaction.rb index 35d1d3206b5..4af9361fbf6 100644 --- a/app/models/users/merge_request_interaction.rb +++ b/app/models/users/merge_request_interaction.rb @@ -41,4 +41,4 @@ module Users end end -::Users::MergeRequestInteraction.prepend_if_ee('EE::Users::MergeRequestInteraction') +::Users::MergeRequestInteraction.prepend_mod_with('Users::MergeRequestInteraction') diff --git a/app/models/users_statistics.rb b/app/models/users_statistics.rb index d724b06a996..a903541f69a 100644 --- a/app/models/users_statistics.rb +++ b/app/models/users_statistics.rb @@ -71,4 +71,4 @@ class UsersStatistics < ApplicationRecord end end -UsersStatistics.prepend_if_ee('EE::UsersStatistics') +UsersStatistics.prepend_mod_with('UsersStatistics') diff --git a/app/models/vulnerability.rb b/app/models/vulnerability.rb index 7728c9c174e..4e1f48227d9 100644 --- a/app/models/vulnerability.rb +++ b/app/models/vulnerability.rb @@ -17,4 +17,4 @@ class Vulnerability < ApplicationRecord end end -Vulnerability.prepend_ee_mod +Vulnerability.prepend_mod diff --git a/app/models/wiki.rb b/app/models/wiki.rb index 47fe40b0e57..7fc01f373c8 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -88,7 +88,7 @@ class Wiki repository.create_if_not_exists raise CouldNotCreateWikiError unless repository_exists? - rescue => err + rescue StandardError => err Gitlab::ErrorTracking.track_exception(err, wiki: { container_type: container.class.name, container_id: container.id, @@ -192,16 +192,9 @@ class Wiki def delete_page(page, message = nil) return unless page - if Feature.enabled?(:gitaly_replace_wiki_delete_page, user, default_enabled: :yaml) - capture_git_error(:deleted) do - repository.delete_file(user, page.path, **multi_commit_options(:deleted, message, page.title)) + capture_git_error(:deleted) do + repository.delete_file(user, page.path, **multi_commit_options(:deleted, message, page.title)) - after_wiki_activity - - true - end - else - wiki.delete_page(page.path, commit_details(:deleted, message, page.title)) after_wiki_activity true @@ -327,4 +320,4 @@ class Wiki end end -Wiki.prepend_if_ee('EE::Wiki') +Wiki.prepend_mod_with('Wiki') diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 3b9a7ded83e..9ae5a870323 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -127,10 +127,21 @@ class WikiPage @path ||= @page.path end + # Returns a CommitCollection + # + # Queries the commits for current page's path, equivalent to + # `git log path/to/page`. Filters and options supported: + # https://gitlab.com/gitlab-org/gitaly/-/blob/master/proto/commit.proto#L322-344 def versions(options = {}) return [] unless persisted? - wiki.wiki.page_versions(page.path, options) + default_per_page = Kaminari.config.default_per_page + offset = [options[:page].to_i - 1, 0].max * options.fetch(:per_page, default_per_page) + + wiki.repository.commits('HEAD', + path: page.path, + limit: options.fetch(:limit, default_per_page), + offset: offset) end def count_versions |