summaryrefslogtreecommitdiff
path: root/app/models
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-05-20 14:34:42 +0000
commit9f46488805e86b1bc341ea1620b866016c2ce5ed (patch)
treef9748c7e287041e37d6da49e0a29c9511dc34768 /app/models
parentdfc92d081ea0332d69c8aca2f0e745cb48ae5e6d (diff)
downloadgitlab-ce-9f46488805e86b1bc341ea1620b866016c2ce5ed.tar.gz
Add latest changes from gitlab-org/gitlab@13-0-stable-ee
Diffstat (limited to 'app/models')
-rw-r--r--app/models/active_session.rb4
-rw-r--r--app/models/alert_management/alert.rb146
-rw-r--r--app/models/appearance.rb3
-rw-r--r--app/models/application_setting.rb12
-rw-r--r--app/models/application_setting_implementation.rb5
-rw-r--r--app/models/blob.rb6
-rw-r--r--app/models/blob_viewer/dependency_manager.rb2
-rw-r--r--app/models/broadcast_message.rb5
-rw-r--r--app/models/ci/bridge.rb4
-rw-r--r--app/models/ci/build.rb67
-rw-r--r--app/models/ci/daily_build_group_report_result.rb20
-rw-r--r--app/models/ci/daily_report_result.rb22
-rw-r--r--app/models/ci/freeze_period.rb18
-rw-r--r--app/models/ci/freeze_period_status.rb47
-rw-r--r--app/models/ci/group.rb2
-rw-r--r--app/models/ci/instance_variable.rb76
-rw-r--r--app/models/ci/job_artifact.rb61
-rw-r--r--app/models/ci/legacy_stage.rb4
-rw-r--r--app/models/ci/persistent_ref.rb12
-rw-r--r--app/models/ci/pipeline.rb55
-rw-r--r--app/models/ci/pipeline_schedule.rb4
-rw-r--r--app/models/ci/processable.rb12
-rw-r--r--app/models/ci/stage.rb4
-rw-r--r--app/models/clusters/applications/elastic_stack.rb47
-rw-r--r--app/models/clusters/applications/fluentd.rb20
-rw-r--r--app/models/clusters/applications/ingress.rb7
-rw-r--r--app/models/clusters/applications/jupyter.rb2
-rw-r--r--app/models/clusters/applications/knative.rb4
-rw-r--r--app/models/clusters/applications/runner.rb2
-rw-r--r--app/models/clusters/cluster.rb92
-rw-r--r--app/models/clusters/concerns/application_status.rb9
-rw-r--r--app/models/commit_status.rb12
-rw-r--r--app/models/concerns/async_devise_email.rb14
-rw-r--r--app/models/concerns/awardable.rb43
-rw-r--r--app/models/concerns/cache_markdown_field.rb1
-rw-r--r--app/models/concerns/ci/contextable.rb8
-rw-r--r--app/models/concerns/diff_positionable_note.rb4
-rw-r--r--app/models/concerns/has_repository.rb1
-rw-r--r--app/models/concerns/has_user_type.rb45
-rw-r--r--app/models/concerns/has_wiki.rb44
-rw-r--r--app/models/concerns/issuable.rb29
-rw-r--r--app/models/concerns/issue_resource_event.rb13
-rw-r--r--app/models/concerns/limitable.rb27
-rw-r--r--app/models/concerns/merge_request_resource_event.rb11
-rw-r--r--app/models/concerns/milestoneable.rb2
-rw-r--r--app/models/concerns/noteable.rb14
-rw-r--r--app/models/concerns/prometheus_adapter.rb1
-rw-r--r--app/models/concerns/protected_ref_access.rb4
-rw-r--r--app/models/concerns/reactive_caching.rb15
-rw-r--r--app/models/concerns/redis_cacheable.rb6
-rw-r--r--app/models/concerns/spammable.rb33
-rw-r--r--app/models/concerns/state_eventable.rb9
-rw-r--r--app/models/concerns/storage/legacy_project_wiki.rb11
-rw-r--r--app/models/concerns/timebox.rb204
-rw-r--r--app/models/concerns/update_project_statistics.rb14
-rw-r--r--app/models/container_repository.rb2
-rw-r--r--app/models/cycle_analytics/group_level.rb29
-rw-r--r--app/models/deploy_token.rb5
-rw-r--r--app/models/design_management.rb13
-rw-r--r--app/models/design_management/action.rb44
-rw-r--r--app/models/design_management/design.rb266
-rw-r--r--app/models/design_management/design_action.rb64
-rw-r--r--app/models/design_management/design_at_version.rb119
-rw-r--r--app/models/design_management/design_collection.rb30
-rw-r--r--app/models/design_management/repository.rb51
-rw-r--r--app/models/design_management/version.rb144
-rw-r--r--app/models/design_user_mention.rb6
-rw-r--r--app/models/diff_note.rb10
-rw-r--r--app/models/email.rb14
-rw-r--r--app/models/environment.rb9
-rw-r--r--app/models/epic.rb2
-rw-r--r--app/models/error_tracking/project_error_tracking_setting.rb1
-rw-r--r--app/models/event.rb15
-rw-r--r--app/models/global_milestone.rb5
-rw-r--r--app/models/group.rb35
-rw-r--r--app/models/group_import_state.rb34
-rw-r--r--app/models/group_milestone.rb3
-rw-r--r--app/models/hooks/project_hook.rb3
-rw-r--r--app/models/internal_id_enums.rb13
-rw-r--r--app/models/issue.rb22
-rw-r--r--app/models/iteration.rb100
-rw-r--r--app/models/jira_import_state.rb7
-rw-r--r--app/models/list.rb14
-rw-r--r--app/models/member.rb1
-rw-r--r--app/models/members/project_member.rb5
-rw-r--r--app/models/members_preloader.rb4
-rw-r--r--app/models/merge_request.rb54
-rw-r--r--app/models/merge_request_diff.rb30
-rw-r--r--app/models/metrics/users_starred_dashboard.rb18
-rw-r--r--app/models/milestone.rb195
-rw-r--r--app/models/milestone_note.rb2
-rw-r--r--app/models/namespace.rb25
-rw-r--r--app/models/namespace/root_storage_size.rb31
-rw-r--r--app/models/note.rb14
-rw-r--r--app/models/pages_domain.rb11
-rw-r--r--app/models/performance_monitoring/prometheus_dashboard.rb2
-rw-r--r--app/models/personal_access_token.rb21
-rw-r--r--app/models/personal_snippet.rb4
-rw-r--r--app/models/plan.rb42
-rw-r--r--app/models/plan_limits.rb23
-rw-r--r--app/models/project.rb127
-rw-r--r--app/models/project_authorization.rb3
-rw-r--r--app/models/project_ci_cd_setting.rb2
-rw-r--r--app/models/project_feature.rb2
-rw-r--r--app/models/project_repository_storage_move.rb58
-rw-r--r--app/models/project_services/chat_message/merge_message.rb4
-rw-r--r--app/models/project_services/chat_message/pipeline_message.rb48
-rw-r--r--app/models/project_services/jira_service.rb66
-rw-r--r--app/models/project_services/mattermost_slash_commands_service.rb4
-rw-r--r--app/models/project_services/mock_monitoring_service.rb2
-rw-r--r--app/models/project_services/webex_teams_service.rb57
-rw-r--r--app/models/project_services/youtrack_service.rb4
-rw-r--r--app/models/project_statistics.rb3
-rw-r--r--app/models/project_wiki.rb220
-rw-r--r--app/models/release.rb14
-rw-r--r--app/models/remote_mirror.rb26
-rw-r--r--app/models/repository.rb11
-rw-r--r--app/models/resource_label_event.rb6
-rw-r--r--app/models/resource_milestone_event.rb11
-rw-r--r--app/models/resource_state_event.rb15
-rw-r--r--app/models/resource_weight_event.rb4
-rw-r--r--app/models/sent_notification.rb4
-rw-r--r--app/models/service.rb14
-rw-r--r--app/models/snippet.rb31
-rw-r--r--app/models/snippet_repository.rb30
-rw-r--r--app/models/ssh_host_key.rb1
-rw-r--r--app/models/state_note.rb19
-rw-r--r--app/models/storage/hashed.rb1
-rw-r--r--app/models/system_note_metadata.rb1
-rw-r--r--app/models/timelog.rb4
-rw-r--r--app/models/todo.rb6
-rw-r--r--app/models/user.rb105
-rw-r--r--app/models/user_type_enums.rb13
-rw-r--r--app/models/wiki.rb233
-rw-r--r--app/models/wiki_page.rb59
-rw-r--r--app/models/wiki_page/meta.rb108
-rw-r--r--app/models/x509_certificate.rb6
-rw-r--r--app/models/x509_commit_signature.rb4
138 files changed, 3171 insertions, 1036 deletions
diff --git a/app/models/active_session.rb b/app/models/active_session.rb
index 050155398ab..065bd5507be 100644
--- a/app/models/active_session.rb
+++ b/app/models/active_session.rb
@@ -124,7 +124,7 @@ class ActiveSession
end
end
- # Lists the ActiveSession objects for the given session IDs.
+ # Lists the session Hash objects for the given session IDs.
#
# session_ids - An array of Rack::Session::SessionId objects
#
@@ -143,7 +143,7 @@ class ActiveSession
end
end
- # Deserializes an ActiveSession object from Redis.
+ # Deserializes a session Hash object from Redis.
#
# raw_session - Raw bytes from Redis
#
diff --git a/app/models/alert_management/alert.rb b/app/models/alert_management/alert.rb
new file mode 100644
index 00000000000..acaf474ecc2
--- /dev/null
+++ b/app/models/alert_management/alert.rb
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+
+module AlertManagement
+ class Alert < ApplicationRecord
+ include AtomicInternalId
+ include ShaAttribute
+ include Sortable
+ include Gitlab::SQL::Pattern
+
+ STATUSES = {
+ triggered: 0,
+ acknowledged: 1,
+ resolved: 2,
+ ignored: 3
+ }.freeze
+
+ STATUS_EVENTS = {
+ triggered: :trigger,
+ acknowledged: :acknowledge,
+ resolved: :resolve,
+ ignored: :ignore
+ }.freeze
+
+ belongs_to :project
+ belongs_to :issue, optional: true
+ has_internal_id :iid, scope: :project, init: ->(s) { s.project.alert_management_alerts.maximum(:iid) }
+
+ self.table_name = 'alert_management_alerts'
+
+ sha_attribute :fingerprint
+
+ HOSTS_MAX_LENGTH = 255
+
+ validates :title, length: { maximum: 200 }, presence: true
+ validates :description, length: { maximum: 1_000 }
+ validates :service, length: { maximum: 100 }
+ validates :monitoring_tool, length: { maximum: 100 }
+ validates :project, presence: true
+ validates :events, presence: true
+ validates :severity, presence: true
+ validates :status, presence: true
+ validates :started_at, presence: true
+ validates :fingerprint, uniqueness: { scope: :project }, allow_blank: true
+ validate :hosts_length
+
+ enum severity: {
+ critical: 0,
+ high: 1,
+ medium: 2,
+ low: 3,
+ info: 4,
+ unknown: 5
+ }
+
+ state_machine :status, initial: :triggered do
+ state :triggered, value: STATUSES[:triggered]
+
+ state :acknowledged, value: STATUSES[:acknowledged]
+
+ state :resolved, value: STATUSES[:resolved] do
+ validates :ended_at, presence: true
+ end
+
+ state :ignored, value: STATUSES[:ignored]
+
+ state :triggered, :acknowledged, :ignored do
+ validates :ended_at, absence: true
+ end
+
+ event :trigger do
+ transition any => :triggered
+ end
+
+ event :acknowledge do
+ transition any => :acknowledged
+ end
+
+ event :resolve do
+ transition any => :resolved
+ end
+
+ event :ignore do
+ transition any => :ignored
+ end
+
+ before_transition to: [:triggered, :acknowledged, :ignored] do |alert, _transition|
+ alert.ended_at = nil
+ end
+
+ before_transition to: :resolved do |alert, transition|
+ ended_at = transition.args.first
+ alert.ended_at = ended_at || Time.current
+ end
+ end
+
+ delegate :iid, to: :issue, prefix: true, allow_nil: true
+
+ scope :for_iid, -> (iid) { where(iid: iid) }
+ scope :for_status, -> (status) { where(status: status) }
+ scope :for_fingerprint, -> (project, fingerprint) { where(project: project, fingerprint: fingerprint) }
+ scope :search, -> (query) { fuzzy_search(query, [:title, :description, :monitoring_tool, :service]) }
+
+ scope :order_start_time, -> (sort_order) { order(started_at: sort_order) }
+ scope :order_end_time, -> (sort_order) { order(ended_at: sort_order) }
+ scope :order_events_count, -> (sort_order) { order(events: sort_order) }
+ scope :order_severity, -> (sort_order) { order(severity: sort_order) }
+ scope :order_status, -> (sort_order) { order(status: sort_order) }
+
+ scope :counts_by_status, -> { group(:status).count }
+
+ def self.sort_by_attribute(method)
+ case method.to_s
+ when 'start_time_asc' then order_start_time(:asc)
+ when 'start_time_desc' then order_start_time(:desc)
+ when 'end_time_asc' then order_end_time(:asc)
+ when 'end_time_desc' then order_end_time(:desc)
+ when 'events_count_asc' then order_events_count(:asc)
+ when 'events_count_desc' then order_events_count(:desc)
+ when 'severity_asc' then order_severity(:asc)
+ when 'severity_desc' then order_severity(:desc)
+ when 'status_asc' then order_status(:asc)
+ when 'status_desc' then order_status(:desc)
+ else
+ order_by(method)
+ end
+ end
+
+ def details
+ details_payload = payload.except(*attributes.keys)
+
+ Gitlab::Utils::InlineHash.merge_keys(details_payload)
+ end
+
+ def prometheus?
+ monitoring_tool == Gitlab::AlertManagement::AlertParams::MONITORING_TOOLS[:prometheus]
+ end
+
+ private
+
+ def hosts_length
+ return unless hosts
+
+ errors.add(:hosts, "hosts array is over #{HOSTS_MAX_LENGTH} chars") if hosts.join.length > HOSTS_MAX_LENGTH
+ end
+ end
+end
diff --git a/app/models/appearance.rb b/app/models/appearance.rb
index 9da4dfd43b5..00a95070691 100644
--- a/app/models/appearance.rb
+++ b/app/models/appearance.rb
@@ -8,6 +8,7 @@ class Appearance < ApplicationRecord
cache_markdown_field :description
cache_markdown_field :new_project_guidelines
+ cache_markdown_field :profile_image_guidelines
cache_markdown_field :header_message, pipeline: :broadcast_message
cache_markdown_field :footer_message, pipeline: :broadcast_message
@@ -15,12 +16,14 @@ class Appearance < ApplicationRecord
validates :header_logo, file_size: { maximum: 1.megabyte }
validates :message_background_color, allow_blank: true, color: true
validates :message_font_color, allow_blank: true, color: true
+ validates :profile_image_guidelines, length: { maximum: 4096 }
validate :single_appearance_row, on: :create
default_value_for :title, ''
default_value_for :description, ''
default_value_for :new_project_guidelines, ''
+ default_value_for :profile_image_guidelines, ''
default_value_for :header_message, ''
default_value_for :footer_message, ''
default_value_for :message_background_color, '#E75E40'
diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb
index 0aa0216558f..b29d6731b08 100644
--- a/app/models/application_setting.rb
+++ b/app/models/application_setting.rb
@@ -144,7 +144,7 @@ class ApplicationSetting < ApplicationRecord
validates :default_artifacts_expire_in, presence: true, duration: true
validates :container_expiration_policies_enable_historic_entries,
- inclusion: { in: [true, false], message: 'must be a boolean value' }
+ inclusion: { in: [true, false], message: 'must be a boolean value' }
validates :container_registry_token_expire_delay,
presence: true,
@@ -263,6 +263,8 @@ class ApplicationSetting < ApplicationRecord
validates :email_restrictions, untrusted_regexp: true
+ validates :hashed_storage_enabled, inclusion: { in: [true], message: _("Hashed storage can't be disabled anymore for new projects") }
+
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@@ -345,6 +347,12 @@ class ApplicationSetting < ApplicationRecord
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+ validates :issues_create_limit,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
+ validates :raw_blob_request_limit,
+ numericality: { only_integer: true, greater_than_or_equal_to: 0 }
+
attr_encrypted :asset_proxy_secret_key,
mode: :per_attribute_iv,
key: Settings.attr_encrypted_db_key_base_truncated,
@@ -412,7 +420,7 @@ class ApplicationSetting < ApplicationRecord
# can cause a significant amount of load on Redis, let's cache it in
# memory.
def self.cache_backend
- Gitlab::ThreadMemoryCache.cache_backend
+ Gitlab::ProcessMemoryCache.cache_backend
end
def recaptcha_or_login_protection_enabled
diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb
index c96f086684f..221e4d5e0c6 100644
--- a/app/models/application_setting_implementation.rb
+++ b/app/models/application_setting_implementation.rb
@@ -43,7 +43,10 @@ module ApplicationSettingImplementation
authorized_keys_enabled: true, # TODO default to false if the instance is configured to use AuthorizedKeysCommand
commit_email_hostname: default_commit_email_hostname,
container_expiration_policies_enable_historic_entries: false,
+ container_registry_features: [],
container_registry_token_expire_delay: 5,
+ container_registry_vendor: '',
+ container_registry_version: '',
default_artifacts_expire_in: '30 days',
default_branch_protection: Settings.gitlab['default_branch_protection'],
default_ci_config_path: nil,
@@ -93,7 +96,7 @@ module ApplicationSettingImplementation
plantuml_url: nil,
polling_interval_multiplier: 1,
project_export_enabled: true,
- protected_ci_variables: false,
+ protected_ci_variables: true,
push_event_hooks_limit: 3,
push_event_activities_limit: 3,
raw_blob_request_limit: 300,
diff --git a/app/models/blob.rb b/app/models/blob.rb
index cdc5838797b..c8df6c7732a 100644
--- a/app/models/blob.rb
+++ b/app/models/blob.rb
@@ -86,8 +86,8 @@ class Blob < SimpleDelegator
new(blob, container)
end
- def self.lazy(container, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE)
- BatchLoader.for([commit_id, path]).batch(key: container.repository) do |items, loader, args|
+ def self.lazy(repository, commit_id, path, blob_size_limit: Gitlab::Git::Blob::MAX_DATA_DISPLAY_SIZE)
+ BatchLoader.for([commit_id, path]).batch(key: repository) do |items, loader, args|
args[:key].blobs_at(items, blob_size_limit: blob_size_limit).each do |blob|
loader.call([blob.commit_id, blob.path], blob) if blob
end
@@ -129,7 +129,7 @@ class Blob < SimpleDelegator
def external_storage_error?
if external_storage == :lfs
- !project&.lfs_enabled?
+ !repository.lfs_enabled?
else
false
end
diff --git a/app/models/blob_viewer/dependency_manager.rb b/app/models/blob_viewer/dependency_manager.rb
index 711465c7c79..a851f22bfcd 100644
--- a/app/models/blob_viewer/dependency_manager.rb
+++ b/app/models/blob_viewer/dependency_manager.rb
@@ -32,7 +32,7 @@ module BlobViewer
def json_data
@json_data ||= begin
prepare!
- JSON.parse(blob.data)
+ Gitlab::Json.parse(blob.data)
rescue
{}
end
diff --git a/app/models/broadcast_message.rb b/app/models/broadcast_message.rb
index 0a536a01f72..856f86201ec 100644
--- a/app/models/broadcast_message.rb
+++ b/app/models/broadcast_message.rb
@@ -105,7 +105,10 @@ class BroadcastMessage < ApplicationRecord
def matches_current_path(current_path)
return true if current_path.blank? || target_path.blank?
- current_path.match(Regexp.escape(target_path).gsub('\\*', '.*'))
+ escaped = Regexp.escape(target_path).gsub('\\*', '.*')
+ regexp = Regexp.new "^#{escaped}$", Regexp::IGNORECASE
+
+ regexp.match(current_path)
end
def flush_redis_cache
diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index 76882dfcb0d..1e92a47ab49 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -166,6 +166,10 @@ module Ci
end
end
+ def dependency_variables
+ []
+ end
+
private
def cross_project_params
diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb
index e515447e394..7f64ea7dd97 100644
--- a/app/models/ci/build.rb
+++ b/app/models/ci/build.rb
@@ -25,13 +25,16 @@ module Ci
RUNNER_FEATURES = {
upload_multiple_artifacts: -> (build) { build.publishes_artifacts_reports? },
- refspecs: -> (build) { build.merge_request_ref? }
+ refspecs: -> (build) { build.merge_request_ref? },
+ artifacts_exclude: -> (build) { build.supports_artifacts_exclude? }
}.freeze
DEFAULT_RETRIES = {
scheduler_failure: 2
}.freeze
+ DEGRADATION_THRESHOLD_VARIABLE_NAME = 'DEGRADATION_THRESHOLD'
+
has_one :deployment, as: :deployable, class_name: 'Deployment'
has_one :resource, class_name: 'Ci::Resource', inverse_of: :build
has_many :trace_sections, class_name: 'Ci::BuildTraceSection'
@@ -87,8 +90,12 @@ module Ci
scope :unstarted, ->() { where(runner_id: nil) }
scope :ignore_failures, ->() { where(allow_failure: false) }
- scope :with_artifacts_archive, ->() do
- where('EXISTS (?)', Ci::JobArtifact.select(1).where('ci_builds.id = ci_job_artifacts.job_id').archive)
+ scope :with_downloadable_artifacts, ->() do
+ where('EXISTS (?)',
+ Ci::JobArtifact.select(1)
+ .where('ci_builds.id = ci_job_artifacts.job_id')
+ .where(file_type: Ci::JobArtifact::DOWNLOADABLE_TYPES)
+ )
end
scope :with_existing_job_artifacts, ->(query) do
@@ -130,8 +137,8 @@ module Ci
.includes(:metadata, :job_artifacts_metadata)
end
- scope :with_artifacts_not_expired, ->() { with_artifacts_archive.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
- scope :with_expired_artifacts, ->() { with_artifacts_archive.where('artifacts_expire_at < ?', Time.now) }
+ scope :with_artifacts_not_expired, ->() { with_downloadable_artifacts.where('artifacts_expire_at IS NULL OR artifacts_expire_at > ?', Time.now) }
+ scope :with_expired_artifacts, ->() { with_downloadable_artifacts.where('artifacts_expire_at < ?', Time.now) }
scope :last_month, ->() { where('created_at > ?', Date.today - 1.month) }
scope :manual_actions, ->() { where(when: :manual, status: COMPLETED_STATUSES + %i[manual]) }
scope :scheduled_actions, ->() { where(when: :delayed, status: COMPLETED_STATUSES + %i[scheduled]) }
@@ -486,8 +493,7 @@ module Ci
end
def requires_resource?
- Feature.enabled?(:ci_resource_group, project, default_enabled: true) &&
- self.resource_group_id.present?
+ self.resource_group_id.present?
end
def has_environment?
@@ -530,6 +536,7 @@ module Ci
.concat(job_variables)
.concat(environment_changed_page_variables)
.concat(persisted_environment_variables)
+ .concat(deploy_freeze_variables)
.to_runner_variables
end
end
@@ -585,6 +592,26 @@ module Ci
end
end
+ def deploy_freeze_variables
+ Gitlab::Ci::Variables::Collection.new.tap do |variables|
+ break variables unless freeze_period?
+
+ variables.append(key: 'CI_DEPLOY_FREEZE', value: 'true')
+ end
+ end
+
+ def freeze_period?
+ Ci::FreezePeriodStatus.new(project: project).execute
+ end
+
+ def dependency_variables
+ return [] if all_dependencies.empty?
+
+ Gitlab::Ci::Variables::Collection.new.concat(
+ Ci::JobVariable.where(job: all_dependencies).dotenv_source
+ )
+ end
+
def features
{ trace_sections: true }
end
@@ -870,6 +897,14 @@ module Ci
end
end
+ def collect_accessibility_reports!(accessibility_report)
+ each_report(Ci::JobArtifact::ACCESSIBILITY_REPORT_FILE_TYPES) do |file_type, blob|
+ Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, accessibility_report)
+ end
+
+ accessibility_report
+ end
+
def collect_coverage_reports!(coverage_report)
each_report(Ci::JobArtifact::COVERAGE_REPORT_FILE_TYPES) do |file_type, blob|
Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, coverage_report)
@@ -878,6 +913,14 @@ module Ci
coverage_report
end
+ def collect_terraform_reports!(terraform_reports)
+ each_report(::Ci::JobArtifact::TERRAFORM_REPORT_FILE_TYPES) do |file_type, blob, report_artifact|
+ ::Gitlab::Ci::Parsers.fabricate!(file_type).parse!(blob, terraform_reports, artifact: report_artifact)
+ end
+
+ terraform_reports
+ end
+
def report_artifacts
job_artifacts.with_reports
end
@@ -902,6 +945,16 @@ module Ci
failure_reason: :data_integrity_failure)
end
+ def supports_artifacts_exclude?
+ options&.dig(:artifacts, :exclude)&.any? &&
+ Gitlab::Ci::Features.artifacts_exclude_enabled?
+ end
+
+ def degradation_threshold
+ var = yaml_variables.find { |v| v[:key] == DEGRADATION_THRESHOLD_VARIABLE_NAME }
+ var[:value]&.to_i if var
+ end
+
private
def dependencies
diff --git a/app/models/ci/daily_build_group_report_result.rb b/app/models/ci/daily_build_group_report_result.rb
new file mode 100644
index 00000000000..3506b27e974
--- /dev/null
+++ b/app/models/ci/daily_build_group_report_result.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+module Ci
+ class DailyBuildGroupReportResult < ApplicationRecord
+ extend Gitlab::Ci::Model
+
+ PARAM_TYPES = %w[coverage].freeze
+
+ belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id
+ belongs_to :project
+
+ def self.upsert_reports(data)
+ upsert_all(data, unique_by: :index_daily_build_group_report_results_unique_columns) if data.any?
+ end
+
+ def self.recent_results(attrs, limit: nil)
+ where(attrs).order(date: :desc, group_name: :asc).limit(limit)
+ end
+ end
+end
diff --git a/app/models/ci/daily_report_result.rb b/app/models/ci/daily_report_result.rb
deleted file mode 100644
index 3c1c5f11ed4..00000000000
--- a/app/models/ci/daily_report_result.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-module Ci
- class DailyReportResult < ApplicationRecord
- extend Gitlab::Ci::Model
-
- belongs_to :last_pipeline, class_name: 'Ci::Pipeline', foreign_key: :last_pipeline_id
- belongs_to :project
-
- # TODO: Refactor this out when BuildReportResult is implemented.
- # They both need to share the same enum values for param.
- REPORT_PARAMS = {
- coverage: 0
- }.freeze
-
- enum param_type: REPORT_PARAMS
-
- def self.upsert_reports(data)
- upsert_all(data, unique_by: :index_daily_report_results_unique_columns) if data.any?
- end
- end
-end
diff --git a/app/models/ci/freeze_period.rb b/app/models/ci/freeze_period.rb
new file mode 100644
index 00000000000..bf03b92259a
--- /dev/null
+++ b/app/models/ci/freeze_period.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Ci
+ class FreezePeriod < ApplicationRecord
+ include StripAttribute
+ self.table_name = 'ci_freeze_periods'
+
+ default_scope { order(created_at: :asc) }
+
+ belongs_to :project, inverse_of: :freeze_periods
+
+ strip_attributes :freeze_start, :freeze_end
+
+ validates :freeze_start, cron: true, presence: true
+ validates :freeze_end, cron: true, presence: true
+ validates :cron_timezone, cron_freeze_period_timezone: true, presence: true
+ end
+end
diff --git a/app/models/ci/freeze_period_status.rb b/app/models/ci/freeze_period_status.rb
new file mode 100644
index 00000000000..befa935e750
--- /dev/null
+++ b/app/models/ci/freeze_period_status.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Ci
+ class FreezePeriodStatus
+ attr_reader :project
+
+ def initialize(project:)
+ @project = project
+ end
+
+ def execute
+ project.freeze_periods.any? { |period| within_freeze_period?(period) }
+ end
+
+ def within_freeze_period?(period)
+ # previous_freeze_end, ..., previous_freeze_start, ..., NOW, ..., next_freeze_end, ..., next_freeze_start
+ # Current time is within a freeze period if
+ # it falls between a previous freeze start and next freeze end
+ start_freeze = Gitlab::Ci::CronParser.new(period.freeze_start, period.cron_timezone)
+ end_freeze = Gitlab::Ci::CronParser.new(period.freeze_end, period.cron_timezone)
+
+ previous_freeze_start = previous_time(start_freeze)
+ previous_freeze_end = previous_time(end_freeze)
+ next_freeze_start = next_time(start_freeze)
+ next_freeze_end = next_time(end_freeze)
+
+ previous_freeze_end < previous_freeze_start &&
+ previous_freeze_start <= time_zone_now &&
+ time_zone_now <= next_freeze_end &&
+ next_freeze_end < next_freeze_start
+ end
+
+ private
+
+ def previous_time(cron_parser)
+ cron_parser.previous_time_from(time_zone_now)
+ end
+
+ def next_time(cron_parser)
+ cron_parser.next_time_from(time_zone_now)
+ end
+
+ def time_zone_now
+ @time_zone_now ||= Time.zone.now
+ end
+ end
+end
diff --git a/app/models/ci/group.rb b/app/models/ci/group.rb
index 15dc1ca8954..4b2081f2977 100644
--- a/app/models/ci/group.rb
+++ b/app/models/ci/group.rb
@@ -46,7 +46,7 @@ module Ci
end
def self.fabricate(project, stage)
- stage.statuses.ordered.latest
+ stage.latest_statuses
.sort_by(&:sortable_name).group_by(&:group_name)
.map do |group_name, grouped_statuses|
self.new(project, stage, name: group_name, jobs: grouped_statuses)
diff --git a/app/models/ci/instance_variable.rb b/app/models/ci/instance_variable.rb
new file mode 100644
index 00000000000..c674f76d229
--- /dev/null
+++ b/app/models/ci/instance_variable.rb
@@ -0,0 +1,76 @@
+# frozen_string_literal: true
+
+module Ci
+ class InstanceVariable < ApplicationRecord
+ extend Gitlab::Ci::Model
+ include Ci::NewHasVariable
+ include Ci::Maskable
+
+ alias_attribute :secret_value, :value
+
+ validates :key, uniqueness: {
+ message: "(%{value}) has already been taken"
+ }
+
+ scope :unprotected, -> { where(protected: false) }
+ after_commit { self.class.touch_redis_cache_timestamp }
+
+ class << self
+ def all_cached
+ cached_data[:all]
+ end
+
+ def unprotected_cached
+ cached_data[:unprotected]
+ end
+
+ def touch_redis_cache_timestamp(time = Time.current.to_f)
+ shared_backend.write(:ci_instance_variable_changed_at, time)
+ end
+
+ private
+
+ def cached_data
+ fetch_memory_cache(:ci_instance_variable_data) do
+ all_records = unscoped.all.to_a
+
+ { all: all_records, unprotected: all_records.reject(&:protected?) }
+ end
+ end
+
+ def fetch_memory_cache(key, &payload)
+ cache = process_backend.read(key)
+
+ if cache && !stale_cache?(cache)
+ cache[:data]
+ else
+ store_cache(key, &payload)
+ end
+ end
+
+ def stale_cache?(cache_info)
+ shared_timestamp = shared_backend.read(:ci_instance_variable_changed_at)
+ return true unless shared_timestamp
+
+ shared_timestamp.to_f > cache_info[:cached_at].to_f
+ end
+
+ def store_cache(key)
+ data = yield
+ time = Time.current.to_f
+
+ process_backend.write(key, data: data, cached_at: time)
+ touch_redis_cache_timestamp(time)
+ data
+ end
+
+ def shared_backend
+ Rails.cache
+ end
+
+ def process_backend
+ Gitlab::ProcessMemoryCache.cache_backend
+ end
+ end
+ end
+end
diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb
index ef0701b3874..d931428dccd 100644
--- a/app/models/ci/job_artifact.rb
+++ b/app/models/ci/job_artifact.rb
@@ -12,7 +12,10 @@ module Ci
TEST_REPORT_FILE_TYPES = %w[junit].freeze
COVERAGE_REPORT_FILE_TYPES = %w[cobertura].freeze
+ ACCESSIBILITY_REPORT_FILE_TYPES = %w[accessibility].freeze
NON_ERASABLE_FILE_TYPES = %w[trace].freeze
+ TERRAFORM_REPORT_FILE_TYPES = %w[terraform].freeze
+ UNSUPPORTED_FILE_TYPES = %i[license_management].freeze
DEFAULT_FILE_NAMES = {
archive: nil,
metadata: nil,
@@ -20,6 +23,7 @@ module Ci
metrics_referee: nil,
network_referee: nil,
junit: 'junit.xml',
+ accessibility: 'gl-accessibility.json',
codequality: 'gl-code-quality-report.json',
sast: 'gl-sast-report.json',
dependency_scanning: 'gl-dependency-scanning-report.json',
@@ -32,7 +36,8 @@ module Ci
lsif: 'lsif.json',
dotenv: '.env',
cobertura: 'cobertura-coverage.xml',
- terraform: 'tfplan.json'
+ terraform: 'tfplan.json',
+ cluster_applications: 'gl-cluster-applications.json'
}.freeze
INTERNAL_TYPES = {
@@ -46,13 +51,15 @@ module Ci
metrics: :gzip,
metrics_referee: :gzip,
network_referee: :gzip,
- lsif: :gzip,
dotenv: :gzip,
cobertura: :gzip,
+ cluster_applications: :gzip,
+ lsif: :zip,
# All these file formats use `raw` as we need to store them uncompressed
# for Frontend to fetch the files and do analysis
# When they will be only used by backend, they can be `gzipped`.
+ accessibility: :raw,
codequality: :raw,
sast: :raw,
dependency_scanning: :raw,
@@ -64,15 +71,38 @@ module Ci
terraform: :raw
}.freeze
+ DOWNLOADABLE_TYPES = %w[
+ accessibility
+ archive
+ cobertura
+ codequality
+ container_scanning
+ dast
+ dependency_scanning
+ dotenv
+ junit
+ license_management
+ license_scanning
+ lsif
+ metrics
+ performance
+ sast
+ ].freeze
+
TYPE_AND_FORMAT_PAIRS = INTERNAL_TYPES.merge(REPORT_TYPES).freeze
+ # This is required since we cannot add a default to the database
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/215418
+ attribute :locked, :boolean, default: false
+
belongs_to :project
belongs_to :job, class_name: "Ci::Build", foreign_key: :job_id
mount_uploader :file, JobArtifactUploader
validates :file_format, presence: true, unless: :trace?, on: :create
- validate :valid_file_format?, unless: :trace?, on: :create
+ validate :validate_supported_file_format!, on: :create
+ validate :validate_file_format!, unless: :trace?, on: :create
before_save :set_size, if: :file_changed?
update_project_statistics project_statistics_name: :build_artifacts_size
@@ -82,6 +112,7 @@ module Ci
scope :with_files_stored_locally, -> { where(file_store: [nil, ::JobArtifactUploader::Store::LOCAL]) }
scope :with_files_stored_remotely, -> { where(file_store: ::JobArtifactUploader::Store::REMOTE) }
scope :for_sha, ->(sha, project_id) { joins(job: :pipeline).where(ci_pipelines: { sha: sha, project_id: project_id }) }
+ scope :for_ref, ->(ref, project_id) { joins(job: :pipeline).where(ci_pipelines: { ref: ref, project_id: project_id }) }
scope :for_job_name, ->(name) { joins(:job).where(ci_builds: { name: name }) }
scope :with_file_types, -> (file_types) do
@@ -98,10 +129,18 @@ module Ci
with_file_types(TEST_REPORT_FILE_TYPES)
end
+ scope :accessibility_reports, -> do
+ with_file_types(ACCESSIBILITY_REPORT_FILE_TYPES)
+ end
+
scope :coverage_reports, -> do
with_file_types(COVERAGE_REPORT_FILE_TYPES)
end
+ scope :terraform_reports, -> do
+ with_file_types(TERRAFORM_REPORT_FILE_TYPES)
+ end
+
scope :erasable, -> do
types = self.file_types.reject { |file_type| NON_ERASABLE_FILE_TYPES.include?(file_type) }.values
@@ -109,6 +148,8 @@ module Ci
end
scope :expired, -> (limit) { where('expire_at < ?', Time.now).limit(limit) }
+ scope :locked, -> { where(locked: true) }
+ scope :unlocked, -> { where(locked: [false, nil]) }
scope :scoped_project, -> { where('ci_job_artifacts.project_id = projects.id') }
@@ -133,7 +174,9 @@ module Ci
lsif: 15, # LSIF data for code navigation
dotenv: 16,
cobertura: 17,
- terraform: 18 # Transformed json
+ terraform: 18, # Transformed json
+ accessibility: 19,
+ cluster_applications: 20
}
enum file_format: {
@@ -161,7 +204,15 @@ module Ci
raw: Gitlab::Ci::Build::Artifacts::Adapters::RawStream
}.freeze
- def valid_file_format?
+ def validate_supported_file_format!
+ return if Feature.disabled?(:drop_license_management_artifact, project, default_enabled: true)
+
+ if UNSUPPORTED_FILE_TYPES.include?(self.file_type&.to_sym)
+ errors.add(:base, _("File format is no longer supported"))
+ end
+ end
+
+ def validate_file_format!
unless TYPE_AND_FORMAT_PAIRS[self.file_type&.to_sym] == self.file_format&.to_sym
errors.add(:base, _('Invalid file format with specified file type'))
end
diff --git a/app/models/ci/legacy_stage.rb b/app/models/ci/legacy_stage.rb
index f156219ea81..250306e2be4 100644
--- a/app/models/ci/legacy_stage.rb
+++ b/app/models/ci/legacy_stage.rb
@@ -41,6 +41,10 @@ module Ci
.fabricate!
end
+ def latest_statuses
+ statuses.ordered.latest
+ end
+
def statuses
@statuses ||= pipeline.statuses.where(stage: name)
end
diff --git a/app/models/ci/persistent_ref.rb b/app/models/ci/persistent_ref.rb
index 76139f5d676..91163c85a9e 100644
--- a/app/models/ci/persistent_ref.rb
+++ b/app/models/ci/persistent_ref.rb
@@ -14,16 +14,12 @@ module Ci
delegate :ref_exists?, :create_ref, :delete_refs, to: :repository
def exist?
- return unless enabled?
-
ref_exists?(path)
rescue
false
end
def create
- return unless enabled?
-
create_ref(sha, path)
rescue => e
Gitlab::ErrorTracking
@@ -31,8 +27,6 @@ module Ci
end
def delete
- return unless enabled?
-
delete_refs(path)
rescue Gitlab::Git::Repository::NoRepository
# no-op
@@ -44,11 +38,5 @@ module Ci
def path
"refs/#{Repository::REF_PIPELINES}/#{pipeline.id}"
end
-
- private
-
- def enabled?
- Feature.enabled?(:depend_on_persistent_pipeline_ref, project, default_enabled: true)
- end
end
end
diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb
index 8a3ca2e758c..5db1635f64d 100644
--- a/app/models/ci/pipeline.rb
+++ b/app/models/ci/pipeline.rb
@@ -82,7 +82,7 @@ module Ci
has_one :pipeline_config, class_name: 'Ci::PipelineConfig', inverse_of: :pipeline
- has_many :daily_report_results, class_name: 'Ci::DailyReportResult', foreign_key: :last_pipeline_id
+ has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult', foreign_key: :last_pipeline_id
accepts_nested_attributes_for :variables, reject_if: :persisted?
@@ -115,8 +115,11 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition [:created, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending
+ transition [:created, :manual, :waiting_for_resource, :preparing, :skipped, :scheduled] => :pending
transition [:success, :failed, :canceled] => :running
+
+ # this is needed to ensure tests to be covered
+ transition [:running] => :running
end
event :request_resource do
@@ -194,7 +197,7 @@ module Ci
# We wait a little bit to ensure that all BuildFinishedWorkers finish first
# because this is where some metrics like code coverage is parsed and stored
# in CI build records which the daily build metrics worker relies on.
- pipeline.run_after_commit { Ci::DailyReportResultsWorker.perform_in(10.minutes, pipeline.id) }
+ pipeline.run_after_commit { Ci::DailyBuildGroupReportResultsWorker.perform_in(10.minutes, pipeline.id) }
end
after_transition do |pipeline, transition|
@@ -393,16 +396,18 @@ module Ci
false
end
- ##
- # TODO We do not completely switch to persisted stages because of
- # race conditions with setting statuses gitlab-foss#23257.
- #
def ordered_stages
- return legacy_stages unless complete?
-
- if Feature.enabled?('ci_pipeline_persisted_stages', default_enabled: true)
+ if Feature.enabled?(:ci_atomic_processing, project, default_enabled: false)
+ # The `Ci::Stage` contains all up-to date data
+ # as atomic processing updates all data in-bulk
+ stages
+ elsif Feature.enabled?(:ci_pipeline_persisted_stages, default_enabled: true) && complete?
+ # The `Ci::Stage` contains up-to date data only for `completed` pipelines
+ # this is due to asynchronous processing of pipeline, and stages possibly
+ # not updated inline with processing of pipeline
stages
else
+ # In other cases, we need to calculate stages dynamically
legacy_stages
end
end
@@ -440,7 +445,7 @@ module Ci
end
def legacy_stages
- if Feature.enabled?(:ci_composite_status, default_enabled: false)
+ if Feature.enabled?(:ci_composite_status, project, default_enabled: false)
legacy_stages_using_composite_status
else
legacy_stages_using_sql
@@ -681,6 +686,8 @@ module Ci
variables.concat(merge_request.predefined_variables)
end
+ variables.append(key: 'CI_KUBERNETES_ACTIVE', value: 'true') if has_kubernetes_active?
+
if external_pull_request_event? && external_pull_request
variables.concat(external_pull_request.predefined_variables)
end
@@ -781,7 +788,7 @@ module Ci
end
def find_job_with_archive_artifacts(name)
- builds.latest.with_artifacts_archive.find_by_name(name)
+ builds.latest.with_downloadable_artifacts.find_by_name(name)
end
def latest_builds_with_artifacts
@@ -809,6 +816,14 @@ module Ci
end
end
+ def accessibility_reports
+ Gitlab::Ci::Reports::AccessibilityReports.new.tap do |accessibility_reports|
+ builds.latest.with_reports(Ci::JobArtifact.accessibility_reports).each do |build|
+ build.collect_accessibility_reports!(accessibility_reports)
+ end
+ end
+ end
+
def coverage_reports
Gitlab::Ci::Reports::CoverageReports.new.tap do |coverage_reports|
builds.latest.with_reports(Ci::JobArtifact.coverage_reports).each do |build|
@@ -817,6 +832,14 @@ module Ci
end
end
+ def terraform_reports
+ ::Gitlab::Ci::Reports::TerraformReports.new.tap do |terraform_reports|
+ builds.latest.with_reports(::Ci::JobArtifact.terraform_reports).each do |build|
+ build.collect_terraform_reports!(terraform_reports)
+ end
+ end
+ end
+
def has_exposed_artifacts?
complete? && builds.latest.with_exposed_artifacts.exists?
end
@@ -938,6 +961,14 @@ module Ci
end
end
+ # Set scheduling type of processables if they were created before scheduling_type
+ # data was deployed (https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22246).
+ def ensure_scheduling_type!
+ return unless ::Gitlab::Ci::Features.ensure_scheduling_type_enabled?
+
+ processables.populate_scheduling_type!
+ end
+
private
def pipeline_data
diff --git a/app/models/ci/pipeline_schedule.rb b/app/models/ci/pipeline_schedule.rb
index f5785000062..8c9ad343f32 100644
--- a/app/models/ci/pipeline_schedule.rb
+++ b/app/models/ci/pipeline_schedule.rb
@@ -6,6 +6,10 @@ module Ci
include Importable
include StripAttribute
include Schedulable
+ include Limitable
+
+ self.limit_name = 'ci_pipeline_schedules'
+ self.limit_scope = :project
belongs_to :project
belongs_to :owner, class_name: 'User'
diff --git a/app/models/ci/processable.rb b/app/models/ci/processable.rb
index c123bd7c33b..cc00500662d 100644
--- a/app/models/ci/processable.rb
+++ b/app/models/ci/processable.rb
@@ -49,7 +49,7 @@ module Ci
end
validates :type, presence: true
- validates :scheduling_type, presence: true, on: :create, if: :validate_scheduling_type?
+ validates :scheduling_type, presence: true, on: :create, unless: :importing?
delegate :merge_request?,
:merge_request_ref?,
@@ -83,7 +83,7 @@ module Ci
# Overriding scheduling_type enum's method for nil `scheduling_type`s
def scheduling_type_dag?
- super || find_legacy_scheduling_type == :dag
+ scheduling_type.nil? ? find_legacy_scheduling_type == :dag : super
end
# scheduling_type column of previous builds/bridges have not been populated,
@@ -100,10 +100,12 @@ module Ci
end
end
- private
+ def ensure_scheduling_type!
+ # If this has a scheduling_type, it means all processables in the pipeline already have.
+ return if scheduling_type
- def validate_scheduling_type?
- !importing? && Feature.enabled?(:validate_scheduling_type_of_processables, project)
+ pipeline.ensure_scheduling_type!
+ reset
end
end
end
diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb
index 93bd42f8734..a316b4718e0 100644
--- a/app/models/ci/stage.rb
+++ b/app/models/ci/stage.rb
@@ -13,6 +13,7 @@ module Ci
belongs_to :pipeline
has_many :statuses, class_name: 'CommitStatus', foreign_key: :stage_id
+ has_many :latest_statuses, -> { ordered.latest }, class_name: 'CommitStatus', foreign_key: :stage_id
has_many :processables, class_name: 'Ci::Processable', foreign_key: :stage_id
has_many :builds, foreign_key: :stage_id
has_many :bridges, foreign_key: :stage_id
@@ -42,8 +43,7 @@ module Ci
state_machine :status, initial: :created do
event :enqueue do
- transition [:created, :waiting_for_resource, :preparing] => :pending
- transition [:success, :failed, :canceled, :skipped] => :running
+ transition any - [:pending] => :pending
end
event :request_resource do
diff --git a/app/models/clusters/applications/elastic_stack.rb b/app/models/clusters/applications/elastic_stack.rb
index afdc1c91c69..0d029aabc3b 100644
--- a/app/models/clusters/applications/elastic_stack.rb
+++ b/app/models/clusters/applications/elastic_stack.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class ElasticStack < ApplicationRecord
- VERSION = '1.9.0'
+ VERSION = '3.0.0'
ELASTICSEARCH_PORT = 9200
@@ -18,7 +18,11 @@ module Clusters
default_value_for :version, VERSION
def chart
- 'stable/elastic-stack'
+ 'elastic-stack/elastic-stack'
+ end
+
+ def repository
+ 'https://charts.gitlab.io'
end
def install_command
@@ -27,7 +31,9 @@ module Clusters
version: VERSION,
rbac: cluster.platform_kubernetes_rbac?,
chart: chart,
+ repository: repository,
files: files,
+ preinstall: migrate_to_3_script,
postinstall: post_install_script
)
end
@@ -49,7 +55,7 @@ module Clusters
strong_memoize(:elasticsearch_client) do
next unless kube_client
- proxy_url = kube_client.proxy_url('service', 'elastic-stack-elasticsearch-client', ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE)
+ proxy_url = kube_client.proxy_url('service', service_name, ::Clusters::Applications::ElasticStack::ELASTICSEARCH_PORT, Gitlab::Kubernetes::Helm::NAMESPACE)
Elasticsearch::Client.new(url: proxy_url) do |faraday|
# ensures headers containing auth data are appended to original client options
@@ -69,23 +75,54 @@ module Clusters
end
end
+ def chart_above_v2?
+ Gem::Version.new(version) >= Gem::Version.new('2.0.0')
+ end
+
+ def chart_above_v3?
+ Gem::Version.new(version) >= Gem::Version.new('3.0.0')
+ end
+
private
+ def service_name
+ chart_above_v3? ? 'elastic-stack-elasticsearch-master' : 'elastic-stack-elasticsearch-client'
+ end
+
+ def pvc_selector
+ chart_above_v3? ? "app=elastic-stack-elasticsearch-master" : "release=elastic-stack"
+ end
+
def post_install_script
[
- "timeout -t60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-client:9200"
+ "timeout -t60 sh /data/helm/elastic-stack/config/wait-for-elasticsearch.sh http://elastic-stack-elasticsearch-master:9200"
]
end
def post_delete_script
[
- Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack")
+ Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", pvc_selector, "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE)
]
end
def kube_client
cluster&.kubeclient&.core_client
end
+
+ def migrate_to_3_script
+ return [] if !updating? || chart_above_v3?
+
+ # Chart version 3.0.0 moves to our own chart at https://gitlab.com/gitlab-org/charts/elastic-stack
+ # and is not compatible with pre-existing resources. We first remove them.
+ [
+ Gitlab::Kubernetes::Helm::DeleteCommand.new(
+ name: 'elastic-stack',
+ rbac: cluster.platform_kubernetes_rbac?,
+ files: files
+ ).delete_command,
+ Gitlab::Kubernetes::KubectlCmd.delete("pvc", "--selector", "release=elastic-stack", "--namespace", Gitlab::Kubernetes::Helm::NAMESPACE)
+ ]
+ end
end
end
end
diff --git a/app/models/clusters/applications/fluentd.rb b/app/models/clusters/applications/fluentd.rb
index a33b1e39ace..3fd6e870edc 100644
--- a/app/models/clusters/applications/fluentd.rb
+++ b/app/models/clusters/applications/fluentd.rb
@@ -4,6 +4,7 @@ module Clusters
module Applications
class Fluentd < ApplicationRecord
VERSION = '2.4.0'
+ CILIUM_CONTAINER_NAME = 'cilium-monitor'
self.table_name = 'clusters_applications_fluentd'
@@ -18,6 +19,8 @@ module Clusters
enum protocol: { tcp: 0, udp: 1 }
+ validate :has_at_least_one_log_enabled?
+
def chart
'stable/fluentd'
end
@@ -39,6 +42,12 @@ module Clusters
private
+ def has_at_least_one_log_enabled?
+ if !waf_log_enabled && !cilium_log_enabled
+ errors.add(:base, _("At least one logging option is required to be enabled"))
+ end
+ end
+
def content_values
YAML.load_file(chart_values_file).deep_merge!(specification)
end
@@ -62,7 +71,7 @@ module Clusters
program fluentd
hostname ${kubernetes_host}
protocol #{protocol}
- packet_size 65535
+ packet_size 131072
<buffer kubernetes_host>
</buffer>
<format>
@@ -85,7 +94,7 @@ module Clusters
<source>
@type tail
@id in_tail_container_logs
- path /var/log/containers/*#{Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log
+ path #{path_to_logs}
pos_file /var/log/fluentd-containers.log.pos
tag kubernetes.*
read_from_head true
@@ -96,6 +105,13 @@ module Clusters
</source>
EOF
end
+
+ def path_to_logs
+ path = []
+ path << "/var/log/containers/*#{Ingress::MODSECURITY_LOG_CONTAINER_NAME}*.log" if waf_log_enabled
+ path << "/var/log/containers/*#{CILIUM_CONTAINER_NAME}*.log" if cilium_log_enabled
+ path.join(',')
+ end
end
end
end
diff --git a/app/models/clusters/applications/ingress.rb b/app/models/clusters/applications/ingress.rb
index 5985e08d73e..dd354198910 100644
--- a/app/models/clusters/applications/ingress.rb
+++ b/app/models/clusters/applications/ingress.rb
@@ -17,6 +17,7 @@ module Clusters
include ::Clusters::Concerns::ApplicationVersion
include ::Clusters::Concerns::ApplicationData
include AfterCommitQueue
+ include UsageStatistics
default_value_for :ingress_type, :nginx
default_value_for :modsecurity_enabled, true
@@ -29,6 +30,10 @@ module Clusters
enum modsecurity_mode: { logging: 0, blocking: 1 }
+ scope :modsecurity_not_installed, -> { where(modsecurity_enabled: nil) }
+ scope :modsecurity_enabled, -> { where(modsecurity_enabled: true) }
+ scope :modsecurity_disabled, -> { where(modsecurity_enabled: false) }
+
FETCH_IP_ADDRESS_DELAY = 30.seconds
state_machine :status do
@@ -98,7 +103,7 @@ module Clusters
"args" => [
"/bin/sh",
"-c",
- "tail -f /var/log/modsec/audit.log"
+ "tail -F /var/log/modsec/audit.log"
],
"volumeMounts" => [
{
diff --git a/app/models/clusters/applications/jupyter.rb b/app/models/clusters/applications/jupyter.rb
index 42fa4a6f179..056ea355de6 100644
--- a/app/models/clusters/applications/jupyter.rb
+++ b/app/models/clusters/applications/jupyter.rb
@@ -5,7 +5,7 @@ require 'securerandom'
module Clusters
module Applications
class Jupyter < ApplicationRecord
- VERSION = '0.9.0-beta.2'
+ VERSION = '0.9.0'
self.table_name = 'clusters_applications_jupyter'
diff --git a/app/models/clusters/applications/knative.rb b/app/models/clusters/applications/knative.rb
index 1f90318f845..3047da12dd9 100644
--- a/app/models/clusters/applications/knative.rb
+++ b/app/models/clusters/applications/knative.rb
@@ -4,8 +4,8 @@ module Clusters
module Applications
class Knative < ApplicationRecord
VERSION = '0.9.0'
- REPOSITORY = 'https://storage.googleapis.com/triggermesh-charts'
- METRICS_CONFIG = 'https://storage.googleapis.com/triggermesh-charts/istio-metrics.yaml'
+ REPOSITORY = 'https://charts.gitlab.io'
+ METRICS_CONFIG = 'https://gitlab.com/gitlab-org/charts/knative/-/raw/v0.9.0/vendor/istio-metrics.yml'
FETCH_IP_ADDRESS_DELAY = 30.seconds
API_GROUPS_PATH = 'config/knative/api_groups.yml'
diff --git a/app/models/clusters/applications/runner.rb b/app/models/clusters/applications/runner.rb
index 7d67e258991..a861126908f 100644
--- a/app/models/clusters/applications/runner.rb
+++ b/app/models/clusters/applications/runner.rb
@@ -3,7 +3,7 @@
module Clusters
module Applications
class Runner < ApplicationRecord
- VERSION = '0.15.0'
+ VERSION = '0.16.1'
self.table_name = 'clusters_applications_runners'
diff --git a/app/models/clusters/cluster.rb b/app/models/clusters/cluster.rb
index 430a9b3c43e..83f558af1a1 100644
--- a/app/models/clusters/cluster.rb
+++ b/app/models/clusters/cluster.rb
@@ -26,6 +26,8 @@ module Clusters
KUBE_INGRESS_BASE_DOMAIN = 'KUBE_INGRESS_BASE_DOMAIN'
APPLICATIONS_ASSOCIATIONS = APPLICATIONS.values.map(&:association_name).freeze
+ self.reactive_cache_work_type = :external_dependency
+
belongs_to :user
belongs_to :management_project, class_name: '::Project', optional: true
@@ -33,6 +35,7 @@ module Clusters
has_many :projects, through: :cluster_projects, class_name: '::Project'
has_one :cluster_project, -> { order(id: :desc) }, class_name: 'Clusters::Project'
has_many :deployment_clusters
+ has_many :deployments, inverse_of: :cluster
has_many :cluster_groups, class_name: 'Clusters::Group'
has_many :groups, through: :cluster_groups, class_name: '::Group'
@@ -203,10 +206,16 @@ module Clusters
end
end
+ def nodes
+ with_reactive_cache do |data|
+ data[:nodes]
+ end
+ end
+
def calculate_reactive_cache
return unless enabled?
- { connection_status: retrieve_connection_status }
+ { connection_status: retrieve_connection_status, nodes: retrieve_nodes }
end
def persisted_applications
@@ -214,11 +223,19 @@ module Clusters
end
def applications
- APPLICATIONS_ASSOCIATIONS.map do |association_name|
- public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend
+ APPLICATIONS.each_value.map do |application_class|
+ find_or_build_application(application_class)
end
end
+ def find_or_build_application(application_class)
+ raise ArgumentError, "#{application_class} is not in APPLICATIONS" unless APPLICATIONS.value?(application_class)
+
+ association_name = application_class.association_name
+
+ public_send(association_name) || public_send("build_#{association_name}") # rubocop:disable GitlabSecurity/PublicSend
+ end
+
def provider
if gcp?
provider_gcp
@@ -345,32 +362,55 @@ module Clusters
end
def retrieve_connection_status
- kubeclient.core_client.discover
- rescue *Gitlab::Kubernetes::Errors::CONNECTION
- :unreachable
- rescue *Gitlab::Kubernetes::Errors::AUTHENTICATION
- :authentication_failure
- rescue Kubeclient::HttpError => e
- kubeclient_error_status(e.message)
- rescue => e
- Gitlab::ErrorTracking.track_exception(e, cluster_id: id)
-
- :unknown_failure
- else
- :connected
- end
-
- # KubeClient uses the same error class
- # For connection errors (eg. timeout) and
- # for Kubernetes errors.
- def kubeclient_error_status(message)
- if message&.match?(/timed out|timeout/i)
- :unreachable
- else
- :authentication_failure
+ result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.core_client.discover }
+ result[:status]
+ end
+
+ def retrieve_nodes
+ result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.get_nodes }
+ cluster_nodes = result[:response].to_a
+
+ result = ::Gitlab::Kubernetes::KubeClient.graceful_request(id) { kubeclient.metrics_client.get_nodes }
+ nodes_metrics = result[:response].to_a
+
+ cluster_nodes.inject([]) do |memo, node|
+ sliced_node = filter_relevant_node_attributes(node)
+
+ matched_node_metric = nodes_metrics.find { |node_metric| node_metric.metadata.name == node.metadata.name }
+
+ sliced_node_metrics = matched_node_metric ? filter_relevant_node_metrics_attributes(matched_node_metric) : {}
+
+ memo << sliced_node.merge(sliced_node_metrics)
end
end
+ def filter_relevant_node_attributes(node)
+ {
+ 'metadata' => {
+ 'name' => node.metadata.name
+ },
+ 'status' => {
+ 'capacity' => {
+ 'cpu' => node.status.capacity.cpu,
+ 'memory' => node.status.capacity.memory
+ },
+ 'allocatable' => {
+ 'cpu' => node.status.allocatable.cpu,
+ 'memory' => node.status.allocatable.memory
+ }
+ }
+ }
+ end
+
+ def filter_relevant_node_metrics_attributes(node_metrics)
+ {
+ 'usage' => {
+ 'cpu' => node_metrics.usage.cpu,
+ 'memory' => node_metrics.usage.memory
+ }
+ }
+ end
+
# To keep backward compatibility with AUTO_DEVOPS_DOMAIN
# environment variable, we need to ensure KUBE_INGRESS_BASE_DOMAIN
# is set if AUTO_DEVOPS_DOMAIN is set on any of the following options:
diff --git a/app/models/clusters/concerns/application_status.rb b/app/models/clusters/concerns/application_status.rb
index 14237439a8d..0b915126f8a 100644
--- a/app/models/clusters/concerns/application_status.rb
+++ b/app/models/clusters/concerns/application_status.rb
@@ -27,6 +27,7 @@ module Clusters
state :update_errored, value: 6
state :uninstalling, value: 7
state :uninstall_errored, value: 8
+ state :uninstalled, value: 10
# Used for applications that are pre-installed by the cluster,
# e.g. Knative in GCP Cloud Run enabled clusters
@@ -35,6 +36,14 @@ module Clusters
# and no exit transitions.
state :pre_installed, value: 9
+ event :make_externally_installed do
+ transition any => :installed
+ end
+
+ event :make_externally_uninstalled do
+ transition any => :uninstalled
+ end
+
event :make_scheduled do
transition [:installable, :errored, :installed, :updated, :update_errored, :uninstall_errored] => :scheduled
end
diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb
index 046f131b041..7e99f128dad 100644
--- a/app/models/commit_status.rb
+++ b/app/models/commit_status.rb
@@ -7,8 +7,6 @@ class CommitStatus < ApplicationRecord
include Presentable
include EnumWithNil
- prepend_if_ee('::EE::CommitStatus') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
self.table_name = 'ci_builds'
belongs_to :user
@@ -267,8 +265,16 @@ class CommitStatus < ApplicationRecord
end
end
+ def recoverable?
+ failed? && !unrecoverable_failure?
+ end
+
private
+ def unrecoverable_failure?
+ script_failure? || missing_dependency_failure? || archived_failure? || scheduler_failure? || data_integrity_failure?
+ end
+
def schedule_stage_and_pipeline_update
if Feature.enabled?(:ci_atomic_processing, project)
# Atomic Processing requires only single Worker
@@ -284,3 +290,5 @@ class CommitStatus < ApplicationRecord
end
end
end
+
+CommitStatus.prepend_if_ee('::EE::CommitStatus')
diff --git a/app/models/concerns/async_devise_email.rb b/app/models/concerns/async_devise_email.rb
new file mode 100644
index 00000000000..38c99dc7e71
--- /dev/null
+++ b/app/models/concerns/async_devise_email.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+module AsyncDeviseEmail
+ extend ActiveSupport::Concern
+
+ private
+
+ # Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
+ def send_devise_notification(notification, *args)
+ return true unless can?(:receive_notifications)
+
+ devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
+ end
+end
diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb
index 0f2a389f0a3..896f0916d8c 100644
--- a/app/models/concerns/awardable.rb
+++ b/app/models/concerns/awardable.rb
@@ -14,32 +14,29 @@ module Awardable
class_methods do
def awarded(user, name = nil)
- sql = <<~EOL
- EXISTS (
- SELECT TRUE
- FROM award_emoji
- WHERE user_id = :user_id AND
- #{"name = :name AND" if name.present?}
- awardable_type = :awardable_type AND
- awardable_id = #{self.arel_table.name}.id
- )
- EOL
+ award_emoji_table = Arel::Table.new('award_emoji')
+ inner_query = award_emoji_table
+ .project('true')
+ .where(award_emoji_table[:user_id].eq(user.id))
+ .where(award_emoji_table[:awardable_type].eq(self.name))
+ .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id]))
+
+ inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present?
- where(sql, user_id: user.id, name: name, awardable_type: self.name)
+ where(inner_query.exists)
end
- def not_awarded(user)
- sql = <<~EOL
- NOT EXISTS (
- SELECT TRUE
- FROM award_emoji
- WHERE user_id = :user_id AND
- awardable_type = :awardable_type AND
- awardable_id = #{self.arel_table.name}.id
- )
- EOL
+ def not_awarded(user, name = nil)
+ award_emoji_table = Arel::Table.new('award_emoji')
+ inner_query = award_emoji_table
+ .project('true')
+ .where(award_emoji_table[:user_id].eq(user.id))
+ .where(award_emoji_table[:awardable_type].eq(self.name))
+ .where(award_emoji_table[:awardable_id].eq(self.arel_table[:id]))
+
+ inner_query = inner_query.where(award_emoji_table[:name].eq(name)) if name.present?
- where(sql, user_id: user.id, awardable_type: self.name)
+ where(inner_query.exists.not)
end
def order_upvotes_desc
@@ -77,7 +74,7 @@ module Awardable
# By default we always load award_emoji user association
awards = award_emoji.group_by(&:name)
- if with_thumbs
+ if with_thumbs && (!project || project.show_default_award_emojis?)
awards[AwardEmoji::UPVOTE_NAME] ||= []
awards[AwardEmoji::DOWNVOTE_NAME] ||= []
end
diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb
index cc13f279c4d..e4e0f55d5f4 100644
--- a/app/models/concerns/cache_markdown_field.rb
+++ b/app/models/concerns/cache_markdown_field.rb
@@ -161,7 +161,6 @@ module CacheMarkdownField
define_method(invalidation_method) do
changed_fields = changed_attributes.keys
invalidations = changed_fields & [markdown_field.to_s, *INVALIDATED_BY]
- invalidations.delete(markdown_field.to_s) if changed_fields.include?("#{markdown_field}_html")
!invalidations.empty? || !cached_html_up_to_date?(markdown_field)
end
end
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index 5ff537a7837..ccd90ea5900 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -18,6 +18,8 @@ module Ci
variables.concat(deployment_variables(environment: environment))
variables.concat(yaml_variables)
variables.concat(user_variables)
+ variables.concat(dependency_variables) if Feature.enabled?(:ci_dependency_variables, project)
+ variables.concat(secret_instance_variables)
variables.concat(secret_group_variables)
variables.concat(secret_project_variables(environment: environment))
variables.concat(trigger_request.user_variables) if trigger_request
@@ -81,6 +83,12 @@ module Ci
)
end
+ def secret_instance_variables
+ return [] unless ::Feature.enabled?(:ci_instance_level_variables, project, default_enabled: true)
+
+ project.ci_instance_variables_for(ref: git_ref)
+ end
+
def secret_group_variables
return [] unless project.group
diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb
index 6484a3157b1..cea3c7d119c 100644
--- a/app/models/concerns/diff_positionable_note.rb
+++ b/app/models/concerns/diff_positionable_note.rb
@@ -17,12 +17,14 @@ module DiffPositionableNote
%i(original_position position change_position).each do |meth|
define_method "#{meth}=" do |new_position|
if new_position.is_a?(String)
- new_position = JSON.parse(new_position) rescue nil
+ new_position = Gitlab::Json.parse(new_position) rescue nil
end
if new_position.is_a?(Hash)
new_position = new_position.with_indifferent_access
new_position = Gitlab::Diff::Position.new(new_position)
+ elsif !new_position.is_a?(Gitlab::Diff::Position)
+ new_position = nil
end
return if new_position == read_attribute(meth)
diff --git a/app/models/concerns/has_repository.rb b/app/models/concerns/has_repository.rb
index af7afd6604a..29d31b8bb4f 100644
--- a/app/models/concerns/has_repository.rb
+++ b/app/models/concerns/has_repository.rb
@@ -9,7 +9,6 @@
# needs any special behavior.
module HasRepository
extend ActiveSupport::Concern
- include AfterCommitQueue
include Referable
include Gitlab::ShellAdapter
include Gitlab::Utils::StrongMemoize
diff --git a/app/models/concerns/has_user_type.rb b/app/models/concerns/has_user_type.rb
new file mode 100644
index 00000000000..8a238dc736c
--- /dev/null
+++ b/app/models/concerns/has_user_type.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module HasUserType
+ extend ActiveSupport::Concern
+
+ USER_TYPES = {
+ human: nil,
+ support_bot: 1,
+ alert_bot: 2,
+ visual_review_bot: 3,
+ service_user: 4,
+ ghost: 5,
+ project_bot: 6,
+ migration_bot: 7
+ }.with_indifferent_access.freeze
+
+ BOT_USER_TYPES = %w[alert_bot project_bot support_bot visual_review_bot migration_bot].freeze
+ NON_INTERNAL_USER_TYPES = %w[human project_bot service_user].freeze
+ INTERNAL_USER_TYPES = (USER_TYPES.keys - NON_INTERNAL_USER_TYPES).freeze
+
+ included do
+ scope :humans, -> { where(user_type: :human) }
+ scope :bots, -> { where(user_type: BOT_USER_TYPES) }
+ scope :bots_without_project_bot, -> { where(user_type: BOT_USER_TYPES - ['project_bot']) }
+ scope :non_internal, -> { humans.or(where(user_type: NON_INTERNAL_USER_TYPES)) }
+ scope :without_ghosts, -> { humans.or(where.not(user_type: :ghost)) }
+ scope :without_project_bot, -> { humans.or(where.not(user_type: :project_bot)) }
+
+ enum user_type: USER_TYPES
+
+ def human?
+ super || user_type.nil?
+ end
+ end
+
+ def bot?
+ BOT_USER_TYPES.include?(user_type)
+ end
+
+ # The explicit check for project_bot will be removed with Bot Categorization
+ # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
+ def internal?
+ ghost? || (bot? && !project_bot?)
+ end
+end
diff --git a/app/models/concerns/has_wiki.rb b/app/models/concerns/has_wiki.rb
new file mode 100644
index 00000000000..4dd72216e77
--- /dev/null
+++ b/app/models/concerns/has_wiki.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+module HasWiki
+ extend ActiveSupport::Concern
+
+ included do
+ validate :check_wiki_path_conflict
+ end
+
+ def create_wiki
+ wiki.wiki
+ true
+ rescue Wiki::CouldNotCreateWikiError
+ errors.add(:base, _('Failed to create wiki'))
+ false
+ end
+
+ def wiki
+ strong_memoize(:wiki) do
+ Wiki.for_container(self, self.owner)
+ end
+ end
+
+ def wiki_repository_exists?
+ wiki.repository_exists?
+ end
+
+ def after_wiki_activity
+ true
+ end
+
+ private
+
+ def check_wiki_path_conflict
+ return if path.blank?
+
+ path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki"
+
+ if Project.in_namespace(parent_id).where(path: path_to_check).exists? ||
+ GroupsFinder.new(nil, parent: parent_id).execute.where(path: path_to_check).exists?
+ errors.add(:name, _('has already been taken'))
+ end
+ end
+end
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 37f2209b9d2..a1b14dca4ac 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -115,9 +115,31 @@ module Issuable
end
# rubocop:enable GitlabSecurity/SqlInjection
+ scope :not_assigned_to, ->(users) do
+ assignees_table = Arel::Table.new("#{to_ability_name}_assignees")
+ sql = assignees_table.project('true')
+ .where(assignees_table[:user_id].in(users))
+ .where(Arel::Nodes::SqlLiteral.new("#{to_ability_name}_id = #{to_ability_name}s.id"))
+ where(sql.exists.not)
+ end
+
+ scope :without_particular_labels, ->(label_names) do
+ labels_table = Label.arel_table
+ label_links_table = LabelLink.arel_table
+ issuables_table = klass.arel_table
+ inner_query = label_links_table.project('true')
+ .join(labels_table, Arel::Nodes::InnerJoin).on(labels_table[:id].eq(label_links_table[:label_id]))
+ .where(label_links_table[:target_type].eq(name)
+ .and(label_links_table[:target_id].eq(issuables_table[:id]))
+ .and(labels_table[:title].in(label_names)))
+ .exists.not
+
+ where(inner_query)
+ end
+
scope :without_label, -> { joins("LEFT OUTER JOIN label_links ON label_links.target_type = '#{name}' AND label_links.target_id = #{table_name}.id").where(label_links: { id: nil }) }
scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) }
- scope :any_label, -> { joins(:label_links).group(:id) }
+ scope :any_label, -> { joins(:label_links).distinct }
scope :join_project, -> { joins(:project) }
scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
scope :references_project, -> { references(:project) }
@@ -286,9 +308,8 @@ module Issuable
.reorder(Gitlab::Database.nulls_last_order('highest_priority', direction))
end
- def with_label(title, sort = nil, not_query: false)
- multiple_labels = title.is_a?(Array) && title.size > 1
- if multiple_labels && !not_query
+ def with_label(title, sort = nil)
+ if title.is_a?(Array) && title.size > 1
joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}")
else
joins(:labels).where(labels: { title: title })
diff --git a/app/models/concerns/issue_resource_event.rb b/app/models/concerns/issue_resource_event.rb
new file mode 100644
index 00000000000..1c24032dbbb
--- /dev/null
+++ b/app/models/concerns/issue_resource_event.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module IssueResourceEvent
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :issue
+
+ 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) }
+ end
+end
diff --git a/app/models/concerns/limitable.rb b/app/models/concerns/limitable.rb
new file mode 100644
index 00000000000..f320f54bb82
--- /dev/null
+++ b/app/models/concerns/limitable.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Limitable
+ extend ActiveSupport::Concern
+
+ included do
+ class_attribute :limit_scope
+ class_attribute :limit_name
+ self.limit_name = self.name.demodulize.tableize
+
+ validate :validate_plan_limit_not_exceeded, on: :create
+ end
+
+ private
+
+ def validate_plan_limit_not_exceeded
+ scope_relation = self.public_send(limit_scope) # rubocop:disable GitlabSecurity/PublicSend
+ return unless scope_relation
+
+ relation = self.class.where(limit_scope => scope_relation)
+
+ if scope_relation.actual_limits.exceeded?(limit_name, relation)
+ errors.add(:base, _("Maximum number of %{name} (%{count}) exceeded") %
+ { name: limit_name.humanize(capitalize: false), count: scope_relation.actual_limits.public_send(limit_name) }) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+end
diff --git a/app/models/concerns/merge_request_resource_event.rb b/app/models/concerns/merge_request_resource_event.rb
new file mode 100644
index 00000000000..7fb7fb4ec62
--- /dev/null
+++ b/app/models/concerns/merge_request_resource_event.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+module MergeRequestResourceEvent
+ extend ActiveSupport::Concern
+
+ included do
+ belongs_to :merge_request
+
+ scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
+ end
+end
diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb
index 3ffb32f94fc..8f8494a9678 100644
--- a/app/models/concerns/milestoneable.rb
+++ b/app/models/concerns/milestoneable.rb
@@ -17,8 +17,10 @@ module Milestoneable
scope :of_milestones, ->(ids) { where(milestone_id: ids) }
scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
+ scope :without_particular_milestone, ->(title) { left_outer_joins(:milestone).where("milestones.title != ? OR milestone_id IS NULL", title) }
scope :any_release, -> { joins_milestone_releases }
scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) }
+ scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not( milestones: { releases: { tag: tag, project_id: project_id } } ) }
scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
scope :order_milestone_due_desc, -> { left_joins_milestones.reorder(Arel.sql('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC')) }
diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb
index a7f1fb66a88..933a0b167e2 100644
--- a/app/models/concerns/noteable.rb
+++ b/app/models/concerns/noteable.rb
@@ -17,7 +17,7 @@ module Noteable
# `Noteable` class names that support resolvable notes.
def resolvable_types
- %w(MergeRequest)
+ %w(MergeRequest DesignManagement::Design)
end
end
@@ -138,15 +138,25 @@ module Noteable
end
def note_etag_key
+ return Gitlab::Routing.url_helpers.designs_project_issue_path(project, issue, { vueroute: filename }) if self.is_a?(DesignManagement::Design)
+
Gitlab::Routing.url_helpers.project_noteable_notes_path(
project,
target_type: self.class.name.underscore,
target_id: id
)
end
+
+ def after_note_created(_note)
+ # no-op
+ end
+
+ def after_note_destroyed(_note)
+ # no-op
+ end
end
Noteable.extend(Noteable::ClassMethods)
-Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods') # rubocop: disable Cop/InjectEnterpriseEditionModule
+Noteable::ClassMethods.prepend_if_ee('EE::Noteable::ClassMethods')
Noteable.prepend_if_ee('EE::Noteable')
diff --git a/app/models/concerns/prometheus_adapter.rb b/app/models/concerns/prometheus_adapter.rb
index abc41a1c476..761a151a474 100644
--- a/app/models/concerns/prometheus_adapter.rb
+++ b/app/models/concerns/prometheus_adapter.rb
@@ -9,6 +9,7 @@ module PrometheusAdapter
self.reactive_cache_lease_timeout = 30.seconds
self.reactive_cache_refresh_interval = 30.seconds
self.reactive_cache_lifetime = 1.minute
+ self.reactive_cache_work_type = :external_dependency
def prometheus_client
raise NotImplementedError
diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb
index 7373f006d64..d1e3d9b2aff 100644
--- a/app/models/concerns/protected_ref_access.rb
+++ b/app/models/concerns/protected_ref_access.rb
@@ -50,8 +50,8 @@ module ProtectedRefAccess
end
end
-ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes') # rubocop: disable Cop/InjectEnterpriseEditionModule
-ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess') # rubocop: disable Cop/InjectEnterpriseEditionModule
+ProtectedRefAccess.include_if_ee('EE::ProtectedRefAccess::Scopes')
+ProtectedRefAccess.prepend_if_ee('EE::ProtectedRefAccess')
# When using `prepend` (or `include` for that matter), the `ClassMethods`
# constants are not merged. This means that `class_methods` in
diff --git a/app/models/concerns/reactive_caching.rb b/app/models/concerns/reactive_caching.rb
index 4b472cfdf45..d294563139c 100644
--- a/app/models/concerns/reactive_caching.rb
+++ b/app/models/concerns/reactive_caching.rb
@@ -8,6 +8,11 @@ module ReactiveCaching
InvalidateReactiveCache = Class.new(StandardError)
ExceededReactiveCacheLimit = Class.new(StandardError)
+ WORK_TYPE = {
+ default: ReactiveCachingWorker,
+ external_dependency: ExternalServiceReactiveCachingWorker
+ }.freeze
+
included do
extend ActiveModel::Naming
@@ -16,6 +21,7 @@ module ReactiveCaching
class_attribute :reactive_cache_refresh_interval
class_attribute :reactive_cache_lifetime
class_attribute :reactive_cache_hard_limit
+ class_attribute :reactive_cache_work_type
class_attribute :reactive_cache_worker_finder
# defaults
@@ -24,6 +30,7 @@ module ReactiveCaching
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 10.minutes
self.reactive_cache_hard_limit = 1.megabyte
+ self.reactive_cache_work_type = :default
self.reactive_cache_worker_finder = ->(id, *_args) do
find_by(primary_key => id)
end
@@ -112,7 +119,7 @@ module ReactiveCaching
def refresh_reactive_cache!(*args)
clear_reactive_cache!(*args)
keep_alive_reactive_cache!(*args)
- ReactiveCachingWorker.perform_async(self.class, id, *args)
+ worker_class.perform_async(self.class, id, *args)
end
def keep_alive_reactive_cache!(*args)
@@ -145,7 +152,11 @@ module ReactiveCaching
def enqueuing_update(*args)
yield
- ReactiveCachingWorker.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args)
+ worker_class.perform_in(self.class.reactive_cache_refresh_interval, self.class, id, *args)
+ end
+
+ def worker_class
+ WORK_TYPE.fetch(self.class.reactive_cache_work_type.to_sym)
end
def check_exceeded_reactive_cache_limit!(data)
diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb
index 4bb4ffe2a8e..2d4ed51ce3b 100644
--- a/app/models/concerns/redis_cacheable.rb
+++ b/app/models/concerns/redis_cacheable.rb
@@ -26,7 +26,7 @@ module RedisCacheable
end
def cache_attributes(values)
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME)
end
@@ -41,9 +41,9 @@ module RedisCacheable
def cached_attributes
strong_memoize(:cached_attributes) do
- Gitlab::Redis::SharedState.with do |redis|
+ Gitlab::Redis::Cache.with do |redis|
data = redis.get(cache_attribute_key)
- JSON.parse(data, symbolize_names: true) if data
+ Gitlab::Json.parse(data, symbolize_names: true) if data
end
end
end
diff --git a/app/models/concerns/spammable.rb b/app/models/concerns/spammable.rb
index 4fbb5dcb649..9cd1a22b203 100644
--- a/app/models/concerns/spammable.rb
+++ b/app/models/concerns/spammable.rb
@@ -13,9 +13,13 @@ module Spammable
has_one :user_agent_detail, as: :subject, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
attr_accessor :spam
+ attr_accessor :needs_recaptcha
attr_accessor :spam_log
+
alias_method :spam?, :spam
+ alias_method :needs_recaptcha?, :needs_recaptcha
+ # if spam errors are added before validation, they will be wiped
after_validation :invalidate_if_spam, on: [:create, :update]
cattr_accessor :spammable_attrs, instance_accessor: false do
@@ -38,24 +42,35 @@ module Spammable
end
def needs_recaptcha!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam. "\
- "Please, change the content or solve the reCAPTCHA to proceed.")
+ self.needs_recaptcha = true
end
- def unrecoverable_spam_error!
- self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
+ def spam!
+ self.spam = true
end
- def invalidate_if_spam
- return unless spam?
+ def clear_spam_flags!
+ self.spam = false
+ self.needs_recaptcha = false
+ end
- if Gitlab::Recaptcha.enabled?
- needs_recaptcha!
- else
+ def invalidate_if_spam
+ if needs_recaptcha? && Gitlab::Recaptcha.enabled?
+ recaptcha_error!
+ elsif needs_recaptcha? || spam?
unrecoverable_spam_error!
end
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.")
+ end
+
+ def unrecoverable_spam_error!
+ self.errors.add(:base, "Your #{spammable_entity_type} has been recognized as spam and has been discarded.")
+ end
+
def spammable_entity_type
self.class.name.underscore
end
diff --git a/app/models/concerns/state_eventable.rb b/app/models/concerns/state_eventable.rb
new file mode 100644
index 00000000000..68129798543
--- /dev/null
+++ b/app/models/concerns/state_eventable.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+module StateEventable
+ extend ActiveSupport::Concern
+
+ included do
+ has_many :resource_state_events
+ end
+end
diff --git a/app/models/concerns/storage/legacy_project_wiki.rb b/app/models/concerns/storage/legacy_project_wiki.rb
deleted file mode 100644
index a377fa1e5de..00000000000
--- a/app/models/concerns/storage/legacy_project_wiki.rb
+++ /dev/null
@@ -1,11 +0,0 @@
-# frozen_string_literal: true
-
-module Storage
- module LegacyProjectWiki
- extend ActiveSupport::Concern
-
- def disk_path
- project.disk_path + '.wiki'
- end
- end
-end
diff --git a/app/models/concerns/timebox.rb b/app/models/concerns/timebox.rb
new file mode 100644
index 00000000000..d29e6a01c56
--- /dev/null
+++ b/app/models/concerns/timebox.rb
@@ -0,0 +1,204 @@
+# frozen_string_literal: true
+
+module Timebox
+ extend ActiveSupport::Concern
+
+ include AtomicInternalId
+ include CacheMarkdownField
+ include Gitlab::SQL::Pattern
+ include IidRoutes
+ include StripAttribute
+
+ TimeboxStruct = Struct.new(:title, :name, :id) do
+ # Ensure these models match the interface required for exporting
+ def serializable_hash(_opts = {})
+ { title: title, name: name, id: id }
+ end
+ end
+
+ # Represents a "No Timebox" state used for filtering Issues and Merge
+ # Requests that have no timeboxes assigned.
+ None = TimeboxStruct.new('No Timebox', 'No Timebox', 0)
+ Any = TimeboxStruct.new('Any Timebox', '', -1)
+ Upcoming = TimeboxStruct.new('Upcoming', '#upcoming', -2)
+ Started = TimeboxStruct.new('Started', '#started', -3)
+
+ included do
+ # Defines the same constants above, but inside the including class.
+ const_set :None, TimeboxStruct.new("No #{self.name}", "No #{self.name}", 0)
+ const_set :Any, TimeboxStruct.new("Any #{self.name}", '', -1)
+ const_set :Upcoming, TimeboxStruct.new('Upcoming', '#upcoming', -2)
+ const_set :Started, TimeboxStruct.new('Started', '#started', -3)
+
+ alias_method :timebox_id, :id
+
+ validates :group, presence: true, unless: :project
+ validates :project, presence: true, unless: :group
+ validates :title, presence: true
+
+ validate :uniqueness_of_title, if: :title_changed?
+ validate :timebox_type_check
+ validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
+ validate :dates_within_4_digits
+
+ cache_markdown_field :title, pipeline: :single_line
+ cache_markdown_field :description
+
+ belongs_to :project
+ belongs_to :group
+
+ has_many :issues
+ has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
+ has_many :merge_requests
+
+ scope :of_projects, ->(ids) { where(project_id: ids) }
+ scope :of_groups, ->(ids) { where(group_id: ids) }
+ scope :closed, -> { with_state(:closed) }
+ scope :for_projects, -> { where(group: nil).includes(:project) }
+ scope :with_title, -> (title) { where(title: title) }
+
+ scope :for_projects_and_groups, -> (projects, groups) do
+ projects = projects.compact if projects.is_a? Array
+ projects = [] if projects.nil?
+
+ groups = groups.compact if groups.is_a? Array
+ groups = [] if groups.nil?
+
+ where(project_id: projects).or(where(group_id: groups))
+ end
+
+ scope :within_timeframe, -> (start_date, end_date) do
+ where('start_date is not NULL or due_date is not NULL')
+ .where('start_date is NULL or start_date <= ?', end_date)
+ .where('due_date is NULL or due_date >= ?', start_date)
+ end
+
+ strip_attributes :title
+
+ alias_attribute :name, :title
+ end
+
+ class_methods do
+ # Searches for timeboxes with a matching title or description.
+ #
+ # This method uses ILIKE on PostgreSQL
+ #
+ # query - The search query as a String
+ #
+ # Returns an ActiveRecord::Relation.
+ def search(query)
+ fuzzy_search(query, [:title, :description])
+ end
+
+ # Searches for timeboxes with a matching title.
+ #
+ # This method uses ILIKE on PostgreSQL
+ #
+ # query - The search query as a String
+ #
+ # Returns an ActiveRecord::Relation.
+ def search_title(query)
+ fuzzy_search(query, [:title])
+ end
+
+ def filter_by_state(timeboxes, state)
+ case state
+ when 'closed' then timeboxes.closed
+ when 'all' then timeboxes
+ else timeboxes.active
+ end
+ end
+
+ def count_by_state
+ reorder(nil).group(:state).count
+ end
+
+ def predefined_id?(id)
+ [Any.id, None.id, Upcoming.id, Started.id].include?(id)
+ end
+
+ def predefined?(timebox)
+ predefined_id?(timebox&.id)
+ end
+ end
+
+ def title=(value)
+ write_attribute(:title, sanitize_title(value)) if value.present?
+ end
+
+ def timebox_name
+ model_name.singular
+ end
+
+ def group_timebox?
+ group_id.present?
+ end
+
+ def project_timebox?
+ project_id.present?
+ end
+
+ def safe_title
+ title.to_slug.normalize.to_s
+ end
+
+ def resource_parent
+ group || project
+ end
+
+ def to_ability_name
+ model_name.singular
+ end
+
+ def merge_requests_enabled?
+ if group_timebox?
+ # Assume that groups have at least one project with merge requests enabled.
+ # Otherwise, we would need to load all of the projects from the database.
+ true
+ elsif project_timebox?
+ project&.merge_requests_enabled?
+ end
+ end
+
+ private
+
+ # Timebox titles must be unique across project and group timeboxes
+ def uniqueness_of_title
+ if project
+ relation = self.class.for_projects_and_groups([project_id], [project.group&.id])
+ elsif group
+ relation = self.class.for_projects_and_groups(group.projects.select(:id), [group.id])
+ end
+
+ title_exists = relation.find_by_title(title)
+ errors.add(:title, _("already being used for another group or project %{timebox_name}.") % { timebox_name: timebox_name }) if title_exists
+ end
+
+ # Timebox should be either a project timebox or a group timebox
+ def timebox_type_check
+ if group_id && project_id
+ field = project_id_changed? ? :project_id : :group_id
+ errors.add(field, _("%{timebox_name} should belong either to a project or a group.") % { timebox_name: timebox_name })
+ end
+ end
+
+ def start_date_should_be_less_than_due_date
+ if due_date <= start_date
+ errors.add(:due_date, _("must be greater than start date"))
+ end
+ end
+
+ def dates_within_4_digits
+ if start_date && start_date > Date.new(9999, 12, 31)
+ errors.add(:start_date, _("date must not be after 9999-12-31"))
+ end
+
+ if due_date && due_date > Date.new(9999, 12, 31)
+ errors.add(:due_date, _("date must not be after 9999-12-31"))
+ end
+ end
+
+ def sanitize_title(value)
+ CGI.unescape_html(Sanitize.clean(value.to_s))
+ end
+end
diff --git a/app/models/concerns/update_project_statistics.rb b/app/models/concerns/update_project_statistics.rb
index a84fb1cf56d..6cf012680d8 100644
--- a/app/models/concerns/update_project_statistics.rb
+++ b/app/models/concerns/update_project_statistics.rb
@@ -68,21 +68,11 @@ module UpdateProjectStatistics
def schedule_update_project_statistic(delta)
return if delta.zero?
+ return if project.nil?
- if Feature.enabled?(:update_project_statistics_after_commit, default_enabled: true)
- # Update ProjectStatistics after the transaction
- run_after_commit do
- ProjectStatistics.increment_statistic(
- project_id, self.class.project_statistics_name, delta)
- end
- else
- # Use legacy-way to update within transaction
+ run_after_commit do
ProjectStatistics.increment_statistic(
project_id, self.class.project_statistics_name, delta)
- end
-
- run_after_commit do
- next if project.nil?
Namespaces::ScheduleAggregationWorker.perform_async(
project.namespace_id)
diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb
index 3bff7cb06c1..455c672cea3 100644
--- a/app/models/container_repository.rb
+++ b/app/models/container_repository.rb
@@ -2,6 +2,7 @@
class ContainerRepository < ApplicationRecord
include Gitlab::Utils::StrongMemoize
+ include Gitlab::SQL::Pattern
belongs_to :project
@@ -17,6 +18,7 @@ class ContainerRepository < ApplicationRecord
scope :for_group_and_its_subgroups, ->(group) do
where(project_id: Project.for_group_and_its_subgroups(group).with_container_registry.select(:id))
end
+ scope :search_by_name, ->(query) { fuzzy_search(query, [:name], use_minimum_char_limit: false) }
def self.exists_by_path?(path)
where(
diff --git a/app/models/cycle_analytics/group_level.rb b/app/models/cycle_analytics/group_level.rb
deleted file mode 100644
index a41e1375484..00000000000
--- a/app/models/cycle_analytics/group_level.rb
+++ /dev/null
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-module CycleAnalytics
- class GroupLevel
- include LevelBase
- attr_reader :options, :group
-
- def initialize(group:, options:)
- @group = group
- @options = options.merge(group: group)
- end
-
- def summary
- @summary ||= ::Gitlab::CycleAnalytics::GroupStageSummary.new(group, options: options).data
- end
-
- def permissions(*)
- STAGES.each_with_object({}) do |stage, obj|
- obj[stage] = true
- end
- end
-
- def stats
- @stats ||= STAGES.map do |stage_name|
- self[stage_name].as_json(serializer: GroupAnalyticsStageSerializer)
- end
- end
- end
-end
diff --git a/app/models/deploy_token.rb b/app/models/deploy_token.rb
index 69245710f01..395260b5201 100644
--- a/app/models/deploy_token.rb
+++ b/app/models/deploy_token.rb
@@ -7,7 +7,8 @@ class DeployToken < ApplicationRecord
include Gitlab::Utils::StrongMemoize
add_authentication_token_field :token, encrypted: :optional
- AVAILABLE_SCOPES = %i(read_repository read_registry write_registry).freeze
+ AVAILABLE_SCOPES = %i(read_repository read_registry write_registry
+ read_package_registry write_package_registry).freeze
GITLAB_DEPLOY_TOKEN_NAME = 'gitlab-deploy-token'
default_value_for(:expires_at) { Forever.date }
@@ -105,7 +106,7 @@ class DeployToken < ApplicationRecord
end
def ensure_at_least_one_scope
- errors.add(:base, _("Scopes can't be blank")) unless read_repository || read_registry || write_registry
+ errors.add(:base, _("Scopes can't be blank")) unless scopes.any?
end
def default_username
diff --git a/app/models/design_management.rb b/app/models/design_management.rb
new file mode 100644
index 00000000000..81e170f7e59
--- /dev/null
+++ b/app/models/design_management.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ DESIGN_IMAGE_SIZES = %w(v432x230).freeze
+
+ def self.designs_directory
+ 'designs'
+ end
+
+ def self.table_name_prefix
+ 'design_management_'
+ end
+end
diff --git a/app/models/design_management/action.rb b/app/models/design_management/action.rb
new file mode 100644
index 00000000000..ecd7973a523
--- /dev/null
+++ b/app/models/design_management/action.rb
@@ -0,0 +1,44 @@
+# frozen_string_literal: true
+
+require_dependency 'design_management'
+
+module DesignManagement
+ class Action < ApplicationRecord
+ include WithUploads
+
+ self.table_name = "#{DesignManagement.table_name_prefix}designs_versions"
+
+ mount_uploader :image_v432x230, DesignManagement::DesignV432x230Uploader
+
+ belongs_to :design, class_name: "DesignManagement::Design", inverse_of: :actions
+ belongs_to :version, class_name: "DesignManagement::Version", inverse_of: :actions
+
+ enum event: { creation: 0, modification: 1, deletion: 2 }
+
+ # we assume sequential ordering.
+ scope :ordered, -> { order(version_id: :asc) }
+
+ # For each design, only select the most recent action
+ scope :most_recent, -> do
+ selection = Arel.sql("DISTINCT ON (#{table_name}.design_id) #{table_name}.*")
+
+ order(arel_table[:design_id].asc, arel_table[:version_id].desc).select(selection)
+ end
+
+ # Find all records created before or at the given version, or all if nil
+ scope :up_to_version, ->(version = nil) do
+ case version
+ when nil
+ all
+ when DesignManagement::Version
+ where(arel_table[:version_id].lteq(version.id))
+ when ::Gitlab::Git::COMMIT_ID
+ versions = DesignManagement::Version.arel_table
+ subquery = versions.project(versions[:id]).where(versions[:sha].eq(version))
+ where(arel_table[:version_id].lteq(subquery))
+ else
+ raise ArgumentError, "Expected a DesignManagement::Version, got #{version}"
+ end
+ end
+ end
+end
diff --git a/app/models/design_management/design.rb b/app/models/design_management/design.rb
new file mode 100644
index 00000000000..e9b69eab7a7
--- /dev/null
+++ b/app/models/design_management/design.rb
@@ -0,0 +1,266 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Design < ApplicationRecord
+ include Importable
+ include Noteable
+ include Gitlab::FileTypeDetection
+ include Gitlab::Utils::StrongMemoize
+ include Referable
+ include Mentionable
+ include WhereComposite
+
+ belongs_to :project, inverse_of: :designs
+ belongs_to :issue
+
+ has_many :actions
+ has_many :versions, through: :actions, class_name: 'DesignManagement::Version', inverse_of: :designs
+ # This is a polymorphic association, so we can't count on FK's to delete the
+ # data
+ has_many :notes, as: :noteable, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+ has_many :user_mentions, class_name: 'DesignUserMention', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
+
+ validates :project, :filename, presence: true
+ validates :issue, presence: true, unless: :importing?
+ validates :filename, uniqueness: { scope: :issue_id }
+ validate :validate_file_is_image
+
+ alias_attribute :title, :filename
+
+ # Pre-fetching scope to include the data necessary to construct a
+ # reference using `to_reference`.
+ scope :for_reference, -> { includes(issue: [{ project: [:route, :namespace] }]) }
+
+ # A design can be uniquely identified by issue_id and filename
+ # Takes one or more sets of composite IDs of the form:
+ # `{issue_id: Integer, filename: String}`.
+ #
+ # @see WhereComposite::where_composite
+ #
+ # e.g:
+ #
+ # by_issue_id_and_filename(issue_id: 1, filename: 'homescreen.jpg')
+ # by_issue_id_and_filename([]) # returns ActiveRecord::NullRelation
+ # by_issue_id_and_filename([
+ # { issue_id: 1, filename: 'homescreen.jpg' },
+ # { issue_id: 2, filename: 'homescreen.jpg' },
+ # { issue_id: 1, filename: 'menu.png' }
+ # ])
+ #
+ scope :by_issue_id_and_filename, ->(composites) do
+ where_composite(%i[issue_id filename], composites)
+ end
+
+ # Find designs visible at the given version
+ #
+ # @param version [nil, DesignManagement::Version]:
+ # the version at which the designs must be visible
+ # Passing `nil` is the same as passing the most current version
+ #
+ # Restricts to designs
+ # - created at least *before* the given version
+ # - not deleted as of the given version.
+ #
+ # As a query, we ascertain this by finding the last event prior to
+ # (or equal to) the cut-off, and seeing whether that version was a deletion.
+ scope :visible_at_version, -> (version) do
+ deletion = ::DesignManagement::Action.events[:deletion]
+ designs = arel_table
+ actions = ::DesignManagement::Action
+ .most_recent.up_to_version(version)
+ .arel.as('most_recent_actions')
+
+ join = designs.join(actions)
+ .on(actions[:design_id].eq(designs[:id]))
+
+ joins(join.join_sources).where(actions[:event].not_eq(deletion)).order(:id)
+ end
+
+ scope :with_filename, -> (filenames) { where(filename: filenames) }
+ scope :on_issue, ->(issue) { where(issue_id: issue) }
+
+ # Scope called by our REST API to avoid N+1 problems
+ scope :with_api_entity_associations, -> { preload(:issue) }
+
+ # A design is current if the most recent event is not a deletion
+ scope :current, -> { visible_at_version(nil) }
+
+ def status
+ if new_design?
+ :new
+ elsif deleted?
+ :deleted
+ else
+ :current
+ end
+ end
+
+ def deleted?
+ most_recent_action&.deletion?
+ end
+
+ # A design is visible_in? a version if:
+ # * it was created before that version
+ # * the most recent action before the version was not a deletion
+ def visible_in?(version)
+ map = strong_memoize(:visible_in) do
+ Hash.new do |h, k|
+ h[k] = self.class.visible_at_version(k).where(id: id).exists?
+ end
+ end
+
+ map[version]
+ end
+
+ def most_recent_action
+ strong_memoize(:most_recent_action) { actions.ordered.last }
+ end
+
+ # A reference for a design is the issue reference, indexed by the filename
+ # with an optional infix when full.
+ #
+ # e.g.
+ # #123[homescreen.png]
+ # other-project#72[sidebar.jpg]
+ # #38/designs[transition.gif]
+ # #12["filename with [] in it.jpg"]
+ def to_reference(from = nil, full: false)
+ infix = full ? '/designs' : ''
+ totally_simple = %r{ \A #{self.class.simple_file_name} \z }x
+ safe_name = if totally_simple.match?(filename)
+ filename
+ elsif filename =~ /[<>]/
+ %Q{base64:#{Base64.strict_encode64(filename)}}
+ else
+ escaped = filename.gsub(%r{[\\"]}) { |x| "\\#{x}" }
+ %Q{"#{escaped}"}
+ end
+
+ "#{issue.to_reference(from, full: full)}#{infix}[#{safe_name}]"
+ end
+
+ def self.reference_pattern
+ @reference_pattern ||= begin
+ # Filenames can be escaped with double quotes to name filenames
+ # that include square brackets, or other special characters
+ %r{
+ #{Issue.reference_pattern}
+ (\/designs)?
+ \[
+ (?<design> #{simple_file_name} | #{quoted_file_name} | #{base_64_encoded_name})
+ \]
+ }x
+ end
+ end
+
+ def self.simple_file_name
+ %r{
+ (?<simple_file_name>
+ ( \w | [_:,'-] | \. | \s )+
+ \.
+ \w+
+ )
+ }x
+ end
+
+ def self.base_64_encoded_name
+ %r{
+ base64:
+ (?<base_64_encoded_name>
+ [A-Za-z0-9+\n]+
+ =?
+ )
+ }x
+ end
+
+ def self.quoted_file_name
+ %r{
+ "
+ (?<escaped_filename>
+ (\\ \\ | \\ " | [^"\\])+
+ )
+ "
+ }x
+ end
+
+ def self.link_reference_pattern
+ @link_reference_pattern ||= begin
+ exts = SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT
+ path_segment = %r{issues/#{Gitlab::Regex.issue}/designs}
+ filename_pattern = %r{(?<simple_file_name>[a-z0-9_=-]+\.(#{exts.join('|')}))}i
+
+ super(path_segment, filename_pattern)
+ end
+ end
+
+ def to_ability_name
+ 'design'
+ end
+
+ def description
+ ''
+ end
+
+ def new_design?
+ strong_memoize(:new_design) { actions.none? }
+ end
+
+ def full_path
+ @full_path ||= File.join(DesignManagement.designs_directory, "issue-#{issue.iid}", filename)
+ end
+
+ def diff_refs
+ strong_memoize(:diff_refs) { head_version&.diff_refs }
+ end
+
+ def clear_version_cache
+ [versions, actions].each(&:reset)
+ %i[new_design diff_refs head_sha visible_in most_recent_action].each do |key|
+ clear_memoization(key)
+ end
+ end
+
+ def repository
+ project.design_repository
+ end
+
+ def user_notes_count
+ user_notes_count_service.count
+ end
+
+ def after_note_changed(note)
+ user_notes_count_service.delete_cache unless note.system?
+ end
+ alias_method :after_note_created, :after_note_changed
+ alias_method :after_note_destroyed, :after_note_changed
+
+ private
+
+ def head_version
+ strong_memoize(:head_sha) { versions.ordered.first }
+ end
+
+ def allow_dangerous_images?
+ Feature.enabled?(:design_management_allow_dangerous_images, project)
+ end
+
+ def valid_file_extensions
+ allow_dangerous_images? ? (SAFE_IMAGE_EXT + DANGEROUS_IMAGE_EXT) : SAFE_IMAGE_EXT
+ end
+
+ def validate_file_is_image
+ unless image? || (dangerous_image? && allow_dangerous_images?)
+ message = _('does not have a supported extension. Only %{extension_list} are supported') % {
+ extension_list: valid_file_extensions.to_sentence
+ }
+ errors.add(:filename, message)
+ end
+ end
+
+ def user_notes_count_service
+ strong_memoize(:user_notes_count_service) do
+ ::DesignManagement::DesignUserNotesCountService.new(self) # rubocop: disable CodeReuse/ServiceClass
+ end
+ end
+ end
+end
diff --git a/app/models/design_management/design_action.rb b/app/models/design_management/design_action.rb
new file mode 100644
index 00000000000..22baa916296
--- /dev/null
+++ b/app/models/design_management/design_action.rb
@@ -0,0 +1,64 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ # Parameter object which is a tuple of the database record and the
+ # last gitaly call made to change it. This serves to perform the
+ # logical mapping from git action to database representation.
+ class DesignAction
+ include ActiveModel::Validations
+
+ EVENT_FOR_GITALY_ACTION = {
+ create: DesignManagement::Action.events[:creation],
+ update: DesignManagement::Action.events[:modification],
+ delete: DesignManagement::Action.events[:deletion]
+ }.freeze
+
+ attr_reader :design, :action, :content
+
+ delegate :issue_id, to: :design
+
+ validates :design, presence: true
+ validates :action, presence: true, inclusion: { in: EVENT_FOR_GITALY_ACTION.keys }
+ validates :content,
+ absence: { if: :forbids_content?,
+ message: 'this action forbids content' },
+ presence: { if: :needs_content?,
+ message: 'this action needs content' }
+
+ # Parameters:
+ # - design [DesignManagement::Design]: the design that was changed
+ # - action [Symbol]: the action that gitaly performed
+ def initialize(design, action, content = nil)
+ @design, @action, @content = design, action, content
+ validate!
+ end
+
+ def row_attrs(version)
+ { design_id: design.id, version_id: version.id, event: event }
+ end
+
+ def gitaly_action
+ { action: action, file_path: design.full_path, content: content }.compact
+ end
+
+ # This action has been performed - do any post-creation actions
+ # such as clearing method caches.
+ def performed
+ design.clear_version_cache
+ end
+
+ private
+
+ def needs_content?
+ action != :delete
+ end
+
+ def forbids_content?
+ action == :delete
+ end
+
+ def event
+ EVENT_FOR_GITALY_ACTION[action]
+ end
+ end
+end
diff --git a/app/models/design_management/design_at_version.rb b/app/models/design_management/design_at_version.rb
new file mode 100644
index 00000000000..b4cafb93c2c
--- /dev/null
+++ b/app/models/design_management/design_at_version.rb
@@ -0,0 +1,119 @@
+# frozen_string_literal: true
+
+# Tuple of design and version
+# * has a composite ID, with lazy_find
+module DesignManagement
+ class DesignAtVersion
+ include ActiveModel::Validations
+ include GlobalID::Identification
+ include Gitlab::Utils::StrongMemoize
+
+ attr_reader :version
+ attr_reader :design
+
+ validates :version, presence: true
+ validates :design, presence: true
+
+ validate :design_and_version_belong_to_the_same_issue
+ validate :design_and_version_have_issue_id
+
+ def initialize(design: nil, version: nil)
+ @design, @version = design, version
+ end
+
+ def self.instantiate(attrs)
+ new(attrs).tap { |obj| obj.validate! }
+ end
+
+ # The ID, needed by GraphQL types and as part of the Lazy-fetch
+ # protocol, includes information about both the design and the version.
+ #
+ # The particular format is not interesting, and should be treated as opaque
+ # by all callers.
+ def id
+ "#{design.id}.#{version.id}"
+ end
+
+ def ==(other)
+ return false unless other && self.class == other.class
+
+ other.id == id
+ end
+
+ alias_method :eql?, :==
+
+ def self.lazy_find(id)
+ BatchLoader.for(id).batch do |ids, callback|
+ find(ids).each do |record|
+ callback.call(record.id, record)
+ end
+ end
+ end
+
+ def self.find(ids)
+ pairs = ids.map { |id| id.split('.').map(&:to_i) }
+
+ design_ids = pairs.map(&:first).uniq
+ version_ids = pairs.map(&:second).uniq
+
+ designs = ::DesignManagement::Design
+ .where(id: design_ids)
+ .index_by(&:id)
+
+ versions = ::DesignManagement::Version
+ .where(id: version_ids)
+ .index_by(&:id)
+
+ pairs.map do |(design_id, version_id)|
+ design = designs[design_id]
+ version = versions[version_id]
+
+ obj = new(design: design, version: version)
+
+ obj if obj.valid?
+ end.compact
+ end
+
+ def status
+ if not_created_yet?
+ :not_created_yet
+ elsif deleted?
+ :deleted
+ else
+ :current
+ end
+ end
+
+ def deleted?
+ action&.deletion?
+ end
+
+ def not_created_yet?
+ action.nil?
+ end
+
+ private
+
+ def action
+ strong_memoize(:most_recent_action) do
+ ::DesignManagement::Action
+ .most_recent.up_to_version(version)
+ .find_by(design: design)
+ end
+ end
+
+ def design_and_version_belong_to_the_same_issue
+ id_a, id_b = [design, version].map { |obj| obj&.issue_id }
+
+ return if id_a == id_b
+
+ errors.add(:issue, 'must be the same on design and version')
+ end
+
+ def design_and_version_have_issue_id
+ return if [design, version].all? { |obj| obj.try(:issue_id).present? }
+
+ errors.add(:issue, 'must be present on both design and version')
+ end
+ end
+end
diff --git a/app/models/design_management/design_collection.rb b/app/models/design_management/design_collection.rb
new file mode 100644
index 00000000000..18d1541e9c7
--- /dev/null
+++ b/app/models/design_management/design_collection.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class DesignCollection
+ attr_reader :issue
+
+ delegate :designs, :project, to: :issue
+
+ def initialize(issue)
+ @issue = issue
+ end
+
+ def find_or_create_design!(filename:)
+ designs.find { |design| design.filename == filename } ||
+ designs.safe_find_or_create_by!(project: project, filename: filename)
+ end
+
+ def versions
+ @versions ||= DesignManagement::Version.for_designs(designs)
+ end
+
+ def repository
+ project.design_repository
+ end
+
+ def designs_by_filename(filenames)
+ designs.current.where(filename: filenames)
+ end
+ end
+end
diff --git a/app/models/design_management/repository.rb b/app/models/design_management/repository.rb
new file mode 100644
index 00000000000..985d6317d5d
--- /dev/null
+++ b/app/models/design_management/repository.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Repository < ::Repository
+ extend ::Gitlab::Utils::Override
+
+ # We define static git attributes for the design repository as this
+ # repository is entirely GitLab-managed rather than user-facing.
+ #
+ # Enable all uploaded files to be stored in LFS.
+ MANAGED_GIT_ATTRIBUTES = <<~GA.freeze
+ /#{DesignManagement.designs_directory}/* filter=lfs diff=lfs merge=lfs -text
+ GA
+
+ def initialize(project)
+ full_path = project.full_path + Gitlab::GlRepository::DESIGN.path_suffix
+ disk_path = project.disk_path + Gitlab::GlRepository::DESIGN.path_suffix
+
+ super(full_path, project, shard: project.repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::DESIGN)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def info_attributes
+ @info_attributes ||= Gitlab::Git::AttributesParser.new(MANAGED_GIT_ATTRIBUTES)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def attributes(path)
+ info_attributes.attributes(path)
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def gitattribute(path, name)
+ attributes(path)[name]
+ end
+
+ # Override of a method called on Repository instances but sent via
+ # method_missing to Gitlab::Git::Repository where it is defined
+ def attributes_at(_ref = nil)
+ info_attributes
+ end
+
+ override :copy_gitattributes
+ def copy_gitattributes(_ref = nil)
+ true
+ end
+ end
+end
diff --git a/app/models/design_management/version.rb b/app/models/design_management/version.rb
new file mode 100644
index 00000000000..6be98fe3d44
--- /dev/null
+++ b/app/models/design_management/version.rb
@@ -0,0 +1,144 @@
+# frozen_string_literal: true
+
+module DesignManagement
+ class Version < ApplicationRecord
+ include Importable
+ include ShaAttribute
+ include AfterCommitQueue
+ include Gitlab::Utils::StrongMemoize
+ extend Gitlab::ExclusiveLeaseHelpers
+
+ NotSameIssue = Class.new(StandardError)
+
+ class CouldNotCreateVersion < StandardError
+ attr_reader :sha, :issue_id, :actions
+
+ def initialize(sha, issue_id, actions)
+ @sha, @issue_id, @actions = sha, issue_id, actions
+ end
+
+ def message
+ "could not create version from commit: #{sha}"
+ end
+
+ def sentry_extra_data
+ {
+ sha: sha,
+ issue_id: issue_id,
+ design_ids: actions.map { |a| a.design.id }
+ }
+ end
+ end
+
+ belongs_to :issue
+ belongs_to :author, class_name: 'User'
+ has_many :actions
+ has_many :designs,
+ through: :actions,
+ class_name: "DesignManagement::Design",
+ source: :design,
+ inverse_of: :versions
+
+ validates :designs, presence: true, unless: :importing?
+ validates :sha, presence: true
+ validates :sha, uniqueness: { case_sensitive: false, scope: :issue_id }
+ validates :author, presence: true
+ # We are not validating the issue object as it incurs an extra query to fetch
+ # the record from the DB. Instead, we rely on the foreign key constraint to
+ # ensure referential integrity.
+ validates :issue_id, presence: true, unless: :importing?
+
+ sha_attribute :sha
+
+ delegate :project, to: :issue
+
+ scope :for_designs, -> (designs) do
+ where(id: ::DesignManagement::Action.where(design_id: designs).select(:version_id)).distinct
+ end
+ scope :earlier_or_equal_to, -> (version) { where("(#{table_name}.id) <= ?", version) } # rubocop:disable GitlabSecurity/SqlInjection
+ scope :ordered, -> { order(id: :desc) }
+ scope :for_issue, -> (issue) { where(issue: issue) }
+ scope :by_sha, -> (sha) { where(sha: sha) }
+
+ # This is the one true way to create a Version.
+ #
+ # This method means you can avoid the paradox of versions being invalid without
+ # designs, and not being able to add designs without a saved version. Also this
+ # method inserts designs in bulk, rather than one by one.
+ #
+ # Before calling this method, callers must guard against concurrent
+ # modification by obtaining the lock on the design repository. See:
+ # `DesignManagement::Version.with_lock`.
+ #
+ # Parameters:
+ # - design_actions [DesignManagement::DesignAction]:
+ # the actions that have been performed in the repository.
+ # - sha [String]:
+ # the SHA of the commit that performed them
+ # - author [User]:
+ # the user who performed the commit
+ # returns [DesignManagement::Version]
+ def self.create_for_designs(design_actions, sha, author)
+ issue_id, not_uniq = design_actions.map(&:issue_id).compact.uniq
+ raise NotSameIssue, 'All designs must belong to the same issue!' if not_uniq
+
+ transaction do
+ version = new(sha: sha, issue_id: issue_id, author: author)
+ version.save(validate: false) # We need it to have an ID. Validate later when designs are present
+
+ rows = design_actions.map { |action| action.row_attrs(version) }
+
+ Gitlab::Database.bulk_insert(::DesignManagement::Action.table_name, rows)
+ version.designs.reset
+ version.validate!
+ design_actions.each(&:performed)
+
+ version
+ end
+ rescue
+ raise CouldNotCreateVersion.new(sha, issue_id, design_actions)
+ end
+
+ CREATION_TTL = 5.seconds
+ RETRY_DELAY = ->(num) { 0.2.seconds * num**2 }
+
+ def self.with_lock(project_id, repository, &block)
+ key = "with_lock:#{name}:{#{project_id}}"
+
+ in_lock(key, ttl: CREATION_TTL, retries: 5, sleep_sec: RETRY_DELAY) do |_retried|
+ repository.create_if_not_exists
+ yield
+ end
+ end
+
+ def designs_by_event
+ actions
+ .includes(:design)
+ .group_by(&:event)
+ .transform_values { |group| group.map(&:design) }
+ end
+
+ def author
+ super || (commit_author if persisted?)
+ end
+
+ def diff_refs
+ strong_memoize(:diff_refs) { commit&.diff_refs }
+ end
+
+ def reset
+ %i[diff_refs commit].each { |k| clear_memoization(k) }
+ super
+ end
+
+ private
+
+ def commit_author
+ commit&.author
+ end
+
+ def commit
+ strong_memoize(:commit) { issue.project.design_repository.commit(sha) }
+ end
+ end
+end
diff --git a/app/models/design_user_mention.rb b/app/models/design_user_mention.rb
new file mode 100644
index 00000000000..baf4db29a0f
--- /dev/null
+++ b/app/models/design_user_mention.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+
+class DesignUserMention < UserMention
+ belongs_to :design, class_name: 'DesignManagement::Design'
+ belongs_to :note
+end
diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb
index e3df61dadae..ff39dbb59f3 100644
--- a/app/models/diff_note.rb
+++ b/app/models/diff_note.rb
@@ -9,7 +9,7 @@ class DiffNote < Note
include Gitlab::Utils::StrongMemoize
def self.noteable_types
- %w(MergeRequest Commit)
+ %w(MergeRequest Commit DesignManagement::Design)
end
validates :original_position, presence: true
@@ -60,6 +60,8 @@ class DiffNote < Note
# Returns the diff file from `position`
def latest_diff_file
strong_memoize(:latest_diff_file) do
+ next if for_design?
+
position.diff_file(repository)
end
end
@@ -67,6 +69,8 @@ class DiffNote < Note
# Returns the diff file from `original_position`
def diff_file
strong_memoize(:diff_file) do
+ next if for_design?
+
enqueue_diff_file_creation_job if should_create_diff_file?
fetch_diff_file
@@ -145,7 +149,7 @@ class DiffNote < Note
end
def supported?
- for_commit? || self.noteable.has_complete_diff_refs?
+ for_commit? || for_design? || self.noteable.has_complete_diff_refs?
end
def set_line_code
@@ -184,5 +188,3 @@ class DiffNote < Note
noteable.respond_to?(:repository) ? noteable.repository : project.repository
end
end
-
-DiffNote.prepend_if_ee('::EE::DiffNote')
diff --git a/app/models/email.rb b/app/models/email.rb
index 580633d3232..c5154267ff0 100644
--- a/app/models/email.rb
+++ b/app/models/email.rb
@@ -6,7 +6,8 @@ class Email < ApplicationRecord
belongs_to :user, optional: false
- validates :email, presence: true, uniqueness: true, devise_email: true
+ validates :email, presence: true, uniqueness: true
+ validate :validate_email_format
validate :unique_email, if: ->(email) { email.email_changed? }
scope :confirmed, -> { where.not(confirmed_at: nil) }
@@ -14,9 +15,14 @@ class Email < ApplicationRecord
after_commit :update_invalid_gpg_signatures, if: -> { previous_changes.key?('confirmed_at') }
devise :confirmable
+
+ # This module adds async behaviour to Devise emails
+ # and should be added after Devise modules are initialized.
+ include AsyncDeviseEmail
+
self.reconfirmable = false # currently email can't be changed, no need to reconfirm
- delegate :username, to: :user
+ delegate :username, :can?, to: :user
def email=(value)
write_attribute(:email, value.downcase.strip)
@@ -30,6 +36,10 @@ class Email < ApplicationRecord
user.accept_pending_invitations!
end
+ def validate_email_format
+ self.errors.add(:email, I18n.t(:invalid, scope: 'valid_email.validations.email')) unless ValidateEmail.valid?(self.email)
+ end
+
# once email is confirmed, update the gpg signatures
def update_invalid_gpg_signatures
user.update_invalid_gpg_signatures if confirmed?
diff --git a/app/models/environment.rb b/app/models/environment.rb
index b2391f33aca..21044771bbb 100644
--- a/app/models/environment.rb
+++ b/app/models/environment.rb
@@ -8,6 +8,7 @@ class Environment < ApplicationRecord
self.reactive_cache_refresh_interval = 1.minute
self.reactive_cache_lifetime = 55.seconds
self.reactive_cache_hard_limit = 10.megabytes
+ self.reactive_cache_work_type = :external_dependency
belongs_to :project, required: true
@@ -151,6 +152,14 @@ class Environment < ApplicationRecord
.preload(:user, :metadata, :deployment)
end
+ def count_by_state
+ environments_count_by_state = group(:state).count
+
+ valid_states.each_with_object({}) do |state, count_hash|
+ count_hash[state] = environments_count_by_state[state.to_s] || 0
+ end
+ end
+
private
def cte_for_deployments_with_stop_action
diff --git a/app/models/epic.rb b/app/models/epic.rb
index 04e19c17e18..e09dc1080e6 100644
--- a/app/models/epic.rb
+++ b/app/models/epic.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
# Placeholder class for model that is implemented in EE
-# It reserves '&' as a reference prefix, but the table does not exists in CE
+# It reserves '&' as a reference prefix, but the table does not exist in FOSS
class Epic < ApplicationRecord
include IgnorableColumns
diff --git a/app/models/error_tracking/project_error_tracking_setting.rb b/app/models/error_tracking/project_error_tracking_setting.rb
index 133850b6ab6..fa32c8a5450 100644
--- a/app/models/error_tracking/project_error_tracking_setting.rb
+++ b/app/models/error_tracking/project_error_tracking_setting.rb
@@ -22,6 +22,7 @@ module ErrorTracking
}x.freeze
self.reactive_cache_key = ->(setting) { [setting.class.model_name.singular, setting.project_id] }
+ self.reactive_cache_work_type = :external_dependency
belongs_to :project
diff --git a/app/models/event.rb b/app/models/event.rb
index 447ab753421..12b85697690 100644
--- a/app/models/event.rb
+++ b/app/models/event.rb
@@ -96,6 +96,8 @@ class Event < ApplicationRecord
end
scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) }
+ scope :for_wiki_meta, ->(meta) { where(target_type: 'WikiPage::Meta', target_id: meta.id) }
+ scope :created_at, ->(time) { where(created_at: time) }
# Authors are required as they're used to display who pushed data.
#
@@ -313,6 +315,10 @@ class Event < ApplicationRecord
note? && target && target.for_personal_snippet?
end
+ def design_note?
+ note? && note.for_design?
+ end
+
def note_target
target.noteable
end
@@ -380,6 +386,11 @@ class Event < ApplicationRecord
protected
+ # rubocop:disable Metrics/CyclomaticComplexity
+ # rubocop:disable Metrics/PerceivedComplexity
+ #
+ # TODO Refactor this method so we no longer need to disable the above cops
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/216879.
def capability
@capability ||= begin
if push_action? || commit_note?
@@ -396,9 +407,13 @@ class Event < ApplicationRecord
:read_milestone
elsif wiki_page?
:read_wiki
+ elsif design_note?
+ :read_design
end
end
end
+ # rubocop:enable Metrics/CyclomaticComplexity
+ # rubocop:enable Metrics/PerceivedComplexity
private
diff --git a/app/models/global_milestone.rb b/app/models/global_milestone.rb
index d0cec0e9fc6..43de7454cb7 100644
--- a/app/models/global_milestone.rb
+++ b/app/models/global_milestone.rb
@@ -2,7 +2,6 @@
# Global Milestones are milestones that can be shared across multiple projects
class GlobalMilestone
include Milestoneish
- include_if_ee('::EE::GlobalMilestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
STATE_COUNT_HASH = { opened: 0, closed: 0, all: 0 }.freeze
@@ -11,7 +10,7 @@ class GlobalMilestone
delegate :title, :state, :due_date, :start_date, :participants, :project,
:group, :expires_at, :closed?, :iid, :group_milestone?, :safe_title,
- :milestoneish_id, :resource_parent, :releases, to: :milestone
+ :timebox_id, :milestoneish_id, :resource_parent, :releases, to: :milestone
def to_hash
{
@@ -105,3 +104,5 @@ class GlobalMilestone
true
end
end
+
+GlobalMilestone.include_if_ee('::EE::GlobalMilestone')
diff --git a/app/models/group.rb b/app/models/group.rb
index 55a2c4ba9a9..04cb6b8b4da 100644
--- a/app/models/group.rb
+++ b/app/models/group.rb
@@ -30,6 +30,7 @@ class Group < Namespace
has_many :members_and_requesters, as: :source, class_name: 'GroupMember'
has_many :milestones
+ has_many :iterations
has_many :shared_group_links, foreign_key: :shared_with_group_id, class_name: 'GroupGroupLink'
has_many :shared_with_group_links, foreign_key: :shared_group_id, class_name: 'GroupGroupLink'
has_many :shared_groups, through: :shared_group_links, source: :shared_group
@@ -59,6 +60,8 @@ class Group < Namespace
has_many :import_failures, inverse_of: :group
+ has_one :import_state, class_name: 'GroupImportState', inverse_of: :group
+
has_many :group_deploy_tokens
has_many :deploy_tokens, through: :group_deploy_tokens
@@ -168,7 +171,7 @@ class Group < Namespace
notification_settings.find { |n| n.notification_email.present? }&.notification_email
end
- def to_reference(_from = nil, full: nil)
+ def to_reference(_from = nil, target_project: nil, full: nil)
"#{self.class.reference_prefix}#{full_path}"
end
@@ -302,9 +305,10 @@ class Group < Namespace
# rubocop: enable CodeReuse/ServiceClass
# rubocop: disable CodeReuse/ServiceClass
- def refresh_members_authorized_projects(blocking: true)
- UserProjectAccessChangedService.new(user_ids_for_project_authorizations)
- .execute(blocking: blocking)
+ def refresh_members_authorized_projects(blocking: true, priority: UserProjectAccessChangedService::HIGH_PRIORITY)
+ UserProjectAccessChangedService
+ .new(user_ids_for_project_authorizations)
+ .execute(blocking: blocking, priority: priority)
end
# rubocop: enable CodeReuse/ServiceClass
@@ -332,6 +336,11 @@ class Group < Namespace
.where(source_id: source_ids)
end
+ def members_from_self_and_ancestors_with_effective_access_level
+ members_with_parents.select([:user_id, 'MAX(access_level) AS access_level'])
+ .group(:user_id)
+ end
+
def members_with_descendants
GroupMember
.active_without_invites_and_requests
@@ -475,14 +484,14 @@ class Group < Namespace
false
end
- def wiki_access_level
- # TODO: Remove this method once we implement group-level features.
- # https://gitlab.com/gitlab-org/gitlab/-/issues/208412
- if Feature.enabled?(:group_wiki, self)
- ProjectFeature::ENABLED
- else
- ProjectFeature::DISABLED
- end
+ def execute_hooks(data, hooks_scope)
+ # NOOP
+ # TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904
+ end
+
+ def execute_services(data, hooks_scope)
+ # NOOP
+ # TODO: group hooks https://gitlab.com/gitlab-org/gitlab/-/issues/216904
end
private
@@ -516,8 +525,6 @@ class Group < Namespace
end
def max_member_access_for_user_from_shared_groups(user)
- return unless Feature.enabled?(:share_group_with_group, default_enabled: true)
-
group_group_link_table = GroupGroupLink.arel_table
group_member_table = GroupMember.arel_table
diff --git a/app/models/group_import_state.rb b/app/models/group_import_state.rb
new file mode 100644
index 00000000000..7773b887249
--- /dev/null
+++ b/app/models/group_import_state.rb
@@ -0,0 +1,34 @@
+# frozen_string_literal: true
+
+class GroupImportState < ApplicationRecord
+ self.primary_key = :group_id
+
+ belongs_to :group, inverse_of: :import_state
+
+ validates :group, :status, :jid, presence: true
+
+ state_machine :status, initial: :created do
+ state :created, value: 0
+ state :started, value: 1
+ state :finished, value: 2
+ state :failed, value: -1
+
+ event :start do
+ transition created: :started
+ end
+
+ event :finish do
+ transition started: :finished
+ end
+
+ event :fail_op do
+ transition any => :failed
+ end
+
+ after_transition any => :failed do |state, transition|
+ last_error = transition.args.first
+
+ state.update_column(:last_error, last_error) if last_error
+ end
+ end
+end
diff --git a/app/models/group_milestone.rb b/app/models/group_milestone.rb
index 87338512d99..60e97174e50 100644
--- a/app/models/group_milestone.rb
+++ b/app/models/group_milestone.rb
@@ -1,7 +1,6 @@
# frozen_string_literal: true
# Group Milestones are milestones that can be shared among many projects within the same group
class GroupMilestone < GlobalMilestone
- include_if_ee('::EE::GroupMilestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
attr_reader :group, :milestones
def self.build_collection(group, projects, params)
@@ -46,3 +45,5 @@ class GroupMilestone < GlobalMilestone
true
end
end
+
+GroupMilestone.include_if_ee('::EE::GroupMilestone')
diff --git a/app/models/hooks/project_hook.rb b/app/models/hooks/project_hook.rb
index bc480b14e67..71494b6de4d 100644
--- a/app/models/hooks/project_hook.rb
+++ b/app/models/hooks/project_hook.rb
@@ -3,6 +3,9 @@
class ProjectHook < WebHook
include TriggerableHooks
include Presentable
+ include Limitable
+
+ self.limit_scope = :project
triggerable_hooks [
:push_hooks,
diff --git a/app/models/internal_id_enums.rb b/app/models/internal_id_enums.rb
index 2f7d7aeff2f..125ae7573b6 100644
--- a/app/models/internal_id_enums.rb
+++ b/app/models/internal_id_enums.rb
@@ -3,7 +3,18 @@
module InternalIdEnums
def self.usage_resources
# when adding new resource, make sure it doesn't conflict with EE usage_resources
- { issues: 0, merge_requests: 1, deployments: 2, milestones: 3, epics: 4, ci_pipelines: 5, operations_feature_flags: 6 }
+ {
+ issues: 0,
+ merge_requests: 1,
+ deployments: 2,
+ milestones: 3,
+ epics: 4,
+ ci_pipelines: 5,
+ operations_feature_flags: 6,
+ operations_user_lists: 7,
+ alert_management_alerts: 8,
+ sprints: 9 # iterations
+ }
end
end
diff --git a/app/models/issue.rb b/app/models/issue.rb
index cdd7429bc58..a04ac412940 100644
--- a/app/models/issue.rb
+++ b/app/models/issue.rb
@@ -17,6 +17,7 @@ class Issue < ApplicationRecord
include IgnorableColumns
include MilestoneEventable
include WhereComposite
+ include StateEventable
DueDateStruct = Struct.new(:title, :name).freeze
NoDueDate = DueDateStruct.new('No Due Date', '0').freeze
@@ -29,9 +30,12 @@ class Issue < ApplicationRecord
SORTING_PREFERENCE_FIELD = :issues_sort
belongs_to :project
- belongs_to :moved_to, class_name: 'Issue'
belongs_to :duplicated_to, class_name: 'Issue'
belongs_to :closed_by, class_name: 'User'
+ belongs_to :iteration, foreign_key: 'sprint_id'
+
+ belongs_to :moved_to, class_name: 'Issue'
+ has_one :moved_from, class_name: 'Issue', foreign_key: :moved_to_id
has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.issues&.maximum(:iid) }
@@ -46,8 +50,15 @@ class Issue < ApplicationRecord
has_many :zoom_meetings
has_many :user_mentions, class_name: "IssueUserMention", dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :sent_notifications, as: :noteable
+ has_many :designs, class_name: 'DesignManagement::Design', inverse_of: :issue
+ has_many :design_versions, class_name: 'DesignManagement::Version', inverse_of: :issue do
+ def most_recent
+ ordered.first
+ end
+ end
has_one :sentry_issue
+ has_one :alert_management_alert, class_name: 'AlertManagement::Alert'
accepts_nested_attributes_for :sentry_issue
@@ -63,6 +74,7 @@ class Issue < ApplicationRecord
scope :due_before, ->(date) { where('issues.due_date < ?', date) }
scope :due_between, ->(from_date, to_date) { where('issues.due_date >= ?', from_date).where('issues.due_date <= ?', to_date) }
scope :due_tomorrow, -> { where(due_date: Date.tomorrow) }
+ scope :not_authored_by, ->(user) { where.not(author_id: user) }
scope :order_due_date_asc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
scope :order_due_date_desc, -> { reorder(::Gitlab::Database.nulls_last_order('due_date', 'DESC')) }
@@ -73,11 +85,13 @@ class Issue < ApplicationRecord
scope :preload_associated_models, -> { preload(:assignees, :labels, project: :namespace) }
scope :with_api_entity_associations, -> { preload(:timelogs, :assignees, :author, :notes, :labels, project: [:route, { namespace: :route }] ) }
+ scope :with_label_attributes, ->(label_attributes) { joins(:labels).where(labels: label_attributes) }
scope :public_only, -> { where(confidential: false) }
scope :confidential_only, -> { where(confidential: true) }
scope :counts_by_state, -> { reorder(nil).group(:state_id).count }
+ scope :with_alert_management_alerts, -> { joins(:alert_management_alert) }
# An issue can be uniquely identified by project_id and iid
# Takes one or more sets of composite IDs, expressed as hash-like records of
@@ -330,6 +344,10 @@ class Issue < ApplicationRecord
previous_changes['updated_at']&.first || updated_at
end
+ def design_collection
+ @design_collection ||= ::DesignManagement::DesignCollection.new(self)
+ end
+
private
def ensure_metrics
@@ -343,7 +361,7 @@ class Issue < ApplicationRecord
# for performance reasons, check commit: 002ad215818450d2cbbc5fa065850a953dc7ada8
# Make sure to sync this method with issue_policy.rb
def readable_by?(user)
- if user.admin?
+ if user.can_read_all_resources?
true
elsif project.owner == user
true
diff --git a/app/models/iteration.rb b/app/models/iteration.rb
new file mode 100644
index 00000000000..1acd08f2063
--- /dev/null
+++ b/app/models/iteration.rb
@@ -0,0 +1,100 @@
+# frozen_string_literal: true
+
+class Iteration < ApplicationRecord
+ include Timebox
+
+ self.table_name = 'sprints'
+
+ attr_accessor :skip_future_date_validation
+
+ STATE_ENUM_MAP = {
+ upcoming: 1,
+ started: 2,
+ closed: 3
+ }.with_indifferent_access.freeze
+
+ include AtomicInternalId
+
+ has_many :issues, foreign_key: 'sprint_id'
+ has_many :merge_requests, foreign_key: 'sprint_id'
+
+ belongs_to :project
+ belongs_to :group
+
+ has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.iterations&.maximum(:iid) }
+ has_internal_id :iid, scope: :group, init: ->(s) { s&.group&.iterations&.maximum(:iid) }
+
+ validates :start_date, presence: true
+ validates :due_date, presence: true
+
+ validate :dates_do_not_overlap, if: :start_or_due_dates_changed?
+ validate :future_date, if: :start_or_due_dates_changed?, unless: :skip_future_date_validation
+
+ scope :upcoming, -> { with_state(:upcoming) }
+ scope :started, -> { with_state(:started) }
+
+ state_machine :state_enum, initial: :upcoming do
+ event :start do
+ transition upcoming: :started
+ end
+
+ event :close do
+ transition [:upcoming, :started] => :closed
+ end
+
+ state :upcoming, value: Iteration::STATE_ENUM_MAP[:upcoming]
+ state :started, value: Iteration::STATE_ENUM_MAP[:started]
+ state :closed, value: Iteration::STATE_ENUM_MAP[:closed]
+ end
+
+ # Alias to state machine .with_state_enum method
+ # This needs to be defined after the state machine block to avoid errors
+ class << self
+ alias_method :with_state, :with_state_enum
+ alias_method :with_states, :with_state_enums
+
+ def filter_by_state(iterations, state)
+ case state
+ when 'closed' then iterations.closed
+ when 'started' then iterations.started
+ when 'opened' then iterations.started.or(iterations.upcoming)
+ when 'all' then iterations
+ else iterations.upcoming
+ end
+ end
+ end
+
+ def state
+ STATE_ENUM_MAP.key(state_enum)
+ end
+
+ def state=(value)
+ self.state_enum = STATE_ENUM_MAP[value]
+ end
+
+ private
+
+ def start_or_due_dates_changed?
+ start_date_changed? || due_date_changed?
+ end
+
+ # ensure dates do not overlap with other Iterations in the same group/project
+ def dates_do_not_overlap
+ return unless resource_parent.iterations.within_timeframe(start_date, due_date).exists?
+
+ errors.add(:base, s_("Iteration|Dates cannot overlap with other existing Iterations"))
+ end
+
+ # ensure dates are in the future
+ def future_date
+ if start_date_changed?
+ errors.add(:start_date, s_("Iteration|cannot be in the past")) if start_date < Date.current
+ errors.add(:start_date, s_("Iteration|cannot be more than 500 years in the future")) if start_date > 500.years.from_now
+ end
+
+ if due_date_changed?
+ errors.add(:due_date, s_("Iteration|cannot be in the past")) if due_date < Date.current
+ errors.add(:due_date, s_("Iteration|cannot be more than 500 years in the future")) if due_date > 500.years.from_now
+ end
+ end
+end
diff --git a/app/models/jira_import_state.rb b/app/models/jira_import_state.rb
index bde2795e7b8..92147794e88 100644
--- a/app/models/jira_import_state.rb
+++ b/app/models/jira_import_state.rb
@@ -3,6 +3,7 @@
class JiraImportState < ApplicationRecord
include AfterCommitQueue
include ImportState::SidekiqJobTracker
+ include UsageStatistics
self.table_name = 'jira_imports'
@@ -46,7 +47,7 @@ class JiraImportState < ApplicationRecord
after_transition initial: :scheduled do |state, _|
state.run_after_commit do
job_id = Gitlab::JiraImport::Stage::StartImportWorker.perform_async(project.id)
- state.update(jid: job_id) if job_id
+ state.update(jid: job_id, scheduled_at: Time.now) if job_id
end
end
@@ -97,4 +98,8 @@ class JiraImportState < ApplicationRecord
}
)
end
+
+ def self.finished_imports_count
+ finished.sum(:imported_issues_count)
+ end
end
diff --git a/app/models/list.rb b/app/models/list.rb
index 64247fdb983..ec211dfd497 100644
--- a/app/models/list.rb
+++ b/app/models/list.rb
@@ -3,8 +3,6 @@
class List < ApplicationRecord
include Importable
- prepend_if_ee('::EE::List') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
belongs_to :board
belongs_to :label
has_many :list_user_preferences
@@ -74,14 +72,18 @@ class List < ApplicationRecord
label? ? label.name : list_type.humanize
end
+ def collapsed?(user)
+ preferences = preferences_for(user)
+
+ preferences.collapsed?
+ end
+
def as_json(options = {})
super(options).tap do |json|
json[:collapsed] = false
if options.key?(:collapsed)
- preferences = preferences_for(options[:current_user])
-
- json[:collapsed] = preferences.collapsed?
+ json[:collapsed] = collapsed?(options[:current_user])
end
if options.key?(:label)
@@ -100,3 +102,5 @@ class List < ApplicationRecord
throw(:abort) unless destroyable? # rubocop:disable Cop/BanCatchThrow
end
end
+
+List.prepend_if_ee('::EE::List')
diff --git a/app/models/member.rb b/app/models/member.rb
index 5b33333aa23..791073da095 100644
--- a/app/models/member.rb
+++ b/app/models/member.rb
@@ -1,6 +1,7 @@
# frozen_string_literal: true
class Member < ApplicationRecord
+ include EachBatch
include AfterCommitQueue
include Sortable
include Importable
diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb
index 68c51860c47..fa2e0cb8198 100644
--- a/app/models/members/project_member.rb
+++ b/app/models/members/project_member.rb
@@ -17,6 +17,11 @@ class ProjectMember < Member
.where('projects.namespace_id in (?)', groups.select(:id))
end
+ scope :without_project_bots, -> do
+ left_join_users
+ .merge(User.without_project_bot)
+ end
+
class << self
# Add users to projects with passed access option
#
diff --git a/app/models/members_preloader.rb b/app/models/members_preloader.rb
index 1ed0434eacf..6da8d5f3161 100644
--- a/app/models/members_preloader.rb
+++ b/app/models/members_preloader.rb
@@ -1,8 +1,6 @@
# frozen_string_literal: true
class MembersPreloader
- prepend_if_ee('EE::MembersPreloader') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
attr_reader :members
def initialize(members)
@@ -16,3 +14,5 @@ class MembersPreloader
ActiveRecord::Associations::Preloader.new.preload(members.map(&:user), :u2f_registrations)
end
end
+
+MembersPreloader.prepend_if_ee('EE::MembersPreloader')
diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb
index a28e054e13c..b4d0b729454 100644
--- a/app/models/merge_request.rb
+++ b/app/models/merge_request.rb
@@ -19,6 +19,7 @@ class MergeRequest < ApplicationRecord
include ShaAttribute
include IgnorableColumns
include MilestoneEventable
+ include StateEventable
sha_attribute :squash_commit_sha
@@ -32,6 +33,7 @@ class MergeRequest < ApplicationRecord
belongs_to :target_project, class_name: "Project"
belongs_to :source_project, class_name: "Project"
belongs_to :merge_user, class_name: "User"
+ belongs_to :iteration, foreign_key: 'sprint_id'
has_internal_id :iid, scope: :target_project, track_if: -> { !importing? }, init: ->(s) { s&.target_project&.merge_requests&.maximum(:iid) }
@@ -864,7 +866,7 @@ class MergeRequest < ApplicationRecord
check_service = MergeRequests::MergeabilityCheckService.new(self)
- if async && Feature.enabled?(:async_merge_request_check_mergeability, project)
+ if async && Feature.enabled?(:async_merge_request_check_mergeability, project, default_enabled: true)
check_service.async_execute
else
check_service.execute(retry_lease: false)
@@ -873,7 +875,7 @@ class MergeRequest < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def diffable_merge_ref?
- Feature.enabled?(:diff_compare_with_head, target_project) && can_be_merged? && merge_ref_head.present?
+ can_be_merged? && merge_ref_head.present?
end
# Returns boolean indicating the merge_status should be rechecked in order to
@@ -1129,26 +1131,6 @@ class MergeRequest < ApplicationRecord
end
end
- # Return array of possible target branches
- # depends on target project of MR
- def target_branches
- if target_project.nil?
- []
- else
- target_project.repository.branch_names
- end
- end
-
- # Return array of possible source branches
- # depends on source project of MR
- def source_branches
- if source_project.nil?
- []
- else
- source_project.repository.branch_names
- end
- end
-
def has_ci?
return false if has_no_commits?
@@ -1319,12 +1301,30 @@ class MergeRequest < ApplicationRecord
compare_reports(Ci::CompareTestReportsService)
end
+ def has_accessibility_reports?
+ return false unless Feature.enabled?(:accessibility_report_view, project)
+
+ actual_head_pipeline.present? && actual_head_pipeline.has_reports?(Ci::JobArtifact.accessibility_reports)
+ end
+
def has_coverage_reports?
return false unless Feature.enabled?(:coverage_report_view, project)
actual_head_pipeline&.has_reports?(Ci::JobArtifact.coverage_reports)
end
+ def has_terraform_reports?
+ actual_head_pipeline&.has_reports?(Ci::JobArtifact.terraform_reports)
+ end
+
+ def compare_accessibility_reports
+ unless has_accessibility_reports?
+ return { status: :error, status_reason: _('This merge request does not have accessibility reports') }
+ end
+
+ compare_reports(Ci::CompareAccessibilityReportsService)
+ end
+
# TODO: this method and compare_test_reports use the same
# result type, which is handled by the controller's #reports_response.
# we should minimize mistakes by isolating the common parts.
@@ -1337,9 +1337,15 @@ class MergeRequest < ApplicationRecord
compare_reports(Ci::GenerateCoverageReportsService)
end
- def has_exposed_artifacts?
- return false unless Feature.enabled?(:ci_expose_arbitrary_artifacts_in_mr, default_enabled: true)
+ def find_terraform_reports
+ unless has_terraform_reports?
+ return { status: :error, status_reason: 'This merge request does not have terraform reports' }
+ end
+ compare_reports(Ci::GenerateTerraformReportsService)
+ end
+
+ def has_exposed_artifacts?
actual_head_pipeline&.has_exposed_artifacts?
end
diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb
index 7b15d21c095..f793bd3d76f 100644
--- a/app/models/merge_request_diff.rb
+++ b/app/models/merge_request_diff.rb
@@ -141,7 +141,7 @@ class MergeRequestDiff < ApplicationRecord
after_create :save_git_content, unless: :importing?
after_create_commit :set_as_latest_diff, unless: :importing?
- after_save :update_external_diff_store, if: -> { !importing? && saved_change_to_external_diff? }
+ after_save :update_external_diff_store
def self.find_by_diff_refs(diff_refs)
find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
@@ -385,34 +385,11 @@ class MergeRequestDiff < ApplicationRecord
end
end
- # Carrierwave defines `write_uploader` dynamically on this class, so `super`
- # does not work. Alias the carrierwave method so we can call it when needed
- alias_method :carrierwave_write_uploader, :write_uploader
-
- # The `external_diff`, `external_diff_store`, and `stored_externally`
- # columns were introduced in GitLab 11.8, but some background migration specs
- # use factories that rely on current code with an old schema. Without these
- # `has_attribute?` guards, they fail with a `MissingAttributeError`.
- #
- # For more details, see: https://gitlab.com/gitlab-org/gitlab-foss/issues/44990
-
- def write_uploader(column, identifier)
- carrierwave_write_uploader(column, identifier) if has_attribute?(column)
- end
-
def update_external_diff_store
- update_column(:external_diff_store, external_diff.object_store) if
- has_attribute?(:external_diff_store)
- end
-
- def saved_change_to_external_diff?
- super if has_attribute?(:external_diff)
- end
+ return unless saved_change_to_external_diff? || saved_change_to_stored_externally?
- def stored_externally
- super if has_attribute?(:stored_externally)
+ update_column(:external_diff_store, external_diff.object_store)
end
- alias_method :stored_externally?, :stored_externally
# If enabled, yields the external file containing the diff. Otherwise, yields
# nil. This method is not thread-safe, but it *is* re-entrant, which allows
@@ -575,7 +552,6 @@ class MergeRequestDiff < ApplicationRecord
end
def use_external_diff?
- return false unless has_attribute?(:external_diff)
return false unless Gitlab.config.external_diffs.enabled
case Gitlab.config.external_diffs.when
diff --git a/app/models/metrics/users_starred_dashboard.rb b/app/models/metrics/users_starred_dashboard.rb
new file mode 100644
index 00000000000..07748eb1431
--- /dev/null
+++ b/app/models/metrics/users_starred_dashboard.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+module Metrics
+ class UsersStarredDashboard < ApplicationRecord
+ self.table_name = 'metrics_users_starred_dashboards'
+
+ belongs_to :user, inverse_of: :metrics_users_starred_dashboards
+ belongs_to :project, inverse_of: :metrics_users_starred_dashboards
+
+ validates :user_id, presence: true
+ validates :project_id, presence: true
+ validates :dashboard_path, presence: true, length: { maximum: 255 }
+ validates :dashboard_path, uniqueness: { scope: %i[user_id project_id] }
+
+ scope :for_project, ->(project) { where(project: project) }
+ scope :for_project_dashboard, ->(project, path) { for_project(project).where(dashboard_path: path) }
+ end
+end
diff --git a/app/models/milestone.rb b/app/models/milestone.rb
index 4ccfe314526..b5e4f62792e 100644
--- a/app/models/milestone.rb
+++ b/app/models/milestone.rb
@@ -1,88 +1,37 @@
# frozen_string_literal: true
class Milestone < ApplicationRecord
- # Represents a "No Milestone" state used for filtering Issues and Merge
- # Requests that have no milestone assigned.
- MilestoneStruct = Struct.new(:title, :name, :id) do
- # Ensure these models match the interface required for exporting
- def serializable_hash(_opts = {})
- { title: title, name: name, id: id }
- end
- end
-
- None = MilestoneStruct.new('No Milestone', 'No Milestone', 0)
- Any = MilestoneStruct.new('Any Milestone', '', -1)
- Upcoming = MilestoneStruct.new('Upcoming', '#upcoming', -2)
- Started = MilestoneStruct.new('Started', '#started', -3)
-
- include CacheMarkdownField
- include AtomicInternalId
- include IidRoutes
include Sortable
include Referable
- include StripAttribute
+ include Timebox
include Milestoneish
include FromUnion
include Importable
- include Gitlab::SQL::Pattern
prepend_if_ee('::EE::Milestone') # rubocop: disable Cop/InjectEnterpriseEditionModule
- cache_markdown_field :title, pipeline: :single_line
- cache_markdown_field :description
-
- belongs_to :project
- belongs_to :group
-
has_many :milestone_releases
has_many :releases, through: :milestone_releases
has_internal_id :iid, scope: :project, track_if: -> { !importing? }, init: ->(s) { s&.project&.milestones&.maximum(:iid) }
has_internal_id :iid, scope: :group, track_if: -> { !importing? }, init: ->(s) { s&.group&.milestones&.maximum(:iid) }
- has_many :issues
- has_many :labels, -> { distinct.reorder('labels.title') }, through: :issues
- has_many :merge_requests
has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
- scope :of_projects, ->(ids) { where(project_id: ids) }
- scope :of_groups, ->(ids) { where(group_id: ids) }
scope :active, -> { with_state(:active) }
- scope :closed, -> { with_state(:closed) }
- scope :for_projects, -> { where(group: nil).includes(:project) }
scope :started, -> { active.where('milestones.start_date <= CURRENT_DATE') }
-
- scope :for_projects_and_groups, -> (projects, groups) do
- projects = projects.compact if projects.is_a? Array
- projects = [] if projects.nil?
-
- groups = groups.compact if groups.is_a? Array
- groups = [] if groups.nil?
-
- where(project_id: projects).or(where(group_id: groups))
- end
-
- scope :within_timeframe, -> (start_date, end_date) do
- where('start_date is not NULL or due_date is not NULL')
- .where('start_date is NULL or start_date <= ?', end_date)
- .where('due_date is NULL or due_date >= ?', start_date)
+ scope :not_started, -> { active.where('milestones.start_date > CURRENT_DATE') }
+ scope :not_upcoming, -> do
+ active
+ .where('milestones.due_date <= CURRENT_DATE')
+ .order(:project_id, :group_id, :due_date)
end
scope :order_by_name_asc, -> { order(Arel::Nodes::Ascending.new(arel_table[:title].lower)) }
scope :reorder_by_due_date_asc, -> { reorder(Gitlab::Database.nulls_last_order('due_date', 'ASC')) }
- validates :group, presence: true, unless: :project
- validates :project, presence: true, unless: :group
- validates :title, presence: true
-
- validate :uniqueness_of_title, if: :title_changed?
- validate :milestone_type_check
- validate :start_date_should_be_less_than_due_date, if: proc { |m| m.start_date.present? && m.due_date.present? }
- validate :dates_within_4_digits
validates_associated :milestone_releases, message: -> (_, obj) { obj[:value].map(&:errors).map(&:full_messages).join(",") }
- strip_attributes :title
-
state_machine :state, initial: :active do
event :close do
transition active: :closed
@@ -97,52 +46,6 @@ class Milestone < ApplicationRecord
state :active
end
- alias_attribute :name, :title
-
- class << self
- # Searches for milestones with a matching title or description.
- #
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
- #
- # query - The search query as a String
- #
- # Returns an ActiveRecord::Relation.
- def search(query)
- fuzzy_search(query, [:title, :description])
- end
-
- # Searches for milestones with a matching title.
- #
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
- #
- # query - The search query as a String
- #
- # Returns an ActiveRecord::Relation.
- def search_title(query)
- fuzzy_search(query, [:title])
- end
-
- def filter_by_state(milestones, state)
- case state
- when 'closed' then milestones.closed
- when 'all' then milestones
- else milestones.active
- end
- end
-
- def count_by_state
- reorder(nil).group(:state).count
- end
-
- def predefined_id?(id)
- [Any.id, None.id, Upcoming.id, Started.id].include?(id)
- end
-
- def predefined?(milestone)
- predefined_id?(milestone&.id)
- end
- end
-
def self.reference_prefix
'%'
end
@@ -220,7 +123,7 @@ class Milestone < ApplicationRecord
end
##
- # Returns the String necessary to reference this Milestone in Markdown. Group
+ # Returns the String necessary to reference a Milestone in Markdown. Group
# milestones only support name references, and do not support cross-project
# references.
#
@@ -248,10 +151,6 @@ class Milestone < ApplicationRecord
self.class.reference_prefix + self.title
end
- def milestoneish_id
- id
- end
-
def for_display
self
end
@@ -264,62 +163,24 @@ class Milestone < ApplicationRecord
nil
end
- def title=(value)
- write_attribute(:title, sanitize_title(value)) if value.present?
- end
+ # TODO: remove after all code paths use `timebox_id`
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/215688
+ alias_method :milestoneish_id, :timebox_id
+ # TODO: remove after all code paths use (group|project)_timebox?
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/215690
+ alias_method :group_milestone?, :group_timebox?
+ alias_method :project_milestone?, :project_timebox?
- def safe_title
- title.to_slug.normalize.to_s
- end
-
- def resource_parent
- group || project
- end
-
- def to_ability_name
- model_name.singular
- end
-
- def group_milestone?
- group_id.present?
- end
-
- def project_milestone?
- project_id.present?
- end
-
- def merge_requests_enabled?
+ def parent
if group_milestone?
- # Assume that groups have at least one project with merge requests enabled.
- # Otherwise, we would need to load all of the projects from the database.
- true
- elsif project_milestone?
- project&.merge_requests_enabled?
+ group
+ else
+ project
end
end
private
- # Milestone titles must be unique across project milestones and group milestones
- def uniqueness_of_title
- if project
- relation = Milestone.for_projects_and_groups([project_id], [project.group&.id])
- elsif group
- relation = Milestone.for_projects_and_groups(group.projects.select(:id), [group.id])
- end
-
- title_exists = relation.find_by_title(title)
- errors.add(:title, _("already being used for another group or project milestone.")) if title_exists
- end
-
- # Milestone should be either a project milestone or a group milestone
- def milestone_type_check
- if group_id && project_id
- field = project_id_changed? ? :project_id : :group_id
- errors.add(field, _("milestone should belong either to a project or a group."))
- end
- end
-
def milestone_format_reference(format = :iid)
raise ArgumentError, _('Unknown format') unless [:iid, :name].include?(format)
@@ -334,26 +195,6 @@ class Milestone < ApplicationRecord
end
end
- def sanitize_title(value)
- CGI.unescape_html(Sanitize.clean(value.to_s))
- end
-
- def start_date_should_be_less_than_due_date
- if due_date <= start_date
- errors.add(:due_date, _("must be greater than start date"))
- end
- end
-
- def dates_within_4_digits
- if start_date && start_date > Date.new(9999, 12, 31)
- errors.add(:start_date, _("date must not be after 9999-12-31"))
- end
-
- if due_date && due_date > Date.new(9999, 12, 31)
- errors.add(:due_date, _("date must not be after 9999-12-31"))
- end
- end
-
def issues_finder_params
{ project_id: project_id, group_id: group_id, include_subgroups: group_id.present? }.compact
end
diff --git a/app/models/milestone_note.rb b/app/models/milestone_note.rb
index 2ff9791feb0..19171e682b7 100644
--- a/app/models/milestone_note.rb
+++ b/app/models/milestone_note.rb
@@ -17,6 +17,6 @@ class MilestoneNote < SyntheticNote
def note_text(html: false)
format = milestone&.group_milestone? ? :name : :iid
- milestone.nil? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}"
+ event.remove? ? 'removed milestone' : "changed milestone to #{milestone.to_reference(project, format: format)}"
end
end
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 9e7589a1f18..8116f7a256f 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -14,6 +14,7 @@ class Namespace < ApplicationRecord
include IgnorableColumns
ignore_column :plan_id, remove_with: '13.1', remove_after: '2020-06-22'
+ ignore_column :trial_ends_on, remove_with: '13.2', remove_after: '2020-07-22'
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@@ -135,11 +136,6 @@ class Namespace < ApplicationRecord
name = host.delete_suffix(gitlab_host)
Namespace.where(parent_id: nil).by_path(name)
end
-
- # overridden in ee
- def reset_ci_minutes!(namespace_id)
- false
- end
end
def default_branch_protection
@@ -180,6 +176,10 @@ class Namespace < ApplicationRecord
kind == 'user'
end
+ def group?
+ type == 'Group'
+ end
+
def find_fork_of(project)
return unless project.fork_network
@@ -346,6 +346,21 @@ class Namespace < ApplicationRecord
.try(name)
end
+ def actual_plan
+ Plan.default
+ end
+
+ def actual_limits
+ # We default to PlanLimits.new otherwise a lot of specs would fail
+ # On production each plan should already have associated limits record
+ # https://gitlab.com/gitlab-org/gitlab/issues/36037
+ actual_plan.actual_limits
+ end
+
+ def actual_plan_name
+ actual_plan.name
+ end
+
private
def all_projects_with_pages
diff --git a/app/models/namespace/root_storage_size.rb b/app/models/namespace/root_storage_size.rb
new file mode 100644
index 00000000000..d61917e468e
--- /dev/null
+++ b/app/models/namespace/root_storage_size.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+class Namespace::RootStorageSize
+ def initialize(root_namespace)
+ @root_namespace = root_namespace
+ end
+
+ def above_size_limit?
+ return false if limit == 0
+
+ usage_ratio > 1
+ end
+
+ def usage_ratio
+ return 0 if limit == 0
+
+ current_size.to_f / limit.to_f
+ end
+
+ def current_size
+ @current_size ||= root_namespace.root_storage_statistics&.storage_size
+ end
+
+ def limit
+ @limit ||= Gitlab::CurrentSettings.namespace_storage_size_limit.megabytes
+ end
+
+ private
+
+ attr_reader :root_namespace
+end
diff --git a/app/models/note.rb b/app/models/note.rb
index a2a711c987f..d174ba8fe83 100644
--- a/app/models/note.rb
+++ b/app/models/note.rb
@@ -159,6 +159,8 @@ class Note < ApplicationRecord
after_save :touch_noteable, unless: :importing?
after_destroy :expire_etag_cache
after_save :store_mentions!, if: :any_mentionable_attributes_changed?
+ after_commit :notify_after_create, on: :create
+ after_commit :notify_after_destroy, on: :destroy
class << self
def model_name
@@ -279,6 +281,10 @@ class Note < ApplicationRecord
!for_personal_snippet?
end
+ def for_design?
+ noteable_type == DesignManagement::Design.name
+ end
+
def for_issuable?
for_issue? || for_merge_request?
end
@@ -505,6 +511,14 @@ class Note < ApplicationRecord
noteable_object
end
+ def notify_after_create
+ noteable&.after_note_created(self)
+ end
+
+ def notify_after_destroy
+ noteable&.after_note_destroyed(self)
+ end
+
def banzai_render_context(field)
super.merge(noteable: noteable, system_note: system?)
end
diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb
index 486da2c6b45..da5e4012f05 100644
--- a/app/models/pages_domain.rb
+++ b/app/models/pages_domain.rb
@@ -2,6 +2,7 @@
class PagesDomain < ApplicationRecord
include Presentable
+ include FromUnion
VERIFICATION_KEY = 'gitlab-pages-verification-code'
VERIFICATION_THRESHOLD = 3.days.freeze
@@ -58,12 +59,14 @@ class PagesDomain < ApplicationRecord
end
scope :need_auto_ssl_renewal, -> do
- expiring = where(certificate_valid_not_after: nil).or(
- where(arel_table[:certificate_valid_not_after].lt(SSL_RENEWAL_THRESHOLD.from_now)))
+ enabled_and_not_failed = where(auto_ssl_enabled: true, auto_ssl_failed: false)
- user_provided_or_expiring = certificate_user_provided.or(expiring)
+ user_provided = enabled_and_not_failed.certificate_user_provided
+ certificate_not_valid = enabled_and_not_failed.where(certificate_valid_not_after: nil)
+ certificate_expiring = enabled_and_not_failed
+ .where(arel_table[:certificate_valid_not_after].lt(SSL_RENEWAL_THRESHOLD.from_now))
- where(auto_ssl_enabled: true).merge(user_provided_or_expiring)
+ from_union([user_provided, certificate_not_valid, certificate_expiring])
end
scope :for_removal, -> { where("remove_at < ?", Time.now) }
diff --git a/app/models/performance_monitoring/prometheus_dashboard.rb b/app/models/performance_monitoring/prometheus_dashboard.rb
index 30fb1935a27..57222c61b36 100644
--- a/app/models/performance_monitoring/prometheus_dashboard.rb
+++ b/app/models/performance_monitoring/prometheus_dashboard.rb
@@ -4,7 +4,7 @@ module PerformanceMonitoring
class PrometheusDashboard
include ActiveModel::Model
- attr_accessor :dashboard, :panel_groups, :path, :environment, :priority
+ attr_accessor :dashboard, :panel_groups, :path, :environment, :priority, :templating
validates :dashboard, presence: true
validates :panel_groups, presence: true
diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb
index af079f7ebc4..7afee2a35cb 100644
--- a/app/models/personal_access_token.rb
+++ b/app/models/personal_access_token.rb
@@ -4,6 +4,7 @@ class PersonalAccessToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
include Sortable
+ extend ::Gitlab::Utils::Override
add_authentication_token_field :token, digest: true
@@ -23,6 +24,8 @@ class PersonalAccessToken < ApplicationRecord
scope :without_impersonation, -> { where(impersonation: false) }
scope :for_user, -> (user) { where(user: user) }
scope :preload_users, -> { preload(:user) }
+ scope :order_expires_at_asc, -> { reorder(expires_at: :asc) }
+ scope :order_expires_at_desc, -> { reorder(expires_at: :desc) }
validates :scopes, presence: true
validate :validate_scopes
@@ -39,12 +42,14 @@ class PersonalAccessToken < ApplicationRecord
def self.redis_getdel(user_id)
Gitlab::Redis::SharedState.with do |redis|
- encrypted_token = redis.get(redis_shared_state_key(user_id))
- redis.del(redis_shared_state_key(user_id))
+ redis_key = redis_shared_state_key(user_id)
+ encrypted_token = redis.get(redis_key)
+ redis.del(redis_key)
+
begin
Gitlab::CryptoHelper.aes256_gcm_decrypt(encrypted_token)
rescue => ex
- logger.warn "Failed to decrypt PersonalAccessToken value stored in Redis for User ##{user_id}: #{ex.class}"
+ logger.warn "Failed to decrypt #{self.name} value stored in Redis for key ##{redis_key}: #{ex.class}"
encrypted_token
end
end
@@ -58,6 +63,16 @@ class PersonalAccessToken < ApplicationRecord
end
end
+ override :simple_sorts
+ def self.simple_sorts
+ super.merge(
+ {
+ 'expires_at_asc' => -> { order_expires_at_asc },
+ 'expires_at_desc' => -> { order_expires_at_desc }
+ }
+ )
+ end
+
protected
def validate_scopes
diff --git a/app/models/personal_snippet.rb b/app/models/personal_snippet.rb
index 1b5be8698b1..197795dccfe 100644
--- a/app/models/personal_snippet.rb
+++ b/app/models/personal_snippet.rb
@@ -2,4 +2,8 @@
class PersonalSnippet < Snippet
include WithUploads
+
+ def skip_project_check?
+ true
+ end
end
diff --git a/app/models/plan.rb b/app/models/plan.rb
new file mode 100644
index 00000000000..acac5f9aeae
--- /dev/null
+++ b/app/models/plan.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+class Plan < ApplicationRecord
+ DEFAULT = 'default'.freeze
+
+ has_one :limits, class_name: 'PlanLimits'
+
+ ALL_PLANS = [DEFAULT].freeze
+ DEFAULT_PLANS = [DEFAULT].freeze
+ private_constant :ALL_PLANS, :DEFAULT_PLANS
+
+ # This always returns an object
+ def self.default
+ Gitlab::SafeRequestStore.fetch(:plan_default) do
+ # find_by allows us to find object (cheaply) against replica DB
+ # safe_find_or_create_by does stick to primary DB
+ find_by(name: DEFAULT) || safe_find_or_create_by(name: DEFAULT)
+ end
+ end
+
+ def self.all_plans
+ ALL_PLANS
+ end
+
+ def self.default_plans
+ DEFAULT_PLANS
+ end
+
+ def actual_limits
+ self.limits || PlanLimits.new
+ end
+
+ def default?
+ self.class.default_plans.include?(name)
+ end
+
+ def paid?
+ false
+ end
+end
+
+Plan.prepend_if_ee('EE::Plan')
diff --git a/app/models/plan_limits.rb b/app/models/plan_limits.rb
new file mode 100644
index 00000000000..575105cfd79
--- /dev/null
+++ b/app/models/plan_limits.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class PlanLimits < ApplicationRecord
+ belongs_to :plan
+
+ def exceeded?(limit_name, object)
+ return false unless enabled?(limit_name)
+
+ if object.is_a?(Integer)
+ object >= read_attribute(limit_name)
+ else
+ # object.count >= limit value is slower than checking
+ # if a record exists at the limit value - 1 position.
+ object.offset(read_attribute(limit_name) - 1).exists?
+ end
+ end
+
+ private
+
+ def enabled?(limit_name)
+ read_attribute(limit_name) > 0
+ end
+end
diff --git a/app/models/project.rb b/app/models/project.rb
index 5db349463d8..c0dd2eb8584 100644
--- a/app/models/project.rb
+++ b/app/models/project.rb
@@ -3,6 +3,7 @@
require 'carrierwave/orm/activerecord'
class Project < ApplicationRecord
+ extend ::Gitlab::Utils::Override
include Gitlab::ConfigHelper
include Gitlab::VisibilityLevel
include AccessRequestable
@@ -18,6 +19,7 @@ class Project < ApplicationRecord
include SelectForProjectAuthorization
include Presentable
include HasRepository
+ include HasWiki
include Routable
include GroupDescendant
include Gitlab::SQL::Pattern
@@ -175,6 +177,7 @@ class Project < ApplicationRecord
has_one :packagist_service
has_one :hangouts_chat_service
has_one :unify_circuit_service
+ has_one :webex_teams_service
has_one :root_of_fork_network,
foreign_key: 'root_project_id',
@@ -206,12 +209,14 @@ class Project < ApplicationRecord
has_many :services
has_many :events
has_many :milestones
+ has_many :iterations
has_many :notes
has_many :snippets, class_name: 'ProjectSnippet'
has_many :hooks, class_name: 'ProjectHook'
has_many :protected_branches
has_many :protected_tags
has_many :repository_languages, -> { order "share DESC" }
+ has_many :designs, inverse_of: :project, class_name: 'DesignManagement::Design'
has_many :project_authorizations
has_many :authorized_users, through: :project_authorizations, source: :user, class_name: 'User'
@@ -254,6 +259,9 @@ class Project < ApplicationRecord
has_many :prometheus_alerts, inverse_of: :project
has_many :prometheus_alert_events, inverse_of: :project
has_many :self_managed_prometheus_alert_events, inverse_of: :project
+ has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :project
+
+ has_many :alert_management_alerts, class_name: 'AlertManagement::Alert', inverse_of: :project
# Container repositories need to remove data from the container registry,
# which is not managed by the DB. Hence we're still using dependent: :destroy
@@ -295,6 +303,7 @@ class Project < ApplicationRecord
has_many :project_deploy_tokens
has_many :deploy_tokens, through: :project_deploy_tokens
has_many :resource_groups, class_name: 'Ci::ResourceGroup', inverse_of: :project
+ has_many :freeze_periods, class_name: 'Ci::FreezePeriod', inverse_of: :project
has_one :auto_devops, class_name: 'ProjectAutoDevops', inverse_of: :project, autosave: true
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
@@ -315,10 +324,13 @@ class Project < ApplicationRecord
has_many :import_failures, inverse_of: :project
has_many :jira_imports, -> { order 'jira_imports.created_at' }, class_name: 'JiraImportState', inverse_of: :project
- has_many :daily_report_results, class_name: 'Ci::DailyReportResult'
+ has_many :daily_build_group_report_results, class_name: 'Ci::DailyBuildGroupReportResult'
+
+ has_many :repository_storage_moves, class_name: 'ProjectRepositoryStorageMove'
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
+ accepts_nested_attributes_for :project_setting, update_only: true
accepts_nested_attributes_for :import_data
accepts_nested_attributes_for :auto_devops, update_only: true
accepts_nested_attributes_for :ci_cd_settings, update_only: true
@@ -342,6 +354,9 @@ class Project < ApplicationRecord
:wiki_access_level, :snippets_access_level, :builds_access_level,
:repository_access_level, :pages_access_level, :metrics_dashboard_access_level,
to: :project_feature, allow_nil: true
+ delegate :show_default_award_emojis, :show_default_award_emojis=,
+ :show_default_award_emojis?,
+ to: :project_setting, allow_nil: true
delegate :scheduled?, :started?, :in_progress?, :failed?, :finished?,
prefix: :import, to: :import_state, allow_nil: true
delegate :no_import?, to: :import_state, allow_nil: true
@@ -355,6 +370,7 @@ class Project < ApplicationRecord
delegate :external_dashboard_url, to: :metrics_setting, allow_nil: true, prefix: true
delegate :default_git_depth, :default_git_depth=, to: :ci_cd_settings, prefix: :ci
delegate :forward_deployment_enabled, :forward_deployment_enabled=, :forward_deployment_enabled?, to: :ci_cd_settings
+ delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true
# Validations
validates :creator, presence: true, on: :create
@@ -386,7 +402,6 @@ class Project < ApplicationRecord
validate :check_repository_path_availability, on: :update, if: ->(project) { project.renamed? }
validate :visibility_level_allowed_by_group, if: :should_validate_visibility_level?
validate :visibility_level_allowed_as_fork, if: :should_validate_visibility_level?
- validate :check_wiki_path_conflict
validate :validate_pages_https_only, if: -> { changes.has_key?(:pages_https_only) }
validates :repository_storage,
presence: true,
@@ -515,12 +530,14 @@ class Project < ApplicationRecord
def self.public_or_visible_to_user(user = nil, min_access_level = nil)
min_access_level = nil if user&.admin?
- if user
+ return public_to_user unless user
+
+ if user.is_a?(DeployToken)
+ user.projects
+ else
where('EXISTS (?) OR projects.visibility_level IN (?)',
user.authorizations_for_projects(min_access_level: min_access_level),
Gitlab::VisibilityLevel.levels_for_user(user))
- else
- public_to_user
end
end
@@ -785,6 +802,11 @@ class Project < ApplicationRecord
Feature.enabled?(:jira_issue_import, self, default_enabled: true)
end
+ # LFS and hashed repository storage are required for using Design Management.
+ def design_management_enabled?
+ lfs_enabled? && hashed_storage?(:repository)
+ end
+
def team
@team ||= ProjectTeam.new(self)
end
@@ -793,6 +815,12 @@ class Project < ApplicationRecord
@repository ||= Repository.new(full_path, self, shard: repository_storage, disk_path: disk_path)
end
+ def design_repository
+ strong_memoize(:design_repository) do
+ DesignManagement::Repository.new(self)
+ end
+ end
+
def cleanup
@repository = nil
end
@@ -819,7 +847,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_ref(ref)
return unless latest_pipeline
- latest_pipeline.builds.latest.with_artifacts_archive.find_by(name: job_name)
+ latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name)
end
def latest_successful_build_for_sha(job_name, sha)
@@ -828,7 +856,7 @@ class Project < ApplicationRecord
latest_pipeline = ci_pipelines.latest_successful_for_sha(sha)
return unless latest_pipeline
- latest_pipeline.builds.latest.with_artifacts_archive.find_by(name: job_name)
+ latest_pipeline.builds.latest.with_downloadable_artifacts.find_by(name: job_name)
end
def latest_successful_build_for_ref!(job_name, ref = default_branch)
@@ -865,10 +893,12 @@ class Project < ApplicationRecord
raise Projects::ImportService::Error, _('Jira import feature is disabled.') unless jira_issues_import_feature_flag_enabled?
raise Projects::ImportService::Error, _('Jira integration not configured.') unless jira_service&.active?
- return unless user
+ if user
+ raise Projects::ImportService::Error, _('Cannot import because issues are not available in this project.') unless feature_available?(:issues, user)
+ raise Projects::ImportService::Error, _('You do not have permissions to run the import.') unless user.can?(:admin_project, self)
+ end
- raise Projects::ImportService::Error, _('Cannot import because issues are not available in this project.') unless feature_available?(:issues, user)
- raise Projects::ImportService::Error, _('You do not have permissions to run the import.') unless user.can?(:admin_project, self)
+ raise Projects::ImportService::Error, _('Unable to connect to the Jira instance. Please check your Jira integration configuration.') unless jira_service.test(nil)[:success]
end
def human_import_status_name
@@ -1056,16 +1086,6 @@ class Project < ApplicationRecord
self.errors.add(:visibility_level, _("%{level_name} is not allowed since the fork source project has lower visibility.") % { level_name: level_name })
end
- def check_wiki_path_conflict
- return if path.blank?
-
- path_to_check = path.ends_with?('.wiki') ? path.chomp('.wiki') : "#{path}.wiki"
-
- if Project.where(namespace_id: namespace_id, path: path_to_check).exists?
- errors.add(:name, _('has already been taken'))
- end
- end
-
def pages_https_only
return false unless Gitlab.config.pages.external_https
@@ -1179,11 +1199,7 @@ class Project < ApplicationRecord
end
def issues_tracker
- if external_issue_tracker
- external_issue_tracker
- else
- default_issue_tracker
- end
+ external_issue_tracker || default_issue_tracker
end
def external_issue_reference_pattern
@@ -1328,11 +1344,7 @@ class Project < ApplicationRecord
# rubocop: enable CodeReuse/ServiceClass
def owner
- if group
- group
- else
- namespace.try(:owner)
- end
+ group || namespace.try(:owner)
end
def to_ability_name
@@ -1432,15 +1444,12 @@ class Project < ApplicationRecord
# Expires various caches before a project is renamed.
def expire_caches_before_rename(old_path)
- repo = Repository.new(old_path, self, shard: repository_storage)
- wiki = Repository.new("#{old_path}.wiki", self, shard: repository_storage, repo_type: Gitlab::GlRepository::WIKI)
+ project_repo = Repository.new(old_path, self, shard: repository_storage)
+ wiki_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::WIKI.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::WIKI)
+ design_repo = Repository.new("#{old_path}#{Gitlab::GlRepository::DESIGN.path_suffix}", self, shard: repository_storage, repo_type: Gitlab::GlRepository::DESIGN)
- if repo.exists?
- repo.before_delete
- end
-
- if wiki.exists?
- wiki.before_delete
+ [project_repo, wiki_repo, design_repo].each do |repo|
+ repo.before_delete if repo.exists?
end
end
@@ -1517,6 +1526,10 @@ class Project < ApplicationRecord
end
end
+ def bots
+ users.project_bot
+ end
+
# Filters `users` to return only authorized users of the project
def members_among(users)
if users.is_a?(ActiveRecord::Relation) && !users.loaded?
@@ -1565,10 +1578,6 @@ class Project < ApplicationRecord
create_repository(force: true) unless repository_exists?
end
- def wiki_repository_exists?
- wiki.repository_exists?
- end
-
# update visibility_level of forks
def update_forks_visibility_level
return if unlink_forks_upon_visibility_decrease_enabled?
@@ -1582,20 +1591,6 @@ class Project < ApplicationRecord
end
end
- def create_wiki
- ProjectWiki.new(self, self.owner).wiki
- true
- rescue ProjectWiki::CouldNotCreateWikiError
- errors.add(:base, _('Failed create wiki'))
- false
- end
-
- def wiki
- strong_memoize(:wiki) do
- ProjectWiki.new(self, self.owner)
- end
- end
-
def allowed_to_share_with_group?
!namespace.share_with_group_lock
end
@@ -2024,6 +2019,14 @@ class Project < ApplicationRecord
end
end
+ def ci_instance_variables_for(ref:)
+ if protected_for?(ref)
+ Ci::InstanceVariable.all_cached
+ else
+ Ci::InstanceVariable.unprotected_cached
+ end
+ end
+
def protected_for?(ref)
raise Repository::AmbiguousRefError if repository.ambiguous_ref?(ref)
@@ -2085,7 +2088,12 @@ class Project < ApplicationRecord
raise ArgumentError unless ::Gitlab.config.repositories.storages.key?(new_repository_storage_key)
- run_after_commit { ProjectUpdateRepositoryStorageWorker.perform_async(id, new_repository_storage_key) }
+ storage_move = repository_storage_moves.create!(
+ source_storage_name: repository_storage,
+ destination_storage_name: new_repository_storage_key
+ )
+ storage_move.schedule!
+
self.repository_read_only = true
end
@@ -2425,6 +2433,11 @@ class Project < ApplicationRecord
jira_imports.last
end
+ override :after_wiki_activity
+ def after_wiki_activity
+ touch(:last_activity_at, :last_repository_updated_at)
+ end
+
private
def find_service(services, name)
diff --git a/app/models/project_authorization.rb b/app/models/project_authorization.rb
index e81d9d0f5fe..366852d93bf 100644
--- a/app/models/project_authorization.rb
+++ b/app/models/project_authorization.rb
@@ -2,7 +2,6 @@
class ProjectAuthorization < ApplicationRecord
include FromUnion
- prepend_if_ee('::EE::ProjectAuthorization') # rubocop: disable Cop/InjectEnterpriseEditionModule
belongs_to :user
belongs_to :project
@@ -30,3 +29,5 @@ class ProjectAuthorization < ApplicationRecord
end
end
end
+
+ProjectAuthorization.prepend_if_ee('::EE::ProjectAuthorization')
diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb
index 39e177e8bd8..c295837002a 100644
--- a/app/models/project_ci_cd_setting.rb
+++ b/app/models/project_ci_cd_setting.rb
@@ -37,8 +37,6 @@ class ProjectCiCdSetting < ApplicationRecord
private
def set_default_git_depth
- return unless Feature.enabled?(:ci_set_project_default_git_depth, default_enabled: true)
-
self.default_git_depth ||= DEFAULT_GIT_DEPTH
end
end
diff --git a/app/models/project_feature.rb b/app/models/project_feature.rb
index 31a3fa12c00..9201cd24d66 100644
--- a/app/models/project_feature.rb
+++ b/app/models/project_feature.rb
@@ -23,7 +23,7 @@ class ProjectFeature < ApplicationRecord
PUBLIC = 30
FEATURES = %i(issues forking merge_requests wiki snippets builds repository pages metrics_dashboard).freeze
- PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER }.freeze
+ PRIVATE_FEATURES_MIN_ACCESS_LEVEL = { merge_requests: Gitlab::Access::REPORTER, metrics_dashboard: Gitlab::Access::REPORTER }.freeze
PRIVATE_FEATURES_MIN_ACCESS_LEVEL_FOR_PRIVATE_PROJECT = { repository: Gitlab::Access::REPORTER }.freeze
STRING_OPTIONS = HashWithIndifferentAccess.new({
'disabled' => DISABLED,
diff --git a/app/models/project_repository_storage_move.rb b/app/models/project_repository_storage_move.rb
new file mode 100644
index 00000000000..e88cc5cfca6
--- /dev/null
+++ b/app/models/project_repository_storage_move.rb
@@ -0,0 +1,58 @@
+# frozen_string_literal: true
+
+# ProjectRepositoryStorageMove are details of repository storage moves for a
+# project. For example, moving a project to another gitaly node to help
+# balance storage capacity.
+class ProjectRepositoryStorageMove < ApplicationRecord
+ include AfterCommitQueue
+
+ belongs_to :project, inverse_of: :repository_storage_moves
+
+ validates :project, presence: true
+ validates :state, presence: true
+ validates :source_storage_name,
+ on: :create,
+ presence: true,
+ inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
+ validates :destination_storage_name,
+ on: :create,
+ presence: true,
+ inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } }
+
+ state_machine initial: :initial do
+ event :schedule do
+ transition initial: :scheduled
+ end
+
+ event :start do
+ transition scheduled: :started
+ end
+
+ event :finish do
+ transition started: :finished
+ end
+
+ event :do_fail do
+ transition [:initial, :scheduled, :started] => :failed
+ end
+
+ after_transition initial: :scheduled do |storage_move, _|
+ storage_move.run_after_commit do
+ ProjectUpdateRepositoryStorageWorker.perform_async(
+ storage_move.project_id,
+ storage_move.destination_storage_name,
+ storage_move.id
+ )
+ end
+ end
+
+ state :initial, value: 1
+ state :scheduled, value: 2
+ state :started, value: 3
+ state :finished, value: 4
+ state :failed, value: 5
+ end
+
+ scope :order_created_at_desc, -> { order(created_at: :desc) }
+ scope :with_projects, -> { includes(project: :route) }
+end
diff --git a/app/models/project_services/chat_message/merge_message.rb b/app/models/project_services/chat_message/merge_message.rb
index dc62a4c8908..0a2d9120adc 100644
--- a/app/models/project_services/chat_message/merge_message.rb
+++ b/app/models/project_services/chat_message/merge_message.rb
@@ -2,8 +2,6 @@
module ChatMessage
class MergeMessage < BaseMessage
- prepend_if_ee('::EE::ChatMessage::MergeMessage') # rubocop: disable Cop/InjectEnterpriseEditionModule
-
attr_reader :merge_request_iid
attr_reader :source_branch
attr_reader :target_branch
@@ -71,3 +69,5 @@ module ChatMessage
end
end
end
+
+ChatMessage::MergeMessage.prepend_if_ee('::EE::ChatMessage::MergeMessage')
diff --git a/app/models/project_services/chat_message/pipeline_message.rb b/app/models/project_services/chat_message/pipeline_message.rb
index 50b982a803f..1cd3837433f 100644
--- a/app/models/project_services/chat_message/pipeline_message.rb
+++ b/app/models/project_services/chat_message/pipeline_message.rb
@@ -52,8 +52,6 @@ module ChatMessage
def attachments
return message if markdown
- return [{ text: format(message), color: attachment_color }] unless fancy_notifications?
-
[{
fallback: format(message),
color: attachment_color,
@@ -103,10 +101,6 @@ module ChatMessage
failed_jobs.uniq { |job| job[:name] }.reverse
end
- def fancy_notifications?
- Feature.enabled?(:fancy_pipeline_slack_notifications, default_enabled: true)
- end
-
def failed_stages_field
{
title: s_("ChatMessage|Failed stage").pluralize(failed_stages.length),
@@ -166,42 +160,22 @@ module ChatMessage
end
def humanized_status
- if fancy_notifications?
- case status
- when 'success'
- detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed")
- when 'failed'
- s_("ChatMessage|has failed")
- else
- status
- end
+ case status
+ when 'success'
+ detailed_status == "passed with warnings" ? s_("ChatMessage|has passed with warnings") : s_("ChatMessage|has passed")
+ when 'failed'
+ s_("ChatMessage|has failed")
else
- case status
- when 'success'
- s_("ChatMessage|passed")
- when 'failed'
- s_("ChatMessage|failed")
- else
- status
- end
+ status
end
end
def attachment_color
- if fancy_notifications?
- case status
- when 'success'
- detailed_status == 'passed with warnings' ? 'warning' : 'good'
- else
- 'danger'
- end
+ case status
+ when 'success'
+ detailed_status == 'passed with warnings' ? 'warning' : 'good'
else
- case status
- when 'success'
- 'good'
- else
- 'danger'
- end
+ 'danger'
end
end
@@ -230,7 +204,7 @@ module ChatMessage
end
def pipeline_url
- if fancy_notifications? && failed_jobs.any?
+ if failed_jobs.any?
pipeline_failed_jobs_url
else
"#{project_url}/pipelines/#{pipeline_id}"
diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb
index eaddac9cce3..53da874ede8 100644
--- a/app/models/project_services/jira_service.rb
+++ b/app/models/project_services/jira_service.rb
@@ -25,6 +25,11 @@ class JiraService < IssueTrackerService
before_update :reset_password
+ enum comment_detail: {
+ standard: 1,
+ all_details: 2
+ }
+
alias_method :project_url, :url
# When these are false GitLab does not create cross reference
@@ -172,6 +177,7 @@ class JiraService < IssueTrackerService
noteable_id = noteable.respond_to?(:iid) ? noteable.iid : noteable.id
noteable_type = noteable_name(noteable)
entity_url = build_entity_url(noteable_type, noteable_id)
+ entity_meta = build_entity_meta(noteable)
data = {
user: {
@@ -180,12 +186,15 @@ class JiraService < IssueTrackerService
},
project: {
name: project.full_path,
- url: resource_url(namespace_project_path(project.namespace, project)) # rubocop:disable Cop/ProjectPathHelper
+ url: resource_url(project_path(project))
},
entity: {
+ id: entity_meta[:id],
name: noteable_type.humanize.downcase,
url: entity_url,
- title: noteable.title
+ title: noteable.title,
+ description: entity_meta[:description],
+ branch: entity_meta[:branch]
}
}
@@ -259,14 +268,11 @@ class JiraService < IssueTrackerService
end
def add_comment(data, issue)
- user_name = data[:user][:name]
- user_url = data[:user][:url]
entity_name = data[:entity][:name]
entity_url = data[:entity][:url]
entity_title = data[:entity][:title]
- project_name = data[:project][:name]
- message = "[#{user_name}|#{user_url}] mentioned this issue in [a #{entity_name} of #{project_name}|#{entity_url}]:\n'#{entity_title.chomp}'"
+ message = comment_message(data)
link_title = "#{entity_name.capitalize} - #{entity_title}"
link_props = build_remote_link_props(url: entity_url, title: link_title)
@@ -275,6 +281,37 @@ class JiraService < IssueTrackerService
end
end
+ def comment_message(data)
+ user_link = build_jira_link(data[:user][:name], data[:user][:url])
+
+ entity = data[:entity]
+ entity_ref = all_details? ? "#{entity[:name]} #{entity[:id]}" : "a #{entity[:name]}"
+ entity_link = build_jira_link(entity_ref, entity[:url])
+
+ project_link = build_jira_link(project.full_name, Gitlab::Routing.url_helpers.project_url(project))
+ branch =
+ if entity[:branch].present?
+ s_('JiraService| on branch %{branch_link}') % {
+ branch_link: build_jira_link(entity[:branch], project_tree_url(project, entity[:branch]))
+ }
+ end
+
+ entity_message = entity[:description].presence if all_details?
+ entity_message ||= entity[:title].chomp
+
+ s_('JiraService|%{user_link} mentioned this issue in %{entity_link} of %{project_link}%{branch}:{quote}%{entity_message}{quote}') % {
+ user_link: user_link,
+ entity_link: entity_link,
+ project_link: project_link,
+ branch: branch,
+ entity_message: entity_message
+ }
+ end
+
+ def build_jira_link(title, url)
+ "[#{title}|#{url}]"
+ end
+
def has_resolution?(issue)
issue.respond_to?(:resolution) && issue.resolution.present?
end
@@ -348,6 +385,23 @@ class JiraService < IssueTrackerService
)
end
+ def build_entity_meta(noteable)
+ if noteable.is_a?(Commit)
+ {
+ id: noteable.short_id,
+ description: noteable.safe_message,
+ branch: noteable.ref_names(project.repository).first
+ }
+ elsif noteable.is_a?(MergeRequest)
+ {
+ id: noteable.to_reference,
+ branch: noteable.source_branch
+ }
+ else
+ {}
+ end
+ end
+
def noteable_name(noteable)
name = noteable.model_name.singular
diff --git a/app/models/project_services/mattermost_slash_commands_service.rb b/app/models/project_services/mattermost_slash_commands_service.rb
index ca324f68d2d..0fd85e3a5a9 100644
--- a/app/models/project_services/mattermost_slash_commands_service.rb
+++ b/app/models/project_services/mattermost_slash_commands_service.rb
@@ -36,6 +36,10 @@ class MattermostSlashCommandsService < SlashCommandsService
[[], e.message]
end
+ def chat_responder
+ ::Gitlab::Chat::Responder::Mattermost
+ end
+
private
def command(params)
diff --git a/app/models/project_services/mock_monitoring_service.rb b/app/models/project_services/mock_monitoring_service.rb
index bcf8f1df5da..25ae0f6b60d 100644
--- a/app/models/project_services/mock_monitoring_service.rb
+++ b/app/models/project_services/mock_monitoring_service.rb
@@ -14,7 +14,7 @@ class MockMonitoringService < MonitoringService
end
def metrics(environment)
- JSON.parse(File.read(Rails.root + 'spec/fixtures/metrics.json'))
+ Gitlab::Json.parse(File.read(Rails.root + 'spec/fixtures/metrics.json'))
end
def can_test?
diff --git a/app/models/project_services/webex_teams_service.rb b/app/models/project_services/webex_teams_service.rb
new file mode 100644
index 00000000000..1d791b19486
--- /dev/null
+++ b/app/models/project_services/webex_teams_service.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class WebexTeamsService < ChatNotificationService
+ def title
+ 'Webex Teams'
+ end
+
+ def description
+ 'Receive event notifications in Webex Teams'
+ end
+
+ def self.to_param
+ 'webex_teams'
+ end
+
+ def help
+ 'This service sends notifications about projects events to a Webex Teams conversation.<br />
+ To set up this service:
+ <ol>
+ <li><a href="https://apphub.webex.com/teams/applications/incoming-webhooks-cisco-systems">Set up an incoming webhook for your conversation</a>. All notifications will come to this conversation.</li>
+ <li>Paste the <strong>Webhook URL</strong> into the field below.</li>
+ <li>Select events below to enable notifications.</li>
+ </ol>'
+ end
+
+ def event_field(event)
+ end
+
+ def default_channel_placeholder
+ end
+
+ def self.supported_events
+ %w[push issue confidential_issue merge_request note confidential_note tag_push
+ pipeline wiki_page]
+ end
+
+ def default_fields
+ [
+ { type: 'text', name: 'webhook', placeholder: "e.g. https://api.ciscospark.com/v1/webhooks/incoming/…", required: true },
+ { type: 'checkbox', name: 'notify_only_broken_pipelines' },
+ { type: 'select', name: 'branches_to_be_notified', choices: branch_choices }
+ ]
+ end
+
+ private
+
+ def notify(message, opts)
+ header = { 'Content-Type' => 'application/json' }
+ response = Gitlab::HTTP.post(webhook, headers: header, body: { markdown: message.pretext }.to_json)
+
+ response if response.success?
+ end
+
+ def custom_data(data)
+ super(data).merge(markdown: true)
+ end
+end
diff --git a/app/models/project_services/youtrack_service.rb b/app/models/project_services/youtrack_service.rb
index 0815e27850d..40203ad692d 100644
--- a/app/models/project_services/youtrack_service.rb
+++ b/app/models/project_services/youtrack_service.rb
@@ -27,8 +27,8 @@ class YoutrackService < IssueTrackerService
def fields
[
{ type: 'text', name: 'description', placeholder: description },
- { type: 'text', name: 'project_url', placeholder: 'Project url', required: true },
- { type: 'text', name: 'issues_url', placeholder: 'Issue url', required: true }
+ { type: 'text', name: 'project_url', title: 'Project URL', placeholder: 'Project URL', required: true },
+ { type: 'text', name: 'issues_url', title: 'Issue URL', placeholder: 'Issue URL', required: true }
]
end
end
diff --git a/app/models/project_statistics.rb b/app/models/project_statistics.rb
index b71ed75dde6..6f04a36392d 100644
--- a/app/models/project_statistics.rb
+++ b/app/models/project_statistics.rb
@@ -21,6 +21,9 @@ class ProjectStatistics < ApplicationRecord
scope :for_project_ids, ->(project_ids) { where(project_id: project_ids) }
+ scope :for_namespaces, -> (namespaces) { where(namespace: namespaces) }
+ scope :with_any_ci_minutes_used, -> { where.not(shared_runners_seconds: 0) }
+
def total_repository_size
repository_size + lfs_objects_size
end
diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb
index 708b45cf5f0..5df0a33dc9a 100644
--- a/app/models/project_wiki.rb
+++ b/app/models/project_wiki.rb
@@ -1,219 +1,17 @@
# frozen_string_literal: true
-class ProjectWiki
- include Storage::LegacyProjectWiki
- include Gitlab::Utils::StrongMemoize
+class ProjectWiki < Wiki
+ alias_method :project, :container
- MARKUPS = {
- 'Markdown' => :markdown,
- 'RDoc' => :rdoc,
- 'AsciiDoc' => :asciidoc,
- 'Org' => :org
- }.freeze unless defined?(MARKUPS)
+ # Project wikis are tied to the main project storage
+ delegate :storage, :repository_storage, :hashed_storage?, to: :container
- CouldNotCreateWikiError = Class.new(StandardError)
- SIDEBAR = '_sidebar'
-
- TITLE_ORDER = 'title'
- CREATED_AT_ORDER = 'created_at'
- DIRECTION_DESC = 'desc'
- DIRECTION_ASC = 'asc'
-
- attr_reader :project, :user
-
- # Returns a string describing what went wrong after
- # an operation fails.
- attr_reader :error_message
-
- def initialize(project, user = nil)
- @project = project
- @user = user
- end
-
- delegate :repository_storage, :hashed_storage?, to: :project
-
- def path
- @project.path + '.wiki'
- end
-
- def full_path
- @project.full_path + '.wiki'
- end
- alias_method :id, :full_path
-
- # @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem
- alias_method :path_with_namespace, :full_path
-
- def web_url(only_path: nil)
- Gitlab::UrlBuilder.build(self, only_path: only_path)
- end
-
- def url_to_repo
- ssh_url_to_repo
- end
-
- def ssh_url_to_repo
- Gitlab::RepositoryUrlBuilder.build(repository.full_path, protocol: :ssh)
- end
-
- def http_url_to_repo
- Gitlab::RepositoryUrlBuilder.build(repository.full_path, protocol: :http)
- end
-
- def wiki_base_path
- [Gitlab.config.gitlab.relative_url_root, '/', @project.full_path, '/-', '/wikis'].join('')
- end
-
- # Returns the Gitlab::Git::Wiki object.
- def wiki
- strong_memoize(:wiki) do
- repository.create_if_not_exists
- raise CouldNotCreateWikiError unless repository_exists?
-
- Gitlab::Git::Wiki.new(repository.raw)
- end
- rescue => err
- Gitlab::ErrorTracking.track_exception(err, project_wiki: { project_id: project.id, full_path: full_path, disk_path: disk_path })
- raise CouldNotCreateWikiError
- end
-
- def repository_exists?
- !!repository.exists?
- end
-
- def has_home_page?
- !!find_page('home')
- end
-
- def empty?
- list_pages(limit: 1).empty?
- end
-
- def exists?
- !empty?
- end
-
- # Lists wiki pages of the repository.
- #
- # limit - max number of pages returned by the method.
- # sort - criterion by which the pages are sorted.
- # direction - order of the sorted pages.
- # load_content - option, which specifies whether the content inside the page
- # will be loaded.
- #
- # Returns an Array of GitLab WikiPage instances or an
- # empty Array if this Wiki has no pages.
- def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false)
- wiki.list_pages(
- limit: limit,
- sort: sort,
- direction_desc: direction == DIRECTION_DESC,
- load_content: load_content
- ).map do |page|
- WikiPage.new(self, page)
- end
- end
-
- # Finds a page within the repository based on a tile
- # or slug.
- #
- # title - The human readable or parameterized title of
- # the page.
- #
- # Returns an initialized WikiPage instance or nil
- def find_page(title, version = nil)
- page_title, page_dir = page_title_and_dir(title)
-
- if page = wiki.page(title: page_title, version: version, dir: page_dir)
- WikiPage.new(self, page)
- end
- end
-
- def find_sidebar(version = nil)
- find_page(SIDEBAR, version)
- end
-
- def find_file(name, version = nil)
- wiki.file(name, version)
- end
-
- def create_page(title, content, format = :markdown, message = nil)
- commit = commit_details(:created, message, title)
-
- wiki.write_page(title, format.to_sym, content, commit)
-
- update_project_activity
- rescue Gitlab::Git::Wiki::DuplicatePageError => e
- @error_message = "Duplicate page: #{e.message}"
- false
- end
-
- def update_page(page, content:, title: nil, format: :markdown, message: nil)
- commit = commit_details(:updated, message, page.title)
-
- wiki.update_page(page.path, title || page.name, format.to_sym, content, commit)
-
- update_project_activity
- end
-
- def delete_page(page, message = nil)
- return unless page
-
- wiki.delete_page(page.path, commit_details(:deleted, message, page.title))
-
- update_project_activity
- end
-
- def page_title_and_dir(title)
- return unless title
-
- title_array = title.split("/")
- title = title_array.pop
- [title, title_array.join("/")]
- end
-
- def repository
- @repository ||= Repository.new(full_path, @project, shard: repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::WIKI)
- end
-
- def default_branch
- wiki.class.default_ref
- end
-
- def ensure_repository
- raise CouldNotCreateWikiError unless wiki.repository_exists?
- end
-
- def hook_attrs
- {
- web_url: web_url,
- git_ssh_url: ssh_url_to_repo,
- git_http_url: http_url_to_repo,
- path_with_namespace: full_path,
- default_branch: default_branch
- }
- end
-
- private
-
- def commit_details(action, message = nil, title = nil)
- commit_message = message.presence || default_message(action, title)
- git_user = Gitlab::Git::User.from_gitlab(user)
-
- Gitlab::Git::Wiki::CommitDetails.new(user.id,
- git_user.username,
- git_user.name,
- git_user.email,
- commit_message)
- end
-
- def default_message(action, title)
- "#{user.username} #{action} page: #{title}"
- end
-
- def update_project_activity
- @project.touch(:last_activity_at, :last_repository_updated_at)
+ override :disk_path
+ def disk_path(*args, &block)
+ container.disk_path + '.wiki'
end
end
+# TODO: Remove this once we implement ES support for group wikis.
+# https://gitlab.com/gitlab-org/gitlab/-/issues/207889
ProjectWiki.prepend_if_ee('EE::ProjectWiki')
diff --git a/app/models/release.rb b/app/models/release.rb
index 403087a2cad..a0245105cd9 100644
--- a/app/models/release.rb
+++ b/app/models/release.rb
@@ -34,8 +34,6 @@ class Release < ApplicationRecord
delegate :repository, to: :project
- after_commit :notify_new_release, on: :create, unless: :importing?
-
MAX_NUMBER_TO_DISPLAY = 3
def to_param
@@ -81,14 +79,6 @@ class Release < ApplicationRecord
self.milestones.map {|m| m.title }.sort.join(", ")
end
- def evidence_sha
- evidences.first&.summary_sha
- end
-
- def evidence_summary
- evidences.first&.summary || {}
- end
-
private
def actual_sha
@@ -100,10 +90,6 @@ class Release < ApplicationRecord
repository.find_tag(tag)
end
end
-
- def notify_new_release
- NewReleaseWorker.perform_async(id)
- end
end
Release.prepend_if_ee('EE::Release')
diff --git a/app/models/remote_mirror.rb b/app/models/remote_mirror.rb
index 0334d63dd36..8e7612e63c8 100644
--- a/app/models/remote_mirror.rb
+++ b/app/models/remote_mirror.rb
@@ -106,7 +106,23 @@ class RemoteMirror < ApplicationRecord
update_status == 'started'
end
- def update_repository(options)
+ def update_repository
+ Gitlab::Git::RemoteMirror.new(
+ project.repository.raw,
+ remote_name,
+ **options_for_update
+ ).update
+ end
+
+ def options_for_update
+ options = {
+ keep_divergent_refs: keep_divergent_refs?
+ }
+
+ if only_protected_branches?
+ options[:only_branches_matching] = project.protected_branches.pluck(:name)
+ end
+
if ssh_mirror_url?
if ssh_key_auth? && ssh_private_key.present?
options[:ssh_key] = ssh_private_key
@@ -117,13 +133,7 @@ class RemoteMirror < ApplicationRecord
end
end
- options[:keep_divergent_refs] = keep_divergent_refs?
-
- Gitlab::Git::RemoteMirror.new(
- project.repository.raw,
- remote_name,
- **options
- ).update
+ options
end
def sync?
diff --git a/app/models/repository.rb b/app/models/repository.rb
index a9ef0504a3d..2673033ff1f 100644
--- a/app/models/repository.rb
+++ b/app/models/repository.rb
@@ -1120,6 +1120,17 @@ class Repository
end
end
+ # TODO: pass this in directly to `Blob` rather than delegating it to here
+ #
+ # https://gitlab.com/gitlab-org/gitlab/-/issues/201886
+ def lfs_enabled?
+ if container.is_a?(Project)
+ container.lfs_enabled?
+ else
+ false # LFS is not supported for snippet or group repositories
+ end
+ end
+
private
# TODO Genericize finder, later split this on finders by Ref or Oid
diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb
index cd47c154eef..845be408d5e 100644
--- a/app/models/resource_label_event.rb
+++ b/app/models/resource_label_event.rb
@@ -2,16 +2,14 @@
class ResourceLabelEvent < ResourceEvent
include CacheMarkdownField
+ include IssueResourceEvent
+ include MergeRequestResourceEvent
cache_markdown_field :reference
- belongs_to :issue
- belongs_to :merge_request
belongs_to :label
scope :inc_relations, -> { includes(:label, :user) }
- scope :by_issue, ->(issue) { where(issue_id: issue.id) }
- scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
validates :label, presence: { unless: :importing? }, on: :create
validate :exactly_one_issuable
diff --git a/app/models/resource_milestone_event.rb b/app/models/resource_milestone_event.rb
index a40af22061e..039f26d8e3f 100644
--- a/app/models/resource_milestone_event.rb
+++ b/app/models/resource_milestone_event.rb
@@ -2,14 +2,11 @@
class ResourceMilestoneEvent < ResourceEvent
include IgnorableColumns
+ include IssueResourceEvent
+ include MergeRequestResourceEvent
- belongs_to :issue
- belongs_to :merge_request
belongs_to :milestone
- scope :by_issue, ->(issue) { where(issue_id: issue.id) }
- scope :by_merge_request, ->(merge_request) { where(merge_request_id: merge_request.id) }
-
validate :exactly_one_issuable
enum action: {
@@ -25,4 +22,8 @@ class ResourceMilestoneEvent < ResourceEvent
def self.issuable_attrs
%i(issue merge_request).freeze
end
+
+ def milestone_title
+ milestone&.title
+ end
end
diff --git a/app/models/resource_state_event.rb b/app/models/resource_state_event.rb
new file mode 100644
index 00000000000..1d6573b180f
--- /dev/null
+++ b/app/models/resource_state_event.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class ResourceStateEvent < ResourceEvent
+ include IssueResourceEvent
+ include MergeRequestResourceEvent
+
+ validate :exactly_one_issuable
+
+ # state is used for issue and merge request states.
+ enum state: Issue.available_states.merge(MergeRequest.available_states).merge(reopened: 5)
+
+ def self.issuable_attrs
+ %i(issue merge_request).freeze
+ end
+end
diff --git a/app/models/resource_weight_event.rb b/app/models/resource_weight_event.rb
index e0cc0c87a83..bbabd54325e 100644
--- a/app/models/resource_weight_event.rb
+++ b/app/models/resource_weight_event.rb
@@ -3,7 +3,5 @@
class ResourceWeightEvent < ResourceEvent
validates :issue, presence: true
- belongs_to :issue
-
- scope :by_issue, ->(issue) { where(issue_id: issue.id) }
+ include IssueResourceEvent
end
diff --git a/app/models/sent_notification.rb b/app/models/sent_notification.rb
index f3a9293376f..4165d3b753f 100644
--- a/app/models/sent_notification.rb
+++ b/app/models/sent_notification.rb
@@ -76,12 +76,14 @@ class SentNotification < ApplicationRecord
def position=(new_position)
if new_position.is_a?(String)
- new_position = JSON.parse(new_position) rescue nil
+ new_position = Gitlab::Json.parse(new_position) rescue nil
end
if new_position.is_a?(Hash)
new_position = new_position.with_indifferent_access
new_position = Gitlab::Diff::Position.new(new_position)
+ else
+ new_position = nil
end
super(new_position)
diff --git a/app/models/service.rb b/app/models/service.rb
index 543869c71d6..fb4d9a77077 100644
--- a/app/models/service.rb
+++ b/app/models/service.rb
@@ -12,7 +12,7 @@ class Service < ApplicationRecord
alerts asana assembla bamboo bugzilla buildkite campfire custom_issue_tracker discord
drone_ci emails_on_push external_wiki flowdock hangouts_chat hipchat irker jira
mattermost mattermost_slash_commands microsoft_teams packagist pipelines_email
- pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit youtrack
+ pivotaltracker prometheus pushover redmine slack slack_slash_commands teamcity unify_circuit webex_teams youtrack
].freeze
DEV_SERVICE_NAMES = %w[
@@ -81,6 +81,10 @@ class Service < ApplicationRecord
active
end
+ def operating?
+ active && persisted?
+ end
+
def show_active_box?
true
end
@@ -345,14 +349,6 @@ class Service < ApplicationRecord
service
end
- def deprecated?
- false
- end
-
- def deprecation_message
- nil
- end
-
# override if needed
def supports_data_fields?
false
diff --git a/app/models/snippet.rb b/app/models/snippet.rb
index dbf600cf0df..72ebdf61787 100644
--- a/app/models/snippet.rb
+++ b/app/models/snippet.rb
@@ -15,9 +15,11 @@ class Snippet < ApplicationRecord
include FromUnion
include IgnorableColumns
include HasRepository
+ include AfterCommitQueue
extend ::Gitlab::Utils::Override
- MAX_FILE_COUNT = 1
+ MAX_FILE_COUNT = 10
+ MAX_SINGLE_FILE_COUNT = 1
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@@ -101,6 +103,10 @@ class Snippet < ApplicationRecord
where(project_id: nil)
end
+ def self.only_project_snippets
+ where.not(project_id: nil)
+ end
+
def self.only_include_projects_visible_to(current_user = nil)
levels = Gitlab::VisibilityLevel.levels_for_user(current_user)
@@ -164,6 +170,10 @@ class Snippet < ApplicationRecord
Snippet.find_by(id: id, project: project)
end
+ def self.max_file_limit(user)
+ Feature.enabled?(:snippet_multiple_files, user) ? MAX_FILE_COUNT : MAX_SINGLE_FILE_COUNT
+ end
+
def initialize(attributes = {})
# We can't use default_value_for because the database has a default
# value of 0 for visibility_level. If someone attempts to create a
@@ -199,7 +209,7 @@ class Snippet < ApplicationRecord
def blobs
return [] unless repository_exists?
- repository.ls_files(repository.root_ref).map { |file| Blob.lazy(self, repository.root_ref, file) }
+ repository.ls_files(repository.root_ref).map { |file| Blob.lazy(repository, repository.root_ref, file) }
end
def hook_attrs
@@ -318,8 +328,10 @@ class Snippet < ApplicationRecord
Digest::SHA256.hexdigest("#{title}#{description}#{created_at}#{updated_at}")
end
- def versioned_enabled_for?(user)
- ::Feature.enabled?(:version_snippets, user) && repository_exists?
+ def file_name_on_repo
+ return if repository.empty?
+
+ repository.ls_files(repository.root_ref).first
end
class << self
@@ -334,17 +346,6 @@ class Snippet < ApplicationRecord
fuzzy_search(query, [:title, :description, :file_name])
end
- # Searches for snippets with matching content.
- #
- # This method uses ILIKE on PostgreSQL and LIKE on MySQL.
- #
- # query - The search query as a String.
- #
- # Returns an ActiveRecord::Relation.
- def search_code(query)
- fuzzy_search(query, [:content])
- end
-
def parent_class
::Project
end
diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb
index e60dbb4d141..2276851b7a1 100644
--- a/app/models/snippet_repository.rb
+++ b/app/models/snippet_repository.rb
@@ -7,6 +7,8 @@ class SnippetRepository < ApplicationRecord
EMPTY_FILE_PATTERN = /^#{DEFAULT_EMPTY_FILE_NAME}(\d+)\.txt$/.freeze
CommitError = Class.new(StandardError)
+ InvalidPathError = Class.new(CommitError)
+ InvalidSignatureError = Class.new(CommitError)
belongs_to :snippet, inverse_of: :snippet_repository
@@ -40,8 +42,12 @@ class SnippetRepository < ApplicationRecord
rescue Gitlab::Git::Index::IndexError,
Gitlab::Git::CommitError,
Gitlab::Git::PreReceiveError,
- Gitlab::Git::CommandError => e
- raise CommitError, e.message
+ Gitlab::Git::CommandError,
+ ArgumentError => error
+
+ logger.error(message: "Snippet git error. Reason: #{error.message}", snippet: snippet.id)
+
+ raise commit_error_exception(error)
end
def transform_file_entries(files)
@@ -85,4 +91,24 @@ class SnippetRepository < ApplicationRecord
def build_empty_file_name(index)
"#{DEFAULT_EMPTY_FILE_NAME}#{index}.txt"
end
+
+ def commit_error_exception(err)
+ if invalid_path_error?(err)
+ InvalidPathError.new('Invalid file name') # To avoid returning the message with the path included
+ elsif invalid_signature_error?(err)
+ InvalidSignatureError.new(err.message)
+ else
+ CommitError.new(err.message)
+ end
+ end
+
+ def invalid_path_error?(err)
+ err.is_a?(Gitlab::Git::Index::IndexError) &&
+ err.message.downcase.start_with?('invalid path', 'path cannot include directory traversal')
+ end
+
+ def invalid_signature_error?(err)
+ err.is_a?(ArgumentError) &&
+ err.message.downcase.match?(/failed to parse signature/)
+ end
end
diff --git a/app/models/ssh_host_key.rb b/app/models/ssh_host_key.rb
index 9bd35d30845..72690ad7d04 100644
--- a/app/models/ssh_host_key.rb
+++ b/app/models/ssh_host_key.rb
@@ -24,6 +24,7 @@ class SshHostKey
# This is achieved by making the lifetime shorter than the refresh interval.
self.reactive_cache_refresh_interval = 15.minutes
self.reactive_cache_lifetime = 10.minutes
+ self.reactive_cache_work_type = :external_dependency
def self.find_by(opts = {})
opts = HashWithIndifferentAccess.new(opts)
diff --git a/app/models/state_note.rb b/app/models/state_note.rb
new file mode 100644
index 00000000000..cbcb1c2b49d
--- /dev/null
+++ b/app/models/state_note.rb
@@ -0,0 +1,19 @@
+# frozen_string_literal: true
+
+class StateNote < SyntheticNote
+ def self.from_event(event, resource: nil, resource_parent: nil)
+ attrs = note_attributes(event.state, event, resource, resource_parent)
+
+ StateNote.new(attrs)
+ end
+
+ def note_html
+ @note_html ||= "<p dir=\"auto\">#{note_text(html: true)}</p>"
+ end
+
+ private
+
+ def note_text(html: false)
+ event.state
+ end
+end
diff --git a/app/models/storage/hashed.rb b/app/models/storage/hashed.rb
index 3dea50ab98b..c61cd3b6b30 100644
--- a/app/models/storage/hashed.rb
+++ b/app/models/storage/hashed.rb
@@ -6,6 +6,7 @@ module Storage
delegate :gitlab_shell, :repository_storage, to: :container
REPOSITORY_PATH_PREFIX = '@hashed'
+ GROUP_REPOSITORY_PATH_PREFIX = '@groups'
SNIPPET_REPOSITORY_PATH_PREFIX = '@snippets'
POOL_PATH_PREFIX = '@pools'
diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb
index b881a43ad4d..4e14bb4e92c 100644
--- a/app/models/system_note_metadata.rb
+++ b/app/models/system_note_metadata.rb
@@ -15,6 +15,7 @@ class SystemNoteMetadata < ApplicationRecord
ICON_TYPES = %w[
commit description merge confidential visible label assignee cross_reference
+ designs_added designs_modified designs_removed designs_discussion_added
title time_tracking branch milestone discussion task moved
opened closed merged duplicate locked unlocked outdated
tag due_date pinned_embed cherry_pick health_status
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
index f52dd74d4c9..c0aac6f27aa 100644
--- a/app/models/timelog.rb
+++ b/app/models/timelog.rb
@@ -16,8 +16,8 @@ class Timelog < ApplicationRecord
)
end
- scope :between_dates, -> (start_date, end_date) do
- where('spent_at BETWEEN ? AND ?', start_date, end_date)
+ scope :between_times, -> (start_time, end_time) do
+ where('spent_at BETWEEN ? AND ?', start_time, end_time)
end
def issuable
diff --git a/app/models/todo.rb b/app/models/todo.rb
index d337ef33051..dc42551f0ab 100644
--- a/app/models/todo.rb
+++ b/app/models/todo.rb
@@ -110,7 +110,7 @@ class Todo < ApplicationRecord
base = where.not(state: new_state).except(:order)
ids = base.pluck(:id)
- base.update_all(state: new_state)
+ base.update_all(state: new_state, updated_at: Time.now)
ids
end
@@ -183,6 +183,10 @@ class Todo < ApplicationRecord
target_type == "Commit"
end
+ def for_design?
+ target_type == DesignManagement::Design.name
+ end
+
# override to return commits, which are not active record
def target
if for_commit?
diff --git a/app/models/user.rb b/app/models/user.rb
index 1b087da3a2f..b2d3978551e 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -24,6 +24,7 @@ class User < ApplicationRecord
include HasUniqueInternalUsers
include IgnorableColumns
include UpdateHighestRole
+ include HasUserType
DEFAULT_NOTIFICATION_LEVEL = :participating
@@ -57,6 +58,10 @@ class User < ApplicationRecord
devise :lockable, :recoverable, :rememberable, :trackable,
:validatable, :omniauthable, :confirmable, :registerable
+ # This module adds async behaviour to Devise emails
+ # and should be added after Devise modules are initialized.
+ include AsyncDeviseEmail
+
BLOCKED_MESSAGE = "Your account has been blocked. Please contact your GitLab " \
"administrator if you think this is an error."
LOGIN_FORBIDDEN = "Your account does not have the required permission to login. Please contact your GitLab " \
@@ -64,9 +69,8 @@ class User < ApplicationRecord
MINIMUM_INACTIVE_DAYS = 180
- enum user_type: ::UserTypeEnums.types
-
- ignore_column :bot_type, remove_with: '12.11', remove_after: '2020-04-22'
+ ignore_column :bot_type, remove_with: '13.1', remove_after: '2020-05-22'
+ ignore_column :ghost, remove_with: '13.2', remove_after: '2020-06-22'
# Override Devise::Models::Trackable#update_tracked_fields!
# to limit database writes to at most once every hour
@@ -88,6 +92,9 @@ class User < ApplicationRecord
# Virtual attribute for authenticating by either username or email
attr_accessor :login
+ # Virtual attribute for impersonator
+ attr_accessor :impersonator
+
#
# Relations
#
@@ -166,6 +173,8 @@ class User < ApplicationRecord
has_many :term_agreements
belongs_to :accepted_term, class_name: 'ApplicationSetting::Term'
+ has_many :metrics_users_starred_dashboards, class_name: 'Metrics::UsersStarredDashboard', inverse_of: :user
+
has_one :status, class_name: 'UserStatus'
has_one :user_preference
has_one :user_detail
@@ -246,15 +255,12 @@ class User < ApplicationRecord
enum layout: { fixed: 0, fluid: 1 }
# User's Dashboard preference
- # Note: When adding an option, it MUST go on the end of the array.
enum dashboard: { projects: 0, stars: 1, project_activity: 2, starred_project_activity: 3, groups: 4, todos: 5, issues: 6, merge_requests: 7, operations: 8 }
# User's Project preference
- # Note: When adding an option, it MUST go on the end of the array.
enum project_view: { readme: 0, activity: 1, files: 2 }
# User's role
- # Note: When adding an option, it MUST go on the end of the array.
enum role: { software_developer: 0, development_team_lead: 1, devops_engineer: 2, systems_administrator: 3, security_analyst: 4, data_analyst: 5, product_manager: 6, product_designer: 7, other: 8 }, _suffix: true
delegate :path, to: :namespace, allow_nil: true, prefix: true
@@ -321,32 +327,26 @@ class User < ApplicationRecord
scope :admins, -> { where(admin: true) }
scope :blocked, -> { with_states(:blocked, :ldap_blocked) }
scope :external, -> { where(external: true) }
+ scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :active, -> { with_state(:active).non_internal }
scope :active_without_ghosts, -> { with_state(:active).without_ghosts }
- scope :without_ghosts, -> { where('ghost IS NOT TRUE') }
scope :deactivated, -> { with_state(:deactivated).non_internal }
scope :without_projects, -> { joins('LEFT JOIN project_authorizations ON users.id = project_authorizations.user_id').where(project_authorizations: { user_id: nil }) }
- scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
- scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
- scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }
- scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) }
- scope :confirmed, -> { where.not(confirmed_at: nil) }
scope :by_username, -> (usernames) { iwhere(username: Array(usernames).map(&:to_s)) }
scope :for_todos, -> (todos) { where(id: todos.select(:user_id)) }
scope :with_emails, -> { preload(:emails) }
scope :with_dashboard, -> (dashboard) { where(dashboard: dashboard) }
scope :with_public_profile, -> { where(private_profile: false) }
- scope :bots, -> { where(user_type: UserTypeEnums.bots.values) }
- scope :bots_without_project_bot, -> { bots.where.not(user_type: UserTypeEnums.bots[:project_bot]) }
- scope :with_project_bots, -> { humans.or(where.not(user_type: UserTypeEnums.bots.except(:project_bot).values)) }
- scope :humans, -> { where(user_type: nil) }
-
scope :with_expiring_and_not_notified_personal_access_tokens, ->(at) do
where('EXISTS (?)',
::PersonalAccessToken
.where('personal_access_tokens.user_id = users.id')
.expiring_and_not_notified(at).select(1))
end
+ scope :order_recent_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'DESC')) }
+ scope :order_oldest_sign_in, -> { reorder(Gitlab::Database.nulls_last_order('current_sign_in_at', 'ASC')) }
+ scope :order_recent_last_activity, -> { reorder(Gitlab::Database.nulls_last_order('last_activity_on', 'DESC')) }
+ scope :order_oldest_last_activity, -> { reorder(Gitlab::Database.nulls_first_order('last_activity_on', 'ASC')) }
def active_for_authentication?
super && can?(:log_in)
@@ -624,7 +624,7 @@ class User < ApplicationRecord
# owns records previously belonging to deleted users.
def ghost
email = 'ghost%s@example.com'
- unique_internal(where(ghost: true, user_type: :ghost), 'ghost', email) do |u|
+ unique_internal(where(user_type: :ghost), 'ghost', email) do |u|
u.bio = _('This is a "Ghost User", created to hold all issues authored by users that have since been deleted. This user cannot be removed.')
u.name = 'Ghost User'
end
@@ -639,6 +639,16 @@ class User < ApplicationRecord
end
end
+ def migration_bot
+ email_pattern = "noreply+gitlab-migration-bot%s@#{Settings.gitlab.host}"
+
+ unique_internal(where(user_type: :migration_bot), 'migration-bot', email_pattern) do |u|
+ u.bio = 'The GitLab migration bot'
+ u.name = 'GitLab Migration Bot'
+ u.confirmed_at = Time.zone.now
+ end
+ end
+
# Return true if there is only single non-internal user in the deployment,
# ghost user is ignored.
def single_user?
@@ -650,43 +660,14 @@ class User < ApplicationRecord
end
end
- def full_path
- username
- end
-
- def bot?
- UserTypeEnums.bots.has_key?(user_type)
- end
-
- # The explicit check for project_bot will be removed with Bot Categorization
- # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
- def internal?
- ghost? || (bot? && !project_bot?)
- end
-
- # We are transitioning from ghost boolean column to user_type
- # so we need to read from old column for now
- # @see https://gitlab.com/gitlab-org/gitlab/-/issues/210025
- def ghost?
- ghost
- end
-
- # The explicit check for project_bot will be removed with Bot Categorization
- # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
- def self.internal
- where(ghost: true).or(bots_without_project_bot)
- end
-
- # The explicit check for project_bot will be removed with Bot Categorization
- # Ref: https://gitlab.com/gitlab-org/gitlab/-/issues/213945
- def self.non_internal
- without_ghosts.with_project_bots
- end
-
#
# Instance methods
#
+ def full_path
+ username
+ end
+
def to_param
username
end
@@ -1700,16 +1681,6 @@ class User < ApplicationRecord
callouts.any?
end
- def gitlab_employee?
- strong_memoize(:gitlab_employee) do
- if Feature.enabled?(:gitlab_employee_badge) && Gitlab.com?
- Mail::Address.new(email).domain == "gitlab.com" && confirmed?
- else
- false
- end
- end
- end
-
# Load the current highest access by looking directly at the user's memberships
def current_highest_access_level
members.non_request.maximum(:access_level)
@@ -1719,8 +1690,8 @@ class User < ApplicationRecord
!confirmed? && !confirmation_period_valid?
end
- def organization
- gitlab_employee? ? 'GitLab' : super
+ def impersonated?
+ impersonator.present?
end
protected
@@ -1779,13 +1750,6 @@ class User < ApplicationRecord
ApplicationSetting.current_without_cache&.usage_stats_set_by_user_id == self.id
end
- # Added according to https://github.com/plataformatec/devise/blob/7df57d5081f9884849ca15e4fde179ef164a575f/README.md#activejob-integration
- def send_devise_notification(notification, *args)
- return true unless can?(:receive_notifications)
-
- devise_mailer.__send__(notification, self, *args).deliver_later # rubocop:disable GitlabSecurity/PublicSend
- end
-
def ensure_user_rights_and_limits
if external?
self.can_create_group = false
@@ -1834,7 +1798,6 @@ class User < ApplicationRecord
end
def check_email_restrictions
- return unless Feature.enabled?(:email_restrictions)
return unless Gitlab::CurrentSettings.email_restrictions_enabled?
restrictions = Gitlab::CurrentSettings.email_restrictions
diff --git a/app/models/user_type_enums.rb b/app/models/user_type_enums.rb
deleted file mode 100644
index cb5aac89ed3..00000000000
--- a/app/models/user_type_enums.rb
+++ /dev/null
@@ -1,13 +0,0 @@
-# frozen_string_literal: true
-
-module UserTypeEnums
- def self.types
- @types ||= bots.merge(human: nil, ghost: 5)
- end
-
- def self.bots
- @bots ||= { alert_bot: 2, project_bot: 6 }.with_indifferent_access
- end
-end
-
-UserTypeEnums.prepend_if_ee('EE::UserTypeEnums')
diff --git a/app/models/wiki.rb b/app/models/wiki.rb
new file mode 100644
index 00000000000..54bcec32095
--- /dev/null
+++ b/app/models/wiki.rb
@@ -0,0 +1,233 @@
+# frozen_string_literal: true
+
+class Wiki
+ extend ::Gitlab::Utils::Override
+ include HasRepository
+ include Gitlab::Utils::StrongMemoize
+
+ MARKUPS = { # rubocop:disable Style/MultilineIfModifier
+ 'Markdown' => :markdown,
+ 'RDoc' => :rdoc,
+ 'AsciiDoc' => :asciidoc,
+ 'Org' => :org
+ }.freeze unless defined?(MARKUPS)
+
+ CouldNotCreateWikiError = Class.new(StandardError)
+
+ HOMEPAGE = 'home'
+ SIDEBAR = '_sidebar'
+
+ TITLE_ORDER = 'title'
+ CREATED_AT_ORDER = 'created_at'
+ DIRECTION_DESC = 'desc'
+ DIRECTION_ASC = 'asc'
+
+ attr_reader :container, :user
+
+ # Returns a string describing what went wrong after
+ # an operation fails.
+ attr_reader :error_message
+
+ def self.for_container(container, user = nil)
+ "#{container.class.name}Wiki".constantize.new(container, user)
+ end
+
+ def initialize(container, user = nil)
+ @container = container
+ @user = user
+ end
+
+ def path
+ container.path + '.wiki'
+ end
+
+ # Returns the Gitlab::Git::Wiki object.
+ def wiki
+ strong_memoize(:wiki) do
+ create_wiki_repository
+ Gitlab::Git::Wiki.new(repository.raw)
+ end
+ end
+
+ def create_wiki_repository
+ repository.create_if_not_exists
+
+ raise CouldNotCreateWikiError unless repository_exists?
+ rescue => err
+ Gitlab::ErrorTracking.track_exception(err, wiki: {
+ container_type: container.class.name,
+ container_id: container.id,
+ full_path: full_path,
+ disk_path: disk_path
+ })
+
+ raise CouldNotCreateWikiError
+ end
+
+ def has_home_page?
+ !!find_page(HOMEPAGE)
+ end
+
+ def empty?
+ list_pages(limit: 1).empty?
+ end
+
+ def exists?
+ !empty?
+ end
+
+ # Lists wiki pages of the repository.
+ #
+ # limit - max number of pages returned by the method.
+ # sort - criterion by which the pages are sorted.
+ # direction - order of the sorted pages.
+ # load_content - option, which specifies whether the content inside the page
+ # will be loaded.
+ #
+ # Returns an Array of GitLab WikiPage instances or an
+ # empty Array if this Wiki has no pages.
+ def list_pages(limit: 0, sort: nil, direction: DIRECTION_ASC, load_content: false)
+ wiki.list_pages(
+ limit: limit,
+ sort: sort,
+ direction_desc: direction == DIRECTION_DESC,
+ load_content: load_content
+ ).map do |page|
+ WikiPage.new(self, page)
+ end
+ end
+
+ def sidebar_entries(limit: Gitlab::WikiPages::MAX_SIDEBAR_PAGES, **options)
+ pages = list_pages(**options.merge(limit: limit + 1))
+ limited = pages.size > limit
+ pages = pages.first(limit) if limited
+
+ [WikiPage.group_by_directory(pages), limited]
+ end
+
+ # Finds a page within the repository based on a tile
+ # or slug.
+ #
+ # title - The human readable or parameterized title of
+ # the page.
+ #
+ # Returns an initialized WikiPage instance or nil
+ def find_page(title, version = nil)
+ page_title, page_dir = page_title_and_dir(title)
+
+ if page = wiki.page(title: page_title, version: version, dir: page_dir)
+ WikiPage.new(self, page)
+ end
+ end
+
+ def find_sidebar(version = nil)
+ find_page(SIDEBAR, version)
+ end
+
+ def find_file(name, version = nil)
+ wiki.file(name, version)
+ end
+
+ def create_page(title, content, format = :markdown, message = nil)
+ commit = commit_details(:created, message, title)
+
+ wiki.write_page(title, format.to_sym, content, commit)
+
+ update_container_activity
+ rescue Gitlab::Git::Wiki::DuplicatePageError => e
+ @error_message = "Duplicate page: #{e.message}"
+ false
+ end
+
+ def update_page(page, content:, title: nil, format: :markdown, message: nil)
+ commit = commit_details(:updated, message, page.title)
+
+ wiki.update_page(page.path, title || page.name, format.to_sym, content, commit)
+
+ update_container_activity
+ end
+
+ def delete_page(page, message = nil)
+ return unless page
+
+ wiki.delete_page(page.path, commit_details(:deleted, message, page.title))
+
+ update_container_activity
+ end
+
+ def page_title_and_dir(title)
+ return unless title
+
+ title_array = title.split("/")
+ title = title_array.pop
+ [title, title_array.join("/")]
+ end
+
+ def ensure_repository
+ raise CouldNotCreateWikiError unless wiki.repository_exists?
+ end
+
+ def hook_attrs
+ {
+ web_url: web_url,
+ git_ssh_url: ssh_url_to_repo,
+ git_http_url: http_url_to_repo,
+ path_with_namespace: full_path,
+ default_branch: default_branch
+ }
+ end
+
+ override :repository
+ def repository
+ @repository ||= Repository.new(full_path, container, shard: repository_storage, disk_path: disk_path, repo_type: Gitlab::GlRepository::WIKI)
+ end
+
+ def repository_storage
+ raise NotImplementedError
+ end
+
+ def hashed_storage?
+ raise NotImplementedError
+ end
+
+ override :full_path
+ def full_path
+ container.full_path + '.wiki'
+ end
+ alias_method :id, :full_path
+
+ # @deprecated use full_path when you need it for an URL route or disk_path when you want to point to the filesystem
+ alias_method :path_with_namespace, :full_path
+
+ override :default_branch
+ def default_branch
+ wiki.class.default_ref
+ end
+
+ def wiki_base_path
+ Gitlab.config.gitlab.relative_url_root + web_url(only_path: true).sub(%r{/#{Wiki::HOMEPAGE}\z}, '')
+ end
+
+ private
+
+ def commit_details(action, message = nil, title = nil)
+ commit_message = message.presence || default_message(action, title)
+ git_user = Gitlab::Git::User.from_gitlab(user)
+
+ Gitlab::Git::Wiki::CommitDetails.new(user.id,
+ git_user.username,
+ git_user.name,
+ git_user.email,
+ commit_message)
+ end
+
+ def default_message(action, title)
+ "#{user.username} #{action} page: #{title}"
+ end
+
+ def update_container_activity
+ container.after_wiki_activity
+ end
+end
+
+Wiki.prepend_if_ee('EE::Wiki')
diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb
index 9c887fc87f3..319cdd38d93 100644
--- a/app/models/wiki_page.rb
+++ b/app/models/wiki_page.rb
@@ -26,7 +26,7 @@ class WikiPage
def eql?(other)
return false unless other.present? && other.is_a?(self.class)
- slug == other.slug && wiki.project == other.wiki.project
+ slug == other.slug && wiki.container == other.wiki.container
end
alias_method :==, :eql?
@@ -66,9 +66,9 @@ class WikiPage
validates :content, presence: true
validate :validate_path_limits, if: :title_changed?
- # The GitLab ProjectWiki instance.
+ # The GitLab Wiki instance.
attr_reader :wiki
- delegate :project, to: :wiki
+ delegate :container, to: :wiki
# The raw Gitlab::Git::WikiPage instance.
attr_reader :page
@@ -83,7 +83,7 @@ class WikiPage
# Construct a new WikiPage
#
- # @param [ProjectWiki] wiki
+ # @param [Wiki] wiki
# @param [Gitlab::Git::WikiPage] page
def initialize(wiki, page = nil)
@wiki = wiki
@@ -95,29 +95,29 @@ class WikiPage
# The escaped URL path of this page.
def slug
- @attributes[:slug].presence || wiki.wiki.preview_slug(title, format)
+ attributes[:slug].presence || wiki.wiki.preview_slug(title, format)
end
alias_method :to_param, :slug
def human_title
- return 'Home' if title == 'home'
+ return 'Home' if title == Wiki::HOMEPAGE
title
end
# The formatted title of this page.
def title
- @attributes[:title] || ''
+ attributes[:title] || ''
end
# Sets the title of this page.
def title=(new_title)
- @attributes[:title] = new_title
+ attributes[:title] = new_title
end
def raw_content
- @attributes[:content] ||= @page&.text_data
+ attributes[:content] ||= page&.text_data
end
# The hierarchy of the directory this page is contained in.
@@ -127,7 +127,7 @@ class WikiPage
# The markup format for the page.
def format
- @attributes[:format] || :markdown
+ attributes[:format] || :markdown
end
# The commit message for this page version.
@@ -151,13 +151,13 @@ class WikiPage
def versions(options = {})
return [] unless persisted?
- wiki.wiki.page_versions(@page.path, options)
+ wiki.wiki.page_versions(page.path, options)
end
def count_versions
return [] unless persisted?
- wiki.wiki.count_page_versions(@page.path)
+ wiki.wiki.count_page_versions(page.path)
end
def last_version
@@ -173,7 +173,7 @@ class WikiPage
def historical?
return false unless last_commit_sha && version
- @page.historical? && last_commit_sha != version.sha
+ page.historical? && last_commit_sha != version.sha
end
# Returns boolean True or False if this instance
@@ -185,7 +185,7 @@ class WikiPage
# Returns boolean True or False if this instance
# has been fully created on disk or not.
def persisted?
- @page.present?
+ page.present?
end
# Creates a new Wiki Page.
@@ -195,7 +195,7 @@ class WikiPage
# :content - The raw markup content.
# :format - Optional symbol representing the
# content format. Can be any type
- # listed in the ProjectWiki::MARKUPS
+ # listed in the Wiki::MARKUPS
# Hash.
# :message - Optional commit message to set on
# the new page.
@@ -215,7 +215,7 @@ class WikiPage
# attrs - Hash of attributes to be updated on the page.
# :content - The raw markup content to replace the existing.
# :format - Optional symbol representing the content format.
- # See ProjectWiki::MARKUPS Hash for available formats.
+ # See Wiki::MARKUPS Hash for available formats.
# :message - Optional commit message to set on the new version.
# :last_commit_sha - Optional last commit sha to validate the page unchanged.
# :title - The Title (optionally including dir) to replace existing title
@@ -232,13 +232,13 @@ class WikiPage
update_attributes(attrs)
if title.present? && title_changed? && wiki.find_page(title).present?
- @attributes[:title] = @page.title
+ attributes[:title] = page.title
raise PageRenameError
end
save do
wiki.update_page(
- @page,
+ page,
content: raw_content,
format: format,
message: attrs[:message],
@@ -251,7 +251,7 @@ class WikiPage
#
# Returns boolean True or False.
def delete
- if wiki.delete_page(@page)
+ if wiki.delete_page(page)
true
else
false
@@ -261,6 +261,7 @@ class WikiPage
# Relative path to the partial to be used when rendering collections
# of this object.
def to_partial_path
+ # TODO: Move into shared/ with https://gitlab.com/gitlab-org/gitlab/-/issues/196054
'projects/wikis/wiki_page'
end
@@ -270,7 +271,7 @@ class WikiPage
def title_changed?
if persisted?
- old_title, old_dir = wiki.page_title_and_dir(self.class.unhyphenize(@page.url_path))
+ old_title, old_dir = wiki.page_title_and_dir(self.class.unhyphenize(page.url_path))
new_title, new_dir = wiki.page_title_and_dir(self.class.unhyphenize(title))
new_title != old_title || (title.include?('/') && new_dir != old_dir)
@@ -287,13 +288,17 @@ class WikiPage
attrs.slice!(:content, :format, :message, :title)
clear_memoization(:parsed_content) if attrs.has_key?(:content)
- @attributes.merge!(attrs)
+ attributes.merge!(attrs)
end
def to_ability_name
'wiki_page'
end
+ def version_commit_timestamp
+ version&.commit&.committed_date
+ end
+
private
def serialize_front_matter(hash)
@@ -303,7 +308,7 @@ class WikiPage
end
def update_front_matter(attrs)
- return unless Gitlab::WikiPages::FrontMatterParser.enabled?(project)
+ return unless Gitlab::WikiPages::FrontMatterParser.enabled?(container)
return unless attrs.has_key?(:front_matter)
fm_yaml = serialize_front_matter(attrs[:front_matter])
@@ -314,7 +319,7 @@ class WikiPage
def parsed_content
strong_memoize(:parsed_content) do
- Gitlab::WikiPages::FrontMatterParser.new(raw_content, project).parse
+ Gitlab::WikiPages::FrontMatterParser.new(raw_content, container).parse
end
end
@@ -325,7 +330,7 @@ class WikiPage
title = deep_title_squish(title)
current_dirname = File.dirname(title)
- if @page.present?
+ if persisted?
return title[1..-1] if current_dirname == '/'
return File.join([directory.presence, title].compact) if current_dirname == '.'
end
@@ -362,9 +367,11 @@ class WikiPage
end
def validate_path_limits
- *dirnames, title = @attributes[:title].split('/')
+ return unless title.present?
+
+ *dirnames, filename = title.split('/')
- if title && title.bytesize > Gitlab::WikiPages::MAX_TITLE_BYTES
+ if filename && filename.bytesize > Gitlab::WikiPages::MAX_TITLE_BYTES
errors.add(:title, _("exceeds the limit of %{bytes} bytes") % {
bytes: Gitlab::WikiPages::MAX_TITLE_BYTES
})
diff --git a/app/models/wiki_page/meta.rb b/app/models/wiki_page/meta.rb
index 2af7d86ebcc..474968122b1 100644
--- a/app/models/wiki_page/meta.rb
+++ b/app/models/wiki_page/meta.rb
@@ -5,6 +5,7 @@ class WikiPage
include Gitlab::Utils::StrongMemoize
CanonicalSlugConflictError = Class.new(ActiveRecord::RecordInvalid)
+ WikiPageInvalid = Class.new(ArgumentError)
self.table_name = 'wiki_page_meta'
@@ -23,46 +24,62 @@ class WikiPage
alias_method :resource_parent, :project
- # Return the (updated) WikiPage::Meta record for a given wiki page
- #
- # If none is found, then a new record is created, and its fields are set
- # to reflect the wiki_page passed.
- #
- # @param [String] last_known_slug
- # @param [WikiPage] wiki_page
- #
- # As with all `find_or_create` methods, this one raises errors on
- # validation issues.
- def self.find_or_create(last_known_slug, wiki_page)
- project = wiki_page.wiki.project
- known_slugs = [last_known_slug, wiki_page.slug].compact.uniq
- raise 'no slugs!' if known_slugs.empty?
-
- transaction do
- found = find_by_canonical_slug(known_slugs, project)
- meta = found || create(title: wiki_page.title, project_id: project.id)
-
- meta.update_state(found.nil?, known_slugs, wiki_page)
-
- # We don't need to run validations here, since find_by_canonical_slug
- # guarantees that there is no conflict in canonical_slug, and DB
- # constraints on title and project_id enforce our other invariants
- # This saves us a query.
- meta
+ class << self
+ # Return the (updated) WikiPage::Meta record for a given wiki page
+ #
+ # If none is found, then a new record is created, and its fields are set
+ # to reflect the wiki_page passed.
+ #
+ # @param [String] last_known_slug
+ # @param [WikiPage] wiki_page
+ #
+ # This method raises errors on validation issues.
+ def find_or_create(last_known_slug, wiki_page)
+ raise WikiPageInvalid unless wiki_page.valid?
+
+ project = wiki_page.wiki.project
+ known_slugs = [last_known_slug, wiki_page.slug].compact.uniq
+ raise 'No slugs found! This should not be possible.' if known_slugs.empty?
+
+ transaction do
+ updates = wiki_page_updates(wiki_page)
+ found = find_by_canonical_slug(known_slugs, project)
+ meta = found || create!(updates.merge(project_id: project.id))
+
+ meta.update_state(found.nil?, known_slugs, wiki_page, updates)
+
+ # We don't need to run validations here, since find_by_canonical_slug
+ # guarantees that there is no conflict in canonical_slug, and DB
+ # constraints on title and project_id enforce our other invariants
+ # This saves us a query.
+ meta
+ end
end
- end
- def self.find_by_canonical_slug(canonical_slug, project)
- meta, conflict = with_canonical_slug(canonical_slug)
- .where(project_id: project.id)
- .limit(2)
+ def find_by_canonical_slug(canonical_slug, project)
+ meta, conflict = with_canonical_slug(canonical_slug)
+ .where(project_id: project.id)
+ .limit(2)
- if conflict.present?
- meta.errors.add(:canonical_slug, 'Duplicate value found')
- raise CanonicalSlugConflictError.new(meta)
+ if conflict.present?
+ meta.errors.add(:canonical_slug, 'Duplicate value found')
+ raise CanonicalSlugConflictError.new(meta)
+ end
+
+ meta
end
- meta
+ private
+
+ def wiki_page_updates(wiki_page)
+ last_commit_date = wiki_page.version_commit_timestamp || Time.now.utc
+
+ {
+ title: wiki_page.title,
+ created_at: last_commit_date,
+ updated_at: last_commit_date
+ }
+ end
end
def canonical_slug
@@ -85,24 +102,21 @@ class WikiPage
@canonical_slug = slug
end
- def update_state(created, known_slugs, wiki_page)
- update_wiki_page_attributes(wiki_page)
+ def update_state(created, known_slugs, wiki_page, updates)
+ update_wiki_page_attributes(updates)
insert_slugs(known_slugs, created, wiki_page.slug)
self.canonical_slug = wiki_page.slug
end
- def update_columns(attrs = {})
- super(attrs.reverse_merge(updated_at: Time.now.utc))
- end
-
- def self.update_all(attrs = {})
- super(attrs.reverse_merge(updated_at: Time.now.utc))
- end
-
private
- def update_wiki_page_attributes(page)
- update_columns(title: page.title) unless page.title == title
+ def update_wiki_page_attributes(updates)
+ # Remove all unnecessary updates:
+ updates.delete(:updated_at) if updated_at == updates[:updated_at]
+ updates.delete(:created_at) if created_at <= updates[:created_at]
+ updates.delete(:title) if title == updates[:title]
+
+ update_columns(updates) unless updates.empty?
end
def insert_slugs(strings, is_new, canonical_slug)
diff --git a/app/models/x509_certificate.rb b/app/models/x509_certificate.rb
index 75b711eab5b..428fd336a32 100644
--- a/app/models/x509_certificate.rb
+++ b/app/models/x509_certificate.rb
@@ -26,6 +26,8 @@ class X509Certificate < ApplicationRecord
validates :x509_issuer_id, presence: true
+ scope :by_x509_issuer, ->(issuer) { where(x509_issuer_id: issuer.id) }
+
after_commit :mark_commit_signatures_unverified
def self.safe_create!(attributes)
@@ -33,6 +35,10 @@ class X509Certificate < ApplicationRecord
.safe_find_or_create_by!(subject_key_identifier: attributes[:subject_key_identifier])
end
+ def self.serial_numbers(issuer)
+ by_x509_issuer(issuer).pluck(:serial_number)
+ end
+
def mark_commit_signatures_unverified
X509CertificateRevokeWorker.perform_async(self.id) if revoked?
end
diff --git a/app/models/x509_commit_signature.rb b/app/models/x509_commit_signature.rb
index ed7c638cecc..57d809f7cfb 100644
--- a/app/models/x509_commit_signature.rb
+++ b/app/models/x509_commit_signature.rb
@@ -41,4 +41,8 @@ class X509CommitSignature < ApplicationRecord
Gitlab::X509::Commit.new(commit)
end
+
+ def user
+ commit.committer
+ end
end