Commit d7e01517 authored by Tetiana Chupryna's avatar Tetiana Chupryna

Merge branch '327634-display-status-checks-as-mr-widget' into 'master'

Add non-blocking MR widget for external status checks

See merge request gitlab-org/gitlab!62566
parents 0b692596 bb247896
......@@ -16,6 +16,7 @@ export const STATUS_NEUTRAL = 'neutral';
export const ICON_WARNING = 'warning';
export const ICON_SUCCESS = 'success';
export const ICON_NOTFOUND = 'notfound';
export const ICON_PENDING = 'pending';
export const status = {
LOADING,
......
import BlockingMergeRequestsBody from 'ee/vue_merge_request_widget/components/blocking_merge_requests/blocking_merge_request_body.vue';
import PerformanceIssueBody from 'ee/vue_merge_request_widget/components/performance_issue_body.vue';
import StatusCheckIssueBody from 'ee/vue_merge_request_widget/components/status_check_issue_body.vue';
import LicenseIssueBody from 'ee/vue_shared/license_compliance/components/license_issue_body.vue';
import MetricsReportsIssueBody from 'ee/vue_shared/metrics_reports/components/metrics_reports_issue_body.vue';
import SecurityIssueBody from 'ee/vue_shared/security_reports/components/security_issue_body.vue';
......@@ -10,6 +11,7 @@ import {
export const components = {
...componentsCE,
StatusCheckIssueBody,
PerformanceIssueBody,
LicenseIssueBody,
SecurityIssueBody,
......@@ -19,6 +21,7 @@ export const components = {
export const componentNames = {
...componentNamesCE,
StatusCheckIssueBody: StatusCheckIssueBody.name,
PerformanceIssueBody: PerformanceIssueBody.name,
LicenseIssueBody: LicenseIssueBody.name,
SecurityIssueBody: SecurityIssueBody.name,
......
export const APPROVED = 'approved';
export const PENDING = 'pending';
<script>
import { GlLink, GlSprintf } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { componentNames } from 'ee/reports/components/issue_body';
import { helpPagePath } from '~/helpers/help_page_helper';
import axios from '~/lib/utils/axios_utils';
import { sprintf, s__ } from '~/locale';
import ReportSection from '~/reports/components/report_section.vue';
import { status } from '~/reports/constants';
import { APPROVED, PENDING } from './constants';
export default {
name: 'StatusChecksReportsApp',
components: {
GlLink,
GlSprintf,
ReportSection,
},
componentNames,
props: {
endpoint: {
type: String,
required: true,
},
},
data() {
return {
reportStatus: status.LOADING,
statusChecks: [],
};
},
computed: {
approvedStatusChecks() {
return this.statusChecks.filter((s) => s.status === APPROVED);
},
pendingStatusChecks() {
return this.statusChecks.filter((s) => s.status === PENDING);
},
hasStatusChecks() {
return this.statusChecks.length > 0;
},
headingReportText() {
if (this.pendingStatusChecks.length > 0) {
return sprintf(s__('StatusCheck|%{pending} pending'), {
pending: this.pendingStatusChecks.length,
});
}
return s__('StatusCheck|All passed');
},
},
mounted() {
this.fetchStatusChecks();
},
methods: {
fetchStatusChecks() {
axios
.get(this.endpoint)
.then(({ data }) => {
this.statusChecks = data;
this.reportStatus = status.SUCCESS;
})
.catch((error) => {
this.reportStatus = status.ERROR;
Sentry.captureException(error);
});
},
},
i18n: {
heading: s__('StatusCheck|Status checks'),
subHeading: s__(
'StatusCheck|When this merge request is updated, a call is sent to the following APIs to confirm their status. %{linkStart}Learn more%{linkEnd}.',
),
errorText: s__('StatusCheck|Failed to load status checks.'),
},
docsLink: helpPagePath('user/project/merge_requests/approvals/index.md', {
anchor: 'notify-external-services',
}),
};
</script>
<template>
<report-section
:status="reportStatus"
:loading-text="$options.i18n.heading"
:error-text="$options.i18n.errorText"
:has-issues="hasStatusChecks"
:resolved-issues="approvedStatusChecks"
:neutral-issues="pendingStatusChecks"
:component="$options.componentNames.StatusCheckIssueBody"
:show-report-section-status-icon="false"
issues-list-container-class="gl-p-0 gl-border-top-0"
issues-ul-element-class="gl-p-0"
data-test-id="mr-status-checks"
class="mr-widget-section mr-report"
>
<template #success>
<p class="gl-line-height-normal gl-m-0">
{{ $options.i18n.heading }}
<strong class="gl-p-1">{{ headingReportText }}</strong>
</p>
</template>
<template #sub-heading>
<span class="gl-text-gray-500 gl-font-sm">
<gl-sprintf :message="$options.i18n.subHeading">
<template #link="{ content }">
<gl-link class="gl-font-sm" :href="$options.docsLink">{{ content }}</gl-link>
</template>
</gl-sprintf>
</span>
</template>
</report-section>
</template>
<script>
import SummaryRow from '~/reports/components/summary_row.vue';
import { ICON_SUCCESS, ICON_PENDING } from '~/reports/constants';
import { APPROVED } from '../../reports/status_checks_report/constants';
export default {
name: 'StatusCheckIssueBody',
components: {
SummaryRow,
},
props: {
issue: {
type: Object,
required: true,
},
},
computed: {
statusIcon() {
if (this.issue.status === APPROVED) {
return ICON_SUCCESS;
}
return ICON_PENDING;
},
},
};
</script>
<template>
<div class="gl-w-full" :data-testid="`mr-status-check-issue-${issue.id}`">
<summary-row :status-icon="statusIcon" nested-summary>
<template #summary>
<span>{{ issue.name }}, {{ issue.external_url }}</span>
</template>
</summary-row>
</div>
</template>
......@@ -3,6 +3,7 @@ import { GlSafeHtmlDirective } from '@gitlab/ui';
import GroupedBrowserPerformanceReportsApp from 'ee/reports/browser_performance_report/grouped_browser_performance_reports_app.vue';
import { componentNames } from 'ee/reports/components/issue_body';
import GroupedLoadPerformanceReportsApp from 'ee/reports/load_performance_report/grouped_load_performance_reports_app.vue';
import StatusChecksReportsApp from 'ee/reports/status_checks_report/status_checks_reports_app.vue';
import MrWidgetLicenses from 'ee/vue_shared/license_compliance/mr_widget_license_report.vue';
import GroupedMetricsReportsApp from 'ee/vue_shared/metrics_reports/grouped_metrics_reports_app.vue';
import reportsMixin from 'ee/vue_shared/security_reports/mixins/reports_mixin';
......@@ -20,6 +21,7 @@ export default {
MrWidgetGeoSecondaryNode,
MrWidgetPolicyViolation,
MrWidgetJiraAssociationMissing,
StatusChecksReportsApp,
BlockingMergeRequestsReport,
GroupedSecurityReportsApp: () =>
import('ee/vue_shared/security_reports/grouped_security_reports_app.vue'),
......@@ -101,6 +103,9 @@ export default {
this.$options.securityReportTypes.some((reportType) => enabledReports[reportType])
);
},
shouldRenderStatusReport() {
return this.mr.apiStatusChecksPath && !this.mr.isNothingToMergeState;
},
browserPerformanceText() {
const { improved, degraded, same } = this.mr.browserPerformanceMetrics;
......@@ -417,6 +422,11 @@ export default {
:endpoint="mr.accessibilityReportPath"
/>
<status-checks-reports-app
v-if="shouldRenderStatusReport"
:endpoint="mr.apiStatusChecksPath"
/>
<div class="mr-widget-section">
<component :is="componentName" :mr="mr" :service="service" />
<div class="mr-widget-info">
......
......@@ -56,6 +56,7 @@ export default class MergeRequestStore extends CEMergeRequestStore {
super.setPaths(data);
this.discoverProjectSecurityPath = data.discover_project_security_path;
this.apiStatusChecksPath = data.api_status_checks_path;
// Security scan diff paths
this.containerScanningComparisonPath = data.container_scanning_comparison_path;
......
......@@ -19,6 +19,12 @@ module EE
end
end
def api_status_checks_path
if expose_mr_status_checks?
expose_path(api_v4_projects_merge_requests_status_checks_path(id: project.id, merge_request_iid: merge_request.iid))
end
end
def merge_train_when_pipeline_succeeds_docs_path
help_page_path('ci/merge_request_pipelines/pipelines_for_merged_results/merge_trains/index.md', anchor: 'add-a-merge-request-to-a-merge-train')
end
......@@ -62,6 +68,12 @@ module EE
private
def expose_mr_status_checks?
::Feature.enabled?(:ff_compliance_approval_gates, project, default_enabled: :yaml) &&
current_user.present? &&
project.external_status_checks.any?
end
def expose_mr_approval_path?
approval_feature_available? && merge_request.iid
end
......
......@@ -17,6 +17,10 @@ module EE
presenter(merge_request).missing_security_scan_types
end
expose :api_status_checks_path do |merge_request|
presenter(merge_request).api_status_checks_path
end
expose :jira_associations, if: -> (mr) { mr.project.jira_issue_association_required_to_merge_enabled? } do
expose :enforced do |merge_request|
presenter(merge_request).project.prevent_merge_without_jira_issue
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'Merge request > User sees status checks widget', :js do
let_it_be(:user) { create(:user) }
let_it_be(:project) { create(:project, :repository) }
let_it_be(:check1) { create(:external_status_check, project: project) }
let_it_be(:check2) { create(:external_status_check, project: project) }
let_it_be(:merge_request) { create(:merge_request, source_project: project) }
let_it_be(:status_check_response) { create(:status_check_response, external_status_check: check1, merge_request: merge_request, sha: merge_request.source_branch_sha) }
shared_examples 'no status checks widget' do
it 'does not show the widget' do
expect(page).not_to have_selector('[data-test-id="mr-status-checks"]')
end
end
before do
stub_licensed_features(compliance_approval_gates: true)
end
context 'user is authorized' do
before do
project.add_maintainer(user)
sign_in(user)
visit project_merge_request_path(project, merge_request)
end
context 'feature flag is enabled' do
before do
stub_feature_flags(ff_compliance_approval_gates: true)
end
it 'shows the widget' do
expect(page).to have_content('Status checks 1 pending')
end
it 'shows the status check issues', :aggregate_failures do
within '[data-test-id="mr-status-checks"]' do
click_button 'Expand'
end
[check1, check2].each do |rule|
within "[data-testid='mr-status-check-issue-#{rule.id}']" do
icon_type = rule.approved?(merge_request, merge_request.source_branch_sha) ? 'success' : 'pending'
expect(page).to have_css(".ci-status-icon-#{icon_type}")
expect(page).to have_content("#{rule.name}, #{rule.external_url}")
end
end
end
end
context 'feature flag is disabled' do
before do
stub_feature_flags(ff_compliance_approval_gates: false)
end
it_behaves_like 'no status checks widget'
end
end
context 'user is not logged in' do
before do
visit project_merge_request_path(project, merge_request)
end
it_behaves_like 'no status checks widget'
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Grouped test reports app when mounted matches the default state component snapshot 1`] = `
"<section class=\\"media-section mr-widget-section mr-report\\" data-test-id=\\"mr-status-checks\\">
<div class=\\"media\\">
<status-icon-stub status=\\"loading\\" size=\\"24\\" class=\\"align-self-center\\"></status-icon-stub>
<div class=\\"media-body d-flex flex-align-self-center align-items-center\\">
<div data-testid=\\"report-section-code-text\\" class=\\"js-code-text code-text\\">
<div class=\\"gl-display-flex gl-align-items-center\\">
<p class=\\"gl-line-height-normal gl-m-0\\">Status checks</p>
<!---->
</div> <span class=\\"gl-text-gray-500 gl-font-sm\\">When this merge request is updated, a call is sent to the following APIs to confirm their status. <gl-link-stub href=\\"/help/user/project/merge_requests/approvals/index.md#notify-external-services\\" class=\\"gl-font-sm\\">Learn more</gl-link-stub>.</span>
</div>
<!---->
</div>
</div>
<!---->
</section>"
`;
export const approvedChecks = [
{
id: 1,
name: 'Foo',
external_url: 'http://foo',
status: 'approved',
},
];
export const pendingChecks = [
{
id: 2,
name: 'Foo Bar',
external_url: 'http://foobar',
status: 'pending',
},
];
export const mixedChecks = [...approvedChecks, ...pendingChecks];
import { GlSprintf } from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import StatusChecksReportApp from 'ee/reports/status_checks_report/status_checks_reports_app.vue';
import waitForPromises from 'helpers/wait_for_promises';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
import ReportSection from '~/reports/components/report_section.vue';
import { status as reportStatus } from '~/reports/constants';
import { approvedChecks, pendingChecks, mixedChecks } from './mock_data';
jest.mock('~/flash');
describe('Grouped test reports app', () => {
let wrapper;
let mock;
const endpoint = 'http://test';
const findReport = () => wrapper.findComponent(ReportSection);
const mountComponent = () => {
wrapper = shallowMount(StatusChecksReportApp, {
propsData: {
endpoint,
},
stubs: {
ReportSection,
GlSprintf,
},
});
};
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('when mounted', () => {
beforeEach(() => {
mock.onGet(endpoint).reply(() => new Promise(() => {}));
mountComponent();
});
it('configures the report section', () => {
expect(findReport().props()).toEqual(
expect.objectContaining({
status: reportStatus.LOADING,
component: 'StatusCheckIssueBody',
showReportSectionStatusIcon: false,
resolvedIssues: [],
neutralIssues: [],
hasIssues: false,
}),
);
});
it('matches the default state component snapshot', () => {
expect(wrapper.html()).toMatchSnapshot();
});
});
describe('when the status checks have been fetched', () => {
const mountWithResponse = (statusCode, data) => {
mock.onGet(endpoint).reply(statusCode, data);
mountComponent();
return waitForPromises();
};
describe.each`
state | response | text | resolvedIssues | neutralIssues
${'approved'} | ${approvedChecks} | ${'All passed'} | ${approvedChecks} | ${[]}
${'pending'} | ${pendingChecks} | ${'1 pending'} | ${[]} | ${pendingChecks}
${'mixed'} | ${mixedChecks} | ${'1 pending'} | ${approvedChecks} | ${pendingChecks}
`('and the status checks are $state', ({ response, text, resolvedIssues, neutralIssues }) => {
beforeEach(() => {
return mountWithResponse(httpStatus.OK, response);
});
it('sets the report status to success', () => {
expect(findReport().props('status')).toBe(reportStatus.SUCCESS);
});
it('sets the issues on the report', () => {
expect(findReport().props('hasIssues')).toBe(true);
expect(findReport().props('resolvedIssues')).toStrictEqual(resolvedIssues);
expect(findReport().props('neutralIssues')).toStrictEqual(neutralIssues);
});
it(`renders '${text}' in the report section`, () => {
expect(findReport().text()).toContain(text);
});
});
describe('and an error occurred', () => {
beforeEach(() => {
jest.spyOn(Sentry, 'captureException');
return mountWithResponse(httpStatus.NOT_FOUND);
});
it('sets the report status to error', () => {
expect(findReport().props('status')).toBe(reportStatus.ERROR);
});
it('shows the error text', () => {
expect(findReport().text()).toContain('Failed to load status checks.');
});
it('captures the error', () => {
expect(Sentry.captureException.mock.calls[0]).toEqual([expect.any(Error)]);
});
});
});
});
import { mount } from '@vue/test-utils';
import component from 'ee/vue_merge_request_widget/components/status_check_issue_body.vue';
import SummaryRow from '~/reports/components/summary_row.vue';
import { approvedChecks } from '../../reports/status_checks_report/mock_data';
describe('status check issue body', () => {
let wrapper;
const findSummaryRow = () => wrapper.findComponent(SummaryRow);
const [defaultStatusCheck] = approvedChecks;
const createComponent = (statusCheck = {}) => {
wrapper = mount(component, {
propsData: {
issue: {
...defaultStatusCheck,
...statusCheck,
},
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the status check name and external URL', () => {
expect(wrapper.text()).toBe(`${defaultStatusCheck.name}, ${defaultStatusCheck.external_url}`);
});
it.each`
status | icon
${'approved'} | ${'success'}
${'pending'} | ${'pending'}
`('sets the status-icon to $icon when the check status is $status', ({ status, icon }) => {
createComponent({ status });
expect(findSummaryRow().props('statusIcon')).toBe(icon);
});
});
......@@ -2,6 +2,7 @@ import { mount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import StatusChecksReportsApp from 'ee/reports/status_checks_report/status_checks_reports_app.vue';
import PerformanceIssueBody from 'ee/vue_merge_request_widget/components/performance_issue_body.vue';
import MrWidgetOptions from 'ee/vue_merge_request_widget/mr_widget_options.vue';
// Force Jest to transpile and cache
......@@ -98,6 +99,7 @@ describe('ee merge request widget options', () => {
const findLoadPerformanceWidget = () => wrapper.find('.js-load-performance-widget');
const findExtendedSecurityWidget = () => wrapper.find('.js-security-widget');
const findBaseSecurityWidget = () => wrapper.find('[data-testid="security-mr-widget"]');
const findStatusChecksReport = () => wrapper.findComponent(StatusChecksReportsApp);
const setBrowserPerformance = (data = {}) => {
const browserPerformance = { ...DEFAULT_BROWSER_PERFORMANCE, ...data };
......@@ -1263,4 +1265,30 @@ describe('ee merge request widget options', () => {
expect(findExtendedSecurityWidget().exists()).toBe(false);
});
});
describe.each`
path | mergeState | shouldRender
${'http://test'} | ${'readyToMerge'} | ${true}
${'http://test'} | ${'nothingToMerge'} | ${false}
${undefined} | ${'readyToMerge'} | ${false}
${undefined} | ${'nothingToMerge'} | ${false}
`('status checks widget', ({ path, mergeState, shouldRender }) => {
beforeEach(() => {
createComponent({
propsData: {
mrData: {
...mockData,
api_status_checks_path: path,
},
},
});
wrapper.vm.mr.state = mergeState;
});
it(`${
shouldRender ? 'renders' : 'does not render'
} when the path is '${path}' and the merge state is '${mergeState}'`, () => {
expect(findStatusChecksReport().exists()).toBe(shouldRender);
});
});
});
......@@ -180,4 +180,31 @@ RSpec.describe MergeRequestPresenter do
it { is_expected.to be_empty }
end
end
describe '#api_status_checks_path' do
subject { presenter.api_status_checks_path }
where(:feature_flag_enabled?, :authenticated?, :has_status_checks?, :exposes_path?) do
false | false | false | false
false | false | true | false
false | true | true | false
false | true | false | false
true | false | false | false
true | true | false | false
true | false | true | false
true | true | true | true
end
with_them do
let(:presenter) { described_class.new(merge_request, current_user: authenticated? ? user : nil) }
let(:path) { exposes_path? ? expose_path("/api/v4/projects/#{merge_request.project.id}/merge_requests/#{merge_request.iid}/status_checks") : nil }
before do
stub_feature_flags(ff_compliance_approval_gates: feature_flag_enabled?)
allow(project.external_status_checks).to receive(:any?).and_return(has_status_checks?)
end
it { is_expected.to eq(path) }
end
end
end
......@@ -31089,12 +31089,18 @@ msgstr ""
msgid "Status: %{title}"
msgstr ""
msgid "StatusCheck|%{pending} pending"
msgstr ""
msgid "StatusCheck|API to check"
msgstr ""
msgid "StatusCheck|Add status check"
msgstr ""
msgid "StatusCheck|All passed"
msgstr ""
msgid "StatusCheck|An error occurred deleting the %{name} status check."
msgstr ""
......@@ -31113,6 +31119,9 @@ msgstr ""
msgid "StatusCheck|External API is already in use by another status check."
msgstr ""
msgid "StatusCheck|Failed to load status checks."
msgstr ""
msgid "StatusCheck|Invoke an external API as part of the pipeline process."
msgstr ""
......@@ -31140,6 +31149,9 @@ msgstr ""
msgid "StatusCheck|Update status check"
msgstr ""
msgid "StatusCheck|When this merge request is updated, a call is sent to the following APIs to confirm their status. %{linkStart}Learn more%{linkEnd}."
msgstr ""
msgid "StatusCheck|You are about to remove the %{name} status check."
msgstr ""
......
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