diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
index 1c64cd1dab3c1ca30bbc6c3a5840e9ddc17f74e6..1a35e80061a09e7b42f68b62ed2ceb78c653c7b3 100644
--- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue
@@ -11,6 +11,7 @@ import {
GlSkeletonLoader,
} from '@gitlab/ui';
import { isEmpty, isNil } from 'lodash';
+import axios from '~/lib/utils/axios_utils';
import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge';
import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql';
import { createAlert } from '~/alert';
@@ -19,7 +20,8 @@ import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants';
import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import { secondsToMilliseconds } from '~/lib/utils/datetime_utility';
import simplePoll from '~/lib/utils/simple_poll';
-import { __, s__, n__ } from '~/locale';
+import { joinPaths } from '~/lib/utils/url_utility';
+import { __, s__, n__, sprintf } from '~/locale';
import SmartInterval from '~/smart_interval';
import { helpPagePath } from '~/helpers/help_page_helper';
import { convertToGraphQLId } from '~/graphql_shared/utils';
@@ -44,6 +46,7 @@ import CommitEdit from './commit_edit.vue';
import CommitMessageDropdown from './commit_message_dropdown.vue';
import SquashBeforeMerge from './squash_before_merge.vue';
import MergeFailedPipelineConfirmationDialog from './merge_failed_pipeline_confirmation_dialog.vue';
+import RebaseConfirmationDialog from './rebase_confirmation_dialog.vue';
const PIPELINE_PENDING_STATE = 'pending';
const PIPELINE_SUCCESS_STATE = 'success';
@@ -152,6 +155,7 @@ export default {
GlFormCheckbox,
GlSkeletonLoader,
MergeFailedPipelineConfirmationDialog,
+ RebaseConfirmationDialog,
MergeImmediatelyConfirmationDialog: () =>
import(
'ee_component/vue_merge_request_widget/components/merge_immediately_confirmation_dialog.vue'
@@ -198,6 +202,8 @@ export default {
skipMergeTrain: false,
mergeTrainsSkipAllowed: this.mr.mergeTrainsSkipAllowed,
editCommitMessage: false,
+ isRebaseInProgress: false,
+ isRebaseModalVisible: false,
};
},
computed: {
@@ -355,6 +361,9 @@ export default {
displaySkipMergeTrainOptions() {
return this.shouldDisplayMergeImmediatelyDropdownOptions && this.isSkipMergeTrainAvailable;
},
+ canRebase() {
+ return this.sourceHasDivergedFromTarget && this.shouldShowMergeControls;
+ },
},
watch: {
'mr.state': function mrStateWatcher() {
@@ -539,6 +548,42 @@ export default {
this.removeSourceBranch = checked;
this.mr.setRemoveSourceBranch(checked);
},
+ handleRebaseClick() {
+ this.isRebaseModalVisible = true;
+ },
+ async rebaseConfirmed() {
+ if (this.isRebaseDisabled) return;
+
+ try {
+ this.isRebaseInProgress = true;
+
+ const rebasePath = joinPaths(
+ gon.relative_url_root || '/',
+ this.mr.targetProjectFullPath,
+ '-',
+ 'merge_requests',
+ `${this.mr.iid}`,
+ 'rebase',
+ );
+ await axios.post(rebasePath);
+
+ createAlert({
+ message: sprintf(this.$options.i18n.scheduledRebase, {
+ branch: this.mr.sourceBranch,
+ }),
+ variant: 'success',
+ });
+
+ this.updateGraphqlState();
+ } catch (error) {
+ createAlert({
+ message: error.response?.data?.message || __('Failed to rebase. Please try again.'),
+ variant: 'error',
+ });
+ } finally {
+ this.isRebaseInProgress = false;
+ }
+ },
},
i18n: {
mergeCommitTemplateHintText: s__(
@@ -552,6 +597,7 @@ export default {
),
sourceDivergedFromTargetText: s__('mrWidget|The source branch is %{link} the target branch.'),
divergedCommits: (count) => n__('%d commit behind', '%d commits behind', count),
+ scheduledRebase: s__('mrWidget|Scheduled a rebase of branch %{branch}.'),
},
MT_SKIP_TRAIN,
MT_RESTART_TRAIN,
@@ -666,35 +712,50 @@ export default {
-
+
-
-
- {{
- $options.i18n.divergedCommits(mr.divergedCommitsCount)
- }}
-
-
- ·
+ -
+
+
+ {{
+ $options.i18n.divergedCommits(mr.divergedCommitsCount)
+ }}
+
+
+
+ {{ __('Rebase source branch') }}
+
+
-
-
- ·
-
+
+
+
+ -
+
+
-
+
+
diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/rebase_confirmation_dialog.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/rebase_confirmation_dialog.vue
new file mode 100644
index 0000000000000000000000000000000000000000..1fb5eebf760b519abcd31293b1e76ca505f0d1ca
--- /dev/null
+++ b/app/assets/javascripts/vue_merge_request_widget/components/states/rebase_confirmation_dialog.vue
@@ -0,0 +1,63 @@
+
+
+
+
+ {{ $options.i18n.info }}
+
+ {{
+ $options.i18n.cancel
+ }}
+
+ {{ $options.i18n.primary }}
+
+
+
+
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 575a67bfdd66359dad738d2a7951773d8f2198fc..f2d96682e02a4af3ac07d2dd93faecd5e7d30e27 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -24221,6 +24221,9 @@ msgstr ""
msgid "Failed to publish issue on status page."
msgstr ""
+msgid "Failed to rebase. Please try again."
+msgstr ""
+
msgid "Failed to regenerate unique domain"
msgstr ""
@@ -46981,6 +46984,9 @@ msgstr ""
msgid "Reauthenticating with SAML provider."
msgstr ""
+msgid "Rebase"
+msgstr ""
+
msgid "Rebase completed"
msgstr ""
@@ -46990,6 +46996,9 @@ msgstr ""
msgid "Rebase source branch on the target branch."
msgstr ""
+msgid "Rebase source branch?"
+msgstr ""
+
msgid "Recaptcha verified"
msgstr ""
@@ -58908,6 +58917,9 @@ msgstr ""
msgid "This vulnerability was automatically resolved because its vulnerability type was disabled in this project or removed from GitLab's default ruleset. For details about SAST rule changes, see https://docs.gitlab.com/ee/user/application_security/sast/rules#important-rule-changes."
msgstr ""
+msgid "This will rebase all commits from the source branch onto the target branch."
+msgstr ""
+
msgid "This will remove the fork relationship between this project and %{fork_source}."
msgstr ""
@@ -68769,6 +68781,9 @@ msgstr ""
msgid "mrWidget|Revoke approval"
msgstr ""
+msgid "mrWidget|Scheduled a rebase of branch %{branch}."
+msgstr ""
+
msgid "mrWidget|Set by %{merge_author} to be added to the merge train when all merge checks pass"
msgstr ""
diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
index 780352cfe84225e7a0d9f6d6716514fb4584d7f6..02b2e9ae3d000b6e9b4c11ce9c3e2e2b467af6b4 100644
--- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
+++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js
@@ -4,6 +4,7 @@ import VueApollo from 'vue-apollo';
import produce from 'immer';
import { createMockSubscription as createMockApolloSubscription } from 'mock-apollo-client';
import readyToMergeResponse from 'test_fixtures/graphql/merge_requests/states/ready_to_merge.query.graphql.json';
+import axios from '~/lib/utils/axios_utils';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createMockApollo from 'helpers/mock_apollo_helper';
@@ -17,6 +18,7 @@ import MergeFailedPipelineConfirmationDialog from '~/vue_merge_request_widget/co
import { MWPS_MERGE_STRATEGY, MWCP_MERGE_STRATEGY } from '~/vue_merge_request_widget/constants';
import eventHub from '~/vue_merge_request_widget/event_hub';
import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql';
+import { joinPaths } from '~/lib/utils/url_utility';
jest.mock('~/lib/utils/simple_poll', () =>
jest.fn().mockImplementation(jest.requireActual('~/lib/utils/simple_poll').default),
@@ -1004,6 +1006,74 @@ describe('ReadyToMerge', () => {
});
});
+ describe('rebase button', () => {
+ describe('when rebasing', () => {
+ let axiosSpy;
+ const rebaseModalHide = jest.fn();
+
+ const MockGlModal = {
+ template: `
+
+
+
+
+ `,
+ methods: {
+ hide: rebaseModalHide,
+ },
+ };
+
+ beforeEach(() => {
+ axiosSpy = jest.spyOn(axios, 'post').mockResolvedValue({});
+ createComponent(
+ {
+ mr: {
+ divergedCommitsCount: 2,
+ targetProjectFullPath: 'namespace/project',
+ iid: 123,
+ sourceBranch: 'feature-branch',
+ state: 'readyToMerge',
+ userPermissions: { canMerge: true },
+ availableAutoMergeStrategies: [],
+ },
+ },
+ true,
+ shallowMountExtended,
+ {},
+ {
+ stubs: {
+ GlModal: MockGlModal,
+ RebaseConfirmationDialog: true,
+ },
+ },
+ );
+ });
+
+ afterEach(() => {
+ rebaseModalHide.mockReset();
+ });
+
+ it('shows confirmation dialog when clicking rebase', async () => {
+ await wrapper.findByTestId('rebase-button').trigger('click');
+ await nextTick();
+
+ expect(wrapper.findComponent({ name: 'RebaseConfirmationDialog' }).exists()).toBe(true);
+ });
+
+ it('calls rebase endpoint when confirmed', async () => {
+ const expectedPath = joinPaths('/', 'namespace/project/-/merge_requests/123/rebase');
+
+ await wrapper.findByTestId('rebase-button').trigger('click');
+ await nextTick();
+
+ wrapper.findComponent({ name: 'RebaseConfirmationDialog' }).vm.$emit('rebase-confirmed');
+ await waitForPromises();
+
+ expect(axiosSpy).toHaveBeenCalledWith(expectedPath);
+ });
+ });
+ });
+
describe('only allow merge if pipeline succeeds', () => {
beforeEach(() => {
const response = JSON.parse(JSON.stringify(readyToMergeResponse));
diff --git a/spec/frontend/vue_merge_request_widget/components/states/rebase_confirmation_dialog_spec.js b/spec/frontend/vue_merge_request_widget/components/states/rebase_confirmation_dialog_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..ba22ae62ba7dff34809c9367af1c0f4560d9bc43
--- /dev/null
+++ b/spec/frontend/vue_merge_request_widget/components/states/rebase_confirmation_dialog_spec.js
@@ -0,0 +1,78 @@
+import { shallowMount } from '@vue/test-utils';
+import RebaseConfirmationDialog from '~/vue_merge_request_widget/components/states/rebase_confirmation_dialog.vue';
+import { trimText } from 'helpers/text_helper';
+
+describe('RebaseConfirmationDialog', () => {
+ const mockModalHide = jest.fn();
+ let wrapper;
+
+ const GlModal = {
+ template: `
+
+
+
+
+ `,
+ methods: {
+ hide: mockModalHide,
+ },
+ };
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(RebaseConfirmationDialog, {
+ propsData: {
+ visible: true,
+ ...props,
+ },
+ stubs: {
+ GlModal,
+ },
+ attachTo: document.body,
+ });
+ };
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findRebaseBtn = () => wrapper.find('[data-testid="confirm-rebase"]');
+ const findCancelBtn = () => wrapper.find('[data-testid="rebase-cancel-btn"]');
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ mockModalHide.mockReset();
+ });
+
+ it('renders rebase confirmation text', () => {
+ expect(trimText(wrapper.text())).toContain(
+ 'This will rebase all commits from the source branch onto the target branch.',
+ );
+ });
+
+ it('emits rebaseConfirmed when rebase button is clicked', () => {
+ findRebaseBtn().vm.$emit('click');
+
+ expect(wrapper.emitted('rebase-confirmed')).toHaveLength(1);
+ });
+
+ it('when the cancel button is clicked should emit cancel and call hide', () => {
+ findCancelBtn().vm.$emit('click');
+
+ expect(wrapper.emitted('cancel')).toHaveLength(1);
+ expect(mockModalHide).toHaveBeenCalled();
+ });
+
+ it('should emit cancel when the hide event is emitted', () => {
+ findModal().vm.$emit('hide');
+
+ expect(wrapper.emitted('cancel')).toHaveLength(1);
+ });
+
+ it('when modal is shown it will focus the cancel button', () => {
+ jest.spyOn(findCancelBtn().element, 'focus');
+
+ findModal().vm.$emit('shown');
+
+ expect(findCancelBtn().element.focus).toHaveBeenCalled();
+ });
+});