Commit 014da62a authored by David Pisek's avatar David Pisek Committed by Ezekiel Kigbo

Add security scanner alert to project vulnerabilities

Adds an alert to the vulnerability list on the project-security dashboard
that shows based on the current scanner configuration and successful
pipeline runs.

Can be dismissed and dismissal state gets stored in local-storage.
parent 91429733
<script>
import { __ } from '~/locale';
import { GlAlert, GlDeprecatedButton, GlIntersectionObserver } from '@gitlab/ui';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import VulnerabilityList from './vulnerability_list.vue';
import vulnerabilitiesQuery from '../graphql/project_vulnerabilities.graphql';
import securityScannersQuery from '../graphql/project_security_scanners.graphql';
import { VULNERABILITIES_PER_PAGE } from '../store/constants';
export default {
......@@ -12,6 +15,7 @@ export default {
GlIntersectionObserver,
VulnerabilityList,
},
mixins: [glFeatureFlagsMixin()],
props: {
projectFullPath: {
type: String,
......@@ -27,6 +31,7 @@ export default {
return {
pageInfo: {},
vulnerabilities: [],
securityScanners: {},
errorLoadingVulnerabilities: false,
};
},
......@@ -48,6 +53,30 @@ export default {
this.errorLoadingVulnerabilities = true;
},
},
securityScanners: {
query: securityScannersQuery,
variables() {
return {
fullPath: this.projectFullPath,
};
},
error() {
this.securityScanners = {};
},
update({ project: { securityScanners = {} } = {} }) {
const { available = [], enabled = [], pipelineRun = [] } = securityScanners;
const translateScannerName = scannerName => this.$options.i18n[scannerName] || scannerName;
return {
available: available.map(translateScannerName),
enabled: enabled.map(translateScannerName),
pipelineRun: pipelineRun.map(translateScannerName),
};
},
skip() {
return !this.glFeatures.scannerAlerts;
},
},
},
computed: {
isLoadingVulnerabilities() {
......@@ -77,6 +106,11 @@ export default {
this.$apollo.queries.vulnerabilities.refetch();
},
},
i18n: {
CONTAINER_SCANNING: __('Container Scanning'),
SECRET_DETECTION: __('Secret Detection'),
DEPENDENCY_SCANNING: __('Dependency Scanning'),
},
};
</script>
......@@ -96,6 +130,7 @@ export default {
:vulnerabilities="vulnerabilities"
:should-show-identifier="true"
:should-show-report-type="true"
:security-scanners="securityScanners"
@refetch-vulnerabilities="refetchVulnerabilities"
/>
<gl-intersection-observer
......
<script>
import { GlAlert, GlIntersperse, GlLink, GlSprintf } from '@gitlab/ui';
import { n__ } from '~/locale';
export default {
components: {
GlAlert,
GlIntersperse,
GlLink,
GlSprintf,
},
props: {
notEnabledScanners: {
type: Array,
required: true,
},
noPipelineRunScanners: {
type: Array,
required: true,
},
},
inject: ['notEnabledScannersHelpPath', 'noPipelineRunScannersHelpPath'],
computed: {
alertMessages() {
return [
{
key: 'notEnabled',
documentation: this.notEnabledScannersHelpPath,
content: this.notEnabledAlertMessage,
scanners: this.notEnabledScanners,
},
{
key: 'noPipelineRun',
documentation: this.noPipelineRunScannersHelpPath,
content: this.noPipelineRunAlertMessage,
scanners: this.noPipelineRunScanners,
},
].filter(({ scanners }) => scanners.length > 0);
},
notEnabledAlertMessage() {
return n__(
'%{securityScanner} is not enabled for this project. %{linkStart}More information%{linkEnd}',
'%{securityScanner} are not enabled for this project. %{linkStart}More information%{linkEnd}',
this.notEnabledScanners.length,
);
},
noPipelineRunAlertMessage() {
return n__(
'%{securityScanner} result is not available because a pipeline has not been run since it was enabled. %{linkStart}Run a pipeline%{linkEnd}',
'%{securityScanner} results are not available because a pipeline has not been run since it was enabled. %{linkStart}Run a pipeline%{linkEnd}',
this.noPipelineRunScanners.length,
);
},
},
};
</script>
<template>
<section>
<gl-alert v-if="alertMessages.length > 0" variant="warning" @dismiss="$emit('dismiss')">
<ul class="gl-list-style-none gl-mb-0 gl-pl-0">
<li
v-for="alertMessage in alertMessages"
:key="alertMessage.key"
:data-testid="alertMessage.key"
>
<gl-sprintf :message="alertMessage.content">
<template #securityScanner>
<gl-intersperse>
<span v-for="scanner in alertMessage.scanners" :key="scanner">{{ scanner }}</span>
</gl-intersperse>
</template>
<template #link="{ content }">
<gl-link :href="alertMessage.documentation" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</li>
</ul>
</gl-alert>
</section>
</template>
<script>
import { difference } from 'lodash';
import { s__, __, sprintf } from '~/locale';
import { GlFormCheckbox, GlLink, GlSkeletonLoading, GlTable } from '@gitlab/ui';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import FiltersProducedNoResults from 'ee/security_dashboard/components/empty_states/filters_produced_no_results.vue';
import DashboardHasNoVulnerabilities from 'ee/security_dashboard/components/empty_states/dashboard_has_no_vulnerabilities.vue';
import SecurityScannerAlert from './security_scanner_alert.vue';
import SeverityBadge from 'ee/vue_shared/security_reports/components/severity_badge.vue';
import VulnerabilityCommentIcon from 'ee/security_dashboard/components/vulnerability_comment_icon.vue';
import IssueLink from 'ee/vulnerabilities/components/issue_link.vue';
......@@ -12,6 +16,9 @@ import getPrimaryIdentifier from 'ee/vue_shared/security_reports/store/utils/get
import SelectionSummary from './selection_summary.vue';
import { VULNERABILITIES_PER_PAGE } from '../store/constants';
export const SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY =
'vulnerability_list_scanner_alert_dismissed';
export default {
name: 'VulnerabilityList',
components: {
......@@ -20,7 +27,9 @@ export default {
GlSkeletonLoading,
GlTable,
IssueLink,
LocalStorageSync,
RemediatedBadge,
SecurityScannerAlert,
SelectionSummary,
SeverityBadge,
VulnerabilityCommentIcon,
......@@ -43,6 +52,11 @@ export default {
required: false,
default: false,
},
securityScanners: {
type: Object,
required: false,
default: () => ({}),
},
shouldShowSelection: {
type: Boolean,
required: false,
......@@ -66,9 +80,25 @@ export default {
data() {
return {
selectedVulnerabilities: {},
scannerAlertDismissed: 'false',
};
},
computed: {
notEnabledSecurityScanners() {
const { available = [], enabled = [] } = this.securityScanners;
return difference(available, enabled);
},
noPipelineRunSecurityScanners() {
const { enabled = [], pipelineRun = [] } = this.securityScanners;
return difference(enabled, pipelineRun);
},
shouldShowScannersAlert() {
return (
this.scannerAlertDismissed !== 'true' &&
(this.notEnabledSecurityScanners.length > 0 ||
this.noPipelineRunSecurityScanners.length > 0)
);
},
hasAnyFiltersSelected() {
return Object.keys(this.filters).length > 0;
},
......@@ -165,6 +195,9 @@ export default {
primaryIdentifier(identifiers) {
return getPrimaryIdentifier(identifiers, 'externalType');
},
setScannerAlertDismissed(value) {
this.scannerAlertDismissed = value;
},
isSelected(vulnerability = {}) {
return Boolean(this.selectedVulnerabilities[vulnerability.id]);
},
......@@ -204,11 +237,17 @@ export default {
},
},
VULNERABILITIES_PER_PAGE,
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
};
</script>
<template>
<div class="vulnerability-list">
<local-storage-sync
:value="scannerAlertDismissed"
:storage-key="$options.SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY"
@input="setScannerAlertDismissed"
/>
<selection-summary
v-if="shouldShowSelectionSummary"
:selected-vulnerabilities="Object.values(selectedVulnerabilities)"
......@@ -233,6 +272,16 @@ export default {
/>
</template>
<template v-if="shouldShowScannersAlert" #top-row>
<td :colspan="fields.length">
<security-scanner-alert
:not-enabled-scanners="notEnabledSecurityScanners"
:no-pipeline-run-scanners="noPipelineRunSecurityScanners"
@dismiss="setScannerAlertDismissed('true')"
/>
</td>
</template>
<template #cell(checkbox)="{ item }">
<gl-form-checkbox
class="d-inline-block mr-0 mb-0"
......
......@@ -70,6 +70,8 @@ export default (
dashboardDocumentation: el.dataset.dashboardDocumentation,
noVulnerabilitiesSvgPath: el.dataset.noVulnerabilitiesSvgPath,
emptyStateSvgPath: el.dataset.emptyStateSvgPath,
notEnabledScannersHelpPath: el.dataset.notEnabledScannersHelpPath,
noPipelineRunScannersHelpPath: el.dataset.noPipelineRunScannersHelpPath,
}),
render(createElement) {
return createElement(component, { props });
......
query securityScanners($fullPath: ID!) {
project(fullPath: $fullPath) {
securityScanners {
available
enabled
pipelineRun
}
}
}
......@@ -9,6 +9,7 @@ module Projects
before_action only: [:index] do
push_frontend_feature_flag(:hide_dismissed_vulnerabilities)
push_frontend_feature_flag(:scanner_alerts, default_enabled: true)
end
end
end
......
......@@ -203,6 +203,8 @@ module EE
empty_state_svg_path: image_path('illustrations/security-dashboard-empty-state.svg'),
no_vulnerabilities_svg_path: image_path('illustrations/issues.svg'),
dashboard_documentation: help_page_path('user/application_security/security_dashboard/index'),
not_enabled_scanners_help_path: help_page_path('user/application_security/index', anchor: 'quick-start'),
no_pipeline_run_scanners_help_path: new_project_pipeline_path(project),
security_dashboard_help_path: help_page_path('user/application_security/security_dashboard/index'),
user_callouts_path: user_callouts_path,
user_callout_id: UserCalloutsHelper::STANDALONE_VULNERABILITIES_INTRODUCTION_BANNER,
......
---
title: Add security scanner alerts to project to project security dashboard
merge_request: 36030
author:
type: added
......@@ -40,15 +40,7 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
});
it('passes down isLoading correctly', () => {
expect(findVulnerabilities().props()).toEqual({
filters: {},
isLoading: true,
shouldShowIdentifier: false,
shouldShowReportType: false,
shouldShowSelection: true,
shouldShowProjectNamespace: true,
vulnerabilities: [],
});
expect(findVulnerabilities().props()).toMatchObject({ isLoading: true });
});
});
......@@ -112,6 +104,7 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
filters: {},
isLoading: false,
shouldShowIdentifier: false,
securityScanners: {},
shouldShowReportType: false,
shouldShowSelection: true,
shouldShowProjectNamespace: true,
......
......@@ -62,15 +62,7 @@ describe('First Class Instance Dashboard Vulnerabilities Component', () => {
});
it('passes down isLoading correctly', () => {
expect(findVulnerabilities().props()).toEqual({
filters: {},
isLoading: true,
shouldShowIdentifier: false,
shouldShowReportType: false,
shouldShowSelection: true,
shouldShowProjectNamespace: true,
vulnerabilities: [],
});
expect(findVulnerabilities().props()).toMatchObject({ isLoading: true });
});
});
......@@ -128,6 +120,7 @@ describe('First Class Instance Dashboard Vulnerabilities Component', () => {
filters: {},
isLoading: false,
shouldShowIdentifier: false,
securityScanners: {},
shouldShowReportType: false,
shouldShowSelection: true,
shouldShowProjectNamespace: true,
......
......@@ -15,6 +15,8 @@ import ReportsNotConfigured from 'ee/security_dashboard/components/empty_states/
import CsvExportButton from 'ee/security_dashboard/components/csv_export_button.vue';
const props = {
notEnabledScannersHelpPath: '/help/docs/',
noPipelineRunScannersHelpPath: '/new/pipeline',
projectFullPath: '/group/project',
securityDashboardHelpPath: '/security/dashboard/help-path',
vulnerabilitiesExportEndpoint: '/vulnerabilities/exports',
......@@ -65,7 +67,7 @@ describe('First class Project Security Dashboard component', () => {
expect(findVulnerabilities().exists()).toBe(true);
});
it('should pass down the %s prop to the vulnerabilities', () => {
it('should pass down the "projectFullPath" prop to the vulnerabilities', () => {
expect(findVulnerabilities().props('projectFullPath')).toBe(props.projectFullPath);
});
......
......@@ -11,7 +11,7 @@ describe('Vulnerabilities app component', () => {
};
const createWrapper = ({ props = {}, $apollo = apolloMock } = {}, options = {}) => {
return shallowMount(ProjectVulnerabilitiesApp, {
wrapper = shallowMount(ProjectVulnerabilitiesApp, {
propsData: {
dashboardDocumentation: '#',
emptyStateSvgPath: '#',
......@@ -31,11 +31,12 @@ describe('Vulnerabilities app component', () => {
const findVulnerabilityList = () => wrapper.find(VulnerabilityList);
beforeEach(() => {
wrapper = createWrapper();
createWrapper();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when the vulnerabilities are loading', () => {
......@@ -97,7 +98,7 @@ describe('Vulnerabilities app component', () => {
});
});
describe("when there's a loading error", () => {
describe("when there's an error loading vulnerabilities", () => {
beforeEach(() => {
createWrapper();
wrapper.setData({ errorLoadingVulnerabilities: true });
......@@ -107,4 +108,23 @@ describe('Vulnerabilities app component', () => {
expect(findAlert().exists()).toBe(true);
});
});
describe('security scanners', () => {
const notEnabledScannersHelpPath = '#not-enabled';
const noPipelineRunScannersHelpPath = '#no-pipeline';
beforeEach(() => {
createWrapper({
props: { notEnabledScannersHelpPath, noPipelineRunScannersHelpPath },
});
});
it('should pass the security scanners to the vulnerability list', () => {
const securityScanners = { enabled: ['SAST', 'DAST'], pipelineRun: ['SAST', 'DAST'] };
wrapper.setData({ securityScanners });
expect(findVulnerabilityList().props().securityScanners).toEqual(securityScanners);
});
});
});
import { mount } from '@vue/test-utils';
import { within, fireEvent } from '@testing-library/dom';
import SecurityScannerAlert from 'ee/security_dashboard/components/security_scanner_alert.vue';
describe('EE Vulnerability Security Scanner Alert', () => {
let wrapper;
afterEach(() => {
wrapper.destroy();
});
const createWrapper = ({ props = {}, provide = {} } = {}) => {
const defaultProps = {
notEnabledScanners: [],
noPipelineRunScanners: [],
};
const defaultProvide = {
notEnabledScannersHelpPath: '',
noPipelineRunScannersHelpPath: '',
};
wrapper = mount(SecurityScannerAlert, {
propsData: { ...defaultProps, ...props },
provide: () => ({
...defaultProvide,
...provide,
}),
});
};
const withinWrapper = () => within(wrapper.element);
const findAlert = () => withinWrapper().queryByRole('alert');
const findById = testId => withinWrapper().getByTestId(testId);
describe('container', () => {
it('renders when disabled scanners are detected', () => {
createWrapper({ props: { notEnabledScanners: ['SAST'], noPipelineRunScanners: [] } });
expect(findAlert()).not.toBe(null);
});
it('renders when scanners without pipeline runs are detected', () => {
createWrapper({ props: { notEnabledScanners: [], noPipelineRunScanners: ['DAST'] } });
expect(findAlert()).not.toBe(null);
});
it('does not render when all scanners are enabled', () => {
createWrapper({ props: { notEnabledScanners: [], noPipelineRunScanners: [] } });
expect(findAlert()).toBe(null);
});
});
describe('dismissal', () => {
it('renders a button', () => {
createWrapper({ props: { notEnabledScanners: ['SAST'] } });
expect(withinWrapper().getByRole('button', { name: /dismiss/i })).not.toBe(null);
});
it('emits when the button is clicked', async () => {
createWrapper({ props: { notEnabledScanners: ['SAST'] } });
const dismissalButton = withinWrapper().getByRole('button', { name: /dismiss/i });
expect(wrapper.emitted('dismiss')).toBe(undefined);
await fireEvent.click(dismissalButton);
expect(wrapper.emitted('dismiss')).toHaveLength(1);
});
});
describe('alert text', () => {
it.each`
alertType | givenScanners | expectedTextContained
${'notEnabled'} | ${{ notEnabledScanners: ['SAST'] }} | ${'SAST is not enabled for this project'}
${'notEnabled'} | ${{ notEnabledScanners: ['SAST', 'DAST'] }} | ${'SAST, DAST are not enabled for this project'}
${'noPipelineRun'} | ${{ noPipelineRunScanners: ['SAST'] }} | ${'SAST result is not available because a pipeline has not been run since it was enabled'}
${'noPipelineRun'} | ${{ noPipelineRunScanners: ['SAST', 'DAST'] }} | ${'SAST, DAST results are not available because a pipeline has not been run since it was enabled'}
`('renders the correct warning', ({ alertType, givenScanners, expectedTextContained }) => {
createWrapper({ props: { ...givenScanners } });
expect(findById(alertType).innerText).toContain(expectedTextContained);
});
});
describe('help links', () => {
it.each`
alertType | linkText
${'notEnabled'} | ${'More information'}
${'noPipelineRun'} | ${'Run a pipeline'}
`('link for $alertType scanners renders correctly', ({ alertType, linkText }) => {
createWrapper({
props: {
[`${alertType}Scanners`]: ['SAST'],
},
provide: {
[`${alertType}ScannersHelpPath`]: 'http://foo.com/',
},
});
expect(within(findById(alertType)).getByText(linkText).href).toBe('http://foo.com/');
});
});
});
import { mount } from '@vue/test-utils';
import { GlSkeletonLoading } from '@gitlab/ui';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import SecurityScannerAlert from 'ee/security_dashboard/components/security_scanner_alert.vue';
import SelectionSummary from 'ee/security_dashboard/components/selection_summary.vue';
import VulnerabilityCommentIcon from 'ee/security_dashboard/components/vulnerability_comment_icon.vue';
import VulnerabilityList from 'ee/security_dashboard/components/vulnerability_list.vue';
import VulnerabilityList, {
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
} from 'ee/security_dashboard/components/vulnerability_list.vue';
import FiltersProducedNoResults from 'ee/security_dashboard/components/empty_states/filters_produced_no_results.vue';
import DashboardHasNoVulnerabilities from 'ee/security_dashboard/components/empty_states/dashboard_has_no_vulnerabilities.vue';
import { generateVulnerabilities, vulnerabilities } from './mock_data';
describe('Vulnerability list component', () => {
let wrapper;
useLocalStorageSpy();
const defaultData = {
selectedVulnerabilities: {},
};
let wrapper;
const createWrapper = ({ props = {}, data = defaultData }) => {
const createWrapper = ({ props = {} }) => {
return mount(VulnerabilityList, {
propsData: {
vulnerabilities: [],
......@@ -28,14 +30,16 @@ describe('Vulnerability list component', () => {
noVulnerabilitiesSvgPath: '#',
dashboardDocumentation: '#',
emptyStateSvgPath: '#',
notEnabledScannersHelpPath: '#',
noPipelineRunScannersHelpPath: '#',
}),
data: () => data,
attachToDocument: true,
});
};
const findCell = label => wrapper.find(`.js-${label}`);
const findRow = (index = 0) => wrapper.findAll('tbody tr').at(index);
const findSecurityScannerAlert = () => wrapper.find(SecurityScannerAlert);
const findDismissalButton = () => findSecurityScannerAlert().find('button[aria-label="Dismiss"]');
const findSelectionSummary = () => wrapper.find(SelectionSummary);
const findRowVulnerabilityCommentIcon = row => findRow(row).find(VulnerabilityCommentIcon);
const findDataCell = label => wrapper.find(`[data-testid="${label}"]`);
......@@ -90,14 +94,18 @@ describe('Vulnerability list component', () => {
});
});
it('should sync selected vulnerabilities when the vulnerability list is updated', () => {
it('should sync selected vulnerabilities when the vulnerability list is updated', async () => {
findDataCell('vulnerability-checkbox').setChecked(true);
await wrapper.vm.$nextTick();
expect(findSelectionSummary().props('selectedVulnerabilities')).toHaveLength(1);
wrapper.setProps({ vulnerabilities: [] });
return wrapper.vm.$nextTick().then(() => {
expect(findSelectionSummary().exists()).toBe(false);
});
await wrapper.vm.$nextTick();
expect(findSelectionSummary().exists()).toBe(false);
});
});
......@@ -323,4 +331,64 @@ describe('Vulnerability list component', () => {
expect(findDashboardHasNoVulnerabilities().exists()).toEqual(false);
});
});
describe('security scanner alerts', () => {
describe.each`
available | enabled | pipelineRun | expectAlertShown
${['DAST']} | ${[]} | ${[]} | ${true}
${['DAST']} | ${['DAST']} | ${[]} | ${true}
${['DAST']} | ${[]} | ${['DAST']} | ${true}
${['DAST']} | ${['DAST']} | ${['DAST']} | ${false}
${[]} | ${[]} | ${[]} | ${false}
`('visibility', ({ available, enabled, pipelineRun, expectAlertShown }) => {
it(`should${expectAlertShown ? '' : ' not'} show the alert`, () => {
wrapper = createWrapper({
props: { securityScanners: { available, enabled, pipelineRun } },
});
expect(findSecurityScannerAlert().exists()).toBe(expectAlertShown);
});
it('should never show the alert once it has been dismissed', async () => {
window.localStorage.setItem(SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY, 'true');
wrapper = createWrapper({
props: { securityScanners: { available, enabled, pipelineRun } },
});
await wrapper.vm.$nextTick();
expect(findSecurityScannerAlert().exists()).toBe(false);
});
});
describe('dismissal', () => {
beforeEach(() => {
wrapper = createWrapper({
props: { securityScanners: { available: ['DAST'], enabled: [] } },
});
});
it('should hide the alert when it is dismissed', async () => {
expect(findSecurityScannerAlert().exists()).toBe(true);
findDismissalButton().trigger('click');
await wrapper.vm.$nextTick();
expect(findSecurityScannerAlert().exists()).toBe(false);
});
it('should remember the dismissal state', async () => {
findDismissalButton().trigger('click');
await wrapper.vm.$nextTick();
expect(window.localStorage.setItem.mock.calls).toContainEqual([
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
'true',
]);
});
});
});
});
......@@ -131,7 +131,9 @@ RSpec.describe ProjectsHelper do
security_dashboard_help_path: '/help/user/application_security/security_dashboard/index',
user_callouts_path: '/-/user_callouts',
user_callout_id: 'standalone_vulnerabilities_introduction_banner',
show_introduction_banner: 'true'
show_introduction_banner: 'true',
not_enabled_scanners_help_path: help_page_path('user/application_security/index', anchor: 'quick-start'),
no_pipeline_run_scanners_help_path: new_project_pipeline_path(project)
}
end
......
......@@ -601,6 +601,16 @@ msgstr ""
msgid "%{retryButtonStart}Try again%{retryButtonEnd} or %{newFileButtonStart}attach a new file%{newFileButtonEnd}"
msgstr ""
msgid "%{securityScanner} is not enabled for this project. %{linkStart}More information%{linkEnd}"
msgid_plural "%{securityScanner} are not enabled for this project. %{linkStart}More information%{linkEnd}"
msgstr[0] ""
msgstr[1] ""
msgid "%{securityScanner} result is not available because a pipeline has not been run since it was enabled. %{linkStart}Run a pipeline%{linkEnd}"
msgid_plural "%{securityScanner} results are not available because a pipeline has not been run since it was enabled. %{linkStart}Run a pipeline%{linkEnd}"
msgstr[0] ""
msgstr[1] ""
msgid "%{service_title} %{message}."
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