Commit 47d59559 authored by Luke Bennett's avatar Luke Bennett

Merge branch '9224-create-mr-from-vulnerabilty-feedback-fe' into 'master'

Create a merge request from a vulnerability solution (Frontend Changes)

See merge request gitlab-org/gitlab-ee!9789
parents ae4bed39 7085cc9d
...@@ -52,14 +52,16 @@ ...@@ -52,14 +52,16 @@
display: flex; display: flex;
flex-direction: row; flex-direction: row;
.btn + .btn { .btn + .btn:not(.dropdown-toggle-split),
.btn + .btn-group {
margin-left: $grid-size; margin-left: $grid-size;
} }
@include media-breakpoint-down(xs) { @include media-breakpoint-down(xs) {
flex-direction: column; flex-direction: column;
.btn + .btn { .btn + .btn:not(.dropdown-toggle-split),
.btn + .btn-group {
margin-left: 0; margin-left: 0;
margin-top: $grid-size; margin-top: $grid-size;
} }
......
...@@ -15,6 +15,7 @@ export default () => { ...@@ -15,6 +15,7 @@ export default () => {
const datasetOptions = securityTab.dataset; const datasetOptions = securityTab.dataset;
const { const {
headBlobPath, headBlobPath,
sourceBranch,
sastHeadPath, sastHeadPath,
sastHelpPath, sastHelpPath,
dependencyScanningHeadPath, dependencyScanningHeadPath,
...@@ -44,6 +45,7 @@ export default () => { ...@@ -44,6 +45,7 @@ export default () => {
return createElement('security-report-app', { return createElement('security-report-app', {
props: { props: {
headBlobPath, headBlobPath,
sourceBranch,
sastHeadPath, sastHeadPath,
sastHelpPath, sastHelpPath,
dependencyScanningHeadPath, dependencyScanningHeadPath,
......
...@@ -51,13 +51,16 @@ export default { ...@@ -51,13 +51,16 @@ export default {
...mapState('projects', ['projects']), ...mapState('projects', ['projects']),
...mapGetters('filters', ['activeFilters']), ...mapGetters('filters', ['activeFilters']),
canCreateIssuePermission() { canCreateIssuePermission() {
const path = this.modal.vulnerability.vulnerability_feedback_issue_path; const path = this.vulnerability.vulnerability_feedback_issue_path;
return _.isString(path) && !_.isEmpty(path); return _.isString(path) && !_.isEmpty(path);
}, },
canCreateFeedbackPermission() { canCreateFeedbackPermission() {
const path = this.modal.vulnerability.vulnerability_feedback_dismissal_path; const path = this.vulnerability.vulnerability_feedback_dismissal_path;
return _.isString(path) && !_.isEmpty(path); return _.isString(path) && !_.isEmpty(path);
}, },
vulnerability() {
return this.modal.vulnerability;
},
}, },
created() { created() {
this.setProjectsEndpoint(this.projectsEndpoint); this.setProjectsEndpoint(this.projectsEndpoint);
...@@ -77,6 +80,7 @@ export default { ...@@ -77,6 +80,7 @@ export default {
'fetchVulnerabilitiesCount', 'fetchVulnerabilitiesCount',
'fetchVulnerabilitiesHistory', 'fetchVulnerabilitiesHistory',
'undoDismiss', 'undoDismiss',
'createMergeRequest',
'setVulnerabilitiesCountEndpoint', 'setVulnerabilitiesCountEndpoint',
'setVulnerabilitiesEndpoint', 'setVulnerabilitiesEndpoint',
'setVulnerabilitiesHistoryEndpoint', 'setVulnerabilitiesHistoryEndpoint',
...@@ -102,9 +106,10 @@ export default { ...@@ -102,9 +106,10 @@ export default {
:vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath" :vulnerability-feedback-help-path="vulnerabilityFeedbackHelpPath"
:can-create-issue-permission="canCreateIssuePermission" :can-create-issue-permission="canCreateIssuePermission"
:can-create-feedback-permission="canCreateFeedbackPermission" :can-create-feedback-permission="canCreateFeedbackPermission"
@createNewIssue="createIssue({ vulnerability: modal.vulnerability })" @createNewIssue="createIssue({ vulnerability })"
@dismissIssue="dismissVulnerability({ vulnerability: modal.vulnerability })" @dismissIssue="dismissVulnerability({ vulnerability })"
@revertDismissIssue="undoDismiss({ vulnerability: modal.vulnerability })" @revertDismissIssue="undoDismiss({ vulnerability })"
@createMergeRequest="createMergeRequest({ vulnerability })"
/> />
</div> </div>
</template> </template>
...@@ -209,6 +209,55 @@ export const receiveUndoDismissError = ({ commit }, { flashError }) => { ...@@ -209,6 +209,55 @@ export const receiveUndoDismissError = ({ commit }, { flashError }) => {
} }
}; };
export const createMergeRequest = ({ dispatch }, { vulnerability, flashError }) => {
const {
report_type,
project_fingerprint,
vulnerability_feedback_merge_request_path,
} = vulnerability;
dispatch('requestCreateMergeRequest');
axios
.post(vulnerability_feedback_merge_request_path, {
vulnerability_feedback: {
feedback_type: 'merge_request',
category: report_type,
project_fingerprint,
vulnerability_data: {
...vulnerability,
category: report_type,
},
},
})
.then(({ data }) => {
dispatch('receiveCreateMergeRequestSuccess', data);
})
.catch(() => {
dispatch('receiveCreateMergeRequestError', { flashError });
});
};
export const requestCreateMergeRequest = ({ commit }) => {
commit(types.REQUEST_CREATE_MERGE_REQUEST);
};
export const receiveCreateMergeRequestSuccess = ({ commit }, payload) => {
commit(types.RECEIVE_CREATE_MERGE_REQUEST_SUCCESS, payload);
};
export const receiveCreateMergeRequestError = ({ commit }, { flashError }) => {
commit(types.RECEIVE_CREATE_MERGE_REQUEST_ERROR);
if (flashError) {
createFlash(
s__('Security Reports|There was an error creating the merge request.'),
'alert',
document.querySelector('.ci-table'),
);
}
};
export const setVulnerabilitiesHistoryEndpoint = ({ commit }, endpoint) => { export const setVulnerabilitiesHistoryEndpoint = ({ commit }, endpoint) => {
commit(types.SET_VULNERABILITIES_HISTORY_ENDPOINT, endpoint); commit(types.SET_VULNERABILITIES_HISTORY_ENDPOINT, endpoint);
}; };
......
...@@ -27,3 +27,7 @@ export const RECEIVE_DISMISS_VULNERABILITY_ERROR = 'RECEIVE_DISMISS_VULNERABILIT ...@@ -27,3 +27,7 @@ export const RECEIVE_DISMISS_VULNERABILITY_ERROR = 'RECEIVE_DISMISS_VULNERABILIT
export const REQUEST_REVERT_DISMISSAL = 'REQUEST_REVERT_DISMISSAL'; export const REQUEST_REVERT_DISMISSAL = 'REQUEST_REVERT_DISMISSAL';
export const RECEIVE_REVERT_DISMISSAL_SUCCESS = 'RECEIVE_REVERT_DISMISSAL_SUCCESS'; export const RECEIVE_REVERT_DISMISSAL_SUCCESS = 'RECEIVE_REVERT_DISMISSAL_SUCCESS';
export const RECEIVE_REVERT_DISMISSAL_ERROR = 'RECEIVE_REVERT_DISMISSAL_ERROR'; export const RECEIVE_REVERT_DISMISSAL_ERROR = 'RECEIVE_REVERT_DISMISSAL_ERROR';
export const REQUEST_CREATE_MERGE_REQUEST = 'REQUEST_CREATE_MERGE_REQUEST';
export const RECEIVE_CREATE_MERGE_REQUEST_SUCCESS = 'RECEIVE_CREATE_MERGE_REQUEST_SUCCESS';
export const RECEIVE_CREATE_MERGE_REQUEST_ERROR = 'RECEIVE_CREATE_MERGE_REQUEST_ERROR';
...@@ -94,6 +94,11 @@ export default { ...@@ -94,6 +94,11 @@ export default {
Vue.set(state.modal.data.confidence, 'value', vulnerability.confidence); Vue.set(state.modal.data.confidence, 'value', vulnerability.confidence);
Vue.set(state.modal, 'vulnerability', vulnerability); Vue.set(state.modal, 'vulnerability', vulnerability);
Vue.set(state.modal.vulnerability, 'hasIssue', Boolean(vulnerability.issue_feedback)); Vue.set(state.modal.vulnerability, 'hasIssue', Boolean(vulnerability.issue_feedback));
Vue.set(
state.modal.vulnerability,
'hasMergeRequest',
Boolean(vulnerability.merge_request_feedback),
);
Vue.set(state.modal.vulnerability, 'isDismissed', Boolean(vulnerability.dismissal_feedback)); Vue.set(state.modal.vulnerability, 'isDismissed', Boolean(vulnerability.dismissal_feedback));
Vue.set(state.modal, 'error', null); Vue.set(state.modal, 'error', null);
...@@ -165,4 +170,22 @@ export default { ...@@ -165,4 +170,22 @@ export default {
s__('Security Reports|There was an error reverting the dismissal.'), s__('Security Reports|There was an error reverting the dismissal.'),
); );
}, },
[types.REQUEST_CREATE_MERGE_REQUEST](state) {
state.isCreatingMergeRequest = true;
Vue.set(state.modal, 'isCreatingMergeRequest', true);
Vue.set(state.modal, 'error', null);
},
[types.RECEIVE_CREATE_MERGE_REQUEST_SUCCESS](state, payload) {
// We don't cancel the loading state here because we're navigating away from the page
visitUrl(payload.merge_request_path);
},
[types.RECEIVE_CREATE_MERGE_REQUEST_ERROR](state) {
state.isCreatingIssue = false;
Vue.set(state.modal, 'isCreatingMergeRequest', false);
Vue.set(
state.modal,
'error',
s__('security Reports|There was an error creating the merge request'),
);
},
}; };
...@@ -36,8 +36,10 @@ export default () => ({ ...@@ -36,8 +36,10 @@ export default () => ({
}, },
vulnerability: {}, vulnerability: {},
isCreatingNewIssue: false, isCreatingNewIssue: false,
isCreatingMergeRequest: false,
isDismissingVulnerability: false, isDismissingVulnerability: false,
}, },
isCreatingIssue: false, isCreatingIssue: false,
isCreatingMergeRequest: false,
isDismissingVulnerability: false, isDismissingVulnerability: false,
}); });
...@@ -242,6 +242,7 @@ export default { ...@@ -242,6 +242,7 @@ export default {
<grouped-security-reports-app <grouped-security-reports-app
v-if="shouldRenderSecurityReport" v-if="shouldRenderSecurityReport"
:head-blob-path="mr.headBlobPath" :head-blob-path="mr.headBlobPath"
:source-branch="mr.sourceBranch"
:base-blob-path="mr.baseBlobPath" :base-blob-path="mr.baseBlobPath"
:sast-head-path="mr.sast.head_path" :sast-head-path="mr.sast.head_path"
:sast-base-path="mr.sast.base_path" :sast-base-path="mr.sast.base_path"
......
<script>
import { s__, sprintf } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
export default {
name: 'EventItem',
components: {
Icon,
},
props: {
type: {
type: String,
required: true,
},
authorName: {
type: String,
required: true,
},
authorUsername: {
type: String,
required: true,
},
projectName: {
type: String,
required: false,
default: '',
},
projectLink: {
type: String,
required: false,
default: '',
},
actionLinkText: {
type: String,
required: true,
},
actionLinkUrl: {
type: String,
required: true,
},
},
typeMap: {
issue: {
name: s__('Reports|issue'),
icon: 'issue-created',
},
mergeRequest: {
name: s__('Reports|merge request'),
icon: 'merge-request',
},
},
computed: {
typeData() {
return this.$options.typeMap[this.type] || {};
},
iconName() {
return this.typeData.icon || 'plus';
},
createdText() {
return sprintf(s__('ciReport|Created %{eventType}'), { eventType: this.typeData.name });
},
},
};
</script>
<template>
<div class="card-body d-flex align-items-center">
<div class="circle-icon-container ci-status-icon-success">
<icon :size="16" :name="iconName" />
</div>
<div class="ml-3">
<div>
<strong class="js-author-name">{{ authorName }}</strong>
<em class="js-username">@{{ authorUsername }}</em>
</div>
<div>
<span v-if="typeData.name" class="js-created">{{ createdText }}</span>
<a class="js-action-link" :title="actionLinkText" :href="actionLinkUrl">
{{ actionLinkText }}
</a>
<template v-if="projectName">
<span>{{ __('at') }} </span>
<a class="js-project-name" :title="projectName" :href="projectLink">{{ projectName }}</a>
</template>
</div>
</div>
</div>
</template>
...@@ -4,19 +4,24 @@ import Modal from '~/vue_shared/components/gl_modal.vue'; ...@@ -4,19 +4,24 @@ import Modal from '~/vue_shared/components/gl_modal.vue';
import LoadingButton from '~/vue_shared/components/loading_button.vue'; import LoadingButton from '~/vue_shared/components/loading_button.vue';
import Icon from '~/vue_shared/components/icon.vue'; import Icon from '~/vue_shared/components/icon.vue';
import ExpandButton from '~/vue_shared/components/expand_button.vue'; import ExpandButton from '~/vue_shared/components/expand_button.vue';
import EventItem from 'ee/vue_shared/security_reports/components/event_item.vue';
import SafeLink from 'ee/vue_shared/components/safe_link.vue'; import SafeLink from 'ee/vue_shared/components/safe_link.vue';
import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue'; import SolutionCard from 'ee/vue_shared/security_reports/components/solution_card.vue';
import SeverityBadge from './severity_badge.vue'; import SeverityBadge from './severity_badge.vue';
import SplitButton from 'ee/vue_shared/security_reports/components/split_button.vue';
export default { export default {
components: { components: {
SolutionCard, EventItem,
SafeLink,
Modal,
LoadingButton,
ExpandButton, ExpandButton,
Icon, Icon,
LoadingButton,
Modal,
SafeLink,
SeverityBadge, SeverityBadge,
SolutionCard,
SplitButton,
}, },
props: { props: {
modal: { modal: {
...@@ -40,24 +45,54 @@ export default { ...@@ -40,24 +45,54 @@ export default {
}, },
}, },
computed: { computed: {
actionButtons() {
const buttons = [];
const issueButton = {
name: s__('ciReport|Create issue'),
tagline: s__('ciReport|Investigate this vulnerability by creating an issue'),
isLoading: this.modal.isCreatingNewIssue,
action: 'createNewIssue',
};
const MRButton = {
name: s__('ciReport|Create merge request'),
tagline: s__('ciReport|Implement this solution by creating a merge request'),
isLoading: this.modal.isCreatingMergeRequest,
action: 'createMergeRequest',
};
if (!this.vulnerability.hasMergeRequest && this.remediation) {
buttons.push(MRButton);
}
if (!this.vulnerability.hasIssue && this.canCreateIssuePermission) {
buttons.push(issueButton);
}
return buttons;
},
revertTitle() { revertTitle() {
return this.modal.vulnerability.isDismissed return this.vulnerability.isDismissed
? s__('ciReport|Undo dismiss') ? s__('ciReport|Undo dismiss')
: s__('ciReport|Dismiss vulnerability'); : s__('ciReport|Dismiss vulnerability');
}, },
hasDismissedBy() { hasDismissedBy() {
return ( return (
this.modal.vulnerability && this.vulnerability &&
this.modal.vulnerability.dismissalFeedback && this.vulnerability.dismissalFeedback &&
this.modal.vulnerability.dismissalFeedback.pipeline && this.vulnerability.dismissalFeedback.pipeline &&
this.modal.vulnerability.dismissalFeedback.author this.vulnerability.dismissalFeedback.author
); );
}, },
project() {
return this.modal.data.project || {};
},
solution() { solution() {
return this.modal.vulnerability && this.modal.vulnerability.solution; return this.vulnerability && this.vulnerability.solution;
}, },
remediation() { remediation() {
return this.modal.vulnerability && this.modal.vulnerability.remediation; return (
this.vulnerability && this.vulnerability.remediations && this.vulnerability.remediations[0]
);
}, },
renderSolutionCard() { renderSolutionCard() {
return this.solution || this.remediation; return this.solution || this.remediation;
...@@ -71,10 +106,31 @@ export default { ...@@ -71,10 +106,31 @@ export default {
(this.canCreateFeedbackPermission || this.canCreateIssuePermission) (this.canCreateFeedbackPermission || this.canCreateIssuePermission)
); );
}, },
issueFeedback() {
return this.vulnerability && this.vulnerability.issue_feedback;
},
mergeRequestFeedback() {
return this.vulnerability && this.vulnerability.merge_request_feedback;
},
vulnerability() {
return this.modal.vulnerability;
},
valuedFields() {
const { data } = this.modal;
const result = {};
Object.keys(data).forEach(key => {
if (data[key].value && data[key].value.length) {
result[key] = data[key];
}
});
return result;
},
}, },
methods: { methods: {
handleDismissClick() { handleDismissClick() {
if (this.modal.vulnerability.isDismissed) { if (this.vulnerability.isDismissed) {
this.$emit('revertDismissIssue'); this.$emit('revertDismissIssue');
} else { } else {
this.$emit('dismissIssue'); this.$emit('dismissIssue');
...@@ -110,12 +166,7 @@ export default { ...@@ -110,12 +166,7 @@ export default {
> >
<slot> <slot>
<div class="border-white mb-0 px-3"> <div class="border-white mb-0 px-3">
<div <div v-for="(field, key, index) in valuedFields" :key="index" class="d-flex my-2">
v-for="(field, key, index) in modal.data"
v-if="field.value"
:key="index"
class="d-flex my-2"
>
<label class="col-2 text-right font-weight-bold pl-0">{{ field.text }}:</label> <label class="col-2 text-right font-weight-bold pl-0">{{ field.text }}:</label>
<div class="col-10 pl-0 text-secondary"> <div class="col-10 pl-0 text-secondary">
<div v-if="hasInstances(field, key)" class="info-well"> <div v-if="hasInstances(field, key)" class="info-well">
...@@ -201,16 +252,41 @@ export default { ...@@ -201,16 +252,41 @@ export default {
<solution-card v-if="renderSolutionCard" :solution="solution" :remediation="remediation" /> <solution-card v-if="renderSolutionCard" :solution="solution" :remediation="remediation" />
<hr v-else /> <hr v-else />
<ul v-if="vulnerability.hasIssue || vulnerability.hasMergeRequest" class="notes card">
<li v-if="vulnerability.hasIssue" class="note">
<event-item
type="issue"
:project-name="project.value"
:project-link="project.url"
:author-name="issueFeedback.author.name"
:author-username="issueFeedback.author.username"
:action-link-text="`#${issueFeedback.issue_iid}`"
:action-link-url="issueFeedback.issue_url"
/>
</li>
<li v-if="vulnerability.hasMergeRequest" class="note">
<event-item
type="mergeRequest"
:project-name="project.value"
:project-link="project.url"
:author-name="mergeRequestFeedback.author.name"
:author-username="mergeRequestFeedback.author.username"
:action-link-text="`!${mergeRequestFeedback.merge_request_iid}`"
:action-link-url="mergeRequestFeedback.merge_request_path"
/>
</li>
</ul>
<div class="prepend-top-20 append-bottom-10"> <div class="prepend-top-20 append-bottom-10">
<div class="col-sm-12 text-secondary"> <div class="col-sm-12 text-secondary">
<template v-if="hasDismissedBy"> <template v-if="hasDismissedBy">
{{ s__('ciReport|Dismissed by') }} {{ s__('ciReport|Dismissed by') }}
<a :href="modal.vulnerability.dismissalFeedback.author.web_url" class="pipeline-id"> <a :href="vulnerability.dismissalFeedback.author.web_url" class="pipeline-id">
@{{ modal.vulnerability.dismissalFeedback.author.username }} @{{ vulnerability.dismissalFeedback.author.username }}
</a> </a>
{{ s__('ciReport|on pipeline') }} {{ s__('ciReport|on pipeline') }}
<a :href="modal.vulnerability.dismissalFeedback.pipeline.path" class="pipeline-id" <a :href="vulnerability.dismissalFeedback.pipeline.path" class="pipeline-id"
>#{{ modal.vulnerability.dismissalFeedback.pipeline.id }}</a >#{{ vulnerability.dismissalFeedback.pipeline.id }}</a
>. >.
</template> </template>
<a <a
...@@ -240,22 +316,22 @@ export default { ...@@ -240,22 +316,22 @@ export default {
@click="handleDismissClick" @click="handleDismissClick"
/> />
<a <split-button
v-if="modal.vulnerability.hasIssue" v-if="actionButtons.length > 1"
:href="modal.vulnerability.issue_feedback && modal.vulnerability.issue_feedback.issue_url" :buttons="actionButtons"
rel="noopener noreferrer nofollow" class="js-split-button"
class="btn btn-success btn-inverted" @createMergeRequest="$emit('createMergeRequest')"
> @createNewIssue="$emit('createNewIssue')"
{{ __('View issue') }} />
</a>
<loading-button <loading-button
v-else-if="!modal.vulnerability.hasIssue && canCreateIssuePermission" v-else-if="actionButtons.length > 0"
:loading="modal.isCreatingNewIssue" :loading="actionButtons[0].isLoading"
:disabled="modal.isCreatingNewIssue" :disabled="actionButtons[0].isLoading"
:label="__('Create issue')" :label="actionButtons[0].name"
container-class="js-create-issue-btn btn btn-success btn-inverted" container-class="btn btn-success btn-inverted"
@click="$emit('createNewIssue')" class="js-action-button"
@click="$emit(actionButtons[0].action)"
/> />
</template> </template>
</div> </div>
......
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import Icon from '~/vue_shared/components/icon.vue';
export default {
components: {
GlDropdown,
GlDropdownItem,
Icon,
},
props: {
buttons: {
type: Array,
required: true,
},
},
data: () => ({
selectedButton: {},
}),
created() {
this.setButton(this.buttons[0]);
},
methods: {
setButton(button) {
this.selectedButton = button;
},
handleClick() {
this.$emit(this.selectedButton.action);
},
},
};
</script>
<template>
<gl-dropdown
v-if="selectedButton"
no-caret
right
split
variant="success"
:text="selectedButton.name"
@click="handleClick"
>
<gl-dropdown-item v-for="button in buttons" :key="button.action" @click="setButton(button)">
<div class="media">
<div>
<icon v-if="selectedButton === button" class="append-right-5" name="mobile-issue-close" />
</div>
<div class="media-body" :class="{ 'prepend-left-20': selectedButton !== button }">
<strong>{{ button.name }}</strong>
<br />
<span>{{ button.tagline }}</span>
</div>
</div>
</gl-dropdown-item>
</gl-dropdown>
</template>
...@@ -29,6 +29,11 @@ export default { ...@@ -29,6 +29,11 @@ export default {
required: false, required: false,
default: null, default: null,
}, },
sourceBranch: {
type: String,
required: false,
default: null,
},
sastHeadPath: { sastHeadPath: {
type: String, type: String,
required: false, required: false,
...@@ -150,6 +155,7 @@ export default { ...@@ -150,6 +155,7 @@ export default {
created() { created() {
this.setHeadBlobPath(this.headBlobPath); this.setHeadBlobPath(this.headBlobPath);
this.setBaseBlobPath(this.baseBlobPath); this.setBaseBlobPath(this.baseBlobPath);
this.setSourceBranch(this.sourceBranch);
this.setVulnerabilityFeedbackPath(this.vulnerabilityFeedbackPath); this.setVulnerabilityFeedbackPath(this.vulnerabilityFeedbackPath);
this.setVulnerabilityFeedbackHelpPath(this.vulnerabilityFeedbackHelpPath); this.setVulnerabilityFeedbackHelpPath(this.vulnerabilityFeedbackHelpPath);
...@@ -199,6 +205,7 @@ export default { ...@@ -199,6 +205,7 @@ export default {
'setAppType', 'setAppType',
'setHeadBlobPath', 'setHeadBlobPath',
'setBaseBlobPath', 'setBaseBlobPath',
'setSourceBranch',
'setSastHeadPath', 'setSastHeadPath',
'setSastBasePath', 'setSastBasePath',
'setSastContainerHeadPath', 'setSastContainerHeadPath',
...@@ -219,6 +226,7 @@ export default { ...@@ -219,6 +226,7 @@ export default {
'dismissIssue', 'dismissIssue',
'revertDismissIssue', 'revertDismissIssue',
'createNewIssue', 'createNewIssue',
'createMergeRequest',
]), ]),
}, },
}; };
...@@ -319,6 +327,7 @@ export default { ...@@ -319,6 +327,7 @@ export default {
:can-create-feedback-permission="canCreateFeedbackPermission" :can-create-feedback-permission="canCreateFeedbackPermission"
@createNewIssue="createNewIssue" @createNewIssue="createNewIssue"
@dismissIssue="dismissIssue" @dismissIssue="dismissIssue"
@createMergeRequest="createMergeRequest"
@revertDismissIssue="revertDismissIssue" @revertDismissIssue="revertDismissIssue"
/> />
</div> </div>
......
...@@ -26,6 +26,11 @@ export default { ...@@ -26,6 +26,11 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
sourceBranch: {
type: String,
required: false,
default: null,
},
sastHeadPath: { sastHeadPath: {
type: String, type: String,
required: false, required: false,
...@@ -141,6 +146,7 @@ export default { ...@@ -141,6 +146,7 @@ export default {
created() { created() {
// update the store with the received props // update the store with the received props
this.setHeadBlobPath(this.headBlobPath); this.setHeadBlobPath(this.headBlobPath);
this.setSourceBranch(this.sourceBranch);
this.setVulnerabilityFeedbackPath(this.vulnerabilityFeedbackPath); this.setVulnerabilityFeedbackPath(this.vulnerabilityFeedbackPath);
this.setVulnerabilityFeedbackHelpPath(this.vulnerabilityFeedbackHelpPath); this.setVulnerabilityFeedbackHelpPath(this.vulnerabilityFeedbackHelpPath);
this.setPipelineId(this.pipelineId); this.setPipelineId(this.pipelineId);
...@@ -183,6 +189,7 @@ export default { ...@@ -183,6 +189,7 @@ export default {
methods: { methods: {
...mapActions([ ...mapActions([
'setHeadBlobPath', 'setHeadBlobPath',
'setSourceBranch',
'setSastHeadPath', 'setSastHeadPath',
'setDependencyScanningHeadPath', 'setDependencyScanningHeadPath',
'setSastContainerHeadPath', 'setSastContainerHeadPath',
...@@ -199,6 +206,7 @@ export default { ...@@ -199,6 +206,7 @@ export default {
'dismissIssue', 'dismissIssue',
'revertDismissIssue', 'revertDismissIssue',
'createNewIssue', 'createNewIssue',
'createMergeRequest',
]), ]),
summaryTextBuilder(reportType, issuesCount = 0) { summaryTextBuilder(reportType, issuesCount = 0) {
if (issuesCount === 0) { if (issuesCount === 0) {
...@@ -282,6 +290,7 @@ export default { ...@@ -282,6 +290,7 @@ export default {
:can-create-issue-permission="canCreateIssuePermission" :can-create-issue-permission="canCreateIssuePermission"
:can-create-feedback-permission="canCreateFeedbackPermission" :can-create-feedback-permission="canCreateFeedbackPermission"
@createNewIssue="createNewIssue" @createNewIssue="createNewIssue"
@createMergeRequest="createMergeRequest"
@dismissIssue="dismissIssue" @dismissIssue="dismissIssue"
@revertDismissIssue="revertDismissIssue" @revertDismissIssue="revertDismissIssue"
/> />
......
...@@ -8,6 +8,8 @@ export const setHeadBlobPath = ({ commit }, blobPath) => commit(types.SET_HEAD_B ...@@ -8,6 +8,8 @@ export const setHeadBlobPath = ({ commit }, blobPath) => commit(types.SET_HEAD_B
export const setBaseBlobPath = ({ commit }, blobPath) => commit(types.SET_BASE_BLOB_PATH, blobPath); export const setBaseBlobPath = ({ commit }, blobPath) => commit(types.SET_BASE_BLOB_PATH, blobPath);
export const setSourceBranch = ({ commit }, branch) => commit(types.SET_SOURCE_BRANCH, branch);
export const setVulnerabilityFeedbackPath = ({ commit }, path) => export const setVulnerabilityFeedbackPath = ({ commit }, path) =>
commit(types.SET_VULNERABILITY_FEEDBACK_PATH, path); commit(types.SET_VULNERABILITY_FEEDBACK_PATH, path);
...@@ -331,5 +333,45 @@ export const createNewIssue = ({ state, dispatch }) => { ...@@ -331,5 +333,45 @@ export const createNewIssue = ({ state, dispatch }) => {
); );
}; };
export const createMergeRequest = ({ state, dispatch }) => {
const { vulnerability } = state.modal;
const { category, project_fingerprint } = vulnerability;
vulnerability.target_branch = state.sourceBranch;
dispatch('requestCreateMergeRequest');
axios
.post(state.vulnerabilityFeedbackPath, {
vulnerability_feedback: {
feedback_type: 'merge_request',
category,
project_fingerprint,
vulnerability_data: vulnerability,
},
})
.then(({ data }) => {
dispatch('receiveCreateMergeRequestSuccess', data);
})
.catch(() => {
dispatch(
'receiveCreateMergeRequestError',
s__('ciReport|There was an error creating the merge request. Please try again.'),
);
});
};
export const requestCreateMergeRequest = ({ commit }) => {
commit(types.REQUEST_CREATE_MERGE_REQUEST);
};
export const receiveCreateMergeRequestSuccess = ({ commit }, payload) => {
commit(types.RECEIVE_CREATE_MERGE_REQUEST_SUCCESS, payload);
};
export const receiveCreateMergeRequestError = ({ commit }) => {
commit(types.RECEIVE_CREATE_MERGE_REQUEST_ERROR);
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {}; export default () => {};
export const SET_HEAD_BLOB_PATH = 'SET_HEAD_BLOB_PATH'; export const SET_HEAD_BLOB_PATH = 'SET_HEAD_BLOB_PATH';
export const SET_BASE_BLOB_PATH = 'SET_BASE_BLOB_PATH'; export const SET_BASE_BLOB_PATH = 'SET_BASE_BLOB_PATH';
export const SET_SOURCE_BRANCH = 'SET_SOURCE_BRANCH';
export const SET_VULNERABILITY_FEEDBACK_PATH = 'SET_VULNERABILITY_FEEDBACK_PATH'; export const SET_VULNERABILITY_FEEDBACK_PATH = 'SET_VULNERABILITY_FEEDBACK_PATH';
export const SET_VULNERABILITY_FEEDBACK_HELP_PATH = 'SET_VULNERABILITY_FEEDBACK_HELP_PATH'; export const SET_VULNERABILITY_FEEDBACK_HELP_PATH = 'SET_VULNERABILITY_FEEDBACK_HELP_PATH';
export const SET_PIPELINE_ID = 'SET_PIPELINE_ID'; export const SET_PIPELINE_ID = 'SET_PIPELINE_ID';
...@@ -43,6 +44,10 @@ export const REQUEST_CREATE_ISSUE = 'CREATE_DISMISS_ISSUE'; ...@@ -43,6 +44,10 @@ export const REQUEST_CREATE_ISSUE = 'CREATE_DISMISS_ISSUE';
export const RECEIVE_CREATE_ISSUE_SUCCESS = 'CREATE_DISMISS_ISSUE_SUCCESS'; export const RECEIVE_CREATE_ISSUE_SUCCESS = 'CREATE_DISMISS_ISSUE_SUCCESS';
export const RECEIVE_CREATE_ISSUE_ERROR = 'CREATE_DISMISS_ISSUE_ERROR'; export const RECEIVE_CREATE_ISSUE_ERROR = 'CREATE_DISMISS_ISSUE_ERROR';
export const REQUEST_CREATE_MERGE_REQUEST = 'REQUEST_CREATE_MERGE_REQUEST';
export const RECEIVE_CREATE_MERGE_REQUEST_SUCCESS = 'RECEIVE_CREATE_MERGE_REQUEST_SUCCESS';
export const RECEIVE_CREATE_MERGE_REQUEST_ERROR = 'RECEIVE_CREATE_MERGE_REQUEST_ERROR';
export const UPDATE_SAST_ISSUE = 'UPDATE_SAST_ISSUE'; export const UPDATE_SAST_ISSUE = 'UPDATE_SAST_ISSUE';
export const UPDATE_DEPENDENCY_SCANNING_ISSUE = 'UPDATE_DEPENDENCY_SCANNING_ISSUE'; export const UPDATE_DEPENDENCY_SCANNING_ISSUE = 'UPDATE_DEPENDENCY_SCANNING_ISSUE';
export const UPDATE_CONTAINER_SCANNING_ISSUE = 'UPDATE_CONTAINER_SCANNING_ISSUE'; export const UPDATE_CONTAINER_SCANNING_ISSUE = 'UPDATE_CONTAINER_SCANNING_ISSUE';
......
...@@ -9,6 +9,7 @@ import { ...@@ -9,6 +9,7 @@ import {
getUnapprovedVulnerabilities, getUnapprovedVulnerabilities,
findIssueIndex, findIssueIndex,
} from './utils'; } from './utils';
import { visitUrl } from '~/lib/utils/url_utility';
export default { export default {
[types.SET_HEAD_BLOB_PATH](state, path) { [types.SET_HEAD_BLOB_PATH](state, path) {
...@@ -19,6 +20,10 @@ export default { ...@@ -19,6 +20,10 @@ export default {
Vue.set(state.blobPath, 'base', path); Vue.set(state.blobPath, 'base', path);
}, },
[types.SET_SOURCE_BRANCH](state, branch) {
state.sourceBranch = branch;
},
[types.SET_VULNERABILITY_FEEDBACK_PATH](state, path) { [types.SET_VULNERABILITY_FEEDBACK_PATH](state, path) {
state.vulnerabilityFeedbackPath = path; state.vulnerabilityFeedbackPath = path;
}, },
...@@ -388,4 +393,19 @@ export default { ...@@ -388,4 +393,19 @@ export default {
Vue.set(state.modal, 'error', error); Vue.set(state.modal, 'error', error);
Vue.set(state.modal, 'isCreatingNewIssue', false); Vue.set(state.modal, 'isCreatingNewIssue', false);
}, },
[types.REQUEST_CREATE_MERGE_REQUEST](state) {
state.isCreatingMergeRequest = true;
Vue.set(state.modal, 'isCreatingMergeRequest', true);
Vue.set(state.modal, 'error', null);
},
[types.RECEIVE_CREATE_MERGE_REQUEST_SUCCESS](state, payload) {
// We don't cancel the loading state here because we're navigating away from the page
visitUrl(payload.merge_request_path);
},
[types.RECEIVE_CREATE_MERGE_REQUEST_ERROR](state, error) {
state.isCreatingMergeRequest = false;
Vue.set(state.modal, 'isCreatingMergeRequest', false);
Vue.set(state.modal, 'error', error);
},
}; };
...@@ -6,6 +6,7 @@ export default () => ({ ...@@ -6,6 +6,7 @@ export default () => ({
base: null, base: null,
}, },
sourceBranch: null,
vulnerabilityFeedbackPath: null, vulnerabilityFeedbackPath: null,
vulnerabilityFeedbackHelpPath: null, vulnerabilityFeedbackHelpPath: null,
pipelineId: null, pipelineId: null,
...@@ -126,6 +127,7 @@ export default () => ({ ...@@ -126,6 +127,7 @@ export default () => ({
vulnerability: { vulnerability: {
isDismissed: false, isDismissed: false,
hasIssue: false, hasIssue: false,
hasMergeRequest: false,
}, },
isCreatingNewIssue: false, isCreatingNewIssue: false,
......
...@@ -24,17 +24,17 @@ const hasMatchingFix = (fixes, vulnerability) => ...@@ -24,17 +24,17 @@ const hasMatchingFix = (fixes, vulnerability) =>
/** /**
* *
* Returns the first remediation that fixes the given vulnerability or null * Returns the remediations that fix the given vulnerability
* *
* @param {Array} remediations * @param {Array} remediations
* @param {Object} vulnerability * @param {Object} vulnerability
* @returns {Object|null} * @returns {Array}
*/ */
export const findMatchingRemediation = (remediations, vulnerability) => { export const findMatchingRemediations = (remediations, vulnerability) => {
if (!Array.isArray(remediations)) { if (!Array.isArray(remediations)) {
return null; return [];
} }
return remediations.find(rem => hasMatchingFix(rem.fixes, vulnerability)) || null; return remediations.filter(rem => hasMatchingFix(rem.fixes, vulnerability));
}; };
/** /**
...@@ -59,6 +59,12 @@ function enrichVulnerabilityWithfeedback(vulnerability, feedback = []) { ...@@ -59,6 +59,12 @@ function enrichVulnerabilityWithfeedback(vulnerability, feedback = []) {
hasIssue: true, hasIssue: true,
issue_feedback: fb, issue_feedback: fb,
}; };
} else if (fb.feedback_type === 'merge_request') {
return {
...vuln,
hasMergeRequest: true,
merge_request_feedback: fb,
};
} }
return vuln; return vuln;
}, vulnerability); }, vulnerability);
...@@ -177,10 +183,10 @@ export const parseDependencyScanningIssues = (report = [], feedback = [], path = ...@@ -177,10 +183,10 @@ export const parseDependencyScanningIssues = (report = [], feedback = [], path =
title: issue.message, title: issue.message,
}; };
const remediation = findMatchingRemediation(remediations, parsed); const matchingRemediations = findMatchingRemediations(remediations, parsed);
if (remediation) { if (remediations) {
parsed.remediation = remediation; parsed.remediations = matchingRemediations;
} }
return { return {
......
...@@ -16,6 +16,7 @@ ...@@ -16,6 +16,7 @@
dast_head_path: dast_endpoint, dast_head_path: dast_endpoint,
sast_container_head_path: sast_container_endpoint, sast_container_head_path: sast_container_endpoint,
pipeline_id: pipeline.id, pipeline_id: pipeline.id,
source_branch: pipeline.ref,
vulnerability_feedback_path: project_vulnerability_feedback_index_path(project), vulnerability_feedback_path: project_vulnerability_feedback_index_path(project),
vulnerability_feedback_help_path: help_page_path("user/project/merge_requests/index", anchor: "interacting-with-security-reports-ultimate"), vulnerability_feedback_help_path: help_page_path("user/project/merge_requests/index", anchor: "interacting-with-security-reports-ultimate"),
sast_help_path: help_page_path('user/project/merge_requests/sast'), sast_help_path: help_page_path('user/project/merge_requests/sast'),
......
...@@ -460,6 +460,120 @@ describe('issue creation', () => { ...@@ -460,6 +460,120 @@ describe('issue creation', () => {
}); });
}); });
describe('merge request creation', () => {
describe('createMergeRequest', () => {
const vulnerability = mockDataVulnerabilities[0];
const data = { merge_request_path: 'fakepath.html' };
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('on success', () => {
beforeEach(() => {
mock
.onPost(vulnerability.vulnerability_feedback_merge_request_path)
.replyOnce(200, { data });
});
it('should dispatch the request and success actions', done => {
testAction(
actions.createMergeRequest,
{ vulnerability },
{},
[],
[
{ type: 'requestCreateMergeRequest' },
{
type: 'receiveCreateMergeRequestSuccess',
payload: { data },
},
],
done,
);
});
});
describe('on error', () => {
beforeEach(() => {
mock.onPost(vulnerability.vulnerability_feedback_merge_request_path).replyOnce(404, {});
});
it('should dispatch the request and error actions', done => {
const flashError = false;
testAction(
actions.createMergeRequest,
{ vulnerability, flashError },
{},
[],
[
{ type: 'requestCreateMergeRequest' },
{ type: 'receiveCreateMergeRequestError', payload: { flashError } },
],
done,
);
});
});
});
describe('receiveCreateMergeRequestSuccess', () => {
it('should commit the success mutation', done => {
const state = initialState;
const data = mockDataVulnerabilities[0];
testAction(
actions.receiveCreateMergeRequestSuccess,
{ data },
state,
[
{
type: types.RECEIVE_CREATE_MERGE_REQUEST_SUCCESS,
payload: { data },
},
],
[],
done,
);
});
});
describe('receiveCreateMergeRequestError', () => {
it('should commit the error mutation', done => {
const state = initialState;
testAction(
actions.receiveCreateMergeRequestError,
{},
state,
[{ type: types.RECEIVE_CREATE_MERGE_REQUEST_ERROR }],
[],
done,
);
});
});
describe('requestCreateMergeRequest', () => {
it('should commit the request mutation', done => {
const state = initialState;
testAction(
actions.requestCreateMergeRequest,
{},
state,
[{ type: types.REQUEST_CREATE_MERGE_REQUEST }],
[],
done,
);
});
});
});
describe('vulnerability dismissal', () => { describe('vulnerability dismissal', () => {
describe('dismissVulnerability', () => { describe('dismissVulnerability', () => {
const vulnerability = mockDataVulnerabilities[0]; const vulnerability = mockDataVulnerabilities[0];
......
...@@ -389,6 +389,59 @@ describe('vulnerabilities module mutations', () => { ...@@ -389,6 +389,59 @@ describe('vulnerabilities module mutations', () => {
}); });
}); });
describe('REQUEST_CREATE_MERGE_REQUEST', () => {
let state;
beforeEach(() => {
state = createState();
mutations[types.REQUEST_CREATE_MERGE_REQUEST](state);
});
it('should set isCreatingMergeRequest to true', () => {
expect(state.isCreatingMergeRequest).toBe(true);
});
it('should set isCreatingMergeRequest in the modal data to true', () => {
expect(state.modal.isCreatingMergeRequest).toBe(true);
});
it('should nullify the error state on the modal', () => {
expect(state.modal.error).toBeNull();
});
});
describe('RECEIVE_CREATE_MERGE_REQUEST_SUCCESS', () => {
it('should fire the visitUrl function on the merge request URL', () => {
const state = createState();
const payload = { merge_request_path: 'fakepath.html' };
const visitUrl = spyOnDependency(mutations, 'visitUrl');
mutations[types.RECEIVE_CREATE_MERGE_REQUEST_SUCCESS](state, payload);
expect(visitUrl).toHaveBeenCalledWith(payload.merge_request_path);
});
});
describe('RECEIVE_CREATE_MERGE_REQUEST_ERROR', () => {
let state;
beforeEach(() => {
state = createState();
mutations[types.RECEIVE_CREATE_MERGE_REQUEST_ERROR](state);
});
it('should set isCreatingMergeRequest to false', () => {
expect(state.isCreatingMergeRequest).toBe(false);
});
it('should set isCreatingMergeRequest in the modal data to false', () => {
expect(state.modal.isCreatingMergeRequest).toBe(false);
});
it('should set the error state on the modal', () => {
expect(state.modal.error).toEqual('There was an error creating the merge request');
});
});
describe('REQUEST_DISMISS_VULNERABILITY', () => { describe('REQUEST_DISMISS_VULNERABILITY', () => {
let state; let state;
......
import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/event_item.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Event Item', () => {
const Component = Vue.extend(component);
const props = {
authorName: 'Tanuki',
authorUsername: 'gitlab',
actionLinkText: 'foo',
actionLinkUrl: 'example.com',
};
let vm;
afterEach(() => {
vm.$destroy();
});
describe('issue item', () => {
beforeEach(() => {
props.type = 'issue';
vm = mountComponent(Component, props);
});
it('uses the issue icon', () => {
expect(vm.iconName).toBe('issue-created');
});
it('uses the issue name', () => {
expect(vm.$el.querySelector('.js-created').textContent).toContain('issue');
});
it('uses the author name', () => {
expect(vm.$el.querySelector('.js-author-name').textContent).toContain(props.authorName);
});
it('uses the author username', () => {
expect(vm.$el.querySelector('.js-username').textContent).toContain(props.authorUsername);
});
it('uses the action link text', () => {
expect(vm.$el.querySelector('.js-action-link').textContent).toContain(props.actionLinkText);
});
it('uses the action link url', () => {
expect(vm.$el.querySelector('.js-action-link').getAttribute('href')).toBe(
props.actionLinkUrl,
);
});
});
describe('merge request item', () => {
beforeEach(() => {
props.type = 'mergeRequest';
vm = mountComponent(Component, props);
});
it('uses the merge request icon', () => {
expect(vm.iconName).toBe('merge-request');
});
it('uses the issue name', () => {
expect(vm.$el.querySelector('.js-created').textContent).toContain('merge request');
});
});
describe('unknown item', () => {
beforeEach(() => {
props.type = 'notARealType';
vm = mountComponent(Component, props);
});
it('uses the fallback icon', () => {
expect(vm.iconName).toBe('plus');
});
it("doesn't display the created text", () => {
expect(vm.$el.querySelector('.js-created')).toBeNull();
});
});
});
...@@ -66,7 +66,7 @@ describe('Security Reports modal', () => { ...@@ -66,7 +66,7 @@ describe('Security Reports modal', () => {
expect(vm.$el.querySelector('.js-create-issue-btn')).toBe(null); expect(vm.$el.querySelector('.js-create-issue-btn')).toBe(null);
}); });
it('renders create issue button and footer', () => { it('renders the dismiss button', () => {
expect(vm.$el.querySelector('.js-dismiss-btn')).not.toBe(null); expect(vm.$el.querySelector('.js-dismiss-btn')).not.toBe(null);
}); });
...@@ -98,7 +98,7 @@ describe('Security Reports modal', () => { ...@@ -98,7 +98,7 @@ describe('Security Reports modal', () => {
}); });
it('renders create issue button', () => { it('renders create issue button', () => {
expect(vm.$el.querySelector('.js-create-issue-btn')).not.toBe(null); expect(vm.$el.querySelector('.js-action-button')).not.toBe(null);
}); });
it('renders the footer', () => { it('renders the footer', () => {
...@@ -108,7 +108,7 @@ describe('Security Reports modal', () => { ...@@ -108,7 +108,7 @@ describe('Security Reports modal', () => {
it('emits createIssue when create issue button is clicked', () => { it('emits createIssue when create issue button is clicked', () => {
spyOn(vm, '$emit'); spyOn(vm, '$emit');
const button = vm.$el.querySelector('.js-create-issue-btn'); const button = vm.$el.querySelector('.js-action-button');
button.click(); button.click();
expect(vm.$emit).toHaveBeenCalledWith('createNewIssue'); expect(vm.$emit).toHaveBeenCalledWith('createNewIssue');
...@@ -231,7 +231,7 @@ describe('Security Reports modal', () => { ...@@ -231,7 +231,7 @@ describe('Security Reports modal', () => {
modal: createState().modal, modal: createState().modal,
}; };
const summary = 'Upgrade to 123'; const summary = 'Upgrade to 123';
props.modal.vulnerability.remediation = { summary }; props.modal.vulnerability.remediations = [{ summary }];
vm = mountComponent(Component, props); vm = mountComponent(Component, props);
const solutionCard = vm.$el.querySelector('.js-solution-card'); const solutionCard = vm.$el.querySelector('.js-solution-card');
......
import Vue from 'vue';
import component from 'ee/vue_shared/security_reports/components/split_button.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
describe('Split Button', () => {
const Component = Vue.extend(component);
const buttons = [
{
name: 'button one',
tagline: "button one's tagline",
isLoading: false,
action: 'button1Action',
},
{
name: 'button two',
tagline: "button two's tagline",
isLoading: false,
action: 'button2Action',
},
{
name: 'button three',
tagline: "button three's tagline",
isLoading: true,
action: 'button3Action',
},
];
let props;
let vm;
afterEach(() => {
vm.$destroy();
});
describe('with two buttons', () => {
beforeEach(() => {
props = { buttons: buttons.slice(0, 2) };
vm = mountComponent(Component, props);
});
it('renders two dropdown items', () => {
expect(vm.$el.querySelectorAll('.dropdown-item')).toHaveLength(2);
});
it('displays the first button initially', () => {
expect(vm.$el.querySelector('.btn').textContent.trim()).toBe(buttons[0].name);
});
it('emits the correct event when the button is pressed', () => {
spyOn(vm, '$emit');
vm.$el.querySelector('.btn').click();
expect(vm.$emit).toHaveBeenCalledWith(buttons[0].action);
});
});
describe('with three buttons', () => {
beforeEach(() => {
props = { buttons };
vm = mountComponent(Component, props);
});
it('renders three dropdown items', () => {
expect(vm.$el.querySelectorAll('.dropdown-item')).toHaveLength(3);
});
});
});
...@@ -540,6 +540,7 @@ export const parsedDependencyScanningIssuesStore = [ ...@@ -540,6 +540,7 @@ export const parsedDependencyScanningIssuesStore = [
urlPath: 'path/Gemfile.lock#L5', urlPath: 'path/Gemfile.lock#L5',
category: 'dependency_scanning', category: 'dependency_scanning',
project_fingerprint: 'f55331d66fd4f3bfb4237d48e9c9fa8704bd33c6', project_fingerprint: 'f55331d66fd4f3bfb4237d48e9c9fa8704bd33c6',
remediations: [],
location: { location: {
file: 'Gemfile.lock', file: 'Gemfile.lock',
start_line: 5, start_line: 5,
...@@ -563,6 +564,7 @@ export const parsedDependencyScanningIssuesStore = [ ...@@ -563,6 +564,7 @@ export const parsedDependencyScanningIssuesStore = [
urlPath: 'path/Gemfile.lock', urlPath: 'path/Gemfile.lock',
category: 'dependency_scanning', category: 'dependency_scanning',
project_fingerprint: 'a6b61a2eba59071178d5899b26dd699fb880de1e', project_fingerprint: 'a6b61a2eba59071178d5899b26dd699fb880de1e',
remediations: [],
location: { location: {
file: 'Gemfile.lock', file: 'Gemfile.lock',
start_line: undefined, start_line: undefined,
...@@ -586,6 +588,7 @@ export const parsedDependencyScanningIssuesStore = [ ...@@ -586,6 +588,7 @@ export const parsedDependencyScanningIssuesStore = [
urlPath: 'path/Gemfile.lock', urlPath: 'path/Gemfile.lock',
category: 'dependency_scanning', category: 'dependency_scanning',
project_fingerprint: '830f85e5fb011408bab365eb809cd97a45b0aa17', project_fingerprint: '830f85e5fb011408bab365eb809cd97a45b0aa17',
remediations: [],
location: { location: {
file: 'Gemfile.lock', file: 'Gemfile.lock',
start_line: undefined, start_line: undefined,
...@@ -612,6 +615,7 @@ export const parsedDependencyScanningIssuesHead = [ ...@@ -612,6 +615,7 @@ export const parsedDependencyScanningIssuesHead = [
urlPath: 'path/Gemfile.lock#L5', urlPath: 'path/Gemfile.lock#L5',
category: 'dependency_scanning', category: 'dependency_scanning',
project_fingerprint: 'f55331d66fd4f3bfb4237d48e9c9fa8704bd33c6', project_fingerprint: 'f55331d66fd4f3bfb4237d48e9c9fa8704bd33c6',
remediations: [],
location: { location: {
file: 'Gemfile.lock', file: 'Gemfile.lock',
start_line: 5, start_line: 5,
...@@ -635,6 +639,7 @@ export const parsedDependencyScanningIssuesHead = [ ...@@ -635,6 +639,7 @@ export const parsedDependencyScanningIssuesHead = [
urlPath: 'path/Gemfile.lock', urlPath: 'path/Gemfile.lock',
category: 'dependency_scanning', category: 'dependency_scanning',
project_fingerprint: '830f85e5fb011408bab365eb809cd97a45b0aa17', project_fingerprint: '830f85e5fb011408bab365eb809cd97a45b0aa17',
remediations: [],
location: { location: {
file: 'Gemfile.lock', file: 'Gemfile.lock',
start_line: undefined, start_line: undefined,
...@@ -661,6 +666,7 @@ export const parsedDependencyScanningBaseStore = [ ...@@ -661,6 +666,7 @@ export const parsedDependencyScanningBaseStore = [
urlPath: 'path/Gemfile.lock', urlPath: 'path/Gemfile.lock',
category: 'dependency_scanning', category: 'dependency_scanning',
project_fingerprint: '3f5608c99f0c7442ba59bc6c0c1864d0000f8e1a', project_fingerprint: '3f5608c99f0c7442ba59bc6c0c1864d0000f8e1a',
remediations: [],
location: { location: {
file: 'Gemfile.lock', file: 'Gemfile.lock',
start_line: undefined, start_line: undefined,
......
...@@ -43,6 +43,10 @@ import actions, { ...@@ -43,6 +43,10 @@ import actions, {
receiveCreateIssue, receiveCreateIssue,
receiveCreateIssueError, receiveCreateIssueError,
createNewIssue, createNewIssue,
requestCreateMergeRequest,
receiveCreateMergeRequestSuccess,
receiveCreateMergeRequestError,
createMergeRequest,
updateSastIssue, updateSastIssue,
updateDependencyScanningIssue, updateDependencyScanningIssue,
updateContainerScanningIssue, updateContainerScanningIssue,
...@@ -1548,6 +1552,111 @@ describe('security reports actions', () => { ...@@ -1548,6 +1552,111 @@ describe('security reports actions', () => {
}); });
}); });
describe('requestCreateMergeRequest', () => {
it('commits request create merge request', done => {
testAction(
requestCreateMergeRequest,
null,
mockedState,
[
{
type: types.REQUEST_CREATE_MERGE_REQUEST,
},
],
[],
done,
);
});
});
describe('receiveCreateMergeRequestSuccess', () => {
it('commits receive create merge request', done => {
const data = { foo: 'bar' };
testAction(
receiveCreateMergeRequestSuccess,
data,
mockedState,
[
{
type: types.RECEIVE_CREATE_MERGE_REQUEST_SUCCESS,
payload: data,
},
],
[],
done,
);
});
});
describe('receiveCreateMergeRequestError', () => {
it('commits receive create merge request error', done => {
testAction(
receiveCreateMergeRequestError,
'',
mockedState,
[
{
type: types.RECEIVE_CREATE_MERGE_REQUEST_ERROR,
},
],
[],
done,
);
});
});
describe('createMergeRequest', () => {
beforeEach(() => {
spyOnDependency(actions, 'visitUrl');
});
it('with success should dispatch `receiveCreateMergeRequestSuccess`', done => {
const data = { merge_request_path: 'fakepath.html' };
mock.onPost('create_merge_request_path').reply(200, data);
mockedState.vulnerabilityFeedbackPath = 'create_merge_request_path';
testAction(
createMergeRequest,
null,
mockedState,
[],
[
{
type: 'requestCreateMergeRequest',
},
{
type: 'receiveCreateMergeRequestSuccess',
payload: data,
},
],
done,
);
});
it('with error should dispatch `receiveCreateMergeRequestError`', done => {
mock.onPost('create_merge_request_path').reply(500, {});
mockedState.vulnerabilityFeedbackPath = 'create_merge_request_path';
testAction(
createMergeRequest,
null,
mockedState,
[],
[
{
type: 'requestCreateMergeRequest',
},
{
type: 'receiveCreateMergeRequestError',
payload: 'There was an error creating the merge request. Please try again.',
},
],
done,
);
});
});
describe('updateSastIssue', () => { describe('updateSastIssue', () => {
it('commits update sast issue', done => { it('commits update sast issue', done => {
const payload = { foo: 'bar' }; const payload = { foo: 'bar' };
......
...@@ -497,6 +497,34 @@ describe('security reports mutations', () => { ...@@ -497,6 +497,34 @@ describe('security reports mutations', () => {
}); });
}); });
describe('REQUEST_CREATE_MERGE_REQUEST', () => {
it('sets isCreatingMergeRequest prop to true and resets error', () => {
mutations[types.REQUEST_CREATE_MERGE_REQUEST](stateCopy);
expect(stateCopy.modal.isCreatingMergeRequest).toEqual(true);
expect(stateCopy.modal.error).toBeNull();
});
});
describe('RECEIVE_CREATE_MERGE_REQUEST_SUCCESS', () => {
it('should fire the visitUrl function on the merge request URL', () => {
const payload = { merge_request_path: 'fakepath.html' };
const visitUrl = spyOnDependency(mutations, 'visitUrl');
mutations[types.RECEIVE_CREATE_MERGE_REQUEST_SUCCESS](stateCopy, payload);
expect(visitUrl).toHaveBeenCalledWith(payload.merge_request_path);
});
});
describe('RECEIVE_CREATE_MERGE_REQUEST_ERROR', () => {
it('sets isCreatingMergeRequest prop to false and sets error', () => {
mutations[types.RECEIVE_CREATE_MERGE_REQUEST_ERROR](stateCopy, 'error');
expect(stateCopy.modal.isCreatingMergeRequest).toEqual(false);
expect(stateCopy.modal.error).toEqual('error');
});
});
describe('UPDATE_SAST_ISSUE', () => { describe('UPDATE_SAST_ISSUE', () => {
it('updates issue in the new issues list', () => { it('updates issue in the new issues list', () => {
stateCopy.sast.newIssues = parsedSastIssuesHead; stateCopy.sast.newIssues = parsedSastIssuesHead;
......
import sha1 from 'sha1'; import sha1 from 'sha1';
import { import {
findIssueIndex, findIssueIndex,
findMatchingRemediation, findMatchingRemediations,
parseSastIssues, parseSastIssues,
parseDependencyScanningIssues, parseDependencyScanningIssues,
parseSastContainer, parseSastContainer,
...@@ -57,7 +57,7 @@ describe('security reports utils', () => { ...@@ -57,7 +57,7 @@ describe('security reports utils', () => {
}); });
}); });
describe('findMatchingRemediation', () => { describe('findMatchingRemediations', () => {
const remediation1 = { const remediation1 = {
fixes: [ fixes: [
{ {
...@@ -80,24 +80,31 @@ describe('security reports utils', () => { ...@@ -80,24 +80,31 @@ describe('security reports utils', () => {
const remediations = [impossibleRemediation, remediation1, remediation2]; const remediations = [impossibleRemediation, remediation1, remediation2];
it('returns null for empty vulnerability', () => { it('returns null for empty vulnerability', () => {
expect(findMatchingRemediation(remediations, {})).toBeNull(); expect(findMatchingRemediations(remediations, {})).toHaveLength(0);
expect(findMatchingRemediation(remediations, null)).toBeNull(); expect(findMatchingRemediations(remediations, null)).toHaveLength(0);
expect(findMatchingRemediation(remediations, undefined)).toBeNull(); expect(findMatchingRemediations(remediations, undefined)).toHaveLength(0);
}); });
it('returns null for empty remediations', () => { it('returns empty arrays for empty remediations', () => {
expect(findMatchingRemediation([], { cve: '123' })).toBeNull(); expect(findMatchingRemediations([], { cve: '123' })).toHaveLength(0);
expect(findMatchingRemediation(null, { cve: '123' })).toBeNull(); expect(findMatchingRemediations(null, { cve: '123' })).toHaveLength(0);
expect(findMatchingRemediation(undefined, { cve: '123' })).toBeNull(); expect(findMatchingRemediations(undefined, { cve: '123' })).toHaveLength(0);
}); });
it('returns null for vulnerabilities without remediation', () => { it('returns an empty array for vulnerabilities without a remediation', () => {
expect(findMatchingRemediation(remediations, { cve: 'NOT_FOUND' })).toBeNull(); expect(findMatchingRemediations(remediations, { cve: 'NOT_FOUND' })).toHaveLength(0);
}); });
it('returns first matching remediation for a vulnerability', () => { it('returns all matching remediations for a vulnerability', () => {
expect(findMatchingRemediation(remediations, { cve: '123' })).toEqual(remediation1); expect(findMatchingRemediations(remediations, { cve: '123' })).toEqual([
expect(findMatchingRemediation(remediations, { foobar: 'baz' })).toEqual(remediation1); remediation1,
remediation2,
]);
expect(findMatchingRemediations(remediations, { foobar: 'baz' })).toEqual([
remediation1,
remediation2,
]);
}); });
}); });
...@@ -186,7 +193,7 @@ describe('security reports utils', () => { ...@@ -186,7 +193,7 @@ describe('security reports utils', () => {
expect(parsed.location.end_line).toBeUndefined(); expect(parsed.location.end_line).toBeUndefined();
expect(parsed.urlPath).toEqual(`path/${raw.location.file}`); expect(parsed.urlPath).toEqual(`path/${raw.location.file}`);
expect(parsed.project_fingerprint).toEqual(sha1(raw.cve)); expect(parsed.project_fingerprint).toEqual(sha1(raw.cve));
expect(parsed.remediation).toEqual(dependencyScanningIssuesMajor2.remediations[0]); expect(parsed.remediations).toEqual([dependencyScanningIssuesMajor2.remediations[0]]);
}); });
it('generate correct path to file when there is no line', () => { it('generate correct path to file when there is no line', () => {
......
...@@ -3002,9 +3002,6 @@ msgstr "" ...@@ -3002,9 +3002,6 @@ msgstr ""
msgid "Create group label" msgid "Create group label"
msgstr "" msgstr ""
msgid "Create issue"
msgstr ""
msgid "Create lists from labels. Issues with that label appear in that list." msgid "Create lists from labels. Issues with that label appear in that list."
msgstr "" msgstr ""
...@@ -8324,6 +8321,12 @@ msgstr "" ...@@ -8324,6 +8321,12 @@ msgstr ""
msgid "Reports|Vulnerability" msgid "Reports|Vulnerability"
msgstr "" msgstr ""
msgid "Reports|issue"
msgstr ""
msgid "Reports|merge request"
msgstr ""
msgid "Reports|no changed test results" msgid "Reports|no changed test results"
msgstr "" msgstr ""
...@@ -8750,6 +8753,9 @@ msgstr "" ...@@ -8750,6 +8753,9 @@ msgstr ""
msgid "Security Reports|There was an error creating the issue." msgid "Security Reports|There was an error creating the issue."
msgstr "" msgstr ""
msgid "Security Reports|There was an error creating the merge request."
msgstr ""
msgid "Security Reports|There was an error dismissing the vulnerability." msgid "Security Reports|There was an error dismissing the vulnerability."
msgstr "" msgstr ""
...@@ -10958,9 +10964,6 @@ msgstr "" ...@@ -10958,9 +10964,6 @@ msgstr ""
msgid "View in Sentry" msgid "View in Sentry"
msgstr "" msgstr ""
msgid "View issue"
msgstr ""
msgid "View it on GitLab" msgid "View it on GitLab"
msgstr "" msgstr ""
...@@ -11528,6 +11531,9 @@ msgstr "" ...@@ -11528,6 +11531,9 @@ msgstr ""
msgid "assign yourself" msgid "assign yourself"
msgstr "" msgstr ""
msgid "at"
msgstr ""
msgid "attach a new file" msgid "attach a new file"
msgstr "" msgstr ""
...@@ -11648,6 +11654,15 @@ msgstr "" ...@@ -11648,6 +11654,15 @@ msgstr ""
msgid "ciReport|Container scanning detects known vulnerabilities in your docker images." msgid "ciReport|Container scanning detects known vulnerabilities in your docker images."
msgstr "" msgstr ""
msgid "ciReport|Create issue"
msgstr ""
msgid "ciReport|Create merge request"
msgstr ""
msgid "ciReport|Created %{eventType}"
msgstr ""
msgid "ciReport|DAST" msgid "ciReport|DAST"
msgstr "" msgstr ""
...@@ -11690,9 +11705,15 @@ msgstr "" ...@@ -11690,9 +11705,15 @@ msgstr ""
msgid "ciReport|Identifiers" msgid "ciReport|Identifiers"
msgstr "" msgstr ""
msgid "ciReport|Implement this solution by creating a merge request"
msgstr ""
msgid "ciReport|Instances" msgid "ciReport|Instances"
msgstr "" msgstr ""
msgid "ciReport|Investigate this vulnerability by creating an issue"
msgstr ""
msgid "ciReport|Learn more about interacting with security reports (Alpha)." msgid "ciReport|Learn more about interacting with security reports (Alpha)."
msgstr "" msgstr ""
...@@ -11757,6 +11778,9 @@ msgstr "" ...@@ -11757,6 +11778,9 @@ msgstr ""
msgid "ciReport|There was an error creating the issue. Please try again." msgid "ciReport|There was an error creating the issue. Please try again."
msgstr "" msgstr ""
msgid "ciReport|There was an error creating the merge request. Please try again."
msgstr ""
msgid "ciReport|There was an error dismissing the vulnerability. Please try again." msgid "ciReport|There was an error dismissing the vulnerability. Please try again."
msgstr "" msgstr ""
...@@ -12282,6 +12306,9 @@ msgstr[1] "" ...@@ -12282,6 +12306,9 @@ msgstr[1] ""
msgid "score" msgid "score"
msgstr "" msgstr ""
msgid "security Reports|There was an error creating the merge request"
msgstr ""
msgid "should be higher than %{access} inherited membership from group %{group_name}" msgid "should be higher than %{access} inherited membership from group %{group_name}"
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