From 1628a2cd9ae6c056d02320a2b069e55571df848a Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray Date: Fri, 31 Jan 2025 15:22:22 -0700 Subject: [PATCH 1/6] Add rebase button to MR widget if available Changelog: added --- .../components/states/ready_to_merge.vue | 46 ++++++++++++++++++- locale/gitlab.pot | 6 +++ 2 files changed, 50 insertions(+), 2 deletions(-) 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 1c64cd1dab3c1c..fa343d2bdae283 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,7 @@ 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 { __, s__, n__, sprintf } from '~/locale'; import SmartInterval from '~/smart_interval'; import { helpPagePath } from '~/helpers/help_page_helper'; import { convertToGraphQLId } from '~/graphql_shared/utils'; @@ -198,6 +199,7 @@ export default { skipMergeTrain: false, mergeTrainsSkipAllowed: this.mr.mergeTrainsSkipAllowed, editCommitMessage: false, + isRebaseInProgress: false, }; }, computed: { @@ -355,6 +357,9 @@ export default { displaySkipMergeTrainOptions() { return this.shouldDisplayMergeImmediatelyDropdownOptions && this.isSkipMergeTrainAvailable; }, + canRebase() { + return this.sourceHasDivergedFromTarget && this.shouldShowMergeControls; + }, }, watch: { 'mr.state': function mrStateWatcher() { @@ -539,6 +544,32 @@ export default { this.removeSourceBranch = checked; this.mr.setRemoveSourceBranch(checked); }, + async handleRebaseClick() { + if (this.isRebaseDisabled) return; + + try { + this.isRebaseInProgress = true; + + const rebasePath = `/${this.mr.targetProjectFullPath}/-/merge_requests/${this.mr.iid}/rebase`; + await axios.post(rebasePath); + + createAlert({ + message: sprintf(this.$options.i18n.scheduledRebase, { + branch: this.mr.sourceBranch, + }), + type: 'success', + }); + + this.updateGraphqlState(); + } catch (error) { + createAlert({ + message: error.response?.data?.message || __('Failed to rebase. Please try again.'), + type: 'error', + }); + } finally { + this.isRebaseInProgress = false; + } + }, }, i18n: { mergeCommitTemplateHintText: s__( @@ -552,6 +583,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, @@ -675,6 +707,17 @@ export default { }} + + {{ __('Rebase source branch') }} + · diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9b5ce4d2da0dd3..f2d96682e02a4a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -46984,6 +46984,9 @@ msgstr "" msgid "Reauthenticating with SAML provider." msgstr "" +msgid "Rebase" +msgstr "" + msgid "Rebase completed" msgstr "" @@ -46993,6 +46996,9 @@ msgstr "" msgid "Rebase source branch on the target branch." msgstr "" +msgid "Rebase source branch?" +msgstr "" + msgid "Recaptcha verified" msgstr "" @@ -58911,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 "" -- GitLab From 3b55d06e785f74af279388d6e3cbba68f60162b4 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray Date: Tue, 4 Feb 2025 12:28:29 -0700 Subject: [PATCH 3/6] Restructure merge summary --- .../components/states/ready_to_merge.vue | 89 +++++++++---------- 1 file changed, 42 insertions(+), 47 deletions(-) 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 723353914b3875..1dc160a299bea0 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 @@ -373,19 +373,6 @@ export default { canRebase() { return this.sourceHasDivergedFromTarget && this.shouldShowMergeControls; }, - modalActionPrimary() { - return { - text: __('Rebase'), - attributes: { - variant: 'confirm', - }, - }; - }, - modalActionCancel() { - return { - text: __('Cancel'), - }; - }, }, watch: { 'mr.state': function mrStateWatcher() { @@ -731,42 +718,50 @@ export default {
- - - +
+
+ +
Date: Wed, 5 Feb 2025 12:40:58 -0700 Subject: [PATCH 4/6] Fix rebasePath, use list elements --- .../components/states/ready_to_merge.vue | 44 ++++++++++--------- 1 file changed, 23 insertions(+), 21 deletions(-) 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 1dc160a299bea0..0860bfe7c7b54f 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 @@ -21,6 +21,7 @@ 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 { joinPaths } from '~/lib/utils/url_utility'; import { __, s__, n__, sprintf } from '~/locale'; import SmartInterval from '~/smart_interval'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -566,29 +567,33 @@ export default { try { this.isRebaseInProgress = true; - const rebasePath = `/${this.mr.targetProjectFullPath}/-/merge_requests/${this.mr.iid}/rebase`; + 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, }), - type: 'success', + variant: 'success', }); this.updateGraphqlState(); } catch (error) { createAlert({ message: error.response?.data?.message || __('Failed to rebase. Please try again.'), - type: 'error', + variant: 'error', }); } finally { this.isRebaseInProgress = false; } }, - getErrorMessage(error) { - return error.response?.data?.message || __('Failed to rebase. Please try again.'); - }, }, i18n: { mergeCommitTemplateHintText: s__( @@ -717,10 +722,9 @@ export default { -
-
-
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 00000000000000..1528cbc4ee7f76 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/rebase_confirmation_dialog.vue @@ -0,0 +1,63 @@ + + + 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 780352cfe84225..9e3c9036b2d8c8 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('rebaseConfirmed'); + 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 00000000000000..3b0bfd9378f5fa --- /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('rebaseConfirmed')).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(); + }); +}); -- GitLab From 0b9938fe43d7fb1b3b50ccb0a098b67fc491f820 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray Date: Fri, 7 Feb 2025 05:58:56 -0700 Subject: [PATCH 6/6] Use kebab case --- .../components/states/ready_to_merge.vue | 2 +- .../components/states/rebase_confirmation_dialog.vue | 2 +- .../components/states/mr_widget_ready_to_merge_spec.js | 2 +- .../components/states/rebase_confirmation_dialog_spec.js | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) 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 e863640091cbb1..1a35e80061a09e 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 @@ -915,7 +915,7 @@ export default { /> 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 index 1528cbc4ee7f76..1fb5eebf760b51 100644 --- 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 @@ -32,7 +32,7 @@ export default { this.$refs.cancelButton.$el.focus(); }, confirmRebase() { - this.$emit('rebaseConfirmed'); + this.$emit('rebase-confirmed'); this.hide(); }, }, 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 9e3c9036b2d8c8..02b2e9ae3d000b 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 @@ -1066,7 +1066,7 @@ describe('ReadyToMerge', () => { await wrapper.findByTestId('rebase-button').trigger('click'); await nextTick(); - wrapper.findComponent({ name: 'RebaseConfirmationDialog' }).vm.$emit('rebaseConfirmed'); + wrapper.findComponent({ name: 'RebaseConfirmationDialog' }).vm.$emit('rebase-confirmed'); await waitForPromises(); expect(axiosSpy).toHaveBeenCalledWith(expectedPath); 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 index 3b0bfd9378f5fa..ba22ae62ba7dff 100644 --- 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 @@ -52,7 +52,7 @@ describe('RebaseConfirmationDialog', () => { it('emits rebaseConfirmed when rebase button is clicked', () => { findRebaseBtn().vm.$emit('click'); - expect(wrapper.emitted('rebaseConfirmed')).toHaveLength(1); + expect(wrapper.emitted('rebase-confirmed')).toHaveLength(1); }); it('when the cancel button is clicked should emit cancel and call hide', () => { -- GitLab