Commit 5f972dbc authored by Jannik Lehmann's avatar Jannik Lehmann Committed by Andrew Fontaine

GraphQl mutation for creating a Jira issue on vulnerability details page

This commit introduces the usage of
a GraphQL mutation for creating Jira
issues on the vulnerability details
page. It is currently hidden behind the
createVulnerabilityJiraIssueViaGraphql flag.
The flag is tracked here: https://gitlab.com/gitlab-org/gitlab/-/issues/329780
parent 2a41d2e2
---
name: create_vulnerability_jira_issue_via_graphql
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/60593
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/329780
milestone: '13.12'
type: development
group: group::protect
default_enabled: false
<script>
import { GlButton } from '@gitlab/ui';
import vulnerabilityExternalIssueLinkCreate from 'ee/vue_shared/security_reports/graphql/vulnerabilityExternalIssueLinkCreate.mutation.graphql';
import { convertToGraphQLId } from '~/graphql_shared/utils';
import { s__ } from '~/locale';
export const i18n = {
createNewIssueLinkText: s__('VulnerabilityManagement|Create Jira issue'),
};
export default {
i18n,
components: {
GlButton,
},
props: {
vulnerabilityId: {
type: Number,
required: true,
},
variant: {
type: String,
required: false,
default: 'success',
},
},
data() {
return {
isLoading: false,
};
},
methods: {
async createJiraIssue() {
this.isLoading = true;
try {
/* eslint-disable @gitlab/require-i18n-strings */
const { data } = await this.$apollo.mutate({
mutation: vulnerabilityExternalIssueLinkCreate,
variables: {
input: {
externalTracker: 'JIRA',
linkType: 'CREATED',
id: convertToGraphQLId('Vulnerability', this.vulnerabilityId),
},
},
});
/* eslint-enable @gitlab/require-i18n-strings */
const { errors } = data.vulnerabilityExternalIssueLinkCreate;
if (errors.length > 0) {
throw new Error(errors[0]);
}
this.$emit('mutated');
} catch (e) {
this.$emit('create-jira-issue-error', e.message);
} finally {
this.isLoading = false;
}
},
},
};
</script>
<template>
<gl-button
:variant="variant"
category="secondary"
:loading="isLoading"
icon="external-link"
data-testid="create-new-jira-issue"
@click="createJiraIssue"
>
{{ $options.i18n.createNewIssueLinkText }}
</gl-button>
</template>
mutation vulnerabilityExternalIssueLinkCreate($input: VulnerabilityExternalIssueLinkCreateInput!) {
vulnerabilityExternalIssueLinkCreate(input: $input) {
errors
externalIssueLink {
externalIssue {
webUrl
}
}
}
}
......@@ -11,6 +11,7 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import Poll from '~/lib/utils/poll';
import { s__, __ } from '~/locale';
import initUserPopovers from '~/user_popovers';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import GenericReportSection from './generic_report/report_section.vue';
import HistoryEntry from './history_entry.vue';
import RelatedIssues from './related_issues.vue';
......@@ -29,6 +30,7 @@ export default {
GlIcon,
StatusDescription,
},
mixins: [glFeatureFlagMixin()],
inject: {
createJiraIssueUrl: {
default: '',
......@@ -220,7 +222,10 @@ export default {
class="card-body"
/>
</div>
<related-jira-issues v-if="createJiraIssueUrl" class="gl-mt-6" />
<related-jira-issues
v-if="createJiraIssueUrl || glFeatures.createVulnerabilityJiraIssueViaGraphql"
class="gl-mt-6"
/>
<related-issues
v-else
:endpoint="issueLinksEndpoint"
......
......@@ -2,16 +2,18 @@
import jiraLogo from '@gitlab/svgs/dist/illustrations/logos/jira.svg';
import {
GlAlert,
GlButton,
GlCard,
GlIcon,
GlLink,
GlLoadingIcon,
GlButton,
GlSafeHtmlDirective as SafeHtml,
GlSprintf,
} from '@gitlab/ui';
import CreateJiraIssue from 'ee/vue_shared/security_reports/components/create_jira_issue.vue';
import axios from '~/lib/utils/axios_utils';
import { s__ } from '~/locale';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
export const i18n = {
cardHeading: s__('VulnerabilityManagement|Related Jira issues'),
......@@ -27,8 +29,9 @@ export default {
i18n,
jiraLogo,
components: {
GlAlert,
GlButton,
CreateJiraIssue,
GlAlert,
GlCard,
GlIcon,
GlLink,
......@@ -38,6 +41,7 @@ export default {
directives: {
SafeHtml,
},
mixins: [glFeatureFlagsMixin()],
inject: {
createJiraIssueUrl: {
default: '',
......@@ -51,6 +55,7 @@ export default {
jiraIntegrationSettingsPath: {
default: '',
},
vulnerabilityId: { required: true },
},
data() {
return {
......@@ -58,6 +63,9 @@ export default {
hasFetchIssuesError: false,
isFetchErrorDismissed: false,
relatedIssues: [],
showCreateJiraIssueErrorAlertMessage: '',
isCreateJiraIssueErrorDismissed: false,
hasCreateJiraIssueError: false,
};
},
computed: {
......@@ -74,11 +82,18 @@ export default {
showFetchErrorAlert() {
return this.hasFetchIssuesError && !this.isFetchErrorDismissed;
},
showCreateJiraIssueErrorAlert() {
return this.hasCreateJiraIssueError && !this.isCreateJiraIssueErrorDismissed;
},
},
created() {
this.fetchRelatedIssues();
},
methods: {
createJiraIssueErrorHandler(value) {
this.hasCreateJiraIssueError = true;
this.showCreateJiraIssueErrorAlertMessage = value;
},
// note: this direct API call will be replaced when migrating the vulnerability details page to GraphQL
// related epic: https://gitlab.com/groups/gitlab-org/-/epics/3657
async fetchRelatedIssues() {
......@@ -111,6 +126,15 @@ export default {
</template>
</gl-sprintf>
</gl-alert>
<gl-alert
v-if="showCreateJiraIssueErrorAlert"
data-testid="create-jira-issue-error-alert"
variant="danger"
class="gl-mb-4"
@dismiss="isCreateJiraIssueErrorDismissed = true"
>
{{ showCreateJiraIssueErrorAlertMessage }}
</gl-alert>
<gl-card
:header-class="[
'gl-py-3',
......@@ -138,14 +162,22 @@ export default {
<gl-icon name="issues" class="gl-mr-2 gl-text-gray-500" />
{{ issuesCount }}
</span>
<create-jira-issue
v-if="glFeatures.createVulnerabilityJiraIssueViaGraphql"
class="gl-ml-auto"
:vulnerability-id="vulnerabilityId"
@create-jira-issue-error="createJiraIssueErrorHandler"
@mutated="fetchRelatedIssues"
/>
<gl-button
v-else
variant="success"
category="secondary"
:href="createJiraIssueUrl"
icon="external-link"
target="_blank"
class="gl-ml-auto"
data-testid="create-new-jira-issue"
data-testid="create-new-jira-issue-link"
>
{{ $options.i18n.createNewIssueLinkText }}
</gl-button>
......
......@@ -8,6 +8,10 @@ module Projects
include IssuableActions
include RendersNotes
before_action do
push_frontend_feature_flag(:create_vulnerability_jira_issue_via_graphql, @project, default_enabled: :yaml)
end
before_action :vulnerability, except: :index
alias_method :vulnerable, :project
......
export const vulnerabilityExternalIssueLinkCreateMockFactory = ({ errors = [] } = {}) => ({
data: {
vulnerabilityExternalIssueLinkCreate: {
errors,
externalIssueLink: {
externalIssue: {
webUrl: 'http://foo.bar',
},
},
},
},
});
import { GlButton } from '@gitlab/ui';
import { createLocalVue, mount } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import Component, { i18n } from 'ee/vue_shared/security_reports/components/create_jira_issue.vue';
import vulnerabilityExternalIssueLinkCreate from 'ee/vue_shared/security_reports/graphql/vulnerabilityExternalIssueLinkCreate.mutation.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { vulnerabilityExternalIssueLinkCreateMockFactory } from './apollo_mocks';
const localVue = createLocalVue();
describe('create_jira_issue', () => {
let wrapper;
const defaultProps = {
vulnerabilityId: 1,
};
const findButton = () => wrapper.findComponent(GlButton);
const clickOnButton = async () => {
await findButton().trigger('click');
return waitForPromises();
};
const successHandler = jest
.fn()
.mockResolvedValue(vulnerabilityExternalIssueLinkCreateMockFactory());
const errorHandler = jest.fn().mockResolvedValue(
vulnerabilityExternalIssueLinkCreateMockFactory({
errors: ['foo'],
}),
);
const pendingHandler = jest.fn().mockReturnValue(new Promise(() => {}));
function createMockApolloProvider(handler) {
localVue.use(VueApollo);
const requestHandlers = [[vulnerabilityExternalIssueLinkCreate, handler]];
return createMockApollo(requestHandlers);
}
const createComponent = (options = {}) => {
wrapper = mount(Component, {
localVue,
apolloProvider: options.mockApollo,
propsData: {
...defaultProps,
...options.propsData,
},
});
};
afterEach(() => {
wrapper.destroy();
});
it('should render button with correct text in default variant', () => {
createComponent();
expect(findButton().exists()).toBe(true);
expect(findButton().text()).toBe(i18n.createNewIssueLinkText);
});
it('should render button in correct variant as passed in as props', () => {
createComponent({ propsData: { variant: 'info' } });
expect(findButton().props().variant).toBe('info');
});
describe('given a pending response', () => {
beforeEach(() => {
const mockApollo = createMockApolloProvider(pendingHandler);
createComponent({ mockApollo });
});
it('renders spinner correctly', async () => {
const button = findButton();
expect(button.props('loading')).toBe(false);
await clickOnButton();
expect(button.props('loading')).toBe(true);
});
});
describe('given an error response', () => {
beforeEach(async () => {
const mockApollo = createMockApolloProvider(errorHandler);
createComponent({ mockApollo });
await clickOnButton();
});
it('show throw createJiraIssueError event with correct message', () => {
expect(wrapper.emitted('create-jira-issue-error')).toEqual([['foo']]);
});
});
describe('given an successful response', () => {
beforeEach(async () => {
const mockApollo = createMockApolloProvider(successHandler);
createComponent({ mockApollo });
await clickOnButton();
});
it('should emit mutated event', () => {
expect(wrapper.emitted('mutated')).not.toBe(undefined);
});
});
});
......@@ -284,32 +284,40 @@ 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',
describe.each`
createJiraIssueUrl | createVulnerabilityJiraIssueViaGraphql | shouldShowRelatedJiraIssues
${'http://foo'} | ${false} | ${true}
${'http://foo'} | ${true} | ${true}
${''} | ${true} | ${true}
${''} | ${false} | ${false}
`(
'with createVulnerabilityJiraIssueViaGraphql set to "$createVulnerabilityJiraIssueViaGraphql"',
({
createJiraIssueUrl,
createVulnerabilityJiraIssueViaGraphql,
shouldShowRelatedJiraIssues,
}) => {
beforeEach(() => {
createWrapper(
{},
{
provide: {
createJiraIssueUrl,
glFeatures: {
createVulnerabilityJiraIssueViaGraphql,
},
},
},
},
);
});
);
});
it('shows related jira issues', () => {
expect(relatedJiraIssues().exists()).toBe(true);
});
});
it(`${
shouldShowRelatedJiraIssues ? 'should' : 'should not'
} show related Jira issues`, () => {
expect(relatedJiraIssues().exists()).toBe(shouldShowRelatedJiraIssues);
});
},
);
});
describe('detection note', () => {
......
......@@ -3,6 +3,7 @@ import { within } from '@testing-library/dom';
import { mount, shallowMount } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import createJiraIssue from 'ee/vue_shared/security_reports/components/create_jira_issue.vue';
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';
......@@ -34,8 +35,18 @@ describe('EE RelatedJiraIssues Component', () => {
},
];
const createWrapper = (mountFn) => () => {
return extendedWrapper(mountFn(RelatedJiraIssues, { provide: defaultProvide }));
const createWrapper = (mountFn, { createVulnerabilityJiraIssueViaGraphql = true } = {}) => () => {
return extendedWrapper(
mountFn(RelatedJiraIssues, {
provide: {
...defaultProvide,
...{ vulnerabilityId: 1 },
glFeatures: {
createVulnerabilityJiraIssueViaGraphql,
},
},
}),
);
};
const createFullWrapper = createWrapper(mount);
const createShallowWrapper = createWrapper(shallowMount);
......@@ -57,10 +68,12 @@ describe('EE RelatedJiraIssues Component', () => {
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 findCreateJiraIssueLink = () => wrapper.findByTestId('create-new-jira-issue-link');
const findRelatedJiraIssuesSection = () => wrapper.findByTestId('related-jira-issues-section');
const withinRelatedJiraIssuesSection = () => within(findRelatedJiraIssuesSection().element);
const findShowCreateJiraIssueErrorAlert = () =>
wrapper.findByTestId('create-jira-issue-error-alert');
const findCreateJiraIssueComponent = () => wrapper.findComponent(createJiraIssue);
afterEach(() => {
wrapper.destroy();
wrapper = null;
......@@ -85,7 +98,6 @@ describe('EE RelatedJiraIssues Component', () => {
it('shows a message describing the error', () => {
const expectedLinkText = sprintf(i18n.fetchErrorMessage, { linkStart: '', linkEnd: '' });
expect(findAlert().text()).toBe(expectedLinkText);
});
......@@ -124,13 +136,45 @@ describe('EE RelatedJiraIssues Component', () => {
).not.toBe(undefined);
});
it('shows a link to create a new Jira issues', () => {
const createNewJiraIssueLink = findCreateJiraIssueLink();
describe('when createVulnerabilityJiraIssueViaGraphql Flag is on', () => {
it('shows createJiraIssue Component when createVulnerabilityJiraIssueViaGraphql Flag is true', () => {
expect(findCreateJiraIssueComponent().exists()).toBe(true);
});
it('does not show CreateJiraIssueButton when createVulnerabilityJiraIssueViaGraphql Flag is true ', () => {
expect(findCreateJiraIssueLink().exists()).toBe(false);
});
});
describe('when createVulnerabilityJiraIssueViaGraphql Flag is off', () => {
beforeEach(async () => {
wrapper = await withResponse(
createWrapper(mount, { createVulnerabilityJiraIssueViaGraphql: false }),
{
statusCode: httpStatusCodes,
},
);
});
it('does not show createJiraIssue Component when createVulnerabilityJiraIssueViaGraphql Flag is false ', () => {
expect(findCreateJiraIssueComponent().exists()).toBe(false);
});
it('shows CreateJiraIssueButton when createVulnerabilityJiraIssueViaGraphql Flag is true', () => {
expect(findCreateJiraIssueLink().exists()).toBe(true);
});
});
it('should not show showCreateJiraIssueErrorAlert Banner', () => {
expect(findShowCreateJiraIssueErrorAlert().exists()).toBe(false);
});
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);
it('shows showCreateJiraIssueErrorAlert Banner when createJiraIssue emits an error', async () => {
findCreateJiraIssueComponent().vm.$emit('create-jira-issue-error', 'test-error-message');
await nextTick();
const alert = findShowCreateJiraIssueErrorAlert();
expect(alert.exists()).toBe(true);
expect(alert.text()).toMatch('test-error-message');
});
});
......
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