+import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+import Icon from '~/vue_shared/components/icon.vue';
+import { n__ } from '~/locale';
+
+export default {
+ name: 'BlockingMergeRequestsBody',
+ components: { RelatedIssuableItem, Icon },
+ props: {
+ issue: {
+ type: Object,
+ required: true,
+ },
+ status: {
+ type: String,
+ required: true,
+ },
+ isNew: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ hiddenBlockingMRsText() {
+ return n__(
+ "%d merge request that you don't have access to.",
+ "%d merge requests that you don't have access to.",
+ this.issue.hiddenCount,
+ );
+ },
+ },
+};
+
+
+
+
+
+ {{ hiddenBlockingMRsText }}
+
+
+
diff --git a/ee/app/assets/javascripts/vue_merge_request_widget/components/blocking_merge_requests/blocking_merge_requests_report.vue b/ee/app/assets/javascripts/vue_merge_request_widget/components/blocking_merge_requests/blocking_merge_requests_report.vue
new file mode 100644
index 0000000000000000000000000000000000000000..91ae06fd8b38ebe76c94e36dbe2cd486adf7dfc2
--- /dev/null
+++ b/ee/app/assets/javascripts/vue_merge_request_widget/components/blocking_merge_requests/blocking_merge_requests_report.vue
@@ -0,0 +1,120 @@
+
+
+
+
+
+ {{ __('No blocking merge requests ') }}
+
+ {{
+ sprintf(__('(%{mrCount} merged)'), {
+ mrCount: blockingMergeRequests.total_count - unmergedBlockingMergeRequests.length,
+ })
+ }}
+
+
+
+
+
+
+
diff --git a/ee/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/ee/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
index 1fea3b6570b22e66cc510d18b045893ed1d13ae7..317fb45bb3fd0d3b1a7257876a64b0d4abf6d332 100644
--- a/ee/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
+++ b/ee/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue
@@ -5,6 +5,7 @@ import GroupedMetricsReportsApp from 'ee/vue_shared/metrics_reports/grouped_metr
import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin';
import { componentNames } from 'ee/vue_shared/components/reports/issue_body';
import MrWidgetLicenses from 'ee/vue_shared/license_management/mr_widget_license_report.vue';
+import BlockingMergeRequestsReport from './components/blocking_merge_requests/blocking_merge_requests_report.vue';
import { n__, s__, __, sprintf } from '~/locale';
import CEWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue';
@@ -16,6 +17,7 @@ export default {
MrWidgetLicenses,
MrWidgetApprovals,
MrWidgetGeoSecondaryNode,
+ BlockingMergeRequestsReport,
GroupedSecurityReportsApp,
GroupedMetricsReportsApp,
ReportSection,
@@ -216,6 +218,7 @@ export default {
:service="service"
/>
+
(mr, _) { mr&.target_project&.feature_available?(:blocking_merge_requests) }
+
+ private
+
+ def blocking_merge_requests
+ visible_mrs_by_state = Hash.new { |h, k| h[k] = [] }
+ visible_count = 0
+ hidden_blocking_count = 0
+
+ object.blocking_merge_requests.each do |mr|
+ if can?(current_user, :read_merge_request, mr)
+ visible_mrs_by_state[mr.state_name] << represent_blocking_mr(mr)
+ visible_count += 1
+ elsif !mr.merged? # Ignore merged hidden MRs to make display simpler
+ hidden_blocking_count += 1
+ end
+ end
+
+ {
+ total_count: visible_count + hidden_blocking_count,
+ hidden_count: hidden_blocking_count,
+ visible_merge_requests: visible_mrs_by_state
+ }
+ end
end
- private
+ def represent_blocking_mr(blocking_mr)
+ blocking_mr_options = options.merge(from_project: object.target_project)
+
+ ::BlockingMergeRequestEntity.represent(blocking_mr, blocking_mr_options)
+ end
def head_pipeline_downloadable_path_for_report_type(file_type)
object.head_pipeline&.present(current_user: current_user)
diff --git a/ee/changelogs/unreleased/9688-fe-mr-merge-order.yml b/ee/changelogs/unreleased/9688-fe-mr-merge-order.yml
new file mode 100644
index 0000000000000000000000000000000000000000..a2f3cb09611ed4f889f04177815a598e36e3cf5c
--- /dev/null
+++ b/ee/changelogs/unreleased/9688-fe-mr-merge-order.yml
@@ -0,0 +1,5 @@
+---
+title: When a merge request is blocked by other unmerged merge requests, display them on the show page of a merge request
+merge_request: 12357
+author:
+type: added
diff --git a/ee/spec/features/merge_request/user_views_blocked_merge_request_spec.rb b/ee/spec/features/merge_request/user_views_blocked_merge_request_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..3d2195bb500bc373176430c30aba62a4a843a0eb
--- /dev/null
+++ b/ee/spec/features/merge_request/user_views_blocked_merge_request_spec.rb
@@ -0,0 +1,68 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe 'Merge Request > User views blocked MR', :js do
+ let(:block) { create(:merge_request_block) }
+ let(:blocking_mr) { block.blocking_merge_request }
+ let(:blocked_mr) { block.blocked_merge_request }
+ let(:project) { blocked_mr.target_project }
+ let(:user) { create(:user) }
+
+ let(:merge_button) { find('.qa-merge-button') }
+
+ before do
+ project.add_developer(user)
+
+ sign_in(user)
+ end
+
+ context 'blocking merge requests are disabled' do
+ before do
+ stub_licensed_features(blocking_merge_requests: false)
+ end
+
+ it 'is mergeable' do
+ visit project_merge_request_path(project, blocked_mr)
+
+ expect(page).to have_button('Merge', disabled: false)
+ end
+ end
+
+ context 'blocking merge requests are enabled' do
+ before do
+ stub_licensed_features(blocking_merge_requests: true)
+ end
+
+ context 'blocking MR is not visible' do
+ it 'is not mergeable' do
+ visit project_merge_request_path(project, blocked_mr)
+
+ expect(page).to have_content('Blocked by 1 merge request')
+ expect(page).to have_button('Merge', disabled: true)
+
+ click_button 'Expand'
+
+ expect(page).not_to have_content(blocking_mr.title)
+ expect(page).to have_content("1 merge request that you don't have access to")
+ end
+ end
+
+ context 'blocking MR is visible' do
+ before do
+ blocking_mr.target_project.add_developer(user)
+ end
+
+ it 'is not mergeable' do
+ visit project_merge_request_path(project, blocked_mr)
+
+ expect(page).to have_content('Blocked by 1 merge request')
+ expect(page).to have_button('Merge', disabled: true)
+
+ click_button 'Expand'
+
+ expect(page).to have_content(blocking_mr.title)
+ end
+ end
+ end
+end
diff --git a/ee/spec/frontend/vue_mr_widget/components/blocking_merge_requests/blocking_merge_requests_body_spec.js b/ee/spec/frontend/vue_mr_widget/components/blocking_merge_requests/blocking_merge_requests_body_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..a99e796d4e9467abdac1ad5d649b5d68ae28cffe
--- /dev/null
+++ b/ee/spec/frontend/vue_mr_widget/components/blocking_merge_requests/blocking_merge_requests_body_spec.js
@@ -0,0 +1,30 @@
+import { shallowMount } from '@vue/test-utils';
+import BlockingMergeRequestBody from 'ee/vue_merge_request_widget/components/blocking_merge_requests/blocking_merge_request_body.vue';
+import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue';
+
+describe('BlockingMergeRequestBody', () => {
+ it('shows hidden merge request text if hidden MRs exist', () => {
+ const wrapper = shallowMount(BlockingMergeRequestBody, {
+ propsData: {
+ issue: { hiddenCount: 10000000, id: 10 },
+ status: 'string',
+ isNew: true,
+ },
+ });
+
+ expect(wrapper.html()).toContain("merge requests that you don't have access to");
+ });
+
+ it('does not show hidden merge request if hidden MRs do not exist', () => {
+ const wrapper = shallowMount(BlockingMergeRequestBody, {
+ propsData: {
+ issue: {},
+ status: 'string',
+ isNew: true,
+ },
+ });
+
+ expect(wrapper.html()).not.toContain("merge requests that you don't have access to");
+ expect(wrapper.find(RelatedIssuableItem).exists()).toBe(true);
+ });
+});
diff --git a/ee/spec/frontend/vue_mr_widget/components/blocking_merge_requests/blocking_merge_requests_report_spec.js b/ee/spec/frontend/vue_mr_widget/components/blocking_merge_requests/blocking_merge_requests_report_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..5461ac5c523b275e482f607868fab7d63d2478c8
--- /dev/null
+++ b/ee/spec/frontend/vue_mr_widget/components/blocking_merge_requests/blocking_merge_requests_report_spec.js
@@ -0,0 +1,133 @@
+import { createLocalVue, shallowMount, config } from '@vue/test-utils';
+import BlockingMergeRequestsReport from 'ee/vue_merge_request_widget/components/blocking_merge_requests/blocking_merge_requests_report.vue';
+import ReportSection from '~/reports/components/report_section.vue';
+import { status as reportStatus } from '~/reports/constants';
+
+const localVue = createLocalVue();
+
+describe('BlockingMergeRequestsReport', () => {
+ let wrapper;
+ let props;
+
+ // Remove these hooks once we update @vue/test-utils
+ // See this issue: https://github.com/vuejs/vue-test-utils/issues/973
+ beforeAll(() => {
+ config.logModifiedComponents = false;
+ });
+
+ afterAll(() => {
+ config.logModifiedComponents = true;
+ });
+
+ beforeEach(() => {
+ props = {
+ mr: {
+ blockingMergeRequests: {
+ total_count: 3,
+ hidden_count: 0,
+ visible_merge_requests: {
+ opened: [{ id: 1, state: 'opened' }],
+ closed: [{ id: 2, state: 'closed' }],
+ merged: [{ id: 3, state: 'merged' }],
+ },
+ },
+ },
+ };
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const createComponent = (propsData = props) => {
+ wrapper = shallowMount(BlockingMergeRequestsReport, {
+ propsData,
+ localVue,
+ });
+ };
+
+ it('does not render blocking merge requests report if no blocking MRs exist', () => {
+ props.mr.blockingMergeRequests.total_count = 0;
+ props.mr.blockingMergeRequests.visible_merge_requests = {};
+ createComponent(props);
+
+ expect(wrapper.html()).toBeUndefined();
+ });
+
+ it('passes merged MRs as resolved issues and anything else as unresolved ', () => {
+ createComponent();
+ const reportSectionProps = wrapper.find(ReportSection).props();
+
+ expect(reportSectionProps.resolvedIssues).toHaveLength(1);
+ expect(reportSectionProps.resolvedIssues[0].id).toBe(3);
+ });
+
+ it('passes all non "merged" MRs as unresolved issues', () => {
+ createComponent();
+ const reportSectionProps = wrapper.find(ReportSection).props();
+
+ expect(reportSectionProps.unresolvedIssues.map(issue => issue.id)).toEqual([2, 1]);
+ });
+
+ it('sets status to "ERROR" when there are unmerged blocking MRs', () => {
+ createComponent();
+
+ expect(wrapper.find(ReportSection).props().status).toBe(reportStatus.ERROR);
+ });
+
+ it('sets status to "SUCCESS" when all blocking MRs are merged', () => {
+ props.mr.blockingMergeRequests.total_count = 1;
+ props.mr.blockingMergeRequests.visible_merge_requests = {
+ merged: [{ id: 3, state: 'merged' }],
+ };
+ createComponent();
+
+ expect(wrapper.find(ReportSection).props().status).toBe(reportStatus.SUCCESS);
+ });
+
+ describe('blockedByText', () => {
+ it('contains closed information if some are closed, but not all', () => {
+ createComponent();
+
+ expect(wrapper.vm.blockedByText).toBe(
+ 'Blocked by 2 merge requests (1 closed)',
+ );
+ });
+
+ it('does not contain closed information if no blocking MRs are closed', () => {
+ delete props.mr.blockingMergeRequests.visible_merge_requests.closed;
+ createComponent();
+
+ expect(wrapper.vm.blockedByText).not.toContain('closed');
+ });
+
+ it('states when all blocking mrs are closed', () => {
+ delete props.mr.blockingMergeRequests.visible_merge_requests.opened;
+ createComponent();
+
+ expect(wrapper.vm.blockedByText).toEqual(
+ 'Blocked by 1 closed merge request.',
+ );
+ });
+ });
+
+ describe('unmergedBlockingMergeRequests', () => {
+ it('does not include merged MRs', () => {
+ createComponent();
+ const containsMergedMRs = wrapper.vm.unmergedBlockingMergeRequests.some(
+ mr => mr.state === 'merged',
+ );
+
+ expect(containsMergedMRs).toBe(false);
+ });
+
+ it('puts closed MRs first', () => {
+ createComponent();
+ const closedIndex = wrapper.vm.unmergedBlockingMergeRequests.findIndex(
+ mr => mr.state === 'closed',
+ );
+
+ expect(closedIndex).toBe(0);
+ });
+ });
+});
diff --git a/ee/spec/javascripts/vue_shared/components/reports/report_item_spec.js b/ee/spec/javascripts/vue_shared/components/reports/report_item_spec.js
index 80ad296a4cc4aae7dd781be8e73bae7ffeeb5d91..94ea04b0f6c0324d4af0a6a4962ccc1000039704 100644
--- a/ee/spec/javascripts/vue_shared/components/reports/report_item_spec.js
+++ b/ee/spec/javascripts/vue_shared/components/reports/report_item_spec.js
@@ -125,4 +125,33 @@ describe('Report issue', () => {
);
});
});
+
+ describe('showReportSectionStatusIcon', () => {
+ it('does not render CI Status Icon when showReportSectionStatusIcon is false', () => {
+ vm = mountComponentWithStore(ReportIssue, {
+ store,
+ props: {
+ issue: parsedDast[0],
+ component: componentNames.DastIssueBody,
+ status: STATUS_SUCCESS,
+ showReportSectionStatusIcon: false,
+ },
+ });
+
+ expect(vm.$el.querySelectorAll('.report-block-list-icon')).toHaveLength(0);
+ });
+
+ it('shows status icon when unspecified', () => {
+ vm = mountComponentWithStore(ReportIssue, {
+ store,
+ props: {
+ issue: parsedDast[0],
+ component: componentNames.DastIssueBody,
+ status: STATUS_SUCCESS,
+ },
+ });
+
+ expect(vm.$el.querySelectorAll('.report-block-list-icon')).toHaveLength(1);
+ });
+ });
});
diff --git a/ee/spec/serializers/blocking_merge_request_entity_spec.rb b/ee/spec/serializers/blocking_merge_request_entity_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..8301cc3a5ca1678f10801a83e904cc3f7d2e4f78
--- /dev/null
+++ b/ee/spec/serializers/blocking_merge_request_entity_spec.rb
@@ -0,0 +1,46 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe BlockingMergeRequestEntity do
+ set(:merge_request) { create(:merge_request) }
+ set(:user) { create(:user) }
+
+ let(:web_url) { Gitlab::Routing.url_helpers.project_merge_request_path(merge_request.project, merge_request) }
+ let(:request) { double('request', current_user: user) }
+ let(:extra_options) { {} }
+
+ subject(:entity) do
+ options = extra_options.merge(current_user: user, request: request)
+ described_class.new(merge_request, options)
+ end
+
+ it 'exposes simple attributes' do
+ expect(entity.as_json).to include(
+ id: merge_request.id,
+ iid: merge_request.iid,
+ title: merge_request.title,
+ state: merge_request.state,
+ created_at: merge_request.created_at,
+ merged_at: merge_request.merged_at,
+ closed_at: merge_request.metrics.latest_closed_at,
+ web_url: web_url
+ )
+ end
+
+ describe '#reference' do
+ let(:other_project) { create(:project) }
+
+ subject { entity.as_json[:reference] }
+
+ it { is_expected.to eq(merge_request.to_reference) }
+
+ context 'from another project' do
+ let(:extra_options) { { from_project: other_project } }
+
+ it 'includes the fully-qualified reference when needed' do
+ is_expected.to eq(merge_request.to_reference(other_project))
+ end
+ end
+ end
+end
diff --git a/ee/spec/serializers/merge_request_widget_entity_spec.rb b/ee/spec/serializers/merge_request_widget_entity_spec.rb
index ef939bb6d3c46d9e504ec90d36672804f00dfd72..6e68c175e7d28af95f1c2fb4ea8725c0881a0191 100644
--- a/ee/spec/serializers/merge_request_widget_entity_spec.rb
+++ b/ee/spec/serializers/merge_request_widget_entity_spec.rb
@@ -14,7 +14,7 @@
project.add_developer(user)
end
- subject do
+ subject(:entity) do
described_class.new(merge_request, current_user: user, request: request)
end
@@ -227,4 +227,56 @@
end
end
end
+
+ describe 'blocking merge requests' do
+ set(:merge_request_block) { create(:merge_request_block, blocked_merge_request: merge_request) }
+
+ let(:blocking_mr) { merge_request_block.blocking_merge_request }
+
+ subject { entity.as_json[:blocking_merge_requests] }
+
+ context 'feature disabled' do
+ before do
+ stub_licensed_features(blocking_merge_requests: false)
+ end
+
+ it 'does not have the blocking_merge_requests member' do
+ expect(entity.as_json).not_to include(:blocking_merge_requests)
+ end
+ end
+
+ context 'feature enabled' do
+ before do
+ stub_licensed_features(blocking_merge_requests: true)
+ end
+
+ it 'shows the blocking merge request if visible' do
+ blocking_mr.project.add_developer(user)
+
+ is_expected.to include(
+ hidden_count: 0,
+ total_count: 1,
+ visible_merge_requests: { opened: [kind_of(BlockingMergeRequestEntity)] }
+ )
+ end
+
+ it 'hides the blocking merge request if not visible' do
+ is_expected.to eq(
+ hidden_count: 1,
+ total_count: 1,
+ visible_merge_requests: {}
+ )
+ end
+
+ it 'does not count a merged and hidden blocking MR' do
+ blocking_mr.update_columns(state: 'merged')
+
+ is_expected.to eq(
+ hidden_count: 0,
+ total_count: 0,
+ visible_merge_requests: {}
+ )
+ end
+ end
+ end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 3f2f1f4715d5b62db40ec1d012a831512d6377df..0547bfd9d847fe31642f5a802c335fad7d8b92f5 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -105,6 +105,11 @@ msgid_plural "%d merge requests"
msgstr[0] ""
msgstr[1] ""
+msgid "%d merge request that you don't have access to."
+msgid_plural "%d merge requests that you don't have access to."
+msgstr[0] ""
+msgstr[1] ""
+
msgid "%d metric"
msgid_plural "%d metrics"
msgstr[0] ""
@@ -315,6 +320,14 @@ msgstr ""
msgid "'%{source}' is not a import source"
msgstr ""
+msgid "(%d closed)"
+msgid_plural "(%d closed)"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "(%{mrCount} merged)"
+msgstr ""
+
msgid "(No changes)"
msgstr ""
@@ -1944,6 +1957,16 @@ msgstr ""
msgid "Blocked"
msgstr ""
+msgid "Blocked by %d merge request"
+msgid_plural "Blocked by %d merge requests"
+msgstr[0] ""
+msgstr[1] ""
+
+msgid "Blocked by %d closed merge request."
+msgid_plural "Blocked by %d closed merge requests."
+msgstr[0] ""
+msgstr[1] ""
+
msgid "Blog"
msgstr ""
@@ -8638,6 +8661,9 @@ msgstr ""
msgid "No available namespaces to fork the project."
msgstr ""
+msgid "No blocking merge requests "
+msgstr ""
+
msgid "No branches found"
msgstr ""
diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/reports/components/report_section_spec.js
index 3b609484b9e964483f04ea1f9e7b0caa85238286..d4a3073374a81befaa6e152175a8a65091dd849b 100644
--- a/spec/frontend/reports/components/report_section_spec.js
+++ b/spec/frontend/reports/components/report_section_spec.js
@@ -197,4 +197,44 @@ describe('Report section', () => {
expect(vm.$el.querySelector('.js-collapse-btn').textContent.trim()).toEqual('Expand');
});
});
+
+ describe('Success and Error slots', () => {
+ const createComponent = status => {
+ vm = mountComponentWithSlots(ReportSection, {
+ props: {
+ status,
+ hasIssues: true,
+ },
+ slots: {
+ success: ['This is a success'],
+ loading: ['This is loading'],
+ error: ['This is an error'],
+ },
+ });
+ };
+
+ it('only renders success slot when status is "SUCCESS"', () => {
+ createComponent('SUCCESS');
+
+ expect(vm.$el.textContent.trim()).toContain('This is a success');
+ expect(vm.$el.textContent.trim()).not.toContain('This is an error');
+ expect(vm.$el.textContent.trim()).not.toContain('This is loading');
+ });
+
+ it('only renders error slot when status is "ERROR"', () => {
+ createComponent('ERROR');
+
+ expect(vm.$el.textContent.trim()).toContain('This is an error');
+ expect(vm.$el.textContent.trim()).not.toContain('This is a success');
+ expect(vm.$el.textContent.trim()).not.toContain('This is loading');
+ });
+
+ it('only renders loading slot when status is "LOADING"', () => {
+ createComponent('LOADING');
+
+ expect(vm.$el.textContent.trim()).toContain('This is loading');
+ expect(vm.$el.textContent.trim()).not.toContain('This is an error');
+ expect(vm.$el.textContent.trim()).not.toContain('This is a success');
+ });
+ });
});