Commit 83202c88 authored by Andrew Fontaine's avatar Andrew Fontaine

Merge branch...

Merge branch '300143-jira-integration-fe-on-a-project-s-vulnerability-report-show-count-of-related-jira-issues' into 'master'

JIRA Integration - On a project's vulnerability report, show count of related Jira issues within a rows activity column

See merge request gitlab-org/gitlab!53321
parents 9b22eb48 627eee05
......@@ -15,6 +15,11 @@ export default {
type: Array,
required: true,
},
isJira: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
numberOfIssues() {
......@@ -40,9 +45,11 @@ export default {
<template #title>
{{ popoverTitle }}
</template>
<div v-for="{ issue } in issues" :key="issue.iid">
<issue-link :issue="issue" />
</div>
<ul class="gl-list-style-none gl-p-0 gl-m-0">
<li v-for="{ issue } in issues" :key="issue.iid">
<issue-link :issue="issue" :is-jira="isJira" />
</li>
</ul>
</gl-popover>
</div>
</template>
......@@ -16,7 +16,14 @@ export default {
GlIntersectionObserver,
VulnerabilityList,
},
inject: ['projectFullPath'],
inject: {
projectFullPath: {
default: '',
},
hasJiraVulnerabilitiesIntegrationEnabled: {
default: false,
},
},
props: {
filters: {
type: Object,
......@@ -42,6 +49,7 @@ export default {
fullPath: this.projectFullPath,
first: VULNERABILITIES_PER_PAGE,
sort: this.sort,
includeExternalIssueLinks: this.hasJiraVulnerabilitiesIntegrationEnabled,
...this.filters,
};
},
......
......@@ -52,7 +52,15 @@ export default {
directives: {
GlTooltip: GlTooltipDirective,
},
inject: ['hasVulnerabilities'],
inject: {
hasVulnerabilities: {
default: false,
},
hasJiraVulnerabilitiesIntegrationEnabled: {
default: false,
},
},
props: {
filters: {
type: Object,
......@@ -275,9 +283,20 @@ export default {
this.$set(this.selectedVulnerabilities, `${vulnerability.id}`, vulnerability);
}
},
issues(item) {
gitlabIssues(item) {
return item.issueLinks?.nodes || [];
},
externalIssues(item) {
return item.externalIssueLinks?.nodes || [];
},
jiraIssues(item) {
return this.externalIssues(item).filter(({ issue }) => issue?.externalTracker === 'jira');
},
badgeIssues(item) {
return this.hasJiraVulnerabilitiesIntegrationEnabled
? this.jiraIssues(item)
: this.gitlabIssues(item);
},
formatDate(item) {
return formatDate(item.detectedAt, 'yyyy-mm-dd');
},
......@@ -440,7 +459,11 @@ export default {
<template #cell(activity)="{ item }">
<div class="gl-display-flex gl-justify-content-end">
<auto-fix-help-text v-if="item.hasSolutions" :merge-request="item.mergeRequest" />
<issues-badge v-if="issues(item).length > 0" :issues="issues(item)" />
<issues-badge
v-if="badgeIssues(item).length > 0"
:issues="badgeIssues(item)"
:is-jira="hasJiraVulnerabilitiesIntegrationEnabled"
/>
<remediated-badge v-if="item.resolvedOnDefaultBranch" class="gl-ml-3" />
</div>
</template>
......
......@@ -37,6 +37,7 @@ export default (el, dashboardType) => {
pipelinePath,
pipelineSecurityBuildsFailedCount,
pipelineSecurityBuildsFailedPath,
hasJiraVulnerabilitiesIntegrationEnabled,
} = el.dataset;
if (isUnavailable) {
......@@ -61,6 +62,9 @@ export default (el, dashboardType) => {
noPipelineRunScannersHelpPath,
hasVulnerabilities: parseBoolean(hasVulnerabilities),
scanners: scanners ? JSON.parse(scanners) : [],
hasJiraVulnerabilitiesIntegrationEnabled: parseBoolean(
hasJiraVulnerabilitiesIntegrationEnabled,
),
};
const props = {
......
......@@ -12,6 +12,7 @@ query project(
$sort: VulnerabilitySort
$hasIssues: Boolean
$hasResolution: Boolean
$includeExternalIssueLinks: Boolean = false
) {
project(fullPath: $fullPath) {
vulnerabilities(
......@@ -27,6 +28,16 @@ query project(
) {
nodes {
...Vulnerability
externalIssueLinks @include(if: $includeExternalIssueLinks) {
nodes {
issue: externalIssue {
externalTracker
webUrl
title
iid: relativeReference
}
}
}
hasSolutions
mergeRequest {
webUrl
......
<script>
import { GlIcon, GlLink, GlTooltipDirective } from '@gitlab/ui';
import { GlIcon, GlLink, GlTooltipDirective, GlSafeHtmlDirective } from '@gitlab/ui';
import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg';
export default {
components: {
......@@ -8,13 +9,24 @@ export default {
},
directives: {
GlTooltip: GlTooltipDirective,
SafeHtml: GlSafeHtmlDirective,
},
props: {
issue: {
type: Object,
required: true,
},
isJira: {
type: Boolean,
required: false,
},
},
computed: {
iconName() {
return this.issue.state === this.$options.STATE_OPENED ? 'issue-open-m' : 'issue-close';
},
},
jiraLogo,
STATE_OPENED: 'opened',
};
</script>
......@@ -22,14 +34,22 @@ export default {
<gl-link
v-gl-tooltip="issue.title"
:href="issue.webUrl"
:data-testid="`issue-link-${issue.iid}`"
class="d-inline-flex align-items-center gl-flex-shrink-0"
target="__blank"
class="gl-display-inline-flex gl-align-items-center gl-flex-shrink-0"
>
<span
v-if="isJira"
v-safe-html="$options.jiraLogo"
class="gl-min-h-6 gl-mr-3 gl-display-inline-flex gl-align-items-center"
data-testid="jira-logo"
></span>
<gl-icon
class="mr-1"
:class="{ cgreen: issue.state === $options.STATE_OPENED }"
:name="issue.state === $options.STATE_OPENED ? 'issue-open-m' : 'issue-close'"
v-else
class="gl-mr-1"
:class="{ 'gl-text-green-600': issue.state === $options.STATE_OPENED }"
:name="iconName"
/>
#{{ issue.iid }}
<gl-icon v-if="isJira" :size="12" name="external-link" class="gl-ml-1" />
</gl-link>
</template>
......@@ -236,6 +236,7 @@ module EE
if project.vulnerabilities.none?
{
has_vulnerabilities: 'false',
has_jira_vulnerabilities_integration_enabled: project.configured_to_create_issues_from_vulnerabilities?.to_s,
empty_state_svg_path: image_path('illustrations/security-dashboard_empty.svg'),
security_dashboard_help_path: help_page_path('user/application_security/security_dashboard/index'),
no_vulnerabilities_svg_path: image_path('illustrations/issues.svg'),
......@@ -244,6 +245,7 @@ module EE
else
{
has_vulnerabilities: 'true',
has_jira_vulnerabilities_integration_enabled: project.configured_to_create_issues_from_vulnerabilities?.to_s,
project: { id: project.id, name: project.name },
project_full_path: project.full_path,
vulnerabilities_export_endpoint: api_v4_security_projects_vulnerability_exports_path(id: project.id),
......
......@@ -37,7 +37,7 @@ module VulnerabilitiesHelper
end
def create_jira_issue_url_for(vulnerability)
return unless vulnerability.project.jira_vulnerabilities_integration_enabled?
return unless vulnerability.project.configured_to_create_issues_from_vulnerabilities?
decorated_vulnerability = vulnerability.present
summary = _('Investigate vulnerability: %{title}') % { title: decorated_vulnerability.title }
......
......@@ -200,7 +200,7 @@ module EE
delegate :auto_rollback_enabled, :auto_rollback_enabled=, :auto_rollback_enabled?, to: :ci_cd_settings
delegate :closest_gitlab_subscription, to: :namespace
delegate :jira_vulnerabilities_integration_enabled?, to: :jira_service, allow_nil: true
delegate :jira_vulnerabilities_integration_enabled?, :configured_to_create_issues_from_vulnerabilities?, to: :jira_service, allow_nil: true
delegate :requirements_access_level, :security_and_compliance_access_level, to: :project_feature, allow_nil: true
delegate :pipeline_configuration_full_path, to: :compliance_management_framework, allow_nil: true
......
......@@ -24,7 +24,10 @@ describe('First Class Group Dashboard Vulnerabilities Component', () => {
$apollo,
fetchNextPage: () => {},
},
provide: { hasVulnerabilities: true },
provide: {
hasVulnerabilities: true,
hasJiraVulnerabilitiesIntegrationEnabled: false,
},
});
};
......
......@@ -47,7 +47,10 @@ describe('First Class Instance Dashboard Vulnerabilities Component', () => {
fetchNextPage: () => {},
},
data,
provide: { hasVulnerabilities: true },
provide: {
hasVulnerabilities: true,
hasJiraVulnerabilitiesIntegrationEnabled: false,
},
});
};
......
......@@ -5,12 +5,13 @@ import IssueLink from 'ee/vulnerabilities/components/issue_link.vue';
describe('Remediated badge component', () => {
const issues = [{ issue: { iid: 41 } }, { issue: { iid: 591 } }];
let wrapper;
const findIcon = () => wrapper.find(GlIcon);
const findBadge = () => wrapper.find(GlBadge);
const findIssueLink = () => wrapper.findAll(IssueLink);
const findPopover = () => wrapper.find(GlPopover);
const findIcon = () => wrapper.findComponent(GlIcon);
const findBadge = () => wrapper.findComponent(GlBadge);
const findIssueLinks = () => wrapper.findAllComponents(IssueLink);
const findPopover = () => wrapper.findComponent(GlPopover);
const createWrapper = ({ propsData }) => {
return shallowMount(IssuesBadge, { propsData, stubs: { GlPopover, GlBadge } });
......@@ -23,7 +24,7 @@ describe('Remediated badge component', () => {
describe('when there are multiple issues', () => {
beforeEach(() => {
wrapper = createWrapper({ propsData: { issues } });
wrapper = createWrapper({ propsData: { issues, externalIssues: [] } });
});
it('displays the correct icon', () => {
......@@ -36,7 +37,7 @@ describe('Remediated badge component', () => {
});
it('displays the issues', () => {
expect(findIssueLink()).toHaveLength(issues.length);
expect(findIssueLinks()).toHaveLength(issues.length);
});
it('displays the correct number of issues in the badge', () => {
......@@ -50,7 +51,7 @@ describe('Remediated badge component', () => {
describe('when there are no issues', () => {
beforeEach(() => {
wrapper = createWrapper({ propsData: { issues: [] } });
wrapper = createWrapper({ propsData: { issues: [], externalIssues: [] } });
});
it('displays the correct number of issues in the badge', () => {
......@@ -61,4 +62,18 @@ describe('Remediated badge component', () => {
expect(findPopover().text()).toBe('0 Issues');
});
});
describe.each([true, false])('with "isJira" prop set to "%s"', (isJira) => {
beforeEach(() => {
wrapper = createWrapper({
propsData: { issues, isJira },
});
});
it('passes the correct prop to the issue link', () => {
findIssueLinks().wrappers.forEach((issueLink) => {
expect(issueLink.props('isJira')).toBe(isJira);
});
});
});
});
......@@ -37,6 +37,9 @@ export const generateVulnerabilities = () => [
issueLinks: {
nodes: [{ issue: { iid: 15 } }],
},
externalIssueLinks: {
nodes: [{ issue: { iid: 15, externalTracker: 'jira' } }],
},
},
{
id: 'id_1',
......
......@@ -14,6 +14,7 @@ describe('Vulnerabilities app component', () => {
wrapper = shallowMount(ProjectVulnerabilitiesApp, {
provide: {
projectFullPath: '#',
hasJiraVulnerabilitiesIntegrationEnabled: false,
},
propsData: {
dashboardDocumentation: '#',
......
......@@ -11,6 +11,7 @@ import VulnerabilityList, {
SCANNER_ALERT_DISMISSED_LOCAL_STORAGE_KEY,
} from 'ee/security_dashboard/components/vulnerability_list.vue';
import RemediatedBadge from 'ee/vulnerabilities/components/remediated_badge.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import { trimText } from 'helpers/text_helper';
import { generateVulnerabilities, vulnerabilities } from './mock_data';
......@@ -20,46 +21,52 @@ describe('Vulnerability list component', () => {
let wrapper;
const createWrapper = ({ props = {}, listeners } = {}) => {
return mount(VulnerabilityList, {
propsData: {
vulnerabilities: [],
...props,
},
stubs: {
GlPopover: true,
},
listeners,
provide: () => ({
noVulnerabilitiesSvgPath: '#',
dashboardDocumentation: '#',
emptyStateSvgPath: '#',
notEnabledScannersHelpPath: '#',
noPipelineRunScannersHelpPath: '#',
hasVulnerabilities: true,
const createWrapper = ({ props = {}, listeners, provide = {} } = {}) => {
return extendedWrapper(
mount(VulnerabilityList, {
propsData: {
vulnerabilities: [],
...props,
},
stubs: {
GlPopover: true,
},
listeners,
provide: () => ({
noVulnerabilitiesSvgPath: '#',
dashboardDocumentation: '#',
emptyStateSvgPath: '#',
notEnabledScannersHelpPath: '#',
noPipelineRunScannersHelpPath: '#',
hasVulnerabilities: true,
hasJiraVulnerabilitiesIntegrationEnabled: false,
...provide,
}),
}),
});
);
};
const findTable = () => wrapper.find(GlTable);
const findTable = () => wrapper.findComponent(GlTable);
const findSortableColumn = () => wrapper.find('[aria-sort="descending"]');
const findCell = (label) => wrapper.find(`.js-${label}`);
const findRows = () => wrapper.findAll('tbody tr');
const findRow = (index = 0) => findRows().at(index);
const findRowById = (id) => wrapper.find(`tbody tr[data-pk="${id}"`);
const findAutoFixBulbInRow = (row) => row.find('[data-testid="vulnerability-solutions-bulb"]');
const findIssuesBadge = (index = 0) => wrapper.findAll(IssuesBadge).at(index);
const findRemediatedBadge = () => wrapper.find(RemediatedBadge);
const findSecurityScannerAlert = () => wrapper.find(SecurityScannerAlert);
const findIssuesBadge = (index = 0) => wrapper.findAllComponents(IssuesBadge).at(index);
const findRemediatedBadge = () => wrapper.findComponent(RemediatedBadge);
const findSecurityScannerAlert = () => wrapper.findComponent(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}"]`);
const findSelectionSummary = () => wrapper.findComponent(SelectionSummary);
const findRowVulnerabilityCommentIcon = (row) =>
findRow(row).findComponent(VulnerabilityCommentIcon);
const findDataCell = (label) => wrapper.findByTestId(label);
const findDataCells = (label) => wrapper.findAll(`[data-testid="${label}"]`);
const findLocationTextWrapper = (cell) => cell.find(GlTruncate);
const findFiltersProducedNoResults = () => wrapper.find(FiltersProducedNoResults);
const findDashboardHasNoVulnerabilities = () => wrapper.find(DashboardHasNoVulnerabilities);
const findVendorNames = () => wrapper.find(`[data-testid="vulnerability-vendor"]`);
const findFiltersProducedNoResults = () => wrapper.findComponent(FiltersProducedNoResults);
const findDashboardHasNoVulnerabilities = () =>
wrapper.findComponent(DashboardHasNoVulnerabilities);
const findVendorNames = () => wrapper.findByTestId('vulnerability-vendor');
afterEach(() => {
wrapper.destroy();
......@@ -94,14 +101,6 @@ describe('Vulnerability list component', () => {
expect(cell.text()).toBe(newVulnerabilities[0].title);
});
it('should display the issues badge for the first item', () => {
expect(findIssuesBadge(0).exists()).toBe(true);
});
it('should not display the issues badge for the second item', () => {
expect(() => findIssuesBadge(1)).toThrow();
});
it('should display the remediated badge', () => {
expect(findRemediatedBadge().exists()).toBe(true);
});
......@@ -182,6 +181,30 @@ describe('Vulnerability list component', () => {
expect(checkbox().element.checked).toBe(false);
});
describe.each([true, false])(
'issues badge when "hasJiraVulnerabilitiesIntegrationEnabled" is set to "%s"',
(hasJiraVulnerabilitiesIntegrationEnabled) => {
beforeEach(() => {
wrapper = createWrapper({
props: { vulnerabilities: generateVulnerabilities() },
provide: { hasJiraVulnerabilitiesIntegrationEnabled },
});
});
it('should display the issues badge for the first item', () => {
expect(findIssuesBadge(0).exists()).toBe(true);
});
it('should not display the issues badge for the second item', () => {
expect(() => findIssuesBadge(1)).toThrow();
});
it('should render the badge as Jira issues', () => {
expect(findIssuesBadge(0).props('isJira')).toBe(hasJiraVulnerabilitiesIntegrationEnabled);
});
},
);
});
describe('when vulnerability selection is disabled', () => {
......
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlLink } from '@gitlab/ui';
import IssueLink from 'ee/vulnerabilities/components/issue_link.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { getBinding, createMockDirective } from 'helpers/vue_mock_directive';
describe('IssueLink component', () => {
......@@ -13,48 +15,71 @@ describe('IssueLink component', () => {
});
const createWrapper = ({ propsData }) => {
return shallowMount(IssueLink, {
propsData,
directives: {
GlTooltip: createMockDirective(),
},
});
return extendedWrapper(
shallowMount(IssueLink, {
propsData,
directives: {
GlTooltip: createMockDirective(),
},
}),
);
};
const findIssueLink = (id) => wrapper.find(`[data-testid="issue-link-${id}"]`);
const findIssueWithState = (state) =>
wrapper.find(state === 'opened' ? 'issue-open-m' : 'issue-close');
const findIssueLink = () => wrapper.findComponent(GlLink);
const findIcon = () => wrapper.findComponent(GlIcon);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe.each`
state | icon
${'opened'} | ${'issue-open-m'}
${'closed'} | ${'issue-close'}
`('when issue link is mounted', ({ state }) => {
describe(`with state ${state}`, () => {
const issue = createIssue({ state });
describe.each([true, false])('internal and Jira issues with "isJira" set to "%s"', (isJira) => {
const issue = createIssue();
beforeEach(() => {
wrapper = createWrapper({ propsData: { issue, isJira } });
});
it('should contain a link to the issue', () => {
expect(findIssueLink().attributes('href')).toBe(issue.webUrl);
});
it('should contain the title', () => {
const tooltip = getBinding(findIssueLink().element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe(issue.title);
});
});
describe('internal issues', () => {
describe.each`
state | icon
${'opened'} | ${'issue-open-m'}
${'closed'} | ${'issue-close'}
`('with state "$state"', ({ state, icon }) => {
beforeEach(() => {
wrapper = createWrapper({ propsData: { issue } });
wrapper = createWrapper({ propsData: { issue: createIssue({ state }) } });
});
it('should contain the correct issue icon', () => {
expect(findIssueWithState(state)).toBeTruthy();
expect(findIcon().attributes('name')).toBe(icon);
});
});
});
it('should contain a link to the issue', () => {
expect(findIssueLink(issue.iid).attributes('href')).toBe(issue.webUrl);
describe('Jira issues', () => {
beforeEach(() => {
wrapper = createWrapper({
propsData: { issue: createIssue(), isJira: true },
});
});
it('should contain the title', () => {
const tooltip = getBinding(findIssueLink(issue.iid).element, 'gl-tooltip');
expect(tooltip).toBeDefined();
expect(tooltip.value).toBe(issue.title);
});
it('should contain a Jira logo icon', () => {
expect(wrapper.findByTestId('jira-logo').exists()).toBe(true);
});
it('should contain an external-link icon', () => {
expect(findIcon().attributes('name')).toBe('external-link');
});
});
});
......@@ -119,11 +119,13 @@ RSpec.describe ProjectsHelper do
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, group: group) }
let_it_be(:jira_service) { create(:jira_service, project: project, vulnerabilities_enabled: true, project_key: 'GV', vulnerabilities_issuetype: '10000') }
subject { helper.project_security_dashboard_config(project) }
before do
group.add_owner(user)
stub_licensed_features(jira_vulnerabilities_integration: true)
allow(helper).to receive(:current_user).and_return(user)
end
......@@ -131,6 +133,7 @@ RSpec.describe ProjectsHelper do
let(:expected_value) do
{
has_vulnerabilities: 'false',
has_jira_vulnerabilities_integration_enabled: 'true',
empty_state_svg_path: start_with('/assets/illustrations/security-dashboard_empty'),
security_dashboard_help_path: '/help/user/application_security/security_dashboard/index',
project_full_path: project.full_path,
......@@ -145,6 +148,7 @@ RSpec.describe ProjectsHelper do
let(:base_values) do
{
has_vulnerabilities: 'true',
has_jira_vulnerabilities_integration_enabled: 'true',
project: { id: project.id, name: project.name },
project_full_path: project.full_path,
vulnerabilities_export_endpoint: "/api/v4/security/projects/#{project.id}/vulnerability_exports",
......
......@@ -176,6 +176,7 @@ RSpec.describe VulnerabilitiesHelper do
context 'with jira vulnerabilities integration enabled' do
before do
allow(project).to receive(:jira_vulnerabilities_integration_enabled?).and_return(true)
allow(project).to receive(:configured_to_create_issues_from_vulnerabilities?).and_return(true)
end
let(:expected_jira_issue_description) do
......@@ -237,6 +238,7 @@ RSpec.describe VulnerabilitiesHelper do
context 'with jira vulnerabilities integration disabled' do
before do
allow(project).to receive(:jira_vulnerabilities_integration_enabled?).and_return(false)
allow(project).to receive(:configured_to_create_issues_from_vulnerabilities?).and_return(false)
end
it { expect(subject[:create_jira_issue_url]).to be_nil }
......
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