diff --git a/app/assets/javascripts/reports/constants.js b/app/assets/javascripts/reports/constants.js index acd90ebf1b1bf7b7a971b4fd14dd34a505704057..7f7ea2adc0e46457419600c56b5e9cf680f658ab 100644 --- a/app/assets/javascripts/reports/constants.js +++ b/app/assets/javascripts/reports/constants.js @@ -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, diff --git a/ee/app/assets/javascripts/reports/components/issue_body.js b/ee/app/assets/javascripts/reports/components/issue_body.js index 86a5a1d3305faa18f663677dc641dc1103ca9545..bcaf11779c960e8a566467d9599e32e4029f658c 100644 --- a/ee/app/assets/javascripts/reports/components/issue_body.js +++ b/ee/app/assets/javascripts/reports/components/issue_body.js @@ -1,5 +1,6 @@ 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, diff --git a/ee/app/assets/javascripts/reports/status_checks_report/constants.js b/ee/app/assets/javascripts/reports/status_checks_report/constants.js new file mode 100644 index 0000000000000000000000000000000000000000..2b2294222d4989ab33a08485c60a49b46c2cf971 --- /dev/null +++ b/ee/app/assets/javascripts/reports/status_checks_report/constants.js @@ -0,0 +1,3 @@ +export const APPROVED = 'approved'; + +export const PENDING = 'pending'; diff --git a/ee/app/assets/javascripts/reports/status_checks_report/status_checks_reports_app.vue b/ee/app/assets/javascripts/reports/status_checks_report/status_checks_reports_app.vue new file mode 100644 index 0000000000000000000000000000000000000000..ddc21692dd37488f686348dc2ac2374afa5b8f17 --- /dev/null +++ b/ee/app/assets/javascripts/reports/status_checks_report/status_checks_reports_app.vue @@ -0,0 +1,113 @@ +<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> diff --git a/ee/app/assets/javascripts/vue_merge_request_widget/components/status_check_issue_body.vue b/ee/app/assets/javascripts/vue_merge_request_widget/components/status_check_issue_body.vue new file mode 100644 index 0000000000000000000000000000000000000000..b575b0576e0492458876192e132b66c73730c31c --- /dev/null +++ b/ee/app/assets/javascripts/vue_merge_request_widget/components/status_check_issue_body.vue @@ -0,0 +1,36 @@ +<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> 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 5f30d87669689c6fd9b60a0399d3e5311428ffb0..f84e63ff5a3c398155c2640f5eff41e1efc45331 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 @@ -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"> diff --git a/ee/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js b/ee/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js index af5e01eb161a9aa7585f429c864ca65444a36b0b..2035aa1165da8bfbb042aabcafb9a330ca16e04a 100644 --- a/ee/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js +++ b/ee/app/assets/javascripts/vue_merge_request_widget/stores/mr_widget_store.js @@ -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; diff --git a/ee/app/presenters/ee/merge_request_presenter.rb b/ee/app/presenters/ee/merge_request_presenter.rb index 9af6caee12c70d82de98dd8e4202787347ac38a5..75b92b400413fdd17d61e6ddd25890b768f17ff1 100644 --- a/ee/app/presenters/ee/merge_request_presenter.rb +++ b/ee/app/presenters/ee/merge_request_presenter.rb @@ -19,6 +19,12 @@ def api_project_approval_settings_path 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 @@ def issue_keys 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 diff --git a/ee/app/serializers/ee/merge_request_poll_cached_widget_entity.rb b/ee/app/serializers/ee/merge_request_poll_cached_widget_entity.rb index e3e696d78a5189244b16543cf2f60560bc534d8c..8b51c23eca8c6d81e926b3095a5e63b8e5dba327 100644 --- a/ee/app/serializers/ee/merge_request_poll_cached_widget_entity.rb +++ b/ee/app/serializers/ee/merge_request_poll_cached_widget_entity.rb @@ -17,6 +17,10 @@ module MergeRequestPollCachedWidgetEntity 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 diff --git a/ee/spec/features/merge_request/user_sees_status_checks_widget_spec.rb b/ee/spec/features/merge_request/user_sees_status_checks_widget_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..e349bbc1444069d72812a984e5660047f983a31d --- /dev/null +++ b/ee/spec/features/merge_request/user_sees_status_checks_widget_spec.rb @@ -0,0 +1,72 @@ +# 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 diff --git a/ee/spec/frontend/reports/status_checks_report/__snapshots__/status_checks_reports_app_spec.js.snap b/ee/spec/frontend/reports/status_checks_report/__snapshots__/status_checks_reports_app_spec.js.snap new file mode 100644 index 0000000000000000000000000000000000000000..3cea1a397cd9738ba33620f4843e6bd59b070bce --- /dev/null +++ b/ee/spec/frontend/reports/status_checks_report/__snapshots__/status_checks_reports_app_spec.js.snap @@ -0,0 +1,19 @@ +// 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>" +`; diff --git a/ee/spec/frontend/reports/status_checks_report/mock_data.js b/ee/spec/frontend/reports/status_checks_report/mock_data.js new file mode 100644 index 0000000000000000000000000000000000000000..490d24ea984e6086a7fb582feaa39e726ef9a926 --- /dev/null +++ b/ee/spec/frontend/reports/status_checks_report/mock_data.js @@ -0,0 +1,19 @@ +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]; diff --git a/ee/spec/frontend/reports/status_checks_report/status_checks_reports_app_spec.js b/ee/spec/frontend/reports/status_checks_report/status_checks_reports_app_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cb8c81af05b3f52507841d2ff262d215b71ccea1 --- /dev/null +++ b/ee/spec/frontend/reports/status_checks_report/status_checks_reports_app_spec.js @@ -0,0 +1,120 @@ +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)]); + }); + }); + }); +}); diff --git a/ee/spec/frontend/vue_mr_widget/components/status_check_issue_body_spec.js b/ee/spec/frontend/vue_mr_widget/components/status_check_issue_body_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..cf957a6d322e02f1747ba44098133a372079ca15 --- /dev/null +++ b/ee/spec/frontend/vue_mr_widget/components/status_check_issue_body_spec.js @@ -0,0 +1,46 @@ +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); + }); +}); diff --git a/ee/spec/frontend/vue_mr_widget/ee_mr_widget_options_spec.js b/ee/spec/frontend/vue_mr_widget/ee_mr_widget_options_spec.js index dbe874b5f11d65a1a8dae4d04d9f134c6263a7cd..cfbe11b10371bbe1e1104e423dfd909c3b6b4133 100644 --- a/ee/spec/frontend/vue_mr_widget/ee_mr_widget_options_spec.js +++ b/ee/spec/frontend/vue_mr_widget/ee_mr_widget_options_spec.js @@ -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); + }); + }); }); diff --git a/ee/spec/presenters/merge_request_presenter_spec.rb b/ee/spec/presenters/merge_request_presenter_spec.rb index b49d8e8a1e4443836f99cefdeb0bf54f938fce7e..2705df0713b3fdb8bdef540e039e6cae383e846a 100644 --- a/ee/spec/presenters/merge_request_presenter_spec.rb +++ b/ee/spec/presenters/merge_request_presenter_spec.rb @@ -180,4 +180,31 @@ 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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index d0d24dfeb2e4bc748452ae0affc5f35719b28694..af53385a17290c643221325fac19baddf7450a76 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 ""