Commit c9aa0b7b authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents d060a7f0 97b71aa8
......@@ -15,9 +15,9 @@
## 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:
- [ ] 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:
- The corresponding DevOps stage and group labels, if applicable.
- ~"development guidelines" when changing docs under `doc/development/*`, `CONTRIBUTING.md`, or `README.md`.
......
<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 { MAIN } from './constants';
import { DOWNSTREAM, MAIN, UPSTREAM } from './constants';
export default {
name: 'PipelineGraph',
components: {
LinkedGraphWrapper,
LinkedPipelinesColumn,
StageColumnComponent,
},
props: {
......@@ -23,10 +27,60 @@ export default {
default: MAIN,
},
},
pipelineTypeConstants: {
DOWNSTREAM,
UPSTREAM,
},
data() {
return {
hoveredJobName: '',
pipelineExpanded: {
jobName: '',
expanded: false,
},
};
},
computed: {
downstreamPipelines() {
return this.hasDownstreamPipelines ? this.pipeline.downstream : [];
},
graph() {
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>
......@@ -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-py-5': !isLinkedPipeline }"
>
<stage-column-component
v-for="stage in graph"
:key="stage.name"
:title="stage.name"
:groups="stage.groups"
:action="stage.status.action"
/>
<linked-graph-wrapper>
<template #upstream>
<linked-pipelines-column
v-if="showUpstreamPipelines"
:linked-pipelines="upstreamPipelines"
: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>
</template>
......@@ -42,7 +42,7 @@ export default {
};
},
update(data) {
return unwrapPipelineData(this.pipelineIid, data);
return unwrapPipelineData(this.pipelineProjectPath, data);
},
error() {
this.reportFailure(LOAD_FAILURE);
......@@ -77,13 +77,11 @@ export default {
};
</script>
<template>
<gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
{{ alert.text }}
</gl-alert>
<gl-loading-icon
v-else-if="$apollo.queries.pipeline.loading"
class="gl-mx-auto gl-my-4"
size="lg"
/>
<pipeline-graph v-else :pipeline="pipeline" />
<div>
<gl-alert v-if="showAlert" :variant="alert.variant" @dismiss="hideAlert">
{{ alert.text }}
</gl-alert>
<gl-loading-icon v-if="$apollo.queries.pipeline.loading" class="gl-mx-auto gl-my-4" size="lg" />
<pipeline-graph v-if="pipeline" :pipeline="pipeline" @error="reportFailure" />
</div>
</template>
......@@ -25,23 +25,33 @@ export default {
type: String,
required: true,
},
pipeline: {
type: Object,
expanded: {
type: Boolean,
required: true,
},
projectId: {
type: Number,
pipeline: {
type: Object,
required: true,
},
type: {
type: String,
required: true,
},
},
data() {
return {
expanded: false,
};
/*
The next two props will be removed or required
once the graph transition is done.
See: https://gitlab.com/gitlab-org/gitlab/-/issues/291043
*/
isLoading: {
type: Boolean,
required: false,
default: false,
},
projectId: {
type: Number,
required: false,
default: -1,
},
},
computed: {
tooltipText() {
......@@ -74,6 +84,9 @@ export default {
}
return __('Multi-project');
},
pipelineIsLoading() {
return Boolean(this.isLoading || this.pipeline.isLoading);
},
isDownstream() {
return this.type === DOWNSTREAM;
},
......@@ -81,7 +94,9 @@ export default {
return this.type === UPSTREAM;
},
isSameProject() {
return this.projectId === this.pipeline.project.id;
return this.projectId > -1
? this.projectId === this.pipeline.project.id
: !this.pipeline.multiproject;
},
sourceJobName() {
return accessValue(this.dataMethod, 'sourceJob', this.pipeline);
......@@ -101,16 +116,15 @@ export default {
},
methods: {
onClickLinkedPipeline() {
this.$root.$emit('bv::hide::tooltip', this.buttonId);
this.expanded = !this.expanded;
this.hideTooltips();
this.$emit('pipelineClicked', this.$refs.linkedPipeline);
this.$emit('pipelineExpandToggle', this.pipeline.source_job.name, this.expanded);
this.$emit('pipelineExpandToggle', this.sourceJobName, !this.expanded);
},
hideTooltips() {
this.$root.$emit('bv::hide::tooltip');
},
onDownstreamHovered() {
this.$emit('downstreamHovered', this.pipeline.source_job.name);
this.$emit('downstreamHovered', this.sourceJobName);
},
onDownstreamHoverLeave() {
this.$emit('downstreamHovered', '');
......@@ -120,10 +134,10 @@ export default {
</script>
<template>
<li
<div
ref="linkedPipeline"
v-gl-tooltip
class="linked-pipeline build"
class="linked-pipeline build gl-pipeline-job-width"
:title="tooltipText"
:class="{ 'downstream-pipeline': isDownstream }"
data-qa-selector="child_pipeline"
......@@ -136,8 +150,9 @@ export default {
>
<div class="gl-display-flex">
<ci-status
v-if="!pipeline.isLoading"
v-if="!pipelineIsLoading"
:status="pipelineStatus"
:size="24"
css-classes="gl-top-0 gl-pr-2"
/>
<div v-else class="gl-pr-2"><gl-loading-icon inline /></div>
......@@ -160,10 +175,10 @@ export default {
class="gl-absolute gl-top-0 gl-bottom-0 gl-shadow-none! gl-rounded-0!"
:class="`js-pipeline-expand-${pipeline.id} ${expandButtonPosition}`"
:icon="expandedIcon"
data-testid="expandPipelineButton"
data-testid="expand-pipeline-button"
data-qa-selector="expand_pipeline_button"
@click="onClickLinkedPipeline"
/>
</div>
</li>
</div>
</template>
<script>
import getPipelineDetails from '../../graphql/queries/get_pipeline_details.query.graphql';
import LinkedPipeline from './linked_pipeline.vue';
import { LOAD_FAILURE } from '../../constants';
import { UPSTREAM } from './constants';
import { unwrapPipelineData } from './utils';
export default {
components: {
LinkedPipeline,
PipelineGraph: () => import('./graph_component.vue'),
},
props: {
columnTitle: {
......@@ -19,11 +23,22 @@ export default {
type: String,
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: {
columnClass() {
const positionValues = {
......@@ -35,14 +50,66 @@ export default {
graphPosition() {
return this.isUpstream ? 'left' : 'right';
},
// Refactor string match when BE returns Upstream/Downstream indicators
isUpstream() {
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: {
onPipelineClick(downstreamNode, pipeline, index) {
this.$emit('linkedPipelineClick', pipeline, index, downstreamNode);
getPipelineData(pipeline) {
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) {
this.$emit('downstreamHovered', jobName);
......@@ -60,25 +127,40 @@ export default {
</script>
<template>
<div :class="columnClass" class="stage-column linked-pipelines-column">
<div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
<div v-if="isUpstream" class="cross-project-triangle"></div>
<ul>
<linked-pipeline
v-for="(pipeline, index) in linkedPipelines"
:key="pipeline.id"
:class="{
active: pipeline.isExpanded,
'left-connector': pipeline.isExpanded && graphPosition === 'left',
}"
:pipeline="pipeline"
:column-title="columnTitle"
:project-id="projectId"
:type="type"
@pipelineClicked="onPipelineClick($event, pipeline, index)"
@downstreamHovered="onDownstreamHovered"
@pipelineExpandToggle="onPipelineExpandToggle"
/>
</ul>
<div class="gl-display-flex">
<div :class="columnClass" class="linked-pipelines-column">
<div data-testid="linked-column-title" class="stage-name" :class="computedTitleClasses">
{{ columnTitle }}
</div>
<ul class="gl-pl-0">
<li
v-for="pipeline in linkedPipelines"
:key="pipeline.id"
class="gl-display-flex gl-mb-4"
:class="{ 'gl-flex-direction-row-reverse': isUpstream }"
>
<linked-pipeline
class="gl-display-inline-block"
:is-loading="isLoadingPipeline(pipeline.id)"
:pipeline="pipeline"
:column-title="columnTitle"
:type="type"
:expanded="isExpanded(pipeline.id)"
@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>
</template>
......@@ -35,7 +35,9 @@ export default {
graphPosition() {
return this.isUpstream ? 'left' : 'right';
},
// Refactor string match when BE returns Upstream/Downstream indicators
isExpanded() {
return this.pipeline?.isExpanded || false;
},
isUpstream() {
return this.type === UPSTREAM;
},
......@@ -64,21 +66,22 @@ export default {
<div class="stage-name linked-pipelines-column-title">{{ columnTitle }}</div>
<div v-if="isUpstream" class="cross-project-triangle"></div>
<ul>
<linked-pipeline
v-for="(pipeline, index) in linkedPipelines"
:key="pipeline.id"
:class="{
active: pipeline.isExpanded,
'left-connector': pipeline.isExpanded && graphPosition === 'left',
}"
:pipeline="pipeline"
:column-title="columnTitle"
:project-id="projectId"
:type="type"
@pipelineClicked="onPipelineClick($event, pipeline, index)"
@downstreamHovered="onDownstreamHovered"
@pipelineExpandToggle="onPipelineExpandToggle"
/>
<li v-for="(pipeline, index) in linkedPipelines" :key="pipeline.id">
<linked-pipeline
:class="{
active: pipeline.isExpanded,
'left-connector': pipeline.isExpanded && graphPosition === 'left',
}"
:pipeline="pipeline"
:column-title="columnTitle"
:project-id="projectId"
:type="type"
:expanded="isExpanded"
@pipelineClicked="onPipelineClick($event, pipeline, index)"
@downstreamHovered="onDownstreamHovered"
@pipelineExpandToggle="onPipelineExpandToggle"
/>
</li>
</ul>
</div>
</template>
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { unwrapStagesWithNeeds } from '../unwrapping_utils';
const addMulti = (mainId, pipeline) => {
return { ...pipeline, multiproject: mainId !== pipeline.id };
const addMulti = (mainPipelineProjectPath, linkedPipeline) => {
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) {
return null;
}
const { pipeline } = data.project;
const {
id,
upstream,
downstream,
stages: { nodes: stages },
} = data.project.pipeline;
} = pipeline;
const nodes = unwrapStagesWithNeeds(stages);
return {
id,
...pipeline,
id: getIdFromGraphQLId(pipeline.id),
stages: nodes,
upstream: upstream ? [upstream].map(addMulti.bind(null, mainPipelineId)) : [],
downstream: downstream ? downstream.map(addMulti.bind(null, mainPipelineId)) : [],
upstream: upstream
? [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 {
<template>
<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"
>
<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!) {
project(fullPath: $projectPath) {
pipeline(iid: $iid) {
id: iid
id
iid
downstream {
nodes {
...LinkedPipelineData
}
}
upstream {
...LinkedPipelineData
}
stages {
nodes {
name
......
......@@ -2,6 +2,7 @@ query getPipelineHeaderData($fullPath: ID!, $iid: ID!) {
project(fullPath: $fullPath) {
pipeline(iid: $iid) {
id
iid
status
retryable
cancelable
......
......@@ -139,6 +139,10 @@
width: 186px;
}
.gl-linked-pipeline-padding {
padding-right: 120px;
}
.gl-build-content {
@include build-content();
}
......
{
"type": "object",
"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:
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
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.
### Enterprise to Community Edition
......
......@@ -111,7 +111,7 @@ It is also possible to manage multiple assignees:
- When creating a merge request.
- 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.
> - 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
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
deployed behind a feature flag that is **disabled by default**.
......
......@@ -3,12 +3,16 @@ import { GlButton, GlIcon, GlTooltipDirective } from '@gitlab/ui';
import {
DAST_SITE_VALIDATION_STATUS,
DAST_SITE_VALIDATION_STATUS_PROPS,
DAST_SITE_VALIDATION_POLLING_INTERVAL,
} from 'ee/security_configuration/dast_site_validation/constants';
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 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 {
components: {
......@@ -17,6 +21,42 @@ export default {
DastSiteValidationModal,
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: {
GlTooltip: GlTooltipDirective,
},
......@@ -26,6 +66,10 @@ export default {
type: String,
required: true,
},
profiles: {
type: Array,
required: true,
},
},
data() {
return {
......@@ -33,6 +77,19 @@ export default {
};
},
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: {
shouldShowValidationBtn(status) {
return (
......@@ -52,11 +109,19 @@ export default {
this.showValidationModal();
});
},
startValidatingProfile({ normalizedTargetUrl }) {
updateSiteProfilesStatuses({
fullPath: this.fullPath,
normalizedTargetUrl,
status: PENDING,
store: this.$apollo.getClient(),
});
},
},
};
</script>
<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 v-if="shouldShowValidationStatus(value)">
<span :class="$options.statuses[value].cssClass">
......@@ -87,6 +152,7 @@ export default {
ref="dast-site-validation-modal"
:full-path="fullPath"
:target-url="validatingProfile.targetUrl"
@primary="startValidatingProfile(validatingProfile)"
/>
</profiles-list>
</template>
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
* - to be used with $apollo.queries.x.fetchMore
......@@ -54,3 +57,34 @@ export const dastProfilesDeleteResponse = ({ mutationName, payloadTypeName }) =>
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 @@
query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first: Int, $last: Int) {
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
}
......@@ -11,6 +12,7 @@ query DastSiteProfiles($fullPath: ID!, $after: String, $before: String, $first:
node {
id
profileName
normalizedTargetUrl
targetUrl
editPath
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 = {
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_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
feature_category :purchase
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)
end
......@@ -115,4 +115,12 @@ class TrialsController < ApplicationController
def record_user_for_group_only_trials_experiment
record_experiment_user(:group_only_trials)
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
......@@ -263,7 +263,6 @@ module EE
def generic_alert_with_default_title?
title == ::Gitlab::AlertManagement::Payload::Generic::DEFAULT_TITLE &&
project.alerts_service_activated? &&
author == ::User.alert_bot
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
links: links,
remediations: remediations,
raw_metadata: data.to_json,
metadata_version: version))
metadata_version: version,
details: data['details'] || {}))
end
def create_scan(report, scan_data)
......
......@@ -23,10 +23,11 @@ module Gitlab
attr_reader :severity
attr_reader :uuid
attr_reader :remediations
attr_reader :details
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
@confidence = confidence
@identifiers = identifiers
......@@ -41,6 +42,7 @@ module Gitlab
@severity = severity
@uuid = uuid
@remediations = remediations
@details = details
@project_fingerprint = generate_project_fingerprint
end
......@@ -61,6 +63,7 @@ module Gitlab
scan
severity
uuid
details
].each_with_object({}) do |key, hash|
hash[key] = public_send(key) # rubocop:disable GitlabSecurity/PublicSend
end
......
......@@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe TrialsController do
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?,
last_name_present: user.last_name.present?,
......@@ -55,7 +55,7 @@ RSpec.describe TrialsController do
end
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)
subject
......
......@@ -18,7 +18,25 @@
{
"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",
......
......@@ -4,7 +4,7 @@ import { mount, shallowMount, createWrapper } from '@vue/test-utils';
import { merge } from 'lodash';
import DastProfilesList from 'ee/security_configuration/dast_profiles/components/dast_profiles_list.vue';
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';
......
......@@ -2,7 +2,7 @@ import { mount, shallowMount } from '@vue/test-utils';
import { merge } from 'lodash';
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 { scannerProfiles } from './mock_data';
import { scannerProfiles } from '../mocks/mock_data';
describe('EE - DastScannerProfileList', () => {
let wrapper;
......
import { mount, shallowMount } from '@vue/test-utils';
import { mount, shallowMount, createLocalVue } from '@vue/test-utils';
import { within } from '@testing-library/dom';
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 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', () => {
let localVue;
let wrapper;
let requestHandlers;
let apolloProvider;
const defaultProps = {
profiles: [],
......@@ -20,7 +33,15 @@ describe('EE - DastSiteProfileList', () => {
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(
Component,
merge(
......@@ -30,7 +51,7 @@ describe('EE - DastSiteProfileList', () => {
glFeatures: { securityOnDemandScansSiteValidation: true },
},
},
options,
{ ...options, localVue, apolloProvider },
),
);
};
......@@ -52,6 +73,7 @@ describe('EE - DastSiteProfileList', () => {
afterEach(() => {
wrapper.destroy();
apolloProvider = null;
});
it('renders profile list properly', () => {
......@@ -81,16 +103,38 @@ describe('EE - DastSiteProfileList', () => {
});
describe('with site validation enabled', () => {
const [pendingValidation, inProgressValidation] = siteProfiles;
const urlsPendingValidation = [
pendingValidation.normalizedTargetUrl,
inProgressValidation.normalizedTargetUrl,
];
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`
status | statusEnum | label | hasValidateButton
${'pending'} | ${'PENDING_VALIDATION'} | ${''} | ${true}
${'in-progress'} | ${'INPROGRESS_VALIDATION'} | ${'Validating...'} | ${false}
${'passed'} | ${'PASSED_VALIDATION'} | ${'Validated'} | ${false}
${'failed'} | ${'FAILED_VALIDATION'} | ${'Validation failed'} | ${true}
status | statusEnum | label | hasValidateButton
${'pending'} | ${DAST_SITE_VALIDATION_STATUS.PENDING} | ${''} | ${true}
${'in-progress'} | ${DAST_SITE_VALIDATION_STATUS.INPROGRESS} | ${'Validating...'} | ${false}
${'passed'} | ${DAST_SITE_VALIDATION_STATUS.PASSED} | ${'Validated'} | ${false}
${'failed'} | ${DAST_SITE_VALIDATION_STATUS.FAILED} | ${'Validation failed'} | ${true}
`('profile with validation $status', ({ statusEnum, label, hasValidateButton }) => {
const profile = siteProfiles.find(({ validationStatus }) => validationStatus === statusEnum);
......@@ -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', () => {
......
import gql from 'graphql-tag';
import {
appendToPreviousResult,
removeProfile,
dastProfilesDeleteResponse,
updateSiteProfilesStatuses,
} from 'ee/security_configuration/dast_profiles/graphql/cache_utils';
import { siteProfiles } from '../mocks/mock_data';
describe('EE - DastProfiles GraphQL CacheUtils', () => {
describe('appendToPreviousResult', () => {
......@@ -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 = [
id: 1,
profileName: 'Profile 1',
targetUrl: 'http://example-1.com',
normalizedTargetUrl: 'http://example-1.com',
editPath: '/1/edit',
validationStatus: 'PENDING_VALIDATION',
},
......@@ -10,6 +11,7 @@ export const siteProfiles = [
id: 2,
profileName: 'Profile 2',
targetUrl: 'http://example-2.com',
normalizedTargetUrl: 'http://example-2.com',
editPath: '/2/edit',
validationStatus: 'INPROGRESS_VALIDATION',
},
......@@ -17,6 +19,7 @@ export const siteProfiles = [
id: 3,
profileName: 'Profile 3',
targetUrl: 'http://example-2.com',
normalizedTargetUrl: 'http://example-2.com',
editPath: '/3/edit',
validationStatus: 'PASSED_VALIDATION',
},
......@@ -24,6 +27,7 @@ export const siteProfiles = [
id: 4,
profileName: 'Profile 4',
targetUrl: 'http://example-3.com',
normalizedTargetUrl: 'http://example-3.com',
editPath: '/3/edit',
validationStatus: 'FAILED_VALIDATION',
},
......
......@@ -65,6 +65,26 @@ RSpec.describe Gitlab::Ci::Parsers::Security::Common do
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
let(:expected_remediation) { create(:ci_reports_security_remediation, diff: '') }
......
......@@ -30,7 +30,25 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
scanner: scanner,
scan: nil,
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
......@@ -52,7 +70,25 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
report_type: :sast,
scanner: scanner,
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
......@@ -100,7 +136,8 @@ RSpec.describe Gitlab::Ci::Reports::Security::Finding do
scanner: occurrence.scanner,
scan: occurrence.scan,
severity: occurrence.severity,
uuid: occurrence.uuid
uuid: occurrence.uuid,
details: occurrence.details
})
end
end
......
......@@ -30,30 +30,14 @@ RSpec.describe Issue do
context 'when issue title is "New: Incident"' do
let(:issue) { build(:issue, project: project, author: author, title: 'New: Incident', iid: 503503) }
context 'when alerts service is active' do
before do
allow(project).to receive(:alerts_service_activated?).and_return(true)
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
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 alerts service is not active' do
before do
allow(project).to receive(:alerts_service_activated?).and_return(false)
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 }
......
......@@ -15,8 +15,8 @@ describe('graph component', () => {
let mediator;
let wrapper;
const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]');
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]');
const findExpandPipelineBtn = () => wrapper.find('[data-testid="expand-pipeline-button"]');
const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expand-pipeline-button"]');
const findStageColumns = () => wrapper.findAll(StageColumnComponentLegacy);
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 StageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue';
import LinkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines_column.vue';
import { unwrapPipelineData } from '~/pipelines/components/graph/utils';
import { mockPipelineResponse } from './mock_data';
import { GRAPHQL } from '~/pipelines/components/graph/constants';
import {
generateResponse,
mockPipelineResponse,
pipelineWithUpstreamDownstream,
} from './mock_data';
describe('graph component', () => {
let wrapper;
......@@ -11,10 +15,8 @@ describe('graph component', () => {
const findLinkedColumns = () => wrapper.findAll(LinkedPipelinesColumn);
const findStageColumns = () => wrapper.findAll(StageColumnComponent);
const generateResponse = raw => unwrapPipelineData(raw.data.project.pipeline.id, raw.data);
const defaultProps = {
pipeline: generateResponse(mockPipelineResponse),
pipeline: generateResponse(mockPipelineResponse, 'root/fungi-xoxo'),
};
const createComponent = ({ mountFn = shallowMount, props = {} } = {}) => {
......@@ -23,6 +25,9 @@ describe('graph component', () => {
...defaultProps,
...props,
},
provide: {
dataMethod: GRAPHQL,
},
});
};
......@@ -33,7 +38,7 @@ describe('graph component', () => {
describe('with data', () => {
beforeEach(() => {
createComponent();
createComponent({ mountFn: mount });
});
it('renders the main columns in the graph', () => {
......@@ -43,11 +48,24 @@ describe('graph component', () => {
describe('when linked pipelines are not present', () => {
beforeEach(() => {
createComponent();
createComponent({ mountFn: mount });
});
it('should not render a linked pipelines column', () => {
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', () => {
const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' });
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
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 = []) => {
wrapper = mount(LinkedPipelineComponent, {
......@@ -40,20 +40,13 @@ describe('Linked pipeline', () => {
projectId: invalidTriggeredPipelineId,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
};
beforeEach(() => {
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', () => {
expect(wrapper.text()).toContain(props.pipeline.project.name);
});
......@@ -105,12 +98,14 @@ describe('Linked pipeline', () => {
projectId: validTriggeredPipelineId,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
};
const upstreamProps = {
...downstreamProps,
columnTitle: 'Upstream',
type: UPSTREAM,
expanded: false,
};
it('parent/child label container should exist', () => {
......@@ -173,7 +168,7 @@ describe('Linked pipeline', () => {
`(
'$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded',
({ pipelineType, anglePosition, expanded }) => {
createWrapper(pipelineType, { expanded });
createWrapper({ ...pipelineType, expanded });
expect(findExpandButton().props('icon')).toBe(anglePosition);
},
);
......@@ -185,6 +180,7 @@ describe('Linked pipeline', () => {
projectId: invalidTriggeredPipelineId,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
};
beforeEach(() => {
......@@ -202,6 +198,7 @@ describe('Linked pipeline', () => {
projectId: validTriggeredPipelineId,
columnTitle: 'Downstream',
type: DOWNSTREAM,
expanded: false,
};
beforeEach(() => {
......@@ -219,10 +216,7 @@ describe('Linked pipeline', () => {
jest.spyOn(wrapper.vm.$root, '$emit');
findButton().trigger('click');
expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual([
'bv::hide::tooltip',
'js-linked-pipeline-34993051',
]);
expect(wrapper.vm.$root.$emit.mock.calls[0]).toEqual(['bv::hide::tooltip']);
});
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 LinkedPipeline from '~/pipelines/components/graph/linked_pipeline.vue';
import { UPSTREAM } from '~/pipelines/components/graph/constants';
import mockData from './linked_pipelines_mock_data';
import getPipelineDetails from '~/pipelines/graphql/queries/get_pipeline_details.query.graphql';
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', () => {
const propsData = {
const defaultProps = {
columnTitle: 'Upstream',
linkedPipelines: mockData.triggered,
graphPosition: 'right',
projectId: 19,
type: UPSTREAM,
linkedPipelines: processedPipeline.downstream,
type: DOWNSTREAM,
};
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(() => {
wrapper = shallowMount(LinkedPipelinesColumn, { propsData });
});
const localVue = createLocalVue();
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(() => {
wrapper.destroy();
wrapper = null;
});
it('renders the pipeline orientation', () => {
const titleElement = wrapper.find('.linked-pipelines-column-title');
describe('it renders correctly', () => {
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', () => {
const linkedPipelineElements = wrapper.findAll(LinkedPipeline);
describe('click action', () => {
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', () => {
expect(wrapper.find('.cross-project-triangle').exists()).toBe(true);
it('does not show the pipeline', async () => {
expect(findPipelineGraph().exists()).toBe(false);
await clickExpandButtonAndAwaitTimers();
expect(findPipelineGraph().exists()).toBe(false);
});
});
});
});
import { unwrapPipelineData } from '~/pipelines/components/graph/utils';
export const mockPipelineResponse = {
data: {
project: {
__typename: 'Project',
pipeline: {
__typename: 'Pipeline',
id: '22',
id: 163,
iid: '22',
downstream: null,
upstream: null,
stages: {
__typename: 'CiStageConnection',
nodes: [
......@@ -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