Commit 869b4111 authored by GitLab Bot's avatar GitLab Bot

Automatic merge of gitlab-org/gitlab master

parents 9e5bc0e5 790071b3
......@@ -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
......
......@@ -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."
......
<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,
},
];
},
......
<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();
handleSuccess() {
this.$toast.show(SUCCESS_UPDATING_SETTINGS);
}
})
.catch((e) => {
if (e.graphQLErrors) {
e.graphQLErrors.forEach((error) => {
const [
{
path: [key],
message,
this.dismissAlert();
},
] = error.extensions.problems;
this.errors = { ...this.errors, [key]: message };
});
}
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"
<packages-settings
:package-settings="packageSettings"
:is-loading="isLoading"
@success="handleSuccess"
@error="handleError"
/>
</template>
</generic-settings>
</template>
</settings-block>
</div>
</template>
<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>
......@@ -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');
......@@ -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')
......@@ -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.
......
......@@ -16,15 +16,9 @@ module SubscriptionsHelper
}
end
def buy_minutes_addon_data(group)
def buy_addon_data(group, anchor, purchased_product)
{
redirect_after_success: group_usage_quotas_path(group, anchor: 'pipelines-quota-tab', purchased_product: _('CI minutes'))
}.merge(addon_data(group))
end
def buy_storage_addon_data(group)
{
redirect_after_success: group_usage_quotas_path(group, anchor: 'storage-quota-tab', purchased_product: _('Storage'))
redirect_after_success: group_usage_quotas_path(group, anchor: anchor, purchased_product: purchased_product)
}.merge(addon_data(group))
end
......
......@@ -404,6 +404,11 @@ module EE
::Gitlab::CurrentSettings.invalidate_elasticsearch_indexes_cache_for_namespace!(self.id)
end
def elastic_namespace_ancestry
separator = '-'
self_and_ancestor_ids(hierarchy_order: :desc).join(separator) + separator
end
def enable_temporary_storage_increase!
update(temporary_storage_increase_ends_on: TEMPORARY_STORAGE_INCREASE_DAYS.days.from_now)
end
......
- page_title _('Buy CI Minutes')
#js-buy-minutes{ data: buy_minutes_addon_data(@group) }
#js-buy-minutes{ data: buy_addon_data(@group, 'pipelines-quota-tab', _('CI minutes')) }
- page_title _('Buy Storage')
#js-buy-storage{ data: buy_storage_addon_data(@group) }
#js-buy-storage{ data: buy_addon_data(@group, 'storage-quota-tab', s_('Checkout|a storage subscription')) }
---
name: elasticsearch_use_group_level_optimization
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/69741
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/340596
milestone: '14.4'
type: development
group: group::global search
default_enabled: false
......@@ -381,6 +381,37 @@ module Elastic
authorized_project_ids.to_a
end
def authorized_namespace_ids(current_user, options = {})
return [] unless current_user && options[:group_ids].present?
authorized_ids = current_user.authorized_groups.pluck_primary_key.to_set
authorized_ids.intersection(options[:group_ids].to_set).to_a
end
def ancestry_filter(current_user, namespace_ancestry)
return {} unless current_user
return {} if namespace_ancestry.blank?
context.name(:ancestry_filter) do
filters = namespace_ancestry.map do |namespace_ids|
{
prefix: {
namespace_ancestry_ids: {
_name: context.name(:descendants),
value: namespace_ids
}
}
}
end
{
bool: {
should: filters
}
}
end
end
end
end
end
......@@ -5,8 +5,6 @@ module Elastic
class ApplicationInstanceProxy < Elasticsearch::Model::Proxy::InstanceMethodsProxy
include InstanceProxyUtil
NAMESPACE_ANCESTRY_SEPARATOR = '-'
def es_parent
"project_#{target.project_id}" unless target.is_a?(Project) || target&.project_id.nil?
end
......@@ -21,8 +19,7 @@ module Elastic
def namespace_ancestry
project = target.is_a?(Project) ? target : target.project
namespace = project.namespace
namespace.self_and_ancestor_ids(hierarchy_order: :desc).join(NAMESPACE_ANCESTRY_SEPARATOR) + NAMESPACE_ANCESTRY_SEPARATOR
project.namespace.elastic_namespace_ancestry
end
private
......
......@@ -19,7 +19,7 @@ module Elastic
options[:features] = 'issues'
options[:no_join_project] = true
context.name(:issue) do
query_hash = context.name(:authorized) { project_ids_filter(query_hash, options) }
query_hash = context.name(:authorized) { authorization_filter(query_hash, options) }
query_hash = context.name(:confidentiality) { confidentiality_filter(query_hash, options) }
query_hash = context.name(:match) { state_filter(query_hash, options) }
end
......@@ -56,6 +56,30 @@ module Elastic
end
end
def should_use_project_ids_filter?(options)
options[:project_ids] == :any ||
options[:group_ids].blank? ||
Feature.disabled?(:elasticsearch_use_group_level_optimization) ||
!Elastic::DataMigrationService.migration_has_finished?(:redo_backfill_namespace_ancestry_ids_for_issues)
end
def authorization_filter(query_hash, options)
return project_ids_filter(query_hash, options) if should_use_project_ids_filter?(options)
current_user = options[:current_user]
namespace_ancestry = Namespace.find(authorized_namespace_ids(current_user, options))
.map(&:elastic_namespace_ancestry)
return project_ids_filter(query_hash, options) if namespace_ancestry.blank?
context.name(:namespace) do
query_hash[:query][:bool][:filter] ||= []
query_hash[:query][:bool][:filter] << ancestry_filter(current_user, namespace_ancestry)
end
query_hash
end
# Builds an elasticsearch query that will select documents from a
# set of projects for Group and Project searches, taking user access
# rules for issues into account. Relies upon super for Global searches
......
......@@ -6,6 +6,8 @@ module Gitlab
# superclass inside a module, because autoloading can occur in a
# different order between execution environments.
class GroupSearchResults < Gitlab::Elastic::SearchResults
extend Gitlab::Utils::Override
attr_reader :group, :default_project_filter, :filters
# rubocop:disable Metrics/ParameterLists
......@@ -17,6 +19,16 @@ module Gitlab
super(current_user, query, limit_project_ids, public_and_internal_projects: public_and_internal_projects, order_by: order_by, sort: sort, filters: filters)
end
# rubocop:enable Metrics/ParameterLists
override :scope_options
def scope_options(scope)
case scope
when :issues
super.merge(group_ids: [group.id])
else
super
end
end
end
end
end
......@@ -150,29 +150,14 @@ RSpec.describe SubscriptionsHelper do
it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"name":"My Namespace","users":1,"guests":0}]}) }
end
describe '#buy_minutes_addon_data' do
subject(:buy_minutes_addon_data) { helper.buy_minutes_addon_data(group) }
describe '#buy_addon_data' do
subject(:buy_addon_data) { helper.buy_addon_data(group, anchor, purchased_product) }
let_it_be(:user) { create(:user, name: 'First Last') }
let_it_be(:group) { create(:group, name: 'My Namespace') }
before do
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:params).and_return({ selected_group: group.id.to_s, source: 'some_source' })
group.add_owner(user)
end
it { is_expected.to include(namespace_id: group.id.to_s) }
it { is_expected.to include(source: 'some_source') }
it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"name":"My Namespace","users":1,"guests":0}]}) }
it { is_expected.to include(redirect_after_success: group_usage_quotas_path(group, anchor: 'pipelines-quota-tab', purchased_product: 'CI minutes')) }
end
describe '#buy_storage_addon_data' do
subject(:buy_storage_addon_data) { helper.buy_storage_addon_data(group) }
let_it_be(:user) { create(:user, name: 'First Last') }
let_it_be(:group) { create(:group, name: 'My Namespace') }
let(:anchor) { 'pipelines-quota-tab' }
let(:purchased_product) { 'CI Minutes' }
before do
allow(helper).to receive(:current_user).and_return(user)
......@@ -183,6 +168,6 @@ RSpec.describe SubscriptionsHelper do
it { is_expected.to include(namespace_id: group.id.to_s) }
it { is_expected.to include(source: 'some_source') }
it { is_expected.to include(group_data: %Q{[{"id":#{group.id},"name":"My Namespace","users":1,"guests":0}]}) }
it { is_expected.to include(redirect_after_success: group_usage_quotas_path(group, anchor: 'storage-quota-tab', purchased_product: 'Storage')) }
it { is_expected.to include(redirect_after_success: group_usage_quotas_path(group, anchor: anchor, purchased_product: purchased_product)) }
end
end
......@@ -206,11 +206,27 @@ RSpec.describe Search::GroupService do
permission_table_for_guest_feature_access
end
context 'elasticsearch_use_group_level_optimization is enabled' do
before do
stub_feature_flags(elasticsearch_use_group_level_optimization: true)
end
with_them do
it_behaves_like 'search respects visibility'
end
end
context 'elasticsearch_use_group_level_optimization is disabled' do
before do
stub_feature_flags(elasticsearch_use_group_level_optimization: false)
end
with_them do
it_behaves_like 'search respects visibility'
end
end
end
context 'wiki' do
let!(:project) { create(:project, project_level, :wiki_repo) }
let(:group) { project.namespace }
......@@ -296,13 +312,27 @@ RSpec.describe Search::GroupService do
let!(:new_updated) { create(:issue, project: project, title: 'updated recent', updated_at: 1.day.ago) }
let!(:very_old_updated) { create(:issue, project: project, title: 'updated very old', updated_at: 1.year.ago) }
let(:results_created) { described_class.new(nil, group, search: 'sorted', sort: sort).execute }
let(:results_updated) { described_class.new(nil, group, search: 'updated', sort: sort).execute }
before do
ensure_elasticsearch_index!
end
include_examples 'search results sorted' do
let(:results_created) { described_class.new(nil, group, search: 'sorted', sort: sort).execute }
let(:results_updated) { described_class.new(nil, group, search: 'updated', sort: sort).execute }
context 'elasticsearch_use_group_level_optimization is enabled' do
before do
stub_feature_flags(elasticsearch_use_group_level_optimization: true)
end
include_examples 'search results sorted'
end
context 'elasticsearch_use_group_level_optimization is disabled' do
before do
stub_feature_flags(elasticsearch_use_group_level_optimization: false)
end
include_examples 'search results sorted'
end
end
......
......@@ -22,12 +22,12 @@ end
RSpec.shared_examples_for 'buy minutes addon form data' do |js_selector|
before do
allow(view).to receive(:buy_minutes_addon_data).and_return(
allow(view).to receive(:buy_addon_data).and_return(
group_data: '[{"id":"ci_minutes_plan_id","code":"ci_minutes","price_per_year":10.0}]',
namespace_id: '1',
plan_id: 'ci_minutes_plan_id',
source: 'some_source',
redirect_after_success: '/groups/my-ci-minutes-group/-/usage_quotas'
redirect_after_success: '/groups/my-ci-minutes-group/-/usage_quotas#pipelines-quota-tab'
)
end
......@@ -37,17 +37,17 @@ RSpec.shared_examples_for 'buy minutes addon form data' do |js_selector|
it { is_expected.to have_selector("#{js_selector}[data-plan-id='ci_minutes_plan_id']") }
it { is_expected.to have_selector("#{js_selector}[data-namespace-id='1']") }
it { is_expected.to have_selector("#{js_selector}[data-source='some_source']") }
it { is_expected.to have_selector("#{js_selector}[data-redirect-after-success='/groups/my-ci-minutes-group/-/usage_quotas']") }
it { is_expected.to have_selector("#{js_selector}[data-redirect-after-success='/groups/my-ci-minutes-group/-/usage_quotas#pipelines-quota-tab']") }
end
RSpec.shared_examples_for 'buy storage addon form data' do |js_selector|
before do
allow(view).to receive(:buy_storage_addon_data).and_return(
allow(view).to receive(:buy_addon_data).and_return(
group_data: '[{"id":"storage_plan_id","code":"storage","price_per_year":10.0}]',
namespace_id: '2',
plan_id: 'storage_plan_id',
source: 'some_source',
redirect_after_success: '/groups/my-group/-/usage_quotas'
redirect_after_success: '/groups/my-group/-/usage_quotas#storage-quota-tab'
)
end
......@@ -57,5 +57,5 @@ RSpec.shared_examples_for 'buy storage addon form data' do |js_selector|
it { is_expected.to have_selector("#{js_selector}[data-plan-id='storage_plan_id']") }
it { is_expected.to have_selector("#{js_selector}[data-namespace-id='2']") }
it { is_expected.to have_selector("#{js_selector}[data-source='some_source']") }
it { is_expected.to have_selector("#{js_selector}[data-redirect-after-success='/groups/my-group/-/usage_quotas']") }
it { is_expected.to have_selector("#{js_selector}[data-redirect-after-success='/groups/my-group/-/usage_quotas#storage-quota-tab']") }
end
......@@ -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)
......
......@@ -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 ""
......
......@@ -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
......
......@@ -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
......
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);
});
});
});
......
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([[]]);
});
});
});
});
export const groupPackageSettingsMock = {
data: {
group: {
packageSettings: {
export const packageSettings = () => ({
mavenDuplicatesAllowed: true,
mavenDuplicateExceptionRegex: '',
genericDuplicatesAllowed: true,
genericDuplicateExceptionRegex: '',
},
});
export const groupPackageSettingsMock = {
data: {
group: {
fullPath: 'foo_group_path',
packageSettings: packageSettings(),
},
},
};
......
......@@ -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
......
......@@ -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
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment