Commit 749168c2 authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch '118825-Allow-skipping-CI-when-rebasing-in-UI' into 'master'

Allow skipping CI when rebasing in UI

See merge request gitlab-org/gitlab!76056
parents adffe5c2 8d50d4d1
<script> <script>
import { GlButton, GlSkeletonLoader } from '@gitlab/ui'; import { GlSkeletonLoader } from '@gitlab/ui';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { __ } from '~/locale'; import { __ } from '~/locale';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
import simplePoll from '../../../lib/utils/simple_poll'; import simplePoll from '../../../lib/utils/simple_poll';
import eventHub from '../../event_hub'; import eventHub from '../../event_hub';
import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables';
import rebaseQuery from '../../queries/states/rebase.query.graphql'; import rebaseQuery from '../../queries/states/rebase.query.graphql';
import statusIcon from '../mr_widget_status_icon.vue'; import statusIcon from '../mr_widget_status_icon.vue';
import { REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY } from '../../constants';
export default { export default {
name: 'MRWidgetRebase', name: 'MRWidgetRebase',
...@@ -25,8 +27,8 @@ export default { ...@@ -25,8 +27,8 @@ export default {
}, },
components: { components: {
statusIcon, statusIcon,
GlButton,
GlSkeletonLoader, GlSkeletonLoader,
ActionsButton,
}, },
mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin], mixins: [glFeatureFlagMixin(), mergeRequestQueryVariablesMixin],
props: { props: {
...@@ -44,6 +46,7 @@ export default { ...@@ -44,6 +46,7 @@ export default {
state: {}, state: {},
isMakingRequest: false, isMakingRequest: false,
rebasingError: null, rebasingError: null,
selectedRebaseAction: REBASE_BUTTON_KEY,
}; };
}, },
computed: { computed: {
...@@ -86,14 +89,36 @@ export default { ...@@ -86,14 +89,36 @@ export default {
fastForwardMergeText() { fastForwardMergeText() {
return __('Merge blocked: the source branch must be rebased onto the target branch.'); return __('Merge blocked: the source branch must be rebased onto the target branch.');
}, },
actions() {
return [this.rebaseAction, this.rebaseWithoutCiAction].filter((action) => action);
},
rebaseAction() {
return {
key: REBASE_BUTTON_KEY,
text: __('Rebase'),
secondaryText: __('Rebases and triggers a pipeline'),
attrs: {
'data-qa-selector': 'mr_rebase_button',
},
handle: () => this.rebase(),
};
},
rebaseWithoutCiAction() {
return {
key: REBASE_WITHOUT_CI_BUTTON_KEY,
text: __('Rebase without CI'),
secondaryText: __('Performs a rebase but skips triggering a new pipeline'),
handle: () => this.rebase({ skipCi: true }),
};
},
}, },
methods: { methods: {
rebase() { rebase({ skipCi = false } = {}) {
this.isMakingRequest = true; this.isMakingRequest = true;
this.rebasingError = null; this.rebasingError = null;
this.service this.service
.rebase() .rebase({ skipCi })
.then(() => { .then(() => {
simplePoll(this.checkRebaseStatus); simplePoll(this.checkRebaseStatus);
}) })
...@@ -109,6 +134,9 @@ export default { ...@@ -109,6 +134,9 @@ export default {
} }
}); });
}, },
selectRebaseAction(key) {
this.selectedRebaseAction = key;
},
checkRebaseStatus(continuePolling, stopPolling) { checkRebaseStatus(continuePolling, stopPolling) {
this.service this.service
.poll() .poll()
...@@ -168,15 +196,14 @@ export default { ...@@ -168,15 +196,14 @@ export default {
v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest" v-if="!rebaseInProgress && canPushToSourceBranch && !isMakingRequest"
class="accept-merge-holder clearfix js-toggle-container accept-action media space-children" class="accept-merge-holder clearfix js-toggle-container accept-action media space-children"
> >
<gl-button <actions-button
v-if="!glFeatures.restructuredMrWidget" v-if="!glFeatures.restructuredMrWidget"
:loading="isMakingRequest" :actions="actions"
:selected-key="selectedRebaseAction"
variant="confirm" variant="confirm"
data-qa-selector="mr_rebase_button" category="primary"
@click="rebase" @select="selectRebaseAction"
> />
{{ __('Rebase') }}
</gl-button>
<span <span
v-if="!rebasingError" v-if="!rebasingError"
:class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }" :class="{ 'gl-ml-0! gl-text-body!': glFeatures.restructuredMrWidget }"
......
...@@ -162,3 +162,6 @@ export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500'; ...@@ -162,3 +162,6 @@ export const EXTENSION_SUMMARY_FAILED_CLASS = 'gl-text-red-500';
export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700'; export const EXTENSION_SUMMARY_NEUTRAL_CLASS = 'gl-text-gray-700';
export { STATE_MACHINE }; export { STATE_MACHINE };
export const REBASE_BUTTON_KEY = 'rebase';
export const REBASE_WITHOUT_CI_BUTTON_KEY = 'rebaseWithoutCi';
...@@ -55,8 +55,9 @@ export default class MRWidgetService { ...@@ -55,8 +55,9 @@ export default class MRWidgetService {
return axios.get(this.endpoints.mergeActionsContentPath); return axios.get(this.endpoints.mergeActionsContentPath);
} }
rebase() { rebase({ skipCi = false } = {}) {
return axios.post(this.endpoints.rebasePath); const path = `${this.endpoints.rebasePath}?skip_ci=${Boolean(skipCi)}`;
return axios.post(path);
} }
fetchApprovals() { fetchApprovals() {
......
...@@ -38,9 +38,12 @@ Now, when you visit the merge request page, you can accept it ...@@ -38,9 +38,12 @@ Now, when you visit the merge request page, you can accept it
If a fast-forward merge is not possible but a conflict free rebase is possible, If a fast-forward merge is not possible but a conflict free rebase is possible,
a rebase button is offered. a rebase button is offered.
You can also rebase without running a CI/CD pipeline.
[Introduced in](https://gitlab.com/gitlab-org/gitlab/-/issues/118825) GitLab 14.7.
The rebase action is also available as a [quick action command: `/rebase`](../../../topics/git/git_rebase.md#rebase-from-the-gitlab-ui). The rebase action is also available as a [quick action command: `/rebase`](../../../topics/git/git_rebase.md#rebase-from-the-gitlab-ui).
![Fast forward merge request](img/ff_merge_rebase.png) ![Fast forward merge request](img/ff_merge_rebase_v14_7.png)
If the target branch is ahead of the source branch and a conflict free rebase is If the target branch is ahead of the source branch and a conflict free rebase is
not possible, you need to rebase the not possible, you need to rebase the
......
...@@ -25846,6 +25846,9 @@ msgstr "" ...@@ -25846,6 +25846,9 @@ msgstr ""
msgid "PerformanceBar|wall" msgid "PerformanceBar|wall"
msgstr "" msgstr ""
msgid "Performs a rebase but skips triggering a new pipeline"
msgstr ""
msgid "Period in seconds" msgid "Period in seconds"
msgstr "" msgstr ""
...@@ -29227,6 +29230,12 @@ msgstr "" ...@@ -29227,6 +29230,12 @@ msgstr ""
msgid "Rebase source branch on the target branch." msgid "Rebase source branch on the target branch."
msgstr "" msgstr ""
msgid "Rebase without CI"
msgstr ""
msgid "Rebases and triggers a pipeline"
msgstr ""
msgid "Recaptcha verified?" msgid "Recaptcha verified?"
msgstr "" msgstr ""
......
...@@ -2,10 +2,15 @@ import { shallowMount } from '@vue/test-utils'; ...@@ -2,10 +2,15 @@ import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue'; import WidgetRebase from '~/vue_merge_request_widget/components/states/mr_widget_rebase.vue';
import eventHub from '~/vue_merge_request_widget/event_hub'; import eventHub from '~/vue_merge_request_widget/event_hub';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
import {
REBASE_BUTTON_KEY,
REBASE_WITHOUT_CI_BUTTON_KEY,
} from '~/vue_merge_request_widget/constants';
let wrapper; let wrapper;
function factory(propsData, mergeRequestWidgetGraphql) { function createWrapper(propsData, mergeRequestWidgetGraphql) {
wrapper = shallowMount(WidgetRebase, { wrapper = shallowMount(WidgetRebase, {
propsData, propsData,
data() { data() {
...@@ -31,8 +36,9 @@ function factory(propsData, mergeRequestWidgetGraphql) { ...@@ -31,8 +36,9 @@ function factory(propsData, mergeRequestWidgetGraphql) {
} }
describe('Merge request widget rebase component', () => { describe('Merge request widget rebase component', () => {
const findRebaseMessageEl = () => wrapper.find('[data-testid="rebase-message"]'); const findRebaseMessage = () => wrapper.find('[data-testid="rebase-message"]');
const findRebaseMessageElText = () => findRebaseMessageEl().text(); const findRebaseMessageText = () => findRebaseMessage().text();
const findRebaseButton = () => wrapper.find(ActionsButton);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -40,10 +46,10 @@ describe('Merge request widget rebase component', () => { ...@@ -40,10 +46,10 @@ describe('Merge request widget rebase component', () => {
}); });
[true, false].forEach((mergeRequestWidgetGraphql) => { [true, false].forEach((mergeRequestWidgetGraphql) => {
describe(`widget graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'dislabed'}`, () => { describe(`widget graphql is ${mergeRequestWidgetGraphql ? 'enabled' : 'disabled'}`, () => {
describe('While rebasing', () => { describe('while rebasing', () => {
it('should show progress message', () => { it('should show progress message', () => {
factory( createWrapper(
{ {
mr: { rebaseInProgress: true }, mr: { rebaseInProgress: true },
service: {}, service: {},
...@@ -51,24 +57,32 @@ describe('Merge request widget rebase component', () => { ...@@ -51,24 +57,32 @@ describe('Merge request widget rebase component', () => {
mergeRequestWidgetGraphql, mergeRequestWidgetGraphql,
); );
expect(findRebaseMessageElText()).toContain('Rebase in progress'); expect(findRebaseMessageText()).toContain('Rebase in progress');
}); });
}); });
describe('With permissions', () => { describe('with permissions', () => {
it('it should render rebase button and warning message', () => { const rebaseMock = jest.fn().mockResolvedValue();
factory( const pollMock = jest.fn().mockResolvedValue({});
beforeEach(() => {
createWrapper(
{ {
mr: { mr: {
rebaseInProgress: false, rebaseInProgress: false,
canPushToSourceBranch: true, canPushToSourceBranch: true,
}, },
service: {}, service: {
rebase: rebaseMock,
poll: pollMock,
},
}, },
mergeRequestWidgetGraphql, mergeRequestWidgetGraphql,
); );
});
const text = findRebaseMessageElText(); it('renders the warning message', () => {
const text = findRebaseMessageText();
expect(text).toContain('Merge blocked'); expect(text).toContain('Merge blocked');
expect(text.replace(/\s\s+/g, ' ')).toContain( expect(text.replace(/\s\s+/g, ' ')).toContain(
...@@ -76,42 +90,79 @@ describe('Merge request widget rebase component', () => { ...@@ -76,42 +90,79 @@ describe('Merge request widget rebase component', () => {
); );
}); });
it('it should render error message when it fails', async () => { it('renders an error message when rebasing has failed', async () => {
factory(
{
mr: {
rebaseInProgress: false,
canPushToSourceBranch: true,
},
service: {},
},
mergeRequestWidgetGraphql,
);
// setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details
// eslint-disable-next-line no-restricted-syntax // eslint-disable-next-line no-restricted-syntax
wrapper.setData({ rebasingError: 'Something went wrong!' }); wrapper.setData({ rebasingError: 'Something went wrong!' });
await nextTick(); await nextTick();
expect(findRebaseMessageElText()).toContain('Something went wrong!'); expect(findRebaseMessageText()).toContain('Something went wrong!');
});
describe('"Rebase" button', () => {
it('is rendered', () => {
expect(findRebaseButton().exists()).toBe(true);
});
it('has rebase and rebase without CI actions', () => {
const actionNames = findRebaseButton()
.props('actions')
.map((action) => action.key);
expect(actionNames).toStrictEqual([REBASE_BUTTON_KEY, REBASE_WITHOUT_CI_BUTTON_KEY]);
});
it('defaults to rebase action', () => {
expect(findRebaseButton().props('selectedKey')).toStrictEqual(REBASE_BUTTON_KEY);
});
it('starts the rebase when clicking', async () => {
// ActionButtons use the actions props instead of emitting
// a click event, therefore simulating the behavior here:
findRebaseButton()
.props('actions')
.find((x) => x.key === REBASE_BUTTON_KEY)
.handle();
await nextTick();
expect(rebaseMock).toHaveBeenCalledWith({ skipCi: false });
});
it('starts the CI-skipping rebase when clicking on "Rebase without CI"', async () => {
// ActionButtons use the actions props instead of emitting
// a click event, therefore simulating the behavior here:
findRebaseButton()
.props('actions')
.find((x) => x.key === REBASE_WITHOUT_CI_BUTTON_KEY)
.handle();
await nextTick();
expect(rebaseMock).toHaveBeenCalledWith({ skipCi: true });
});
}); });
}); });
describe('Without permissions', () => { describe('without permissions', () => {
it('should render a message explaining user does not have permissions', () => { const exampleTargetBranch = 'fake-branch-to-test-with';
factory(
beforeEach(() => {
createWrapper(
{ {
mr: { mr: {
rebaseInProgress: false, rebaseInProgress: false,
canPushToSourceBranch: false, canPushToSourceBranch: false,
targetBranch: 'foo', targetBranch: exampleTargetBranch,
}, },
service: {}, service: {},
}, },
mergeRequestWidgetGraphql, mergeRequestWidgetGraphql,
); );
});
const text = findRebaseMessageElText(); it('renders a message explaining user does not have permissions', () => {
const text = findRebaseMessageText();
expect(text).toContain( expect(text).toContain(
'Merge blocked: the source branch must be rebased onto the target branch.', 'Merge blocked: the source branch must be rebased onto the target branch.',
...@@ -119,32 +170,23 @@ describe('Merge request widget rebase component', () => { ...@@ -119,32 +170,23 @@ describe('Merge request widget rebase component', () => {
expect(text).toContain('the source branch must be rebased'); expect(text).toContain('the source branch must be rebased');
}); });
it('should render the correct target branch name', () => { it('renders the correct target branch name', () => {
const targetBranch = 'fake-branch-to-test-with'; const elem = findRebaseMessage();
factory(
{
mr: {
rebaseInProgress: false,
canPushToSourceBranch: false,
targetBranch,
},
service: {},
},
mergeRequestWidgetGraphql,
);
const elem = findRebaseMessageEl();
expect(elem.text()).toContain( expect(elem.text()).toContain(
`Merge blocked: the source branch must be rebased onto the target branch.`, `Merge blocked: the source branch must be rebased onto the target branch.`,
); );
}); });
it('does not render the "Rebase" button', () => {
expect(findRebaseButton().exists()).toBe(false);
});
}); });
describe('methods', () => { describe('methods', () => {
it('checkRebaseStatus', async () => { it('checkRebaseStatus', async () => {
jest.spyOn(eventHub, '$emit').mockImplementation(() => {}); jest.spyOn(eventHub, '$emit').mockImplementation(() => {});
factory( createWrapper(
{ {
mr: {}, mr: {},
service: { service: {
......
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