diff options
author | Jan Provaznik <jprovaznik@gitlab.com> | 2017-12-20 12:01:21 +0300 |
---|---|---|
committer | Jan Provaznik <jprovaznik@gitlab.com> | 2018-01-05 11:34:59 +0300 |
commit | 27a75ea1757d1c1b67bf501ec333221ed5e92d04 (patch) | |
tree | 4ef9caa88a19ed27cc58d60e42769a2fe5cf1e8d | |
parent | 1560c234d2410ccbd163b9aedecf2d8b7a596627 (diff) |
Backport 'Rebase' feature from EE to CE
When a project uses fast-forward merging strategy user has
to rebase MRs to target branch before it can be merged.
Now user can do rebase in UI by clicking 'Rebase' button
instead of doing rebase locally.
This feature was already present in EE, this is only backport
of the feature to CE. Couple of changes:
* removed rebase license check
* renamed migration (changed timestamp)
Closes #40301
40 files changed, 825 insertions, 13 deletions
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue new file mode 100644 index 00000000000..09276ba2769 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_rebase.vue @@ -0,0 +1,133 @@ +<script> + import simplePoll from '../../../lib/utils/simple_poll'; + import eventHub from '../../event_hub'; + import statusIcon from '../mr_widget_status_icon'; + import loadingIcon from '../../../vue_shared/components/loading_icon.vue'; + import Flash from '../../../flash'; + + export default { + name: 'MRWidgetRebase', + props: { + mr: { + type: Object, + required: true, + }, + service: { + type: Object, + required: true, + }, + }, + components: { + statusIcon, + loadingIcon, + }, + data() { + return { + isMakingRequest: false, + rebasingError: null, + }; + }, + computed: { + status() { + if (this.mr.rebaseInProgress || this.isMakingRequest) { + return 'loading'; + } + if (!this.mr.canPushToSourceBranch && !this.mr.rebaseInProgress) { + return 'warning'; + } + return 'success'; + }, + showDisabledButton() { + return ['failed', 'loading'].includes(this.status); + }, + }, + methods: { + rebase() { + this.isMakingRequest = true; + this.rebasingError = null; + + this.service.rebase() + .then(() => { + simplePoll(this.checkRebaseStatus); + }) + .catch((error) => { + this.rebasingError = error.merge_error; + this.isMakingRequest = false; + Flash('Something went wrong. Please try again.'); + }); + }, + checkRebaseStatus(continuePolling, stopPolling) { + this.service.poll() + .then(res => res.data) + .then((res) => { + if (res.rebase_in_progress) { + continuePolling(); + } else { + this.isMakingRequest = false; + + if (res.merge_error && res.merge_error.length) { + this.rebasingError = res.merge_error; + Flash('Something went wrong. Please try again.'); + } + + eventHub.$emit('MRWidgetUpdateRequested'); + stopPolling(); + } + }) + .catch(() => { + this.isMakingRequest = false; + Flash('Something went wrong. Please try again.'); + stopPolling(); + }); + }, + }, + }; +</script> +<template> + <div class="mr-widget-body media"> + <status-icon + :status="status" + :show-disabled-button="showDisabledButton" + /> + + <div class="rebase-state-find-class-convention media media-body space-children"> + <template v-if="mr.rebaseInProgress || isMakingRequest"> + <span class="bold"> + Rebase in progress + </span> + </template> + <template v-if="!mr.rebaseInProgress && !mr.canPushToSourceBranch"> + <span class="bold"> + Fast-forward merge is not possible. + Rebase the source branch onto + <span class="label-branch">{{mr.targetBranch}}</span> + to allow this merge request to be merged. + </span> + </template> + <template v-if="!mr.rebaseInProgress && mr.canPushToSourceBranch && !isMakingRequest"> + <div class="accept-merge-holder clearfix js-toggle-container accept-action media space-children"> + <button + type="button" + class="btn btn-sm btn-reopen btn-success" + :disabled="isMakingRequest" + @click="rebase"> + <loading-icon v-if="isMakingRequest" /> + Rebase + </button> + <span + v-if="!rebasingError" + class="bold"> + Fast-forward merge is not possible. + Rebase the source branch onto the target branch or merge target + branch into source branch to allow this merge request to be merged. + </span> + <span + v-else + class="bold danger"> + {{rebasingError}} + </span> + </div> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index 5bd8b99420a..940f3d9b2d0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -32,6 +32,7 @@ export { default as UnresolvedDiscussionsState } from './components/states/mr_wi export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked'; export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; export { default as MergeWhenPipelineSucceedsState } from './components/states/mr_widget_merge_when_pipeline_succeeds'; +export { default as RebaseState } from './components/states/mr_widget_rebase.vue'; export { default as AutoMergeFailed } from './components/states/mr_widget_auto_merge_failed'; export { default as CheckingState } from './components/states/mr_widget_checking'; export { default as MRWidgetStore } from './stores/mr_widget_store'; diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index fdae06200de..2075f8e4fec 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -10,6 +10,7 @@ import { MergedState, ClosedState, MergingState, + RebaseState, WipState, ArchivedState, ConflictsState, @@ -79,6 +80,7 @@ export default { ciEnvironmentsStatusPath: store.ciEnvironmentsStatusPath, statusPath: store.statusPath, mergeActionsContentPath: store.mergeActionsContentPath, + rebasePath: store.rebasePath, }; return new MRWidgetService(endpoints); }, @@ -232,6 +234,7 @@ export default { 'mr-widget-pipeline-failed': PipelineFailedState, 'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState, 'mr-widget-auto-merge-failed': AutoMergeFailed, + 'mr-widget-rebase': RebaseState, }, template: ` <div class="mr-state-widget prepend-top-default"> diff --git a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js index 7c0bbdd403f..fecbfec2214 100644 --- a/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js +++ b/app/assets/javascripts/vue_merge_request_widget/services/mr_widget_service.js @@ -37,6 +37,10 @@ export default class MRWidgetService { return axios.get(this.endpoints.mergeActionsContentPath); } + rebase() { + return axios.post(this.endpoints.rebasePath); + } + static stopEnvironment(url) { return axios.post(url); } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js index 2bace3311c8..f7f0c1b6cb7 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/get_state_key.js @@ -25,6 +25,8 @@ export default function deviseState(data) { return this.mergeError ? stateKey.autoMergeFailed : stateKey.mergeWhenPipelineSucceeds; } else if (!this.canMerge) { return stateKey.notAllowedToMerge; + } else if (this.shouldBeRebased) { + return stateKey.rebase; } else if (this.canBeMerged) { return stateKey.readyToMerge; } diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index 474c17ec133..ed004b3bb08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -26,6 +26,7 @@ export default class MergeRequestStore { this.divergedCommitsCount = data.diverged_commits_count; this.pipeline = data.pipeline || {}; this.deployments = this.deployments || data.deployments || []; + this.initRebase(data); if (data.issues_links) { const links = data.issues_links; @@ -124,6 +125,13 @@ export default class MergeRequestStore { return this.state === stateKey.nothingToMerge; } + initRebase(data) { + this.canPushToSourceBranch = data.can_push_to_source_branch; + this.rebaseInProgress = data.rebase_in_progress; + this.approvalsLeft = !data.approved; + this.rebasePath = data.rebase_path; + } + static buildMetrics(metrics) { if (!metrics) { return {}; diff --git a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js index de980c175fb..29d5bd4a1da 100644 --- a/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js +++ b/app/assets/javascripts/vue_merge_request_widget/stores/state_maps.js @@ -17,6 +17,7 @@ const stateToComponentMap = { failedToMerge: 'mr-widget-failed-to-merge', autoMergeFailed: 'mr-widget-auto-merge-failed', shaMismatch: 'mr-widget-sha-mismatch', + rebase: 'mr-widget-rebase', }; const statesToShowHelpWidget = [ @@ -29,6 +30,7 @@ const statesToShowHelpWidget = [ 'pipelineFailed', 'pipelineBlocked', 'autoMergeFailed', + 'rebase', ]; export const stateKey = { @@ -46,6 +48,7 @@ export const stateKey = { mergeWhenPipelineSucceeds: 'mergeWhenPipelineSucceeds', notAllowedToMerge: 'notAllowedToMerge', readyToMerge: 'readyToMerge', + rebase: 'rebase', }; export default { diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 6b59c8461a3..2e8a738b6d9 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -10,6 +10,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] before_action :set_issuables_index, only: [:index] before_action :authenticate_user!, only: [:assign_related_issues] + before_action :check_user_can_push_to_source_branch!, only: [:rebase] def index @merge_requests = @issuables @@ -223,6 +224,12 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo render json: environments end + def rebase + RebaseWorker.perform_async(@merge_request.id, current_user.id) + + render nothing: true, status: 200 + end + protected alias_method :subscribable_resource, :merge_request @@ -322,4 +329,14 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo @finder_type = MergeRequestsFinder super end + + def check_user_can_push_to_source_branch! + return access_denied! unless @merge_request.source_branch_exists? + + access_check = ::Gitlab::UserAccess + .new(current_user, project: @merge_request.source_project) + .can_push_to_branch?(@merge_request.source_branch) + + access_denied! unless access_check + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index c39789b047d..ef58816937c 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -156,6 +156,13 @@ class MergeRequest < ActiveRecord::Base '!' end + def rebase_in_progress? + # The source project can be deleted + return false unless source_project + + source_project.repository.rebase_in_progress?(id) + end + # Use this method whenever you need to make sure the head_pipeline is synced with the # branch head commit, for example checking if a merge request can be merged. # For more information check: https://gitlab.com/gitlab-org/gitlab-ce/issues/40004 @@ -607,7 +614,7 @@ class MergeRequest < ActiveRecord::Base check_if_can_be_merged - can_be_merged? + can_be_merged? && !should_be_rebased? end def mergeable_state?(skip_ci_check: false) diff --git a/app/models/repository.rb b/app/models/repository.rb index b1fd981965c..4bedcbfb6a2 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1099,6 +1099,13 @@ class Repository @project.repository_storage_path end + def rebase(user, merge_request) + raw.rebase(user, merge_request.id, branch: merge_request.source_branch, + branch_sha: merge_request.source_branch_sha, + remote_repository: merge_request.target_project.repository.raw, + remote_branch: merge_request.target_branch) + end + private # TODO Generice finder, later split this on finders by Ref or Oid diff --git a/app/presenters/merge_request_presenter.rb b/app/presenters/merge_request_presenter.rb index ab4c87c0169..c6806b7cc26 100644 --- a/app/presenters/merge_request_presenter.rb +++ b/app/presenters/merge_request_presenter.rb @@ -76,6 +76,12 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end end + def rebase_path + if !rebase_in_progress? && should_be_rebased? && user_can_push_to_source_branch? + rebase_project_merge_request_path(project, merge_request) + end + end + def target_branch_tree_path if target_branch_exists? project_tree_path(project, target_branch) @@ -152,6 +158,10 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated user_can_collaborate_with_project? && can_be_cherry_picked? end + def can_push_to_source_branch? + source_branch_exists? && user_can_push_to_source_branch? + end + private def conflicts @@ -174,6 +184,14 @@ class MergeRequestPresenter < Gitlab::View::Presenter::Delegated end.sort.to_sentence end + def user_can_push_to_source_branch? + return false unless source_branch_exists? + + ::Gitlab::UserAccess + .new(current_user, project: source_project) + .can_push_to_branch?(source_branch) + end + def user_can_collaborate_with_project? can?(current_user, :push_code, project) || (current_user && current_user.already_forked?(project)) diff --git a/app/serializers/merge_request_basic_entity.rb b/app/serializers/merge_request_basic_entity.rb index d54a6516aed..e4aec977f01 100644 --- a/app/serializers/merge_request_basic_entity.rb +++ b/app/serializers/merge_request_basic_entity.rb @@ -4,4 +4,5 @@ class MergeRequestBasicEntity < IssuableSidebarEntity expose :merge_error expose :state expose :source_branch_exists?, as: :source_branch_exists + expose :rebase_in_progress?, as: :rebase_in_progress end diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index e905e6876c2..48cd2317f46 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -23,6 +23,16 @@ class MergeRequestWidgetEntity < IssuableEntity MergeRequestMetricsEntity.new(metrics).as_json end + expose :rebase_commit_sha + expose :rebase_in_progress?, as: :rebase_in_progress + + expose :can_push_to_source_branch do |merge_request| + presenter(merge_request).can_push_to_source_branch? + end + expose :rebase_path do |merge_request| + presenter(merge_request).rebase_path + end + # User entities expose :merge_user, using: UserEntity diff --git a/app/services/merge_requests/rebase_service.rb b/app/services/merge_requests/rebase_service.rb new file mode 100644 index 00000000000..0d5a25fa28e --- /dev/null +++ b/app/services/merge_requests/rebase_service.rb @@ -0,0 +1,30 @@ +module MergeRequests + class RebaseService < MergeRequests::WorkingCopyBaseService + def execute(merge_request) + @merge_request = merge_request + + if rebase + success + else + error('Failed to rebase. Should be done manually') + end + end + + def rebase + if merge_request.rebase_in_progress? + log_error('Rebase task canceled: Another rebase is already in progress', save_message_on_model: true) + return false + end + + rebase_sha = repository.rebase(current_user, merge_request) + + merge_request.update_attributes(rebase_commit_sha: rebase_sha) + + true + rescue => e + log_error('Failed to rebase branch:') + log_error(e.message, save_message_on_model: true) + false + end + end +end diff --git a/app/services/merge_requests/working_copy_base_service.rb b/app/services/merge_requests/working_copy_base_service.rb new file mode 100644 index 00000000000..186e05bf966 --- /dev/null +++ b/app/services/merge_requests/working_copy_base_service.rb @@ -0,0 +1,24 @@ +module MergeRequests + class WorkingCopyBaseService < MergeRequests::BaseService + attr_reader :merge_request + + def source_project + @source_project ||= merge_request.source_project + end + + def target_project + @target_project ||= merge_request.target_project + end + + def log_error(message, save_message_on_model: false) + Gitlab::GitLogger.error("#{self.class.name} error (#{merge_request.to_reference(full: true)}): #{message}") + + merge_request.update(merge_error: message) if save_message_on_model + end + + # Don't try to print expensive instance variables. + def inspect + "#<#{self.class} #{merge_request.to_reference(full: true)}>" + end + end +end diff --git a/app/views/projects/_merge_request_fast_forward_settings.html.haml b/app/views/projects/_merge_request_fast_forward_settings.html.haml index 9d357293a2f..8129c72feb2 100644 --- a/app/views/projects/_merge_request_fast_forward_settings.html.haml +++ b/app/views/projects/_merge_request_fast_forward_settings.html.haml @@ -10,4 +10,4 @@ No merge commits are created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded. %br %span.descr - When fast-forward merge is not possible, the user must first rebase locally. + When fast-forward merge is not possible, the user is given the option to rebase. diff --git a/app/views/projects/_merge_request_rebase_settings.html.haml b/app/views/projects/_merge_request_rebase_settings.html.haml index c52e09573a6..54e0b73d24c 100644 --- a/app/views/projects/_merge_request_rebase_settings.html.haml +++ b/app/views/projects/_merge_request_rebase_settings.html.haml @@ -10,4 +10,4 @@ This way you could make sure that if this merge request would build, after merging to target branch it would also build. %br %span.descr - When fast-forward merge is not possible, the user must first rebase locally. + When fast-forward merge is not possible, the user is given the option to rebase. diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 268b7028fd9..fafd9e5ef00 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -89,6 +89,7 @@ - project_service - propagate_service_template - reactive_caching +- rebase - repository_fork - repository_import - storage_migrator diff --git a/app/workers/rebase_worker.rb b/app/workers/rebase_worker.rb new file mode 100644 index 00000000000..090987778a2 --- /dev/null +++ b/app/workers/rebase_worker.rb @@ -0,0 +1,12 @@ +class RebaseWorker + include ApplicationWorker + + def perform(merge_request_id, current_user_id) + current_user = User.find(current_user_id) + merge_request = MergeRequest.find(merge_request_id) + + MergeRequests::RebaseService + .new(merge_request.source_project, current_user) + .execute(merge_request) + end +end diff --git a/changelogs/unreleased/40301-rebase.yml b/changelogs/unreleased/40301-rebase.yml new file mode 100644 index 00000000000..1c0fc0cd8ae --- /dev/null +++ b/changelogs/unreleased/40301-rebase.yml @@ -0,0 +1,5 @@ +--- +title: Allow user to rebase merge requests. +merge_request: +author: +type: added diff --git a/config/routes/project.rb b/config/routes/project.rb index c3ad53a387f..1354c4c5537 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -96,6 +96,7 @@ constraints(ProjectUrlConstrainer.new) do post :toggle_subscription post :remove_wip post :assign_related_issues + post :rebase scope constraints: { format: nil }, action: :show do get :commits, defaults: { tab: 'commits' } diff --git a/db/migrate/20171230123729_add_rebase_commit_sha_to_merge_requests.rb b/db/migrate/20171230123729_add_rebase_commit_sha_to_merge_requests.rb new file mode 100644 index 00000000000..2ce156fa92e --- /dev/null +++ b/db/migrate/20171230123729_add_rebase_commit_sha_to_merge_requests.rb @@ -0,0 +1,7 @@ +class AddRebaseCommitShaToMergeRequests < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :merge_requests, :rebase_commit_sha, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 778d66f16b0..740e80ccfd4 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20171229225929) do +ActiveRecord::Schema.define(version: 20171230123729) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1099,6 +1099,7 @@ ActiveRecord::Schema.define(version: 20171229225929) do t.string "merge_jid" t.boolean "discussion_locked" t.integer "latest_merge_request_diff_id" + t.string "rebase_commit_sha" end add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree diff --git a/doc/user/project/merge_requests/fast_forward_merge.md b/doc/user/project/merge_requests/fast_forward_merge.md index 085170d9f03..3cd91a185e3 100644 --- a/doc/user/project/merge_requests/fast_forward_merge.md +++ b/doc/user/project/merge_requests/fast_forward_merge.md @@ -9,7 +9,7 @@ When the fast-forward merge ([`--ff-only`][ffonly]) setting is enabled, no merge commits will be created and all merges are fast-forwarded, which means that merging is only allowed if the branch could be fast-forwarded. -When a fast-forward merge is not possible, the user must rebase the branch manually. +When a fast-forward merge is not possible, the user is given the option to rebase. ## Use cases @@ -25,7 +25,7 @@ merge commits. In such cases, the fast-forward merge is the perfect candidate. Now, when you visit the merge request page, you will be able to accept it **only if a fast-forward merge is possible**. -![Fast forward merge request](img/ff_merge_mr.png) +![Fast forward merge request](img/ff_merge_rebase.png) If the target branch is ahead of the source branch, you need to rebase the source branch locally before you will be able to do a fast-forward merge. diff --git a/doc/user/project/merge_requests/img/ff_merge_mr.png b/doc/user/project/merge_requests/img/ff_merge_mr.png Binary files differdeleted file mode 100644 index 241cc990343..00000000000 --- a/doc/user/project/merge_requests/img/ff_merge_mr.png +++ /dev/null diff --git a/doc/user/project/merge_requests/img/ff_merge_rebase.png b/doc/user/project/merge_requests/img/ff_merge_rebase.png Binary files differnew file mode 100644 index 00000000000..f6139f189ce --- /dev/null +++ b/doc/user/project/merge_requests/img/ff_merge_rebase.png diff --git a/features/project/ff_merge_requests.feature b/features/project/ff_merge_requests.feature index 995e52f9332..39035d551d1 100644 --- a/features/project/ff_merge_requests.feature +++ b/features/project/ff_merge_requests.feature @@ -22,3 +22,20 @@ Feature: Project Ff Merge Requests Then I should see ff-only merge button When I accept this merge request Then I should see merged request + + @javascript + Scenario: I do rebase before ff-only merge + Given ff merge enabled + And rebase before merge enabled + When I visit merge request page "Bug NS-05" + Then I should see rebase button + When I press rebase button + Then I should see rebase in progress message + + @javascript + Scenario: I do rebase before regular merge + Given rebase before merge enabled + When I visit merge request page "Bug NS-05" + Then I should see rebase button + When I press rebase button + Then I should see rebase in progress message diff --git a/features/steps/project/ff_merge_requests.rb b/features/steps/project/ff_merge_requests.rb index d68fe71e16e..27efcfd65b6 100644 --- a/features/steps/project/ff_merge_requests.rb +++ b/features/steps/project/ff_merge_requests.rb @@ -17,6 +17,10 @@ class Spinach::Features::ProjectFfMergeRequests < Spinach::FeatureSteps author: project.users.first) end + step 'merge request is mergeable' do + expect(page).to have_button 'Merge' + end + step 'I should see ff-only merge button' do expect(page).to have_content "Fast-forward merge without a merge commit" expect(page).to have_button 'Merge' @@ -45,6 +49,10 @@ class Spinach::Features::ProjectFfMergeRequests < Spinach::FeatureSteps project.save! end + step 'I should see rebase button' do + expect(page).to have_button "Rebase" + end + step 'merge request "Bug NS-05" is rebased' do merge_request.source_branch = 'flatten-dir' merge_request.target_branch = 'improve/awesome' @@ -59,6 +67,20 @@ class Spinach::Features::ProjectFfMergeRequests < Spinach::FeatureSteps merge_request.save! end + step 'rebase before merge enabled' do + project = merge_request.target_project + project.merge_requests_rebase_enabled = true + project.save! + end + + step 'I press rebase button' do + click_button "Rebase" + end + + step "I should see rebase in progress message" do + expect(page).to have_content("Rebase in progress") + end + def merge_request @merge_request ||= MergeRequest.find_by!(title: "Bug NS-05") end diff --git a/lib/gitlab/git/operation_service.rb b/lib/gitlab/git/operation_service.rb index ef5bdbaf819..3fb0e2eed93 100644 --- a/lib/gitlab/git/operation_service.rb +++ b/lib/gitlab/git/operation_service.rb @@ -97,6 +97,11 @@ module Gitlab end end + def update_branch(branch_name, newrev, oldrev) + ref = Gitlab::Git::BRANCH_REF_PREFIX + branch_name + update_ref_in_hooks(ref, newrev, oldrev) + end + private # Returns [newrev, should_run_after_create, should_run_after_create_branch] diff --git a/spec/controllers/projects/merge_requests_controller_spec.rb b/spec/controllers/projects/merge_requests_controller_spec.rb index 45c424af8c4..c8cc6b374f6 100644 --- a/spec/controllers/projects/merge_requests_controller_spec.rb +++ b/spec/controllers/projects/merge_requests_controller_spec.rb @@ -684,4 +684,62 @@ describe Projects::MergeRequestsController do format: :json end end + + describe 'POST #rebase' do + let(:viewer) { user } + + def post_rebase + post :rebase, namespace_id: project.namespace, project_id: project, id: merge_request + end + + def expect_rebase_worker_for(user) + expect(RebaseWorker).to receive(:perform_async).with(merge_request.id, user.id) + end + + context 'successfully' do + it 'enqeues a RebaseWorker' do + expect_rebase_worker_for(viewer) + + post_rebase + + expect(response.status).to eq(200) + end + end + + context 'with a forked project' do + let(:fork_project) { create(:project, :repository, forked_from_project: project) } + let(:fork_owner) { fork_project.owner } + + before do + merge_request.update!(source_project: fork_project) + fork_project.add_reporter(user) + end + + context 'user cannot push to source branch' do + it 'returns 404' do + expect_rebase_worker_for(viewer).never + + post_rebase + + expect(response.status).to eq(404) + end + end + + context 'user can push to source branch' do + before do + project.add_reporter(fork_owner) + + sign_in(fork_owner) + end + + it 'returns 200' do + expect_rebase_worker_for(fork_owner) + + post_rebase + + expect(response.status).to eq(200) + end + end + end + end end diff --git a/spec/fixtures/api/schemas/entities/merge_request_basic.json b/spec/fixtures/api/schemas/entities/merge_request_basic.json index 995f13381ad..f1199468d53 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_basic.json +++ b/spec/fixtures/api/schemas/entities/merge_request_basic.json @@ -9,6 +9,7 @@ "human_time_estimate": { "type": ["string", "null"] }, "human_total_time_spent": { "type": ["string", "null"] }, "merge_error": { "type": ["string", "null"] }, + "rebase_in_progress": { "type": "boolean" }, "assignee_id": { "type": ["integer", "null"] }, "subscribed": { "type": ["boolean", "null"] }, "participants": { "type": "array" } diff --git a/spec/fixtures/api/schemas/entities/merge_request_widget.json b/spec/fixtures/api/schemas/entities/merge_request_widget.json index 9de27bee751..7f662098216 100644 --- a/spec/fixtures/api/schemas/entities/merge_request_widget.json +++ b/spec/fixtures/api/schemas/entities/merge_request_widget.json @@ -103,7 +103,11 @@ "remove_source_branch": { "type": ["boolean", "null"] }, "merge_ongoing": { "type": "boolean" }, "ff_only_enabled": { "type": ["boolean", false] }, - "should_be_rebased": { "type": "boolean" } + "should_be_rebased": { "type": "boolean" }, + "rebase_commit_sha": { "type": ["string", "null"] }, + "rebase_in_progress": { "type": "boolean" }, + "can_push_to_source_branch": { "type": "boolean" }, + "rebase_path": { "type": ["string", "null"] } }, "additionalProperties": false } diff --git a/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js b/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js new file mode 100644 index 00000000000..66ecaa316c8 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/mr_widget_rebase_spec.js @@ -0,0 +1,115 @@ +import Vue from 'vue'; +import eventHub from '~/vue_merge_request_widget/event_hub'; +import component from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('Merge request widget rebase component', () => { + let Component; + let vm; + beforeEach(() => { + Component = Vue.extend(component); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('While rebasing', () => { + it('should show progress message', () => { + vm = mountComponent(Component, { + mr: { rebaseInProgress: true }, + service: {}, + }); + + expect( + vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(), + ).toContain('Rebase in progress'); + }); + }); + + describe('With permissions', () => { + beforeEach(() => { + vm = mountComponent(Component, { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: true, + }, + service: {}, + }); + }); + + it('it should render rebase button and warning message', () => { + const text = vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(); + expect(text).toContain('Fast-forward merge is not possible.'); + expect(text).toContain('Rebase the source branch onto the target branch or merge target'); + expect(text).toContain('branch into source branch to allow this merge request to be merged.'); + }); + + it('it should render error message when it fails', (done) => { + vm.rebasingError = 'Something went wrong!'; + + Vue.nextTick(() => { + expect( + vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(), + ).toContain('Something went wrong!'); + done(); + }); + }); + }); + + describe('Without permissions', () => { + it('should render a message explaining user does not have permissions', () => { + vm = mountComponent(Component, { + mr: { + rebaseInProgress: false, + canPushToSourceBranch: false, + targetBranch: 'foo', + }, + service: {}, + }); + + const text = vm.$el.querySelector('.rebase-state-find-class-convention span').textContent.trim(); + + expect(text).toContain('Fast-forward merge is not possible.'); + expect(text).toContain('Rebase the source branch onto'); + expect(text).toContain('foo'); + expect(text).toContain('to allow this merge request to be merged.'); + }); + }); + + describe('methods', () => { + it('checkRebaseStatus', (done) => { + spyOn(eventHub, '$emit'); + vm = mountComponent(Component, { + mr: {}, + service: { + rebase() { + return Promise.resolve(); + }, + poll() { + return Promise.resolve({ + data: { + rebase_in_progress: false, + merge_error: null, + }, + }); + }, + }, + }); + + vm.rebase(); + + // Wait for the rebase request + vm.$nextTick() + // Wait for the polling request + .then(vm.$nextTick()) + // Wait for the eventHub to be called + .then(vm.$nextTick()) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested'); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index d8ebd46faab..07b3e1c1758 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -1903,4 +1903,50 @@ describe MergeRequest do end end end + + describe '#should_be_rebased?' do + let(:project) { create(:project, :repository) } + + it 'returns false for the same source and target branches' do + merge_request = create(:merge_request, source_project: project, target_project: project) + + expect(merge_request.should_be_rebased?).to be_falsey + end + end + + describe '#rebase_in_progress?' do + # Create merge request and project before we stub file calls + before do + subject + end + + it 'returns true when there is a current rebase directory' do + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:mtime).and_return(Time.now) + + expect(subject.rebase_in_progress?).to be_truthy + end + + it 'returns false when there is no rebase directory' do + allow(File).to receive(:exist?).and_return(false) + + expect(subject.rebase_in_progress?).to be_falsey + end + + it 'returns false when the rebase directory has expired' do + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:mtime).and_return(20.minutes.ago) + + expect(subject.rebase_in_progress?).to be_falsey + end + + it 'returns false when the source project has been removed' do + allow(subject).to receive(:source_project).and_return(nil) + allow(File).to receive(:exist?).and_return(true) + allow(File).to receive(:mtime).and_return(Time.now) + + expect(File).not_to have_received(:exist?) + expect(subject.rebase_in_progress?).to be_falsey + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 3c2ed043b82..13e5345ee4c 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -418,14 +418,21 @@ describe Project do end describe '#merge_method' do - it 'returns "ff" merge_method when ff is enabled' do - project = build(:project, merge_requests_ff_only_enabled: true) - expect(project.merge_method).to be :ff + using RSpec::Parameterized::TableSyntax + + where(:ff, :rebase, :method) do + true | true | :ff + true | false | :ff + false | true | :rebase_merge + false | false | :merge end - it 'returns "merge" merge_method when ff is disabled' do - project = build(:project, merge_requests_ff_only_enabled: false) - expect(project.merge_method).to be :merge + with_them do + let(:project) { build(:project, merge_requests_rebase_enabled: rebase, merge_requests_ff_only_enabled: ff) } + + subject { project.merge_method } + + it { is_expected.to eq(method) } end end diff --git a/spec/presenters/merge_request_presenter_spec.rb b/spec/presenters/merge_request_presenter_spec.rb index 969c4753f33..e3b37739e8e 100644 --- a/spec/presenters/merge_request_presenter_spec.rb +++ b/spec/presenters/merge_request_presenter_spec.rb @@ -404,4 +404,67 @@ describe MergeRequestPresenter do .to eq("<a href=\"/#{resource.source_project.full_path}/tree/#{resource.source_branch}\">#{resource.source_branch}</a>") end end + + describe '#rebase_path' do + before do + allow(resource).to receive(:rebase_in_progress?) { rebase_in_progress } + allow(resource).to receive(:should_be_rebased?) { should_be_rebased } + + allow_any_instance_of(Gitlab::UserAccess::RequestCacheExtension) + .to receive(:can_push_to_branch?) + .with(resource.source_branch) + .and_return(can_push_to_branch) + end + + subject do + described_class.new(resource, current_user: user).rebase_path + end + + context 'when can rebase' do + let(:rebase_in_progress) { false } + let(:can_push_to_branch) { true } + let(:should_be_rebased) { true } + + before do + allow(resource).to receive(:source_branch_exists?) { true } + end + + it 'returns path' do + is_expected + .to eq("/#{project.full_path}/merge_requests/#{resource.iid}/rebase") + end + end + + context 'when cannot rebase' do + context 'when rebase in progress' do + let(:rebase_in_progress) { true } + let(:can_push_to_branch) { true } + let(:should_be_rebased) { true } + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'when user cannot merge' do + let(:rebase_in_progress) { false } + let(:can_push_to_branch) { false } + let(:should_be_rebased) { true } + + it 'returns nil' do + is_expected.to be_nil + end + end + + context 'should not be rebased' do + let(:rebase_in_progress) { false } + let(:can_push_to_branch) { true } + let(:should_be_rebased) { false } + + it 'returns nil' do + is_expected.to be_nil + end + end + end + end end diff --git a/spec/serializers/merge_request_widget_entity_spec.rb b/spec/serializers/merge_request_widget_entity_spec.rb index e25552eb0d8..80a271ba7fb 100644 --- a/spec/serializers/merge_request_widget_entity_spec.rb +++ b/spec/serializers/merge_request_widget_entity_spec.rb @@ -190,4 +190,20 @@ describe MergeRequestWidgetEntity do end end end + + describe 'when source project is deleted' do + let(:project) { create(:project, :repository) } + let(:fork_project) { create(:project, :repository, forked_from_project: project) } + let(:merge_request) { create(:merge_request, source_project: fork_project, target_project: project) } + + it 'returns a blank rebase_path' do + allow(merge_request).to receive(:should_be_rebased?).and_return(true) + fork_project.destroy + merge_request.reload + + entity = described_class.new(merge_request, request: request).as_json + + expect(entity[:rebase_path]).to be_nil + end + end end diff --git a/spec/services/merge_requests/rebase_service_spec.rb b/spec/services/merge_requests/rebase_service_spec.rb new file mode 100644 index 00000000000..d1b37cdd073 --- /dev/null +++ b/spec/services/merge_requests/rebase_service_spec.rb @@ -0,0 +1,134 @@ +require 'spec_helper' + +describe MergeRequests::RebaseService do + include ProjectForksHelper + + let(:user) { create(:user) } + let(:merge_request) do + create(:merge_request, + source_branch: 'feature_conflict', + target_branch: 'master') + end + let(:project) { merge_request.project } + let(:repository) { project.repository.raw } + + subject(:service) { described_class.new(project, user, {}) } + + before do + project.add_master(user) + end + + describe '#execute' do + context 'when another rebase is already in progress' do + before do + allow(merge_request).to receive(:rebase_in_progress?).and_return(true) + end + + it 'saves the error message' do + subject.execute(merge_request) + + expect(merge_request.reload.merge_error).to eq 'Rebase task canceled: Another rebase is already in progress' + end + + it 'returns an error' do + expect(service.execute(merge_request)).to match(status: :error, + message: 'Failed to rebase. Should be done manually') + end + end + + context 'when unexpected error occurs' do + before do + allow(repository).to receive(:run_git!).and_raise('Something went wrong') + end + + it 'saves the error message' do + subject.execute(merge_request) + + expect(merge_request.reload.merge_error).to eq 'Something went wrong' + end + + it 'returns an error' do + expect(service.execute(merge_request)).to match(status: :error, + message: 'Failed to rebase. Should be done manually') + end + end + + context 'with git command failure' do + before do + allow(repository).to receive(:run_git!).and_raise(Gitlab::Git::Repository::GitError, 'Something went wrong') + end + + it 'saves the error message' do + subject.execute(merge_request) + + expect(merge_request.reload.merge_error).to eq 'Something went wrong' + end + + it 'returns an error' do + expect(service.execute(merge_request)).to match(status: :error, + message: 'Failed to rebase. Should be done manually') + end + end + + context 'valid params' do + before do + service.execute(merge_request) + end + + it 'rebases source branch' do + parent_sha = merge_request.source_project.repository.commit(merge_request.source_branch).parents.first.sha + target_branch_sha = merge_request.target_project.repository.commit(merge_request.target_branch).sha + expect(parent_sha).to eq(target_branch_sha) + end + + it 'records the new SHA on the merge request' do + head_sha = merge_request.source_project.repository.commit(merge_request.source_branch).sha + expect(merge_request.reload.rebase_commit_sha).to eq(head_sha) + end + + it 'logs correct author and commiter' do + head_commit = merge_request.source_project.repository.commit(merge_request.source_branch) + + expect(head_commit.author_email).to eq('dmitriy.zaporozhets@gmail.com') + expect(head_commit.author_name).to eq('Dmitriy Zaporozhets') + expect(head_commit.committer_email).to eq(user.email) + expect(head_commit.committer_name).to eq(user.name) + end + + context 'git commands' do + it 'sets GL_REPOSITORY env variable when calling git commands' do + expect(repository).to receive(:popen).exactly(3) + .with(anything, anything, hash_including('GL_REPOSITORY')) + .and_return(['', 0]) + + service.execute(merge_request) + end + end + + context 'fork' do + let(:forked_project) do + fork_project(project, user, repository: true) + end + + let(:merge_request_from_fork) do + forked_project.repository.create_file( + user, + 'new-file-to-target', + '', + message: 'Add new file to target', + branch_name: 'master') + + create(:merge_request, + source_branch: 'master', source_project: forked_project, + target_branch: 'master', target_project: project) + end + + it 'rebases source branch' do + parent_sha = forked_project.repository.commit(merge_request_from_fork.source_branch).parents.first.sha + target_branch_sha = project.repository.commit(merge_request_from_fork.target_branch).sha + expect(parent_sha).to eq(target_branch_sha) + end + end + end + end +end diff --git a/spec/views/projects/merge_requests/show.html.haml_spec.rb b/spec/views/projects/merge_requests/show.html.haml_spec.rb index 28d54c2fb77..264e0ce0b40 100644 --- a/spec/views/projects/merge_requests/show.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/show.html.haml_spec.rb @@ -54,6 +54,8 @@ describe 'projects/merge_requests/show.html.haml' do it 'closes the merge request if the source project does not exist' do closed_merge_request.update_attributes(state: 'open') forked_project.destroy + # Reload merge request so MergeRequest#source_project turns to `nil` + closed_merge_request.reload render diff --git a/spec/workers/rebase_worker_spec.rb b/spec/workers/rebase_worker_spec.rb new file mode 100644 index 00000000000..20aff020dbb --- /dev/null +++ b/spec/workers/rebase_worker_spec.rb @@ -0,0 +1,27 @@ +require 'spec_helper' + +describe RebaseWorker, '#perform' do + context 'when rebasing an MR from a fork where upstream has protected branches' do + let(:upstream_project) { create(:project, :repository) } + let(:fork_project) { create(:project, :repository) } + + let(:merge_request) do + create(:merge_request, + source_project: fork_project, + source_branch: 'feature_conflict', + target_project: upstream_project, + target_branch: 'master') + end + + before do + create(:forked_project_link, forked_to_project: fork_project, forked_from_project: upstream_project) + end + + it 'sets the correct project for running hooks' do + expect(MergeRequests::RebaseService) + .to receive(:new).with(fork_project, merge_request.author).and_call_original + + subject.perform(merge_request, merge_request.author) + end + end +end |