Commit c9aa0b7b authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents d060a7f0 97b71aa8
...@@ -15,9 +15,9 @@ ...@@ -15,9 +15,9 @@
## Author's checklist (required) ## Author's checklist (required)
- [ ] Follow the [Documentation Guidelines](https://docs.gitlab.com/ee/development/documentation/) and [Style Guide](https://docs.gitlab.com/ee/development/documentation/styleguide.html). - [ ] Follow the [Documentation Guidelines](https://docs.gitlab.com/ee/development/documentation/) and [Style Guide](https://docs.gitlab.com/ee/development/documentation/styleguide/).
- If you have **Developer** permissions or higher: - If you have **Developer** permissions or higher:
- [ ] Ensure that the [product tier badge](https://docs.gitlab.com/ee/development/documentation/styleguide.html#product-badges) is added to doc's `h1`. - [ ] Ensure that the [product tier badge](https://docs.gitlab.com/ee/development/documentation/styleguide/index.html#product-tier-badges) is added to doc's `h1`.
- [ ] Apply the ~documentation label, plus: - [ ] Apply the ~documentation label, plus:
- The corresponding DevOps stage and group labels, if applicable. - The corresponding DevOps stage and group labels, if applicable.
- ~"development guidelines" when changing docs under `doc/development/*`, `CONTRIBUTING.md`, or `README.md`. - ~"development guidelines" when changing docs under `doc/development/*`, `CONTRIBUTING.md`, or `README.md`.
......
<script> <script>
import LinkedGraphWrapper from '../graph_shared/linked_graph_wrapper.vue';
import LinkedPipelinesColumn from './linked_pipelines_column.vue';
import StageColumnComponent from './stage_column_component.vue'; import StageColumnComponent from './stage_column_component.vue';
import { MAIN } from './constants'; import { DOWNSTREAM, MAIN, UPSTREAM } from './constants';
export default { export default {
name: 'PipelineGraph', name: 'PipelineGraph',
components: { components: {
LinkedGraphWrapper,
LinkedPipelinesColumn,
StageColumnComponent, StageColumnComponent,
}, },
props: { props: {
...@@ -23,10 +27,60 @@ export default { ...@@ -23,10 +27,60 @@ export default {
default: MAIN, default: MAIN,
}, },
}, },
pipelineTypeConstants: {
DOWNSTREAM,
UPSTREAM,
},
data() {
return {
hoveredJobName: '',
pipelineExpanded: {
jobName: '',
expanded: false,
},
};
},
computed: { computed: {
downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
},
graph() { graph() {
return this.pipeline.stages; return this.pipeline.stages;
}, },
hasDownstreamPipelines() {
return Boolean(this.pipeline?.downstream?.length > 0);
},
hasUpstreamPipelines() {
return Boolean(this.pipeline?.upstream?.length > 0);
},
// The two show checks prevent upstream / downstream from showing redundant linked columns
showDownstreamPipelines() {
return (
this.hasDownstreamPipelines && this.type !== this.$options.pipelineTypeConstants.UPSTREAM
);
},
showUpstreamPipelines() {
return (
this.hasUpstreamPipelines && this.type !== this.$options.pipelineTypeConstants.DOWNSTREAM
);
},
upstreamPipelines() {
return this.hasUpstreamPipelines ? this.pipeline.upstream : [];
},
},
methods: {
handleError(errorType) {
this.$emit('error', errorType);
},
setJob(jobName) {
this.hoveredJobName = jobName;
},
togglePipelineExpanded(jobName, expanded) {
this.pipelineExpanded = {
expanded,
jobName: expanded ? jobName : '',
};
},
}, },
}; };
</script> </script>
...@@ -36,13 +90,39 @@ export default { ...@@ -36,13 +90,39 @@ export default {
class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap" class="gl-pipeline-min-h gl-display-flex gl-position-relative gl-overflow-auto gl-bg-gray-10 gl-white-space-nowrap"
:class="{ 'gl-py-5': !isLinkedPipeline }" :class="{ 'gl-py-5': !isLinkedPipeline }"
> >
<stage-column-component <linked-graph-wrapper>
v-for="stage in graph" <template #upstream>
:key="stage.name" <linked-pipelines-column
:title="stage.name" v-if="showUpstreamPipelines"
:groups="stage.groups" :linked-pipelines="upstreamPipelines"
:action="stage.status.action" :column-title="__('Upstream')"
/> :type="$options.pipelineTypeConstants.UPSTREAM"
@error="handleError"
/>
</template>
<template #main>
<stage-column-component
v-for="stage in graph"
:key="stage.name"
:title="stage.name"
:groups="stage.groups"
:action="stage.status.action"
:job-hovered="hoveredJobName"
:pipeline-expanded="pipelineExpanded"
/>
</template>
<template #downstream>
<linked-pipelines-column
v-if="showDownstreamPipelines"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:type="$options.pipelineTypeConstants.DOWNSTREAM"
@downstreamHovered="setJob"
@pipelineExpandToggle="togglePipelineExpanded"
@error="handleError"
/>
</template>
</linked-graph-wrapper>
</div> </div>
</div> </div>
</template> </template>
...@@ -42,7 +42,7 @@ export default { ...@@ -42,7 +42,7 @@ export default {
}; };
}, },
update(data) { update(data) {
return unwrapPipelineData(this.pipelineIid, data); return unwrapPipelineData(this.pipelineProjectPath, data);
}, },
error() { error() {
this.reportFailure(LOAD_FAILURE); this.reportFailure(LOAD_FAILURE);
...@@ -77,13 +77,11 @@ export default { ...@@ -77,13 +77,11 @@ export default {
}; };
</script> </script>
<template> <template>
<gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert"> <div>
{{ alert.text }} <gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
</gl-alert> {{ alert.text }}
<gl-loading-icon </gl-alert>
v-else-if="$apollo.queries.pipeline.loading" <gl-loading-icon v-if="$apollo.queries.pipeline.loading" class="gl-mx-auto gl-my-4" size="lg" />
class="gl-mx-auto gl-my-4" <pipeline-graph v-if="pipeline" :pipeline="pipeline" @error="reportFailure" />
size="lg" </div>
/>
<pipeline-graph v-else :pipeline="pipeline" />
</template> </template>
...@@ -25,23 +25,33 @@ export default { ...@@ -25,23 +25,33 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
pipeline: { expanded: {
type: Object, type: Boolean,
required: true, required: true,
}, },
projectId: { pipeline: {
type: Number, type: Object,
required: true, required: true,
}, },
type: { type: {
type: String, type: String,
required: true, required: true,
}, },
}, /*
data() { The next two props will be removed or required
return { once the graph transition is done.
expanded: false, See: https://gitlab.com/gitlab-org/gitlab/-/issues/291043
}; */
isLoading: {
type: Boolean,
required: false,
default: false,
},
projectId: {
type: Number,
required: false,
default: -1,
},
}, },
computed: { computed: {
tooltipText() { tooltipText() {
...@@ -74,6 +84,9 @@ export default { ...@@ -74,6 +84,9 @@ export default {
} }
return __('Multi-project'); return __('Multi-project');
}, },
pipelineIsLoading() {
return Boolean(this.isLoading || this.pipeline.isLoading);
},
isDownstream() { isDownstream() {
return this.type === DOWNSTREAM; return this.type === DOWNSTREAM;
}, },
...@@ -81,7 +94,9 @@ export default { ...@@ -81,7 +94,9 @@ export default {
return this.type === UPSTREAM; return this.type === UPSTREAM;
}, },
isSameProject() { isSameProject() {
return this.projectId === this.pipeline.project.id; return this.projectId > -1
? this.projectId === this.pipeline.project.id
: !this.pipeline.multiproject;
}, },
sourceJobName() { sourceJobName() {
return accessValue(this.dataMethod, 'sourceJob', this.pipeline); return accessValue(this.dataMethod, 'sourceJob', this.pipeline);
...@@ -101,16 +116,15 @@ export default { ...@@ -101,16 +116,15 @@ export default {
}, },
methods: { methods: {
onClickLinkedPipeline() { onClickLinkedPipeline() {
this.$root.$emit('bv::hide::tooltip', this.buttonId); this.hideTooltips();
this.expanded = !this.expanded;
this.$emit('pipelineClicked', this.$refs.linkedPipeline); this.$emit('pipelineClicked', this.$refs.linkedPipeline);
this.$emit('pipelineExpandToggle', this.pipeline.source_job.name, this.expanded); this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded);
}, },
hideTooltips() { hideTooltips() {
this.$root.$emit('bv::hide::tooltip'); this.$root.$emit('bv::hide::tooltip');
}, },
onDownstreamHovered() { onDownstreamHovered() {
this.$emit('downstreamHovered', this.pipeline.source_job.name); this.$emit('downstreamHovered', this.sourceJobName);
}, },
onDownstreamHoverLeave() { onDownstreamHoverLeave() {
this.$emit('downstreamHovered', ''); this.$emit('downstreamHovered', '');
...@@ -120,10 +134,10 @@ export default { ...@@ -120,10 +134,10 @@ export default {
</script> </script>
<template> <template>
<li <div
ref="linkedPipeline" ref="linkedPipeline"
v-gl-tooltip v-gl-tooltip
class="linked-pipeline build" class="linked-pipeline build gl-pipeline-job-width"
:title="tooltipText" :title="tooltipText"
:class="{ 'downstream-pipeline': isDownstream }" :class="{ 'downstream-pipeline': isDownstream }"
data-qa-selector="child_pipeline" data-qa-selector="child_pipeline"
...@@ -136,8 +150,9 @@ export default { ...@@ -136,8 +150,9 @@ export default {
> >
<div class="gl-display-flex"> <div class="gl-display-flex">
<ci-status <ci-status
v-if="!pipeline.isLoading" v-if="!pipelineIsLoading"
:status="pipelineStatus" :status="pipelineStatus"
:size="24"
css-classes="gl-top-0 gl-pr-2" css-classes="gl-top-0 gl-pr-2"
/> />
<div v-else class="gl-pr-2"><gl-loading-icon inline /></div> <div v-else class="gl-pr-2"><gl-loading-icon inline /></div>
...@@ -160,10 +175,10 @@ export default { ...@@ -160,10 +175,10 @@ export default {
class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!" class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
:class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`" :class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
:icon="expandedIcon" :icon="expandedIcon"
data-testid="expandPipelineButton" data-testid="expand-pipeline-button"
data-qa-selector="expand_pipeline_button" data-qa-selector="expand_pipeline_button"
@click="onClickLinkedPipeline" @click="onClickLinkedPipeline"
/> />
</div> </div>
</li> </div>
</template> </template>
<script> <script>
import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql';
import LinkedPipeline from './linked_pipeline.vue'; import LinkedPipeline from './linked_pipeline.vue';
import { LOAD_FAILURE } from '../../constants';
import { UPSTREAM } from './constants'; import { UPSTREAM } from './constants';
import { unwrapPipelineData } from './utils';
export default { export default {
components: { components: {
LinkedPipeline, LinkedPipeline,
PipelineGraph: () => import('./graph_component.vue'),
}, },
props: { props: {
columnTitle: { columnTitle: {
...@@ -19,11 +23,22 @@ export default { ...@@ -19,11 +23,22 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
projectId: {
type: Number,
required: true,
},
}, },
data() {
return {
currentPipeline: null,
loadingPipelineId: null,
pipelineExpanded: false,
};
},
titleClasses: [
'gl-font-weight-bold',
'gl-pipeline-job-width',
'gl-text-truncate',
'gl-line-height-36',
'gl-pl-3',
'gl-mb-5',
],
computed: { computed: {
columnClass() { columnClass() {
const positionValues = { const positionValues = {
...@@ -35,14 +50,66 @@ export default { ...@@ -35,14 +50,66 @@ export default {
graphPosition() { graphPosition() {
return this.isUpstream ? 'left' : 'right'; return this.isUpstream ? 'left' : 'right';
}, },
// Refactor string match when BE returns Upstream/Downstream indicators
isUpstream() { isUpstream() {
return this.type === UPSTREAM; return this.type === UPSTREAM;
}, },
computedTitleClasses() {
const positionalClasses = this.isUpstream
? ['gl-w-full', 'gl-text-right', 'gl-linked-pipeline-padding']
: [];
return [...this.$options.titleClasses, ...positionalClasses];
},
}, },
methods: { methods: {
onPipelineClick(downstreamNode, pipeline, index) { getPipelineData(pipeline) {
this.$emit('linkedPipelineClick', pipeline, index, downstreamNode); const projectPath = pipeline.project.fullPath;
this.$apollo.addSmartQuery('currentPipeline', {
query: getPipelineDetails,
variables() {
return {
projectPath,
iid: pipeline.iid,
};
},
update(data) {
return unwrapPipelineData(projectPath, data);
},
result() {
this.loadingPipelineId = null;
},
error() {
this.$emit('error', LOAD_FAILURE);
},
});
},
isExpanded(id) {
return Boolean(this.currentPipeline?.id && id === this.currentPipeline.id);
},
isLoadingPipeline(id) {
return this.loadingPipelineId === id;
},
onPipelineClick(pipeline) {
/* If the clicked pipeline has been expanded already, close it, clear, exit */
if (this.currentPipeline?.id === pipeline.id) {
this.pipelineExpanded = false;
this.currentPipeline = null;
return;
}
/* Set the loading id */
this.loadingPipelineId = pipeline.id;
/*
Expand the pipeline.
If this was not a toggle close action, and
it was already showing a different pipeline, then
this will be a no-op, but that doesn't matter.
*/
this.pipelineExpanded = true;
this.getPipelineData(pipeline);
}, },
onDownstreamHovered(jobName) { onDownstreamHovered(jobName) {
this.$emit('downstreamHovered', jobName); this.$emit('downstreamHovered', jobName);
...@@ -60,25 +127,40 @@ export default { ...@@ -60,25 +127,40 @@ export default {
</script> </script>
<template> <template>
<div :class="columnClass" class="stage-column linked-pipelines-column"> <div class="gl-display-flex">
<div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> <div :class="columnClass" class="linked-pipelines-column">
<div v-if="isUpstream" class="cross-project-triangle"></div> <div data-testid="linked-column-title" class="stage-name" :class="computedTitleClasses">
<ul> {{ columnTitle }}
<linked-pipeline </div>
v-for="(pipeline, index) in linkedPipelines" <ul class="gl-pl-0">
:key="pipeline.id" <li
:class="{ v-for="pipeline in linkedPipelines"
active: pipeline.isExpanded, :key="pipeline.id"
'left-connector': pipeline.isExpanded && graphPosition === 'left', class="gl-display-flex gl-mb-4"
}" :class="{ 'gl-flex-direction-row-reverse': isUpstream }"
:pipeline="pipeline" >
:column-title="columnTitle" <linked-pipeline
:project-id="projectId" class="gl-display-inline-block"
:type="type" :is-loading="isLoadingPipeline(pipeline.id)"
@pipelineClicked="onPipelineClick($event, pipeline, index)" :pipeline="pipeline"
@downstreamHovered="onDownstreamHovered" :column-title="columnTitle"
@pipelineExpandToggle="onPipelineExpandToggle" :type="type"
/> :expanded="isExpanded(pipeline.id)"
</ul> @downstreamHovered="onDownstreamHovered"
@pipelineClicked="onPipelineClick(pipeline)"
@pipelineExpandToggle="onPipelineExpandToggle"
/>
<div v-if="isExpanded(pipeline.id)" class="gl-display-inline-block">
<pipeline-graph
v-if="currentPipeline"
:type="type"
class="d-inline-block gl-mt-n2"
:pipeline="currentPipeline"
:is-linked-pipeline="true"
/>
</div>
</li>
</ul>
</div>
</div> </div>
</template> </template>
...@@ -35,7 +35,9 @@ export default { ...@@ -35,7 +35,9 @@ export default {
graphPosition() { graphPosition() {
return this.isUpstream ? 'left' : 'right'; return this.isUpstream ? 'left' : 'right';
}, },
// Refactor string match when BE returns Upstream/Downstream indicators isExpanded() {
return this.pipeline?.isExpanded || false;
},
isUpstream() { isUpstream() {
return this.type === UPSTREAM; return this.type === UPSTREAM;
}, },
...@@ -64,21 +66,22 @@ export default { ...@@ -64,21 +66,22 @@ export default {
<div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div> <div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
<div v-if="isUpstream" class="cross-project-triangle"></div> <div v-if="isUpstream" class="cross-project-triangle"></div>
<ul> <ul>
<linked-pipeline <li v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id">
v-for="(pipeline, index) in linkedPipelines" <linked-pipeline
:key="pipeline.id" :class="{
:class="{ active: pipeline.isExpanded,
active: pipeline.isExpanded, 'left-connector': pipeline.isExpanded && graphPosition === 'left',
'left-connector': pipeline.isExpanded && graphPosition === 'left', }"
}" :pipeline="pipeline"
:pipeline="pipeline" :column-title="columnTitle"
:column-title="columnTitle" :project-id="projectId"
:project-id="projectId" :type="type"
:type="type" :expanded="isExpanded"
@pipelineClicked="onPipelineClick($event, pipeline, index)" @pipelineClicked="onPipelineClick($event, pipeline, index)"
@downstreamHovered="onDownstreamHovered" @downstreamHovered="onDownstreamHovered"
@pipelineExpandToggle="onPipelineExpandToggle" @pipelineExpandToggle="onPipelineExpandToggle"
/> />
</li>
</ul> </ul>
</div> </div>
</template> </template>
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { unwrapStagesWithNeeds } from '../unwrapping_utils'; import { unwrapStagesWithNeeds } from '../unwrapping_utils';
const addMulti = (mainId, pipeline) => { const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
return { ...pipeline, multiproject: mainId !== pipeline.id }; return {
...linkedPipeline,
multiproject: mainPipelineProjectPath !== linkedPipeline.project.fullPath,
};
}; };
const unwrapPipelineData = (mainPipelineId, data) => { const transformId = linkedPipeline => {
return { ...linkedPipeline, id: getIdFromGraphQLId(linkedPipeline.id) };
};
const unwrapPipelineData = (mainPipelineProjectPath, data) => {
if (!data?.project?.pipeline) { if (!data?.project?.pipeline) {
return null; return null;
} }
const { pipeline } = data.project;
const { const {
id,
upstream, upstream,
downstream, downstream,
stages: { nodes: stages }, stages: { nodes: stages },
} = data.project.pipeline; } = pipeline;
const nodes = unwrapStagesWithNeeds(stages); const nodes = unwrapStagesWithNeeds(stages);
return { return {
id, ...pipeline,
id: getIdFromGraphQLId(pipeline.id),
stages: nodes, stages: nodes,
upstream: upstream ? [upstream].map(addMulti.bind(null, mainPipelineId)) : [], upstream: upstream
downstream: downstream ? downstream.map(addMulti.bind(null, mainPipelineId)) : [], ? [upstream].map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
: [],
downstream: downstream
? downstream.nodes.map(addMulti.bind(null, mainPipelineProjectPath)).map(transformId)
: [],
}; };
}; };
......
<template>
<div class="gl-display-flex">
<slot name="upstream"></slot>
<slot name="main"></slot>
<slot name="downstream"></slot>
</div>
</template>
...@@ -17,7 +17,7 @@ export default { ...@@ -17,7 +17,7 @@ export default {
<template> <template>
<div> <div>
<div <div
class="gl-display-flex gl-align-items-center gl-w-full gl-px-8 gl-py-4 gl-mb-5" class="gl-display-flex gl-align-items-center gl-w-full gl-px-8 gl-mb-5"
:class="stageClasses" :class="stageClasses"
> >
<slot name="stages"> </slot> <slot name="stages"> </slot>
......
fragment LinkedPipelineData on Pipeline {
id
iid
path
status: detailedStatus {
group
label
icon
}
sourceJob {
name
}
project {
name
fullPath
}
}
#import "../fragments/linked_pipelines.fragment.graphql"
query getPipelineDetails($projectPath: ID!, $iid: ID!) { query getPipelineDetails($projectPath: ID!, $iid: ID!) {
project(fullPath: $projectPath) { project(fullPath: $projectPath) {
pipeline(iid: $iid) { pipeline(iid: $iid) {
id: iid id
iid
downstream {
nodes {
...LinkedPipelineData
}
}
upstream {
...LinkedPipelineData
}
stages { stages {
nodes { nodes {
name name
......
...@@ -2,6 +2,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) { ...@@ -2,6 +2,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
pipeline(iid: $iid) { pipeline(iid: $iid) {
id id
iid
status status
retryable retryable
cancelable cancelable
......
...@@ -139,6 +139,10 @@ ...@@ -139,6 +139,10 @@
width: 186px; width: 186px;
} }
.gl-linked-pipeline-padding {
padding-right: 120px;
}
.gl-build-content { .gl-build-content {
@include build-content(); @include build-content();
} }
......
{ {
"type": "object", "type": "object",
"description": "The schema for vulnerability finding details", "description": "The schema for vulnerability finding details",
"additionalProperties": false "additionalProperties": false,
"patternProperties": {
"^.*$": {
"allOf": [
{ "$ref": "#/definitions/named_field" },
{ "$ref": "#/definitions/type_list" }
]
}
},
"definitions": {
"type_list": {
"oneOf": [
{ "$ref": "#/definitions/named_list" },
{ "$ref": "#/definitions/list" },
{ "$ref": "#/definitions/table" },
{ "$ref": "#/definitions/text" },
{ "$ref": "#/definitions/url" },
{ "$ref": "#/definitions/code" },
{ "$ref": "#/definitions/int" },
{ "$ref": "#/definitions/commit" },
{ "$ref": "#/definitions/file_location" },
{ "$ref": "#/definitions/module_location" }
]
},
"lang_text": {
"type": "object",
"required": [ "value", "lang" ],
"properties": {
"lang": { "type": "string" },
"value": { "type": "string" }
}
},
"lang_text_list": {
"type": "array",
"items": { "$ref": "#/definitions/lang_text" }
},
"named_field": {
"type": "object",
"required": [ "name" ],
"properties": {
"name": { "$ref": "#/definitions/lang_text_list" },
"description": { "$ref": "#/definitions/lang_text_list" }
}
},
"named_list": {
"type": "object",
"description": "An object with named and typed fields",
"required": [ "type", "items" ],
"properties": {
"type": { "const": "named-list" },
"items": {
"type": "object",
"patternProperties": {
"^.*$": {
"allOf": [
{ "$ref": "#/definitions/named_field" },
{ "$ref": "#/definitions/type_list" }
]
}
}
}
}
},
"list": {
"type": "object",
"description": "A list of typed fields",
"required": [ "type", "items" ],
"properties": {
"type": { "const": "list" },
"items": {
"type": "array",
"items": { "$ref": "#/definitions/type_list" }
}
}
},
"table": {
"type": "object",
"description": "A table of typed fields",
"required": [],
"properties": {
"type": { "const": "table" },
"items": {
"type": "object",
"properties": {
"header": {
"type": "array",
"items": {
"$ref": "#/definitions/type_list"
}
},
"rows": {
"type": "array",
"items": {
"type": "array",
"items": {
"$ref": "#/definitions/type_list"
}
}
}
}
}
}
},
"text": {
"type": "object",
"description": "Raw text",
"required": [ "type", "value" ],
"properties": {
"type": { "const": "text" },
"value": { "$ref": "#/definitions/lang_text_list" }
}
},
"url": {
"type": "object",
"description": "A single URL",
"required": [ "type", "href" ],
"properties": {
"type": { "const": "url" },
"text": { "$ref": "#/definitions/lang_text_list" },
"href": { "type": "string" }
}
},
"code": {
"type": "object",
"description": "A codeblock",
"required": [ "type", "value" ],
"properties": {
"type": { "const": "code" },
"value": { "type": "string" },
"lang": { "type": "string" }
}
},
"int": {
"type": "object",
"description": "An integer",
"required": [ "type", "value" ],
"properties": {
"type": { "const": "int" },
"value": { "type": "integer" },
"format": {
"type": "string",
"enum": [ "default", "hex" ]
}
}
},
"commit": {
"type": "object",
"description": "A specific commit within the project",
"required": [ "type", "value" ],
"properties": {
"type": { "const": "commit" },
"value": { "type": "string", "description": "The commit SHA" }
}
},
"file_location": {
"type": "object",
"description": "A location within a file in the project",
"required": [ "type", "file_name", "line_start" ],
"properties": {
"type": { "const": "file-location" },
"file_name": { "type": "string" },
"line_start": { "type": "integer" },
"line_end": { "type": "integer" }
}
},
"module_location": {
"type": "object",
"description": "A location within a binary module of the form module+relative_offset",
"required": [ "type", "module_name", "offset" ],
"properties": {
"type": { "const": "module-location" },
"module_name": { "type": "string" },
"offset": { "type": "integer" }
}
}
}
} }
...@@ -276,7 +276,7 @@ Edition, follow the guides below based on the installation method: ...@@ -276,7 +276,7 @@ Edition, follow the guides below based on the installation method:
to a version upgrade: stop the server, get the code, update configuration files for to a version upgrade: stop the server, get the code, update configuration files for
the new functionality, install libraries and do migrations, update the init the new functionality, install libraries and do migrations, update the init
script, start the application and check its status. script, start the application and check its status.
- [Omnibus CE to EE](https://docs.gitlab.com/omnibus/update/README.html#updating-community-edition-to-enterprise-edition) - Follow this guide to update your Omnibus - [Omnibus CE to EE](https://docs.gitlab.com/omnibus/update/README.html#update-community-edition-to-enterprise-edition) - Follow this guide to update your Omnibus
GitLab Community Edition to the Enterprise Edition. GitLab Community Edition to the Enterprise Edition.
### Enterprise to Community Edition ### Enterprise to Community Edition
......
...@@ -111,7 +111,7 @@ It is also possible to manage multiple assignees: ...@@ -111,7 +111,7 @@ It is also possible to manage multiple assignees:
- When creating a merge request. - When creating a merge request.
- Using [quick actions](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics). - Using [quick actions](../quick_actions.md#quick-actions-for-issues-merge-requests-and-epics).
## Reviewer ### Reviewer
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216054) in GitLab 13.5. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/216054) in GitLab 13.5.
> - It's [deployed behind a feature flag](../../../user/feature_flags.md), enabled by default. > - It's [deployed behind a feature flag](../../../user/feature_flags.md), enabled by default.
...@@ -134,7 +134,7 @@ This makes it easy to determine the relevant roles for the users involved in the ...@@ -134,7 +134,7 @@ This makes it easy to determine the relevant roles for the users involved in the
To request it, open the **Reviewers** drop-down box to search for the user you wish to get a review from. To request it, open the **Reviewers** drop-down box to search for the user you wish to get a review from.
### Enable or disable Merge Request Reviewers **(CORE ONLY)** #### Enable or disable Merge Request Reviewers **(CORE ONLY)**
Merge Request Reviewers is under development and not ready for production use. It is Merge Request Reviewers is under development and not ready for production use. It is
deployed behind a feature flag that is **disabled by default**. deployed behind a feature flag that is **disabled by default**.
......
...@@ -3,12 +3,16 @@ import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui'; ...@@ -3,12 +3,16 @@ import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import { import {
DAST_SITE_VALIDATION_STATUS, DAST_SITE_VALIDATION_STATUS,
DAST_SITE_VALIDATION_STATUS_PROPS, DAST_SITE_VALIDATION_STATUS_PROPS,
DAST_SITE_VALIDATION_POLLING_INTERVAL,
} from 'ee/security_configuration/dast_site_validation/constants'; } from 'ee/security_configuration/dast_site_validation/constants';
import DastSiteValidationModal from 'ee/security_configuration/dast_site_validation/components/dast_site_validation_modal.vue'; import DastSiteValidationModal from 'ee/security_configuration/dast_site_validation/components/dast_site_validation_modal.vue';
import dastSiteValidationsQuery from 'ee/security_configuration/dast_site_validation/graphql/dast_site_validations.query.graphql';
import { updateSiteProfilesStatuses } from '../graphql/cache_utils';
import ProfilesList from './dast_profiles_list.vue'; import ProfilesList from './dast_profiles_list.vue';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { fetchPolicies } from '~/lib/graphql';
const { PENDING, FAILED } = DAST_SITE_VALIDATION_STATUS; const { PENDING, INPROGRESS, FAILED } = DAST_SITE_VALIDATION_STATUS;
export default { export default {
components: { components: {
...@@ -17,6 +21,42 @@ export default { ...@@ -17,6 +21,42 @@ export default {
DastSiteValidationModal, DastSiteValidationModal,
ProfilesList, ProfilesList,
}, },
apollo: {
validations: {
query: dastSiteValidationsQuery,
fetchPolicy: fetchPolicies.NO_CACHE,
manual: true,
variables() {
return {
fullPath: this.fullPath,
urls: this.urlsPendingValidation,
};
},
pollInterval: DAST_SITE_VALIDATION_POLLING_INTERVAL,
skip() {
return (
!this.glFeatures.securityOnDemandScansSiteValidation || !this.urlsPendingValidation.length
);
},
result({
data: {
project: {
validations: { nodes = [] },
},
},
}) {
const store = this.$apollo.getClient();
nodes.forEach(({ normalizedTargetUrl, status }) => {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status,
store,
});
});
},
},
},
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
...@@ -26,6 +66,10 @@ export default { ...@@ -26,6 +66,10 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
profiles: {
type: Array,
required: true,
},
}, },
data() { data() {
return { return {
...@@ -33,6 +77,19 @@ export default { ...@@ -33,6 +77,19 @@ export default {
}; };
}, },
statuses: DAST_SITE_VALIDATION_STATUS_PROPS, statuses: DAST_SITE_VALIDATION_STATUS_PROPS,
computed: {
urlsPendingValidation() {
return this.profiles.reduce((acc, { validationStatus, normalizedTargetUrl }) => {
if (
[PENDING, INPROGRESS].includes(validationStatus) &&
!acc.includes(normalizedTargetUrl)
) {
return [...acc, normalizedTargetUrl];
}
return acc;
}, []);
},
},
methods: { methods: {
shouldShowValidationBtn(status) { shouldShowValidationBtn(status) {
return ( return (
...@@ -52,11 +109,19 @@ export default { ...@@ -52,11 +109,19 @@ export default {
this.showValidationModal(); this.showValidationModal();
}); });
}, },
startValidatingProfile({ normalizedTargetUrl }) {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status: PENDING,
store: this.$apollo.getClient(),
});
},
}, },
}; };
</script> </script>
<template> <template>
<profiles-list :full-path="fullPath" v-bind="$attrs" v-on="$listeners"> <profiles-list :full-path="fullPath" :profiles="profiles" v-bind="$attrs" v-on="$listeners">
<template #cell(validationStatus)="{ value }"> <template #cell(validationStatus)="{ value }">
<template v-if="shouldShowValidationStatus(value)"> <template v-if="shouldShowValidationStatus(value)">
<span :class="$options.statuses[value].cssClass"> <span :class="$options.statuses[value].cssClass">
...@@ -87,6 +152,7 @@ export default { ...@@ -87,6 +152,7 @@ export default {
ref="dast-site-validation-modal" ref="dast-site-validation-modal"
:full-path="fullPath" :full-path="fullPath"
:target-url="validatingProfile.targetUrl" :target-url="validatingProfile.targetUrl"
@primary="startValidatingProfile(validatingProfile)"
/> />
</profiles-list> </profiles-list>
</template> </template>
import { produce } from 'immer'; import { produce } from 'immer';
import gql from 'graphql-tag';
import dastSiteProfilesQuery from 'ee/security_configuration/dast_profiles/graphql/dast_site_profiles.query.graphql';
/** /**
* Appends paginated results to existing ones * Appends paginated results to existing ones
* - to be used with $apollo.queries.x.fetchMore * - to be used with $apollo.queries.x.fetchMore
...@@ -54,3 +57,34 @@ export const dastProfilesDeleteResponse = ({ mutationName, payloadTypeName }) => ...@@ -54,3 +57,34 @@ export const dastProfilesDeleteResponse = ({ mutationName, payloadTypeName }) =>
errors: [], errors: [],
}, },
}); });
export const updateSiteProfilesStatuses = ({ fullPath, normalizedTargetUrl, status, store }) => {
const queryBody = {
query: dastSiteProfilesQuery,
variables: {
fullPath,
},
};
const sourceData = store.readQuery(queryBody);
const profilesWithNormalizedTargetUrl = sourceData.project.siteProfiles.edges.flatMap(
({ node }) => (node.normalizedTargetUrl === normalizedTargetUrl ? node : []),
);
profilesWithNormalizedTargetUrl.forEach(({ id }) => {
store.writeFragment({
id: `DastSiteProfile:${id}`,
fragment: gql`
fragment profile on DastSiteProfile {
validationStatus
__typename
}
`,
data: {
validationStatus: status,
__typename: 'DastSiteProfile',
},
});
});
};
...@@ -2,7 +2,8 @@ ...@@ -2,7 +2,8 @@
query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) { query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
project(fullPath: $fullPath) { project(fullPath: $fullPath) {
siteProfiles: dastSiteProfiles(after: $after, before: $before, first: $first, last: $last) { siteProfiles: dastSiteProfiles(after: $after, before: $before, first: $first, last: $last)
@connection(key: "dastSiteProfiles") {
pageInfo { pageInfo {
...PageInfo ...PageInfo
} }
...@@ -11,6 +12,7 @@ query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: ...@@ -11,6 +12,7 @@ query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first:
node { node {
id id
profileName profileName
normalizedTargetUrl
targetUrl targetUrl
editPath editPath
validationStatus validationStatus
......
query project($fullPath: ID!, $targetUrl: String!) {
project(fullPath: $fullPath) {
dastSiteValidation(targetUrl: $targetUrl) {
status
}
}
}
...@@ -50,3 +50,5 @@ export const DAST_SITE_VALIDATION_STATUS_PROPS = { ...@@ -50,3 +50,5 @@ export const DAST_SITE_VALIDATION_STATUS_PROPS = {
export const DAST_SITE_VALIDATION_HTTP_HEADER_KEY = 'Gitlab-On-Demand-DAST'; export const DAST_SITE_VALIDATION_HTTP_HEADER_KEY = 'Gitlab-On-Demand-DAST';
export const DAST_SITE_VALIDATION_MODAL_ID = 'dast-site-validation-modal'; export const DAST_SITE_VALIDATION_MODAL_ID = 'dast-site-validation-modal';
export const DAST_SITE_VALIDATION_POLLING_INTERVAL = 3000;
query project($fullPath: ID!, $urls: [String!]) {
project(fullPath: $fullPath) {
validations: dastSiteValidations(normalizedTargetUrls: $urls) {
nodes {
normalizedTargetUrl
status
}
}
}
}
...@@ -13,7 +13,7 @@ class TrialsController < ApplicationController ...@@ -13,7 +13,7 @@ class TrialsController < ApplicationController
feature_category :purchase feature_category :purchase
def new def new
record_experiment_user(:remove_known_trial_form_fields) record_experiment_user(:remove_known_trial_form_fields, remove_known_trial_form_fields_context)
record_experiment_user(:trimmed_skip_trial_copy) record_experiment_user(:trimmed_skip_trial_copy)
end end
...@@ -115,4 +115,12 @@ class TrialsController < ApplicationController ...@@ -115,4 +115,12 @@ class TrialsController < ApplicationController
def record_user_for_group_only_trials_experiment def record_user_for_group_only_trials_experiment
record_experiment_user(:group_only_trials) record_experiment_user(:group_only_trials)
end end
def remove_known_trial_form_fields_context
{
first_name_present: current_user.first_name.present?,
last_name_present: current_user.last_name.present?,
company_name_present: current_user.organization.present?
}
end
end end
...@@ -263,7 +263,6 @@ module EE ...@@ -263,7 +263,6 @@ module EE
def generic_alert_with_default_title? def generic_alert_with_default_title?
title == ::Gitlab::AlertManagement::Payload::Generic::DEFAULT_TITLE && title == ::Gitlab::AlertManagement::Payload::Generic::DEFAULT_TITLE &&
project.alerts_service_activated? &&
author == ::User.alert_bot author == ::User.alert_bot
end end
......
---
title: Add parsing details from security reports
merge_request: 49107
author:
type: added
---
title: Include issue iid in default title for untitled incidents when created from
alert via HTTP integration
merge_request: 49274
author:
type: fixed
...@@ -74,7 +74,8 @@ module Gitlab ...@@ -74,7 +74,8 @@ module Gitlab
links: links, links: links,
remediations: remediations, remediations: remediations,
raw_metadata: data.to_json, raw_metadata: data.to_json,
metadata_version: version)) metadata_version: version,
details: data['details'] || {}))
end end
def create_scan(report, scan_data) def create_scan(report, scan_data)
......
...@@ -23,10 +23,11 @@ module Gitlab ...@@ -23,10 +23,11 @@ module Gitlab
attr_reader :severity attr_reader :severity
attr_reader :uuid attr_reader :uuid
attr_reader :remediations attr_reader :remediations
attr_reader :details
delegate :file_path, :start_line, :end_line, to: :location delegate :file_path, :start_line, :end_line, to: :location
def initialize(compare_key:, identifiers:, links: [], remediations: [], location:, metadata_version:, name:, raw_metadata:, report_type:, scanner:, scan:, uuid:, confidence: nil, severity: nil) # rubocop:disable Metrics/ParameterLists def initialize(compare_key:, identifiers:, links: [], remediations: [], location:, metadata_version:, name:, raw_metadata:, report_type:, scanner:, scan:, uuid:, confidence: nil, severity: nil, details: {}) # rubocop:disable Metrics/ParameterLists
@compare_key = compare_key @compare_key = compare_key
@confidence = confidence @confidence = confidence
@identifiers = identifiers @identifiers = identifiers
...@@ -41,6 +42,7 @@ module Gitlab ...@@ -41,6 +42,7 @@ module Gitlab
@severity = severity @severity = severity
@uuid = uuid @uuid = uuid
@remediations = remediations @remediations = remediations
@details = details
@project_fingerprint = generate_project_fingerprint @project_fingerprint = generate_project_fingerprint
end end
...@@ -61,6 +63,7 @@ module Gitlab ...@@ -61,6 +63,7 @@ module Gitlab
scan scan
severity severity
uuid uuid
details
].each_with_object({}) do |key, hash| ].each_with_object({}) do |key, hash|
hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
end end
......
...@@ -4,7 +4,7 @@ require 'spec_helper' ...@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe TrialsController do RSpec.describe TrialsController do
let_it_be(:user) { create(:user, email_opted_in: true, last_name: 'Doe') } let_it_be(:user) { create(:user, email_opted_in: true, last_name: 'Doe') }
let_it_be(:experiment_user_context) do let_it_be(:remove_known_trial_form_fields_context) do
{ {
first_name_present: user.first_name.present?, first_name_present: user.first_name.present?,
last_name_present: user.last_name.present?, last_name_present: user.last_name.present?,
...@@ -55,7 +55,7 @@ RSpec.describe TrialsController do ...@@ -55,7 +55,7 @@ RSpec.describe TrialsController do
end end
it 'calls record_experiment_user for the remove_known_trial_form_fields & trimmed_skip_trial_copy experiments' do it 'calls record_experiment_user for the remove_known_trial_form_fields & trimmed_skip_trial_copy experiments' do
expect(controller).to receive(:record_experiment_user).with(:remove_known_trial_form_fields) expect(controller).to receive(:record_experiment_user).with(:remove_known_trial_form_fields, remove_known_trial_form_fields_context)
expect(controller).to receive(:record_experiment_user).with(:trimmed_skip_trial_copy) expect(controller).to receive(:record_experiment_user).with(:trimmed_skip_trial_copy)
subject subject
......
...@@ -18,7 +18,25 @@ ...@@ -18,7 +18,25 @@
{ {
"url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1020" "url": "https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2020-1020"
} }
] ],
"details": {
"commit": {
"name": [
{
"lang": "en",
"value": "The Commit"
}
],
"description": [
{
"lang": "en",
"value": "Commit where the vulnerability was identified"
}
],
"type": "commit",
"value": "41df7b7eb3be2b5be2c406c2f6d28cd6631eeb19"
}
}
}, },
{ {
"id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3", "id": "bb2fbeb1b71ea360ce3f86f001d4e84823c3ffe1a1f7d41ba7466b14cfa953d3",
......
...@@ -4,7 +4,7 @@ import { mount, shallowMount, createWrapper } from '@vue/test-utils'; ...@@ -4,7 +4,7 @@ import { mount, shallowMount, createWrapper } from '@vue/test-utils';
import { merge } from 'lodash'; import { merge } from 'lodash';
import DastProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue'; import DastProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { siteProfiles as profiles } from './mock_data'; import { siteProfiles as profiles } from '../mocks/mock_data';
const TEST_ERROR_MESSAGE = 'something went wrong'; const TEST_ERROR_MESSAGE = 'something went wrong';
......
...@@ -2,7 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils'; ...@@ -2,7 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import { merge } from 'lodash'; import { merge } from 'lodash';
import Component from 'ee/security_configuration/dast_profiles/components/dast_scanner_profiles_list.vue'; import Component from 'ee/security_configuration/dast_profiles/components/dast_scanner_profiles_list.vue';
import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue'; import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
import { scannerProfiles } from './mock_data'; import { scannerProfiles } from '../mocks/mock_data';
describe('EE - DastScannerProfileList', () => { describe('EE - DastScannerProfileList', () => {
let wrapper; let wrapper;
......
import { mount, shallowMount } from '@vue/test-utils'; import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import { within } from '@testing-library/dom'; import { within } from '@testing-library/dom';
import { merge } from 'lodash'; import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
import createApolloProvider from 'helpers/mock_apollo_helper';
import dastSiteValidationsQuery from 'ee/security_configuration/dast_site_validation/graphql/dast_site_validations.query.graphql';
import Component from 'ee/security_configuration/dast_profiles/components/dast_site_profiles_list.vue'; import Component from 'ee/security_configuration/dast_profiles/components/dast_site_profiles_list.vue';
import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue'; import ProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
import { siteProfiles } from './mock_data'; import { updateSiteProfilesStatuses } from 'ee/security_configuration/dast_profiles/graphql/cache_utils';
import { DAST_SITE_VALIDATION_STATUS } from 'ee/security_configuration/dast_site_validation/constants';
import { siteProfiles } from '../mocks/mock_data';
import * as responses from '../mocks/apollo_mock';
jest.mock('ee/security_configuration/dast_profiles/graphql/cache_utils', () => ({
updateSiteProfilesStatuses: jest.fn(),
}));
describe('EE - DastSiteProfileList', () => { describe('EE - DastSiteProfileList', () => {
let localVue;
let wrapper; let wrapper;
let requestHandlers;
let apolloProvider;
const defaultProps = { const defaultProps = {
profiles: [], profiles: [],
...@@ -20,7 +33,15 @@ describe('EE - DastSiteProfileList', () => { ...@@ -20,7 +33,15 @@ describe('EE - DastSiteProfileList', () => {
isLoading: false, isLoading: false,
}; };
const wrapperFactory = (mountFn = shallowMount) => (options = {}) => { const createMockApolloProvider = handlers => {
localVue.use(VueApollo);
requestHandlers = handlers;
return createApolloProvider([[dastSiteValidationsQuery, requestHandlers.dastSiteValidations]]);
};
const wrapperFactory = (mountFn = shallowMount) => (options = {}, handlers) => {
localVue = createLocalVue();
apolloProvider = handlers && createMockApolloProvider(handlers);
wrapper = mountFn( wrapper = mountFn(
Component, Component,
merge( merge(
...@@ -30,7 +51,7 @@ describe('EE - DastSiteProfileList', () => { ...@@ -30,7 +51,7 @@ describe('EE - DastSiteProfileList', () => {
glFeatures: { securityOnDemandScansSiteValidation: true }, glFeatures: { securityOnDemandScansSiteValidation: true },
}, },
}, },
options, { ...options, localVue, apolloProvider },
), ),
); );
}; };
...@@ -52,6 +73,7 @@ describe('EE - DastSiteProfileList', () => { ...@@ -52,6 +73,7 @@ describe('EE - DastSiteProfileList', () => {
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
apolloProvider = null;
}); });
it('renders profile list properly', () => { it('renders profile list properly', () => {
...@@ -81,16 +103,38 @@ describe('EE - DastSiteProfileList', () => { ...@@ -81,16 +103,38 @@ describe('EE - DastSiteProfileList', () => {
}); });
describe('with site validation enabled', () => { describe('with site validation enabled', () => {
const [pendingValidation, inProgressValidation] = siteProfiles;
const urlsPendingValidation = [
pendingValidation.normalizedTargetUrl,
inProgressValidation.normalizedTargetUrl,
];
beforeEach(() => { beforeEach(() => {
createFullComponent({ propsData: { siteProfiles } }); createFullComponent(
{ propsData: { profiles: siteProfiles } },
{
dastSiteValidations: jest.fn().mockResolvedValue(
responses.dastSiteValidations([
{
normalizedTargetUrl: pendingValidation.normalizedTargetUrl,
status: DAST_SITE_VALIDATION_STATUS.FAILED,
},
{
normalizedTargetUrl: inProgressValidation.normalizedTargetUrl,
status: DAST_SITE_VALIDATION_STATUS.PASSED,
},
]),
),
},
);
}); });
describe.each` describe.each`
status | statusEnum | label | hasValidateButton status | statusEnum | label | hasValidateButton
${'pending'} | ${'PENDING_VALIDATION'} | ${''} | ${true} ${'pending'} | ${DAST_SITE_VALIDATION_STATUS.PENDING} | ${''} | ${true}
${'in-progress'} | ${'INPROGRESS_VALIDATION'} | ${'Validating...'} | ${false} ${'in-progress'} | ${DAST_SITE_VALIDATION_STATUS.INPROGRESS} | ${'Validating...'} | ${false}
${'passed'} | ${'PASSED_VALIDATION'} | ${'Validated'} | ${false} ${'passed'} | ${DAST_SITE_VALIDATION_STATUS.PASSED} | ${'Validated'} | ${false}
${'failed'} | ${'FAILED_VALIDATION'} | ${'Validation failed'} | ${true} ${'failed'} | ${DAST_SITE_VALIDATION_STATUS.FAILED} | ${'Validation failed'} | ${true}
`('profile with validation $status', ({ statusEnum, label, hasValidateButton }) => { `('profile with validation $status', ({ statusEnum, label, hasValidateButton }) => {
const profile = siteProfiles.find(({ validationStatus }) => validationStatus === statusEnum); const profile = siteProfiles.find(({ validationStatus }) => validationStatus === statusEnum);
...@@ -112,6 +156,30 @@ describe('EE - DastSiteProfileList', () => { ...@@ -112,6 +156,30 @@ describe('EE - DastSiteProfileList', () => {
} }
}); });
}); });
it('fetches validation statuses for all profiles that are being validated and updates the cache', () => {
expect(requestHandlers.dastSiteValidations).toHaveBeenCalledWith({
fullPath: defaultProps.fullPath,
urls: urlsPendingValidation,
});
expect(updateSiteProfilesStatuses).toHaveBeenCalledTimes(2);
});
it.each`
nthCall | normalizedTargetUrl | status
${1} | ${pendingValidation.normalizedTargetUrl} | ${DAST_SITE_VALIDATION_STATUS.FAILED}
${2} | ${inProgressValidation.normalizedTargetUrl} | ${DAST_SITE_VALIDATION_STATUS.PASSED}
`(
'in the local cache, profile with normalized URL $normalizedTargetUrl has its status set to $status',
({ nthCall, normalizedTargetUrl, status }) => {
expect(updateSiteProfilesStatuses).toHaveBeenNthCalledWith(nthCall, {
fullPath: defaultProps.fullPath,
normalizedTargetUrl,
status,
store: apolloProvider.defaultClient,
});
},
);
}); });
describe('without site validation enabled', () => { describe('without site validation enabled', () => {
......
import gql from 'graphql-tag';
import { import {
appendToPreviousResult, appendToPreviousResult,
removeProfile, removeProfile,
dastProfilesDeleteResponse, dastProfilesDeleteResponse,
updateSiteProfilesStatuses,
} from 'ee/security_configuration/dast_profiles/graphql/cache_utils'; } from 'ee/security_configuration/dast_profiles/graphql/cache_utils';
import { siteProfiles } from '../mocks/mock_data';
describe('EE - DastProfiles GraphQL CacheUtils', () => { describe('EE - DastProfiles GraphQL CacheUtils', () => {
describe('appendToPreviousResult', () => { describe('appendToPreviousResult', () => {
...@@ -72,4 +75,45 @@ describe('EE - DastProfiles GraphQL CacheUtils', () => { ...@@ -72,4 +75,45 @@ describe('EE - DastProfiles GraphQL CacheUtils', () => {
}); });
}); });
}); });
describe('updateSiteProfilesStatuses', () => {
it.each`
siteProfile | status
${siteProfiles[0]} | ${'PASSED_VALIDATION'}
${siteProfiles[1]} | ${'FAILED_VALIDATION'}
`("set the profile's status in the cache", ({ siteProfile, status }) => {
const mockData = {
project: {
siteProfiles: {
edges: [{ node: siteProfile }],
},
},
};
const mockStore = {
readQuery: () => mockData,
writeFragment: jest.fn(),
};
updateSiteProfilesStatuses({
fullPath: 'full/path',
normalizedTargetUrl: siteProfile.normalizedTargetUrl,
status,
store: mockStore,
});
expect(mockStore.writeFragment).toHaveBeenCalledWith({
id: `DastSiteProfile:${siteProfile.id}`,
fragment: gql`
fragment profile on DastSiteProfile {
validationStatus
__typename
}
`,
data: {
validationStatus: status,
__typename: 'DastSiteProfile',
},
});
});
});
}); });
export const dastSiteValidations = (nodes = []) => ({
data: {
project: {
validations: {
nodes,
},
},
},
});
...@@ -3,6 +3,7 @@ export const siteProfiles = [ ...@@ -3,6 +3,7 @@ export const siteProfiles = [
id: 1, id: 1,
profileName: 'Profile 1', profileName: 'Profile 1',
targetUrl: 'http://example-1.com', targetUrl: 'http://example-1.com',
normalizedTargetUrl: 'http://example-1.com',
editPath: '/1/edit', editPath: '/1/edit',
validationStatus: 'PENDING_VALIDATION', validationStatus: 'PENDING_VALIDATION',
}, },
...@@ -10,6 +11,7 @@ export const siteProfiles = [ ...@@ -10,6 +11,7 @@ export const siteProfiles = [
id: 2, id: 2,
profileName: 'Profile 2', profileName: 'Profile 2',
targetUrl: 'http://example-2.com', targetUrl: 'http://example-2.com',
normalizedTargetUrl: 'http://example-2.com',
editPath: '/2/edit', editPath: '/2/edit',
validationStatus: 'INPROGRESS_VALIDATION', validationStatus: 'INPROGRESS_VALIDATION',
}, },
...@@ -17,6 +19,7 @@ export const siteProfiles = [ ...@@ -17,6 +19,7 @@ export const siteProfiles = [
id: 3, id: 3,
profileName: 'Profile 3', profileName: 'Profile 3',
targetUrl: 'http://example-2.com', targetUrl: 'http://example-2.com',
normalizedTargetUrl: 'http://example-2.com',
editPath: '/3/edit', editPath: '/3/edit',
validationStatus: 'PASSED_VALIDATION', validationStatus: 'PASSED_VALIDATION',
}, },
...@@ -24,6 +27,7 @@ export const siteProfiles = [ ...@@ -24,6 +27,7 @@ export const siteProfiles = [
id: 4, id: 4,
profileName: 'Profile 4', profileName: 'Profile 4',
targetUrl: 'http://example-3.com', targetUrl: 'http://example-3.com',
normalizedTargetUrl: 'http://example-3.com',
editPath: '/3/edit', editPath: '/3/edit',
validationStatus: 'FAILED_VALIDATION', validationStatus: 'FAILED_VALIDATION',
}, },
......
...@@ -65,6 +65,26 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do ...@@ -65,6 +65,26 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
end end
end end
context 'parsing finding.details' do
let(:artifact) { build(:ee_ci_job_artifact, :common_security_report) }
context 'when details are provided' do
it 'sets details from the report' do
vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1020' }
expected_details = Gitlab::Json.parse(vulnerability.raw_metadata)['details']
expect(vulnerability.details).to eq(expected_details)
end
end
context 'when details are not provided' do
it 'sets empty hash' do
vulnerability = report.findings.find { |x| x.compare_key == 'CVE-1030' }
expect(vulnerability.details).to eq({})
end
end
end
context 'parsing remediations' do context 'parsing remediations' do
let(:expected_remediation) { create(:ci_reports_security_remediation, diff: '') } let(:expected_remediation) { create(:ci_reports_security_remediation, diff: '') }
......
...@@ -30,7 +30,25 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do ...@@ -30,7 +30,25 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
scanner: scanner, scanner: scanner,
scan: nil, scan: nil,
severity: :high, severity: :high,
uuid: 'cadf8cf0a8228fa92a0f4897a0314083bb38' uuid: 'cadf8cf0a8228fa92a0f4897a0314083bb38',
details: {
'commit' => {
'name' => [
{
'lang' => 'en',
'value' => 'The Commit'
}
],
'description' => [
{
'lang' => 'en',
'value' => 'Commit where the vulnerability was identified'
}
],
'type' => 'commit',
'value' => '41df7b7eb3be2b5be2c406c2f6d28cd6631eeb19'
}
}
} }
end end
...@@ -52,7 +70,25 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do ...@@ -52,7 +70,25 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
report_type: :sast, report_type: :sast,
scanner: scanner, scanner: scanner,
severity: :high, severity: :high,
uuid: 'cadf8cf0a8228fa92a0f4897a0314083bb38' uuid: 'cadf8cf0a8228fa92a0f4897a0314083bb38',
details: {
'commit' => {
'name' => [
{
'lang' => 'en',
'value' => 'The Commit'
}
],
'description' => [
{
'lang' => 'en',
'value' => 'Commit where the vulnerability was identified'
}
],
'type' => 'commit',
'value' => '41df7b7eb3be2b5be2c406c2f6d28cd6631eeb19'
}
}
) )
end end
end end
...@@ -100,7 +136,8 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do ...@@ -100,7 +136,8 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
scanner: occurrence.scanner, scanner: occurrence.scanner,
scan: occurrence.scan, scan: occurrence.scan,
severity: occurrence.severity, severity: occurrence.severity,
uuid: occurrence.uuid uuid: occurrence.uuid,
details: occurrence.details
}) })
end end
end end
......
...@@ -30,30 +30,14 @@ RSpec.describe Issue do ...@@ -30,30 +30,14 @@ RSpec.describe Issue do
context 'when issue title is "New: Incident"' do context 'when issue title is "New: Incident"' do
let(:issue) { build(:issue, project: project, author: author, title: 'New: Incident', iid: 503503) } let(:issue) { build(:issue, project: project, author: author, title: 'New: Incident', iid: 503503) }
context 'when alerts service is active' do context 'when the author is Alert Bot' do
before do it 'updates issue title with the IID' do
allow(project).to receive(:alerts_service_activated?).and_return(true) expect { issue.save }.to change { issue.title }.to("New: Incident 503503")
end
context 'when the author is Alert Bot' do
it 'updates issue title with the IID' do
expect { issue.save }.to change { issue.title }.to("New: Incident 503503")
end
end
context 'when the author is not an Alert Bot' do
let(:author) { create(:user) }
it 'does not change issue title' do
expect { issue.save }.not_to change { issue.title }
end
end end
end end
context 'when alerts service is not active' do context 'when the author is not an Alert Bot' do
before do let(:author) { create(:user) }
allow(project).to receive(:alerts_service_activated?).and_return(false)
end
it 'does not change issue title' do it 'does not change issue title' do
expect { issue.save }.not_to change { issue.title } expect { issue.save }.not_to change { issue.title }
......
...@@ -15,8 +15,8 @@ describe('graph component', () => { ...@@ -15,8 +15,8 @@ describe('graph component', () => {
let mediator; let mediator;
let wrapper; let wrapper;
const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]'); const findExpandPipelineBtn = () => wrapper.find('[data-testid="expand-pipeline-button"]');
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]'); const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expand-pipeline-button"]');
const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy); const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy);
const findStageColumnAt = i => findStageColumns().at(i); const findStageColumnAt = i => findStageColumns().at(i);
......
import { shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue'; import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; import StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import { unwrapPipelineData } from '~/pipelines/components/graph/utils'; import { GRAPHQL } from '~/pipelines/components/graph/constants';
import { mockPipelineResponse } from './mock_data'; import {
generateResponse,
mockPipelineResponse,
pipelineWithUpstreamDownstream,
} from './mock_data';
describe('graph component', () => { describe('graph component', () => {
let wrapper; let wrapper;
...@@ -11,10 +15,8 @@ describe('graph component', () => { ...@@ -11,10 +15,8 @@ describe('graph component', () => {
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn); const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findStageColumns = () => wrapper.findAll(StageColumnComponent); const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const generateResponse = raw => unwrapPipelineData(raw.data.project.pipeline.id, raw.data);
const defaultProps = { const defaultProps = {
pipeline: generateResponse(mockPipelineResponse), pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
}; };
const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => { const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => {
...@@ -23,6 +25,9 @@ describe('graph component', () => { ...@@ -23,6 +25,9 @@ describe('graph component', () => {
...defaultProps, ...defaultProps,
...props, ...props,
}, },
provide: {
dataMethod: GRAPHQL,
},
}); });
}; };
...@@ -33,7 +38,7 @@ describe('graph component', () => { ...@@ -33,7 +38,7 @@ describe('graph component', () => {
describe('with data', () => { describe('with data', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent({ mountFn: mount });
}); });
it('renders the main columns in the graph', () => { it('renders the main columns in the graph', () => {
...@@ -43,11 +48,24 @@ describe('graph component', () => { ...@@ -43,11 +48,24 @@ describe('graph component', () => {
describe('when linked pipelines are not present', () => { describe('when linked pipelines are not present', () => {
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent({ mountFn: mount });
}); });
it('should not render a linked pipelines column', () => { it('should not render a linked pipelines column', () => {
expect(findLinkedColumns()).toHaveLength(0); expect(findLinkedColumns()).toHaveLength(0);
}); });
}); });
describe('when linked pipelines are present', () => {
beforeEach(() => {
createComponent({
mountFn: mount,
props: { pipeline: pipelineWithUpstreamDownstream(mockPipelineResponse) },
});
});
it('should render linked pipelines columns', () => {
expect(findLinkedColumns()).toHaveLength(2);
});
});
}); });
...@@ -17,7 +17,7 @@ describe('Linked pipeline', () => { ...@@ -17,7 +17,7 @@ describe('Linked pipeline', () => {
const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]'); const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]');
const findExpandButton = () => wrapper.find('[data-testid="expandPipelineButton"]'); const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]');
const createWrapper = (propsData, data = []) => { const createWrapper = (propsData, data = []) => {
wrapper = mount(LinkedPipelineComponent, { wrapper = mount(LinkedPipelineComponent, {
...@@ -40,20 +40,13 @@ describe('Linked pipeline', () => { ...@@ -40,20 +40,13 @@ describe('Linked pipeline', () => {
projectId: invalidTriggeredPipelineId, projectId: invalidTriggeredPipelineId,
columnTitle: 'Downstream', columnTitle: 'Downstream',
type: DOWNSTREAM, type: DOWNSTREAM,
expanded: false,
}; };
beforeEach(() => { beforeEach(() => {
createWrapper(props); createWrapper(props);
}); });
it('should render a list item as the containing element', () => {
expect(wrapper.element.tagName).toBe('LI');
});
it('should render a button', () => {
expect(findButton().exists()).toBe(true);
});
it('should render the project name', () => { it('should render the project name', () => {
expect(wrapper.text()).toContain(props.pipeline.project.name); expect(wrapper.text()).toContain(props.pipeline.project.name);
}); });
...@@ -105,12 +98,14 @@ describe('Linked pipeline', () => { ...@@ -105,12 +98,14 @@ describe('Linked pipeline', () => {
projectId: validTriggeredPipelineId, projectId: validTriggeredPipelineId,
columnTitle: 'Downstream', columnTitle: 'Downstream',
type: DOWNSTREAM, type: DOWNSTREAM,
expanded: false,
}; };
const upstreamProps = { const upstreamProps = {
...downstreamProps, ...downstreamProps,
columnTitle: 'Upstream', columnTitle: 'Upstream',
type: UPSTREAM, type: UPSTREAM,
expanded: false,
}; };
it('parent/child label container should exist', () => { it('parent/child label container should exist', () => {
...@@ -173,7 +168,7 @@ describe('Linked pipeline', () => { ...@@ -173,7 +168,7 @@ describe('Linked pipeline', () => {
`( `(
'$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded', '$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded',
({ pipelineType, anglePosition, expanded }) => { ({ pipelineType, anglePosition, expanded }) => {
createWrapper(pipelineType, { expanded }); createWrapper({ ...pipelineType, expanded });
expect(findExpandButton().props('icon')).toBe(anglePosition); expect(findExpandButton().props('icon')).toBe(anglePosition);
}, },
); );
...@@ -185,6 +180,7 @@ describe('Linked pipeline', () => { ...@@ -185,6 +180,7 @@ describe('Linked pipeline', () => {
projectId: invalidTriggeredPipelineId, projectId: invalidTriggeredPipelineId,
columnTitle: 'Downstream', columnTitle: 'Downstream',
type: DOWNSTREAM, type: DOWNSTREAM,
expanded: false,
}; };
beforeEach(() => { beforeEach(() => {
...@@ -202,6 +198,7 @@ describe('Linked pipeline', () => { ...@@ -202,6 +198,7 @@ describe('Linked pipeline', () => {
projectId: validTriggeredPipelineId, projectId: validTriggeredPipelineId,
columnTitle: 'Downstream', columnTitle: 'Downstream',
type: DOWNSTREAM, type: DOWNSTREAM,
expanded: false,
}; };
beforeEach(() => { beforeEach(() => {
...@@ -219,10 +216,7 @@ describe('Linked pipeline', () => { ...@@ -219,10 +216,7 @@ describe('Linked pipeline', () => {
jest.spyOn(wrapper.vm.$root, '$emit'); jest.spyOn(wrapper.vm.$root, '$emit');
findButton().trigger('click'); findButton().trigger('click');
expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual([ expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual(['bv::hide::tooltip']);
'bv::hide::tooltip',
'js-linked-pipeline-34993051',
]);
}); });
it('should emit downstreamHovered with job name on mouseover', () => { it('should emit downstreamHovered with job name on mouseover', () => {
......
import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import PipelineGraph from '~/pipelines/components/graph/graph_component.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue'; import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue'; import LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue';
import { UPSTREAM } from '~/pipelines/components/graph/constants'; import getPipelineDetails from '~/pipelines/graphql/queries/get_pipeline_details.query.graphql';
import mockData from './linked_pipelines_mock_data'; import { DOWNSTREAM, GRAPHQL } from '~/pipelines/components/graph/constants';
import { LOAD_FAILURE } from '~/pipelines/constants';
import {
mockPipelineResponse,
pipelineWithUpstreamDownstream,
wrappedPipelineReturn,
} from './mock_data';
const processedPipeline = pipelineWithUpstreamDownstream(mockPipelineResponse);
describe('Linked Pipelines Column', () => { describe('Linked Pipelines Column', () => {
const propsData = { const defaultProps = {
columnTitle: 'Upstream', columnTitle: 'Upstream',
linkedPipelines: mockData.triggered, linkedPipelines: processedPipeline.downstream,
graphPosition: 'right', type: DOWNSTREAM,
projectId: 19,
type: UPSTREAM,
}; };
let wrapper; let wrapper;
const findLinkedColumnTitle = () => wrapper.find('[data-testid="linked-column-title"]');
const findLinkedPipelineElements = () => wrapper.findAll(LinkedPipeline);
const findPipelineGraph = () => wrapper.find(PipelineGraph);
const findExpandButton = () => wrapper.find('[data-testid="expand-pipeline-button"]');
beforeEach(() => { const localVue = createLocalVue();
wrapper = shallowMount(LinkedPipelinesColumn, { propsData }); localVue.use(VueApollo);
});
const createComponent = ({ apolloProvider, mountFn = shallowMount, props = {} } = {}) => {
wrapper = mountFn(LinkedPipelinesColumn, {
apolloProvider,
localVue,
propsData: {
...defaultProps,
...props,
},
provide: {
dataMethod: GRAPHQL,
},
});
};
const createComponentWithApollo = (
mountFn = shallowMount,
getPipelineDetailsHandler = jest.fn().mockResolvedValue(wrappedPipelineReturn),
) => {
const requestHandlers = [[getPipelineDetails, getPipelineDetailsHandler]];
const apolloProvider = createMockApollo(requestHandlers);
createComponent({ apolloProvider, mountFn });
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
wrapper = null;
}); });
it('renders the pipeline orientation', () => { describe('it renders correctly', () => {
const titleElement = wrapper.find('.linked-pipelines-column-title'); beforeEach(() => {
createComponent();
});
it('renders the pipeline title', () => {
expect(findLinkedColumnTitle().text()).toBe(defaultProps.columnTitle);
});
expect(titleElement.text()).toBe(propsData.columnTitle); it('renders the correct number of linked pipelines', () => {
expect(findLinkedPipelineElements()).toHaveLength(defaultProps.linkedPipelines.length);
});
}); });
it('renders the correct number of linked pipelines', () => { describe('click action', () => {
const linkedPipelineElements = wrapper.findAll(LinkedPipeline); const clickExpandButton = async () => {
await findExpandButton().trigger('click');
await wrapper.vm.$nextTick();
};
expect(linkedPipelineElements.length).toBe(propsData.linkedPipelines.length); const clickExpandButtonAndAwaitTimers = async () => {
}); await clickExpandButton();
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
};
describe('when successful', () => {
beforeEach(() => {
createComponentWithApollo(mount);
});
it('toggles the pipeline visibility', async () => {
expect(findPipelineGraph().exists()).toBe(false);
await clickExpandButtonAndAwaitTimers();
expect(findPipelineGraph().exists()).toBe(true);
await clickExpandButton();
expect(findPipelineGraph().exists()).toBe(false);
});
});
describe('on error', () => {
beforeEach(() => {
createComponentWithApollo(mount, jest.fn().mockRejectedValue(new Error('GraphQL error')));
});
it('emits the error', async () => {
await clickExpandButton();
expect(wrapper.emitted().error).toEqual([[LOAD_FAILURE]]);
});
it('renders cross project triangle when column is upstream', () => { it('does not show the pipeline', async () => {
expect(wrapper.find('.cross-project-triangle').exists()).toBe(true); expect(findPipelineGraph().exists()).toBe(false);
await clickExpandButtonAndAwaitTimers();
expect(findPipelineGraph().exists()).toBe(false);
});
});
}); });
}); });
import { unwrapPipelineData } from '~/pipelines/components/graph/utils';
export const mockPipelineResponse = { export const mockPipelineResponse = {
data: { data: {
project: { project: {
__typename: 'Project', __typename: 'Project',
pipeline: { pipeline: {
__typename: 'Pipeline', __typename: 'Pipeline',
id: '22', id: 163,
iid: '22',
downstream: null,
upstream: null,
stages: { stages: {
__typename: 'CiStageConnection', __typename: 'CiStageConnection',
nodes: [ nodes: [
...@@ -497,3 +502,164 @@ export const mockPipelineResponse = { ...@@ -497,3 +502,164 @@ export const mockPipelineResponse = {
}, },
}, },
}; };
export const downstream = {
nodes: [
{
id: 175,
iid: '31',
path: '/root/elemenohpee/-/pipelines/175',
status: {
group: 'success',
label: 'passed',
icon: 'status_success',
__typename: 'DetailedStatus',
},
sourceJob: {
name: 'test_c',
__typename: 'CiJob',
},
project: {
id: 'gid://gitlab/Project/25',
name: 'elemenohpee',
fullPath: 'root/elemenohpee',
__typename: 'Project',
},
__typename: 'Pipeline',
multiproject: true,
},
{
id: 181,
iid: '27',
path: '/root/abcd-dag/-/pipelines/181',
status: {
group: 'success',
label: 'passed',
icon: 'status_success',
__typename: 'DetailedStatus',
},
sourceJob: {
name: 'test_d',
__typename: 'CiJob',
},
project: {
id: 'gid://gitlab/Project/23',
name: 'abcd-dag',
fullPath: 'root/abcd-dag',
__typename: 'Project',
},
__typename: 'Pipeline',
multiproject: false,
},
],
};
export const upstream = {
id: 161,
iid: '24',
path: '/root/abcd-dag/-/pipelines/161',
status: {
group: 'success',
label: 'passed',
icon: 'status_success',
__typename: 'DetailedStatus',
},
sourceJob: null,
project: {
id: 'gid://gitlab/Project/23',
name: 'abcd-dag',
fullPath: 'root/abcd-dag',
__typename: 'Project',
},
__typename: 'Pipeline',
multiproject: true,
};
export const wrappedPipelineReturn = {
data: {
project: {
pipeline: {
id: 'gid://gitlab/Ci::Pipeline/175',
iid: '38',
downstream: {
nodes: [],
},
upstream: {
id: 'gid://gitlab/Ci::Pipeline/174',
iid: '37',
path: '/root/elemenohpee/-/pipelines/174',
status: {
group: 'success',
label: 'passed',
icon: 'status_success',
},
sourceJob: {
name: 'test_c',
},
project: {
id: 'gid://gitlab/Project/25',
name: 'elemenohpee',
fullPath: 'root/elemenohpee',
},
},
stages: {
nodes: [
{
name: 'build',
status: {
action: null,
},
groups: {
nodes: [
{
status: {
label: 'passed',
group: 'success',
icon: 'status_success',
},
name: 'build_n',
size: 1,
jobs: {
nodes: [
{
name: 'build_n',
scheduledAt: null,
needs: {
nodes: [],
},
status: {
icon: 'status_success',
tooltip: 'passed',
hasDetails: true,
detailsPath: '/root/elemenohpee/-/jobs/1662',
group: 'success',
action: {
buttonTitle: 'Retry this job',
icon: 'retry',
path: '/root/elemenohpee/-/jobs/1662/retry',
title: 'Retry',
},
},
},
],
},
},
],
},
},
],
},
},
},
},
};
export const generateResponse = (raw, mockPath) => unwrapPipelineData(mockPath, raw.data);
export const pipelineWithUpstreamDownstream = base => {
const pip = { ...base };
pip.data.project.pipeline.downstream = downstream;
pip.data.project.pipeline.upstream = upstream;
return generateResponse(pip, 'root/abcd-dag');
};
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