From 2e4e93a39e7afe24ecd0718cf7546526c74a18e3 Mon Sep 17 00:00:00 2001 From: Oswaldo Ferreira <oswaldo@gitlab.com> Date: Thu, 31 Jan 2019 16:32:44 -0200 Subject: [PATCH] Support merge to ref for merge-commit and squash Adds the ground work for writing into the merge ref refs/merge-requests/:iid/merge the merge result between source and target branches of a MR, without further side-effects such as mailing, MR updates and target branch changes. --- GITALY_SERVER_VERSION | 2 +- Gemfile | 3 +- Gemfile.lock | 4 +- app/models/merge_request.rb | 14 ++ app/models/project.rb | 8 + app/models/repository.rb | 6 + .../merge_requests/merge_base_service.rb | 65 ++++++ app/services/merge_requests/merge_service.rb | 60 ++---- .../merge_requests/merge_to_ref_service.rb | 76 +++++++ ...osw-create-and-store-merge-ref-for-mrs.yml | 5 + ...merge_service.rb => merge_base_service.rb} | 44 ++-- .../merge_requests/merge_service_spec.rb | 82 +------ .../merge_to_ref_service_spec.rb | 35 +++ .../services/merge_merge_requests.rb | 98 +++++++++ lib/gitlab/git/repository.rb | 6 + lib/gitlab/gitaly_client/operation_service.rb | 19 ++ spec/lib/gitlab/git/repository_spec.rb | 31 +++ spec/models/repository_spec.rb | 23 ++ .../merge_requests/merge_service_spec.rb | 12 ++ .../merge_to_ref_service_spec.rb | 201 ++++++++++++++++++ 20 files changed, 649 insertions(+), 145 deletions(-) create mode 100644 app/services/merge_requests/merge_base_service.rb create mode 100644 app/services/merge_requests/merge_to_ref_service.rb create mode 100644 changelogs/unreleased/osw-create-and-store-merge-ref-for-mrs.yml rename ee/app/services/ee/merge_requests/{merge_service.rb => merge_base_service.rb} (50%) create mode 100644 ee/spec/services/merge_requests/merge_to_ref_service_spec.rb create mode 100644 ee/spec/support/shared_examples/services/merge_merge_requests.rb create mode 100644 spec/services/merge_requests/merge_to_ref_service_spec.rb diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 3989355915568..3500250a4b05b 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -1.20.0 +1.21.0 diff --git a/Gemfile b/Gemfile index c68c769ef3d25..ce6cec8229ce1 100644 --- a/Gemfile +++ b/Gemfile @@ -434,7 +434,8 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 1.10.0', require: 'gitaly' +gem 'gitaly-proto', '~> 1.12.0', require: 'gitaly' + gem 'grpc', '~> 1.15.0' gem 'google-protobuf', '~> 3.6' diff --git a/Gemfile.lock b/Gemfile.lock index cfb8a42dc1537..532a76322fbe6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -302,7 +302,7 @@ GEM gettext_i18n_rails (>= 0.7.1) po_to_json (>= 1.0.0) rails (>= 3.2.0) - gitaly-proto (1.10.0) + gitaly-proto (1.12.0) grpc (~> 1.0) github-markup (1.7.0) gitlab-default_value_for (3.1.1) @@ -1052,7 +1052,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.3) - gitaly-proto (~> 1.10.0) + gitaly-proto (~> 1.12.0) github-markup (~> 1.7.0) gitlab-default_value_for (~> 3.1.1) gitlab-license (~> 1.0) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index f02c29a733a02..2783b309a9893 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -766,6 +766,16 @@ def mergeable_state?(skip_ci_check: false, skip_discussions_check: false) true end + def mergeable_to_ref? + return false if merged? + return false if broken? + + # Given the `merge_ref_path` will have the same + # state the `target_branch` would have. Ideally + # we need to check if it can be merged to it. + project.repository.can_be_merged?(diff_head_sha, target_branch) + end + def ff_merge_possible? project.repository.ancestor?(target_branch_sha, diff_head_sha) end @@ -1079,6 +1089,10 @@ def ref_path "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/head" end + def merge_ref_path + "refs/#{Repository::REF_MERGE_REQUEST}/#{iid}/merge" + end + def in_locked_state begin lock_mr diff --git a/app/models/project.rb b/app/models/project.rb index 7607fd4ca17fe..0ae75e217f94c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1925,6 +1925,14 @@ def renamed? persisted? && path_changed? end + def human_merge_method + if merge_method == :ff + 'Fast-forward' + else + merge_method.to_s.humanize + end + end + def merge_method if self.merge_requests_ff_only_enabled :ff diff --git a/app/models/repository.rb b/app/models/repository.rb index f94a09b67fe19..2fb4ca79e6b76 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -854,6 +854,12 @@ def merge(user, source_sha, merge_request, message) end end + def merge_to_ref(user, source_sha, merge_request, target_ref, message) + branch = merge_request.target_branch + + raw.merge_to_ref(user, source_sha, branch, target_ref, message) + end + def ff_merge(user, source, target_branch, merge_request: nil) their_commit_id = commit(source)&.id raise 'Invalid merge source' if their_commit_id.nil? diff --git a/app/services/merge_requests/merge_base_service.rb b/app/services/merge_requests/merge_base_service.rb new file mode 100644 index 0000000000000..2c17725493761 --- /dev/null +++ b/app/services/merge_requests/merge_base_service.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module MergeRequests + class MergeBaseService < MergeRequests::BaseService + include Gitlab::Utils::StrongMemoize + + MergeError = Class.new(StandardError) + + attr_reader :merge_request + + # Overridden in EE. + def hooks_validation_pass?(_merge_request) + true + end + + # Overridden in EE. + def hooks_validation_error(_merge_request) + # No-op + end + + def source + if merge_request.squash + squash_sha! + else + merge_request.diff_head_sha + end + end + + private + + # Overridden in EE. + def error_check! + # No-op + end + + def raise_error(message) + raise MergeError, message + end + + def handle_merge_error(*args) + # No-op + end + + def commit_message + params[:commit_message] || + merge_request.default_merge_commit_message + end + + def squash_sha! + strong_memoize(:squash_sha) do + params[:merge_request] = merge_request + squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute + + case squash_result[:status] + when :success + squash_result[:squash_sha] + when :error + raise ::MergeRequests::MergeService::MergeError, squash_result[:message] + end + end + end + end +end + +MergeRequests::MergeBaseService.prepend(EE::MergeRequests::MergeBaseService) diff --git a/app/services/merge_requests/merge_service.rb b/app/services/merge_requests/merge_service.rb index 3deb9198bcca7..8241e408ce517 100644 --- a/app/services/merge_requests/merge_service.rb +++ b/app/services/merge_requests/merge_service.rb @@ -7,13 +7,7 @@ module MergeRequests # mark merge request as merged and execute all hooks and notifications # Executed when you do merge via GitLab UI # - class MergeService < MergeRequests::BaseService - include Gitlab::Utils::StrongMemoize - - MergeError = Class.new(StandardError) - - attr_reader :merge_request, :source - + class MergeService < MergeRequests::MergeBaseService delegate :merge_jid, :state, to: :@merge_request def execute(merge_request) @@ -24,7 +18,7 @@ def execute(merge_request) @merge_request = merge_request - error_check! + validate! merge_request.in_locked_state do if commit @@ -38,22 +32,22 @@ def execute(merge_request) handle_merge_error(log_message: e.message, save_message_on_model: true) end - def source - if merge_request.squash - squash_sha! - else - merge_request.diff_head_sha - end - end + private - # Overridden in EE. - def hooks_validation_pass?(_merge_request) - true + def validate! + authorization_check! + error_check! end - private + def authorization_check! + unless @merge_request.can_be_merged_by?(current_user) + raise_error('You are not allowed to merge this merge request') + end + end def error_check! + super + error = if @merge_request.should_be_rebased? 'Only fast-forward merge is allowed for your project. Please update your source branch' @@ -63,7 +57,7 @@ def error_check! 'No source for merge' end - raise MergeError, error if error + raise_error(error) if error end def commit @@ -73,36 +67,20 @@ def commit if commit_id log_info("Git merge finished on JID #{merge_jid} commit #{commit_id}") else - raise MergeError, 'Conflicts detected during merge' + raise_error('Conflicts detected during merge') end merge_request.update!(merge_commit_sha: commit_id) end - def squash_sha! - strong_memoize(:squash_sha) do - params[:merge_request] = merge_request - squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute - - case squash_result[:status] - when :success - squash_result[:squash_sha] - when :error - raise ::MergeRequests::MergeService::MergeError, squash_result[:message] - end - end - end - def try_merge - message = params[:commit_message] || merge_request.default_merge_commit_message - - repository.merge(current_user, source, merge_request, message) + repository.merge(current_user, source, merge_request, commit_message) rescue Gitlab::Git::PreReceiveError => e handle_merge_error(log_message: e.message) - raise MergeError, 'Something went wrong during merge pre-receive hook' + raise_error('Something went wrong during merge pre-receive hook') rescue => e handle_merge_error(log_message: e.message) - raise MergeError, 'Something went wrong during merge' + raise_error('Something went wrong during merge') ensure merge_request.update!(in_progress_merge_commit_sha: nil) end @@ -149,5 +127,3 @@ def merge_request_info end end end - -MergeRequests::MergeService.prepend(EE::MergeRequests::MergeService) diff --git a/app/services/merge_requests/merge_to_ref_service.rb b/app/services/merge_requests/merge_to_ref_service.rb new file mode 100644 index 0000000000000..586652ae44e5b --- /dev/null +++ b/app/services/merge_requests/merge_to_ref_service.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module MergeRequests + # Performs the merge between source SHA and the target branch. Instead + # of writing the result to the MR target branch, it targets the `target_ref`. + # + # Ideally this should leave the `target_ref` state with the same state the + # target branch would have if we used the regular `MergeService`, but without + # every side-effect that comes with it (MR updates, mails, source branch + # deletion, etc). This service should be kept idempotent (i.e. can + # be executed regardless of the `target_ref` current state). + # + class MergeToRefService < MergeRequests::MergeBaseService + def execute(merge_request) + @merge_request = merge_request + + validate! + + commit_id = commit + + raise_error('Conflicts detected during merge') unless commit_id + + success(commit_id: commit_id) + rescue MergeError => error + error(error.message) + end + + private + + def validate! + authorization_check! + error_check! + end + + def error_check! + super + + error = + if Feature.disabled?(:merge_to_tmp_merge_ref_path, project) + 'Feature is not enabled' + elsif !merge_method_supported? + "#{project.human_merge_method} to #{target_ref} is currently not supported." + elsif !hooks_validation_pass?(merge_request) + hooks_validation_error(merge_request) + elsif @merge_request.should_be_rebased? + 'Fast-forward merge is not possible. Please update your source branch.' + elsif !@merge_request.mergeable_to_ref? + "Merge request is not mergeable to #{target_ref}" + elsif !source + 'No source for merge' + end + + raise_error(error) if error + end + + def authorization_check! + unless Ability.allowed?(current_user, :admin_merge_request, project) + raise_error("You are not allowed to merge to this ref") + end + end + + def target_ref + merge_request.merge_ref_path + end + + def commit + repository.merge_to_ref(current_user, source, merge_request, target_ref, commit_message) + rescue Gitlab::Git::PreReceiveError => error + raise MergeError, error.message + end + + def merge_method_supported? + [:merge, :rebase_merge].include?(project.merge_method) + end + end +end diff --git a/changelogs/unreleased/osw-create-and-store-merge-ref-for-mrs.yml b/changelogs/unreleased/osw-create-and-store-merge-ref-for-mrs.yml new file mode 100644 index 0000000000000..012b547a6304e --- /dev/null +++ b/changelogs/unreleased/osw-create-and-store-merge-ref-for-mrs.yml @@ -0,0 +1,5 @@ +--- +title: Support merge ref writing (without merging to target branch) +merge_request: 24692 +author: +type: added diff --git a/ee/app/services/ee/merge_requests/merge_service.rb b/ee/app/services/ee/merge_requests/merge_base_service.rb similarity index 50% rename from ee/app/services/ee/merge_requests/merge_service.rb rename to ee/app/services/ee/merge_requests/merge_base_service.rb index ff0a7fbc11ef9..e7f0c914c8524 100644 --- a/ee/app/services/ee/merge_requests/merge_service.rb +++ b/ee/app/services/ee/merge_requests/merge_base_service.rb @@ -2,14 +2,12 @@ module EE module MergeRequests - module MergeService + module MergeBaseService extend ::Gitlab::Utils::Override override :error_check! def error_check! check_size_limit - - super end override :hooks_validation_pass? @@ -18,33 +16,35 @@ def hooks_validation_pass?(merge_request) # object instead of relying on the order of method calls. @merge_request = merge_request # rubocop:disable Gitlab/ModuleWithInstanceVariables - return true if project.merge_requests_ff_only_enabled - return true unless project.feature_available?(:push_rules) - - push_rule = merge_request.project.push_rule - return true unless push_rule + hooks_error = hooks_validation_error(merge_request) - unless push_rule.commit_message_allowed?(params[:commit_message]) - handle_merge_error(log_message: "Commit message does not follow the pattern '#{push_rule.commit_message_regex}'", save_message_on_model: true) - return false - end + return true unless hooks_error - if push_rule.commit_message_blocked?(params[:commit_message]) - handle_merge_error(log_message: "Commit message contains the forbidden pattern '#{push_rule.commit_message_negative_regex}'", save_message_on_model: true) - return false - end - - unless push_rule.author_email_allowed?(current_user.commit_email) - handle_merge_error(log_message: "Commit author's email '#{current_user.commit_email}' does not follow the pattern '#{push_rule.author_email_regex}'", save_message_on_model: true) - return false - end + handle_merge_error(log_message: hooks_error, save_message_on_model: true) - true + false rescue PushRule::MatchError => e handle_merge_error(log_message: e.message, save_message_on_model: true) false end + override :hooks_validation_error + def hooks_validation_error(merge_request) + return if project.merge_requests_ff_only_enabled + return unless project.feature_available?(:push_rules) + + push_rule = merge_request.project.push_rule + return unless push_rule + + if !push_rule.commit_message_allowed?(params[:commit_message]) + "Commit message does not follow the pattern '#{push_rule.commit_message_regex}'" + elsif push_rule.commit_message_blocked?(params[:commit_message]) + "Commit message contains the forbidden pattern '#{push_rule.commit_message_negative_regex}'" + elsif !push_rule.author_email_allowed?(current_user.commit_email) + "Commit author's email '#{current_user.commit_email}' does not follow the pattern '#{push_rule.author_email_regex}'" + end + end + private def check_size_limit diff --git a/ee/spec/services/merge_requests/merge_service_spec.rb b/ee/spec/services/merge_requests/merge_service_spec.rb index 3d8977661720b..15f41158fd0fa 100644 --- a/ee/spec/services/merge_requests/merge_service_spec.rb +++ b/ee/spec/services/merge_requests/merge_service_spec.rb @@ -4,24 +4,21 @@ let(:user) { create(:user) } let(:merge_request) { create(:merge_request, :simple) } let(:project) { merge_request.project } + let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } before do project.add_maintainer(user) end describe '#execute' do - let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } - context 'project has exceeded size limit' do before do allow(project).to receive(:above_size_limit?).and_return(true) - - perform_enqueued_jobs do - service.execute(merge_request) - end end - it 'returns the correct error message' do + it 'persists the correct error message' do + service.execute(merge_request) + expect(merge_request.merge_error).to include('This merge request cannot be merged') end end @@ -46,74 +43,5 @@ end end - describe '#hooks_validation_pass?' do - shared_examples 'hook validations are skipped when push rules unlicensed' do - subject { service.hooks_validation_pass?(merge_request) } - - before do - stub_licensed_features(push_rules: false) - end - - it { is_expected.to be_truthy } - end - - let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } - - it 'returns true when valid' do - expect(service.hooks_validation_pass?(merge_request)).to be_truthy - end - - context 'commit message validation for required characters' do - before do - allow(project).to receive(:push_rule) { build(:push_rule, commit_message_regex: 'unmatched pattern .*') } - end - - it_behaves_like 'hook validations are skipped when push rules unlicensed' - - it 'returns false and saves error when invalid' do - expect(service.hooks_validation_pass?(merge_request)).to be_falsey - expect(merge_request.merge_error).not_to be_empty - end - end - - context 'commit message validation for forbidden characters' do - before do - allow(project).to receive(:push_rule) { build(:push_rule, commit_message_negative_regex: '.*') } - end - - it_behaves_like 'hook validations are skipped when push rules unlicensed' - - it 'returns false and saves error when invalid' do - expect(service.hooks_validation_pass?(merge_request)).to be_falsey - expect(merge_request.merge_error).not_to be_empty - end - end - - context 'authors email validation' do - before do - allow(project).to receive(:push_rule) { build(:push_rule, author_email_regex: '.*@unmatchedemaildomain.com') } - end - - it_behaves_like 'hook validations are skipped when push rules unlicensed' - - it 'returns false and saves error when invalid' do - expect(service.hooks_validation_pass?(merge_request)).to be_falsey - expect(merge_request.merge_error).not_to be_empty - end - - it 'validates against the commit email' do - user.commit_email = 'foo@unmatchedemaildomain.com' - - expect(service.hooks_validation_pass?(merge_request)).to be_truthy - end - end - - context 'fast forward merge request' do - it 'returns true when fast forward is enabled' do - allow(project).to receive(:merge_requests_ff_only_enabled) { true } - - expect(service.hooks_validation_pass?(merge_request)).to be_truthy - end - end - end + it_behaves_like 'merge validation hooks', persisted: true end diff --git a/ee/spec/services/merge_requests/merge_to_ref_service_spec.rb b/ee/spec/services/merge_requests/merge_to_ref_service_spec.rb new file mode 100644 index 0000000000000..0308fce5a9b1d --- /dev/null +++ b/ee/spec/services/merge_requests/merge_to_ref_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MergeRequests::MergeToRefService do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request, :simple) } + let(:project) { merge_request.project } + let(:service) { described_class.new(project, user, commit_message: 'Awesome message') } + + before do + project.add_maintainer(user) + end + + describe '#execute' do + context 'project has exceeded size limit' do + before do + allow(project).to receive(:above_size_limit?).and_return(true) + end + + it 'returns the correct error message' do + result = service.execute(merge_request) + + expected_error = + 'This merge request cannot be merged, because this repository ' \ + 'has exceeded its size limit' + + expect(result[:status]).to eq(:error) + expect(result[:message]).to start_with(expected_error) + end + end + end + + it_behaves_like 'merge validation hooks', persisted: false +end diff --git a/ee/spec/support/shared_examples/services/merge_merge_requests.rb b/ee/spec/support/shared_examples/services/merge_merge_requests.rb new file mode 100644 index 0000000000000..68bc17b3e1532 --- /dev/null +++ b/ee/spec/support/shared_examples/services/merge_merge_requests.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +shared_examples 'merge validation hooks' do |args| + def hooks_error + service.hooks_validation_error(merge_request) + end + + def hooks_pass? + service.hooks_validation_pass?(merge_request) + end + + shared_examples 'hook validations are skipped when push rules unlicensed' do + subject { service.hooks_validation_pass?(merge_request) } + + before do + stub_licensed_features(push_rules: false) + end + + it { is_expected.to be_truthy } + end + + it 'returns true when valid' do + expect(service.hooks_validation_pass?(merge_request)).to be(true) + end + + context 'commit message validation for required characters' do + before do + allow(project).to receive(:push_rule) { build(:push_rule, commit_message_regex: 'unmatched pattern .*') } + end + + it_behaves_like 'hook validations are skipped when push rules unlicensed' + + it 'returns false and matches validation error' do + expect(hooks_pass?).to be(false) + expect(hooks_error).not_to be_empty + + if args[:persisted] + expect(merge_request.merge_error).to eq(hooks_error) + else + expect(merge_request.merge_error).to be_nil + end + end + end + + context 'commit message validation for forbidden characters' do + before do + allow(project).to receive(:push_rule) { build(:push_rule, commit_message_negative_regex: '.*') } + end + + it_behaves_like 'hook validations are skipped when push rules unlicensed' + + it 'returns false and saves error when invalid' do + expect(hooks_pass?).to be(false) + expect(hooks_error).not_to be_empty + + if args[:persisted] + expect(merge_request.merge_error).to eq(hooks_error) + else + expect(merge_request.merge_error).to be_nil + end + end + end + + context 'authors email validation' do + before do + allow(project).to receive(:push_rule) { build(:push_rule, author_email_regex: '.*@unmatchedemaildomain.com') } + end + + it_behaves_like 'hook validations are skipped when push rules unlicensed' + + it 'returns false and saves error when invalid' do + expect(hooks_pass?).to be(false) + expect(hooks_error).not_to be_empty + + if args[:persisted] + expect(merge_request.merge_error).to eq(hooks_error) + else + expect(merge_request.merge_error).to be_nil + end + end + + it 'validates against the commit email' do + user.commit_email = 'foo@unmatchedemaildomain.com' + + expect(hooks_pass?).to be(true) + expect(hooks_error).to be_nil + end + end + + context 'fast forward merge request' do + it 'returns true when fast forward is enabled' do + allow(project).to receive(:merge_requests_ff_only_enabled) { true } + + expect(hooks_pass?).to be(true) + expect(hooks_error).to be_nil + end + end +end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 51d68fa410eb2..6d0c27e23fca0 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -556,6 +556,12 @@ def find_tag(name) tags.find { |tag| tag.name == name } end + def merge_to_ref(user, source_sha, branch, target_ref, message) + wrapped_gitaly_errors do + gitaly_operation_client.user_merge_to_ref(user, source_sha, branch, target_ref, message) + end + end + def merge(user, source_sha, target_branch, message, &block) wrapped_gitaly_errors do gitaly_operation_client.user_merge_branch(user, source_sha, target_branch, message, &block) diff --git a/lib/gitlab/gitaly_client/operation_service.rb b/lib/gitlab/gitaly_client/operation_service.rb index 22d2d149e65ce..d172c798da24d 100644 --- a/lib/gitlab/gitaly_client/operation_service.rb +++ b/lib/gitlab/gitaly_client/operation_service.rb @@ -100,6 +100,25 @@ def user_delete_branch(branch_name, user) end end + def user_merge_to_ref(user, source_sha, branch, target_ref, message) + request = Gitaly::UserMergeToRefRequest.new( + repository: @gitaly_repo, + source_sha: source_sha, + branch: encode_binary(branch), + target_ref: encode_binary(target_ref), + user: Gitlab::Git::User.from_gitlab(user).to_gitaly, + message: message + ) + + response = GitalyClient.call(@repository.storage, :operation_service, :user_merge_to_ref, request) + + if pre_receive_error = response.pre_receive_error.presence + raise Gitlab::Git::PreReceiveError, pre_receive_error + end + + response.commit_id + end + def user_merge_branch(user, source_sha, target_branch, message) request_enum = QueueEnumerator.new response_enum = GitalyClient.call( diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 8a9e78ba3c309..b3a728c139ed1 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1704,6 +1704,37 @@ def commit_files(commit) end end + describe '#merge_to_ref' do + let(:repository) { mutable_repository } + let(:branch_head) { '6d394385cf567f80a8fd85055db1ab4c5295806f' } + let(:left_sha) { 'cfe32cf61b73a0d5e9f13e774abde7ff789b1660' } + let(:right_branch) { 'test-master' } + let(:target_ref) { 'refs/merge-requests/999/merge' } + + before do + repository.create_branch(right_branch, branch_head) unless repository.branch_exists?(right_branch) + end + + def merge_to_ref + repository.merge_to_ref(user, left_sha, right_branch, target_ref, 'Merge message') + end + + it 'generates a commit in the target_ref' do + expect(repository.ref_exists?(target_ref)).to be(false) + + commit_sha = merge_to_ref + ref_head = repository.commit(target_ref) + + expect(commit_sha).to be_present + expect(repository.ref_exists?(target_ref)).to be(true) + expect(ref_head.id).to eq(commit_sha) + end + + it 'does not change the right branch HEAD' do + expect { merge_to_ref }.not_to change { repository.find_branch(right_branch).target } + end + end + describe '#merge' do let(:repository) { mutable_repository } let(:source_sha) { '913c66a37b4a45b9769037c55c2d238bd0942d2e' } diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index f78760bf567f3..17201d8b90a53 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -1373,6 +1373,29 @@ def merge(repository, user, merge_request, message) end end + describe '#merge_to_ref' do + let(:merge_request) do + create(:merge_request, source_branch: 'feature', + target_branch: 'master', + source_project: project) + end + + it 'writes merge of source and target to MR merge_ref_path' do + merge_commit_id = repository.merge_to_ref(user, + merge_request.diff_head_sha, + merge_request, + merge_request.merge_ref_path, + 'Custom message') + + merge_commit = repository.commit(merge_commit_id) + + expect(merge_commit.message).to eq('Custom message') + expect(merge_commit.author_name).to eq(user.name) + expect(merge_commit.author_email).to eq(user.commit_email) + expect(repository.blob_at(merge_commit.id, 'files/ruby/feature.rb')).to be_present + end + end + describe '#ff_merge' do before do repository.add_branch(user, 'ff-target', 'feature~5') diff --git a/spec/services/merge_requests/merge_service_spec.rb b/spec/services/merge_requests/merge_service_spec.rb index 04a62aa454d7a..ede79b87bcc38 100644 --- a/spec/services/merge_requests/merge_service_spec.rb +++ b/spec/services/merge_requests/merge_service_spec.rb @@ -224,6 +224,18 @@ expect(Rails.logger).to have_received(:error).with(a_string_matching(error_message)) end + it 'logs and saves error if user is not authorized' do + unauthorized_user = create(:user) + project.add_reporter(unauthorized_user) + + service = described_class.new(project, unauthorized_user) + + service.execute(merge_request) + + expect(merge_request.merge_error) + .to eq('You are not allowed to merge this merge request') + end + it 'logs and saves error if there is an PreReceiveError exception' do error_message = 'error message' diff --git a/spec/services/merge_requests/merge_to_ref_service_spec.rb b/spec/services/merge_requests/merge_to_ref_service_spec.rb new file mode 100644 index 0000000000000..96f2fde711790 --- /dev/null +++ b/spec/services/merge_requests/merge_to_ref_service_spec.rb @@ -0,0 +1,201 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe MergeRequests::MergeToRefService do + shared_examples_for 'MergeService for target ref' do + it 'target_ref has the same state of target branch' do + repo = merge_request.target_project.repository + + process_merge_to_ref + merge_service.execute(merge_request) + + ref_commits = repo.commits(merge_request.merge_ref_path, limit: 3) + target_branch_commits = repo.commits(merge_request.target_branch, limit: 3) + + ref_commits.zip(target_branch_commits).each do |ref_commit, target_branch_commit| + expect(ref_commit.parents).to eq(target_branch_commit.parents) + end + end + end + + set(:user) { create(:user) } + let(:merge_request) { create(:merge_request, :simple) } + let(:project) { merge_request.project } + + before do + project.add_maintainer(user) + end + + describe '#execute' do + let(:service) do + described_class.new(project, user, + commit_message: 'Awesome message', + 'should_remove_source_branch' => true) + end + + def process_merge_to_ref + perform_enqueued_jobs do + service.execute(merge_request) + end + end + + it 'writes commit to merge ref' do + repository = project.repository + target_ref = merge_request.merge_ref_path + + expect(repository.ref_exists?(target_ref)).to be(false) + + result = service.execute(merge_request) + + ref_head = repository.commit(target_ref) + + expect(result[:status]).to eq(:success) + expect(result[:commit_id]).to be_present + expect(repository.ref_exists?(target_ref)).to be(true) + expect(ref_head.id).to eq(result[:commit_id]) + end + + it 'does not send any mail' do + expect { process_merge_to_ref }.not_to change { ActionMailer::Base.deliveries.count } + end + + it 'does not change the MR state' do + expect { process_merge_to_ref }.not_to change { merge_request.state } + end + + it 'does not create notes' do + expect { process_merge_to_ref }.not_to change { merge_request.notes.count } + end + + it 'does not delete the source branch' do + expect(DeleteBranchService).not_to receive(:new) + + process_merge_to_ref + end + + it 'returns error when feature is disabled' do + stub_feature_flags(merge_to_tmp_merge_ref_path: false) + + result = service.execute(merge_request) + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Feature is not enabled') + end + + it 'returns an error when the failing to process the merge' do + allow(project.repository).to receive(:merge_to_ref).and_return(nil) + + result = service.execute(merge_request) + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Conflicts detected during merge') + end + + context 'commit history comparison with regular MergeService' do + let(:merge_ref_service) do + described_class.new(project, user, {}) + end + + let(:merge_service) do + MergeRequests::MergeService.new(project, user, {}) + end + + context 'when merge commit' do + it_behaves_like 'MergeService for target ref' + end + + context 'when merge commit with squash' do + before do + merge_request.update!(squash: true, source_branch: 'master', target_branch: 'feature') + end + + it_behaves_like 'MergeService for target ref' + end + end + + context 'merge pre-condition checks' do + before do + merge_request.project.update!(merge_method: merge_method) + end + + context 'when semi-linear merge method' do + let(:merge_method) { :rebase_merge } + + it 'return error when MR should be able to fast-forward' do + allow(merge_request).to receive(:should_be_rebased?) { true } + + error_message = 'Fast-forward merge is not possible. Please update your source branch.' + + result = service.execute(merge_request) + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq(error_message) + end + end + + context 'when fast-forward merge method' do + let(:merge_method) { :ff } + + it 'returns error' do + error_message = "Fast-forward to #{merge_request.merge_ref_path} is currently not supported." + + result = service.execute(merge_request) + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq(error_message) + end + end + + context 'when MR is not mergeable to ref' do + let(:merge_method) { :merge } + + it 'returns error' do + allow(merge_request).to receive(:mergeable_to_ref?) { false } + + error_message = "Merge request is not mergeable to #{merge_request.merge_ref_path}" + + result = service.execute(merge_request) + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq(error_message) + end + end + end + + context 'does not close related todos' do + let(:merge_request) { create(:merge_request, assignee: user, author: user) } + let(:project) { merge_request.project } + let!(:todo) do + create(:todo, :assigned, + project: project, + author: user, + user: user, + target: merge_request) + end + + before do + allow(service).to receive(:execute_hooks) + + perform_enqueued_jobs do + service.execute(merge_request) + todo.reload + end + end + + it { expect(todo).not_to be_done } + end + + it 'returns error when user has no authorization to admin the merge request' do + unauthorized_user = create(:user) + project.add_reporter(unauthorized_user) + + service = described_class.new(project, unauthorized_user) + + result = service.execute(merge_request) + + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('You are not allowed to merge to this ref') + end + end +end -- GitLab