diff --git a/app/assets/javascripts/merge_requests/components/merge_schedule_input.vue b/app/assets/javascripts/merge_requests/components/merge_schedule_input.vue new file mode 100644 index 0000000000000000000000000000000000000000..94f9c45b32578854e755efef7db875330ab9be27 --- /dev/null +++ b/app/assets/javascripts/merge_requests/components/merge_schedule_input.vue @@ -0,0 +1,70 @@ +<script> +import { GlCollapsibleListbox, GlFormInput } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +const ANYTIME = 'anytime'; +const AFTER = 'after'; + +export default { + components: { + GlCollapsibleListbox, + GlFormInput, + }, + props: { + mergeAfter: { + type: String, + required: false, + default: undefined, + }, + paramKey: { + type: String, + required: true, + }, + }, + data() { + return { + selectedMode: this.mergeAfter !== undefined ? AFTER : ANYTIME, + localMergeAfter: formatDate(this.mergeAfter, "yyyy-mm-dd'T'HH:MM"), + }; + }, + computed: { + actualMergeAfter() { + if (this.selectedMode === ANYTIME) { + return ''; + } + + return new Date(this.localMergeAfter).toJSON(); + }, + dropdownValues() { + return [ + { + text: __('Anytime'), + value: ANYTIME, + }, + { + text: __('After scheduled date'), + value: AFTER, + }, + ]; + }, + shouldDisplayAfterInput() { + return this.selectedMode === AFTER; + }, + }, +}; +</script> + +<template> + <div class="col-12"> + <gl-collapsible-listbox v-model="selectedMode" :items="dropdownValues" /> + <div class="issuable-form-select-holder"> + <gl-form-input + v-if="shouldDisplayAfterInput" + v-model="localMergeAfter" + type="datetime-local" + /> + </div> + <input type="hidden" :name="`${paramKey}[merge_after]`" :value="actualMergeAfter" /> + </div> +</template> diff --git a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js index 0e66c3521dd594d75492e5a7f89eb74526653cc3..0773b6aed5cc4410501a3cb2ba0d08ec1867a843 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js +++ b/app/assets/javascripts/pages/projects/merge_requests/init_merge_request.js @@ -7,6 +7,7 @@ import { addShortcutsExtension } from '~/behaviors/shortcuts'; import ShortcutsNavigation from '~/behaviors/shortcuts/shortcuts_navigation'; import LabelsSelect from '~/labels/labels_select'; import { mountMilestoneDropdown } from '~/sidebar/mount_sidebar'; +import mountMergeScheduleInput from './mount_merge_schedule_input'; export default () => { addShortcutsExtension(ShortcutsNavigation); @@ -14,4 +15,5 @@ export default () => { IssuableLabelSelector(); new LabelsSelect(); mountMilestoneDropdown('[name="merge_request[milestone_id]"]'); + mountMergeScheduleInput(); }; diff --git a/app/assets/javascripts/pages/projects/merge_requests/mount_merge_schedule_input.js b/app/assets/javascripts/pages/projects/merge_requests/mount_merge_schedule_input.js new file mode 100644 index 0000000000000000000000000000000000000000..93b2a62488a73beafa992a4cab13583480e74e1e --- /dev/null +++ b/app/assets/javascripts/pages/projects/merge_requests/mount_merge_schedule_input.js @@ -0,0 +1,22 @@ +import Vue from 'vue'; +import MergeScheduleInput from '~/merge_requests/components/merge_schedule_input.vue'; + +export default () => { + const el = document.querySelector('.js-merge-request-schedule-input'); + + if (!el) return false; + + const { mergeAfter, paramKey } = el.dataset; + + return new Vue({ + el, + render(h) { + return h(MergeScheduleInput, { + props: { + mergeAfter, + paramKey, + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js index becca99b1095faff7f43752809ba5e21cc4e8219..a65dfd0168cd028b26258ead47f258b63498f449 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js @@ -4,6 +4,7 @@ export const COMPONENTS = { conflict: () => import('./conflicts.vue'), discussions_not_resolved: () => import('./unresolved_discussions.vue'), draft_status: () => import('./draft.vue'), + merge_time: () => import('./merge_time.vue'), need_rebase: () => import('./rebase.vue'), default: () => import('./message.vue'), requested_changes: () => @@ -39,4 +40,12 @@ export const FAILURE_REASONS = { // TODO: Remove this in 17.7 security_policy_evaluation: __('All security policies must be evaluated.'), security_policy_violations: __('All policy rules must be satisfied.'), + merge_time: __('Cannot merge until this date and time.'), }; + +export const ICON_NAMES = Object.freeze({ + failed: 'failed', + inactive: 'neutral', + success: 'success', + warning: 'warning', +}); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/merge_time.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/merge_time.vue new file mode 100644 index 0000000000000000000000000000000000000000..7b201b0dfb9bf26473bc066d4b097789f919184e --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/merge_time.vue @@ -0,0 +1,47 @@ +<script> +import { s__, sprintf } from '~/locale'; +import { localeDateFormat } from '~/lib/utils/datetime_utility'; +import StatusIcon from '../widget/status_icon.vue'; +import { ICON_NAMES } from './constants'; + +const dateFormatter = localeDateFormat.asDateTime; + +export default { + name: 'MergeChecksMergeTime', + components: { + StatusIcon, + }, + props: { + check: { + type: Object, + required: true, + }, + mr: { + type: Object, + required: false, + default: () => ({}), + }, + }, + computed: { + iconName() { + return ICON_NAMES[this.check.status.toLowerCase()]; + }, + failureReason() { + return sprintf(s__('mrWidget|Cannot merge until %{mergeAfter}'), { + mergeAfter: dateFormatter.format(new Date(this.mr.mergeAfter)), + }); + }, + }, +}; +</script> + +<template> + <div class="gl-py-3 gl-pl-7 gl-pr-4"> + <div class="gl-flex"> + <status-icon :icon-name="iconName" :level="2" /> + <div class="gl-w-full gl-min-w-0"> + <div class="gl-flex">{{ failureReason }}</div> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue index bfd7c525ea9f4539bcdbf795715bd86c9ef41037..56c0f75248c8cb18c45e2b848b9241d130688829 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue @@ -1,14 +1,7 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; import StatusIcon from '../widget/status_icon.vue'; -import { FAILURE_REASONS } from './constants'; - -const ICON_NAMES = { - failed: 'failed', - inactive: 'neutral', - success: 'success', - warning: 'warning', -}; +import { FAILURE_REASONS, ICON_NAMES } from './constants'; export default { name: 'MergeChecksMessage', diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql index 4e96e4abe4257240a93fdb77d683d84b09a61b31..c5f758970fd2dba98e2bf175037fd9bf4ccdd819 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/get_state.query.graphql @@ -16,6 +16,7 @@ query getState($projectPath: ID!, $iid: String!) { conflicts detailedMergeStatus diffHeadSha + mergeAfter mergeError mergeStatus mergeable 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 827925db00a3dd515c3ebde1a2d837503b1fe228..5d8ee6815ae5f65b1486fb6f5d712624ef412539 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 @@ -206,6 +206,7 @@ export default class MergeRequestStore { this.hasMergeableDiscussionsState = mergeRequest.mergeableDiscussionsState === false; this.mergeError = mergeRequest.mergeError; this.mergeStatus = mergeRequest.mergeStatus; + this.mergeAfter = mergeRequest.mergeAfter; this.isPipelineFailed = this.ciStatus === 'failed' || this.ciStatus === 'canceled'; this.isSHAMismatch = this.sha !== mergeRequest.diffHeadSha; this.shouldBeRebased = mergeRequest.shouldBeRebased; diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index efce772388de18347697e7fff9e3846adec5f43f..a448cc562f25cfaeb1cad684c87638ac4ea0060c 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -65,6 +65,7 @@ def merge_request_params_attributes :title, :discussion_locked, :issue_iid, + :merge_after, { label_ids: [], assignee_ids: [], reviewer_ids: [], diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 9135a80c883b6bd3f74740f9c168e7264c180c7d..7d6fc42814b458902533093534fd0fe3c928b9bc 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -11,7 +11,13 @@ def execute merge_request.source_project = @source_project merge_request.source_branch = params[:source_branch] - create(merge_request) + merge_after = params.delete(:merge_after) + + created_merge_request = create(merge_request) + + UpdateMergeScheduleService.new(created_merge_request, merge_after: merge_after).execute + + created_merge_request end def after_create(issuable) diff --git a/app/services/merge_requests/update_merge_schedule_service.rb b/app/services/merge_requests/update_merge_schedule_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..ea4566d313f3979f397f9b6dc5ef58e0297ec7ad --- /dev/null +++ b/app/services/merge_requests/update_merge_schedule_service.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module MergeRequests + class UpdateMergeScheduleService + def initialize(merge_request, merge_after:) + @merge_request = merge_request + @merge_after = merge_after + end + + def execute + if merge_after.present? + merge_schedule = merge_request.merge_schedule || merge_request.build_merge_schedule + merge_schedule.merge_after = merge_after + merge_request.merge_schedule = merge_schedule + elsif merge_request.merge_schedule.present? + merge_request.merge_schedule.destroy! + end + end + + private + + attr_reader :merge_request, :merge_after + end +end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 0c70468b9079acbd765cd97758016c130d7e81fc..b94bc68b7d7cad637f6fefb99c49e54db3e8f82e 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -15,6 +15,11 @@ def execute(merge_request) merge_request.title = merge_request.draft_title end + if params.key?(:merge_after) + merge_after = params.delete(:merge_after) + UpdateMergeScheduleService.new(merge_request, merge_after: merge_after).execute + end + update_merge_request_with_specialized_service(merge_request) || general_fallback(merge_request) end diff --git a/app/views/shared/issuable/form/_metadata.html.haml b/app/views/shared/issuable/form/_metadata.html.haml index 24e04818bf6b7037ac56c2ce2a843f5104b64939..aeb819588688acb617413feb5e9e136bd020ec19 100644 --- a/app/views/shared/issuable/form/_metadata.html.haml +++ b/app/views/shared/issuable/form/_metadata.html.haml @@ -42,6 +42,15 @@ = render_if_exists "shared/issuable/form/merge_request_blocks", issuable: issuable, form: form + - if issuable.is_a?(MergeRequest) + .row + = form.label :merge_after, s_('MergeRequests|Merge can start'), class: 'col-12' + .js-merge-request-schedule-input{ data: { + merge_after: issuable.merge_schedule&.merge_after&.strftime('%Y-%m-%dT%H:%MZ'), + param_key: issuable.class.model_name.param_key + } } + %p.gl-text-gray-500.col-12= s_('MergeRequests|Requires that merge checks pass.') + - if has_due_date .col-lg-6 = render_if_exists "shared/issuable/form/weight", issuable: issuable, form: form diff --git a/doc/user/project/merge_requests/auto_merge.md b/doc/user/project/merge_requests/auto_merge.md index f114a7afe3657c736cddc057987b4fb764e82c21..2f9f65e96443dbe3cbd072b8ab118df9a35b6c24 100644 --- a/doc/user/project/merge_requests/auto_merge.md +++ b/doc/user/project/merge_requests/auto_merge.md @@ -34,6 +34,7 @@ After you set auto-merge, these checks must all pass before the merge request me - If your project [requires merge requests to reference a Jira issue](../../../integration/jira/issues.md#require-associated-jira-issue-for-merge-requests-to-be-merged), the merge request title or description contains a Jira issue link. +- If the merge request has a **Merge after** date set, the current time must be after the configured date. For a full list of checks and their API equivalents, see [Merge status](../../../api/merge_requests.md#merge-status). @@ -175,6 +176,27 @@ To change this behavior: - Select **Skipped pipelines are considered successful**. 1. Select **Save**. +## Prevent merge before a specific date + +> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/14380) in GitLab 17.6. + +If your merge request should not merge before a specific date and time, set a **Merge after** date. +This value sets when the merge (or merge train) can start. The exact time of merge can vary, +however, depending on the satisfaction of other merge checks or the length of your merge train. + +Prerequisites: + +- You must have at least the Developer role for the project. + +To do this: + +1. On the left sidebar, select **Search or go to** and find your project. +1. Select **Code > Merge requests**. +1. Select the merge request to edit. +1. Select **Edit**. +1. Find the **Merge after** input and select a date and time. +1. Select **Save changes**. + ## Troubleshooting ### Merge request can't merge despite no failed pipeline diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 017d5a4770b4423cc01f298313c3581ad963e066..ba62dac6d0f5b5526ec4b94e740772b5855c8d35 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5043,6 +5043,9 @@ msgstr "" msgid "After it is removed, the fork relationship can only be restored by using the API. This project will no longer be able to receive or send merge requests to the upstream project or other forks." msgstr "" +msgid "After scheduled date" +msgstr "" + msgid "After the export is complete, download the data file from a notification email or from this page. You can then import the data file from the %{strong_text_start}Create new group%{strong_text_end} page of another GitLab instance." msgstr "" @@ -6663,6 +6666,9 @@ msgstr "" msgid "Any user who is added or requests access in excess of the user cap must be approved by an administrator" msgstr "" +msgid "Anytime" +msgstr "" + msgid "App ID" msgstr "" @@ -10899,6 +10905,9 @@ msgstr "" msgid "Cannot merge" msgstr "" +msgid "Cannot merge until this date and time." +msgstr "" + msgid "Cannot modify %{profile_name} referenced in scan" msgstr "" @@ -34213,6 +34222,9 @@ msgstr "" msgid "MergeRequests|Mark as draft" msgstr "" +msgid "MergeRequests|Merge can start" +msgstr "" + msgid "MergeRequests|Merge request cherry-pick failed" msgstr "" @@ -34222,6 +34234,9 @@ msgstr "" msgid "MergeRequests|Reference copied" msgstr "" +msgid "MergeRequests|Requires that merge checks pass." +msgstr "" + msgid "MergeRequests|Squashing failed: Squash the commits locally, resolve any conflicts, then push the branch." msgstr "" @@ -66033,6 +66048,9 @@ msgstr "" msgid "mrWidget|Cancel auto-merge" msgstr "" +msgid "mrWidget|Cannot merge until %{mergeAfter}" +msgstr "" + msgid "mrWidget|Checking if merge request can be merged…" msgstr "" diff --git a/spec/frontend/merge_requests/components/merge_schedule_input_spec.js b/spec/frontend/merge_requests/components/merge_schedule_input_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..9f8894986c3563841c2ebb9f6df7ce046fe4886b --- /dev/null +++ b/spec/frontend/merge_requests/components/merge_schedule_input_spec.js @@ -0,0 +1,30 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import MergeScheduleInput from '~/merge_requests/components/merge_schedule_input.vue'; + +let wrapper; + +function createComponent(propsData = {}) { + wrapper = shallowMount(MergeScheduleInput, { propsData }); +} + +const findInput = () => wrapper.findComponent(GlFormInput); +const findHiddenInput = () => wrapper.find('input[type="hidden"]'); + +describe('Merge schedule input component', () => { + it('Hides input if mergeAfter is undefined', () => { + createComponent({ mergeAfter: undefined }); + + expect(findInput().exists()).toBe(false); + expect(findHiddenInput().exists()).toBe(true); + expect([undefined, '']).toContain(findHiddenInput().attributes('value')); + }); + + it('Shows input if mergeAfter is set', () => { + createComponent({ mergeAfter: '2024-10-27T20:40' }); + + expect(findInput().exists()).toBe(true); + expect(findHiddenInput().exists()).toBe(true); + expect(findHiddenInput().attributes('value')).toBe('2024-10-27T20:40:00.000Z'); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/checks/merge_time_spec.js b/spec/frontend/vue_merge_request_widget/components/checks/merge_time_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..b7e31e8040a1a527a09beebe9c6f40cd25420e3a --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/checks/merge_time_spec.js @@ -0,0 +1,36 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import MergeTimeComponent from '~/vue_merge_request_widget/components/checks/merge_time.vue'; +import StatusIcon from '~/vue_merge_request_widget/components/widget/status_icon.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = mountExtended(MergeTimeComponent, { + propsData, + }); +} + +describe('Merge request merge checks merge time component', () => { + it('renders failure reason text', () => { + factory({ + check: { status: 'success', identifier: 'merge_time' }, + mr: { mergeAfter: '2024-10-17T18:23:00Z' }, + }); + + expect(wrapper.text()).toBe('Cannot merge until Oct 17, 2024, 6:23 PM'); + }); + + it.each` + status | icon + ${'success'} | ${'success'} + ${'failed'} | ${'failed'} + ${'inactive'} | ${'neutral'} + `('renders $icon icon for $status result', ({ status, icon }) => { + factory({ + check: { status, identifier: 'merge_time' }, + mr: { mergeAfter: '2024-10-17T18:23:00Z' }, + }); + + expect(wrapper.findComponent(StatusIcon).props('iconName')).toBe(icon); + }); +}); diff --git a/spec/requests/projects/merge_requests/creations_spec.rb b/spec/requests/projects/merge_requests/creations_spec.rb index 41246d419a1809c607196ed504fd9281c59c19b8..93a5e6cf5bc7908eb7ed8c7805a0ff8082eaa07d 100644 --- a/spec/requests/projects/merge_requests/creations_spec.rb +++ b/spec/requests/projects/merge_requests/creations_spec.rb @@ -77,4 +77,42 @@ def get_new(params = get_params) end end end + + describe 'POST /:namespace/:project/merge_requests' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, :repository, group: group) } + let_it_be(:user) { create(:user) } + + let(:create_params) do + { + namespace_id: project.namespace.to_param, + project_id: project, + merge_request: { + source_branch: 'two-commits', + target_branch: 'master', + title: 'Something', + merge_after: '2024-09-03T21:18' + } + } + end + + before_all do + group.add_developer(user) + end + + before do + login_as(user) + end + + it 'creates correct merge schedule' do + post namespace_project_merge_requests_path(create_params) + + expect(response).to redirect_to(project_merge_request_path(project, MergeRequest.last)) + + merge_request = MergeRequest.last + expect(merge_request.merge_schedule.merge_after).to eq( + Time.zone.parse('2024-09-03T21:18') + ) + end + end end diff --git a/spec/requests/projects/merge_requests_controller_spec.rb b/spec/requests/projects/merge_requests_controller_spec.rb index a9452c21dc74dee38362ba6977e712279b6abb01..adcaa807b009ba5dd649a8d2e9b5ab06e5b1d0d4 100644 --- a/spec/requests/projects/merge_requests_controller_spec.rb +++ b/spec/requests/projects/merge_requests_controller_spec.rb @@ -261,4 +261,37 @@ def create_pipeline ) end end + + describe 'PUT #update' do + before do + project.add_developer(user) + login_as(user) + end + + it 'applies correct timezone to merge_after' do + put project_merge_request_path(project, merge_request, merge_request: { merge_after: '2024-09-03T21:18' }) + + expect(response).to redirect_to(project_merge_request_path(project, merge_request)) + + expect(merge_request.reload.merge_schedule.merge_after).to eq( + Time.zone.parse('2024-09-03T21:18') + ) + end + + it 'resets merge_schedule if merge_after is not set' do + create(:merge_request_merge_schedule, merge_request: merge_request, merge_after: '2024-10-27T21:06') + + expect do + put project_merge_request_path(project, merge_request, merge_request: { merge_after: '' }) + end.to change { merge_request.reload.merge_schedule }.to(nil) + end + + it 'does not reset merge_schedule if merge_after is not sent' do + create(:merge_request_merge_schedule, merge_request: merge_request, merge_after: '2024-10-27T21:06') + + expect do + put project_merge_request_path(project, merge_request, merge_request: { title: 'Something' }) + end.not_to change { merge_request.reload.merge_schedule.merge_after } + end + end end diff --git a/spec/services/merge_requests/update_merge_schedule_service_spec.rb b/spec/services/merge_requests/update_merge_schedule_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..629a7b226e648a39759b89f1ceed13a912e30491 --- /dev/null +++ b/spec/services/merge_requests/update_merge_schedule_service_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe MergeRequests::UpdateMergeScheduleService, feature_category: :code_review_workflow do + let(:merge_request) { create(:merge_request) } + + let(:service) { described_class.new(merge_request, merge_after: merge_after) } + + subject(:execute) { service.execute } + + describe '#execute' do + context 'when passing a merge_after date' do + let(:merge_after) { '2024-11-07T19:17+0200' } + + specify do + expect { execute }.to change { merge_request.reload.merge_schedule&.merge_after } + .to(Time.zone.parse(merge_after).in_time_zone('Etc/UTC')) + end + + it { expect { execute }.to change { MergeRequests::MergeSchedule.count }.by(1) } + end + + context 'when passing nil for merge_after' do + let(:merge_after) { nil } + + context 'when merge_schedule exists' do + before do + merge_request.create_merge_schedule(merge_after: '2024-11-07T19:17+0200') + end + + it { expect { execute }.to change { merge_request.reload.merge_schedule&.merge_after }.to(nil) } + it { expect { execute }.to change { MergeRequests::MergeSchedule.count }.by(-1) } + end + + context 'when merge_schedule does not exist' do + it { expect { execute }.not_to change { merge_request.reload.merge_schedule } } + it { expect { execute }.not_to change { MergeRequests::MergeSchedule.count } } + end + end + end +end