diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js new file mode 100644 index 00000000000..79f8ef408e6 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/mr_widget_sha_mismatch.js @@ -0,0 +1,16 @@ +export default { + name: 'MRWidgetSHAMismatch', + template: ` +
+ + + The source branch HEAD has recently changed. Please reload the page and review the changes before merging. + +
+ `, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/dependencies.js b/app/assets/javascripts/vue_merge_request_widget/dependencies.js index b2eb32ead5f..bfe30ee4c08 100644 --- a/app/assets/javascripts/vue_merge_request_widget/dependencies.js +++ b/app/assets/javascripts/vue_merge_request_widget/dependencies.js @@ -27,6 +27,7 @@ export { default as NothingToMergeState } from './components/states/mr_widget_no export { default as MissingBranchState } from './components/states/mr_widget_missing_branch'; export { default as NotAllowedState } from './components/states/mr_widget_not_allowed'; export { default as ReadyToMergeState } from './components/states/mr_widget_ready_to_merge'; +export { default as SHAMismatchState } from './components/states/mr_widget_sha_mismatch'; export { default as UnresolvedDiscussionsState } from './components/states/mr_widget_unresolved_discussions'; export { default as PipelineBlockedState } from './components/states/mr_widget_pipeline_blocked'; export { default as PipelineFailedState } from './components/states/mr_widget_pipeline_failed'; 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 7c6c2d21714..5452e19bd8e 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 @@ -16,6 +16,7 @@ import { MissingBranchState, NotAllowedState, ReadyToMergeState, + SHAMismatchState, UnresolvedDiscussionsState, PipelineBlockedState, PipelineFailedState, @@ -203,6 +204,7 @@ export default { 'mr-widget-not-allowed': NotAllowedState, 'mr-widget-missing-branch': MissingBranchState, 'mr-widget-ready-to-merge': ReadyToMergeState, + 'mr-widget-sha-mismatch': SHAMismatchState, 'mr-widget-squash-before-merge': SquashBeforeMerge, 'mr-widget-checking': CheckingState, 'mr-widget-unresolved-discussions': UnresolvedDiscussionsState, 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 fee4113f3c8..fb78ea92da1 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 @@ -21,6 +21,8 @@ export default function deviseState(data) { return 'unresolvedDiscussions'; } else if (this.isPipelineBlocked) { return 'pipelineBlocked'; + } else if (this.hasSHAChanged) { + return 'shaMismatch'; } else if (this.canBeMerged) { return '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 faafeae5c5b..05e67706983 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 @@ -4,6 +4,7 @@ import { getStateKey } from '../dependencies'; export default class MergeRequestStore { constructor(data) { + this.startingSha = data.diff_head_sha; this.setData(data); } @@ -67,6 +68,7 @@ export default class MergeRequestStore { this.canMerge = !!data.merge_path; this.canCreateIssue = currentUser.can_create_issue || false; this.canCancelAutomaticMerge = !!data.cancel_merge_when_pipeline_succeeds_path; + this.hasSHAChanged = this.sha !== this.startingSha; this.canBeMerged = data.can_be_merged || false; // Cherry-pick and Revert actions related 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 625d7a01c65..605dd3a1ff4 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 @@ -16,6 +16,7 @@ const stateToComponentMap = { mergeWhenPipelineSucceeds: 'mr-widget-merge-when-pipeline-succeeds', failedToMerge: 'mr-widget-failed-to-merge', autoMergeFailed: 'mr-widget-auto-merge-failed', + shaMismatch: 'mr-widget-sha-mismatch', }; const statesToShowHelpWidget = [ diff --git a/changelogs/unreleased/32178-prevent-merge-on-sha-change.yml b/changelogs/unreleased/32178-prevent-merge-on-sha-change.yml new file mode 100644 index 00000000000..d3208973de6 --- /dev/null +++ b/changelogs/unreleased/32178-prevent-merge-on-sha-change.yml @@ -0,0 +1,4 @@ +--- +title: Add state to MR widget that prevent merges when branch changes after page load +merge_request: 11316 +author: diff --git a/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js new file mode 100644 index 00000000000..5fb1d69a8b3 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/components/states/mr_widget_sha_mismatch_spec.js @@ -0,0 +1,16 @@ +import Vue from 'vue'; +import shaMismatchComponent from '~/vue_merge_request_widget/components/states/mr_widget_sha_mismatch'; + +describe('MRWidgetSHAMismatch', () => { + describe('template', () => { + const Component = Vue.extend(shaMismatchComponent); + const vm = new Component({ + el: document.createElement('div'), + }); + it('should have correct elements', () => { + expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy(); + expect(vm.$el.querySelector('button').getAttribute('disabled')).toBeTruthy(); + expect(vm.$el.innerText).toContain('The source branch HEAD has recently changed. Please reload the page and review the changes before merging.'); + }); + }); +}); diff --git a/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js index ee944f4d4e5..9a331d99865 100644 --- a/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js +++ b/spec/javascripts/vue_mr_widget/stores/get_state_key_spec.js @@ -25,6 +25,9 @@ describe('getStateKey', () => { context.canBeMerged = true; expect(bound()).toEqual('readyToMerge'); + context.hasSHAChanged = true; + expect(bound()).toEqual('shaMismatch'); + context.isPipelineBlocked = true; expect(bound()).toEqual('pipelineBlocked'); diff --git a/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js new file mode 100644 index 00000000000..56dd0198ae2 --- /dev/null +++ b/spec/javascripts/vue_mr_widget/stores/mr_widget_store_spec.js @@ -0,0 +1,22 @@ +import MergeRequestStore from '~/vue_merge_request_widget/stores/mr_widget_store'; +import mockData from '../mock_data'; + +describe('MergeRequestStore', () => { + describe('setData', () => { + let store; + + beforeEach(() => { + store = new MergeRequestStore(mockData); + }); + + it('should set hasSHAChanged when the diff SHA changes', () => { + store.setData({ ...mockData, diff_head_sha: 'a-different-string' }); + expect(store.hasSHAChanged).toBe(true); + }); + + it('should not set hasSHAChanged when other data changes', () => { + store.setData({ ...mockData, work_in_progress: !mockData.work_in_progress }); + expect(store.hasSHAChanged).toBe(false); + }); + }); +});