diff options
Diffstat (limited to 'app/services')
176 files changed, 2553 insertions, 1050 deletions
diff --git a/app/services/application_settings/base_service.rb b/app/services/application_settings/base_service.rb index ebe067536ca..0929b30b7e9 100644 --- a/app/services/application_settings/base_service.rb +++ b/app/services/application_settings/base_service.rb @@ -3,7 +3,9 @@ module ApplicationSettings class BaseService < ::BaseService def initialize(application_setting, user, params = {}) - @application_setting, @current_user, @params = application_setting, user, params.dup + @application_setting = application_setting + @current_user = user + @params = params.dup end end end diff --git a/app/services/authorized_project_update/find_records_due_for_refresh_service.rb b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb new file mode 100644 index 00000000000..c4b18a26d0e --- /dev/null +++ b/app/services/authorized_project_update/find_records_due_for_refresh_service.rb @@ -0,0 +1,92 @@ +# frozen_string_literal: true + +module AuthorizedProjectUpdate + # Service for finding the authorized_projects records of a user that needs addition or removal. + # + # Usage: + # + # user = User.find_by(username: 'alice') + # service = AuthorizedProjectUpdate::FindRecordsDueForRefreshService.new(some_user) + # service.execute + class FindRecordsDueForRefreshService + def initialize(user, source: nil, incorrect_auth_found_callback: nil, missing_auth_found_callback: nil) + @user = user + @source = source + @incorrect_auth_found_callback = incorrect_auth_found_callback + @missing_auth_found_callback = missing_auth_found_callback + end + + def execute + current = current_authorizations_per_project + fresh = fresh_access_levels_per_project + + # Projects that have more than one authorizations associated with + # the user needs to be deleted. + # The correct authorization is added to the ``add`` array in the + # next stage. + remove = projects_with_duplicates + current.except!(*projects_with_duplicates) + + remove |= current.each_with_object([]) do |(project_id, row), array| + # rows not in the new list or with a different access level should be + # removed. + if !fresh[project_id] || fresh[project_id] != row.access_level + if incorrect_auth_found_callback + incorrect_auth_found_callback.call(project_id, row.access_level) + end + + array << row.project_id + end + end + + add = fresh.each_with_object([]) do |(project_id, level), array| + # rows not in the old list or with a different access level should be + # added. + if !current[project_id] || current[project_id].access_level != level + if missing_auth_found_callback + missing_auth_found_callback.call(project_id, level) + end + + array << [user.id, project_id, level] + end + end + + [remove, add] + end + + def needs_refresh? + remove, add = execute + + remove.present? || add.present? + end + + def fresh_access_levels_per_project + fresh_authorizations.each_with_object({}) do |row, hash| + hash[row.project_id] = row.access_level + end + end + + def current_authorizations_per_project + current_authorizations.index_by(&:project_id) + end + + def current_authorizations + @current_authorizations ||= user.project_authorizations.select(:project_id, :access_level) + end + + def fresh_authorizations + Gitlab::ProjectAuthorizations.new(user).calculate + end + + private + + attr_reader :user, :source, :incorrect_auth_found_callback, :missing_auth_found_callback + + def projects_with_duplicates + @projects_with_duplicates ||= current_authorizations + .group_by(&:project_id) + .select { |project_id, authorizations| authorizations.count > 1 } + .keys + end + end +end diff --git a/app/services/base_container_service.rb b/app/services/base_container_service.rb index 56e4b8c908c..6852237dc25 100644 --- a/app/services/base_container_service.rb +++ b/app/services/base_container_service.rb @@ -7,6 +7,8 @@ class BaseContainerService attr_reader :container, :current_user, :params def initialize(container:, current_user: nil, params: {}) - @container, @current_user, @params = container, current_user, params.dup + @container = container + @current_user = current_user + @params = params.dup end end diff --git a/app/services/base_service.rb b/app/services/base_service.rb index b4c4b6980a8..20dfeb67815 100644 --- a/app/services/base_service.rb +++ b/app/services/base_service.rb @@ -15,7 +15,9 @@ class BaseService attr_accessor :project, :current_user, :params def initialize(project, user = nil, params = {}) - @project, @current_user, @params = project, user, params.dup + @project = project + @current_user = user + @params = params.dup end delegate :repository, to: :project diff --git a/app/services/boards/base_item_move_service.rb b/app/services/boards/base_item_move_service.rb index bf3e29df54b..28fb1e43043 100644 --- a/app/services/boards/base_item_move_service.rb +++ b/app/services/boards/base_item_move_service.rb @@ -22,6 +22,12 @@ module Boards ) end + reposition_ids = move_between_ids(params) + if reposition_ids + attrs[:move_between_ids] = reposition_ids + attrs.merge!(reposition_parent) + end + attrs end @@ -63,10 +69,22 @@ module Boards if moving_to_list.movable? moving_from_list.label_id else - ::Label.ids_on_board(board.id) + board_label_ids end Array(label_ids).compact end + + def board_label_ids + ::Label.ids_on_board(board.id) + end + + def move_between_ids(move_params) + ids = [move_params[:move_after_id], move_params[:move_before_id]] + .map(&:to_i) + .map { |m| m > 0 ? m : nil } + + ids.any? ? ids : nil + end end end diff --git a/app/services/boards/base_service.rb b/app/services/boards/base_service.rb index 439a5c06223..83bb69b3822 100644 --- a/app/services/boards/base_service.rb +++ b/app/services/boards/base_service.rb @@ -6,7 +6,9 @@ module Boards attr_accessor :parent, :current_user, :params def initialize(parent, user, params = {}) - @parent, @current_user, @params = parent, user, params.dup + @parent = parent + @current_user = user + @params = params.dup end end end diff --git a/app/services/boards/destroy_service.rb b/app/services/boards/destroy_service.rb index 8f3d4b58b7b..0b1cd61b119 100644 --- a/app/services/boards/destroy_service.rb +++ b/app/services/boards/destroy_service.rb @@ -3,7 +3,7 @@ module Boards class DestroyService < Boards::BaseService def execute(board) - if parent.boards.size == 1 + if boards.size == 1 return ServiceResponse.error(message: "The board could not be deleted, because the parent doesn't have any other boards.") end @@ -11,5 +11,11 @@ module Boards ServiceResponse.success end + + private + + def boards + parent.boards + end end end diff --git a/app/services/boards/issues/move_service.rb b/app/services/boards/issues/move_service.rb index 99374fa01ae..76ea57968b2 100644 --- a/app/services/boards/issues/move_service.rb +++ b/app/services/boards/issues/move_service.rb @@ -3,8 +3,6 @@ module Boards module Issues class MoveService < Boards::BaseItemMoveService - extend ::Gitlab::Utils::Override - def execute_multiple(issues) return execute_multiple_empty_result if issues.empty? @@ -57,25 +55,8 @@ module Boards ::Issues::UpdateService.new(issue.project, current_user, issue_modification_params).execute(issue) end - override :issuable_params - def issuable_params(issuable) - attrs = super - - move_between_ids = move_between_ids(params) - if move_between_ids - attrs[:move_between_ids] = move_between_ids - attrs[:board_group_id] = board.group&.id - end - - attrs - end - - def move_between_ids(move_params) - ids = [move_params[:move_after_id], move_params[:move_before_id]] - .map(&:to_i) - .map { |m| m > 0 ? m : nil } - - ids.any? ? ids : nil + def reposition_parent + { board_group_id: board.group&.id } end end end diff --git a/app/services/boards/lists/base_update_service.rb b/app/services/boards/lists/base_update_service.rb new file mode 100644 index 00000000000..faf58e405fc --- /dev/null +++ b/app/services/boards/lists/base_update_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Boards + module Lists + class BaseUpdateService < Boards::BaseService + def execute(list) + if execute_by_params(list) + success(list: list) + else + error(list.errors.messages, 422) + end + end + + private + + def execute_by_params(list) + update_preferences_result = update_preferences(list) if can_read?(list) + update_position_result = update_position(list) if can_admin?(list) + + update_preferences_result || update_position_result + end + + def update_preferences(list) + return unless preferences? + + list.update_preferences_for(current_user, preferences) + end + + def update_position(list) + return unless position? + + move_service = Boards::Lists::MoveService.new(parent, current_user, params) + + move_service.execute(list) + end + + def preferences + { collapsed: Gitlab::Utils.to_boolean(params[:collapsed]) } + end + + def preferences? + params.has_key?(:collapsed) + end + + def position? + params.has_key?(:position) + end + + def can_read?(list) + raise NotImplementedError + end + + def can_admin?(list) + raise NotImplementedError + end + end + end +end diff --git a/app/services/boards/lists/list_service.rb b/app/services/boards/lists/list_service.rb index 3c296cde51e..03d54a8c74c 100644 --- a/app/services/boards/lists/list_service.rb +++ b/app/services/boards/lists/list_service.rb @@ -23,12 +23,10 @@ module Boards end def hidden_lists_for(board) - hidden = [] - - hidden << ::List.list_types[:backlog] if board.hide_backlog_list - hidden << ::List.list_types[:closed] if board.hide_closed_list - - hidden + [].tap do |hidden| + hidden << ::List.list_types[:backlog] if board.hide_backlog_list? + hidden << ::List.list_types[:closed] if board.hide_closed_list? + end end end end diff --git a/app/services/boards/lists/update_service.rb b/app/services/boards/lists/update_service.rb index e2d9c371ca2..2e1a6592cd9 100644 --- a/app/services/boards/lists/update_service.rb +++ b/app/services/boards/lists/update_service.rb @@ -2,50 +2,7 @@ module Boards module Lists - class UpdateService < Boards::BaseService - def execute(list) - if execute_by_params(list) - success(list: list) - else - error(list.errors.messages, 422) - end - end - - private - - def execute_by_params(list) - update_preferences_result = update_preferences(list) if can_read?(list) - update_position_result = update_position(list) if can_admin?(list) - - update_preferences_result || update_position_result - end - - def update_preferences(list) - return unless preferences? - - list.update_preferences_for(current_user, preferences) - end - - def update_position(list) - return unless position? - - move_service = Boards::Lists::MoveService.new(parent, current_user, params) - - move_service.execute(list) - end - - def preferences - { collapsed: Gitlab::Utils.to_boolean(params[:collapsed]) } - end - - def preferences? - params.has_key?(:collapsed) - end - - def position? - params.has_key?(:position) - end - + class UpdateService < Boards::Lists::BaseUpdateService def can_read?(list) Ability.allowed?(current_user, :read_issue_board_list, parent) end diff --git a/app/services/ci/abort_pipelines_service.rb b/app/services/ci/abort_pipelines_service.rb new file mode 100644 index 00000000000..43734c4dd39 --- /dev/null +++ b/app/services/ci/abort_pipelines_service.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Ci + class AbortPipelinesService + # NOTE: This call fails pipelines in bulk without running callbacks. + # Only for pipeline abandonment scenarios (examples: project delete) + def execute(pipelines, failure_reason) + pipelines.cancelable.each_batch(of: 100) do |pipeline_batch| + now = Time.current + + basic_attributes = { status: :failed } + all_attributes = basic_attributes.merge(failure_reason: failure_reason, finished_at: now) + + bulk_fail_for(Ci::Stage, pipeline_batch, basic_attributes) + bulk_fail_for(CommitStatus, pipeline_batch, all_attributes) + + pipeline_batch.update_all(all_attributes) + end + + ServiceResponse.success(message: 'Pipelines stopped') + end + + private + + def bulk_fail_for(klass, pipelines, attributes) + klass.in_pipelines(pipelines) + .cancelable + .in_batches(of: 150) # rubocop:disable Cop/InBatches + .update_all(attributes) + end + end +end diff --git a/app/services/ci/abort_project_pipelines_service.rb b/app/services/ci/abort_project_pipelines_service.rb deleted file mode 100644 index 0b2fa9ed3c0..00000000000 --- a/app/services/ci/abort_project_pipelines_service.rb +++ /dev/null @@ -1,25 +0,0 @@ -# frozen_string_literal: true - -module Ci - class AbortProjectPipelinesService - # Danger: Cancels in bulk without callbacks - # Only for pipeline abandonment scenarios (current example: project delete) - def execute(project) - return unless Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml) - - pipelines = project.all_pipelines.cancelable - bulk_abort!(pipelines, status: :canceled) - - ServiceResponse.success(message: 'Pipelines canceled') - end - - private - - def bulk_abort!(pipelines, status:) - pipelines.each_batch do |pipeline_batch| - CommitStatus.in_pipelines(pipeline_batch).in_batches.update_all(status: status) # rubocop: disable Cop/InBatches - pipeline_batch.update_all(status: status) - end - end - end -end diff --git a/app/services/ci/after_requeue_job_service.rb b/app/services/ci/after_requeue_job_service.rb new file mode 100644 index 00000000000..3858ee9d550 --- /dev/null +++ b/app/services/ci/after_requeue_job_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module Ci + class AfterRequeueJobService < ::BaseService + def execute(processable) + process_subsequent_jobs(processable) + reset_ancestor_bridges(processable) + end + + private + + def process_subsequent_jobs(processable) + processable.pipeline.processables.skipped.after_stage(processable.stage_idx).find_each do |processable| + process(processable) + end + end + + def reset_ancestor_bridges(processable) + processable.pipeline.reset_ancestor_bridges! + end + + def process(processable) + Gitlab::OptimisticLocking.retry_lock(processable, name: 'ci_requeue_job') do |processable| + processable.process(current_user) + end + end + end +end diff --git a/app/services/ci/cancel_user_pipelines_service.rb b/app/services/ci/cancel_user_pipelines_service.rb deleted file mode 100644 index 3d3a8032e8e..00000000000 --- a/app/services/ci/cancel_user_pipelines_service.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module Ci - class CancelUserPipelinesService - # rubocop: disable CodeReuse/ActiveRecord - # This is a bug with CodeReuse/ActiveRecord cop - # https://gitlab.com/gitlab-org/gitlab/issues/32332 - def execute(user) - # TODO: fix N+1 queries https://gitlab.com/gitlab-org/gitlab/-/issues/300685 - user.pipelines.cancelable.find_each(&:cancel_running) - - ServiceResponse.success(message: 'Pipeline canceled') - rescue ActiveRecord::StaleObjectError - ServiceResponse.error(message: 'Error canceling pipeline') - end - # rubocop: enable CodeReuse/ActiveRecord - end -end diff --git a/app/services/ci/create_job_artifacts_service.rb b/app/services/ci/create_job_artifacts_service.rb deleted file mode 100644 index f1fdc8e2490..00000000000 --- a/app/services/ci/create_job_artifacts_service.rb +++ /dev/null @@ -1,172 +0,0 @@ -# frozen_string_literal: true - -module Ci - class CreateJobArtifactsService < ::BaseService - include Gitlab::Utils::UsageData - - ArtifactsExistError = Class.new(StandardError) - - LSIF_ARTIFACT_TYPE = 'lsif' - METRICS_REPORT_UPLOAD_EVENT_NAME = 'i_testing_metrics_report_artifact_uploaders' - - OBJECT_STORAGE_ERRORS = [ - Errno::EIO, - Google::Apis::ServerError, - Signet::RemoteServerError - ].freeze - - def initialize(job) - @job = job - @project = job.project - end - - def authorize(artifact_type:, filesize: nil) - result = validate_requirements(artifact_type: artifact_type, filesize: filesize) - return result unless result[:status] == :success - - headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size(artifact_type)) - - if lsif?(artifact_type) - headers[:ProcessLsif] = true - track_usage_event('i_source_code_code_intelligence', project.id) - end - - success(headers: headers) - end - - def execute(artifacts_file, params, metadata_file: nil) - result = validate_requirements(artifact_type: params[:artifact_type], filesize: artifacts_file.size) - return result unless result[:status] == :success - - return success if sha256_matches_existing_artifact?(params[:artifact_type], artifacts_file) - - artifact, artifact_metadata = build_artifact(artifacts_file, params, metadata_file) - result = parse_artifact(artifact) - - track_artifact_uploader(artifact) - - return result unless result[:status] == :success - - persist_artifact(artifact, artifact_metadata, params) - end - - private - - attr_reader :job, :project - - def validate_requirements(artifact_type:, filesize:) - return too_large_error if too_large?(artifact_type, filesize) - - success - end - - def too_large?(type, size) - size > max_size(type) if size - end - - def lsif?(type) - type == LSIF_ARTIFACT_TYPE - end - - def max_size(type) - Ci::JobArtifact.max_artifact_size(type: type, project: project) - end - - def forbidden_type_error(type) - error("#{type} artifacts are forbidden", :forbidden) - end - - def too_large_error - error('file size has reached maximum size limit', :payload_too_large) - end - - def build_artifact(artifacts_file, params, metadata_file) - expire_in = params['expire_in'] || - Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in - - artifact = Ci::JobArtifact.new( - job_id: job.id, - project: project, - file: artifacts_file, - file_type: params[:artifact_type], - file_format: params[:artifact_format], - file_sha256: artifacts_file.sha256, - expire_in: expire_in) - - artifact_metadata = if metadata_file - Ci::JobArtifact.new( - job_id: job.id, - project: project, - file: metadata_file, - file_type: :metadata, - file_format: :gzip, - file_sha256: metadata_file.sha256, - expire_in: expire_in) - end - - [artifact, artifact_metadata] - end - - def parse_artifact(artifact) - unless Feature.enabled?(:ci_synchronous_artifact_parsing, project, default_enabled: true) - return success - end - - case artifact.file_type - when 'dotenv' then parse_dotenv_artifact(artifact) - when 'cluster_applications' then parse_cluster_applications_artifact(artifact) - else success - end - end - - def persist_artifact(artifact, artifact_metadata, params) - Ci::JobArtifact.transaction do - artifact.save! - artifact_metadata&.save! - - # NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future. - job.update_column(:artifacts_expire_at, artifact.expire_at) - end - - success - rescue ActiveRecord::RecordNotUnique => error - track_exception(error, params) - error('another artifact of the same type already exists', :bad_request) - rescue *OBJECT_STORAGE_ERRORS => error - track_exception(error, params) - error(error.message, :service_unavailable) - rescue => error - track_exception(error, params) - error(error.message, :bad_request) - end - - def sha256_matches_existing_artifact?(artifact_type, artifacts_file) - existing_artifact = job.job_artifacts.find_by_file_type(artifact_type) - return false unless existing_artifact - - existing_artifact.file_sha256 == artifacts_file.sha256 - end - - def track_exception(error, params) - Gitlab::ErrorTracking.track_exception(error, - job_id: job.id, - project_id: job.project_id, - uploading_type: params[:artifact_type] - ) - end - - def track_artifact_uploader(artifact) - return unless artifact.file_type == 'metrics' - - track_usage_event(METRICS_REPORT_UPLOAD_EVENT_NAME, @job.user_id) - end - - def parse_dotenv_artifact(artifact) - Ci::ParseDotenvArtifactService.new(project, current_user).execute(artifact) - end - - def parse_cluster_applications_artifact(artifact) - Clusters::ParseClusterApplicationsArtifactService.new(job, job.user).execute(artifact) - end - end -end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 0fd47e625fd..ca936307acc 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -96,7 +96,8 @@ module Ci # rubocop: enable Metrics/ParameterLists def execute!(*args, &block) - source, params = args[0], Hash(args[1]) + source = args[0] + params = Hash(args[1]) execute(source, **params, &block).tap do |pipeline| unless pipeline.persisted? diff --git a/app/services/ci/create_web_ide_terminal_service.rb b/app/services/ci/create_web_ide_terminal_service.rb index 785d82094b9..3b89a599180 100644 --- a/app/services/ci/create_web_ide_terminal_service.rb +++ b/app/services/ci/create_web_ide_terminal_service.rb @@ -58,7 +58,8 @@ module Ci builds: [terminal_build_seed] } - Gitlab::Ci::Pipeline::Seed::Stage.new(pipeline, attributes, []) + seed_context = Gitlab::Ci::Pipeline::Seed::Context.new(pipeline) + Gitlab::Ci::Pipeline::Seed::Stage.new(seed_context, attributes, []) end def terminal_build_seed diff --git a/app/services/ci/destroy_expired_job_artifacts_service.rb b/app/services/ci/destroy_expired_job_artifacts_service.rb deleted file mode 100644 index d91cfb3cc82..00000000000 --- a/app/services/ci/destroy_expired_job_artifacts_service.rb +++ /dev/null @@ -1,56 +0,0 @@ -# frozen_string_literal: true - -module Ci - class DestroyExpiredJobArtifactsService - include ::Gitlab::ExclusiveLeaseHelpers - include ::Gitlab::LoopHelpers - - BATCH_SIZE = 100 - LOOP_TIMEOUT = 5.minutes - LOOP_LIMIT = 1000 - EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock' - LOCK_TIMEOUT = 6.minutes - - def initialize - @removed_artifacts_count = 0 - end - - ## - # Destroy expired job artifacts on GitLab instance - # - # This destroy process cannot run for more than 6 minutes. This is for - # preventing multiple `ExpireBuildArtifactsWorker` CRON jobs run concurrently, - # which is scheduled every 7 minutes. - def execute - in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do - destroy_job_artifacts_with_slow_iteration(Time.current) - end - - @removed_artifacts_count - end - - private - - def destroy_job_artifacts_with_slow_iteration(start_at) - Ci::JobArtifact.expired_before(start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index| - # For performance reasons, join with ci_pipelines after the batch is queried. - # See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47496 - artifacts = relation.unlocked - - service_response = destroy_batch_async(artifacts) - @removed_artifacts_count += service_response[:destroyed_artifacts_count] - - break if loop_timeout?(start_at) - break if index >= LOOP_LIMIT - end - end - - def destroy_batch_async(artifacts) - Ci::JobArtifactsDestroyBatchService.new(artifacts).execute - end - - def loop_timeout?(start_at) - Time.current > start_at + LOOP_TIMEOUT - end - end -end diff --git a/app/services/ci/disable_user_pipeline_schedules_service.rb b/app/services/ci/disable_user_pipeline_schedules_service.rb new file mode 100644 index 00000000000..6499fbba0ec --- /dev/null +++ b/app/services/ci/disable_user_pipeline_schedules_service.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Ci + class DisableUserPipelineSchedulesService + def execute(user) + Ci::PipelineSchedule.active.owned_by(user).each_batch do |relation| + relation.update_all(active: false) + end + end + end +end diff --git a/app/services/ci/drop_pipeline_service.rb b/app/services/ci/drop_pipeline_service.rb new file mode 100644 index 00000000000..f510943251b --- /dev/null +++ b/app/services/ci/drop_pipeline_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Ci + class DropPipelineService + # execute service asynchronously for each cancelable pipeline + def execute_async_for_all(pipelines, failure_reason, context_user) + pipelines.cancelable.select(:id).find_in_batches do |pipelines_batch| + Ci::DropPipelineWorker.bulk_perform_async_with_contexts( + pipelines_batch, + arguments_proc: -> (pipeline) { [pipeline.id, failure_reason] }, + context_proc: -> (_) { { user: context_user } } + ) + end + end + + def execute(pipeline, failure_reason, retries: 3) + Gitlab::OptimisticLocking.retry_lock(pipeline.cancelable_statuses, retries, name: 'ci_pipeline_drop_running') do |cancelables| + cancelables.find_in_batches do |batch| + preload_associations_for_drop(batch) + + batch.each do |job| + job.drop(failure_reason) + end + end + end + end + + private + + def preload_associations_for_drop(builds_batch) + ActiveRecord::Associations::Preloader.new.preload( # rubocop: disable CodeReuse/ActiveRecord + builds_batch, + [:project, :pipeline, :metadata, :deployment, :taggings] + ) + end + end +end diff --git a/app/services/ci/generate_coverage_reports_service.rb b/app/services/ci/generate_coverage_reports_service.rb index b3aa7b3091b..4e6fbc5462a 100644 --- a/app/services/ci/generate_coverage_reports_service.rb +++ b/app/services/ci/generate_coverage_reports_service.rb @@ -15,7 +15,13 @@ module Ci data: head_pipeline.pipeline_artifacts.find_by_file_type(:code_coverage).present.for_files(merge_request.new_paths) } rescue => e - Gitlab::ErrorTracking.track_exception(e, project_id: project.id) + Gitlab::ErrorTracking.track_exception( + e, + project_id: project.id, + base_pipeline_id: base_pipeline&.id, + head_pipeline_id: head_pipeline&.id + ) + { status: :error, key: key(base_pipeline, head_pipeline), diff --git a/app/services/ci/job_artifacts/create_service.rb b/app/services/ci/job_artifacts/create_service.rb new file mode 100644 index 00000000000..65752e56c64 --- /dev/null +++ b/app/services/ci/job_artifacts/create_service.rb @@ -0,0 +1,174 @@ +# frozen_string_literal: true + +module Ci + module JobArtifacts + class CreateService < ::BaseService + include Gitlab::Utils::UsageData + + ArtifactsExistError = Class.new(StandardError) + + LSIF_ARTIFACT_TYPE = 'lsif' + METRICS_REPORT_UPLOAD_EVENT_NAME = 'i_testing_metrics_report_artifact_uploaders' + + OBJECT_STORAGE_ERRORS = [ + Errno::EIO, + Google::Apis::ServerError, + Signet::RemoteServerError + ].freeze + + def initialize(job) + @job = job + @project = job.project + end + + def authorize(artifact_type:, filesize: nil) + result = validate_requirements(artifact_type: artifact_type, filesize: filesize) + return result unless result[:status] == :success + + headers = JobArtifactUploader.workhorse_authorize(has_length: false, maximum_size: max_size(artifact_type)) + + if lsif?(artifact_type) + headers[:ProcessLsif] = true + track_usage_event('i_source_code_code_intelligence', project.id) + end + + success(headers: headers) + end + + def execute(artifacts_file, params, metadata_file: nil) + result = validate_requirements(artifact_type: params[:artifact_type], filesize: artifacts_file.size) + return result unless result[:status] == :success + + return success if sha256_matches_existing_artifact?(params[:artifact_type], artifacts_file) + + artifact, artifact_metadata = build_artifact(artifacts_file, params, metadata_file) + result = parse_artifact(artifact) + + track_artifact_uploader(artifact) + + return result unless result[:status] == :success + + persist_artifact(artifact, artifact_metadata, params) + end + + private + + attr_reader :job, :project + + def validate_requirements(artifact_type:, filesize:) + return too_large_error if too_large?(artifact_type, filesize) + + success + end + + def too_large?(type, size) + size > max_size(type) if size + end + + def lsif?(type) + type == LSIF_ARTIFACT_TYPE + end + + def max_size(type) + Ci::JobArtifact.max_artifact_size(type: type, project: project) + end + + def forbidden_type_error(type) + error("#{type} artifacts are forbidden", :forbidden) + end + + def too_large_error + error('file size has reached maximum size limit', :payload_too_large) + end + + def build_artifact(artifacts_file, params, metadata_file) + expire_in = params['expire_in'] || + Gitlab::CurrentSettings.current_application_settings.default_artifacts_expire_in + + artifact = Ci::JobArtifact.new( + job_id: job.id, + project: project, + file: artifacts_file, + file_type: params[:artifact_type], + file_format: params[:artifact_format], + file_sha256: artifacts_file.sha256, + expire_in: expire_in) + + artifact_metadata = if metadata_file + Ci::JobArtifact.new( + job_id: job.id, + project: project, + file: metadata_file, + file_type: :metadata, + file_format: :gzip, + file_sha256: metadata_file.sha256, + expire_in: expire_in) + end + + [artifact, artifact_metadata] + end + + def parse_artifact(artifact) + unless Feature.enabled?(:ci_synchronous_artifact_parsing, project, default_enabled: true) + return success + end + + case artifact.file_type + when 'dotenv' then parse_dotenv_artifact(artifact) + when 'cluster_applications' then parse_cluster_applications_artifact(artifact) + else success + end + end + + def persist_artifact(artifact, artifact_metadata, params) + Ci::JobArtifact.transaction do + artifact.save! + artifact_metadata&.save! + + # NOTE: The `artifacts_expire_at` column is already deprecated and to be removed in the near future. + job.update_column(:artifacts_expire_at, artifact.expire_at) + end + + success + rescue ActiveRecord::RecordNotUnique => error + track_exception(error, params) + error('another artifact of the same type already exists', :bad_request) + rescue *OBJECT_STORAGE_ERRORS => error + track_exception(error, params) + error(error.message, :service_unavailable) + rescue => error + track_exception(error, params) + error(error.message, :bad_request) + end + + def sha256_matches_existing_artifact?(artifact_type, artifacts_file) + existing_artifact = job.job_artifacts.find_by_file_type(artifact_type) + return false unless existing_artifact + + existing_artifact.file_sha256 == artifacts_file.sha256 + end + + def track_exception(error, params) + Gitlab::ErrorTracking.track_exception(error, + job_id: job.id, + project_id: job.project_id, + uploading_type: params[:artifact_type] + ) + end + + def track_artifact_uploader(artifact) + return unless artifact.file_type == 'metrics' + + track_usage_event(METRICS_REPORT_UPLOAD_EVENT_NAME, @job.user_id) + end + + def parse_dotenv_artifact(artifact) + Ci::ParseDotenvArtifactService.new(project, current_user).execute(artifact) + end + + def parse_cluster_applications_artifact(artifact) + Clusters::ParseClusterApplicationsArtifactService.new(job, job.user).execute(artifact) + end + end + end +end diff --git a/app/services/ci/job_artifacts/destroy_all_expired_service.rb b/app/services/ci/job_artifacts/destroy_all_expired_service.rb new file mode 100644 index 00000000000..3e9cc95d135 --- /dev/null +++ b/app/services/ci/job_artifacts/destroy_all_expired_service.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Ci + module JobArtifacts + class DestroyAllExpiredService + include ::Gitlab::ExclusiveLeaseHelpers + include ::Gitlab::LoopHelpers + + BATCH_SIZE = 100 + LOOP_TIMEOUT = 5.minutes + LOOP_LIMIT = 1000 + EXCLUSIVE_LOCK_KEY = 'expired_job_artifacts:destroy:lock' + LOCK_TIMEOUT = 6.minutes + + def initialize + @removed_artifacts_count = 0 + end + + ## + # Destroy expired job artifacts on GitLab instance + # + # This destroy process cannot run for more than 6 minutes. This is for + # preventing multiple `ExpireBuildArtifactsWorker` CRON jobs run concurrently, + # which is scheduled every 7 minutes. + def execute + in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do + destroy_job_artifacts_with_slow_iteration(Time.current) + end + + @removed_artifacts_count + end + + private + + def destroy_job_artifacts_with_slow_iteration(start_at) + Ci::JobArtifact.expired_before(start_at).each_batch(of: BATCH_SIZE, column: :expire_at, order: :desc) do |relation, index| + # For performance reasons, join with ci_pipelines after the batch is queried. + # See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/47496 + artifacts = relation.unlocked + + service_response = destroy_batch_async(artifacts) + @removed_artifacts_count += service_response[:destroyed_artifacts_count] + + break if loop_timeout?(start_at) + break if index >= LOOP_LIMIT + end + end + + def destroy_batch_async(artifacts) + Ci::JobArtifacts::DestroyBatchService.new(artifacts).execute + end + + def loop_timeout?(start_at) + Time.current > start_at + LOOP_TIMEOUT + end + end + end +end diff --git a/app/services/ci/job_artifacts/destroy_batch_service.rb b/app/services/ci/job_artifacts/destroy_batch_service.rb new file mode 100644 index 00000000000..95315dd11ec --- /dev/null +++ b/app/services/ci/job_artifacts/destroy_batch_service.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module Ci + module JobArtifacts + class DestroyBatchService + include BaseServiceUtility + include ::Gitlab::Utils::StrongMemoize + + # Danger: Private - Should only be called in Ci Services that pass a batch of job artifacts + # Not for use outside of the Ci:: namespace + + # Adds the passed batch of job artifacts to the `ci_deleted_objects` table + # for asyncronous destruction of the objects in Object Storage via the `Ci::DeleteObjectsService` + # and then deletes the batch of related `ci_job_artifacts` records. + # Params: + # +job_artifacts+:: A relation of job artifacts to destroy (fewer than MAX_JOB_ARTIFACT_BATCH_SIZE) + # +pick_up_at+:: When to pick up for deletion of files + # Returns: + # +Hash+:: A hash with status and destroyed_artifacts_count keys + def initialize(job_artifacts, pick_up_at: nil) + @job_artifacts = job_artifacts.with_destroy_preloads.to_a + @pick_up_at = pick_up_at + end + + # rubocop: disable CodeReuse/ActiveRecord + def execute + return success(destroyed_artifacts_count: artifacts_count) if @job_artifacts.empty? + + Ci::DeletedObject.transaction do + Ci::DeletedObject.bulk_import(@job_artifacts, @pick_up_at) + Ci::JobArtifact.id_in(@job_artifacts.map(&:id)).delete_all + destroy_related_records(@job_artifacts) + end + + # This is executed outside of the transaction because it depends on Redis + update_project_statistics + increment_monitoring_statistics(artifacts_count) + + success(destroyed_artifacts_count: artifacts_count) + end + # rubocop: enable CodeReuse/ActiveRecord + + private + + # This method is implemented in EE and it must do only database work + def destroy_related_records(artifacts); end + + def update_project_statistics + artifacts_by_project = @job_artifacts.group_by(&:project) + artifacts_by_project.each do |project, artifacts| + delta = -artifacts.sum { |artifact| artifact.size.to_i } + ProjectStatistics.increment_statistic( + project, Ci::JobArtifact.project_statistics_name, delta) + end + end + + def increment_monitoring_statistics(size) + metrics.increment_destroyed_artifacts(size) + end + + def metrics + @metrics ||= ::Gitlab::Ci::Artifacts::Metrics.new + end + + def artifacts_count + strong_memoize(:artifacts_count) do + @job_artifacts.count + end + end + end + end +end + +Ci::JobArtifacts::DestroyBatchService.prepend_if_ee('EE::Ci::JobArtifacts::DestroyBatchService') diff --git a/app/services/ci/job_artifacts_destroy_batch_service.rb b/app/services/ci/job_artifacts_destroy_batch_service.rb deleted file mode 100644 index f8ece27fe86..00000000000 --- a/app/services/ci/job_artifacts_destroy_batch_service.rb +++ /dev/null @@ -1,72 +0,0 @@ -# frozen_string_literal: true - -module Ci - class JobArtifactsDestroyBatchService - include BaseServiceUtility - include ::Gitlab::Utils::StrongMemoize - - # Danger: Private - Should only be called in Ci Services that pass a batch of job artifacts - # Not for use outsie of the ci namespace - - # Adds the passed batch of job artifacts to the `ci_deleted_objects` table - # for asyncronous destruction of the objects in Object Storage via the `Ci::DeleteObjectsService` - # and then deletes the batch of related `ci_job_artifacts` records. - # Params: - # +job_artifacts+:: A relation of job artifacts to destroy (fewer than MAX_JOB_ARTIFACT_BATCH_SIZE) - # +pick_up_at+:: When to pick up for deletion of files - # Returns: - # +Hash+:: A hash with status and destroyed_artifacts_count keys - def initialize(job_artifacts, pick_up_at: nil) - @job_artifacts = job_artifacts.with_destroy_preloads.to_a - @pick_up_at = pick_up_at - end - - # rubocop: disable CodeReuse/ActiveRecord - def execute - return success(destroyed_artifacts_count: artifacts_count) if @job_artifacts.empty? - - Ci::DeletedObject.transaction do - Ci::DeletedObject.bulk_import(@job_artifacts, @pick_up_at) - Ci::JobArtifact.id_in(@job_artifacts.map(&:id)).delete_all - destroy_related_records(@job_artifacts) - end - - # This is executed outside of the transaction because it depends on Redis - update_project_statistics - increment_monitoring_statistics(artifacts_count) - - success(destroyed_artifacts_count: artifacts_count) - end - # rubocop: enable CodeReuse/ActiveRecord - - private - - # This method is implemented in EE and it must do only database work - def destroy_related_records(artifacts); end - - def update_project_statistics - artifacts_by_project = @job_artifacts.group_by(&:project) - artifacts_by_project.each do |project, artifacts| - delta = -artifacts.sum { |artifact| artifact.size.to_i } - ProjectStatistics.increment_statistic( - project, Ci::JobArtifact.project_statistics_name, delta) - end - end - - def increment_monitoring_statistics(size) - metrics.increment_destroyed_artifacts(size) - end - - def metrics - @metrics ||= ::Gitlab::Ci::Artifacts::Metrics.new - end - - def artifacts_count - strong_memoize(:artifacts_count) do - @job_artifacts.count - end - end - end -end - -Ci::JobArtifactsDestroyBatchService.prepend_if_ee('EE::Ci::JobArtifactsDestroyBatchService') diff --git a/app/services/ci/pipeline_artifacts/destroy_expired_artifacts_service.rb b/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb index 0dbabe178da..fed40aef697 100644 --- a/app/services/ci/pipeline_artifacts/destroy_expired_artifacts_service.rb +++ b/app/services/ci/pipeline_artifacts/destroy_all_expired_service.rb @@ -2,7 +2,7 @@ module Ci module PipelineArtifacts - class DestroyExpiredArtifactsService + class DestroyAllExpiredService include ::Gitlab::LoopHelpers include ::Gitlab::Utils::StrongMemoize diff --git a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb index 35818e2cf3d..883a70c9795 100644 --- a/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb +++ b/app/services/ci/pipeline_processing/atomic_processing_service/status_collection.rb @@ -91,17 +91,17 @@ module Ci def all_statuses_by_id strong_memoize(:all_statuses_by_id) do - all_statuses.map do |row| + all_statuses.to_h do |row| [row[:id], row] - end.to_h + end end end def all_statuses_by_name strong_memoize(:statuses_by_name) do - all_statuses.map do |row| + all_statuses.to_h do |row| [row[:name], row] - end.to_h + end end end diff --git a/app/services/ci/pipeline_trigger_service.rb b/app/services/ci/pipeline_trigger_service.rb index dbbaefb2b2f..a5f70d62e13 100644 --- a/app/services/ci/pipeline_trigger_service.rb +++ b/app/services/ci/pipeline_trigger_service.rb @@ -6,8 +6,10 @@ module Ci def execute if trigger_from_token + set_application_context_from_trigger(trigger_from_token) create_pipeline_from_trigger(trigger_from_token) elsif job_from_token + set_application_context_from_job(job_from_token) create_pipeline_from_job(job_from_token) end @@ -73,11 +75,7 @@ module Ci end def variables - if ::Feature.enabled?(:ci_trigger_payload_into_pipeline, project, default_enabled: :yaml) - param_variables + [payload_variable] - else - param_variables - end + param_variables + [payload_variable] end def param_variables @@ -91,5 +89,20 @@ module Ci value: params.except(*PAYLOAD_VARIABLE_HIDDEN_PARAMS).to_json, variable_type: :file } end + + def set_application_context_from_trigger(trigger) + Gitlab::ApplicationContext.push( + user: trigger.owner, + project: trigger.project + ) + end + + def set_application_context_from_job(job) + Gitlab::ApplicationContext.push( + user: job.user, + project: job.project, + runner: job.runner + ) + end end end diff --git a/app/services/ci/play_bridge_service.rb b/app/services/ci/play_bridge_service.rb index 70c4a8e6136..c5b19a3963a 100644 --- a/app/services/ci/play_bridge_service.rb +++ b/app/services/ci/play_bridge_service.rb @@ -8,6 +8,10 @@ module Ci bridge.tap do |bridge| bridge.user = current_user bridge.enqueue! + + next unless ::Feature.enabled?(:ci_fix_pipeline_status_for_dag_needs_manual, project, default_enabled: :yaml) + + AfterRequeueJobService.new(project, current_user).execute(bridge) end end end diff --git a/app/services/ci/play_build_service.rb b/app/services/ci/play_build_service.rb index ebc980a9053..4953b1ea5fc 100644 --- a/app/services/ci/play_build_service.rb +++ b/app/services/ci/play_build_service.rb @@ -12,7 +12,13 @@ module Ci # Try to enqueue the build, otherwise create a duplicate. # if build.enqueue - build.tap { |action| action.update(user: current_user, job_variables_attributes: job_variables_attributes || []) } + build.tap do |build| + build.update(user: current_user, job_variables_attributes: job_variables_attributes || []) + + next unless ::Feature.enabled?(:ci_fix_pipeline_status_for_dag_needs_manual, project, default_enabled: :yaml) + + AfterRequeueJobService.new(project, current_user).execute(build) + end else Ci::Build.retry(build, current_user) end diff --git a/app/services/ci/process_build_service.rb b/app/services/ci/process_build_service.rb index 733aa75f255..73cf3308fe7 100644 --- a/app/services/ci/process_build_service.rb +++ b/app/services/ci/process_build_service.rb @@ -26,14 +26,6 @@ module Ci end def valid_statuses_for_build(build) - if ::Feature.enabled?(:skip_dag_manual_and_delayed_jobs, build.project, default_enabled: :yaml) - current_valid_statuses_for_build(build) - else - legacy_valid_statuses_for_build(build) - end - end - - def current_valid_statuses_for_build(build) case build.when when 'on_success', 'manual', 'delayed' build.scheduling_type_dag? ? %w[success] : %w[success skipped] @@ -45,23 +37,6 @@ module Ci [] end end - - def legacy_valid_statuses_for_build(build) - case build.when - when 'on_success' - build.scheduling_type_dag? ? %w[success] : %w[success skipped] - when 'on_failure' - %w[failed] - when 'always' - %w[success failed skipped] - when 'manual' - %w[success skipped] - when 'delayed' - %w[success skipped] - else - [] - end - end end end diff --git a/app/services/ci/process_pipeline_service.rb b/app/services/ci/process_pipeline_service.rb index 970652b4da3..6c69df0c616 100644 --- a/app/services/ci/process_pipeline_service.rb +++ b/app/services/ci/process_pipeline_service.rb @@ -19,7 +19,7 @@ module Ci end def metrics - @metrics ||= ::Gitlab::Ci::Pipeline::Metrics.new + @metrics ||= ::Gitlab::Ci::Pipeline::Metrics end private diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index ed9e44d60f1..90341b26fd6 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -10,7 +10,11 @@ module Ci Result = Struct.new(:build, :build_json, :valid?) - MAX_QUEUE_DEPTH = 50 + ## + # The queue depth limit number has been determined by observing 95 + # percentile of effective queue depth on gitlab.com. This is only likely to + # affect 5% of the worst case scenarios. + MAX_QUEUE_DEPTH = 45 def initialize(runner) @runner = runner @@ -20,7 +24,7 @@ module Ci def execute(params = {}) @metrics.increment_queue_operation(:queue_attempt) - @metrics.observe_queue_time do + @metrics.observe_queue_time(:process, @runner.runner_type) do process_queue(params) end end @@ -105,22 +109,30 @@ module Ci builds = builds.queued_before(params[:job_age].seconds.ago) end - if Feature.enabled?(:ci_register_job_service_one_by_one, runner) - build_ids = builds.pluck(:id) + if Feature.enabled?(:ci_register_job_service_one_by_one, runner, default_enabled: true) + build_ids = retrieve_queue(-> { builds.pluck(:id) }) - @metrics.observe_queue_size(-> { build_ids.size }) + @metrics.observe_queue_size(-> { build_ids.size }, @runner.runner_type) build_ids.each do |build_id| yield Ci::Build.find(build_id) end else - @metrics.observe_queue_size(-> { builds.to_a.size }) + builds_array = retrieve_queue(-> { builds.to_a }) - builds.each(&blk) + @metrics.observe_queue_size(-> { builds_array.size }, @runner.runner_type) + + builds_array.each(&blk) end end # rubocop: enable CodeReuse/ActiveRecord + def retrieve_queue(queue_query_proc) + @metrics.observe_queue_time(:retrieve, @runner.runner_type) do + queue_query_proc.call + end + end + def process_build(build, params) unless build.pending? @metrics.increment_queue_operation(:build_not_pending) @@ -171,7 +183,7 @@ module Ci def max_queue_depth @max_queue_depth ||= begin - if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: false) + if Feature.enabled?(:gitlab_ci_builds_queue_limit, runner, default_enabled: true) MAX_QUEUE_DEPTH else ::Gitlab::Database::MAX_INT_VALUE @@ -266,7 +278,7 @@ module Ci # Workaround for weird Rails bug, that makes `runner.groups.to_sql` to return `runner_id = NULL` groups = ::Group.joins(:runner_namespaces).merge(runner.runner_namespaces) - hierarchy_groups = Gitlab::ObjectHierarchy.new(groups).base_and_descendants + hierarchy_groups = Gitlab::ObjectHierarchy.new(groups, options: { use_distinct: Feature.enabled?(:use_distinct_in_register_job_object_hierarchy) }).base_and_descendants projects = Project.where(namespace_id: hierarchy_groups) .with_group_runners_enabled .with_builds_enabled diff --git a/app/services/ci/retry_build_service.rb b/app/services/ci/retry_build_service.rb index b2c5249a0c7..e3de7f43fda 100644 --- a/app/services/ci/retry_build_service.rb +++ b/app/services/ci/retry_build_service.rb @@ -2,8 +2,6 @@ module Ci class RetryBuildService < ::BaseService - include Gitlab::OptimisticLocking - def self.clone_accessors %i[pipeline project ref tag options name allow_failure stage stage_id stage_idx trigger_request @@ -16,12 +14,10 @@ module Ci build.ensure_scheduling_type! reprocess!(build).tap do |new_build| - mark_subsequent_stages_as_processable(build) - build.pipeline.reset_ancestor_bridges! - Gitlab::OptimisticLocking.retry_lock(new_build, name: 'retry_build', &:enqueue) + AfterRequeueJobService.new(project, current_user).execute(build) - MergeRequests::AddTodoWhenBuildFailsService + ::MergeRequests::AddTodoWhenBuildFailsService .new(project, current_user) .close(new_build) end @@ -33,9 +29,9 @@ module Ci raise Gitlab::Access::AccessDeniedError end - attributes = self.class.clone_accessors.map do |attribute| + attributes = self.class.clone_accessors.to_h do |attribute| [attribute, build.public_send(attribute)] # rubocop:disable GitlabSecurity/PublicSend - end.to_h + end attributes[:user] = current_user @@ -65,12 +61,6 @@ module Ci end build end - - def mark_subsequent_stages_as_processable(build) - build.pipeline.processables.skipped.after_stage(build.stage_idx).find_each do |skipped| - retry_optimistic_lock(skipped, name: 'ci_retry_build_mark_subsequent_stages') { |build| build.process(current_user) } - end - end end end diff --git a/app/services/ci/retry_pipeline_service.rb b/app/services/ci/retry_pipeline_service.rb index 90ee7b9b3ba..bb8590a769c 100644 --- a/app/services/ci/retry_pipeline_service.rb +++ b/app/services/ci/retry_pipeline_service.rb @@ -28,7 +28,7 @@ module Ci pipeline.reset_ancestor_bridges! - MergeRequests::AddTodoWhenBuildFailsService + ::MergeRequests::AddTodoWhenBuildFailsService .new(project, current_user) .close_all(pipeline) diff --git a/app/services/ci/stop_environments_service.rb b/app/services/ci/stop_environments_service.rb index b6c5b398cb1..81457130fa0 100644 --- a/app/services/ci/stop_environments_service.rb +++ b/app/services/ci/stop_environments_service.rb @@ -35,7 +35,7 @@ module Ci private def environments - @environments ||= EnvironmentsFinder + @environments ||= EnvironmentsByDeploymentsFinder .new(project, current_user, ref: @ref, recently_updated: true) .execute end diff --git a/app/services/ci/test_failure_history_service.rb b/app/services/ci/test_failure_history_service.rb index 61fda79a4a2..58bbc716ff0 100644 --- a/app/services/ci/test_failure_history_service.rb +++ b/app/services/ci/test_failure_history_service.rb @@ -34,7 +34,7 @@ module Ci # We fetch for up to MAX_TRACKABLE_FAILURES + 1 builds. So if ever we get # 201 total number of builds with the assumption that each job has at least - # 1 failed test case, then we have at least 201 failed test cases which exceeds + # 1 failed unit test, then we have at least 201 failed unit tests which exceeds # the MAX_TRACKABLE_FAILURES of 200. If this is the case, let's early exit so we # don't have to parse each JUnit report of each of the 201 builds. failed_builds.length <= MAX_TRACKABLE_FAILURES @@ -51,25 +51,29 @@ module Ci end def track_failures - failed_test_cases = gather_failed_test_cases(failed_builds) + failed_unit_tests = gather_failed_unit_tests_from_reports(failed_builds) - return if failed_test_cases.size > MAX_TRACKABLE_FAILURES + return if failed_unit_tests.size > MAX_TRACKABLE_FAILURES - failed_test_cases.keys.each_slice(100) do |key_hashes| - Ci::TestCase.transaction do - ci_test_cases = Ci::TestCase.find_or_create_by_batch(project, key_hashes) - failures = test_case_failures(ci_test_cases, failed_test_cases) + failed_unit_tests.each_slice(100) do |batch| + Ci::UnitTest.transaction do + unit_test_attrs = ci_unit_test_attrs(batch) + ci_unit_tests = Ci::UnitTest.find_or_create_by_batch(project, unit_test_attrs) - Ci::TestCaseFailure.insert_all(failures) + failures = ci_unit_test_failure_attrs(ci_unit_tests, failed_unit_tests) + Ci::UnitTestFailure.insert_all(failures) end end end - def gather_failed_test_cases(failed_builds) - failed_builds.each_with_object({}) do |build, failed_test_cases| + def gather_failed_unit_tests_from_reports(failed_builds) + failed_builds.each_with_object({}) do |build, failed_unit_tests| test_suite = generate_test_suite!(build) - test_suite.failed.keys.each do |key| - failed_test_cases[key] = build + test_suite.failed.each do |key, unit_test| + failed_unit_tests[key] = { + build: build, # This will be used in ci_unit_test_failure_attrs + unit_test: unit_test # This will be used in ci_unit_test_attrs + } end end end @@ -79,12 +83,24 @@ module Ci build.collect_test_reports!(Gitlab::Ci::Reports::TestReports.new) end - def test_case_failures(ci_test_cases, failed_test_cases) - ci_test_cases.map do |test_case| - build = failed_test_cases[test_case.key_hash] + def ci_unit_test_attrs(batch) + batch.map do |item| + unit_test = item.last[:unit_test] { - test_case_id: test_case.id, + key_hash: unit_test.key, + name: unit_test.name, + suite_name: unit_test.suite_name + } + end + end + + def ci_unit_test_failure_attrs(ci_unit_tests, failed_unit_tests) + ci_unit_tests.map do |ci_unit_test| + build = failed_unit_tests[ci_unit_test.key_hash][:build] + + { + unit_test_id: ci_unit_test.id, build_id: build.id, failed_at: build.finished_at } diff --git a/app/services/clusters/create_service.rb b/app/services/clusters/create_service.rb index 6693a58683f..cb2de8b943c 100644 --- a/app/services/clusters/create_service.rb +++ b/app/services/clusters/create_service.rb @@ -5,7 +5,8 @@ module Clusters attr_reader :current_user, :params def initialize(user = nil, params = {}) - @current_user, @params = user, params.dup + @current_user = user + @params = params.dup end def execute(access_token: nil) diff --git a/app/services/clusters/destroy_service.rb b/app/services/clusters/destroy_service.rb index a8de04683fa..371f947add7 100644 --- a/app/services/clusters/destroy_service.rb +++ b/app/services/clusters/destroy_service.rb @@ -5,7 +5,8 @@ module Clusters attr_reader :current_user, :params def initialize(user = nil, params = {}) - @current_user, @params = user, params.dup + @current_user = user + @params = params.dup @response = {} end diff --git a/app/services/clusters/integrations/create_service.rb b/app/services/clusters/integrations/create_service.rb new file mode 100644 index 00000000000..f9e9dd3e457 --- /dev/null +++ b/app/services/clusters/integrations/create_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Clusters + module Integrations + class CreateService < BaseContainerService + attr_accessor :cluster + + def initialize(container:, cluster:, current_user: nil, params: {}) + @cluster = cluster + + super(container: container, current_user: current_user, params: params) + end + + def execute + return ServiceResponse.error(message: 'Unauthorized') unless authorized? + + integration.enabled = params[:enabled] + integration.save! + + if integration.enabled? + ServiceResponse.success(message: s_('ClusterIntegration|Integration enabled'), payload: { integration: integration }) + else + ServiceResponse.success(message: s_('ClusterIntegration|Integration disabled'), payload: { integration: integration }) + end + end + + private + + def integration + case params[:application_type] + when 'prometheus' + cluster.find_or_build_integration_prometheus + else + raise ArgumentError, "invalid application_type: #{params[:application_type]}" + end + end + + def authorized? + Ability.allowed?(current_user, :admin_cluster, cluster) + end + end + end +end diff --git a/app/services/clusters/update_service.rb b/app/services/clusters/update_service.rb index ba20826848d..5432d9fbca1 100644 --- a/app/services/clusters/update_service.rb +++ b/app/services/clusters/update_service.rb @@ -5,7 +5,8 @@ module Clusters attr_reader :current_user, :params def initialize(user = nil, params = {}) - @current_user, @params = user, params.dup + @current_user = user + @params = params.dup end def execute(cluster) diff --git a/app/services/concerns/integrations/project_test_data.rb b/app/services/concerns/integrations/project_test_data.rb index 57bcba98b49..5968b90f8fe 100644 --- a/app/services/concerns/integrations/project_test_data.rb +++ b/app/services/concerns/integrations/project_test_data.rb @@ -9,7 +9,7 @@ module Integrations end def note_events_data - note = NotesFinder.new(current_user, project: project, target: project).execute.reorder(nil).last # rubocop: disable CodeReuse/ActiveRecord + note = NotesFinder.new(current_user, project: project, target: project, sort: 'id_desc').execute.first return { error: s_('TestHooks|Ensure the project has notes.') } unless note.present? diff --git a/app/services/concerns/suggestible.rb b/app/services/concerns/suggestible.rb index 0cba9bf1b8a..82e43c856f8 100644 --- a/app/services/concerns/suggestible.rb +++ b/app/services/concerns/suggestible.rb @@ -5,7 +5,7 @@ module Suggestible include Gitlab::Utils::StrongMemoize # This translates into limiting suggestion changes to `suggestion:-100+100`. - MAX_LINES_CONTEXT = 100.freeze + MAX_LINES_CONTEXT = 100 def diff_lines strong_memoize(:diff_lines) do diff --git a/app/services/deployments/link_merge_requests_service.rb b/app/services/deployments/link_merge_requests_service.rb index eba5082e6c3..39fbef5dee2 100644 --- a/app/services/deployments/link_merge_requests_service.rb +++ b/app/services/deployments/link_merge_requests_service.rb @@ -18,7 +18,22 @@ module Deployments # app deployments, as this is not useful. return if deployment.environment.environment_type - if (prev = deployment.previous_environment_deployment) + # This service is triggered by a Sidekiq worker, which only runs when a + # deployment is successful. We add an extra check here in case we ever + # call this service elsewhere and forget to check the status there. + # + # The reason we only want to link successful deployments is as follows: + # when we link a merge request, we don't link it to future deployments for + # the same environment. If we were to link an MR to a failed deploy, we + # wouldn't be able to later on link it to a successful deploy (e.g. after + # the deploy is retried). + # + # In addition, showing failed deploys in the UI of a merge request isn't + # useful to users, as they can't act upon the information in any + # meaningful way (i.e. they can't just retry the deploy themselves). + return unless deployment.success? + + if (prev = deployment.previous_deployment) link_merge_requests_for_range(prev.sha, deployment.sha) else # When no previous deployment is found we fall back to linking all merge @@ -51,8 +66,15 @@ module Deployments deployment.link_merge_requests(merge_requests) - picked_merge_requests = - project.merge_requests.by_cherry_pick_sha(slice) + # The cherry picked commits are tracked via `notes.commit_id` + # See https://gitlab.com/gitlab-org/gitlab/-/merge_requests/22209 + # + # NOTE: cross-joining `merge_requests` table and `notes` table could + # result in very poor performance because PG planner often uses an + # inappropriate index. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/321032. + mr_ids = project.notes.cherry_picked_merge_requests(slice) + picked_merge_requests = project.merge_requests.id_in(mr_ids) deployment.link_merge_requests(picked_merge_requests) end diff --git a/app/services/draft_notes/base_service.rb b/app/services/draft_notes/base_service.rb index 95c291ea800..66f9e04ef24 100644 --- a/app/services/draft_notes/base_service.rb +++ b/app/services/draft_notes/base_service.rb @@ -5,7 +5,9 @@ module DraftNotes attr_accessor :merge_request, :current_user, :params def initialize(merge_request, current_user, params = nil) - @merge_request, @current_user, @params = merge_request, current_user, params.dup + @merge_request = merge_request + @current_user = current_user + @params = params.dup end def merge_request_activity_counter diff --git a/app/services/git/wiki_push_service.rb b/app/services/git/wiki_push_service.rb index 0905b2d98df..82958abfe6e 100644 --- a/app/services/git/wiki_push_service.rb +++ b/app/services/git/wiki_push_service.rb @@ -8,7 +8,9 @@ module Git attr_reader :wiki def initialize(wiki, current_user, params) - @wiki, @current_user, @params = wiki, current_user, params.dup + @wiki = wiki + @current_user = current_user + @params = params.dup end def execute diff --git a/app/services/git/wiki_push_service/change.rb b/app/services/git/wiki_push_service/change.rb index 3d1d0fe8c4e..9109a7f9d58 100644 --- a/app/services/git/wiki_push_service/change.rb +++ b/app/services/git/wiki_push_service/change.rb @@ -9,7 +9,9 @@ module Git # @param [Hash] change - must have keys `:oldrev` and `:newrev` # @param [Gitlab::Git::RawDiffChange] raw_change def initialize(wiki, change, raw_change) - @wiki, @raw_change, @change = wiki, raw_change, change + @wiki = wiki + @raw_change = raw_change + @change = change end def page diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb index 019cd047ae9..06136aff50e 100644 --- a/app/services/groups/base_service.rb +++ b/app/services/groups/base_service.rb @@ -5,11 +5,25 @@ module Groups attr_accessor :group, :current_user, :params def initialize(group, user, params = {}) - @group, @current_user, @params = group, user, params.dup + @group = group + @current_user = user + @params = params.dup end private + def handle_namespace_settings + settings_params = params.slice(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS) + + return if settings_params.empty? + + ::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS.each do |nsp| + params.delete(nsp) + end + + ::NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute + end + def remove_unallowed_params # overridden in EE end diff --git a/app/services/groups/count_service.rb b/app/services/groups/count_service.rb new file mode 100644 index 00000000000..2a15ae3bc57 --- /dev/null +++ b/app/services/groups/count_service.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module Groups + class CountService < BaseCountService + include Gitlab::Utils::StrongMemoize + + VERSION = 1 + CACHED_COUNT_THRESHOLD = 1000 + EXPIRATION_TIME = 24.hours + + attr_reader :group, :user + + def initialize(group, user = nil) + @group = group + @user = user + end + + def count + cached_count = Rails.cache.read(cache_key) + return cached_count unless cached_count.blank? + + refreshed_count = uncached_count + update_cache_for_key(cache_key) { refreshed_count } if refreshed_count > CACHED_COUNT_THRESHOLD + refreshed_count + end + + def cache_key + ['groups', "#{issuable_key}_count_service", VERSION, group.id, cache_key_name] + end + + private + + def relation_for_count + raise NotImplementedError + end + + def cache_options + super.merge({ expires_in: EXPIRATION_TIME }) + end + + def cache_key_name + raise NotImplementedError, 'cache_key_name must be implemented and return a String' + end + + def issuable_key + raise NotImplementedError, 'issuable_key must be implemented and return a String' + end + end +end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index 3ead2323588..9ddb8ae7695 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -3,7 +3,8 @@ module Groups class CreateService < Groups::BaseService def initialize(user, params = {}) - @current_user, @params = user, params.dup + @current_user = user + @params = params.dup @chat_team = @params.delete(:create_chat_team) end @@ -11,7 +12,10 @@ module Groups remove_unallowed_params set_visibility_level - @group = Group.new(params) + @group = Group.new(params.except(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS)) + + @group.build_namespace_settings + handle_namespace_settings after_build_hook(@group, params) @@ -33,7 +37,6 @@ module Groups Group.transaction do if @group.save @group.add_owner(current_user) - @group.create_namespace_settings unless @group.namespace_settings Service.create_from_active_default_integrations(@group, :group_id) OnboardingProgress.onboard(@group) end diff --git a/app/services/groups/group_links/create_service.rb b/app/services/groups/group_links/create_service.rb index 57c746c3841..0a60140d037 100644 --- a/app/services/groups/group_links/create_service.rb +++ b/app/services/groups/group_links/create_service.rb @@ -2,7 +2,7 @@ module Groups module GroupLinks - class CreateService < BaseService + class CreateService < Groups::BaseService def execute(shared_group) unless group && shared_group && can?(current_user, :admin_group_member, shared_group) && diff --git a/app/services/groups/merge_requests_count_service.rb b/app/services/groups/merge_requests_count_service.rb new file mode 100644 index 00000000000..bb49efe571a --- /dev/null +++ b/app/services/groups/merge_requests_count_service.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Groups + # Service class for counting and caching the number of open merge requests of a group. + class MergeRequestsCountService < Groups::CountService + private + + def cache_key_name + 'open_merge_requests_count' + end + + def relation_for_count + MergeRequestsFinder + .new(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true) + .execute + end + + def issuable_key + 'open_merge_requests' + end + end +end diff --git a/app/services/groups/nested_create_service.rb b/app/services/groups/nested_create_service.rb index a51ac9aa593..35d45aaf0cc 100644 --- a/app/services/groups/nested_create_service.rb +++ b/app/services/groups/nested_create_service.rb @@ -5,7 +5,8 @@ module Groups attr_reader :group_path, :visibility_level def initialize(user, params) - @current_user, @params = user, params.dup + @current_user = user + @params = params.dup @group_path = @params.delete(:group_path) @visibility_level = @params.delete(:visibility_level) || Gitlab::CurrentSettings.current_application_settings.default_group_visibility diff --git a/app/services/groups/open_issues_count_service.rb b/app/services/groups/open_issues_count_service.rb index db1ca09212a..ef787a04315 100644 --- a/app/services/groups/open_issues_count_service.rb +++ b/app/services/groups/open_issues_count_service.rb @@ -2,47 +2,12 @@ module Groups # Service class for counting and caching the number of open issues of a group. - class OpenIssuesCountService < BaseCountService - include Gitlab::Utils::StrongMemoize - - VERSION = 1 + class OpenIssuesCountService < Groups::CountService PUBLIC_COUNT_KEY = 'group_public_open_issues_count' TOTAL_COUNT_KEY = 'group_total_open_issues_count' - CACHED_COUNT_THRESHOLD = 1000 - EXPIRATION_TIME = 24.hours - - attr_reader :group, :user - - def initialize(group, user = nil) - @group = group - @user = user - end - - # Reads count value from cache and return it if present. - # If empty or expired, #uncached_count will calculate the issues count for the group and - # compare it with the threshold. If it is greater, it will be written to the cache and returned. - # If below, it will be returned without being cached. - # This results in only caching large counts and calculating the rest with every call to maintain - # accuracy. - def count - cached_count = Rails.cache.read(cache_key) - return cached_count unless cached_count.blank? - - refreshed_count = uncached_count - update_cache_for_key(cache_key) { refreshed_count } if refreshed_count > CACHED_COUNT_THRESHOLD - refreshed_count - end - - def cache_key(key = nil) - ['groups', 'open_issues_count_service', VERSION, group.id, cache_key_name] - end private - def cache_options - super.merge({ expires_in: EXPIRATION_TIME }) - end - def cache_key_name public_only? ? PUBLIC_COUNT_KEY : TOTAL_COUNT_KEY end @@ -60,5 +25,9 @@ module Groups def relation_for_count IssuesFinder.new(user, group_id: group.id, state: 'opened', non_archived: true, include_subgroups: true, public_only: public_only?).execute end + + def issuable_key + 'open_issues' + end end end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 84385f5da25..ff369d01efc 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -46,18 +46,6 @@ module Groups private - def handle_namespace_settings - settings_params = params.slice(*::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS) - - return if settings_params.empty? - - ::NamespaceSetting::NAMESPACE_SETTINGS_PARAMS.each do |nsp| - params.delete(nsp) - end - - ::NamespaceSettings::UpdateService.new(current_user, group, settings_params).execute - end - def valid_path_change_with_npm_packages? return true unless group.packages_feature_enabled? return true if params[:path].blank? diff --git a/app/services/issuable/bulk_update_service.rb b/app/services/issuable/bulk_update_service.rb index d3d543edcd7..8bcbb92cd0e 100644 --- a/app/services/issuable/bulk_update_service.rb +++ b/app/services/issuable/bulk_update_service.rb @@ -7,7 +7,9 @@ module Issuable attr_accessor :parent, :current_user, :params def initialize(parent, user = nil, params = {}) - @parent, @current_user, @params = parent, user, params.dup + @parent = parent + @current_user = user + @params = params.dup end def execute(type) @@ -15,7 +17,7 @@ module Issuable set_update_params(type) items = update_issuables(type, ids) - response_success(payload: { count: items.count }) + response_success(payload: { count: items.size }) rescue ArgumentError => e response_error(e.message, 422) end @@ -59,10 +61,17 @@ module Issuable def find_issuables(parent, model_class, ids) if parent.is_a?(Project) - model_class.id_in(ids).of_projects(parent) + projects = parent elsif parent.is_a?(Group) - model_class.id_in(ids).of_projects(parent.all_projects) + projects = parent.all_projects + else + return end + + model_class + .id_in(ids) + .of_projects(projects) + .includes_for_bulk_update end def response_success(message: nil, payload: nil) diff --git a/app/services/issuable/destroy_service.rb b/app/services/issuable/destroy_service.rb index 4c64655a622..d5aa84d8d6c 100644 --- a/app/services/issuable/destroy_service.rb +++ b/app/services/issuable/destroy_service.rb @@ -3,12 +3,36 @@ module Issuable class DestroyService < IssuableBaseService def execute(issuable) - TodoService.new.destroy_target(issuable) do |issuable| - if issuable.destroy - issuable.update_project_counter_caches - issuable.assignees.each(&:invalidate_cache_counts) - end + if issuable.destroy + after_destroy(issuable) + end + end + + private + + def after_destroy(issuable) + delete_todos(issuable) + issuable.update_project_counter_caches + issuable.assignees.each(&:invalidate_cache_counts) + end + + def group_for(issuable) + issuable.resource_parent.group + end + + def delete_todos(issuable) + actor = group_for(issuable) + + if Feature.enabled?(:destroy_issuable_todos_async, actor, default_enabled: :yaml) + TodosDestroyer::DestroyedIssuableWorker + .perform_async(issuable.id, issuable.class.name) + else + TodosDestroyer::DestroyedIssuableWorker + .new + .perform(issuable.id, issuable.class.name) end end end end + +Issuable::DestroyService.prepend_if_ee('EE::Issuable::DestroyService') diff --git a/app/services/issuable/process_assignees.rb b/app/services/issuable/process_assignees.rb index c9c6b0bed85..1ef6d3d9c42 100644 --- a/app/services/issuable/process_assignees.rb +++ b/app/services/issuable/process_assignees.rb @@ -14,12 +14,13 @@ module Issuable end def execute - if assignee_ids.blank? - updated_new_assignees = new_assignee_ids + updated_new_assignees = new_assignee_ids + + if add_assignee_ids.blank? && remove_assignee_ids.blank? + updated_new_assignees = assignee_ids if assignee_ids + else updated_new_assignees |= add_assignee_ids if add_assignee_ids updated_new_assignees -= remove_assignee_ids if remove_assignee_ids - else - updated_new_assignees = assignee_ids end updated_new_assignees.uniq diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 094b31b4ad6..add53bc6267 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -29,32 +29,48 @@ class IssuableBaseService < BaseService params.delete(:label_ids) params.delete(:assignee_ids) params.delete(:assignee_id) + params.delete(:add_assignee_ids) + params.delete(:remove_assignee_ids) params.delete(:due_date) params.delete(:canonical_issue_id) params.delete(:project) params.delete(:discussion_locked) end - filter_assignee(issuable) + filter_assignees(issuable) filter_milestone filter_labels end - def filter_assignee(issuable) - return if params[:assignee_ids].blank? + def filter_assignees(issuable) + filter_assignees_with_key(issuable, :assignee_ids, :assignees) + filter_assignees_with_key(issuable, :add_assignee_ids, :add_assignees) + filter_assignees_with_key(issuable, :remove_assignee_ids, :remove_assignees) + end + + def filter_assignees_with_key(issuable, id_key, key) + if params[key] && params[id_key].blank? + params[id_key] = params[key].map(&:id) + end + + return if params[id_key].blank? + + filter_assignees_using_checks(issuable, id_key) + end + def filter_assignees_using_checks(issuable, id_key) unless issuable.allows_multiple_assignees? - params[:assignee_ids] = params[:assignee_ids].first(1) + params[id_key] = params[id_key].first(1) end - assignee_ids = params[:assignee_ids].select { |assignee_id| user_can_read?(issuable, assignee_id) } + assignee_ids = params[id_key].select { |assignee_id| user_can_read?(issuable, assignee_id) } - if params[:assignee_ids].map(&:to_s) == [IssuableFinder::Params::NONE] - params[:assignee_ids] = [] + if params[id_key].map(&:to_s) == [IssuableFinder::Params::NONE] + params[id_key] = [] elsif assignee_ids.any? - params[:assignee_ids] = assignee_ids + params[id_key] = assignee_ids else - params.delete(:assignee_ids) + params.delete(id_key) end end @@ -116,6 +132,15 @@ class IssuableBaseService < BaseService new_label_ids.uniq end + def process_assignee_ids(attributes, existing_assignee_ids: nil, extra_assignee_ids: []) + process = Issuable::ProcessAssignees.new(assignee_ids: attributes.delete(:assignee_ids), + add_assignee_ids: attributes.delete(:add_assignee_ids), + remove_assignee_ids: attributes.delete(:remove_assignee_ids), + existing_assignee_ids: existing_assignee_ids, + extra_assignee_ids: extra_assignee_ids) + process.execute + end + def handle_quick_actions(issuable) merge_quick_actions_into_params!(issuable) end @@ -145,6 +170,10 @@ class IssuableBaseService < BaseService params[:author] ||= current_user params[:label_ids] = process_label_ids(params, extra_label_ids: issuable.label_ids.to_a) + if issuable.respond_to?(:assignee_ids) + params[:assignee_ids] = process_assignee_ids(params, extra_assignee_ids: issuable.assignee_ids.to_a) + end + issuable.assign_attributes(params) before_create(issuable) @@ -191,6 +220,7 @@ class IssuableBaseService < BaseService old_associations = associations_before_update(issuable) assign_requested_labels(issuable) + assign_requested_assignees(issuable) if issuable.changed? || params.present? issuable.assign_attributes(params) @@ -354,6 +384,16 @@ class IssuableBaseService < BaseService issuable.touch end + def assign_requested_assignees(issuable) + return if issuable.is_a?(Epic) + + assignee_ids = process_assignee_ids(params, existing_assignee_ids: issuable.assignee_ids) + if ids_changing?(issuable.assignee_ids, assignee_ids) + params[:assignee_ids] = assignee_ids + issuable.touch + end + end + # Arrays of ids are used, but we should really use sets of ids, so # let's have an helper to properly check if some ids are changing def ids_changing?(old_array, new_array) @@ -384,6 +424,20 @@ class IssuableBaseService < BaseService associations end + def handle_move_between_ids(issuable_position) + return unless params[:move_between_ids] + + after_id, before_id = params.delete(:move_between_ids) + positioning_scope_id = params.delete(positioning_scope_key) + + issuable_before = issuable_for_positioning(before_id, positioning_scope_id) + issuable_after = issuable_for_positioning(after_id, positioning_scope_id) + + raise ActiveRecord::RecordNotFound unless issuable_before || issuable_after + + issuable_position.move_between(issuable_before, issuable_after) + end + def has_changes?(issuable, old_labels: [], old_assignees: [], old_reviewers: []) valid_attrs = [:title, :description, :assignee_ids, :reviewer_ids, :milestone_id, :target_branch] @@ -429,6 +483,8 @@ class IssuableBaseService < BaseService # we need to check this because milestone from milestone_id param is displayed on "new" page # where private project milestone could leak without this check def ensure_milestone_available(issuable) + return unless issuable.supports_milestone? && issuable.milestone_id.present? + issuable.milestone_id = nil unless issuable.milestone_available? end diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb index f148c503dcf..cbb81f1f521 100644 --- a/app/services/issuable_links/create_service.rb +++ b/app/services/issuable_links/create_service.rb @@ -7,7 +7,9 @@ module IssuableLinks attr_reader :issuable, :current_user, :params def initialize(issuable, user, params) - @issuable, @current_user, @params = issuable, user, params.dup + @issuable = issuable + @current_user = user + @params = params.dup end def execute @@ -107,11 +109,11 @@ module IssuableLinks end def issuables_assigned_message - 'Issue(s) already assigned' + _("Issue(s) already assigned") end def issuables_not_found_message - 'No Issue found for given params' + _("No matching issue found. Make sure that you are adding a valid issue URL.") end end end diff --git a/app/services/issuable_links/destroy_service.rb b/app/services/issuable_links/destroy_service.rb index 57e1314e0da..28035bbb291 100644 --- a/app/services/issuable_links/destroy_service.rb +++ b/app/services/issuable_links/destroy_service.rb @@ -15,14 +15,18 @@ module IssuableLinks return error(not_found_message, 404) unless permission_to_remove_relation? remove_relation - create_notes - track_event + after_destroy success(message: 'Relation was removed') end private + def after_destroy + create_notes + track_event + end + def remove_relation link.destroy! end diff --git a/app/services/issuable_links/list_service.rb b/app/services/issuable_links/list_service.rb index 10a2da7eb03..fe9678dcc32 100644 --- a/app/services/issuable_links/list_service.rb +++ b/app/services/issuable_links/list_service.rb @@ -7,7 +7,8 @@ module IssuableLinks attr_reader :issuable, :current_user def initialize(issuable, user) - @issuable, @current_user = issuable, user + @issuable = issuable + @current_user = user end def execute diff --git a/app/services/issue_rebalancing_service.rb b/app/services/issue_rebalancing_service.rb index db5c5ddfb84..f9c3388204f 100644 --- a/app/services/issue_rebalancing_service.rb +++ b/app/services/issue_rebalancing_service.rb @@ -62,7 +62,7 @@ class IssueRebalancingService def run_update_query(values, query_name) Issue.connection.exec_query(<<~SQL, query_name) - WITH cte(cte_id, new_pos) AS ( + WITH cte(cte_id, new_pos) AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} ( SELECT * FROM (VALUES #{values}) as t (id, pos) ) diff --git a/app/services/issues/after_create_service.rb b/app/services/issues/after_create_service.rb new file mode 100644 index 00000000000..0c6ec65f0e2 --- /dev/null +++ b/app/services/issues/after_create_service.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Issues + class AfterCreateService < Issues::BaseService + def execute(issue) + todo_service.new_issue(issue, current_user) + delete_milestone_total_issue_counter_cache(issue.milestone) + track_incident_action(current_user, issue, :incident_created) + end + end +end + +Issues::AfterCreateService.prepend_ee_mod diff --git a/app/services/issues/base_service.rb b/app/services/issues/base_service.rb index 25f319da03b..87615d1b4f2 100644 --- a/app/services/issues/base_service.rb +++ b/app/services/issues/base_service.rb @@ -52,7 +52,7 @@ module Issues end def execute_hooks(issue, action = 'open', old_associations: {}) - issue_data = hook_data(issue, action, old_associations: old_associations) + issue_data = Gitlab::Lazy.new { hook_data(issue, action, old_associations: old_associations) } hooks_scope = issue.confidential? ? :confidential_issue_hooks : :issue_hooks issue.project.execute_hooks(issue_data, hooks_scope) issue.project.execute_services(issue_data, hooks_scope) diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 3fdc66ed84e..68660b35bee 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -6,7 +6,7 @@ module Issues def execute(skip_system_notes: false) @request = params.delete(:request) - @spam_params = Spam::SpamActionService.filter_spam_params!(params) + @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request) @issue = BuildService.new(project, current_user, params).execute @@ -32,13 +32,11 @@ module Issues end end + # Add new items to Issues::AfterCreateService if they can be performed in Sidekiq def after_create(issue) add_incident_label(issue) - todo_service.new_issue(issue, current_user) user_agent_detail_service.create resolve_discussions_with_issue(issue) - delete_milestone_total_issue_counter_cache(issue.milestone) - track_incident_action(current_user, issue, :incident_created) super end @@ -77,4 +75,4 @@ module Issues end end -Issues::CreateService.prepend_if_ee('EE::Issues::CreateService') +Issues::CreateService.prepend_ee_mod diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 2906bdf62a7..702527d80a7 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -8,7 +8,7 @@ module Issues handle_move_between_ids(issue) @request = params.delete(:request) - @spam_params = Spam::SpamActionService.filter_spam_params!(params) + @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request) change_issue_duplicate(issue) move_issue_to_new_project(issue) || clone_issue(issue) || update_task_event(issue) || update(issue) @@ -96,19 +96,15 @@ module Issues end def handle_move_between_ids(issue) - return unless params[:move_between_ids] - - after_id, before_id = params.delete(:move_between_ids) - board_group_id = params.delete(:board_group_id) - - issue_before = get_issue_if_allowed(before_id, board_group_id) - issue_after = get_issue_if_allowed(after_id, board_group_id) - raise ActiveRecord::RecordNotFound unless issue_before || issue_after + super - issue.move_between(issue_before, issue_after) rebalance_if_needed(issue) end + def positioning_scope_key + :board_group_id + end + # rubocop: disable CodeReuse/ActiveRecord def change_issue_duplicate(issue) canonical_issue_id = params.delete(:canonical_issue_id) @@ -185,7 +181,7 @@ module Issues end # rubocop: disable CodeReuse/ActiveRecord - def get_issue_if_allowed(id, board_group_id = nil) + def issuable_for_positioning(id, board_group_id = nil) return unless id issue = diff --git a/app/services/jira_connect_subscriptions/base_service.rb b/app/services/jira_connect_subscriptions/base_service.rb index 0e5bb91660e..042169acb6f 100644 --- a/app/services/jira_connect_subscriptions/base_service.rb +++ b/app/services/jira_connect_subscriptions/base_service.rb @@ -5,7 +5,9 @@ module JiraConnectSubscriptions attr_accessor :jira_connect_installation, :current_user, :params def initialize(jira_connect_installation, user = nil, params = {}) - @jira_connect_installation, @current_user, @params = jira_connect_installation, user, params.dup + @jira_connect_installation = jira_connect_installation + @current_user = user + @params = params.dup end end end diff --git a/app/services/keys/base_service.rb b/app/services/keys/base_service.rb index 113e22b01ce..9b238e2f176 100644 --- a/app/services/keys/base_service.rb +++ b/app/services/keys/base_service.rb @@ -5,7 +5,8 @@ module Keys attr_accessor :user, :params def initialize(user, params = {}) - @user, @params = user, params + @user = user + @params = params @ip_address = @params.delete(:ip_address) end diff --git a/app/services/keys/create_service.rb b/app/services/keys/create_service.rb index c256de7b35d..c1c3ef8792f 100644 --- a/app/services/keys/create_service.rb +++ b/app/services/keys/create_service.rb @@ -5,7 +5,8 @@ module Keys attr_accessor :current_user def initialize(current_user, params = {}) - @current_user, @params = current_user, params + @current_user = current_user + @params = params @ip_address = @params.delete(:ip_address) @user = params.delete(:user) || current_user end diff --git a/app/services/keys/expiry_notification_service.rb b/app/services/keys/expiry_notification_service.rb new file mode 100644 index 00000000000..b486f77ced2 --- /dev/null +++ b/app/services/keys/expiry_notification_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Keys + class ExpiryNotificationService < ::Keys::BaseService + attr_accessor :keys, :expiring_soon + + def initialize(user, params) + @keys = params[:keys] + @expiring_soon = params[:expiring_soon] + + super + end + + def execute + return unless allowed? + + if expiring_soon + trigger_expiring_soon_notification + else + trigger_expired_notification + end + end + + private + + def allowed? + user.can?(:receive_notifications) + end + + def trigger_expiring_soon_notification + notification_service.ssh_key_expiring_soon(user, keys.map(&:fingerprint)) + + keys.update_all(before_expiry_notification_delivered_at: Time.current.utc) + end + + def trigger_expired_notification + notification_service.ssh_key_expired(user, keys.map(&:fingerprint)) + + keys.update_all(expiry_notification_delivered_at: Time.current.utc) + end + end +end diff --git a/app/services/mattermost/create_team_service.rb b/app/services/mattermost/create_team_service.rb index afcd6439a14..2cbcaaad5e1 100644 --- a/app/services/mattermost/create_team_service.rb +++ b/app/services/mattermost/create_team_service.rb @@ -3,7 +3,8 @@ module Mattermost class CreateTeamService < ::BaseService def initialize(group, current_user) - @group, @current_user = group, current_user + @group = group + @current_user = current_user end def execute diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index cffccda1a44..953cf7f5bf6 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -2,67 +2,98 @@ module Members class CreateService < Members::BaseService - include Gitlab::Utils::StrongMemoize + BlankInvitesError = Class.new(StandardError) + TooManyInvitesError = Class.new(StandardError) - DEFAULT_LIMIT = 100 + DEFAULT_INVITE_LIMIT = 100 - def execute(source) - return error(s_('AddMember|No users specified.')) if user_ids.blank? + def initialize(*args) + super - return error(s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit }) if - user_limit && user_ids.size > user_limit + @errors = [] + @invites = invites_from_params&.split(',')&.uniq&.flatten + @source = params[:source] + end + + def execute + validate_invites! + + add_members + enqueue_onboarding_progress_action + result + rescue BlankInvitesError, TooManyInvitesError => e + error(e.message) + end + + private + + attr_reader :source, :errors, :invites, :member_created_namespace_id + + def invites_from_params + params[:user_ids] + end + + def validate_invites! + raise BlankInvitesError, blank_invites_message if invites.blank? + + return unless user_limit && invites.size > user_limit + + raise TooManyInvitesError, + format(s_("AddMember|Too many users specified (limit is %{user_limit})"), user_limit: user_limit) + end + + def blank_invites_message + s_('AddMember|No users specified.') + end + def add_members members = source.add_users( - user_ids, + invites, params[:access_level], expires_at: params[:expires_at], current_user: current_user ) - errors = [] - - members.each do |member| - if member.invalid? - current_error = - # Invited users may not have an associated user - if member.user.present? - "#{member.user.username}: " - else - "" - end - - current_error += member.errors.full_messages.to_sentence - errors << current_error - else - after_execute(member: member) - end - end - - enqueue_onboarding_progress_action(source) if members.size > errors.size - - return success unless errors.any? + members.each { |member| process_result(member) } + end - error(errors.to_sentence) + def process_result(member) + if member.invalid? + add_error_for_member(member) + else + after_execute(member: member) + @member_created_namespace_id ||= member.namespace_id + end end - private + def add_error_for_member(member) + prefix = "#{member.user.username}: " if member.user.present? - def user_ids - strong_memoize(:user_ids) do - ids = params[:user_ids] || '' - ids.split(',').uniq.flatten - end + errors << "#{prefix}#{member.errors.full_messages.to_sentence}" end def user_limit - limit = params.fetch(:limit, DEFAULT_LIMIT) + limit = params.fetch(:limit, DEFAULT_INVITE_LIMIT) limit && limit < 0 ? nil : limit end - def enqueue_onboarding_progress_action(source) - namespace_id = source.is_a?(Project) ? source.namespace_id : source.id - Namespaces::OnboardingUserAddedWorker.perform_async(namespace_id) + def enqueue_onboarding_progress_action + return unless member_created_namespace_id + + Namespaces::OnboardingUserAddedWorker.perform_async(member_created_namespace_id) + end + + def result + if errors.any? + error(formatted_errors) + else + success + end + end + + def formatted_errors + errors.to_sentence end end end diff --git a/app/services/members/invite_service.rb b/app/services/members/invite_service.rb index 169500d08f0..48010f9c8e7 100644 --- a/app/services/members/invite_service.rb +++ b/app/services/members/invite_service.rb @@ -1,98 +1,46 @@ # frozen_string_literal: true module Members - class InviteService < Members::BaseService - BlankEmailsError = Class.new(StandardError) - TooManyEmailsError = Class.new(StandardError) + class InviteService < Members::CreateService + extend ::Gitlab::Utils::Override def initialize(*args) super @errors = {} - @emails = params[:email]&.split(',')&.uniq&.flatten - end - - def execute(source) - validate_emails! - - @source = source - emails.each(&method(:process_email)) - result - rescue BlankEmailsError, TooManyEmailsError => e - error(e.message) end private - attr_reader :source, :errors, :emails - - def validate_emails! - raise BlankEmailsError, s_('AddMember|Email cannot be blank') if emails.blank? - - if user_limit && emails.size > user_limit - raise TooManyEmailsError, s_("AddMember|Too many users specified (limit is %{user_limit})") % { user_limit: user_limit } - end - end - - def user_limit - limit = params.fetch(:limit, Members::CreateService::DEFAULT_LIMIT) - - limit < 0 ? nil : limit - end - - def process_email(email) - return if existing_member?(email) - return if existing_invite?(email) - return if existing_request?(email) - - add_member(email) - end - - def existing_member?(email) - existing_member = source.members.with_user_by_email(email).exists? - - if existing_member - errors[email] = s_("AddMember|Already a member of %{source_name}") % { source_name: source.name } - return true - end + alias_method :formatted_errors, :errors - false + def invites_from_params + params[:email] end - def existing_invite?(email) - existing_invite = source.members.search_invite_email(email).exists? + def validate_invites! + super - if existing_invite - errors[email] = s_("AddMember|Member already invited to %{source_name}") % { source_name: source.name } - return true - end + # we need the below due to add_users hitting Member#parse_users_list and ignoring invalid emails + # ideally we wouldn't need this, but we can't really change the add_users method + valid, invalid = invites.partition { |email| Member.valid_email?(email) } + @invites = valid - false + invalid.each { |email| errors[email] = s_('AddMember|Invite email is invalid') } end - def existing_request?(email) - existing_request = source.requesters.with_user_by_email(email).exists? - - if existing_request - errors[email] = s_("AddMember|Member cannot be invited because they already requested to join %{source_name}") % { source_name: source.name } - return true - end - - false + override :blank_invites_message + def blank_invites_message + s_('AddMember|Emails cannot be blank') end - def add_member(email) - new_member = source.add_user(email, params[:access_level], current_user: current_user, expires_at: params[:expires_at]) - - errors[email] = new_member.errors.full_messages.to_sentence if new_member.invalid? + override :add_error_for_member + def add_error_for_member(member) + errors[invite_email(member)] = member.errors.full_messages.to_sentence end - def result - if errors.any? - error(errors) - else - success - end + def invite_email(member) + member.invite_email || member.user.email end end end diff --git a/app/services/merge_requests/add_context_service.rb b/app/services/merge_requests/add_context_service.rb index b693f8509a2..77b00f645c9 100644 --- a/app/services/merge_requests/add_context_service.rb +++ b/app/services/merge_requests/add_context_service.rb @@ -49,11 +49,9 @@ module MergeRequests def duplicates existing_oids = merge_request.merge_request_context_commits.map { |commit| commit.sha.to_s } - duplicate_oids = existing_oids.select do |existing_oid| + existing_oids.select do |existing_oid| commit_ids.select { |commit_id| existing_oid.start_with?(commit_id) }.count > 0 end - - duplicate_oids end def build_context_commit_rows(merge_request_id, commits) diff --git a/app/services/merge_requests/after_create_service.rb b/app/services/merge_requests/after_create_service.rb index b22afe8a20d..ed9747a8c99 100644 --- a/app/services/merge_requests/after_create_service.rb +++ b/app/services/merge_requests/after_create_service.rb @@ -24,6 +24,18 @@ module MergeRequests merge_request.create_cross_references!(current_user) OnboardingProgressService.new(merge_request.target_project.namespace).execute(action: :merge_request_created) + + todo_service.new_merge_request(merge_request, current_user) + merge_request.cache_merge_request_closes_issues!(current_user) + + Gitlab::UsageDataCounters::MergeRequestCounter.count(:create) + link_lfs_objects(merge_request) + + delete_milestone_total_merge_requests_counter_cache(merge_request.milestone) + end + + def link_lfs_objects(merge_request) + LinkLfsObjectsService.new(merge_request.target_project).execute(merge_request) end end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 317cd11a69d..3a3765355d8 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -143,8 +143,12 @@ module MergeRequests merge_request, merge_request.project, current_user, old_reviewers) end - def create_pipeline_for(merge_request, user) - MergeRequests::CreatePipelineService.new(project, user).execute(merge_request) + def create_pipeline_for(merge_request, user, async: false) + if async + MergeRequests::CreatePipelineWorker.perform_async(project.id, user.id, merge_request.id) + else + MergeRequests::CreatePipelineService.new(project, user).execute(merge_request) + end end def abort_auto_merge(merge_request, reason) @@ -164,7 +168,7 @@ module MergeRequests def pipeline_merge_requests(pipeline) pipeline.all_merge_requests.opened.each do |merge_request| - next unless pipeline == merge_request.head_pipeline + next unless pipeline.id == merge_request.head_pipeline_id yield merge_request end @@ -195,6 +199,12 @@ module MergeRequests merge_request.update(merge_error: message) if save_message_on_model end + + def delete_milestone_total_merge_requests_counter_cache(milestone) + return unless milestone + + Milestones::MergeRequestsCountService.new(milestone).delete_cache + end end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index e4d3c91d13e..ecc55eae5de 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -16,17 +16,7 @@ module MergeRequests merge_request.source_project = find_source_project merge_request.target_project = find_target_project - # Force remove the source branch? - merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch - - # Only assign merge requests params that are allowed - self.params = assign_allowed_merge_params(merge_request, params) - - # Filter out params that are either not allowed or invalid - filter_params(merge_request) - - # Filter out :add_label_ids and :remove_label_ids params - filter_label_id_params + process_params merge_request.compare_commits = [] set_merge_request_target_branch @@ -70,21 +60,41 @@ module MergeRequests end end - def filter_label_id_params + def filter_id_params # merge_request.assign_attributes(...) below is a Rails # method that only work if all the params it is passed have # corresponding fields in the database. As there are no fields - # in the database for :add_label_ids and :remove_label_ids, we + # in the database for :add_label_ids, :remove_label_ids, + # :add_assignee_ids and :remove_assignee_ids, we # need to remove them from the params before the call to # merge_request.assign_attributes(...) # - # IssuableBaseService#process_label_ids takes care + # IssuableBaseService#process_label_ids and + # IssuableBaseService#process_assignee_ids take care # of the removal. params[:label_ids] = process_label_ids(params, extra_label_ids: merge_request.label_ids.to_a) + params[:assignee_ids] = process_assignee_ids(params, extra_assignee_ids: merge_request.assignee_ids.to_a) + merge_request.assign_attributes(params.to_h.compact) end + def process_params + # Force remove the source branch? + merge_request.merge_params['force_remove_source_branch'] = force_remove_source_branch + + # Only assign merge requests params that are allowed + self.params = assign_allowed_merge_params(merge_request, params) + + # Filter out params that are either not allowed or invalid + filter_params(merge_request) + + # Filter out the following from params: + # - :add_label_ids and :remove_label_ids + # - :add_assignee_ids and :remove_assignee_ids + filter_id_params + end + def find_source_project source_project = project_from_params(:source_project) return source_project if source_project.present? && can?(current_user, :create_merge_request_from, source_project) diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index ac84a13f437..8186472ec65 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -14,16 +14,12 @@ module MergeRequests end def after_create(issuable) + issuable.mark_as_preparing + # Add new items to MergeRequests::AfterCreateService if they can # be performed in Sidekiq NewMergeRequestWorker.perform_async(issuable.id, current_user.id) - todo_service.new_merge_request(issuable, current_user) - issuable.cache_merge_request_closes_issues!(current_user) - - Gitlab::UsageDataCounters::MergeRequestCounter.count(:create) - link_lfs_objects(issuable) - super end @@ -54,10 +50,6 @@ module MergeRequests raise Gitlab::Access::AccessDeniedError end end - - def link_lfs_objects(issuable) - LinkLfsObjectsService.new(issuable.target_project).execute(issuable) - end end end diff --git a/app/services/merge_requests/handle_assignees_change_service.rb b/app/services/merge_requests/handle_assignees_change_service.rb new file mode 100644 index 00000000000..77ff0791eb4 --- /dev/null +++ b/app/services/merge_requests/handle_assignees_change_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module MergeRequests + class HandleAssigneesChangeService < MergeRequests::BaseService + def async_execute(merge_request, old_assignees, options = {}) + if Feature.enabled?(:async_handle_merge_request_assignees_change, merge_request.target_project, default_enabled: :yaml) + MergeRequests::HandleAssigneesChangeWorker + .perform_async( + merge_request.id, + current_user.id, + old_assignees.map(&:id), + options + ) + else + execute(merge_request, old_assignees, options) + end + end + + def execute(merge_request, old_assignees, options = {}) + create_assignee_note(merge_request, old_assignees) + notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees.to_a) + todo_service.reassigned_assignable(merge_request, current_user, old_assignees) + + new_assignees = merge_request.assignees - old_assignees + merge_request_activity_counter.track_users_assigned_to_mr(users: new_assignees) + merge_request_activity_counter.track_assignees_changed_action(user: current_user) + + execute_assignees_hooks(merge_request, old_assignees) if options[:execute_hooks] + end + + private + + def execute_assignees_hooks(merge_request, old_assignees) + execute_hooks( + merge_request, + 'update', + old_associations: { assignees: old_assignees } + ) + end + end +end + +MergeRequests::HandleAssigneesChangeService.prepend_if_ee('EE::MergeRequests::HandleAssigneesChangeService') diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb index c0115e94903..e07e0c985b4 100644 --- a/app/services/merge_requests/merge_to_ref_service.rb +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -66,7 +66,13 @@ module MergeRequests end def commit - repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message, first_parent_ref, allow_conflicts) + repository.merge_to_ref(current_user, + source_sha: source, + branch: merge_request.target_branch, + target_ref: target_ref, + message: commit_message, + first_parent_ref: first_parent_ref, + allow_conflicts: allow_conflicts) rescue Gitlab::Git::PreReceiveError, Gitlab::Git::CommandError => error raise MergeError, error.message end diff --git a/app/services/merge_requests/migrate_external_diffs_service.rb b/app/services/merge_requests/migrate_external_diffs_service.rb index 89b1e594c95..b1d2cd5d1c7 100644 --- a/app/services/merge_requests/migrate_external_diffs_service.rb +++ b/app/services/merge_requests/migrate_external_diffs_service.rb @@ -2,7 +2,7 @@ module MergeRequests class MigrateExternalDiffsService < ::BaseService - MAX_JOBS = 1000.freeze + MAX_JOBS = 1000 attr_reader :diff diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb index 821558b8d6f..05ec87c7d60 100644 --- a/app/services/merge_requests/push_options_handler_service.rb +++ b/app/services/merge_requests/push_options_handler_service.rb @@ -129,7 +129,9 @@ module MergeRequests target_branch: push_options[:target], force_remove_source_branch: push_options[:remove_source_branch], label: push_options[:label], - unlabel: push_options[:unlabel] + unlabel: push_options[:unlabel], + assign: push_options[:assign], + unassign: push_options[:unassign] } params.compact! @@ -137,6 +139,9 @@ module MergeRequests params[:add_labels] = params.delete(:label).keys if params.has_key?(:label) params[:remove_labels] = params.delete(:unlabel).keys if params.has_key?(:unlabel) + params[:add_assignee_ids] = params.delete(:assign).keys if params.has_key?(:assign) + params[:remove_assignee_ids] = params.delete(:unassign).keys if params.has_key?(:unassign) + params end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 0fb16597aff..e04c5168cef 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -162,9 +162,12 @@ module MergeRequests end def refresh_pipelines_on_merge_requests(merge_request) - create_pipeline_for(merge_request, current_user) - - UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) + if Feature.enabled?(:code_review_async_pipeline_creation, project, default_enabled: :yaml) + create_pipeline_for(merge_request, current_user, async: true) + else + create_pipeline_for(merge_request, current_user, async: false) + UpdateHeadPipelineForMergeRequestWorker.perform_async(merge_request.id) + end end def abort_auto_merges(merge_request) diff --git a/app/services/merge_requests/resolve_todos_service.rb b/app/services/merge_requests/resolve_todos_service.rb new file mode 100644 index 00000000000..0010b596eee --- /dev/null +++ b/app/services/merge_requests/resolve_todos_service.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module MergeRequests + class ResolveTodosService + include BaseServiceUtility + + def initialize(merge_request, user) + @merge_request = merge_request + @user = user + end + + def async_execute + if Feature.enabled?(:resolve_merge_request_todos_async, merge_request.target_project, default_enabled: :yaml) + MergeRequests::ResolveTodosWorker.perform_async(merge_request.id, user.id) + else + execute + end + end + + def execute + todo_service.resolve_todos_for_target(merge_request, user) + end + + private + + attr_reader :merge_request, :user + end +end diff --git a/app/services/merge_requests/retarget_chain_service.rb b/app/services/merge_requests/retarget_chain_service.rb index f24d67243c9..e8101e447d2 100644 --- a/app/services/merge_requests/retarget_chain_service.rb +++ b/app/services/merge_requests/retarget_chain_service.rb @@ -17,7 +17,7 @@ module MergeRequests .opened .by_target_branch(merge_request.source_branch) .preload_source_project - .at_most(MAX_RETARGET_MERGE_REQUESTS) + .limit(MAX_RETARGET_MERGE_REQUESTS) other_merge_requests.find_each do |other_merge_request| # Update only MRs on projects that we have access to diff --git a/app/services/merge_requests/update_assignees_service.rb b/app/services/merge_requests/update_assignees_service.rb new file mode 100644 index 00000000000..b339a644e8c --- /dev/null +++ b/app/services/merge_requests/update_assignees_service.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module MergeRequests + class UpdateAssigneesService < UpdateService + # a stripped down service that only does what it must to update the + # assignees, and knows that it does not have to check for other updates. + # This saves a lot of queries for irrelevant things that cannot possibly + # change in the execution of this service. + def execute(merge_request) + return merge_request unless current_user&.can?(:update_merge_request, merge_request) + + old_assignees = merge_request.assignees + old_ids = old_assignees.map(&:id) + new_ids = new_assignee_ids(merge_request) + return merge_request if new_ids.size != update_attrs[:assignee_ids].size + return merge_request if old_ids.to_set == new_ids.to_set # no-change + + attrs = update_attrs.merge(assignee_ids: new_ids) + merge_request.update!(**attrs) + + # Defer the more expensive operations (handle_assignee_changes) to the background + MergeRequests::HandleAssigneesChangeService + .new(project, current_user) + .async_execute(merge_request, old_assignees, execute_hooks: true) + + merge_request + end + + private + + def new_assignee_ids(merge_request) + # prime the cache - prevent N+1 lookup during authorization loop. + merge_request.project.team.max_member_access_for_user_ids(update_attrs[:assignee_ids]) + User.id_in(update_attrs[:assignee_ids]).map do |user| + if user.can?(:read_merge_request, merge_request) + user.id + else + merge_request.errors.add( + :assignees, + "Cannot assign #{user.to_reference} to #{merge_request.to_reference}" + ) + nil + end + end.compact + end + + def assignee_ids + params.fetch(:assignee_ids).first(1) + end + + def params + ps = super + + # allow either assignee_id or assignee_ids, preferring assignee_id if passed. + { assignee_ids: ps.key?(:assignee_id) ? Array.wrap(ps[:assignee_id]) : ps[:assignee_ids] } + end + + def update_attrs + @attrs ||= { updated_at: Time.current, updated_by: current_user, assignee_ids: assignee_ids } + end + end +end + +MergeRequests::UpdateAssigneesService.prepend_if_ee('EE::MergeRequests::UpdateAssigneesService') diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index f5e14797f7e..8995c5f2411 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -11,18 +11,7 @@ module MergeRequests end def execute(merge_request) - # We don't allow change of source/target projects and source branch - # after merge request was created - params.delete(:source_project_id) - params.delete(:target_project_id) - params.delete(:source_branch) - - if merge_request.closed_or_merged_without_fork? - params.delete(:target_branch) - params.delete(:force_remove_source_branch) - end - - update_task_event(merge_request) || update(merge_request) + update_merge_request_with_specialized_service(merge_request) || general_fallback(merge_request) end def handle_changes(merge_request, options) @@ -86,6 +75,21 @@ module MergeRequests attr_reader :target_branch_was_deleted + def general_fallback(merge_request) + # We don't allow change of source/target projects and source branch + # after merge request was created + params.delete(:source_project_id) + params.delete(:target_project_id) + params.delete(:source_branch) + + if merge_request.closed_or_merged_without_fork? + params.delete(:target_branch) + params.delete(:force_remove_source_branch) + end + + update_task_event(merge_request) || update(merge_request) + end + def track_title_and_desc_edits(changed_fields) tracked_fields = %w(title description) @@ -147,7 +151,11 @@ module MergeRequests def resolve_todos(merge_request, old_labels, old_assignees, old_reviewers) return unless has_changes?(merge_request, old_labels: old_labels, old_assignees: old_assignees, old_reviewers: old_reviewers) - todo_service.resolve_todos_for_target(merge_request, current_user) + service_user = current_user + + merge_request.run_after_commit_or_now do + ::MergeRequests::ResolveTodosService.new(merge_request, service_user).async_execute + end end def handle_target_branch_change(merge_request) @@ -200,21 +208,22 @@ module MergeRequests merge_request_activity_counter.track_milestone_changed_action(user: current_user) + previous_milestone = Milestone.find_by_id(merge_request.previous_changes['milestone_id'].first) + delete_milestone_total_merge_requests_counter_cache(previous_milestone) + if merge_request.milestone.nil? notification_service.async.removed_milestone_merge_request(merge_request, current_user) else notification_service.async.changed_milestone_merge_request(merge_request, merge_request.milestone, current_user) + + delete_milestone_total_merge_requests_counter_cache(merge_request.milestone) end end def handle_assignees_change(merge_request, old_assignees) - create_assignee_note(merge_request, old_assignees) - notification_service.async.reassigned_merge_request(merge_request, current_user, old_assignees) - todo_service.reassigned_assignable(merge_request, current_user, old_assignees) - - new_assignees = merge_request.assignees - old_assignees - merge_request_activity_counter.track_users_assigned_to_mr(users: new_assignees) - merge_request_activity_counter.track_assignees_changed_action(user: current_user) + MergeRequests::HandleAssigneesChangeService + .new(project, current_user) + .async_execute(merge_request, old_assignees) end def handle_reviewers_change(merge_request, old_reviewers) @@ -267,6 +276,34 @@ module MergeRequests def quick_action_options { merge_request_diff_head_sha: params.delete(:merge_request_diff_head_sha) } end + + def update_merge_request_with_specialized_service(merge_request) + return unless params.delete(:use_specialized_service) + + # If we're attempting to modify only a single attribute, look up whether + # we have a specialized, targeted service we should use instead. We may + # in the future extend this to include specialized services that operate + # on multiple attributes, but for now limit to only single attribute + # updates. + # + return unless params.one? + + attempt_specialized_update_services(merge_request, params.each_key.first.to_sym) + end + + def attempt_specialized_update_services(merge_request, attribute) + case attribute + when :assignee_ids + assignees_service.execute(merge_request) + else + nil + end + end + + def assignees_service + @assignees_service ||= ::MergeRequests::UpdateAssigneesService + .new(project, current_user, params) + end end end diff --git a/app/services/metrics/dashboard/annotations/create_service.rb b/app/services/metrics/dashboard/annotations/create_service.rb index c04f4c56b51..54f4e96378c 100644 --- a/app/services/metrics/dashboard/annotations/create_service.rb +++ b/app/services/metrics/dashboard/annotations/create_service.rb @@ -13,7 +13,8 @@ module Metrics :create def initialize(user, params) - @user, @params = user, params + @user = user + @params = params end def execute diff --git a/app/services/metrics/dashboard/annotations/delete_service.rb b/app/services/metrics/dashboard/annotations/delete_service.rb index c6a6c4f5fbf..3efe6924a9b 100644 --- a/app/services/metrics/dashboard/annotations/delete_service.rb +++ b/app/services/metrics/dashboard/annotations/delete_service.rb @@ -11,7 +11,8 @@ module Metrics :delete def initialize(user, annotation) - @user, @annotation = user, annotation + @user = user + @annotation = annotation end def execute diff --git a/app/services/metrics/dashboard/grafana_metric_embed_service.rb b/app/services/metrics/dashboard/grafana_metric_embed_service.rb index b8c5c17c738..6069d236e82 100644 --- a/app/services/metrics/dashboard/grafana_metric_embed_service.rb +++ b/app/services/metrics/dashboard/grafana_metric_embed_service.rb @@ -122,7 +122,8 @@ module Metrics # Identifies the uid of the dashboard based on url format class GrafanaUidParser def initialize(grafana_url, project) - @grafana_url, @project = grafana_url, project + @grafana_url = grafana_url + @project = project end def parse @@ -145,7 +146,8 @@ module Metrics # If no panel is specified, defaults to the first valid panel. class DatasourceNameParser def initialize(grafana_url, grafana_dashboard) - @grafana_url, @grafana_dashboard = grafana_url, grafana_dashboard + @grafana_url = grafana_url + @grafana_dashboard = grafana_dashboard end def parse diff --git a/app/services/metrics/dashboard/panel_preview_service.rb b/app/services/metrics/dashboard/panel_preview_service.rb index 5b24d817fb6..02dd908e229 100644 --- a/app/services/metrics/dashboard/panel_preview_service.rb +++ b/app/services/metrics/dashboard/panel_preview_service.rb @@ -22,7 +22,9 @@ module Metrics ].freeze def initialize(project, panel_yaml, environment) - @project, @panel_yaml, @environment = project, panel_yaml, environment + @project = project + @panel_yaml = panel_yaml + @environment = environment end def execute diff --git a/app/services/metrics/users_starred_dashboards/create_service.rb b/app/services/metrics/users_starred_dashboards/create_service.rb index 7784ed4eb4e..9642df87861 100644 --- a/app/services/metrics/users_starred_dashboards/create_service.rb +++ b/app/services/metrics/users_starred_dashboards/create_service.rb @@ -11,7 +11,9 @@ module Metrics :create def initialize(user, project, dashboard_path) - @user, @project, @dashboard_path = user, project, dashboard_path + @user = user + @project = project + @dashboard_path = dashboard_path end def execute diff --git a/app/services/metrics/users_starred_dashboards/delete_service.rb b/app/services/metrics/users_starred_dashboards/delete_service.rb index 579715bd49f..229c0e8cfc0 100644 --- a/app/services/metrics/users_starred_dashboards/delete_service.rb +++ b/app/services/metrics/users_starred_dashboards/delete_service.rb @@ -5,7 +5,9 @@ module Metrics module UsersStarredDashboards class DeleteService < ::BaseService def initialize(user, project, dashboard_path = nil) - @user, @project, @dashboard_path = user, project, dashboard_path + @user = user + @project = project + @dashboard_path = dashboard_path end def execute diff --git a/app/services/milestones/base_service.rb b/app/services/milestones/base_service.rb index f30194c0bfe..0d7d855bf5e 100644 --- a/app/services/milestones/base_service.rb +++ b/app/services/milestones/base_service.rb @@ -6,7 +6,9 @@ module Milestones attr_accessor :parent, :current_user, :params def initialize(parent, user, params = {}) - @parent, @current_user, @params = parent, user, params.dup + @parent = parent + @current_user = user + @params = params.dup super end end diff --git a/app/services/milestones/find_or_create_service.rb b/app/services/milestones/find_or_create_service.rb index 881011e5106..b467ff98f54 100644 --- a/app/services/milestones/find_or_create_service.rb +++ b/app/services/milestones/find_or_create_service.rb @@ -5,7 +5,9 @@ module Milestones attr_accessor :project, :current_user, :params def initialize(project, user, params = {}) - @project, @current_user, @params = project, user, params.dup + @project = project + @current_user = user + @params = params.dup end def execute diff --git a/app/services/milestones/merge_requests_count_service.rb b/app/services/milestones/merge_requests_count_service.rb new file mode 100644 index 00000000000..be9ce3af44d --- /dev/null +++ b/app/services/milestones/merge_requests_count_service.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Milestones + class MergeRequestsCountService < BaseCountService + def initialize(milestone) + @milestone = milestone + end + + def cache_key + "milestone_merge_requests_count_#{@milestone.milestoneish_id}" + end + + def relation_for_count + @milestone.merge_requests + end + end +end diff --git a/app/services/milestones/transfer_service.rb b/app/services/milestones/transfer_service.rb index 18d7e41adc7..b9bd259ca8b 100644 --- a/app/services/milestones/transfer_service.rb +++ b/app/services/milestones/transfer_service.rb @@ -24,6 +24,9 @@ module Milestones update_issues_milestone(milestone, new_milestone) update_merge_requests_milestone(milestone.id, new_milestone&.id) + + delete_milestone_counts_caches(milestone) + delete_milestone_counts_caches(new_milestone) end end end @@ -71,9 +74,6 @@ module Milestones def update_issues_milestone(old_milestone, new_milestone) Issue.where(project: project, milestone_id: old_milestone.id) .update_all(milestone_id: new_milestone&.id) - - delete_milestone_issues_caches(old_milestone) - delete_milestone_issues_caches(new_milestone) end # rubocop: enable CodeReuse/ActiveRecord @@ -84,11 +84,12 @@ module Milestones end # rubocop: enable CodeReuse/ActiveRecord - def delete_milestone_issues_caches(milestone) + def delete_milestone_counts_caches(milestone) return unless milestone Milestones::IssuesCountService.new(milestone).delete_cache Milestones::ClosedIssuesCountService.new(milestone).delete_cache + Milestones::MergeRequestsCountService.new(milestone).delete_cache end end end diff --git a/app/services/namespace_settings/update_service.rb b/app/services/namespace_settings/update_service.rb index 3c9b7b637ac..c6c04b63690 100644 --- a/app/services/namespace_settings/update_service.rb +++ b/app/services/namespace_settings/update_service.rb @@ -13,12 +13,25 @@ module NamespaceSettings end def execute + validate_resource_access_token_creation_allowed_param + if group.namespace_settings group.namespace_settings.attributes = settings_params else group.build_namespace_settings(settings_params) end end + + private + + def validate_resource_access_token_creation_allowed_param + return if settings_params[:resource_access_token_creation_allowed].nil? + + unless can?(current_user, :admin_group, group) + settings_params.delete(:resource_access_token_creation_allowed) + group.namespace_settings.errors.add(:resource_access_token_creation_allowed, _('can only be changed by a group admin.')) + end + end end end diff --git a/app/services/namespaces/in_product_marketing_emails_service.rb b/app/services/namespaces/in_product_marketing_emails_service.rb index f009f5d8538..eb81253bc08 100644 --- a/app/services/namespaces/in_product_marketing_emails_service.rb +++ b/app/services/namespaces/in_product_marketing_emails_service.rb @@ -23,10 +23,12 @@ module Namespaces def initialize(track, interval) @track = track @interval = interval - @sent_email_user_ids = [] + @in_product_marketing_email_records = [] end def execute + raise ArgumentError, "Track #{track} not defined" unless TRACKS.key?(track) + groups_for_track.each_batch do |groups| groups.each do |group| send_email_for_group(group) @@ -36,16 +38,23 @@ module Namespaces private - attr_reader :track, :interval, :sent_email_user_ids + attr_reader :track, :interval, :in_product_marketing_email_records def send_email_for_group(group) - experiment_enabled_for_group = experiment_enabled_for_group?(group) - experiment_add_group(group, experiment_enabled_for_group) - return unless experiment_enabled_for_group + if Gitlab.com? + experiment_enabled_for_group = experiment_enabled_for_group?(group) + experiment_add_group(group, experiment_enabled_for_group) + return unless experiment_enabled_for_group + end users_for_group(group).each do |user| - send_email(user, group) if can_perform_action?(user, group) + if can_perform_action?(user, group) + send_email(user, group) + track_sent_email(user, track, series) + end end + + save_tracked_emails! end def experiment_enabled_for_group?(group) @@ -70,8 +79,9 @@ module Namespaces end def users_for_group(group) - group.users.where(email_opted_in: true) - .where.not(id: sent_email_user_ids) + group.users + .where(email_opted_in: true) + .merge(Users::InProductMarketingEmail.without_track_and_series(track, series)) end # rubocop: enable CodeReuse/ActiveRecord @@ -85,14 +95,11 @@ module Namespaces user.can?(:start_trial, group) when :team user.can?(:admin_group_member, group) - else - raise NotImplementedError, "No ability defined for track #{track}" end end def send_email(user, group) NotificationService.new.in_product_marketing(user.id, group.id, track, series) - sent_email_user_ids << user.id end def completed_actions @@ -101,7 +108,8 @@ module Namespaces end def range - (interval + 1).days.ago.beginning_of_day..(interval + 1).days.ago.end_of_day + date = (interval + 1).days.ago + date.beginning_of_day..date.end_of_day end def incomplete_action @@ -111,5 +119,20 @@ module Namespaces def series INTERVAL_DAYS.index(interval) end + + def save_tracked_emails! + Users::InProductMarketingEmail.bulk_insert!(in_product_marketing_email_records) + @in_product_marketing_email_records = [] + end + + def track_sent_email(user, track, series) + in_product_marketing_email_records << Users::InProductMarketingEmail.new( + user: user, + track: track, + series: series, + created_at: Time.zone.now, + updated_at: Time.zone.now + ) + end end end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 488c847dcbb..e63099a0820 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -75,16 +75,9 @@ module Notes increment_usage_counter(note) track_event(note, current_user) - if Feature.enabled?(:notes_create_service_tracking, project) - Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note)) - end - if note.for_merge_request? && note.diff_note? && note.start_of_discussion? Discussions::CaptureDiffNotePositionService.new(note.noteable, note.diff_file&.paths).execute(note.discussion) end - - track_note_creation_usage_for_issues(note) if note.for_issue? - track_note_creation_usage_for_merge_requests(note) if note.for_merge_request? end def do_commands(note, update_params, message, only_commands) @@ -111,6 +104,16 @@ module Notes } end + def track_event(note, user) + track_note_creation_usage_for_issues(note) if note.for_issue? + track_note_creation_usage_for_merge_requests(note) if note.for_merge_request? + track_usage_event(:incident_management_incident_comment, user.id) if note.for_issue? && note.noteable.incident? + + if Feature.enabled?(:notes_create_service_tracking, project) + Gitlab::Tracking.event('Notes::CreateService', 'execute', **tracking_data_for(note)) + end + end + def tracking_data_for(note) label = Gitlab.ee? && note.author == User.visual_review_bot ? 'anonymous_visual_review_note' : 'note' @@ -120,12 +123,6 @@ module Notes } end - def track_event(note, user) - return unless note.noteable.is_a?(Issue) && note.noteable.incident? - - track_usage_event(:incident_management_incident_comment, user.id) - end - def track_note_creation_usage_for_issues(note) Gitlab::UsageDataCounters::IssueActivityUniqueCounter.track_issue_comment_added_action(author: note.author) end @@ -135,3 +132,5 @@ module Notes end end end + +Notes::CreateService.prepend_if_ee('EE::Notes::CreateService') diff --git a/app/services/notification_recipients/builder/base.rb b/app/services/notification_recipients/builder/base.rb index 81e6750a4ca..b41b969ad7c 100644 --- a/app/services/notification_recipients/builder/base.rb +++ b/app/services/notification_recipients/builder/base.rb @@ -100,6 +100,8 @@ module NotificationRecipients # Get project/group users with CUSTOM notification level # rubocop: disable CodeReuse/ActiveRecord def add_custom_notifications + return new_add_custom_notifications if Feature.enabled?(:notification_setting_recipient_refactor, project) + user_ids = [] # Users with a notification setting on group or project @@ -115,6 +117,48 @@ module NotificationRecipients add_recipients(user_scope.where(id: user_ids), :custom, nil) end + + def new_add_custom_notifications + notification_by_sources = related_notification_settings_sources(:custom) + + return if notification_by_sources.blank? + + user_ids = NotificationSetting.from_union(notification_by_sources).select(:user_id) + + add_recipients(user_scope.where(id: user_ids), :custom, nil) + end + + def related_notification_settings_sources(level) + sources = [project, group].compact + + sources.map do |source| + source + .notification_settings + .where(source_or_global_setting_by_level_query(level)).select(:user_id) + end + end + + def global_setting_by_level_query(level) + table = NotificationSetting.arel_table + aliased_table = table.alias + + table + .project('true') + .from(aliased_table) + .where( + aliased_table[:user_id].eq(table[:user_id]) + .and(aliased_table[:source_id].eq(nil)) + .and(aliased_table[:source_type].eq(nil)) + .and(aliased_table[:level].eq(level)) + ).exists + end + + def source_or_global_setting_by_level_query(level) + table = NotificationSetting.arel_table + table.grouping( + table[:level].eq(:global).and(global_setting_by_level_query(level)) + ).or(table[:level].eq(level)) + end # rubocop: enable CodeReuse/ActiveRecord def add_project_watchers diff --git a/app/services/notification_recipients/builder/request_review.rb b/app/services/notification_recipients/builder/request_review.rb index 911d89c6a8e..8dd0c5d1587 100644 --- a/app/services/notification_recipients/builder/request_review.rb +++ b/app/services/notification_recipients/builder/request_review.rb @@ -6,7 +6,9 @@ module NotificationRecipients attr_reader :merge_request, :current_user, :reviewer def initialize(merge_request, current_user, reviewer) - @merge_request, @current_user, @reviewer = merge_request, current_user, reviewer + @merge_request = merge_request + @current_user = current_user + @reviewer = reviewer end def target diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index fc2eb1dc4e4..6f1f3309ad9 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -79,6 +79,20 @@ class NotificationService mailer.access_token_expired_email(user).deliver_later end + # Notify the user when at least one of their ssh key has expired today + def ssh_key_expired(user, fingerprints) + return unless user.can?(:receive_notifications) + + mailer.ssh_key_expired_email(user, fingerprints).deliver_later + end + + # Notify the user when at least one of their ssh key is expiring soon + def ssh_key_expiring_soon(user, fingerprints) + return unless user.can?(:receive_notifications) + + mailer.ssh_key_expiring_soon_email(user, fingerprints).deliver_later + end + # Notify a user when a previously unknown IP or device is used to # sign in to their account def unknown_sign_in(user, ip, time) @@ -857,7 +871,7 @@ class NotificationService end def warn_skipping_notifications(user, object) - Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class, object_id: object.id) + Gitlab::AppLogger.warn(message: "Skipping sending notifications", user: user.id, klass: object.class.to_s, object_id: object.id) end end diff --git a/app/services/packages/composer/composer_json_service.rb b/app/services/packages/composer/composer_json_service.rb index 98aabd84d3d..f346b654c59 100644 --- a/app/services/packages/composer/composer_json_service.rb +++ b/app/services/packages/composer/composer_json_service.rb @@ -6,7 +6,8 @@ module Packages InvalidJson = Class.new(StandardError) def initialize(project, target) - @project, @target = project, target + @project = project + @target = target end def execute diff --git a/app/services/packages/composer/version_parser_service.rb b/app/services/packages/composer/version_parser_service.rb index 811cac0b3b7..36275d1b680 100644 --- a/app/services/packages/composer/version_parser_service.rb +++ b/app/services/packages/composer/version_parser_service.rb @@ -4,7 +4,8 @@ module Packages module Composer class VersionParserService def initialize(tag_name: nil, branch_name: nil) - @tag_name, @branch_name = tag_name, branch_name + @tag_name = tag_name + @branch_name = branch_name end def execute diff --git a/app/services/packages/debian/create_distribution_service.rb b/app/services/packages/debian/create_distribution_service.rb index c6df033e3c1..f947d2e4293 100644 --- a/app/services/packages/debian/create_distribution_service.rb +++ b/app/services/packages/debian/create_distribution_service.rb @@ -4,7 +4,8 @@ module Packages module Debian class CreateDistributionService def initialize(container, user, params) - @container, @params = container, params + @container = container + @params = params @params[:creator] = user @components = params.delete(:components) || ['main'] diff --git a/app/services/packages/debian/extract_changes_metadata_service.rb b/app/services/packages/debian/extract_changes_metadata_service.rb new file mode 100644 index 00000000000..eb5baa7e53f --- /dev/null +++ b/app/services/packages/debian/extract_changes_metadata_service.rb @@ -0,0 +1,112 @@ +# frozen_string_literal: true + +module Packages + module Debian + class ExtractChangesMetadataService + include Gitlab::Utils::StrongMemoize + + ExtractionError = Class.new(StandardError) + + def initialize(package_file) + @package_file = package_file + @entries = {} + end + + def execute + { + file_type: file_type, + architecture: metadata[:architecture], + fields: fields, + files: files + } + rescue ActiveModel::ValidationError => e + raise ExtractionError.new(e.message) + end + + private + + def metadata + strong_memoize(:metadata) do + ::Packages::Debian::ExtractMetadataService.new(@package_file).execute + end + end + + def file_type + metadata[:file_type] + end + + def fields + metadata[:fields] + end + + def files + strong_memoize(:files) do + raise ExtractionError.new("is not a changes file") unless file_type == :changes + raise ExtractionError.new("Files field is missing") if fields['Files'].blank? + raise ExtractionError.new("Checksums-Sha1 field is missing") if fields['Checksums-Sha1'].blank? + raise ExtractionError.new("Checksums-Sha256 field is missing") if fields['Checksums-Sha256'].blank? + + init_entries_from_files + entries_from_checksums_sha1 + entries_from_checksums_sha256 + entries_from_package_files + + @entries + end + end + + def init_entries_from_files + each_lines_for('Files') do |line| + md5sum, size, section, priority, filename = line.split + entry = FileEntry.new( + filename: filename, + size: size.to_i, + md5sum: md5sum, + section: section, + priority: priority) + + @entries[filename] = entry + end + end + + def entries_from_checksums_sha1 + each_lines_for('Checksums-Sha1') do |line| + sha1sum, size, filename = line.split + entry = @entries[filename] + raise ExtractionError.new("#{filename} is listed in Checksums-Sha1 but not in Files") unless entry + raise ExtractionError.new("Size for #{filename} in Files and Checksums-Sha1 differ") unless entry.size == size.to_i + + entry.sha1sum = sha1sum + end + end + + def entries_from_checksums_sha256 + each_lines_for('Checksums-Sha256') do |line| + sha256sum, size, filename = line.split + entry = @entries[filename] + raise ExtractionError.new("#{filename} is listed in Checksums-Sha256 but not in Files") unless entry + raise ExtractionError.new("Size for #{filename} in Files and Checksums-Sha256 differ") unless entry.size == size.to_i + + entry.sha256sum = sha256sum + end + end + + def each_lines_for(field) + fields[field].split("\n").each do |line| + next if line.blank? + + yield(line) + end + end + + def entries_from_package_files + @entries.each do |filename, entry| + entry.package_file = ::Packages::PackageFileFinder.new(@package_file.package, filename).execute! + entry.validate! + rescue ActiveRecord::RecordNotFound + raise ExtractionError.new("#{filename} is listed in Files but was not uploaded") + end + end + end + end +end diff --git a/app/services/packages/debian/extract_metadata_service.rb b/app/services/packages/debian/extract_metadata_service.rb index fd5832bc0ba..015f472c7c9 100644 --- a/app/services/packages/debian/extract_metadata_service.rb +++ b/app/services/packages/debian/extract_metadata_service.rb @@ -58,21 +58,22 @@ module Packages file_type == :dsc || file_type == :buildinfo || file_type == :changes end - def extracted_fields - if file_type_debian? - package_file.file.use_file do |file_path| - ::Packages::Debian::ExtractDebMetadataService.new(file_path).execute - end - elsif file_type_meta? - package_file.file.use_file do |file_path| - ::Packages::Debian::ParseDebian822Service.new(File.read(file_path)).execute.each_value.first + def fields + strong_memoize(:fields) do + if file_type_debian? + package_file.file.use_file do |file_path| + ::Packages::Debian::ExtractDebMetadataService.new(file_path).execute + end + elsif file_type_meta? + package_file.file.use_file do |file_path| + ::Packages::Debian::ParseDebian822Service.new(File.read(file_path)).execute.each_value.first + end end end end def extract_metadata - fields = extracted_fields - architecture = fields.delete(:Architecture) if file_type_debian? + architecture = fields['Architecture'] if file_type_debian? { file_type: file_type, diff --git a/app/services/packages/debian/parse_debian822_service.rb b/app/services/packages/debian/parse_debian822_service.rb index 665929d2324..8be5fdf3b66 100644 --- a/app/services/packages/debian/parse_debian822_service.rb +++ b/app/services/packages/debian/parse_debian822_service.rb @@ -26,7 +26,7 @@ module Packages section[field] += line[1..] unless paragraph_separator?(line) elsif match = match_section_line(line) section_name = match[:name] if section_name.nil? - field = match[:field].to_sym + field = match[:field] raise InvalidDebian822Error, "Duplicate field '#{field}' in section '#{section_name}'" if section.include?(field) diff --git a/app/services/packages/debian/process_changes_service.rb b/app/services/packages/debian/process_changes_service.rb new file mode 100644 index 00000000000..881ad2c46f4 --- /dev/null +++ b/app/services/packages/debian/process_changes_service.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module Packages + module Debian + class ProcessChangesService + include ExclusiveLeaseGuard + include Gitlab::Utils::StrongMemoize + + # used by ExclusiveLeaseGuard + DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze + + def initialize(package_file, creator) + @package_file = package_file + @creator = creator + end + + def execute + try_obtain_lease do + # return if changes file has already been processed + break if package_file.debian_file_metadatum&.changes? + + validate! + + package_file.transaction do + update_files_metadata + update_changes_metadata + end + end + end + + private + + attr_reader :package_file, :creator + + def validate! + raise ArgumentError, 'invalid package file' unless package_file.debian_file_metadatum + raise ArgumentError, 'invalid package file' unless package_file.debian_file_metadatum.unknown? + raise ArgumentError, 'invalid package file' unless metadata[:file_type] == :changes + end + + def update_files_metadata + files.each do |filename, entry| + entry.package_file.package = package + + file_metadata = ::Packages::Debian::ExtractMetadataService.new(entry.package_file).execute + + entry.package_file.debian_file_metadatum.update!( + file_type: file_metadata[:file_type], + component: files[filename].component, + architecture: file_metadata[:architecture], + fields: file_metadata[:fields] + ) + entry.package_file.save! + end + end + + def update_changes_metadata + package_file.update!(package: package) + package_file.debian_file_metadatum.update!( + file_type: metadata[:file_type], + fields: metadata[:fields] + ) + end + + def metadata + strong_memoize(:metadata) do + ::Packages::Debian::ExtractChangesMetadataService.new(package_file).execute + end + end + + def files + metadata[:files] + end + + def project + package_file.package.project + end + + def package + strong_memoize(:package) do + params = { + 'name': metadata[:fields]['Source'], + 'version': metadata[:fields]['Version'], + 'distribution_name': metadata[:fields]['Distribution'] + } + response = Packages::Debian::FindOrCreatePackageService.new(project, creator, params).execute + response.payload[:package] + end + end + + # used by ExclusiveLeaseGuard + def lease_key + "packages:debian:process_changes_service:package_file:#{package_file.id}" + end + + # used by ExclusiveLeaseGuard + def lease_timeout + DEFAULT_LEASE_TIMEOUT + end + end + end +end diff --git a/app/services/packages/debian/update_distribution_service.rb b/app/services/packages/debian/update_distribution_service.rb index 5bb59b854e9..95face912d5 100644 --- a/app/services/packages/debian/update_distribution_service.rb +++ b/app/services/packages/debian/update_distribution_service.rb @@ -4,7 +4,8 @@ module Packages module Debian class UpdateDistributionService def initialize(distribution, params) - @distribution, @params = distribution, params + @distribution = distribution + @params = params @components = params.delete(:components) diff --git a/app/services/packages/go/create_package_service.rb b/app/services/packages/go/create_package_service.rb new file mode 100644 index 00000000000..4e8b8ef8d6b --- /dev/null +++ b/app/services/packages/go/create_package_service.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +module Packages + module Go + class CreatePackageService < BaseService + GoZipSizeError = Class.new(StandardError) + + attr_accessor :version + + def initialize(project, user = nil, version:) + super(project, user) + + @version = version + end + + def execute + # check for existing package to avoid SQL errors due to the index + package = ::Packages::Go::PackageFinder.new(version.mod.project, version.mod.name, version.name).execute + return package if package + + # this can be expensive, so do it outside the transaction + files = {} + files[:mod] = prepare_file(version, :mod, version.gomod) + files[:zip] = prepare_file(version, :zip, version.archive.string) + + ActiveRecord::Base.transaction do + # create new package and files + package = create_package + files.each { |type, (file, digests)| create_file(package, type, file, digests) } + package + end + end + + private + + def prepare_file(version, type, content) + file = CarrierWaveStringFile.new(content) + raise GoZipSizeError, "#{version.mod.name}@#{version.name}.#{type} exceeds size limit" if file.size > project.actual_limits.golang_max_file_size + + digests = { + md5: Digest::MD5.hexdigest(content), + sha1: Digest::SHA1.hexdigest(content), + sha256: Digest::SHA256.hexdigest(content) + } + + [file, digests] + end + + def create_package + version.mod.project.packages.create!( + name: version.mod.name, + version: version.name, + package_type: :golang, + created_at: version.commit.committed_date + ) + end + + def create_file(package, type, file, digests) + CreatePackageFileService.new(package, + file: file, + size: file.size, + file_name: "#{version.name}.#{type}", + file_md5: digests[:md5], + file_sha1: digests[:sha1], + file_sha256: digests[:sha256] + ).execute + end + end + end +end diff --git a/app/services/packages/go/sync_packages_service.rb b/app/services/packages/go/sync_packages_service.rb new file mode 100644 index 00000000000..c35d3600388 --- /dev/null +++ b/app/services/packages/go/sync_packages_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Packages + module Go + class SyncPackagesService < BaseService + include Gitlab::Golang + + def initialize(project, ref, path = '') + super(project) + + @ref = ref + @path = path + + raise ArgumentError, 'project is required' unless project + raise ArgumentError, 'ref is required' unless ref + raise ArgumentError, "ref #{ref} not found" unless project.repository.find_tag(ref) || project.repository.find_branch(ref) + end + + def execute_async + Packages::Go::SyncPackagesWorker.perform_async(project.id, @ref, @path) + end + end + end +end diff --git a/app/services/packages/maven/find_or_create_package_service.rb b/app/services/packages/maven/find_or_create_package_service.rb index 401e52f7e51..a6cffa3038c 100644 --- a/app/services/packages/maven/find_or_create_package_service.rb +++ b/app/services/packages/maven/find_or_create_package_service.rb @@ -33,7 +33,8 @@ module Packages # # The first upload has to create the proper package (the one with the version set). if params[:file_name] == Packages::Maven::Metadata.filename && !params[:path]&.ends_with?(SNAPSHOT_TERM) - package_name, version = params[:path], nil + package_name = params[:path] + version = nil else package_name, _, version = params[:path].rpartition('/') end diff --git a/app/services/packages/maven/metadata/sync_service.rb b/app/services/packages/maven/metadata/sync_service.rb index a6534aa706d..48e157d4930 100644 --- a/app/services/packages/maven/metadata/sync_service.rb +++ b/app/services/packages/maven/metadata/sync_service.rb @@ -13,16 +13,20 @@ module Packages def execute return error('Blank package name') unless package_name return error('Not allowed') unless Ability.allowed?(current_user, :destroy_package, project) - return error('Non existing versionless package') unless versionless_package_for_versions - return error('Non existing metadata file for versions') unless metadata_package_file_for_versions + result = success('Non existing versionless package(s). Nothing to do.') + + # update versionless package for plugins if it exists if metadata_package_file_for_plugins result = update_plugins_xml return result if result.error? end - update_versions_xml + # update versionless_package for versions if it exists + return update_versions_xml if metadata_package_file_for_versions + + result end private @@ -79,6 +83,9 @@ module Packages def metadata_package_file_for_plugins strong_memoize(:metadata_package_file_for_plugins) do + pkg_name = package_name_for_plugins + next unless pkg_name + metadata_package_file_for(versionless_package_named(package_name_for_plugins)) end end @@ -106,6 +113,8 @@ module Packages end def package_name_for_plugins + return unless versionless_package_for_versions + group = versionless_package_for_versions.maven_metadatum.app_group group.tr('.', '/') end diff --git a/app/services/packages/nuget/create_dependency_service.rb b/app/services/packages/nuget/create_dependency_service.rb index 19143fe3778..62ab485c0fc 100644 --- a/app/services/packages/nuget/create_dependency_service.rb +++ b/app/services/packages/nuget/create_dependency_service.rb @@ -54,9 +54,9 @@ module Packages end def dependencies_for_create_dependency_service - names_and_versions = @dependencies.map do |dependency| + names_and_versions = @dependencies.to_h do |dependency| [dependency[:name], version_or_empty_string(dependency[:version])] - end.to_h + end { 'dependencies' => names_and_versions } end diff --git a/app/services/packages/rubygems/create_dependencies_service.rb b/app/services/packages/rubygems/create_dependencies_service.rb new file mode 100644 index 00000000000..dea429148cf --- /dev/null +++ b/app/services/packages/rubygems/create_dependencies_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Packages + module Rubygems + class CreateDependenciesService + include BulkInsertSafe + + def initialize(package, gemspec) + @package = package + @gemspec = gemspec + end + + def execute + set_dependencies + end + + private + + attr_reader :package, :gemspec + + def set_dependencies + Packages::Dependency.transaction do + dependency_type_rows = gemspec.dependencies.map do |dependency| + dependency = Packages::Dependency.safe_find_or_create_by!( + name: dependency.name, + version_pattern: dependency.requirement.to_s + ) + + { + dependency_id: dependency.id, + package_id: package.id, + dependency_type: :dependencies + } + end + + package.dependency_links.upsert_all( + dependency_type_rows, + unique_by: %i[package_id dependency_id dependency_type] + ) + end + end + end + end +end diff --git a/app/services/packages/rubygems/create_gemspec_service.rb b/app/services/packages/rubygems/create_gemspec_service.rb new file mode 100644 index 00000000000..22533264480 --- /dev/null +++ b/app/services/packages/rubygems/create_gemspec_service.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Packages + module Rubygems + class CreateGemspecService + def initialize(package, gemspec) + @package = package + @gemspec = gemspec + end + + def execute + write_gemspec_to_file + end + + private + + attr_reader :package, :gemspec + + def write_gemspec_to_file + file = Tempfile.new + + begin + content = gemspec.to_ruby + file.write(content) + file.flush + + package.package_files.create!( + file: file, + size: file.size, + file_name: "#{gemspec.name}.gemspec", + file_sha1: Digest::SHA1.hexdigest(content), + file_md5: Digest::MD5.hexdigest(content), + file_sha256: Digest::SHA256.hexdigest(content) + ) + ensure + file.close + file.unlink + end + end + end + end +end diff --git a/app/services/packages/rubygems/metadata_extraction_service.rb b/app/services/packages/rubygems/metadata_extraction_service.rb new file mode 100644 index 00000000000..b3bac1854d7 --- /dev/null +++ b/app/services/packages/rubygems/metadata_extraction_service.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +module Packages + module Rubygems + class MetadataExtractionService + def initialize(package, gemspec) + @package = package + @gemspec = gemspec + end + + def execute + write_metadata + end + + private + + attr_reader :package, :gemspec + + # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/PerceivedComplexity + # rubocop:disable Metrics/CyclomaticComplexity + def write_metadata + metadatum.update!( + authors: gemspec&.authors, + files: gemspec&.files&.to_json, + summary: gemspec&.summary, + description: gemspec&.description, + email: gemspec&.email, + homepage: gemspec&.homepage, + licenses: gemspec&.licenses&.to_json, + metadata: gemspec&.metadata&.to_json, + author: gemspec&.author, + bindir: gemspec&.bindir, + executables: gemspec&.executables&.to_json, + extensions: gemspec&.extensions&.to_json, + extra_rdoc_files: gemspec&.extra_rdoc_files&.to_json, + platform: gemspec&.platform, + post_install_message: gemspec&.post_install_message, + rdoc_options: gemspec&.rdoc_options&.to_json, + require_paths: gemspec&.require_paths&.to_json, + required_ruby_version: gemspec&.required_ruby_version&.to_s, + required_rubygems_version: gemspec&.required_rubygems_version&.to_s, + requirements: gemspec&.requirements&.to_json, + rubygems_version: gemspec&.rubygems_version + ) + end + # rubocop:enable Metrics/AbcSize + # rubocop:enable Metrics/PerceivedComplexity + # rubocop:enable Metrics/CyclomaticComplexity + + def metadatum + Packages::Rubygems::Metadatum.safe_find_or_create_by!(package: package) + end + end + end +end diff --git a/app/services/packages/rubygems/process_gem_service.rb b/app/services/packages/rubygems/process_gem_service.rb new file mode 100644 index 00000000000..59bf2a1ec28 --- /dev/null +++ b/app/services/packages/rubygems/process_gem_service.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +require 'rubygems/package' + +module Packages + module Rubygems + class ProcessGemService + include Gitlab::Utils::StrongMemoize + include ExclusiveLeaseGuard + + ExtractionError = Class.new(StandardError) + DEFAULT_LEASE_TIMEOUT = 1.hour.to_i.freeze + + def initialize(package_file) + @package_file = package_file + end + + def execute + return success if process_gem + + error('Gem was not processed') + end + + private + + attr_reader :package_file + + def process_gem + return false unless package_file + + try_obtain_lease do + package.transaction do + rename_package_and_set_version + rename_package_file + ::Packages::Rubygems::MetadataExtractionService.new(package, gemspec).execute + ::Packages::Rubygems::CreateGemspecService.new(package, gemspec).execute + ::Packages::Rubygems::CreateDependenciesService.new(package, gemspec).execute + cleanup_temp_package + end + end + + true + end + + def rename_package_and_set_version + package.update!( + name: gemspec.name, + version: gemspec.version, + status: :default + ) + end + + def rename_package_file + # Updating file_name updates the path where the file is stored. + # We must pass the file again so that CarrierWave can handle the update + package_file.update!( + file_name: "#{gemspec.name}-#{gemspec.version}.gem", + file: package_file.file, + package_id: package.id + ) + end + + def cleanup_temp_package + temp_package.destroy if package.id != temp_package.id + end + + def gemspec + strong_memoize(:gemspec) do + gem.spec + end + end + + def success + ServiceResponse.success(payload: { package: package }) + end + + def error(message) + ServiceResponse.error(message: message) + end + + def temp_package + strong_memoize(:temp_package) do + package_file.package + end + end + + def package + strong_memoize(:package) do + # if package with name/version already exists, use that package + package = temp_package.project + .packages + .rubygems + .with_name(gemspec.name) + .with_version(gemspec.version.to_s) + .last + package || temp_package + end + end + + def gem + # use_file will set an exclusive lease on the file for as long as + # the resulting gem object is being used. This means we are not + # able to rename the package_file while also using the gem object. + # We need to use a separate AR object to create the gem file to allow + # `package_file` to be free for update so we re-find the file here. + Packages::PackageFile.find(package_file.id).file.use_file do |file_path| + Gem::Package.new(File.open(file_path)) + end + rescue + raise ExtractionError.new('Unable to read gem file') + end + + # used by ExclusiveLeaseGuard + def lease_key + "packages:rubygems:process_gem_service:package:#{package.id}" + end + + # used by ExclusiveLeaseGuard + def lease_timeout + DEFAULT_LEASE_TIMEOUT + end + end + end +end diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb index 3dc9254718e..c4009dcc4ec 100644 --- a/app/services/pages/delete_service.rb +++ b/app/services/pages/delete_service.rb @@ -9,7 +9,7 @@ module Pages DestroyPagesDeploymentsWorker.perform_async(project.id) # TODO: remove this call https://gitlab.com/gitlab-org/gitlab/-/issues/320775 - PagesRemoveWorker.perform_async(project.id) if Feature.enabled?(:pages_update_legacy_storage, default_enabled: true) + PagesRemoveWorker.perform_async(project.id) if ::Settings.pages.local_store.enabled end end end diff --git a/app/services/pages/migrate_from_legacy_storage_service.rb b/app/services/pages/migrate_from_legacy_storage_service.rb index 9b36b3f11b4..b6aa08bba01 100644 --- a/app/services/pages/migrate_from_legacy_storage_service.rb +++ b/app/services/pages/migrate_from_legacy_storage_service.rb @@ -2,36 +2,45 @@ module Pages class MigrateFromLegacyStorageService - def initialize(logger, migration_threads:, batch_size:, ignore_invalid_entries:) + def initialize(logger, ignore_invalid_entries:, mark_projects_as_not_deployed:) @logger = logger - @migration_threads = migration_threads - @batch_size = batch_size @ignore_invalid_entries = ignore_invalid_entries + @mark_projects_as_not_deployed = mark_projects_as_not_deployed @migrated = 0 @errored = 0 @counters_lock = Mutex.new end - def execute + def execute_with_threads(threads:, batch_size:) @queue = SizedQueue.new(1) - threads = start_migration_threads + migration_threads = start_migration_threads(threads) - ProjectPagesMetadatum.only_on_legacy_storage.each_batch(of: @batch_size) do |batch| + ProjectPagesMetadatum.only_on_legacy_storage.each_batch(of: batch_size) do |batch| @queue.push(batch) end @queue.close - @logger.info("Waiting for threads to finish...") - threads.each(&:join) + @logger.info(message: "Pages legacy storage migration: Waiting for threads to finish...") + migration_threads.each(&:join) { migrated: @migrated, errored: @errored } end - def start_migration_threads - Array.new(@migration_threads) do + def execute_for_batch(project_ids) + batch = ProjectPagesMetadatum.only_on_legacy_storage.where(project_id: project_ids) # rubocop: disable CodeReuse/ActiveRecord + + process_batch(batch) + + { migrated: @migrated, errored: @errored } + end + + private + + def start_migration_threads(count) + Array.new(count) do Thread.new do while batch = @queue.pop Rails.application.executor.wrap do @@ -49,30 +58,32 @@ module Pages migrate_project(project) end - @logger.info("#{@migrated} projects are migrated successfully, #{@errored} projects failed to be migrated") + @logger.info(message: "Pages legacy storage migration: batch processed", migrated: @migrated, errored: @errored) rescue => e # This method should never raise exception otherwise all threads might be killed # and this will result in queue starving (and deadlock) Gitlab::ErrorTracking.track_exception(e) - @logger.error("failed processing a batch: #{e.message}") + @logger.error(message: "Pages legacy storage migration: failed processing a batch: #{e.message}") end def migrate_project(project) result = nil time = Benchmark.realtime do - result = ::Pages::MigrateLegacyStorageToDeploymentService.new(project, ignore_invalid_entries: @ignore_invalid_entries).execute + result = ::Pages::MigrateLegacyStorageToDeploymentService.new(project, + ignore_invalid_entries: @ignore_invalid_entries, + mark_projects_as_not_deployed: @mark_projects_as_not_deployed).execute end if result[:status] == :success - @logger.info("project_id: #{project.id} #{project.pages_path} has been migrated in #{time.round(2)} seconds") + @logger.info(message: "Pages legacy storage migration: project migrated: #{result[:message]}", project_id: project.id, pages_path: project.pages_path, duration: time.round(2)) @counters_lock.synchronize { @migrated += 1 } else - @logger.error("project_id: #{project.id} #{project.pages_path} failed to be migrated in #{time.round(2)} seconds: #{result[:message]}") + @logger.error(message: "Pages legacy storage migration: project failed to be migrated: #{result[:message]}", project_id: project.id, pages_path: project.pages_path, duration: time.round(2)) @counters_lock.synchronize { @errored += 1 } end rescue => e @counters_lock.synchronize { @errored += 1 } - @logger.error("project_id: #{project&.id} #{project&.pages_path} failed to be migrated: #{e.message}") + @logger.error(message: "Pages legacy storage migration: project failed to be migrated: #{result[:message]}", project_id: project&.id, pages_path: project&.pages_path) Gitlab::ErrorTracking.track_exception(e, project_id: project&.id) end end diff --git a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb b/app/services/pages/migrate_legacy_storage_to_deployment_service.rb index 63410b9fe4a..95c7107eb62 100644 --- a/app/services/pages/migrate_legacy_storage_to_deployment_service.rb +++ b/app/services/pages/migrate_legacy_storage_to_deployment_service.rb @@ -9,9 +9,10 @@ module Pages attr_reader :project - def initialize(project, ignore_invalid_entries: false) + def initialize(project, ignore_invalid_entries: false, mark_projects_as_not_deployed: false) @project = project @ignore_invalid_entries = ignore_invalid_entries + @mark_projects_as_not_deployed = mark_projects_as_not_deployed end def execute @@ -30,16 +31,20 @@ module Pages zip_result = ::Pages::ZipDirectoryService.new(project.pages_path, ignore_invalid_entries: @ignore_invalid_entries).execute if zip_result[:status] == :error - if !project.pages_metadatum&.reload&.pages_deployment && - Feature.enabled?(:pages_migration_mark_as_not_deployed, project) - project.mark_pages_as_not_deployed - end - return error("Can't create zip archive: #{zip_result[:message]}") end archive_path = zip_result[:archive_path] + unless archive_path + return error("Archive not created. Missing public directory in #{@project.pages_path}") unless @mark_projects_as_not_deployed + + project.set_first_pages_deployment!(nil) + + return success( + message: "Archive not created. Missing public directory in #{project.pages_path}? Marked project as not deployed") + end + deployment = nil File.open(archive_path) do |file| deployment = project.pages_deployments.create!( diff --git a/app/services/pages/zip_directory_service.rb b/app/services/pages/zip_directory_service.rb index ae08d40ee37..6cb79452e1b 100644 --- a/app/services/pages/zip_directory_service.rb +++ b/app/services/pages/zip_directory_service.rb @@ -18,9 +18,7 @@ module Pages end def execute - unless resolve_public_dir - return error("Can not find valid public dir in #{@input_dir}") - end + return success unless resolve_public_dir output_file = File.join(real_dir, "@migrated.zip") # '@' to avoid any name collision with groups or projects diff --git a/app/services/pod_logs/kubernetes_service.rb b/app/services/pod_logs/kubernetes_service.rb index 03b84f98973..28b1a179635 100644 --- a/app/services/pod_logs/kubernetes_service.rb +++ b/app/services/pod_logs/kubernetes_service.rb @@ -2,7 +2,7 @@ module PodLogs class KubernetesService < PodLogs::BaseService - LOGS_LIMIT = 500.freeze + LOGS_LIMIT = 500 REPLACEMENT_CHAR = "\u{FFFD}" EncodingHelperError = Class.new(StandardError) diff --git a/app/services/post_receive_service.rb b/app/services/post_receive_service.rb index 84d9db5435b..3dc8fd8929a 100644 --- a/app/services/post_receive_service.rb +++ b/app/services/post_receive_service.rb @@ -48,7 +48,7 @@ class PostReceiveService end def process_mr_push_options(push_options, changes) - Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-foss/issues/61359') + Gitlab::QueryLimiting.disable!('https://gitlab.com/gitlab-org/gitlab/-/issues/28494') return unless repository unless repository.repo_type.project? diff --git a/app/services/projects/alerting/notify_service.rb b/app/services/projects/alerting/notify_service.rb index 2ba64b73699..a5ee7173bdf 100644 --- a/app/services/projects/alerting/notify_service.rb +++ b/app/services/projects/alerting/notify_service.rb @@ -36,7 +36,7 @@ module Projects override :alert_source def alert_source - alert.monitoring_tool || integration&.name || 'Generic Alert Endpoint' + super || integration&.name || 'Generic Alert Endpoint' end def active_integration? diff --git a/app/services/projects/branches_by_mode_service.rb b/app/services/projects/branches_by_mode_service.rb index fb66bfa073b..dbdcef066f4 100644 --- a/app/services/projects/branches_by_mode_service.rb +++ b/app/services/projects/branches_by_mode_service.rb @@ -71,7 +71,8 @@ class Projects::BranchesByModeService # And increase it whenever we go to the next page previous_offset = params[:offset].to_i - previous_path, next_path = nil, nil + previous_path = nil + next_path = nil return [branches, previous_path, next_path] if branches.blank? diff --git a/app/services/projects/create_from_template_service.rb b/app/services/projects/create_from_template_service.rb index 45b52a1861c..3c66ff709c9 100644 --- a/app/services/projects/create_from_template_service.rb +++ b/app/services/projects/create_from_template_service.rb @@ -7,7 +7,8 @@ module Projects attr_reader :template_name def initialize(user, params) - @current_user, @params = user, params.to_h.dup + @current_user = user + @params = params.to_h.dup @template_name = @params.delete(:template_name).presence end diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index e3b1fd5f4c0..5fb0bda912e 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -5,11 +5,12 @@ module Projects include ValidatesClassificationLabel def initialize(user, params) - @current_user, @params = user, params.dup - @skip_wiki = @params.delete(:skip_wiki) + @current_user = user + @params = params.dup + @skip_wiki = @params.delete(:skip_wiki) @initialize_with_readme = Gitlab::Utils.to_boolean(@params.delete(:initialize_with_readme)) - @import_data = @params.delete(:import_data) - @relations_block = @params.delete(:relations_block) + @import_data = @params.delete(:import_data) + @relations_block = @params.delete(:relations_block) end def execute @@ -110,7 +111,12 @@ module Projects setup_authorizations current_user.invalidate_personal_projects_count - create_prometheus_service + + if Feature.enabled?(:projects_post_creation_worker, current_user, default_enabled: :yaml) + Projects::PostCreationWorker.perform_async(@project.id) + else + create_prometheus_service + end create_readme if @initialize_with_readme end @@ -193,6 +199,7 @@ module Projects @project end + # Deprecated: https://gitlab.com/gitlab-org/gitlab/-/issues/326665 def create_prometheus_service service = @project.find_or_initialize_service(::PrometheusService.to_param) diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index 6840c395a76..4ba48f74273 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -27,7 +27,9 @@ module Projects # Git data (e.g. a list of branch names). flush_caches(project) - ::Ci::AbortProjectPipelinesService.new.execute(project) + if Feature.enabled?(:abort_deleted_project_pipelines, default_enabled: :yaml) + ::Ci::AbortPipelinesService.new.execute(project.all_pipelines, :project_deleted) + end Projects::UnlinkForkService.new(project, current_user).execute diff --git a/app/services/projects/download_service.rb b/app/services/projects/download_service.rb index 9810db84605..72cb3997045 100644 --- a/app/services/projects/download_service.rb +++ b/app/services/projects/download_service.rb @@ -7,7 +7,8 @@ module Projects ].freeze def initialize(project, url) - @project, @url = project, url + @project = project + @url = url end def execute diff --git a/app/services/projects/gitlab_projects_import_service.rb b/app/services/projects/gitlab_projects_import_service.rb index 27cce15f97d..38f0e2f7c1a 100644 --- a/app/services/projects/gitlab_projects_import_service.rb +++ b/app/services/projects/gitlab_projects_import_service.rb @@ -11,7 +11,9 @@ module Projects attr_reader :current_user, :params def initialize(user, import_params, override_params = nil) - @current_user, @params, @override_params = user, import_params.dup, override_params + @current_user = user + @params = import_params.dup + @override_params = override_params end def execute diff --git a/app/services/projects/update_pages_configuration_service.rb b/app/services/projects/update_pages_configuration_service.rb index 01539d58545..b63903c6c61 100644 --- a/app/services/projects/update_pages_configuration_service.rb +++ b/app/services/projects/update_pages_configuration_service.rb @@ -11,7 +11,7 @@ module Projects end def execute - return success unless Feature.enabled?(:pages_update_legacy_storage, default_enabled: true) + return success unless ::Settings.pages.local_store.enabled # If the pages were never deployed, we can't write out the config, as the # directory would not exist. diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index 2b59fdd539d..6fa42b293c5 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -23,7 +23,8 @@ module Projects attr_reader :build def initialize(project, build) - @project, @build = project, build + @project = project + @build = build end def execute @@ -31,9 +32,9 @@ module Projects # Create status notifying the deployment of pages @status = create_status + @status.update_older_statuses_retried! if Feature.enabled?(:ci_fix_commit_status_retried, project, default_enabled: :yaml) @status.enqueue! @status.run! - @status.update_older_statuses_retried! if Feature.enabled?(:ci_fix_commit_status_retried, project, default_enabled: :yaml) raise InvalidStateError, 'missing pages artifacts' unless build.artifacts? raise InvalidStateError, 'build SHA is outdated for this ref' unless latest? @@ -83,7 +84,9 @@ module Projects def deploy_to_legacy_storage(artifacts_path) # path today used by one project can later be used by another # so we can't really scope this feature flag by project or group - return unless Feature.enabled?(:pages_update_legacy_storage, default_enabled: true) + return unless ::Settings.pages.local_store.enabled + + return if Feature.enabled?(:skip_pages_deploy_to_legacy_storage, project, default_enabled: :yaml) # Create temporary directory in which we will extract the artifacts make_secure_tmp_dir(tmp_path) do |tmp_path| @@ -250,13 +253,17 @@ module Projects def make_secure_tmp_dir(tmp_path) FileUtils.mkdir_p(tmp_path) - path = Dir.mktmpdir(nil, tmp_path) + path = Dir.mktmpdir(tmp_dir_prefix, tmp_path) begin yield(path) ensure FileUtils.remove_entry_secure(path) end end + + def tmp_dir_prefix + "project-#{project.id}-build-#{build.id}-" + end end end diff --git a/app/services/projects/update_remote_mirror_service.rb b/app/services/projects/update_remote_mirror_service.rb index 6115db54829..8832a1bc027 100644 --- a/app/services/projects/update_remote_mirror_service.rb +++ b/app/services/projects/update_remote_mirror_service.rb @@ -9,8 +9,10 @@ module Projects def execute(remote_mirror, tries) return success unless remote_mirror.enabled? + # Blocked URLs are a hard failure, no need to attempt to retry if Gitlab::UrlBlocker.blocked_url?(normalized_url(remote_mirror.url)) - return error("The remote mirror URL is invalid.") + hard_retry_or_fail(remote_mirror, _('The remote mirror URL is invalid.'), tries) + return error(remote_mirror.last_error) end update_mirror(remote_mirror) @@ -19,11 +21,11 @@ module Projects rescue Gitlab::Git::CommandError => e # This happens if one of the gitaly calls above fail, for example when # branches have diverged, or the pre-receive hook fails. - retry_or_fail(remote_mirror, e.message, tries) + hard_retry_or_fail(remote_mirror, e.message, tries) error(e.message) rescue => e - remote_mirror.mark_as_failed!(e.message) + remote_mirror.hard_fail!(e.message) raise e end @@ -70,15 +72,15 @@ module Projects ).execute end - def retry_or_fail(mirror, message, tries) + def hard_retry_or_fail(mirror, message, tries) if tries < MAX_TRIES - mirror.mark_for_retry!(message) + mirror.hard_retry!(message) else # It's not likely we'll be able to recover from this ourselves, so we'll # notify the users of the problem, and don't trigger any sidekiq retries # Instead, we'll wait for the next change to try the push again, or until # a user manually retries. - mirror.mark_as_failed!(message) + mirror.hard_fail!(message) end end end diff --git a/app/services/prometheus/create_default_alerts_service.rb b/app/services/prometheus/create_default_alerts_service.rb index 53baf6a650e..4ae2743cc28 100644 --- a/app/services/prometheus/create_default_alerts_service.rb +++ b/app/services/prometheus/create_default_alerts_service.rb @@ -84,7 +84,7 @@ module Prometheus def environment strong_memoize(:environment) do - EnvironmentsFinder.new(project, nil, name: 'production').find.first || + EnvironmentsFinder.new(project, nil, name: 'production').execute.first || project.environments.first end end diff --git a/app/services/prometheus/proxy_service.rb b/app/services/prometheus/proxy_service.rb index c1bafd03b48..33635796771 100644 --- a/app/services/prometheus/proxy_service.rb +++ b/app/services/prometheus/proxy_service.rb @@ -44,8 +44,8 @@ module Prometheus def self.from_cache(proxyable_class_name, proxyable_id, method, path, params) proxyable_class = begin proxyable_class_name.constantize - rescue NameError - nil + rescue NameError + nil end return unless proxyable_class diff --git a/app/services/prometheus/proxy_variable_substitution_service.rb b/app/services/prometheus/proxy_variable_substitution_service.rb index 820b551c30a..846dfeb33ce 100644 --- a/app/services/prometheus/proxy_variable_substitution_service.rb +++ b/app/services/prometheus/proxy_variable_substitution_service.rb @@ -41,7 +41,8 @@ module Prometheus # } # }) def initialize(environment, params = {}) - @environment, @params = environment, params.deep_dup + @environment = environment + @params = params.deep_dup end # @return - params [Hash<Symbol,Any>] Returns a Hash containing a params key which is diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb index d0e1577bd8d..de7c97b3518 100644 --- a/app/services/releases/base_service.rb +++ b/app/services/releases/base_service.rb @@ -8,7 +8,9 @@ module Releases attr_accessor :project, :current_user, :params def initialize(project, user = nil, params = {}) - @project, @current_user, @params = project, user, params.dup + @project = project + @current_user = user + @params = params.dup end def tag_name diff --git a/app/services/repositories/changelog_service.rb b/app/services/repositories/changelog_service.rb index 3981e91e7f3..0122bfb154d 100644 --- a/app/services/repositories/changelog_service.rb +++ b/app/services/repositories/changelog_service.rb @@ -61,14 +61,14 @@ module Repositories # rubocop: enable Metrics/ParameterLists def execute - from = start_of_commit_range + config = Gitlab::Changelog::Config.from_git(@project) + from = start_of_commit_range(config) # For every entry we want to only include the merge request that # originally introduced the commit, which is the oldest merge request that # contains the commit. We fetch there merge requests in batches, reducing # the number of SQL queries needed to get this data. mrs_finder = MergeRequests::OldestPerCommitFinder.new(@project) - config = Gitlab::Changelog::Config.from_git(@project) release = Gitlab::Changelog::Release .new(version: @version, date: @date, config: config) @@ -98,10 +98,12 @@ module Repositories .commit(release: release, file: @file, branch: @branch, message: @message) end - def start_of_commit_range + def start_of_commit_range(config) return @from if @from - if (prev_tag = PreviousTagFinder.new(@project).execute(@version)) + finder = ChangelogTagFinder.new(@project, regex: config.tag_regex) + + if (prev_tag = finder.execute(@version)) return prev_tag.target_commit.id end diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 36858f33b49..620dfff91e2 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -39,7 +39,7 @@ module ResourceAccessTokens attr_reader :resource_type, :resource def has_permission_to_create? - %w(project group).include?(resource_type) && can?(current_user, :admin_resource_access_tokens, resource) + %w(project group).include?(resource_type) && can?(current_user, :create_resource_access_tokens, resource) end def create_user diff --git a/app/services/resource_access_tokens/revoke_service.rb b/app/services/resource_access_tokens/revoke_service.rb index 59402701ddc..0924ca3bac4 100644 --- a/app/services/resource_access_tokens/revoke_service.rb +++ b/app/services/resource_access_tokens/revoke_service.rb @@ -14,7 +14,7 @@ module ResourceAccessTokens end def execute - return error("#{current_user.name} cannot delete #{bot_user.name}") unless can_destroy_bot_member? + return error("#{current_user.name} cannot delete #{bot_user.name}") unless can_destroy_token? return error("Failed to find bot user") unless find_member access_token.revoke! @@ -37,14 +37,8 @@ module ResourceAccessTokens DeleteUserWorker.perform_async(current_user.id, bot_user.id, skip_authorization: true) end - def can_destroy_bot_member? - if resource.is_a?(Project) - can?(current_user, :admin_project_member, @resource) - elsif resource.is_a?(Group) - can?(current_user, :admin_group_member, @resource) - else - false - end + def can_destroy_token? + %w(project group).include?(resource.class.name.downcase) && can?(current_user, :destroy_resource_access_tokens, resource) end def find_member diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb index a2d78ec67c3..5939b9d2f9c 100644 --- a/app/services/resource_events/base_synthetic_notes_builder_service.rb +++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb @@ -25,9 +25,7 @@ module ResourceEvents def apply_common_filters(events) events = apply_last_fetched_at(events) - events = apply_fetch_until(events) - - events + apply_fetch_until(events) end def apply_last_fetched_at(events) diff --git a/app/services/resource_events/change_labels_service.rb b/app/services/resource_events/change_labels_service.rb index ddf3b05ac10..89eb90e9360 100644 --- a/app/services/resource_events/change_labels_service.rb +++ b/app/services/resource_events/change_labels_service.rb @@ -5,7 +5,8 @@ module ResourceEvents attr_reader :resource, :user def initialize(resource, user) - @resource, @user = resource, user + @resource = resource + @user = user end def execute(added_labels: [], removed_labels: []) diff --git a/app/services/resource_events/change_state_service.rb b/app/services/resource_events/change_state_service.rb index c5120ba82e1..d68b86a1513 100644 --- a/app/services/resource_events/change_state_service.rb +++ b/app/services/resource_events/change_state_service.rb @@ -5,7 +5,8 @@ module ResourceEvents attr_reader :resource, :user def initialize(user:, resource:) - @user, @resource = user, resource + @user = user + @resource = resource end def execute(params) diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index 9038650adb7..055034d87a1 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -9,7 +9,8 @@ module Search attr_accessor :current_user, :params def initialize(user, params) - @current_user, @params = user, params.dup + @current_user = user + @params = params.dup end def execute diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index e5fc5a7a438..4227dfe2fac 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -9,7 +9,9 @@ module Search attr_accessor :project, :current_user, :params def initialize(project, user, params) - @project, @current_user, @params = project, user, params.dup + @project = project + @current_user = user + @params = params.dup end def execute diff --git a/app/services/snippets/create_service.rb b/app/services/snippets/create_service.rb index 802bfd813dc..c95b459cd2a 100644 --- a/app/services/snippets/create_service.rb +++ b/app/services/snippets/create_service.rb @@ -6,7 +6,7 @@ module Snippets # NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed. disable_spam_action_service = params.delete(:disable_spam_action_service) == true @request = params.delete(:request) - @spam_params = Spam::SpamActionService.filter_spam_params!(params) + @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request) @snippet = build_from_params diff --git a/app/services/snippets/update_service.rb b/app/services/snippets/update_service.rb index 5b427817a02..aedb6a4819d 100644 --- a/app/services/snippets/update_service.rb +++ b/app/services/snippets/update_service.rb @@ -10,7 +10,7 @@ module Snippets # NOTE: disable_spam_action_service can be removed when the ':snippet_spam' feature flag is removed. disable_spam_action_service = params.delete(:disable_spam_action_service) == true @request = params.delete(:request) - @spam_params = Spam::SpamActionService.filter_spam_params!(params) + @spam_params = Spam::SpamActionService.filter_spam_params!(params, @request) return invalid_params_error(snippet) unless valid_params? diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index 185b9e39070..2220198583c 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -11,22 +11,30 @@ module Spam # Takes a hash of parameters from an incoming request to modify a model (via a controller, # service, or GraphQL mutation). The parameters will either be camelCase (if they are # received directly via controller params) or underscore_case (if they have come from - # a GraphQL mutation which has converted them to underscore) + # a GraphQL mutation which has converted them to underscore), or in the + # headers when using the header based flow. # # Deletes the parameters which are related to spam and captcha processing, and returns # them in a SpamParams parameters object. See: # https://refactoring.com/catalog/introduceParameterObject.html - def self.filter_spam_params!(params) + def self.filter_spam_params!(params, request) # NOTE: The 'captcha_response' field can be expanded to multiple fields when we move to future # alternative captcha implementations such as FriendlyCaptcha. See # https://gitlab.com/gitlab-org/gitlab/-/issues/273480 - captcha_response = params.delete(:captcha_response) || params.delete(:captchaResponse) + headers = request&.headers || {} + api = params.delete(:api) + captcha_response = read_parameter(:captcha_response, params, headers) + spam_log_id = read_parameter(:spam_log_id, params, headers)&.to_i - SpamParams.new( - api: params.delete(:api), - captcha_response: captcha_response, - spam_log_id: params.delete(:spam_log_id) || params.delete(:spamLogId) - ) + SpamParams.new(api: api, captcha_response: captcha_response, spam_log_id: spam_log_id) + end + + def self.read_parameter(name, params, headers) + [ + params.delete(name), + params.delete(name.to_s.camelize(:lower).to_sym), + headers["X-GitLab-#{name.to_s.titlecase(keep_id_suffix: true).tr(' ', '-')}"] + ].compact.first end attr_accessor :target, :request, :options @@ -40,6 +48,7 @@ module Spam @options = {} end + # rubocop:disable Metrics/AbcSize def execute(spam_params:) if request options[:ip_address] = request.env['action_dispatch.remote_ip'].to_s @@ -58,19 +67,20 @@ module Spam ) if recaptcha_verified - # If it's a request which is already verified through captcha, + # If it's a request which is already verified through CAPTCHA, # update the spam log accordingly. SpamLog.verify_recaptcha!(user_id: user.id, id: spam_params.spam_log_id) - ServiceResponse.success(message: "Captcha was successfully verified") + ServiceResponse.success(message: "CAPTCHA successfully verified") else return ServiceResponse.success(message: 'Skipped spam check because user was allowlisted') if allowlisted?(user) return ServiceResponse.success(message: 'Skipped spam check because request was not present') unless request return ServiceResponse.success(message: 'Skipped spam check because it was not required') unless check_for_spam? perform_spam_service_check(spam_params.api) - ServiceResponse.success(message: "Spam check performed, check #{target.class.name} spammable model for any errors or captcha requirement") + ServiceResponse.success(message: "Spam check performed. Check #{target.class.name} spammable model for any errors or CAPTCHA requirement") end end + # rubocop:enable Metrics/AbcSize delegate :check_for_spam?, to: :target diff --git a/app/services/spam/spam_params.rb b/app/services/spam/spam_params.rb index fef5355c7f3..3420748822d 100644 --- a/app/services/spam/spam_params.rb +++ b/app/services/spam/spam_params.rb @@ -23,10 +23,10 @@ module Spam end def ==(other) - other.class == self.class && - other.api == self.api && - other.captcha_response == self.captcha_response && - other.spam_log_id == self.spam_log_id + other.class <= self.class && + other.api == api && + other.captcha_response == captcha_response && + other.spam_log_id == spam_log_id end end end diff --git a/app/services/submit_usage_ping_service.rb b/app/services/submit_usage_ping_service.rb index 8ab1193b04f..d628b1ea7c7 100644 --- a/app/services/submit_usage_ping_service.rb +++ b/app/services/submit_usage_ping_service.rb @@ -35,7 +35,13 @@ class SubmitUsagePingService raise SubmissionError.new("Unsuccessful response code: #{response.code}") unless response.success? - raw_usage_data.update_sent_at! if raw_usage_data + version_usage_data_id = response.dig('conv_index', 'usage_data_id') || response.dig('dev_ops_score', 'usage_data_id') + + unless version_usage_data_id.is_a?(Integer) && version_usage_data_id > 0 + raise SubmissionError.new("Invalid usage_data_id in response: #{version_usage_data_id}") + end + + raw_usage_data.update_version_metadata!(usage_data_id: version_usage_data_id) store_metrics(response) end diff --git a/app/services/system_hooks_service.rb b/app/services/system_hooks_service.rb index d854b95cb93..53e810035c5 100644 --- a/app/services/system_hooks_service.rb +++ b/app/services/system_hooks_service.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class SystemHooksService - BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group, ProjectMember].freeze + BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES = [GroupMember, Group, ProjectMember, User].freeze def execute_hooks_for(model, event) data = build_event_data(model, event) @@ -47,15 +47,6 @@ class SystemHooksService if event == :rename || event == :transfer data[:old_path_with_namespace] = model.old_path_with_namespace end - when User - data.merge!(user_data(model)) - - case event - when :rename - data[:old_username] = model.username_before_last_save - when :failed_login - data[:state] = model.state - end end data @@ -79,15 +70,6 @@ class SystemHooksService } end - def user_data(model) - { - name: model.name, - email: model.email, - user_id: model.id, - username: model.username - } - end - def builder_driven_event_data_available?(model) model.class.in?(BUILDER_DRIVEN_EVENT_DATA_AVAILABLE_FOR_CLASSES) end @@ -100,10 +82,10 @@ class SystemHooksService Gitlab::HookData::GroupBuilder when ProjectMember Gitlab::HookData::ProjectMemberBuilder + when User + Gitlab::HookData::UserBuilder end builder_class.new(model).build(event) end end - -SystemHooksService.prepend_if_ee('EE::SystemHooksService') diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 082ed93eca2..4377bd8554b 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -7,7 +7,7 @@ module SystemNoteService extend self - # Called when commits are added to a Merge Request + # Called when commits are added to a merge request # # noteable - Noteable object # project - Project owning noteable diff --git a/app/services/system_notes/alert_management_service.rb b/app/services/system_notes/alert_management_service.rb index 27ddf2e36f1..70cdd5c6434 100644 --- a/app/services/system_notes/alert_management_service.rb +++ b/app/services/system_notes/alert_management_service.rb @@ -73,7 +73,7 @@ module SystemNotes # # Returns the created Note object def log_resolving_alert(monitoring_tool) - body = "logged a resolving alert from **#{monitoring_tool}**" + body = "logged a recovery alert from **#{monitoring_tool}**" create_note(NoteSummary.new(noteable, project, User.alert_bot, body, action: 'new_alert_added')) end diff --git a/app/services/system_notes/commit_service.rb b/app/services/system_notes/commit_service.rb index 11119956e0f..c89998f77c7 100644 --- a/app/services/system_notes/commit_service.rb +++ b/app/services/system_notes/commit_service.rb @@ -2,7 +2,7 @@ module SystemNotes class CommitService < ::SystemNotes::BaseService - # Called when commits are added to a Merge Request + # Called when commits are added to a merge request # # new_commits - Array of Commits added since last push # existing_commits - Array of Commits added in a previous push diff --git a/app/services/task_list_toggle_service.rb b/app/services/task_list_toggle_service.rb index f6602a35033..32cfa198ce8 100644 --- a/app/services/task_list_toggle_service.rb +++ b/app/services/task_list_toggle_service.rb @@ -9,9 +9,11 @@ class TaskListToggleService attr_reader :updated_markdown, :updated_markdown_html def initialize(markdown, markdown_html, line_source:, line_number:, toggle_as_checked:) - @markdown, @markdown_html = markdown, markdown_html - @line_source, @line_number = line_source, line_number - @toggle_as_checked = toggle_as_checked + @markdown = markdown + @markdown_html = markdown_html + @line_source = line_source + @line_number = line_number + @toggle_as_checked = toggle_as_checked @updated_markdown, @updated_markdown_html = nil end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index dea116c8546..e473a6dc594 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -47,7 +47,7 @@ class TodoService yield target - todo_users.each(&:update_todos_count_cache) + Users::UpdateTodoCountCacheService.new(todo_users).execute if todo_users.present? end # When we reassign an assignable object (issuable, alert) we should: @@ -177,7 +177,7 @@ class TodoService def resolve_todos_for_target(target, current_user) attributes = attributes_for_target(target) - resolve_todos(pending_todos(current_user, attributes), current_user) + resolve_todos(pending_todos([current_user], attributes), current_user) end def resolve_todos(todos, current_user, resolution: :done, resolved_by_action: :system_done) @@ -220,16 +220,23 @@ class TodoService private def create_todos(users, attributes) - Array(users).map do |user| - next if pending_todos(user, attributes).exists? && Feature.disabled?(:multiple_todos, user) + users = Array(users) + return if users.empty? + + users_with_pending_todos = pending_todos(users, attributes).pluck_user_id + users.reject! { |user| users_with_pending_todos.include?(user.id) && Feature.disabled?(:multiple_todos, user) } + + todos = users.map do |user| issue_type = attributes.delete(:issue_type) track_todo_creation(user, issue_type) - todo = Todo.create(attributes.merge(user_id: user.id)) - user.update_todos_count_cache - todo + Todo.create(attributes.merge(user_id: user.id)) end + + Users::UpdateTodoCountCacheService.new(users).execute + + todos end def new_issuable(issuable, author) @@ -353,8 +360,8 @@ class TodoService end end - def pending_todos(user, criteria = {}) - PendingTodosFinder.new(user, criteria).execute + def pending_todos(users, criteria = {}) + PendingTodosFinder.new(users, criteria).execute end def track_todo_creation(user, issue_type) diff --git a/app/services/todos/destroy/base_service.rb b/app/services/todos/destroy/base_service.rb index 7378f10e7c4..4e971246185 100644 --- a/app/services/todos/destroy/base_service.rb +++ b/app/services/todos/destroy/base_service.rb @@ -13,7 +13,7 @@ module Todos # rubocop: disable CodeReuse/ActiveRecord def without_authorized(items) - items.where('todos.user_id NOT IN (?)', authorized_users) + items.where.not('todos.user_id' => authorized_users) end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/services/todos/destroy/destroyed_issuable_service.rb b/app/services/todos/destroy/destroyed_issuable_service.rb new file mode 100644 index 00000000000..db12965224b --- /dev/null +++ b/app/services/todos/destroy/destroyed_issuable_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Todos + module Destroy + class DestroyedIssuableService + BATCH_SIZE = 100 + + def initialize(target_id, target_type) + @target_id = target_id + @target_type = target_type + end + + def execute + inner_query = Todo.select(:id).for_target(target_id).for_type(target_type).limit(BATCH_SIZE) + + delete_query = <<~SQL + DELETE FROM "#{Todo.table_name}" + WHERE id IN (#{inner_query.to_sql}) + RETURNING user_id + SQL + + loop do + result = ActiveRecord::Base.connection.execute(delete_query) + + break if result.cmd_tuples == 0 + + user_ids = result.map { |row| row['user_id'] }.uniq + + invalidate_todos_cache_counts(user_ids) + end + end + + private + + attr_reader :target_id, :target_type + + def invalidate_todos_cache_counts(user_ids) + user_ids.each do |id| + # Only build a user instance since we only need its ID for + # `User#invalidate_todos_cache_counts` to work. + User.new(id: id).invalidate_todos_cache_counts + end + end + end + end +end diff --git a/app/services/todos/destroy/entity_leave_service.rb b/app/services/todos/destroy/entity_leave_service.rb index 7cfedc2233a..6d4fc3865ac 100644 --- a/app/services/todos/destroy/entity_leave_service.rb +++ b/app/services/todos/destroy/entity_leave_service.rb @@ -65,8 +65,10 @@ module Todos end def remove_group_todos + return unless entity.is_a?(Namespace) + Todo - .for_group(non_authorized_groups) + .for_group(non_authorized_non_public_groups) .for_user(user) .delete_all end @@ -102,12 +104,19 @@ module Todos GroupsFinder.new(user, min_access_level: Gitlab::Access::REPORTER).execute.select(:id) end - def non_authorized_groups + # since the entity is a private group, we can assume all subgroups are also + # private. We can therefore limit GroupsFinder with `all_available: false`. + # Otherwise it tries to include all public groups. This generates an expensive + # SQL queries: https://gitlab.com/gitlab-org/gitlab/-/issues/325133 + # rubocop: disable CodeReuse/ActiveRecord + def non_authorized_non_public_groups return [] unless entity.is_a?(Namespace) + return [] unless entity.private? entity.self_and_descendants.select(:id) - .id_not_in(GroupsFinder.new(user).execute.select(:id)) + .id_not_in(GroupsFinder.new(user, all_available: false).execute.select(:id).reorder(nil)) end + # rubocop: enable CodeReuse/ActiveRecord def non_authorized_reporter_groups entity.self_and_descendants.select(:id) diff --git a/app/services/todos/destroy/private_features_service.rb b/app/services/todos/destroy/private_features_service.rb index bd49519d694..44c3ff231f8 100644 --- a/app/services/todos/destroy/private_features_service.rb +++ b/app/services/todos/destroy/private_features_service.rb @@ -36,7 +36,7 @@ module Todos items = Todo.where(project_id: project_id) items = items.where(user_id: user_id) if user_id - items.where('user_id NOT IN (?)', authorized_users) + items.where.not(user_id: authorized_users) .where(target_type: target_types) .delete_all end diff --git a/app/services/two_factor/base_service.rb b/app/services/two_factor/base_service.rb index 7d3f63f3442..0957d7ebabd 100644 --- a/app/services/two_factor/base_service.rb +++ b/app/services/two_factor/base_service.rb @@ -7,7 +7,8 @@ module TwoFactor attr_reader :current_user, :params, :user def initialize(current_user, params = {}) - @current_user, @params = current_user, params + @current_user = current_user + @params = params @user = params.delete(:user) end end diff --git a/app/services/upload_service.rb b/app/services/upload_service.rb index ba6ead41836..39d1ffa4d6b 100644 --- a/app/services/upload_service.rb +++ b/app/services/upload_service.rb @@ -1,8 +1,14 @@ # frozen_string_literal: true class UploadService + # Temporarily introduced for upload API: https://gitlab.com/gitlab-org/gitlab/-/issues/325788 + attr_accessor :override_max_attachment_size + def initialize(model, file, uploader_class = FileUploader, **uploader_context) - @model, @file, @uploader_class, @uploader_context = model, file, uploader_class, uploader_context + @model = model + @file = file + @uploader_class = uploader_class + @uploader_context = uploader_context end def execute @@ -19,6 +25,6 @@ class UploadService attr_reader :model, :file, :uploader_class, :uploader_context def max_attachment_size - Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i + override_max_attachment_size || Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i end end diff --git a/app/services/user_agent_detail_service.rb b/app/services/user_agent_detail_service.rb index 5cb42e879a0..9302c86d3e6 100644 --- a/app/services/user_agent_detail_service.rb +++ b/app/services/user_agent_detail_service.rb @@ -4,7 +4,8 @@ class UserAgentDetailService attr_accessor :spammable, :request def initialize(spammable, request) - @spammable, @request = spammable, request + @spammable = spammable + @request = request end def create diff --git a/app/services/user_preferences/update_service.rb b/app/services/user_preferences/update_service.rb new file mode 100644 index 00000000000..a1ee35d4580 --- /dev/null +++ b/app/services/user_preferences/update_service.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module UserPreferences + class UpdateService < BaseService + def initialize(user, params = {}) + @preferences = user.user_preference + @params = params.to_h.dup.with_indifferent_access + end + + def execute + if @preferences.update(@params) + ServiceResponse.success( + message: 'Preference was updated', + payload: { preferences: @preferences }) + else + ServiceResponse.error(message: 'Could not update preference') + end + end + end +end diff --git a/app/services/users/activity_service.rb b/app/services/users/activity_service.rb index 85855f45e33..64844a3f002 100644 --- a/app/services/users/activity_service.rb +++ b/app/services/users/activity_service.rb @@ -37,3 +37,5 @@ module Users end end end + +Users::ActivityService.prepend_ee_mod diff --git a/app/services/users/batch_status_cleaner_service.rb b/app/services/users/batch_status_cleaner_service.rb index ea6142f13cc..533794f8d60 100644 --- a/app/services/users/batch_status_cleaner_service.rb +++ b/app/services/users/batch_status_cleaner_service.rb @@ -2,7 +2,7 @@ module Users class BatchStatusCleanerService - BATCH_SIZE = 100.freeze + BATCH_SIZE = 100 # Cleanup BATCH_SIZE user_statuses records # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 070713929e4..d28ff45bfdf 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -51,38 +51,12 @@ module Users # This method returns the updated User object. def execute_without_lease - current = current_authorizations_per_project - fresh = fresh_access_levels_per_project - - # Delete projects that have more than one authorizations associated with - # the user. The correct authorization is added to the ``add`` array in the - # next stage. - remove = projects_with_duplicates - current.except!(*projects_with_duplicates) - - remove |= current.each_with_object([]) do |(project_id, row), array| - # rows not in the new list or with a different access level should be - # removed. - if !fresh[project_id] || fresh[project_id] != row.access_level - if incorrect_auth_found_callback - incorrect_auth_found_callback.call(project_id, row.access_level) - end - - array << row.project_id - end - end - - add = fresh.each_with_object([]) do |(project_id, level), array| - # rows not in the old list or with a different access level should be - # added. - if !current[project_id] || current[project_id].access_level != level - if missing_auth_found_callback - missing_auth_found_callback.call(project_id, level) - end - - array << [user.id, project_id, level] - end - end + remove, add = AuthorizedProjectUpdate::FindRecordsDueForRefreshService.new( + user, + source: source, + incorrect_auth_found_callback: incorrect_auth_found_callback, + missing_auth_found_callback: missing_auth_found_callback + ).execute update_authorizations(remove, add) end @@ -104,6 +78,10 @@ module Users user.reset end + private + + attr_reader :incorrect_auth_found_callback, :missing_auth_found_callback + def log_refresh_details(remove, add) Gitlab::AppJsonLogger.info(event: 'authorized_projects_refresh', user_id: user.id, @@ -115,34 +93,5 @@ module Users 'authorized_projects_refresh.rows_deleted_slice': remove.first(5), 'authorized_projects_refresh.rows_added_slice': add.first(5)) end - - def fresh_access_levels_per_project - fresh_authorizations.each_with_object({}) do |row, hash| - hash[row.project_id] = row.access_level - end - end - - def current_authorizations_per_project - current_authorizations.index_by(&:project_id) - end - - def current_authorizations - @current_authorizations ||= user.project_authorizations.select(:project_id, :access_level) - end - - def fresh_authorizations - Gitlab::ProjectAuthorizations.new(user).calculate - end - - private - - attr_reader :incorrect_auth_found_callback, :missing_auth_found_callback - - def projects_with_duplicates - @projects_with_duplicates ||= current_authorizations - .group_by(&:project_id) - .select { |project_id, authorizations| authorizations.count > 1 } - .keys - end end end diff --git a/app/services/users/respond_to_terms_service.rb b/app/services/users/respond_to_terms_service.rb index 254480304f9..7cdfef1489b 100644 --- a/app/services/users/respond_to_terms_service.rb +++ b/app/services/users/respond_to_terms_service.rb @@ -3,7 +3,8 @@ module Users class RespondToTermsService def initialize(user, term) - @user, @term = user, term + @user = user + @term = term end # rubocop: disable CodeReuse/ActiveRecord diff --git a/app/services/users/set_status_service.rb b/app/services/users/set_status_service.rb index a907937070f..2b4be8c833b 100644 --- a/app/services/users/set_status_service.rb +++ b/app/services/users/set_status_service.rb @@ -7,7 +7,8 @@ module Users attr_reader :current_user, :target_user, :params def initialize(current_user, params) - @current_user, @params = current_user, params.dup + @current_user = current_user + @params = params.dup @target_user = params.delete(:user) || current_user end diff --git a/app/services/users/update_canonical_email_service.rb b/app/services/users/update_canonical_email_service.rb index 1400fd58eb4..e75452f60fd 100644 --- a/app/services/users/update_canonical_email_service.rb +++ b/app/services/users/update_canonical_email_service.rb @@ -7,7 +7,7 @@ module Users INCLUDED_DOMAINS_PATTERN = [/gmail.com/].freeze def initialize(user:) - raise ArgumentError.new("Please provide a user") unless user&.is_a?(User) + raise ArgumentError.new("Please provide a user") unless user.is_a?(User) @user = user end diff --git a/app/services/users/update_todo_count_cache_service.rb b/app/services/users/update_todo_count_cache_service.rb new file mode 100644 index 00000000000..03ab66bd64a --- /dev/null +++ b/app/services/users/update_todo_count_cache_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module Users + class UpdateTodoCountCacheService < BaseService + QUERY_BATCH_SIZE = 10 + + attr_reader :users + + # users - An array of User objects + def initialize(users) + @users = users + end + + def execute + users.each_slice(QUERY_BATCH_SIZE) do |users_batch| + todo_counts = Todo.for_user(users_batch).count_grouped_by_user_id_and_state + + users_batch.each do |user| + update_count_cache(user, todo_counts, :done) + update_count_cache(user, todo_counts, :pending) + end + end + end + + private + + def update_count_cache(user, todo_counts, state) + count = todo_counts.fetch([user.id, state.to_s], 0) + expiration_time = user.count_cache_validity_period + + Rails.cache.write(['users', user.id, "todos_#{state}_count"], count, expires_in: expiration_time) + end + end +end |