Commit 35ae9d8a authored by Nathan Friend's avatar Nathan Friend

Add merge train support to MR merge button (CE)

This commit updates the merge request widget's "Merge" button to
support merge trains.
parent fc27c93e
<script>
import _ from 'underscore';
import autoMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/auto_merge';
import Flash from '../../../flash';
import statusIcon from '../mr_widget_status_icon.vue';
import MrWidgetAuthor from '../../components/mr_widget_author.vue';
import eventHub from '../../event_hub';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
export default {
name: 'MRWidgetMergeWhenPipelineSucceeds',
name: 'MRWidgetAutoMergeEnabled',
components: {
MrWidgetAuthor,
statusIcon,
},
mixins: [autoMergeMixin],
props: {
mr: {
type: Object,
......@@ -57,7 +61,7 @@ export default {
removeSourceBranch() {
const options = {
sha: this.mr.sha,
auto_merge_strategy: 'merge_when_pipeline_succeeds',
auto_merge_strategy: this.mr.autoMergeStrategy,
should_remove_source_branch: true,
};
......@@ -66,7 +70,7 @@ export default {
.merge(options)
.then(res => res.data)
.then(data => {
if (data.status === 'merge_when_pipeline_succeeds') {
if (_.includes(AUTO_MERGE_STRATEGIES, data.status)) {
eventHub.$emit('MRWidgetUpdateRequested');
}
})
......@@ -84,9 +88,9 @@ export default {
<div class="media-body">
<h4 class="d-flex align-items-start">
<span class="append-right-10">
{{ s__('mrWidget|Set by') }}
<span class="js-status-text-before-author">{{ statusTextBeforeAuthor }}</span>
<mr-widget-author :author="mr.setToAutoMergeBy" />
{{ s__('mrWidget|to be merged automatically when the pipeline succeeds') }}
<span class="js-status-text-after-author">{{ statusTextAfterAuthor }}</span>
</span>
<a
v-if="mr.canCancelAutomaticMerge"
......@@ -97,7 +101,7 @@ export default {
@click.prevent="cancelAutomaticMerge"
>
<i v-if="isCancellingAutoMerge" class="fa fa-spinner fa-spin" aria-hidden="true"> </i>
{{ s__('mrWidget|Cancel automatic merge') }}
{{ cancelButtonText }}
</a>
</h4>
<section class="mr-info-list">
......
<script>
import _ from 'underscore';
import successSvg from 'icons/_icon_status_success.svg';
import warningSvg from 'icons/_icon_status_warning.svg';
import simplePoll from '~/lib/utils/simple_poll';
......@@ -12,6 +13,7 @@ import SquashBeforeMerge from './squash_before_merge.vue';
import CommitsHeader from './commits_header.vue';
import CommitEdit from './commit_edit.vue';
import CommitMessageDropdown from './commit_message_dropdown.vue';
import { AUTO_MERGE_STRATEGIES } from '../../constants';
export default {
name: 'ReadyToMerge',
......@@ -30,8 +32,6 @@ export default {
data() {
return {
removeSourceBranch: this.mr.shouldRemoveSourceBranch,
mergeWhenBuildSucceeds: false,
autoMergeStrategy: undefined,
isMakingRequest: false,
isMergingImmediately: false,
commitMessage: this.mr.commitMessage,
......@@ -42,18 +42,18 @@ export default {
};
},
computed: {
shouldShowAutoMergeText() {
return this.mr.isPipelineActive;
isAutoMergeAvailable() {
return !_.isEmpty(this.mr.availableAutoMergeStrategies);
},
status() {
const { pipeline, isPipelineActive, isPipelineFailed, hasCI, ciStatus } = this.mr;
const { pipeline, isPipelineFailed, hasCI, ciStatus } = this.mr;
if (hasCI && !ciStatus) {
return 'failed';
} else if (this.isAutoMergeAvailable) {
return 'pending';
} else if (!pipeline) {
return 'success';
} else if (isPipelineActive) {
return 'pending';
} else if (isPipelineFailed) {
return 'failed';
}
......@@ -87,14 +87,14 @@ export default {
mergeButtonText() {
if (this.isMergingImmediately) {
return __('Merge in progress');
} else if (this.shouldShowAutoMergeText) {
return __('Merge when pipeline succeeds');
} else if (this.isAutoMergeAvailable) {
return this.autoMergeText;
}
return 'Merge';
return __('Merge');
},
shouldShowMergeOptionsDropdown() {
return this.mr.isPipelineActive && !this.mr.onlyAllowMergeIfPipelineSucceeds;
return this.isAutoMergeAvailable && !this.mr.onlyAllowMergeIfPipelineSucceeds;
},
isRemoveSourceBranchButtonDisabled() {
return this.isMergeButtonDisabled;
......@@ -104,7 +104,7 @@ export default {
return enableSquashBeforeMerge && commitsCount > 1;
},
shouldShowMergeControls() {
return this.mr.isMergeAllowed || this.shouldShowAutoMergeText;
return this.mr.isMergeAllowed || this.isAutoMergeAvailable;
},
shouldShowSquashEdit() {
return this.squashBeforeMerge && this.shouldShowSquashBeforeMerge;
......@@ -118,20 +118,15 @@ export default {
const { commitMessageWithDescription, commitMessage } = this.mr;
this.commitMessage = includeDescription ? commitMessageWithDescription : commitMessage;
},
handleMergeButtonClick(mergeWhenBuildSucceeds, mergeImmediately) {
// TODO: Remove no-param-reassign
if (mergeWhenBuildSucceeds === undefined) {
mergeWhenBuildSucceeds = this.mr.isPipelineActive; // eslint-disable-line no-param-reassign
} else if (mergeImmediately) {
handleMergeButtonClick(useAutoMerge, mergeImmediately = false) {
if (mergeImmediately) {
this.isMergingImmediately = true;
}
this.autoMergeStrategy = mergeWhenBuildSucceeds ? 'merge_when_pipeline_succeeds' : undefined;
const options = {
sha: this.mr.sha,
commit_message: this.commitMessage,
auto_merge_strategy: this.autoMergeStrategy,
auto_merge_strategy: useAutoMerge ? this.mr.preferredAutoMergeStrategy : undefined,
should_remove_source_branch: this.removeSourceBranch === true,
squash: this.squashBeforeMerge,
squash_commit_message: this.squashCommitMessage,
......@@ -144,7 +139,7 @@ export default {
.then(data => {
const hasError = data.status === 'failed' || data.status === 'hook_validation_error';
if (data.status === 'merge_when_pipeline_succeeds') {
if (_.includes(AUTO_MERGE_STRATEGIES, data.status)) {
eventHub.$emit('MRWidgetUpdateRequested');
} else if (data.status === 'success') {
this.initiateMergePolling();
......@@ -242,13 +237,13 @@ export default {
:class="mergeButtonClass"
type="button"
class="qa-merge-button"
@click="handleMergeButtonClick()"
@click="handleMergeButtonClick(isAutoMergeAvailable)"
>
<i v-if="isMakingRequest" class="fa fa-spinner fa-spin" aria-hidden="true"></i>
{{ mergeButtonText }}
</button>
<button
v-if="shouldShowMergeOptionsDropdown"
v-if="isAutoMergeAvailable"
:disabled="isMergeButtonDisabled"
type="button"
class="btn btn-sm btn-info dropdown-toggle js-merge-moment"
......@@ -264,15 +259,13 @@ export default {
>
<li>
<a
class="merge_when_pipeline_succeeds qa-merge-when-pipeline-succeeds-option"
class="auto_merge_enabled qa-merge-when-pipeline-succeeds-option"
href="#"
@click.prevent="handleMergeButtonClick(true)"
>
<span class="media">
<span class="merge-opt-icon" aria-hidden="true" v-html="successSvg"></span>
<span class="media-body merge-opt-title">{{
__('Merge when pipeline succeeds')
}}</span>
<span class="media-body merge-opt-title">{{ autoMergeText }}</span>
</span>
</a>
</li>
......
......@@ -3,3 +3,13 @@ export const DANGER = 'danger';
export const WARNING_MESSAGE_CLASS = 'warning_message';
export const DANGER_MESSAGE_CLASS = 'danger_message';
export const MWPS_MERGE_STRATEGY = 'merge_when_pipeline_succeeds';
export const ATMTWPS_MERGE_STRATEGY = 'add_to_merge_train_when_pipeline_succeeds';
export const MT_MERGE_STRATEGY = 'merge_train';
export const AUTO_MERGE_STRATEGIES = [
MWPS_MERGE_STRATEGY,
ATMTWPS_MERGE_STRATEGY,
MT_MERGE_STRATEGY,
];
import { s__ } from '~/locale';
export default {
computed: {
statusTextBeforeAuthor() {
return s__('mrWidget|Set by');
},
statusTextAfterAuthor() {
return s__('mrWidget|to be merged automatically when the pipeline succeeds');
},
cancelButtonText() {
return s__('mrWidget|Cancel automatic merge');
},
},
};
import { __ } from '~/locale';
export default {
computed: {
isMergeButtonDisabled() {
......@@ -9,5 +11,9 @@ export default {
this.mr.preventMerge,
);
},
autoMergeText() {
// MWPS is currently the only auto merge strategy available in CE
return __('Merge when pipeline succeeds');
},
},
};
......@@ -29,7 +29,7 @@ import UnresolvedDiscussionsState from './components/states/unresolved_discussio
import PipelineBlockedState from './components/states/mr_widget_pipeline_blocked.vue';
import PipelineFailedState from './components/states/pipeline_failed.vue';
import FailedToMerge from './components/states/mr_widget_failed_to_merge.vue';
import MergeWhenPipelineSucceedsState from './components/states/mr_widget_merge_when_pipeline_succeeds.vue';
import MrWidgetAutoMergeEnabled from './components/states/mr_widget_auto_merge_enabled.vue';
import AutoMergeFailed from './components/states/mr_widget_auto_merge_failed.vue';
import CheckingState from './components/states/mr_widget_checking.vue';
import eventHub from './event_hub';
......@@ -64,7 +64,7 @@ export default {
'mr-widget-unresolved-discussions': UnresolvedDiscussionsState,
'mr-widget-pipeline-blocked': PipelineBlockedState,
'mr-widget-pipeline-failed': PipelineFailedState,
'mr-widget-merge-when-pipeline-succeeds': MergeWhenPipelineSucceedsState,
MrWidgetAutoMergeEnabled,
'mr-widget-auto-merge-failed': AutoMergeFailed,
'mr-widget-rebase': RebaseState,
SourceBranchRemovalStatus,
......
import Timeago from 'timeago.js';
import _ from 'underscore';
import getStateKey from 'ee_else_ce/vue_merge_request_widget/stores/get_state_key';
import { stateKey } from './state_maps';
import { formatDate } from '../../lib/utils/datetime_utility';
import { ATMTWPS_MERGE_STRATEGY, MT_MERGE_STRATEGY, MWPS_MERGE_STRATEGY } from '../constants';
export default class MergeRequestStore {
constructor(data) {
......@@ -77,6 +79,10 @@ export default class MergeRequestStore {
this.onlyAllowMergeIfPipelineSucceeds = data.only_allow_merge_if_pipeline_succeeds || false;
this.autoMergeEnabled = Boolean(data.auto_merge_enabled);
this.autoMergeStrategy = data.auto_merge_strategy;
this.availableAutoMergeStrategies = data.available_auto_merge_strategies;
this.preferredAutoMergeStrategy = MergeRequestStore.getPreferredAutoMergeStrategy(
this.availableAutoMergeStrategies,
);
this.mergePath = data.merge_path;
this.ffOnlyEnabled = data.ff_only_enabled;
this.shouldBeRebased = Boolean(data.should_be_rebased);
......@@ -104,7 +110,9 @@ export default class MergeRequestStore {
this.sourceProjectFullPath = data.source_project_full_path;
this.sourceProjectId = data.source_project_id;
this.targetProjectId = data.target_project_id;
this.mergePipelinesEnabled = data.merge_pipelines_enabled;
this.mergePipelinesEnabled = Boolean(data.merge_pipelines_enabled);
this.mergeTrainsCount = data.merge_trains_count || 0;
this.mergeTrainIndex = data.merge_train_index;
// Cherry-pick and Revert actions related
this.canCherryPickInCurrentMR = currentUser.can_cherry_pick_on_current_merge_request || false;
......@@ -204,4 +212,16 @@ export default class MergeRequestStore {
return timeagoInstance.format(date);
}
static getPreferredAutoMergeStrategy(availableAutoMergeStrategies) {
if (_.includes(availableAutoMergeStrategies, ATMTWPS_MERGE_STRATEGY)) {
return ATMTWPS_MERGE_STRATEGY;
} else if (_.includes(availableAutoMergeStrategies, MT_MERGE_STRATEGY)) {
return MT_MERGE_STRATEGY;
} else if (_.includes(availableAutoMergeStrategies, MWPS_MERGE_STRATEGY)) {
return MWPS_MERGE_STRATEGY;
}
return undefined;
}
}
......@@ -13,7 +13,7 @@ const stateToComponentMap = {
unresolvedDiscussions: 'mr-widget-unresolved-discussions',
pipelineBlocked: 'mr-widget-pipeline-blocked',
pipelineFailed: 'mr-widget-pipeline-failed',
autoMergeEnabled: 'mr-widget-merge-when-pipeline-succeeds',
autoMergeEnabled: 'mr-widget-auto-merge-enabled',
failedToMerge: 'mr-widget-failed-to-merge',
autoMergeFailed: 'mr-widget-auto-merge-failed',
shaMismatch: 'sha-mismatch',
......
---
title: Update the merge request widget's "Merge" button to support merge trains
merge_request: 27594
author:
type: added
......@@ -6045,6 +6045,9 @@ msgstr ""
msgid "Members of <strong>%{project_name}</strong>"
msgstr ""
msgid "Merge"
msgstr ""
msgid "Merge Request"
msgstr ""
......
import Vue from 'vue';
import mwpsComponent from '~/vue_merge_request_widget/components/states/mr_widget_merge_when_pipeline_succeeds.vue';
import autoMergeEnabledComponent from '~/vue_merge_request_widget/components/states/mr_widget_auto_merge_enabled.vue';
import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
import eventHub from '~/vue_merge_request_widget/event_hub';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { trimText } from 'spec/helpers/text_helper';
import { MWPS_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
describe('MRWidgetMergeWhenPipelineSucceeds', () => {
describe('MRWidgetAutoMergeEnabled', () => {
let vm;
const targetBranchPath = '/foo/bar';
const targetBranch = 'foo';
const sha = '1EA2EZ34';
beforeEach(() => {
const Component = Vue.extend(mwpsComponent);
const Component = Vue.extend(autoMergeEnabledComponent);
spyOn(eventHub, '$emit');
vm = mountComponent(Component, {
......@@ -25,6 +27,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
sha,
targetBranchPath,
targetBranch,
autoMergeStrategy: MWPS_MERGE_STRATEGY,
},
service: new MRWidgetService({}),
});
......@@ -66,6 +69,32 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
expect(vm.canRemoveSourceBranch).toBeFalsy();
});
});
describe('statusTextBeforeAuthor', () => {
it('should return "Set by" if the MWPS is selected', () => {
Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY);
expect(vm.statusTextBeforeAuthor).toBe('Set by');
});
});
describe('statusTextAfterAuthor', () => {
it('should return "to be merged automatically..." if MWPS is selected', () => {
Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY);
expect(vm.statusTextAfterAuthor).toBe(
'to be merged automatically when the pipeline succeeds',
);
});
});
describe('cancelButtonText', () => {
it('should return "Cancel automatic merge" if MWPS is selected', () => {
Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY);
expect(vm.cancelButtonText).toBe('Cancel automatic merge');
});
});
});
describe('methods', () => {
......@@ -96,7 +125,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
spyOn(vm.service, 'merge').and.returnValue(
Promise.resolve({
data: {
status: 'merge_when_pipeline_succeeds',
status: MWPS_MERGE_STRATEGY,
},
}),
);
......@@ -106,7 +135,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('MRWidgetUpdateRequested');
expect(vm.service.merge).toHaveBeenCalledWith({
sha,
auto_merge_strategy: 'merge_when_pipeline_succeeds',
auto_merge_strategy: MWPS_MERGE_STRATEGY,
should_remove_source_branch: true,
});
done();
......@@ -119,6 +148,7 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
it('should have correct elements', () => {
expect(vm.$el.classList.contains('mr-widget-body')).toBeTruthy();
expect(vm.$el.innerText).toContain('to be merged automatically when the pipeline succeeds');
expect(vm.$el.innerText).toContain('The changes will be merged into');
expect(vm.$el.innerText).toContain(targetBranch);
expect(vm.$el.innerText).toContain('The source branch will not be deleted');
......@@ -174,5 +204,27 @@ describe('MRWidgetMergeWhenPipelineSucceeds', () => {
done();
});
});
it('should render the status text as "...to merged automatically" if MWPS is selected', done => {
Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY);
Vue.nextTick(() => {
const statusText = trimText(vm.$el.querySelector('.js-status-text-after-author').innerText);
expect(statusText).toBe('to be merged automatically when the pipeline succeeds');
done();
});
});
it('should render the cancel button as "Cancel automatic merge" if MWPS is selected', done => {
Vue.set(vm.mr, 'autoMergeStrategy', MWPS_MERGE_STRATEGY);
Vue.nextTick(() => {
const cancelButtonText = trimText(vm.$el.querySelector('.js-cancel-auto-merge').innerText);
expect(cancelButtonText).toBe('Cancel automatic merge');
done();
});
});
});
});
......@@ -25,7 +25,6 @@ export default {
},
merge_status: 'can_be_merged',
merge_user_id: null,
merge_when_pipeline_succeeds: false,
source_branch: 'daaaa',
source_branch_link: 'daaaa',
source_project_id: 19,
......@@ -210,8 +209,7 @@ export default {
source_branch_path: '/root/acets-app/branches/daaaa',
conflict_resolution_ui_path: '/root/acets-app/merge_requests/22/conflicts',
remove_wip_path: '/root/acets-app/merge_requests/22/remove_wip',
cancel_merge_when_pipeline_succeeds_path:
'/root/acets-app/merge_requests/22/cancel_merge_when_pipeline_succeeds',
cancel_auto_merge_path: '/root/acets-app/merge_requests/22/cancel_auto_merge',
create_issue_to_resolve_discussions_path:
'/root/acets-app/issues/new?merge_request_to_resolve_discussions_of=22',
merge_path: '/root/acets-app/merge_requests/22/merge',
......@@ -237,6 +235,9 @@ export default {
merge_request_pipelines_docs_path: '/help/ci/merge_request_pipelines/index.md',
squash: true,
visual_review_app_available: true,
merge_trains_enabled: true,
merge_trains_count: 3,
merge_train_index: 1,
};
export const mockStore = {
......
......@@ -82,5 +82,47 @@ describe('MergeRequestStore', () => {
expect(store.isNothingToMergeState).toEqual(false);
});
});
describe('mergePipelinesEnabled', () => {
it('should set mergePipelinesEnabled = true when merge_pipelines_enabled is true', () => {
store.setData({ ...mockData, merge_pipelines_enabled: true });
expect(store.mergePipelinesEnabled).toBe(true);
});
it('should set mergePipelinesEnabled = false when merge_pipelines_enabled is not provided', () => {
store.setData({ ...mockData, merge_pipelines_enabled: undefined });
expect(store.mergePipelinesEnabled).toBe(false);
});
});
describe('mergeTrainsCount', () => {
it('should set mergeTrainsCount when merge_trains_count is provided', () => {
store.setData({ ...mockData, merge_trains_count: 3 });
expect(store.mergeTrainsCount).toBe(3);
});
it('should set mergeTrainsCount = 0 when merge_trains_count is not provided', () => {
store.setData({ ...mockData, merge_trains_count: undefined });
expect(store.mergeTrainsCount).toBe(0);
});
});
describe('mergeTrainIndex', () => {
it('should set mergeTrainIndex when merge_train_index is provided', () => {
store.setData({ ...mockData, merge_train_index: 3 });
expect(store.mergeTrainIndex).toBe(3);
});
it('should not set mergeTrainIndex when merge_train_index is not provided', () => {
store.setData({ ...mockData, merge_train_index: undefined });
expect(store.mergeTrainIndex).toBeUndefined();
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment