Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-12-09 15:10:12 +00:00
parent 1361891b0a
commit e91cb68359
98 changed files with 1990 additions and 1757 deletions

View File

@ -22,7 +22,7 @@
RUBY_GC_MALLOC_LIMIT_MAX: 134217728
CRYSTALBALL: "true"
RECORD_DEPRECATIONS: "true"
needs: ["setup-test-env", "retrieve-tests-metadata", "compile-test-assets"]
needs: ["setup-test-env", "retrieve-tests-metadata", "compile-test-assets", "detect-tests"]
script:
- *base-script
- rspec_paralellized_job "--tag ~quarantine --tag ~geo --tag ~level:migration"
@ -64,7 +64,7 @@
- .rspec-base
- .as-if-foss
- .use-pg11
needs: ["setup-test-env", "retrieve-tests-metadata", "compile-test-assets as-if-foss"]
needs: ["setup-test-env", "retrieve-tests-metadata", "compile-test-assets as-if-foss", "detect-tests"]
.rspec-ee-base-pg11:
extends:

View File

@ -604,10 +604,9 @@
rules:
- <<: *if-not-ee
when: never
- <<: *if-security-merge-request
changes: *code-backstage-patterns
- <<: *if-dot-com-gitlab-org-merge-request
- <<: *if-default-refs
changes: *code-backstage-patterns
- <<: *if-merge-request-title-run-all-rspec
.rails:rules:rspec-foss-impact:
rules:

View File

@ -61,15 +61,17 @@ verify-tests-yml:
- scripts/verify-tff-mapping
.detect-test-base:
image: ruby:2.7-alpine
image: ruby:2.7
needs: []
stage: prepare
script:
- source scripts/utils.sh
- source ./scripts/utils.sh
- source ./scripts/rspec_helpers.sh
- install_gitlab_gem
- install_tff_gem
- tooling/bin/find_foss_tests ${MATCHED_TESTS_FILE}
- 'echo "test files affected: $(cat $MATCHED_TESTS_FILE)"'
- retrieve_tests_mapping
- 'if [ -n "$CI_MERGE_REQUEST_IID" ]; then tooling/bin/find_tests ${MATCHED_TESTS_FILE}; fi'
- 'if [ -n "$CI_MERGE_REQUEST_IID" ]; then echo "test files affected: $(cat $MATCHED_TESTS_FILE)"; fi'
artifacts:
expire_in: 7d
paths:

View File

@ -1 +1 @@
0bdcff0f59fb1bc52eb93e930e53965b19296c99
c0ea152ccad891cda5fd255c1fea78562aae5e4a

View File

@ -1,13 +1,14 @@
<script>
/* eslint-disable @gitlab/vue-require-i18n-strings */
import { isEmpty } from 'lodash';
import { GlTooltipDirective, GlIcon } from '@gitlab/ui';
import { __, sprintf } from '~/locale';
import { GlTooltipDirective, GlIcon, GlLink } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue';
import CommitComponent from '~/vue_shared/components/commit.vue';
import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue';
import CiIcon from '~/vue_shared/components/ci_icon.vue';
import eventHub from '../event_hub';
import ActionsComponent from './environment_actions.vue';
import ExternalUrlComponent from './environment_external_url.vue';
@ -30,6 +31,7 @@ export default {
CommitComponent,
ExternalUrlComponent,
GlIcon,
GlLink,
MonitoringButtonComponent,
PinComponent,
DeleteComponent,
@ -38,6 +40,7 @@ export default {
TerminalButtonComponent,
TooltipOnTruncate,
UserAvatarLink,
CiIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
@ -80,6 +83,24 @@ export default {
return false;
},
/**
* @returns {Object|Undefined} The `upcoming_deployment` object if it exists.
* Otherwise, `undefined`.
*/
upcomingDeployment() {
return this.model?.upcoming_deployment;
},
/**
* @returns {String} Text that will be shown in the tooltip when
* the user hovers over the upcoming deployment's status icon.
*/
upcomingDeploymentTooltipText() {
return sprintf(s__('Environments|Deployment %{status}'), {
status: this.upcomingDeployment.deployable.status.text,
});
},
/**
* Checkes whether the row displayed is a folder.
*
@ -234,6 +255,18 @@ export default {
return '';
},
/**
* Same as `userImageAltDescription`, but for the
* upcoming deployment's user
*
* @returns {String}
*/
upcomingDeploymentUserImageAltDescription() {
return sprintf(__("%{username}'s avatar"), {
username: this.upcomingDeployment.user.username,
});
},
/**
* If provided, returns the commit tag.
*
@ -381,6 +414,15 @@ export default {
return '';
},
/**
* Same as `deploymentInternalId`, but for the upcoming deployment
*
* @returns {String}
*/
upcomingDeploymentInternalId() {
return `#${this.upcomingDeployment.iid}`;
},
/**
* Verifies if the user object is present under last_deployment object.
*
@ -503,6 +545,13 @@ export default {
folderIconName() {
return this.model.isOpen ? 'chevron-down' : 'chevron-right';
},
upcomingDeploymentCellClasses() {
return [
this.tableData.upcoming.spacing,
{ 'gl-display-none gl-display-md-block': !this.upcomingDeployment },
];
},
},
methods: {
@ -512,6 +561,19 @@ export default {
onClickFolder() {
eventHub.$emit('toggleFolder', this.model);
},
/**
* Returns the field title that will be shown in the field's row
* in the mobile view.
*
* @returns `field.mobileTitle` if present;
* if not, falls back to `field.title`.
*/
getMobileViewTitleForField(fieldName) {
const field = this.tableData[fieldName];
return field.mobileTitle || field.title;
},
},
};
</script>
@ -530,7 +592,7 @@ export default {
role="gridcell"
>
<div v-if="!isFolder" class="table-mobile-header" role="rowheader">
{{ tableData.name.title }}
{{ getMobileViewTitleForField('name') }}
</div>
<span v-if="shouldRenderDeployBoard" class="deploy-board-icon" @click="toggleDeployBoard">
@ -609,7 +671,9 @@ export default {
</div>
<div v-if="!isFolder" class="table-section" :class="tableData.commit.spacing" role="gridcell">
<div role="rowheader" class="table-mobile-header">{{ tableData.commit.title }}</div>
<div role="rowheader" class="table-mobile-header">
{{ getMobileViewTitleForField('commit') }}
</div>
<div v-if="hasLastDeploymentKey" class="js-commit-component table-mobile-content">
<commit-component
:tag="commitTag"
@ -623,7 +687,9 @@ export default {
</div>
<div v-if="!isFolder" class="table-section" :class="tableData.date.spacing" role="gridcell">
<div role="rowheader" class="table-mobile-header">{{ tableData.date.title }}</div>
<div role="rowheader" class="table-mobile-header">
{{ getMobileViewTitleForField('date') }}
</div>
<span
v-if="canShowDeploymentDate"
v-gl-tooltip
@ -636,8 +702,51 @@ export default {
</span>
</div>
<div
v-if="!isFolder"
class="table-section"
:class="upcomingDeploymentCellClasses"
role="gridcell"
data-testid="upcoming-deployment"
>
<div role="rowheader" class="table-mobile-header">
{{ getMobileViewTitleForField('upcoming') }}
</div>
<div
v-if="upcomingDeployment"
class="gl-w-full gl-display-flex gl-flex-direction-row gl-md-flex-direction-column! gl-justify-content-end"
data-testid="upcoming-deployment-content"
>
<div class="gl-display-flex gl-align-items-center">
<span class="gl-mr-2">{{ upcomingDeploymentInternalId }}</span>
<gl-link
v-if="upcomingDeployment.deployable"
v-gl-tooltip
:href="upcomingDeployment.deployable.build_path"
:title="upcomingDeploymentTooltipText"
data-testid="upcoming-deployment-status-link"
>
<ci-icon class="gl-mr-2" :status="upcomingDeployment.deployable.status" />
</gl-link>
</div>
<div class="gl-display-flex">
<span v-if="upcomingDeployment.user" class="text-break-word">
by
<user-avatar-link
:link-href="upcomingDeployment.user.web_url"
:img-src="upcomingDeployment.user.avatar_url"
:img-alt="upcomingDeploymentUserImageAltDescription"
:tooltip-text="upcomingDeployment.user.username"
/>
</span>
</div>
</div>
</div>
<div v-if="!isFolder" class="table-section" :class="tableData.autoStop.spacing" role="gridcell">
<div role="rowheader" class="table-mobile-header">{{ tableData.autoStop.title }}</div>
<div role="rowheader" class="table-mobile-header">
{{ getMobileViewTitleForField('autoStop') }}
</div>
<span
v-if="canShowAutoStopDate"
v-gl-tooltip

View File

@ -71,7 +71,7 @@ export default {
// percent spacing for cols, should add up to 100
name: {
title: s__('Environments|Environment'),
spacing: 'section-15',
spacing: 'section-10',
},
deploy: {
title: s__('Environments|Deployment'),
@ -83,18 +83,23 @@ export default {
},
commit: {
title: s__('Environments|Commit'),
spacing: 'section-20',
spacing: 'section-15',
},
date: {
title: s__('Environments|Updated'),
spacing: 'section-10',
},
upcoming: {
title: s__('Environments|Upcoming'),
mobileTitle: s__('Environments|Upcoming deployment'),
spacing: 'section-10',
},
autoStop: {
title: s__('Environments|Auto stop in'),
spacing: 'section-5',
spacing: 'section-10',
},
actions: {
spacing: 'section-25',
spacing: 'section-20',
},
};
},
@ -160,6 +165,9 @@ export default {
<div class="table-section" :class="tableData.date.spacing" role="columnheader">
{{ tableData.date.title }}
</div>
<div class="table-section" :class="tableData.upcoming.spacing" role="columnheader">
{{ tableData.upcoming.title }}
</div>
<div class="table-section" :class="tableData.autoStop.spacing" role="columnheader">
{{ tableData.autoStop.title }}
</div>

View File

@ -2,7 +2,6 @@
import { escape, capitalize } from 'lodash';
import { GlLoadingIcon } from '@gitlab/ui';
import StageColumnComponentLegacy from './stage_column_component_legacy.vue';
import GraphWidthMixin from '../../mixins/graph_width_mixin';
import LinkedPipelinesColumnLegacy from './linked_pipelines_column_legacy.vue';
import GraphBundleMixin from '../../mixins/graph_pipeline_bundle_mixin';
import { UPSTREAM, DOWNSTREAM, MAIN } from './constants';
@ -14,7 +13,7 @@ export default {
LinkedPipelinesColumnLegacy,
StageColumnComponentLegacy,
},
mixins: [GraphWidthMixin, GraphBundleMixin],
mixins: [GraphBundleMixin],
props: {
isLoading: {
type: Boolean,
@ -183,87 +182,83 @@ export default {
class="pipeline-visualization pipeline-graph"
:class="{ 'pipeline-tab-content': !isLinkedPipeline }"
>
<div
:style="{
paddingLeft: `${graphLeftPadding}px`,
paddingRight: `${graphRightPadding}px`,
}"
>
<gl-loading-icon v-if="isLoading" class="m-auto" size="lg" />
<pipeline-graph-legacy
v-if="pipelineTypeUpstream"
:type="$options.upstream"
class="d-inline-block upstream-pipeline"
:class="`js-upstream-pipeline-${expandedUpstream.id}`"
:is-loading="false"
:pipeline="expandedUpstream"
:is-linked-pipeline="true"
:mediator="mediator"
@onClickUpstreamPipeline="clickUpstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
<linked-pipelines-column-legacy
v-if="hasUpstream"
:type="$options.upstream"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:project-id="pipelineProjectId"
@linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)"
/>
<ul
v-if="!isLoading"
:class="{
'inline js-has-linked-pipelines': hasDownstream || hasUpstream,
}"
class="stage-column-list align-top"
>
<stage-column-component-legacy
v-for="(stage, index) in graph"
:key="stage.name"
:class="{
'has-upstream gl-ml-11': hasUpstreamColumn(index),
'has-only-one-job': hasOnlyOneJob(stage),
'gl-mr-26': shouldAddRightMargin(index),
}"
:title="capitalizeStageName(stage.name)"
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
:has-upstream="hasUpstream"
:action="stage.status.action"
:job-hovered="jobName"
:pipeline-expanded="pipelineExpanded"
@refreshPipelineGraph="refreshPipelineGraph"
<div class="gl-w-full">
<div class="container-fluid container-limited">
<gl-loading-icon v-if="isLoading" class="m-auto" size="lg" />
<pipeline-graph-legacy
v-if="pipelineTypeUpstream"
:type="$options.upstream"
class="d-inline-block upstream-pipeline"
:class="`js-upstream-pipeline-${expandedUpstream.id}`"
:is-loading="false"
:pipeline="expandedUpstream"
:is-linked-pipeline="true"
:mediator="mediator"
@onClickUpstreamPipeline="clickUpstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
</ul>
<linked-pipelines-column-legacy
v-if="hasDownstream"
:type="$options.downstream"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:project-id="pipelineProjectId"
@linkedPipelineClick="handleClickedDownstream"
@downstreamHovered="setJob"
@pipelineExpandToggle="setPipelineExpanded"
/>
<linked-pipelines-column-legacy
v-if="hasUpstream"
:type="$options.upstream"
:linked-pipelines="upstreamPipelines"
:column-title="__('Upstream')"
:project-id="pipelineProjectId"
@linkedPipelineClick="$emit('onClickUpstreamPipeline', $event)"
/>
<pipeline-graph-legacy
v-if="pipelineTypeDownstream"
:type="$options.downstream"
class="d-inline-block"
:class="`js-downstream-pipeline-${expandedDownstream.id}`"
:is-loading="false"
:pipeline="expandedDownstream"
:is-linked-pipeline="true"
:style="{ 'margin-top': downstreamMarginTop }"
:mediator="mediator"
@onClickDownstreamPipeline="clickDownstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
<ul
v-if="!isLoading"
:class="{
'inline js-has-linked-pipelines': hasDownstream || hasUpstream,
}"
class="stage-column-list align-top"
>
<stage-column-component-legacy
v-for="(stage, index) in graph"
:key="stage.name"
:class="{
'has-upstream gl-ml-11': hasUpstreamColumn(index),
'has-only-one-job': hasOnlyOneJob(stage),
'gl-mr-26': shouldAddRightMargin(index),
}"
:title="capitalizeStageName(stage.name)"
:groups="stage.groups"
:stage-connector-class="stageConnectorClass(index, stage)"
:is-first-column="isFirstColumn(index)"
:has-upstream="hasUpstream"
:action="stage.status.action"
:job-hovered="jobName"
:pipeline-expanded="pipelineExpanded"
@refreshPipelineGraph="refreshPipelineGraph"
/>
</ul>
<linked-pipelines-column-legacy
v-if="hasDownstream"
:type="$options.downstream"
:linked-pipelines="downstreamPipelines"
:column-title="__('Downstream')"
:project-id="pipelineProjectId"
@linkedPipelineClick="handleClickedDownstream"
@downstreamHovered="setJob"
@pipelineExpandToggle="setPipelineExpanded"
/>
<pipeline-graph-legacy
v-if="pipelineTypeDownstream"
:type="$options.downstream"
class="d-inline-block"
:class="`js-downstream-pipeline-${expandedDownstream.id}`"
:is-loading="false"
:pipeline="expandedDownstream"
:is-linked-pipeline="true"
:style="{ 'margin-top': downstreamMarginTop }"
:mediator="mediator"
@onClickDownstreamPipeline="clickDownstreamPipeline"
@refreshPipelineGraph="requestRefreshPipelineGraph"
/>
</div>
</div>
</div>
</div>

View File

@ -1,50 +0,0 @@
import { debounceByAnimationFrame } from '~/lib/utils/common_utils';
import { LAYOUT_CHANGE_DELAY } from '~/pipelines/constants';
export default {
debouncedResize: null,
sidebarMutationObserver: null,
data() {
return {
graphLeftPadding: 0,
graphRightPadding: 0,
};
},
beforeDestroy() {
window.removeEventListener('resize', this.$options.debouncedResize);
if (this.$options.sidebarMutationObserver) {
this.$options.sidebarMutationObserver.disconnect();
}
},
created() {
this.$options.debouncedResize = debounceByAnimationFrame(this.setGraphPadding);
window.addEventListener('resize', this.$options.debouncedResize);
},
mounted() {
this.setGraphPadding();
this.$options.sidebarMutationObserver = new MutationObserver(this.handleLayoutChange);
this.$options.sidebarMutationObserver.observe(document.querySelector('.layout-page'), {
attributes: true,
childList: false,
subtree: false,
});
},
methods: {
setGraphPadding() {
// only add padding to main graph (not inline upstream/downstream graphs)
if (this.type && this.type !== 'main') return;
const container = document.querySelector('.js-pipeline-container');
if (!container) return;
this.graphLeftPadding = container.offsetLeft;
this.graphRightPadding = window.innerWidth - container.offsetLeft - container.offsetWidth;
},
handleLayoutChange() {
// wait until animations finish, then recalculate padding
window.setTimeout(this.setGraphPadding, LAYOUT_CHANGE_DELAY);
},
},
};

View File

@ -60,6 +60,7 @@ export default {
},
data() {
return {
formattedMarkdown: null,
parsedSource: parseSourceFile(this.preProcess(true, this.content)),
editorMode: EDITOR_TYPES.wysiwyg,
hasMatter: false,
@ -140,10 +141,14 @@ export default {
onSubmit() {
const preProcessedContent = this.preProcess(false, this.parsedSource.content());
this.$emit('submit', {
formattedMarkdown: this.formattedMarkdown,
content: preProcessedContent,
images: this.$options.imageRepository.getAll(),
});
},
onEditorLoad({ formattedMarkdown }) {
this.formattedMarkdown = formattedMarkdown;
},
},
};
</script>
@ -167,6 +172,7 @@ export default {
@modeChange="onModeChange"
@input="onInputChange"
@uploadImage="onUploadImage"
@load="onEditorLoad"
/>
<unsaved-changes-confirm-dialog :modified="isSaveable" />
<publish-toolbar

View File

@ -15,6 +15,14 @@ export const LOAD_CONTENT_ERROR = __(
'An error ocurred while loading your content. Please try again.',
);
export const DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE = s__(
'StaticSiteEditor|Automatic formatting changes',
);
export const DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION = s__(
'StaticSiteEditor|Markdown formatting preferences introduced by the Static Site Editor',
);
export const DEFAULT_HEADING = s__('StaticSiteEditor|Static site editor');
export const TRACKING_ACTION_CREATE_COMMIT = 'create_commit';

View File

@ -4,7 +4,17 @@ import savedContentMetaQuery from '../queries/saved_content_meta.query.graphql';
const submitContentChangesResolver = (
_,
{ input: { project: projectId, username, sourcePath, content, images, mergeRequestMeta } },
{
input: {
project: projectId,
username,
sourcePath,
content,
images,
mergeRequestMeta,
formattedMarkdown,
},
},
{ cache },
) => {
return submitContentChanges({
@ -14,6 +24,7 @@ const submitContentChangesResolver = (
content,
images,
mergeRequestMeta,
formattedMarkdown,
}).then(savedContentMeta => {
const data = produce(savedContentMeta, draftState => {
return {

View File

@ -53,6 +53,7 @@ export default {
return {
content: null,
images: null,
formattedMarkdown: null,
submitChangesError: null,
isSavingChanges: false,
};
@ -79,9 +80,10 @@ export default {
onDismissError() {
this.submitChangesError = null;
},
onPrepareSubmit({ content, images }) {
onPrepareSubmit({ formattedMarkdown, content, images }) {
this.content = content;
this.images = images;
this.formattedMarkdown = formattedMarkdown;
this.isSavingChanges = true;
this.$refs.editMetaModal.show();
@ -110,6 +112,7 @@ export default {
username: this.appData.username,
sourcePath: this.appData.sourcePath,
content: this.content,
formattedMarkdown: this.formattedMarkdown,
images: this.images,
mergeRequestMeta,
},

View File

@ -12,6 +12,8 @@ import {
TRACKING_ACTION_CREATE_MERGE_REQUEST,
USAGE_PING_TRACKING_ACTION_CREATE_COMMIT,
USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST,
DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE,
DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION,
} from '../constants';
const createBranch = (projectId, branch) =>
@ -47,7 +49,15 @@ const createImageActions = (images, markdown) => {
return actions;
};
const commitContent = (projectId, message, branch, sourcePath, content, images) => {
const createUpdateSourceFileAction = (sourcePath, content) => [
convertObjectPropsToSnakeCase({
action: 'update',
filePath: sourcePath,
content,
}),
];
const commit = (projectId, message, branch, actions) => {
Tracking.event(document.body.dataset.page, TRACKING_ACTION_CREATE_COMMIT);
Api.trackRedisCounterEvent(USAGE_PING_TRACKING_ACTION_CREATE_COMMIT);
@ -56,14 +66,7 @@ const commitContent = (projectId, message, branch, sourcePath, content, images)
convertObjectPropsToSnakeCase({
branch,
commitMessage: message,
actions: [
convertObjectPropsToSnakeCase({
action: 'update',
filePath: sourcePath,
content,
}),
...createImageActions(images, content),
],
actions,
}),
).catch(() => {
throw new Error(SUBMIT_CHANGES_COMMIT_ERROR);
@ -100,6 +103,7 @@ const submitContentChanges = ({
content,
images,
mergeRequestMeta,
formattedMarkdown,
}) => {
const branch = generateBranchName(username);
const { title: mergeRequestTitle, description: mergeRequestDescription } = mergeRequestMeta;
@ -107,10 +111,25 @@ const submitContentChanges = ({
return createBranch(projectId, branch)
.then(({ data: { web_url: url } }) => {
const message = `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`;
Object.assign(meta, { branch: { label: branch, url } });
return commitContent(projectId, mergeRequestTitle, branch, sourcePath, content, images);
return formattedMarkdown
? commit(
projectId,
message,
branch,
createUpdateSourceFileAction(sourcePath, formattedMarkdown),
)
: meta;
})
.then(() =>
commit(projectId, mergeRequestTitle, branch, [
...createUpdateSourceFileAction(sourcePath, content),
...createImageActions(images, content),
]),
)
.then(({ data: { short_id: label, web_url: url } }) => {
Object.assign(meta, { commit: { label, url } });

View File

@ -469,6 +469,8 @@ export default {
:pipeline-id="mr.pipeline.id"
:project-id="mr.sourceProjectId"
:security-reports-docs-path="mr.securityReportsDocsPath"
:target-project-full-path="mr.targetProjectFullPath"
:mr-iid="mr.iid"
/>
<grouped-test-reports-app

View File

@ -105,6 +105,8 @@ export default {
registerHTMLToMarkdownRenderer(editorApi);
this.addListeners(editorApi);
this.$emit('load', { formattedMarkdown: editorApi.getMarkdown() });
},
onOpenAddImageModal() {
this.$refs.addImageModal.show();

View File

@ -0,0 +1,48 @@
<script>
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
name: 'SecurityReportDownloadDropdown',
components: {
GlDropdown,
GlDropdownItem,
},
props: {
artifacts: {
type: Array,
required: true,
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
methods: {
artifactText({ name }) {
return sprintf(s__('SecurityReports|Download %{artifactName}'), {
artifactName: name,
});
},
},
};
</script>
<template>
<gl-dropdown
:text="s__('SecurityReports|Download results')"
:loading="loading"
icon="download"
right
>
<gl-dropdown-item
v-for="artifact in artifacts"
:key="artifact.path"
:href="artifact.path"
download
>
{{ artifactText(artifact) }}
</gl-dropdown-item>
</gl-dropdown>
</template>

View File

@ -1,3 +1,5 @@
import { invert } from 'lodash';
export const FEEDBACK_TYPE_DISMISSAL = 'dismissal';
export const FEEDBACK_TYPE_ISSUE = 'issue';
export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request';
@ -7,3 +9,24 @@ export const FEEDBACK_TYPE_MERGE_REQUEST = 'merge_request';
*/
export const REPORT_TYPE_SAST = 'sast';
export const REPORT_TYPE_SECRET_DETECTION = 'secret_detection';
/**
* SecurityReportTypeEnum values for use with GraphQL.
*
* These should correspond to the lowercase security scan report types.
*/
export const SECURITY_REPORT_TYPE_ENUM_SAST = 'SAST';
export const SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION = 'SECRET_DETECTION';
/**
* A mapping from security scan report types to SecurityReportTypeEnum values.
*/
export const reportTypeToSecurityReportTypeEnum = {
[REPORT_TYPE_SAST]: SECURITY_REPORT_TYPE_ENUM_SAST,
[REPORT_TYPE_SECRET_DETECTION]: SECURITY_REPORT_TYPE_ENUM_SECRET_DETECTION,
};
/**
* A mapping from SecurityReportTypeEnum values to security scan report types.
*/
export const securityReportTypeEnumToReportType = invert(reportTypeToSecurityReportTypeEnum);

View File

@ -0,0 +1,23 @@
query securityReportDownloadPaths(
$projectPath: ID!
$iid: String!
$reportTypes: [SecurityReportTypeEnum!]
) {
project(fullPath: $projectPath) {
mergeRequest(iid: $iid) {
headPipeline {
jobs(securityReportTypes: $reportTypes) {
nodes {
name
artifacts {
nodes {
downloadPath
fileType
}
}
}
}
}
}
}
}

View File

@ -8,10 +8,17 @@ import { s__ } from '~/locale';
import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils';
import createFlash from '~/flash';
import Api from '~/api';
import SecurityReportDownloadDropdown from './components/security_report_download_dropdown.vue';
import SecuritySummary from './components/security_summary.vue';
import store from './store';
import { MODULE_SAST, MODULE_SECRET_DETECTION } from './store/constants';
import { REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION } from './constants';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
reportTypeToSecurityReportTypeEnum,
} from './constants';
import securityReportDownloadPathsQuery from './queries/security_report_download_paths.query.graphql';
import { extractSecurityReportArtifacts } from './utils';
export default {
store,
@ -20,6 +27,7 @@ export default {
GlLink,
GlSprintf,
ReportSection,
SecurityReportDownloadDropdown,
SecuritySummary,
},
mixins: [glFeatureFlagsMixin()],
@ -46,6 +54,16 @@ export default {
required: false,
default: '',
},
targetProjectFullPath: {
type: String,
required: false,
default: '',
},
mrIid: {
type: Number,
required: false,
default: 0,
},
},
data() {
return {
@ -60,8 +78,44 @@ export default {
status: ERROR,
};
},
apollo: {
reportArtifacts: {
query: securityReportDownloadPathsQuery,
variables() {
return {
projectPath: this.targetProjectFullPath,
iid: String(this.mrIid),
reportTypes: this.$options.reportTypes.map(
reportType => reportTypeToSecurityReportTypeEnum[reportType],
),
};
},
skip() {
return !this.canShowDownloads;
},
update(data) {
return extractSecurityReportArtifacts(this.$options.reportTypes, data);
},
error(error) {
this.showError(error);
},
result({ loading }) {
if (loading) {
return;
}
// Query has completed, so populate the availableSecurityReports.
this.onCheckingAvailableSecurityReports(
this.reportArtifacts.map(({ reportType }) => reportType),
);
},
},
},
computed: {
...mapGetters(['groupedSummaryText', 'summaryStatus']),
canShowDownloads() {
return this.glFeatures.coreSecurityMrWidgetDownloads;
},
hasSecurityReports() {
return this.availableSecurityReports.length > 0;
},
@ -71,23 +125,26 @@ export default {
hasSecretDetectionReports() {
return this.availableSecurityReports.includes(REPORT_TYPE_SECRET_DETECTION);
},
isLoaded() {
return this.summaryStatus !== LOADING;
isLoadingReportArtifacts() {
return this.$apollo.queries.reportArtifacts.loading;
},
shouldShowDownloadGuidance() {
return !this.canShowDownloads && this.summaryStatus !== LOADING;
},
scansHaveRunMessage() {
return this.canShowDownloads
? this.$options.i18n.scansHaveRun
: this.$options.i18n.scansHaveRunWithDownloadGuidance;
},
},
created() {
this.checkAvailableSecurityReports(this.$options.reportTypes)
.then(availableSecurityReports => {
this.availableSecurityReports = Array.from(availableSecurityReports);
this.fetchCounts();
})
.catch(error => {
createFlash({
message: this.$options.i18n.apiError,
captureError: true,
error,
});
});
if (!this.canShowDownloads) {
this.checkAvailableSecurityReports(this.$options.reportTypes)
.then(availableSecurityReports => {
this.onCheckingAvailableSecurityReports(Array.from(availableSecurityReports));
})
.catch(this.showError);
}
},
methods: {
...mapActions(MODULE_SAST, {
@ -150,13 +207,25 @@ export default {
window.mrTabs.tabShown('pipelines');
}
},
onCheckingAvailableSecurityReports(availableSecurityReports) {
this.availableSecurityReports = availableSecurityReports;
this.fetchCounts();
},
showError(error) {
createFlash({
message: this.$options.i18n.apiError,
captureError: true,
error,
});
},
},
reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION],
i18n: {
apiError: s__(
'SecurityReports|Failed to get security report information. Please reload the page or try again later.',
),
scansHaveRun: s__(
scansHaveRun: s__('SecurityReports|Security scans have run'),
scansHaveRunWithDownloadGuidance: s__(
'SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports',
),
downloadFromPipelineTab: s__(
@ -190,7 +259,7 @@ export default {
</span>
</template>
<template v-if="isLoaded" #sub-heading>
<template v-if="shouldShowDownloadGuidance" #sub-heading>
<span class="gl-font-sm">
<gl-sprintf :message="$options.i18n.downloadFromPipelineTab">
<template #link="{ content }">
@ -204,6 +273,13 @@ export default {
</gl-sprintf>
</span>
</template>
<template v-if="canShowDownloads" #action-buttons>
<security-report-download-dropdown
:artifacts="reportArtifacts"
:loading="isLoadingReportArtifacts"
/>
</template>
</report-section>
<!-- TODO: Remove this section when removing core_security_mr_widget_counts
@ -216,7 +292,7 @@ export default {
data-testid="security-mr-widget"
>
<template #error>
<gl-sprintf :message="$options.i18n.scansHaveRun">
<gl-sprintf :message="scansHaveRunMessage">
<template #link="{ content }">
<gl-link data-testid="show-pipelines" @click="activatePipelinesTab">{{
content
@ -233,5 +309,12 @@ export default {
<gl-icon name="question" />
</gl-link>
</template>
<template v-if="canShowDownloads" #action-buttons>
<security-report-download-dropdown
:artifacts="reportArtifacts"
:loading="isLoadingReportArtifacts"
/>
</template>
</report-section>
</template>

View File

@ -0,0 +1,22 @@
import { securityReportTypeEnumToReportType } from './constants';
export const extractSecurityReportArtifacts = (reportTypes, data) => {
const jobs = data.project?.mergeRequest?.headPipeline?.jobs?.nodes ?? [];
return jobs.reduce((acc, job) => {
const artifacts = job.artifacts?.nodes ?? [];
artifacts.forEach(({ downloadPath, fileType }) => {
const reportType = securityReportTypeEnumToReportType[fileType];
if (reportType && reportTypes.includes(reportType)) {
acc.push({
name: job.name,
reportType,
path: downloadPath,
});
}
});
return acc;
}, []);
};

View File

@ -129,3 +129,17 @@
content: '';
display: flex;
}
// Will be moved to @gitlab/ui in https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1085
.gl-md-flex-direction-column {
@media (min-width: $breakpoint-md) {
flex-direction: column;
}
}
// Same as above
.gl-md-flex-direction-column\! {
@media (min-width: $breakpoint-md) {
flex-direction: column !important;
}
}

View File

@ -19,12 +19,12 @@ module Boards
end
def create
list = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board)
response = Boards::Lists::CreateService.new(board.resource_parent, current_user, create_list_params).execute(board)
if list.valid?
render json: serialize_as_json(list)
if response.success?
render json: serialize_as_json(response.payload[:list])
else
render json: list.errors, status: :unprocessable_entity
render json: { errors: response.errors }, status: :unprocessable_entity
end
end

View File

@ -40,6 +40,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo
push_frontend_feature_flag(:default_merge_ref_for_diffs, @project)
push_frontend_feature_flag(:core_security_mr_widget, @project, default_enabled: true)
push_frontend_feature_flag(:core_security_mr_widget_counts, @project)
push_frontend_feature_flag(:core_security_mr_widget_downloads, @project)
push_frontend_feature_flag(:remove_resolve_note, @project, default_enabled: true)
push_frontend_feature_flag(:test_failure_history, @project)
push_frontend_feature_flag(:diffs_gradual_load, @project)

View File

@ -27,30 +27,16 @@ module Mutations
board = authorized_find!(id: args[:board_id])
params = create_list_params(args)
authorize_list_type_resource!(board, params)
list = create_list(board, params)
response = create_list(board, params)
{
list: list.valid? ? list : nil,
errors: errors_on_object(list)
list: response.success? ? response.payload[:list] : nil,
errors: response.errors
}
end
private
# Overridden in EE
def authorize_list_type_resource!(board, params)
return unless params[:label_id]
labels = ::Labels::AvailableLabelsService.new(current_user, board.resource_parent, params)
.filter_labels_ids_in_param(:label_id)
unless labels.present?
raise Gitlab::Graphql::Errors::ArgumentError, 'Label not found!'
end
end
def create_list(board, params)
create_list_service =
::Boards::Lists::CreateService.new(board.resource_parent, current_user, params)

View File

@ -25,8 +25,6 @@ module Enums
schedule: 4,
api: 5,
external: 6,
# TODO: Rename `pipeline` to `cross_project_pipeline` in 13.0
# https://gitlab.com/gitlab-org/gitlab/issues/195991
pipeline: 7,
chat: 8,
webide: 9,

View File

@ -7,7 +7,7 @@ class List < ApplicationRecord
belongs_to :label
has_many :list_user_preferences
enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4 }
enum list_type: { backlog: 0, label: 1, closed: 2, assignee: 3, milestone: 4, iteration: 5 }
validates :board, :list_type, presence: true, unless: :importing?
validates :label, :position, presence: true, if: :label?

View File

@ -274,7 +274,7 @@ class MergeRequest < ApplicationRecord
scope :with_api_entity_associations, -> {
preload_routables
.preload(:assignees, :author, :unresolved_notes, :labels, :milestone,
:timelogs, :latest_merge_request_diff,
:timelogs, :latest_merge_request_diff, :reviewers,
target_project: :project_feature,
metrics: [:latest_closed_by, :merged_by])
}

View File

@ -6,17 +6,21 @@ module Boards
include Gitlab::Utils::StrongMemoize
def execute(board)
List.transaction do
case type
when :backlog
create_backlog(board)
else
target = target(board)
position = next_position(board)
list = case type
when :backlog
create_backlog(board)
else
target = target(board)
position = next_position(board)
create_list(board, type, target, position)
end
end
return ServiceResponse.error(message: _('%{board_target} not found') % { board_target: type.to_s.capitalize }) if target.blank?
create_list(board, type, target, position)
end
return ServiceResponse.error(message: list.errors.full_messages) unless list.persisted?
ServiceResponse.success(payload: { list: list })
end
private
@ -33,7 +37,7 @@ module Boards
def target(board)
strong_memoize(:target) do
available_labels.find(params[:label_id])
available_labels.find_by(id: params[:label_id]) # rubocop: disable CodeReuse/ActiveRecord
end
end

View File

@ -7,7 +7,11 @@ module Boards
return false unless board.lists.movable.empty?
List.transaction do
label_params.each { |params| create_list(board, params) }
label_params.each do |params|
response = create_list(board, params)
raise ActiveRecord::Rollback unless response.success?
end
end
true

View File

@ -2,7 +2,7 @@
.branch-commit.cgray
- if deployment.ref
%span.icon-container.gl-display-inline-block
= deployment.tag? ? icon('tag') : sprite_icon('fork', css_class: 'sprite')
= deployment.tag? ? sprite_icon('tag', css_class: 'sprite') : sprite_icon('fork', css_class: 'sprite')
= link_to deployment.ref, project_ref_path(@project, deployment.ref), class: "ref-name"
.icon-container.commit-icon
= custom_icon("icon_commit")

View File

@ -26,7 +26,7 @@
= render_if_exists "projects/pipelines/tabs_holder", pipeline: @pipeline, project: @project
.tab-content
#js-tab-pipeline.tab-pane.gl-absolute.gl-left-0.gl-w-full
#js-tab-pipeline.tab-pane.gl-w-full
#js-pipeline-graph-vue
#js-tab-builds.tab-pane

View File

@ -1610,6 +1610,14 @@
:weight: 1
:idempotent:
:tags: []
- :name: gitlab_performance_bar_stats
:feature_category: :metrics
:has_external_dependencies:
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
- :name: gitlab_shell
:feature_category: :source_code_management
:has_external_dependencies:

View File

@ -0,0 +1,34 @@
# frozen_string_literal: true
class GitlabPerformanceBarStatsWorker
include ApplicationWorker
LEASE_KEY = 'gitlab:performance_bar_stats'
LEASE_TIMEOUT = 600
WORKER_DELAY = 120
STATS_KEY = 'performance_bar_stats:pending_request_ids'
feature_category :metrics
idempotent!
def perform(lease_uuid)
Gitlab::Redis::SharedState.with do |redis|
request_ids = fetch_request_ids(redis, lease_uuid)
stats = Gitlab::PerformanceBar::Stats.new(redis)
request_ids.each do |id|
stats.process(id)
end
end
end
private
def fetch_request_ids(redis, lease_uuid)
ids = redis.smembers(STATS_KEY)
redis.del(STATS_KEY)
Gitlab::ExclusiveLease.cancel(LEASE_KEY, lease_uuid)
ids
end
end

View File

@ -0,0 +1,5 @@
---
title: Add iteration_id column to lists
merge_request: 48103
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Use a separate commit to store formatting changes in the Static Site Editor
merge_request: 49052
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Set vulnerability as dismissed when there is dismissal feedback
merge_request: 48795
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Remove references to cross_project_pipeline source in documentation
merge_request: 49579
author:
type: other

View File

@ -0,0 +1,5 @@
---
title: Add upcoming deployment column to Environments page
merge_request: 48062
author:
type: added

View File

@ -0,0 +1,8 @@
---
name: core_security_mr_widget_downloads
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48769
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/273418
milestone: '13.7'
type: development
group: group::static analysis
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: performance_bar_stats
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48149
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/285480
milestone: '13.7'
type: development
group: group::product_planning
default_enabled: false

View File

@ -142,6 +142,8 @@
- 1
- - github_importer
- 1
- - gitlab_performance_bar_stats
- 1
- - gitlab_shell
- 2
- - group_destroy

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddIterationIdToLists < ActiveRecord::Migration[6.0]
DOWNTIME = false
def change
add_column :lists, :iteration_id, :bigint
end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class AddIterationListsForeignKey < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
INDEX_NAME = 'index_lists_on_iteration_id'
disable_ddl_transaction!
def up
add_concurrent_index :lists, :iteration_id, name: INDEX_NAME
add_concurrent_foreign_key :lists, :sprints, column: :iteration_id, on_delete: :cascade
end
def down
remove_foreign_key_if_exists :lists, :sprints, column: :iteration_id
remove_concurrent_index_by_name :lists, INDEX_NAME
end
end

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
class SchedulePopulateDismissedStateForVulnerabilities < ActiveRecord::Migration[6.0]
include Gitlab::Database::MigrationHelpers
TMP_INDEX_NAME = 'tmp_index_on_vulnerabilities_non_dismissed'
DOWNTIME = false
BATCH_SIZE = 1_000
VULNERABILITY_BATCH_SIZE = 5_000
DELAY_INTERVAL = 3.minutes.to_i
MIGRATION_CLASS = 'PopulateDismissedStateForVulnerabilities'
VULNERABILITY_JOIN_CONDITION = 'JOIN "vulnerability_occurrences" ON "vulnerability_occurrences"."vulnerability_id" = "vulnerabilities"."id"'
FEEDBACK_WHERE_CONDITION = <<~SQL
EXISTS (SELECT 1 FROM vulnerability_feedback
WHERE "vulnerability_occurrences"."project_id" = "vulnerability_feedback"."project_id"
AND "vulnerability_occurrences"."report_type" = "vulnerability_feedback"."category"
AND ENCODE("vulnerability_occurrences"."project_fingerprint", 'hex') = "vulnerability_feedback"."project_fingerprint"
AND "vulnerability_feedback"."feedback_type" = 0
)
SQL
class Vulnerability < ActiveRecord::Base # rubocop:disable Style/Documentation
include EachBatch
self.table_name = 'vulnerabilities'
end
disable_ddl_transaction!
def up
add_concurrent_index(:vulnerabilities, :id, where: 'state <> 2', name: TMP_INDEX_NAME)
batch = []
index = 1
Vulnerability.where('state <> 2').each_batch(of: VULNERABILITY_BATCH_SIZE) do |relation|
ids = relation
.joins(VULNERABILITY_JOIN_CONDITION)
.where(FEEDBACK_WHERE_CONDITION)
.pluck('vulnerabilities.id')
ids.each do |id|
batch << id
if batch.size == BATCH_SIZE
migrate_in(index * DELAY_INTERVAL, MIGRATION_CLASS, batch)
index += 1
batch.clear
end
end
end
migrate_in(index * DELAY_INTERVAL, MIGRATION_CLASS, batch) unless batch.empty?
end
def down
remove_concurrent_index_by_name(:vulnerabilities, TMP_INDEX_NAME)
end
end

View File

@ -0,0 +1 @@
6d2e6937c9e41975b1fd402bf2985796792a1e5f8e4f4f98bc76b65ff73c4e02

View File

@ -0,0 +1 @@
c7567489156bbc047cf9f7827f060ad507fd5d328179f2796566a7dc54806e3e

View File

@ -0,0 +1 @@
27cd7e7cd01175c157e6aa666b2263bf29210277d5acd997a0619cee67870345

View File

@ -13652,7 +13652,8 @@ CREATE TABLE lists (
milestone_id integer,
max_issue_count integer DEFAULT 0 NOT NULL,
max_issue_weight integer DEFAULT 0 NOT NULL,
limit_metric character varying(20)
limit_metric character varying(20),
iteration_id bigint
);
CREATE SEQUENCE lists_id_seq
@ -21617,6 +21618,8 @@ CREATE UNIQUE INDEX index_list_user_preferences_on_user_id_and_list_id ON list_u
CREATE UNIQUE INDEX index_lists_on_board_id_and_label_id ON lists USING btree (board_id, label_id);
CREATE INDEX index_lists_on_iteration_id ON lists USING btree (iteration_id);
CREATE INDEX index_lists_on_label_id ON lists USING btree (label_id);
CREATE INDEX index_lists_on_list_type ON lists USING btree (list_type);
@ -22873,6 +22876,8 @@ CREATE INDEX tmp_build_stage_position_index ON ci_builds USING btree (stage_id,
CREATE INDEX tmp_index_for_email_unconfirmation_migration ON emails USING btree (id) WHERE (confirmed_at IS NOT NULL);
CREATE INDEX tmp_index_on_vulnerabilities_non_dismissed ON vulnerabilities USING btree (id) WHERE (state <> 2);
CREATE UNIQUE INDEX unique_merge_request_metrics_by_merge_request_id ON merge_request_metrics USING btree (merge_request_id);
CREATE UNIQUE INDEX vulnerability_feedback_unique_idx ON vulnerability_feedback USING btree (project_id, category, feedback_type, project_fingerprint);
@ -23263,6 +23268,9 @@ ALTER TABLE ONLY notes
ALTER TABLE ONLY members
ADD CONSTRAINT fk_2e88fb7ce9 FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY lists
ADD CONSTRAINT fk_30f2a831f4 FOREIGN KEY (iteration_id) REFERENCES sprints(id) ON DELETE CASCADE;
ALTER TABLE ONLY approvals
ADD CONSTRAINT fk_310d714958 FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE CASCADE;

View File

@ -998,6 +998,22 @@ For Omnibus GitLab installations, GitLab Exporter logs reside in `/var/log/gitla
For Omnibus GitLab installations, GitLab Kubernetes Agent Server logs reside
in `/var/log/gitlab/gitlab-kas/`.
## Performance bar stats
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48149) in GitLab 13.7.
This file lives in `/var/log/gitlab/gitlab-rails/performance_bar_json.log` for
Omnibus GitLab packages or in `/home/git/gitlab/log/performance_bar_json.log` for
installations from source.
Performance bar statistics (currently only duration of SQL queries) are recorded in that file. For example:
```json
{"severity":"INFO","time":"2020-12-04T09:29:44.592Z","correlation_id":"33680b1490ccd35981b03639c406a697","filename":"app/models/ci/pipeline.rb","filenum":"395","method":"each_with_object","request_id":"rYHomD0VJS4","duration_ms":26.889,"type": "sql"}
```
These statistics are logged on .com only, disabled on self-deployments.
## Gathering logs
When [troubleshooting](troubleshooting/index.md) issues that aren't localized to one of the

View File

@ -104,7 +104,7 @@ Kubernetes-specific environment variables are detailed in the
| `CI_PAGES_URL` | 11.8 | all | URL to GitLab Pages-built pages. Always belongs to a subdomain of `CI_PAGES_DOMAIN`. |
| `CI_PIPELINE_ID` | 8.10 | all | The instance-level ID of the current pipeline. This is a unique ID across all projects on GitLab. |
| `CI_PIPELINE_IID` | 11.0 | all | The project-level IID (internal ID) of the current pipeline. This ID is unique for the current project. |
| `CI_PIPELINE_SOURCE` | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `schedule`, `api`, `external`, `chat`, `webide`, `merge_request_event`, `external_pull_request_event`, `parent_pipeline`, [`trigger`, or `pipeline`](../triggers/README.md#authentication-tokens) (renamed to `cross_project_pipeline` since 13.0). For pipelines created before GitLab 9.5, this is displayed as `unknown`. |
| `CI_PIPELINE_SOURCE` | 10.0 | all | Indicates how the pipeline was triggered. Possible options are: `push`, `web`, `schedule`, `api`, `external`, `chat`, `webide`, `merge_request_event`, `external_pull_request_event`, `parent_pipeline`, [`trigger`, or `pipeline`](../triggers/README.md#authentication-tokens). For pipelines created before GitLab 9.5, this is displayed as `unknown`. |
| `CI_PIPELINE_TRIGGERED` | all | all | The flag to indicate that job was [triggered](../triggers/README.md) |
| `CI_PIPELINE_URL` | 11.1 | 0.5 | Pipeline details URL |
| `CI_PROJECT_DIR` | all | all | The full path where the repository is cloned and where the job is run. If the GitLab Runner `builds_dir` parameter is set, this variable is set relative to the value of `builds_dir`. For more information, see [Advanced configuration](https://docs.gitlab.com/runner/configuration/advanced-configuration.html#the-runners-section) for GitLab Runner. |

View File

@ -840,11 +840,22 @@ Read more about [configuring NFS mounts](../administration/nfs.md)
### Restore for installation from source
First, ensure your backup tar file is in the backup directory described in the
`gitlab.yml` configuration:
```yaml
## Backup settings
backup:
path: "tmp/backups" # Relative paths are relative to Rails.root (default: tmp/backups/)
```
The default is `/home/git/gitlab/tmp/backups`, and it needs to be owned by the `git` user. Now, you can begin the backup procedure:
```shell
# Stop processes that are connected to the database
sudo service gitlab stop
bundle exec rake gitlab:backup:restore RAILS_ENV=production
sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production
```
Example output:

View File

@ -53,10 +53,15 @@ click of a button:
![Static Site Editor](img/wysiwyg_editor_v13_3.png)
When an editor submits their changes, in the background, GitLab automatically
creates a new branch, commits their changes, and opens a merge request. The
editor lands directly on the merge request, and then they can assign it to
a colleague for review.
When an editor submits their changes, these are the following actions that GitLab
performs automatically in the background:
1. Creates a new branch.
1. Commits their changes.
1. Fixes formatting according to the [Handbook Markdown Style Guide](https://about.gitlab.com/handbook/markdown-guide/) style guide and add them through another commit.
1. Opens a merge request against the default branch.
The editor can then navigate to the merge request to assign it to a colleague for review.
## Set up your project

View File

@ -117,8 +117,6 @@ module API
use :list_creation_params
end
post '/lists' do
authorize_list_type_resource!
authorize!(:admin_list, user_project)
create_list

View File

@ -47,12 +47,12 @@ module API
create_list_service =
::Boards::Lists::CreateService.new(board_parent, current_user, create_list_params)
list = create_list_service.execute(board)
response = create_list_service.execute(board)
if list.valid?
present list, with: Entities::List
if response.success?
present response.payload[:list], with: Entities::List
else
render_validation_error!(list)
render_api_error!({ error: response.errors.first }, 400)
end
end
@ -80,14 +80,6 @@ module API
end
end
# rubocop: disable CodeReuse/ActiveRecord
def authorize_list_type_resource!
unless available_labels_for(board_parent).exists?(params[:label_id])
render_api_error!({ error: 'Label not found!' }, 400)
end
end
# rubocop: enable CodeReuse/ActiveRecord
params :list_creation_params do
requires :label_id, type: Integer, desc: 'The ID of an existing label'
end

View File

@ -27,6 +27,7 @@ module API
expose(:downvotes) { |merge_request, options| issuable_metadata.downvotes }
expose :author, :assignees, :assignee, using: Entities::UserBasic
expose :reviewers, if: -> (merge_request, _) { merge_request.allows_reviewers? }, using: Entities::UserBasic
expose :source_project_id, :target_project_id
expose :labels do |merge_request, options|
if options[:with_labels_details]

View File

@ -83,8 +83,6 @@ module API
use :list_creation_params
end
post '/lists' do
authorize_list_type_resource!
authorize!(:admin_list, user_group)
create_list

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
module Gitlab
module BackgroundMigration
# This class updates vulnerabilities entities with state dismissed
class PopulateDismissedStateForVulnerabilities
class Vulnerability < ActiveRecord::Base # rubocop:disable Style/Documentation
self.table_name = 'vulnerabilities'
end
def perform(*vulnerability_ids)
Vulnerability.where(id: vulnerability_ids).update_all(state: 2)
PopulateMissingVulnerabilityDismissalInformation.new.perform(*vulnerability_ids)
end
end
end
end

View File

@ -26,13 +26,16 @@ module Gitlab
class Finding < ActiveRecord::Base # rubocop:disable Style/Documentation
include ShaAttribute
include ::Gitlab::Utils::StrongMemoize
self.table_name = 'vulnerability_occurrences'
sha_attribute :project_fingerprint
def dismissal_feedback
Feedback.dismissal.where(category: report_type, project_fingerprint: project_fingerprint, project_id: project_id).first
strong_memoize(:dismissal_feedback) do
Feedback.dismissal.where(category: report_type, project_fingerprint: project_fingerprint, project_id: project_id).first
end
end
end

View File

@ -1,57 +0,0 @@
#
# PLEASE DO NOT ADD NEW STRINGS TO THIS FILE.
#
# See https://docs.gitlab.com/ee/development/i18n/externalization.html#html
# for information on how to handle HTML in translations.
#
# This file contains strings that need to be fixed to use the
# updated HTML guidelines. Any strings in this file will no
# longer be able to be translated until they have been updated.
#
# This file (and the functionality around it) will be removed
# once https://gitlab.com/gitlab-org/gitlab/-/issues/217933 is complete.
#
# See https://gitlab.com/gitlab-org/gitlab/-/issues/19485 for more details
# why this change has been made.
#
#
# Strings below are fixed in the source code but the translations are still present in CrowdIn so the
# locale files will fail the linter. They can be deleted after next CrowdIn sync, likely in:
# https://gitlab.com/gitlab-org/gitlab/-/issues/226008
#
"This commit was signed with an <strong>unverified</strong> signature.":
plural_id:
translations:
- "このコミットは<strong>検証されていない</strong> 署名でサインされています。"
- "Этот коммит был подписан <strong>непроверенной</strong> подписью."
- "此提交使用 <strong>未经验证的</strong> 签名进行签名。"
- "Цей коміт підписано <strong>неперевіреним</strong> підписом."
- "Esta commit fue firmado con una firma <strong>no verificada</strong>."
"This commit was signed with a <strong>verified</strong> signature and the committer email is verified to belong to the same user.":
plural_id:
translations:
- "このコミットは <strong>検証済み</strong> の署名でサインされており、このコミッターのメールは同じユーザーのものであることが検証されています。"
- "Это коммит был подписан <strong>верифицированной</strong> подписью и коммитер подтвердил, что адрес почты принадлежит ему."
- "此提交使用 <strong>已验证</strong> 的签名进行签名,并且已验证提交者的电子邮件属于同一用户。"
- "Цей коміт підписано <strong>перевіреним</strong> підписом і адреса електронної пошти комітера гарантовано належить тому самому користувачу."
- "Este commit fue firmado con una firma verificada, y <strong>se ha verificado</strong> que la dirección de correo electrónico del committer y la firma pertenecen al mismo usuario."
"Branch <strong>%{branch_name}</strong> was created. To set up auto deploy, choose a GitLab CI Yaml template and commit your changes. %{link_to_autodeploy_doc}":
plural_id:
translations:
- "分支 <strong>%{branch_name}</strong> 已創建。如需設置自動部署, 請選擇合適的 GitLab CI Yaml 模板併提交更改。%{link_to_autodeploy_doc}"
- "O branch <strong>%{branch_name}</strong> foi criado. Para configurar o deploy automático, selecione um modelo de Yaml do GitLab CI e commit suas mudanças. %{link_to_autodeploy_doc}"
- "<strong>%{branch_name}</strong> ブランチが作成されました。自動デプロイを設定するには、GitLab CI Yaml テンプレートを選択して、変更をコミットしてください。 %{link_to_autodeploy_doc}"
- "La branch <strong>%{branch_name}</strong> è stata creata. Per impostare un rilascio automatico scegli un template CI di Gitlab e committa le tue modifiche %{link_to_autodeploy_doc}"
- "O ramo <strong>%{branch_name}</strong> foi criado. Para configurar a implantação automática, seleciona um modelo de Yaml do GitLab CI e envia as tuas alterações. %{link_to_autodeploy_doc}"
- "Ветка <strong>%{branch_name}</strong> создана. Для настройки автоматического развертывания выберите YAML-шаблон для GitLab CI и зафиксируйте свои изменения. %{link_to_autodeploy_doc}"
- "已创建分支 <strong>%{branch_name}</strong> 。如需设置自动部署, 请选择合适的 GitLab CI Yaml 模板并提交更改。%{link_to_autodeploy_doc}"
- "Гілка <strong>%{branch_name}</strong> створена. Для настройки автоматичного розгортання виберіть GitLab CI Yaml-шаблон і закомітьте зміни. %{link_to_autodeploy_doc}"
- "Клонът <strong>%{branch_name}</strong> беше създаден. За да настроите автоматичното внедряване, изберете Yaml шаблон за GitLab CI и подайте промените си. %{link_to_autodeploy_doc}"
- "Branch <strong>%{branch_name}</strong> wurde erstellt. Um die automatische Bereitstellung einzurichten, wähle eine GitLab CI Yaml Vorlage und committe deine Änderungen. %{link_to_autodeploy_doc}"
- "<strong>%{branch_name}</strong> 브랜치가 생성되었습니다. 자동 배포를 설정하려면 GitLab CI Yaml 템플릿을 선택하고 변경 사항을 적용하십시오. %{link_to_autodeploy_doc}"
- "La branĉo <strong>%{branch_name}</strong> estis kreita. Por agordi aŭtomatan disponigadon, bonvolu elekti Yaml-ŝablonon por GitLab CI kaj enmeti viajn ŝanĝojn. %{link_to_autodeploy_doc}"
- "La branche <strong>%{branch_name}</strong> a été créée. Pour mettre en place le déploiement automatisé, sélectionnez un modèle de fichier YAML pour lintégration continue (CI) de GitLab, et validez les modifications. %{link_to_autodeploy_doc}"
- "La rama <strong>%{branch_name}</strong> fue creada. Para configurar el auto despliegue, escoge una plantilla Yaml para GitLab CI y envía tus cambios. %{link_to_autodeploy_doc}"

View File

@ -5,14 +5,13 @@ module Gitlab
class PoLinter
include Gitlab::Utils::StrongMemoize
attr_reader :po_path, :translation_entries, :metadata_entry, :locale, :html_todolist
attr_reader :po_path, :translation_entries, :metadata_entry, :locale
VARIABLE_REGEX = /%{\w*}|%[a-z]/.freeze
def initialize(po_path:, html_todolist:, locale: I18n.locale.to_s)
def initialize(po_path:, locale: I18n.locale.to_s)
@po_path = po_path
@locale = locale
@html_todolist = html_todolist
end
def errors
@ -43,8 +42,7 @@ module Gitlab
@translation_entries = entries.map do |entry_data|
Gitlab::I18n::TranslationEntry.new(
entry_data: entry_data,
nplurals: metadata_entry.expected_forms,
html_allowed: html_todolist.fetch(entry_data[:msgid], false)
nplurals: metadata_entry.expected_forms
)
end
@ -97,15 +95,15 @@ module Gitlab
common_message = 'contains < or >. Use variables to include HTML in the string, or the &lt; and &gt; codes ' \
'for the symbols. For more info see: https://docs.gitlab.com/ee/development/i18n/externalization.html#html'
if entry.msgid_contains_potential_html? && !entry.msgid_html_allowed?
if entry.msgid_contains_potential_html?
errors << common_message
end
if entry.plural_id_contains_potential_html? && !entry.plural_id_html_allowed?
if entry.plural_id_contains_potential_html?
errors << 'plural id ' + common_message
end
if entry.translations_contain_potential_html? && !entry.translations_html_allowed?
if entry.translations_contain_potential_html?
errors << 'translation ' + common_message
end
end

View File

@ -6,12 +6,11 @@ module Gitlab
PERCENT_REGEX = /(?:^|[^%])%(?!{\w*}|[a-z%])/.freeze
ANGLE_BRACKET_REGEX = /[<>]/.freeze
attr_reader :nplurals, :entry_data, :html_allowed
attr_reader :nplurals, :entry_data
def initialize(entry_data:, nplurals:, html_allowed:)
def initialize(entry_data:, nplurals:)
@entry_data = entry_data
@nplurals = nplurals
@html_allowed = html_allowed
end
def msgid
@ -97,20 +96,6 @@ module Gitlab
all_translations.any? { |translation| contains_angle_brackets?(translation) }
end
def msgid_html_allowed?
html_allowed.present?
end
def plural_id_html_allowed?
html_allowed.present? && html_allowed['plural_id'] == plural_id
end
def translations_html_allowed?
msgid_html_allowed? && html_allowed['translations'].present? && all_translations.all? do |translation|
html_allowed['translations'].include?(translation)
end
end
private
def contains_angle_brackets?(string)

View File

@ -347,6 +347,7 @@ excluded_attributes:
- :board_id
- :label_id
- :milestone_id
- :iteration_id
epic:
- :start_date_sourcing_milestone_id
- :due_date_sourcing_milestone_id

View File

@ -0,0 +1,11 @@
# frozen_string_literal: true
module Gitlab
module PerformanceBar
class Logger < ::Gitlab::JsonLogger
def self.file_name_noext
'performance_bar_json'
end
end
end
end

View File

@ -5,7 +5,33 @@ module Gitlab
module PerformanceBar
module RedisAdapterWhenPeekEnabled
def save(request_id)
super if ::Gitlab::PerformanceBar.enabled_for_request?
return unless ::Gitlab::PerformanceBar.enabled_for_request?
return if request_id.blank?
super
enqueue_stats_job(request_id)
end
# schedules a job which parses peek profile data and adds them
# to a structured log
def enqueue_stats_job(request_id)
return unless gather_stats?
@client.sadd(GitlabPerformanceBarStatsWorker::STATS_KEY, request_id) # rubocop:disable Gitlab/ModuleWithInstanceVariables
return unless uuid = Gitlab::ExclusiveLease.new(
GitlabPerformanceBarStatsWorker::LEASE_KEY,
timeout: GitlabPerformanceBarStatsWorker::LEASE_TIMEOUT
).try_obtain
GitlabPerformanceBarStatsWorker.perform_in(GitlabPerformanceBarStatsWorker::WORKER_DELAY, uuid)
end
def gather_stats?
return unless Feature.enabled?(:performance_bar_stats)
Gitlab.com? || !Rails.env.production?
end
end
end

View File

@ -0,0 +1,60 @@
# frozen_string_literal: true
module Gitlab
module PerformanceBar
# This class fetches Peek stats stored in redis and logs them in a
# structured log (so these can be then analyzed in Kibana)
class Stats
def initialize(redis)
@redis = redis
end
def process(id)
data = request(id)
return unless data
log_sql_queries(id, data)
rescue => err
logger.error(message: "failed to process request id #{id}: #{err.message}")
end
private
def request(id)
# Peek gem stores request data under peek:requests:request_id key
json_data = @redis.get("peek:requests:#{id}")
Gitlab::Json.parse(json_data)
end
def log_sql_queries(id, data)
return [] unless queries = data.dig('data', 'active-record', 'details')
queries.each do |query|
next unless location = parse_backtrace(query['backtrace'])
log_info = location.merge(
type: :sql,
request_id: id,
duration_ms: query['duration'].to_f
)
logger.info(log_info)
end
end
def parse_backtrace(backtrace)
return unless match = /(?<filename>.*):(?<filenum>\d+):in `(?<method>.*)'/.match(backtrace.first)
{
filename: match[:filename],
filenum: match[:filenum].to_i,
method: match[:method]
}
end
def logger
@logger ||= Gitlab::PerformanceBar::Logger.build
end
end
end
end

View File

@ -65,10 +65,10 @@ namespace :gettext do
linters = files.map do |file|
locale = File.basename(File.dirname(file))
Gitlab::I18n::PoLinter.new(po_path: file, html_todolist: html_todolist, locale: locale)
Gitlab::I18n::PoLinter.new(po_path: file, locale: locale)
end
linters.unshift(Gitlab::I18n::PoLinter.new(po_path: pot_file_path, html_todolist: html_todolist))
linters.unshift(Gitlab::I18n::PoLinter.new(po_path: pot_file_path))
failed_linters = linters.select { |linter| linter.errors.any? }

File diff suppressed because it is too large Load Diff

View File

@ -371,6 +371,9 @@ msgstr ""
msgid "%{authorsName}'s thread"
msgstr ""
msgid "%{board_target} not found"
msgstr ""
msgid "%{code_open}Masked%{code_close} variables are hidden in job logs (though they must match certain regexp requirements to do so)."
msgstr ""
@ -9640,6 +9643,9 @@ msgstr ""
msgid "DevopsAdoption|Feature adoption is based on usage in the last calendar month. Last updated: %{timestamp}."
msgstr ""
msgid "DevopsAdoption|Filter by name"
msgstr ""
msgid "DevopsAdoption|Issues"
msgstr ""
@ -9655,6 +9661,9 @@ msgstr ""
msgid "DevopsAdoption|New segment"
msgstr ""
msgid "DevopsAdoption|No filter results."
msgstr ""
msgid "DevopsAdoption|Pipelines"
msgstr ""
@ -10677,6 +10686,9 @@ msgstr ""
msgid "Environments|Deployment"
msgstr ""
msgid "Environments|Deployment %{status}"
msgstr ""
msgid "Environments|Enable review app"
msgstr ""
@ -10794,6 +10806,12 @@ msgstr ""
msgid "Environments|This action will run the job defined by %{name} for commit %{linkStart}%{commitId}%{linkEnd} putting the environment in a previous version. You can revert it by re-deploying the latest version of your application. Are you sure you want to continue?"
msgstr ""
msgid "Environments|Upcoming"
msgstr ""
msgid "Environments|Upcoming deployment"
msgstr ""
msgid "Environments|Updated"
msgstr ""
@ -15399,6 +15417,9 @@ msgstr ""
msgid "Iteration changed to"
msgstr ""
msgid "Iteration lists not available with your current license"
msgstr ""
msgid "Iteration removed"
msgstr ""
@ -24383,9 +24404,15 @@ msgstr ""
msgid "SecurityReports|Dismissed '%{vulnerabilityName}'. Turn off the hide dismissed toggle to view."
msgstr ""
msgid "SecurityReports|Download %{artifactName}"
msgstr ""
msgid "SecurityReports|Download Report"
msgstr ""
msgid "SecurityReports|Download results"
msgstr ""
msgid "SecurityReports|Either you don't have permission to view this dashboard or the dashboard has not been setup. Please check your permission settings with your administrator or check your dashboard configurations to proceed."
msgstr ""
@ -24470,6 +24497,9 @@ msgstr ""
msgid "SecurityReports|Security reports help page link"
msgstr ""
msgid "SecurityReports|Security scans have run"
msgstr ""
msgid "SecurityReports|Security scans have run. Go to the %{linkStart}pipelines tab%{linkEnd} to download the security reports"
msgstr ""
@ -26204,6 +26234,9 @@ msgstr ""
msgid "StaticSiteEditor|An error occurred while submitting your changes."
msgstr ""
msgid "StaticSiteEditor|Automatic formatting changes"
msgstr ""
msgid "StaticSiteEditor|Branch could not be created."
msgstr ""
@ -26222,6 +26255,9 @@ msgstr ""
msgid "StaticSiteEditor|Incompatible file content"
msgstr ""
msgid "StaticSiteEditor|Markdown formatting preferences introduced by the Static Site Editor"
msgstr ""
msgid "StaticSiteEditor|Return to site"
msgstr ""

View File

@ -20,6 +20,7 @@ class JobFinder
@job_query = options.delete(:job_query)
@pipeline_id = options.delete(:pipeline_id)
@job_name = options.delete(:job_name)
@artifact_path = options.delete(:artifact_path)
# Force the token to be a string so that if api_token is nil, it's set to '', allowing unauthenticated requests (for forks).
api_token = options.delete(:api_token).to_s
@ -33,19 +34,31 @@ class JobFinder
end
def execute
find_job_with_filtered_pipelines || find_job_in_pipeline
find_job_with_artifact || find_job_with_filtered_pipelines || find_job_in_pipeline
end
private
attr_reader :project, :pipeline_query, :job_query, :pipeline_id, :job_name
attr_reader :project, :pipeline_query, :job_query, :pipeline_id, :job_name, :artifact_path
def find_job_with_artifact
return if artifact_path.nil?
Gitlab.pipelines(project, pipeline_query_params).auto_paginate do |pipeline|
Gitlab.pipeline_jobs(project, pipeline.id, job_query_params).auto_paginate do |job|
return job if found_job_with_artifact?(job) # rubocop:disable Cop/AvoidReturnFromBlocks
end
end
raise 'Job not found!'
end
def find_job_with_filtered_pipelines
return if pipeline_query.empty?
Gitlab.pipelines(project, pipeline_query_params).auto_paginate do |pipeline|
Gitlab.pipeline_jobs(project, pipeline.id, job_query_params).auto_paginate do |job|
return job if job.name == job_name # rubocop:disable Cop/AvoidReturnFromBlocks
return job if found_job_by_name?(job) # rubocop:disable Cop/AvoidReturnFromBlocks
end
end
@ -56,12 +69,22 @@ class JobFinder
return unless pipeline_id
Gitlab.pipeline_jobs(project, pipeline_id, job_query_params).auto_paginate do |job|
return job if job.name == job_name # rubocop:disable Cop/AvoidReturnFromBlocks
return job if found_job_by_name?(job) # rubocop:disable Cop/AvoidReturnFromBlocks
end
raise 'Job not found!'
end
def found_job_with_artifact?(job)
artifact_url = "https://gitlab.com/api/v4/projects/#{CGI.escape(project)}/jobs/#{job.id}/artifacts/#{artifact_path}"
response = HTTParty.head(artifact_url) # rubocop:disable Gitlab/HTTParty
response.success?
end
def found_job_by_name?(job)
job.name == job_name
end
def pipeline_query_params
@pipeline_query_params ||= { per_page: 100, **pipeline_query }
end
@ -95,6 +118,10 @@ if $0 == __FILE__
options[:job_name] = value
end
opts.on("-a", "--artifact-path ARTIFACT_PATH", String, "A valid artifact path") do |value|
options[:artifact_path] = value
end
opts.on("-t", "--api-token API_TOKEN", String, "A value API token with the `read_api` scope") do |value|
options[:api_token] = value
end

View File

@ -1,7 +1,7 @@
#!/usr/bin/env bash
function retrieve_tests_metadata() {
mkdir -p crystalball/ knapsack/ rspec_flaky/ rspec_profiling/
mkdir -p knapsack/ rspec_flaky/ rspec_profiling/
local project_path="gitlab-org/gitlab"
local test_metadata_job_id
@ -16,13 +16,6 @@ function retrieve_tests_metadata() {
if [[ ! -f "${FLAKY_RSPEC_SUITE_REPORT_PATH}" ]]; then
scripts/api/download_job_artifact --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${FLAKY_RSPEC_SUITE_REPORT_PATH}" || echo "{}" > "${FLAKY_RSPEC_SUITE_REPORT_PATH}"
fi
# FIXME: We will need to find a pipeline where the $RSPEC_PACKED_TESTS_MAPPING_PATH.gz actually exists (Crystalball only runs every two-hours, but the `update-tests-metadata` runs for all `master` pipelines...).
# if [[ ! -f "${RSPEC_PACKED_TESTS_MAPPING_PATH}" ]]; then
# (scripts/api/download_job_artifact --project "${project_path}" --job-id "${test_metadata_job_id}" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" && gzip -d "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") || echo "{}" > "${RSPEC_PACKED_TESTS_MAPPING_PATH}"
# fi
#
# scripts/unpack-test-mapping "${RSPEC_PACKED_TESTS_MAPPING_PATH}" "${RSPEC_TESTS_MAPPING_PATH}"
}
function update_tests_metadata() {
@ -43,6 +36,21 @@ function update_tests_metadata() {
fi
}
function retrieve_tests_mapping() {
mkdir -p crystalball/
local project_path="gitlab-org/gitlab"
local test_metadata_with_mapping_job_id
test_metadata_with_mapping_job_id=$(scripts/api/get_job_id --project "${project_path}" -q "status=success" -q "ref=master" -q "username=gitlab-bot" -Q "scope=success" --job-name "update-tests-metadata" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz")
if [[ ! -f "${RSPEC_PACKED_TESTS_MAPPING_PATH}" ]]; then
(scripts/api/download_job_artifact --project "${project_path}" --job-id "${test_metadata_with_mapping_job_id}" --artifact-path "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz" && gzip -d "${RSPEC_PACKED_TESTS_MAPPING_PATH}.gz") || echo "{}" > "${RSPEC_PACKED_TESTS_MAPPING_PATH}"
fi
scripts/unpack-test-mapping "${RSPEC_PACKED_TESTS_MAPPING_PATH}" "${RSPEC_TESTS_MAPPING_PATH}"
}
function update_tests_mapping() {
if ! crystalball_rspec_data_exists; then
echo "No crystalball rspec data found."
@ -119,8 +127,8 @@ function rspec_paralellized_job() {
local rspec_args="-Ispec -rspec_helper --color --format documentation --format RspecJunitFormatter --out junit_rspec.xml ${rspec_opts}"
if [[ -n $RSPEC_MATCHING_TESTS_ENABLED ]]; then
tooling/bin/parallel_rspec --rspec_args "${rspec_args}" --filter tmp/matching_tests.txt
if [[ -n $RSPEC_TESTS_MAPPING_ENABLED ]]; then
tooling/bin/parallel_rspec --rspec_args "${rspec_args}" --filter "tmp/matching_tests.txt"
else
tooling/bin/parallel_rspec --rspec_args "${rspec_args}"
fi

View File

@ -36,7 +36,7 @@ function install_gitlab_gem() {
}
function install_tff_gem() {
gem install test_file_finder --version 0.1.0
gem install test_file_finder --version 0.1.1
}
function run_timed_command() {

View File

@ -85,20 +85,22 @@ RSpec.describe Boards::ListsController do
context 'with invalid params' do
context 'when label is nil' do
it 'returns a not found 404 response' do
it 'returns an unprocessable entity 422 response' do
create_board_list user: user, board: board, label_id: nil
expect(response).to have_gitlab_http_status(:not_found)
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['errors']).to eq(['Label not found'])
end
end
context 'when label that does not belongs to project' do
it 'returns a not found 404 response' do
it 'returns an unprocessable entity 422 response' do
label = create(:label, name: 'Development')
create_board_list user: user, board: board, label_id: label.id
expect(response).to have_gitlab_http_status(:not_found)
expect(response).to have_gitlab_http_status(:unprocessable_entity)
expect(json_response['errors']).to eq(['Label not found'])
end
end
end

View File

@ -24,6 +24,10 @@ RSpec.describe 'Environments page', :js do
'button[title="Stop environment"]'
end
def upcoming_deployment_content_selector
'[data-testid="upcoming-deployment-content"]'
end
describe 'page tabs' do
it 'shows "Available" and "Stopped" tab with links' do
visit_environments(project)
@ -362,6 +366,26 @@ RSpec.describe 'Environments page', :js do
expect(page).to have_content('No deployments yet')
end
end
context 'when there is an upcoming deployment' do
let_it_be(:project) { create(:project, :repository) }
let!(:deployment) do
create(:deployment, :running,
environment: environment,
sha: project.commit.id)
end
it "renders the upcoming deployment", :aggregate_failures do
visit_environments(project)
within(upcoming_deployment_content_selector) do
expect(page).to have_content("##{deployment.iid}")
expect(page).to have_selector("a[href=\"#{project_job_path(project, deployment.deployable)}\"]")
expect(page).to have_link(href: /#{deployment.user.username}/)
end
end
end
end
it 'does have a new environment button' do

View File

@ -0,0 +1,104 @@
{
"context": {},
"data": {
"host": {
"hostname": "pc",
"canary": null
},
"active-record": {
"duration": "6ms",
"calls": "7 (0 cached)",
"details": [
{
"duration": 1.096,
"sql": "SELECT COUNT(*) FROM ((SELECT \"badges\".* FROM \"badges\" WHERE \"badges\".\"type\" = 'ProjectBadge' AND \"badges\".\"project_id\" = 8)\nUNION\n(SELECT \"badges\".* FROM \"badges\" WHERE \"badges\".\"type\" = 'GroupBadge' AND \"badges\".\"group_id\" IN (SELECT \"namespaces\".\"id\" FROM \"namespaces\" WHERE \"namespaces\".\"type\" = 'Group' AND \"namespaces\".\"id\" = 28))) badges",
"backtrace": [
"lib/gitlab/pagination/offset_pagination.rb:53:in `add_pagination_headers'",
"lib/gitlab/pagination/offset_pagination.rb:15:in `block in paginate'",
"lib/gitlab/pagination/offset_pagination.rb:14:in `tap'",
"lib/gitlab/pagination/offset_pagination.rb:14:in `paginate'",
"lib/api/helpers/pagination.rb:7:in `paginate'",
"lib/api/badges.rb:42:in `block (3 levels) in <class:Badges>'",
"ee/lib/gitlab/ip_address_state.rb:10:in `with'",
"lib/api/api_guard.rb:208:in `call'",
"lib/gitlab/jira/middleware.rb:19:in `call'"
],
"cached": "",
"warnings": []
},
{
"duration": 0.817,
"sql": "SELECT \"projects\".* FROM \"projects\" WHERE \"projects\".\"pending_delete\" = $1 AND \"projects\".\"id\" = $2 LIMIT $3",
"backtrace": [
"lib/api/helpers.rb:112:in `find_project'",
"ee/lib/ee/api/helpers.rb:88:in `find_project!'",
"lib/api/helpers/members_helpers.rb:14:in `public_send'",
"lib/api/helpers/members_helpers.rb:14:in `find_source'",
"lib/api/badges.rb:36:in `block (3 levels) in <class:Badges>'",
"ee/lib/gitlab/ip_address_state.rb:10:in `with'",
"lib/api/api_guard.rb:208:in `call'",
"lib/gitlab/jira/middleware.rb:19:in `call'"
],
"cached": "",
"warnings": []
},
{
"duration": 0.817,
"sql": "SELECT \"projects\".* FROM \"projects\" WHERE \"projects\".\"pending_delete\" = $1 AND \"projects\".\"id\" = $2 LIMIT $3",
"backtrace": [
"lib/api/helpers.rb:112:in `find_project'",
"ee/lib/ee/api/helpers.rb:88:in `find_project!'",
"lib/api/helpers/members_helpers.rb:14:in `public_send'",
"lib/api/helpers/members_helpers.rb:14:in `find_source'",
"lib/api/badges.rb:36:in `block (3 levels) in <class:Badges>'",
"ee/lib/gitlab/ip_address_state.rb:10:in `with'",
"lib/api/api_guard.rb:208:in `call'",
"lib/gitlab/jira/middleware.rb:19:in `call'"
],
"cached": "",
"warnings": []
}
],
"warnings": []
},
"gitaly": {
"duration": "0ms",
"calls": 0,
"details": [],
"warnings": []
},
"redis": {
"duration": "0ms",
"calls": 1,
"details": [
{
"cmd": "get cache:gitlab:flipper/v1/feature/api_kaminari_count_with_limit",
"duration": 0.155,
"backtrace": [
"lib/gitlab/instrumentation/redis_interceptor.rb:30:in `call'",
"lib/feature.rb:81:in `enabled?'",
"lib/gitlab/pagination/offset_pagination.rb:30:in `paginate_with_limit_optimization'",
"lib/gitlab/pagination/offset_pagination.rb:14:in `paginate'",
"lib/api/helpers/pagination.rb:7:in `paginate'",
"lib/api/badges.rb:42:in `block (3 levels) in <class:Badges>'",
"ee/lib/gitlab/ip_address_state.rb:10:in `with'",
"lib/api/api_guard.rb:208:in `call'",
"lib/gitlab/jira/middleware.rb:19:in `call'"
],
"storage": "Cache",
"warnings": [],
"instance": "Cache"
}
],
"warnings": []
},
"es": {
"duration": "0ms",
"calls": 0,
"details": [],
"warnings": []
}
},
"has_warnings": false
}

View File

@ -1,3 +1,12 @@
export const mockEditorApi = {
eventManager: {
addEventType: jest.fn(),
listen: jest.fn(),
removeEventHandler: jest.fn(),
},
getMarkdown: jest.fn(),
};
export const Editor = {
props: {
initialValue: {
@ -18,14 +27,6 @@ export const Editor = {
},
},
created() {
const mockEditorApi = {
eventManager: {
addEventType: jest.fn(),
listen: jest.fn(),
removeEventHandler: jest.fn(),
},
};
this.$emit('load', mockEditorApi);
},
render(h) {

View File

@ -1,3 +1,4 @@
import { cloneDeep } from 'lodash';
import { mount } from '@vue/test-utils';
import { format } from 'timeago.js';
import EnvironmentItem from '~/environments/components/environment_item.vue';
@ -30,6 +31,11 @@ describe('Environment item', () => {
});
const findAutoStop = () => wrapper.find('.js-auto-stop');
const findUpcomingDeployment = () => wrapper.find('[data-testid="upcoming-deployment"]');
const findUpcomingDeploymentContent = () =>
wrapper.find('[data-testid="upcoming-deployment-content"]');
const findUpcomingDeploymentStatusLink = () =>
wrapper.find('[data-testid="upcoming-deployment-status-link"]');
afterEach(() => {
wrapper.destroy();
@ -87,6 +93,72 @@ describe('Environment item', () => {
});
});
describe('When the envionment has an upcoming deployment', () => {
describe('When the upcoming deployment has a deployable', () => {
it('should render the build ID and user', () => {
expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText(
'#27 by upcoming-username',
);
});
it('should render a status icon with a link and tooltip', () => {
expect(findUpcomingDeploymentStatusLink().exists()).toBe(true);
expect(findUpcomingDeploymentStatusLink().attributes().href).toBe(
'/root/environment-test/-/jobs/892',
);
expect(findUpcomingDeploymentStatusLink().attributes().title).toBe(
'Deployment running',
);
});
});
describe('When the deployment does not have a deployable', () => {
beforeEach(() => {
const environmentWithoutDeployable = cloneDeep(environment);
delete environmentWithoutDeployable.upcoming_deployment.deployable;
factory({
propsData: {
model: environmentWithoutDeployable,
canReadEnvironment: true,
tableData,
},
});
});
it('should still renders the build ID and user', () => {
expect(findUpcomingDeploymentContent().text()).toMatchInterpolatedText(
'#27 by upcoming-username',
);
});
it('should not render the status icon', () => {
expect(findUpcomingDeploymentStatusLink().exists()).toBe(false);
});
});
});
describe('Without upcoming deployment', () => {
beforeEach(() => {
const environmentWithoutUpcomingDeployment = cloneDeep(environment);
delete environmentWithoutUpcomingDeployment.upcoming_deployment;
factory({
propsData: {
model: environmentWithoutUpcomingDeployment,
canReadEnvironment: true,
tableData,
},
});
});
it('should not render anything in the upcoming deployment column', () => {
expect(findUpcomingDeploymentContent().exists()).toBe(false);
});
});
describe('Without auto-stop date', () => {
beforeEach(() => {
factory({
@ -209,6 +281,10 @@ describe('Environment item', () => {
it('should render the number of children in a badge', () => {
expect(wrapper.find('.folder-name .badge').text()).toContain(folder.size);
});
it('should not render the "Upcoming deployment" column', () => {
expect(findUpcomingDeployment().exists()).toBe(false);
});
});
describe('When environment can be deleted', () => {

View File

@ -86,6 +86,98 @@ const environment = {
],
deployed_at: '2016-11-29T18:11:58.430Z',
},
upcoming_deployment: {
id: 82,
iid: 27,
sha: '1132df044b73943943c949e7ac2c2f120a89bf59',
ref: {
name: 'master',
ref_path: '/root/environment-test/-/tree/master',
},
status: 'running',
created_at: '2020-12-04T19:57:49.514Z',
deployed_at: null,
tag: false,
'last?': false,
user: {
id: 1,
name: 'Upcoming Name',
username: 'upcoming-username',
state: 'active',
avatar_url: 'http://0.0.0.0:3000/uploads/-/system/user/avatar/2/avatar.png',
web_url: 'http://0.0.0.0:3000/upcoming-username',
show_status: false,
path: '/upcoming-username',
},
deployable: {
id: 1310,
name: 'deploy_to_development',
started: '2020-12-04T19:58:10.806Z',
archived: false,
build_path: '/root/environment-test/-/jobs/892',
cancel_path:
'/root/environment-test/-/jobs/892/cancel?continue%5Bto%5D=%2Froot%2Fenvironment-test%2F-%2Fjobs%2F892',
playable: false,
scheduled: false,
created_at: '2020-12-04T19:57:49.455Z',
updated_at: '2020-12-04T19:58:10.809Z',
status: {
icon: 'status_running',
text: 'running',
label: 'running',
group: 'running',
tooltip: 'running',
has_details: true,
details_path: '/root/environment-test/-/jobs/892',
illustration: {
image:
'/assets/illustrations/skipped-job_empty-29a8a37d8a61d1b6f68cf3484f9024e53cd6eb95e28eae3554f8011a1146bf27.svg',
size: 'svg-430',
title: 'This job does not have a trace.',
},
favicon:
'/assets/ci_favicons/favicon_status_running-9c635b2419a8e1ec991c993061b89cc5aefc0743bb238ecd0c381e7741a70e8c.png',
action: {
icon: 'cancel',
title: 'Cancel',
path: '/root/environment-test/-/jobs/892/cancel',
method: 'post',
button_title: 'Cancel this job',
},
},
},
commit: {
id: '1132df044b73943943c949e7ac2c2f120a89bf59',
short_id: '1132df04',
created_at: '2020-12-01T15:46:26.000-05:00',
parent_ids: ['e0808dee2a5877563ec140e65d8b41908f90098c'],
title: 'Update .gitlab-ci.yml',
message: 'Update .gitlab-ci.yml',
author_name: 'Upcoming Name',
author_email: 'admin@example.com',
authored_date: '2020-12-01T15:46:26.000-05:00',
committer_name: 'Upcoming Name',
committer_email: 'admin@example.com',
committed_date: '2020-12-01T15:46:26.000-05:00',
web_url:
'http://0.0.0.0:3000/root/environment-test/-/commit/1132df044b73943943c949e7ac2c2f120a89bf59',
author: {
id: 1,
name: 'Upcoming Name',
username: 'upcoming-username',
state: 'active',
avatar_url: 'http://0.0.0.0:3000/uploads/-/system/user/avatar/2/avatar.png',
web_url: 'http://0.0.0.0:3000/upcoming-username',
show_status: false,
path: '/upcoming-username',
},
author_gravatar_url:
'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
commit_url:
'http://0.0.0.0:3000/root/environment-test/-/commit/1132df044b73943943c949e7ac2c2f120a89bf59',
commit_path: '/root/environment-test/-/commit/1132df044b73943943c949e7ac2c2f120a89bf59',
},
},
has_stop_action: true,
environment_path: 'root/ci-folders/environments/31',
log_path: 'root/ci-folders/environments/31/logs',
@ -156,6 +248,11 @@ const tableData = {
title: 'Updated',
spacing: 'section-10',
},
upcoming: {
title: 'Upcoming',
mobileTitle: 'Upcoming deployment',
spacing: 'section-10',
},
autoStop: {
title: 'Auto stop in',
spacing: 'section-5',

View File

@ -250,4 +250,17 @@ describe('~/static_site_editor/components/edit_area.vue', () => {
expect(wrapper.emitted('submit').length).toBe(1);
});
});
describe('when RichContentEditor component triggers load event', () => {
it('stores formatted markdown provided in the event data', () => {
const data = { formattedMarkdown: 'formatted markdown' };
findRichContentEditor().vm.$emit('load', data);
// We can access the formatted markdown when submitting changes
findPublishToolbar().vm.$emit('submit');
expect(wrapper.emitted('submit')[0][0]).toMatchObject(data);
});
});
});

View File

@ -235,6 +235,7 @@ describe('static_site_editor/pages/home', () => {
describe('when submitting changes succeeds', () => {
const newContent = `new ${content}`;
const formattedMarkdown = `formatted ${content}`;
beforeEach(() => {
mutateMock.mockResolvedValueOnce(hasSubmittedChangesMutationPayload).mockResolvedValueOnce({
@ -243,7 +244,12 @@ describe('static_site_editor/pages/home', () => {
},
});
buildWrapper({ content: newContent, images });
buildWrapper();
findEditMetaModal().vm.show = jest.fn();
findEditArea().vm.$emit('submit', { content: newContent, images, formattedMarkdown });
findEditMetaModal().vm.$emit('primary', mergeRequestMeta);
return wrapper.vm.$nextTick();
@ -266,6 +272,7 @@ describe('static_site_editor/pages/home', () => {
variables: {
input: {
content: newContent,
formattedMarkdown,
project,
sourcePath,
username,

View File

@ -11,6 +11,8 @@ import {
TRACKING_ACTION_CREATE_MERGE_REQUEST,
USAGE_PING_TRACKING_ACTION_CREATE_COMMIT,
USAGE_PING_TRACKING_ACTION_CREATE_MERGE_REQUEST,
DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE,
DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION,
} from '~/static_site_editor/constants';
import generateBranchName from '~/static_site_editor/services/generate_branch_name';
import submitContentChanges from '~/static_site_editor/services/submit_content_changes';
@ -81,6 +83,36 @@ describe('submitContentChanges', () => {
);
});
describe('committing markdown formatting changes', () => {
const formattedMarkdown = `formatted ${content}`;
const commitPayload = {
branch,
commit_message: `${DEFAULT_FORMATTING_CHANGES_COMMIT_MESSAGE}\n\n${DEFAULT_FORMATTING_CHANGES_COMMIT_DESCRIPTION}`,
actions: [
{
action: 'update',
file_path: sourcePath,
content: formattedMarkdown,
},
],
};
it('commits markdown formatting changes in a separate commit', () => {
return submitContentChanges(buildPayload({ formattedMarkdown })).then(() => {
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, commitPayload);
});
});
it('does not commit markdown formatting changes when there are none', () => {
return submitContentChanges(buildPayload()).then(() => {
expect(Api.commitMultiple.mock.calls).toHaveLength(1);
expect(Api.commitMultiple.mock.calls[0][1]).not.toMatchObject({
actions: commitPayload.actions,
});
});
});
});
it('commits the content changes to the branch when creating branch succeeds', () => {
return submitContentChanges(buildPayload()).then(() => {
expect(Api.commitMultiple).toHaveBeenCalledWith(projectId, {

View File

@ -1,4 +1,5 @@
import { shallowMount } from '@vue/test-utils';
import { mockEditorApi } from '@toast-ui/vue-editor';
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue';
import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue';
@ -114,10 +115,17 @@ describe('Rich Content Editor', () => {
});
describe('when editor is loaded', () => {
const formattedMarkdown = 'formatted markdown';
beforeEach(() => {
mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown);
buildWrapper();
});
afterEach(() => {
mockEditorApi.getMarkdown.mockReset();
});
it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => {
expect(addCustomEventListener).toHaveBeenCalledWith(
wrapper.vm.editorApi,
@ -137,6 +145,11 @@ describe('Rich Content Editor', () => {
it('registers HTML to markdown renderer', () => {
expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi);
});
it('emits load event with the markdown formatted by Toast UI', () => {
expect(mockEditorApi.getMarkdown).toHaveBeenCalled();
expect(wrapper.emitted('load')[0]).toEqual([{ formattedMarkdown }]);
});
});
describe('when editor is destroyed', () => {

View File

@ -0,0 +1,64 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
describe('SecurityReportDownloadDropdown component', () => {
let wrapper;
let artifacts;
const createComponent = props => {
wrapper = shallowMount(SecurityReportDownloadDropdown, {
propsData: { ...props },
});
};
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () => wrapper.findAll(GlDropdownItem);
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('given report artifacts', () => {
beforeEach(() => {
artifacts = [
{
name: 'foo',
path: '/foo.json',
},
{
name: 'bar',
path: '/bar.json',
},
];
createComponent({ artifacts });
});
it('renders a dropdown', () => {
expect(findDropdown().props('loading')).toBe(false);
});
it('renders a dropdown items for each artifact', () => {
artifacts.forEach((artifact, i) => {
const item = findDropdownItems().at(i);
expect(item.text()).toContain(artifact.name);
expect(item.attributes()).toMatchObject({
href: artifact.path,
download: expect.any(String),
});
});
});
});
describe('given it is loading', () => {
beforeEach(() => {
createComponent({ artifacts: [], loading: true });
});
it('renders a loading dropdown', () => {
expect(findDropdown().props('loading')).toBe(true);
});
});
});

View File

@ -1,3 +1,8 @@
import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
export const mockFindings = [
{
id: null,
@ -316,3 +321,117 @@ export const secretScanningDiffSuccessMock = {
base_report_out_of_date: false,
head_report_created_at: '2020-01-10T10:00:00.000Z',
};
export const securityReportDownloadPathsQueryResponse = {
project: {
mergeRequest: {
headPipeline: {
id: 'gid://gitlab/Ci::Pipeline/176',
jobs: {
nodes: [
{
name: 'secret_detection',
artifacts: {
nodes: [
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection',
fileType: 'SECRET_DETECTION',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
__typename: 'CiJob',
},
{
name: 'bandit-sast',
artifacts: {
nodes: [
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast',
fileType: 'SAST',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
__typename: 'CiJob',
},
{
name: 'eslint-sast',
artifacts: {
nodes: [
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=trace',
fileType: 'TRACE',
__typename: 'CiJobArtifact',
},
{
downloadPath:
'/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast',
fileType: 'SAST',
__typename: 'CiJobArtifact',
},
],
__typename: 'CiJobArtifactConnection',
},
__typename: 'CiJob',
},
],
__typename: 'CiJobConnection',
},
__typename: 'Pipeline',
},
__typename: 'MergeRequest',
},
__typename: 'Project',
},
};
/**
* These correspond to SAST jobs in the securityReportDownloadPathsQueryResponse above.
*/
export const sastArtifacts = [
{
name: 'bandit-sast',
reportType: REPORT_TYPE_SAST,
path: '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast',
},
{
name: 'eslint-sast',
reportType: REPORT_TYPE_SAST,
path: '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast',
},
];
/**
* These correspond to Secret Detection jobs in the securityReportDownloadPathsQueryResponse above.
*/
export const secretDetectionArtifacts = [
{
name: 'secret_detection',
reportType: REPORT_TYPE_SECRET_DETECTION,
path:
'/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection',
},
];
export const expectedDownloadDropdownProps = {
loading: false,
artifacts: [...secretDetectionArtifacts, ...sastArtifacts],
};

View File

@ -1,10 +1,14 @@
import { mount, createLocalVue } from '@vue/test-utils';
import MockAdapter from 'axios-mock-adapter';
import { merge } from 'lodash';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
expectedDownloadDropdownProps,
securityReportDownloadPathsQueryResponse,
sastDiffSuccessMock,
secretScanningDiffSuccessMock,
} from 'jest/vue_shared/security_reports/mock_data';
@ -15,7 +19,9 @@ import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql';
jest.mock('~/flash');
@ -47,8 +53,20 @@ describe('Security reports app', () => {
);
};
const pendingHandler = () => new Promise(() => {});
const successHandler = () => Promise.resolve({ data: securityReportDownloadPathsQueryResponse });
const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] });
const createMockApolloProvider = handler => {
localVue.use(VueApollo);
const requestHandlers = [[securityReportDownloadPathsQuery, handler]];
return createMockApollo(requestHandlers);
};
const anyParams = expect.any(Object);
const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown);
const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
const findHelpLink = () => wrapper.find('[data-testid="help"]');
const setupMockJobArtifact = reportType => {
@ -103,7 +121,9 @@ describe('Security reports app', () => {
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
expect(wrapper.text()).toMatchInterpolatedText(
SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance,
);
});
describe('clicking the anchor to the pipelines tab', () => {
@ -172,7 +192,9 @@ describe('Security reports app', () => {
});
it('renders the expected message', () => {
expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
expect(wrapper.text()).toMatchInterpolatedText(
SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance,
);
});
});
@ -320,4 +342,118 @@ describe('Security reports app', () => {
},
);
});
describe('given coreSecurityMrWidgetDownloads feature flag is enabled', () => {
const createComponentWithFlagEnabled = options =>
createComponent(
merge(options, {
provide: {
glFeatures: {
coreSecurityMrWidgetDownloads: true,
},
},
}),
);
describe('given the query is loading', () => {
beforeEach(() => {
createComponentWithFlagEnabled({
apolloProvider: createMockApolloProvider(pendingHandler),
});
});
// TODO: Remove this assertion as part of
// https://gitlab.com/gitlab-org/gitlab/-/issues/273431
it('initially renders nothing', () => {
expect(wrapper.isEmpty()).toBe(true);
});
});
describe('given the query loads successfully', () => {
beforeEach(() => {
createComponentWithFlagEnabled({
apolloProvider: createMockApolloProvider(successHandler),
});
});
it('renders the download dropdown', () => {
expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps);
});
it('renders the expected message', () => {
const text = wrapper.text();
expect(text).not.toContain(SecurityReportsApp.i18n.scansHaveRunWithDownloadGuidance);
expect(text).toContain(SecurityReportsApp.i18n.scansHaveRun);
});
it('should not render the pipeline tab anchor', () => {
expect(findPipelinesTabAnchor().exists()).toBe(false);
});
});
describe('given the query fails', () => {
beforeEach(() => {
createComponentWithFlagEnabled({
apolloProvider: createMockApolloProvider(failureHandler),
});
});
it('calls createFlash correctly', () => {
expect(createFlash).toHaveBeenCalledWith({
message: SecurityReportsApp.i18n.apiError,
captureError: true,
error: expect.any(Error),
});
});
// TODO: Remove this assertion as part of
// https://gitlab.com/gitlab-org/gitlab/-/issues/273431
it('renders nothing', () => {
expect(wrapper.isEmpty()).toBe(true);
});
});
});
describe('given coreSecurityMrWidgetCounts and coreSecurityMrWidgetDownloads feature flags are enabled', () => {
let mock;
beforeEach(() => {
mock = new MockAdapter(axios);
mock.onGet(SAST_COMPARISON_PATH).replyOnce(200, sastDiffSuccessMock);
mock.onGet(SECRET_SCANNING_COMPARISON_PATH).replyOnce(200, secretScanningDiffSuccessMock);
createComponent({
propsData: {
sastComparisonPath: SAST_COMPARISON_PATH,
secretScanningComparisonPath: SECRET_SCANNING_COMPARISON_PATH,
},
provide: {
glFeatures: {
coreSecurityMrWidgetCounts: true,
coreSecurityMrWidgetDownloads: true,
},
},
apolloProvider: createMockApolloProvider(successHandler),
});
return waitForPromises();
});
afterEach(() => {
mock.restore();
});
it('renders the download dropdown', () => {
expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps);
});
it('renders the expected counts message', () => {
expect(trimText(wrapper.text())).toContain(
'Security scanning detected 3 potential vulnerabilities 2 Critical 1 High and 0 Others',
);
});
it('should not render the pipeline tab anchor', () => {
expect(findPipelinesTabAnchor().exists()).toBe(false);
});
});
});

View File

@ -0,0 +1,28 @@
import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/utils';
import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
import {
securityReportDownloadPathsQueryResponse,
sastArtifacts,
secretDetectionArtifacts,
} from './mock_data';
describe('extractSecurityReportArtifacts', () => {
it.each`
reportTypes | expectedArtifacts
${[]} | ${[]}
${['foo']} | ${[]}
${[REPORT_TYPE_SAST]} | ${sastArtifacts}
${[REPORT_TYPE_SECRET_DETECTION]} | ${secretDetectionArtifacts}
${[REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION]} | ${[...secretDetectionArtifacts, ...sastArtifacts]}
`(
'returns the expected artifacts given report types $reportTypes',
({ reportTypes, expectedArtifacts }) => {
expect(
extractSecurityReportArtifacts(reportTypes, securityReportDownloadPathsQueryResponse),
).toEqual(expectedArtifacts);
},
);
});

View File

@ -68,9 +68,8 @@ RSpec.describe Mutations::Boards::Lists::Create do
context 'when label not found' do
let(:list_create_params) { { label_id: "gid://gitlab/Label/#{non_existing_record_id}" } }
it 'raises an error' do
expect { subject }
.to raise_error(Gitlab::Graphql::Errors::ArgumentError, 'Label not found!')
it 'returns an error' do
expect(subject[:errors]).to include 'Label not found'
end
end
end

View File

@ -40,4 +40,31 @@ RSpec.describe ::API::Entities::MergeRequestBasic do
expect(batch.count).to be_within(3 * query.count).of(control.count)
end
end
context 'reviewers' do
context "when merge_request_reviewers FF is enabled" do
before do
stub_feature_flags(merge_request_reviewers: true)
merge_request.reviewers = [user]
end
it 'includes assigned reviewers' do
result = Gitlab::Json.parse(present(merge_request).to_json)
expect(result['reviewers'][0]['username']).to eq user.username
end
end
context "when merge_request_reviewers FF is disabled" do
before do
stub_feature_flags(merge_request_reviewers: false)
end
it 'does not include reviewers' do
result = Gitlab::Json.parse(present(merge_request).to_json)
expect(result.keys).not_to include('reviewers')
end
end
end
end

View File

@ -0,0 +1,44 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe ::Gitlab::BackgroundMigration::PopulateDismissedStateForVulnerabilities, schema: 2020_11_30_103926 do
let(:users) { table(:users) }
let(:namespaces) { table(:namespaces) }
let(:projects) { table(:projects) }
let(:vulnerabilities) { table(:vulnerabilities) }
let!(:namespace) { namespaces.create!(name: "foo", path: "bar") }
let!(:user) { users.create!(name: 'John Doe', email: 'test@example.com', projects_limit: 5) }
let!(:project) { projects.create!(namespace_id: namespace.id) }
let!(:vulnerability_params) do
{
project_id: project.id,
author_id: user.id,
title: 'Vulnerability',
severity: 5,
confidence: 5,
report_type: 5
}
end
let!(:vulnerability_1) { vulnerabilities.create!(vulnerability_params.merge(state: 1)) }
let!(:vulnerability_2) { vulnerabilities.create!(vulnerability_params.merge(state: 3)) }
describe '#perform' do
it 'changes state of vulnerability to dismissed' do
subject.perform(vulnerability_1.id, vulnerability_2.id)
expect(vulnerability_1.reload.state).to eq(2)
expect(vulnerability_2.reload.state).to eq(2)
end
it 'populates missing dismissal information' do
expect_next_instance_of(::Gitlab::BackgroundMigration::PopulateMissingVulnerabilityDismissalInformation) do |migration|
expect(migration).to receive(:perform).with(vulnerability_1.id, vulnerability_2.id)
end
subject.perform(vulnerability_1.id, vulnerability_2.id)
end
end
end

View File

@ -6,7 +6,7 @@ require 'simple_po_parser'
# Disabling this cop to allow for multi-language examples in comments
# rubocop:disable Style/AsciiComments
RSpec.describe Gitlab::I18n::PoLinter do
let(:linter) { described_class.new(po_path: po_path, html_todolist: {}) }
let(:linter) { described_class.new(po_path: po_path) }
let(:po_path) { 'spec/fixtures/valid.po' }
def fake_translation(msgid:, translation:, plural_id: nil, plurals: [])
@ -24,8 +24,7 @@ RSpec.describe Gitlab::I18n::PoLinter do
Gitlab::I18n::TranslationEntry.new(
entry_data: data,
nplurals: plurals.size + 1,
html_allowed: nil
nplurals: plurals.size + 1
)
end
@ -160,53 +159,6 @@ RSpec.describe Gitlab::I18n::PoLinter do
]
end
end
context 'when an entry contains html on the todolist' do
subject(:linter) { described_class.new(po_path: po_path, html_todolist: todolist) }
let(:po_path) { 'spec/fixtures/potential_html.po' }
let(:todolist) do
{
'String with a legitimate < use' => {
'plural_id' => 'String with lots of < > uses',
'translations' => [
'Translated string with a legitimate < use',
'Translated string with lots of < > uses'
]
}
}
end
it 'does not present an error' do
message_id = 'String with a legitimate < use'
expect(errors[message_id]).to be_nil
end
end
context 'when an entry on the html todolist has changed' do
subject(:linter) { described_class.new(po_path: po_path, html_todolist: todolist) }
let(:po_path) { 'spec/fixtures/potential_html.po' }
let(:todolist) do
{
'String with a legitimate < use' => {
'plural_id' => 'String with lots of < > uses',
'translations' => [
'Translated string with a different legitimate < use',
'Translated string with lots of < > uses'
]
}
}
end
it 'presents an error for the changed component' do
message_id = 'String with a legitimate < use'
expect(errors[message_id])
.to include a_string_starting_with('translation contains < or >.')
end
end
end
describe '#parse_po' do
@ -276,8 +228,7 @@ RSpec.describe Gitlab::I18n::PoLinter do
fake_entry = Gitlab::I18n::TranslationEntry.new(
entry_data: { msgid: 'the singular', msgid_plural: 'the plural', 'msgstr[0]' => 'the singular' },
nplurals: 2,
html_allowed: nil
nplurals: 2
)
errors = []

View File

@ -6,7 +6,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
describe '#singular_translation' do
it 'returns the normal `msgstr` for translations without plural' do
data = { msgid: 'Hello world', msgstr: 'Bonjour monde' }
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry.singular_translation).to eq('Bonjour monde')
end
@ -18,7 +18,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
'msgstr[0]' => 'Bonjour monde',
'msgstr[1]' => 'Bonjour mondes'
}
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry.singular_translation).to eq('Bonjour monde')
end
@ -27,7 +27,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
describe '#all_translations' do
it 'returns all translations for singular translations' do
data = { msgid: 'Hello world', msgstr: 'Bonjour monde' }
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry.all_translations).to eq(['Bonjour monde'])
end
@ -39,7 +39,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
'msgstr[0]' => 'Bonjour monde',
'msgstr[1]' => 'Bonjour mondes'
}
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry.all_translations).to eq(['Bonjour monde', 'Bonjour mondes'])
end
@ -52,7 +52,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
msgid_plural: 'Hello worlds',
'msgstr[0]' => 'Bonjour monde'
}
entry = described_class.new(entry_data: data, nplurals: 1, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 1)
expect(entry.plural_translations).to eq(['Bonjour monde'])
end
@ -65,7 +65,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
'msgstr[1]' => 'Bonjour mondes',
'msgstr[2]' => 'Bonjour tous les mondes'
}
entry = described_class.new(entry_data: data, nplurals: 3, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 3)
expect(entry.plural_translations).to eq(['Bonjour mondes', 'Bonjour tous les mondes'])
end
@ -77,7 +77,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
msgid: 'hello world',
msgstr: 'hello'
}
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry).to have_singular_translation
end
@ -89,7 +89,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
"msgstr[0]" => 'hello world',
"msgstr[1]" => 'hello worlds'
}
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry).to have_singular_translation
end
@ -100,7 +100,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
msgid_plural: 'hello worlds',
"msgstr[0]" => 'hello worlds'
}
entry = described_class.new(entry_data: data, nplurals: 1, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 1)
expect(entry).not_to have_singular_translation
end
@ -109,7 +109,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
describe '#msgid_contains_newlines' do
it 'is true when the msgid is an array' do
data = { msgid: %w(hello world) }
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry.msgid_has_multiple_lines?).to be_truthy
end
@ -118,7 +118,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
describe '#plural_id_contains_newlines' do
it 'is true when the msgid is an array' do
data = { msgid_plural: %w(hello world) }
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry.plural_id_has_multiple_lines?).to be_truthy
end
@ -127,7 +127,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
describe '#translations_contain_newlines' do
it 'is true when the msgid is an array' do
data = { msgstr: %w(hello world) }
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry.translations_have_multiple_lines?).to be_truthy
end
@ -135,7 +135,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
describe '#contains_unescaped_chars' do
let(:data) { { msgid: '' } }
let(:entry) { described_class.new(entry_data: data, nplurals: 2, html_allowed: nil) }
let(:entry) { described_class.new(entry_data: data, nplurals: 2) }
it 'is true when the msgid is an array' do
string = '「100%確定」'
@ -177,7 +177,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
describe '#msgid_contains_unescaped_chars' do
it 'is true when the msgid contains a `%`' do
data = { msgid: '「100%確定」' }
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry).to receive(:contains_unescaped_chars?).and_call_original
expect(entry.msgid_contains_unescaped_chars?).to be_truthy
@ -187,7 +187,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
describe '#plural_id_contains_unescaped_chars' do
it 'is true when the plural msgid contains a `%`' do
data = { msgid_plural: '「100%確定」' }
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry).to receive(:contains_unescaped_chars?).and_call_original
expect(entry.plural_id_contains_unescaped_chars?).to be_truthy
@ -197,7 +197,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
describe '#translations_contain_unescaped_chars' do
it 'is true when the translation contains a `%`' do
data = { msgstr: '「100%確定」' }
entry = described_class.new(entry_data: data, nplurals: 2, html_allowed: nil)
entry = described_class.new(entry_data: data, nplurals: 2)
expect(entry).to receive(:contains_unescaped_chars?).and_call_original
expect(entry.translations_contain_unescaped_chars?).to be_truthy
@ -205,7 +205,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
end
describe '#msgid_contains_potential_html?' do
subject(:entry) { described_class.new(entry_data: data, nplurals: 2, html_allowed: nil) }
subject(:entry) { described_class.new(entry_data: data, nplurals: 2) }
context 'when there are no angle brackets in the msgid' do
let(:data) { { msgid: 'String with no brackets' } }
@ -225,7 +225,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
end
describe '#plural_id_contains_potential_html?' do
subject(:entry) { described_class.new(entry_data: data, nplurals: 2, html_allowed: nil) }
subject(:entry) { described_class.new(entry_data: data, nplurals: 2) }
context 'when there are no angle brackets in the plural_id' do
let(:data) { { msgid_plural: 'String with no brackets' } }
@ -245,7 +245,7 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
end
describe '#translations_contain_potential_html?' do
subject(:entry) { described_class.new(entry_data: data, nplurals: 2, html_allowed: nil) }
subject(:entry) { described_class.new(entry_data: data, nplurals: 2) }
context 'when there are no angle brackets in the translations' do
let(:data) { { msgstr: 'This string has no angle brackets' } }
@ -263,78 +263,4 @@ RSpec.describe Gitlab::I18n::TranslationEntry do
end
end
end
describe '#msgid_html_allowed?' do
subject(:entry) do
described_class.new(entry_data: { msgid: 'String with a <strong>' }, nplurals: 2, html_allowed: html_todo)
end
context 'when the html in the string is in the todolist' do
let(:html_todo) { { 'plural_id' => nil, 'translations' => [] } }
it 'returns true' do
expect(entry.msgid_html_allowed?).to be true
end
end
context 'when the html in the string is not in the todolist' do
let(:html_todo) { nil }
it 'returns false' do
expect(entry.msgid_html_allowed?).to be false
end
end
end
describe '#plural_id_html_allowed?' do
subject(:entry) do
described_class.new(entry_data: { msgid_plural: 'String with many <strong>' }, nplurals: 2, html_allowed: html_todo)
end
context 'when the html in the string is in the todolist' do
let(:html_todo) { { 'plural_id' => 'String with many <strong>', 'translations' => [] } }
it 'returns true' do
expect(entry.plural_id_html_allowed?).to be true
end
end
context 'when the html in the string is not in the todolist' do
let(:html_todo) { { 'plural_id' => 'String with some <strong>', 'translations' => [] } }
it 'returns false' do
expect(entry.plural_id_html_allowed?).to be false
end
end
end
describe '#translations_html_allowed?' do
subject(:entry) do
described_class.new(entry_data: { msgstr: 'String with a <strong>' }, nplurals: 2, html_allowed: html_todo)
end
context 'when the html in the string is in the todolist' do
let(:html_todo) { { 'plural_id' => nil, 'translations' => ['String with a <strong>'] } }
it 'returns true' do
expect(entry.translations_html_allowed?).to be true
end
end
context 'when the html in the string is not in the todolist' do
let(:html_todo) { { 'plural_id' => nil, 'translations' => ['String with a different <strong>'] } }
it 'returns false' do
expect(entry.translations_html_allowed?).to be false
end
end
context 'when the todolist only has the msgid' do
let(:html_todo) { { 'plural_id' => nil, 'translations' => nil } }
it 'returns false' do
expect(entry.translations_html_allowed?).to be false
end
end
end
end

View File

@ -656,6 +656,7 @@ boards:
lists:
- user
- milestone
- iteration
- board
- label
- list_user_preferences

View File

@ -0,0 +1,64 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::PerformanceBar::RedisAdapterWhenPeekEnabled do
include ExclusiveLeaseHelpers
let(:peek_adapter) do
Class.new do
prepend Gitlab::PerformanceBar::RedisAdapterWhenPeekEnabled
def initialize(client)
@client = client
end
def save(id)
# no-op
end
end
end
describe '#save' do
let(:client) { double }
let(:uuid) { 'foo' }
before do
allow(Gitlab::PerformanceBar).to receive(:enabled_for_request?).and_return(true)
end
it 'stores request id and enqueues stats job' do
expect_to_obtain_exclusive_lease(GitlabPerformanceBarStatsWorker::LEASE_KEY, uuid)
expect(GitlabPerformanceBarStatsWorker).to receive(:perform_in).with(GitlabPerformanceBarStatsWorker::WORKER_DELAY, uuid)
expect(client).to receive(:sadd).with(GitlabPerformanceBarStatsWorker::STATS_KEY, uuid)
peek_adapter.new(client).save('foo')
end
context 'when performance_bar_stats is disabled' do
before do
stub_feature_flags(performance_bar_stats: false)
end
it 'ignores stats processing for the request' do
expect(GitlabPerformanceBarStatsWorker).not_to receive(:perform_in)
expect(client).not_to receive(:sadd)
peek_adapter.new(client).save('foo')
end
end
context 'when exclusive lease has been already taken' do
before do
stub_exclusive_lease_taken(GitlabPerformanceBarStatsWorker::LEASE_KEY)
end
it 'stores request id but does not enqueue any job' do
expect(GitlabPerformanceBarStatsWorker).not_to receive(:perform_in)
expect(client).to receive(:sadd).with(GitlabPerformanceBarStatsWorker::STATS_KEY, uuid)
peek_adapter.new(client).save('foo')
end
end
end
end

View File

@ -0,0 +1,42 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Gitlab::PerformanceBar::Stats do
describe '#process' do
let(:request) { fixture_file('lib/gitlab/performance_bar/peek_data.json') }
let(:redis) { double(Gitlab::Redis::SharedState) }
let(:logger) { double(Gitlab::PerformanceBar::Logger) }
let(:request_id) { 'foo' }
let(:stats) { described_class.new(redis) }
describe '#process' do
subject(:process) { stats.process(request_id) }
before do
allow(stats).to receive(:logger).and_return(logger)
end
it 'logs each SQL query including its duration' do
allow(redis).to receive(:get).and_return(request)
expect(logger).to receive(:info)
.with({ duration_ms: 1.096, filename: 'lib/gitlab/pagination/offset_pagination.rb',
filenum: 53, method: 'add_pagination_headers', request_id: 'foo', type: :sql })
expect(logger).to receive(:info)
.with({ duration_ms: 0.817, filename: 'lib/api/helpers.rb',
filenum: 112, method: 'find_project', request_id: 'foo', type: :sql }).twice
subject
end
it 'logs an error when the request could not be processed' do
allow(redis).to receive(:get).and_return(nil)
expect(logger).to receive(:error).with(message: anything)
subject
end
end
end
end

View File

@ -5,27 +5,29 @@ require 'spec_helper'
RSpec.describe Boards::Lists::CreateService do
describe '#execute' do
shared_examples 'creating board lists' do
let(:user) { create(:user) }
let_it_be(:user) { create(:user) }
subject(:service) { described_class.new(parent, user, label_id: label.id) }
before do
before_all do
parent.add_developer(user)
end
subject(:service) { described_class.new(parent, user, label_id: label.id) }
context 'when board lists is empty' do
it 'creates a new list at beginning of the list' do
list = service.execute(board)
response = service.execute(board)
expect(list.position).to eq 0
expect(response.success?).to eq(true)
expect(response.payload[:list].position).to eq 0
end
end
context 'when board lists has the done list' do
it 'creates a new list at beginning of the list' do
list = service.execute(board)
response = service.execute(board)
expect(list.position).to eq 0
expect(response.success?).to eq(true)
expect(response.payload[:list].position).to eq 0
end
end
@ -34,9 +36,10 @@ RSpec.describe Boards::Lists::CreateService do
create(:list, board: board, position: 0)
create(:list, board: board, position: 1)
list = service.execute(board)
response = service.execute(board)
expect(list.position).to eq 2
expect(response.success?).to eq(true)
expect(response.payload[:list].position).to eq 2
end
end
@ -44,32 +47,35 @@ RSpec.describe Boards::Lists::CreateService do
it 'creates a new list at end of the label lists' do
list1 = create(:list, board: board, position: 0)
list2 = service.execute(board)
list2 = service.execute(board).payload[:list]
expect(list1.reload.position).to eq 0
expect(list2.reload.position).to eq 1
end
end
context 'when provided label does not belongs to the parent' do
it 'raises an error' do
context 'when provided label does not belong to the parent' do
it 'returns an error' do
label = create(:label, name: 'in-development')
service = described_class.new(parent, user, label_id: label.id)
expect { service.execute(board) }.to raise_error(ActiveRecord::RecordNotFound)
response = service.execute(board)
expect(response.success?).to eq(false)
expect(response.errors).to include('Label not found')
end
end
context 'when backlog param is sent' do
it 'creates one and only one backlog list' do
service = described_class.new(parent, user, 'backlog' => true)
list = service.execute(board)
list = service.execute(board).payload[:list]
expect(list.list_type).to eq('backlog')
expect(list.position).to be_nil
expect(list).to be_valid
another_backlog = service.execute(board)
another_backlog = service.execute(board).payload[:list]
expect(another_backlog).to eq list
end
@ -77,17 +83,17 @@ RSpec.describe Boards::Lists::CreateService do
end
context 'when board parent is a project' do
let(:parent) { create(:project) }
let(:board) { create(:board, project: parent) }
let(:label) { create(:label, project: parent, name: 'in-progress') }
let_it_be(:parent) { create(:project) }
let_it_be(:board) { create(:board, project: parent) }
let_it_be(:label) { create(:label, project: parent, name: 'in-progress') }
it_behaves_like 'creating board lists'
end
context 'when board parent is a group' do
let(:parent) { create(:group) }
let(:board) { create(:board, group: parent) }
let(:label) { create(:group_label, group: parent, name: 'in-progress') }
let_it_be(:parent) { create(:group) }
let_it_be(:board) { create(:board, group: parent) }
let_it_be(:label) { create(:group_label, group: parent, name: 'in-progress') }
it_behaves_like 'creating board lists'
end

View File

@ -0,0 +1,30 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabPerformanceBarStatsWorker do
include ExclusiveLeaseHelpers
subject(:worker) { described_class.new }
describe '#perform' do
let(:redis) { double(Gitlab::Redis::SharedState) }
let(:uuid) { 1 }
before do
expect(Gitlab::Redis::SharedState).to receive(:with).and_yield(redis)
expect_to_cancel_exclusive_lease(GitlabPerformanceBarStatsWorker::LEASE_KEY, uuid)
end
it 'fetches list of request ids and processes them' do
expect(redis).to receive(:smembers).with(GitlabPerformanceBarStatsWorker::STATS_KEY).and_return([1, 2])
expect(redis).to receive(:del).with(GitlabPerformanceBarStatsWorker::STATS_KEY)
expect_next_instance_of(Gitlab::PerformanceBar::Stats) do |stats|
expect(stats).to receive(:process).with(1)
expect(stats).to receive(:process).with(2)
end
worker.perform(uuid)
end
end
end

View File

@ -19,7 +19,12 @@ mr_iid = ENV.fetch('CI_MERGE_REQUEST_IID')
mr_changes = Gitlab.merge_request_changes(mr_project_path, mr_iid)
changed_files = mr_changes.changes.map { |change| change['new_path'] }
mapping = TestFileFinder::Mapping.load('tests.yml')
test_files = TestFileFinder::FileFinder.new(paths: changed_files, mapping: mapping).test_files
tff = TestFileFinder::FileFinder.new(paths: changed_files).tap do |file_finder|
file_finder.use TestFileFinder::MappingStrategies::PatternMatching.load('tests.yml')
File.write(output_file, test_files.uniq.join(' '))
if ENV['RSPEC_TESTS_MAPPING_ENABLED']
file_finder.use TestFileFinder::MappingStrategies::DirectMatching.load_json(ENV['RSPEC_TESTS_MAPPING_PATH'])
end
end
File.write(output_file, tff.test_files.uniq.join(' '))

View File

@ -60,7 +60,10 @@ module Tooling
end
def tests_to_run
return node_tests if filter_tests.empty?
if filter_tests.empty?
Knapsack.logger.info 'Running all node tests without filter'
return node_tests
end
@tests_to_run ||= node_tests & filter_tests
end