Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
458b945df3
commit
5ff5438a06
|
@ -1118,7 +1118,7 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab
|
|||
/ee/lib/audit/group_push_rules_changes_auditor.rb @gitlab-org/manage/compliance
|
||||
/ee/lib/ee/api/entities/audit_event.rb @gitlab-org/manage/compliance
|
||||
/ee/lib/ee/audit/ @gitlab-org/manage/compliance
|
||||
/ee/lib/gitlab/audit/auditor.rb @gitlab-org/manage/compliance
|
||||
/ee/lib/ee/gitlab/audit/ @gitlab-org/manage/compliance
|
||||
/ee/spec/controllers/admin/audit_log_reports_controller_spec.rb @gitlab-org/manage/compliance
|
||||
/ee/spec/controllers/admin/audit_logs_controller_spec.rb @gitlab-org/manage/compliance
|
||||
/ee/spec/controllers/groups/audit_events_controller_spec.rb @gitlab-org/manage/compliance
|
||||
|
@ -1163,13 +1163,10 @@ lib/gitlab/checks/** @proglottis @toon @zj-gitlab
|
|||
/ee/spec/support/shared_examples/features/audit_events_filter_shared_examples.rb @gitlab-org/manage/compliance
|
||||
/ee/spec/support/shared_examples/services/audit_event_logging_shared_examples.rb @gitlab-org/manage/compliance
|
||||
/ee/spec/workers/audit_events/audit_event_streaming_worker_spec.rb @gitlab-org/manage/compliance
|
||||
/lib/gitlab/audit/auditor.rb @gitlab-org/manage/compliance
|
||||
/lib/gitlab/audit_json_logger.rb @gitlab-org/manage/compliance
|
||||
/qa/qa/ee/page/admin/monitoring/ @gitlab-org/manage/compliance
|
||||
/qa/qa/specs/features/ee/browser_ui/1_manage/group/group_audit_logs_1_spec.rb @gitlab-org/manage/compliance
|
||||
/qa/qa/specs/features/ee/browser_ui/1_manage/group/group_audit_logs_2_spec.rb @gitlab-org/manage/compliance
|
||||
/qa/qa/specs/features/ee/browser_ui/1_manage/instance/ @gitlab-org/manage/compliance
|
||||
/qa/qa/specs/features/ee/browser_ui/1_manage/project/project_audit_logs_spec.rb @gitlab-org/manage/compliance
|
||||
/spec/factories/audit_events.rb @gitlab-org/manage/compliance
|
||||
/spec/lib/gitlab/audit/auditor_spec.rb @gitlab-org/manage/compliance
|
||||
/spec/migrations/populate_audit_event_streaming_verification_token_spec.rb @gitlab-org/manage/compliance
|
||||
/spec/models/audit_event_spec.rb @gitlab-org/manage/compliance
|
||||
/spec/services/audit_event_service_spec.rb @gitlab-org/manage/compliance
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
<script>
|
||||
import createFlash from '~/flash';
|
||||
import { convertToGraphQLId } from '~/graphql_shared/utils';
|
||||
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
|
||||
import getGroupVariables from '../graphql/queries/group_variables.query.graphql';
|
||||
import {
|
||||
ADD_MUTATION_ACTION,
|
||||
DELETE_MUTATION_ACTION,
|
||||
GRAPHQL_GROUP_TYPE,
|
||||
UPDATE_MUTATION_ACTION,
|
||||
genericMutationErrorText,
|
||||
variableFetchErrorText,
|
||||
} from '../constants';
|
||||
import addGroupVariable from '../graphql/mutations/group_add_variable.mutation.graphql';
|
||||
import deleteGroupVariable from '../graphql/mutations/group_delete_variable.mutation.graphql';
|
||||
import updateGroupVariable from '../graphql/mutations/group_update_variable.mutation.graphql';
|
||||
import ciVariableSettings from './ci_variable_settings.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ciVariableSettings,
|
||||
},
|
||||
mixins: [glFeatureFlagsMixin()],
|
||||
inject: ['endpoint', 'groupPath', 'groupId'],
|
||||
data() {
|
||||
return {
|
||||
groupVariables: [],
|
||||
};
|
||||
},
|
||||
apollo: {
|
||||
groupVariables: {
|
||||
query: getGroupVariables,
|
||||
variables() {
|
||||
return {
|
||||
fullPath: this.groupPath,
|
||||
};
|
||||
},
|
||||
update(data) {
|
||||
return data?.group?.ciVariables?.nodes || [];
|
||||
},
|
||||
error() {
|
||||
createFlash({ message: variableFetchErrorText });
|
||||
},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
areScopedVariablesAvailable() {
|
||||
return this.glFeatures.groupScopedCiVariables;
|
||||
},
|
||||
isLoading() {
|
||||
return this.$apollo.queries.groupVariables.loading;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
addVariable(variable) {
|
||||
this.variableMutation(ADD_MUTATION_ACTION, variable);
|
||||
},
|
||||
deleteVariable(variable) {
|
||||
this.variableMutation(DELETE_MUTATION_ACTION, variable);
|
||||
},
|
||||
updateVariable(variable) {
|
||||
this.variableMutation(UPDATE_MUTATION_ACTION, variable);
|
||||
},
|
||||
async variableMutation(mutationAction, variable) {
|
||||
try {
|
||||
const currentMutation = this.$options.mutationData[mutationAction];
|
||||
const { data } = await this.$apollo.mutate({
|
||||
mutation: currentMutation.action,
|
||||
variables: {
|
||||
endpoint: this.endpoint,
|
||||
fullPath: this.groupPath,
|
||||
groupId: convertToGraphQLId(GRAPHQL_GROUP_TYPE, this.groupId),
|
||||
variable,
|
||||
},
|
||||
});
|
||||
|
||||
const { errors } = data[currentMutation.name];
|
||||
|
||||
if (errors.length > 0) {
|
||||
createFlash({ message: errors[0] });
|
||||
}
|
||||
} catch {
|
||||
createFlash({ message: genericMutationErrorText });
|
||||
}
|
||||
},
|
||||
},
|
||||
mutationData: {
|
||||
[ADD_MUTATION_ACTION]: { action: addGroupVariable, name: 'addGroupVariable' },
|
||||
[UPDATE_MUTATION_ACTION]: { action: updateGroupVariable, name: 'updateGroupVariable' },
|
||||
[DELETE_MUTATION_ACTION]: { action: deleteGroupVariable, name: 'deleteGroupVariable' },
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ci-variable-settings
|
||||
:are-scoped-variables-available="areScopedVariablesAvailable"
|
||||
:is-loading="isLoading"
|
||||
:variables="groupVariables"
|
||||
@add-variable="addVariable"
|
||||
@delete-variable="deleteVariable"
|
||||
@update-variable="updateVariable"
|
||||
/>
|
||||
</template>
|
|
@ -0,0 +1,30 @@
|
|||
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
|
||||
|
||||
mutation addGroupVariable(
|
||||
$variable: CiVariable!
|
||||
$endpoint: String!
|
||||
$fullPath: ID!
|
||||
$groupId: ID!
|
||||
) {
|
||||
addGroupVariable(
|
||||
variable: $variable
|
||||
endpoint: $endpoint
|
||||
fullPath: $fullPath
|
||||
groupId: $groupId
|
||||
) @client {
|
||||
group {
|
||||
id
|
||||
ciVariables {
|
||||
nodes {
|
||||
...BaseCiVariable
|
||||
... on CiGroupVariable {
|
||||
environmentScope
|
||||
masked
|
||||
protected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
|
||||
|
||||
mutation deleteGroupVariable(
|
||||
$variable: CiVariable!
|
||||
$endpoint: String!
|
||||
$fullPath: ID!
|
||||
$groupId: ID!
|
||||
) {
|
||||
deleteGroupVariable(
|
||||
variable: $variable
|
||||
endpoint: $endpoint
|
||||
fullPath: $fullPath
|
||||
groupId: $groupId
|
||||
) @client {
|
||||
group {
|
||||
id
|
||||
ciVariables {
|
||||
nodes {
|
||||
...BaseCiVariable
|
||||
... on CiGroupVariable {
|
||||
environmentScope
|
||||
masked
|
||||
protected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
|
||||
|
||||
mutation updateGroupVariable(
|
||||
$variable: CiVariable!
|
||||
$endpoint: String!
|
||||
$fullPath: ID!
|
||||
$groupId: ID!
|
||||
) {
|
||||
updateGroupVariable(
|
||||
variable: $variable
|
||||
endpoint: $endpoint
|
||||
fullPath: $fullPath
|
||||
groupId: $groupId
|
||||
) @client {
|
||||
group {
|
||||
id
|
||||
ciVariables {
|
||||
nodes {
|
||||
...BaseCiVariable
|
||||
... on CiGroupVariable {
|
||||
environmentScope
|
||||
masked
|
||||
protected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
errors
|
||||
}
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
#import "~/ci_variable_list/graphql/fragments/ci_variable.fragment.graphql"
|
||||
|
||||
query getGroupVariables($fullPath: ID!) {
|
||||
group(fullPath: $fullPath) {
|
||||
id
|
||||
ciVariables {
|
||||
nodes {
|
||||
...BaseCiVariable
|
||||
... on CiGroupVariable {
|
||||
environmentScope
|
||||
masked
|
||||
protected
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -4,8 +4,9 @@ import {
|
|||
convertObjectPropsToSnakeCase,
|
||||
} from '../../lib/utils/common_utils';
|
||||
import { getIdFromGraphQLId } from '../../graphql_shared/utils';
|
||||
import { instanceString } from '../constants';
|
||||
import { GRAPHQL_GROUP_TYPE, groupString, instanceString } from '../constants';
|
||||
import getAdminVariables from './queries/variables.query.graphql';
|
||||
import getGroupVariables from './queries/group_variables.query.graphql';
|
||||
|
||||
const prepareVariableForApi = ({ variable, destroy = false }) => {
|
||||
return {
|
||||
|
@ -27,6 +28,20 @@ const mapVariableTypes = (variables = [], kind) => {
|
|||
});
|
||||
};
|
||||
|
||||
const prepareGroupGraphQLResponse = ({ data, groupId, errors = [] }) => {
|
||||
return {
|
||||
errors,
|
||||
group: {
|
||||
__typename: GRAPHQL_GROUP_TYPE,
|
||||
id: groupId,
|
||||
ciVariables: {
|
||||
__typename: 'CiVariableConnection',
|
||||
nodes: mapVariableTypes(data.variables, groupString),
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const prepareAdminGraphQLResponse = ({ data, errors = [] }) => {
|
||||
return {
|
||||
errors,
|
||||
|
@ -37,6 +52,28 @@ const prepareAdminGraphQLResponse = ({ data, errors = [] }) => {
|
|||
};
|
||||
};
|
||||
|
||||
const callGroupEndpoint = async ({
|
||||
endpoint,
|
||||
fullPath,
|
||||
variable,
|
||||
groupId,
|
||||
cache,
|
||||
destroy = false,
|
||||
}) => {
|
||||
try {
|
||||
const { data } = await axios.patch(endpoint, {
|
||||
variables_attributes: [prepareVariableForApi({ variable, destroy })],
|
||||
});
|
||||
return prepareGroupGraphQLResponse({ data, groupId });
|
||||
} catch (e) {
|
||||
return prepareGroupGraphQLResponse({
|
||||
data: cache.readQuery({ query: getGroupVariables, variables: { fullPath } }),
|
||||
groupId,
|
||||
errors: [...e.response.data],
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false }) => {
|
||||
try {
|
||||
const { data } = await axios.patch(endpoint, {
|
||||
|
@ -54,6 +91,15 @@ const callAdminEndpoint = async ({ endpoint, variable, cache, destroy = false })
|
|||
|
||||
export const resolvers = {
|
||||
Mutation: {
|
||||
addGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
|
||||
return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
|
||||
},
|
||||
updateGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
|
||||
return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache });
|
||||
},
|
||||
deleteGroupVariable: async (_, { endpoint, fullPath, variable, groupId }, { cache }) => {
|
||||
return callGroupEndpoint({ endpoint, fullPath, variable, groupId, cache, destroy: true });
|
||||
},
|
||||
addAdminVariable: async (_, { endpoint, variable }, { cache }) => {
|
||||
return callAdminEndpoint({ endpoint, variable, cache });
|
||||
},
|
||||
|
|
|
@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo';
|
|||
import createDefaultClient from '~/lib/graphql';
|
||||
import { parseBoolean } from '~/lib/utils/common_utils';
|
||||
import CiAdminVariables from './components/ci_admin_variables.vue';
|
||||
import CiGroupVariables from './components/ci_group_variables.vue';
|
||||
import LegacyCiVariableSettings from './components/legacy_ci_variable_settings.vue';
|
||||
import { resolvers } from './graphql/resolvers';
|
||||
import createStore from './store';
|
||||
|
@ -32,7 +33,11 @@ const mountCiVariableListApp = (containerEl) => {
|
|||
const parsedIsGroup = parseBoolean(isGroup);
|
||||
const isProtectedByDefault = parseBoolean(protectedByDefault);
|
||||
|
||||
const component = CiAdminVariables;
|
||||
let component = CiAdminVariables;
|
||||
|
||||
if (parsedIsGroup) {
|
||||
component = CiGroupVariables;
|
||||
}
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
|
|
|
@ -23,6 +23,9 @@ import {
|
|||
SEARCH_SHORTCUTS_MIN_CHARACTERS,
|
||||
SCOPE_TOKEN_MAX_LENGTH,
|
||||
INPUT_FIELD_PADDING,
|
||||
IS_SEARCHING,
|
||||
IS_FOCUSED,
|
||||
IS_NOT_FOCUSED,
|
||||
} from '../constants';
|
||||
import HeaderSearchAutocompleteItems from './header_search_autocomplete_items.vue';
|
||||
import HeaderSearchDefaultItems from './header_search_default_items.vue';
|
||||
|
@ -65,6 +68,7 @@ export default {
|
|||
data() {
|
||||
return {
|
||||
showDropdown: false,
|
||||
isFocused: false,
|
||||
currentFocusIndex: SEARCH_BOX_INDEX,
|
||||
};
|
||||
},
|
||||
|
@ -92,20 +96,18 @@ export default {
|
|||
if (!this.showDropdown || !this.isLoggedIn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return this.searchOptions?.length > 0;
|
||||
},
|
||||
showDefaultItems() {
|
||||
return !this.searchText;
|
||||
},
|
||||
showScopes() {
|
||||
searchTermOverMin() {
|
||||
return this.searchText?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
|
||||
},
|
||||
defaultIndex() {
|
||||
if (this.showDefaultItems) {
|
||||
return SEARCH_BOX_INDEX;
|
||||
}
|
||||
|
||||
return FIRST_DROPDOWN_INDEX;
|
||||
},
|
||||
|
||||
|
@ -132,12 +134,15 @@ export default {
|
|||
count: this.searchOptions.length,
|
||||
});
|
||||
},
|
||||
searchBarStateIndicator() {
|
||||
const hasIcon =
|
||||
this.searchContext?.project || this.searchContext?.group ? 'has-icon' : 'has-no-icon';
|
||||
const isSearching = this.showScopes ? 'is-searching' : 'is-not-searching';
|
||||
const isActive = this.showSearchDropdown ? 'is-active' : 'is-not-active';
|
||||
return `${isActive} ${isSearching} ${hasIcon}`;
|
||||
searchBarClasses() {
|
||||
return {
|
||||
[IS_SEARCHING]: this.searchTermOverMin,
|
||||
[IS_FOCUSED]: this.isFocused,
|
||||
[IS_NOT_FOCUSED]: !this.isFocused,
|
||||
};
|
||||
},
|
||||
showScopeHelp() {
|
||||
return this.searchTermOverMin && this.isFocused;
|
||||
},
|
||||
searchBarItem() {
|
||||
return this.searchOptions?.[0];
|
||||
|
@ -158,11 +163,22 @@ export default {
|
|||
...mapActions(['setSearch', 'fetchAutocompleteOptions', 'clearAutocomplete']),
|
||||
openDropdown() {
|
||||
this.showDropdown = true;
|
||||
this.$emit('toggleDropdown', this.showDropdown);
|
||||
this.isFocused = true;
|
||||
this.$emit('expandSearchBar', true);
|
||||
},
|
||||
closeDropdown() {
|
||||
this.showDropdown = false;
|
||||
this.$emit('toggleDropdown', this.showDropdown);
|
||||
},
|
||||
collapseAndCloseSearchBar() {
|
||||
// we need a delay on this method
|
||||
// for the search bar not to remove
|
||||
// the clear button from dom
|
||||
// and register clicks on dropdown items
|
||||
setTimeout(() => {
|
||||
this.showDropdown = false;
|
||||
this.isFocused = false;
|
||||
this.$emit('collapseSearchBar');
|
||||
}, 200);
|
||||
},
|
||||
submitSearch() {
|
||||
if (this.search?.length <= SEARCH_SHORTCUTS_MIN_CHARACTERS && this.currentFocusIndex < 0) {
|
||||
|
@ -171,6 +187,7 @@ export default {
|
|||
return visitUrl(this.currentFocusedOption?.url || this.searchQuery);
|
||||
},
|
||||
getAutocompleteOptions: debounce(function debouncedSearch(searchTerm) {
|
||||
this.openDropdown();
|
||||
if (!searchTerm) {
|
||||
this.clearAutocomplete();
|
||||
} else {
|
||||
|
@ -201,7 +218,7 @@ export default {
|
|||
role="search"
|
||||
:aria-label="$options.i18n.searchGitlab"
|
||||
class="header-search gl-relative gl-rounded-base gl-w-full"
|
||||
:class="searchBarStateIndicator"
|
||||
:class="searchBarClasses"
|
||||
data-testid="header-search-form"
|
||||
>
|
||||
<gl-search-box-by-type
|
||||
|
@ -217,12 +234,13 @@ export default {
|
|||
:aria-describedby="$options.SEARCH_INPUT_DESCRIPTION"
|
||||
@focus="openDropdown"
|
||||
@click="openDropdown"
|
||||
@blur="collapseAndCloseSearchBar"
|
||||
@input="getAutocompleteOptions"
|
||||
@keydown.enter.stop.prevent="submitSearch"
|
||||
@keydown.esc.stop.prevent="closeDropdown"
|
||||
/>
|
||||
<gl-token
|
||||
v-if="showScopes"
|
||||
v-if="showScopeHelp"
|
||||
v-gl-resize-observer-directive="observeTokenWidth"
|
||||
class="in-search-scope-help"
|
||||
:view-only="true"
|
||||
|
@ -242,6 +260,7 @@ export default {
|
|||
}}
|
||||
</gl-token>
|
||||
<kbd
|
||||
v-show="!isFocused"
|
||||
v-gl-tooltip.bottom.hover.html
|
||||
class="gl-absolute gl-right-3 gl-top-0 gl-z-index-1 keyboard-shortcut-helper"
|
||||
:title="$options.i18n.kbdHelp"
|
||||
|
@ -278,7 +297,7 @@ export default {
|
|||
/>
|
||||
<template v-else>
|
||||
<header-search-scoped-items
|
||||
v-if="showScopes"
|
||||
v-if="searchTermOverMin"
|
||||
:current-focused-option="currentFocusedOption"
|
||||
/>
|
||||
<header-search-autocomplete-items :current-focused-option="currentFocusedOption" />
|
||||
|
|
|
@ -51,3 +51,7 @@ export const SCOPE_TOKEN_MAX_LENGTH = 36;
|
|||
export const INPUT_FIELD_PADDING = 52;
|
||||
|
||||
export const HEADER_INIT_EVENTS = ['input', 'focus'];
|
||||
|
||||
export const IS_SEARCHING = 'is-searching';
|
||||
export const IS_FOCUSED = 'is-focused';
|
||||
export const IS_NOT_FOCUSED = 'is-not-focused';
|
||||
|
|
|
@ -26,12 +26,11 @@ export const initHeaderSearchApp = (search = '') => {
|
|||
render(createElement) {
|
||||
return createElement(HeaderSearchApp, {
|
||||
on: {
|
||||
toggleDropdown: (isVisible = false) => {
|
||||
if (isVisible) {
|
||||
navBarEl?.classList.add('header-search-is-active');
|
||||
} else {
|
||||
navBarEl?.classList.remove('header-search-is-active');
|
||||
}
|
||||
expandSearchBar: () => {
|
||||
navBarEl?.classList.add('header-search-is-active');
|
||||
},
|
||||
collapseSearchBar: () => {
|
||||
navBarEl?.classList.remove('header-search-is-active');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
@ -68,7 +68,7 @@ export default {
|
|||
}),
|
||||
tableCell({
|
||||
key: 'created_at',
|
||||
label: __('Date'),
|
||||
label: __('Start date'),
|
||||
}),
|
||||
tableCell({
|
||||
key: 'status',
|
||||
|
|
|
@ -264,9 +264,15 @@ export default {
|
|||
data-testid="work-item-type"
|
||||
/>
|
||||
<gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" />
|
||||
<gl-badge v-if="workItem.confidential" variant="warning" icon="eye-slash" class="gl-mr-3">{{
|
||||
__('Confidential')
|
||||
}}</gl-badge>
|
||||
<gl-badge
|
||||
v-if="workItem.confidential"
|
||||
v-gl-tooltip.bottom
|
||||
:title="$options.i18n.confidentialTooltip"
|
||||
variant="warning"
|
||||
icon="eye-slash"
|
||||
class="gl-mr-3 gl-cursor-help"
|
||||
>{{ __('Confidential') }}</gl-badge
|
||||
>
|
||||
<work-item-actions
|
||||
v-if="canUpdate || canDelete"
|
||||
:work-item-id="workItem.id"
|
||||
|
|
|
@ -26,6 +26,9 @@ export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS';
|
|||
export const i18n = {
|
||||
fetchError: s__('WorkItem|Something went wrong when fetching the work item. Please try again.'),
|
||||
updateError: s__('WorkItem|Something went wrong while updating the work item. Please try again.'),
|
||||
confidentialTooltip: s__(
|
||||
'WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.',
|
||||
),
|
||||
};
|
||||
|
||||
export const WIDGET_ICONS = {
|
||||
|
|
|
@ -82,19 +82,17 @@ input[type='checkbox']:hover {
|
|||
min-width: $search-input-field-x-min-width;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
&.is-searching {
|
||||
.in-search-scope-help {
|
||||
position: absolute;
|
||||
top: $gl-spacing-scale-2;
|
||||
right: 2.125rem;
|
||||
z-index: 2;
|
||||
}
|
||||
&.is-searching {
|
||||
.in-search-scope-help {
|
||||
position: absolute;
|
||||
top: $gl-spacing-scale-2;
|
||||
right: 2.125rem;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-not-searching {
|
||||
.in-search-scope-help {
|
||||
&.is-not-focused {
|
||||
.gl-search-box-by-type-clear {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
@ -104,19 +102,6 @@ input[type='checkbox']:hover {
|
|||
box-shadow: none;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
.keyboard-shortcut-helper {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.is-not-active {
|
||||
.btn.gl-clear-icon-button,
|
||||
.in-search-scope-help {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header-search-dropdown-menu {
|
||||
|
|
|
@ -1892,7 +1892,7 @@ body.gl-dark .header-search input::placeholder {
|
|||
body.gl-dark .header-search input:active::placeholder {
|
||||
color: #868686;
|
||||
}
|
||||
body.gl-dark .header-search.is-not-active .keyboard-shortcut-helper {
|
||||
body.gl-dark .header-search .keyboard-shortcut-helper {
|
||||
color: #fafafa;
|
||||
background-color: rgba(250, 250, 250, 0.2);
|
||||
}
|
||||
|
|
|
@ -176,11 +176,9 @@
|
|||
}
|
||||
}
|
||||
|
||||
&.is-not-active {
|
||||
.keyboard-shortcut-helper {
|
||||
color: $search-and-nav-links;
|
||||
background-color: rgba($search-and-nav-links, 0.2);
|
||||
}
|
||||
.keyboard-shortcut-helper {
|
||||
color: $search-and-nav-links;
|
||||
background-color: rgba($search-and-nav-links, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -10,6 +10,9 @@ module Groups
|
|||
before_action :define_variables, only: [:show]
|
||||
before_action :push_licensed_features, only: [:show]
|
||||
before_action :assign_variables_to_gon, only: [:show]
|
||||
before_action do
|
||||
push_frontend_feature_flag(:ci_variable_settings_graphql, @group)
|
||||
end
|
||||
|
||||
feature_category :continuous_integration
|
||||
urgency :low
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
module Ml
|
||||
def self.table_name_prefix
|
||||
'ml_'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ml
|
||||
class Candidate < ApplicationRecord
|
||||
validates :iid, :experiment, presence: true
|
||||
|
||||
belongs_to :experiment, class_name: 'Ml::Experiment'
|
||||
belongs_to :user
|
||||
has_many :metrics, class_name: 'Ml::CandidateMetric'
|
||||
has_many :params, class_name: 'Ml::CandidateParam'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ml
|
||||
class CandidateMetric < ApplicationRecord
|
||||
validates :candidate, presence: true
|
||||
validates :name, length: { maximum: 250 }, presence: true
|
||||
|
||||
belongs_to :candidate, class_name: 'Ml::Candidate'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,10 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ml
|
||||
class CandidateParam < ApplicationRecord
|
||||
validates :candidate, presence: true
|
||||
validates :name, :value, length: { maximum: 250 }, presence: true
|
||||
|
||||
belongs_to :candidate, class_name: 'Ml::Candidate'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Ml
|
||||
class Experiment < ApplicationRecord
|
||||
validates :name, :iid, :project, presence: true
|
||||
validates :iid, :name, uniqueness: { scope: :project, message: "should be unique in the project" }
|
||||
|
||||
belongs_to :project
|
||||
belongs_to :user
|
||||
has_many :candidates, class_name: 'Ml::Candidate'
|
||||
end
|
||||
end
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
table_name: ml_candidate_metrics
|
||||
classes:
|
||||
- Ml::CandidateMetric
|
||||
feature_categories:
|
||||
- mlops
|
||||
- incubation
|
||||
description: Metrics recorded for a Machine Learning model candidate
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95168
|
||||
milestone: '15.4'
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
table_name: ml_candidate_params
|
||||
classes:
|
||||
- Ml::CandidateParams
|
||||
feature_categories:
|
||||
- mlops
|
||||
- incubation
|
||||
description: Configuration parameters recorded for a Machine Learning model candidate
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95168
|
||||
milestone: '15.4'
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
table_name: ml_candidates
|
||||
classes:
|
||||
- Ml::Candidate
|
||||
feature_categories:
|
||||
- mlops
|
||||
- incubation
|
||||
description: A Model Candidate is a record of the results on training a model on some configuration
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95168
|
||||
milestone: '15.4'
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
table_name: ml_experiments
|
||||
classes:
|
||||
- Ml::Experiment
|
||||
feature_categories:
|
||||
- mlops
|
||||
- incubation
|
||||
description: A Machine Learning Experiments groups many Model Candidates
|
||||
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/95168
|
||||
milestone: '15.4'
|
|
@ -0,0 +1,18 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateMlExperiments < Gitlab::Database::Migration[2.0]
|
||||
enable_lock_retries!
|
||||
|
||||
def change
|
||||
create_table :ml_experiments do |t|
|
||||
t.timestamps_with_timezone null: false
|
||||
t.bigint :iid, null: false
|
||||
t.bigint :project_id, null: false
|
||||
t.references :user, foreign_key: true, index: true, on_delete: :nullify
|
||||
t.text :name, limit: 255, null: false
|
||||
|
||||
t.index [:project_id, :iid], unique: true
|
||||
t.index [:project_id, :name], unique: true
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateMlCandidates < Gitlab::Database::Migration[2.0]
|
||||
enable_lock_retries!
|
||||
|
||||
def change
|
||||
create_table :ml_candidates do |t|
|
||||
t.timestamps_with_timezone null: false
|
||||
t.uuid :iid, null: false
|
||||
t.bigint :experiment_id, null: false
|
||||
t.references :user, foreign_key: true, index: true, on_delete: :nullify
|
||||
|
||||
t.index [:experiment_id, :iid], unique: true
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,14 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateMlCandidateParams < Gitlab::Database::Migration[2.0]
|
||||
def change
|
||||
create_table :ml_candidate_params do |t|
|
||||
t.timestamps_with_timezone null: false
|
||||
t.references :candidate,
|
||||
foreign_key: { to_table: :ml_candidates },
|
||||
index: true
|
||||
t.text :name, limit: 250, null: false
|
||||
t.text :value, limit: 250, null: false
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,16 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateMlCandidateMetrics < Gitlab::Database::Migration[2.0]
|
||||
def change
|
||||
create_table :ml_candidate_metrics do |t|
|
||||
t.timestamps_with_timezone null: false
|
||||
t.references :candidate,
|
||||
foreign_key: { to_table: :ml_candidates },
|
||||
index: true
|
||||
t.float :value
|
||||
t.integer :step
|
||||
t.binary :is_nan
|
||||
t.text :name, limit: 250, null: false
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddMlCandidatesReferenceToExperiment < Gitlab::Database::Migration[2.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_concurrent_foreign_key :ml_candidates, :ml_experiments, column: :experiment_id
|
||||
end
|
||||
|
||||
def down
|
||||
with_lock_retries do
|
||||
remove_foreign_key :ml_candidates, column: :experiment_id
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class AddMlExperimentsReferenceToProject < Gitlab::Database::Migration[2.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_concurrent_foreign_key :ml_experiments, :projects, column: :project_id, on_delete: :cascade
|
||||
end
|
||||
|
||||
def down
|
||||
with_lock_retries do
|
||||
remove_foreign_key :ml_experiments, column: :project_id
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1 @@
|
|||
211eda22a78d14aaaf86345d3e33b852ba22a7dc9e41d9d683d58f162a7bdcc7
|
|
@ -0,0 +1 @@
|
|||
f871847fbd494e31f13cf2fb87a1b8e9fc47c44e7f0ec9cf37f2084d19b9bf5f
|
|
@ -0,0 +1 @@
|
|||
0c856ce8170e4b864578f1bcb89d8930d8c1952e92356965a98e057521456968
|
|
@ -0,0 +1 @@
|
|||
17bcb2fddd6331cbcec505e8094d1a400b7c3fd8b18897697aa9868689147cd7
|
|
@ -0,0 +1 @@
|
|||
4ea4bc7e6f88561553b19c7bf4992561772506cf532cf569241a536f69e19b7f
|
|
@ -0,0 +1 @@
|
|||
6a6eed069e051786a925b40469e7b53a563f99f0c6bfb810058511d3de8b0923
|
131
db/structure.sql
131
db/structure.sql
|
@ -17596,6 +17596,85 @@ CREATE SEQUENCE milestones_id_seq
|
|||
|
||||
ALTER SEQUENCE milestones_id_seq OWNED BY milestones.id;
|
||||
|
||||
CREATE TABLE ml_candidate_metrics (
|
||||
id bigint NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
candidate_id bigint,
|
||||
value double precision,
|
||||
step integer,
|
||||
is_nan bytea,
|
||||
name text NOT NULL,
|
||||
CONSTRAINT check_3bb4a3fbd9 CHECK ((char_length(name) <= 250))
|
||||
);
|
||||
|
||||
CREATE SEQUENCE ml_candidate_metrics_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
ALTER SEQUENCE ml_candidate_metrics_id_seq OWNED BY ml_candidate_metrics.id;
|
||||
|
||||
CREATE TABLE ml_candidate_params (
|
||||
id bigint NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
candidate_id bigint,
|
||||
name text NOT NULL,
|
||||
value text NOT NULL,
|
||||
CONSTRAINT check_093034d049 CHECK ((char_length(name) <= 250)),
|
||||
CONSTRAINT check_28a3c29e43 CHECK ((char_length(value) <= 250))
|
||||
);
|
||||
|
||||
CREATE SEQUENCE ml_candidate_params_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
ALTER SEQUENCE ml_candidate_params_id_seq OWNED BY ml_candidate_params.id;
|
||||
|
||||
CREATE TABLE ml_candidates (
|
||||
id bigint NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
iid uuid NOT NULL,
|
||||
experiment_id bigint NOT NULL,
|
||||
user_id bigint
|
||||
);
|
||||
|
||||
CREATE SEQUENCE ml_candidates_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
ALTER SEQUENCE ml_candidates_id_seq OWNED BY ml_candidates.id;
|
||||
|
||||
CREATE TABLE ml_experiments (
|
||||
id bigint NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
updated_at timestamp with time zone NOT NULL,
|
||||
iid bigint NOT NULL,
|
||||
project_id bigint NOT NULL,
|
||||
user_id bigint,
|
||||
name text NOT NULL,
|
||||
CONSTRAINT check_ee07a0be2c CHECK ((char_length(name) <= 255))
|
||||
);
|
||||
|
||||
CREATE SEQUENCE ml_experiments_id_seq
|
||||
START WITH 1
|
||||
INCREMENT BY 1
|
||||
NO MINVALUE
|
||||
NO MAXVALUE
|
||||
CACHE 1;
|
||||
|
||||
ALTER SEQUENCE ml_experiments_id_seq OWNED BY ml_experiments.id;
|
||||
|
||||
CREATE TABLE namespace_admin_notes (
|
||||
id bigint NOT NULL,
|
||||
created_at timestamp with time zone NOT NULL,
|
||||
|
@ -23468,6 +23547,14 @@ ALTER TABLE ONLY metrics_users_starred_dashboards ALTER COLUMN id SET DEFAULT ne
|
|||
|
||||
ALTER TABLE ONLY milestones ALTER COLUMN id SET DEFAULT nextval('milestones_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY ml_candidate_metrics ALTER COLUMN id SET DEFAULT nextval('ml_candidate_metrics_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY ml_candidate_params ALTER COLUMN id SET DEFAULT nextval('ml_candidate_params_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY ml_candidates ALTER COLUMN id SET DEFAULT nextval('ml_candidates_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY ml_experiments ALTER COLUMN id SET DEFAULT nextval('ml_experiments_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY namespace_admin_notes ALTER COLUMN id SET DEFAULT nextval('namespace_admin_notes_id_seq'::regclass);
|
||||
|
||||
ALTER TABLE ONLY namespace_bans ALTER COLUMN id SET DEFAULT nextval('namespace_bans_id_seq'::regclass);
|
||||
|
@ -25479,6 +25566,18 @@ ALTER TABLE ONLY milestone_releases
|
|||
ALTER TABLE ONLY milestones
|
||||
ADD CONSTRAINT milestones_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY ml_candidate_metrics
|
||||
ADD CONSTRAINT ml_candidate_metrics_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY ml_candidate_params
|
||||
ADD CONSTRAINT ml_candidate_params_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY ml_candidates
|
||||
ADD CONSTRAINT ml_candidates_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY ml_experiments
|
||||
ADD CONSTRAINT ml_experiments_pkey PRIMARY KEY (id);
|
||||
|
||||
ALTER TABLE ONLY namespace_admin_notes
|
||||
ADD CONSTRAINT namespace_admin_notes_pkey PRIMARY KEY (id);
|
||||
|
||||
|
@ -29037,6 +29136,20 @@ CREATE INDEX index_milestones_on_title_trigram ON milestones USING gin (title gi
|
|||
|
||||
CREATE INDEX index_mirror_data_non_scheduled_or_started ON project_mirror_data USING btree (next_execution_timestamp, retry_count) WHERE ((status)::text <> ALL ('{scheduled,started}'::text[]));
|
||||
|
||||
CREATE INDEX index_ml_candidate_metrics_on_candidate_id ON ml_candidate_metrics USING btree (candidate_id);
|
||||
|
||||
CREATE INDEX index_ml_candidate_params_on_candidate_id ON ml_candidate_params USING btree (candidate_id);
|
||||
|
||||
CREATE UNIQUE INDEX index_ml_candidates_on_experiment_id_and_iid ON ml_candidates USING btree (experiment_id, iid);
|
||||
|
||||
CREATE INDEX index_ml_candidates_on_user_id ON ml_candidates USING btree (user_id);
|
||||
|
||||
CREATE UNIQUE INDEX index_ml_experiments_on_project_id_and_iid ON ml_experiments USING btree (project_id, iid);
|
||||
|
||||
CREATE UNIQUE INDEX index_ml_experiments_on_project_id_and_name ON ml_experiments USING btree (project_id, name);
|
||||
|
||||
CREATE INDEX index_ml_experiments_on_user_id ON ml_experiments USING btree (user_id);
|
||||
|
||||
CREATE UNIQUE INDEX index_mr_blocks_on_blocking_and_blocked_mr_ids ON merge_request_blocks USING btree (blocking_merge_request_id, blocked_merge_request_id);
|
||||
|
||||
CREATE INDEX index_mr_cleanup_schedules_timestamps_status ON merge_request_cleanup_schedules USING btree (scheduled_at) WHERE ((completed_at IS NULL) AND (status = 0));
|
||||
|
@ -32180,6 +32293,9 @@ ALTER TABLE ONLY merge_request_metrics
|
|||
ALTER TABLE ONLY vulnerability_feedback
|
||||
ADD CONSTRAINT fk_563ff1912e FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY ml_candidates
|
||||
ADD CONSTRAINT fk_56d6ed4d3d FOREIGN KEY (experiment_id) REFERENCES ml_experiments(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY deploy_keys_projects
|
||||
ADD CONSTRAINT fk_58a901ca7e FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
|
@ -32477,6 +32593,9 @@ ALTER TABLE ONLY member_tasks
|
|||
ALTER TABLE ONLY merge_requests
|
||||
ADD CONSTRAINT fk_ad525e1f87 FOREIGN KEY (merge_user_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
ALTER TABLE ONLY ml_experiments
|
||||
ADD CONSTRAINT fk_ad89c59858 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY merge_request_metrics
|
||||
ADD CONSTRAINT fk_ae440388cc FOREIGN KEY (latest_closed_by_id) REFERENCES users(id) ON DELETE SET NULL;
|
||||
|
||||
|
@ -32987,6 +33106,9 @@ ALTER TABLE ONLY vulnerability_user_mentions
|
|||
ALTER TABLE ONLY packages_debian_file_metadata
|
||||
ADD CONSTRAINT fk_rails_1ae85be112 FOREIGN KEY (package_file_id) REFERENCES packages_package_files(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY ml_candidates
|
||||
ADD CONSTRAINT fk_rails_1b37441fe5 FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
ALTER TABLE ONLY issuable_slas
|
||||
ADD CONSTRAINT fk_rails_1b8768cd63 FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE;
|
||||
|
||||
|
@ -33014,6 +33136,9 @@ ALTER TABLE ONLY geo_repository_created_events
|
|||
ALTER TABLE ONLY external_status_checks
|
||||
ADD CONSTRAINT fk_rails_1f5a8aa809 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY ml_experiments
|
||||
ADD CONSTRAINT fk_rails_1fbc5e001f FOREIGN KEY (user_id) REFERENCES users(id);
|
||||
|
||||
ALTER TABLE ONLY dora_daily_metrics
|
||||
ADD CONSTRAINT fk_rails_1fd07aff6f FOREIGN KEY (environment_id) REFERENCES environments(id) ON DELETE CASCADE;
|
||||
|
||||
|
@ -34145,6 +34270,9 @@ ALTER TABLE ONLY alert_management_alert_assignees
|
|||
ALTER TABLE ONLY geo_hashed_storage_attachments_events
|
||||
ADD CONSTRAINT fk_rails_d496b088e9 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY ml_candidate_params
|
||||
ADD CONSTRAINT fk_rails_d4a51d1185 FOREIGN KEY (candidate_id) REFERENCES ml_candidates(id);
|
||||
|
||||
ALTER TABLE ONLY merge_request_reviewers
|
||||
ADD CONSTRAINT fk_rails_d9fec24b9d FOREIGN KEY (merge_request_id) REFERENCES merge_requests(id) ON DELETE CASCADE;
|
||||
|
||||
|
@ -34292,6 +34420,9 @@ ALTER TABLE ONLY project_relation_exports
|
|||
ALTER TABLE ONLY label_priorities
|
||||
ADD CONSTRAINT fk_rails_ef916d14fa FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE ONLY ml_candidate_metrics
|
||||
ADD CONSTRAINT fk_rails_efb613a25a FOREIGN KEY (candidate_id) REFERENCES ml_candidates(id);
|
||||
|
||||
ALTER TABLE ONLY fork_network_members
|
||||
ADD CONSTRAINT fk_rails_efccadc4ec FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE;
|
||||
|
||||
|
|
|
@ -15,10 +15,18 @@ See also:
|
|||
|
||||
Start a new export.
|
||||
|
||||
The endpoint also accepts an `upload` parameter. This parameter is a hash. It contains
|
||||
all the necessary information to upload the exported project to a web server or
|
||||
to any S3-compatible platform. At the moment we only support binary
|
||||
data file uploads to the final server.
|
||||
The endpoint also accepts an `upload` hash parameter. It contains all the necessary information to upload the exported
|
||||
project to a web server or to any S3-compatible platform. For exports, GitLab:
|
||||
|
||||
- Only supports binary data file uploads to the final server.
|
||||
- Sends the `Content-Type: application/gzip` header with upload requests. Ensure that your pre-signed URL includes this
|
||||
as part of the signature.
|
||||
- Can take some time to complete the project export process. Make sure the upload URL doesn't have a short expiration
|
||||
time and is available throughout the export process.
|
||||
- Administrators can modify the maximum export file size. By default, the maximum is unlimited (`0`). To change this,
|
||||
edit `max_export_size` using either:
|
||||
- [Application settings API](settings.md#change-application-settings)
|
||||
- [GitLab UI](../user/admin_area/settings/account_and_limit_settings.md).
|
||||
|
||||
The `upload[url]` parameter is required if the `upload` parameter is present.
|
||||
|
||||
|
@ -46,20 +54,6 @@ curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" "https://gitla
|
|||
}
|
||||
```
|
||||
|
||||
NOTE:
|
||||
The upload request is sent with `Content-Type: application/gzip` header. Ensure that your pre-signed URL includes this as part of the signature.
|
||||
|
||||
NOTE:
|
||||
The project export process may take some time to complete. Make sure the
|
||||
upload URL doesn't have a short expiration time and is available thought
|
||||
the export process.
|
||||
|
||||
NOTE:
|
||||
As an administrator, you can modify the maximum export file size. By default,
|
||||
it is set to `0`, for unlimited. To change this value, edit `max_export_size`
|
||||
in the [Application settings API](settings.md#change-application-settings)
|
||||
or the [Admin UI](../user/admin_area/settings/account_and_limit_settings.md).
|
||||
|
||||
## Export status
|
||||
|
||||
Get the status of export.
|
||||
|
|
|
@ -258,3 +258,27 @@ pages-job:
|
|||
script:
|
||||
- 'curl --header "PRIVATE-TOKEN: ${PRIVATE_TOKEN}" "https://gitlab.example.com/api/v4/projects"'
|
||||
```
|
||||
|
||||
### Job does not fail when using `&&` in a script
|
||||
|
||||
If you use `&&` to combine two commands together in a single script line, the job
|
||||
might return as successful, even if one of the commands failed. For example:
|
||||
|
||||
```yaml
|
||||
job-does-not-fail:
|
||||
script:
|
||||
- invalid-command xyz && invalid-command abc
|
||||
- echo $?
|
||||
- echo "The job should have failed already, but this is executed unexpectedly."
|
||||
```
|
||||
|
||||
The `&&` operator returns an exit code of `0` even though the two commands failed,
|
||||
and the job continues to run. To force the script to exit when either command fails,
|
||||
enclose the entire line in parentheses:
|
||||
|
||||
```yaml
|
||||
job-fails:
|
||||
script:
|
||||
- (invalid-command xyz && invalid-command abc)
|
||||
- echo "The job failed already, and this is not executed."
|
||||
```
|
||||
|
|
|
@ -32,15 +32,18 @@ Amazon provides a managed Kubernetes service offering known as [Amazon Elastic K
|
|||
|
||||
## Available Infrastructure as Code for GitLab Cloud Native Hybrid
|
||||
|
||||
The [GitLab Environment Toolkit (GET)](https://gitlab.com/gitlab-org/gitlab-environment-toolkit/-/blob/main/README.md) is an effort made by GitLab to create a multi-cloud, multi-GitLab (Omnibus + Cloud Native Hybrid) toolkit to provision GitLab. GET is developed by GitLab developers and is open to community contributions. GET is where GitLab is investing its resources as the primary option for Infrastructure as Code, and is being actively used in production as a part of [GitLab Dedicated](../../subscriptions/gitlab_dedicated/index.md).
|
||||
|
||||
Read the [GitLab Environment Toolkit (GET) direction](https://gitlab.com/gitlab-org/gitlab-environment-toolkit/-/blob/main/README.md#direction) to learn more about the project and where it is going.
|
||||
|
||||
The [AWS Quick Start for GitLab Cloud Native Hybrid on EKS](https://aws-quickstart.github.io/quickstart-eks-gitlab/) is developed by AWS, GitLab, and the community that contributes to AWS Quick Starts, whether directly to the GitLab Quick Start or to the underlying Quick Start dependencies GitLab inherits (for example, EKS Quick Start).
|
||||
|
||||
GET is recommended for most deployments. The AWS Quick Start can be used if the IaC language of choice is CloudFormation, integration with AWS services like Control Tower is desired, or preference for a UI-driven configuration experience or when any aspect in the below table is an overriding concern.
|
||||
|
||||
NOTE:
|
||||
This automation is in **[Open Beta](https://about.gitlab.com/handbook/product/gitlab-the-product/#open-beta)**. GitLab is working with AWS on resolving [the outstanding issues](https://github.com/aws-quickstart/quickstart-eks-gitlab/issues?q=is%3Aissue+is%3Aopen+%5BHL%5D) before it is fully released. You can subscribe to this issue to be notified of progress and release announcements: [AWS Quick Start for GitLab Cloud Native Hybrid on EKS Status: Beta](https://gitlab.com/gitlab-com/alliances/aws/public-tracker/-/issues/11).<br><br>
|
||||
The Beta version deploys Aurora PostgreSQL, but the release version will deploy Amazon RDS PostgreSQL due to [known issues](https://gitlab.com/gitlab-com/alliances/aws/public-tracker/-/issues?label_name%5B%5D=AWS+Known+Issue) with Aurora. All performance testing results will also be redone after this change has been made.
|
||||
|
||||
The [GitLab Environment Toolkit (GET)](https://gitlab.com/gitlab-org/gitlab-environment-toolkit/-/tree/main) is an effort made by GitLab to create a multi-cloud, multi-GitLab (Omnibus + Cloud Native Hybrid) toolkit to provision GitLab. GET is developed by GitLab developers and is open to community contributions.
|
||||
It is helpful to review the [GitLab Environment Toolkit (GET) Issues](https://gitlab.com/gitlab-org/gitlab-environment-toolkit/-/issues) to understand if any of them may affect your provisioning plans.
|
||||
|
||||
| | [AWS Quick Start for GitLab Cloud Native Hybrid on EKS](https://aws-quickstart.github.io/quickstart-eks-gitlab/) | [GitLab Environment Toolkit (GET)](https://gitlab.com/gitlab-org/gitlab-environment-toolkit) |
|
||||
| ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||
| Overview and Vision | [AWS Quick Start](https://aws.amazon.com/quickstart/) | [GitLab Environment Toolkit](https://gitlab.com/gitlab-org/gitlab-environment-toolkit/-/blob/main/README.md) |
|
||||
|
@ -56,9 +59,11 @@ It is helpful to review the [GitLab Environment Toolkit (GET) Issues](https://gi
|
|||
| Results in a Ready-to-Use instance | Yes | Manual Actions or <br />Supplemental IaC Required |
|
||||
| **<u>Configuration Features</u>** | | |
|
||||
| Can deploy Omnibus GitLab (non-Kubernetes) | No | Yes |
|
||||
| Results in a self-healing Gitaly Cluster configuration | Yes | No |
|
||||
| Can deploy Single Instance Omnibus GitLab (non-Kubernetes) | No | Yes |
|
||||
| Complete Internal Encryption | 85%, Targeting 100% | Manual |
|
||||
| AWS GovCloud Support | Yes | TBD |
|
||||
| No Code Form-Based Deployment User Experience Available | Yes | No |
|
||||
| Full IaC User Experience Available | Yes | Yes |
|
||||
|
||||
### Two and Three Zone High Availability
|
||||
|
||||
|
|
|
@ -8,37 +8,13 @@ module Gitlab
|
|||
# Entry that represents a Docker image.
|
||||
#
|
||||
class Image < ::Gitlab::Config::Entry::Node
|
||||
include ::Gitlab::Config::Entry::Validatable
|
||||
include ::Gitlab::Config::Entry::Attributable
|
||||
include ::Gitlab::Config::Entry::Configurable
|
||||
|
||||
ALLOWED_KEYS = %i[name entrypoint ports pull_policy].freeze
|
||||
LEGACY_ALLOWED_KEYS = %i[name entrypoint ports].freeze
|
||||
include ::Gitlab::Ci::Config::Entry::Imageable
|
||||
|
||||
validations do
|
||||
validates :config, hash_or_string: true
|
||||
validates :config, allowed_keys: ALLOWED_KEYS, if: :ci_docker_image_pull_policy_enabled?
|
||||
validates :config, allowed_keys: LEGACY_ALLOWED_KEYS, unless: :ci_docker_image_pull_policy_enabled?
|
||||
validates :config, disallowed_keys: %i[ports], unless: :with_image_ports?
|
||||
|
||||
validates :name, type: String, presence: true
|
||||
validates :entrypoint, array_of_strings: true, allow_nil: true
|
||||
end
|
||||
|
||||
entry :ports, Entry::Ports,
|
||||
description: 'Ports used to expose the image'
|
||||
|
||||
entry :pull_policy, Entry::PullPolicy,
|
||||
description: 'Pull policy for the image'
|
||||
|
||||
attributes :ports, :pull_policy
|
||||
|
||||
def name
|
||||
value[:name]
|
||||
end
|
||||
|
||||
def entrypoint
|
||||
value[:entrypoint]
|
||||
validates :config, allowed_keys: IMAGEABLE_ALLOWED_KEYS,
|
||||
if: :ci_docker_image_pull_policy_enabled?
|
||||
validates :config, allowed_keys: IMAGEABLE_LEGACY_ALLOWED_KEYS,
|
||||
unless: :ci_docker_image_pull_policy_enabled?
|
||||
end
|
||||
|
||||
def value
|
||||
|
@ -55,18 +31,6 @@ module Gitlab
|
|||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def with_image_ports?
|
||||
opt(:with_image_ports)
|
||||
end
|
||||
|
||||
def ci_docker_image_pull_policy_enabled?
|
||||
::Feature.enabled?(:ci_docker_image_pull_policy)
|
||||
end
|
||||
|
||||
def skip_config_hash_validation?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,61 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Gitlab
|
||||
module Ci
|
||||
class Config
|
||||
module Entry
|
||||
##
|
||||
# Represents Imageable concern shared by Image and Service.
|
||||
module Imageable
|
||||
extend ActiveSupport::Concern
|
||||
|
||||
include ::Gitlab::Config::Entry::Attributable
|
||||
include ::Gitlab::Config::Entry::Configurable
|
||||
|
||||
IMAGEABLE_ALLOWED_KEYS = %i[name entrypoint ports pull_policy].freeze
|
||||
IMAGEABLE_LEGACY_ALLOWED_KEYS = %i[name entrypoint ports].freeze
|
||||
|
||||
included do
|
||||
include ::Gitlab::Config::Entry::Validatable
|
||||
|
||||
validations do
|
||||
validates :config, hash_or_string: true
|
||||
validates :config, disallowed_keys: %i[ports], unless: :with_image_ports?
|
||||
|
||||
validates :name, type: String, presence: true
|
||||
validates :entrypoint, array_of_strings: true, allow_nil: true
|
||||
end
|
||||
|
||||
attributes :ports, :pull_policy
|
||||
|
||||
entry :ports, Entry::Ports,
|
||||
description: 'Ports used to expose the image/service'
|
||||
|
||||
entry :pull_policy, Entry::PullPolicy,
|
||||
description: 'Pull policy for the image/service'
|
||||
end
|
||||
|
||||
def name
|
||||
value[:name]
|
||||
end
|
||||
|
||||
def entrypoint
|
||||
value[:entrypoint]
|
||||
end
|
||||
|
||||
def with_image_ports?
|
||||
opt(:with_image_ports)
|
||||
end
|
||||
|
||||
def ci_docker_image_pull_policy_enabled?
|
||||
::Feature.enabled?(:ci_docker_image_pull_policy)
|
||||
end
|
||||
|
||||
def skip_config_hash_validation?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -7,41 +7,28 @@ module Gitlab
|
|||
##
|
||||
# Entry that represents a configuration of Docker service.
|
||||
#
|
||||
# TODO: remove duplication with Image superclass by defining a common
|
||||
# Imageable concern.
|
||||
# https://gitlab.com/gitlab-org/gitlab/issues/208774
|
||||
class Service < ::Gitlab::Config::Entry::Node
|
||||
include ::Gitlab::Config::Entry::Validatable
|
||||
include ::Gitlab::Config::Entry::Attributable
|
||||
include ::Gitlab::Config::Entry::Configurable
|
||||
include ::Gitlab::Ci::Config::Entry::Imageable
|
||||
|
||||
ALLOWED_KEYS = %i[name entrypoint command alias ports variables pull_policy].freeze
|
||||
LEGACY_ALLOWED_KEYS = %i[name entrypoint command alias ports variables].freeze
|
||||
ALLOWED_KEYS = %i[command alias variables].freeze
|
||||
LEGACY_ALLOWED_KEYS = %i[command alias variables].freeze
|
||||
|
||||
validations do
|
||||
validates :config, hash_or_string: true
|
||||
validates :config, allowed_keys: ALLOWED_KEYS, if: :ci_docker_image_pull_policy_enabled?
|
||||
validates :config, allowed_keys: LEGACY_ALLOWED_KEYS, unless: :ci_docker_image_pull_policy_enabled?
|
||||
validates :config, disallowed_keys: %i[ports], unless: :with_image_ports?
|
||||
validates :name, type: String, presence: true
|
||||
validates :entrypoint, array_of_strings: true, allow_nil: true
|
||||
validates :config, allowed_keys: ALLOWED_KEYS + IMAGEABLE_ALLOWED_KEYS,
|
||||
if: :ci_docker_image_pull_policy_enabled?
|
||||
validates :config, allowed_keys: LEGACY_ALLOWED_KEYS + IMAGEABLE_LEGACY_ALLOWED_KEYS,
|
||||
unless: :ci_docker_image_pull_policy_enabled?
|
||||
|
||||
validates :command, array_of_strings: true, allow_nil: true
|
||||
validates :alias, type: String, allow_nil: true
|
||||
validates :alias, type: String, presence: true, unless: ->(record) { record.ports.blank? }
|
||||
end
|
||||
|
||||
entry :ports, Entry::Ports,
|
||||
description: 'Ports used to expose the service'
|
||||
|
||||
entry :pull_policy, Entry::PullPolicy,
|
||||
description: 'Pull policy for the service'
|
||||
|
||||
entry :variables, ::Gitlab::Ci::Config::Entry::Variables,
|
||||
description: 'Environment variables available for this service.',
|
||||
inherit: false
|
||||
|
||||
attributes :ports, :pull_policy, :variables
|
||||
attributes :variables
|
||||
|
||||
def alias
|
||||
value[:alias]
|
||||
|
@ -51,14 +38,6 @@ module Gitlab
|
|||
value[:command]
|
||||
end
|
||||
|
||||
def name
|
||||
value[:name]
|
||||
end
|
||||
|
||||
def entrypoint
|
||||
value[:entrypoint]
|
||||
end
|
||||
|
||||
def value
|
||||
if string?
|
||||
{ name: @config }
|
||||
|
@ -70,18 +49,6 @@ module Gitlab
|
|||
{}
|
||||
end
|
||||
end
|
||||
|
||||
def with_image_ports?
|
||||
opt(:with_image_ports)
|
||||
end
|
||||
|
||||
def ci_docker_image_pull_policy_enabled?
|
||||
::Feature.enabled?(:ci_docker_image_pull_policy)
|
||||
end
|
||||
|
||||
def skip_config_hash_validation?
|
||||
true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -324,6 +324,10 @@ metrics_dashboard_annotations: :gitlab_main
|
|||
metrics_users_starred_dashboards: :gitlab_main
|
||||
milestone_releases: :gitlab_main
|
||||
milestones: :gitlab_main
|
||||
ml_candidates: :gitlab_main
|
||||
ml_experiments: :gitlab_main
|
||||
ml_candidate_metrics: :gitlab_main
|
||||
ml_candidate_params: :gitlab_main
|
||||
namespace_admin_notes: :gitlab_main
|
||||
namespace_aggregation_schedules: :gitlab_main
|
||||
namespace_bans: :gitlab_main
|
||||
|
|
|
@ -44391,6 +44391,9 @@ msgstr ""
|
|||
msgid "WorkItem|None"
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task."
|
||||
msgstr ""
|
||||
|
||||
msgid "WorkItem|Open"
|
||||
msgstr ""
|
||||
|
||||
|
|
|
@ -23,7 +23,11 @@ RSpec.describe 'Group variables', :js do
|
|||
it_behaves_like 'variable list'
|
||||
end
|
||||
|
||||
# TODO: Uncomment when the new graphQL app for variable settings
|
||||
# is enabled.
|
||||
# it_behaves_like 'variable list'
|
||||
context 'with enabled ff `ci_variable_settings_graphql' do
|
||||
before do
|
||||
visit page_path
|
||||
end
|
||||
|
||||
it_behaves_like 'variable list'
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,183 @@
|
|||
import Vue, { nextTick } from 'vue';
|
||||
import VueApollo from 'vue-apollo';
|
||||
import { GlLoadingIcon, GlTable } from '@gitlab/ui';
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import createFlash from '~/flash';
|
||||
import { resolvers } from '~/ci_variable_list/graphql/resolvers';
|
||||
import { convertToGraphQLId } from '~/graphql_shared/utils';
|
||||
|
||||
import ciGroupVariables from '~/ci_variable_list/components/ci_group_variables.vue';
|
||||
import ciVariableSettings from '~/ci_variable_list/components/ci_variable_settings.vue';
|
||||
import ciVariableTable from '~/ci_variable_list/components/ci_variable_table.vue';
|
||||
import getGroupVariables from '~/ci_variable_list/graphql/queries/group_variables.query.graphql';
|
||||
|
||||
import addGroupVariable from '~/ci_variable_list/graphql/mutations/group_add_variable.mutation.graphql';
|
||||
import deleteGroupVariable from '~/ci_variable_list/graphql/mutations/group_delete_variable.mutation.graphql';
|
||||
import updateGroupVariable from '~/ci_variable_list/graphql/mutations/group_update_variable.mutation.graphql';
|
||||
|
||||
import { genericMutationErrorText, variableFetchErrorText } from '~/ci_variable_list/constants';
|
||||
|
||||
import { mockGroupVariables, newVariable } from '../mocks';
|
||||
|
||||
jest.mock('~/flash');
|
||||
|
||||
Vue.use(VueApollo);
|
||||
|
||||
const mockProvide = {
|
||||
endpoint: '/variables',
|
||||
groupPath: '/namespace/group',
|
||||
groupId: 1,
|
||||
};
|
||||
|
||||
describe('Ci Group Variable list', () => {
|
||||
let wrapper;
|
||||
|
||||
let mockApollo;
|
||||
let mockVariables;
|
||||
|
||||
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
|
||||
const findCiTable = () => wrapper.findComponent(GlTable);
|
||||
const findCiSettings = () => wrapper.findComponent(ciVariableSettings);
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
const createComponentWithApollo = async ({ isLoading = false } = {}) => {
|
||||
const handlers = [[getGroupVariables, mockVariables]];
|
||||
|
||||
mockApollo = createMockApollo(handlers, resolvers);
|
||||
|
||||
wrapper = shallowMount(ciGroupVariables, {
|
||||
provide: mockProvide,
|
||||
apolloProvider: mockApollo,
|
||||
stubs: { ciVariableSettings, ciVariableTable },
|
||||
});
|
||||
|
||||
if (!isLoading) {
|
||||
return waitForPromises();
|
||||
}
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockVariables = jest.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
wrapper.destroy();
|
||||
});
|
||||
|
||||
describe('while queries are being fetch', () => {
|
||||
beforeEach(() => {
|
||||
createComponentWithApollo({ isLoading: true });
|
||||
});
|
||||
|
||||
it('shows a loading icon', () => {
|
||||
expect(findLoadingIcon().exists()).toBe(true);
|
||||
expect(findCiTable().exists()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('when queries are resolved', () => {
|
||||
describe('successfuly', () => {
|
||||
beforeEach(async () => {
|
||||
mockVariables.mockResolvedValue(mockGroupVariables);
|
||||
|
||||
await createComponentWithApollo();
|
||||
});
|
||||
|
||||
it('passes down the expected environments as props', () => {
|
||||
expect(findCiSettings().props('environments')).toEqual([]);
|
||||
});
|
||||
|
||||
it('passes down the expected variables as props', () => {
|
||||
expect(findCiSettings().props('variables')).toEqual(
|
||||
mockGroupVariables.data.group.ciVariables.nodes,
|
||||
);
|
||||
});
|
||||
|
||||
it('createFlash was not called', () => {
|
||||
expect(createFlash).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('with an error for variables', () => {
|
||||
beforeEach(async () => {
|
||||
mockVariables.mockRejectedValue();
|
||||
|
||||
await createComponentWithApollo();
|
||||
});
|
||||
|
||||
it('calls createFlash with the expected error message', () => {
|
||||
expect(createFlash).toHaveBeenCalledWith({ message: variableFetchErrorText });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('mutations', () => {
|
||||
beforeEach(async () => {
|
||||
mockVariables.mockResolvedValue(mockGroupVariables);
|
||||
|
||||
await createComponentWithApollo();
|
||||
});
|
||||
it.each`
|
||||
actionName | mutation | event
|
||||
${'add'} | ${addGroupVariable} | ${'add-variable'}
|
||||
${'update'} | ${updateGroupVariable} | ${'update-variable'}
|
||||
${'delete'} | ${deleteGroupVariable} | ${'delete-variable'}
|
||||
`(
|
||||
'calls the right mutation when user performs $actionName variable',
|
||||
async ({ event, mutation }) => {
|
||||
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue();
|
||||
await findCiSettings().vm.$emit(event, newVariable);
|
||||
|
||||
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({
|
||||
mutation,
|
||||
variables: {
|
||||
endpoint: mockProvide.endpoint,
|
||||
fullPath: mockProvide.groupPath,
|
||||
groupId: convertToGraphQLId('Group', mockProvide.groupId),
|
||||
variable: newVariable,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each`
|
||||
actionName | event | mutationName
|
||||
${'add'} | ${'add-variable'} | ${'addGroupVariable'}
|
||||
${'update'} | ${'update-variable'} | ${'updateGroupVariable'}
|
||||
${'delete'} | ${'delete-variable'} | ${'deleteGroupVariable'}
|
||||
`(
|
||||
'throws with the specific graphql error if present when user performs $actionName variable',
|
||||
async ({ event, mutationName }) => {
|
||||
const graphQLErrorMessage = 'There is a problem with this graphQL action';
|
||||
jest
|
||||
.spyOn(wrapper.vm.$apollo, 'mutate')
|
||||
.mockResolvedValue({ data: { [mutationName]: { errors: [graphQLErrorMessage] } } });
|
||||
await findCiSettings().vm.$emit(event, newVariable);
|
||||
await nextTick();
|
||||
|
||||
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
|
||||
expect(createFlash).toHaveBeenCalledWith({ message: graphQLErrorMessage });
|
||||
},
|
||||
);
|
||||
|
||||
it.each`
|
||||
actionName | event
|
||||
${'add'} | ${'add-variable'}
|
||||
${'update'} | ${'update-variable'}
|
||||
${'delete'} | ${'delete-variable'}
|
||||
`(
|
||||
'throws generic error when the mutation fails with no graphql errors and user performs $actionName variable',
|
||||
async ({ event }) => {
|
||||
jest.spyOn(wrapper.vm.$apollo, 'mutate').mockImplementationOnce(() => {
|
||||
throw new Error();
|
||||
});
|
||||
await findCiSettings().vm.$emit(event, newVariable);
|
||||
|
||||
expect(wrapper.vm.$apollo.mutate).toHaveBeenCalled();
|
||||
expect(createFlash).toHaveBeenCalledWith({ message: genericMutationErrorText });
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
|
@ -1,4 +1,4 @@
|
|||
import { variableTypes, instanceString } from '~/ci_variable_list/constants';
|
||||
import { variableTypes, groupString, instanceString } from '~/ci_variable_list/constants';
|
||||
|
||||
export const devName = 'dev';
|
||||
export const prodName = 'prod';
|
||||
|
@ -82,22 +82,12 @@ export const mockProjectVariables = {
|
|||
},
|
||||
};
|
||||
|
||||
export const mockGroupEnvironments = {
|
||||
data: {
|
||||
group: {
|
||||
__typename: 'Group',
|
||||
id: 1,
|
||||
environments: defaultEnvs,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const mockGroupVariables = {
|
||||
data: {
|
||||
group: {
|
||||
__typename: 'Group',
|
||||
id: 1,
|
||||
ciVariables: createDefaultVars(),
|
||||
ciVariables: createDefaultVars({ kind: groupString }),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
@ -15,6 +15,10 @@ import {
|
|||
ICON_GROUP,
|
||||
ICON_SUBGROUP,
|
||||
SCOPE_TOKEN_MAX_LENGTH,
|
||||
IS_SEARCHING,
|
||||
IS_NOT_FOCUSED,
|
||||
IS_FOCUSED,
|
||||
SEARCH_SHORTCUTS_MIN_CHARACTERS,
|
||||
} from '~/header_search/constants';
|
||||
import DropdownKeyboardNavigation from '~/vue_shared/components/dropdown_keyboard_navigation.vue';
|
||||
import { ENTER_KEY } from '~/lib/utils/keys';
|
||||
|
@ -170,6 +174,14 @@ describe('HeaderSearchApp', () => {
|
|||
it(`should render the Dropdown Navigation Component`, () => {
|
||||
expect(findDropdownKeyboardNavigation().exists()).toBe(true);
|
||||
});
|
||||
|
||||
it(`should close the dropdown when press escape key`, async () => {
|
||||
findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: 27 }));
|
||||
await nextTick();
|
||||
expect(findHeaderSearchDropdown().exists()).toBe(false);
|
||||
// only one event emmited from findHeaderSearchInput().vm.$emit('click');
|
||||
expect(wrapper.emitted().expandSearchBar.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -245,6 +257,7 @@ describe('HeaderSearchApp', () => {
|
|||
searchOptions: () => searchOptions,
|
||||
},
|
||||
);
|
||||
findHeaderSearchInput().vm.$emit('click');
|
||||
});
|
||||
|
||||
it(`${hasToken ? 'is' : 'is NOT'} rendered when data set has type "${
|
||||
|
@ -263,47 +276,43 @@ describe('HeaderSearchApp', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('form wrapper', () => {
|
||||
describe('form', () => {
|
||||
describe.each`
|
||||
searchContext | search | searchOptions
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]}
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]}
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
|
||||
${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS}
|
||||
${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS}
|
||||
${null} | ${null} | ${[]}
|
||||
`('', ({ searchContext, search, searchOptions }) => {
|
||||
searchContext | search | searchOptions | isFocused
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${null} | ${[]} | ${true}
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${[]} | ${true}
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true}
|
||||
${MOCK_SEARCH_CONTEXT_FULL} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${false}
|
||||
${null} | ${MOCK_SEARCH} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true}
|
||||
${null} | ${null} | ${MOCK_SCOPED_SEARCH_OPTIONS} | ${true}
|
||||
${null} | ${null} | ${[]} | ${true}
|
||||
`('wrapper', ({ searchContext, search, searchOptions, isFocused }) => {
|
||||
beforeEach(() => {
|
||||
window.gon.current_username = MOCK_USERNAME;
|
||||
|
||||
createComponent({ search, searchContext }, { searchOptions: () => searchOptions });
|
||||
|
||||
findHeaderSearchInput().vm.$emit('click');
|
||||
if (isFocused) {
|
||||
findHeaderSearchInput().vm.$emit('click');
|
||||
}
|
||||
});
|
||||
|
||||
const hasIcon = Boolean(searchContext?.group);
|
||||
const isSearching = Boolean(search);
|
||||
const isActive = Boolean(searchOptions.length > 0);
|
||||
const isSearching = search?.length > SEARCH_SHORTCUTS_MIN_CHARACTERS;
|
||||
|
||||
it(`${hasIcon ? 'with' : 'without'} search context classes contain "${
|
||||
hasIcon ? 'has-icon' : 'has-no-icon'
|
||||
}"`, () => {
|
||||
const iconClassRegex = hasIcon ? 'has-icon' : 'has-no-icon';
|
||||
expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
|
||||
it(`classes ${isSearching ? 'contain' : 'do not contain'} "${IS_SEARCHING}"`, () => {
|
||||
if (isSearching) {
|
||||
expect(findHeaderSearchForm().classes()).toContain(IS_SEARCHING);
|
||||
return;
|
||||
}
|
||||
if (!isSearching) {
|
||||
expect(findHeaderSearchForm().classes()).not.toContain(IS_SEARCHING);
|
||||
}
|
||||
});
|
||||
|
||||
it(`${isSearching ? 'with' : 'without'} search string classes contain "${
|
||||
isSearching ? 'is-searching' : 'is-not-searching'
|
||||
it(`classes ${isSearching ? 'contain' : 'do not contain'} "${
|
||||
isFocused ? IS_FOCUSED : IS_NOT_FOCUSED
|
||||
}"`, () => {
|
||||
const iconClassRegex = isSearching ? 'is-searching' : 'is-not-searching';
|
||||
expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
|
||||
});
|
||||
|
||||
it(`${isActive ? 'with' : 'without'} search results classes contain "${
|
||||
isActive ? 'is-active' : 'is-not-active'
|
||||
}"`, () => {
|
||||
const iconClassRegex = isActive ? 'is-active' : 'is-not-active';
|
||||
expect(findHeaderSearchForm().classes()).toContain(iconClassRegex);
|
||||
expect(findHeaderSearchForm().classes()).toContain(
|
||||
isFocused ? IS_FOCUSED : IS_NOT_FOCUSED,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -323,6 +332,7 @@ describe('HeaderSearchApp', () => {
|
|||
searchOptions: () => searchOptions,
|
||||
},
|
||||
);
|
||||
findHeaderSearchInput().vm.$emit('click');
|
||||
});
|
||||
|
||||
it(`icon for data set type "${searchOptions[0]?.html_id}" ${
|
||||
|
|
|
@ -164,7 +164,7 @@ describe('RepoTab', () => {
|
|||
|
||||
await wrapper.find('.multi-file-tab-close').trigger('click');
|
||||
|
||||
expect(tab.opened).toBeFalsy();
|
||||
expect(tab.opened).toBe(false);
|
||||
expect(wrapper.vm.$store.state.changedFiles).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
@ -180,7 +180,7 @@ describe('RepoTab', () => {
|
|||
|
||||
await wrapper.find('.multi-file-tab-close').trigger('click');
|
||||
|
||||
expect(tab.opened).toBeFalsy();
|
||||
expect(tab.opened).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -128,7 +128,7 @@ describe('SignInOauthButton', () => {
|
|||
});
|
||||
|
||||
it('does not emit `sign-in` event', () => {
|
||||
expect(wrapper.emitted('sign-in')).toBeFalsy();
|
||||
expect(wrapper.emitted('sign-in')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets `loading` prop of button to `false`', () => {
|
||||
|
@ -179,7 +179,7 @@ describe('SignInOauthButton', () => {
|
|||
});
|
||||
|
||||
it('emits `sign-in` event with user data', () => {
|
||||
expect(wrapper.emitted('sign-in')[0]).toBeTruthy();
|
||||
expect(wrapper.emitted('sign-in')).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -200,7 +200,7 @@ describe('SignInOauthButton', () => {
|
|||
});
|
||||
|
||||
it('does not emit `sign-in` event', () => {
|
||||
expect(wrapper.emitted('sign-in')).toBeFalsy();
|
||||
expect(wrapper.emitted('sign-in')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('sets `loading` prop of button to `false`', () => {
|
||||
|
|
|
@ -4,7 +4,6 @@ import { GlEmptyState } from '@gitlab/ui';
|
|||
import VueApollo from 'vue-apollo';
|
||||
import createMockApollo from 'helpers/mock_apollo_helper';
|
||||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { stripTypenames } from 'helpers/graphql_helpers';
|
||||
|
||||
import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue';
|
||||
import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue';
|
||||
|
@ -96,8 +95,8 @@ describe('Tags List', () => {
|
|||
it('binds the correct props', () => {
|
||||
expect(findRegistryList().props()).toMatchObject({
|
||||
title: '2 tags',
|
||||
pagination: stripTypenames(tagsPageInfo),
|
||||
items: stripTypenames(tags),
|
||||
pagination: tagsPageInfo,
|
||||
items: tags,
|
||||
idProperty: 'name',
|
||||
});
|
||||
});
|
||||
|
|
|
@ -210,12 +210,15 @@ exports[`releases/util.js convertOneReleaseForEditingGraphQLResponse matches sna
|
|||
Object {
|
||||
"data": Object {
|
||||
"_links": Object {
|
||||
"__typename": "ReleaseLinks",
|
||||
"self": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
|
||||
"selfUrl": "http://localhost/releases-namespace/releases-project/-/releases/v1.1",
|
||||
},
|
||||
"assets": Object {
|
||||
"count": undefined,
|
||||
"links": Array [
|
||||
Object {
|
||||
"__typename": "ReleaseAssetLink",
|
||||
"directAssetPath": "/binaries/awesome-app-3",
|
||||
"id": "gid://gitlab/Releases::Link/13",
|
||||
"linkType": "image",
|
||||
|
@ -223,6 +226,7 @@ Object {
|
|||
"url": "https://example.com/image",
|
||||
},
|
||||
Object {
|
||||
"__typename": "ReleaseAssetLink",
|
||||
"directAssetPath": "/binaries/awesome-app-2",
|
||||
"id": "gid://gitlab/Releases::Link/12",
|
||||
"linkType": "package",
|
||||
|
@ -230,6 +234,7 @@ Object {
|
|||
"url": "https://example.com/package",
|
||||
},
|
||||
Object {
|
||||
"__typename": "ReleaseAssetLink",
|
||||
"directAssetPath": "/binaries/awesome-app-1",
|
||||
"id": "gid://gitlab/Releases::Link/11",
|
||||
"linkType": "runbook",
|
||||
|
@ -237,6 +242,7 @@ Object {
|
|||
"url": "http://localhost/releases-namespace/releases-project/runbook",
|
||||
},
|
||||
Object {
|
||||
"__typename": "ReleaseAssetLink",
|
||||
"directAssetPath": "/binaries/linux-amd64",
|
||||
"id": "gid://gitlab/Releases::Link/10",
|
||||
"linkType": "other",
|
||||
|
@ -246,22 +252,31 @@ Object {
|
|||
],
|
||||
"sources": Array [],
|
||||
},
|
||||
"author": undefined,
|
||||
"description": "Best. Release. **Ever.** :rocket:",
|
||||
"evidences": Array [],
|
||||
"milestones": Array [
|
||||
Object {
|
||||
"__typename": "Milestone",
|
||||
"id": "gid://gitlab/Milestone/123",
|
||||
"issueStats": Object {},
|
||||
"stats": undefined,
|
||||
"title": "12.3",
|
||||
"webPath": undefined,
|
||||
"webUrl": undefined,
|
||||
},
|
||||
Object {
|
||||
"__typename": "Milestone",
|
||||
"id": "gid://gitlab/Milestone/124",
|
||||
"issueStats": Object {},
|
||||
"stats": undefined,
|
||||
"title": "12.4",
|
||||
"webPath": undefined,
|
||||
"webUrl": undefined,
|
||||
},
|
||||
],
|
||||
"name": "The first release",
|
||||
"releasedAt": "2018-12-10T00:00:00.000Z",
|
||||
"releasedAt": 2018-12-10T00:00:00.000Z,
|
||||
"tagName": "v1.1",
|
||||
"tagPath": "/releases-namespace/releases-project/-/tags/v1.1",
|
||||
},
|
||||
|
|
|
@ -7,7 +7,6 @@ import {
|
|||
convertAllReleasesGraphQLResponse,
|
||||
convertOneReleaseGraphQLResponse,
|
||||
} from '~/releases/util';
|
||||
import { stripTypenames } from 'helpers/graphql_helpers';
|
||||
|
||||
describe('releases/util.js', () => {
|
||||
describe('convertGraphQLRelease', () => {
|
||||
|
@ -137,7 +136,7 @@ describe('releases/util.js', () => {
|
|||
describe('convertOneReleaseForEditingGraphQLResponse', () => {
|
||||
it('matches snapshot', () => {
|
||||
expect(
|
||||
stripTypenames(convertOneReleaseGraphQLResponse(originalOneReleaseForEditingQueryResponse)),
|
||||
convertOneReleaseGraphQLResponse(originalOneReleaseForEditingQueryResponse),
|
||||
).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,6 @@ import createMockApollo from 'helpers/mock_apollo_helper';
|
|||
import waitForPromises from 'helpers/wait_for_promises';
|
||||
import { mountExtended } from 'helpers/vue_test_utils_helper';
|
||||
import { mockTracking } from 'helpers/tracking_helper';
|
||||
import { stripTypenames } from 'helpers/graphql_helpers';
|
||||
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
|
||||
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
|
||||
import currentUserQuery from '~/graphql_shared/queries/current_user.query.graphql';
|
||||
|
@ -311,9 +310,7 @@ describe('WorkItemAssignees component', () => {
|
|||
findAssignSelfButton().vm.$emit('click', new MouseEvent('click'));
|
||||
await nextTick();
|
||||
|
||||
expect(findTokenSelector().props('selectedTokens')).toMatchObject([
|
||||
stripTypenames(currentUser),
|
||||
]);
|
||||
expect(findTokenSelector().props('selectedTokens')).toMatchObject([currentUser]);
|
||||
expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({
|
||||
input: {
|
||||
id: workItemId,
|
||||
|
@ -330,9 +327,7 @@ describe('WorkItemAssignees component', () => {
|
|||
await waitForPromises();
|
||||
|
||||
expect(findTokenSelector().props('dropdownItems')[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
...stripTypenames(currentUserResponse.data.currentUser),
|
||||
}),
|
||||
expect.objectContaining(currentUserResponse.data.currentUser),
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
@ -247,6 +247,9 @@ describe('WorkItemDetail component', () => {
|
|||
variant: 'warning',
|
||||
icon: 'eye-slash',
|
||||
});
|
||||
expect(confidentialBadge.attributes('title')).toBe(
|
||||
'Only project members with at least the Reporter role, the author, and assignees can view or be notified about this task.',
|
||||
);
|
||||
expect(confidentialBadge.text()).toBe('Confidential');
|
||||
});
|
||||
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'fast_spec_helper'
|
||||
require 'support/helpers/stubbed_feature'
|
||||
require 'support/helpers/stub_feature_flags'
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Ci::Config::Entry::Image do
|
||||
include StubFeatureFlags
|
||||
|
||||
before do
|
||||
stub_feature_flags(ci_docker_image_pull_policy: true)
|
||||
|
||||
|
|
|
@ -0,0 +1,81 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Ci::Config::Entry::Imageable do
|
||||
let(:node_class) do
|
||||
Class.new(::Gitlab::Config::Entry::Node) do
|
||||
include ::Gitlab::Ci::Config::Entry::Imageable
|
||||
|
||||
validations do
|
||||
validates :config, allowed_keys: ::Gitlab::Ci::Config::Entry::Imageable::IMAGEABLE_ALLOWED_KEYS
|
||||
end
|
||||
|
||||
def self.name
|
||||
'node'
|
||||
end
|
||||
|
||||
def value
|
||||
if string?
|
||||
{ name: @config }
|
||||
elsif hash?
|
||||
{
|
||||
name: @config[:name]
|
||||
}.compact
|
||||
else
|
||||
{}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
subject(:entry) { node_class.new(config) }
|
||||
|
||||
before do
|
||||
entry.compose!
|
||||
end
|
||||
|
||||
context 'when entry value is correct' do
|
||||
let(:config) { 'image:1.0' }
|
||||
|
||||
describe '#valid?' do
|
||||
it 'is valid' do
|
||||
expect(entry).to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when entry value is not correct' do
|
||||
let(:config) { ['image:1.0'] }
|
||||
|
||||
describe '#errors' do
|
||||
it 'saves errors' do
|
||||
expect(entry.errors.first)
|
||||
.to match /config should be a hash or a string/
|
||||
end
|
||||
end
|
||||
|
||||
describe '#valid?' do
|
||||
it 'is not valid' do
|
||||
expect(entry).not_to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when unexpected key is specified' do
|
||||
let(:config) { { name: 'image:1.0', non_existing: 'test' } }
|
||||
|
||||
describe '#errors' do
|
||||
it 'saves errors' do
|
||||
expect(entry.errors.first)
|
||||
.to match /config contains unknown keys: non_existing/
|
||||
end
|
||||
end
|
||||
|
||||
describe '#valid?' do
|
||||
it 'is not valid' do
|
||||
expect(entry).not_to be_valid
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
require 'fast_spec_helper'
|
||||
require 'gitlab_chronic_duration'
|
||||
require 'support/helpers/stub_feature_flags'
|
||||
require_dependency 'active_model'
|
||||
|
||||
RSpec.describe Gitlab::Ci::Config::Entry::Rules::Rule do
|
||||
|
|
|
@ -1,12 +1,8 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'fast_spec_helper'
|
||||
require 'support/helpers/stubbed_feature'
|
||||
require 'support/helpers/stub_feature_flags'
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Gitlab::Ci::Config::Entry::Service do
|
||||
include StubFeatureFlags
|
||||
|
||||
before do
|
||||
stub_feature_flags(ci_docker_image_pull_policy: true)
|
||||
entry.compose!
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Ml::CandidateMetric do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:candidate) }
|
||||
end
|
||||
end
|
|
@ -0,0 +1,9 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Ml::CandidateParam do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:candidate) }
|
||||
end
|
||||
end
|
|
@ -0,0 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Ml::Candidate do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:experiment) }
|
||||
it { is_expected.to belong_to(:user) }
|
||||
it { is_expected.to have_many(:params) }
|
||||
it { is_expected.to have_many(:metrics) }
|
||||
end
|
||||
end
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'spec_helper'
|
||||
|
||||
RSpec.describe Ml::Experiment do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:project) }
|
||||
it { is_expected.to belong_to(:user) }
|
||||
it { is_expected.to have_many(:candidates) }
|
||||
end
|
||||
end
|
|
@ -70,9 +70,12 @@
|
|||
keywords:
|
||||
- '*.png'
|
||||
- '*bundler-audit*'
|
||||
- '**/merge_requests/**'
|
||||
- '/ee/app/services/audit_events/*'
|
||||
- '/ee/config/feature_flags/development/auditor_group_runner_access.yml'
|
||||
- '/ee/spec/services/audit_events/*'
|
||||
- '/ee/spec/services/ci/*'
|
||||
- '/ee/spec/services/personal_access_tokens/*'
|
||||
- '/qa/**/*'
|
||||
patterns:
|
||||
- '%{keyword}'
|
||||
|
|
Loading…
Reference in New Issue