diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-18 21:09:37 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-18 21:09:37 +0000 |
commit | cace5e8ff1f766b8098e35adc94abc4402aeb2a9 (patch) | |
tree | 96bea3616ee60702be89f4845580f3b3db22f936 | |
parent | e4220eeccaf1d53444fdd9102a4061336f91784e (diff) | |
download | gitlab-ce-cace5e8ff1f766b8098e35adc94abc4402aeb2a9.tar.gz |
Add latest changes from gitlab-org/gitlab@master
99 files changed, 1999 insertions, 366 deletions
diff --git a/.gitlab/ci/glfm.gitlab-ci.yml b/.gitlab/ci/glfm.gitlab-ci.yml new file mode 100644 index 00000000000..fcc9a035c1f --- /dev/null +++ b/.gitlab/ci/glfm.gitlab-ci.yml @@ -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 diff --git a/app/assets/javascripts/graphql_shared/issuable_client.js b/app/assets/javascripts/graphql_shared/issuable_client.js index 3849bd0289d..3b737dfff33 100644 --- a/app/assets/javascripts/graphql_shared/issuable_client.js +++ b/app/assets/javascripts/graphql_shared/issuable_client.js @@ -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) { diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index d74cb2d8175..15f5a3518a5 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -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" /> diff --git a/app/assets/javascripts/groups/components/groups.vue b/app/assets/javascripts/groups/components/groups.vue index 3a05c308a2a..43aa0753082 100644 --- a/app/assets/javascripts/groups/components/groups.vue +++ b/app/assets/javascripts/groups/components/groups.vue @@ -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 diff --git a/app/assets/javascripts/groups/components/overview_tabs.vue b/app/assets/javascripts/groups/components/overview_tabs.vue index 15a0c686548..d0c5846ac88 100644 --- a/app/assets/javascripts/groups/components/overview_tabs.vue +++ b/app/assets/javascripts/groups/components/overview_tabs.vue @@ -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"> diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js index 33bfcade336..6fb12cd6270 100644 --- a/app/assets/javascripts/groups/constants.js +++ b/app/assets/javascripts/groups/constants.js @@ -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 = { diff --git a/app/assets/javascripts/ide/index.js b/app/assets/javascripts/ide/index.js index 10e9f6a9488..1a191f6f76f 100644 --- a/app/assets/javascripts/ide/index.js +++ b/app/assets/javascripts/ide/index.js @@ -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(); diff --git a/app/assets/javascripts/ide/init_gitlab_web_ide.js b/app/assets/javascripts/ide/init_gitlab_web_ide.js index a061da38d4f..140f2895a29 100644 --- a/app/assets/javascripts/ide/init_gitlab_web_ide.js +++ b/app/assets/javascripts/ide/init_gitlab_web_ide.js @@ -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 diff --git a/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue b/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue new file mode 100644 index 00000000000..20ce296bbec --- /dev/null +++ b/app/assets/javascripts/pages/import/fogbugz/new_user_map/components/user_select.vue @@ -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> diff --git a/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js index 86b80a0ba5b..ef549f20cf3 100644 --- a/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js +++ b/app/assets/javascripts/pages/import/fogbugz/new_user_map/index.js @@ -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 } }), + }), +); diff --git a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue index 7126d69c8c6..c33b1468ca4 100644 --- a/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue +++ b/app/assets/javascripts/sidebar/components/sidebar_dropdown_widget.vue @@ -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 diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 60cb4cff727..6248bcb8e2d 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -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', }; diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 5963568a00b..bd425bdc2a8 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -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"> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js index d67ff11f297..e3f87c08ad4 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/telemetry.js @@ -28,7 +28,7 @@ const nonStandardEvents = { }, counter: {}, }, - testReport: { + testSummary: { uniqueUser: { expand: ['i_testing_summary_widget_total'], }, diff --git a/app/assets/javascripts/webhooks/components/form_url_app.vue b/app/assets/javascripts/webhooks/components/form_url_app.vue index 62d6c03bbb3..5ec16d4ba15 100644 --- a/app/assets/javascripts/webhooks/components/form_url_app.vue +++ b/app/assets/javascripts/webhooks/components/form_url_app.vue @@ -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> diff --git a/app/assets/javascripts/webhooks/components/form_url_mask_item.vue b/app/assets/javascripts/webhooks/components/form_url_mask_item.vue index 1e74b4a8215..3b75f9b6c0d 100644 --- a/app/assets/javascripts/webhooks/components/form_url_mask_item.vue +++ b/app/assets/javascripts/webhooks/components/form_url_mask_item.vue @@ -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> diff --git a/app/assets/javascripts/webhooks/index.js b/app/assets/javascripts/webhooks/index.js index bfa33560fa5..1b2b33e44c1 100644 --- a/app/assets/javascripts/webhooks/index.js +++ b/app/assets/javascripts/webhooks/index.js @@ -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, + }, + }); }, }); }; diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index cf0aafc2eb0..af9b8c6101a 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -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" diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue new file mode 100644 index 00000000000..c4a36e36555 --- /dev/null +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -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> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index 0d426299408..7737c535650 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -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'; diff --git a/app/assets/javascripts/work_items/graphql/typedefs.graphql b/app/assets/javascripts/work_items/graphql/typedefs.graphql index d3712da1329..36779dfe11e 100644 --- a/app/assets/javascripts/work_items/graphql/typedefs.graphql +++ b/app/assets/javascripts/work_items/graphql/typedefs.graphql @@ -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 { diff --git a/app/assets/javascripts/work_items/graphql/work_item.query.graphql b/app/assets/javascripts/work_items/graphql/work_item.query.graphql index 3b46fed97ec..fa0ab56df75 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.query.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.query.graphql @@ -3,5 +3,16 @@ query workItem($id: WorkItemID!) { workItem(id: $id) { ...WorkItem + mockWidgets @client { + ... on LocalWorkItemMilestone { + type + nodes { + id + title + expired + dueDate + } + } + } } } diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 7a5cc72ceb8..820a1a0b53e 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -64,7 +64,7 @@ } } -.work-item-iteration { +.work-item-dropdown { .gl-dropdown-toggle { background: none !important; @@ -82,4 +82,3 @@ } } } - diff --git a/app/controllers/ide_controller.rb b/app/controllers/ide_controller.rb index ebd958822ed..fcf6871d137 100644 --- a/app/controllers/ide_controller.rb +++ b/app/controllers/ide_controller.rb @@ -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 diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index c0360d10392..a57c87bf691 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -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 diff --git a/app/graphql/types/environment_type.rb b/app/graphql/types/environment_type.rb index 2484081a828..dd2286d333d 100644 --- a/app/graphql/types/environment_type.rb +++ b/app/graphql/types/environment_type.rb @@ -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 diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index 1e50033e0e0..e050ccc0e40 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -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 diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index ec1327cf7ae..5b3ca25b5af 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -3,6 +3,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'), 'committed-state-svg-path' => image_path('illustrations/multi-editor_all_changes_committed_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 diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index f83aa79b461..361b1a8dca9 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -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 diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 4287c0b7884..950e0a583bc 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -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 diff --git a/app/models/clusters/agents/implicit_authorization.rb b/app/models/clusters/agents/implicit_authorization.rb index 9f7f653ed65..a365ccdc568 100644 --- a/app/models/clusters/agents/implicit_authorization.rb +++ b/app/models/clusters/agents/implicit_authorization.rb @@ -16,7 +16,7 @@ module Clusters end def config - nil + {} end end end diff --git a/app/models/user.rb b/app/models/user.rb index b36b00fcbaf..6d198fc755b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index b8f30413404..c6ebd550daf 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -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' diff --git a/app/presenters/ci/build_runner_presenter.rb b/app/presenters/ci/build_runner_presenter.rb index 71a05ef2c72..706608e3029 100644 --- a/app/presenters/ci/build_runner_presenter.rb +++ b/app/presenters/ci/build_runner_presenter.rb @@ -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 diff --git a/app/services/bulk_imports/uploads_export_service.rb b/app/services/bulk_imports/uploads_export_service.rb index 7f5ee7b8624..315590bea31 100644 --- a/app/services/bulk_imports/uploads_export_service.rb +++ b/app/services/bulk_imports/uploads_export_service.rb @@ -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 diff --git a/app/services/ci/generate_kubeconfig_service.rb b/app/services/ci/generate_kubeconfig_service.rb index 894ab8e8505..347bc99dbf5 100644 --- a/app/services/ci/generate_kubeconfig_service.rb +++ b/app/services/ci/generate_kubeconfig_service.rb @@ -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 diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index b08a549148d..c091a2180c5 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -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' diff --git a/app/views/import/fogbugz/new_user_map.html.haml b/app/views/import/fogbugz/new_user_map.html.haml index 28836055e0e..9d4c0f62134 100644 --- a/app/views/import/fogbugz/new_user_map.html.haml +++ b/app/views/import/fogbugz/new_user_map.html.haml @@ -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' diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 549436ccabf..c95e63bdc83 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -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' diff --git a/db/migrate/20221006141145_add_targets_to_elastic_reindexing_tasks.rb b/db/migrate/20221006141145_add_targets_to_elastic_reindexing_tasks.rb new file mode 100644 index 00000000000..1631f8ae57e --- /dev/null +++ b/db/migrate/20221006141145_add_targets_to_elastic_reindexing_tasks.rb @@ -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 diff --git a/db/migrate/20221011210455_add_use_legacy_web_ide_to_user_preferences.rb b/db/migrate/20221011210455_add_use_legacy_web_ide_to_user_preferences.rb new file mode 100644 index 00000000000..1b434e10ab0 --- /dev/null +++ b/db/migrate/20221011210455_add_use_legacy_web_ide_to_user_preferences.rb @@ -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 diff --git a/db/post_migrate/20221013154159_update_invalid_dormant_user_setting.rb b/db/post_migrate/20221013154159_update_invalid_dormant_user_setting.rb new file mode 100644 index 00000000000..1f1e47fdac1 --- /dev/null +++ b/db/post_migrate/20221013154159_update_invalid_dormant_user_setting.rb @@ -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 diff --git a/db/schema_migrations/20221006141145 b/db/schema_migrations/20221006141145 new file mode 100644 index 00000000000..269913ca389 --- /dev/null +++ b/db/schema_migrations/20221006141145 @@ -0,0 +1 @@ +ae45bc7d67354b64e359ac7fadefec6a0d81cd529f5ae2517a6a6a5d250f9024
\ No newline at end of file diff --git a/db/schema_migrations/20221011210455 b/db/schema_migrations/20221011210455 new file mode 100644 index 00000000000..2a6a7349f5d --- /dev/null +++ b/db/schema_migrations/20221011210455 @@ -0,0 +1 @@ +3c2445871613743560b2dd0a111fafab30f503b1c462e7ba7aee03f85e25f775
\ No newline at end of file diff --git a/db/schema_migrations/20221013154159 b/db/schema_migrations/20221013154159 new file mode 100644 index 00000000000..2e147bb199d --- /dev/null +++ b/db/schema_migrations/20221013154159 @@ -0,0 +1 @@ +dbf241baf6d3deb1ef29a7cdca012050cab51c5f86762a0363d9dc4dc14fd804
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 10b2d26b1e0..2dab5e7abc9 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -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)) ); diff --git a/doc/administration/geo/replication/datatypes.md b/doc/administration/geo/replication/datatypes.md index 69883b159c3..566df2ee509 100644 --- a/doc/administration/geo/replication/datatypes.md +++ b/doc/administration/geo/replication/datatypes.md @@ -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. | diff --git a/doc/administration/operations/rails_console.md b/doc/administration/operations/rails_console.md index 9d6d0a0774d..1ef985b8938 100644 --- a/doc/administration/operations/rails_console.md +++ b/doc/administration/operations/rails_console.md @@ -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 diff --git a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md index e5ee6051000..6ae840a18ec 100644 --- a/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md +++ b/doc/administration/troubleshooting/gitlab_rails_cheat_sheet.md @@ -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 diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 544251668f9..1cf1051cb47 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -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). diff --git a/doc/api/packages/terraform-modules.md b/doc/api/packages/terraform-modules.md index d7b14cb7c96..4c32e3f7cb4 100644 --- a/doc/api/packages/terraform-modules.md +++ b/doc/api/packages/terraform-modules.md @@ -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 ``` diff --git a/doc/api/repositories.md b/doc/api/repositories.md index b0cfb7c7d9f..751bbd75c7a 100644 --- a/doc/api/repositories.md +++ b/doc/api/repositories.md @@ -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" diff --git a/doc/development/gitlab_flavored_markdown/specification_guide/index.md b/doc/development/gitlab_flavored_markdown/specification_guide/index.md index b1ab39b0321..95d06907aa6 100644 --- a/doc/development/gitlab_flavored_markdown/specification_guide/index.md +++ b/doc/development/gitlab_flavored_markdown/specification_guide/index.md @@ -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 diff --git a/doc/development/performance.md b/doc/development/performance.md index f2417a49b27..4f22d9ceb03 100644 --- a/doc/development/performance.md +++ b/doc/development/performance.md @@ -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 diff --git a/doc/user/admin_area/moderate_users.md b/doc/user/admin_area/moderate_users.md index fa2bf4b9616..ace1c6be5f8 100644 --- a/doc/user/admin_area/moderate_users.md +++ b/doc/user/admin_area/moderate_users.md @@ -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. diff --git a/doc/user/project/import/manifest.md b/doc/user/project/import/manifest.md index 18055e95bfb..ea26613639d 100644 --- a/doc/user/project/import/manifest.md +++ b/doc/user/project/import/manifest.md @@ -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 diff --git a/doc/user/project/issues/csv_export.md b/doc/user/project/issues/csv_export.md index 2e945402d7f..83265d3e954 100644 --- a/doc/user/project/issues/csv_export.md +++ b/doc/user/project/issues/csv_export.md @@ -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 --> diff --git a/lib/gitlab/ci/variables/collection.rb b/lib/gitlab/ci/variables/collection.rb index 52673d03e69..b6d6e1a3e5f 100644 --- a/lib/gitlab/ci/variables/collection.rb +++ b/lib/gitlab/ci/variables/collection.rb @@ -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 diff --git a/lib/gitlab/database/partitioning/convert_table_to_first_list_partition.rb b/lib/gitlab/database/partitioning/convert_table_to_first_list_partition.rb index 4c8c7f26fe2..23a8dc0b44f 100644 --- a/lib/gitlab/database/partitioning/convert_table_to_first_list_partition.rb +++ b/lib/gitlab/database/partitioning/convert_table_to_first_list_partition.rb @@ -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 diff --git a/lib/gitlab/import_export/uploads_manager.rb b/lib/gitlab/import_export/uploads_manager.rb index ad19508fb99..bc0563729a7 100644 --- a/lib/gitlab/import_export/uploads_manager.rb +++ b/lib/gitlab/import_export/uploads_manager.rb @@ -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 diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 6a40f2b7142..359e6b8ea8d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/qa/qa/page/group/show.rb b/qa/qa/page/group/show.rb index b057a27fa3e..a30d489e6ff 100644 --- a/qa/qa/page/group/show.rb +++ b/qa/qa/page/group/show.rb @@ -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 diff --git a/scripts/glfm/verify-all-generated-files-are-up-to-date.rb b/scripts/glfm/verify-all-generated-files-are-up-to-date.rb new file mode 100755 index 00000000000..7710997e3ed --- /dev/null +++ b/scripts/glfm/verify-all-generated-files-are-up-to-date.rb @@ -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 diff --git a/scripts/lib/glfm/constants.rb b/scripts/lib/glfm/constants.rb index 352bd867a61..d020d2fec5c 100644 --- a/scripts/lib/glfm/constants.rb +++ b/scripts/lib/glfm/constants.rb @@ -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 diff --git a/scripts/lib/glfm/verify_all_generated_files_are_up_to_date.rb b/scripts/lib/glfm/verify_all_generated_files_are_up_to_date.rb new file mode 100644 index 00000000000..0b824fc589d --- /dev/null +++ b/scripts/lib/glfm/verify_all_generated_files_are_up_to_date.rb @@ -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 diff --git a/spec/controllers/profiles/preferences_controller_spec.rb b/spec/controllers/profiles/preferences_controller_spec.rb index 7add3a72337..e2a216bb462 100644 --- a/spec/controllers/profiles/preferences_controller_spec.rb +++ b/spec/controllers/profiles/preferences_controller_spec.rb @@ -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!) diff --git a/spec/features/admin/admin_settings_spec.rb b/spec/features/admin/admin_settings_spec.rb index 9e7666b920f..94c5f397670 100644 --- a/spec/features/admin/admin_settings_spec.rb +++ b/spec/features/admin/admin_settings_spec.rb @@ -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 diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 54081e21a46..349ffd09324 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -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 diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 56529726350..091ec17d58e 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -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', () => { diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index 866868eff36..0cbb6cc8309 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -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, + }); }); }); }); diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index ec8559f1b56..067da25cb52 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -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, diff --git a/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js b/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js new file mode 100644 index 00000000000..c1e1545944b --- /dev/null +++ b/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js @@ -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', + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index 5788968100a..6622749da92 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -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 }) => { diff --git a/spec/frontend/webhooks/components/form_url_app_spec.js b/spec/frontend/webhooks/components/form_url_app_spec.js index 40de3cc0d33..16e0a3f549e 100644 --- a/spec/frontend/webhooks/components/form_url_app_spec.js +++ b/spec/frontend/webhooks/components/form_url_app_spec.js @@ -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); + }); + }); }); }); }); diff --git a/spec/frontend/webhooks/components/form_url_mask_item_spec.js b/spec/frontend/webhooks/components/form_url_mask_item_spec.js index 76681e6ab26..ab028ef2997 100644 --- a/spec/frontend/webhooks/components/form_url_mask_item_spec.js +++ b/spec/frontend/webhooks/components/form_url_mask_item_spec.js @@ -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]]); + }); + }); }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 3580842fc1a..aae61b11196 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -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(); diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js new file mode 100644 index 00000000000..08cdf62ae52 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_milestone_spec.js @@ -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', + }); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index a0ed4ed1425..ed90b11222a 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -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', + }, + }, +}; diff --git a/spec/helpers/hooks_helper_spec.rb b/spec/helpers/hooks_helper_spec.rb index bac73db5dd4..8f438a3ddc8 100644 --- a/spec/helpers/hooks_helper_spec.rb +++ b/spec/helpers/hooks_helper_spec.rb @@ -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' } diff --git a/spec/helpers/ide_helper_spec.rb b/spec/helpers/ide_helper_spec.rb index dc0a234f981..e750379f62d 100644 --- a/spec/helpers/ide_helper_spec.rb +++ b/spec/helpers/ide_helper_spec.rb @@ -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 '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 + 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 'when the project has environments' do - it 'disables environment guidance' do - create(:environment, project: project) + context 'environments guidance experiment', :experiment do + before do + stub_experiments(in_product_guidance_environments_webide: :candidate) + self.instance_variable_set(:@project, project) + end + + 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 - expect(helper.ide_data).to include('enable-environments-guidance' => 'false') + 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') + end end end end diff --git a/spec/lib/gitlab/ci/variables/collection_spec.rb b/spec/lib/gitlab/ci/variables/collection_spec.rb index 8ac03301322..7d4a1eef70b 100644 --- a/spec/lib/gitlab/ci/variables/collection_spec.rb +++ b/spec/lib/gitlab/ci/variables/collection_spec.rb @@ -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 diff --git a/spec/lib/gitlab/database/partitioning/convert_table_to_first_list_partition_spec.rb b/spec/lib/gitlab/database/partitioning/convert_table_to_first_list_partition_spec.rb index af7d751a404..0e804b4feac 100644 --- a/spec/lib/gitlab/database/partitioning/convert_table_to_first_list_partition_spec.rb +++ b/spec/lib/gitlab/database/partitioning/convert_table_to_first_list_partition_spec.rb @@ -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 diff --git a/spec/lib/gitlab/import_export/uploads_manager_spec.rb b/spec/lib/gitlab/import_export/uploads_manager_spec.rb index 0cfe3a69a09..5fc3a70169a 100644 --- a/spec/lib/gitlab/import_export/uploads_manager_spec.rb +++ b/spec/lib/gitlab/import_export/uploads_manager_spec.rb @@ -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 diff --git a/spec/migrations/20221013154159_update_invalid_dormant_user_setting_spec.rb b/spec/migrations/20221013154159_update_invalid_dormant_user_setting_spec.rb new file mode 100644 index 00000000000..eac71e428be --- /dev/null +++ b/spec/migrations/20221013154159_update_invalid_dormant_user_setting_spec.rb @@ -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 diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 687ffbe87bf..77bb6b502b5 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -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) } diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 751a303739c..b2316949497 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -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 diff --git a/spec/models/ci/variable_spec.rb b/spec/models/ci/variable_spec.rb index f0af229ff2c..5f2b5971508 100644 --- a/spec/models/ci/variable_spec.rb +++ b/spec/models/ci/variable_spec.rb @@ -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 diff --git a/spec/models/clusters/agents/implicit_authorization_spec.rb b/spec/models/clusters/agents/implicit_authorization_spec.rb index 2d6c3ddb426..1f4c5b1ac9e 100644 --- a/spec/models/clusters/agents/implicit_authorization_spec.rb +++ b/spec/models/clusters/agents/implicit_authorization_spec.rb @@ -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 diff --git a/spec/models/user_preference_spec.rb b/spec/models/user_preference_spec.rb index 029667a60b0..d76334d7c9e 100644 --- a/spec/models/user_preference_spec.rb +++ b/spec/models/user_preference_spec.rb @@ -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 diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 73ac4e7d3f2..8ebf3d70165 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -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 } diff --git a/spec/presenters/ci/build_runner_presenter_spec.rb b/spec/presenters/ci/build_runner_presenter_spec.rb index 33d1bc104ce..396fe7843ba 100644 --- a/spec/presenters/ci/build_runner_presenter_spec.rb +++ b/spec/presenters/ci/build_runner_presenter_spec.rb @@ -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) diff --git a/spec/requests/ide_controller_spec.rb b/spec/requests/ide_controller_spec.rb index 191fc2a6704..8d61399c824 100644 --- a/spec/requests/ide_controller_spec.rb +++ b/spec/requests/ide_controller_spec.rb @@ -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) + + subject + end - expect(response).to render_template(top_nav_partial) + 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 diff --git a/spec/scripts/lib/glfm/verify_all_generated_files_are_up_to_date_spec.rb b/spec/scripts/lib/glfm/verify_all_generated_files_are_up_to_date_spec.rb new file mode 100644 index 00000000000..fca037c9ff3 --- /dev/null +++ b/spec/scripts/lib/glfm/verify_all_generated_files_are_up_to_date_spec.rb @@ -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 diff --git a/spec/services/bulk_imports/uploads_export_service_spec.rb b/spec/services/bulk_imports/uploads_export_service_spec.rb index 39bcacfdc5e..ad6e005485c 100644 --- a/spec/services/bulk_imports/uploads_export_service_spec.rb +++ b/spec/services/bulk_imports/uploads_export_service_spec.rb @@ -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).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 } - 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) + include_examples 'export with invalid upload' + end + end end end end diff --git a/spec/services/ci/generate_kubeconfig_service_spec.rb b/spec/services/ci/generate_kubeconfig_service_spec.rb index e3088ca6ea7..bfde39780dd 100644 --- a/spec/services/ci/generate_kubeconfig_service_spec.rb +++ b/spec/services/ci/generate_kubeconfig_service_spec.rb @@ -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}" ) diff --git a/spec/support/cross_database_modification.rb b/spec/support/cross_database_modification.rb deleted file mode 100644 index e0d91001c03..00000000000 --- a/spec/support/cross_database_modification.rb +++ /dev/null @@ -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 diff --git a/spec/support/database/prevent_cross_database_modification.rb b/spec/support/database/prevent_cross_database_modification.rb index 19fbf902d87..759e8316cc5 100644 --- a/spec/support/database/prevent_cross_database_modification.rb +++ b/spec/support/database/prevent_cross_database_modification.rb @@ -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 diff --git a/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb b/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb index 91b6baac610..8a64efe9df5 100644 --- a/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb +++ b/spec/support/shared_contexts/finders/merge_requests_finder_shared_contexts.rb @@ -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 diff --git a/spec/tooling/quality/test_level_spec.rb b/spec/tooling/quality/test_level_spec.rb index f4eea28b66f..6084dc194da 100644 --- a/spec/tooling/quality/test_level_spec.rb +++ b/spec/tooling/quality/test_level_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -require 'spec_helper' +require 'fast_spec_helper' require_relative '../../../tooling/quality/test_level' |