Commit 717cb96d authored by David Pisek's avatar David Pisek Committed by Ezekiel Kigbo

Add related jira issues to vulnerabily page

* Adds new component to fetch, render and create related jira issues
* Adds logic to render the new component when needed
* Adds feature flag to related controller
* Adds new property to vulnerabily
parent d7c96e4b
...@@ -7,11 +7,13 @@ import Api from 'ee/api'; ...@@ -7,11 +7,13 @@ import Api from 'ee/api';
import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants'; import { VULNERABILITY_STATE_OBJECTS } from 'ee/vulnerabilities/constants';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import Poll from '~/lib/utils/poll'; import Poll from '~/lib/utils/poll';
import { deprecatedCreateFlash as createFlash } from '~/flash'; import { deprecatedCreateFlash as createFlash } from '~/flash';
import { s__, __ } from '~/locale'; import { s__, __ } from '~/locale';
import initUserPopovers from '~/user_popovers'; import initUserPopovers from '~/user_popovers';
import RelatedIssues from './related_issues.vue'; import RelatedIssues from './related_issues.vue';
import RelatedJiraIssues from './related_jira_issues.vue';
import HistoryEntry from './history_entry.vue'; import HistoryEntry from './history_entry.vue';
import StatusDescription from './status_description.vue'; import StatusDescription from './status_description.vue';
...@@ -22,9 +24,16 @@ export default { ...@@ -22,9 +24,16 @@ export default {
MergeRequestNote, MergeRequestNote,
HistoryEntry, HistoryEntry,
RelatedIssues, RelatedIssues,
RelatedJiraIssues,
GlIcon, GlIcon,
StatusDescription, StatusDescription,
}, },
mixins: [glFeatureFlagMixin()],
inject: {
createJiraIssueUrl: {
default: '',
},
},
props: { props: {
vulnerability: { vulnerability: {
type: Object, type: Object,
...@@ -207,7 +216,9 @@ export default { ...@@ -207,7 +216,9 @@ export default {
/> />
</div> </div>
<related-jira-issues v-if="glFeatures.jiraForVulnerabilities && createJiraIssueUrl" />
<related-issues <related-issues
v-else
:endpoint="issueLinksEndpoint" :endpoint="issueLinksEndpoint"
:can-modify-related-issues="vulnerability.canModifyRelatedIssues" :can-modify-related-issues="vulnerability.canModifyRelatedIssues"
:project-path="project.url" :project-path="project.url"
......
<script>
import {
GlAlert,
GlButton,
GlCard,
GlIcon,
GlLink,
GlLoadingIcon,
GlSafeHtmlDirective as SafeHtml,
GlSprintf,
} from '@gitlab/ui';
import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
export const i18n = {
cardHeading: s__('VulnerabilityManagement|Related Jira issues'),
fetchErrorMessage: s__(
'VulnerabilityManagement|Something went wrong while trying to fetch related Jira issues. Please check the %{linkStart}Jira integration settings%{linkEnd} and try again.',
),
helpPageLinkLabel: s__('VulnerabilityManagement|Read more about related issues'),
createNewIssueLinkText: s__('VulnerabilityManagement|Create Jira issue'),
loadingStateLabel: s__('VulnerabilityManagement, Fetching linked Jira issues'),
};
export default {
i18n,
jiraLogo,
components: {
GlAlert,
GlButton,
GlCard,
GlIcon,
GlLink,
GlLoadingIcon,
GlSprintf,
},
directives: {
SafeHtml,
},
inject: {
createJiraIssueUrl: {
default: '',
},
relatedJiraIssuesPath: {
default: '',
},
relatedJiraIssuesHelpPath: {
default: '',
},
jiraIntegrationSettingsPath: {
default: '',
},
},
data() {
return {
isFetchingRelatedIssues: false,
hasFetchIssuesError: false,
isFetchErrorDismissed: false,
relatedIssues: [],
};
},
computed: {
shouldShowIssuesBody() {
return this.isFetchingRelatedIssues || this.relatedIssues.length > 0;
},
issuesCount() {
return this.isFetchingRelatedIssues ? '...' : this.relatedIssues.length;
},
lastIssue() {
const [lastIssue] = this.relatedIssues.slice(-1);
return lastIssue;
},
showFetchErrorAlert() {
return this.hasFetchIssuesError && !this.isFetchErrorDismissed;
},
},
created() {
this.fetchRelatedIssues();
},
methods: {
async fetchRelatedIssues() {
this.isFetchingRelatedIssues = true;
try {
const { data } = await axios.get(this.relatedJiraIssuesPath);
if (Array.isArray(data)) {
this.relatedIssues = data;
}
} catch {
this.hasFetchIssuesError = true;
} finally {
this.isFetchingRelatedIssues = false;
}
},
},
};
</script>
<template>
<section>
<gl-alert
v-if="showFetchErrorAlert"
variant="danger"
class="gl-mb-4"
@dismiss="isFetchErrorDismissed = true"
>
<gl-sprintf :message="$options.i18n.fetchErrorMessage">
<template #link="{ content }">
<gl-link :href="jiraIntegrationSettingsPath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</gl-alert>
<gl-card
:header-class="[
'gl-py-3',
'gl-display-flex',
'gl-align-items-center',
{ 'gl-border-b-0': !shouldShowIssuesBody },
]"
:body-class="['gl-bg-gray-10', { 'gl-display-none': !shouldShowIssuesBody }]"
>
<template #header>
<h3 class="h5 gl-m-0">{{ $options.i18n.cardHeading }}</h3>
<gl-link
v-if="relatedJiraIssuesHelpPath"
:aria-label="$options.i18n.helpPageLinkLabel"
:href="relatedJiraIssuesHelpPath"
target="_blank"
class="gl-display-flex gl-align-items-center gl-ml-2 gl-text-gray-500"
>
<gl-icon name="question" :size="12" role="text" />
</gl-link>
<span
class="gl-display-inline-flex gl-align-items-center gl-ml-4"
data-testid="related-jira-issues-count"
>
<gl-icon name="issues" class="gl-mr-2 gl-text-gray-500" />
{{ issuesCount }}
</span>
<gl-button
variant="success"
category="secondary"
:href="createJiraIssueUrl"
icon="external-link"
target="_blank"
class="gl-ml-auto"
data-testid="create-new-jira-issue"
>
{{ $options.i18n.createNewIssueLinkText }}
</gl-button>
</template>
<section
:hidden="!shouldShowIssuesBody"
class="gl-p-0 gl-m-0"
data-testid="related-jira-issues-section"
>
<gl-card body-class="gl-p-0">
<gl-loading-icon
v-if="isFetchingRelatedIssues"
ref="loadingIcon"
:label="$options.i18n.loadingStateLabel"
class="gl-my-3"
/>
<ul class="gl-list-style-none gl-m-0 gl-p-0">
<li
v-for="issue in relatedIssues"
:key="issue.created_at"
class="gl-display-flex gl-align-items-center gl-py-3 gl-px-4"
:class="
issue !== lastIssue && [
'gl-border-b-1',
'gl-border-b-gray-100',
'gl-border-b-solid',
]
"
>
<span
v-safe-html="$options.jiraLogo"
class="gl-min-h-6 gl-mr-5 gl-display-inline-flex gl-align-items-center"
>
</span>
<gl-link :href="issue.web_url" target="_blank" class="gl-text-gray-900">
{{ issue.title }}
</gl-link>
<span class="gl-ml-3 gl-text-gray-500">&num;{{ issue.references.relative }}</span>
</li>
</ul>
</gl-card>
</section>
</gl-card>
</section>
</template>
...@@ -22,6 +22,10 @@ export default (el) => { ...@@ -22,6 +22,10 @@ export default (el) => {
vulnerabilityId: vulnerability.id, vulnerabilityId: vulnerability.id,
issueTrackingHelpPath: vulnerability.issueTrackingHelpPath, issueTrackingHelpPath: vulnerability.issueTrackingHelpPath,
permissionsHelpPath: vulnerability.permissionsHelpPath, permissionsHelpPath: vulnerability.permissionsHelpPath,
createJiraIssueUrl: vulnerability.createJiraIssueUrl,
relatedJiraIssuesPath: vulnerability.relatedJiraIssuesPath,
relatedJiraIssuesHelpPath: vulnerability.relatedJiraIssuesHelpPath,
jiraIntegrationSettingsPath: vulnerability.jiraIntegrationSettingsPath,
}, },
render: (h) => render: (h) =>
h(App, { h(App, {
......
...@@ -7,6 +7,10 @@ module Projects ...@@ -7,6 +7,10 @@ module Projects
include IssuableActions include IssuableActions
include RendersNotes include RendersNotes
before_action do
push_frontend_feature_flag(:jira_for_vulnerabilities, @project, default_enabled: :yaml)
end
before_action :vulnerability, except: :index before_action :vulnerability, except: :index
alias_method :vulnerable, :project alias_method :vulnerable, :project
......
...@@ -15,6 +15,7 @@ module VulnerabilitiesHelper ...@@ -15,6 +15,7 @@ module VulnerabilitiesHelper
new_issue_url: new_issue_url_for(vulnerability), new_issue_url: new_issue_url_for(vulnerability),
create_jira_issue_url: create_jira_issue_url_for(vulnerability), create_jira_issue_url: create_jira_issue_url_for(vulnerability),
related_jira_issues_path: project_integrations_jira_issues_path(vulnerability.project, vulnerability_ids: [vulnerability.id]), related_jira_issues_path: project_integrations_jira_issues_path(vulnerability.project, vulnerability_ids: [vulnerability.id]),
jira_integration_settings_path: edit_project_service_path(vulnerability.project, ::JiraService),
has_mr: !!vulnerability.finding.merge_request_feedback.try(:merge_request_id), has_mr: !!vulnerability.finding.merge_request_feedback.try(:merge_request_id),
create_mr_url: create_vulnerability_feedback_merge_request_path(vulnerability.finding.project), create_mr_url: create_vulnerability_feedback_merge_request_path(vulnerability.finding.project),
discussions_url: discussions_project_security_vulnerability_path(vulnerability.project, vulnerability), discussions_url: discussions_project_security_vulnerability_path(vulnerability.project, vulnerability),
......
...@@ -6,6 +6,7 @@ import SolutionCard from 'ee/vue_shared/security_reports/components/solution_car ...@@ -6,6 +6,7 @@ import SolutionCard from 'ee/vue_shared/security_reports/components/solution_car
import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue'; import VulnerabilityFooter from 'ee/vulnerabilities/components/footer.vue';
import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue'; import HistoryEntry from 'ee/vulnerabilities/components/history_entry.vue';
import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue'; import RelatedIssues from 'ee/vulnerabilities/components/related_issues.vue';
import RelatedJiraIssues from 'ee/vulnerabilities/components/related_jira_issues.vue';
import StatusDescription from 'ee/vulnerabilities/components/status_description.vue'; import StatusDescription from 'ee/vulnerabilities/components/status_description.vue';
import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants'; import { VULNERABILITY_STATES } from 'ee/vulnerabilities/constants';
import { deprecatedCreateFlash as createFlash } from '~/flash'; import { deprecatedCreateFlash as createFlash } from '~/flash';
...@@ -33,9 +34,10 @@ describe('Vulnerability Footer', () => { ...@@ -33,9 +34,10 @@ describe('Vulnerability Footer', () => {
pipeline: {}, pipeline: {},
}; };
const createWrapper = (properties = {}) => { const createWrapper = (properties = {}, mountOptions = {}) => {
wrapper = shallowMount(VulnerabilityFooter, { wrapper = shallowMount(VulnerabilityFooter, {
propsData: { vulnerability: { ...vulnerability, ...properties } }, propsData: { vulnerability: { ...vulnerability, ...properties } },
...mountOptions,
}); });
}; };
...@@ -278,6 +280,38 @@ describe('Vulnerability Footer', () => { ...@@ -278,6 +280,38 @@ describe('Vulnerability Footer', () => {
}); });
}); });
describe('related jira issues', () => {
const relatedJiraIssues = () => wrapper.find(RelatedJiraIssues);
describe('with `createJiraIssueUrl` not provided', () => {
beforeEach(() => {
createWrapper();
});
it('does not show related jira issues', () => {
expect(relatedJiraIssues().exists()).toBe(false);
});
});
describe('with `createJiraIssueUrl` provided', () => {
beforeEach(() => {
createWrapper(
{},
{
provide: {
createJiraIssueUrl: 'http://foo',
glFeatures: { jiraForVulnerabilities: true },
},
},
);
});
it('shows related jira issues', () => {
expect(relatedJiraIssues().exists()).toBe(true);
});
});
});
describe('detection note', () => { describe('detection note', () => {
const detectionNote = () => wrapper.find('[data-testid="detection-note"]'); const detectionNote = () => wrapper.find('[data-testid="detection-note"]');
const statusDescription = () => wrapper.find(StatusDescription); const statusDescription = () => wrapper.find(StatusDescription);
......
import { nextTick } from 'vue';
import { mount, shallowMount } from '@vue/test-utils';
import { GlAlert, GlLink } from '@gitlab/ui';
import { within } from '@testing-library/dom';
import MockAdapter from 'axios-mock-adapter';
import RelatedJiraIssues, { i18n } from 'ee/vulnerabilities/components/related_jira_issues.vue';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import axios from '~/lib/utils/axios_utils';
import httpStatusCodes from '~/lib/utils/http_status';
import { sprintf } from '~/locale';
const mockAxios = new MockAdapter(axios);
describe('EE RelatedJiraIssues Component', () => {
let wrapper;
const defaultProvide = {
createJiraIssueUrl: 'http://createNewJiraIssue',
relatedJiraIssuesPath: 'http://fetchRelatedIssues',
relatedJiraIssuesHelpPath: 'http://helpPage',
jiraIntegrationSettingsPath: 'http://jiraIntegrationSettings',
};
const TEST_ISSUES = [
{
web_url: 'http://example.com',
title: 'Issue #1',
references: { relative: 'TES-1' },
},
{
web_url: 'http://example.com',
title: 'Issue #2',
references: { relative: 'TES-2' },
},
];
const createWrapper = (mountFn) => () => {
return extendedWrapper(mountFn(RelatedJiraIssues, { provide: defaultProvide }));
};
const createFullWrapper = createWrapper(mount);
const createShallowWrapper = createWrapper(shallowMount);
const withResponse = async (
createWrapperFn,
{ statusCode = httpStatusCodes.OK, data, waitForRequestsToFinish = true },
) => {
mockAxios.onGet(defaultProvide.relatedJiraIssuesPath).replyOnce(statusCode, data);
const wrapperWithResponse = createWrapperFn();
if (waitForRequestsToFinish) {
await axios.waitForAll();
}
return wrapperWithResponse;
};
const withinComponent = () => within(wrapper.element);
const findAlert = () => wrapper.findComponent(GlAlert);
const findRelatedJiraIssuesCount = () => wrapper.findByTestId('related-jira-issues-count');
const findCreateJiraIssueLink = () => wrapper.findByTestId('create-new-jira-issue');
const findRelatedJiraIssuesSection = () => wrapper.findByTestId('related-jira-issues-section');
const withinRelatedJiraIssuesSection = () => within(findRelatedJiraIssuesSection().element);
afterEach(() => {
wrapper.destroy();
wrapper = null;
mockAxios.reset();
});
describe('fetch related issues error message', () => {
it('is not showing by default', () => {
wrapper = createShallowWrapper();
expect(findAlert().exists()).toBe(false);
});
describe('with error while fetching related Jira issues', () => {
beforeEach(async () => {
wrapper = await withResponse(createFullWrapper, { statusCode: httpStatusCodes });
});
it('shows when there is an error while fetching the related jira issues', () => {
expect(findAlert().exists()).toBe(true);
});
it('shows a message describing the error', () => {
const expectedLinkText = sprintf(i18n.fetchErrorMessage, { linkStart: '', linkEnd: '' });
expect(findAlert().text()).toBe(expectedLinkText);
});
it('shows a link to the Jira integration settings', () => {
expect(findAlert().findComponent(GlLink).attributes('href')).toBe(
defaultProvide.jiraIntegrationSettingsPath,
);
});
it('can be dismissed', async () => {
findAlert().vm.$emit('dismiss');
await nextTick();
expect(findAlert().exists()).toBe(false);
});
});
});
describe('header', () => {
describe('static content', () => {
beforeEach(() => {
wrapper = createFullWrapper();
});
it('shows a heading', () => {
expect(withinComponent().getByRole('heading', { name: i18n.cardHeading })).not.toBe(
undefined,
);
});
it('shows a link to a help page', () => {
expect(
withinComponent().getByLabelText(i18n.helpPageLinkLabel, {
selector: `[href="${defaultProvide.relatedJiraIssuesHelpPath}"]`,
}),
).not.toBe(undefined);
});
it('shows a link to create a new Jira issues', () => {
const createNewJiraIssueLink = findCreateJiraIssueLink();
expect(createNewJiraIssueLink.exists()).toBe(true);
expect(createNewJiraIssueLink.attributes('href')).toBe(defaultProvide.createJiraIssueUrl);
expect(createNewJiraIssueLink.props('icon')).toBe('external-link');
expect(createNewJiraIssueLink.text()).toMatch(i18n.createNewIssueLinkText);
});
});
describe('related issues count', () => {
it('shows a placeholder while fetching', async () => {
wrapper = await withResponse(createFullWrapper, {
data: [],
waitForRequestsToFinish: false,
});
expect(findRelatedJiraIssuesCount().text()).toBe('...');
});
it('shows the number of fetched issues', async () => {
wrapper = await withResponse(createFullWrapper, {
data: TEST_ISSUES,
});
expect(findRelatedJiraIssuesCount().text()).toBe(`${TEST_ISSUES.length}`);
});
});
});
describe('body', () => {
describe.each(TEST_ISSUES)('related Jira issues', (issue) => {
beforeEach(async () => {
wrapper = await withResponse(createFullWrapper, {
data: TEST_ISSUES,
});
});
it('shows the issue title with a link to the issue', () => {
expect(
withinRelatedJiraIssuesSection().getByText(issue.title, {
selector: `[href="${issue.web_url}"]`,
}),
).not.toBe(undefined);
});
it('shows the related Jira project-id', () => {
expect(
withinRelatedJiraIssuesSection().getByText(`#${issue.references.relative}`),
).not.toBe(undefined);
});
});
it('is hidden when there are no related issues', async () => {
wrapper = await withResponse(createFullWrapper, {
data: [],
});
expect(findRelatedJiraIssuesSection().isVisible()).toBe(false);
});
});
});
...@@ -64,6 +64,7 @@ RSpec.describe VulnerabilitiesHelper do ...@@ -64,6 +64,7 @@ RSpec.describe VulnerabilitiesHelper do
new_issue_url: "/#{project.full_path}/-/issues/new?vulnerability_id=#{vulnerability.id}", new_issue_url: "/#{project.full_path}/-/issues/new?vulnerability_id=#{vulnerability.id}",
create_jira_issue_url: nil, create_jira_issue_url: nil,
related_jira_issues_path: "/#{project.full_path}/-/integrations/jira/issues?vulnerability_ids%5B%5D=#{vulnerability.id}", related_jira_issues_path: "/#{project.full_path}/-/integrations/jira/issues?vulnerability_ids%5B%5D=#{vulnerability.id}",
jira_integration_settings_path: "/#{project.full_path}/-/services/jira/edit",
has_mr: anything, has_mr: anything,
create_mr_url: "/#{project.full_path}/-/vulnerability_feedback", create_mr_url: "/#{project.full_path}/-/vulnerability_feedback",
discussions_url: "/#{project.full_path}/-/security/vulnerabilities/#{vulnerability.id}/discussions", discussions_url: "/#{project.full_path}/-/security/vulnerabilities/#{vulnerability.id}/discussions",
......
...@@ -32095,6 +32095,9 @@ msgstr "" ...@@ -32095,6 +32095,9 @@ msgstr ""
msgid "VulnerabilityChart|Severity" msgid "VulnerabilityChart|Severity"
msgstr "" msgstr ""
msgid "VulnerabilityManagement, Fetching linked Jira issues"
msgstr ""
msgid "VulnerabilityManagement|%{statusStart}Confirmed%{statusEnd} %{timeago} by %{user}" msgid "VulnerabilityManagement|%{statusStart}Confirmed%{statusEnd} %{timeago} by %{user}"
msgstr "" msgstr ""
...@@ -32116,15 +32119,27 @@ msgstr "" ...@@ -32116,15 +32119,27 @@ msgstr ""
msgid "VulnerabilityManagement|Could not process %{issueReference}: %{errorMessage}." msgid "VulnerabilityManagement|Could not process %{issueReference}: %{errorMessage}."
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Create Jira issue"
msgstr ""
msgid "VulnerabilityManagement|Detected" msgid "VulnerabilityManagement|Detected"
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Needs triage" msgid "VulnerabilityManagement|Needs triage"
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Read more about related issues"
msgstr ""
msgid "VulnerabilityManagement|Related Jira issues"
msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later." msgid "VulnerabilityManagement|Something went wrong while trying to delete the comment. Please try again later."
msgstr "" msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to fetch related Jira issues. Please check the %{linkStart}Jira integration settings%{linkEnd} and try again."
msgstr ""
msgid "VulnerabilityManagement|Something went wrong while trying to refresh the vulnerability. Please try again later." msgid "VulnerabilityManagement|Something went wrong while trying to refresh the vulnerability. Please try again later."
msgstr "" 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