Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-09-30 15:12:24 +00:00
parent 4e8c8922da
commit eaec42f9e3
61 changed files with 1727 additions and 696 deletions

View File

@ -15,6 +15,8 @@ import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { n__, s__, __ } from '~/locale';
import getProjects from '../graphql/projects.query.graphql';
const sortByProjectName = (projects = []) => projects.sort((a, b) => a.name.localeCompare(b.name));
export default {
name: 'ProjectsDropdownFilter',
components: {
@ -88,6 +90,9 @@ export default {
selectedProjectIds() {
return this.selectedProjects.map((p) => p.id);
},
hasSelectedProjects() {
return Boolean(this.selectedProjects.length);
},
availableProjects() {
return filterBySearchTerm(this.projects, this.searchTerm);
},
@ -95,6 +100,14 @@ export default {
const { loading, availableProjects } = this;
return !loading && !availableProjects.length;
},
selectedItems() {
return sortByProjectName(
this.availableProjects.filter(({ id }) => this.selectedProjectIds.includes(id)),
);
},
unselectedItems() {
return this.availableProjects.filter(({ id }) => !this.selectedProjectIds.includes(id));
},
},
watch: {
searchTerm() {
@ -105,44 +118,53 @@ export default {
this.search();
},
methods: {
handleUpdatedSelectedProjects() {
this.$emit('selected', this.selectedProjects);
},
search: debounce(function debouncedSearch() {
this.fetchData();
}, DEFAULT_DEBOUNCE_AND_THROTTLE_MS),
getSelectedProjects(selectedProject, isMarking) {
return isMarking
getSelectedProjects(selectedProject, isSelected) {
return isSelected
? this.selectedProjects.concat([selectedProject])
: this.selectedProjects.filter((project) => project.id !== selectedProject.id);
},
singleSelectedProject(selectedObj, isMarking) {
return isMarking ? [selectedObj] : [];
},
setSelectedProjects(selectedObj, isMarking) {
setSelectedProjects(project) {
this.selectedProjects = this.multiSelect
? this.getSelectedProjects(selectedObj, isMarking)
: this.singleSelectedProject(selectedObj, isMarking);
? this.getSelectedProjects(project, !this.isProjectSelected(project))
: this.singleSelectedProject(project, !this.isProjectSelected(project));
},
onClick({ project, isSelected }) {
this.setSelectedProjects(project, !isSelected);
this.$emit('selected', this.selectedProjects);
onClick(project) {
this.setSelectedProjects(project);
this.handleUpdatedSelectedProjects();
},
onMultiSelectClick({ project, isSelected }) {
this.setSelectedProjects(project, !isSelected);
onMultiSelectClick(project) {
this.setSelectedProjects(project);
this.isDirty = true;
},
onSelected(ev) {
onSelected(project) {
if (this.multiSelect) {
this.onMultiSelectClick(ev);
this.onMultiSelectClick(project);
} else {
this.onClick(ev);
this.onClick(project);
}
},
onHide() {
if (this.multiSelect && this.isDirty) {
this.$emit('selected', this.selectedProjects);
this.handleUpdatedSelectedProjects();
}
this.searchTerm = '';
this.isDirty = false;
},
onClearAll() {
if (this.hasSelectedProjects) {
this.isDirty = true;
}
this.selectedProjects = [];
},
fetchData() {
this.loading = true;
@ -168,8 +190,8 @@ export default {
this.projects = nodes;
});
},
isProjectSelected(id) {
return this.selectedProjects ? this.selectedProjectIds.includes(id) : false;
isProjectSelected(project) {
return this.selectedProjectIds.includes(project.id);
},
getEntityId(project) {
return getIdFromGraphQLId(project.id);
@ -182,6 +204,10 @@ export default {
ref="projectsDropdown"
class="dropdown dropdown-projects"
toggle-class="gl-shadow-none"
:show-clear-all="hasSelectedProjects"
show-highlighted-items-title
highlighted-items-title-class="gl-p-3"
@clear-all.stop="onClearAll"
@hide="onHide"
>
<template #button-content>
@ -204,14 +230,37 @@ export default {
<gl-dropdown-section-header>{{ __('Projects') }}</gl-dropdown-section-header>
<gl-search-box-by-type v-model.trim="searchTerm" />
</template>
<template #highlighted-items>
<gl-dropdown-item
v-for="project in selectedItems"
:key="project.id"
is-check-item
:is-checked="isProjectSelected(project)"
@click.native.capture.stop="onSelected(project)"
>
<div class="gl-display-flex">
<gl-avatar
class="gl-mr-2 gl-vertical-align-middle"
:alt="project.name"
:size="16"
:entity-id="getEntityId(project)"
:entity-name="project.name"
:src="project.avatarUrl"
shape="rect"
/>
<div>
<div data-testid="project-name">{{ project.name }}</div>
<div class="gl-text-gray-500" data-testid="project-full-path">
{{ project.fullPath }}
</div>
</div>
</div>
</gl-dropdown-item>
</template>
<gl-dropdown-item
v-for="project in availableProjects"
v-for="project in unselectedItems"
:key="project.id"
:is-check-item="true"
:is-checked="isProjectSelected(project.id)"
@click.native.capture.stop="
onSelected({ project, isSelected: isProjectSelected(project.id) })
"
@click.native.capture.stop="onSelected(project)"
>
<div class="gl-display-flex">
<gl-avatar

View File

@ -851,10 +851,6 @@
"const": "runner_system_failure",
"description": "Retry if there is a runner system failure (for example, job setup failed)."
},
{
"const": "missing_dependency_failure",
"description": "Retry if a dependency is missing."
},
{
"const": "runner_unsupported",
"description": "Retry if the runner is unsupported."

View File

@ -1,9 +1,12 @@
<script>
import { GlAlert, GlFormGroup, GlFormInputGroup, GlSkeletonLoader, GlSprintf } from '@gitlab/ui';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import {
DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
DEPENDENCY_PROXY_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
import getDependencyProxyDetailsQuery from '~/packages_and_registries/dependency_proxy/graphql/queries/get_dependency_proxy_details.query.graphql';
@ -19,9 +22,6 @@ export default {
},
inject: ['groupPath', 'dependencyProxyAvailable'],
i18n: {
subTitle: __(
'Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.',
),
proxyNotAvailableText: __('Dependency proxy feature is limited to public groups for now.'),
proxyImagePrefix: __('Dependency proxy image prefix'),
copyImagePrefixText: __('Copy prefix'),
@ -47,8 +47,8 @@ export default {
infoMessages() {
return [
{
text: this.$options.i18n.subTitle,
link: helpPagePath('user/packages/dependency_proxy/index'),
text: DEPENDENCY_PROXY_SETTINGS_DESCRIPTION,
link: DEPENDENCY_PROXY_DOCS_PATH,
},
];
},

View File

@ -1,108 +1,54 @@
<script>
import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
import { GlAlert } from '@gitlab/ui';
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
PACKAGES_DOCS_PATH,
ERROR_UPDATING_SETTINGS,
SUCCESS_UPDATING_SETTINGS,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue';
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
export default {
name: 'GroupSettingsApp',
i18n: {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
},
links: {
PACKAGES_DOCS_PATH,
},
components: {
GlAlert,
GlSprintf,
GlLink,
SettingsBlock,
MavenSettings,
GenericSettings,
DuplicatesSettings,
PackagesSettings,
},
inject: ['defaultExpanded', 'groupPath'],
inject: ['groupPath'],
apollo: {
packageSettings: {
group: {
query: getGroupPackagesSettingsQuery,
variables() {
return {
fullPath: this.groupPath,
};
},
update(data) {
return data.group?.packageSettings;
},
},
},
data() {
return {
packageSettings: {},
errors: {},
group: {},
alertMessage: null,
};
},
computed: {
packageSettings() {
return this.group?.packageSettings || {};
},
isLoading() {
return this.$apollo.queries.packageSettings.loading;
return this.$apollo.queries.group.loading;
},
},
methods: {
dismissAlert() {
this.alertMessage = null;
},
updateSettings(payload) {
this.errors = {};
return this.$apollo
.mutate({
mutation: updateNamespacePackageSettings,
variables: {
input: {
namespacePath: this.groupPath,
...payload,
},
},
update: updateGroupPackageSettings(this.groupPath),
optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({
...this.packageSettings,
...payload,
}),
})
.then(({ data }) => {
if (data.updateNamespacePackageSettings?.errors?.length > 0) {
this.alertMessage = ERROR_UPDATING_SETTINGS;
} else {
this.dismissAlert();
this.$toast.show(SUCCESS_UPDATING_SETTINGS);
}
})
.catch((e) => {
if (e.graphQLErrors) {
e.graphQLErrors.forEach((error) => {
const [
{
path: [key],
message,
},
] = error.extensions.problems;
this.errors = { ...this.errors, [key]: message };
});
}
this.alertMessage = ERROR_UPDATING_SETTINGS;
});
handleSuccess() {
this.$toast.show(SUCCESS_UPDATING_SETTINGS);
this.dismissAlert();
},
handleError() {
this.alertMessage = ERROR_UPDATING_SETTINGS;
},
},
};
@ -114,50 +60,11 @@ export default {
{{ alertMessage }}
</gl-alert>
<settings-block
:default-expanded="defaultExpanded"
data-qa-selector="package_registry_settings_content"
>
<template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
<template #description>
<span data-testid="description">
<gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
<template #link="{ content }">
<gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</span>
</template>
<template #default>
<maven-settings data-testid="maven-settings">
<template #default="{ modelNames }">
<duplicates-settings
:duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
:duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
:duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
toggle-qa-selector="allow_duplicates_toggle"
label-qa-selector="allow_duplicates_label"
@update="updateSettings"
/>
</template>
</maven-settings>
<generic-settings class="gl-mt-6" data-testid="generic-settings">
<template #default="{ modelNames }">
<duplicates-settings
:duplicates-allowed="packageSettings.genericDuplicatesAllowed"
:duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
:duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
@update="updateSettings"
/>
</template>
</generic-settings>
</template>
</settings-block>
<packages-settings
:package-settings="packageSettings"
:is-loading="isLoading"
@success="handleSuccess"
@error="handleError"
/>
</div>
</template>

View File

@ -0,0 +1,139 @@
<script>
import { GlSprintf, GlLink } from '@gitlab/ui';
import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
PACKAGES_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import { updateGroupPackageSettings } from '~/packages_and_registries/settings/group/graphql/utils/cache_update';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
export default {
name: 'PackageSettings',
i18n: {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
},
links: {
PACKAGES_DOCS_PATH,
},
components: {
GlSprintf,
GlLink,
SettingsBlock,
MavenSettings,
GenericSettings,
DuplicatesSettings,
},
inject: ['defaultExpanded', 'groupPath'],
props: {
packageSettings: {
type: Object,
required: true,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
errors: {},
};
},
methods: {
async updateSettings(payload) {
this.errors = {};
try {
const { data } = await this.$apollo.mutate({
mutation: updateNamespacePackageSettings,
variables: {
input: {
namespacePath: this.groupPath,
...payload,
},
},
update: updateGroupPackageSettings(this.groupPath),
optimisticResponse: updateGroupPackagesSettingsOptimisticResponse({
...this.packageSettings,
...payload,
}),
});
if (data.updateNamespacePackageSettings?.errors?.length > 0) {
throw new Error();
} else {
this.$emit('success');
}
} catch (e) {
if (e.graphQLErrors) {
e.graphQLErrors.forEach((error) => {
const [
{
path: [key],
message,
},
] = error.extensions.problems;
this.errors = { ...this.errors, [key]: message };
});
}
this.$emit('error');
}
},
},
};
</script>
<template>
<settings-block
:default-expanded="defaultExpanded"
data-qa-selector="package_registry_settings_content"
>
<template #title> {{ $options.i18n.PACKAGE_SETTINGS_HEADER }}</template>
<template #description>
<span data-testid="description">
<gl-sprintf :message="$options.i18n.PACKAGE_SETTINGS_DESCRIPTION">
<template #link="{ content }">
<gl-link :href="$options.links.PACKAGES_DOCS_PATH" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</span>
</template>
<template #default>
<maven-settings data-testid="maven-settings">
<template #default="{ modelNames }">
<duplicates-settings
:duplicates-allowed="packageSettings.mavenDuplicatesAllowed"
:duplicate-exception-regex="packageSettings.mavenDuplicateExceptionRegex"
:duplicate-exception-regex-error="errors.mavenDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
toggle-qa-selector="allow_duplicates_toggle"
label-qa-selector="allow_duplicates_label"
@update="updateSettings"
/>
</template>
</maven-settings>
<generic-settings class="gl-mt-6" data-testid="generic-settings">
<template #default="{ modelNames }">
<duplicates-settings
:duplicates-allowed="packageSettings.genericDuplicatesAllowed"
:duplicate-exception-regex="packageSettings.genericDuplicateExceptionRegex"
:duplicate-exception-regex-error="errors.genericDuplicateExceptionRegex"
:model-names="modelNames"
:loading="isLoading"
@update="updateSettings"
/>
</template>
</generic-settings>
</template>
</settings-block>
</template>

View File

@ -23,8 +23,15 @@ export const ERROR_UPDATING_SETTINGS = s__(
'PackageRegistry|An error occurred while saving the settings',
);
export const DEPENDENCY_PROXY_HEADER = s__('DependencyProxy|Dependency Proxy');
export const DEPENDENCY_PROXY_SETTINGS_DESCRIPTION = s__(
'DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies.',
);
// Parameters
export const PACKAGES_DOCS_PATH = helpPagePath('user/packages');
export const MAVEN_DUPLICATES_ALLOWED = 'mavenDuplicatesAllowed';
export const MAVEN_DUPLICATE_EXCEPTION_REGEX = 'mavenDuplicateExceptionRegex';
export const DEPENDENCY_PROXY_DOCS_PATH = helpPagePath('user/packages/dependency_proxy/index');

View File

@ -1,5 +1,7 @@
import $ from 'jquery';
import { debounce } from 'lodash';
import DEFAULT_PROJECT_TEMPLATES from 'ee_else_ce/projects/default_project_templates';
import axios from '../lib/utils/axios_utils';
import {
convertToTitleCase,
humanize,
@ -9,6 +11,23 @@ import {
let hasUserDefinedProjectPath = false;
let hasUserDefinedProjectName = false;
const invalidInputClass = 'gl-field-error-outline';
const validateImportCredentials = (url, user, password) => {
const endpoint = `${gon.relative_url_root}/import/url/validate`;
return axios
.post(endpoint, {
url,
user,
password,
})
.then(({ data }) => data)
.catch(() => ({
// intentionally reporting success in case of validation error
// we do not want to block users from trying import in case of validation exception
success: true,
}));
};
const onProjectNameChange = ($projectNameInput, $projectPathInput) => {
const slug = slugify(convertUnicodeToAscii($projectNameInput.val()));
@ -85,7 +104,10 @@ const bindHowToImport = () => {
const bindEvents = () => {
const $newProjectForm = $('#new_project');
const $projectImportUrl = $('#project_import_url');
const $projectImportUrlWarning = $('.js-import-url-warning');
const $projectImportUrlUser = $('#project_import_url_user');
const $projectImportUrlPassword = $('#project_import_url_password');
const $projectImportUrlError = $('.js-import-url-error');
const $projectImportForm = $('.project-import form');
const $projectPath = $('.tab-pane.active #project_path');
const $useTemplateBtn = $('.template-button > input');
const $projectFieldsForm = $('.project-fields-form');
@ -139,12 +161,15 @@ const bindEvents = () => {
$projectPath.val($projectPath.val().trim());
});
function updateUrlPathWarningVisibility() {
const url = $projectImportUrl.val();
const URL_PATTERN = /(?:git|https?):\/\/.*\/.*\.git$/;
const isUrlValid = URL_PATTERN.test(url);
$projectImportUrlWarning.toggleClass('hide', isUrlValid);
}
const updateUrlPathWarningVisibility = debounce(async () => {
const { success: isUrlValid } = await validateImportCredentials(
$projectImportUrl.val(),
$projectImportUrlUser.val(),
$projectImportUrlPassword.val(),
);
$projectImportUrl.toggleClass(invalidInputClass, !isUrlValid);
$projectImportUrlError.toggleClass('hide', isUrlValid);
}, 500);
let isProjectImportUrlDirty = false;
$projectImportUrl.on('blur', () => {
@ -153,9 +178,22 @@ const bindEvents = () => {
});
$projectImportUrl.on('keyup', () => {
deriveProjectPathFromUrl($projectImportUrl);
// defer error message till first input blur
if (isProjectImportUrlDirty) {
updateUrlPathWarningVisibility();
});
[$projectImportUrl, $projectImportUrlUser, $projectImportUrlPassword].forEach(($f) => {
$f.on('input', () => {
if (isProjectImportUrlDirty) {
updateUrlPathWarningVisibility();
}
});
});
$projectImportForm.on('submit', (e) => {
const $invalidFields = $projectImportForm.find(`.${invalidInputClass}`);
if ($invalidFields.length > 0) {
$invalidFields[0].focus();
e.preventDefault();
e.stopPropagation();
}
});

View File

@ -0,0 +1,20 @@
# frozen_string_literal: true
class Import::UrlController < ApplicationController
feature_category :importers
def validate
result = Import::ValidateRemoteGitEndpointService.new(validate_params).execute
if result.success?
render json: { success: true }
else
render json: { success: false, message: result.message }
end
end
private
def validate_params
params.permit(:user, :password, :url)
end
end

View File

@ -89,17 +89,25 @@ module Issuables
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def find_label_ids(label_names)
group_labels = Label
.where(project_id: nil)
.where(title: label_names)
.where(group_id: root_namespace.self_and_descendant_ids)
find_label_ids_uncached(label_names)
end
# Avoid repeating label queries times when the finder is instantiated multiple times during the request.
request_cache(:find_label_ids) { root_namespace.id }
project_labels = Label
.where(group_id: nil)
.where(title: label_names)
.where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
# This returns an array of label IDs per label name. It is possible for a label name
# to have multiple IDs because we allow labels with the same name if they are on a different
# project or group.
#
# For example, if we pass in `['bug', 'feature']`, this will return something like:
# `[ [1, 2], [3] ]`
#
# rubocop: disable CodeReuse/ActiveRecord
def find_label_ids_uncached(label_names)
return [] if label_names.empty?
group_labels = group_labels_for_root_namespace.where(title: label_names)
project_labels = project_labels_for_root_namespace.where(title: label_names)
Label
.from_union([group_labels, project_labels], remove_duplicates: false)
@ -109,8 +117,18 @@ module Issuables
.values
.map { |labels| labels.map(&:last) }
end
# Avoid repeating label queries times when the finder is instantiated multiple times during the request.
request_cache(:find_label_ids) { root_namespace.id }
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def group_labels_for_root_namespace
Label.where(project_id: nil).where(group_id: root_namespace.self_and_descendant_ids)
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
def project_labels_for_root_namespace
Label.where(group_id: nil).where(project_id: Project.select(:id).where(namespace_id: root_namespace.self_and_descendant_ids))
end
# rubocop: enable CodeReuse/ActiveRecord
# rubocop: disable CodeReuse/ActiveRecord
@ -153,3 +171,5 @@ module Issuables
end
end
end
Issuables::LabelFilter.prepend_mod

View File

@ -2,6 +2,15 @@
module VulnerabilityFindingHelpers
extend ActiveSupport::Concern
# Manually resolvable report types cannot be considered fixed once removed from the
# target branch due to requiring active triage, such as rotation of an exposed token.
REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION = %w[secret_detection].freeze
def requires_manual_resolution?
REPORT_TYPES_REQUIRING_MANUAL_RESOLUTION.include?(report_type)
end
def matches_signatures(other_signatures, other_uuid)
other_signature_types = other_signatures.index_by(&:algorithm_type)

View File

@ -11,8 +11,6 @@ module Ci
def execute
increment_processing_counter
update_retried
Ci::PipelineProcessing::AtomicProcessingService
.new(pipeline)
.execute
@ -24,41 +22,6 @@ module Ci
private
# This method is for compatibility and data consistency and should be removed with 9.3 version of GitLab
# This replicates what is db/post_migrate/20170416103934_upate_retried_for_ci_build.rb
# and ensures that functionality will not be broken before migration is run
# this updates only when there are data that needs to be updated, there are two groups with no retried flag
# rubocop: disable CodeReuse/ActiveRecord
def update_retried
return if Feature.enabled?(:ci_remove_update_retried_from_process_pipeline, pipeline.project, default_enabled: :yaml)
# find the latest builds for each name
latest_statuses = pipeline.latest_statuses
.group(:name)
.having('count(*) > 1')
.pluck(Arel.sql('MAX(id)'), 'name')
# mark builds that are retried
if latest_statuses.any?
updated_count = pipeline.latest_statuses
.where(name: latest_statuses.map(&:second))
.where.not(id: latest_statuses.map(&:first))
.update_all(retried: true)
# This counter is temporary. It will be used to check whether if we still use this method or not
# after setting correct value of `GenericCommitStatus#retried`.
# More info: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50465#note_491657115
if updated_count > 0
Gitlab::AppJsonLogger.info(event: 'update_retried_is_used',
project_id: pipeline.project.id,
pipeline_id: pipeline.id)
metrics.legacy_update_jobs_counter.increment
end
end
end
# rubocop: enable CodeReuse/ActiveRecord
def increment_processing_counter
metrics.pipeline_processing_events_counter.increment
end

View File

@ -16,7 +16,12 @@ module Ci
private
def running_timed_out_builds
Ci::Build.running.updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
if Feature.enabled?(:ci_new_query_for_running_stuck_jobs, default_enabled: :yaml)
running_builds = Ci::Build.running.created_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago).order(created_at: :asc, project_id: :asc) # rubocop: disable CodeReuse/ActiveRecord
Ci::Build.id_in(running_builds).updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
else
Ci::Build.running.updated_at_before(BUILD_RUNNING_OUTDATED_TIMEOUT.ago)
end
end
end
end

View File

@ -0,0 +1,76 @@
# frozen_string_literal: true
module Import
class ValidateRemoteGitEndpointService
# Validates if the remote endpoint is a valid GIT repository
# Only smart protocol is supported
# Validation rules are taken from https://git-scm.com/docs/http-protocol#_smart_clients
GIT_SERVICE_NAME = "git-upload-pack"
GIT_EXPECTED_FIRST_PACKET_LINE = "# service=#{GIT_SERVICE_NAME}"
GIT_BODY_MESSAGE_REGEXP = /^[0-9a-f]{4}#{GIT_EXPECTED_FIRST_PACKET_LINE}/.freeze
# https://github.com/git/git/blob/master/Documentation/technical/protocol-common.txt#L56-L59
GIT_PROTOCOL_PKT_LEN = 4
GIT_MINIMUM_RESPONSE_LENGTH = GIT_PROTOCOL_PKT_LEN + GIT_EXPECTED_FIRST_PACKET_LINE.length
EXPECTED_CONTENT_TYPE = "application/x-#{GIT_SERVICE_NAME}-advertisement"
def initialize(params)
@params = params
end
def execute
uri = Gitlab::Utils.parse_url(@params[:url])
return error("Invalid URL") unless uri
uri.fragment = nil
url = Gitlab::Utils.append_path(uri.to_s, "/info/refs?service=#{GIT_SERVICE_NAME}")
response_body = ''
result = nil
Gitlab::HTTP.try_get(url, stream_body: true, follow_redirects: false, basic_auth: auth) do |fragment|
response_body += fragment
next if response_body.length < GIT_MINIMUM_RESPONSE_LENGTH
result = if status_code_is_valid(fragment) && content_type_is_valid(fragment) && response_body_is_valid(response_body)
:success
else
:error
end
# We are interested only in the first chunks of the response
# So we're using stream_body: true and breaking when receive enough body
break
end
if result == :success
ServiceResponse.success
else
ServiceResponse.error(message: "#{uri} is not a valid HTTP Git repository")
end
end
private
def auth
unless @params[:user].to_s.blank?
{
username: @params[:user],
password: @params[:password]
}
end
end
def status_code_is_valid(fragment)
fragment.http_response.code == '200'
end
def content_type_is_valid(fragment)
fragment.http_response['content-type'] == EXPECTED_CONTENT_TYPE
end
def response_body_is_valid(response_body)
response_body.match?(GIT_BODY_MESSAGE_REGEXP)
end
end
end

View File

@ -2,13 +2,7 @@
%h1.page-title= _('Activity')
.top-area
%ul.nav-links.nav.nav-tabs
%li{ class: active_when(params[:filter].nil?) }>
= link_to activity_dashboard_path, class: 'shortcuts-activity', data: {placement: 'right'} do
= _('Your projects')
%li{ class: active_when(params[:filter] == 'starred') }>
= link_to activity_dashboard_path(filter: 'starred'), data: {placement: 'right'} do
= _('Starred projects')
%li{ class: active_when(params[:filter] == 'followed') }>
= link_to activity_dashboard_path(filter: 'followed'), data: {placement: 'right'} do
= _('Followed users')
= gl_tabs_nav({ class: 'gl-border-b-0', data: { testid: 'dashboard-activity-tabs' } }) do
= gl_tab_link_to _("Your projects"), activity_dashboard_path, { item_active: params[:filter].nil? }
= gl_tab_link_to _("Starred projects"), activity_dashboard_path(filter: 'starred')
= gl_tab_link_to _("Followed users"), activity_dashboard_path(filter: 'followed')

View File

@ -83,7 +83,7 @@
.js-toggle-content.toggle-import-form{ class: ('hide' if active_tab != 'import') }
= form_for @project, html: { class: 'new_project' } do |f|
= form_for @project, html: { class: 'new_project gl-show-field-errors' } do |f|
%hr
= render "shared/import_form", f: f
= render 'projects/new_project_fields', f: f, project_name_id: "import-url-name", hide_init_with_readme: true, track_label: track_label

View File

@ -9,17 +9,12 @@
= f.text_field :import_url, value: import_url.sanitized_url,
autocomplete: 'off', class: 'form-control gl-form-input', placeholder: 'https://gitlab.company.com/group/project.git', required: true
= render 'shared/global_alert',
variant: :warning,
alert_class: 'gl-mt-3 js-import-url-warning hide',
variant: :danger,
alert_class: 'gl-mt-3 js-import-url-error hide',
dismissible: false,
close_button_class: 'js-close-2fa-enabled-success-alert' do
.gl-alert-body
= s_('Import|A repository URL usually ends in a .git suffix, although this is not required. Double check to make sure your repository URL is correct.')
.gl-alert.gl-alert-not-dismissible.gl-alert-warning.gl-mt-3.hide#project_import_url_warning
.gl-alert-container
= sprite_icon('warning-solid', css_class: 'gl-icon s16 gl-alert-icon gl-alert-icon-no-title')
.gl-alert-content{ role: 'alert' }
= s_('Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials.')
.row
.form-group.col-md-6
= f.label :import_url_user, class: 'label-bold' do

View File

@ -1,8 +1,8 @@
---
name: ci_remove_update_retried_from_process_pipeline
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/54300
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/321630
milestone: '13.9'
name: ci_new_query_for_running_stuck_jobs
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71013
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/339264
milestone: '14.4'
type: development
group: group::pipeline authoring
default_enabled: true
group: group::pipeline execution
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: reference_cache_memoization
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71310
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/341849
milestone: '14.4'
type: development
group: group::source code
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: security_report_ingestion_framework
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/66735
rollout_issue_url:
milestone: '14.4'
type: development
group: group::threat insights
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: use_cte_for_any_project_with_shared_runners_enabled
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71452
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/342024
milestone: '14.4'
type: development
group: group::optimize
default_enabled: false

View File

@ -12,6 +12,10 @@ end
namespace :import do
resources :available_namespaces, only: [:index], controller: :available_namespaces
namespace :url do
post :validate
end
resource :github, only: [:create, :new], controller: :github do
post :personal_access_token
get :status

View File

@ -0,0 +1,39 @@
# frozen_string_literal: true
class IndexLabelsUsingVarcharPatternOps < Gitlab::Database::Migration[1.0]
disable_ddl_transaction!
NEW_TITLE_INDEX_NAME = 'index_labels_on_title_varchar'
NEW_PROJECT_ID_TITLE_INDEX_NAME = 'index_labels_on_project_id_and_title_varchar_unique'
NEW_GROUP_ID_TITLE_INDEX_NAME = 'index_labels_on_group_id_and_title_varchar_unique'
NEW_GROUP_ID_INDEX_NAME = 'index_labels_on_group_id'
OLD_TITLE_INDEX_NAME = 'index_labels_on_title'
OLD_PROJECT_ID_TITLE_INDEX_NAME = 'index_labels_on_project_id_and_title_unique'
OLD_GROUP_ID_TITLE_INDEX_NAME = 'index_labels_on_group_id_and_title_unique'
OLD_GROUP_ID_PROJECT_ID_TITLE_INDEX_NAME = 'index_labels_on_group_id_and_project_id_and_title'
def up
add_concurrent_index :labels, :title, order: { title: :varchar_pattern_ops }, name: NEW_TITLE_INDEX_NAME
add_concurrent_index :labels, [:project_id, :title], where: "labels.group_id IS NULL", unique: true, order: { title: :varchar_pattern_ops }, name: NEW_PROJECT_ID_TITLE_INDEX_NAME
add_concurrent_index :labels, [:group_id, :title], where: "labels.project_id IS NULL", unique: true, order: { title: :varchar_pattern_ops }, name: NEW_GROUP_ID_TITLE_INDEX_NAME
add_concurrent_index :labels, :group_id, name: NEW_GROUP_ID_INDEX_NAME
remove_concurrent_index_by_name :labels, OLD_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, OLD_PROJECT_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, OLD_GROUP_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, OLD_GROUP_ID_PROJECT_ID_TITLE_INDEX_NAME
end
def down
add_concurrent_index :labels, :title, name: OLD_TITLE_INDEX_NAME
add_concurrent_index :labels, [:project_id, :title], where: "labels.group_id IS NULL", unique: true, name: OLD_PROJECT_ID_TITLE_INDEX_NAME
add_concurrent_index :labels, [:group_id, :title], where: "labels.project_id IS NULL", unique: true, name: OLD_GROUP_ID_TITLE_INDEX_NAME
add_concurrent_index :labels, [:group_id, :project_id, :title], unique: true, name: OLD_GROUP_ID_PROJECT_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, NEW_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, NEW_PROJECT_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, NEW_GROUP_ID_TITLE_INDEX_NAME
remove_concurrent_index_by_name :labels, NEW_GROUP_ID_INDEX_NAME
end
end

View File

@ -0,0 +1 @@
4430d4e0d688c85768201ab09056d60151fdc949b4b5f4ebc5397a99b9ec5f83

View File

@ -25451,17 +25451,17 @@ CREATE INDEX index_label_priorities_on_priority ON label_priorities USING btree
CREATE UNIQUE INDEX index_label_priorities_on_project_id_and_label_id ON label_priorities USING btree (project_id, label_id);
CREATE UNIQUE INDEX index_labels_on_group_id_and_project_id_and_title ON labels USING btree (group_id, project_id, title);
CREATE INDEX index_labels_on_group_id ON labels USING btree (group_id);
CREATE UNIQUE INDEX index_labels_on_group_id_and_title_unique ON labels USING btree (group_id, title) WHERE (project_id IS NULL);
CREATE UNIQUE INDEX index_labels_on_group_id_and_title_varchar_unique ON labels USING btree (group_id, title varchar_pattern_ops) WHERE (project_id IS NULL);
CREATE INDEX index_labels_on_project_id ON labels USING btree (project_id);
CREATE UNIQUE INDEX index_labels_on_project_id_and_title_unique ON labels USING btree (project_id, title) WHERE (group_id IS NULL);
CREATE UNIQUE INDEX index_labels_on_project_id_and_title_varchar_unique ON labels USING btree (project_id, title varchar_pattern_ops) WHERE (group_id IS NULL);
CREATE INDEX index_labels_on_template ON labels USING btree (template) WHERE template;
CREATE INDEX index_labels_on_title ON labels USING btree (title);
CREATE INDEX index_labels_on_title_varchar ON labels USING btree (title varchar_pattern_ops);
CREATE INDEX index_labels_on_type_and_project_id ON labels USING btree (type, project_id);

View File

@ -162,6 +162,9 @@ be disabled on the **primary** site:
## Finish replicating and verifying all data
NOTE:
GitLab 13.9 through GitLab 14.3 are affected by a bug in which the Geo secondary site statuses will appear to stop updating and become unhealthy. For more information, see [Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](../replication/troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
1. If you are manually replicating any data not managed by Geo, trigger the
final replication process now.
1. On the **primary** node:
@ -192,12 +195,13 @@ At this point, your **secondary** node contains an up-to-date copy of everything
## Promote the **secondary** node
Finally, follow the [Disaster Recovery docs](index.md) to promote the
**secondary** node to a **primary** node. This process causes a brief outage on the **secondary** node, and users may need to log in again.
After the replication is finished, [promote the **secondary** node to a **primary** node](index.md). This process causes a brief outage on the **secondary** node, and users may need to log in again. If you follow the steps correctly, the old primary Geo site should still be disabled and user traffic should go to the newly-promoted site instead.
Once it is completed, the maintenance window is over! Your new **primary** node, now
begin to diverge from the old one. If problems do arise at this point, failing
When the promotion is completed, the maintenance window is over, and your new **primary** node now
begins to diverge from the old one. If problems do arise at this point, failing
back to the old **primary** node [is possible](bring_primary_back.md), but likely to result
in the loss of any data uploaded to the new **primary** in the meantime.
Don't forget to remove the broadcast message after failover is complete.
Don't forget to remove the broadcast message after the failover is complete.
Finally, you can bring the [old site back as a secondary](bring_primary_back.md#configure-the-former-primary-node-to-be-a-secondary-node).

View File

@ -63,6 +63,9 @@ Before following any of those steps, make sure you have `root` access to the
**secondary** to promote it, since there isn't provided an automated way to
promote a Geo replica and perform a failover.
NOTE:
GitLab 13.9 through GitLab 14.3 are affected by a bug in which the Geo secondary site statuses will appear to stop updating and become unhealthy. For more information, see [Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](../../replication/troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
On the **secondary** node:
1. On the top bar, select **Menu > Admin**.

View File

@ -51,6 +51,9 @@ Before following any of those steps, make sure you have `root` access to the
**secondary** to promote it, since there isn't provided an automated way to
promote a Geo replica and perform a failover.
NOTE:
GitLab 13.9 through GitLab 14.3 are affected by a bug in which the Geo secondary site statuses will appear to stop updating and become unhealthy. For more information, see [Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](../../replication/troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
On the **secondary** node, navigate to the **Admin Area > Geo** dashboard to
review its status. Replicated objects (shown in green) should be close to 100%,
and there should be no failures (shown in red). If a large proportion of

View File

@ -83,7 +83,7 @@ Checking Geo ... Finished
#### Sync status Rake task
Current sync information can be found manually by running this Rake task on any
**secondary** app node:
node running Rails (Puma, Sidekiq, or Geo Log Cursor) on the Geo **secondary** site:
```shell
sudo gitlab-rake geo:status
@ -923,6 +923,14 @@ To resolve this issue:
If using a load balancer, ensure that the load balancer's URL is set as the `external_url` in the
`/etc/gitlab/gitlab.rb` of the nodes behind the load balancer.
### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
In GitLab 13.9 through GitLab 14.3, when [GitLab Maintenance Mode](../../maintenance_mode/index.md) is enabled, the status of Geo secondary sites will stop getting updated. After 10 minutes, the status will become `Unhealthy`.
Geo secondary sites will continue to replicate and verify data, and the secondary sites should still be usable. You can use the [Sync status Rake task](#sync-status-rake-task) to determine the actual status of a secondary site during Maintenance Mode.
This bug was [fixed in GitLab 14.4](https://gitlab.com/gitlab-org/gitlab/-/issues/292983).
### GitLab Pages return 404 errors after promoting
This is due to [Pages data not being managed by Geo](datatypes.md#limitations-on-replicationverification).

View File

@ -13,6 +13,8 @@ for updating Geo nodes.
## Updating to 14.1, 14.2, 14.3
### Multi-arch images
We found an [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/336013) where the Container Registry replication wasn't fully working if you used multi-arch images. In case of a multi-arch image, only the primary architecture (for example `amd64`) would be replicated to the secondary node. This has been [fixed in GitLab 14.3](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67624) and was backported to 14.2 and 14.1, but manual steps are required to force a re-sync.
You can check if you are affected by running:
@ -46,18 +48,28 @@ Otherwise, on all your **secondary** nodes, in a [Rails console](../../operation
If you are running a version prior to 14.1 and are using Geo and multi-arch containers in your Container Registry, we recommend [upgrading](updating_the_geo_sites.md) to at least GitLab 14.1.
### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
## Updating to GitLab 14.0/14.1
### Primary sites can not be removed from the UI
We found an issue where [Primary sites can not be removed from the UI](https://gitlab.com/gitlab-org/gitlab/-/issues/338231).
This bug only exists in the UI and does not block the removal of Primary sites using any other method.
### If you have already updated to an affected version and need to remove your Primary site
If you are running an affected version and need to remove your Primary site, you can manually remove the Primary site by using the [Geo Nodes API](../../../api/geo_nodes.md#delete-a-geo-node).
You can manually remove the Primary site by using the [Geo Nodes API](../../../api/geo_nodes.md#delete-a-geo-node).
### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
## Updating to GitLab 13.12
### Secondary nodes re-download all LFS files upon update
We found an issue where [secondary nodes re-download all LFS files](https://gitlab.com/gitlab-org/gitlab/-/issues/334550) upon update. This bug:
- Only applies to Geo secondary sites that have replicated LFS objects.
@ -68,7 +80,7 @@ We found an issue where [secondary nodes re-download all LFS files](https://gitl
If you don't have many LFS objects or can stand a bit of churn, then it is safe to let the secondary sites re-download LFS objects.
If you do have many LFS objects, or many Geo secondary sites, or limited bandwidth, or a combination of them all, then we recommend you skip GitLab 13.12.0 through 13.12.6 and update to GitLab 13.12.7 or newer.
### If you have already updated to an affected version, and the re-sync is ongoing
#### If you have already updated to an affected version, and the re-sync is ongoing
You can manually migrate the legacy sync state to the new state column by running the following command in a [Rails console](../../operations/rails_console.md). It should take under a minute:
@ -76,15 +88,31 @@ You can manually migrate the legacy sync state to the new state column by runnin
Geo::LfsObjectRegistry.where(state: 0, success: true).update_all(state: 2)
```
### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
## Updating to GitLab 13.11
We found an [issue with Git clone/pull through HTTP(s)](https://gitlab.com/gitlab-org/gitlab/-/issues/330787) on Geo secondaries and on any GitLab instance if maintenance mode is enabled. This was caused by a regression in GitLab Workhorse. This is fixed in the [GitLab 13.11.4 patch release](https://about.gitlab.com/releases/2021/05/14/gitlab-13-11-4-released/). To avoid this issue, upgrade to GitLab 13.11.4 or later.
### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
## Updating to GitLab 13.10
### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
## Updating to GitLab 13.9
### Error during zero-downtime update: "cannot drop column asset_proxy_whitelist"
We've detected an issue [with a column rename](https://gitlab.com/gitlab-org/gitlab/-/issues/324160)
that will prevent upgrades to GitLab 13.9.0, 13.9.1, 13.9.2 and 13.9.3 when following the zero-downtime steps. It is necessary
to perform the following additional steps for the zero-downtime upgrade:
to perform the following additional steps for the zero-downtime update:
1. Before running the final `sudo gitlab-rake db:migrate` command on the deploy node,
execute the following queries using the PostgreSQL console (or `sudo gitlab-psql`)
@ -118,6 +146,10 @@ DETAIL: trigger trigger_0d588df444c8 on table application_settings depends on co
To work around this bug, follow the previous steps to complete the update.
More details are available [in this issue](https://gitlab.com/gitlab-org/gitlab/-/issues/324160).
### Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode
GitLab 13.9 through GitLab 14.3 are affected by a bug in which enabling [GitLab Maintenance Mode](../../maintenance_mode/index.md) will cause Geo secondary site statuses to appear to stop updating and become unhealthy. For more information, see [Troubleshooting - Geo Admin Area shows 'Unhealthy' after enabling Maintenance Mode](troubleshooting.md#geo-admin-area-shows-unhealthy-after-enabling-maintenance-mode).
## Updating to GitLab 13.7
We've detected an issue with the `FetchRemove` call used by Geo secondaries.

View File

@ -3405,7 +3405,6 @@ Possible values for `when` are:
- `api_failure`: Retry on API failure.
- `stuck_or_timeout_failure`: Retry when the job got stuck or timed out.
- `runner_system_failure`: Retry if there is a runner system failure (for example, job setup failed).
- `missing_dependency_failure`: Retry if a dependency is missing.
- `runner_unsupported`: Retry if the runner is unsupported.
- `stale_schedule`: Retry if a delayed job could not be executed.
- `job_execution_timeout`: Retry if the script exceeded the maximum execution time set for the job.

View File

@ -0,0 +1,130 @@
---
stage: Platforms
group: Scalability
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# GitLab Application Service Level Indicators (SLIs)
> [Introduced](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/525) in GitLab 14.4
It is possible to define [Service Level Indicators
(SLIs)](https://en.wikipedia.org/wiki/Service_level_indicator)
directly in the Ruby codebase. This keeps the definition of operations
and their success close to the implementation and allows the people
building features to easily define how these features should be
monitored.
Defining an SLI causes 2
[Prometheus
counters](https://prometheus.io/docs/concepts/metric_types/#counter)
to be emitted from the rails application:
- `gitlab_sli:<sli name>:total`: incremented for each operation.
- `gitlab_sli:<sli_name>:success_total`: incremented for successful
operations.
## Existing SLIs
1. [`rails_request_apdex`](rails_request_apdex.md)
## Defining a new SLI
An SLI can be defined using the `Gitlab::Metrics::Sli` class.
Before the first scrape, it is important to have [initialized the SLI
with all possible
label-combinations](https://prometheus.io/docs/practices/instrumentation/#avoid-missing-metrics). This
avoid confusing results when using these counters in calculations.
To initialize an SLI, use the `.inilialize_sli` class method, for
example:
```ruby
Gitlab::Metrics::Sli.initialize_sli(:received_email, [
{
feature_category: :issue_tracking,
email_type: :create_issue
},
{
feature_category: :service_desk,
email_type: :service_desk
},
{
feature_category: :code_review,
email_type: :create_merge_request
}
])
```
Metrics must be initialized before they get
scraped for the first time. This could be done at the start time of the
process that will emit them, in which case we need to pay attention
not to increase application's boot time too much. This is preferable
if possible.
Alternatively, if initializing would take too long, this can be done
during the first scrape. We need to make sure we don't do it for every
scrape. This can be done as follows:
```ruby
def initialize_request_slis_if_needed!
return if Gitlab::Metrics::Sli.initialized?(:rails_request_apdex)
Gitlab::Metrics::Sli.initialize_sli(:rails_request_apdex, possible_request_labels)
end
```
Also pay attention to do it for the different metrics
endpoints we have. Currently the
[`WebExporter`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/metrics/exporter/web_exporter.rb)
and the
[`HealthController`](https://gitlab.com/gitlab-org/gitlab/blob/master/app/controllers/health_controller.rb)
for Rails and
[`SidekiqExporter`](https://gitlab.com/gitlab-org/gitlab/blob/master/lib/gitlab/metrics/exporter/sidekiq_exporter.rb)
for Sidekiq.
## Tracking operations for an SLI
Tracking an operation in the newly defined SLI can be done like this:
```ruby
Gitlab::Metrics::Sli[:received_email].increment(
labels: {
feature_category: :service_desk,
email_type: :service_desk
},
success: issue_created?
)
```
Calling `#increment` on this SLI will increment the total Prometheus counter
```prometheus
gitlab_sli:received_email:total{ feature_category='service_desk', email_type='service_desk' }
```
If the `success:` argument passed is truthy, then the success counter
will also be incremented:
```prometheus
gitlab_sli:received_email:success_total{ feature_category='service_desk', email_type='service_desk' }
```
## Using the SLI in service monitoring and alerts
When the application is emitting metrics for the new SLI, those need
to be consumed in the service catalog to result in alerts, and be
included in the error budget for stage groups and GitLab.com's overall
availability.
This is currently being worked on in [this
project](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/573). As
part of [this
issue](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues/1307)
we will update the documentation.
For any question, please don't hesitate to createan issue in [the
Scalability issue
tracker](https://gitlab.com/gitlab-com/gl-infra/scalability/-/issues)
or come find us in
[#g_scalability](https://gitlab.slack.com/archives/CMMF8TKR9) on Slack.

View File

@ -0,0 +1,230 @@
---
stage: Platforms
group: Scalability
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# Rails request apdex SLI
> [Introduced](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/525) in GitLab 14.4
NOTE:
This SLI is not yet used in [error budgets for stage
groups](../stage_group_dashboards.md#error-budget) or service
monitoring. This is being worked on in [this
project](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/573).
The request apdex SLI is [an SLI defined in the application](index.md)
that measures the duration of successful requests as an indicator for
application performance. This includes the REST and GraphQL API, and the
regular controller endpoints. It consists of these counters:
1. `gitlab_sli:rails_request_apdex:total`: This counter gets
incremented for every request that did not result in a response
with a 5xx status code. This means that slow failures don't get
counted twice: The request is already counted in the error-SLI.
1. `gitlab_sli:rails_request_apdex:success_total`: This counter gets
incremented for every successful request that performed faster than
the [defined target duration](#adjusting-request-target-duration).
Both these counters are labeled with:
1. `endpoint_id`: The identification of the Rails Controller or the
Grape-API endpoint
1. `feature_category`: The feature category specified for that
controller or API endpoint.
## Request Apdex SLO
These counters can be combined into a success ratio, the objective for
this ratio is defined in the service catalog per service:
1. [Web: 0.998](https://gitlab.com/gitlab-com/runbooks/blob/master/metrics-catalog/services/web.jsonnet#L19)
1. [API: 0.995](https://gitlab.com/gitlab-com/runbooks/blob/master/metrics-catalog/services/api.jsonnet#L19)
1. [Git: 0.998](https://gitlab.com/gitlab-com/runbooks/blob/master/metrics-catalog/services/git.jsonnet#L22)
This means that for this SLI to meet SLO, the ratio recorded needs to
be higher than those defined above.
For example: for the web-service, we want at least 99.8% of requests
to be faster than their target duration.
These are the targets we use for alerting and service montoring. So
durations should be set keeping those into account.
Both successful measurements and unsuccessful ones have an impact on the
error budget for stage groups.
## Adjusting request target duration
Not all endpoints perform the same type of work, so it is possible to
define different durations for different endpoints.
Long-running requests are more expensive for our
infrastructure: while one request is being served, the thread remains
occupied for the duration of that request. So nothing else can be handled by that
thread. Because of Ruby's Global VM Lock, the thread might keep the
lock and stall other requests handled by the same Puma worker
process. The request is in fact a noisy neighbor for other requests
handled by the worker. This is why the upper bound for a target
duration is capped at 5 seconds.
## Increasing the target duration (setting a slower target)
Increasing the target duration on an existing endpoint can be done on
a case-by-case basis. Please take the following into account:
1. Apdex is about perceived performance, if a user is actively waiting
for the result of a request, waiting 5 seconds might not be
acceptable. While if the endpoint is used by an automation
requiring a lot of data, 5 seconds could be okay.
A product manager can help to identify how an endpoint is used.
1. The workload for some endpoints can sometimes differ greatly
depending on the parameters specified by the caller. The target
duration needs to accomodate that. In some cases, it might be
interesting to define a separate [application
SLI](index.md#defining-a-new-sli) for what the endpoint is doing.
When the endpoints in certain cases turn into no-ops, making them
very fast, we should ignore these fast requests when setting the
target. For example, if the `MergeRequests::DraftsController` is
hit for every merge request being viewed, but doesn't need to
render anything in most cases, then we should pick the target that
would still accomodate the endpoint performing work.
1. Consider the dependent resources consumed by the endpoint. If the endpoint
loads a lot of data from Gitaly or the database and this is causing
it to not perform satisfactory. It could be better to optimize the
way the data is loaded rather than increasing the target duration.
In cases like this, it might be appropriate to temporarily increase
the duration to make the endpoint meet SLO, if this is bearable for
the infrastructure. In such cases, please link an issue from a code
comment.
If the endpoint consumes a lot of CPU time, we should also consider
this: these kinds of requests are the kind of noisy neighbors we
should try to keep as short as possible.
1. Traffic characteristics should also be taken into account: if the
trafic to the endpoint is bursty, like CI traffic spinning up a
big batch of jobs hitting the same endpoint, then having these
endpoints take 5s is not acceptable from an infrastructure point of
view. We cannot scale up the fleet fast enough to accomodate for
the incoming slow requests alongside the regular traffic.
When increasing the target duration for an existing endpoint, please
involve a [Scalability team
member](https://about.gitlab.com/handbook/engineering/infrastructure/team/scalability/#team-members)
in the review. We can use request rates and durations available in the
logs to come up with a recommendation. Picking a threshold can be done
using the same process as for [decreasing a target
duration](#decreasing-a-target-duration-setting-a-faster-target), picking a duration that is
higher than the SLO for the service.
We shouldn't set the longest durations on endpoints in the merge
requests that introduces them, since we don't yet have data to support
the decision.
## Decreasing a target duration (setting a faster target)
When decreasing the target duration, we need to make sure the endpoint
still meets SLO for the fleet that handles the request. You can use the
information in the logs to determine this:
1. Open [this table in
Kibana](https://log.gprd.gitlab.net/goto/bbb6465c68eb83642269e64a467df3df)
1. The table loads information for the busiest endpoints by
default. You can speed things up by adding a filter for
`json.caller_id.keyword` and adding the identifier you're intersted
in (for example: `Projects::RawController#show`).
1. Check the [appropriate percentile duration](#request-apdex-slo) for
the service the endpoint is handled by. The overall duration should
be lower than the target you intend to set.
1. If the overall duration is below the intended targed. Please also
check the peaks over time in [this
graph](https://log.gprd.gitlab.net/goto/9319c4a402461d204d13f3a4924a89fc)
in Kibana. Here, the percentile in question should not peak above
the target duration we want to set.
Since decreasing a threshold too much could result in alerts for the
apdex degradation, please also involve a Scalability team member in
the merge reqeust.
## How to adjust the target duration
The target duration can be specified similar to how endpoints [get a
feature category](../feature_categorization/index.md).
For endpoints that don't have a specific target, the default of 1s
(medium) will be used.
The following configurations are available:
| Name | Duration in seconds | Notes |
|------------|---------------------|-----------------------------------------------|
| :very_fast | 0.25s | |
| :fast | 0.5s | |
| :medium | 1s | This is the default when nothing is specified |
| :slow | 5s | |
### Rails controller
A duration can be specified for all actions in a controller like this:
```ruby
class Boards::ListsController < ApplicationController
target_duration :fast
end
```
To specify the duration also for certain actions in a controller, they
can be specified like this:
```ruby
class Boards::ListsController < ApplicationController
target_duration :fast, [:index, :show]
end
```
### Grape endpoints
To specify the duration for an entire API class, this can be done as
follows:
```ruby
module API
class Issues < ::API::Base
target_duration :slow
end
end
```
To specify the duration also for certain actions in a API class, they
can be specified like this:
```ruby
module API
class Issues < ::API::Base
target_duration :fast, [
'/groups/:id/issues',
'/groups/:id/issues_statistics'
]
end
end
```
Or, we can specify a custom duration per endpoint:
```ruby
get 'client/features', target_duration: :fast do
# endpoint logic
end
```

View File

@ -334,6 +334,7 @@ See [database guidelines](database/index.md).
- [Features inside `.gitlab/`](features_inside_dot_gitlab.md)
- [Dashboards for stage groups](stage_group_dashboards.md)
- [Preventing transient bugs](transient/prevention-patterns.md)
- [GitLab Application SLIs](application_slis/index.md)
## Other GitLab Development Kit (GDK) guides

View File

@ -1,6 +1,6 @@
---
stage: Enablement
group: Infrastructure
stage: Platforms
group: Scalability
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
@ -58,6 +58,12 @@ component can have 2 indicators:
[Web](https://gitlab.com/gitlab-com/runbooks/-/blob/f22f40b2c2eab37d85e23ccac45e658b2c914445/metrics-catalog/services/web.jsonnet#L154)
services, that threshold is **5 seconds**.
We're working on making this target configurable per endpoint in [this
project](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/525). Learn
how to [customize the request
apdex](application_slis/rails_request_apdex.md), this new apdex
measurement is not yet part of the error budget.
For Sidekiq job execution, the threshold depends on the [job
urgency](sidekiq_style_guide.md#job-urgency). It is
[currently](https://gitlab.com/gitlab-com/runbooks/-/blob/f22f40b2c2eab37d85e23ccac45e658b2c914445/metrics-catalog/services/lib/sidekiq-helpers.libsonnet#L25-38)

View File

@ -180,6 +180,19 @@ For example:
1. GitLab automatically removes the `priority::low` label, as an issue should not
have two priority labels at the same time.
### Filter by scoped labels
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/12285) in GitLab 14.4.
To filter issue, merge request, or epic lists for ones with labels that belong to a given scope, enter
`<scope>::*` in the searched label name.
For example, filtering by the `platform::*` label returns issues that have `platform::iOS`,
`platform::Android`, or `platform::Linux` labels.
NOTE:
This is not available on the [issues or merge requests dashboard pages](../search/index.md#issues-and-merge-requests).
### Workflows with scoped labels
Suppose you wanted a custom field in issues to track the operating system platform

View File

@ -11,7 +11,7 @@ module Banzai
def initialize(doc, context = nil, result = nil)
super
@reference_cache = ReferenceCache.new(self, context)
@reference_cache = ReferenceCache.new(self, context, result)
end
# REFERENCE_PLACEHOLDER is used for re-escaping HTML text except found

View File

@ -7,9 +7,10 @@ module Banzai
include Gitlab::Utils::StrongMemoize
include RequestStoreReferenceCache
def initialize(filter, context)
def initialize(filter, context, result)
@filter = filter
@context = context
@result = result || {}
end
def load_reference_cache(nodes)
@ -166,7 +167,7 @@ module Banzai
private
attr_accessor :filter, :context
attr_accessor :filter, :context, :result
delegate :project, :group, :parent, :parent_type, to: :filter
@ -184,7 +185,11 @@ module Banzai
end
def prepare_doc_for_scan(doc)
html = doc.to_html
html = if Feature.enabled?(:reference_cache_memoization, project, default_enabled: :yaml)
result[:rendered_html] ||= doc.to_html
else
doc.to_html
end
filter.requires_unescaping? ? unescape_html_entities(html) : html
end

View File

@ -9,7 +9,8 @@ class Gitlab::Ci::Build::AutoRetry
RETRY_OVERRIDES = {
ci_quota_exceeded: 0,
no_matching_runner: 0
no_matching_runner: 0,
missing_dependency_failure: 0
}.freeze
def initialize(build)

View File

@ -65,13 +65,6 @@ module Gitlab
Gitlab::Metrics.counter(name, comment)
end
def self.legacy_update_jobs_counter
name = :ci_legacy_update_jobs_as_retried_total
comment = 'Counter of occurrences when jobs were not being set as retried before update_retried'
Gitlab::Metrics.counter(name, comment)
end
def self.pipeline_failure_reason_counter
name = :gitlab_ci_pipeline_failure_reasons
comment = 'Counter of pipeline failure reasons'

View File

@ -80,6 +80,8 @@ module Gitlab
matcher = FindingMatcher.new(head_findings)
base_findings.each do |base_finding|
next if base_finding.requires_manual_resolution?
matched_head_finding = matcher.find_and_remove_match!(base_finding)
@fixed_findings << base_finding if matched_head_finding.nil?

View File

@ -6760,6 +6760,9 @@ msgstr ""
msgid "Checkout|Zip code"
msgstr ""
msgid "Checkout|a storage subscription"
msgstr ""
msgid "Checkout|company or team"
msgstr ""
@ -9510,9 +9513,6 @@ msgstr ""
msgid "Create a Mattermost team for this group"
msgstr ""
msgid "Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies."
msgstr ""
msgid "Create a local proxy for storing frequently used upstream images. %{link_start}Learn more%{link_end} about dependency proxies."
msgstr ""
@ -11160,6 +11160,12 @@ msgstr ""
msgid "Dependency proxy image prefix"
msgstr ""
msgid "DependencyProxy|Create a local proxy for storing frequently used upstream images. %{docLinkStart}Learn more%{docLinkEnd} about dependency proxies."
msgstr ""
msgid "DependencyProxy|Dependency Proxy"
msgstr ""
msgid "DependencyProxy|Toggle Dependency Proxy"
msgstr ""
@ -17327,7 +17333,7 @@ msgstr[1] ""
msgid "Importing..."
msgstr ""
msgid "Import|A repository URL usually ends in a .git suffix, although this is not required. Double check to make sure your repository URL is correct."
msgid "Import|There is not a valid Git repository at this URL. If your HTTP repository is not publicly accessible, verify your credentials."
msgstr ""
msgid "Improve customer support with Service Desk"

View File

@ -7,11 +7,11 @@ module QA
class PackageRegistries < QA::Page::Base
include ::QA::Page::Settings::Common
view 'app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue' do
view 'app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue' do
element :package_registry_settings_content
end
view 'app/assets/javascripts/packages_and_registries/settings/group/components/group_settings_app.vue' do
view 'app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue' do
element :allow_duplicates_toggle
element :allow_duplicates_label
end

View File

@ -8,6 +8,10 @@ class AutomatedCleanup
attr_reader :project_path, :gitlab_token
DEPLOYMENTS_PER_PAGE = 100
ENVIRONMENT_PREFIX = {
review_app: 'review/',
docs_review_app: 'review-docs/'
}.freeze
IGNORED_HELM_ERRORS = [
'transport is closing',
'error upgrading connection',
@ -62,13 +66,14 @@ class AutomatedCleanup
releases_to_delete = []
# Delete environments via deployments
gitlab.deployments(project_path, per_page: DEPLOYMENTS_PER_PAGE, sort: 'desc').auto_paginate do |deployment|
break if Time.parse(deployment.created_at) < deployments_look_back_threshold
environment = deployment.environment
next unless environment
next unless environment.name.start_with?('review/')
next unless environment.name.start_with?(ENVIRONMENT_PREFIX[:review_app])
next if checked_environments.include?(environment.slug)
last_deploy = deployment.created_at
@ -92,6 +97,10 @@ class AutomatedCleanup
checked_environments << environment.slug
end
delete_stopped_environments(environment_type: :review_app, checked_environments: checked_environments, last_updated_threshold: delete_threshold) do |environment|
releases_to_delete << Tooling::Helm3Client::Release.new(environment.slug, 1, environment.updated_at, nil, nil, review_apps_namespace)
end
delete_helm_releases(releases_to_delete)
end
@ -102,14 +111,12 @@ class AutomatedCleanup
stop_threshold = threshold_time(days: days_for_stop)
delete_threshold = threshold_time(days: days_for_delete)
max_delete_count = 1000
delete_count = 0
# Delete environments via deployments
gitlab.deployments(project_path, per_page: DEPLOYMENTS_PER_PAGE, sort: 'desc').auto_paginate do |deployment|
environment = deployment.environment
next unless environment
next unless environment.name.start_with?('review-docs/')
next unless environment.name.start_with?(ENVIRONMENT_PREFIX[:docs_review_app])
next if checked_environments.include?(environment.slug)
last_deploy = deployment.created_at
@ -120,15 +127,12 @@ class AutomatedCleanup
stop_environment(environment, deployment) if environment_state && environment_state != 'stopped'
end
if deployed_at < delete_threshold
delete_environment(environment, deployment)
delete_count += 1
break if delete_count > max_delete_count
end
delete_environment(environment, deployment) if deployed_at < delete_threshold
checked_environments << environment.slug
end
delete_stopped_environments(environment_type: :docs_review_app, checked_environments: checked_environments, last_updated_threshold: delete_threshold)
end
def perform_helm_releases_cleanup!(days:)
@ -171,8 +175,9 @@ class AutomatedCleanup
nil
end
def delete_environment(environment, deployment)
print_release_state(subject: 'Review app', release_name: environment.slug, release_date: deployment.created_at, action: 'deleting')
def delete_environment(environment, deployment = nil)
release_date = deployment ? deployment.created_at : environment.updated_at
print_release_state(subject: 'Review app', release_name: environment.slug, release_date: release_date, action: 'deleting')
gitlab.delete_environment(project_path, environment.id)
rescue Gitlab::Error::Forbidden
@ -187,6 +192,24 @@ class AutomatedCleanup
puts "Review app '#{environment.name}' / '#{environment.slug}' (##{environment.id}) is forbidden: skipping it"
end
def delete_stopped_environments(environment_type:, checked_environments:, last_updated_threshold:)
gitlab.environments(project_path, per_page: DEPLOYMENTS_PER_PAGE, sort: 'desc', states: 'stopped', search: ENVIRONMENT_PREFIX[environment_type]).auto_paginate do |environment|
next if skip_environment?(environment: environment, checked_environments: checked_environments, last_updated_threshold: delete_threshold, environment_type: environment_type)
yield environment if delete_environment(environment)
checked_environments << environment.slug
end
end
def skip_environment?(environment:, checked_environments:, last_updated_threshold:, environment_type:)
return true unless environment.name.start_with?(ENVIRONMENT_PREFIX[environment_type])
return true if checked_environments.include?(environment.slug)
return true if Time.parse(environment.updated_at) > last_updated_threshold
false
end
def helm_releases
args = ['--all', '--date']

View File

@ -13,19 +13,19 @@ RSpec.describe 'Dashboard > Activity' do
it 'shows Your Projects' do
visit activity_dashboard_path
expect(find('.top-area .nav-tabs li.active')).to have_content('Your projects')
expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Your projects')
end
it 'shows Starred Projects' do
visit activity_dashboard_path(filter: 'starred')
expect(find('.top-area .nav-tabs li.active')).to have_content('Starred projects')
expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Starred projects')
end
it 'shows Followed Projects' do
visit activity_dashboard_path(filter: 'followed')
expect(find('.top-area .nav-tabs li.active')).to have_content('Followed users')
expect(find('[data-testid="dashboard-activity-tabs"] a.active')).to have_content('Followed users')
end
end

View File

@ -296,12 +296,16 @@ RSpec.describe 'New project', :js do
expect(git_import_instructions).to have_content 'Git repository URL'
end
it 'reports error if repo URL does not end with .git' do
it 'reports error if repo URL is not a valid Git repository' do
stub_request(:get, "http://foo/bar/info/refs?service=git-upload-pack").to_return(status: 200, body: "not-a-git-repo")
fill_in 'project_import_url', with: 'http://foo/bar'
# simulate blur event
find('body').click
expect(page).to have_text('A repository URL usually ends in a .git suffix')
wait_for_requests
expect(page).to have_text('There is not a valid Git repository at this URL')
end
it 'keeps "Import project" tab open after form validation error' do

View File

@ -1,5 +1,6 @@
import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'helpers/test_constants';
import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue';
import getProjects from '~/analytics/shared/graphql/projects.query.graphql';
@ -25,6 +26,17 @@ const projects = [
},
];
const MockGlDropdown = stubComponent(GlDropdown, {
template: `
<div>
<div data-testid="vsa-highlighted-items">
<slot name="highlighted-items"></slot>
</div>
<div data-testid="vsa-default-items"><slot></slot></div>
</div>
`,
});
const defaultMocks = {
$apollo: {
query: jest.fn().mockResolvedValue({
@ -38,22 +50,32 @@ let spyQuery;
describe('ProjectsDropdownFilter component', () => {
let wrapper;
const createComponent = (props = {}) => {
const createComponent = (props = {}, stubs = {}) => {
spyQuery = defaultMocks.$apollo.query;
wrapper = mount(ProjectsDropdownFilter, {
wrapper = mountExtended(ProjectsDropdownFilter, {
mocks: { ...defaultMocks },
propsData: {
groupId: 1,
groupNamespace: 'gitlab-org',
...props,
},
stubs,
});
};
const createWithMockDropdown = (props) => {
createComponent(props, { GlDropdown: MockGlDropdown });
return wrapper.vm.$nextTick();
};
afterEach(() => {
wrapper.destroy();
});
const findHighlightedItems = () => wrapper.findByTestId('vsa-highlighted-items');
const findHighlightedItemsTitle = () => wrapper.findByText('Selected');
const findClearAllButton = () => wrapper.findByText('Clear all');
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItems = () =>
@ -75,8 +97,19 @@ describe('ProjectsDropdownFilter component', () => {
const findDropdownFullPathAtIndex = (index) =>
findDropdownAtIndex(index).find('[data-testid="project-full-path"]');
const selectDropdownItemAtIndex = (index) =>
const selectDropdownItemAtIndex = (index) => {
findDropdownAtIndex(index).find('button').trigger('click');
return wrapper.vm.$nextTick();
};
// NOTE: Selected items are now visually separated from unselected items
const findSelectedDropdownItems = () => findHighlightedItems().findAll(GlDropdownItem);
const findSelectedDropdownAtIndex = (index) => findSelectedDropdownItems().at(index);
const findSelectedButtonIdentIconAtIndex = (index) =>
findSelectedDropdownAtIndex(index).find('div.gl-avatar-identicon');
const findSelectedButtonAvatarItemAtIndex = (index) =>
findSelectedDropdownAtIndex(index).find('img.gl-avatar');
const selectedIds = () => wrapper.vm.selectedProjects.map(({ id }) => id);
@ -109,7 +142,62 @@ describe('ProjectsDropdownFilter component', () => {
});
});
describe('when passed a an array of defaultProject as prop', () => {
describe('highlighted items', () => {
const blockDefaultProps = { multiSelect: true };
beforeEach(() => {
createComponent(blockDefaultProps);
});
describe('with no project selected', () => {
it('does not render the highlighted items', async () => {
await createWithMockDropdown(blockDefaultProps);
expect(findSelectedDropdownItems().length).toBe(0);
});
it('does not render the highlighted items title', () => {
expect(findHighlightedItemsTitle().exists()).toBe(false);
});
it('does not render the clear all button', () => {
expect(findClearAllButton().exists()).toBe(false);
});
});
describe('with a selected project', () => {
beforeEach(async () => {
await selectDropdownItemAtIndex(0);
});
it('renders the highlighted items', async () => {
await createWithMockDropdown(blockDefaultProps);
await selectDropdownItemAtIndex(0);
expect(findSelectedDropdownItems().length).toBe(1);
});
it('renders the highlighted items title', () => {
expect(findHighlightedItemsTitle().exists()).toBe(true);
});
it('renders the clear all button', () => {
expect(findClearAllButton().exists()).toBe(true);
});
it('clears all selected items when the clear all button is clicked', async () => {
await selectDropdownItemAtIndex(1);
expect(wrapper.text()).toContain('2 projects selected');
findClearAllButton().trigger('click');
await wrapper.vm.$nextTick();
expect(wrapper.text()).not.toContain('2 projects selected');
expect(wrapper.text()).toContain('Select projects');
});
});
});
describe('when passed an array of defaultProject as prop', () => {
beforeEach(() => {
createComponent({
defaultProjects: [projects[0]],
@ -130,8 +218,9 @@ describe('ProjectsDropdownFilter component', () => {
});
describe('when multiSelect is false', () => {
const blockDefaultProps = { multiSelect: false };
beforeEach(() => {
createComponent({ multiSelect: false });
createComponent(blockDefaultProps);
});
describe('displays the correct information', () => {
@ -183,21 +272,19 @@ describe('ProjectsDropdownFilter component', () => {
});
it('renders an avatar in the dropdown button when the project has an avatarUrl', async () => {
selectDropdownItemAtIndex(0);
await createWithMockDropdown(blockDefaultProps);
await selectDropdownItemAtIndex(0);
await wrapper.vm.$nextTick().then(() => {
expect(findDropdownButtonAvatarAtIndex(0).exists()).toBe(true);
expect(findDropdownButtonIdentIconAtIndex(0).exists()).toBe(false);
});
expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(true);
expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(false);
});
it("renders an identicon in the dropdown button when the project doesn't have an avatarUrl", async () => {
selectDropdownItemAtIndex(1);
await createWithMockDropdown(blockDefaultProps);
await selectDropdownItemAtIndex(1);
await wrapper.vm.$nextTick().then(() => {
expect(findDropdownButtonAvatarAtIndex(1).exists()).toBe(false);
expect(findDropdownButtonIdentIconAtIndex(1).exists()).toBe(true);
});
expect(findSelectedButtonAvatarItemAtIndex(0).exists()).toBe(false);
expect(findSelectedButtonIdentIconAtIndex(0).exists()).toBe(true);
});
});
});

View File

@ -1,28 +1,20 @@
import { GlSprintf, GlLink, GlAlert } from '@gitlab/ui';
import { GlAlert } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { nextTick } from 'vue';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import PackagesSettings from '~/packages_and_registries/settings/group/components/packages_settings.vue';
import component from '~/packages_and_registries/settings/group/components/group_settings_app.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
PACKAGES_DOCS_PATH,
ERROR_UPDATING_SETTINGS,
SUCCESS_UPDATING_SETTINGS,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
import {
groupPackageSettingsMock,
groupPackageSettingsMutationMock,
groupPackageSettingsMutationErrorMock,
} from '../mock_data';
import { groupPackageSettingsMock, packageSettings } from '../mock_data';
jest.mock('~/flash');
@ -39,35 +31,18 @@ describe('Group Settings App', () => {
};
const mountComponent = ({
provide = defaultProvide,
resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock),
mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()),
data = {},
} = {}) => {
localVue.use(VueApollo);
const requestHandlers = [
[getGroupPackagesSettingsQuery, resolver],
[updateNamespacePackageSettings, mutationResolver],
];
const requestHandlers = [[getGroupPackagesSettingsQuery, resolver]];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMount(component, {
localVue,
apolloProvider,
provide,
data() {
return {
...data,
};
},
stubs: {
GlSprintf,
SettingsBlock,
MavenSettings,
GenericSettings,
},
provide: defaultProvide,
mocks: {
$toast: {
show,
@ -84,271 +59,73 @@ describe('Group Settings App', () => {
wrapper.destroy();
});
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
const findDescription = () => wrapper.find('[data-testid="description"');
const findLink = () => wrapper.findComponent(GlLink);
const findAlert = () => wrapper.findComponent(GlAlert);
const findMavenSettings = () => wrapper.findComponent(MavenSettings);
const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings);
const findGenericSettings = () => wrapper.findComponent(GenericSettings);
const findGenericDuplicatedSettings = () =>
findGenericSettings().findComponent(DuplicatesSettings);
const findPackageSettings = () => wrapper.findComponent(PackagesSettings);
const waitForApolloQueryAndRender = async () => {
await waitForPromises();
await wrapper.vm.$nextTick();
await nextTick();
};
const emitSettingsUpdate = (override) => {
findMavenDuplicatedSettings().vm.$emit('update', {
mavenDuplicateExceptionRegex: ')',
...override,
});
};
it('renders a settings block', () => {
mountComponent();
expect(findSettingsBlock().exists()).toBe(true);
});
it('passes the correct props to settings block', () => {
mountComponent();
expect(findSettingsBlock().props('defaultExpanded')).toBe(false);
});
it('has the correct header text', () => {
mountComponent();
expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER);
});
it('has the correct description text', () => {
mountComponent();
expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION);
});
it('has the correct link', () => {
mountComponent();
expect(findLink().attributes()).toMatchObject({
href: PACKAGES_DOCS_PATH,
target: '_blank',
});
expect(findLink().text()).toBe('Learn more.');
});
it('calls the graphql API with the proper variables', () => {
const resolver = jest.fn().mockResolvedValue(groupPackageSettingsMock);
mountComponent({ resolver });
expect(resolver).toHaveBeenCalledWith({
fullPath: defaultProvide.groupPath,
});
});
describe('maven settings', () => {
it('exists', () => {
describe.each`
finder | entityProp | entityValue
${findPackageSettings} | ${'packageSettings'} | ${packageSettings()}
`('settings blocks', ({ finder, entityProp, entityValue }) => {
beforeEach(() => {
mountComponent();
expect(findMavenSettings().exists()).toBe(true);
return waitForApolloQueryAndRender();
});
it('assigns duplication allowness and exception props', async () => {
mountComponent();
it('renders the settings block', () => {
expect(finder().exists()).toBe(true);
});
expect(findMavenDuplicatedSettings().props('loading')).toBe(true);
await waitForApolloQueryAndRender();
const {
mavenDuplicatesAllowed,
mavenDuplicateExceptionRegex,
} = groupPackageSettingsMock.data.group.packageSettings;
expect(findMavenDuplicatedSettings().props()).toMatchObject({
duplicatesAllowed: mavenDuplicatesAllowed,
duplicateExceptionRegex: mavenDuplicateExceptionRegex,
duplicateExceptionRegexError: '',
loading: false,
it('binds the correctProps', () => {
expect(finder().props()).toMatchObject({
isLoading: false,
[entityProp]: entityValue,
});
});
it('on update event calls the mutation', async () => {
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
mountComponent({ mutationResolver });
await waitForApolloQueryAndRender();
emitSettingsUpdate();
expect(mutationResolver).toHaveBeenCalledWith({
input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
});
});
});
describe('generic settings', () => {
it('exists', () => {
mountComponent();
expect(findGenericSettings().exists()).toBe(true);
});
it('assigns duplication allowness and exception props', async () => {
mountComponent();
expect(findGenericDuplicatedSettings().props('loading')).toBe(true);
await waitForApolloQueryAndRender();
const {
genericDuplicatesAllowed,
genericDuplicateExceptionRegex,
} = groupPackageSettingsMock.data.group.packageSettings;
expect(findGenericDuplicatedSettings().props()).toMatchObject({
duplicatesAllowed: genericDuplicatesAllowed,
duplicateExceptionRegex: genericDuplicateExceptionRegex,
duplicateExceptionRegexError: '',
loading: false,
});
});
it('on update event calls the mutation', async () => {
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
mountComponent({ mutationResolver });
await waitForApolloQueryAndRender();
findMavenDuplicatedSettings().vm.$emit('update', {
genericDuplicateExceptionRegex: ')',
});
expect(mutationResolver).toHaveBeenCalledWith({
input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
});
});
});
describe('settings update', () => {
describe('success state', () => {
it('shows a success alert', async () => {
mountComponent();
await waitForApolloQueryAndRender();
emitSettingsUpdate();
await waitForPromises();
describe('success event', () => {
it('shows a success toast', () => {
finder().vm.$emit('success');
expect(show).toHaveBeenCalledWith(SUCCESS_UPDATING_SETTINGS);
});
it('has an optimistic response', async () => {
const mavenDuplicateExceptionRegex = 'latest[main]something';
mountComponent();
await waitForApolloQueryAndRender();
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe('');
emitSettingsUpdate({ mavenDuplicateExceptionRegex });
// wait for apollo to update the model with the optimistic response
await wrapper.vm.$nextTick();
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(
mavenDuplicateExceptionRegex,
);
// wait for the call to resolve
await waitForPromises();
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe(
mavenDuplicateExceptionRegex,
);
});
});
describe('errors', () => {
const verifyAlert = () => {
expect(findAlert().exists()).toBe(true);
expect(findAlert().text()).toBe(ERROR_UPDATING_SETTINGS);
expect(findAlert().props('variant')).toBe('warning');
};
it('mutation payload with root level errors', async () => {
// note this is a complex test that covers all the path around errors that are shown in the form
// it's one single it case, due to the expensive preparation and execution
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock);
mountComponent({ mutationResolver });
await waitForApolloQueryAndRender();
emitSettingsUpdate();
await waitForApolloQueryAndRender();
// errors are bound to the component
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(
groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message,
);
// general error message is shown
verifyAlert();
emitSettingsUpdate();
await wrapper.vm.$nextTick();
// errors are reset on mutation call
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe('');
});
it.each`
type | mutationResolver
${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))}
${'network'} | ${jest.fn().mockRejectedValue()}
`('mutation payload with $type error', async ({ mutationResolver }) => {
mountComponent({ mutationResolver });
await waitForApolloQueryAndRender();
emitSettingsUpdate();
await waitForPromises();
verifyAlert();
});
it('a successful request dismisses the alert', async () => {
mountComponent({ data: { alertMessage: 'foo' } });
await waitForApolloQueryAndRender();
it('hides the error alert', async () => {
finder().vm.$emit('error');
await nextTick();
expect(findAlert().exists()).toBe(true);
emitSettingsUpdate();
await waitForPromises();
finder().vm.$emit('success');
await nextTick();
expect(findAlert().exists()).toBe(false);
});
});
it('dismiss event from alert dismiss it from the page', async () => {
mountComponent({ data: { alertMessage: 'foo' } });
describe('error event', () => {
beforeEach(() => {
finder().vm.$emit('error');
return nextTick();
});
await waitForApolloQueryAndRender();
it('shows an alert', () => {
expect(findAlert().exists()).toBe(true);
});
it('alert has the right text', () => {
expect(findAlert().text()).toBe(ERROR_UPDATING_SETTINGS);
});
it('dismissing the alert removes it', async () => {
expect(findAlert().exists()).toBe(true);
findAlert().vm.$emit('dismiss');
await wrapper.vm.$nextTick();
await nextTick();
expect(findAlert().exists()).toBe(false);
});

View File

@ -0,0 +1,277 @@
import { GlSprintf, GlLink } from '@gitlab/ui';
import { createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DuplicatesSettings from '~/packages_and_registries/settings/group/components/duplicates_settings.vue';
import GenericSettings from '~/packages_and_registries/settings/group/components/generic_settings.vue';
import component from '~/packages_and_registries/settings/group/components/packages_settings.vue';
import MavenSettings from '~/packages_and_registries/settings/group/components/maven_settings.vue';
import {
PACKAGE_SETTINGS_HEADER,
PACKAGE_SETTINGS_DESCRIPTION,
PACKAGES_DOCS_PATH,
} from '~/packages_and_registries/settings/group/constants';
import updateNamespacePackageSettings from '~/packages_and_registries/settings/group/graphql/mutations/update_group_packages_settings.mutation.graphql';
import getGroupPackagesSettingsQuery from '~/packages_and_registries/settings/group/graphql/queries/get_group_packages_settings.query.graphql';
import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue';
import { updateGroupPackagesSettingsOptimisticResponse } from '~/packages_and_registries/settings/group/graphql/utils/optimistic_responses';
import {
packageSettings,
groupPackageSettingsMock,
groupPackageSettingsMutationMock,
groupPackageSettingsMutationErrorMock,
} from '../mock_data';
jest.mock('~/flash');
jest.mock('~/packages_and_registries/settings/group/graphql/utils/optimistic_responses');
const localVue = createLocalVue();
describe('Packages Settings', () => {
let wrapper;
let apolloProvider;
const defaultProvide = {
defaultExpanded: false,
groupPath: 'foo_group_path',
};
const mountComponent = ({
mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock()),
} = {}) => {
localVue.use(VueApollo);
const requestHandlers = [[updateNamespacePackageSettings, mutationResolver]];
apolloProvider = createMockApollo(requestHandlers);
wrapper = shallowMountExtended(component, {
localVue,
apolloProvider,
provide: defaultProvide,
propsData: {
packageSettings: packageSettings(),
},
stubs: {
GlSprintf,
SettingsBlock,
MavenSettings,
GenericSettings,
},
});
};
afterEach(() => {
wrapper.destroy();
});
const findSettingsBlock = () => wrapper.findComponent(SettingsBlock);
const findDescription = () => wrapper.findByTestId('description');
const findLink = () => wrapper.findComponent(GlLink);
const findMavenSettings = () => wrapper.findComponent(MavenSettings);
const findMavenDuplicatedSettings = () => findMavenSettings().findComponent(DuplicatesSettings);
const findGenericSettings = () => wrapper.findComponent(GenericSettings);
const findGenericDuplicatedSettings = () =>
findGenericSettings().findComponent(DuplicatesSettings);
const fillApolloCache = () => {
apolloProvider.defaultClient.cache.writeQuery({
query: getGroupPackagesSettingsQuery,
variables: {
fullPath: defaultProvide.groupPath,
},
...groupPackageSettingsMock,
});
};
const emitMavenSettingsUpdate = (override) => {
findMavenDuplicatedSettings().vm.$emit('update', {
mavenDuplicateExceptionRegex: ')',
...override,
});
};
it('renders a settings block', () => {
mountComponent();
expect(findSettingsBlock().exists()).toBe(true);
});
it('passes the correct props to settings block', () => {
mountComponent();
expect(findSettingsBlock().props('defaultExpanded')).toBe(false);
});
it('has the correct header text', () => {
mountComponent();
expect(wrapper.text()).toContain(PACKAGE_SETTINGS_HEADER);
});
it('has the correct description text', () => {
mountComponent();
expect(findDescription().text()).toMatchInterpolatedText(PACKAGE_SETTINGS_DESCRIPTION);
});
it('has the correct link', () => {
mountComponent();
expect(findLink().attributes()).toMatchObject({
href: PACKAGES_DOCS_PATH,
target: '_blank',
});
expect(findLink().text()).toBe('Learn more.');
});
describe('maven settings', () => {
it('exists', () => {
mountComponent();
expect(findMavenSettings().exists()).toBe(true);
});
it('assigns duplication allowness and exception props', async () => {
mountComponent();
const { mavenDuplicatesAllowed, mavenDuplicateExceptionRegex } = packageSettings();
expect(findMavenDuplicatedSettings().props()).toMatchObject({
duplicatesAllowed: mavenDuplicatesAllowed,
duplicateExceptionRegex: mavenDuplicateExceptionRegex,
duplicateExceptionRegexError: '',
loading: false,
});
});
it('on update event calls the mutation', () => {
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
mountComponent({ mutationResolver });
fillApolloCache();
emitMavenSettingsUpdate();
expect(mutationResolver).toHaveBeenCalledWith({
input: { mavenDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
});
});
});
describe('generic settings', () => {
it('exists', () => {
mountComponent();
expect(findGenericSettings().exists()).toBe(true);
});
it('assigns duplication allowness and exception props', async () => {
mountComponent();
const { genericDuplicatesAllowed, genericDuplicateExceptionRegex } = packageSettings();
expect(findGenericDuplicatedSettings().props()).toMatchObject({
duplicatesAllowed: genericDuplicatesAllowed,
duplicateExceptionRegex: genericDuplicateExceptionRegex,
duplicateExceptionRegexError: '',
loading: false,
});
});
it('on update event calls the mutation', async () => {
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationMock());
mountComponent({ mutationResolver });
fillApolloCache();
findMavenDuplicatedSettings().vm.$emit('update', {
genericDuplicateExceptionRegex: ')',
});
expect(mutationResolver).toHaveBeenCalledWith({
input: { genericDuplicateExceptionRegex: ')', namespacePath: 'foo_group_path' },
});
});
});
describe('settings update', () => {
describe('success state', () => {
it('emits a success event', async () => {
mountComponent();
fillApolloCache();
emitMavenSettingsUpdate();
await waitForPromises();
expect(wrapper.emitted('success')).toEqual([[]]);
});
it('has an optimistic response', () => {
const mavenDuplicateExceptionRegex = 'latest[main]something';
mountComponent();
fillApolloCache();
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegex')).toBe('');
emitMavenSettingsUpdate({ mavenDuplicateExceptionRegex });
expect(updateGroupPackagesSettingsOptimisticResponse).toHaveBeenCalledWith({
...packageSettings(),
mavenDuplicateExceptionRegex,
});
});
});
describe('errors', () => {
it('mutation payload with root level errors', async () => {
// note this is a complex test that covers all the path around errors that are shown in the form
// it's one single it case, due to the expensive preparation and execution
const mutationResolver = jest.fn().mockResolvedValue(groupPackageSettingsMutationErrorMock);
mountComponent({ mutationResolver });
fillApolloCache();
emitMavenSettingsUpdate();
await waitForPromises();
// errors are bound to the component
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe(
groupPackageSettingsMutationErrorMock.errors[0].extensions.problems[0].message,
);
// general error message is shown
expect(wrapper.emitted('error')).toEqual([[]]);
emitMavenSettingsUpdate();
await wrapper.vm.$nextTick();
// errors are reset on mutation call
expect(findMavenDuplicatedSettings().props('duplicateExceptionRegexError')).toBe('');
});
it.each`
type | mutationResolver
${'local'} | ${jest.fn().mockResolvedValue(groupPackageSettingsMutationMock({ errors: ['foo'] }))}
${'network'} | ${jest.fn().mockRejectedValue()}
`('mutation payload with $type error', async ({ mutationResolver }) => {
mountComponent({ mutationResolver });
fillApolloCache();
emitMavenSettingsUpdate();
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[]]);
});
});
});
});

View File

@ -1,12 +1,15 @@
export const packageSettings = () => ({
mavenDuplicatesAllowed: true,
mavenDuplicateExceptionRegex: '',
genericDuplicatesAllowed: true,
genericDuplicateExceptionRegex: '',
});
export const groupPackageSettingsMock = {
data: {
group: {
packageSettings: {
mavenDuplicatesAllowed: true,
mavenDuplicateExceptionRegex: '',
genericDuplicatesAllowed: true,
genericDuplicateExceptionRegex: '',
},
fullPath: 'foo_group_path',
packageSettings: packageSettings(),
},
},
};

View File

@ -4,7 +4,7 @@ require 'spec_helper'
RSpec.describe Banzai::CrossProjectReference do
let(:including_class) { Class.new.include(described_class).new }
let(:reference_cache) { Banzai::Filter::References::ReferenceCache.new(including_class, {})}
let(:reference_cache) { Banzai::Filter::References::ReferenceCache.new(including_class, {}, {})}
before do
allow(including_class).to receive(:context).and_return({})

View File

@ -12,15 +12,48 @@ RSpec.describe Banzai::Filter::References::ReferenceCache do
let(:filter_class) { Banzai::Filter::References::IssueReferenceFilter }
let(:filter) { filter_class.new(doc, project: project) }
let(:cache) { described_class.new(filter, { project: project }) }
let(:cache) { described_class.new(filter, { project: project }, result) }
let(:result) { {} }
describe '#load_references_per_parent' do
subject { cache.load_references_per_parent(filter.nodes) }
it 'loads references grouped per parent paths' do
cache.load_references_per_parent(filter.nodes)
expect(doc).to receive(:to_html).and_call_original
subject
expect(cache.references_per_parent).to eq({ project.full_path => [issue1.iid, issue2.iid].to_set,
project2.full_path => [issue3.iid].to_set })
end
context 'when rendered_html is memoized' do
let(:result) { { rendered_html: 'html' } }
it 'reuses memoized rendered HTML when available' do
expect(doc).not_to receive(:to_html)
subject
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(reference_cache_memoization: false)
end
it 'ignores memoized rendered HTML' do
expect(doc).to receive(:to_html).and_call_original
subject
end
end
end
context 'when result is not available' do
let(:result) { nil }
it { expect { subject }.not_to raise_error }
end
end
describe '#load_parent_per_reference' do
@ -47,7 +80,7 @@ RSpec.describe Banzai::Filter::References::ReferenceCache do
it 'does not have an N+1 query problem with cross projects' do
doc_single = Nokogiri::HTML.fragment("#1")
filter_single = filter_class.new(doc_single, project: project)
cache_single = described_class.new(filter_single, { project: project })
cache_single = described_class.new(filter_single, { project: project }, {})
control_count = ActiveRecord::QueryRecorder.new do
cache_single.load_references_per_parent(filter_single.nodes)

View File

@ -24,6 +24,7 @@ RSpec.describe Gitlab::Ci::Build::AutoRetry do
"default for scheduler failure" | 1 | {} | :scheduler_failure | true
"quota is exceeded" | 0 | { max: 2 } | :ci_quota_exceeded | false
"no matching runner" | 0 | { max: 2 } | :no_matching_runner | false
"missing dependencies" | 0 | { max: 2 } | :missing_dependency_failure | false
end
with_them do

View File

@ -101,7 +101,6 @@ RSpec.describe Gitlab::Ci::Config::Entry::Retry do
api_failure
stuck_or_timeout_failure
runner_system_failure
missing_dependency_failure
runner_unsupported
stale_schedule
job_execution_timeout

View File

@ -1221,32 +1221,6 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
%w(test success),
%w(deploy running)])
end
context 'when commit status is retried' do
let!(:old_commit_status) do
create(:commit_status, pipeline: pipeline,
stage: 'build',
name: 'mac',
stage_idx: 0,
status: 'success')
end
context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do
before do
stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false)
Ci::ProcessPipelineService
.new(pipeline)
.execute
end
it 'ignores the previous state' do
expect(statuses).to eq([%w(build success),
%w(test success),
%w(deploy running)])
end
end
end
end
context 'when there is a stage with warnings' do

View File

@ -0,0 +1,27 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe VulnerabilityFindingHelpers do
let(:cls) do
Class.new do
include VulnerabilityFindingHelpers
attr_accessor :report_type
def initialize(report_type)
@report_type = report_type
end
end
end
describe '#requires_manual_resolution?' do
it 'returns false if the finding does not require manual resolution' do
expect(cls.new('sast').requires_manual_resolution?).to eq(false)
end
it 'returns true when the finding requires manual resolution' do
expect(cls.new('secret_detection').requires_manual_resolution?).to eq(true)
end
end
end

View File

@ -0,0 +1,45 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Import::UrlController do
let_it_be(:user) { create(:user) }
before do
login_as(user)
end
describe 'POST #validate' do
it 'reports success when service reports success status' do
allow_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service|
allow(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.success)
end
post import_url_validate_path, params: { url: 'https://fake.repo' }
expect(json_response).to eq({ 'success' => true })
end
it 'exposes error message when service reports error' do
expect_next_instance_of(Import::ValidateRemoteGitEndpointService) do |validate_endpoint_service|
expect(validate_endpoint_service).to receive(:execute).and_return(ServiceResponse.error(message: 'foobar'))
end
post import_url_validate_path, params: { url: 'https://fake.repo' }
expect(json_response).to eq({ 'success' => false, 'message' => 'foobar' })
end
context 'with an anonymous user' do
before do
sign_out(user)
end
it 'redirects to sign-in page' do
post import_url_validate_path
expect(response).to redirect_to(new_user_session_path)
end
end
end
end

View File

@ -10,11 +10,9 @@ RSpec.describe Ci::ProcessPipelineService do
end
let(:pipeline_processing_events_counter) { double(increment: true) }
let(:legacy_update_jobs_counter) { double(increment: true) }
let(:metrics) do
double(pipeline_processing_events_counter: pipeline_processing_events_counter,
legacy_update_jobs_counter: legacy_update_jobs_counter)
double(pipeline_processing_events_counter: pipeline_processing_events_counter)
end
subject { described_class.new(pipeline) }
@ -33,68 +31,4 @@ RSpec.describe Ci::ProcessPipelineService do
subject.execute
end
end
describe 'updating a list of retried builds' do
let!(:build_retried) { create_build('build') }
let!(:build) { create_build('build') }
let!(:test) { create_build('test') }
context 'when FF ci_remove_update_retried_from_process_pipeline is enabled' do
it 'does not update older builds as retried' do
subject.execute
expect(all_builds.latest).to contain_exactly(build, build_retried, test)
expect(all_builds.retried).to be_empty
end
end
context 'when FF ci_remove_update_retried_from_process_pipeline is disabled' do
before do
stub_feature_flags(ci_remove_update_retried_from_process_pipeline: false)
end
it 'returns unique statuses' do
subject.execute
expect(all_builds.latest).to contain_exactly(build, test)
expect(all_builds.retried).to contain_exactly(build_retried)
end
it 'increments the counter' do
expect(legacy_update_jobs_counter).to receive(:increment)
subject.execute
end
it 'logs the project and pipeline id' do
expect(Gitlab::AppJsonLogger).to receive(:info).with(event: 'update_retried_is_used',
project_id: project.id,
pipeline_id: pipeline.id)
subject.execute
end
context 'when the previous build has already retried column true' do
before do
build_retried.update_columns(retried: true)
end
it 'does not increment the counter' do
expect(legacy_update_jobs_counter).not_to receive(:increment)
subject.execute
end
end
end
private
def create_build(name, **opts)
create(:ci_build, :created, pipeline: pipeline, name: name, **opts)
end
def all_builds
pipeline.builds.order(:stage_idx, :id)
end
end
end

View File

@ -17,20 +17,47 @@ RSpec.describe Ci::StuckBuilds::DropRunningService do
job.update!(job_attributes)
end
context 'when job is running' do
let(:status) { 'running' }
around do |example|
freeze_time { example.run }
end
context 'when job was updated_at more than an hour ago' do
let(:updated_at) { 2.hours.ago }
shared_examples 'running builds' do
context 'when job is running' do
let(:status) { 'running' }
let(:outdated_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago - 30.minutes }
let(:fresh_time) { described_class::BUILD_RUNNING_OUTDATED_TIMEOUT.ago + 30.minutes }
it_behaves_like 'job is dropped'
context 'when job is outdated' do
let(:created_at) { outdated_time }
let(:updated_at) { outdated_time }
it_behaves_like 'job is dropped'
end
context 'when job is fresh' do
let(:created_at) { fresh_time }
let(:updated_at) { fresh_time }
it_behaves_like 'job is unchanged'
end
context 'when job freshly updated' do
let(:created_at) { outdated_time }
let(:updated_at) { fresh_time }
it_behaves_like 'job is unchanged'
end
end
end
include_examples 'running builds'
context 'when ci_new_query_for_running_stuck_jobs flag is disabled' do
before do
stub_feature_flags(ci_new_query_for_running_stuck_jobs: false)
end
context 'when job was updated in less than 1 hour ago' do
let(:updated_at) { 30.minutes.ago }
it_behaves_like 'job is unchanged'
end
include_examples 'running builds'
end
%w(success skipped failed canceled scheduled pending).each do |status|
@ -51,15 +78,4 @@ RSpec.describe Ci::StuckBuilds::DropRunningService do
end
end
end
context 'for deleted project' do
let(:status) { 'running' }
let(:updated_at) { 2.days.ago }
before do
job.project.update!(pending_delete: true)
end
it_behaves_like 'job is dropped'
end
end

View File

@ -0,0 +1,88 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Import::ValidateRemoteGitEndpointService do
include StubRequests
let_it_be(:base_url) { 'http://demo.host/path' }
let_it_be(:endpoint_url) { "#{base_url}/info/refs?service=git-upload-pack" }
let_it_be(:error_message) { "#{base_url} is not a valid HTTP Git repository" }
describe '#execute' do
let(:valid_response) do
{ status: 200,
body: '001e# service=git-upload-pack',
headers: { 'Content-Type': 'application/x-git-upload-pack-advertisement' } }
end
it 'correctly handles URLs with fragment' do
allow(Gitlab::HTTP).to receive(:get)
described_class.new(url: "#{base_url}#somehash").execute
expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: nil, stream_body: true, follow_redirects: false)
end
context 'when receiving HTTP response' do
subject { described_class.new(url: base_url) }
it 'returns success when HTTP response is valid and contains correct payload' do
stub_full_request(endpoint_url, method: :get).to_return(valid_response)
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.success?).to be(true)
end
it 'reports error when status code is not 200' do
stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ status: 301 }))
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.error?).to be(true)
expect(result.message).to eq(error_message)
end
it 'reports error when required header is missing' do
stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ headers: nil }))
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.error?).to be(true)
expect(result.message).to eq(error_message)
end
it 'reports error when body is in invalid format' do
stub_full_request(endpoint_url, method: :get).to_return(valid_response.merge({ body: 'invalid content' }))
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.error?).to be(true)
expect(result.message).to eq(error_message)
end
it 'reports error when exception is raised' do
stub_full_request(endpoint_url, method: :get).to_raise(SocketError.new('dummy message'))
result = subject.execute
expect(result).to be_a(ServiceResponse)
expect(result.error?).to be(true)
expect(result.message).to eq(error_message)
end
end
it 'passes basic auth when credentials are provided' do
allow(Gitlab::HTTP).to receive(:get)
described_class.new(url: "#{base_url}#somehash", user: 'user', password: 'password').execute
expect(Gitlab::HTTP).to have_received(:get).with(endpoint_url, basic_auth: { username: 'user', password: 'password' }, stream_body: true, follow_redirects: false)
end
end
end

View File

@ -101,6 +101,27 @@ module FilteredSearchHelpers
end
end
# Same as `expect_tokens` but works with GlFilteredSearch
def expect_vue_tokens(tokens)
page.within '.gl-search-box-by-click .gl-filtered-search-scrollable' do
token_elements = page.all(:css, '.gl-filtered-search-token')
tokens.each_with_index do |token, index|
el = token_elements[index]
expect(el.find('.gl-filtered-search-token-type')).to have_content(token[:name])
expect(el.find('.gl-filtered-search-token-operator')).to have_content(token[:operator]) if token[:operator].present?
expect(el.find('.gl-filtered-search-token-data')).to have_content(token[:value]) if token[:value].present?
# gl-emoji content is blank when the emoji unicode is not supported
if token[:emoji_name].present?
selector = %(gl-emoji[data-name="#{token[:emoji_name]}"])
expect(el.find('.gl-filtered-search-token-data-content')).to have_css(selector)
end
end
end
end
def create_token(token_name, token_value = nil, symbol = nil, token_operator = '=')
{ name: token_name, operator: token_operator, value: "#{symbol}#{token_value}" }
end

View File

@ -19,7 +19,7 @@ module Tooling
end
def last_update
@last_update ||= Time.parse(self[:last_update])
@last_update ||= self[:last_update] ? Time.parse(self[:last_update]) : nil
end
end