From 2a04ffdd32afafd7d77c24bd7f84adffbdff006d Mon Sep 17 00:00:00 2001 From: Niklas van Schrick <mc.taucher2003@gmail.com> Date: Mon, 11 Nov 2024 14:06:38 +0000 Subject: [PATCH] Add frontend and controllers for scheduled merge Changelog: added --- .../components/merge_schedule_input.vue | 70 +++++++++++++++++++ .../merge_requests/init_merge_request.js | 2 + .../mount_merge_schedule_input.js | 22 ++++++ .../components/checks/constants.js | 9 +++ .../components/checks/merge_time.vue | 47 +++++++++++++ .../components/checks/message.vue | 9 +-- .../queries/get_state.query.graphql | 1 + .../stores/mr_widget_store.js | 1 + .../merge_requests/application_controller.rb | 1 + app/services/merge_requests/create_service.rb | 8 ++- .../update_merge_schedule_service.rb | 24 +++++++ app/services/merge_requests/update_service.rb | 5 ++ .../shared/issuable/form/_metadata.html.haml | 9 +++ doc/user/project/merge_requests/auto_merge.md | 22 ++++++ locale/gitlab.pot | 18 +++++ .../components/merge_schedule_input_spec.js | 30 ++++++++ .../components/checks/merge_time_spec.js | 36 ++++++++++ .../projects/merge_requests/creations_spec.rb | 38 ++++++++++ .../merge_requests_controller_spec.rb | 33 +++++++++ .../update_merge_schedule_service_spec.rb | 42 +++++++++++ 20 files changed, 418 insertions(+), 9 deletions(-) create mode 100644 app/assets/javascripts/merge_requests/components/merge_schedule_input.vue create mode 100644 app/assets/javascripts/pages/projects/merge_requests/mount_merge_schedule_input.js create mode 100644 app/assets/javascripts/vue_merge_request_widget/components/checks/merge_time.vue create mode 100644 app/services/merge_requests/update_merge_schedule_service.rb create mode 100644 spec/frontend/merge_requests/components/merge_schedule_input_spec.js create mode 100644 spec/frontend/vue_merge_request_widget/components/checks/merge_time_spec.js create mode 100644 spec/services/merge_requests/update_merge_schedule_service_spec.rb 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 0000000000000..94f9c45b32578 --- /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 0e66c3521dd59..0773b6aed5cc4 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 0000000000000..93b2a62488a73 --- /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 becca99b1095f..a65dfd0168cd0 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 0000000000000..7b201b0dfb9bf --- /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 bfd7c525ea9f4..56c0f75248c8c 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 4e96e4abe4257..c5f758970fd2d 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 827925db00a3d..5d8ee6815ae5f 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 efce772388de1..a448cc562f25c 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 9135a80c883b6..7d6fc42814b45 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 0000000000000..ea4566d313f39 --- /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 0c70468b9079a..b94bc68b7d7ca 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 24e04818bf6b7..aeb819588688a 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 f114a7afe3657..2f9f65e96443d 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 017d5a4770b44..ba62dac6d0f5b 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 0000000000000..9f8894986c356 --- /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 0000000000000..b7e31e8040a1a --- /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 41246d419a180..93a5e6cf5bc79 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 a9452c21dc74d..adcaa807b009b 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 0000000000000..629a7b226e648 --- /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 -- GitLab