Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 23:02:30 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2022-03-18 23:02:30 +0300
commit41fe97390ceddf945f3d967b8fdb3de4c66b7dea (patch)
tree9c8d89a8624828992f06d892cd2f43818ff5dcc8 /app/models
parent0804d2dc31052fb45a1efecedc8e06ce9bc32862 (diff)
Add latest changes from gitlab-org/gitlab@14-9-stable-eev14.9.0-rc42
Diffstat (limited to 'app/models')
-rw-r--r--app/models/analytics/cycle_analytics/aggregation.rb75
-rw-r--r--app/models/application_record.rb11
-rw-r--r--app/models/application_setting.rb21
-rw-r--r--app/models/application_setting_implementation.rb9
-rw-r--r--app/models/blobs/notebook.rb12
-rw-r--r--app/models/broadcast_message.rb40
-rw-r--r--app/models/bulk_imports/entity.rb2
-rw-r--r--app/models/bulk_imports/export_status.rb7
-rw-r--r--app/models/ci/bridge.rb70
-rw-r--r--app/models/ci/build.rb16
-rw-r--r--app/models/ci/group_variable.rb1
-rw-r--r--app/models/ci/pipeline.rb13
-rw-r--r--app/models/ci/pipeline_schedule.rb12
-rw-r--r--app/models/ci/processable.rb2
-rw-r--r--app/models/ci/runner.rb8
-rw-r--r--app/models/ci/secure_file.rb4
-rw-r--r--app/models/concerns/blocks_json_serialization.rb18
-rw-r--r--app/models/concerns/blocks_unsafe_serialization.rb32
-rw-r--r--app/models/concerns/bulk_member_access_load.rb52
-rw-r--r--app/models/concerns/ci/has_deployment_name.rb15
-rw-r--r--app/models/concerns/ci/has_status.rb6
-rw-r--r--app/models/concerns/counter_attribute.rb26
-rw-r--r--app/models/concerns/deployment_platform.rb2
-rw-r--r--app/models/concerns/has_user_type.rb13
-rw-r--r--app/models/concerns/integrations/has_issue_tracker_fields.rb30
-rw-r--r--app/models/concerns/issuable.rb60
-rw-r--r--app/models/concerns/issuable_link.rb55
-rw-r--r--app/models/concerns/issue_resource_event.rb6
-rw-r--r--app/models/concerns/merge_request_reviewer_state.rb8
-rw-r--r--app/models/concerns/pg_full_text_searchable.rb111
-rw-r--r--app/models/concerns/runners_token_prefixable.rb6
-rw-r--r--app/models/concerns/select_for_project_authorization.rb6
-rw-r--r--app/models/concerns/sensitive_serializable_hash.rb46
-rw-r--r--app/models/concerns/spammable.rb11
-rw-r--r--app/models/concerns/timebox.rb1
-rw-r--r--app/models/concerns/token_authenticatable.rb10
-rw-r--r--app/models/concerns/token_authenticatable_strategies/base.rb8
-rw-r--r--app/models/concerns/token_authenticatable_strategies/digest.rb4
-rw-r--r--app/models/concerns/token_authenticatable_strategies/encrypted.rb4
-rw-r--r--app/models/concerns/update_namespace_statistics.rb54
-rw-r--r--app/models/container_repository.rb16
-rw-r--r--app/models/customer_relations/contact.rb18
-rw-r--r--app/models/customer_relations/issue_contact.rb8
-rw-r--r--app/models/customer_relations/organization.rb9
-rw-r--r--app/models/dependency_proxy/blob.rb3
-rw-r--r--app/models/dependency_proxy/manifest.rb3
-rw-r--r--app/models/deployment.rb1
-rw-r--r--app/models/diff_note.rb2
-rw-r--r--app/models/environment.rb15
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb4
-rw-r--r--app/models/event_collection.rb48
-rw-r--r--app/models/group.rb10
-rw-r--r--app/models/hooks/web_hook.rb15
-rw-r--r--app/models/instance_configuration.rb3
-rw-r--r--app/models/integration.rb129
-rw-r--r--app/models/integrations/asana.rb4
-rw-r--r--app/models/integrations/bamboo.rb3
-rw-r--r--app/models/integrations/base_chat_notification.rb1
-rw-r--r--app/models/integrations/base_issue_tracker.rb26
-rw-r--r--app/models/integrations/bugzilla.rb4
-rw-r--r--app/models/integrations/campfire.rb4
-rw-r--r--app/models/integrations/confluence.rb4
-rw-r--r--app/models/integrations/custom_issue_tracker.rb5
-rw-r--r--app/models/integrations/discord.rb4
-rw-r--r--app/models/integrations/ewm.rb4
-rw-r--r--app/models/integrations/external_wiki.rb4
-rw-r--r--app/models/integrations/field.rb42
-rw-r--r--app/models/integrations/flowdock.rb4
-rw-r--r--app/models/integrations/hangouts_chat.rb4
-rw-r--r--app/models/integrations/harbor.rb104
-rw-r--r--app/models/integrations/irker.rb6
-rw-r--r--app/models/integrations/jenkins.rb4
-rw-r--r--app/models/integrations/jira.rb110
-rw-r--r--app/models/integrations/mattermost.rb3
-rw-r--r--app/models/integrations/pivotaltracker.rb3
-rw-r--r--app/models/integrations/prometheus.rb1
-rw-r--r--app/models/integrations/redmine.rb5
-rw-r--r--app/models/integrations/webex_teams.rb4
-rw-r--r--app/models/integrations/youtrack.rb4
-rw-r--r--app/models/integrations/zentao.rb5
-rw-r--r--app/models/issue.rb14
-rw-r--r--app/models/issue_link.rb37
-rw-r--r--app/models/issues/search_data.rb11
-rw-r--r--app/models/label.rb25
-rw-r--r--app/models/lfs_download_object.rb1
-rw-r--r--app/models/members/project_member.rb6
-rw-r--r--app/models/merge_request.rb45
-rw-r--r--app/models/milestone.rb1
-rw-r--r--app/models/namespace.rb32
-rw-r--r--app/models/namespace/traversal_hierarchy.rb17
-rw-r--r--app/models/namespaces/traversal/linear.rb9
-rw-r--r--app/models/namespaces/traversal/linear_scopes.rb28
-rw-r--r--app/models/note.rb1
-rw-r--r--app/models/packages/package_file.rb1
-rw-r--r--app/models/packages/pypi/metadatum.rb2
-rw-r--r--app/models/personal_access_token.rb1
-rw-r--r--app/models/preloaders/environments/deployment_preloader.rb22
-rw-r--r--app/models/project.rb79
-rw-r--r--app/models/project_authorization.rb2
-rw-r--r--app/models/project_import_data.rb7
-rw-r--r--app/models/project_pages_metadatum.rb4
-rw-r--r--app/models/project_team.rb44
-rw-r--r--app/models/projects/build_artifacts_size_refresh.rb91
-rw-r--r--app/models/projects/topic.rb7
-rw-r--r--app/models/projects/triggered_hooks.rb25
-rw-r--r--app/models/release.rb4
-rw-r--r--app/models/repository.rb5
-rw-r--r--app/models/snippet.rb16
-rw-r--r--app/models/storage/hashed.rb1
-rw-r--r--app/models/storage/legacy_project.rb1
-rw-r--r--app/models/todo.rb2
-rw-r--r--app/models/user.rb79
-rw-r--r--app/models/users/callout.rb8
-rw-r--r--app/models/users/credit_card_validation.rb2
-rw-r--r--app/models/users/group_callout.rb8
-rw-r--r--app/models/users/saved_reply.rb19
-rw-r--r--app/models/wiki.rb17
-rw-r--r--app/models/wiki_page.rb6
-rw-r--r--app/models/work_item.rb8
-rw-r--r--app/models/work_items/type.rb1
120 files changed, 1637 insertions, 585 deletions
diff --git a/app/models/analytics/cycle_analytics/aggregation.rb b/app/models/analytics/cycle_analytics/aggregation.rb
new file mode 100644
index 00000000000..44d2dc369f7
--- /dev/null
+++ b/app/models/analytics/cycle_analytics/aggregation.rb
@@ -0,0 +1,75 @@
+# frozen_string_literal: true
+
+class Analytics::CycleAnalytics::Aggregation < ApplicationRecord
+ include FromUnion
+
+ belongs_to :group, optional: false
+
+ validates :incremental_runtimes_in_seconds, :incremental_processed_records, :last_full_run_runtimes_in_seconds, :last_full_run_processed_records, presence: true, length: { maximum: 10 }, allow_blank: true
+
+ scope :priority_order, -> (column_to_sort = :last_incremental_run_at) { order(arel_table[column_to_sort].asc.nulls_first) }
+ scope :enabled, -> { where('enabled IS TRUE') }
+
+ def estimated_next_run_at
+ return unless enabled
+ return if last_incremental_run_at.nil?
+
+ estimation = duration_until_the_next_aggregation_job +
+ average_aggregation_duration +
+ (last_incremental_run_at - earliest_last_run_at)
+
+ estimation < 1 ? nil : estimation.from_now
+ end
+
+ def self.safe_create_for_group(group)
+ top_level_group = group.root_ancestor
+ aggregation = find_by(group_id: top_level_group.id)
+ return aggregation if aggregation.present?
+
+ insert({ group_id: top_level_group.id }, unique_by: :group_id)
+ find_by(group_id: top_level_group.id)
+ end
+
+ private
+
+ # The aggregation job is scheduled every 10 minutes: */10 * * * *
+ def duration_until_the_next_aggregation_job
+ (10 - (DateTime.current.minute % 10)).minutes.seconds
+ end
+
+ def average_aggregation_duration
+ return 0.seconds if incremental_runtimes_in_seconds.empty?
+
+ average = incremental_runtimes_in_seconds.sum.fdiv(incremental_runtimes_in_seconds.size)
+ average.seconds
+ end
+
+ def earliest_last_run_at
+ max = self.class.select(:last_incremental_run_at)
+ .where(enabled: true)
+ .where.not(last_incremental_run_at: nil)
+ .priority_order
+ .limit(1)
+ .to_sql
+
+ connection.select_value("(#{max})")
+ end
+
+ def self.load_batch(last_run_at, column_to_query = :last_incremental_run_at, batch_size = 100)
+ last_run_at_not_set = Analytics::CycleAnalytics::Aggregation
+ .enabled
+ .where(column_to_query => nil)
+ .priority_order(column_to_query)
+ .limit(batch_size)
+
+ last_run_at_before = Analytics::CycleAnalytics::Aggregation
+ .enabled
+ .where(arel_table[column_to_query].lt(last_run_at))
+ .priority_order(column_to_query)
+ .limit(batch_size)
+
+ Analytics::CycleAnalytics::Aggregation
+ .from_union([last_run_at_not_set, last_run_at_before], remove_order: false, remove_duplicates: false)
+ .limit(batch_size)
+ end
+end
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 06ff18ca409..198a3653cd3 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -5,6 +5,7 @@ class ApplicationRecord < ActiveRecord::Base
include Transactions
include LegacyBulkInsert
include CrossDatabaseModification
+ include SensitiveSerializableHash
self.abstract_class = true
@@ -60,8 +61,10 @@ class ApplicationRecord < ActiveRecord::Base
end
# Start a new transaction with a shorter-than-usual statement timeout. This is
- # currently one third of the default 15-second timeout
- def self.with_fast_read_statement_timeout(timeout_ms = 5000)
+ # currently one third of the default 15-second timeout with a 500ms buffer
+ # to allow callers gracefully handling the errors to still complete within
+ # the 5s target duration of a low urgency request.
+ def self.with_fast_read_statement_timeout(timeout_ms = 4500)
::Gitlab::Database::LoadBalancing::Session.current.fallback_to_replicas_for_ambiguous_queries do
transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
connection.exec_query("SET LOCAL statement_timeout = #{timeout_ms}")
@@ -99,6 +102,10 @@ class ApplicationRecord < ActiveRecord::Base
where('EXISTS (?)', query.select(1))
end
+ def self.where_not_exists(query)
+ where('NOT EXISTS (?)', query.select(1))
+ end
+
def self.declarative_enum(enum_mod)
enum(enum_mod.key => enum_mod.values)
end
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 02fbf0f855e..c7aad7ff861 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -11,6 +11,7 @@ class ApplicationSetting < ApplicationRecord
ignore_columns %i[elasticsearch_shards elasticsearch_replicas], remove_with: '14.4', remove_after: '2021-09-22'
ignore_columns %i[static_objects_external_storage_auth_token], remove_with: '14.9', remove_after: '2022-03-22'
ignore_column %i[max_package_files_for_package_destruction], remove_with: '14.9', remove_after: '2022-03-22'
+ ignore_column :user_email_lookup_limit, remove_with: '15.0', remove_after: '2022-04-18'
INSTANCE_REVIEW_MIN_USERS = 50
GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \
@@ -362,6 +363,9 @@ class ApplicationSetting < ApplicationRecord
:container_registry_expiration_policies_worker_capacity,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :container_registry_expiration_policies_caching,
+ inclusion: { in: [true, false], message: _('must be a boolean value') }
+
validates :container_registry_import_max_tags_count,
:container_registry_import_max_retries,
:container_registry_import_start_max_retries,
@@ -516,9 +520,12 @@ class ApplicationSetting < ApplicationRecord
validates :notes_create_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
- validates :user_email_lookup_limit,
+ validates :search_rate_limit,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :search_rate_limit_unauthenticated,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
validates :notes_create_limit_allowlist,
length: { maximum: 100, message: N_('is too long (maximum is 100 entries)') },
allow_nil: false
@@ -650,7 +657,17 @@ class ApplicationSetting < ApplicationRecord
users_count >= INSTANCE_REVIEW_MIN_USERS
end
+ Recursion = Class.new(RuntimeError)
+
def self.create_from_defaults
+ # this is posssible if calls to create the record depend on application
+ # settings themselves. This was seen in the case of a feature flag called by
+ # `transaction` that ended up requiring application settings to determine metrics behavior.
+ # If something like that happens, we break the loop here, and let the caller decide how to manage it.
+ raise Recursion if Thread.current[:application_setting_create_from_defaults]
+
+ Thread.current[:application_setting_create_from_defaults] = true
+
check_schema!
transaction(requires_new: true) do # rubocop:disable Performance/ActiveRecordSubtransactions
@@ -659,6 +676,8 @@ class ApplicationSetting < ApplicationRecord
rescue ActiveRecord::RecordNotUnique
# We already have an ApplicationSetting record, so just return it.
current_without_cache
+ ensure
+ Thread.current[:application_setting_create_from_defaults] = nil
end
def self.find_or_create_without_cache
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index 415f0b35f3a..42049713883 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -218,7 +218,9 @@ module ApplicationSettingImplementation
valid_runner_registrars: VALID_RUNNER_REGISTRAR_TYPES,
wiki_page_max_content_bytes: 50.megabytes,
container_registry_delete_tags_service_timeout: 250,
- container_registry_expiration_policies_worker_capacity: 0,
+ container_registry_expiration_policies_worker_capacity: 4,
+ container_registry_cleanup_tags_service_max_list_size: 200,
+ container_registry_expiration_policies_caching: true,
container_registry_import_max_tags_count: 100,
container_registry_import_max_retries: 3,
container_registry_import_start_max_retries: 50,
@@ -231,7 +233,8 @@ module ApplicationSettingImplementation
rate_limiting_response_text: nil,
whats_new_variant: 0,
user_deactivation_emails_enabled: true,
- user_email_lookup_limit: 60,
+ search_rate_limit: 30,
+ search_rate_limit_unauthenticated: 10,
users_get_by_id_limit: 300,
users_get_by_id_limit_allowlist: []
}
@@ -402,7 +405,7 @@ module ApplicationSettingImplementation
def normalized_repository_storage_weights
strong_memoize(:normalized_repository_storage_weights) do
repository_storages_weights = repository_storages_weighted.slice(*Gitlab.config.repositories.storages.keys)
- weights_total = repository_storages_weights.values.reduce(:+)
+ weights_total = repository_storages_weights.values.sum
repository_storages_weights.transform_values do |w|
next w if weights_total == 0
diff --git a/app/models/blobs/notebook.rb b/app/models/blobs/notebook.rb
new file mode 100644
index 00000000000..bdb438cccd9
--- /dev/null
+++ b/app/models/blobs/notebook.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+module Blobs
+ class Notebook < ::Blob
+ attr_reader :data
+
+ def initialize(blob, data)
+ super(blob.__getobj__, blob.container)
+ @data = data
+ end
+ end
+end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 1ee5c081840..949902fbb77 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -4,12 +4,21 @@ class BroadcastMessage < ApplicationRecord
include CacheMarkdownField
include Sortable
+ ALLOWED_TARGET_ACCESS_LEVELS = [
+ Gitlab::Access::GUEST,
+ Gitlab::Access::REPORTER,
+ Gitlab::Access::DEVELOPER,
+ Gitlab::Access::MAINTAINER,
+ Gitlab::Access::OWNER
+ ].freeze
+
cache_markdown_field :message, pipeline: :broadcast_message, whitelisted: true
validates :message, presence: true
validates :starts_at, presence: true
validates :ends_at, presence: true
validates :broadcast_type, presence: true
+ validates :target_access_levels, inclusion: { in: ALLOWED_TARGET_ACCESS_LEVELS }
validates :color, allow_blank: true, color: true
validates :font, allow_blank: true, color: true
@@ -29,20 +38,20 @@ class BroadcastMessage < ApplicationRecord
}
class << self
- def current_banner_messages(current_path = nil)
- fetch_messages BANNER_CACHE_KEY, current_path do
+ def current_banner_messages(current_path: nil, user_access_level: nil)
+ fetch_messages BANNER_CACHE_KEY, current_path, user_access_level do
current_and_future_messages.banner
end
end
- def current_notification_messages(current_path = nil)
- fetch_messages NOTIFICATION_CACHE_KEY, current_path do
+ def current_notification_messages(current_path: nil, user_access_level: nil)
+ fetch_messages NOTIFICATION_CACHE_KEY, current_path, user_access_level do
current_and_future_messages.notification
end
end
- def current(current_path = nil)
- fetch_messages CACHE_KEY, current_path do
+ def current(current_path: nil, user_access_level: nil)
+ fetch_messages CACHE_KEY, current_path, user_access_level do
current_and_future_messages
end
end
@@ -53,7 +62,7 @@ class BroadcastMessage < ApplicationRecord
def cache
::Gitlab::SafeRequestStore.fetch(:broadcast_message_json_cache) do
- Gitlab::JsonCache.new(cache_key_with_version: false)
+ Gitlab::JsonCache.new
end
end
@@ -63,7 +72,7 @@ class BroadcastMessage < ApplicationRecord
private
- def fetch_messages(cache_key, current_path)
+ def fetch_messages(cache_key, current_path, user_access_level)
messages = cache.fetch(cache_key, as: BroadcastMessage, expires_in: cache_expires_in) do
yield
end
@@ -74,7 +83,13 @@ class BroadcastMessage < ApplicationRecord
# displaying we'll refresh the cache so we don't need to keep filtering.
cache.expire(cache_key) if now_or_future != messages
- now_or_future.select(&:now?).select { |message| message.matches_current_path(current_path) }
+ messages = now_or_future.select(&:now?)
+ messages = messages.select do |message|
+ message.matches_current_user_access_level?(user_access_level)
+ end
+ messages.select do |message|
+ message.matches_current_path(current_path)
+ end
end
end
@@ -102,6 +117,13 @@ class BroadcastMessage < ApplicationRecord
now? || future?
end
+ def matches_current_user_access_level?(user_access_level)
+ return false if target_access_levels.present? && Feature.disabled?(:role_targeted_broadcast_messages, default_enabled: :yaml)
+ return true unless target_access_levels.present?
+
+ target_access_levels.include? user_access_level
+ end
+
def matches_current_path(current_path)
return false if current_path.blank? && target_path.present?
return true if current_path.blank? || target_path.blank?
diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb
index 38b7da76306..a7e1384641c 100644
--- a/app/models/bulk_imports/entity.rb
+++ b/app/models/bulk_imports/entity.rb
@@ -20,6 +20,8 @@
class BulkImports::Entity < ApplicationRecord
self.table_name = 'bulk_import_entities'
+ FailedError = Class.new(StandardError)
+
belongs_to :bulk_import, optional: false
belongs_to :parent, class_name: 'BulkImports::Entity', optional: true
diff --git a/app/models/bulk_imports/export_status.rb b/app/models/bulk_imports/export_status.rb
index abf064adaae..cae6aad27da 100644
--- a/app/models/bulk_imports/export_status.rb
+++ b/app/models/bulk_imports/export_status.rb
@@ -30,7 +30,12 @@ module BulkImports
def export_status
strong_memoize(:export_status) do
- fetch_export_status.find { |item| item['relation'] == relation }
+ status = fetch_export_status
+
+ # Consider empty response as failed export
+ raise StandardError, 'Empty export status response' unless status&.present?
+
+ status.find { |item| item['relation'] == relation }
end
rescue StandardError => e
{ 'status' => Export::FAILED, 'error' => e.message }
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 50bda64d537..2ff777bfc89 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -11,6 +11,11 @@ module Ci
InvalidBridgeTypeError = Class.new(StandardError)
InvalidTransitionError = Class.new(StandardError)
+ FORWARD_DEFAULTS = {
+ yaml_variables: true,
+ pipeline_variables: false
+ }.freeze
+
belongs_to :project
belongs_to :trigger_request
has_many :sourced_pipelines, class_name: "::Ci::Sources::Pipeline",
@@ -199,12 +204,13 @@ module Ci
end
def downstream_variables
- variables = scoped_variables.concat(pipeline.persisted_variables)
-
- variables.to_runner_variables.yield_self do |all_variables|
- yaml_variables.to_a.map do |hash|
- { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], all_variables) }
- end
+ if ::Feature.enabled?(:ci_trigger_forward_variables, project, default_enabled: :yaml)
+ calculate_downstream_variables
+ .reverse # variables priority
+ .uniq { |var| var[:key] } # only one variable key to pass
+ .reverse
+ else
+ legacy_downstream_variables
end
end
@@ -250,6 +256,58 @@ module Ci
}
}
end
+
+ def legacy_downstream_variables
+ variables = scoped_variables.concat(pipeline.persisted_variables)
+
+ variables.to_runner_variables.yield_self do |all_variables|
+ yaml_variables.to_a.map do |hash|
+ { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], all_variables) }
+ end
+ end
+ end
+
+ def calculate_downstream_variables
+ expand_variables = scoped_variables
+ .concat(pipeline.persisted_variables)
+ .to_runner_variables
+
+ # The order of this list refers to the priority of the variables
+ downstream_yaml_variables(expand_variables) +
+ downstream_pipeline_variables(expand_variables)
+ end
+
+ def downstream_yaml_variables(expand_variables)
+ return [] unless forward_yaml_variables?
+
+ yaml_variables.to_a.map do |hash|
+ { key: hash[:key], value: ::ExpandVariables.expand(hash[:value], expand_variables) }
+ end
+ end
+
+ def downstream_pipeline_variables(expand_variables)
+ return [] unless forward_pipeline_variables?
+
+ pipeline.variables.to_a.map do |variable|
+ { key: variable.key, value: ::ExpandVariables.expand(variable.value, expand_variables) }
+ end
+ end
+
+ def forward_yaml_variables?
+ strong_memoize(:forward_yaml_variables) do
+ result = options&.dig(:trigger, :forward, :yaml_variables)
+
+ result.nil? ? FORWARD_DEFAULTS[:yaml_variables] : result
+ end
+ end
+
+ def forward_pipeline_variables?
+ strong_memoize(:forward_pipeline_variables) do
+ result = options&.dig(:trigger, :forward, :pipeline_variables)
+
+ result.nil? ? FORWARD_DEFAULTS[:pipeline_variables] : result
+ end
+ end
end
end
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index c4d1a2c740b..68ec196a9ee 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -10,6 +10,8 @@ module Ci
include Presentable
include Importable
include Ci::HasRef
+ include HasDeploymentName
+
extend ::Gitlab::Utils::Override
BuildArchivedError = Class.new(StandardError)
@@ -35,6 +37,8 @@ module Ci
DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD'
RUNNERS_STATUS_CACHE_EXPIRATION = 1.minute
+ DEPLOYMENT_NAMES = %w[deploy release rollout].freeze
+
has_one :deployment, as: :deployable, class_name: 'Deployment'
has_one :pending_state, class_name: 'Ci::BuildPendingState', inverse_of: :build
has_one :queuing_entry, class_name: 'Ci::PendingBuild', foreign_key: :build_id
@@ -68,6 +72,7 @@ module Ci
delegate :terminal_specification, to: :runner_session, allow_nil: true
delegate :service_specification, to: :runner_session, allow_nil: true
delegate :gitlab_deploy_token, to: :project
+ delegate :harbor_integration, to: :project
delegate :trigger_short_token, to: :trigger_request, allow_nil: true
##
@@ -579,6 +584,7 @@ module Ci
.append(key: 'CI_REGISTRY_PASSWORD', value: token.to_s, public: false, masked: true)
.append(key: 'CI_REPOSITORY_URL', value: repo_url.to_s, public: false)
.concat(deploy_token_variables)
+ .concat(harbor_variables)
end
end
@@ -615,6 +621,12 @@ module Ci
end
end
+ def harbor_variables
+ return [] unless harbor_integration.try(:activated?)
+
+ Gitlab::Ci::Variables::Collection.new(harbor_integration.ci_variables)
+ end
+
def features
{
trace_sections: true,
@@ -1123,6 +1135,10 @@ module Ci
.include?(exit_code)
end
+ def track_deployment_usage
+ Gitlab::Utils::UsageData.track_usage_event('ci_users_executing_deployment_job', user_id) if user_id.present? && count_user_deployment?
+ end
+
protected
def run_status_commit_hooks!
diff --git a/app/models/ci/group_variable.rb b/app/models/ci/group_variable.rb
index 165bee5c54d..0af5533613f 100644
--- a/app/models/ci/group_variable.rb
+++ b/app/models/ci/group_variable.rb
@@ -18,5 +18,6 @@ module Ci
scope :unprotected, -> { where(protected: false) }
scope :by_environment_scope, -> (environment_scope) { where(environment_scope: environment_scope) }
+ scope :for_groups, ->(group_ids) { where(group_id: group_ids) }
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index a1311b8555f..ae3ea7aa03f 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -25,6 +25,7 @@ module Ci
}.freeze
CONFIG_EXTENSION = '.gitlab-ci.yml'
DEFAULT_CONFIG_PATH = CONFIG_EXTENSION
+ CANCELABLE_STATUSES = (Ci::HasStatus::CANCELABLE_STATUSES + ['manual']).freeze
BridgeStatusError = Class.new(StandardError)
@@ -421,9 +422,7 @@ module Ci
sql = sql.where(ref: ref) if ref
- sql.each_with_object({}) do |pipeline, hash|
- hash[pipeline.sha] = pipeline
- end
+ sql.index_by(&:sha)
end
def self.latest_successful_ids_per_project
@@ -653,7 +652,7 @@ module Ci
def coverage
coverage_array = latest_statuses.map(&:coverage).compact
if coverage_array.size >= 1
- coverage_array.reduce(:+) / coverage_array.size
+ coverage_array.sum / coverage_array.size
end
end
@@ -1165,11 +1164,7 @@ module Ci
end
def merge_request?
- if Feature.enabled?(:ci_pipeline_merge_request_presence_check, default_enabled: :yaml)
- merge_request_id.present? && merge_request
- else
- merge_request_id.present?
- end
+ merge_request_id.present? && merge_request.present?
end
def external_pull_request?
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index b915495ac38..96e5567e85e 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -66,6 +66,18 @@ module Ci
project.actual_limits.limit_for(:ci_daily_pipeline_schedule_triggers)
end
+ def ref_for_display
+ return unless ref.present?
+
+ ref.gsub(%r{^refs/(heads|tags)/}, '')
+ end
+
+ def for_tag?
+ return false unless ref.present?
+
+ ref.start_with? 'refs/tags/'
+ end
+
private
def worker_cron_expression
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index 372df8cc264..4d119706a43 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -16,7 +16,7 @@ module Ci
scope :with_needs, -> (names = nil) do
needs = Ci::BuildNeed.scoped_build.select(1)
needs = needs.where(name: names) if names
- where('EXISTS (?)', needs).preload(:needs)
+ where('EXISTS (?)', needs)
end
scope :without_needs, -> (names = nil) do
diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb
index 11150e839a3..4228da279a4 100644
--- a/app/models/ci/runner.rb
+++ b/app/models/ci/runner.rb
@@ -59,7 +59,7 @@ module Ci
AVAILABLE_TYPES_LEGACY = %w[specific shared].freeze
AVAILABLE_TYPES = runner_types.keys.freeze
- AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: active, paused, not_connected. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648
+ AVAILABLE_STATUSES = %w[active paused online offline not_connected never_contacted stale].freeze # TODO: Remove in %15.0: not_connected. In %16.0: active, paused. Relevant issues: https://gitlab.com/gitlab-org/gitlab/-/issues/347303, https://gitlab.com/gitlab-org/gitlab/-/issues/347305, https://gitlab.com/gitlab-org/gitlab/-/issues/344648
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
@@ -200,7 +200,7 @@ module Ci
validates :config, json_schema: { filename: 'ci_runner_config' }
- validates :maintenance_note, length: { maximum: 255 }
+ validates :maintenance_note, length: { maximum: 1024 }
alias_attribute :maintenance_note, :maintainer_note
@@ -329,9 +329,9 @@ module Ci
end
# DEPRECATED
- # TODO Remove in %15.0 in favor of `status` for REST calls, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648
+ # TODO Remove in %16.0 in favor of `status` for REST calls
def deprecated_rest_status
- if contacted_at.nil?
+ if contacted_at.nil? # TODO Remove in %15.0, see https://gitlab.com/gitlab-org/gitlab/-/issues/344648
:not_connected
elsif active?
online? ? :online : :offline
diff --git a/app/models/ci/secure_file.rb b/app/models/ci/secure_file.rb
index 56f632b6232..18f0093ea41 100644
--- a/app/models/ci/secure_file.rb
+++ b/app/models/ci/secure_file.rb
@@ -3,10 +3,14 @@
module Ci
class SecureFile < Ci::ApplicationRecord
include FileStoreMounter
+ include Limitable
FILE_SIZE_LIMIT = 5.megabytes.freeze
CHECKSUM_ALGORITHM = 'sha256'
+ self.limit_scope = :project
+ self.limit_name = 'project_ci_secure_files'
+
belongs_to :project, optional: false
validates :file, presence: true, file_size: { maximum: FILE_SIZE_LIMIT }
diff --git a/app/models/concerns/blocks_json_serialization.rb b/app/models/concerns/blocks_json_serialization.rb
deleted file mode 100644
index 18c00532d78..00000000000
--- a/app/models/concerns/blocks_json_serialization.rb
+++ /dev/null
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-# Overrides `as_json` and `to_json` to raise an exception when called in order
-# to prevent accidentally exposing attributes
-#
-# Not that would ever happen... but just in case.
-module BlocksJsonSerialization
- extend ActiveSupport::Concern
-
- JsonSerializationError = Class.new(StandardError)
-
- def to_json(*)
- raise JsonSerializationError,
- "JSON serialization has been disabled on #{self.class.name}"
- end
-
- alias_method :as_json, :to_json
-end
diff --git a/app/models/concerns/blocks_unsafe_serialization.rb b/app/models/concerns/blocks_unsafe_serialization.rb
new file mode 100644
index 00000000000..72adbe70f15
--- /dev/null
+++ b/app/models/concerns/blocks_unsafe_serialization.rb
@@ -0,0 +1,32 @@
+# frozen_string_literal: true
+
+# Overrides `#serializable_hash` to raise an exception when called without the `only` option
+# in order to prevent accidentally exposing attributes.
+#
+# An `unsafe: true` option can also be passed in to bypass this check.
+#
+# `#serializable_hash` is used by ActiveModel serializers like `ActiveModel::Serializers::JSON`
+# which overrides `#as_json` and `#to_json`.
+#
+module BlocksUnsafeSerialization
+ extend ActiveSupport::Concern
+ extend ::Gitlab::Utils::Override
+
+ UnsafeSerializationError = Class.new(StandardError)
+
+ override :serializable_hash
+ def serializable_hash(options = nil)
+ return super if allow_serialization?(options)
+
+ raise UnsafeSerializationError,
+ "Serialization has been disabled on #{self.class.name}"
+ end
+
+ private
+
+ def allow_serialization?(options = nil)
+ return false unless options
+
+ !!(options[:only] || options[:unsafe])
+ end
+end
diff --git a/app/models/concerns/bulk_member_access_load.rb b/app/models/concerns/bulk_member_access_load.rb
index 927d6ccb28f..efc65e55e40 100644
--- a/app/models/concerns/bulk_member_access_load.rb
+++ b/app/models/concerns/bulk_member_access_load.rb
@@ -1,61 +1,19 @@
# frozen_string_literal: true
-# Returns and caches in thread max member access for a resource
-#
module BulkMemberAccessLoad
extend ActiveSupport::Concern
included do
- # Determine the maximum access level for a group of resources in bulk.
- #
- # Returns a Hash mapping resource ID -> maximum access level.
- def max_member_access_for_resource_ids(resource_klass, resource_ids, &block)
- raise 'Block is mandatory' unless block_given?
-
- memoization_index = self.id
- memoization_class = self.class
-
- resource_ids = resource_ids.uniq
- memo_id = "#{memoization_class}:#{memoization_index}"
- access = load_access_hash(resource_klass, memo_id)
-
- # Look up only the IDs we need
- resource_ids -= access.keys
-
- return access if resource_ids.empty?
-
- resource_access = yield(resource_ids)
-
- access.merge!(resource_access)
-
- missing_resource_ids = resource_ids - resource_access.keys
-
- missing_resource_ids.each do |resource_id|
- access[resource_id] = Gitlab::Access::NO_ACCESS
- end
-
- access
- end
-
def merge_value_to_request_store(resource_klass, resource_id, value)
- max_member_access_for_resource_ids(resource_klass, [resource_id]) do
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(resource_klass),
+ resource_ids: [resource_id],
+ default_value: Gitlab::Access::NO_ACCESS) do
{ resource_id => value }
end
end
- private
-
- def max_member_access_for_resource_key(klass, memoization_index)
- "max_member_access_for_#{klass.name.underscore.pluralize}:#{memoization_index}"
- end
-
- def load_access_hash(resource_klass, memo_id)
- return {} unless Gitlab::SafeRequestStore.active?
-
- key = max_member_access_for_resource_key(resource_klass, memo_id)
- Gitlab::SafeRequestStore[key] ||= {}
-
- Gitlab::SafeRequestStore[key]
+ def max_member_access_for_resource_key(klass)
+ "max_member_access_for_#{klass.name.underscore.pluralize}:#{self.class}:#{self.id}"
end
end
end
diff --git a/app/models/concerns/ci/has_deployment_name.rb b/app/models/concerns/ci/has_deployment_name.rb
new file mode 100644
index 00000000000..fe288134872
--- /dev/null
+++ b/app/models/concerns/ci/has_deployment_name.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Ci
+ module HasDeploymentName
+ extend ActiveSupport::Concern
+
+ def count_user_deployment?
+ Feature.enabled?(:job_deployment_count) && deployment_name?
+ end
+
+ def deployment_name?
+ self.class::DEPLOYMENT_NAMES.any? { |n| name.downcase.include?(n) }
+ end
+ end
+end
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb
index ccaccec3b6b..313c767e59f 100644
--- a/app/models/concerns/ci/has_status.rb
+++ b/app/models/concerns/ci/has_status.rb
@@ -7,12 +7,16 @@ module Ci
DEFAULT_STATUS = 'created'
BLOCKED_STATUS = %w[manual scheduled].freeze
AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze
+ # TODO: replace STARTED_STATUSES with data from BUILD_STARTED_RUNNING_STATUSES in https://gitlab.com/gitlab-org/gitlab/-/issues/273378
+ # see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/82149#note_865508501
+ BUILD_STARTED_RUNNING_STATUSES = %w[running success failed].freeze
STARTED_STATUSES = %w[running success failed skipped manual scheduled].freeze
ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze
COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze
PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze
EXCLUDE_IGNORED_STATUSES = %w[manual failed canceled].to_set.freeze
+ CANCELABLE_STATUSES = %w[running waiting_for_resource preparing pending created scheduled].freeze
STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3,
failed: 4, canceled: 5, skipped: 6, manual: 7,
scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze
@@ -85,7 +89,7 @@ module Ci
scope :waiting_for_resource_or_upcoming, -> { with_status(:created, :scheduled, :waiting_for_resource) }
scope :cancelable, -> do
- where(status: [:running, :waiting_for_resource, :preparing, :pending, :created, :scheduled])
+ where(status: klass::CANCELABLE_STATUSES)
end
scope :without_statuses, -> (names) do
diff --git a/app/models/concerns/counter_attribute.rb b/app/models/concerns/counter_attribute.rb
index 4bfeba338d2..b41b1ba6008 100644
--- a/app/models/concerns/counter_attribute.rb
+++ b/app/models/concerns/counter_attribute.rb
@@ -102,9 +102,7 @@ module CounterAttribute
run_after_commit_or_now do
if counter_attribute_enabled?(attribute)
- redis_state do |redis|
- redis.incrby(counter_key(attribute), increment)
- end
+ increment_counter(attribute, increment)
FlushCounterIncrementsWorker.perform_in(WORKER_DELAY, self.class.name, self.id, attribute)
else
@@ -115,6 +113,28 @@ module CounterAttribute
true
end
+ def increment_counter(attribute, increment)
+ if counter_attribute_enabled?(attribute)
+ redis_state do |redis|
+ redis.incrby(counter_key(attribute), increment)
+ end
+ end
+ end
+
+ def clear_counter!(attribute)
+ if counter_attribute_enabled?(attribute)
+ redis_state { |redis| redis.del(counter_key(attribute)) }
+ end
+ end
+
+ def get_counter_value(attribute)
+ if counter_attribute_enabled?(attribute)
+ redis_state do |redis|
+ redis.get(counter_key(attribute)).to_i
+ end
+ end
+ end
+
def counter_key(attribute)
"project:{#{project_id}}:counters:#{self.class}:#{id}:#{attribute}"
end
diff --git a/app/models/concerns/deployment_platform.rb b/app/models/concerns/deployment_platform.rb
index b6245e29746..d9c622f247a 100644
--- a/app/models/concerns/deployment_platform.rb
+++ b/app/models/concerns/deployment_platform.rb
@@ -3,6 +3,8 @@
module DeploymentPlatform
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def deployment_platform(environment: nil)
+ return if Feature.disabled?(:certificate_based_clusters, default_enabled: :yaml, type: :ops)
+
@deployment_platform ||= {}
@deployment_platform[environment] ||= find_deployment_platform(environment)
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
index 28ee54afaa9..ad070090dd5 100644
--- a/app/models/concerns/has_user_type.rb
+++ b/app/models/concerns/has_user_type.rb
@@ -46,4 +46,17 @@ module HasUserType
def internal?
ghost? || (bot? && !project_bot?)
end
+
+ def redacted_name(viewing_user)
+ return self.name unless self.project_bot?
+
+ return self.name if self.groups.any? && viewing_user&.can?(:read_group, self.groups.first)
+
+ return self.name if viewing_user&.can?(:read_project, self.projects.first)
+
+ # If the requester does not have permission to read the project bot name,
+ # the API returns an arbitrary string. UI changes will be addressed in a follow up issue:
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/346058
+ '****'
+ end
end
diff --git a/app/models/concerns/integrations/has_issue_tracker_fields.rb b/app/models/concerns/integrations/has_issue_tracker_fields.rb
new file mode 100644
index 00000000000..b1def38d019
--- /dev/null
+++ b/app/models/concerns/integrations/has_issue_tracker_fields.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module Integrations
+ module HasIssueTrackerFields
+ extend ActiveSupport::Concern
+
+ included do
+ field :project_url,
+ required: true,
+ storage: :data_fields,
+ title: -> { _('Project URL') },
+ help: -> { s_('IssueTracker|The URL to the project in the external issue tracker.') }
+
+ field :issues_url,
+ required: true,
+ storage: :data_fields,
+ title: -> { s_('IssueTracker|Issue URL') },
+ help: -> do
+ format s_('IssueTracker|The URL to view an issue in the external issue tracker. Must contain %{colon_id}.'),
+ colon_id: '<code>:id</code>'.html_safe
+ end
+
+ field :new_issue_url,
+ required: true,
+ storage: :data_fields,
+ title: -> { s_('IssueTracker|New issue URL') },
+ help: -> { s_('IssueTracker|The URL to create an issue in the external issue tracker.') }
+ end
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 0138c0ad20f..1eb30e88f16 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -74,6 +74,7 @@ module Issuable
end
has_many :note_authors, -> { distinct }, through: :notes, source: :author
+ has_many :user_note_authors, -> { distinct.where("notes.system = false") }, through: :notes, source: :author
has_many :label_links, as: :target, inverse_of: :target
has_many :labels, through: :label_links
@@ -464,37 +465,54 @@ module Issuable
false
end
- def to_hook_data(user, old_associations: {})
- changes = previous_changes
+ def hook_association_changes(old_associations)
+ changes = {}
- if old_associations
- old_labels = old_associations.fetch(:labels, labels)
- old_assignees = old_associations.fetch(:assignees, assignees)
- old_severity = old_associations.fetch(:severity, severity)
+ old_labels = old_associations.fetch(:labels, labels)
+ old_assignees = old_associations.fetch(:assignees, assignees)
+ old_severity = old_associations.fetch(:severity, severity)
- if old_labels != labels
- changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
- end
+ if old_labels != labels
+ changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
+ end
- if old_assignees != assignees
- changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
- end
+ if old_assignees != assignees
+ changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
+ end
+
+ if supports_severity? && old_severity != severity
+ changes[:severity] = [old_severity, severity]
+ end
+
+ if supports_escalation? && escalation_status
+ current_escalation_status = escalation_status.status_name
+ old_escalation_status = old_associations.fetch(:escalation_status, current_escalation_status)
- if supports_severity? && old_severity != severity
- changes[:severity] = [old_severity, severity]
+ if old_escalation_status != current_escalation_status
+ changes[:escalation_status] = [old_escalation_status, current_escalation_status]
end
+ end
- if self.respond_to?(:total_time_spent)
- old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent)
- old_time_change = old_associations.fetch(:time_change, time_change)
+ if self.respond_to?(:total_time_spent)
+ old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent)
+ old_time_change = old_associations.fetch(:time_change, time_change)
- if old_total_time_spent != total_time_spent
- changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
- changes[:time_change] = [old_time_change, time_change]
- end
+ if old_total_time_spent != total_time_spent
+ changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
+ changes[:time_change] = [old_time_change, time_change]
end
end
+ changes
+ end
+
+ def to_hook_data(user, old_associations: {})
+ changes = previous_changes
+
+ if old_associations.present?
+ changes.merge!(hook_association_changes(old_associations))
+ end
+
Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes)
end
diff --git a/app/models/concerns/issuable_link.rb b/app/models/concerns/issuable_link.rb
new file mode 100644
index 00000000000..3e14507bc70
--- /dev/null
+++ b/app/models/concerns/issuable_link.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+# == IssuableLink concern
+#
+# Contains common functionality shared between related Issues and related Epics
+#
+# Used by IssueLink, Epic::RelatedEpicLink
+#
+module IssuableLink
+ extend ActiveSupport::Concern
+
+ TYPE_RELATES_TO = 'relates_to'
+ TYPE_BLOCKS = 'blocks' ## EE-only. Kept here to be used on link_type enum.
+
+ class_methods do
+ def inverse_link_type(type)
+ type
+ end
+
+ def issuable_type
+ raise NotImplementedError
+ end
+ end
+
+ included do
+ validates :source, presence: true
+ validates :target, presence: true
+ validates :source, uniqueness: { scope: :target_id, message: 'is already related' }
+ validate :check_self_relation
+ validate :check_opposite_relation
+
+ enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 }
+
+ private
+
+ def check_self_relation
+ return unless source && target
+
+ if source == target
+ errors.add(:source, 'cannot be related to itself')
+ end
+ end
+
+ def check_opposite_relation
+ return unless source && target
+
+ if self.class.base_class.find_by(source: target, target: source)
+ errors.add(:source, "is already related to this #{self.class.issuable_type}")
+ end
+ end
+ end
+end
+
+IssuableLink.prepend_mod_with('IssuableLink')
+IssuableLink::ClassMethods.prepend_mod_with('IssuableLink::ClassMethods')
diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb
index 1c24032dbbb..5cbc937e465 100644
--- a/app/models/concerns/issue_resource_event.rb
+++ b/app/models/concerns/issue_resource_event.rb
@@ -8,6 +8,10 @@ module IssueResourceEvent
scope :by_issue, ->(issue) { where(issue_id: issue.id) }
- scope :by_issue_ids_and_created_at_earlier_or_equal_to, ->(issue_ids, time) { where(issue_id: issue_ids).where('created_at <= ?', time) }
+ scope :by_created_at_earlier_or_equal_to, ->(time) { where('created_at <= ?', time) }
+ scope :by_issue_ids, ->(issue_ids) do
+ table = self.klass.arel_table
+ where(table[:issue_id].in(issue_ids))
+ end
end
end
diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb
index 5859f43a70c..893d06b4da8 100644
--- a/app/models/concerns/merge_request_reviewer_state.rb
+++ b/app/models/concerns/merge_request_reviewer_state.rb
@@ -14,6 +14,14 @@ module MergeRequestReviewerState
presence: true,
inclusion: { in: self.states.keys }
+ belongs_to :updated_state_by, class_name: 'User', foreign_key: :updated_state_by_user_id
+
after_initialize :set_state, unless: :persisted?
+
+ def attention_requested_by
+ return unless attention_requested?
+
+ updated_state_by
+ end
end
end
diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb
new file mode 100644
index 00000000000..68357c44300
--- /dev/null
+++ b/app/models/concerns/pg_full_text_searchable.rb
@@ -0,0 +1,111 @@
+# frozen_string_literal: true
+
+# This module adds PG full-text search capabilities to a model.
+# A `search_data` association with a `search_vector` column is required.
+#
+# Declare the fields that will be part of the search vector with their
+# corresponding weights. Possible values for weight are A, B, C, or D.
+# For example:
+#
+# include PgFullTextSearchable
+# pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }]
+#
+# This module sets up an after_commit hook that updates the search data
+# when the searchable columns are changed. You will need to implement the
+# `#persist_pg_full_text_search_vector` method that does the actual insert or update.
+#
+# This also adds a `pg_full_text_search` scope so you can do:
+#
+# Model.pg_full_text_search("some search term")
+
+module PgFullTextSearchable
+ extend ActiveSupport::Concern
+
+ LONG_WORDS_REGEX = %r([A-Za-z0-9+/@]{50,}).freeze
+ TSVECTOR_MAX_LENGTH = 1.megabyte.freeze
+ TEXT_SEARCH_DICTIONARY = 'english'
+
+ def update_search_data!
+ tsvector_sql_nodes = self.class.pg_full_text_searchable_columns.map do |column, weight|
+ tsvector_arel_node(column, weight)&.to_sql
+ end
+
+ persist_pg_full_text_search_vector(Arel.sql(tsvector_sql_nodes.compact.join(' || ')))
+ rescue ActiveRecord::StatementInvalid => e
+ raise unless e.cause.is_a?(PG::ProgramLimitExceeded) && e.message.include?('string is too long for tsvector')
+
+ Gitlab::AppJsonLogger.error(
+ message: 'Error updating search data: string is too long for tsvector',
+ class: self.class.name,
+ model_id: self.id
+ )
+ end
+
+ private
+
+ def persist_pg_full_text_search_vector(search_vector)
+ raise NotImplementedError
+ end
+
+ def tsvector_arel_node(column, weight)
+ return if self[column].blank?
+
+ column_text = self[column].gsub(LONG_WORDS_REGEX, ' ')
+ column_text = column_text[0..(TSVECTOR_MAX_LENGTH - 1)]
+ column_text = ActiveSupport::Inflector.transliterate(column_text)
+
+ Arel::Nodes::NamedFunction.new(
+ 'setweight',
+ [
+ Arel::Nodes::NamedFunction.new(
+ 'to_tsvector',
+ [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(column_text)]
+ ),
+ Arel::Nodes.build_quoted(weight)
+ ]
+ )
+ end
+
+ included do
+ cattr_reader :pg_full_text_searchable_columns do
+ {}
+ end
+ end
+
+ class_methods do
+ def pg_full_text_searchable(columns:)
+ raise 'Full text search columns already defined!' if pg_full_text_searchable_columns.present?
+
+ columns.each do |column|
+ pg_full_text_searchable_columns[column[:name]] = column[:weight]
+ end
+
+ # We update this outside the transaction because this could raise an error if the resulting tsvector
+ # is too long. When that happens, we still persist the create / update but the model will not have a
+ # search data record. This is fine in most cases because this is a very rare occurrence and only happens
+ # with strings that are most likely unsearchable anyway.
+ #
+ # We also do not want to use a subtransaction here due to: https://gitlab.com/groups/gitlab-org/-/epics/6540
+ after_save_commit do
+ next unless pg_full_text_searchable_columns.keys.any? { |f| saved_changes.has_key?(f) }
+
+ update_search_data!
+ end
+ end
+
+ def pg_full_text_search(search_term)
+ search_data_table = reflect_on_association(:search_data).klass.arel_table
+
+ joins(:search_data).where(
+ Arel::Nodes::InfixOperation.new(
+ '@@',
+ search_data_table[:search_vector],
+ Arel::Nodes::NamedFunction.new(
+ 'websearch_to_tsquery',
+ [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(search_term)]
+ )
+ )
+ )
+ end
+ end
+end
diff --git a/app/models/concerns/runners_token_prefixable.rb b/app/models/concerns/runners_token_prefixable.rb
index 1aea874337e..99bbbece7c7 100644
--- a/app/models/concerns/runners_token_prefixable.rb
+++ b/app/models/concerns/runners_token_prefixable.rb
@@ -1,14 +1,8 @@
# frozen_string_literal: true
module RunnersTokenPrefixable
- extend ActiveSupport::Concern
-
# Prefix for runners_token which can be used to invalidate existing tokens.
# The value chosen here is GR (for Gitlab Runner) combined with the rotation
# date (20220225) decimal to hex encoded.
RUNNERS_TOKEN_PREFIX = 'GR1348941'
-
- def runners_token_prefix
- RUNNERS_TOKEN_PREFIX
- end
end
diff --git a/app/models/concerns/select_for_project_authorization.rb b/app/models/concerns/select_for_project_authorization.rb
index 49342e30db6..5a7e16eb2c4 100644
--- a/app/models/concerns/select_for_project_authorization.rb
+++ b/app/models/concerns/select_for_project_authorization.rb
@@ -8,8 +8,10 @@ module SelectForProjectAuthorization
select("projects.id AS project_id", "members.access_level")
end
- def select_as_maintainer_for_project_authorization
- select(["projects.id AS project_id", "#{Gitlab::Access::MAINTAINER} AS access_level"])
+ # workaround until we migrate Project#owners to have membership with
+ # OWNER access level
+ def select_project_owner_for_project_authorization
+ select(["projects.id AS project_id", "#{Gitlab::Access::OWNER} AS access_level"])
end
end
end
diff --git a/app/models/concerns/sensitive_serializable_hash.rb b/app/models/concerns/sensitive_serializable_hash.rb
new file mode 100644
index 00000000000..725ec60e9b6
--- /dev/null
+++ b/app/models/concerns/sensitive_serializable_hash.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+module SensitiveSerializableHash
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :attributes_exempt_from_serializable_hash, default: []
+ end
+
+ class_methods do
+ def prevent_from_serialization(*keys)
+ self.attributes_exempt_from_serializable_hash ||= []
+ self.attributes_exempt_from_serializable_hash.concat keys
+ end
+ end
+
+ # Override serializable_hash to exclude sensitive attributes by default
+ #
+ # In general, prefer NOT to use serializable_hash / to_json / as_json in favor
+ # of serializers / entities instead which has an allowlist of attributes
+ def serializable_hash(options = nil)
+ return super unless prevent_sensitive_fields_from_serializable_hash?
+ return super if options && options[:unsafe_serialization_hash]
+
+ options = options.try(:dup) || {}
+ options[:except] = Array(options[:except]).dup
+
+ options[:except].concat self.class.attributes_exempt_from_serializable_hash
+
+ if self.class.respond_to?(:encrypted_attributes)
+ options[:except].concat self.class.encrypted_attributes.keys
+
+ # Per https://github.com/attr-encrypted/attr_encrypted/blob/a96693e9a2a25f4f910bf915e29b0f364f277032/lib/attr_encrypted.rb#L413
+ options[:except].concat self.class.encrypted_attributes.values.map { |v| v[:attribute] }
+ options[:except].concat self.class.encrypted_attributes.values.map { |v| "#{v[:attribute]}_iv" }
+ end
+
+ super(options)
+ end
+
+ private
+
+ def prevent_sensitive_fields_from_serializable_hash?
+ Feature.enabled?(:prevent_sensitive_fields_from_serializable_hash, default_enabled: :yaml)
+ end
+end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 4901cd832ff..b475eb79aa3 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -12,7 +12,7 @@ module Spammable
included do
has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
- attr_accessor :spam
+ attr_writer :spam
attr_accessor :needs_recaptcha
attr_accessor :spam_log
@@ -29,6 +29,10 @@ module Spammable
delegate :ip_address, :user_agent, to: :user_agent_detail, allow_nil: true
end
+ def spam
+ !!@spam # rubocop:disable Gitlab/ModuleWithInstanceVariables
+ end
+
def submittable_as_spam_by?(current_user)
current_user && current_user.admin? && submittable_as_spam?
end
@@ -74,8 +78,9 @@ module Spammable
end
def recaptcha_error!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\
- "Please, change the content or solve the reCAPTCHA to proceed.")
+ self.errors.add(:base, _("Your %{spammable_entity_type} has been recognized as spam. "\
+ "Please, change the content or solve the reCAPTCHA to proceed.") \
+ % { spammable_entity_type: spammable_entity_type })
end
def unrecoverable_spam_error!
diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb
index 943ef3fa59f..d53594eb5af 100644
--- a/app/models/concerns/timebox.rb
+++ b/app/models/concerns/timebox.rb
@@ -44,7 +44,6 @@ module Timebox
validates :group, presence: true, unless: :project
validates :project, presence: true, unless: :group
- validates :title, presence: true
validate :timebox_type_check
validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb
index f44ad8ebe90..d91ec161b84 100644
--- a/app/models/concerns/token_authenticatable.rb
+++ b/app/models/concerns/token_authenticatable.rb
@@ -8,6 +8,10 @@ module TokenAuthenticatable
@encrypted_token_authenticatable_fields ||= []
end
+ def token_authenticatable_fields
+ @token_authenticatable_fields ||= []
+ end
+
private
def add_authentication_token_field(token_field, options = {})
@@ -23,6 +27,8 @@ module TokenAuthenticatable
strategy = TokenAuthenticatableStrategies::Base
.fabricate(self, token_field, options)
+ prevent_from_serialization(*strategy.token_fields) if respond_to?(:prevent_from_serialization)
+
if options.fetch(:unique, true)
define_singleton_method("find_by_#{token_field}") do |token|
strategy.find_token_authenticatable(token)
@@ -82,9 +88,5 @@ module TokenAuthenticatable
@token_authenticatable_module ||=
const_set(:TokenAuthenticatable, Module.new).tap(&method(:include))
end
-
- def token_authenticatable_fields
- @token_authenticatable_fields ||= []
- end
end
end
diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb
index 2cec4ab460e..2b677f37c89 100644
--- a/app/models/concerns/token_authenticatable_strategies/base.rb
+++ b/app/models/concerns/token_authenticatable_strategies/base.rb
@@ -23,6 +23,14 @@ module TokenAuthenticatableStrategies
raise NotImplementedError
end
+ def token_fields
+ result = [token_field]
+
+ result << @expires_at_field if expirable?
+
+ result
+ end
+
# Default implementation returns the token as-is
def format_token(instance, token)
instance.send("format_#{@token_field}", token) # rubocop:disable GitlabSecurity/PublicSend
diff --git a/app/models/concerns/token_authenticatable_strategies/digest.rb b/app/models/concerns/token_authenticatable_strategies/digest.rb
index 9926662ed66..5c94f25949f 100644
--- a/app/models/concerns/token_authenticatable_strategies/digest.rb
+++ b/app/models/concerns/token_authenticatable_strategies/digest.rb
@@ -2,6 +2,10 @@
module TokenAuthenticatableStrategies
class Digest < Base
+ def token_fields
+ super + [token_field_name]
+ end
+
def find_token_authenticatable(token, unscoped = false)
return unless token
diff --git a/app/models/concerns/token_authenticatable_strategies/encrypted.rb b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
index e957d09fbc6..1db88c27181 100644
--- a/app/models/concerns/token_authenticatable_strategies/encrypted.rb
+++ b/app/models/concerns/token_authenticatable_strategies/encrypted.rb
@@ -2,6 +2,10 @@
module TokenAuthenticatableStrategies
class Encrypted < Base
+ def token_fields
+ super + [encrypted_field]
+ end
+
def find_token_authenticatable(token, unscoped = false)
return if token.blank?
diff --git a/app/models/concerns/update_namespace_statistics.rb b/app/models/concerns/update_namespace_statistics.rb
new file mode 100644
index 00000000000..26d6fc10228
--- /dev/null
+++ b/app/models/concerns/update_namespace_statistics.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+# This module provides helpers for updating `NamespaceStatistics` with `after_save` and
+# `after_destroy` hooks.
+#
+# Models including this module must respond to and return a `namespace`
+#
+# Example:
+#
+# class DependencyProxy::Manifest
+# include UpdateNamespaceStatistics
+#
+# belongs_to :group
+# alias_attribute :namespace, :group
+#
+# update_namespace_statistics namespace_statistics_name: :dependency_proxy_size
+# end
+module UpdateNamespaceStatistics
+ extend ActiveSupport::Concern
+ include AfterCommitQueue
+
+ class_methods do
+ attr_reader :namespace_statistics_name, :statistic_attribute
+
+ # Configure the model to update `namespace_statistics_name` on NamespaceStatistics,
+ # when `statistic_attribute` changes
+ #
+ # - namespace_statistics_name: A column of `NamespaceStatistics` to update
+ # - statistic_attribute: An attribute of the current model, default to `size`
+ def update_namespace_statistics(namespace_statistics_name:, statistic_attribute: :size)
+ @namespace_statistics_name = namespace_statistics_name
+ @statistic_attribute = statistic_attribute
+
+ after_save(:schedule_namespace_statistics_refresh, if: :update_namespace_statistics?)
+ after_destroy(:schedule_namespace_statistics_refresh)
+ end
+
+ private :update_namespace_statistics
+ end
+
+ included do
+ private
+
+ def update_namespace_statistics?
+ saved_change_to_attribute?(self.class.statistic_attribute)
+ end
+
+ def schedule_namespace_statistics_refresh
+ run_after_commit do
+ Groups::UpdateStatisticsWorker.perform_async(namespace.id, [self.class.namespace_statistics_name])
+ end
+ end
+ end
+end
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 1f123cb0244..fa03d73646d 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -14,6 +14,8 @@ class ContainerRepository < ApplicationRecord
ABORTABLE_MIGRATION_STATES = (ACTIVE_MIGRATION_STATES + %w[pre_import_done default]).freeze
MIGRATION_STATES = (IDLE_MIGRATION_STATES + ACTIVE_MIGRATION_STATES).freeze
+ MIGRATION_PHASE_1_STARTED_AT = Date.new(2021, 11, 4).freeze
+
TooManyImportsError = Class.new(StandardError)
NativeImportError = Class.new(StandardError)
@@ -64,7 +66,7 @@ class ContainerRepository < ApplicationRecord
# feature flag since it is only accessed in this query.
# https://gitlab.com/gitlab-org/gitlab/-/issues/350543 tracks the rollout and
# removal of this feature flag.
- joins(:project).where(
+ joins(project: [:namespace]).where(
migration_state: [:default],
created_at: ...ContainerRegistry::Migration.created_before
).with_target_import_tier
@@ -74,7 +76,7 @@ class ContainerRepository < ApplicationRecord
FROM feature_gates
WHERE feature_gates.feature_key = 'container_registry_phase_2_deny_list'
AND feature_gates.key = 'actors'
- AND feature_gates.value = concat('Group:', projects.namespace_id)
+ AND feature_gates.value = concat('Group:', namespaces.traversal_ids[1])
)"
)
end
@@ -408,6 +410,16 @@ class ContainerRepository < ApplicationRecord
update!(expiration_policy_started_at: Time.zone.now)
end
+ def size
+ strong_memoize(:size) do
+ next unless Gitlab.com?
+ next if self.created_at.before?(MIGRATION_PHASE_1_STARTED_AT)
+ next unless gitlab_api_client.supports_gitlab_api?
+
+ gitlab_api_client.repository_details(self.path, with_size: true)['size_bytes']
+ end
+ end
+
def migration_in_active_state?
migration_state.in?(ACTIVE_MIGRATION_STATES)
end
diff --git a/app/models/customer_relations/contact.rb b/app/models/customer_relations/contact.rb
index a981351f4a0..4fa2c3fb8cf 100644
--- a/app/models/customer_relations/contact.rb
+++ b/app/models/customer_relations/contact.rb
@@ -23,8 +23,9 @@ class CustomerRelations::Contact < ApplicationRecord
validates :last_name, presence: true, length: { maximum: 255 }
validates :email, length: { maximum: 255 }
validates :description, length: { maximum: 1024 }
+ validates :email, uniqueness: { scope: :group_id }
validate :validate_email_format
- validate :unique_email_for_group_hierarchy
+ validate :validate_root_group
def self.reference_prefix
'[contact:'
@@ -41,14 +42,13 @@ class CustomerRelations::Contact < ApplicationRecord
def self.find_ids_by_emails(group, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
- where(group_id: group.self_and_ancestor_ids, email: emails)
- .pluck(:id)
+ where(group: group, email: emails).pluck(:id)
end
def self.exists_for_group?(group)
return false unless group
- exists?(group_id: group.self_and_ancestor_ids)
+ exists?(group: group)
end
private
@@ -59,13 +59,9 @@ class CustomerRelations::Contact < ApplicationRecord
self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
end
- def unique_email_for_group_hierarchy
- return unless group
- return unless email
+ def validate_root_group
+ return if group&.root?
- duplicate_email_exists = CustomerRelations::Contact
- .where(group_id: group.self_and_hierarchy.pluck(:id), email: email)
- .where.not(id: id).exists?
- self.errors.add(:email, _('contact with same email already exists in group hierarchy')) if duplicate_email_exists
+ self.errors.add(:base, _('contacts can only be added to root groups'))
end
end
diff --git a/app/models/customer_relations/issue_contact.rb b/app/models/customer_relations/issue_contact.rb
index 3e9d1e97c8c..dc7a3fd87bc 100644
--- a/app/models/customer_relations/issue_contact.rb
+++ b/app/models/customer_relations/issue_contact.rb
@@ -6,7 +6,7 @@ class CustomerRelations::IssueContact < ApplicationRecord
belongs_to :issue, optional: false, inverse_of: :customer_relations_contacts
belongs_to :contact, optional: false, inverse_of: :issue_contacts
- validate :contact_belongs_to_issue_group_or_ancestor
+ validate :contact_belongs_to_root_group
def self.find_contact_ids_by_emails(issue_id, emails)
raise ArgumentError, "Cannot lookup more than #{MAX_PLUCK} emails" if emails.length > MAX_PLUCK
@@ -24,11 +24,11 @@ class CustomerRelations::IssueContact < ApplicationRecord
private
- def contact_belongs_to_issue_group_or_ancestor
+ def contact_belongs_to_root_group
return unless contact&.group_id
return unless issue&.project&.namespace_id
- return if issue.project.group&.self_and_ancestor_ids&.include?(contact.group_id)
+ return if issue.project.root_ancestor&.id == contact.group_id
- errors.add(:base, _('The contact does not belong to the issue group or an ancestor'))
+ errors.add(:base, _("The contact does not belong to the issue group's root ancestor"))
end
end
diff --git a/app/models/customer_relations/organization.rb b/app/models/customer_relations/organization.rb
index c206d1e05f5..a23b9d8fe28 100644
--- a/app/models/customer_relations/organization.rb
+++ b/app/models/customer_relations/organization.rb
@@ -19,9 +19,18 @@ class CustomerRelations::Organization < ApplicationRecord
validates :name, uniqueness: { case_sensitive: false, scope: [:group_id] }
validates :name, length: { maximum: 255 }
validates :description, length: { maximum: 1024 }
+ validate :validate_root_group
def self.find_by_name(group_id, name)
where(group: group_id)
.where('LOWER(name) = LOWER(?)', name)
end
+
+ private
+
+ def validate_root_group
+ return if group&.root?
+
+ self.errors.add(:base, _('organizations can only be added to root groups'))
+ end
end
diff --git a/app/models/dependency_proxy/blob.rb b/app/models/dependency_proxy/blob.rb
index f7b08f1d077..dc40ff62adb 100644
--- a/app/models/dependency_proxy/blob.rb
+++ b/app/models/dependency_proxy/blob.rb
@@ -5,8 +5,10 @@ class DependencyProxy::Blob < ApplicationRecord
include TtlExpirable
include Packages::Destructible
include EachBatch
+ include UpdateNamespaceStatistics
belongs_to :group
+ alias_attribute :namespace, :group
MAX_FILE_SIZE = 5.gigabytes.freeze
@@ -17,6 +19,7 @@ class DependencyProxy::Blob < ApplicationRecord
scope :with_files_stored_locally, -> { where(file_store: ::DependencyProxy::FileUploader::Store::LOCAL) }
mount_file_store_uploader DependencyProxy::FileUploader
+ update_namespace_statistics namespace_statistics_name: :dependency_proxy_size
def self.total_size
sum(:size)
diff --git a/app/models/dependency_proxy/manifest.rb b/app/models/dependency_proxy/manifest.rb
index c2587ffac9d..5ad746e4cd1 100644
--- a/app/models/dependency_proxy/manifest.rb
+++ b/app/models/dependency_proxy/manifest.rb
@@ -5,8 +5,10 @@ class DependencyProxy::Manifest < ApplicationRecord
include TtlExpirable
include Packages::Destructible
include EachBatch
+ include UpdateNamespaceStatistics
belongs_to :group
+ alias_attribute :namespace, :group
MAX_FILE_SIZE = 10.megabytes.freeze
DIGEST_HEADER = 'Docker-Content-Digest'
@@ -20,6 +22,7 @@ class DependencyProxy::Manifest < ApplicationRecord
scope :with_files_stored_locally, -> { where(file_store: ::DependencyProxy::FileUploader::Store::LOCAL) }
mount_file_store_uploader DependencyProxy::FileUploader
+ update_namespace_statistics namespace_statistics_name: :dependency_proxy_size
def self.find_by_file_name_or_digest(file_name:, digest:)
find_by(file_name: file_name) || find_by(digest: digest)
diff --git a/app/models/deployment.rb b/app/models/deployment.rb
index 46409465209..c06c809538a 100644
--- a/app/models/deployment.rb
+++ b/app/models/deployment.rb
@@ -8,7 +8,6 @@ class Deployment < ApplicationRecord
include Importable
include Gitlab::Utils::StrongMemoize
include FastDestroyAll
- include FromUnion
StatusUpdateError = Class.new(StandardError)
StatusSyncError = Class.new(StandardError)
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index 6ebac6384bc..02979d5f804 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -145,7 +145,7 @@ class DiffNote < Note
end
def fetch_diff_file
- return note_diff_file.raw_diff_file if note_diff_file
+ return note_diff_file.raw_diff_file if note_diff_file && !note_diff_file.raw_diff_file.has_renderable?
if created_at_diff?(noteable.diff_refs)
# We're able to use the already persisted diffs (Postgres) if we're
diff --git a/app/models/environment.rb b/app/models/environment.rb
index 51a9024721b..450ed6206d5 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -461,11 +461,16 @@ class Environment < ApplicationRecord
# See https://en.wikipedia.org/wiki/Deployment_environment for industry standard deployment environments
def guess_tier
case name
- when %r{dev|review|trunk}i then self.class.tiers[:development]
- when %r{test|qc}i then self.class.tiers[:testing]
- when %r{st(a|)g|mod(e|)l|pre|demo}i then self.class.tiers[:staging]
- when %r{pr(o|)d|live}i then self.class.tiers[:production]
- else self.class.tiers[:other]
+ when /(dev|review|trunk)/i
+ self.class.tiers[:development]
+ when /(test|tst|int|ac(ce|)pt|qa|qc|control|quality)/i
+ self.class.tiers[:testing]
+ when /(st(a|)g|mod(e|)l|pre|demo)/i
+ self.class.tiers[:staging]
+ when /(pr(o|)d|live)/i
+ self.class.tiers[:production]
+ else
+ self.class.tiers[:other]
end
end
end
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 25f812645b1..0a429bb7afd 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -59,6 +59,10 @@ module ErrorTracking
integrated
end
+ def integrated_enabled?
+ enabled? && integrated_client?
+ end
+
def gitlab_dsn
strong_memoize(:gitlab_dsn) do
client_key&.sentry_dsn
diff --git a/app/models/event_collection.rb b/app/models/event_collection.rb
index f799377a15f..fc093894847 100644
--- a/app/models/event_collection.rb
+++ b/app/models/event_collection.rb
@@ -44,31 +44,31 @@ class EventCollection
private
def project_events
- relation_with_join_lateral('project_id', projects)
+ in_operator_optimized_relation('project_id', projects)
end
- def project_and_group_events
- group_events = relation_with_join_lateral('group_id', groups)
+ def group_events
+ in_operator_optimized_relation('group_id', groups)
+ end
+ def project_and_group_events
Event.from_union([project_events, group_events]).recent
end
- # This relation is built using JOIN LATERAL, producing faster queries than a
- # regular LIMIT + OFFSET approach.
- def relation_with_join_lateral(parent_column, parents)
- parents_for_lateral = parents.select(:id).to_sql
-
- lateral = filtered_events
- # Applying the limit here (before we filter (permissions) means we may get less than limit)
- .limit(limit_for_join_lateral)
- .where("events.#{parent_column} = parents_for_lateral.id") # rubocop:disable GitlabSecurity/SqlInjection
- .to_sql
-
- # The outer query does not need to re-apply the filters since the JOIN
- # LATERAL body already takes care of this.
- base_relation
- .from("(#{parents_for_lateral}) parents_for_lateral")
- .joins("JOIN LATERAL (#{lateral}) AS #{Event.table_name} ON true")
+ def in_operator_optimized_relation(parent_column, parents)
+ scope = filtered_events
+ array_scope = parents.select(:id)
+ array_mapping_scope = -> (parent_id_expression) { Event.where(Event.arel_table[parent_column].eq(parent_id_expression)).reorder(id: :desc) }
+ finder_query = -> (id_expression) { Event.where(Event.arel_table[:id].eq(id_expression)) }
+
+ Gitlab::Pagination::Keyset::InOperatorOptimization::QueryBuilder
+ .new(
+ scope: scope,
+ array_scope: array_scope,
+ array_mapping_scope: array_mapping_scope,
+ finder_query: finder_query
+ )
+ .execute
end
def filtered_events
@@ -85,16 +85,6 @@ class EventCollection
Event.unscoped.recent
end
- def limit_for_join_lateral
- # Applying the OFFSET on the inside of a JOIN LATERAL leads to incorrect
- # results. To work around this we need to increase the inner limit for every
- # page.
- #
- # This means that on page 1 we use LIMIT 20, and an outer OFFSET of 0. On
- # page 2 we use LIMIT 40 and an outer OFFSET of 20.
- @limit + @offset
- end
-
def current_page
(@offset / @limit) + 1
end
diff --git a/app/models/group.rb b/app/models/group.rb
index 1d6a3a14450..14d088dd38b 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -19,7 +19,6 @@ class Group < Namespace
include BulkMemberAccessLoad
include ChronicDurationAttribute
include RunnerTokenExpirationInterval
- include RunnersTokenPrefixable
extend ::Gitlab::Utils::Override
@@ -120,7 +119,7 @@ class Group < Namespace
add_authentication_token_field :runners_token,
encrypted: -> { Feature.enabled?(:groups_tokens_optional_encryption, default_enabled: true) ? :optional : :required },
- prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
+ prefix: RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX
after_create :post_create_hook
after_destroy :post_destroy_hook
@@ -676,7 +675,7 @@ class Group < Namespace
override :format_runners_token
def format_runners_token(token)
- "#{runners_token_prefix}#{token}"
+ "#{RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX}#{token}"
end
def project_creation_level
@@ -817,7 +816,9 @@ class Group < Namespace
private
def max_member_access(user_ids)
- max_member_access_for_resource_ids(User, user_ids) do |user_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(User),
+ resource_ids: user_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |user_ids|
members_with_parents.where(user_id: user_ids).group(:user_id).maximum(:access_level)
end
end
@@ -892,6 +893,7 @@ class Group < Namespace
.where(group_member_table[:requested_at].eq(nil))
.where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id]))
.where(group_member_table[:source_type].eq('Namespace'))
+ .where(group_member_table[:state].eq(::Member::STATE_ACTIVE))
.non_minimal_access
end
diff --git a/app/models/hooks/web_hook.rb b/app/models/hooks/web_hook.rb
index 7e538238cbd..88941df691c 100644
--- a/app/models/hooks/web_hook.rb
+++ b/app/models/hooks/web_hook.rb
@@ -37,14 +37,14 @@ class WebHook < ApplicationRecord
!temporarily_disabled? && !permanently_disabled?
end
- def temporarily_disabled?
- return false unless web_hooks_disable_failed?
+ def temporarily_disabled?(ignore_flag: false)
+ return false unless ignore_flag || web_hooks_disable_failed?
disabled_until.present? && disabled_until >= Time.current
end
- def permanently_disabled?
- return false unless web_hooks_disable_failed?
+ def permanently_disabled?(ignore_flag: false)
+ return false unless ignore_flag || web_hooks_disable_failed?
recent_failures > FAILURE_THRESHOLD
end
@@ -106,6 +106,13 @@ class WebHook < ApplicationRecord
save(validate: false)
end
+ def active_state(ignore_flag: false)
+ return :permanently_disabled if permanently_disabled?(ignore_flag: ignore_flag)
+ return :temporarily_disabled if temporarily_disabled?(ignore_flag: ignore_flag)
+
+ :enabled
+ end
+
# @return [Boolean] Whether or not the WebHook is currently throttled.
def rate_limited?
return false unless rate_limit
diff --git a/app/models/instance_configuration.rb b/app/models/instance_configuration.rb
index 2016024b2f4..00e55d0fd89 100644
--- a/app/models/instance_configuration.rb
+++ b/app/models/instance_configuration.rb
@@ -118,7 +118,8 @@ class InstanceConfiguration
group_export_download: application_setting_limit_per_minute(:group_download_export_limit),
group_import: application_setting_limit_per_minute(:group_import_limit),
raw_blob: application_setting_limit_per_minute(:raw_blob_request_limit),
- user_email_lookup: application_setting_limit_per_minute(:user_email_lookup_limit),
+ search_rate_limit: application_setting_limit_per_minute(:search_rate_limit),
+ search_rate_limit_unauthenticated: application_setting_limit_per_minute(:search_rate_limit_unauthenticated),
users_get_by_id: {
enabled: application_settings[:users_get_by_id_limit] > 0,
requests_per_period: application_settings[:users_get_by_id_limit],
diff --git a/app/models/integration.rb b/app/models/integration.rb
index e9cd90649ba..274c16507b7 100644
--- a/app/models/integration.rb
+++ b/app/models/integration.rb
@@ -9,10 +9,18 @@ class Integration < ApplicationRecord
include Integrations::HasDataFields
include FromUnion
include EachBatch
+ include IgnorableColumns
+
+ ignore_column :template, remove_with: '15.0', remove_after: '2022-04-22'
+ ignore_column :type, remove_with: '15.0', remove_after: '2022-04-22'
+
+ UnknownType = Class.new(StandardError)
+
+ self.inheritance_column = :type_new
INTEGRATION_NAMES = %w[
asana assembla bamboo bugzilla buildkite campfire confluence custom_issue_tracker datadog discord
- drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat irker jira
+ drone_ci emails_on_push ewm external_wiki flowdock hangouts_chat harbor 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 zentao
].freeze
@@ -37,9 +45,21 @@ class Integration < ApplicationRecord
Integrations::BaseSlashCommands
].freeze
+ SECTION_TYPE_CONNECTION = 'connection'
+
serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize
- attribute :type, Gitlab::Integrations::StiType.new
+ attr_encrypted :encrypted_properties_tmp,
+ attribute: :encrypted_properties,
+ mode: :per_attribute_iv,
+ key: Settings.attr_encrypted_db_key_base_32,
+ algorithm: 'aes-256-gcm',
+ marshal: true,
+ marshaler: ::Gitlab::Json,
+ encode: false,
+ encode_iv: false
+
+ alias_attribute :type, :type_new
default_value_for :active, false
default_value_for :alert_events, true
@@ -57,6 +77,8 @@ class Integration < ApplicationRecord
default_value_for :wiki_page_events, true
after_initialize :initialize_properties
+ after_initialize :copy_properties_to_encrypted_properties
+ before_save :copy_properties_to_encrypted_properties
after_commit :reset_updated_properties
@@ -74,9 +96,10 @@ class Integration < ApplicationRecord
validate :validate_belongs_to_project_or_group
scope :external_issue_trackers, -> { where(category: 'issue_tracker').active }
- scope :external_wikis, -> { where(type: 'ExternalWikiService').active }
+ scope :by_name, ->(name) { by_type(integration_name_to_type(name)) }
+ scope :external_wikis, -> { by_name(:external_wiki).active }
scope :active, -> { where(active: true) }
- scope :by_type, -> (type) { where(type: type) }
+ scope :by_type, ->(type) { where(type: type) } # INTERNAL USE ONLY: use by_name instead
scope :by_active_flag, -> (flag) { where(active: flag) }
scope :inherit_from_id, -> (id) { where(inherit_from_id: id) }
scope :with_default_settings, -> { where.not(inherit_from_id: nil) }
@@ -99,6 +122,39 @@ class Integration < ApplicationRecord
scope :alert_hooks, -> { where(alert_events: true, active: true) }
scope :deployment, -> { where(category: 'deployment') }
+ class << self
+ private
+
+ attr_writer :field_storage
+
+ def field_storage
+ @field_storage || :properties
+ end
+ end
+
+ # :nocov: Tested on subclasses.
+ def self.field(name, storage: field_storage, **attrs)
+ fields << ::Integrations::Field.new(name: name, **attrs)
+
+ case storage
+ when :properties
+ prop_accessor(name)
+ when :data_fields
+ data_field(name)
+ else
+ raise ArgumentError, "Unknown field storage: #{storage}"
+ end
+ end
+ # :nocov:
+
+ def self.fields
+ @fields ||= []
+ end
+
+ def fields
+ self.class.fields
+ end
+
# Provide convenient accessor methods for each serialized property.
# Also keep track of updated properties in a similar way as ActiveModel::Dirty
def self.prop_accessor(*args)
@@ -112,8 +168,10 @@ class Integration < ApplicationRecord
def #{arg}=(value)
self.properties ||= {}
+ self.encrypted_properties_tmp = properties
updated_properties['#{arg}'] = #{arg} unless #{arg}_changed?
self.properties['#{arg}'] = value
+ self.encrypted_properties_tmp['#{arg}'] = value
end
def #{arg}_changed?
@@ -158,10 +216,6 @@ class Integration < ApplicationRecord
self.supported_events.map { |event| IntegrationsHelper.integration_event_field_name(event) }
end
- def self.supported_event_actions
- %w[]
- end
-
def self.supported_events
%w[commit push tag_push issue confidential_issue merge_request wiki_page]
end
@@ -226,7 +280,7 @@ class Integration < ApplicationRecord
end
# Returns a list of available integration types.
- # Example: ["AsanaService", ...]
+ # Example: ["Integrations::Asana", ...]
def self.available_integration_types(include_project_specific: true, include_dev: true)
available_integration_names(include_project_specific: include_project_specific, include_dev: include_dev).map do
integration_name_to_type(_1)
@@ -234,22 +288,27 @@ class Integration < ApplicationRecord
end
# Returns the model for the given integration name.
- # Example: "asana" => Integrations::Asana
+ # Example: :asana => Integrations::Asana
def self.integration_name_to_model(name)
type = integration_name_to_type(name)
integration_type_to_model(type)
end
# Returns the STI type for the given integration name.
- # Example: "asana" => "AsanaService"
+ # Example: "asana" => "Integrations::Asana"
def self.integration_name_to_type(name)
- "#{name}_service".camelize
+ name = name.to_s
+ if available_integration_names.exclude?(name)
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(UnknownType.new(name.inspect))
+ else
+ "Integrations::#{name.camelize}"
+ end
end
# Returns the model for the given STI type.
- # Example: "AsanaService" => Integrations::Asana
+ # Example: "Integrations::Asana" => Integrations::Asana
def self.integration_type_to_model(type)
- Gitlab::Integrations::StiType.new.cast(type).constantize
+ type.constantize
end
private_class_method :integration_type_to_model
@@ -298,7 +357,7 @@ class Integration < ApplicationRecord
from_union([
active.where(instance: true),
active.where(group_id: group_ids, inherit_from_id: nil)
- ]).order(Arel.sql("type ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")).group_by(&:type).each do |type, records|
+ ]).order(Arel.sql("type_new ASC, array_position(#{array}::bigint[], #{table_name}.group_id), instance DESC")).group_by(&:type).each do |type, records|
build_from_integration(records.first, association => scope.id).save
end
end
@@ -330,6 +389,10 @@ class Integration < ApplicationRecord
true
end
+ def activate_disabled_reason
+ nil
+ end
+
def category
read_attribute(:category).to_sym
end
@@ -338,6 +401,12 @@ class Integration < ApplicationRecord
self.properties = {} if has_attribute?(:properties) && properties.nil?
end
+ def copy_properties_to_encrypted_properties
+ self.encrypted_properties_tmp = properties
+ rescue ActiveModel::MissingAttributeError
+ # ignore - in a record built from using a restricted select list
+ end
+
def title
# implement inside child
end
@@ -355,8 +424,7 @@ class Integration < ApplicationRecord
self.class.to_param
end
- def fields
- # implement inside child
+ def sections
[]
end
@@ -371,8 +439,24 @@ class Integration < ApplicationRecord
%w[active]
end
+ # return a hash of columns => values suitable for passing to insert_all
def to_integration_hash
- as_json(methods: :type, except: %w[id instance project_id group_id])
+ column = self.class.attribute_aliases.fetch('type', 'type')
+ copy_properties_to_encrypted_properties
+
+ as_json(except: %w[id instance project_id group_id encrypted_properties_tmp])
+ .merge(column => type)
+ .merge(reencrypt_properties)
+ end
+
+ def reencrypt_properties
+ unless properties.nil? || properties.empty?
+ alg = self.class.encrypted_attributes[:encrypted_properties_tmp][:algorithm]
+ iv = generate_iv(alg)
+ ep = self.class.encrypt(:encrypted_properties_tmp, properties, { iv: iv })
+ end
+
+ { 'encrypted_properties' => ep, 'encrypted_properties_iv' => iv }
end
def to_data_fields_hash
@@ -392,7 +476,10 @@ class Integration < ApplicationRecord
end
def api_field_names
- fields.pluck(:name).grep_v(/password|token|key|title|description/)
+ fields
+ .reject { _1[:type] == 'password' }
+ .pluck(:name)
+ .grep_v(/password|token|key/)
end
def global_fields
@@ -410,10 +497,6 @@ class Integration < ApplicationRecord
end
end
- def configurable_event_actions
- self.class.supported_event_actions
- end
-
def supported_events
self.class.supported_events
end
diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb
index 7949563a1dc..054f0606dd2 100644
--- a/app/models/integrations/asana.rb
+++ b/app/models/integrations/asana.rb
@@ -4,8 +4,6 @@ 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?
@@ -18,7 +16,7 @@ module Integrations
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'
+ docs_link = ActionController::Base.helpers.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
diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb
index 57767c63cf4..c614a9415ab 100644
--- a/app/models/integrations/bamboo.rb
+++ b/app/models/integrations/bamboo.rb
@@ -2,7 +2,6 @@
module Integrations
class Bamboo < BaseCi
- include ActionView::Helpers::UrlHelper
include ReactivelyCached
prepend EnableSslVerification
@@ -36,7 +35,7 @@ module Integrations
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'
+ docs_link = ActionController::Base.helpers.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
diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb
index d0d54a92021..d5b6357cb66 100644
--- a/app/models/integrations/base_chat_notification.rb
+++ b/app/models/integrations/base_chat_notification.rb
@@ -241,7 +241,6 @@ module Integrations
def notify_for_ref?(data)
return true if data[:object_kind] == 'tag_push'
- return true if data[:object_kind] == 'deployment' && !Feature.enabled?(:chat_notification_deployment_protected_branch_filter, project)
ref = data[:ref] || data.dig(:object_attributes, :ref)
return true if ref.blank? # No need to check protected branches when there is no ref
diff --git a/app/models/integrations/base_issue_tracker.rb b/app/models/integrations/base_issue_tracker.rb
index 42a6a3a19c8..458d0199e7a 100644
--- a/app/models/integrations/base_issue_tracker.rb
+++ b/app/models/integrations/base_issue_tracker.rb
@@ -4,10 +4,6 @@ module Integrations
class BaseIssueTracker < Integration
validate :one_issue_tracker, if: :activated?, on: :manual_change
- # TODO: we can probably just delegate as part of
- # https://gitlab.com/gitlab-org/gitlab/issues/29404
- data_field :project_url, :issues_url, :new_issue_url
-
default_value_for :category, 'issue_tracker'
before_validation :handle_properties
@@ -72,14 +68,6 @@ module Integrations
issue_url(iid)
end
- def fields
- [
- { 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
-
def initialize_properties
{}
end
@@ -132,8 +120,18 @@ module Integrations
# implement inside child
end
+ def activate_disabled_reason
+ { trackers: other_external_issue_trackers } if other_external_issue_trackers.any?
+ end
+
private
+ def other_external_issue_trackers
+ return [] unless project_level?
+
+ @other_external_issue_trackers ||= project.integrations.external_issue_trackers.where.not(id: id)
+ end
+
def enabled_in_gitlab_config
Gitlab.config.issues_tracker &&
Gitlab.config.issues_tracker.values.any? &&
@@ -145,10 +143,10 @@ module Integrations
end
def one_issue_tracker
- return if template? || instance?
+ return if instance?
return if project.blank?
- if project.integrations.external_issue_trackers.where.not(id: id).any?
+ if other_external_issue_trackers.any?
errors.add(:base, _('Another issue tracker is already in use. Only one issue tracker service can be active at a time'))
end
end
diff --git a/app/models/integrations/bugzilla.rb b/app/models/integrations/bugzilla.rb
index 9251015acb8..74e282f6848 100644
--- a/app/models/integrations/bugzilla.rb
+++ b/app/models/integrations/bugzilla.rb
@@ -2,7 +2,7 @@
module Integrations
class Bugzilla < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include Integrations::HasIssueTrackerFields
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
@@ -15,7 +15,7 @@ module Integrations
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'
+ docs_link = ActionController::Base.helpers.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
diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb
index c78fc6eff51..81e6c2411b8 100644
--- a/app/models/integrations/campfire.rb
+++ b/app/models/integrations/campfire.rb
@@ -2,8 +2,6 @@
module Integrations
class Campfire < Integration
- include ActionView::Helpers::UrlHelper
-
prop_accessor :token, :subdomain, :room
validates :token, presence: true, if: :activated?
@@ -16,7 +14,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/services', anchor: 'campfire'), target: '_blank', rel: 'noopener noreferrer'
s_('CampfireService|Send notifications about push events to Campfire chat rooms. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb
index 7f111f482dd..65adce7a8d6 100644
--- a/app/models/integrations/confluence.rb
+++ b/app/models/integrations/confluence.rb
@@ -2,8 +2,6 @@
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
@@ -39,7 +37,7 @@ module Integrations
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) }
+ { wiki_link: ActionController::Base.helpers.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
diff --git a/app/models/integrations/custom_issue_tracker.rb b/app/models/integrations/custom_issue_tracker.rb
index 635a9d093e9..3770e813eaa 100644
--- a/app/models/integrations/custom_issue_tracker.rb
+++ b/app/models/integrations/custom_issue_tracker.rb
@@ -2,7 +2,8 @@
module Integrations
class CustomIssueTracker < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include HasIssueTrackerFields
+
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
def title
@@ -14,7 +15,7 @@ module Integrations
end
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'
+ docs_link = ActionController::Base.helpers.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
diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb
index 21993dd3c43..790e41e5a2a 100644
--- a/app/models/integrations/discord.rb
+++ b/app/models/integrations/discord.rb
@@ -4,8 +4,6 @@ require "discordrb/webhooks"
module Integrations
class Discord < BaseChatNotification
- include ActionView::Helpers::UrlHelper
-
ATTACHMENT_REGEX = /: (?<entry>.*?)\n - (?<name>.*)\n*/.freeze
def title
@@ -21,7 +19,7 @@ module Integrations
end
def help
- docs_link = link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer'
s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb
index 24d343b7cb4..1b86ef73c85 100644
--- a/app/models/integrations/ewm.rb
+++ b/app/models/integrations/ewm.rb
@@ -2,7 +2,7 @@
module Integrations
class Ewm < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include HasIssueTrackerFields
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
@@ -19,7 +19,7 @@ module Integrations
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'
+ docs_link = ActionController::Base.helpers.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
diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb
index 2a8d598117b..18c48411e30 100644
--- a/app/models/integrations/external_wiki.rb
+++ b/app/models/integrations/external_wiki.rb
@@ -2,8 +2,6 @@
module Integrations
class ExternalWiki < Integration
- include ActionView::Helpers::UrlHelper
-
prop_accessor :external_wiki_url
validates :external_wiki_url, presence: true, public_url: true, if: :activated?
@@ -33,7 +31,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer'
s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/field.rb b/app/models/integrations/field.rb
new file mode 100644
index 00000000000..49ab97677db
--- /dev/null
+++ b/app/models/integrations/field.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Field
+ SENSITIVE_NAME = %r/token|key|password|passphrase|secret/.freeze
+
+ ATTRIBUTES = %i[
+ section type placeholder required choices value checkbox_label
+ title help
+ non_empty_password_help
+ non_empty_password_title
+ api_only
+ ].freeze
+
+ attr_reader :name
+
+ def initialize(name:, type: 'text', api_only: false, **attributes)
+ @name = name.to_s.freeze
+
+ attributes[:type] = SENSITIVE_NAME.match?(@name) ? 'password' : type
+ attributes[:api_only] = api_only
+ @attributes = attributes.freeze
+ end
+
+ def [](key)
+ return name if key == :name
+
+ value = @attributes[key]
+ return value.call if value.respond_to?(:call)
+
+ value
+ end
+
+ def sensitive?
+ @attributes[:type] == 'password'
+ end
+
+ ATTRIBUTES.each do |name|
+ define_method(name) { self[name] }
+ end
+ end
+end
diff --git a/app/models/integrations/flowdock.rb b/app/models/integrations/flowdock.rb
index 443f61e65dd..476cdc35585 100644
--- a/app/models/integrations/flowdock.rb
+++ b/app/models/integrations/flowdock.rb
@@ -2,8 +2,6 @@
module Integrations
class Flowdock < Integration
- include ActionView::Helpers::UrlHelper
-
prop_accessor :token
validates :token, presence: true, if: :activated?
@@ -16,7 +14,7 @@ module Integrations
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'
+ docs_link = ActionController::Base.helpers.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
diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb
index 0d6b9fb1019..8c68c9ff95a 100644
--- a/app/models/integrations/hangouts_chat.rb
+++ b/app/models/integrations/hangouts_chat.rb
@@ -2,8 +2,6 @@
module Integrations
class HangoutsChat < BaseChatNotification
- include ActionView::Helpers::UrlHelper
-
def title
'Google Chat'
end
@@ -17,7 +15,7 @@ module Integrations
end
def help
- 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'
+ docs_link = ActionController::Base.helpers.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
diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb
new file mode 100644
index 00000000000..4c76e418886
--- /dev/null
+++ b/app/models/integrations/harbor.rb
@@ -0,0 +1,104 @@
+# frozen_string_literal: true
+
+module Integrations
+ class Harbor < Integration
+ prop_accessor :url, :project_name, :username, :password
+
+ validates :url, public_url: true, presence: true, if: :activated?
+ validates :project_name, presence: true, if: :activated?
+ validates :username, presence: true, if: :activated?
+ validates :password, format: { with: ::Ci::Maskable::REGEX }, if: :activated?
+
+ before_validation :reset_username_and_password
+
+ def title
+ 'Harbor'
+ end
+
+ def description
+ s_("HarborIntegration|Use Harbor as this project's container registry.")
+ end
+
+ def help
+ s_("HarborIntegration|After the Harbor integration is activated, global variables ‘$HARBOR_USERNAME’, ‘$HARBOR_PASSWORD’, ‘$HARBOR_URL’ and ‘$HARBOR_PROJECT’ will be created for CI/CD use.")
+ end
+
+ class << self
+ def to_param
+ name.demodulize.downcase
+ end
+
+ def supported_events
+ []
+ end
+
+ def supported_event_actions
+ []
+ end
+ end
+
+ def test(*_args)
+ client.ping
+ end
+
+ def fields
+ [
+ {
+ type: 'text',
+ name: 'url',
+ title: s_('HarborIntegration|Harbor URL'),
+ placeholder: 'https://demo.goharbor.io',
+ help: s_('HarborIntegration|Base URL of the Harbor instance.'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'project_name',
+ title: s_('HarborIntegration|Harbor project name'),
+ help: s_('HarborIntegration|The name of the project in Harbor.')
+ },
+ {
+ type: 'text',
+ name: 'username',
+ title: s_('HarborIntegration|Harbor username'),
+ required: true
+ },
+ {
+ type: 'text',
+ name: 'password',
+ title: s_('HarborIntegration|Harbor password'),
+ non_empty_password_title: s_('HarborIntegration|Enter Harbor password'),
+ non_empty_password_help: s_('HarborIntegration|Password for your Harbor username.'),
+ required: true
+ }
+ ]
+ end
+
+ def ci_variables
+ return [] unless activated?
+
+ [
+ { key: 'HARBOR_URL', value: url },
+ { key: 'HARBOR_PROJECT', value: project_name },
+ { key: 'HARBOR_USERNAME', value: username },
+ { key: 'HARBOR_PASSWORD', value: password, public: false, masked: true }
+ ]
+ end
+
+ private
+
+ def client
+ @client ||= ::Gitlab::Harbor::Client.new(self)
+ end
+
+ def reset_username_and_password
+ if url_changed? && !password_touched?
+ self.password = nil
+ end
+
+ if url_changed? && !username_touched?
+ self.username = nil
+ end
+ end
+ end
+end
diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb
index cea4aa2038d..116d1fb233d 100644
--- a/app/models/integrations/irker.rb
+++ b/app/models/integrations/irker.rb
@@ -4,8 +4,6 @@ require 'uri'
module Integrations
class Irker < Integration
- include ActionView::Helpers::UrlHelper
-
prop_accessor :server_host, :server_port, :default_irc_uri
prop_accessor :recipients, :channels
boolean_accessor :colorize_messages
@@ -44,7 +42,7 @@ module Integrations
end
def fields
- recipients_docs_link = link_to s_('IrkerService|How to enter channels or users?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'enter-irker-recipients'), target: '_blank', rel: 'noopener noreferrer'
+ recipients_docs_link = ActionController::Base.helpers.link_to s_('IrkerService|How to enter channels or users?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'enter-irker-recipients'), target: '_blank', rel: 'noopener noreferrer'
[
{ type: 'text', name: 'server_host', placeholder: 'localhost', title: s_('IrkerService|Server host (optional)'),
help: s_('IrkerService|irker daemon hostname (defaults to localhost).') },
@@ -61,7 +59,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'set-up-an-irker-daemon'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/irker', anchor: 'set-up-an-irker-daemon'), target: '_blank', rel: 'noopener noreferrer'
s_('IrkerService|Send update messages to an irker server. Before you can use this, you need to set up the irker daemon. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb
index 5ea92170c26..32f11ee23eb 100644
--- a/app/models/integrations/jenkins.rb
+++ b/app/models/integrations/jenkins.rb
@@ -3,7 +3,7 @@
module Integrations
class Jenkins < BaseCi
include HasWebHook
- include ActionView::Helpers::UrlHelper
+
prepend EnableSslVerification
extend Gitlab::Utils::Override
@@ -65,7 +65,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer'
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
diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb
index 966ad07afad..74ece57000f 100644
--- a/app/models/integrations/jira.rb
+++ b/app/models/integrations/jira.rb
@@ -15,6 +15,9 @@ module Integrations
ATLASSIAN_REFERRER_GITLAB_COM = { atlOrigin: 'eyJpIjoiY2QyZTJiZDRkNGZhNGZlMWI3NzRkNTBmZmVlNzNiZTkiLCJwIjoianN3LWdpdGxhYi1pbnQifQ' }.freeze
ATLASSIAN_REFERRER_SELF_MANAGED = { atlOrigin: 'eyJpIjoiYjM0MTA4MzUyYTYxNDVkY2IwMzVjOGQ3ZWQ3NzMwM2QiLCJwIjoianN3LWdpdGxhYlNNLWludCJ9' }.freeze
+ SECTION_TYPE_JIRA_TRIGGER = 'jira_trigger'
+ SECTION_TYPE_JIRA_ISSUES = 'jira_issues'
+
validates :url, public_url: true, presence: true, if: :activated?
validates :api_url, public_url: true, allow_blank: true
validates :username, presence: true, if: :activated?
@@ -28,11 +31,6 @@ module Integrations
# We should use username/password for Jira Server and email/api_token for Jira Cloud,
# for more information check: https://gitlab.com/gitlab-org/gitlab-foss/issues/49936.
- # TODO: we can probably just delegate as part of
- # https://gitlab.com/gitlab-org/gitlab/issues/29404
- data_field :username, :password, :url, :api_url, :jira_issue_transition_automatic, :jira_issue_transition_id, :project_key, :issues_enabled,
- :vulnerabilities_enabled, :vulnerabilities_issuetype
-
before_validation :reset_password
after_commit :update_deployment_type, on: [:create, :update], if: :update_deployment_type?
@@ -41,16 +39,50 @@ module Integrations
all_details: 2
}
+ self.field_storage = :data_fields
+
+ field :url,
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('JiraService|Web URL') },
+ help: -> { s_('JiraService|Base URL of the Jira instance.') },
+ placeholder: 'https://jira.example.com'
+
+ field :api_url,
+ section: SECTION_TYPE_CONNECTION,
+ title: -> { s_('JiraService|Jira API URL') },
+ help: -> { s_('JiraService|If different from Web URL.') }
+
+ field :username,
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('JiraService|Username or Email') },
+ help: -> { s_('JiraService|Use a username for server version and an email for cloud version.') }
+
+ field :password,
+ section: SECTION_TYPE_CONNECTION,
+ required: true,
+ title: -> { s_('JiraService|Password or API token') },
+ non_empty_password_title: -> { s_('JiraService|Enter new password or API token') },
+ non_empty_password_help: -> { s_('JiraService|Leave blank to use your current password or API token.') },
+ help: -> { s_('JiraService|Use a password for server version and an API token for cloud version.') }
+
+ # TODO: we can probably just delegate as part of
+ # https://gitlab.com/gitlab-org/gitlab/issues/29404
+ # These fields are API only, so no field definition is required.
+ data_field :jira_issue_transition_automatic
+ data_field :jira_issue_transition_id
+ data_field :project_key
+ data_field :issues_enabled
+ data_field :vulnerabilities_enabled
+ data_field :vulnerabilities_issuetype
+
# When these are false GitLab does not create cross reference
# comments on Jira except when an issue gets transitioned.
def self.supported_events
%w(commit merge_request)
end
- def self.supported_event_actions
- %w(comment)
- end
-
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
def self.reference_pattern(only_long: true)
@reference_pattern ||= /(?<issue>\b#{Gitlab::Regex.jira_issue_key_regex})/
@@ -111,8 +143,8 @@ module Integrations
end
def help
- 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 }
+ jira_doc_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('integration/jira/index.html') }
+ s_("JiraService|You must configure Jira before enabling this integration. %{jira_doc_link_start}Learn more.%{link_end}") % { jira_doc_link_start: jira_doc_link_start, link_end: '</a>'.html_safe }
end
def title
@@ -127,39 +159,32 @@ module Integrations
'jira'
end
- def fields
- [
- {
- type: 'text',
- name: 'url',
- title: s_('JiraService|Web URL'),
- placeholder: 'https://jira.example.com',
- help: s_('JiraService|Base URL of the Jira instance.'),
- required: true
- },
- {
- type: 'text',
- name: 'api_url',
- title: s_('JiraService|Jira API URL'),
- help: s_('JiraService|If different from Web URL.')
- },
+ def sections
+ jira_issues_link_start = '<a href="%{url}">'.html_safe % { url: help_page_url('integration/jira/issues.html') }
+
+ sections = [
{
- type: 'text',
- name: 'username',
- title: s_('JiraService|Username or Email'),
- help: s_('JiraService|Use a username for server version and an email for cloud version.'),
- required: true
+ type: SECTION_TYPE_CONNECTION,
+ title: s_('Integrations|Connection details'),
+ description: help
},
{
- type: 'password',
- name: 'password',
- title: s_('JiraService|Password or API token'),
- non_empty_password_title: s_('JiraService|Enter new password or API token'),
- non_empty_password_help: s_('JiraService|Leave blank to use your current password or API token.'),
- help: s_('JiraService|Use a password for server version and an API token for cloud version.'),
- required: true
+ type: SECTION_TYPE_JIRA_TRIGGER,
+ title: _('Trigger'),
+ description: s_('JiraService|When a Jira issue is mentioned in a commit or merge request, a remote link and comment (if enabled) will be created.')
}
]
+
+ # Jira issues is currently only configurable on the project level.
+ if project_level?
+ sections.push({
+ type: SECTION_TYPE_JIRA_ISSUES,
+ title: _('Issues'),
+ description: s_('JiraService|Work on Jira issues without leaving GitLab. Add a Jira menu to access a read-only list of your Jira issues. %{jira_issues_link_start}Learn more.%{link_end}') % { jira_issues_link_start: jira_issues_link_start, link_end: '</a>'.html_safe }
+ })
+ end
+
+ sections
end
def web_url(path = nil, **params)
@@ -180,17 +205,12 @@ module Integrations
url.to_s
end
- override :project_url
- def project_url
- web_url
- end
+ alias_method :project_url, :web_url
- override :issues_url
def issues_url
web_url('browse/:id')
end
- override :new_issue_url
def new_issue_url
web_url('secure/CreateIssue!default.jspa')
end
diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb
index 07a5086b8e9..d9ccbb7ea34 100644
--- a/app/models/integrations/mattermost.rb
+++ b/app/models/integrations/mattermost.rb
@@ -3,7 +3,6 @@
module Integrations
class Mattermost < BaseChatNotification
include SlackMattermostNotifier
- include ActionView::Helpers::UrlHelper
def title
s_('Mattermost notifications')
@@ -18,7 +17,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer'
s_('Send notifications about project events to Mattermost channels. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb
index 24cfd51eb55..5b9ac023b7e 100644
--- a/app/models/integrations/pivotaltracker.rb
+++ b/app/models/integrations/pivotaltracker.rb
@@ -2,7 +2,6 @@
module Integrations
class Pivotaltracker < Integration
- include ActionView::Helpers::UrlHelper
API_ENDPOINT = 'https://www.pivotaltracker.com/services/v5/source_commits'
prop_accessor :token, :restrict_to_branch
@@ -17,7 +16,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pivotal_tracker'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pivotal_tracker'), target: '_blank', rel: 'noopener noreferrer'
s_('Add commit messages as comments to Pivotal Tracker stories. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb
index 5746343c31c..2e275dab91b 100644
--- a/app/models/integrations/prometheus.rb
+++ b/app/models/integrations/prometheus.rb
@@ -115,7 +115,6 @@ module Integrations
end
def prometheus_available?
- return false if template?
return false unless project
project.all_clusters.enabled.eager_load(:integration_prometheus).any? do |cluster|
diff --git a/app/models/integrations/redmine.rb b/app/models/integrations/redmine.rb
index 990b538f294..bc2a64b0848 100644
--- a/app/models/integrations/redmine.rb
+++ b/app/models/integrations/redmine.rb
@@ -2,7 +2,8 @@
module Integrations
class Redmine < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include Integrations::HasIssueTrackerFields
+
validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated?
def title
@@ -14,7 +15,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer'
s_('IssueTracker|Use Redmine as the issue tracker. %{docs_link}').html_safe % { docs_link: docs_link.html_safe }
end
diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb
index 7660eda6f83..345dd98cbc1 100644
--- a/app/models/integrations/webex_teams.rb
+++ b/app/models/integrations/webex_teams.rb
@@ -2,8 +2,6 @@
module Integrations
class WebexTeams < BaseChatNotification
- include ActionView::Helpers::UrlHelper
-
def title
s_("WebexTeamsService|Webex Teams")
end
@@ -17,7 +15,7 @@ module Integrations
end
def help
- docs_link = link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer'
+ docs_link = ActionController::Base.helpers.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
diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb
index 10531717f11..ab6e1da27f8 100644
--- a/app/models/integrations/youtrack.rb
+++ b/app/models/integrations/youtrack.rb
@@ -2,7 +2,7 @@
module Integrations
class Youtrack < BaseIssueTracker
- include ActionView::Helpers::UrlHelper
+ include Integrations::HasIssueTrackerFields
validates :project_url, :issues_url, presence: true, public_url: true, if: :activated?
@@ -24,7 +24,7 @@ module Integrations
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'
+ docs_link = ActionController::Base.helpers.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
diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb
index 493d42cc40b..c33df465fde 100644
--- a/app/models/integrations/zentao.rb
+++ b/app/models/integrations/zentao.rb
@@ -11,7 +11,6 @@ module Integrations
validates :api_token, presence: true, if: :activated?
validates :zentao_product_xid, presence: true, if: :activated?
- # License Level: EEP_FEATURES
def self.issues_license_available?(project)
project&.licensed_feature_available?(:zentao_issues_integration)
end
@@ -48,10 +47,6 @@ module Integrations
%w()
end
- def self.supported_event_actions
- %w()
- end
-
def fields
[
{
diff --git a/app/models/issue.rb b/app/models/issue.rb
index 68ea6cb3abc..75727fff2cd 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -24,6 +24,7 @@ class Issue < ApplicationRecord
include Todoable
include FromUnion
include EachBatch
+ include PgFullTextSearchable
extend ::Gitlab::Utils::Override
@@ -77,6 +78,7 @@ class Issue < ApplicationRecord
end
end
+ has_one :search_data, class_name: 'Issues::SearchData'
has_one :issuable_severity
has_one :sentry_issue
has_one :alert_management_alert, class_name: 'AlertManagement::Alert'
@@ -102,6 +104,8 @@ class Issue < ApplicationRecord
alias_attribute :external_author, :service_desk_reply_to
+ pg_full_text_searchable columns: [{ name: 'title', weight: 'A' }, { name: 'description', weight: 'B' }]
+
scope :in_projects, ->(project_ids) { where(project_id: project_ids) }
scope :not_in_projects, ->(project_ids) { where.not(project_id: project_ids) }
@@ -233,6 +237,11 @@ class Issue < ApplicationRecord
def order_upvotes_asc
reorder(upvotes_count: :asc)
end
+
+ override :pg_full_text_search
+ def pg_full_text_search(search_term)
+ super.where('issue_search_data.project_id = issues.project_id')
+ end
end
def next_object_by_relative_position(ignoring: nil, order: :asc)
@@ -611,6 +620,11 @@ class Issue < ApplicationRecord
private
+ override :persist_pg_full_text_search_vector
+ def persist_pg_full_text_search_vector(search_vector)
+ Issues::SearchData.upsert({ project_id: project_id, issue_id: id, search_vector: search_vector }, unique_by: %i(project_id issue_id))
+ end
+
def spammable_attribute_changed?
title_changed? ||
description_changed? ||
diff --git a/app/models/issue_link.rb b/app/models/issue_link.rb
index 920586cc1ba..1bd34aa0083 100644
--- a/app/models/issue_link.rb
+++ b/app/models/issue_link.rb
@@ -2,46 +2,17 @@
class IssueLink < ApplicationRecord
include FromUnion
+ include IssuableLink
belongs_to :source, class_name: 'Issue'
belongs_to :target, class_name: 'Issue'
- validates :source, presence: true
- validates :target, presence: true
- validates :source, uniqueness: { scope: :target_id, message: 'is already related' }
- validate :check_self_relation
- validate :check_opposite_relation
-
scope :for_source_issue, ->(issue) { where(source_id: issue.id) }
scope :for_target_issue, ->(issue) { where(target_id: issue.id) }
- TYPE_RELATES_TO = 'relates_to'
- TYPE_BLOCKS = 'blocks'
- # we don't store is_blocked_by in the db but need it for displaying the relation
- # from the target (used in IssueLink.inverse_link_type)
- TYPE_IS_BLOCKED_BY = 'is_blocked_by'
-
- enum link_type: { TYPE_RELATES_TO => 0, TYPE_BLOCKS => 1 }
-
- def self.inverse_link_type(type)
- type
- end
-
- private
-
- def check_self_relation
- return unless source && target
-
- if source == target
- errors.add(:source, 'cannot be related to itself')
- end
- end
-
- def check_opposite_relation
- return unless source && target
-
- if IssueLink.find_by(source: target, target: source)
- errors.add(:source, 'is already related to this issue')
+ class << self
+ def issuable_type
+ :issue
end
end
end
diff --git a/app/models/issues/search_data.rb b/app/models/issues/search_data.rb
new file mode 100644
index 00000000000..0eda292796d
--- /dev/null
+++ b/app/models/issues/search_data.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module Issues
+ class SearchData < ApplicationRecord
+ extend SuppressCompositePrimaryKeyWarning
+
+ self.table_name = 'issue_search_data'
+
+ belongs_to :issue
+ end
+end
diff --git a/app/models/label.rb b/app/models/label.rb
index 0ebbb5b9bd3..4c9f071f43a 100644
--- a/app/models/label.rb
+++ b/app/models/label.rb
@@ -12,8 +12,9 @@ class Label < ApplicationRecord
cache_markdown_field :description, pipeline: :single_line
- DEFAULT_COLOR = '#6699cc'
+ DEFAULT_COLOR = ::Gitlab::Color.of('#6699cc')
+ attribute :color, ::Gitlab::Database::Type::Color.new
default_value_for :color, DEFAULT_COLOR
has_many :lists, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
@@ -22,9 +23,9 @@ class Label < ApplicationRecord
has_many :issues, through: :label_links, source: :target, source_type: 'Issue'
has_many :merge_requests, through: :label_links, source: :target, source_type: 'MergeRequest'
- before_validation :strip_whitespace_from_title_and_color
+ before_validation :strip_whitespace_from_title
- validates :color, color: true, allow_blank: false
+ validates :color, color: true, presence: true
# Don't allow ',' for label titles
validates :title, presence: true, format: { with: /\A[^,]+\z/ }
@@ -212,15 +213,23 @@ class Label < ApplicationRecord
end
def text_color
- LabelsHelper.text_color_for_bg(self.color)
+ color.contrast
end
def title=(value)
- write_attribute(:title, sanitize_value(value)) if value.present?
+ if value.blank?
+ super
+ else
+ write_attribute(:title, sanitize_value(value))
+ end
end
def description=(value)
- write_attribute(:description, sanitize_value(value)) if value.present?
+ if value.blank?
+ super
+ else
+ write_attribute(:description, sanitize_value(value))
+ end
end
##
@@ -285,8 +294,8 @@ class Label < ApplicationRecord
CGI.unescapeHTML(Sanitize.clean(value.to_s))
end
- def strip_whitespace_from_title_and_color
- %w(color title).each { |attr| self[attr] = self[attr]&.strip }
+ def strip_whitespace_from_title
+ self[:title] = title&.strip
end
end
diff --git a/app/models/lfs_download_object.rb b/app/models/lfs_download_object.rb
index 319499fd1b7..3df6742fbc9 100644
--- a/app/models/lfs_download_object.rb
+++ b/app/models/lfs_download_object.rb
@@ -4,6 +4,7 @@ class LfsDownloadObject
include ActiveModel::Validations
attr_accessor :oid, :size, :link, :headers
+
delegate :sanitized_url, :credentials, to: :sanitized_uri
validates :oid, format: { with: /\A\h{64}\z/ }
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 3a449055bc1..3e19f294253 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -94,9 +94,9 @@ class ProjectMember < Member
override :access_level_inclusion
def access_level_inclusion
- return if access_level.in?(Gitlab::Access.values)
-
- errors.add(:access_level, "is not included in the list")
+ unless access_level.in?(Gitlab::Access.all_values)
+ errors.add(:access_level, "is not included in the list")
+ end
end
override :refresh_member_authorized_projects
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index 29540cbde2f..854325e1fcd 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -1016,8 +1016,24 @@ class MergeRequest < ApplicationRecord
merge_request_diff.persisted? || create_merge_request_diff
end
- def create_merge_request_diff
+ def eager_fetch_ref!
+ return unless valid?
+
+ # has_internal_id normally attempts to allocate the iid in the
+ # before_create hook, but we need the iid to be available before
+ # that to fetch the ref into the target project.
+ track_target_project_iid!
+ ensure_target_project_iid!
+
fetch_ref!
+ # Prevent the after_create hook from fetching the source branch again.
+ @skip_fetch_ref = true
+ end
+
+ def create_merge_request_diff
+ # Callers such as MergeRequests::BuildService may not call eager_fetch_ref!. Just
+ # in case they haven't, we fetch the ref.
+ fetch_ref! unless skip_fetch_ref
# n+1: https://gitlab.com/gitlab-org/gitlab/-/issues/19377
Gitlab::GitalyClient.allow_n_plus_1_calls do
@@ -1136,15 +1152,20 @@ class MergeRequest < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def mergeable_state?(skip_ci_check: false, skip_discussions_check: false)
- return false unless open?
- return false if work_in_progress?
- return false if broken?
- return false unless skip_discussions_check || mergeable_discussions_state?
-
if Feature.enabled?(:improved_mergeability_checks, self.project, default_enabled: :yaml)
- additional_checks = MergeRequests::Mergeability::RunChecksService.new(merge_request: self, params: { skip_ci_check: skip_ci_check })
+ additional_checks = MergeRequests::Mergeability::RunChecksService.new(
+ merge_request: self,
+ params: {
+ skip_ci_check: skip_ci_check,
+ skip_discussions_check: skip_discussions_check
+ }
+ )
additional_checks.execute.all?(&:success?)
else
+ return false unless open?
+ return false if draft?
+ return false if broken?
+ return false unless skip_discussions_check || mergeable_discussions_state?
return false unless skip_ci_check || mergeable_ci_state?
true
@@ -1921,10 +1942,18 @@ class MergeRequest < ApplicationRecord
merge_request_assignees.find_by(user_id: user.id)
end
+ def merge_request_assignees_with(user_ids)
+ merge_request_assignees.where(user_id: user_ids)
+ end
+
def find_reviewer(user)
merge_request_reviewers.find_by(user_id: user.id)
end
+ def merge_request_reviewers_with(user_ids)
+ merge_request_reviewers.where(user_id: user_ids)
+ end
+
def enabled_reports
{
sast: report_type_enabled?(:sast),
@@ -1950,6 +1979,8 @@ class MergeRequest < ApplicationRecord
private
+ attr_accessor :skip_fetch_ref
+
def set_draft_status
self.draft = draft?
end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 2c95cc2672c..86da29dd27a 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -35,6 +35,7 @@ class Milestone < ApplicationRecord
scope :with_api_entity_associations, -> { preload(project: [:project_feature, :route, namespace: :route]) }
scope :order_by_dates_and_title, -> { order(due_date: :asc, start_date: :asc, title: :asc) }
+ validates :title, presence: true
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
validate :uniqueness_of_title, if: :title_changed?
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 5c55f4d3def..ffaeb2071f6 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -117,6 +117,7 @@ class Namespace < ApplicationRecord
before_create :sync_share_with_group_lock_with_parent
before_update :sync_share_with_group_lock_with_parent, if: :parent_changed?
after_update :force_share_with_group_lock_on_descendants, if: -> { saved_change_to_share_with_group_lock? && share_with_group_lock? }
+ after_update :expire_first_auto_devops_config_cache, if: -> { saved_change_to_auto_devops_enabled? }
# Legacy Storage specific hooks
@@ -401,7 +402,11 @@ class Namespace < ApplicationRecord
return { scope: :group, status: auto_devops_enabled } unless auto_devops_enabled.nil?
strong_memoize(:first_auto_devops_config) do
- if has_parent?
+ if has_parent? && cache_first_auto_devops_config?
+ Rails.cache.fetch(first_auto_devops_config_cache_key_for(id), expires_in: 1.day) do
+ parent.first_auto_devops_config
+ end
+ elsif has_parent?
parent.first_auto_devops_config
else
{ scope: :instance, status: Gitlab::CurrentSettings.auto_devops_enabled? }
@@ -509,10 +514,6 @@ class Namespace < ApplicationRecord
Feature.enabled?(:block_issue_repositioning, self, type: :ops, default_enabled: :yaml)
end
- def project_namespace_creation_enabled?
- Feature.enabled?(:create_project_namespace_on_project_create, self, default_enabled: :yaml)
- end
-
def storage_enforcement_date
# should return something like Date.new(2022, 02, 03)
# TBD: https://gitlab.com/gitlab-org/gitlab/-/issues/350632
@@ -621,6 +622,20 @@ class Namespace < ApplicationRecord
.update_all(share_with_group_lock: true)
end
+ def expire_first_auto_devops_config_cache
+ return unless cache_first_auto_devops_config?
+
+ descendants_to_expire = self_and_descendants.as_ids
+ return if descendants_to_expire.load.empty?
+
+ keys = descendants_to_expire.map { |group| first_auto_devops_config_cache_key_for(group.id) }
+ Rails.cache.delete_multi(keys)
+ end
+
+ def cache_first_auto_devops_config?
+ ::Feature.enabled?(:namespaces_cache_first_auto_devops_config, default_enabled: :yaml)
+ end
+
def write_projects_repository_config
all_projects.find_each do |project|
project.set_full_path
@@ -638,6 +653,13 @@ class Namespace < ApplicationRecord
Namespaces::SyncEvent.enqueue_worker
end
end
+
+ def first_auto_devops_config_cache_key_for(group_id)
+ return "namespaces:{first_auto_devops_config}:#{group_id}" unless sync_traversal_ids?
+
+ # Use SHA2 of `traversal_ids` to account for moving a namespace within the same root ancestor hierarchy.
+ "namespaces:{#{traversal_ids.first}}:first_auto_devops_config:#{group_id}:#{Digest::SHA2.hexdigest(traversal_ids.join(' '))}"
+ end
end
Namespace.prepend_mod_with('Namespace')
diff --git a/app/models/namespace/traversal_hierarchy.rb b/app/models/namespace/traversal_hierarchy.rb
index 34086a8af5d..d2de85b5dd4 100644
--- a/app/models/namespace/traversal_hierarchy.rb
+++ b/app/models/namespace/traversal_hierarchy.rb
@@ -31,15 +31,16 @@ class Namespace
# ActiveRecord. https://github.com/rails/rails/issues/13496
# Ideally it would be:
# `incorrect_traversal_ids.update_all('traversal_ids = cte.traversal_ids')`
- sql = """
- UPDATE namespaces
- SET traversal_ids = cte.traversal_ids
- FROM (#{recursive_traversal_ids}) as cte
- WHERE namespaces.id = cte.id
- AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
- """
+ sql = <<-SQL
+ UPDATE namespaces
+ SET traversal_ids = cte.traversal_ids
+ FROM (#{recursive_traversal_ids}) as cte
+ WHERE namespaces.id = cte.id
+ AND namespaces.traversal_ids::bigint[] <> cte.traversal_ids
+ SQL
+
Namespace.transaction do
- @root.lock!
+ @root.lock!("FOR NO KEY UPDATE")
Namespace.connection.exec_query(sql)
end
rescue ActiveRecord::Deadlocked
diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 99a5b8cb063..1963745cf4d 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -44,22 +44,15 @@ module Namespaces
included do
before_update :lock_both_roots, if: -> { sync_traversal_ids? && parent_id_changed? }
after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? }
- # sync traversal_ids on namespace create, which can happen quite early within a transaction, thus keeping the lock on root namespace record
- # for a relatively long time, e.g. creating the project namespace when a project is being created.
- after_create :sync_traversal_ids, if: -> { sync_traversal_ids? && !sync_traversal_ids_before_commit? }
# This uses rails internal before_commit API to sync traversal_ids on namespace create, right before transaction is committed.
# This helps reduce the time during which the root namespace record is locked to ensure updated traversal_ids are valid
- before_commit :sync_traversal_ids, on: [:create], if: -> { sync_traversal_ids? && sync_traversal_ids_before_commit? }
+ before_commit :sync_traversal_ids, on: [:create], if: -> { sync_traversal_ids? }
end
def sync_traversal_ids?
Feature.enabled?(:sync_traversal_ids, root_ancestor, default_enabled: :yaml)
end
- def sync_traversal_ids_before_commit?
- Feature.enabled?(:sync_traversal_ids_before_commit, root_ancestor, default_enabled: :yaml)
- end
-
def use_traversal_ids?
return false unless Feature.enabled?(:use_traversal_ids, default_enabled: :yaml)
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
index 09d69a5f77a..0cac4c9143a 100644
--- a/app/models/namespaces/traversal/linear_scopes.rb
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -126,36 +126,26 @@ module Namespaces
end
def self_and_descendants_with_comparison_operators(include_self: true)
- base = all.select(
- :traversal_ids,
- 'LEAD (namespaces.traversal_ids, 1) OVER (ORDER BY namespaces.traversal_ids ASC) next_traversal_ids'
- )
+ base = all.select(:traversal_ids)
base_cte = Gitlab::SQL::CTE.new(:descendants_base_cte, base)
namespaces = Arel::Table.new(:namespaces)
# Bound the search space to ourselves (optional) and descendants.
#
- # WHERE (base_cte.next_traversal_ids IS NULL OR base_cte.next_traversal_ids > namespaces.traversal_ids)
- # AND next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids
+ # WHERE next_traversal_ids_sibling(base_cte.traversal_ids) > namespaces.traversal_ids
records = unscoped
+ .distinct
+ .with(base_cte.to_arel)
.from([base_cte.table, namespaces])
- .where(base_cte.table[:next_traversal_ids].eq(nil).or(base_cte.table[:next_traversal_ids].gt(namespaces[:traversal_ids])))
.where(next_sibling_func(base_cte.table[:traversal_ids]).gt(namespaces[:traversal_ids]))
# AND base_cte.traversal_ids <= namespaces.traversal_ids
- records = if include_self
- records.where(base_cte.table[:traversal_ids].lteq(namespaces[:traversal_ids]))
- else
- records.where(base_cte.table[:traversal_ids].lt(namespaces[:traversal_ids]))
- end
-
- records_cte = Gitlab::SQL::CTE.new(:descendants_cte, records)
-
- unscoped
- .unscope(where: [:type])
- .with(base_cte.to_arel, records_cte.to_arel)
- .from(records_cte.alias_to(namespaces))
+ if include_self
+ records.where(base_cte.table[:traversal_ids].lteq(namespaces[:traversal_ids]))
+ else
+ records.where(base_cte.table[:traversal_ids].lt(namespaces[:traversal_ids]))
+ end
end
def next_sibling_func(*args)
diff --git a/app/models/note.rb b/app/models/note.rb
index a84da066968..4f2e7ebe2c5 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -609,7 +609,6 @@ class Note < ApplicationRecord
def show_outdated_changes?
return false unless for_merge_request?
- return false unless Feature.enabled?(:display_outdated_line_diff, noteable.source_project, default_enabled: :yaml)
return false unless system?
return false unless change_position&.line_range
diff --git a/app/models/packages/package_file.rb b/app/models/packages/package_file.rb
index fc7c348dfdb..ad8140ac684 100644
--- a/app/models/packages/package_file.rb
+++ b/app/models/packages/package_file.rb
@@ -49,6 +49,7 @@ class Packages::PackageFile < ApplicationRecord
scope :preload_conan_file_metadata, -> { preload(:conan_file_metadatum) }
scope :preload_debian_file_metadata, -> { preload(:debian_file_metadatum) }
scope :preload_helm_file_metadata, -> { preload(:helm_file_metadatum) }
+ scope :order_id_asc, -> { order(id: :asc) }
scope :for_rubygem_with_file_name, ->(project, file_name) do
joins(:package).merge(project.packages.rubygems).with_file_name(file_name)
diff --git a/app/models/packages/pypi/metadatum.rb b/app/models/packages/pypi/metadatum.rb
index 2e4d61eaf53..ff247fedb59 100644
--- a/app/models/packages/pypi/metadatum.rb
+++ b/app/models/packages/pypi/metadatum.rb
@@ -6,7 +6,7 @@ class Packages::Pypi::Metadatum < ApplicationRecord
belongs_to :package, -> { where(package_type: :pypi) }, inverse_of: :pypi_metadatum
validates :package, presence: true
- validates :required_python, length: { maximum: 255 }, allow_blank: true
+ validates :required_python, length: { maximum: 255 }, allow_nil: false
validate :pypi_package_type
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index 2f515f3443d..021ff789b13 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -34,6 +34,7 @@ class PersonalAccessToken < ApplicationRecord
scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
scope :order_expires_at_desc, -> { reorder(expires_at: :desc) }
scope :project_access_token, -> { includes(:user).where(user: { user_type: :project_bot }) }
+ scope :owner_is_human, -> { includes(:user).where(user: { user_type: :human }) }
validates :scopes, presence: true
validate :validate_scopes
diff --git a/app/models/preloaders/environments/deployment_preloader.rb b/app/models/preloaders/environments/deployment_preloader.rb
index fcf892698bb..251d1837f19 100644
--- a/app/models/preloaders/environments/deployment_preloader.rb
+++ b/app/models/preloaders/environments/deployment_preloader.rb
@@ -21,11 +21,13 @@ module Preloaders
def load_deployment_association(association_name, association_attributes)
return unless environments.present?
- union_arg = environments.inject([]) do |result, environment|
- result << environment.association(association_name).scope
- end
-
- union_sql = Deployment.from_union(union_arg).to_sql
+ # Not using Gitlab::SQL::Union as `order_by` in the SQL constructed is ignored.
+ # See:
+ # 1) https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/sql/union.rb#L7
+ # 2) https://gitlab.com/gitlab-org/gitlab/-/issues/353966#note_860928647
+ union_sql = environments.map do |environment|
+ "(#{environment.association(association_name).scope.to_sql})"
+ end.join(' UNION ')
deployments = Deployment
.from("(#{union_sql}) #{::Deployment.table_name}")
@@ -34,8 +36,16 @@ module Preloaders
deployments_by_environment_id = deployments.index_by(&:environment_id)
environments.each do |environment|
- environment.association(association_name).target = deployments_by_environment_id[environment.id]
+ associated_deployment = deployments_by_environment_id[environment.id]
+
+ environment.association(association_name).target = associated_deployment
environment.association(association_name).loaded!
+
+ if associated_deployment
+ # `last?` in DeploymentEntity requires this environment to be loaded
+ associated_deployment.association(:environment).target = environment
+ associated_deployment.association(:environment).loaded!
+ end
end
end
end
diff --git a/app/models/project.rb b/app/models/project.rb
index f89e616a5ca..155ebe88d33 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -38,7 +38,7 @@ class Project < ApplicationRecord
include GitlabRoutingHelper
include BulkMemberAccessLoad
include RunnerTokenExpirationInterval
- include RunnersTokenPrefixable
+ include BlocksUnsafeSerialization
extend Gitlab::Cache::RequestCache
extend Gitlab::Utils::Override
@@ -196,6 +196,7 @@ class Project < ApplicationRecord
has_one :external_wiki_integration, class_name: 'Integrations::ExternalWiki'
has_one :flowdock_integration, class_name: 'Integrations::Flowdock'
has_one :hangouts_chat_integration, class_name: 'Integrations::HangoutsChat'
+ has_one :harbor_integration, class_name: 'Integrations::Harbor'
has_one :irker_integration, class_name: 'Integrations::Irker'
has_one :jenkins_integration, class_name: 'Integrations::Jenkins'
has_one :jira_integration, class_name: 'Integrations::Jira'
@@ -344,22 +345,18 @@ class Project < ApplicationRecord
has_many :stages, class_name: 'Ci::Stage', inverse_of: :project
has_many :ci_refs, class_name: 'Ci::Ref', inverse_of: :project
- # Ci::Build objects store data on the file system such as artifact files and
- # build traces. Currently there's no efficient way of removing this data in
- # bulk that doesn't involve loading the rows into memory. As a result we're
- # still using `dependent: :destroy` here.
has_many :pending_builds, class_name: 'Ci::PendingBuild'
- has_many :builds, class_name: 'Ci::Build', inverse_of: :project, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :builds, class_name: 'Ci::Build', inverse_of: :project
has_many :processables, class_name: 'Ci::Processable', inverse_of: :project
- has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks
+ has_many :build_trace_chunks, class_name: 'Ci::BuildTraceChunk', through: :builds, source: :trace_chunks, dependent: :restrict_with_error
has_many :build_report_results, class_name: 'Ci::BuildReportResult', inverse_of: :project
- has_many :job_artifacts, class_name: 'Ci::JobArtifact'
- has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :project
+ has_many :job_artifacts, class_name: 'Ci::JobArtifact', dependent: :restrict_with_error
+ has_many :pipeline_artifacts, class_name: 'Ci::PipelineArtifact', inverse_of: :project, dependent: :restrict_with_error
has_many :runner_projects, class_name: 'Ci::RunnerProject', inverse_of: :project
has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner'
has_many :variables, class_name: 'Ci::Variable'
has_many :triggers, class_name: 'Ci::Trigger'
- has_many :secure_files, class_name: 'Ci::SecureFile'
+ has_many :secure_files, class_name: 'Ci::SecureFile', dependent: :restrict_with_error
has_many :environments
has_many :environments_for_dashboard, -> { from(with_rank.unfoldered.available, :environments).where('rank <= 3') }, class_name: 'Environment'
has_many :deployments
@@ -462,7 +459,7 @@ class Project < ApplicationRecord
delegate :name, to: :owner, allow_nil: true, prefix: true
delegate :members, to: :team, prefix: true
delegate :add_user, :add_users, to: :team
- delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_role, to: :team
+ delegate :add_guest, :add_reporter, :add_developer, :add_maintainer, :add_owner, :add_role, to: :team
delegate :group_runners_enabled, :group_runners_enabled=, to: :ci_cd_settings, allow_nil: true
delegate :root_ancestor, to: :namespace, allow_nil: true
delegate :last_pipeline, to: :commit, allow_nil: true
@@ -501,11 +498,15 @@ class Project < ApplicationRecord
presence: true,
project_path: true,
length: { maximum: 255 }
+ validates :path,
+ format: { with: Gitlab::Regex.oci_repository_path_regex,
+ message: Gitlab::Regex.oci_repository_path_regex_message },
+ if: :path_changed?
validates :project_feature, presence: true
validates :namespace, presence: true
- validates :project_namespace, presence: true, on: :create, if: -> { self.namespace && self.root_namespace.project_namespace_creation_enabled? }
+ validates :project_namespace, presence: true, on: :create, if: -> { self.namespace }
validates :project_namespace, presence: true, on: :update, if: -> { self.project_namespace_id_changed?(to: nil) }
validates :name, uniqueness: { scope: :namespace_id }
validates :import_url, public_url: { schemes: ->(project) { project.persisted? ? VALID_MIRROR_PROTOCOLS : VALID_IMPORT_PROTOCOLS },
@@ -529,6 +530,7 @@ class Project < ApplicationRecord
# Scopes
scope :pending_delete, -> { where(pending_delete: true) }
scope :without_deleted, -> { where(pending_delete: false) }
+ scope :not_hidden, -> { where(hidden: false) }
scope :not_aimed_for_deletion, -> { where(marked_for_deletion_at: nil).without_deleted }
scope :with_storage_feature, ->(feature) do
@@ -1006,10 +1008,6 @@ class Project < ApplicationRecord
Feature.enabled?(:unlink_fork_network_upon_visibility_decrease, self, default_enabled: true)
end
- def context_commits_enabled?
- Feature.enabled?(:context_commits, self.group, default_enabled: :yaml)
- end
-
# LFS and hashed repository storage are required for using Design Management.
def design_management_enabled?
lfs_enabled? && hashed_storage?(:repository)
@@ -1565,14 +1563,17 @@ class Project < ApplicationRecord
# rubocop: disable CodeReuse/ServiceClass
def execute_hooks(data, hooks_scope = :push_hooks)
run_after_commit_or_now do
- hooks.hooks_for(hooks_scope).select_active(hooks_scope, data).each do |hook|
- hook.async_execute(data, hooks_scope.to_s)
- end
+ triggered_hooks(hooks_scope, data).execute
SystemHooksService.new.execute_hooks(data, hooks_scope)
end
end
# rubocop: enable CodeReuse/ServiceClass
+ def triggered_hooks(hooks_scope, data)
+ triggered = ::Projects::TriggeredHooks.new(hooks_scope, data)
+ triggered.add_hooks(hooks)
+ end
+
def execute_integrations(data, hooks_scope = :push_hooks)
# Call only service hooks that are active for this scope
run_after_commit_or_now do
@@ -1876,13 +1877,9 @@ class Project < ApplicationRecord
ensure_runners_token!
end
- def runners_token_prefix
- RUNNERS_TOKEN_PREFIX
- end
-
override :format_runners_token
def format_runners_token(token)
- "#{runners_token_prefix}#{token}"
+ "#{RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX}#{token}"
end
def pages_deployed?
@@ -1938,12 +1935,12 @@ class Project < ApplicationRecord
.delete_all
end
- def mark_pages_as_deployed(artifacts_archive: nil)
- ensure_pages_metadatum.update!(deployed: true, artifacts_archive: artifacts_archive)
+ def mark_pages_as_deployed
+ ensure_pages_metadatum.update!(deployed: true)
end
def mark_pages_as_not_deployed
- ensure_pages_metadatum.update!(deployed: false, artifacts_archive: nil, pages_deployment: nil)
+ ensure_pages_metadatum.update!(deployed: false)
end
def update_pages_deployment!(deployment)
@@ -2521,7 +2518,18 @@ class Project < ApplicationRecord
end
def access_request_approvers_to_be_notified
- members.maintainers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT)
+ # For a personal project:
+ # The creator is added as a member with `Owner` access level, starting from GitLab 14.8
+ # The creator was added as a member with `Maintainer` access level, before GitLab 14.8
+ # So, to make sure access requests for all personal projects work as expected,
+ # we need to filter members with the scope `owners_and_maintainers`.
+ access_request_approvers = if personal?
+ members.owners_and_maintainers
+ else
+ members.maintainers
+ end
+
+ access_request_approvers.connected_to_user.order_recent_sign_in.limit(Member::ACCESS_REQUEST_APPROVERS_TO_BE_NOTIFIED_LIMIT)
end
def pages_lookup_path(trim_prefix: nil, domain: nil)
@@ -2817,6 +2825,10 @@ class Project < ApplicationRecord
end
end
+ def pending_delete_or_hidden?
+ pending_delete? || hidden?
+ end
+
private
# overridden in EE
@@ -2838,7 +2850,9 @@ class Project < ApplicationRecord
if @topic_list != self.topic_list
self.topics.delete_all
- self.topics = @topic_list.map { |topic| Projects::Topic.find_or_create_by(name: topic) }
+ self.topics = @topic_list.map do |topic|
+ Projects::Topic.where('lower(name) = ?', topic.downcase).order(total_projects_count: :desc).first_or_create(name: topic)
+ end
end
@topic_list = nil
@@ -3010,16 +3024,15 @@ class Project < ApplicationRecord
end
def ensure_project_namespace_in_sync
- # create project_namespace when project is created if create_project_namespace_on_project_create FF is enabled
+ # create project_namespace when project is created
build_project_namespace if project_namespace_creation_enabled?
- # regardless of create_project_namespace_on_project_create FF we need
- # to keep project and project namespace in sync if there is one
+ # we need to keep project and project namespace in sync if there is one
sync_attributes(project_namespace) if sync_project_namespace?
end
def project_namespace_creation_enabled?
- new_record? && !project_namespace && self.namespace && self.root_namespace.project_namespace_creation_enabled?
+ new_record? && !project_namespace && self.namespace
end
def sync_project_namespace?
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index c76332b21cd..5c6fdec16ca 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -9,7 +9,7 @@ class ProjectAuthorization < ApplicationRecord
validates :project, presence: true
validates :access_level, inclusion: { in: Gitlab::Access.all_values }, presence: true
- validates :user, uniqueness: { scope: [:project, :access_level] }, presence: true
+ validates :user, uniqueness: { scope: :project }, presence: true
def self.select_from_union(relations)
from_union(relations)
diff --git a/app/models/project_import_data.rb b/app/models/project_import_data.rb
index d374ee120d1..3b514d5c5ff 100644
--- a/app/models/project_import_data.rb
+++ b/app/models/project_import_data.rb
@@ -14,7 +14,12 @@ class ProjectImportData < ApplicationRecord
insecure_mode: true,
algorithm: 'aes-256-cbc'
- serialize :data, JSON # rubocop:disable Cop/ActiveRecordSerialize
+ # NOTE
+ # We are serializing a project as `data` in an "unsafe" way here
+ # because the credentials are necessary for a successful import.
+ # This is safe because the serialization is only going between rails
+ # and the database, never to any end users.
+ serialize :data, Serializers::UnsafeJson # rubocop:disable Cop/ActiveRecordSerialize
validates :project, presence: true
diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb
index 58dbac9057f..dc1e9319340 100644
--- a/app/models/project_pages_metadatum.rb
+++ b/app/models/project_pages_metadatum.rb
@@ -4,11 +4,13 @@ class ProjectPagesMetadatum < ApplicationRecord
extend SuppressCompositePrimaryKeyWarning
include EachBatch
+ include IgnorableColumns
self.primary_key = :project_id
+ ignore_columns :artifacts_archive_id, remove_with: '15.0', remove_after: '2022-04-22'
+
belongs_to :project, inverse_of: :pages_metadatum
- belongs_to :artifacts_archive, class_name: 'Ci::JobArtifact'
belongs_to :pages_deployment
scope :deployed, -> { where(deployed: true) }
diff --git a/app/models/project_team.rb b/app/models/project_team.rb
index c3c7508df9f..4b89d95c1a3 100644
--- a/app/models/project_team.rb
+++ b/app/models/project_team.rb
@@ -23,6 +23,10 @@ class ProjectTeam
add_user(user, :maintainer, current_user: current_user)
end
+ def add_owner(user, current_user: nil)
+ add_user(user, :owner, current_user: current_user)
+ end
+
def add_role(user, role, current_user: nil)
public_send(:"add_#{role}", user, current_user: current_user) # rubocop:disable GitlabSecurity/PublicSend
end
@@ -103,7 +107,9 @@ class ProjectTeam
if group
group.owners
else
- [project.owner]
+ # workaround until we migrate Project#owners to have membership with
+ # OWNER access level
+ Array.wrap(fetch_members(Gitlab::Access::OWNER)) | Array.wrap(project.owner)
end
end
@@ -173,7 +179,9 @@ class ProjectTeam
#
# Returns a Hash mapping user ID -> maximum access level.
def max_member_access_for_user_ids(user_ids)
- project.max_member_access_for_resource_ids(User, user_ids) do |user_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: project.max_member_access_for_resource_key(User),
+ resource_ids: user_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |user_ids|
project.project_authorizations
.where(user: user_ids)
.group(:user_id)
@@ -190,31 +198,15 @@ class ProjectTeam
end
def contribution_check_for_user_ids(user_ids)
- user_ids = user_ids.uniq
- key = "contribution_check_for_users:#{project.id}"
-
- Gitlab::SafeRequestStore[key] ||= {}
- contributors = Gitlab::SafeRequestStore[key] || {}
-
- user_ids -= contributors.keys
-
- return contributors if user_ids.empty?
-
- resource_contributors = project.merge_requests
- .merged
- .where(author_id: user_ids, target_branch: project.default_branch.to_s)
- .pluck(:author_id)
- .product([true]).to_h
-
- contributors.merge!(resource_contributors)
-
- missing_resource_ids = user_ids - resource_contributors.keys
-
- missing_resource_ids.each do |resource_id|
- contributors[resource_id] = false
+ Gitlab::SafeRequestLoader.execute(resource_key: "contribution_check_for_users:#{project.id}",
+ resource_ids: user_ids,
+ default_value: false) do |user_ids|
+ project.merge_requests
+ .merged
+ .where(author_id: user_ids, target_branch: project.default_branch.to_s)
+ .pluck(:author_id)
+ .product([true]).to_h
end
-
- contributors
end
def contributor?(user_id)
diff --git a/app/models/projects/build_artifacts_size_refresh.rb b/app/models/projects/build_artifacts_size_refresh.rb
new file mode 100644
index 00000000000..afb67b79f0d
--- /dev/null
+++ b/app/models/projects/build_artifacts_size_refresh.rb
@@ -0,0 +1,91 @@
+# frozen_string_literal: true
+
+module Projects
+ class BuildArtifactsSizeRefresh < ApplicationRecord
+ include BulkInsertSafe
+
+ STALE_WINDOW = 3.days
+
+ self.table_name = 'project_build_artifacts_size_refreshes'
+
+ belongs_to :project
+
+ validates :project, presence: true
+
+ STATES = {
+ created: 1,
+ running: 2,
+ pending: 3
+ }.freeze
+
+ state_machine :state, initial: :created do
+ # created -> running <-> pending
+ state :created, value: STATES[:created]
+ state :running, value: STATES[:running]
+ state :pending, value: STATES[:pending]
+
+ event :process do
+ transition [:created, :pending, :running] => :running
+ end
+
+ event :requeue do
+ transition running: :pending
+ end
+
+ # set it only the first time we execute the refresh
+ before_transition created: :running do |refresh|
+ refresh.reset_project_statistics!
+ refresh.refresh_started_at = Time.zone.now
+ end
+
+ before_transition running: any do |refresh, transition|
+ refresh.updated_at = Time.zone.now
+ end
+
+ before_transition running: :pending do |refresh, transition|
+ refresh.last_job_artifact_id = transition.args.first
+ end
+ end
+
+ scope :stale, -> { with_state(:running).where('updated_at < ?', STALE_WINDOW.ago) }
+ scope :remaining, -> { with_state(:created, :pending).or(stale) }
+
+ def self.enqueue_refresh(projects)
+ now = Time.zone.now
+
+ records = Array(projects).map do |project|
+ new(project: project, state: STATES[:created], created_at: now, updated_at: now)
+ end
+
+ bulk_insert!(records, skip_duplicates: true)
+ end
+
+ def self.process_next_refresh!
+ next_refresh = nil
+
+ transaction do
+ next_refresh = remaining
+ .order(:state, :updated_at)
+ .lock('FOR UPDATE SKIP LOCKED')
+ .take
+
+ next_refresh&.process!
+ end
+
+ next_refresh
+ end
+
+ def reset_project_statistics!
+ statistics = project.statistics
+ statistics.update!(build_artifacts_size: 0)
+ statistics.clear_counter!(:build_artifacts_size)
+ end
+
+ def next_batch(limit:)
+ project.job_artifacts.select(:id, :size)
+ .where('created_at <= ? AND id > ?', refresh_started_at, last_job_artifact_id.to_i)
+ .order(:created_at)
+ .limit(limit)
+ end
+ end
+end
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index 78bc2df2e1e..b42b03f0618 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -7,18 +7,19 @@ module Projects
include Avatarable
include Gitlab::SQL::Pattern
- validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
+ validates :name, presence: true, length: { maximum: 255 }
+ validates :name, uniqueness: { case_sensitive: false }, if: :name_changed?
validates :description, length: { maximum: 1024 }
has_many :project_topics, class_name: 'Projects::ProjectTopic'
has_many :projects, through: :project_topics
- scope :order_by_total_projects_count, -> { order(total_projects_count: :desc).order(id: :asc) }
+ scope :order_by_non_private_projects_count, -> { order(non_private_projects_count: :desc).order(id: :asc) }
scope :reorder_by_similarity, -> (search) do
order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [
{ column: arel_table['name'] }
])
- reorder(order_expression.desc, arel_table['total_projects_count'].desc, arel_table['id'])
+ reorder(order_expression.desc, arel_table['non_private_projects_count'].desc, arel_table['id'])
end
class << self
diff --git a/app/models/projects/triggered_hooks.rb b/app/models/projects/triggered_hooks.rb
new file mode 100644
index 00000000000..e3aa3d106b7
--- /dev/null
+++ b/app/models/projects/triggered_hooks.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Projects
+ class TriggeredHooks
+ def initialize(scope, data)
+ @scope = scope
+ @data = data
+ @relations = []
+ end
+
+ def add_hooks(relation)
+ @relations << relation
+ self
+ end
+
+ def execute
+ # Assumes that the relations implement TriggerableHooks
+ @relations.each do |hooks|
+ hooks.hooks_for(@scope).select_active(@scope, @data).each do |hook|
+ hook.async_execute(@data, @scope.to_s)
+ end
+ end
+ end
+ end
+end
diff --git a/app/models/release.rb b/app/models/release.rb
index 0fda6940249..c6c0920c4d0 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -5,6 +5,8 @@ class Release < ApplicationRecord
include CacheMarkdownField
include Importable
include Gitlab::Utils::StrongMemoize
+ include EachBatch
+ include FromUnion
cache_markdown_field :description
@@ -24,6 +26,8 @@ class Release < ApplicationRecord
before_create :set_released_at
validates :project, :tag, presence: true
+ validates :tag, uniqueness: { scope: :project_id }
+
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] }
diff --git a/app/models/repository.rb b/app/models/repository.rb
index be8e530c650..346478b6689 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -15,6 +15,7 @@ class Repository
heads
tags
replace
+ #{REF_MERGE_REQUEST}
#{REF_ENVIRONMENTS}
#{REF_KEEP_AROUND}
#{REF_PIPELINES}
@@ -1084,10 +1085,10 @@ class Repository
blob.data
end
- def create_if_not_exists
+ def create_if_not_exists(default_branch = nil)
return if exists?
- raw.create_repository
+ raw.create_repository(default_branch)
after_create
true
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index b04fca64c87..38aaeff5c9a 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -350,24 +350,10 @@ class Snippet < ApplicationRecord
snippet_repository&.shard_name || Repository.pick_storage_shard
end
- # 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 different.
- def change_head_to_default_branch
- return unless repository.exists?
- # All snippets must have at least 1 file. Therefore, if
- # `HEAD` is empty is because it's pointing to the wrong
- # default branch
- return unless repository.empty? || list_files('HEAD').empty?
-
- repository.raw_repository.write_ref('HEAD', "refs/heads/#{default_branch}")
- end
-
def create_repository
return if repository_exists? && snippet_repository
- repository.create_if_not_exists
+ repository.create_if_not_exists(default_branch)
track_snippet_repository(repository.storage)
end
diff --git a/app/models/storage/hashed.rb b/app/models/storage/hashed.rb
index c61cd3b6b30..05e93f00912 100644
--- a/app/models/storage/hashed.rb
+++ b/app/models/storage/hashed.rb
@@ -3,6 +3,7 @@
module Storage
class Hashed
attr_accessor :container
+
delegate :gitlab_shell, :repository_storage, to: :container
REPOSITORY_PATH_PREFIX = '@hashed'
diff --git a/app/models/storage/legacy_project.rb b/app/models/storage/legacy_project.rb
index 092e5249a3e..0d12a629b8e 100644
--- a/app/models/storage/legacy_project.rb
+++ b/app/models/storage/legacy_project.rb
@@ -3,6 +3,7 @@
module Storage
class LegacyProject
attr_accessor :project
+
delegate :namespace, :gitlab_shell, :repository_storage, to: :project
def initialize(project)
diff --git a/app/models/todo.rb b/app/models/todo.rb
index dc436570f52..eb5d9965955 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -34,6 +34,8 @@ class Todo < ApplicationRecord
ATTENTION_REQUESTED => :attention_requested
}.freeze
+ ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED].freeze
+
belongs_to :author, class_name: "User"
belongs_to :note
belongs_to :project
diff --git a/app/models/user.rb b/app/models/user.rb
index 9cd238904ff..b3bdc2c1c42 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -16,7 +16,7 @@ class User < ApplicationRecord
include FeatureGate
include CreatedAtFilterable
include BulkMemberAccessLoad
- include BlocksJsonSerialization
+ include BlocksUnsafeSerialization
include WithUploads
include OptionallySearch
include FromUnion
@@ -135,6 +135,7 @@ class User < ApplicationRecord
has_many :u2f_registrations, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
has_many :webauthn_registrations
has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
+ has_many :saved_replies, class_name: '::Users::SavedReply'
has_one :user_synced_attributes_metadata, autosave: true
has_one :aws_role, class_name: 'Aws::Role'
@@ -276,24 +277,22 @@ class User < ApplicationRecord
after_update :username_changed_hook, if: :saved_change_to_username?
after_destroy :post_destroy_hook
after_destroy :remove_key_cache
- after_create :add_primary_email_to_emails!, if: :confirmed?
- after_commit(on: :update) do
- if previous_changes.key?('email')
- # Add the old primary email to Emails if not added already - this should be removed
- # after the background migration for MR https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70872/ has completed,
- # as the primary email is now added to Emails upon confirmation
- # Issue to remove that: https://gitlab.com/gitlab-org/gitlab/-/issues/344134
- previous_confirmed_at = previous_changes.key?('confirmed_at') ? previous_changes['confirmed_at'][0] : confirmed_at
- previous_email = previous_changes[:email][0]
- if previous_confirmed_at && !emails.exists?(email: previous_email)
- # rubocop: disable CodeReuse/ServiceClass
- Emails::CreateService.new(self, user: self, email: previous_email).execute(confirmed_at: previous_confirmed_at)
- # rubocop: enable CodeReuse/ServiceClass
- end
+ after_save if: -> { saved_change_to_email? && confirmed? } do
+ email_to_confirm = self.emails.find_by(email: self.email)
- update_invalid_gpg_signatures
+ if email_to_confirm.present?
+ if skip_confirmation_period_expiry_check
+ email_to_confirm.force_confirm
+ else
+ email_to_confirm.confirm
+ end
+ else
+ add_primary_email_to_emails!
end
end
+ after_commit(on: :update) do
+ update_invalid_gpg_signatures if previous_changes.key?('email')
+ end
after_initialize :set_projects_limit
@@ -1692,6 +1691,12 @@ class User < ApplicationRecord
end
end
+ def attention_requested_open_merge_requests_count(force: false)
+ Rails.cache.fetch(attention_request_cache_key, force: force, expires_in: COUNT_CACHE_VALIDITY_PERIOD) do
+ MergeRequestsFinder.new(self, attention: self.username, 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
IssuesFinder.new(self, assignee_id: self.id, state: 'opened', non_archived: true).execute.count
@@ -1735,6 +1740,11 @@ class User < ApplicationRecord
def invalidate_merge_request_cache_counts
Rails.cache.delete(['users', id, 'assigned_open_merge_requests_count'])
Rails.cache.delete(['users', id, 'review_requested_open_merge_requests_count'])
+ invalidate_attention_requested_count
+ end
+
+ def invalidate_attention_requested_count
+ Rails.cache.delete(attention_request_cache_key)
end
def invalidate_todos_cache_counts
@@ -1746,6 +1756,10 @@ class User < ApplicationRecord
Rails.cache.delete(['users', id, 'personal_projects_count'])
end
+ def attention_request_cache_key
+ ['users', id, 'attention_requested_open_merge_requests_count']
+ end
+
# This is copied from Devise::Models::Lockable#valid_for_authentication?, as our auth
# flow means we don't call that automatically (and can't conveniently do so).
#
@@ -1846,7 +1860,9 @@ class User < ApplicationRecord
#
# Returns a Hash mapping project ID -> maximum access level.
def max_member_access_for_project_ids(project_ids)
- max_member_access_for_resource_ids(Project, project_ids) do |project_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Project),
+ resource_ids: project_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |project_ids|
project_authorizations.where(project: project_ids)
.group(:project_id)
.maximum(:access_level)
@@ -1861,7 +1877,9 @@ class User < ApplicationRecord
#
# Returns a Hash mapping project ID -> maximum access level.
def max_member_access_for_group_ids(group_ids)
- max_member_access_for_resource_ids(Group, group_ids) do |group_ids|
+ Gitlab::SafeRequestLoader.execute(resource_key: max_member_access_for_resource_key(Group),
+ resource_ids: group_ids,
+ default_value: Gitlab::Access::NO_ACCESS) do |group_ids|
group_members.where(source: group_ids).group(:source_id).maximum(:access_level)
end
end
@@ -1993,29 +2011,6 @@ class User < ApplicationRecord
ci_job_token_scope.present?
end
- # override from Devise::Models::Confirmable
- #
- # Add the primary email to user.emails (or confirm it if it was already
- # present) when the primary email is confirmed.
- def confirm(args = {})
- saved = super(args)
- return false unless saved
-
- email_to_confirm = self.emails.find_by(email: self.email)
-
- if email_to_confirm.present?
- if skip_confirmation_period_expiry_check
- email_to_confirm.force_confirm(args)
- else
- email_to_confirm.confirm(args)
- end
- else
- add_primary_email_to_emails!
- end
-
- saved
- end
-
def user_project
strong_memoize(:user_project) do
personal_projects.find_by(path: username, visibility_level: Gitlab::VisibilityLevel::PUBLIC)
@@ -2166,7 +2161,7 @@ class User < ApplicationRecord
end
def signup_email_invalid_message
- self.new_record? ? _('is not allowed for sign-up.') : _('is not allowed.')
+ self.new_record? ? _('is not allowed for sign-up. Please use your regular email address.') : _('is not allowed. Please use your regular email address.')
end
def check_username_format
diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb
index 5c39e29a128..0922323e12b 100644
--- a/app/models/users/callout.rb
+++ b/app/models/users/callout.rb
@@ -42,7 +42,13 @@ module Users
security_newsletter_callout: 39,
verification_reminder: 40, # EE-only
ci_deprecation_warning_for_types_keyword: 41,
- security_training_feature_promotion: 42 # EE-only
+ security_training_feature_promotion: 42, # EE-only
+ storage_enforcement_banner_first_enforcement_threshold: 43,
+ storage_enforcement_banner_second_enforcement_threshold: 44,
+ storage_enforcement_banner_third_enforcement_threshold: 45,
+ storage_enforcement_banner_fourth_enforcement_threshold: 46,
+ attention_requests_top_nav: 47,
+ attention_requests_side_nav: 48
}
validates :feature_name,
diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb
index 556ee03605d..998a5deb0fd 100644
--- a/app/models/users/credit_card_validation.rb
+++ b/app/models/users/credit_card_validation.rb
@@ -8,7 +8,7 @@ module Users
belongs_to :user
- validates :holder_name, length: { maximum: 26 }
+ validates :holder_name, length: { maximum: 50 }
validates :network, length: { maximum: 32 }
validates :last_digits, allow_nil: true, numericality: {
greater_than_or_equal_to: 0, less_than_or_equal_to: 9999
diff --git a/app/models/users/group_callout.rb b/app/models/users/group_callout.rb
index 0dc449719ab..839be8d2a48 100644
--- a/app/models/users/group_callout.rb
+++ b/app/models/users/group_callout.rb
@@ -11,10 +11,10 @@ module Users
enum feature_name: {
invite_members_banner: 1,
approaching_seat_count_threshold: 2, # EE-only
- storage_enforcement_banner_first_enforcement_threshold: 43,
- storage_enforcement_banner_second_enforcement_threshold: 44,
- storage_enforcement_banner_third_enforcement_threshold: 45,
- storage_enforcement_banner_fourth_enforcement_threshold: 46
+ storage_enforcement_banner_first_enforcement_threshold: 3,
+ storage_enforcement_banner_second_enforcement_threshold: 4,
+ storage_enforcement_banner_third_enforcement_threshold: 5,
+ storage_enforcement_banner_fourth_enforcement_threshold: 6
}
validates :group, presence: true
diff --git a/app/models/users/saved_reply.rb b/app/models/users/saved_reply.rb
new file mode 100644
index 00000000000..7737d826b05
--- /dev/null
+++ b/app/models/users/saved_reply.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+module Users
+ class SavedReply < ApplicationRecord
+ self.table_name = 'saved_replies'
+
+ belongs_to :user
+
+ validates :user_id, :name, :content, presence: true
+ validates :name,
+ length: { maximum: 255 },
+ uniqueness: { scope: [:user_id] },
+ format: {
+ with: Gitlab::Regex.saved_reply_name_regex,
+ message: Gitlab::Regex.saved_reply_name_regex_message
+ }
+ validates :content, length: { maximum: 10000 }
+ end
+end
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
index e114e30d589..622070abd88 100644
--- a/app/models/wiki.rb
+++ b/app/models/wiki.rb
@@ -87,8 +87,7 @@ class Wiki
end
def create_wiki_repository
- repository.create_if_not_exists
- change_head_to_default_branch
+ repository.create_if_not_exists(default_branch)
raise CouldNotCreateWikiError unless repository_exists?
rescue StandardError => err
@@ -150,10 +149,10 @@ class Wiki
# the page.
#
# Returns an initialized WikiPage instance or nil
- def find_page(title, version = nil)
+ def find_page(title, version = nil, load_content: true)
page_title, page_dir = page_title_and_dir(title)
- if page = wiki.page(title: page_title, version: version, dir: page_dir)
+ if page = wiki.page(title: page_title, version: version, dir: page_dir, load_content: load_content)
WikiPage.new(self, page)
end
end
@@ -322,16 +321,6 @@ class Wiki
def default_message(action, title)
"#{user.username} #{action} page: #{title}"
end
-
- def change_head_to_default_branch
- # If the wiki has commits in the 'HEAD' branch means that the current
- # HEAD is pointing to the right branch. If not, it could mean that either
- # the repo has just been created or that 'HEAD' is pointing
- # to the wrong branch and we need to rewrite it
- return if repository.raw_repository.commit_count('HEAD') != 0
-
- repository.raw_repository.write_ref('HEAD', "refs/heads/#{default_branch}")
- end
end
Wiki.prepend_mod_with('Wiki')
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 3dbbbcdfe23..803b9781ac4 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -45,6 +45,7 @@ class WikiPage
# The GitLab Wiki instance.
attr_reader :wiki
+
delegate :container, to: :wiki
# The raw Gitlab::Git::WikiPage instance.
@@ -315,7 +316,6 @@ class WikiPage
end
def update_front_matter(attrs)
- return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container)
return unless attrs.has_key?(:front_matter)
fm_yaml = serialize_front_matter(attrs[:front_matter])
@@ -326,7 +326,7 @@ class WikiPage
def parsed_content
strong_memoize(:parsed_content) do
- Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse
+ Gitlab::WikiPages::FrontMatterParser.new(raw_content).parse
end
end
@@ -404,3 +404,5 @@ class WikiPage
})
end
end
+
+WikiPage.prepend_mod
diff --git a/app/models/work_item.rb b/app/models/work_item.rb
index 99f05e4a181..557694da35a 100644
--- a/app/models/work_item.rb
+++ b/app/models/work_item.rb
@@ -7,4 +7,12 @@ class WorkItem < Issue
def noteable_target_type_name
'issue'
end
+
+ private
+
+ def record_create_action
+ super
+
+ Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter.track_work_item_created_action(author: author)
+ end
end
diff --git a/app/models/work_items/type.rb b/app/models/work_items/type.rb
index 494c4f5abe4..080513b28e9 100644
--- a/app/models/work_items/type.rb
+++ b/app/models/work_items/type.rb
@@ -38,6 +38,7 @@ module WorkItems
scope :default, -> { where(namespace: nil) }
scope :order_by_name_asc, -> { order('LOWER(name)') }
+ scope :by_type, ->(base_type) { where(base_type: base_type) }
def self.default_by_type(type)
find_by(namespace_id: nil, base_type: type)