Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-10-18 21:09:37 +00:00
parent e4220eecca
commit cace5e8ff1
99 changed files with 1999 additions and 366 deletions

View File

@ -0,0 +1,12 @@
glfm-verify:
# NOTE: We do not restrict this job to any specific subset of file changes via rules, because
# there are potentially many different source files within the codebase which could
# change the contents of the generated GLFM files. It is therefore safer to always
# run this job to ensure that no changes are missed.
extends:
- .rspec-ee-base-pg12
stage: test
needs: ["setup-test-env"]
script:
- !reference [.base-script, script]
- bundle exec scripts/glfm/verify-all-generated-files-are-up-to-date.rb

View File

@ -4,10 +4,14 @@ import { concatPagination } from '@apollo/client/utilities';
import getIssueStateQuery from '~/issues/show/queries/get_issue_state.query.graphql';
import createDefaultClient from '~/lib/graphql';
import typeDefs from '~/work_items/graphql/typedefs.graphql';
import { WIDGET_TYPE_MILESTONE } from '~/work_items/constants';
export const temporaryConfig = {
typeDefs,
cacheConfig: {
possibleTypes: {
LocalWorkItemWidget: ['LocalWorkItemMilestone'],
},
typePolicies: {
Project: {
fields: {
@ -18,6 +22,28 @@ export const temporaryConfig = {
},
WorkItem: {
fields: {
mockWidgets: {
read(widgets) {
return (
widgets || [
{
__typename: 'LocalWorkItemMilestone',
type: WIDGET_TYPE_MILESTONE,
nodes: [
{
dueDate: null,
expired: false,
id: 'gid://gitlab/Milestone/30',
title: 'v4.0',
// eslint-disable-next-line @gitlab/require-i18n-strings
__typename: 'Milestone',
},
],
},
]
);
},
},
widgets: {
merge(existing = [], incoming) {
if (existing.length === 0) {

View File

@ -51,7 +51,6 @@ export default {
isModalVisible: false,
isLoading: true,
isSearchEmpty: false,
searchEmptyMessage: '',
targetGroup: null,
targetParentGroup: null,
showEmptyState: false,
@ -88,10 +87,6 @@ export default {
},
},
created() {
this.searchEmptyMessage = this.hideProjects
? COMMON_STR.GROUP_SEARCH_EMPTY
: COMMON_STR.GROUP_PROJECT_SEARCH_EMPTY;
eventHub.$on(`${this.action}fetchPage`, this.fetchPage);
eventHub.$on(`${this.action}toggleChildren`, this.toggleChildren);
eventHub.$on(`${this.action}showLeaveGroupModal`, this.showLeaveGroupModal);
@ -259,7 +254,7 @@ export default {
const hasGroups = groups && groups.length > 0;
if (this.renderEmptyState) {
this.isSearchEmpty = this.filterGroupsBy !== null && !hasGroups;
this.isSearchEmpty = fromSearch && !hasGroups;
} else {
this.isSearchEmpty = !hasGroups;
}
@ -294,7 +289,6 @@ export default {
v-else
:groups="groups"
:search-empty="isSearchEmpty"
:search-empty-message="searchEmptyMessage"
:page-info="pageInfo"
:action="action"
/>

View File

@ -1,11 +1,18 @@
<script>
import { GlEmptyState } from '@gitlab/ui';
import PaginationLinks from '~/vue_shared/components/pagination_links.vue';
import { getParameterByName } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import eventHub from '../event_hub';
export default {
i18n: {
emptyStateTitle: __('No results found'),
emptyStateDescription: __('Edit your search and try again'),
},
components: {
PaginationLinks,
GlEmptyState,
},
props: {
groups: {
@ -20,10 +27,6 @@ export default {
type: Boolean,
required: true,
},
searchEmptyMessage: {
type: String,
required: true,
},
action: {
type: String,
required: false,
@ -43,12 +46,11 @@ export default {
<template>
<div class="groups-list-tree-container" data-qa-selector="groups_list_tree_container">
<div
<gl-empty-state
v-if="searchEmpty"
class="has-no-search-results gl-font-style-italic gl-text-center gl-text-gray-600 gl-p-5"
>
{{ searchEmptyMessage }}
</div>
:title="$options.i18n.emptyStateTitle"
:description="$options.i18n.emptyStateDescription"
/>
<template v-else>
<group-folder :groups="groups" :action="action" />
<pagination-links

View File

@ -2,6 +2,7 @@
import { GlTabs, GlTab, GlSearchBoxByType, GlSorting, GlSortingItem } from '@gitlab/ui';
import { isString, debounce } from 'lodash';
import { __ } from '~/locale';
import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants';
import GroupsStore from '../store/groups_store';
import GroupsService from '../service/groups_service';
import {
@ -61,11 +62,6 @@ export default {
return this.isAscending ? this.sort.asc : this.sort.desc;
},
},
watch: {
search: debounce(async function debouncedSearch() {
this.handleSearchOrSortChange();
}, 250),
},
mounted() {
this.search = this.$route.query?.filter || '';
@ -137,6 +133,14 @@ export default {
this.handleSearchOrSortChange();
},
handleSearchInput(value) {
this.search = value;
this.debouncedSearch();
},
debouncedSearch: debounce(async function debouncedSearch() {
this.handleSearchOrSortChange();
}, DEBOUNCE_DELAY),
},
i18n: {
[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]: __('Subgroups and projects'),
@ -169,9 +173,10 @@ export default {
<div class="gl-lg-display-flex gl-justify-content-end gl-mx-n2 gl-my-n2">
<div class="gl-p-2 gl-lg-form-input-md gl-w-full">
<gl-search-box-by-type
v-model="search"
:value="search"
:placeholder="$options.i18n.searchPlaceholder"
data-qa-selector="groups_filter_field"
@input="handleSearchInput"
/>
</div>
<div class="gl-p-2 gl-w-full gl-lg-w-auto">

View File

@ -24,8 +24,6 @@ export const COMMON_STR = {
EDIT_BTN_TITLE: s__('GroupsTree|Edit'),
REMOVE_BTN_TITLE: s__('GroupsTree|Delete'),
OPTIONS_DROPDOWN_TITLE: s__('GroupsTree|Options'),
GROUP_SEARCH_EMPTY: s__('GroupsTree|No groups matched your search'),
GROUP_PROJECT_SEARCH_EMPTY: s__('GroupsTree|No groups or projects matched your search'),
};
export const ITEM_TYPE = {

View File

@ -99,7 +99,9 @@ export function startIde(options) {
return;
}
if (gon.features?.vscodeWebIde) {
const useNewWebIde = parseBoolean(ideElement.dataset.useNewWebIde);
if (useNewWebIde) {
initGitlabWebIDE(ideElement);
} else {
resetServiceWorkersPublicPath();

View File

@ -7,8 +7,7 @@ export const initGitlabWebIDE = async (el) => {
const baseUrl = new URL(process.env.GITLAB_WEB_IDE_PUBLIC_PATH, window.location.origin);
// what: Pull what we need from the element. We will replace it soon.
const { path_with_namespace: projectPath } = JSON.parse(el.dataset.project);
const { cspNonce: nonce, branchName: ref } = el.dataset;
const { cspNonce: nonce, branchName: ref, projectPath } = el.dataset;
// what: Clean up the element, but preserve id.
// why: This way we don't inherit any `ide-loading` side-effects. This

View File

@ -0,0 +1,95 @@
<script>
import { GlAvatarLabeled, GlListbox } from '@gitlab/ui';
import { __ } from '~/locale';
import searchUsersQuery from '~/graphql_shared/queries/users_search_all.query.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
const USERS_PER_PAGE = 20;
export default {
components: {
GlAvatarLabeled,
GlListbox,
},
props: {
name: {
type: String,
required: true,
},
},
apollo: {
usersQuery: {
query: searchUsersQuery,
variables() {
return {
search: this.search,
first: USERS_PER_PAGE,
};
},
update(data) {
return data;
},
debounce: DEFAULT_DEBOUNCE_AND_THROTTLE_MS,
},
},
data() {
return {
user: '',
search: '',
};
},
computed: {
userId() {
return getIdFromGraphQLId(this.user);
},
users() {
return [
{ text: __('(no user)'), value: '' },
...(this.usersQuery?.users.nodes || []).map((u) => ({
username: `@${u.username}`,
avatarUrl: u.avatarUrl,
text: u.name,
value: u.id,
})),
];
},
},
methods: {
clearTransform() {
// FIXME: workaround for listbox issue
// https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1986
const { listbox } = this.$refs;
if (listbox.querySelector('.dropdown-menu')) {
listbox.querySelector('.dropdown-menu').style.transform = '';
}
},
},
};
</script>
<template>
<div>
<gl-listbox
ref="listbox"
v-model="user"
:items="users"
searchable
is-check-centered
:searching="$apollo.loading"
@click.capture.native="clearTransform"
@search="search = $event"
>
<template #list-item="{ item }">
<gl-avatar-labeled
shape="circle"
:size="32"
:src="item.avatarUrl"
:label="item.text"
:sub-label="item.username"
/>
</template>
</gl-listbox>
<input type="hidden" :name="name" :value="userId" />
</div>
</template>

View File

@ -1,3 +1,19 @@
import UsersSelect from '~/users_select';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import UserSelect from './components/user_select.vue';
new UsersSelect(); // eslint-disable-line no-new
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
Array.from(document.querySelectorAll('.js-gitlab-user')).forEach(
(node) =>
new Vue({
el: node,
apolloProvider,
render: (h) => h(UserSelect, { props: { name: node.dataset.name } }),
}),
);

View File

@ -25,6 +25,8 @@ import {
Tracking,
IssuableAttributeState,
IssuableAttributeType,
LocalizedIssuableAttributeType,
IssuableAttributeTypeKeyMap,
issuableAttributesQueries,
noAttributeId,
defaultEpicSort,
@ -229,7 +231,9 @@ export default {
return timeFor(this.currentAttribute?.dueDate);
},
i18n() {
return dropdowni18nText(this.issuableAttribute, this.issuableType);
const localizedAttribute =
LocalizedIssuableAttributeType[IssuableAttributeTypeKeyMap[this.issuableAttribute]];
return dropdowni18nText(localizedAttribute, this.issuableType);
},
isEpic() {
// MV to EE https://gitlab.com/gitlab-org/gitlab/-/issues/345311

View File

@ -1,3 +1,4 @@
import { invert } from 'lodash';
import { s__, __, sprintf } from '~/locale';
import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql';
import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql';
@ -251,6 +252,12 @@ export const IssuableAttributeType = {
Milestone: 'milestone',
};
export const LocalizedIssuableAttributeType = {
Milestone: s__('Issuable|milestone'),
};
export const IssuableAttributeTypeKeyMap = invert(IssuableAttributeType);
export const IssuableAttributeState = {
[IssuableAttributeType.Milestone]: 'active',
};

View File

@ -819,13 +819,14 @@ UsersSelect.prototype.renderRow = function (
const tooltipAttributes = tooltip
? `data-container="body" data-placement="left" data-title="${tooltip}"`
: '';
const dataUserSuggested = user.suggested ? `data-user-suggested=${user.suggested}` : '';
const name =
user?.availability && isUserBusy(user.availability)
? sprintf(__('%{name} (Busy)'), { name: user.name })
: user.name;
return `
<li data-user-id=${user.id}>
<li data-user-id=${user.id} ${dataUserSuggested}>
<a href="#" class="dropdown-menu-user-link gl-display-flex! gl-align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
<span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden">

View File

@ -28,7 +28,7 @@ const nonStandardEvents = {
},
counter: {},
},
testReport: {
testSummary: {
uniqueUser: {
expand: ['i_testing_summary_widget_total'],
},

View File

@ -1,5 +1,6 @@
<script>
import { GlFormGroup, GlFormInput, GlFormRadio, GlFormRadioGroup } from '@gitlab/ui';
import { isEmpty } from 'lodash';
import { GlFormGroup, GlFormInput, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import FormUrlMaskItem from './form_url_mask_item.vue';
@ -11,19 +12,60 @@ export default {
GlFormInput,
GlFormRadio,
GlFormRadioGroup,
GlLink,
},
props: {
initialUrl: {
type: String,
required: false,
default: null,
},
initialUrlVariables: {
type: Array,
required: false,
default: null,
},
},
data() {
return {
maskEnabled: false,
url: null,
maskEnabled: !isEmpty(this.initialUrlVariables),
url: this.initialUrl,
items: isEmpty(this.initialUrlVariables) ? [{}] : this.initialUrlVariables,
};
},
computed: {
maskedUrl() {
return this.url;
if (!this.url) {
return null;
}
let maskedUrl = this.url;
this.items.forEach(({ key, value }) => {
if (!key || !value) {
return;
}
const replacementExpression = new RegExp(value, 'g');
maskedUrl = maskedUrl.replace(replacementExpression, `{${key}}`);
});
return maskedUrl;
},
},
methods: {
onItemInput({ index, key, value }) {
this.$set(this.items, index, { key, value });
},
addItem() {
this.items.push({});
},
removeItem(index) {
this.items.splice(index, 1);
},
},
i18n: {
addItem: s__('Webhooks|+ Mask another portion of URL'),
radioFullUrlText: s__('Webhooks|Show full URL'),
radioMaskUrlText: s__('Webhooks|Mask portions of URL'),
radioMaskUrlHelp: s__('Webhooks|Do not show sensitive data such as tokens in the UI.'),
@ -49,6 +91,7 @@ export default {
v-model="url"
name="hook[url]"
:placeholder="$options.i18n.urlPlaceholder"
data-testid="form-url"
/>
</gl-form-group>
<div class="gl-mt-5">
@ -63,9 +106,27 @@ export default {
</gl-form-radio-group>
<div v-if="maskEnabled" class="gl-ml-6" data-testid="url-mask-section">
<form-url-mask-item :index="0" />
<form-url-mask-item
v-for="({ key, value }, index) in items"
:key="index"
:index="index"
:item-key="key"
:item-value="value"
@input="onItemInput"
@remove="removeItem"
/>
<div class="gl-mb-5">
<gl-link @click="addItem">{{ $options.i18n.addItem }}</gl-link>
</div>
<gl-form-group :label="$options.i18n.urlPreview" label-for="webhook-url-preview">
<gl-form-input id="webhook-url-preview" :value="maskedUrl" readonly />
<gl-form-input
id="webhook-url-preview"
:value="maskedUrl"
readonly
name="hook[url]"
data-testid="form-url-preview"
/>
</gl-form-group>
</div>
</div>

View File

@ -14,6 +14,16 @@ export default {
required: false,
default: null,
},
itemKey: {
type: String,
required: false,
default: null,
},
itemValue: {
type: String,
required: false,
default: null,
},
},
computed: {
keyInputId() {
@ -30,6 +40,15 @@ export default {
inputName(type) {
return `hook[url_variables][][${type}]`;
},
onKeyInput(key) {
this.$emit('input', { index: this.index, key, value: this.itemValue });
},
onValueInput(value) {
this.$emit('input', { index: this.index, key: this.itemKey, value });
},
onRemoveClick() {
this.$emit('remove', this.index);
},
},
i18n: {
keyLabel: s__('Webhooks|How it looks in the UI'),
@ -39,14 +58,19 @@ export default {
</script>
<template>
<div class="gl-display-flex gl-align-items-flex-end gl-gap-3 gl-mb-5">
<div class="gl-display-flex gl-align-items-flex-end gl-gap-3 gl-mb-3">
<gl-form-group
:label="$options.i18n.valueLabel"
:label-for="valueInputId"
class="gl-flex-grow-1 gl-mb-0"
data-testid="mask-item-value"
>
<gl-form-input :id="valueInputId" :name="inputName('value')" />
<gl-form-input
:id="valueInputId"
:name="inputName('value')"
:value="itemValue"
@input="onValueInput"
/>
</gl-form-group>
<gl-form-group
:label="$options.i18n.keyLabel"
@ -54,8 +78,13 @@ export default {
class="gl-flex-grow-1 gl-mb-0"
data-testid="mask-item-key"
>
<gl-form-input :id="keyInputId" :name="inputName('key')" />
<gl-form-input
:id="keyInputId"
:name="inputName('key')"
:value="itemKey"
@input="onKeyInput"
/>
</gl-form-group>
<gl-button icon="remove" />
<gl-button icon="remove" :aria-label="__('Remove')" @click="onRemoveClick" />
</div>
</template>

View File

@ -8,11 +8,18 @@ export default () => {
return null;
}
const { url: initialUrl, urlVariables } = el.dataset;
return new Vue({
el,
name: 'WebhookFormRoot',
render(createElement) {
return createElement(FormUrlApp, {});
return createElement(FormUrlApp, {
props: {
initialUrl,
initialUrlVariables: urlVariables ? JSON.parse(urlVariables) : undefined,
},
});
},
});
};

View File

@ -23,6 +23,7 @@ import {
WIDGET_TYPE_WEIGHT,
WIDGET_TYPE_HIERARCHY,
WORK_ITEM_VIEWED_STORAGE_KEY,
WIDGET_TYPE_MILESTONE,
WIDGET_TYPE_ITERATION,
} from '../constants';
@ -40,6 +41,7 @@ import WorkItemDescription from './work_item_description.vue';
import WorkItemDueDate from './work_item_due_date.vue';
import WorkItemAssignees from './work_item_assignees.vue';
import WorkItemLabels from './work_item_labels.vue';
import WorkItemMilestone from './work_item_milestone.vue';
import WorkItemInformation from './work_item_information.vue';
export default {
@ -67,6 +69,7 @@ export default {
LocalStorageSync,
WorkItemTypeIcon,
WorkItemIteration: () => import('ee_component/work_items/components/work_item_iteration.vue'),
WorkItemMilestone,
},
mixins: [glFeatureFlagMixin()],
props: {
@ -208,6 +211,9 @@ export default {
workItemIteration() {
return this.isWidgetPresent(WIDGET_TYPE_ITERATION);
},
workItemMilestone() {
return this.workItem?.mockWidgets?.find((widget) => widget.type === WIDGET_TYPE_MILESTONE);
},
},
beforeDestroy() {
/** make sure that if the user has not even dismissed the alert ,
@ -411,6 +417,17 @@ export default {
:work-item-type="workItemType"
@error="updateError = $event"
/>
<template v-if="workItemsMvc2Enabled">
<work-item-milestone
v-if="workItemMilestone"
:work-item-id="workItem.id"
:work-item-milestone="workItemMilestone.nodes[0]"
:work-item-type="workItemType"
:can-update="canUpdate"
:full-path="fullPath"
@error="updateError = $event"
/>
</template>
<work-item-weight
v-if="workItemWeight"
class="gl-mb-5"

View File

@ -0,0 +1,248 @@
<script>
import {
GlFormGroup,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlSkeletonLoader,
GlSearchBoxByType,
GlDropdownText,
} from '@gitlab/ui';
import * as Sentry from '@sentry/browser';
import { debounce } from 'lodash';
import Tracking from '~/tracking';
import { s__ } from '~/locale';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
import localUpdateWorkItemMutation from '../graphql/local_update_work_item.mutation.graphql';
import {
I18N_WORK_ITEM_ERROR_UPDATING,
sprintfWorkItem,
TRACKING_CATEGORY_SHOW,
} from '../constants';
const noMilestoneId = 'no-milestone-id';
export default {
i18n: {
MILESTONE: s__('WorkItem|Milestone'),
NONE: s__('WorkItem|None'),
MILESTONE_PLACEHOLDER: s__('WorkItem|Add to milestone'),
NO_MATCHING_RESULTS: s__('WorkItem|No matching results'),
NO_MILESTONE: s__('WorkItem|No milestone'),
MILESTONE_FETCH_ERROR: s__(
'WorkItem|Something went wrong while fetching milestones. Please try again.',
),
},
components: {
GlFormGroup,
GlDropdown,
GlDropdownItem,
GlDropdownDivider,
GlSkeletonLoader,
GlSearchBoxByType,
GlDropdownText,
},
mixins: [Tracking.mixin()],
props: {
workItemId: {
type: String,
required: true,
},
workItemMilestone: {
type: Object,
required: false,
default: () => {},
},
workItemType: {
type: String,
required: false,
default: '',
},
canUpdate: {
type: Boolean,
required: false,
default: false,
},
fullPath: {
type: String,
required: true,
},
},
data() {
return {
localMilestone: this.workItemMilestone,
searchTerm: '',
shouldFetch: false,
updateInProgress: false,
isFocused: false,
milestones: [],
};
},
computed: {
tracking() {
return {
category: TRACKING_CATEGORY_SHOW,
label: 'item_milestone',
property: `type_${this.workItemType}`,
};
},
emptyPlaceholder() {
return this.canUpdate ? this.$options.i18n.MILESTONE_PLACEHOLDER : this.$options.i18n.NONE;
},
dropdownText() {
return this.localMilestone?.title || this.emptyPlaceholder;
},
isLoadingMilestones() {
return this.$apollo.queries.milestones.loading;
},
isNoMilestone() {
return this.localMilestone?.id === noMilestoneId || !this.localMilestone?.id;
},
dropdownClasses() {
return {
'gl-text-gray-500!': this.canUpdate && this.isNoMilestone,
'is-not-focused': !this.isFocused,
};
},
},
created() {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
},
apollo: {
milestones: {
query: projectMilestonesQuery,
variables() {
return {
fullPath: this.fullPath,
title: this.searchTerm,
first: 20,
};
},
skip() {
return !this.shouldFetch;
},
update(data) {
return data?.workspace?.attributes?.nodes || [];
},
error() {
this.$emit('error', this.i18n.MILESTONE_FETCH_ERROR);
},
},
},
methods: {
handleMilestoneClick(milestone) {
this.localMilestone = milestone;
},
onDropdownShown() {
this.$refs.search.focusInput();
this.shouldFetch = true;
this.isFocused = true;
},
onDropdownHide() {
this.isFocused = false;
this.searchTerm = '';
this.shouldFetch = false;
this.updateMilestone();
},
setSearchKey(value) {
this.searchTerm = value;
},
isMilestoneChecked(milestone) {
return this.localMilestone?.id === milestone?.id;
},
updateMilestone() {
if (this.workItemMilestone?.id === this.localMilestone?.id) {
return;
}
this.track('updated_milestone');
this.updateInProgress = true;
this.$apollo
.mutate({
mutation: localUpdateWorkItemMutation,
variables: {
input: {
id: this.workItemId,
milestone: {
milestoneId: this.localMilestone?.id,
},
},
},
})
.then(({ data }) => {
if (data.workItemUpdate.errors.length) {
throw new Error(data.workItemUpdate.errors.join('\n'));
}
})
.catch((error) => {
const msg = sprintfWorkItem(I18N_WORK_ITEM_ERROR_UPDATING, this.workItemType);
this.$emit('error', msg);
Sentry.captureException(error);
})
.finally(() => {
this.updateInProgress = false;
});
},
},
};
</script>
<template>
<gl-form-group
class="work-item-dropdown"
:label="$options.i18n.MILESTONE"
label-class="gl-pb-0! gl-overflow-wrap-break gl-mt-3"
label-cols="3"
label-cols-lg="2"
>
<span
v-if="!canUpdate"
class="gl-text-secondary gl-ml-4 gl-mt-3 gl-display-inline-block gl-line-height-normal"
data-testid="disabled-text"
>
{{ dropdownText }}
</span>
<gl-dropdown
v-else
:toggle-class="dropdownClasses"
:text="dropdownText"
:loading="updateInProgress"
@shown="onDropdownShown"
@hide="onDropdownHide"
>
<template #header>
<gl-search-box-by-type ref="search" :value="searchTerm" @input="debouncedSearchKeyUpdate" />
</template>
<gl-dropdown-item
data-testid="no-milestone"
is-check-item
:is-checked="isNoMilestone"
@click="handleMilestoneClick({ id: 'no-milestone-id' })"
>
{{ $options.i18n.NO_MILESTONE }}
</gl-dropdown-item>
<gl-dropdown-divider />
<gl-dropdown-text v-if="isLoadingMilestones">
<gl-skeleton-loader :height="90">
<rect width="380" height="10" x="10" y="15" rx="4" />
<rect width="280" height="10" x="10" y="30" rx="4" />
<rect width="380" height="10" x="10" y="50" rx="4" />
<rect width="280" height="10" x="10" y="65" rx="4" />
</gl-skeleton-loader>
</gl-dropdown-text>
<template v-else-if="milestones.length">
<gl-dropdown-item
v-for="milestone in milestones"
:key="milestone.id"
is-check-item
:is-checked="isMilestoneChecked(milestone)"
@click="handleMilestoneClick(milestone)"
>
{{ milestone.title }}
</gl-dropdown-item>
</template>
<gl-dropdown-text v-else>{{ $options.i18n.NO_MATCHING_RESULTS }}</gl-dropdown-text>
</gl-dropdown>
</gl-form-group>
</template>

View File

@ -17,6 +17,7 @@ export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
export const WIDGET_TYPE_HIERARCHY = 'HIERARCHY';
export const WIDGET_TYPE_MILESTONE = 'MILESTONE';
export const WIDGET_TYPE_ITERATION = 'ITERATION';
export const WORK_ITEM_VIEWED_STORAGE_KEY = 'gl-show-work-item-banner';

View File

@ -1,5 +1,6 @@
enum LocalWidgetType {
ASSIGNEES
MILESTONE
}
interface LocalWorkItemWidget {
@ -11,6 +12,15 @@ type LocalWorkItemAssignees implements LocalWorkItemWidget {
nodes: [UserCore]
}
type LocalWorkItemMilestone implements LocalWorkItemWidget {
type: LocalWidgetType!
nodes: [Milestone!]
}
extend type WorkItem {
mockWidgets: [LocalWorkItemWidget]
}
input LocalUserInput {
id: ID!
name: String
@ -19,9 +29,14 @@ input LocalUserInput {
avatarUrl: String
}
input LocalMilestoneInput {
milestoneId: ID!
}
input LocalUpdateWorkItemInput {
id: WorkItemID!
assignees: [LocalUserInput!]
milestone: LocalMilestoneInput!
}
type LocalWorkItemPayload {

View File

@ -3,5 +3,16 @@
query workItem($id: WorkItemID!) {
workItem(id: $id) {
...WorkItem
mockWidgets @client {
... on LocalWorkItemMilestone {
type
nodes {
id
title
expired
dueDate
}
}
}
}
}

View File

@ -64,7 +64,7 @@
}
}
.work-item-iteration {
.work-item-dropdown {
.gl-dropdown-toggle {
background: none !important;
@ -82,4 +82,3 @@
}
}
}

View File

@ -11,7 +11,6 @@ class IdeController < ApplicationController
push_frontend_feature_flag(:build_service_proxy)
push_frontend_feature_flag(:schema_linting)
push_frontend_feature_flag(:reject_unsigned_commits_by_gitlab)
push_frontend_feature_flag(:vscode_web_ide, current_user)
define_index_vars
end
@ -27,7 +26,7 @@ class IdeController < ApplicationController
namespace: project&.namespace, user: current_user)
end
render layout: 'fullscreen', locals: { minimal: Feature.enabled?(:vscode_web_ide, current_user) }
render layout: 'fullscreen', locals: { minimal: helpers.use_new_web_ide? }
end
private

View File

@ -56,7 +56,8 @@ class Profiles::PreferencesController < Profiles::ApplicationController
:gitpod_enabled,
:render_whitespace_in_code,
:markdown_surround_selection,
:markdown_automatic_lists
:markdown_automatic_lists,
:use_legacy_web_ide
]
end
end

View File

@ -57,7 +57,7 @@ module Types
field :deployments,
Types::DeploymentType.connection_type,
null: true,
description: 'Deployments of the environment. This field can only be resolved for one project in any single request.',
description: 'Deployments of the environment. This field can only be resolved for one environment in any single request.',
resolver: Resolvers::DeploymentsResolver do
extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1
end

View File

@ -1,6 +1,13 @@
# frozen_string_literal: true
module HooksHelper
def webhook_form_data(hook)
{
url: hook.url,
url_variables: nil
}
end
def link_to_test_hook(hook, trigger)
path = test_hook_path(hook, trigger)
trigger_human_name = trigger.to_s.tr('_', ' ').camelize

View File

@ -2,6 +2,32 @@
module IdeHelper
def ide_data
{
'can-use-new-web-ide' => can_use_new_web_ide?.to_s,
'use-new-web-ide' => use_new_web_ide?.to_s,
'user-preferences-path' => profile_preferences_path,
'branch-name' => @branch
}.merge(use_new_web_ide? ? new_ide_data : legacy_ide_data)
end
def can_use_new_web_ide?
Feature.enabled?(:vscode_web_ide, current_user)
end
def use_new_web_ide?
can_use_new_web_ide? && !current_user.use_legacy_web_ide
end
private
def new_ide_data
{
'project-path' => @project&.path_with_namespace,
'csp-nonce' => content_security_policy_nonce
}
end
def legacy_ide_data
{
'empty-state-svg-path' => image_path('illustrations/multi_file_editor_empty.svg'),
'no-changes-state-svg-path' => image_path('illustrations/multi-editor_no_changes_empty.svg'),
@ -13,7 +39,6 @@ module IdeHelper
'clientside-preview-enabled': Gitlab::CurrentSettings.web_ide_clientside_preview_enabled?.to_s,
'render-whitespace-in-code': current_user.render_whitespace_in_code.to_s,
'codesandbox-bundler-url': Gitlab::CurrentSettings.web_ide_clientside_preview_bundler_url,
'branch-name' => @branch,
'default-branch' => @project && @project.default_branch,
'file-path' => @path,
'merge-request' => @merge_request,
@ -24,13 +49,10 @@ module IdeHelper
'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'),
'web-terminal-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'interactive-web-terminals-for-the-web-ide'),
'web-terminal-config-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'web-ide-configuration-file'),
'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration'),
'csp-nonce' => content_security_policy_nonce
'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration')
}
end
private
def convert_to_project_entity_json(project)
return unless project

View File

@ -410,6 +410,13 @@ class ApplicationSetting < ApplicationRecord
allow_nil: false,
inclusion: { in: [true, false], message: N_('must be a boolean value') }
# rubocop:disable Cop/StaticTranslationDefinition
validates :deactivate_dormant_users_period,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 90, message: _("'%{value}' days of inactivity must be greater than or equal to 90") },
if: :deactivate_dormant_users?
# rubocop:enable Cop/StaticTranslationDefinition
Gitlab::SSHPublicKey.supported_types.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end

View File

@ -1364,9 +1364,9 @@ module Ci
self.builds.latest.build_matchers(project)
end
def authorized_cluster_agents
strong_memoize(:authorized_cluster_agents) do
::Clusters::AgentAuthorizationsFinder.new(project).execute.map(&:agent)
def cluster_agent_authorizations
strong_memoize(:cluster_agent_authorizations) do
::Clusters::AgentAuthorizationsFinder.new(project).execute
end
end

View File

@ -16,7 +16,7 @@ module Clusters
end
def config
nil
{}
end
end
end

View File

@ -354,6 +354,7 @@ class User < ApplicationRecord
:markdown_automatic_lists, :markdown_automatic_lists=,
:diffs_deletion_color, :diffs_deletion_color=,
:diffs_addition_color, :diffs_addition_color=,
:use_legacy_web_ide, :use_legacy_web_ide=,
to: :user_preference
delegate :path, to: :namespace, allow_nil: true, prefix: true

View File

@ -22,6 +22,7 @@ class UserPreference < ApplicationRecord
validates :diffs_deletion_color, :diffs_addition_color,
format: { with: ColorsHelper::HEX_COLOR_PATTERN },
allow_blank: true
validates :use_legacy_web_ide, allow_nil: false, inclusion: { in: [true, false] }
ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22'

View File

@ -34,7 +34,9 @@ module Ci
def runner_variables
stop_expanding_file_vars = ::Feature.enabled?(:ci_stop_expanding_file_vars_for_runners, project)
variables.sort_and_expand_all(keep_undefined: true, expand_file_vars: !stop_expanding_file_vars).to_runner_variables
variables
.sort_and_expand_all(keep_undefined: true, expand_file_vars: !stop_expanding_file_vars, project: project)
.to_runner_variables
end
def refspecs

View File

@ -22,8 +22,9 @@ module BulkImports
subdir_path = export_subdir_path(upload)
mkdir_p(subdir_path)
download_or_copy_upload(uploader, File.join(subdir_path, uploader.filename))
rescue Errno::ENAMETOOLONG => e
# Do not fail entire export process if downloaded file has filename that exceeds 255 characters.
rescue StandardError => e
# Do not fail entire project export if something goes wrong during file download
# (e.g. downloaded file has filename that exceeds 255 characters).
# Ignore raised exception, skip such upload, log the error and keep going with the export instead.
Gitlab::ErrorTracking.log_exception(e, portable_id: portable.id, portable_class: portable.class.name, upload_id: upload.id)
end

View File

@ -14,7 +14,8 @@ module Ci
url: Gitlab::Kas.tunnel_url
)
agents.each do |agent|
agent_authorizations.each do |authorization|
agent = authorization.agent
user = user_name(agent)
template.add_user(
@ -24,6 +25,7 @@ module Ci
template.add_context(
name: context_name(agent),
namespace: context_namespace(authorization),
cluster: cluster_name,
user: user
)
@ -36,8 +38,8 @@ module Ci
attr_reader :pipeline, :token, :template
def agents
pipeline.authorized_cluster_agents
def agent_authorizations
pipeline.cluster_agent_authorizations
end
def cluster_name
@ -52,6 +54,10 @@ module Ci
[agent.project.full_path, agent.name].join(delimiter)
end
def context_namespace(authorization)
authorization.config['default_namespace']
end
def agent_token(agent)
['ci', agent.id, token].join(delimiter)
end

View File

@ -54,10 +54,10 @@
- dormant_users_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: dormant_users_help_link }
= f.gitlab_ui_checkbox_component :deactivate_dormant_users, _('Deactivate dormant users after a period of inactivity'), help_text: _('Users can reactivate their account by signing in. %{link_start}Learn more.%{link_end}').html_safe % { link_start: dormant_users_help_link_start, link_end: '</a>'.html_safe }
.form-group
= f.label :deactivate_dormant_users_period, _('Period of inactivity (days)'), class: 'label-light'
= f.number_field :deactivate_dormant_users_period, class: 'form-control gl-form-input', min: '1'
= f.label :deactivate_dormant_users_period, _('Days of inactivity before deactivation'), class: 'label-light'
= f.number_field :deactivate_dormant_users_period, class: 'form-control gl-form-input', min: '90', step: '1'
.form-text.text-muted
= _('Period of inactivity before deactivation.')
= _('Must be 90 days or more.')
.form-group
= f.label :personal_access_token_prefix, _('Personal Access Token prefix'), class: 'label-light'

View File

@ -23,23 +23,21 @@
%p
= html_escape(_('Selecting a GitLab user will add a link to the GitLab user in the descriptions of issues and comments (e.g. "By %{link_open}@johnsmith%{link_close}"). It will also associate and/or assign these issues and comments with the selected user.')) % { link_open: '<a href="#">'.html_safe, link_close: '</a>'.html_safe }
.table-holder
%table.table
%thead
%table.table
%thead
%tr
%th= _("ID")
%th= _("Name")
%th= _("Email")
%th= _("GitLab User")
%tbody
- @user_map.each do |id, user|
%tr
%th= _("ID")
%th= _("Name")
%th= _("Email")
%th= _("GitLab User")
%tbody
- @user_map.each do |id, user|
%tr
%td= id
%td= text_field_tag "users[#{id}][name]", user[:name], class: 'form-control'
%td= text_field_tag "users[#{id}][email]", user[:email], class: 'form-control'
%td
= users_select_tag("users[#{id}][gitlab_user]", class: 'custom-form-control',
scope: :all, email_user: true, selected: user[:gitlab_user])
%td= id
%td= text_field_tag "users[#{id}][name]", user[:name], class: 'form-control gl-form-input'
%td= text_field_tag "users[#{id}][email]", user[:email], class: 'form-control gl-form-input'
%td
.js-gitlab-user{ data: { name: "users[#{id}][gitlab_user]" } }
.form-actions
= submit_tag _('Continue to the next step'), class: 'gl-button btn btn-confirm'

View File

@ -1,7 +1,7 @@
= form_errors(hook)
- if Feature.enabled?(:webhook_form_mask_url)
.js-vue-webhook-form
.js-vue-webhook-form{ data: webhook_form_data(hook) }
- else
.form-group
= form.label :url, s_('Webhooks|URL'), class: 'label-bold'

View File

@ -0,0 +1,7 @@
# frozen_string_literal: true
class AddTargetsToElasticReindexingTasks < Gitlab::Database::Migration[2.0]
def change
add_column :elastic_reindexing_tasks, :targets, :text, array: true
end
end

View File

@ -0,0 +1,9 @@
# frozen_string_literal: true
class AddUseLegacyWebIdeToUserPreferences < Gitlab::Database::Migration[2.0]
enable_lock_retries!
def change
add_column :user_preferences, :use_legacy_web_ide, :boolean, default: false, null: false
end
end

View File

@ -0,0 +1,17 @@
# frozen_string_literal: true
class UpdateInvalidDormantUserSetting < Gitlab::Database::Migration[2.0]
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main
# rubocop:disable Layout/LineLength
def up
execute("update application_settings set deactivate_dormant_users_period=90 where deactivate_dormant_users_period < 90")
end
# rubocop:enable Layout/LineLength
def down
# no-op
end
end

View File

@ -0,0 +1 @@
ae45bc7d67354b64e359ac7fadefec6a0d81cd529f5ae2517a6a6a5d250f9024

View File

@ -0,0 +1 @@
3c2445871613743560b2dd0a111fafab30f503b1c462e7ba7aee03f85e25f775

View File

@ -0,0 +1 @@
dbf241baf6d3deb1ef29a7cdca012050cab51c5f86762a0363d9dc4dc14fd804

View File

@ -14981,6 +14981,7 @@ CREATE TABLE elastic_reindexing_tasks (
delete_original_index_at timestamp with time zone,
max_slices_running smallint DEFAULT 60 NOT NULL,
slice_multiplier smallint DEFAULT 2 NOT NULL,
targets text[],
CONSTRAINT check_7f64acda8e CHECK ((char_length(error_message) <= 255))
);
@ -22272,6 +22273,7 @@ CREATE TABLE user_preferences (
diffs_deletion_color text,
diffs_addition_color text,
markdown_automatic_lists boolean DEFAULT true NOT NULL,
use_legacy_web_ide boolean DEFAULT false NOT NULL,
CONSTRAINT check_89bf269f41 CHECK ((char_length(diffs_deletion_color) <= 7)),
CONSTRAINT check_d07ccd35f7 CHECK ((char_length(diffs_addition_color) <= 7))
);

View File

@ -61,6 +61,8 @@ verification methods:
| Blobs | CI Secure Files _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ |
| Blobs | Incident Metric Images _(file system)_ | Geo with API/Managed | SHA256 checksum |
| Blobs | Incident Metric Images _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ |
| Blobs | Alert Metric Images _(file system)_ | Geo with API | SHA256 checksum |
| Blobs | Alert Metric Images _(object storage)_ | Geo with API/Managed (*2*) | _Not implemented_ |
- (*1*): Redis replication can be used as part of HA with Redis sentinel. It's not used between Geo sites.
- (*2*): Object storage replication can be performed by Geo or by your object storage provider/appliance
@ -207,10 +209,10 @@ Requires additional configuration. See [instructions](container_registry.md) to
|[Versioned Terraform State](../../terraform_state.md) | **Yes** (13.5) | **Yes** (13.12) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication is behind the feature flag `geo_terraform_state_version_replication`, enabled by default. Verification was behind the feature flag `geo_terraform_state_version_verification`, which was removed in 14.0. |
|[External merge request diffs](../../merge_request_diffs.md) | **Yes** (13.5) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication is behind the feature flag `geo_merge_request_diff_replication`, enabled by default. Verification was behind the feature flag `geo_merge_request_diff_verification`, removed in 14.7.|
|[Versioned snippets](../../../user/snippets.md#versioned-snippets) | [**Yes** (13.7)](https://gitlab.com/groups/gitlab-org/-/epics/2809) | [**Yes** (14.2)](https://gitlab.com/groups/gitlab-org/-/epics/2810) | N/A | N/A | Verification was implemented behind the feature flag `geo_snippet_repository_verification` in 13.11, and the feature flag was removed in 14.2. |
| [GitLab Pages](../../pages/index.md) | [**Yes** (14.3)](https://gitlab.com/groups/gitlab-org/-/epics/589) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Behind feature flag `geo_pages_deployment_replication`, enabled by default. Verification was behind the feature flag `geo_pages_deployment_verification`, removed in 14.7. |
| [Project-level Secure files](../../../ci/secure_files/index.md) | **Yes** (15.3) | **Yes** (15.3) | **Yes** (15.3) | [No](object_storage.md#verification-of-files-in-object-storage) | |
| [Incident Metric Images](../../../operations/incident_management/incidents.md#metrics) | **Yes** (15.5) | **Yes**(15.5) | Yes | Yes | Replication/Verification is handled via the Uploads data type |
|[Alert Metric Images](../../../operations/incident_management/alerts.md#metrics-tab) | [Planned](https://gitlab.com/gitlab-org/gitlab/-/issues/362564) | [No](https://gitlab.com/gitlab-org/gitlab/-/issues/362564) | No | No | |
|[GitLab Pages](../../pages/index.md) | [**Yes** (14.3)](https://gitlab.com/groups/gitlab-org/-/epics/589) | **Yes** (14.6) | [**Yes** (15.1)](https://gitlab.com/groups/gitlab-org/-/epics/5551) | [No](object_storage.md#verification-of-files-in-object-storage) | Behind feature flag `geo_pages_deployment_replication`, enabled by default. Verification was behind the feature flag `geo_pages_deployment_verification`, removed in 14.7. |
|[Project-level Secure files](../../../ci/secure_files/index.md) | **Yes** (15.3) | **Yes** (15.3) | **Yes** (15.3) | [No](object_storage.md#verification-of-files-in-object-storage) | |
| [Incident Metric Images](../../../operations/incident_management/incidents.md#metrics) | **Yes** (15.5) | **Yes**(15.5) | **Yes** (15.5) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication/Verification is handled via the Uploads data type. | |
|[Alert Metric Images](../../../operations/incident_management/alerts.md#metrics-tab) | **Yes** (15.5) | **Yes** (15.5) | **Yes** (15.5) | [No](object_storage.md#verification-of-files-in-object-storage) | Replication/Verification is handled via the Uploads data type. |
|[Server-side Git hooks](../../server_hooks.md) | [Not planned](https://gitlab.com/groups/gitlab-org/-/epics/1867) | No | N/A | N/A | Not planned because of current implementation complexity, low customer interest, and availability of alternatives to hooks. |
|[Elasticsearch integration](../../../integration/advanced_search/elasticsearch.md) | [Not planned](https://gitlab.com/gitlab-org/gitlab/-/issues/1186) | No | No | No | Not planned because further product discovery is required and Elasticsearch (ES) clusters can be rebuilt. Secondaries use the same ES cluster as the primary. |
|[Dependency proxy images](../../../user/packages/dependency_proxy/index.md) | [Planned](https://gitlab.com/groups/gitlab-org/-/epics/8833) | No | No | No | Blocked by [Geo: Secondary Mimicry](https://gitlab.com/groups/gitlab-org/-/epics/1528). Replication of this cache is not needed for disaster recovery purposes because it can be recreated from external sources. |

View File

@ -240,6 +240,24 @@ project.id
# => 2537
```
## Time an operation
If you'd like to time one or more operations, use the following format, replacing
the placeholder `<operation>` with your Ruby or Rails commands of choice:
```ruby
# A single operation
Benchmark.measure { <operation> }
# A breakdown of multiple operations
Benchmark.bm do |x|
x.report(:label1) { <operation_1> }
x.report(:label2) { <operation_2> }
end
```
For more information, review [our developer documentation about benchmarks](../../development/performance.md#benchmarks).
## Active Record objects
### Looking up database-persisted objects

View File

@ -62,19 +62,6 @@ Notify.test_email(e, "Test email for #{n}", 'Test email').deliver_now
Notify.test_email(u.email, "Test email for #{u.name}", 'Test email').deliver_now
```
## Time an operation
```ruby
# A single operation
Benchmark.measure { <operation> }
# A breakdown of multiple operations
Benchmark.bm do |x|
x.report(:label1) { <operation_1> }
x.report(:label2) { <operation_2> }
end
```
## Imports and exports
### Import a project

View File

@ -11916,7 +11916,7 @@ Describes where code is deployed for a project.
##### `Environment.deployments`
Deployments of the environment. This field can only be resolved for one project in any single request.
Deployments of the environment. This field can only be resolved for one environment in any single request.
Returns [`DeploymentConnection`](#deploymentconnection).

View File

@ -25,12 +25,12 @@ GET packages/terraform/modules/v1/:module_namespace/:module_name/:module_system/
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `module_namespace` | string | yes | The group to which Terraform module's project belongs. |
| `module_namespace` | string | yes | The top-level group (namespace) to which Terraform module's project or subgroup belongs.|
| `module_name` | string | yes | The module name. |
| `module_system` | string | yes | The name of the module system or [provider](https://www.terraform.io/registry/providers). |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/versions"
curl --header "Authorization: Bearer <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/versions"
```
Example response:
@ -88,7 +88,7 @@ GET packages/terraform/modules/v1/:module_namespace/:module_name/:module_system
| `module_system` | string | yes | The name of the module system or [provider](https://www.terraform.io/registry/providers). |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local"
curl --header "Authorization: Bearer <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local"
```
Example response:
@ -127,7 +127,7 @@ GET packages/terraform/modules/v1/:module_namespace/:module_name/:module_system/
| `module_system` | string | yes | The name of the module system or [provider](https://www.terraform.io/registry/providers). |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/1.0.0"
curl --header "Authorization: Bearer <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/1.0.0"
```
Example response:
@ -166,7 +166,7 @@ GET packages/terraform/modules/v1/:module_namespace/:module_name/:module_system/
| `module_system` | string | yes | The name of the module system or [provider](https://www.terraform.io/registry/providers). |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/download"
curl --header "Authorization: Bearer <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/download"
```
Example response:
@ -195,7 +195,7 @@ GET packages/terraform/modules/v1/:module_namespace/:module_name/:module_system/
| `module_version` | string | yes | Specific module version to download. |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/1.0.0/download"
curl --header "Authorization: Bearer <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/1.0.0/download"
```
Example response:
@ -220,11 +220,11 @@ GET packages/terraform/modules/v1/:module_namespace/:module_name/:module_system/
| `module_version` | string | yes | Specific module version to download. |
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/1.0.0/file"
curl --header "Authorization: Bearer <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/1.0.0/file"
```
To write the output to file:
```shell
curl --header "Private-Token: <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/1.0.0/file" --output hello-world-local.tgz
curl --header "Authorization: Bearer <personal_access_token>" "https://gitlab.example.com/api/v4/packages/terraform/modules/v1/group/hello-world/local/1.0.0/file" --output hello-world-local.tgz
```

View File

@ -9,16 +9,19 @@ type: reference, api
## List repository tree
> Iterating pages of results with a number (`?page=2`) [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67509) in GitLab 14.3.
Get a list of repository files and directories in a project. This endpoint can
be accessed without authentication if the repository is publicly accessible.
This command provides essentially the same functionality as the `git ls-tree` command. For more information, see the section _Tree Objects_ in the [Git internals documentation](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects/#_tree_objects).
This command provides essentially the same features as the `git ls-tree`
command. For more information, refer to the section
[Tree Objects](https://git-scm.com/book/en/v2/Git-Internals-Git-Objects/#_tree_objects)
in the Git internals documentation.
WARNING:
This endpoint is changing to keyset-based pagination. Iterating pages of results
with a number (`?page=2`) [is deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/67509).
Support for iterating with a number became supported in GitLab 15.0. Use
the new [keyset pagination system](index.md#keyset-based-pagination) instead.
This endpoint changed to [keyset-based pagination](index.md#keyset-based-pagination)
in GitLab 15.0. Iterating pages of results with a number (`?page=2`) is unsupported.
```plaintext
GET /projects/:id/repository/tree
@ -29,12 +32,12 @@ Supported attributes:
| Attribute | Type | Required | Description |
| :---------- | :------------- | :------- | :---------- |
| `id` | integer or string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user. |
| `path` | string | no | The path inside repository. Used to get content of subdirectories. |
| `ref` | string | no | The name of a repository branch or tag or if not given the default branch. |
| `recursive` | boolean | no | Boolean value used to get a recursive tree (false by default). |
| `per_page` | integer | no | Number of results to show per page. If not specified, defaults to `20`. [Learn more on pagination](index.md#pagination). |
| `pagination` | string | no | If set to `keyset`, use the new keyset pagination method. |
| `page_token` | string | no | The tree record ID at which to fetch the next page. Used only with keyset pagination. |
| `pagination` | string | no | If `keyset`, use the [keyset-based pagination method](index.md#keyset-based-pagination). |
| `path` | string | no | The path inside the repository. Used to get content of subdirectories. |
| `per_page` | integer | no | Number of results to show per page. If not specified, defaults to `20`. [Learn more on pagination](index.md#pagination). |
| `recursive` | boolean | no | Boolean value used to get a recursive tree. Default is `false`. |
| `ref` | string | no | The name of a repository branch or tag or, if not given, the default branch. |
```json
[
@ -92,9 +95,9 @@ Supported attributes:
## Get a blob from repository
Allows you to receive information about blob in repository like size and
content. Blob content is Base64 encoded. This endpoint can be accessed
without authentication if the repository is publicly accessible.
Allows you to receive information, such as size and content, about blobs in a repository.
Blob content is Base64 encoded. This endpoint can be accessed without authentication,
if the repository is publicly accessible.
```plaintext
GET /projects/:id/repository/blobs/:sha
@ -109,7 +112,7 @@ Supported attributes:
## Raw blob content
Get the raw file contents for a blob by blob SHA. This endpoint can be accessed
Get the raw file contents for a blob, by blob SHA. This endpoint can be accessed
without authentication if the repository is publicly accessible.
```plaintext
@ -131,24 +134,32 @@ Supported attributes:
Get an archive of the repository. This endpoint can be accessed without
authentication if the repository is publicly accessible.
This endpoint has a rate limit threshold of 5 requests per minute for GitLab.com users.
For GitLab.com users, this endpoint has a rate limit threshold of 5 requests per minute.
```plaintext
GET /projects/:id/repository/archive[.format]
```
`format` is an optional suffix for the archive format. Default is
`tar.gz`. Options are `tar.gz`, `tar.bz2`, `tbz`, `tbz2`, `tb2`,
`bz2`, `tar`, and `zip`. For example, specifying `archive.zip`
would send an archive in ZIP format.
`format` is an optional suffix for the archive format, and defaults to
`tar.gz`. For example, specifying `archive.zip` sends an archive in ZIP format.
Available options are:
- `bz2`
- `tar`
- `tar.bz2`
- `tar.gz`
- `tb2`
- `tbz`
- `tbz2`
- `zip`
Supported attributes:
| Attribute | Type | Required | Description |
|:------------|:---------------|:---------|:----------------------|
| `id` | integer or string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user. |
| `sha` | string | no | The commit SHA to download. A tag, branch reference, or SHA can be used. This defaults to the tip of the default branch if not specified. |
| `path` | string | no | The subpath of the repository to download. This defaults to the whole repository (empty string). |
| `path` | string | no | The subpath of the repository to download. If an empty string, defaults to the whole repository. |
| `sha` | string | no | The commit SHA to download. A tag, branch reference, or SHA can be used. If not specified, defaults to the tip of the default branch. |
Example request:
@ -159,7 +170,8 @@ curl --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.com/api/v4/pr
## Compare branches, tags or commits
This endpoint can be accessed without authentication if the repository is
publicly accessible. Diffs can have an empty diff string if [diff limits](../development/diffs.md#diff-limits) are reached.
publicly accessible. Diffs can have an empty diff string if
[diff limits](../development/diffs.md#diff-limits) are reached.
```plaintext
GET /projects/:id/repository/compare
@ -172,8 +184,8 @@ Supported attributes:
| `id` | integer or string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) owned by the authenticated user. |
| `from` | string | yes | The commit SHA or branch name. |
| `to` | string | yes | The commit SHA or branch name. |
| `from_project_id` | integer | no | The ID to compare from |
| `straight` | boolean | no | Comparison method, `true` for direct comparison between `from` and `to` (`from`..`to`), `false` to compare using merge base (`from`...`to`)'. Default is `false`. |
| `from_project_id` | integer | no | The ID to compare from. |
| `straight` | boolean | no | Comparison method: `true` for direct comparison between `from` and `to` (`from`..`to`), `false` to compare using merge base (`from`...`to`)'. Default is `false`. |
```plaintext
GET /projects/:id/repository/compare?from=master&to=feature
@ -217,6 +229,9 @@ Example response:
## Contributors
> - Attributes `additions` and `deletions` [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39653) in GitLab 13.4, because they [always returned `0`](https://gitlab.com/gitlab-org/gitlab/-/issues/233119).
> - Attributes `additions` and `deletions` [removed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/38920) in GitLab 14.0.
Get repository contributors list. This endpoint can be accessed without
authentication if the repository is publicly accessible.
@ -224,9 +239,6 @@ authentication if the repository is publicly accessible.
GET /projects/:id/repository/contributors
```
WARNING:
The `additions` and `deletions` attributes are [deprecated](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39653) as of GitLab 13.4, because they [always return `0`](https://gitlab.com/gitlab-org/gitlab/-/issues/233119).
Supported attributes:
| Attribute | Type | Required | Description |
@ -255,16 +267,16 @@ Example response:
## Merge Base
Get the common ancestor for 2 or more refs (commit SHAs, branch names or tags).
Get the common ancestor for 2 or more refs, such as commit SHAs, branch names, or tags.
```plaintext
GET /projects/:id/repository/merge_base
```
| Attribute | Type | Required | Description |
| --------- | -------------- | -------- | ------------------------------------------------------------------------------- |
| `id` | integer or string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding) |
| `refs` | array | yes | The refs to find the common ancestor of, multiple refs can be passed |
| --------- | -------------- | -------- | ---------------------------------------------------------------------------------- |
| `id` | integer or string | yes | The ID or [URL-encoded path of the project](index.md#namespaced-path-encoding). |
| `refs` | array | yes | The refs to find the common ancestor of. Accepts multiple refs. |
Example request:
@ -293,17 +305,16 @@ Example response:
## Add changelog data to a changelog file
> [Introduced](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/351) in GitLab 13.9.
> - [Introduced](https://gitlab.com/groups/gitlab-com/gl-infra/-/epics/351) in GitLab 13.9.
> - Commit range limits [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/89032) in GitLab 15.1 [with a flag](../administration/feature_flags.md) named `changelog_commits_limitation`. Enabled by default.
Generate changelog data based on commits in a repository.
Given a version (using [semantic versioning](https://semver.org/)) and a range
Given a [semantic version](https://semver.org/) and a range
of commits, GitLab generates a changelog for all commits that use a particular
[Git trailer](https://git-scm.com/docs/git-interpret-trailers).
The output of this process is a new section in a changelog file in the Git
repository of the given project. The output format is in Markdown, and can be
customized.
[Git trailer](https://git-scm.com/docs/git-interpret-trailers). GitLab adds
a new Markdown-formatted section to a changelog file in the Git repository of
the project. The output format can be customized.
```plaintext
POST /projects/:id/repository/changelog
@ -314,30 +325,21 @@ Supported attributes:
| Attribute | Type | Required | Description |
| :-------- | :------- | :--------- | :---------- |
| `version` | string | yes | The version to generate the changelog for. The format must follow [semantic versioning](https://semver.org/). |
| `from` | string | no | The start of the range of commits (as a SHA) to use for generating the changelog. This commit itself isn't included in the list. |
| `to` | string | no | The end of the range of commits (as a SHA) to use for the changelog. This commit _is_ included in the list. Defaults to the branch specified in the `branch` attribute. |
| `date` | datetime | no | The date and time of the release, defaults to the current time. |
| `branch` | string | no | The branch to commit the changelog changes to, defaults to the project's default branch. |
| `trailer` | string | no | The Git trailer to use for including commits, defaults to `Changelog`. |
| `branch` | string | no | The branch to commit the changelog changes to. Defaults to the project's default branch. |
| `config_file` | string | no | Path to the changelog configuration file in the project's Git repository. Defaults to `.gitlab/changelog_config.yml`. |
| `file` | string | no | The file to commit the changes to, defaults to `CHANGELOG.md`. |
| `message` | string | no | The commit message to produce when committing the changes, defaults to `Add changelog for version X` where X is the value of the `version` argument. |
| `date` | datetime | no | The date and time of the release. Defaults to the current time. |
| `file` | string | no | The file to commit the changes to. Defaults to `CHANGELOG.md`. |
| `from` | string | no | The SHA of the commit that marks the beginning of the range of commits to include in the changelog. This commit isn't included in the changelog. |
| `message` | string | no | The commit message to use when committing the changes. Defaults to `Add changelog for version X`, where `X` is the value of the `version` argument. |
| `to` | string | no | The SHA of the commit that marks the end of the range of commits to include in the changelog. This commit _is_ included in the changelog. Defaults to the branch specified in the `branch` attribute. Limited to 15000 commits unless the feature flag `changelog_commits_limitation` is disabled. |
| `trailer` | string | no | The Git trailer to use for including commits. Defaults to `Changelog`. Case-sensitive: `Example` does not match `example` or `eXaMpLE`. |
WARNING:
GitLab treats trailers case-sensitively. If you set the `trailer` field to
`Example`, GitLab _won't_ include commits that use the trailer `example`,
`eXaMpLE`, or anything else that isn't _exactly_ `Example`.
WARNING:
The allowed commits range between `from` and `to` is limited to 15000 commits. To disable
this restriction, [turn off the feature flag](../administration/feature_flags.md)
`changelog_commits_limitation`.
### Requirements for `from` attribute
If the `from` attribute is unspecified, GitLab uses the Git tag of the last
stable version that came before the version specified in the `version`
attribute. This requires that Git tag names follow a specific format, allowing
GitLab to extract a version from the tag names. By default, GitLab considers
tags using these formats:
attribute. For GitLab to extract version numbers from tag names, Git tag names
must follow a specific format. By default, GitLab considers tags using these formats:
- `vX.Y.Z`
- `X.Y.Z`
@ -350,7 +352,7 @@ For example, consider a project with the following tags:
- v1.1.0
- v2.0.0
If the `version` attribute is `2.1.0`, GitLab uses tag v2.0.0. And when the
If the `version` attribute is `2.1.0`, GitLab uses tag `v2.0.0`. And when the
version is `1.1.1`, or `1.2.0`, GitLab uses tag v1.1.0. The tag `v1.0.0-pre1` is
never used, because pre-release tags are ignored.
@ -372,7 +374,8 @@ This command generates a changelog for version `1.0.0`.
The commit range:
- Starts with the tag of the last release.
- Ends with the last commit on the target branch. The default target branch is the project's default branch.
- Ends with the last commit on the target branch. The default target branch is
the project's default branch.
If the last tag is `v0.9.0` and the default branch is `main`, the range of commits
included in this example is `v0.9.0..main`:
@ -638,28 +641,28 @@ At the top level, the following variable is available:
In a category, the following variables are available:
- `title`: the title of the category (after it has been remapped).
- `count`: the number of entries in this category.
- `entries`: the entries that belong to this category.
- `single_change`: a boolean that indicates if there is only one change (`true`),
or multiple changes (`false`).
- `entries`: the entries that belong to this category.
- `title`: the title of the category (after it has been remapped).
In an entry, the following variables are available (here `foo.bar` means that
`bar` is a sub-field of `foo`):
- `title`: the title of the changelog entry (this is the commit title).
- `commit.reference`: a reference to the commit, for example,
`gitlab-org/gitlab@0a4cdd86ab31748ba6dac0f69a8653f206e5cfc7`.
- `commit.trailers`: an object containing all the Git trailers that were present
in the commit body.
- `author.reference`: a reference to the commit author (for example, `@alice`).
- `author.contributor`: a boolean set to `true` when the author is not a project
member, otherwise `false`.
- `author.credit`: a boolean set to `true` when `author.contributor` is `true` or
when `include_groups` is configured, and the author is a member of one of the
groups.
- `author.reference`: a reference to the commit author (for example, `@alice`).
- `commit.reference`: a reference to the commit, for example,
`gitlab-org/gitlab@0a4cdd86ab31748ba6dac0f69a8653f206e5cfc7`.
- `commit.trailers`: an object containing all the Git trailers that were present
in the commit body.
- `merge_request.reference`: a reference to the merge request that first
introduced the change (for example, `gitlab-org/gitlab!50063`).
- `title`: the title of the changelog entry (this is the commit title).
The `author` and `merge_request` objects might not be present if the data
couldn't be determined. For example, when a commit is created without a
@ -732,11 +735,11 @@ Supported attributes:
| Attribute | Type | Required | Description |
| :-------- | :------- | :--------- | :---------- |
| `version` | string | yes | The version to generate the changelog for. The format must follow [semantic versioning](https://semver.org/). |
| `config_file` | string | no | The path of changelog configuration file in the project's Git repository, defaults to `.gitlab/changelog_config.yml`. |
| `date` | datetime | no | The date and time of the release, ISO 8601 formatted. Example: `2016-03-11T03:45:40Z`. Defaults to the current time. |
| `from` | string | no | The start of the range of commits (as a SHA) to use for generating the changelog. This commit itself isn't included in the list. |
| `to` | string | no | The end of the range of commits (as a SHA) to use for the changelog. This commit _is_ included in the list. Defaults to the branch specified in the `branch` attribute. |
| `date` | datetime | no | The date and time of the release, ISO 8601 formatted. Example: `2016-03-11T03:45:40Z`. Defaults to the current time. |
| `trailer` | string | no | The Git trailer to use for including commits, defaults to `Changelog`. |
| `config_file` | string | no | The path of changelog configuration file in the project's Git repository, defaults to `.gitlab/changelog_config.yml`. |
```shell
curl --header "PRIVATE-TOKEN: token" "https://gitlab.com/api/v4/projects/42/repository/changelog?version=1.0.0"

View File

@ -738,6 +738,20 @@ subgraph output:<br/>test results/output
end
```
#### `verify-all-generated-files-are-up-to-date.rb` script
The `scripts/glfm/verify-all-generated-files-are-up-to-date.rb` script
runs the [`update-specification.rb`](#update-specificationrb-script).
[`update-example-snapshots.rb`](#update-example-snapshotsrb-script) scripts,
It fails with an exception and non-zero return code if running these scripts
results in any diffs to the generated and committed
[output specification files](#output-specification-files) or
[example snapshot files](#example-snapshot-files).
This script is run via the `glfm-verify` CI job to ensure that all changes to the
[input specification files](#input-specification-files)
are reflected in the generated output specification and example snapshot files.
### Specification files
These files represent the GLFM specification itself. They are all

View File

@ -391,7 +391,7 @@ We store these results also when running nightly scheduled CI jobs on the
default branch on `gitlab.com`. Statistics of these profiling data are
[available online](https://gitlab-org.gitlab.io/rspec_profiling_stats/). For
example, you can find which tests take longest to run or which execute the most
queries. This can be handy for optimizing our tests or identifying performance
queries. Use this to optimize our tests or identify performance
issues in our code.
## Memory optimization

View File

@ -171,11 +171,12 @@ Users can also be deactivated using the [GitLab API](../../api/users.md#deactiva
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/320875) in GitLab 14.0.
> - Customizable time period [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/336747) in GitLab 15.4
> - The lower limit for inactive period set to 90 days [introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/100793) in GitLab 15.5
Administrators can enable automatic deactivation of users who either:
- Were created more than a week ago and have not signed in.
- Have no activity for a specified period of time (defaults to 90 days).
- Have no activity for a specified period of time (default and minimum is 90 days).
To do this:
@ -183,7 +184,7 @@ To do this:
1. On the left sidebar, select **Settings > General**.
1. Expand the **Account and limit** section.
1. Under **Dormant users**, check **Deactivate dormant users after a period of inactivity**.
1. Under **Period of inactivity (days)**, enter a period of time before deactivation.
1. Under **Days of inactivity before deactivation**, enter the number of days before deactivation. Minimum value is 90 days.
1. Select **Save changes**.
When this feature is enabled, GitLab runs a job once a day to deactivate the dormant users.

View File

@ -12,7 +12,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w
GitLab allows you to import all the required Git repositories
based on a manifest file like the one used by the
[Android repository](https://android.googlesource.com/platform/manifest/+/2d6f081a3b05d8ef7a2b1b52b0d536b2b74feab4/default.xml).
This feature can be very handy when you need to import a project with many
Use the manifest to import a project with many
repositories like the Android Open Source Project (AOSP).
## Requirements

View File

@ -15,7 +15,7 @@ notification email address as an attachment.
collected from issues into a **[comma-separated values](https://en.wikipedia.org/wiki/Comma-separated_values)** (CSV)
file, which stores tabular data in plain text.
> _CSVs are a handy way of getting data from one program to another where one
> _CSVs are a way of getting data from one program to another where one
program cannot read the other ones normal output._ [Ref](https://www.quora.com/What-is-a-CSV-file-and-its-uses)
<!-- vale gitlab.Spelling = NO -->

View File

@ -72,7 +72,7 @@ module Gitlab
Collection.new(@variables.reject(&block))
end
def expand_value(value, keep_undefined: false, expand_file_vars: true)
def expand_value(value, keep_undefined: false, expand_file_vars: true, project: nil)
value.gsub(Item::VARIABLES_REGEXP) do
match = Regexp.last_match # it is either a valid variable definition or a ($$ / %%)
full_match = match[0]
@ -88,6 +88,16 @@ module Gitlab
if variable # VARIABLE_NAME is an existing variable
next variable.value unless variable.file?
# Will be cleaned up with https://gitlab.com/gitlab-org/gitlab/-/issues/378266
if project
# We only log if `project` exists to make sure it is called from `Ci::BuildRunnerPresenter`
# when the variables are sent to Runner.
Gitlab::AppJsonLogger.info(
event: 'file_variable_is_referenced_in_another_variable',
project_id: project.id
)
end
expand_file_vars ? variable.value : full_match
elsif keep_undefined
full_match # we do not touch the variable definition
@ -97,7 +107,7 @@ module Gitlab
end
end
def sort_and_expand_all(keep_undefined: false, expand_file_vars: true)
def sort_and_expand_all(keep_undefined: false, expand_file_vars: true, project: nil)
sorted = Sort.new(self)
return self.class.new(self, sorted.errors) unless sorted.valid?
@ -112,7 +122,8 @@ module Gitlab
# expand variables as they are added
variable = item.to_runner_variable
variable[:value] = new_collection.expand_value(variable[:value], keep_undefined: keep_undefined,
expand_file_vars: expand_file_vars)
expand_file_vars: expand_file_vars,
project: project)
new_collection.append(variable)
end

View File

@ -173,9 +173,21 @@ module Gitlab
def alter_sequence_statements(old_table:, new_table:)
sequences_owned_by(old_table).map do |seq_info|
seq_name, column_name = seq_info.values_at(:name, :column_name)
<<~SQL.chomp
statement_parts = []
# If a different user owns the old table, the conversion process will fail to reassign the sequence
# ownership to the new parent table (as it will be owned by the current user).
# Force the old table to be owned by the current user in that case.
unless current_user_owns_table?(old_table)
statement_parts << set_current_user_owns_table_statement(old_table)
end
statement_parts << <<~SQL.chomp
ALTER SEQUENCE #{quote_table_name(seq_name)} OWNED BY #{quote_table_name(new_table)}.#{quote_column_name(column_name)}
SQL
statement_parts.join(SQL_STATEMENT_SEPARATOR)
end
end
@ -206,6 +218,23 @@ module Gitlab
{ name: name, column_name: column_name }
end
end
def table_owner(table_name)
connection.select_value(<<~SQL, nil, [table_name])
SELECT tableowner FROM pg_tables WHERE tablename = $1
SQL
end
def current_user_owns_table?(table_name)
current_user = connection.select_value('select current_user')
table_owner(table_name) == current_user
end
def set_current_user_owns_table_statement(table_name)
<<~SQL.chomp
ALTER TABLE #{connection.quote_table_name(table_name)} OWNER TO CURRENT_USER
SQL
end
end
end
end

View File

@ -86,8 +86,9 @@ module Gitlab
mkdir_p(File.join(uploads_export_path, secret))
download_or_copy_upload(upload, upload_path)
rescue Errno::ENAMETOOLONG => e
# Do not fail entire project export if downloaded file has filename that exceeds 255 characters.
rescue StandardError => e
# Do not fail entire project export if something goes wrong during file download
# (e.g. downloaded file has filename that exceeds 255 characters).
# Ignore raised exception, skip such upload, log the error and keep going with the export instead.
Gitlab::ErrorTracking.log_exception(e, project_id: @project.id)
end

View File

@ -1253,6 +1253,9 @@ msgstr ""
msgid "'%{template_name}' is unknown or invalid"
msgstr ""
msgid "'%{value}' days of inactivity must be greater than or equal to 90"
msgstr ""
msgid "(%d closed)"
msgid_plural "(%d closed)"
msgstr[0] ""
@ -1291,6 +1294,9 @@ msgstr ""
msgid "(max size 15 MB)"
msgstr ""
msgid "(no user)"
msgstr ""
msgid "(optional)"
msgstr ""
@ -12565,6 +12571,9 @@ msgstr ""
msgid "Days"
msgstr ""
msgid "Days of inactivity before deactivation"
msgstr ""
msgid "Days to merge"
msgstr ""
@ -19520,12 +19529,6 @@ msgstr ""
msgid "GroupsTree|Loading groups"
msgstr ""
msgid "GroupsTree|No groups matched your search"
msgstr ""
msgid "GroupsTree|No groups or projects matched your search"
msgstr ""
msgid "GroupsTree|Options"
msgstr ""
@ -22424,6 +22427,18 @@ msgstr ""
msgid "IssuableStatus|promoted"
msgstr ""
msgid "Issuable|epic"
msgstr ""
msgid "Issuable|escalation policy"
msgstr ""
msgid "Issuable|iteration"
msgstr ""
msgid "Issuable|milestone"
msgstr ""
msgid "Issue"
msgstr ""
@ -26426,6 +26441,9 @@ msgstr ""
msgid "Multiplier to apply to polling intervals. Decimal values are supported. Defaults to 1."
msgstr ""
msgid "Must be 90 days or more."
msgstr ""
msgid "My awesome group"
msgstr ""
@ -29354,12 +29372,6 @@ msgstr ""
msgid "Period in seconds"
msgstr ""
msgid "Period of inactivity (days)"
msgstr ""
msgid "Period of inactivity before deactivation."
msgstr ""
msgid "Permalink"
msgstr ""
@ -45217,6 +45229,9 @@ msgstr ""
msgid "Webhooks Help"
msgstr ""
msgid "Webhooks|+ Mask another portion of URL"
msgstr ""
msgid "Webhooks|A comment is added to a confidential issue."
msgstr ""
@ -45797,6 +45812,9 @@ msgstr ""
msgid "WorkItem|Add to iteration"
msgstr ""
msgid "WorkItem|Add to milestone"
msgstr ""
msgid "WorkItem|Are you sure you want to cancel editing?"
msgstr ""
@ -45853,12 +45871,18 @@ msgstr ""
msgid "WorkItem|Learn about tasks."
msgstr ""
msgid "WorkItem|Milestone"
msgstr ""
msgid "WorkItem|No iteration"
msgstr ""
msgid "WorkItem|No matching results"
msgstr ""
msgid "WorkItem|No milestone"
msgstr ""
msgid "WorkItem|No tasks are currently assigned. Use tasks to break down this issue into smaller parts."
msgstr ""
@ -45907,6 +45931,9 @@ msgstr ""
msgid "WorkItem|Something went wrong when trying to create a child. Please try again."
msgstr ""
msgid "WorkItem|Something went wrong while fetching milestones. Please try again."
msgstr ""
msgid "WorkItem|Something went wrong while updating the %{workItemType}. Please try again."
msgstr ""

View File

@ -13,10 +13,6 @@ module QA
element :group_id_content
end
view 'app/assets/javascripts/groups/constants.js' do
element :no_result_text, 'No groups or projects matched your search' # rubocop:disable QA/ElementWithPattern
end
view 'app/views/shared/members/_access_request_links.html.haml' do
element :leave_group_link
end

View File

@ -0,0 +1,5 @@
#!/usr/bin/env ruby
# frozen_string_literal: true
require_relative '../lib/glfm/verify_all_generated_files_are_up_to_date'
Glfm::VerifyAllGeneratedFilesAreUpToDate.new.process

View File

@ -23,15 +23,16 @@ module Glfm
GLFM_EXAMPLE_METADATA_YML_PATH =
specification_input_glfm_path.join('glfm_example_metadata.yml')
GLFM_EXAMPLE_NORMALIZATIONS_YML_PATH = specification_input_glfm_path.join('glfm_example_normalizations.yml')
GLFM_SPEC_TXT_PATH = specification_path.join('output/spec.txt')
GLFM_SPEC_HTML_PATH = specification_path.join('output/spec.html')
GLFM_SPEC_OUTPUT_PATH = specification_path.join('output')
GLFM_SPEC_TXT_PATH = GLFM_SPEC_OUTPUT_PATH.join('spec.txt')
GLFM_SPEC_HTML_PATH = GLFM_SPEC_OUTPUT_PATH.join('spec.html')
# Example Snapshot (ES) files
es_fixtures_path = File.expand_path("../../../glfm_specification/example_snapshots", __dir__)
ES_EXAMPLES_INDEX_YML_PATH = File.join(es_fixtures_path, 'examples_index.yml')
ES_MARKDOWN_YML_PATH = File.join(es_fixtures_path, 'markdown.yml')
ES_HTML_YML_PATH = File.join(es_fixtures_path, 'html.yml')
ES_PROSEMIRROR_JSON_YML_PATH = File.join(es_fixtures_path, 'prosemirror_json.yml')
EXAMPLE_SNAPSHOTS_PATH = File.expand_path("../../../glfm_specification/example_snapshots", __dir__)
ES_EXAMPLES_INDEX_YML_PATH = File.join(EXAMPLE_SNAPSHOTS_PATH, 'examples_index.yml')
ES_MARKDOWN_YML_PATH = File.join(EXAMPLE_SNAPSHOTS_PATH, 'markdown.yml')
ES_HTML_YML_PATH = File.join(EXAMPLE_SNAPSHOTS_PATH, 'html.yml')
ES_PROSEMIRROR_JSON_YML_PATH = File.join(EXAMPLE_SNAPSHOTS_PATH, 'prosemirror_json.yml')
# Other constants used for processing files
GLFM_SPEC_TXT_HEADER = <<~MARKDOWN

View File

@ -0,0 +1,48 @@
# frozen_string_literal: true
require_relative 'constants'
require_relative 'shared'
# IMPORTANT NOTE: See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#verify-all-generated-files-are-up-to-daterb-script
# for details on the implementation and usage of this script. This developers guide
# contains diagrams and documentation of this script,
# including explanations and examples of all files it reads and writes.
module Glfm
class VerifyAllGeneratedFilesAreUpToDate
include Constants
include Shared
def process
verify_cmd = "git status --porcelain #{GLFM_SPEC_OUTPUT_PATH} #{EXAMPLE_SNAPSHOTS_PATH}"
verify_cmd_output = run_external_cmd(verify_cmd)
unless verify_cmd_output.empty?
msg = "ERROR: Cannot run `#{__FILE__}` because `#{verify_cmd}` shows the following uncommitted changes:\n" \
"#{verify_cmd_output}"
raise(msg)
end
output('Verifying all generated files are up to date after running GLFM scripts...')
output("Running `yarn install --frozen-lockfile` to ensure `yarn check-dependencies` doesn't fail...")
run_external_cmd('yarn install --frozen-lockfile')
# noinspection RubyMismatchedArgumentType
update_specification_script = File.expand_path('../../glfm/update-specification.rb', __dir__)
# noinspection RubyMismatchedArgumentType
update_example_snapshots_script = File.expand_path('../../glfm/update-example-snapshots.rb', __dir__)
output("Running `#{update_specification_script}`...")
run_external_cmd(update_specification_script)
output("Running `#{update_example_snapshots_script}`...")
run_external_cmd(update_example_snapshots_script)
output("Running `#{verify_cmd}` to check that no modifications to generated files have occurred...")
verify_cmd_output = run_external_cmd(verify_cmd)
return if verify_cmd_output.empty?
raise "The following files were modified by running GLFM scripts. Please review, verify, and commit " \
"the changes:\n#{verify_cmd_output}"
end
end
end

View File

@ -53,7 +53,8 @@ RSpec.describe Profiles::PreferencesController do
first_day_of_week: '1',
preferred_language: 'jp',
tab_width: '5',
render_whitespace_in_code: 'true'
render_whitespace_in_code: 'true',
use_legacy_web_ide: 'true'
}.with_indifferent_access
expect(user).to receive(:assign_attributes).with(ActionController::Parameters.new(prefs).permit!)

View File

@ -177,10 +177,10 @@ RSpec.describe 'Admin updates settings' do
end
it 'change Dormant users period' do
expect(page).to have_field _('Period of inactivity (days)')
expect(page).to have_field _('Days of inactivity before deactivation')
page.within(find('[data-testid="account-limit"]')) do
fill_in _('application_setting_deactivate_dormant_users_period'), with: '35'
fill_in _('application_setting_deactivate_dormant_users_period'), with: '90'
click_button 'Save changes'
end
@ -188,7 +188,7 @@ RSpec.describe 'Admin updates settings' do
page.refresh
expect(page).to have_field _('Period of inactivity (days)'), with: '35'
expect(page).to have_field _('Days of inactivity before deactivation'), with: '90'
end
end
end

View File

@ -228,9 +228,9 @@ RSpec.describe MergeRequestsFinder do
end
describe ':label_name parameter' do
let(:common_labels) { create_list(:label, 3) }
let(:distinct_labels) { create_list(:label, 3) }
let(:merge_requests) do
let_it_be(:common_labels) { create_list(:label, 3) }
let_it_be(:distinct_labels) { create_list(:label, 3) }
let_it_be(:merge_requests) do
common_attrs = {
source_project: project1, target_project: project1, author: user
}
@ -561,7 +561,7 @@ RSpec.describe MergeRequestsFinder do
end
context 'filtering by created_at/updated_at' do
let(:new_project) { create(:project, forked_from_project: project1) }
let_it_be(:new_project) { create(:project, forked_from_project: project1) }
let!(:new_merge_request) do
create(:merge_request,
@ -584,7 +584,7 @@ RSpec.describe MergeRequestsFinder do
target_project: new_project)
end
before do
before_all do
new_project.add_maintainer(user)
end
@ -646,10 +646,10 @@ RSpec.describe MergeRequestsFinder do
end
context 'filtering by the merge request deployments' do
let(:gstg) { create(:environment, project: project4, name: 'gstg') }
let(:gprd) { create(:environment, project: project4, name: 'gprd') }
let_it_be(:gstg) { create(:environment, project: project4, name: 'gstg') }
let_it_be(:gprd) { create(:environment, project: project4, name: 'gprd') }
let(:mr1) do
let_it_be(:mr1) do
create(
:merge_request,
:simple,
@ -660,7 +660,7 @@ RSpec.describe MergeRequestsFinder do
)
end
let(:mr2) do
let_it_be(:mr2) do
create(
:merge_request,
:simple,
@ -671,7 +671,7 @@ RSpec.describe MergeRequestsFinder do
)
end
let(:deploy1) do
let_it_be(:deploy1) do
create(
:deployment,
:success,
@ -683,7 +683,7 @@ RSpec.describe MergeRequestsFinder do
)
end
let(:deploy2) do
let_it_be(:deploy2) do
create(
:deployment,
:success,
@ -695,7 +695,7 @@ RSpec.describe MergeRequestsFinder do
)
end
before do
before_all do
deploy1.link_merge_requests(MergeRequest.where(id: mr1.id))
deploy2.link_merge_requests(MergeRequest.where(id: mr2.id))
end
@ -833,13 +833,13 @@ RSpec.describe MergeRequestsFinder do
end
context 'when projects require different access levels for merge requests' do
let(:user) { create(:user) }
let_it_be(:user) { create(:user) }
let(:public_project) { create(:project, :public) }
let(:internal) { create(:project, :internal) }
let(:private_project) { create(:project, :private) }
let(:public_with_private_repo) { create(:project, :public, :repository, :repository_private) }
let(:internal_with_private_repo) { create(:project, :internal, :repository, :repository_private) }
let_it_be(:public_project) { create(:project, :public) }
let_it_be(:internal) { create(:project, :internal) }
let_it_be(:private_project) { create(:project, :private) }
let_it_be(:public_with_private_repo) { create(:project, :public, :repository, :repository_private) }
let_it_be(:internal_with_private_repo) { create(:project, :internal, :repository, :repository_private) }
let(:merge_requests) { described_class.new(user, {}).execute }
@ -850,7 +850,7 @@ RSpec.describe MergeRequestsFinder do
let!(:mr_internal_private_repo_access) { create(:merge_request, source_project: internal_with_private_repo) }
context 'with admin user' do
let(:user) { create(:user, :admin) }
let_it_be(:user) { create(:user, :admin) }
context 'when admin mode is enabled', :enable_admin_mode do
it 'returns all merge requests' do
@ -968,7 +968,7 @@ RSpec.describe MergeRequestsFinder do
let_it_be(:labels) { create_list(:label, 2, project: project) }
let_it_be(:merge_requests) { create_list(:merge_request, 4, :unique_branches, author: user, target_project: project, source_project: project, labels: labels) }
before do
before_all do
project.add_developer(user)
end

View File

@ -11,6 +11,7 @@ import eventHub from '~/groups/event_hub';
import GroupsService from '~/groups/service/groups_service';
import GroupsStore from '~/groups/store/groups_store';
import EmptyState from '~/groups/components/empty_state.vue';
import GroupsComponent from '~/groups/components/groups.vue';
import axios from '~/lib/utils/axios_utils';
import * as urlUtilities from '~/lib/utils/url_utility';
import setWindowLocation from 'helpers/set_window_location_helper';
@ -388,24 +389,27 @@ describe('AppComponent', () => {
});
describe.each`
action | groups | fromSearch | renderEmptyState | expected
${'subgroups_and_projects'} | ${[]} | ${false} | ${true} | ${true}
${''} | ${[]} | ${false} | ${true} | ${false}
${'subgroups_and_projects'} | ${mockGroups} | ${false} | ${true} | ${false}
${'subgroups_and_projects'} | ${[]} | ${true} | ${true} | ${false}
action | groups | fromSearch | shouldRenderEmptyState | searchEmpty
${'subgroups_and_projects'} | ${[]} | ${false} | ${true} | ${false}
${''} | ${[]} | ${false} | ${false} | ${false}
${'subgroups_and_projects'} | ${mockGroups} | ${false} | ${false} | ${false}
${'subgroups_and_projects'} | ${[]} | ${true} | ${false} | ${true}
`(
'when `action` is $action, `groups` is $groups, `fromSearch` is $fromSearch, and `renderEmptyState` is $renderEmptyState',
({ action, groups, fromSearch, renderEmptyState, expected }) => {
it(`${expected ? 'renders' : 'does not render'} empty state`, async () => {
'when `action` is $action, `groups` is $groups, and `fromSearch` is $fromSearch',
({ action, groups, fromSearch, shouldRenderEmptyState, searchEmpty }) => {
it(`${shouldRenderEmptyState ? 'renders' : 'does not render'} empty state`, async () => {
createShallowComponent({
propsData: { action, renderEmptyState },
propsData: { action, renderEmptyState: true },
});
await waitForPromises();
vm.updateGroups(groups, fromSearch);
await nextTick();
expect(wrapper.findComponent(EmptyState).exists()).toBe(expected);
expect(wrapper.findComponent(EmptyState).exists()).toBe(shouldRenderEmptyState);
expect(wrapper.findComponent(GroupsComponent).props('searchEmpty')).toBe(searchEmpty);
});
},
);
@ -445,18 +449,6 @@ describe('AppComponent', () => {
expect.any(Function),
);
});
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `false`', async () => {
createShallowComponent();
await nextTick();
expect(vm.searchEmptyMessage).toBe('No groups or projects matched your search');
});
it('should initialize `searchEmptyMessage` prop with correct string when `hideProjects` is `true`', async () => {
createShallowComponent({ propsData: { hideProjects: true } });
await nextTick();
expect(vm.searchEmptyMessage).toBe('No groups matched your search');
});
});
describe('beforeDestroy', () => {

View File

@ -1,4 +1,5 @@
import Vue from 'vue';
import { GlEmptyState } from '@gitlab/ui';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import GroupFolderComponent from '~/groups/components/group_folder.vue';
@ -15,7 +16,6 @@ describe('GroupsComponent', () => {
const defaultPropsData = {
groups: mockGroups,
pageInfo: mockPageInfo,
searchEmptyMessage: 'No matching results',
searchEmpty: false,
};
@ -67,13 +67,16 @@ describe('GroupsComponent', () => {
expect(wrapper.findComponent(GroupFolderComponent).exists()).toBe(true);
expect(findPaginationLinks().exists()).toBe(true);
expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(false);
expect(wrapper.findComponent(GlEmptyState).exists()).toBe(false);
});
it('should render empty search message when `searchEmpty` is `true`', () => {
createComponent({ propsData: { searchEmpty: true } });
expect(wrapper.findByText(defaultPropsData.searchEmptyMessage).exists()).toBe(true);
expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({
title: GroupsComponent.i18n.emptyStateTitle,
description: GroupsComponent.i18n.emptyStateDescription,
});
});
});
});

View File

@ -6,7 +6,7 @@ jest.mock('@gitlab/web-ide');
const ROOT_ELEMENT_ID = 'ide';
const TEST_NONCE = 'test123nonce';
const TEST_PROJECT = { path_with_namespace: 'group1/project1' };
const TEST_PROJECT_PATH = 'group1/project1';
const TEST_BRANCH_NAME = '12345-foo-patch';
const TEST_GITLAB_URL = 'https://test-gitlab/';
const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/webpack/assets/gitlab-web-ide/public/path';
@ -18,7 +18,7 @@ describe('ide/init_gitlab_web_ide', () => {
el.id = ROOT_ELEMENT_ID;
// why: We'll test that this class is removed later
el.classList.add('ide-loading');
el.dataset.project = JSON.stringify(TEST_PROJECT);
el.dataset.projectPath = TEST_PROJECT_PATH;
el.dataset.cspNonce = TEST_NONCE;
el.dataset.branchName = TEST_BRANCH_NAME;
@ -43,7 +43,7 @@ describe('ide/init_gitlab_web_ide', () => {
it('calls start with element', () => {
expect(start).toHaveBeenCalledWith(findRootElement(), {
baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`,
projectPath: TEST_PROJECT.path_with_namespace,
projectPath: TEST_PROJECT_PATH,
ref: TEST_BRANCH_NAME,
gitlabUrl: TEST_GITLAB_URL,
nonce: TEST_NONCE,

View File

@ -0,0 +1,81 @@
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlListbox } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import searchUsersQuery from '~/graphql_shared/queries/users_search_all.query.graphql';
import createMockApollo from 'helpers/mock_apollo_helper';
import UserSelect from '~/pages/import/fogbugz/new_user_map/components/user_select.vue';
Vue.use(VueApollo);
const USERS_RESPONSE = {
data: {
users: {
nodes: [
{
id: 'gid://gitlab/User/44',
avatarUrl: '/avatar1',
webUrl: '/reported_user_22',
name: 'Birgit Steuber',
username: 'reported_user_22',
__typename: 'UserCore',
},
{
id: 'gid://gitlab/User/43',
avatarUrl: '/avatar2',
webUrl: '/reported_user_21',
name: 'Luke Spinka',
username: 'reported_user_21',
__typename: 'UserCore',
},
],
__typename: 'UserCoreConnection',
},
},
};
describe('fogbugz user select component', () => {
let wrapper;
const searchQueryHandlerSuccess = jest.fn().mockResolvedValue(USERS_RESPONSE);
const createComponent = (propsData = { name: 'demo' }) => {
const fakeApollo = createMockApollo([[searchUsersQuery, searchQueryHandlerSuccess]]);
wrapper = shallowMount(UserSelect, {
apolloProvider: fakeApollo,
propsData,
});
};
it('renders hidden input with name from props', () => {
const name = 'test';
createComponent({ name });
expect(wrapper.find('input').attributes('name')).toBe(name);
});
it('syncs input value with value emitted from listbox', async () => {
createComponent();
const id = 8;
wrapper.findComponent(GlListbox).vm.$emit('select', `gid://gitlab/User/${id}`);
await nextTick();
expect(wrapper.get('input').attributes('value')).toBe(id.toString());
});
it('filters users when search is performed in listbox', async () => {
createComponent();
jest.runOnlyPendingTimers();
wrapper.findComponent(GlListbox).vm.$emit('search', 'test');
await nextTick();
jest.runOnlyPendingTimers();
expect(searchQueryHandlerSuccess).toHaveBeenCalledWith({
first: expect.anything(),
search: 'test',
});
});
});

View File

@ -1144,7 +1144,7 @@ describe('MrWidgetOptions', () => {
${'WidgetCodeQuality'} | ${'i_testing_code_quality_widget_total'}
${'WidgetTerraform'} | ${'i_testing_terraform_widget_total'}
${'WidgetIssues'} | ${'i_testing_issues_widget_total'}
${'WidgetTestReport'} | ${'i_testing_summary_widget_total'}
${'WidgetTestSummary'} | ${'i_testing_summary_widget_total'}
`(
"sends non-standard events for the '$widgetName' widget",
async ({ widgetName, nonStandardEvent }) => {

View File

@ -1,15 +1,18 @@
import { nextTick } from 'vue';
import { GlFormRadio, GlFormRadioGroup } from '@gitlab/ui';
import { GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui';
import FormUrlApp from '~/webhooks/components/form_url_app.vue';
import FormUrlMaskItem from '~/webhooks/components/form_url_mask_item.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
describe('FormUrlApp', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMountExtended(FormUrlApp);
const createComponent = ({ props } = {}) => {
wrapper = shallowMountExtended(FormUrlApp, {
propsData: { ...props },
});
};
afterEach(() => {
@ -20,13 +23,17 @@ describe('FormUrlApp', () => {
const findRadioGroup = () => wrapper.findComponent(GlFormRadioGroup);
const findUrlMaskDisable = () => findAllRadioButtons().at(0);
const findUrlMaskEnable = () => findAllRadioButtons().at(1);
const findAllUrlMaskItems = () => wrapper.findAllComponents(FormUrlMaskItem);
const findAddItem = () => wrapper.findComponent(GlLink);
const findFormUrl = () => wrapper.findByTestId('form-url');
const findFormUrlPreview = () => wrapper.findByTestId('form-url-preview');
const findUrlMaskSection = () => wrapper.findByTestId('url-mask-section');
describe('template', () => {
it('renders radio buttons for URL masking', () => {
createComponent();
expect(findAllRadioButtons().length).toBe(2);
expect(findAllRadioButtons()).toHaveLength(2);
expect(findUrlMaskDisable().text()).toBe(FormUrlApp.i18n.radioFullUrlText);
expect(findUrlMaskEnable().text()).toBe(FormUrlApp.i18n.radioMaskUrlText);
});
@ -48,6 +55,88 @@ describe('FormUrlApp', () => {
it('renders mask section', () => {
expect(findUrlMaskSection().exists()).toBe(true);
});
it('renders an empty mask item by default', () => {
expect(findAllUrlMaskItems()).toHaveLength(1);
const firstItem = findAllUrlMaskItems().at(0);
expect(firstItem.props('itemKey')).toBeNull();
expect(firstItem.props('itemValue')).toBeNull();
});
});
describe('with mask items', () => {
const mockItem1 = { key: 'key1', value: 'value1' };
const mockItem2 = { key: 'key2', value: 'value2' };
beforeEach(() => {
createComponent({
props: { initialUrlVariables: [mockItem1, mockItem2] },
});
});
it('renders masked URL preview', async () => {
const mockUrl = 'https://test.host/value1?secret=value2';
findFormUrl().vm.$emit('input', mockUrl);
await nextTick();
expect(findFormUrlPreview().attributes('value')).toBe(
'https://test.host/{key1}?secret={key2}',
);
});
it('renders mask items correctly', () => {
expect(findAllUrlMaskItems()).toHaveLength(2);
const firstItem = findAllUrlMaskItems().at(0);
expect(firstItem.props('itemKey')).toBe(mockItem1.key);
expect(firstItem.props('itemValue')).toBe(mockItem1.value);
const secondItem = findAllUrlMaskItems().at(1);
expect(secondItem.props('itemKey')).toBe(mockItem2.key);
expect(secondItem.props('itemValue')).toBe(mockItem2.value);
});
describe('on mask item input', () => {
const mockInput = { index: 0, key: 'display', value: 'secret' };
it('updates mask item', async () => {
const firstItem = findAllUrlMaskItems().at(0);
firstItem.vm.$emit('input', mockInput);
await nextTick();
expect(firstItem.props('itemKey')).toBe(mockInput.key);
expect(firstItem.props('itemValue')).toBe(mockInput.value);
});
});
describe('when add item is clicked', () => {
it('adds mask item', async () => {
findAddItem().vm.$emit('click');
await nextTick();
expect(findAllUrlMaskItems()).toHaveLength(3);
const lastItem = findAllUrlMaskItems().at(-1);
expect(lastItem.props('itemKey')).toBeNull();
expect(lastItem.props('itemValue')).toBeNull();
});
});
describe('when remove item is clicked', () => {
it('removes the correct mask item', async () => {
const firstItem = findAllUrlMaskItems().at(0);
firstItem.vm.$emit('remove');
await nextTick();
expect(findAllUrlMaskItems()).toHaveLength(1);
const newFirstItem = findAllUrlMaskItems().at(0);
expect(newFirstItem.props('itemKey')).toBe(mockItem2.key);
expect(newFirstItem.props('itemValue')).toBe(mockItem2.value);
});
});
});
});
});

View File

@ -1,3 +1,4 @@
import { nextTick } from 'vue';
import { GlButton, GlFormInput } from '@gitlab/ui';
import FormUrlMaskItem from '~/webhooks/components/form_url_mask_item.vue';
@ -10,10 +11,13 @@ describe('FormUrlMaskItem', () => {
const defaultProps = {
index: 0,
};
const mockKey = 'key';
const mockValue = 'value';
const mockInput = 'input';
const createComponent = () => {
const createComponent = ({ props } = {}) => {
wrapper = shallowMountExtended(FormUrlMaskItem, {
propsData: { ...defaultProps },
propsData: { ...defaultProps, ...props },
});
};
@ -42,10 +46,55 @@ describe('FormUrlMaskItem', () => {
);
});
describe('on key input', () => {
beforeEach(async () => {
createComponent({ props: { itemKey: mockKey, itemValue: mockValue } });
findMaskItemKey().findComponent(GlFormInput).vm.$emit('input', mockInput);
await nextTick();
});
it('emits input event', () => {
expect(wrapper.emitted('input')).toEqual([
[{ index: defaultProps.index, key: mockInput, value: mockValue }],
]);
});
});
describe('on value input', () => {
beforeEach(async () => {
createComponent({ props: { itemKey: mockKey, itemValue: mockValue } });
findMaskItemValue().findComponent(GlFormInput).vm.$emit('input', mockInput);
await nextTick();
});
it('emits input event', () => {
expect(wrapper.emitted('input')).toEqual([
[{ index: defaultProps.index, key: mockKey, value: mockInput }],
]);
});
});
it('renders remove button', () => {
createComponent();
expect(findRemoveButton().props('icon')).toBe('remove');
});
describe('when remove button is clicked', () => {
const mockIndex = 5;
beforeEach(async () => {
createComponent({ props: { index: mockIndex } });
findRemoveButton().vm.$emit('click');
await nextTick();
});
it('emits remove event', () => {
expect(wrapper.emitted('remove')).toEqual([[mockIndex]]);
});
});
});
});

View File

@ -20,6 +20,7 @@ import WorkItemState from '~/work_items/components/work_item_state.vue';
import WorkItemTitle from '~/work_items/components/work_item_title.vue';
import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue';
import WorkItemLabels from '~/work_items/components/work_item_labels.vue';
import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
import WorkItemInformation from '~/work_items/components/work_item_information.vue';
import { i18n } from '~/work_items/constants';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
@ -28,6 +29,7 @@ import workItemTitleSubscription from '~/work_items/graphql/work_item_title.subs
import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assignees.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
import { temporaryConfig } from '~/graphql_shared/issuable_client';
import { useLocalStorageSpy } from 'helpers/local_storage_helper';
import {
mockParent,
@ -67,6 +69,7 @@ describe('WorkItemDetail component', () => {
const findWorkItemDueDate = () => wrapper.findComponent(WorkItemDueDate);
const findWorkItemAssignees = () => wrapper.findComponent(WorkItemAssignees);
const findWorkItemLabels = () => wrapper.findComponent(WorkItemLabels);
const findWorkItemMilestone = () => wrapper.findComponent(WorkItemMilestone);
const findParent = () => wrapper.find('[data-testid="work-item-parent"]');
const findParentButton = () => findParent().findComponent(GlButton);
const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]');
@ -82,6 +85,8 @@ describe('WorkItemDetail component', () => {
subscriptionHandler = titleSubscriptionHandler,
confidentialityMock = [updateWorkItemMutation, jest.fn()],
error = undefined,
includeWidgets = false,
workItemsMvc2Enabled = false,
} = {}) => {
const handlers = [
[workItemQuery, handler],
@ -92,7 +97,13 @@ describe('WorkItemDetail component', () => {
];
wrapper = shallowMount(WorkItemDetail, {
apolloProvider: createMockApollo(handlers),
apolloProvider: createMockApollo(
handlers,
{},
{
typePolicies: includeWidgets ? temporaryConfig.cacheConfig.typePolicies : {},
},
),
propsData: { isModal, workItemId },
data() {
return {
@ -101,6 +112,9 @@ describe('WorkItemDetail component', () => {
};
},
provide: {
glFeatures: {
workItemsMvc2: workItemsMvc2Enabled,
},
hasIssueWeightsFeature: true,
hasIterationsFeature: true,
projectNamespace: 'namespace',
@ -527,6 +541,19 @@ describe('WorkItemDetail component', () => {
});
});
describe('milestone widget', () => {
it.each`
description | includeWidgets | exists
${'renders when widget is returned from API'} | ${true} | ${true}
${'does not render when widget is not returned from API'} | ${false} | ${false}
`('$description', async ({ includeWidgets, exists }) => {
createComponent({ includeWidgets, workItemsMvc2Enabled: true });
await waitForPromises();
expect(findWorkItemMilestone().exists()).toBe(exists);
});
});
describe('work item information', () => {
beforeEach(() => {
createComponent();

View File

@ -0,0 +1,247 @@
import {
GlDropdown,
GlDropdownItem,
GlSearchBoxByType,
GlSkeletonLoader,
GlFormGroup,
GlDropdownText,
} from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue';
import { resolvers, temporaryConfig } from '~/graphql_shared/issuable_client';
import createMockApollo from 'helpers/mock_apollo_helper';
import { mockTracking } from 'helpers/tracking_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants';
import projectMilestonesQuery from '~/sidebar/queries/project_milestones.query.graphql';
import {
projectMilestonesResponse,
projectMilestonesResponseWithNoMilestones,
mockMilestoneWidgetResponse,
workItemResponseFactory,
updateWorkItemMutationErrorResponse,
} from 'jest/work_items/mock_data';
import workItemQuery from '~/work_items/graphql/work_item.query.graphql';
describe('WorkItemMilestone component', () => {
Vue.use(VueApollo);
let wrapper;
const workItemId = 'gid://gitlab/WorkItem/1';
const workItemType = 'Task';
const fullPath = 'full-path';
const findDropdown = () => wrapper.findComponent(GlDropdown);
const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType);
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findNoMilestoneDropdownItem = () => wrapper.findByTestId('no-milestone');
const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
const findFirstDropdownItem = () => findDropdownItems().at(0);
const findDropdownTexts = () => wrapper.findAllComponents(GlDropdownText);
const findDropdownItemAtIndex = (index) => findDropdownItems().at(index);
const findDisabledTextSpan = () => wrapper.findByTestId('disabled-text');
const findDropdownTextAtIndex = (index) => findDropdownTexts().at(index);
const findInputGroup = () => wrapper.findComponent(GlFormGroup);
const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true });
const networkResolvedValue = new Error();
const successSearchQueryHandler = jest.fn().mockResolvedValue(projectMilestonesResponse);
const successSearchWithNoMatchingMilestones = jest
.fn()
.mockResolvedValue(projectMilestonesResponseWithNoMilestones);
const showDropdown = () => {
findDropdown().vm.$emit('shown');
};
const hideDropdown = () => {
findDropdown().vm.$emit('hide');
};
const createComponent = ({
canUpdate = true,
milestone = mockMilestoneWidgetResponse,
searchQueryHandler = successSearchQueryHandler,
} = {}) => {
const apolloProvider = createMockApollo(
[[projectMilestonesQuery, searchQueryHandler]],
resolvers,
{
typePolicies: temporaryConfig.cacheConfig.typePolicies,
},
);
apolloProvider.clients.defaultClient.writeQuery({
query: workItemQuery,
variables: {
id: workItemId,
},
data: workItemQueryResponse.data,
});
wrapper = shallowMountExtended(WorkItemMilestone, {
apolloProvider,
propsData: {
canUpdate,
workItemMilestone: milestone,
workItemId,
workItemType,
fullPath,
},
stubs: {
GlDropdown,
GlSearchBoxByType,
},
});
};
it('has "Milestone" label', () => {
createComponent();
expect(findInputGroup().exists()).toBe(true);
expect(findInputGroup().attributes('label')).toBe(WorkItemMilestone.i18n.MILESTONE);
});
describe('Default text with canUpdate false and milestone value', () => {
describe.each`
description | milestone | value
${'when no milestone'} | ${null} | ${WorkItemMilestone.i18n.NONE}
${'when milestone set'} | ${mockMilestoneWidgetResponse} | ${mockMilestoneWidgetResponse.title}
`('$description', ({ milestone, value }) => {
it(`has a value of "${value}"`, () => {
createComponent({ canUpdate: false, milestone });
expect(findDisabledTextSpan().text()).toBe(value);
expect(findDropdown().exists()).toBe(false);
});
});
});
describe('Default text value when canUpdate true and no milestone set', () => {
it(`has a value of "Add to milestone"`, () => {
createComponent({ canUpdate: true, milestone: null });
expect(findDropdown().props('text')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER);
});
});
describe('Dropdown search', () => {
it('has the search box', () => {
createComponent();
expect(findSearchBox().exists()).toBe(true);
});
it('shows no matching results when no items', () => {
createComponent({
searchQueryHandler: successSearchWithNoMatchingMilestones,
});
expect(findDropdownTextAtIndex(0).text()).toBe(WorkItemMilestone.i18n.NO_MATCHING_RESULTS);
expect(findDropdownItems()).toHaveLength(1);
expect(findDropdownTexts()).toHaveLength(1);
});
});
describe('Dropdown options', () => {
beforeEach(() => {
createComponent({ canUpdate: true });
});
it('shows the skeleton loader when the items are being fetched on click', async () => {
showDropdown();
await nextTick();
expect(findSkeletonLoader().exists()).toBe(true);
});
it('shows the milestones in dropdown when the items have finished fetching', async () => {
showDropdown();
await waitForPromises();
expect(findSkeletonLoader().exists()).toBe(false);
expect(findNoMilestoneDropdownItem().exists()).toBe(true);
expect(findDropdownItems()).toHaveLength(
projectMilestonesResponse.data.workspace.attributes.nodes.length + 1,
);
});
it('changes the milestone to null when clicked on no milestone', async () => {
showDropdown();
findFirstDropdownItem().vm.$emit('click');
hideDropdown();
await nextTick();
expect(findDropdown().props('loading')).toBe(true);
await waitForPromises();
expect(findDropdown().props('loading')).toBe(false);
expect(findDropdown().props('text')).toBe(WorkItemMilestone.i18n.MILESTONE_PLACEHOLDER);
});
it('changes the milestone to the selected milestone', async () => {
const milestoneIndex = 1;
/** the index is -1 since no matching results is also a dropdown item */
const milestoneAtIndex =
projectMilestonesResponse.data.workspace.attributes.nodes[milestoneIndex - 1];
showDropdown();
await waitForPromises();
findDropdownItemAtIndex(milestoneIndex).vm.$emit('click');
hideDropdown();
await waitForPromises();
expect(findDropdown().props('text')).toBe(milestoneAtIndex.title);
});
});
describe('Error handlers', () => {
it.each`
errorType | expectedErrorMessage | mockValue | resolveFunction
${'graphql error'} | ${'Something went wrong while updating the task. Please try again.'} | ${updateWorkItemMutationErrorResponse} | ${'mockResolvedValue'}
${'network error'} | ${'Something went wrong while updating the task. Please try again.'} | ${networkResolvedValue} | ${'mockRejectedValue'}
`(
'emits an error when there is a $errorType',
async ({ mockValue, expectedErrorMessage, resolveFunction }) => {
createComponent({
mutationHandler: jest.fn()[resolveFunction](mockValue),
canUpdate: true,
});
showDropdown();
findFirstDropdownItem().vm.$emit('click');
hideDropdown();
await waitForPromises();
expect(wrapper.emitted('error')).toEqual([[expectedErrorMessage]]);
},
);
});
describe('Tracking event', () => {
it('tracks updating the milestone', async () => {
const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn);
createComponent({ canUpdate: true });
showDropdown();
findFirstDropdownItem().vm.$emit('click');
hideDropdown();
await waitForPromises();
expect(trackingSpy).toHaveBeenCalledWith(TRACKING_CATEGORY_SHOW, 'updated_milestone', {
category: TRACKING_CATEGORY_SHOW,
label: 'item_milestone',
property: 'type_Task',
});
});
});
});

View File

@ -186,6 +186,7 @@ export const workItemResponseFactory = ({
datesWidgetPresent = true,
labelsWidgetPresent = true,
weightWidgetPresent = true,
milestoneWidgetPresent = true,
iterationWidgetPresent = true,
confidential = false,
canInviteMembers = false,
@ -279,6 +280,16 @@ export const workItemResponseFactory = ({
},
}
: { type: 'MOCK TYPE' },
milestoneWidgetPresent
? {
__typename: 'WorkItemWidgetMilestone',
dueDate: null,
expired: false,
id: 'gid://gitlab/Milestone/30',
title: 'v4.0',
type: 'MILESTONE',
}
: { type: 'MOCK TYPE' },
{
__typename: 'WorkItemWidgetHierarchy',
type: 'HIERARCHY',
@ -1059,3 +1070,55 @@ export const groupIterationsResponseWithNoIterations = {
},
},
};
export const mockMilestoneWidgetResponse = {
dueDate: null,
expired: false,
id: 'gid://gitlab/Milestone/30',
title: 'v4.0',
};
export const projectMilestonesResponse = {
data: {
workspace: {
id: 'gid://gitlab/Project/1',
attributes: {
nodes: [
{
id: 'gid://gitlab/Milestone/5',
title: 'v4.0',
webUrl: '/gitlab-org/gitlab-test/-/milestones/5',
dueDate: null,
expired: false,
__typename: 'Milestone',
state: 'active',
},
{
id: 'gid://gitlab/Milestone/4',
title: 'v3.0',
webUrl: '/gitlab-org/gitlab-test/-/milestones/4',
dueDate: null,
expired: false,
__typename: 'Milestone',
state: 'active',
},
],
__typename: 'MilestoneConnection',
},
__typename: 'Project',
},
},
};
export const projectMilestonesResponseWithNoMilestones = {
data: {
workspace: {
id: 'gid://gitlab/Project/1',
attributes: {
nodes: [],
__typename: 'MilestoneConnection',
},
__typename: 'Project',
},
},
};

View File

@ -8,6 +8,13 @@ RSpec.describe HooksHelper do
let(:service_hook) { create(:service_hook, integration: create(:drone_ci_integration)) }
let(:system_hook) { create(:system_hook) }
describe '#webhook_form_data' do
subject { helper.webhook_form_data(project_hook) }
it { expect(subject[:url]).to eq(project_hook.url) }
it { expect(subject[:url_variables]).to be_nil }
end
describe '#link_to_test_hook' do
let(:trigger) { 'push_events' }

View File

@ -5,75 +5,113 @@ require 'spec_helper'
RSpec.describe IdeHelper do
describe '#ide_data' do
let_it_be(:project) { create(:project) }
let_it_be(:user) { project.creator }
before do
allow(helper).to receive(:current_user).and_return(project.creator)
allow(helper).to receive(:current_user).and_return(user)
allow(helper).to receive(:content_security_policy_nonce).and_return('test-csp-nonce')
end
context 'when instance vars are not set' do
it 'returns instance data in the hash as nil' do
expect(helper.ide_data)
.to include(
'branch-name' => nil,
'file-path' => nil,
'merge-request' => nil,
'fork-info' => nil,
'project' => nil,
'preview-markdown-path' => nil
)
end
end
context 'when instance vars are set' do
it 'returns instance data in the hash' do
fork_info = { ide_path: '/test/ide/path' }
context 'with vscode_web_ide=true and instance vars set' do
before do
stub_feature_flags(vscode_web_ide: true)
self.instance_variable_set(:@branch, 'master')
self.instance_variable_set(:@path, 'foo/bar')
self.instance_variable_set(:@merge_request, '1')
self.instance_variable_set(:@fork_info, fork_info)
self.instance_variable_set(:@project, project)
end
serialized_project = API::Entities::Project.represent(project, current_user: project.creator).to_json
it 'returns hash' do
expect(helper.ide_data)
.to include(
.to eq(
'can-use-new-web-ide' => 'true',
'use-new-web-ide' => 'true',
'user-preferences-path' => profile_preferences_path,
'branch-name' => 'master',
'file-path' => 'foo/bar',
'merge-request' => '1',
'fork-info' => fork_info.to_json,
'project' => serialized_project,
'preview-markdown-path' => Gitlab::Routing.url_helpers.preview_markdown_project_path(project)
'project-path' => project.path_with_namespace,
'csp-nonce' => 'test-csp-nonce'
)
end
it 'does not use new web ide if user.use_legacy_web_ide' do
allow(user).to receive(:use_legacy_web_ide).and_return(true)
expect(helper.ide_data).to include('use-new-web-ide' => 'false')
end
end
context 'environments guidance experiment', :experiment do
context 'with vscode_web_ide=false' do
before do
stub_experiments(in_product_guidance_environments_webide: :candidate)
self.instance_variable_set(:@project, project)
stub_feature_flags(vscode_web_ide: false)
end
context 'when project has no enviornments' do
it 'enables environment guidance' do
expect(helper.ide_data).to include('enable-environments-guidance' => 'true')
context 'when instance vars are not set' do
it 'returns instance data in the hash as nil' do
expect(helper.ide_data)
.to include(
'can-use-new-web-ide' => 'false',
'use-new-web-ide' => 'false',
'user-preferences-path' => profile_preferences_path,
'branch-name' => nil,
'file-path' => nil,
'merge-request' => nil,
'fork-info' => nil,
'project' => nil,
'preview-markdown-path' => nil
)
end
end
context 'when instance vars are set' do
it 'returns instance data in the hash' do
fork_info = { ide_path: '/test/ide/path' }
self.instance_variable_set(:@branch, 'master')
self.instance_variable_set(:@path, 'foo/bar')
self.instance_variable_set(:@merge_request, '1')
self.instance_variable_set(:@fork_info, fork_info)
self.instance_variable_set(:@project, project)
serialized_project = API::Entities::Project.represent(project, current_user: project.creator).to_json
expect(helper.ide_data)
.to include(
'branch-name' => 'master',
'file-path' => 'foo/bar',
'merge-request' => '1',
'fork-info' => fork_info.to_json,
'project' => serialized_project,
'preview-markdown-path' => Gitlab::Routing.url_helpers.preview_markdown_project_path(project)
)
end
end
context 'environments guidance experiment', :experiment do
before do
stub_experiments(in_product_guidance_environments_webide: :candidate)
self.instance_variable_set(:@project, project)
end
context 'and the callout has been dismissed' do
it 'disables environment guidance' do
callout = create(:callout, feature_name: :web_ide_ci_environments_guidance, user: project.creator)
callout.update!(dismissed_at: Time.now - 1.week)
allow(helper).to receive(:current_user).and_return(User.find(project.creator.id))
expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
context 'when project has no enviornments' do
it 'enables environment guidance' do
expect(helper.ide_data).to include('enable-environments-guidance' => 'true')
end
context 'and the callout has been dismissed' do
it 'disables environment guidance' do
callout = create(:callout, feature_name: :web_ide_ci_environments_guidance, user: project.creator)
callout.update!(dismissed_at: Time.now - 1.week)
allow(helper).to receive(:current_user).and_return(User.find(project.creator.id))
expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
end
end
end
end
context 'when the project has environments' do
it 'disables environment guidance' do
create(:environment, project: project)
context 'when the project has environments' do
it 'disables environment guidance' do
create(:environment, project: project)
expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
expect(helper.ide_data).to include('enable-environments-guidance' => 'false')
end
end
end
end

View File

@ -571,5 +571,42 @@ RSpec.describe Gitlab::Ci::Variables::Collection do
end
end
end
context 'with the file_variable_is_referenced_in_another_variable logging' do
let(:collection) do
Gitlab::Ci::Variables::Collection.new
.append(key: 'VAR1', value: 'test-1')
.append(key: 'VAR2', value: '$VAR1')
.append(key: 'VAR3', value: '$VAR1', raw: true)
.append(key: 'FILEVAR4', value: 'file-test-4', file: true)
.append(key: 'VAR5', value: '$FILEVAR4')
.append(key: 'VAR6', value: '$FILEVAR4', raw: true)
end
subject(:sort_and_expand_all) { collection.sort_and_expand_all(project: project) }
context 'when a project is not passed' do
let(:project) {}
it 'does not log anything' do
expect(Gitlab::AppJsonLogger).not_to receive(:info)
sort_and_expand_all
end
end
context 'when a project is passed' do
let(:project) { create(:project) }
it 'logs file_variable_is_referenced_in_another_variable once for VAR5' do
expect(Gitlab::AppJsonLogger).to receive(:info).with(
event: 'file_variable_is_referenced_in_another_variable',
project_id: project.id
).once
sort_and_expand_all
end
end
end
end
end

View File

@ -153,6 +153,21 @@ RSpec.describe Gitlab::Database::Partitioning::ConvertTableToFirstListPartition
expect(parent_model.pluck(:id)).to match_array([1, 2, 3])
end
context 'when the existing table is owned by a different user' do
before do
connection.execute(<<~SQL)
CREATE USER other_user SUPERUSER;
ALTER TABLE #{table_name} OWNER TO other_user;
SQL
end
let(:current_user) { model.connection.select_value('select current_user') }
it 'partitions without error' do
expect { partition }.not_to raise_error
end
end
context 'when an error occurs during the conversion' do
def fail_first_time
# We can't directly use a boolean here, as we need something that will be passed by-reference to the proc

View File

@ -78,16 +78,30 @@ RSpec.describe Gitlab::ImportExport::UploadsManager do
context 'when upload is in object storage' do
before do
stub_uploads_object_storage(FileUploader)
allow(manager).to receive(:download_or_copy_upload).and_raise(Errno::ENAMETOOLONG)
end
it 'ignores problematic upload and logs exception' do
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(Errno::ENAMETOOLONG), project_id: project.id)
shared_examples 'export with invalid upload' do
it 'ignores problematic upload and logs exception' do
allow(manager).to receive(:download_or_copy_upload).and_raise(exception)
expect(Gitlab::ErrorTracking).to receive(:log_exception).with(instance_of(exception), project_id: project.id)
manager.save # rubocop:disable Rails/SaveBang
manager.save # rubocop:disable Rails/SaveBang
expect(shared.errors).to be_empty
expect(File).not_to exist(exported_file_path)
expect(shared.errors).to be_empty
expect(File).not_to exist(exported_file_path)
end
end
context 'when filename is too long' do
let(:exception) { Errno::ENAMETOOLONG }
include_examples 'export with invalid upload'
end
context 'when network exception occurs' do
let(:exception) { Net::OpenTimeout }
include_examples 'export with invalid upload'
end
end
end

View File

@ -0,0 +1,40 @@
# frozen_string_literal: true
require 'spec_helper'
require_migration!
RSpec.describe UpdateInvalidDormantUserSetting, :migration do
let(:settings) { table(:application_settings) }
context 'with no rows in the application_settings table' do
it 'does not insert a row' do
expect { migrate! }.to not_change { settings.count }
end
end
context 'with a row in the application_settings table' do
before do
settings.create!(deactivate_dormant_users_period: days)
end
context 'with deactivate_dormant_users_period set to a value greater than or equal to 90' do
let(:days) { 90 }
it 'does not update the row' do
expect { migrate! }
.to not_change { settings.count }
.and not_change { settings.first.deactivate_dormant_users_period }
end
end
context 'with deactivate_dormant_users_period set to a value less than or equal to 90' do
let(:days) { 1 }
it 'updates the existing row' do
expect { migrate! }
.to not_change { settings.count }
.and change { settings.first.deactivate_dormant_users_period }
end
end
end
end

View File

@ -203,6 +203,17 @@ RSpec.describe ApplicationSetting do
it { is_expected.to allow_value([]).for(:valid_runner_registrars) }
it { is_expected.to allow_value(%w(project group)).for(:valid_runner_registrars) }
context 'when deactivate_dormant_users is enabled' do
before do
stub_application_setting(deactivate_dormant_users: true)
end
it { is_expected.not_to allow_value(nil).for(:deactivate_dormant_users_period) }
it { is_expected.to allow_value(90).for(:deactivate_dormant_users_period) }
it { is_expected.to allow_value(365).for(:deactivate_dormant_users_period) }
it { is_expected.not_to allow_value(89).for(:deactivate_dormant_users_period) }
end
context 'help_page_documentation_base_url validations' do
it { is_expected.to allow_value(nil).for(:help_page_documentation_base_url) }
it { is_expected.to allow_value('https://docs.gitlab.com').for(:help_page_documentation_base_url) }

View File

@ -5337,19 +5337,18 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
describe '#authorized_cluster_agents' do
describe '#cluster_agent_authorizations' do
let(:pipeline) { create(:ci_empty_pipeline, :created) }
let(:agent) { instance_double(Clusters::Agent) }
let(:authorization) { instance_double(Clusters::Agents::GroupAuthorization, agent: agent) }
let(:authorization) { instance_double(Clusters::Agents::GroupAuthorization) }
let(:finder) { double(execute: [authorization]) }
it 'retrieves agent records from the finder and caches the result' do
it 'retrieves authorization records from the finder and caches the result' do
expect(Clusters::AgentAuthorizationsFinder).to receive(:new).once
.with(pipeline.project)
.and_return(finder)
expect(pipeline.authorized_cluster_agents).to contain_exactly(agent)
expect(pipeline.authorized_cluster_agents).to contain_exactly(agent) # cached
expect(pipeline.cluster_agent_authorizations).to contain_exactly(authorization)
expect(pipeline.cluster_agent_authorizations).to contain_exactly(authorization) # cached
end
end

View File

@ -47,7 +47,7 @@ RSpec.describe Ci::Variable do
context 'loose foreign key on ci_variables.project_id' do
it_behaves_like 'cleanup by a loose foreign key' do
let!(:parent) { create(:project) }
let!(:parent) { create(:project, namespace: create(:group)) }
let!(:model) { create(:ci_variable, project: parent) }
end
end

View File

@ -10,5 +10,5 @@ RSpec.describe Clusters::Agents::ImplicitAuthorization do
it { expect(subject.agent).to eq(agent) }
it { expect(subject.agent_id).to eq(agent.id) }
it { expect(subject.config_project).to eq(agent.project) }
it { expect(subject.config).to be_nil }
it { expect(subject.config).to eq({}) }
end

View File

@ -45,6 +45,13 @@ RSpec.describe UserPreference do
it { is_expected.not_to allow_value(color).for(:diffs_addition_color) }
end
end
describe 'use_legacy_web_ide' do
it { is_expected.to allow_value(true).for(:use_legacy_web_ide) }
it { is_expected.to allow_value(false).for(:use_legacy_web_ide) }
it { is_expected.not_to allow_value(nil).for(:use_legacy_web_ide) }
it { is_expected.not_to allow_value("").for(:use_legacy_web_ide) }
end
end
describe 'notes filters global keys' do

View File

@ -78,6 +78,9 @@ RSpec.describe User do
it { is_expected.to delegate_method(:diffs_addition_color).to(:user_preference) }
it { is_expected.to delegate_method(:diffs_addition_color=).to(:user_preference).with_arguments(:args) }
it { is_expected.to delegate_method(:use_legacy_web_ide).to(:user_preference) }
it { is_expected.to delegate_method(:use_legacy_web_ide=).to(:user_preference).with_arguments(:args) }
it { is_expected.to delegate_method(:job_title).to(:user_detail).allow_nil }
it { is_expected.to delegate_method(:job_title=).to(:user_detail).with_arguments(:args).allow_nil }

View File

@ -350,6 +350,15 @@ RSpec.describe Ci::BuildRunnerPresenter do
)
end
it 'logs file_variable_is_referenced_in_another_variable' do
expect(Gitlab::AppJsonLogger).to receive(:info).with(
event: 'file_variable_is_referenced_in_another_variable',
project_id: project.id
).once
runner_variables
end
context 'when the FF ci_stop_expanding_file_vars_for_runners is disabled' do
before do
stub_feature_flags(ci_stop_expanding_file_vars_for_runners: false)

View File

@ -3,6 +3,8 @@
require 'spec_helper'
RSpec.describe IdeController do
using RSpec::Parameterized::TableSyntax
let_it_be(:reporter) { create(:user) }
let_it_be(:project) do
@ -237,21 +239,29 @@ RSpec.describe IdeController do
end
# This indirectly tests that `minimal: true` was passed to the fullscreen layout
it 'does not render top nav' do
subject
expect(response).not_to render_template(top_nav_partial)
end
context 'without vscode_web_ide feature flag' do
before do
stub_feature_flags(vscode_web_ide: false)
describe 'layout' do
where(:ff_state, :use_legacy_web_ide, :expect_top_nav) do
false | false | true
false | true | true
true | true | true
true | false | false
end
it 'renders top nav' do
subject
with_them do
before do
stub_feature_flags(vscode_web_ide: ff_state)
allow(user).to receive(:use_legacy_web_ide).and_return(use_legacy_web_ide)
expect(response).to render_template(top_nav_partial)
subject
end
it 'handles rendering top nav' do
if expect_top_nav
expect(response).to render_template(top_nav_partial)
else
expect(response).not_to render_template(top_nav_partial)
end
end
end
end
end

View File

@ -0,0 +1,62 @@
# frozen_string_literal: true
require 'fast_spec_helper'
require_relative '../../../../scripts/lib/glfm/verify_all_generated_files_are_up_to_date'
# IMPORTANT NOTE: See https://docs.gitlab.com/ee/development/gitlab_flavored_markdown/specification_guide/#verify-all-generated-files-are-up-to-daterb-script
# for details on the implementation and usage of the `verify_all_generated_files_are_up_to_date.rb` script being tested.
# This developers guide contains diagrams and documentation of the script,
# including explanations and examples of all files it reads and writes.
RSpec.describe Glfm::VerifyAllGeneratedFilesAreUpToDate, '#process' do
subject { described_class.new }
let(:output_path) { described_class::GLFM_SPEC_OUTPUT_PATH }
let(:snapshots_path) { described_class::EXAMPLE_SNAPSHOTS_PATH }
let(:verify_cmd) { "git status --porcelain #{output_path} #{snapshots_path}" }
before do
# Prevent console output when running tests
allow(subject).to receive(:output)
end
context 'when repo is dirty' do
before do
# Simulate a dirty repo
allow(subject).to receive(:run_external_cmd).with(verify_cmd).and_return(" M #{output_path}")
end
it 'raises an error', :unlimited_max_formatted_output_length do
expect { subject.process }.to raise_error(/Cannot run.*uncommitted changes.*#{output_path}/m)
end
end
context 'when repo is clean' do
before do
# Mock out all yarn install and script execution
allow(subject).to receive(:run_external_cmd).with('yarn install --frozen-lockfile')
allow(subject).to receive(:run_external_cmd).with(/update-specification.rb/)
allow(subject).to receive(:run_external_cmd).with(/update-example-snapshots.rb/)
end
context 'when all generated files are up to date' do
before do
# Simulate a clean repo, then simulate no changes to generated files
allow(subject).to receive(:run_external_cmd).twice.with(verify_cmd).and_return('', '')
end
it 'does not raise an error', :unlimited_max_formatted_output_length do
expect { subject.process }.not_to raise_error
end
end
context 'when generated file(s) are not up to date' do
before do
# Simulate a clean repo, then simulate changes to generated files
allow(subject).to receive(:run_external_cmd).twice.with(verify_cmd).and_return('', "M #{snapshots_path}")
end
it 'raises an error', :unlimited_max_formatted_output_length do
expect { subject.process }.to raise_error(/following files were modified.*#{snapshots_path}/m)
end
end
end
end

View File

@ -3,9 +3,11 @@
require 'spec_helper'
RSpec.describe BulkImports::UploadsExportService do
let_it_be(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) }
let_it_be(:upload) { create(:upload, :with_file, :issuable_upload, uploader: FileUploader, model: project) }
let_it_be(:export_path) { Dir.mktmpdir }
let_it_be(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) }
let!(:upload) { create(:upload, :with_file, :issuable_upload, uploader: FileUploader, model: project) }
let(:exported_filepath) { File.join(export_path, upload.secret, upload.retrieve_uploader.filename) }
subject(:service) { described_class.new(project, export_path) }
@ -15,10 +17,60 @@ RSpec.describe BulkImports::UploadsExportService do
describe '#execute' do
it 'exports project uploads and avatar' do
subject.execute
service.execute
expect(File.exist?(File.join(export_path, 'avatar', 'rails_sample.png'))).to eq(true)
expect(File.exist?(File.join(export_path, upload.secret, upload.retrieve_uploader.filename))).to eq(true)
expect(File).to exist(File.join(export_path, 'avatar', 'rails_sample.png'))
expect(File).to exist(exported_filepath)
end
context 'when upload has underlying file missing' do
context 'with an upload missing its file' do
it 'does not cause errors' do
File.delete(upload.absolute_path)
expect { service.execute }.not_to raise_error
expect(File).not_to exist(exported_filepath)
end
end
context 'when upload is in object storage' do
before do
stub_uploads_object_storage(FileUploader)
end
shared_examples 'export with invalid upload' do
it 'ignores problematic upload and logs exception' do
allow(service).to receive(:download_or_copy_upload).and_raise(exception)
expect(Gitlab::ErrorTracking)
.to receive(:log_exception)
.with(
instance_of(exception), {
portable_id: project.id,
portable_class: 'Project',
upload_id: upload.id
}
)
service.execute
expect(File).not_to exist(exported_filepath)
end
end
context 'when filename is too long' do
let(:exception) { Errno::ENAMETOOLONG }
include_examples 'export with invalid upload'
end
context 'when network exception occurs' do
let(:exception) { Net::OpenTimeout }
include_examples 'export with invalid upload'
end
end
end
end
end

View File

@ -9,6 +9,8 @@ RSpec.describe Ci::GenerateKubeconfigService do
let(:pipeline) { build.pipeline }
let(:agent1) { create(:cluster_agent, project: project) }
let(:agent2) { create(:cluster_agent) }
let(:authorization1) { create(:agent_project_authorization, agent: agent1) }
let(:authorization2) { create(:agent_project_authorization, agent: agent2) }
let(:template) { instance_double(Gitlab::Kubernetes::Kubeconfig::Template) }
@ -16,7 +18,7 @@ RSpec.describe Ci::GenerateKubeconfigService do
before do
expect(Gitlab::Kubernetes::Kubeconfig::Template).to receive(:new).and_return(template)
expect(pipeline).to receive(:authorized_cluster_agents).and_return([agent1, agent2])
expect(pipeline).to receive(:cluster_agent_authorizations).and_return([authorization1, authorization2])
end
it 'adds a cluster, and a user and context for each available agent' do
@ -36,11 +38,13 @@ RSpec.describe Ci::GenerateKubeconfigService do
expect(template).to receive(:add_context).with(
name: "#{project.full_path}:#{agent1.name}",
namespace: 'production',
cluster: 'gitlab',
user: "agent:#{agent1.id}"
)
expect(template).to receive(:add_context).with(
name: "#{agent2.project.full_path}:#{agent2.name}",
namespace: 'production',
cluster: 'gitlab',
user: "agent:#{agent2.id}"
)

View File

@ -1,9 +0,0 @@
# frozen_string_literal: true
RSpec.configure do |config|
config.after do |example|
[::ApplicationRecord, ::Ci::ApplicationRecord].each do |base_class|
base_class.gitlab_transactions_stack.clear if base_class.respond_to?(:gitlab_transactions_stack)
end
end
end

View File

@ -27,5 +27,9 @@ RSpec.configure do |config|
# Reset after execution to preferred state
config.after do |example_file|
::Gitlab::Database::QueryAnalyzers::PreventCrossDatabaseModification.suppress_in_rspec = true
[::ApplicationRecord, ::Ci::ApplicationRecord].each do |base_class|
base_class.gitlab_transactions_stack.clear if base_class.respond_to?(:gitlab_transactions_stack)
end
end
end

View File

@ -50,8 +50,8 @@ RSpec.shared_context 'MergeRequestsFinder multiple projects with merge requests
allow_gitaly_n_plus_1 { create(:project, group: subgroup) }
end
let!(:label) { create(:label, project: project1) }
let!(:label2) { create(:label, project: project1) }
let_it_be(:label) { create(:label, project: project1) }
let_it_be(:label2) { create(:label, project: project1) }
let!(:merge_request1) do
create(:merge_request, assignees: [user], author: user, reviewers: [user2],
@ -87,13 +87,16 @@ RSpec.shared_context 'MergeRequestsFinder multiple projects with merge requests
let!(:label_link) { create(:label_link, label: label, target: merge_request2) }
let!(:label_link2) { create(:label_link, label: label2, target: merge_request3) }
before do
before_all do
project1.add_maintainer(user)
project2.add_developer(user)
project3.add_developer(user)
project4.add_developer(user)
project5.add_developer(user)
project6.add_developer(user)
end
before do
project2.add_developer(user)
project3.add_developer(user)
project2.add_developer(user2)
end

View File

@ -1,6 +1,6 @@
# frozen_string_literal: true
require 'spec_helper'
require 'fast_spec_helper'
require_relative '../../../tooling/quality/test_level'