diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-23 18:08:42 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-23 18:08:42 +0300 |
commit | 9086e66ee72527839053ec6db19ed321a3b3a61b (patch) | |
tree | f2904493d8539228823f15cf4126eb8c4ffa79e3 | |
parent | b17c74a7e2cf516ed189e525291cb096411b7ac5 (diff) |
Add latest changes from gitlab-org/gitlab@master
87 files changed, 2767 insertions, 574 deletions
diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 46f202216f2..badd2d86bf4 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -71d527f4f16c1f0e76793f055def0299b375cc7d +2c750a230dae024d4f59a85c7dba66bac5546fe6 @@ -98,6 +98,7 @@ gem 'graphql', '~> 1.11.4' gem 'graphiql-rails', '~> 1.4.10' gem 'apollo_upload_server', '~> 2.0.2' gem 'graphql-docs', '~> 1.6.0', group: [:development, :test] +gem 'graphlient', '~> 0.4.0' # Used by BulkImport feature (group::import) gem 'hashie' # Disable strong_params so that Mash does not respond to :permitted? diff --git a/Gemfile.lock b/Gemfile.lock index 4d6496fdcf0..01ae48cb172 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -506,7 +506,14 @@ GEM graphiql-rails (1.4.10) railties sprockets-rails + graphlient (0.4.0) + faraday (>= 1.0) + faraday_middleware + graphql-client graphql (1.11.4) + graphql-client (0.16.0) + activesupport (>= 3.0) + graphql (~> 1.8) graphql-docs (1.6.0) commonmarker (~> 0.16) escape_utils (~> 1.2) @@ -1353,6 +1360,7 @@ DEPENDENCIES grape-path-helpers (~> 1.4) grape_logging (~> 1.7) graphiql-rails (~> 1.4.10) + graphlient (~> 0.4.0) graphql (~> 1.11.4) graphql-docs (~> 1.6.0) grpc (~> 1.30.2) diff --git a/README.md b/README.md index 38ecb9ea612..03c2a709bee 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ The canonical source of GitLab where all development takes place is [hosted on GitLab.com](https://gitlab.com/gitlab-org/gitlab). -If you wish to clone a copy of GitLab without proprietary code, you can use the read-only mirror of GitLab located at https://gitlab.com/gitlab-org/gitlab-foss/. Please do not submit any issues and/or merge requests to this project. +If you wish to clone a copy of GitLab without proprietary code, you can use the read-only mirror of GitLab located at https://gitlab.com/gitlab-org/gitlab-foss/. However, please do not submit any issues and/or merge requests to that project. ## Free trial diff --git a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue index 0094b4f8707..ac8eb6ba8f9 100644 --- a/app/assets/javascripts/diffs/components/diff_expansion_cell.vue +++ b/app/assets/javascripts/diffs/components/diff_expansion_cell.vue @@ -6,7 +6,6 @@ import { s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { UNFOLD_COUNT, INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '../constants'; import * as utils from '../store/utils'; -import tooltip from '../../vue_shared/directives/tooltip'; const EXPAND_ALL = 0; const EXPAND_UP = 1; @@ -28,9 +27,6 @@ const i18n = { export default { i18n, - directives: { - tooltip, - }, components: { GlIcon, }, diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue new file mode 100644 index 00000000000..fad61b95124 --- /dev/null +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -0,0 +1,216 @@ +<script> +import { + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + GlIcon, +} from '@gitlab/ui'; +import { debounce, isEqual } from 'lodash'; +import { mapActions, mapGetters, mapState } from 'vuex'; +import { s__, __, sprintf } from '~/locale'; +import createStore from '../stores'; +import MilestoneResultsSection from './milestone_results_section.vue'; + +const SEARCH_DEBOUNCE_MS = 250; + +export default { + name: 'MilestoneCombobox', + store: createStore(), + components: { + GlDropdown, + GlDropdownDivider, + GlDropdownSectionHeader, + GlDropdownItem, + GlLoadingIcon, + GlSearchBoxByType, + GlIcon, + MilestoneResultsSection, + }, + props: { + value: { + type: Array, + required: false, + default: () => [], + }, + projectId: { + type: String, + required: true, + }, + extraLinks: { + type: Array, + default: () => [], + required: false, + }, + }, + data() { + return { + searchQuery: '', + }; + }, + translations: { + milestone: s__('MilestoneCombobox|Milestone'), + selectMilestone: s__('MilestoneCombobox|Select milestone'), + noMilestone: s__('MilestoneCombobox|No milestone'), + noResultsLabel: s__('MilestoneCombobox|No matching results'), + searchMilestones: s__('MilestoneCombobox|Search Milestones'), + searhErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'), + projectMilestones: s__('MilestoneCombobox|Project milestones'), + }, + computed: { + ...mapState(['matches', 'selectedMilestones']), + ...mapGetters(['isLoading']), + selectedMilestonesLabel() { + const { selectedMilestones } = this; + const firstMilestoneName = selectedMilestones[0]; + + if (selectedMilestones.length === 0) { + return this.$options.translations.noMilestone; + } + + if (selectedMilestones.length === 1) { + return firstMilestoneName; + } + + const numberOfOtherMilestones = selectedMilestones.length - 1; + return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), { + firstMilestoneName, + numberOfOtherMilestones, + }); + }, + showProjectMilestoneSection() { + return Boolean( + this.matches.projectMilestones.totalCount > 0 || this.matches.projectMilestones.error, + ); + }, + showNoResults() { + return !this.showProjectMilestoneSection; + }, + }, + watch: { + // Keep the Vuex store synchronized if the parent + // component updates the selected milestones through v-model + value: { + immediate: true, + handler() { + const milestoneTitles = this.value.map(milestone => + milestone.title ? milestone.title : milestone, + ); + if (!isEqual(milestoneTitles, this.selectedMilestones)) { + this.setSelectedMilestones(milestoneTitles); + } + }, + }, + }, + created() { + // This method is defined here instead of in `methods` + // because we need to access the .cancel() method + // lodash attaches to the function, which is + // made inaccessible by Vue. More info: + // https://stackoverflow.com/a/52988020/1063392 + this.debouncedSearch = debounce(function search() { + this.search(this.searchQuery); + }, SEARCH_DEBOUNCE_MS); + + this.setProjectId(this.projectId); + this.fetchMilestones(); + }, + methods: { + ...mapActions([ + 'setProjectId', + 'setSelectedMilestones', + 'clearSelectedMilestones', + 'toggleMilestones', + 'search', + 'fetchMilestones', + ]), + focusSearchBox() { + this.$refs.searchBox.$el.querySelector('input').focus(); + }, + onSearchBoxEnter() { + this.debouncedSearch.cancel(); + this.search(this.searchQuery); + }, + onSearchBoxInput() { + this.debouncedSearch(); + }, + selectMilestone(milestone) { + this.toggleMilestones(milestone); + this.$emit('input', this.selectedMilestones); + }, + selectNoMilestone() { + this.clearSelectedMilestones(); + this.$emit('input', this.selectedMilestones); + }, + }, +}; +</script> + +<template> + <gl-dropdown v-bind="$attrs" class="milestone-combobox" @shown="focusSearchBox"> + <template slot="button-content"> + <span data-testid="milestone-combobox-button-content" class="gl-flex-grow-1 text-muted">{{ + selectedMilestonesLabel + }}</span> + <gl-icon name="chevron-down" /> + </template> + + <gl-dropdown-section-header> + <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span> + </gl-dropdown-section-header> + + <gl-dropdown-divider /> + + <gl-search-box-by-type + ref="searchBox" + v-model.trim="searchQuery" + class="gl-m-3" + :placeholder="this.$options.translations.searchMilestones" + @input="onSearchBoxInput" + @keydown.enter.prevent="onSearchBoxEnter" + /> + + <gl-dropdown-item @click="selectNoMilestone()"> + <span :class="{ 'gl-pl-6': true, 'selected-item': selectedMilestones.length === 0 }"> + {{ $options.translations.noMilestone }} + </span> + </gl-dropdown-item> + + <gl-dropdown-divider /> + + <template v-if="isLoading"> + <gl-loading-icon /> + <gl-dropdown-divider /> + </template> + <template v-else-if="showNoResults"> + <div class="dropdown-item-space"> + <span data-testid="milestone-combobox-no-results" class="gl-pl-6">{{ + $options.translations.noResultsLabel + }}</span> + </div> + <gl-dropdown-divider /> + </template> + <template v-else> + <milestone-results-section + :section-title="$options.translations.projectMilestones" + :total-count="matches.projectMilestones.totalCount" + :items="matches.projectMilestones.list" + :selected-milestones="selectedMilestones" + :error="matches.projectMilestones.error" + :error-message="$options.translations.searhErrorMessage" + data-testid="project-milestones-section" + @selected="selectMilestone($event)" + /> + </template> + <gl-dropdown-item + v-for="(item, idx) in extraLinks" + :key="idx" + :href="item.url" + data-testid="milestone-combobox-extra-links" + > + <span class="gl-pl-6">{{ item.text }}</span> + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/milestones/components/milestone_results_section.vue b/app/assets/javascripts/milestones/components/milestone_results_section.vue new file mode 100644 index 00000000000..d53a59e58d4 --- /dev/null +++ b/app/assets/javascripts/milestones/components/milestone_results_section.vue @@ -0,0 +1,93 @@ +<script> +import { + GlDropdownSectionHeader, + GlDropdownDivider, + GlDropdownItem, + GlBadge, + GlIcon, +} from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'MilestoneResultsSection', + components: { + GlDropdownSectionHeader, + GlDropdownDivider, + GlDropdownItem, + GlBadge, + GlIcon, + }, + props: { + sectionTitle: { + type: String, + required: true, + }, + totalCount: { + type: Number, + required: true, + }, + items: { + type: Array, + required: true, + }, + selectedMilestones: { + type: Array, + required: true, + default: () => [], + }, + error: { + type: Error, + required: false, + default: null, + }, + errorMessage: { + type: String, + required: false, + default: '', + }, + }, + computed: { + totalCountText() { + return this.totalCount > 999 ? s__('TotalMilestonesIndicator|1000+') : `${this.totalCount}`; + }, + }, + methods: { + isSelectedMilestone(item) { + return this.selectedMilestones.includes(item); + }, + }, +}; +</script> + +<template> + <div> + <gl-dropdown-section-header> + <div + class="gl-display-flex gl-align-items-center gl-pl-6" + data-testid="milestone-results-section-header" + > + <span class="gl-mr-2 gl-mb-1">{{ sectionTitle }}</span> + <gl-badge variant="neutral">{{ totalCountText }}</gl-badge> + </div> + </gl-dropdown-section-header> + <template v-if="error"> + <div class="gl-display-flex align-items-start gl-text-red-500 gl-ml-4 gl-mr-4 gl-mb-3"> + <gl-icon name="error" class="gl-mr-2 gl-mt-2 gl-flex-shrink-0" /> + <span>{{ errorMessage }}</span> + </div> + </template> + <template v-else> + <gl-dropdown-item + v-for="{ title } in items" + :key="title" + role="milestone option" + @click="$emit('selected', title)" + > + <span class="gl-pl-6" :class="{ 'selected-item': isSelectedMilestone(title) }"> + {{ title }} + </span> + </gl-dropdown-item> + <gl-dropdown-divider /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/milestones/project_milestone_combobox.vue b/app/assets/javascripts/milestones/project_milestone_combobox.vue deleted file mode 100644 index 0fa5585e858..00000000000 --- a/app/assets/javascripts/milestones/project_milestone_combobox.vue +++ /dev/null @@ -1,249 +0,0 @@ -<script> -import { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIcon, -} from '@gitlab/ui'; -import { intersection, debounce } from 'lodash'; -import { __, sprintf } from '~/locale'; -import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; - -const SEARCH_DEBOUNCE_MS = 250; - -export default { - components: { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIcon, - }, - model: { - prop: 'preselectedMilestones', - event: 'change', - }, - props: { - projectId: { - type: String, - required: true, - }, - preselectedMilestones: { - type: Array, - default: () => [], - required: false, - }, - extraLinks: { - type: Array, - default: () => [], - required: false, - }, - }, - data() { - return { - searchQuery: '', - projectMilestones: [], - searchResults: [], - selectedMilestones: [], - requestCount: 0, - }; - }, - translations: { - milestone: __('Milestone'), - selectMilestone: __('Select milestone'), - noMilestone: __('No milestone'), - noResultsLabel: __('No matching results'), - searchMilestones: __('Search Milestones'), - }, - computed: { - selectedMilestonesLabel() { - if (this.milestoneTitles.length === 1) { - return this.milestoneTitles[0]; - } - - if (this.milestoneTitles.length > 1) { - const firstMilestoneName = this.milestoneTitles[0]; - const numberOfOtherMilestones = this.milestoneTitles.length - 1; - return sprintf(__('%{firstMilestoneName} + %{numberOfOtherMilestones} more'), { - firstMilestoneName, - numberOfOtherMilestones, - }); - } - - return this.$options.translations.noMilestone; - }, - milestoneTitles() { - return this.preselectedMilestones.map(milestone => milestone.title); - }, - dropdownItems() { - return this.searchResults.length ? this.searchResults : this.projectMilestones; - }, - noResults() { - return this.searchQuery.length > 2 && this.searchResults.length === 0; - }, - isLoading() { - return this.requestCount !== 0; - }, - }, - created() { - // This method is defined here instead of in `methods` - // because we need to access the .cancel() method - // lodash attaches to the function, which is - // made inaccessible by Vue. More info: - // https://stackoverflow.com/a/52988020/1063392 - this.debouncedSearchMilestones = debounce(this.searchMilestones, SEARCH_DEBOUNCE_MS); - }, - mounted() { - this.fetchMilestones(); - }, - methods: { - focusSearchBox() { - this.$refs.searchBox.$el.querySelector('input').focus(); - }, - fetchMilestones() { - this.requestCount += 1; - - Api.projectMilestones(this.projectId) - .then(({ data }) => { - this.projectMilestones = this.getTitles(data); - this.selectedMilestones = intersection(this.projectMilestones, this.milestoneTitles); - }) - .catch(() => { - createFlash(__('An error occurred while loading milestones')); - }) - .finally(() => { - this.requestCount -= 1; - }); - }, - searchMilestones() { - this.requestCount += 1; - const options = { - search: this.searchQuery, - scope: 'milestones', - }; - - if (this.searchQuery.length < 3) { - this.requestCount -= 1; - this.searchResults = []; - return; - } - - Api.projectSearch(this.projectId, options) - .then(({ data }) => { - const searchResults = this.getTitles(data); - - this.searchResults = searchResults.length ? searchResults : []; - }) - .catch(() => { - createFlash(__('An error occurred while searching for milestones')); - }) - .finally(() => { - this.requestCount -= 1; - }); - }, - onSearchBoxInput() { - this.debouncedSearchMilestones(); - }, - onSearchBoxEnter() { - this.debouncedSearchMilestones.cancel(); - this.searchMilestones(); - }, - toggleMilestoneSelection(clickedMilestone) { - if (!clickedMilestone) return []; - - let milestones = [...this.preselectedMilestones]; - const hasMilestone = this.milestoneTitles.includes(clickedMilestone); - - if (hasMilestone) { - milestones = milestones.filter(({ title }) => title !== clickedMilestone); - } else { - milestones.push({ title: clickedMilestone }); - } - - return milestones; - }, - onMilestoneClicked(clickedMilestone) { - const milestones = this.toggleMilestoneSelection(clickedMilestone); - this.$emit('change', milestones); - - this.selectedMilestones = intersection( - this.projectMilestones, - milestones.map(milestone => milestone.title), - ); - }, - isSelectedMilestone(milestoneTitle) { - return this.selectedMilestones.includes(milestoneTitle); - }, - getTitles(milestones) { - return milestones.filter(({ state }) => state === 'active').map(({ title }) => title); - }, - }, -}; -</script> - -<template> - <gl-dropdown v-bind="$attrs" class="project-milestone-combobox" @shown="focusSearchBox"> - <template slot="button-content"> - <span ref="buttonText" class="flex-grow-1 ml-1 text-muted">{{ - selectedMilestonesLabel - }}</span> - <gl-icon name="chevron-down" /> - </template> - - <gl-dropdown-section-header> - <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span> - </gl-dropdown-section-header> - - <gl-dropdown-divider /> - - <gl-search-box-by-type - ref="searchBox" - v-model.trim="searchQuery" - :placeholder="this.$options.translations.searchMilestones" - @input="onSearchBoxInput" - @keydown.enter.prevent="onSearchBoxEnter" - /> - - <gl-dropdown-item @click="onMilestoneClicked(null)"> - <span :class="{ 'pl-4': true, 'selected-item': selectedMilestones.length === 0 }"> - {{ $options.translations.noMilestone }} - </span> - </gl-dropdown-item> - - <gl-dropdown-divider /> - - <template v-if="isLoading"> - <gl-loading-icon /> - <gl-dropdown-divider /> - </template> - <template v-else-if="noResults"> - <div class="dropdown-item-space"> - <span ref="noResults" class="pl-4">{{ $options.translations.noResultsLabel }}</span> - </div> - <gl-dropdown-divider /> - </template> - <template v-else-if="dropdownItems.length"> - <gl-dropdown-item - v-for="item in dropdownItems" - :key="item" - role="milestone option" - @click="onMilestoneClicked(item)" - > - <span :class="{ 'pl-4': true, 'selected-item': isSelectedMilestone(item) }"> - {{ item }} - </span> - </gl-dropdown-item> - <gl-dropdown-divider /> - </template> - - <gl-dropdown-item v-for="(item, idx) in extraLinks" :key="idx" :href="item.url"> - <span class="pl-4">{{ item.text }}</span> - </gl-dropdown-item> - </gl-dropdown> -</template> diff --git a/app/assets/javascripts/milestones/stores/actions.js b/app/assets/javascripts/milestones/stores/actions.js index 3859771aeba..56a07562f62 100644 --- a/app/assets/javascripts/milestones/stores/actions.js +++ b/app/assets/javascripts/milestones/stores/actions.js @@ -6,6 +6,8 @@ export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ export const setSelectedMilestones = ({ commit }, selectedMilestones) => commit(types.SET_SELECTED_MILESTONES, selectedMilestones); +export const clearSelectedMilestones = ({ commit }) => commit(types.CLEAR_SELECTED_MILESTONES); + export const toggleMilestones = ({ commit, state }, selectedMilestone) => { const removeMilestone = state.selectedMilestones.includes(selectedMilestone); @@ -16,8 +18,8 @@ export const toggleMilestones = ({ commit, state }, selectedMilestone) => { } }; -export const search = ({ dispatch, commit }, query) => { - commit(types.SET_QUERY, query); +export const search = ({ dispatch, commit }, searchQuery) => { + commit(types.SET_SEARCH_QUERY, searchQuery); dispatch('searchMilestones'); }; @@ -41,7 +43,7 @@ export const searchMilestones = ({ commit, state }) => { commit(types.REQUEST_START); const options = { - search: state.query, + search: state.searchQuery, scope: 'milestones', }; diff --git a/app/assets/javascripts/milestones/stores/mutation_types.js b/app/assets/javascripts/milestones/stores/mutation_types.js index 370d386dba2..6c58fca9dca 100644 --- a/app/assets/javascripts/milestones/stores/mutation_types.js +++ b/app/assets/javascripts/milestones/stores/mutation_types.js @@ -1,10 +1,11 @@ export const SET_PROJECT_ID = 'SET_PROJECT_ID'; export const SET_SELECTED_MILESTONES = 'SET_SELECTED_MILESTONES'; +export const CLEAR_SELECTED_MILESTONES = 'CLEAR_SELECTED_MILESTONES'; export const ADD_SELECTED_MILESTONE = 'ADD_SELECTED_MILESTONE'; export const REMOVE_SELECTED_MILESTONE = 'REMOVE_SELECTED_MILESTONE'; -export const SET_QUERY = 'SET_QUERY'; +export const SET_SEARCH_QUERY = 'SET_SEARCH_QUERY'; export const REQUEST_START = 'REQUEST_START'; export const REQUEST_FINISH = 'REQUEST_FINISH'; diff --git a/app/assets/javascripts/milestones/stores/mutations.js b/app/assets/javascripts/milestones/stores/mutations.js index 7c75d09766c..71331965d2a 100644 --- a/app/assets/javascripts/milestones/stores/mutations.js +++ b/app/assets/javascripts/milestones/stores/mutations.js @@ -9,6 +9,9 @@ export default { [types.SET_SELECTED_MILESTONES](state, selectedMilestones) { Vue.set(state, 'selectedMilestones', selectedMilestones); }, + [types.CLEAR_SELECTED_MILESTONES](state) { + Vue.set(state, 'selectedMilestones', []); + }, [types.ADD_SELECTED_MILESTONE](state, selectedMilestone) { state.selectedMilestones.push(selectedMilestone); }, @@ -18,8 +21,8 @@ export default { ); Vue.set(state, 'selectedMilestones', filteredMilestones); }, - [types.SET_QUERY](state, query) { - state.query = query; + [types.SET_SEARCH_QUERY](state, searchQuery) { + state.searchQuery = searchQuery; }, [types.REQUEST_START](state) { state.requestCount += 1; diff --git a/app/assets/javascripts/milestones/stores/state.js b/app/assets/javascripts/milestones/stores/state.js index 0944539f367..8466228dc17 100644 --- a/app/assets/javascripts/milestones/stores/state.js +++ b/app/assets/javascripts/milestones/stores/state.js @@ -1,7 +1,7 @@ export default () => ({ projectId: null, groupId: null, - query: '', + searchQuery: '', matches: { projectMilestones: { list: [], diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index 1a07e0ed762..c3582cf04dc 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -6,7 +6,7 @@ import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import { getParameterByName } from '~/lib/utils/common_utils'; import AssetLinksForm from './asset_links_form.vue'; -import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; +import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; import TagField from './tag_field.vue'; export default { diff --git a/app/assets/javascripts/releases/util.js b/app/assets/javascripts/releases/util.js index 445c429fd96..ac88bd7e291 100644 --- a/app/assets/javascripts/releases/util.js +++ b/app/assets/javascripts/releases/util.js @@ -15,15 +15,13 @@ import { export const releaseToApiJson = (release, createFrom = null) => { const name = release.name?.trim().length > 0 ? release.name.trim() : null; - const milestones = release.milestones ? release.milestones.map(milestone => milestone.title) : []; - return convertObjectPropsToSnakeCase( { name, tagName: release.tagName, ref: createFrom, description: release.description, - milestones, + milestones: release.milestones, assets: release.assets, }, { deep: true }, diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index cb2922c2d47..d4d85ef6bf4 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -6,13 +6,13 @@ class Import::BulkImportsController < ApplicationController feature_category :importers - rescue_from Gitlab::BulkImport::Client::ConnectionError, with: :bulk_import_connection_error + rescue_from BulkImports::Clients::Http::ConnectionError, with: :bulk_import_connection_error def configure - session[access_token_key] = params[access_token_key]&.strip - session[url_key] = params[url_key] + session[access_token_key] = configure_params[access_token_key]&.strip + session[url_key] = configure_params[url_key] - redirect_to status_import_bulk_import_url + redirect_to status_import_bulk_imports_url end def status @@ -25,6 +25,12 @@ class Import::BulkImportsController < ApplicationController end end + def create + BulkImportService.new(current_user, create_params, credentials).execute + + render json: :ok + end + private def serialized_importable_data @@ -40,16 +46,30 @@ class Import::BulkImportsController < ApplicationController end def client - @client ||= Gitlab::BulkImport::Client.new( + @client ||= BulkImports::Clients::Http.new( uri: session[url_key], token: session[access_token_key] ) end - def import_params + def configure_params params.permit(access_token_key, url_key) end + def create_params + params.permit(:bulk_import, [*bulk_import_params]) + end + + def bulk_import_params + %i[ + source_type + source_name + source_full_path + destination_name + destination_namespace + ] + end + def ensure_group_import_enabled render_404 unless Feature.enabled?(:bulk_import) end @@ -106,4 +126,11 @@ class Import::BulkImportsController < ApplicationController session[url_key] = nil session[access_token_key] = nil end + + def credentials + { + url: session[url_key], + access_token: [access_token_key] + } + end end diff --git a/app/graphql/resolvers/alert_management/alert_resolver.rb b/app/graphql/resolvers/alert_management/alert_resolver.rb index dc9b1dbb5f4..c3219d9cdc3 100644 --- a/app/graphql/resolvers/alert_management/alert_resolver.rb +++ b/app/graphql/resolvers/alert_management/alert_resolver.rb @@ -19,7 +19,7 @@ module Resolvers required: false argument :search, GraphQL::STRING_TYPE, - description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.', + description: 'Search query for title, description, service, or monitoring_tool.', required: false argument :assignee_username, GraphQL::STRING_TYPE, diff --git a/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb index 96ea4610aff..8fc0f9fd1ff 100644 --- a/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb +++ b/app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb @@ -6,7 +6,7 @@ module Resolvers type Types::AlertManagement::AlertStatusCountsType, null: true argument :search, GraphQL::STRING_TYPE, - description: 'Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.', + description: 'Search query for title, description, service, or monitoring_tool.', required: false argument :assignee_username, GraphQL::STRING_TYPE, diff --git a/app/models/bulk_import.rb b/app/models/bulk_import.rb index c922b232046..5d646313423 100644 --- a/app/models/bulk_import.rb +++ b/app/models/bulk_import.rb @@ -15,5 +15,20 @@ class BulkImport < ApplicationRecord state_machine :status, initial: :created do state :created, value: 0 + state :started, value: 1 + state :finished, value: 2 + state :failed, value: -1 + + event :start do + transition created: :started + end + + event :finish do + transition started: :finished + end + + event :fail_op do + transition any => :failed + end end end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 2c4eb32866d..80963ff3304 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -38,6 +38,21 @@ class BulkImports::Entity < ApplicationRecord state_machine :status, initial: :created do state :created, value: 0 + state :started, value: 1 + state :finished, value: 2 + state :failed, value: -1 + + event :start do + transition created: :started + end + + event :finish do + transition started: :finished + end + + event :fail_op do + transition any => :failed + end end private diff --git a/app/services/bulk_import_service.rb b/app/services/bulk_import_service.rb new file mode 100644 index 00000000000..1a1eef092f3 --- /dev/null +++ b/app/services/bulk_import_service.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class BulkImportService + attr_reader :current_user, :params, :credentials + + def initialize(current_user, params, credentials) + @current_user = current_user + @params = params + @credentials = credentials + end + + def execute + bulk_import = create_bulk_import + bulk_import.start! + + BulkImportWorker.perform_async(bulk_import.id) + end + + private + + def create_bulk_import + BulkImport.transaction do + bulk_import = BulkImport.create!(user: current_user, source_type: 'gitlab') + bulk_import.create_configuration!(credentials.slice(:url, :access_token)) + + params.each do |entity| + BulkImports::Entity.create!( + bulk_import: bulk_import, + source_type: entity[:source_type], + source_full_path: entity[:source_full_path], + destination_name: entity[:destination_name], + destination_namespace: entity[:destination_namespace] + ) + end + + bulk_import + end + end +end diff --git a/app/views/import/_project_status.html.haml b/app/views/import/_project_status.html.haml deleted file mode 100644 index b968db58d38..00000000000 --- a/app/views/import/_project_status.html.haml +++ /dev/null @@ -1,11 +0,0 @@ -- case project.import_status -- when 'finished' - = icon('check') - = _('Done') -- when 'started' - = loading_icon - = _('Started') -- when 'failed' - = _('Failed') -- else - = project.human_import_status_name diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index 30b89f37562..6aded5b8d4b 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -1312,6 +1312,14 @@ :weight: 1 :idempotent: :tags: [] +- :name: bulk_import + :feature_category: :importers + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: + :tags: [] - :name: chat_notification :feature_category: :chatops :has_external_dependencies: true diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb new file mode 100644 index 00000000000..df845a2ca37 --- /dev/null +++ b/app/workers/bulk_import_worker.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker + include ApplicationWorker + + feature_category :importers + + sidekiq_options retry: false, dead: false + + worker_has_external_dependencies! + + def perform(bulk_import_id) + bulk_import = BulkImport.find_by_id(bulk_import_id) + + return unless bulk_import + + bulk_import.entities.each do |entity| + entity.start! + + BulkImports::Importers::GroupImporter.new(entity.id).execute + + entity.finish! + end + + bulk_import.finish! + end +end diff --git a/changelogs/unreleased/232569-update-the-milestone-dropdown-combobox-to-display-separated-sectio.yml b/changelogs/unreleased/232569-update-the-milestone-dropdown-combobox-to-display-separated-sectio.yml new file mode 100644 index 00000000000..238ce2c9cc0 --- /dev/null +++ b/changelogs/unreleased/232569-update-the-milestone-dropdown-combobox-to-display-separated-sectio.yml @@ -0,0 +1,6 @@ +--- +title: Update the milestone dropdown combobox to display separated sections + and badge counters +merge_request: 43427 +author: +type: added diff --git a/changelogs/unreleased/241267-add-postgres-partition-model.yml b/changelogs/unreleased/241267-add-postgres-partition-model.yml new file mode 100644 index 00000000000..b11bbdb4849 --- /dev/null +++ b/changelogs/unreleased/241267-add-postgres-partition-model.yml @@ -0,0 +1,5 @@ +--- +title: Add database view for partitions +merge_request: 45592 +author: +type: other diff --git a/changelogs/unreleased/dast-site-validation-create-mutation-233020.yml b/changelogs/unreleased/dast-site-validation-create-mutation-233020.yml new file mode 100644 index 00000000000..4c2759b5f2b --- /dev/null +++ b/changelogs/unreleased/dast-site-validation-create-mutation-233020.yml @@ -0,0 +1,5 @@ +--- +title: Add db index for DastSiteValidation#state +merge_request: 45019 +author: +type: added diff --git a/config/routes/import.rb b/config/routes/import.rb index 3ee44aa8659..557d7fe7143 100644 --- a/config/routes/import.rb +++ b/config/routes/import.rb @@ -69,7 +69,7 @@ namespace :import do post :authorize end - resource :bulk_import, only: [:create] do + resource :bulk_imports, only: [:create] do post :configure get :status end diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index f061efeb427..423cc43d712 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -44,6 +44,8 @@ - 3 - - background_migration - 1 +- - bulk_import + - 1 - - chaos - 2 - - chat_notification diff --git a/db/migrate/20201015194852_add_index_on_state_for_dast_site_validations.rb b/db/migrate/20201015194852_add_index_on_state_for_dast_site_validations.rb new file mode 100644 index 00000000000..785d7c8829c --- /dev/null +++ b/db/migrate/20201015194852_add_index_on_state_for_dast_site_validations.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddIndexOnStateForDastSiteValidations < ActiveRecord::Migration[6.0] + include Gitlab::Database::MigrationHelpers + + OLD_INDEX_NAME = 'index_dast_site_validations_on_url_base' + NEW_INDEX_NAME = 'index_dast_site_validations_on_url_base_and_state' + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index :dast_site_validations, [:url_base, :state], name: NEW_INDEX_NAME + remove_concurrent_index_by_name :dast_site_validations, OLD_INDEX_NAME + end + + def down + add_concurrent_index :dast_site_validations, :url_base, name: OLD_INDEX_NAME + remove_concurrent_index_by_name :dast_site_validations, NEW_INDEX_NAME + end +end diff --git a/db/migrate/20201019172704_add_partitions_view.rb b/db/migrate/20201019172704_add_partitions_view.rb new file mode 100644 index 00000000000..991d137faf3 --- /dev/null +++ b/db/migrate/20201019172704_add_partitions_view.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +class AddPartitionsView < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def up + execute(<<~SQL) + CREATE OR REPLACE VIEW postgres_partitions AS + SELECT + pg_namespace.nspname::text || '.'::text || pg_class.relname::text AS identifier, + pg_class.oid AS oid, + pg_namespace.nspname AS schema, + pg_class.relname AS name, + parent_namespace.nspname::text || '.'::text || parent_class.relname::text AS parent_identifier, + pg_get_expr(pg_class.relpartbound, pg_inherits.inhrelid) AS condition + FROM pg_class + INNER JOIN pg_namespace + ON pg_namespace.oid = pg_class.relnamespace + INNER JOIN pg_inherits + ON pg_class.oid = pg_inherits.inhrelid + INNER JOIN pg_class parent_class + ON pg_inherits.inhparent = parent_class.oid + INNER JOIN pg_namespace parent_namespace + ON parent_class.relnamespace = parent_namespace.oid + WHERE pg_class.relispartition + AND pg_namespace.nspname IN ( + current_schema(), + 'gitlab_partitions_dynamic', + 'gitlab_partitions_static' + ) + SQL + end + + def down + execute(<<~SQL) + DROP VIEW IF EXISTS postgres_partitions + SQL + end +end diff --git a/db/schema_migrations/20201015194852 b/db/schema_migrations/20201015194852 new file mode 100644 index 00000000000..665135a7e3e --- /dev/null +++ b/db/schema_migrations/20201015194852 @@ -0,0 +1 @@ +1b12f68f7d8c56ecdd7d6d7551d81f5d95d1b4dc5f8d4c67eb239b8640128531
\ No newline at end of file diff --git a/db/schema_migrations/20201019172704 b/db/schema_migrations/20201019172704 new file mode 100644 index 00000000000..0bf2d30205f --- /dev/null +++ b/db/schema_migrations/20201019172704 @@ -0,0 +1 @@ +2a426e1a7cc6283e777667a1b4a6987f011c6cfd189ec702abc55d13a8499eca
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 6691aa9c5b5..82067d906a2 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -14673,6 +14673,20 @@ CREATE VIEW postgres_partitioned_tables AS ELSE NULL::text END; +CREATE VIEW postgres_partitions AS + SELECT (((pg_namespace.nspname)::text || '.'::text) || (pg_class.relname)::text) AS identifier, + pg_class.oid, + pg_namespace.nspname AS schema, + pg_class.relname AS name, + (((parent_namespace.nspname)::text || '.'::text) || (parent_class.relname)::text) AS parent_identifier, + pg_get_expr(pg_class.relpartbound, pg_inherits.inhrelid) AS condition + FROM ((((pg_class + JOIN pg_namespace ON ((pg_namespace.oid = pg_class.relnamespace))) + JOIN pg_inherits ON ((pg_class.oid = pg_inherits.inhrelid))) + JOIN pg_class parent_class ON ((pg_inherits.inhparent = parent_class.oid))) + JOIN pg_namespace parent_namespace ON ((parent_class.relnamespace = parent_namespace.oid))) + WHERE (pg_class.relispartition AND (pg_namespace.nspname = ANY (ARRAY["current_schema"(), 'gitlab_partitions_dynamic'::name, 'gitlab_partitions_static'::name]))); + CREATE TABLE postgres_reindex_actions ( id bigint NOT NULL, action_start timestamp with time zone NOT NULL, @@ -20334,7 +20348,7 @@ CREATE INDEX index_dast_site_tokens_on_project_id ON dast_site_tokens USING btre CREATE INDEX index_dast_site_validations_on_dast_site_token_id ON dast_site_validations USING btree (dast_site_token_id); -CREATE INDEX index_dast_site_validations_on_url_base ON dast_site_validations USING btree (url_base); +CREATE INDEX index_dast_site_validations_on_url_base_and_state ON dast_site_validations USING btree (url_base, state); CREATE INDEX index_dast_sites_on_dast_site_validation_id ON dast_sites USING btree (dast_site_validation_id); diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql index ff2bdd8b7b3..8c9d8713b27 100644 --- a/doc/api/graphql/reference/gitlab_schema.graphql +++ b/doc/api/graphql/reference/gitlab_schema.graphql @@ -4570,6 +4570,78 @@ Identifier of DastSiteToken scalar DastSiteTokenID """ +Autogenerated input type of DastSiteValidationCreate +""" +input DastSiteValidationCreateInput { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + ID of the site token. + """ + dastSiteTokenId: DastSiteTokenID! + + """ + The project the site profile belongs to. + """ + fullPath: ID! + + """ + The validation strategy to be used. + """ + strategy: DastSiteValidationStrategyEnum + + """ + The path to be requested during validation. + """ + validationPath: String! +} + +""" +Autogenerated return type of DastSiteValidationCreate +""" +type DastSiteValidationCreatePayload { + """ + A unique identifier for the client performing the mutation. + """ + clientMutationId: String + + """ + Errors encountered during execution of the mutation. + """ + errors: [String!]! + + """ + ID of the site validation. + """ + id: DastSiteValidationID + + """ + The current validation status. + """ + status: DastSiteProfileValidationStatusEnum +} + +""" +Identifier of DastSiteValidation +""" +scalar DastSiteValidationID + +enum DastSiteValidationStrategyEnum { + """ + Header validation + """ + HEADER + + """ + Text file validation + """ + TEXT_FILE +} + +""" Date represented in ISO 8601 """ scalar Date @@ -12377,6 +12449,7 @@ type Mutation { dastSiteProfileDelete(input: DastSiteProfileDeleteInput!): DastSiteProfileDeletePayload dastSiteProfileUpdate(input: DastSiteProfileUpdateInput!): DastSiteProfileUpdatePayload dastSiteTokenCreate(input: DastSiteTokenCreateInput!): DastSiteTokenCreatePayload + dastSiteValidationCreate(input: DastSiteValidationCreateInput!): DastSiteValidationCreatePayload deleteAnnotation(input: DeleteAnnotationInput!): DeleteAnnotationPayload designManagementDelete(input: DesignManagementDeleteInput!): DesignManagementDeletePayload designManagementMove(input: DesignManagementMoveInput!): DesignManagementMovePayload @@ -13512,7 +13585,7 @@ type Project { iid: String """ - Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool. + Search query for title, description, service, or monitoring_tool. """ search: String @@ -13537,7 +13610,7 @@ type Project { assigneeUsername: String """ - Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool. + Search query for title, description, service, or monitoring_tool. """ search: String ): AlertManagementAlertStatusCountsType @@ -13577,7 +13650,7 @@ type Project { last: Int """ - Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool. + Search query for title, description, service, or monitoring_tool. """ search: String diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json index eb421067668..387463595ee 100644 --- a/doc/api/graphql/reference/gitlab_schema.json +++ b/doc/api/graphql/reference/gitlab_schema.json @@ -12361,6 +12361,193 @@ "possibleTypes": null }, { + "kind": "INPUT_OBJECT", + "name": "DastSiteValidationCreateInput", + "description": "Autogenerated input type of DastSiteValidationCreate", + "fields": null, + "inputFields": [ + { + "name": "fullPath", + "description": "The project the site profile belongs to.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "ID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "dastSiteTokenId", + "description": "ID of the site token.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "DastSiteTokenID", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "validationPath", + "description": "The path to be requested during validation.", + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + }, + "defaultValue": null + }, + { + "name": "strategy", + "description": "The validation strategy to be used.", + "type": { + "kind": "ENUM", + "name": "DastSiteValidationStrategyEnum", + "ofType": null + }, + "defaultValue": null + }, + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "defaultValue": null + } + ], + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "OBJECT", + "name": "DastSiteValidationCreatePayload", + "description": "Autogenerated return type of DastSiteValidationCreate", + "fields": [ + { + "name": "clientMutationId", + "description": "A unique identifier for the client performing the mutation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "String", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "errors", + "description": "Errors encountered during execution of the mutation.", + "args": [ + + ], + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "LIST", + "name": null, + "ofType": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "SCALAR", + "name": "String", + "ofType": null + } + } + } + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "id", + "description": "ID of the site validation.", + "args": [ + + ], + "type": { + "kind": "SCALAR", + "name": "DastSiteValidationID", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "status", + "description": "The current validation status.", + "args": [ + + ], + "type": { + "kind": "ENUM", + "name": "DastSiteProfileValidationStatusEnum", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + } + ], + "inputFields": null, + "interfaces": [ + + ], + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "SCALAR", + "name": "DastSiteValidationID", + "description": "Identifier of DastSiteValidation", + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": null, + "possibleTypes": null + }, + { + "kind": "ENUM", + "name": "DastSiteValidationStrategyEnum", + "description": null, + "fields": null, + "inputFields": null, + "interfaces": null, + "enumValues": [ + { + "name": "TEXT_FILE", + "description": "Text file validation", + "isDeprecated": false, + "deprecationReason": null + }, + { + "name": "HEADER", + "description": "Header validation", + "isDeprecated": false, + "deprecationReason": null + } + ], + "possibleTypes": null + }, + { "kind": "SCALAR", "name": "Date", "description": "Date represented in ISO 8601", @@ -34884,6 +35071,33 @@ "deprecationReason": null }, { + "name": "dastSiteValidationCreate", + "description": null, + "args": [ + { + "name": "input", + "description": null, + "type": { + "kind": "NON_NULL", + "name": null, + "ofType": { + "kind": "INPUT_OBJECT", + "name": "DastSiteValidationCreateInput", + "ofType": null + } + }, + "defaultValue": null + } + ], + "type": { + "kind": "OBJECT", + "name": "DastSiteValidationCreatePayload", + "ofType": null + }, + "isDeprecated": false, + "deprecationReason": null + }, + { "name": "deleteAnnotation", "description": null, "args": [ @@ -39769,7 +39983,7 @@ }, { "name": "search", - "description": "Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.", + "description": "Search query for title, description, service, or monitoring_tool.", "type": { "kind": "SCALAR", "name": "String", @@ -39802,7 +40016,7 @@ "args": [ { "name": "search", - "description": "Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.", + "description": "Search query for title, description, service, or monitoring_tool.", "type": { "kind": "SCALAR", "name": "String", @@ -39873,7 +40087,7 @@ }, { "name": "search", - "description": "Search criteria for filtering alerts. This will search on title, description, service, monitoring_tool.", + "description": "Search query for title, description, service, or monitoring_tool.", "type": { "kind": "SCALAR", "name": "String", diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 93bbcb18bf3..ee53ddac5d3 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -733,6 +733,17 @@ Autogenerated return type of DastSiteTokenCreate. | `status` | DastSiteProfileValidationStatusEnum | The current validation status of the target. | | `token` | String | Token string. | +### DastSiteValidationCreatePayload + +Autogenerated return type of DastSiteValidationCreate. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `clientMutationId` | String | A unique identifier for the client performing the mutation. | +| `errors` | String! => Array | Errors encountered during execution of the mutation. | +| `id` | DastSiteValidationID | ID of the site validation. | +| `status` | DastSiteProfileValidationStatusEnum | The current validation status. | + ### DeleteAnnotationPayload Autogenerated return type of DeleteAnnotation. @@ -3286,6 +3297,13 @@ Status of a container repository. | `PASSED_VALIDATION` | Site validation process finished successfully | | `PENDING_VALIDATION` | Site validation process has not started | +### DastSiteValidationStrategyEnum + +| Value | Description | +| ----- | ----------- | +| `HEADER` | Header validation | +| `TEXT_FILE` | Text file validation | + ### DesignCollectionCopyState Copy state of a DesignCollection. diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md index 5fbb7913ff4..127d83b0730 100644 --- a/doc/api/oauth2.md +++ b/doc/api/oauth2.md @@ -1,3 +1,10 @@ +--- +type: reference, howto +stage: Manage +group: Access +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technica l-writing/#designated-technical-writers +--- + # GitLab as an OAuth2 provider This document covers using the [OAuth2](https://oauth.net/2/) protocol to allow @@ -28,12 +35,24 @@ During registration, by enabling proper scopes, you can limit the range of resources which the `application` can access. Upon creation, you'll obtain the `application` credentials: _Application ID_ and _Client Secret_ - **keep them secure**. -CAUTION: **Important:** -OAuth specification advises sending the `state` parameter with each request to -`/oauth/authorize`. We highly recommended sending a unique value with each request -and validate it against the one in the redirect request. This is important in -order to prevent [CSRF attacks](https://wiki.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)). -The `state` parameter really should have been a requirement in the standard! +### Prevent CSRF attacks + +To [protect redirect-based flows](https://tools.ietf.org/id/draft-ietf-oauth-security-topics-13.html#rec_redirect), +the OAuth specification recommends the use of "One-time use CSRF tokens carried in the state +parameter, which are securely bound to the user agent", with each request to the +`/oauth/authorize` endpoint. This can prevent +[CSRF attacks](https://wiki.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)). + +### Use HTTPS in production + +For production, please use HTTPS for your `redirect_uri`. +For development, GitLab allows insecure HTTP redirect URIs. + +As OAuth2 bases its security entirely on the transport layer, you should not use unprotected +URIs. For more information, see the [OAuth 2.0 RFC](https://tools.ietf.org/html/rfc6749#section-3.1.2.1) +and the [OAuth 2.0 Threat Model RFC](https://tools.ietf.org/html/rfc6819#section-4.4.2.1). +These factors are particularly important when using the +[Implicit grant flow](#implicit-grant-flow), where actual credentials are included in the `redirect_uri`. In the following sections you will find detailed instructions on how to obtain authorization with each flow. diff --git a/doc/development/secure_coding_guidelines.md b/doc/development/secure_coding_guidelines.md index 5dedc29ba53..2ab9aef87d8 100644 --- a/doc/development/secure_coding_guidelines.md +++ b/doc/development/secure_coding_guidelines.md @@ -311,6 +311,7 @@ Specifically, the following options are dangerous because they mark strings as t |----------------------|-------------------------------| | HAML templates | `html_safe`, `raw`, `!=` | | Embedded Ruby (ERB) | `html_safe`, `raw`, `<%== %>` | + In case you want to sanitize user-controlled values against XSS vulnerabilities, you can use [`ActionView::Helpers::SanitizeHelper`](https://api.rubyonrails.org/classes/ActionView/Helpers/SanitizeHelper.html). Calling `link_to` and `redirect_to` with user-controlled parameters can also lead to cross-site scripting. diff --git a/doc/topics/git/partial_clone.md b/doc/topics/git/partial_clone.md index c976eda688a..58fa6d2f112 100644 --- a/doc/topics/git/partial_clone.md +++ b/doc/topics/git/partial_clone.md @@ -91,7 +91,7 @@ Updating files: 100% (28/28), done. $ cd www-gitlab-com -$ git sparse-checkout init --clone +$ git sparse-checkout init --cone $ git sparse-checkout add data remote: Enumerating objects: 301, done. diff --git a/doc/user/index.md b/doc/user/index.md index 32a1c235882..0e22c01cc97 100644 --- a/doc/user/index.md +++ b/doc/user/index.md @@ -84,18 +84,6 @@ it all at once, from one single project. - [Milestones](project/milestones/index.md): Work on multiple issues and merge requests towards the same target date with Milestones. -## GitLab CI/CD - -Use built-in [GitLab CI/CD](../ci/README.md) to test, build, and deploy your applications -directly from GitLab. No third-party integrations needed. - -- [GitLab Auto Deploy](../topics/autodevops/stages.md#auto-deploy): Deploy your application out-of-the-box with GitLab Auto Deploy. -- [Review Apps](../ci/review_apps/index.md): Live-preview the changes introduced by a merge request with Review Apps. -- [GitLab Pages](project/pages/index.md): Publish your static site directly from - GitLab with GitLab Pages. You can build, test, and deploy any Static Site Generator with Pages. -- [GitLab Container Registry](packages/container_registry/index.md): Build and deploy Docker - images with Container Registry. - ## Account There is a lot you can customize and configure @@ -151,6 +139,11 @@ requests you're assigned to. you have quick access to. You can also gather feedback on them through [Discussions](#discussions). +## GitLab CI/CD + +Use built-in [GitLab CI/CD](../ci/README.md) to test, build, and deploy your applications +directly from GitLab. No third-party integrations needed. + ## Features behind feature flags Understand what [features behind feature flags](feature_flags.md) mean. diff --git a/doc/user/project/clusters/add_eks_clusters.md b/doc/user/project/clusters/add_eks_clusters.md index 65416d73f06..c0e45a0ff14 100644 --- a/doc/user/project/clusters/add_eks_clusters.md +++ b/doc/user/project/clusters/add_eks_clusters.md @@ -139,6 +139,7 @@ To create and add a new Kubernetes cluster to your project, group, or instance: 1. Enter a role name and optional description into the fields provided. 1. Click **Create role**, the new role name will appear at the top. Click on its name and copy the `Role ARN` from the newly created role. 1. In GitLab, enter the copied role ARN into the `Role ARN` field. +1. In the **Cluster Region** field, enter the [region](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html) you plan to use for your new cluster. GitLab will authenticate you have access to this region when authenticating your role. 1. Click **Authenticate with AWS**. 1. Choose your cluster's settings: - **Kubernetes cluster name** - The name you wish to give the cluster. @@ -152,9 +153,6 @@ To create and add a new Kubernetes cluster to your project, group, or instance: the one you created much earlier by following the [Amazon EKS cluster IAM role](https://docs.aws.amazon.com/eks/latest/userguide/service_IAM_role.html) guide. - - - **Region** - The [region](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html) - in which the cluster will be created. - **Key pair name** - Select the [key pair](https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/ec2-key-pairs.html) that you can use to connect to your worker nodes if required. - **VPC** - Select a [VPC](https://docs.aws.amazon.com/vpc/latest/userguide/what-is-amazon-vpc.html) @@ -184,9 +182,10 @@ The following errors are commonly encountered when creating a new cluster. #### Error: Request failed with status code 422 When submitting the initial authentication form, GitLab returns a status code 422 -error when it can't determine the role you've provided. Make sure you've +error when it can't determine the role or region you've provided. Make sure you've correctly configured your role with the **Account ID** and **External ID** -provided by GitLab. In GitLab, make sure to enter the correct **Role ARN**. +provided by GitLab. In GitLab, make sure to enter the correct **Role ARN**. +Make sure you also have access to the chosen region. #### Could not load Security Groups for this VPC diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md index 6d5ae96ee2c..34a075df990 100644 --- a/doc/user/project/service_desk.md +++ b/doc/user/project/service_desk.md @@ -99,7 +99,7 @@ navigation's **Issues** menu. When a user submits a new issue using Service Desk, or when a new note is created on a Service Desk issue, an email is sent to the author. -The body of these email messages can customized by using templates. To create a new customized template, +The body of these email messages can be customized by using templates. To create a new customized template, create a new Markdown (`.md`) file inside the `.gitlab/service_desk_templates/` directory in your repository. Commit and push to your default branch. diff --git a/lib/bulk_imports/clients/graphql.rb b/lib/bulk_imports/clients/graphql.rb new file mode 100644 index 00000000000..89698cb53ef --- /dev/null +++ b/lib/bulk_imports/clients/graphql.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module BulkImports + module Clients + class Graphql + attr_reader :client + + delegate :query, :parse, :execute, to: :client + + def initialize(url: Gitlab::COM_URL, token: nil) + @url = Gitlab::Utils.append_path(url, '/api/graphql') + @token = token + @client = Graphlient::Client.new( + @url, + request_headers + ) + end + + def request_headers + return {} unless @token + + { + headers: { + 'Content-Type' => 'application/json', + 'Authorization' => "Bearer #{@token}" + } + } + end + end + end +end diff --git a/lib/gitlab/bulk_import/client.rb b/lib/bulk_imports/clients/http.rb index c6e77a158cd..39f56fcc114 100644 --- a/lib/gitlab/bulk_import/client.rb +++ b/lib/bulk_imports/clients/http.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true -module Gitlab - module BulkImport - class Client +module BulkImports + module Clients + class Http API_VERSION = 'v4'.freeze DEFAULT_PAGE = 1.freeze DEFAULT_PER_PAGE = 30.freeze diff --git a/lib/bulk_imports/common/extractors/graphql_extractor.rb b/lib/bulk_imports/common/extractors/graphql_extractor.rb new file mode 100644 index 00000000000..571be747dca --- /dev/null +++ b/lib/bulk_imports/common/extractors/graphql_extractor.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module BulkImports + module Common + module Extractors + class GraphqlExtractor + def initialize(query) + @query = query[:query] + @query_string = @query.to_s + @variables = @query.variables + end + + def extract(context) + @context = context + + Enumerator.new do |yielder| + context.entities.each do |entity| + result = graphql_client.execute(parsed_query, query_variables(entity)) + + yielder << result.original_hash.deep_dup + end + end + end + + private + + def graphql_client + @graphql_client ||= BulkImports::Clients::Graphql.new( + url: @context.configuration.url, + token: @context.configuration.access_token + ) + end + + def parsed_query + @parsed_query ||= graphql_client.parse(@query.to_s) + end + + def query_variables(entity) + return unless @variables + + @variables.transform_values do |entity_attribute| + entity.public_send(entity_attribute) # rubocop:disable GitlabSecurity/PublicSend + end + end + end + end + end +end diff --git a/lib/bulk_imports/common/transformers/graphql_cleaner_transformer.rb b/lib/bulk_imports/common/transformers/graphql_cleaner_transformer.rb new file mode 100644 index 00000000000..dce0fac6999 --- /dev/null +++ b/lib/bulk_imports/common/transformers/graphql_cleaner_transformer.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +# Cleanup GraphQL original response hash from unnecessary nesting +# 1. Remove ['data']['group'] or ['data']['project'] hash nesting +# 2. Remove ['edges'] & ['nodes'] array wrappings +# 3. Remove ['node'] hash wrapping +# +# @example +# data = {"data"=>{"group"=> { +# "name"=>"test", +# "fullName"=>"test", +# "description"=>"test", +# "labels"=>{"edges"=>[{"node"=>{"title"=>"label1"}}, {"node"=>{"title"=>"label2"}}, {"node"=>{"title"=>"label3"}}]}}}} +# +# BulkImports::Common::Transformers::GraphqlCleanerTransformer.new.transform(nil, data) +# +# {"name"=>"test", "fullName"=>"test", "description"=>"test", "labels"=>[{"title"=>"label1"}, {"title"=>"label2"}, {"title"=>"label3"}]} +module BulkImports + module Common + module Transformers + class GraphqlCleanerTransformer + EDGES = 'edges' + NODE = 'node' + + def initialize(options = {}) + @options = options + end + + def transform(_, data) + return data unless data.is_a?(Hash) + + data = data.dig('data', 'group') || data.dig('data', 'project') || data + + clean_edges_and_nodes(data) + end + + def clean_edges_and_nodes(data) + case data + when Array + data.map(&method(:clean_edges_and_nodes)) + when Hash + if data.key?(NODE) + clean_edges_and_nodes(data[NODE]) + else + data.transform_values { |value| clean_edges_and_nodes(value.try(:fetch, EDGES, value) || value) } + end + else + data + end + end + end + end + end +end diff --git a/lib/bulk_imports/common/transformers/underscorify_keys_transformer.rb b/lib/bulk_imports/common/transformers/underscorify_keys_transformer.rb new file mode 100644 index 00000000000..b32ab28fdbb --- /dev/null +++ b/lib/bulk_imports/common/transformers/underscorify_keys_transformer.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module BulkImports + module Common + module Transformers + class UnderscorifyKeysTransformer + def initialize(options = {}) + @options = options + end + + def transform(_, data) + data.deep_transform_keys do |key| + key.to_s.underscore + end + end + end + end + end +end diff --git a/lib/bulk_imports/groups/graphql/get_group_query.rb b/lib/bulk_imports/groups/graphql/get_group_query.rb new file mode 100644 index 00000000000..c50b99aae4e --- /dev/null +++ b/lib/bulk_imports/groups/graphql/get_group_query.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Graphql + module GetGroupQuery + extend self + + def to_s + <<-'GRAPHQL' + query($full_path: ID!) { + group(fullPath: $full_path) { + name + path + fullPath + description + visibility + emailsDisabled + lfsEnabled + mentionsDisabled + projectCreationLevel + requestAccessEnabled + requireTwoFactorAuthentication + shareWithGroupLock + subgroupCreationLevel + twoFactorGracePeriod + } + } + GRAPHQL + end + + def variables + { full_path: :source_full_path } + end + end + end + end +end diff --git a/lib/bulk_imports/groups/loaders/group_loader.rb b/lib/bulk_imports/groups/loaders/group_loader.rb new file mode 100644 index 00000000000..394f0ee10ec --- /dev/null +++ b/lib/bulk_imports/groups/loaders/group_loader.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Loaders + class GroupLoader + def initialize(options = {}) + @options = options + end + + def load(context, data) + return unless user_can_create_group?(context.current_user, data) + + ::Groups::CreateService.new(context.current_user, data).execute + end + + private + + def user_can_create_group?(current_user, data) + if data['parent_id'] + parent = Namespace.find_by_id(data['parent_id']) + + Ability.allowed?(current_user, :create_subgroup, parent) + else + Ability.allowed?(current_user, :create_group) + end + end + end + end + end +end diff --git a/lib/bulk_imports/groups/pipelines/group_pipeline.rb b/lib/bulk_imports/groups/pipelines/group_pipeline.rb new file mode 100644 index 00000000000..2b7d0ef7658 --- /dev/null +++ b/lib/bulk_imports/groups/pipelines/group_pipeline.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Pipelines + class GroupPipeline + include Pipeline + + extractor Common::Extractors::GraphqlExtractor, query: Graphql::GetGroupQuery + + transformer Common::Transformers::GraphqlCleanerTransformer + transformer Common::Transformers::UnderscorifyKeysTransformer + transformer Groups::Transformers::GroupAttributesTransformer + + loader Groups::Loaders::GroupLoader + end + end + end +end diff --git a/lib/bulk_imports/groups/transformers/group_attributes_transformer.rb b/lib/bulk_imports/groups/transformers/group_attributes_transformer.rb new file mode 100644 index 00000000000..c3937cfe652 --- /dev/null +++ b/lib/bulk_imports/groups/transformers/group_attributes_transformer.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Transformers + class GroupAttributesTransformer + def initialize(options = {}) + @options = options + end + + def transform(context, data) + import_entity = find_by_full_path(data['full_path'], context.entities) + + data + .then { |data| transform_name(import_entity, data) } + .then { |data| transform_path(import_entity, data) } + .then { |data| transform_full_path(data) } + .then { |data| transform_parent(context, import_entity, data) } + .then { |data| transform_visibility_level(data) } + .then { |data| transform_project_creation_level(data) } + .then { |data| transform_subgroup_creation_level(data) } + end + + private + + def transform_name(import_entity, data) + data['name'] = import_entity.destination_name + data + end + + def transform_path(import_entity, data) + data['path'] = import_entity.destination_name.parameterize + data + end + + def transform_full_path(data) + data.delete('full_path') + data + end + + def transform_parent(context, import_entity, data) + current_user = context.current_user + namespace = Namespace.find_by_full_path(import_entity.destination_namespace) + + return data if namespace == current_user.namespace + + data['parent_id'] = namespace.id + data + end + + def transform_visibility_level(data) + visibility = data['visibility'] + + return data unless visibility.present? + + data['visibility_level'] = Gitlab::VisibilityLevel.string_options[visibility] + data.delete('visibility') + data + end + + def transform_project_creation_level(data) + project_creation_level = data['project_creation_level'] + + return data unless project_creation_level.present? + + data['project_creation_level'] = Gitlab::Access.project_creation_string_options[project_creation_level] + data + end + + def transform_subgroup_creation_level(data) + subgroup_creation_level = data['subgroup_creation_level'] + + return data unless subgroup_creation_level.present? + + data['subgroup_creation_level'] = Gitlab::Access.subgroup_creation_string_options[subgroup_creation_level] + data + end + + def find_by_full_path(full_path, entities) + entities.find { |entity| entity.source_full_path == full_path } + end + end + end + end +end diff --git a/lib/bulk_imports/importers/group_importer.rb b/lib/bulk_imports/importers/group_importer.rb new file mode 100644 index 00000000000..f053177d9fb --- /dev/null +++ b/lib/bulk_imports/importers/group_importer.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +# Imports a top level group into a destination +# Optionally imports into parent group +# Entity must be of type: 'group' & have parent_id: nil +# Subgroups not handled yet +module BulkImports + module Importers + class GroupImporter + def initialize(entity_id) + @entity_id = entity_id + end + + def execute + return if entity.parent + + bulk_import = entity.bulk_import + configuration = bulk_import.configuration + + context = BulkImports::Pipeline::Context.new( + current_user: bulk_import.user, + entities: [entity], + configuration: configuration + ) + + BulkImports::Groups::Pipelines::GroupPipeline.new.run(context) + end + + def entity + @entity ||= BulkImports::Entity.find(@entity_id) + end + end + end +end diff --git a/lib/bulk_imports/pipeline.rb b/lib/bulk_imports/pipeline.rb new file mode 100644 index 00000000000..70e6030ea2c --- /dev/null +++ b/lib/bulk_imports/pipeline.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module BulkImports + module Pipeline + extend ActiveSupport::Concern + + included do + include Attributes + include Runner + end + end +end diff --git a/lib/bulk_imports/pipeline/attributes.rb b/lib/bulk_imports/pipeline/attributes.rb new file mode 100644 index 00000000000..ebfbaf6f6ba --- /dev/null +++ b/lib/bulk_imports/pipeline/attributes.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module BulkImports + module Pipeline + module Attributes + extend ActiveSupport::Concern + include Gitlab::ClassAttributes + + class_methods do + def extractor(klass, options = nil) + add_attribute(:extractors, klass, options) + end + + def transformer(klass, options = nil) + add_attribute(:transformers, klass, options) + end + + def loader(klass, options = nil) + add_attribute(:loaders, klass, options) + end + + def add_attribute(sym, klass, options) + class_attributes[sym] ||= [] + class_attributes[sym] << { klass: klass, options: options } + end + + def extractors + class_attributes[:extractors] + end + + def transformers + class_attributes[:transformers] + end + + def loaders + class_attributes[:loaders] + end + end + end + end +end diff --git a/lib/bulk_imports/pipeline/context.rb b/lib/bulk_imports/pipeline/context.rb new file mode 100644 index 00000000000..903f474ebbb --- /dev/null +++ b/lib/bulk_imports/pipeline/context.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module BulkImports + module Pipeline + class Context + include Gitlab::Utils::LazyAttributes + + Attribute = Struct.new(:name, :type) + + PIPELINE_ATTRIBUTES = [ + Attribute.new(:current_user, User), + Attribute.new(:entities, Array), + Attribute.new(:configuration, ::BulkImports::Configuration) + ].freeze + + def initialize(args) + assign_attributes(args) + end + + private + + PIPELINE_ATTRIBUTES.each do |attr| + lazy_attr_reader attr.name, type: attr.type + end + + def assign_attributes(values) + values.slice(*PIPELINE_ATTRIBUTES.map(&:name)).each do |name, value| + instance_variable_set("@#{name}", value) + end + end + end + end +end diff --git a/lib/bulk_imports/pipeline/runner.rb b/lib/bulk_imports/pipeline/runner.rb new file mode 100644 index 00000000000..cf94b500612 --- /dev/null +++ b/lib/bulk_imports/pipeline/runner.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module BulkImports + module Pipeline + module Runner + extend ActiveSupport::Concern + + included do + attr_reader :extractors, :transformers, :loaders + + def initialize + @extractors = self.class.extractors.map(&method(:instantiate)) + @transformers = self.class.transformers.map(&method(:instantiate)) + @loaders = self.class.loaders.map(&method(:instantiate)) + + super + end + + def run(context) + extractors.each do |extractor| + extractor.extract(context).each do |entry| + transformers.each do |transformer| + entry = transformer.transform(context, entry) + end + + loaders.each do |loader| + loader.load(context, entry) + end + end + end + end + + def instantiate(class_config) + class_config[:klass].new(class_config[:options]) + end + end + end + end +end diff --git a/lib/gitlab/database/partitioning/monthly_strategy.rb b/lib/gitlab/database/partitioning/monthly_strategy.rb index ecc05d9654a..82ea1ce26fb 100644 --- a/lib/gitlab/database/partitioning/monthly_strategy.rb +++ b/lib/gitlab/database/partitioning/monthly_strategy.rb @@ -17,23 +17,8 @@ module Gitlab end def current_partitions - result = connection.select_all(<<~SQL) - select - pg_class.relname, - parent_class.relname as base_table, - pg_get_expr(pg_class.relpartbound, inhrelid) as condition - from pg_class - inner join pg_inherits i on pg_class.oid = inhrelid - inner join pg_class parent_class on parent_class.oid = inhparent - inner join pg_namespace ON pg_namespace.oid = pg_class.relnamespace - where pg_namespace.nspname = #{connection.quote(Gitlab::Database::DYNAMIC_PARTITIONS_SCHEMA)} - and parent_class.relname = #{connection.quote(table_name)} - and pg_class.relispartition - order by pg_class.relname - SQL - - result.map do |record| - TimePartition.from_sql(table_name, record['relname'], record['condition']) + Gitlab::Database::PostgresPartition.for_parent_table(table_name).map do |partition| + TimePartition.from_sql(table_name, partition.name, partition.condition) end end diff --git a/lib/gitlab/database/postgres_partition.rb b/lib/gitlab/database/postgres_partition.rb new file mode 100644 index 00000000000..0986372586b --- /dev/null +++ b/lib/gitlab/database/postgres_partition.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Gitlab + module Database + class PostgresPartition < ActiveRecord::Base + self.primary_key = :identifier + + belongs_to :postgres_partitioned_table, foreign_key: 'parent_identifier', primary_key: 'identifier' + + scope :by_identifier, ->(identifier) do + raise ArgumentError, "Partition name is not fully qualified with a schema: #{identifier}" unless identifier =~ /^\w+\.\w+$/ + + find(identifier) + end + + scope :for_parent_table, ->(name) { where("parent_identifier = concat(current_schema(), '.', ?)", name).order(:name) } + + def to_s + name + end + end + end +end diff --git a/lib/gitlab/database/postgres_partitioned_table.rb b/lib/gitlab/database/postgres_partitioned_table.rb index 5856f63213a..58385821bde 100644 --- a/lib/gitlab/database/postgres_partitioned_table.rb +++ b/lib/gitlab/database/postgres_partitioned_table.rb @@ -7,6 +7,8 @@ module Gitlab self.primary_key = :identifier + has_many :postgres_partitions, foreign_key: 'parent_identifier', primary_key: 'identifier' + scope :by_identifier, ->(identifier) do raise ArgumentError, "Table name is not fully qualified with a schema: #{identifier}" unless identifier =~ /^\w+\.\w+$/ diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 1fabf661d13..b78aac41e24 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2994,9 +2994,6 @@ msgstr "" msgid "An error occurred while loading merge requests." msgstr "" -msgid "An error occurred while loading milestones" -msgstr "" - msgid "An error occurred while loading project creation UI" msgstr "" @@ -3078,9 +3075,6 @@ msgstr "" msgid "An error occurred while saving assignees" msgstr "" -msgid "An error occurred while searching for milestones" -msgstr "" - msgid "An error occurred while subscribing to notifications." msgstr "" @@ -16992,6 +16986,27 @@ msgstr "" msgid "Milestone lists show all issues from the selected milestone." msgstr "" +msgid "MilestoneCombobox|An error occurred while searching for milestones" +msgstr "" + +msgid "MilestoneCombobox|Milestone" +msgstr "" + +msgid "MilestoneCombobox|No matching results" +msgstr "" + +msgid "MilestoneCombobox|No milestone" +msgstr "" + +msgid "MilestoneCombobox|Project milestones" +msgstr "" + +msgid "MilestoneCombobox|Search Milestones" +msgstr "" + +msgid "MilestoneCombobox|Select milestone" +msgstr "" + msgid "MilestoneSidebar|Closed:" msgstr "" @@ -23061,9 +23076,6 @@ msgstr "" msgid "Search Jira issues" msgstr "" -msgid "Search Milestones" -msgstr "" - msgid "Search an environment spec" msgstr "" @@ -27873,6 +27885,9 @@ msgstr "" msgid "Total: %{total}" msgstr "" +msgid "TotalMilestonesIndicator|1000+" +msgstr "" + msgid "TotalRefCountIndicator|1000+" msgstr "" diff --git a/package.json b/package.json index b86f1508a07..f33dc7ebfd5 100644 --- a/package.json +++ b/package.json @@ -42,8 +42,8 @@ "@babel/plugin-syntax-import-meta": "^7.10.1", "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.5", - "@gitlab/svgs": "1.172.0", - "@gitlab/ui": "21.35.1", + "@gitlab/svgs": "1.173.0", + "@gitlab/ui": "21.35.2", "@gitlab/visual-review-tools": "1.6.1", "@rails/actioncable": "^6.0.3-3", "@rails/ujs": "^6.0.3-2", diff --git a/spec/controllers/import/bulk_imports_controller_spec.rb b/spec/controllers/import/bulk_imports_controller_spec.rb index f3850ff844e..9f0e6ea1a20 100644 --- a/spec/controllers/import/bulk_imports_controller_spec.rb +++ b/spec/controllers/import/bulk_imports_controller_spec.rb @@ -24,7 +24,7 @@ RSpec.describe Import::BulkImportsController do expect(session[:bulk_import_gitlab_url]).to be_nil expect(response).to have_gitlab_http_status(:found) - expect(response).to redirect_to(status_import_bulk_import_url) + expect(response).to redirect_to(status_import_bulk_imports_url) end end @@ -37,7 +37,7 @@ RSpec.describe Import::BulkImportsController do expect(session[:bulk_import_gitlab_access_token]).to eq(token) expect(session[:bulk_import_gitlab_url]).to eq(url) expect(response).to have_gitlab_http_status(:found) - expect(response).to redirect_to(status_import_bulk_import_url) + expect(response).to redirect_to(status_import_bulk_imports_url) end it 'strips access token with spaces' do @@ -46,12 +46,12 @@ RSpec.describe Import::BulkImportsController do post :configure, params: { bulk_import_gitlab_access_token: " #{token} " } expect(session[:bulk_import_gitlab_access_token]).to eq(token) - expect(controller).to redirect_to(status_import_bulk_import_url) + expect(controller).to redirect_to(status_import_bulk_imports_url) end end describe 'GET status' do - let(:client) { Gitlab::BulkImport::Client.new(uri: 'http://gitlab.example', token: 'token') } + let(:client) { BulkImports::Clients::Http.new(uri: 'http://gitlab.example', token: 'token') } describe 'serialized group data' do let(:client_response) do @@ -111,7 +111,7 @@ RSpec.describe Import::BulkImportsController do context 'when connection error occurs' do before do allow(controller).to receive(:client).and_return(client) - allow(client).to receive(:get).and_raise(Gitlab::BulkImport::Client::ConnectionError) + allow(client).to receive(:get).and_raise(BulkImports::Clients::Http::ConnectionError) end it 'returns 422' do @@ -128,9 +128,21 @@ RSpec.describe Import::BulkImportsController do end end end + + describe 'POST create' do + it 'executes BulkImportService' do + expect_next_instance_of(BulkImportService) do |service| + expect(service).to receive(:execute) + end + + post :create + + expect(response).to have_gitlab_http_status(:ok) + end + end end - context 'when gitlab_api_imports feature flag is disabled' do + context 'when bulk_import feature flag is disabled' do before do stub_feature_flags(bulk_import: false) end diff --git a/spec/factories/bulk_import.rb b/spec/factories/bulk_import.rb index 0231fe7cfef..07907bab3df 100644 --- a/spec/factories/bulk_import.rb +++ b/spec/factories/bulk_import.rb @@ -4,5 +4,21 @@ FactoryBot.define do factory :bulk_import, class: 'BulkImport' do user source_type { :gitlab } + + trait :created do + status { 0 } + end + + trait :started do + status { 1 } + end + + trait :finished do + status { 2 } + end + + trait :failed do + status { -1 } + end end end diff --git a/spec/factories/bulk_import/entities.rb b/spec/factories/bulk_import/entities.rb index 3bf6af92d00..cf31ffec4f6 100644 --- a/spec/factories/bulk_import/entities.rb +++ b/spec/factories/bulk_import/entities.rb @@ -17,5 +17,25 @@ FactoryBot.define do trait(:project_entity) do source_type { :project_entity } end + + trait :created do + status { 0 } + end + + trait :started do + status { 1 } + + sequence(:jid) { |n| "bulk_import_entity_#{n}" } + end + + trait :finished do + status { 2 } + + sequence(:jid) { |n| "bulk_import_entity_#{n}" } + end + + trait :failed do + status { -1 } + end end end diff --git a/spec/factories/import_configurations.rb b/spec/factories/import_configurations.rb new file mode 100644 index 00000000000..1686d1b0d75 --- /dev/null +++ b/spec/factories/import_configurations.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :bulk_import_configuration, class: 'BulkImports::Configuration' do + association :bulk_import, factory: :bulk_import + + url { 'https://gitlab.example' } + access_token { 'token' } + end +end diff --git a/spec/frontend/milestones/milestone_combobox_spec.js b/spec/frontend/milestones/milestone_combobox_spec.js new file mode 100644 index 00000000000..2996c05d96e --- /dev/null +++ b/spec/frontend/milestones/milestone_combobox_spec.js @@ -0,0 +1,354 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { ENTER_KEY } from '~/lib/utils/keys'; +import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue'; +import { milestones as projectMilestones } from './mock_data'; +import createStore from '~/milestones/stores/'; + +const extraLinks = [ + { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' }, + { text: 'Manage milestones', url: '/h5bp/html5-boilerplate/-/milestones' }, +]; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Milestone combobox component', () => { + const projectId = '8'; + const X_TOTAL_HEADER = 'x-total'; + + let wrapper; + let projectMilestonesApiCallSpy; + let searchApiCallSpy; + + const createComponent = (props = {}, attrs = {}) => { + wrapper = mount(MilestoneCombobox, { + propsData: { + projectId, + extraLinks, + value: [], + ...props, + }, + attrs, + listeners: { + // simulate a parent component v-model binding + input: selectedMilestone => { + wrapper.setProps({ value: selectedMilestone }); + }, + }, + stubs: { + GlSearchBoxByType: true, + }, + localVue, + store: createStore(), + }); + }; + + beforeEach(() => { + const mock = new MockAdapter(axios); + gon.api_version = 'v4'; + + projectMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]); + + searchApiCallSpy = jest + .fn() + .mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]); + + mock + .onGet(`/api/v4/projects/${projectId}/milestones`) + .reply(config => projectMilestonesApiCallSpy(config)); + + mock.onGet(`/api/v4/projects/${projectId}/search`).reply(config => searchApiCallSpy(config)); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + // + // Finders + // + const findButtonContent = () => wrapper.find('[data-testid="milestone-combobox-button-content"]'); + + const findNoResults = () => wrapper.find('[data-testid="milestone-combobox-no-results"]'); + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + + const findSearchBox = () => wrapper.find(GlSearchBoxByType); + + const findProjectMilestonesSection = () => + wrapper.find('[data-testid="project-milestones-section"]'); + const findProjectMilestonesDropdownItems = () => + findProjectMilestonesSection().findAll(GlDropdownItem); + const findFirstProjectMilestonesDropdownItem = () => findProjectMilestonesDropdownItems().at(0); + + // + // Expecters + // + const projectMilestoneSectionContainsErrorMessage = () => { + const projectMilestoneSection = findProjectMilestonesSection(); + + return projectMilestoneSection + .text() + .includes(s__('MilestoneCombobox|An error occurred while searching for milestones')); + }; + + // + // Convenience methods + // + const updateQuery = newQuery => { + findSearchBox().vm.$emit('input', newQuery); + }; + + const selectFirstProjectMilestone = () => { + findFirstProjectMilestonesDropdownItem().vm.$emit('click'); + }; + + const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) => + axios.waitForAll().then(() => { + if (andClearMocks) { + projectMilestonesApiCallSpy.mockClear(); + } + }); + + describe('initialization behavior', () => { + beforeEach(createComponent); + + it('initializes the dropdown with project milestones when mounted', () => { + return waitForRequests().then(() => { + expect(projectMilestonesApiCallSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('shows a spinner while network requests are in progress', () => { + expect(findLoadingIcon().exists()).toBe(true); + + return waitForRequests().then(() => { + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + it('shows additional links', () => { + const links = wrapper.findAll('[data-testid="milestone-combobox-extra-links"]'); + links.wrappers.forEach((item, idx) => { + expect(item.text()).toBe(extraLinks[idx].text); + expect(item.attributes('href')).toBe(extraLinks[idx].url); + }); + }); + }); + + describe('post-initialization behavior', () => { + describe('when the parent component provides an `id` binding', () => { + const id = '8'; + + beforeEach(() => { + createComponent({}, { id }); + + return waitForRequests(); + }); + + it('adds the provided ID to the GlDropdown instance', () => { + expect(wrapper.attributes().id).toBe(id); + }); + }); + + describe('when milestones are pre-selected', () => { + beforeEach(() => { + createComponent({ value: projectMilestones }); + + return waitForRequests(); + }); + + it('renders the pre-selected project milestones', () => { + expect(findButtonContent().text()).toBe('v0.1 + 5 more'); + }); + }); + + describe('when the search query is updated', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests({ andClearMocks: true }); + }); + + it('requeries the search when the search query is updated', () => { + updateQuery('v1.2.3'); + + return waitForRequests().then(() => { + expect(searchApiCallSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when the Enter is pressed', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests({ andClearMocks: true }); + }); + + it('requeries the search when Enter is pressed', () => { + findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + + return waitForRequests().then(() => { + expect(searchApiCallSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('when no results are found', () => { + beforeEach(() => { + projectMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + createComponent(); + + return waitForRequests(); + }); + + describe('when the search query is empty', () => { + it('renders a "no results" message', () => { + expect(findNoResults().text()).toBe(s__('MilestoneCombobox|No matching results')); + }); + }); + }); + + describe('project milestones', () => { + describe('when the project milestones search returns results', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders the project milestones section in the dropdown', () => { + expect(findProjectMilestonesSection().exists()).toBe(true); + }); + + it('renders the "Project milestones" heading with a total number indicator', () => { + expect( + findProjectMilestonesSection() + .find('[data-testid="milestone-results-section-header"]') + .text(), + ).toBe('Project milestones 6'); + }); + + it("does not render an error message in the project milestone section's body", () => { + expect(projectMilestoneSectionContainsErrorMessage()).toBe(false); + }); + + it('renders each project milestones as a selectable item', () => { + const dropdownItems = findProjectMilestonesDropdownItems(); + + projectMilestones.forEach((milestone, i) => { + expect(dropdownItems.at(i).text()).toBe(milestone.title); + }); + }); + }); + + describe('when the project milestones search returns no results', () => { + beforeEach(() => { + projectMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); + + createComponent(); + + return waitForRequests(); + }); + + it('does not render the project milestones section in the dropdown', () => { + expect(findProjectMilestonesSection().exists()).toBe(false); + }); + }); + + describe('when the project milestones search returns an error', () => { + beforeEach(() => { + projectMilestonesApiCallSpy = jest.fn().mockReturnValue([500]); + searchApiCallSpy = jest.fn().mockReturnValue([500]); + + createComponent({ value: [] }); + + return waitForRequests(); + }); + + it('renders the project milestones section in the dropdown', () => { + expect(findProjectMilestonesSection().exists()).toBe(true); + }); + + it("renders an error message in the project milestones section's body", () => { + expect(projectMilestoneSectionContainsErrorMessage()).toBe(true); + }); + }); + }); + + describe('selection', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders a checkmark by the selected item', async () => { + selectFirstProjectMilestone(); + + await localVue.nextTick(); + + expect( + findFirstProjectMilestonesDropdownItem() + .find('span') + .classes('selected-item'), + ).toBe(false); + + selectFirstProjectMilestone(); + + return localVue.nextTick().then(() => { + expect( + findFirstProjectMilestonesDropdownItem() + .find('span') + .classes('selected-item'), + ).toBe(true); + }); + }); + + describe('when a project milestones is selected', () => { + beforeEach(() => { + createComponent(); + projectMilestonesApiCallSpy = jest + .fn() + .mockReturnValue([200, [{ title: 'v1.0' }], { [X_TOTAL_HEADER]: '1' }]); + + return waitForRequests(); + }); + + it("displays the project milestones name in the dropdown's button", async () => { + selectFirstProjectMilestone(); + await localVue.nextTick(); + + expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone')); + + selectFirstProjectMilestone(); + + await localVue.nextTick(); + expect(findButtonContent().text()).toBe('v1.0'); + }); + + it('updates the v-model binding with the project milestone title', () => { + expect(wrapper.vm.value).toEqual([]); + + selectFirstProjectMilestone(); + + expect(wrapper.vm.value).toEqual(['v1.0']); + }); + }); + }); + }); +}); diff --git a/spec/frontend/milestones/project_milestone_combobox_spec.js b/spec/frontend/milestones/project_milestone_combobox_spec.js deleted file mode 100644 index 60d68aa5816..00000000000 --- a/spec/frontend/milestones/project_milestone_combobox_spec.js +++ /dev/null @@ -1,186 +0,0 @@ -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -import { shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; -import { ENTER_KEY } from '~/lib/utils/keys'; -import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; -import { milestones as projectMilestones } from './mock_data'; - -const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search'; -const TEST_SEARCH = 'TEST_SEARCH'; - -const extraLinks = [ - { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' }, - { text: 'Manage milestones', url: '/h5bp/html5-boilerplate/-/milestones' }, -]; - -const preselectedMilestones = []; -const projectId = '8'; - -describe('Milestone selector', () => { - let wrapper; - let mock; - - const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' }); - - const findSearchBox = () => wrapper.find(GlSearchBoxByType); - - const factory = (options = {}) => { - wrapper = shallowMount(MilestoneCombobox, { - ...options, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - gon.api_version = 'v4'; - - mock.onGet('/api/v4/projects/8/milestones').reply(200, projectMilestones); - - factory({ - propsData: { - projectId, - preselectedMilestones, - extraLinks, - }, - }); - }); - - afterEach(() => { - mock.restore(); - wrapper.destroy(); - wrapper = null; - }); - - it('renders the dropdown', () => { - expect(wrapper.find(GlDropdown)).toExist(); - }); - - it('renders additional links', () => { - const links = wrapper.findAll('[href]'); - links.wrappers.forEach((item, idx) => { - expect(item.text()).toBe(extraLinks[idx].text); - expect(item.attributes('href')).toBe(extraLinks[idx].url); - }); - }); - - describe('before results', () => { - it('should show a loading icon', () => { - const request = mock.onGet(TEST_SEARCH_ENDPOINT, { - params: { search: TEST_SEARCH, scope: 'milestones' }, - }); - - expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - - return wrapper.vm.$nextTick().then(() => { - request.reply(200, []); - }); - }); - - it('should not show any dropdown items', () => { - expect(wrapper.findAll('[role="milestone option"]')).toHaveLength(0); - }); - - it('should have "No milestone" as the button text', () => { - expect(wrapper.find({ ref: 'buttonText' }).text()).toBe('No milestone'); - }); - }); - - describe('with empty results', () => { - beforeEach(() => { - mock - .onGet(TEST_SEARCH_ENDPOINT, { params: { search: TEST_SEARCH, scope: 'milestones' } }) - .reply(200, []); - findSearchBox().vm.$emit('input', TEST_SEARCH); - return axios.waitForAll(); - }); - - it('should display that no matching items are found', () => { - expect(findNoResultsMessage().exists()).toBe(true); - }); - }); - - describe('with results', () => { - let items; - beforeEach(() => { - mock - .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'v0.1', scope: 'milestones' } }) - .reply(200, [ - { - id: 41, - iid: 6, - project_id: 8, - title: 'v0.1', - description: '', - state: 'active', - created_at: '2020-04-04T01:30:40.051Z', - updated_at: '2020-04-04T01:30:40.051Z', - due_date: null, - start_date: null, - web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6', - }, - ]); - findSearchBox().vm.$emit('input', 'v0.1'); - return axios.waitForAll().then(() => { - items = wrapper.findAll('[role="milestone option"]'); - }); - }); - - it('should display one item per result', () => { - expect(items).toHaveLength(1); - }); - - it('should emit a change if an item is clicked', () => { - items.at(0).vm.$emit('click'); - expect(wrapper.emitted().change.length).toBe(1); - expect(wrapper.emitted().change[0]).toEqual([[{ title: 'v0.1' }]]); - }); - - it('should not have a selecton icon on any item', () => { - items.wrappers.forEach(item => { - expect(item.find('.selected-item').exists()).toBe(false); - }); - }); - - it('should have a selecton icon if an item is clicked', () => { - items.at(0).vm.$emit('click'); - expect(wrapper.find('.selected-item').exists()).toBe(true); - }); - - it('should not display a message about no results', () => { - expect(findNoResultsMessage().exists()).toBe(false); - }); - }); - - describe('when Enter is pressed', () => { - beforeEach(() => { - factory({ - propsData: { - projectId, - preselectedMilestones, - extraLinks, - }, - data() { - return { - searchQuery: 'TEST_SEARCH', - }; - }, - }); - - mock - .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } }) - .reply(200, []); - }); - - it('should trigger a search', async () => { - mock.resetHistory(); - - findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); - - await axios.waitForAll(); - - expect(mock.history.get.length).toBe(1); - expect(mock.history.get[0].url).toBe(TEST_SEARCH_ENDPOINT); - }); - }); -}); diff --git a/spec/frontend/milestones/stores/actions_spec.js b/spec/frontend/milestones/stores/actions_spec.js index ad73d0e4238..e14eb9280e4 100644 --- a/spec/frontend/milestones/stores/actions_spec.js +++ b/spec/frontend/milestones/stores/actions_spec.js @@ -41,6 +41,14 @@ describe('Milestone combobox Vuex store actions', () => { }); }); + describe('clearSelectedMilestones', () => { + it(`commits ${types.CLEAR_SELECTED_MILESTONES} with the new selected milestones name`, () => { + testAction(actions.clearSelectedMilestones, null, state, [ + { type: types.CLEAR_SELECTED_MILESTONES }, + ]); + }); + }); + describe('toggleMilestones', () => { const selectedMilestone = 'v1.2.3'; it(`commits ${types.ADD_SELECTED_MILESTONE} with the new selected milestone name`, () => { @@ -58,13 +66,13 @@ describe('Milestone combobox Vuex store actions', () => { }); describe('search', () => { - it(`commits ${types.SET_QUERY} with the new search query`, () => { - const query = 'v1.0'; + it(`commits ${types.SET_SEARCH_QUERY} with the new search query`, () => { + const searchQuery = 'v1.0'; testAction( actions.search, - query, + searchQuery, state, - [{ type: types.SET_QUERY, payload: query }], + [{ type: types.SET_SEARCH_QUERY, payload: searchQuery }], [{ type: 'searchMilestones' }], ); }); diff --git a/spec/frontend/milestones/stores/mutations_spec.js b/spec/frontend/milestones/stores/mutations_spec.js index 8f8ce3c87ad..236e0a49ebe 100644 --- a/spec/frontend/milestones/stores/mutations_spec.js +++ b/spec/frontend/milestones/stores/mutations_spec.js @@ -14,7 +14,7 @@ describe('Milestones combobox Vuex store mutations', () => { expect(state).toEqual({ projectId: null, groupId: null, - query: '', + searchQuery: '', matches: { projectMilestones: { list: [], @@ -46,6 +46,20 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); + describe(`${types.CLEAR_SELECTED_MILESTONES}`, () => { + it('clears the selected milestones', () => { + const selectedMilestones = ['v1.2.3']; + + // Set state.selectedMilestones + mutations[types.SET_SELECTED_MILESTONES](state, selectedMilestones); + + // Clear state.selectedMilestones + mutations[types.CLEAR_SELECTED_MILESTONES](state); + + expect(state.selectedMilestones).toEqual([]); + }); + }); + describe(`${types.ADD_SELECTED_MILESTONESs}`, () => { it('adds the selected milestones', () => { const selectedMilestone = 'v1.2.3'; @@ -67,12 +81,12 @@ describe('Milestones combobox Vuex store mutations', () => { }); }); - describe(`${types.SET_QUERY}`, () => { + describe(`${types.SET_SEARCH_QUERY}`, () => { it('updates the search query', () => { const newQuery = 'hello'; - mutations[types.SET_QUERY](state, newQuery); + mutations[types.SET_SEARCH_QUERY](state, newQuery); - expect(state.query).toBe(newQuery); + expect(state.searchQuery).toBe(newQuery); }); }); diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js index e7b7766c0d0..549cb7b98d9 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -22,7 +22,7 @@ describe('releases/util.js', () => { tagName: 'tag-name', name: 'Release name', description: 'Release description', - milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }], + milestones: ['13.2', '13.3'], assets: { links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }], }, @@ -73,18 +73,6 @@ describe('releases/util.js', () => { expect(releaseToApiJson(release)).toMatchObject(expectedJson); }); }); - - describe('when release.milestones is falsy', () => { - it('includes a "milestone" property in the returned result as an empty array', () => { - const release = {}; - - const expectedJson = { - milestones: [], - }; - - expect(releaseToApiJson(release)).toMatchObject(expectedJson); - }); - }); }); describe('apiJsonToRelease', () => { diff --git a/spec/lib/gitlab/bulk_import/client_spec.rb b/spec/lib/bulk_imports/clients/http_spec.rb index a6f8dd6d194..5bf386a8ce2 100644 --- a/spec/lib/gitlab/bulk_import/client_spec.rb +++ b/spec/lib/bulk_imports/clients/http_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::BulkImport::Client do +RSpec.describe BulkImports::Clients::Http do include ImportSpecHelper let(:uri) { 'http://gitlab.example' } diff --git a/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb b/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb new file mode 100644 index 00000000000..885e99d1fd3 --- /dev/null +++ b/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Common::Extractors::GraphqlExtractor do + let(:graphql_client) { instance_double(BulkImports::Clients::Graphql) } + let(:import_entity) { create(:bulk_import_entity) } + let(:response) { double(original_hash: { foo: :bar }) } + let(:query) { { query: double(to_s: 'test', variables: {}) } } + let(:context) do + instance_double( + BulkImports::Pipeline::Context, + entities: [import_entity] + ) + end + + subject { described_class.new(query) } + + before do + allow(subject).to receive(:graphql_client).and_return(graphql_client) + allow(graphql_client).to receive(:parse) + end + + describe '#extract' do + before do + allow(subject).to receive(:query_variables).and_return({}) + allow(graphql_client).to receive(:execute).and_return(response) + end + + it 'returns an enumerator with fetched results' do + response = subject.extract(context) + + expect(response).to be_instance_of(Enumerator) + expect(response.first).to eq({ foo: :bar }) + end + end + + describe 'query variables' do + before do + allow(graphql_client).to receive(:execute).and_return(response) + end + + context 'when variables are present' do + let(:query) { { query: double(to_s: 'test', variables: { full_path: :source_full_path }) } } + + it 'builds graphql query variables for import entity' do + expected_variables = { full_path: import_entity.source_full_path } + + expect(graphql_client).to receive(:execute).with(anything, expected_variables) + + subject.extract(context).first + end + end + + context 'when no variables are present' do + let(:query) { { query: double(to_s: 'test', variables: nil) } } + + it 'returns empty hash' do + expect(graphql_client).to receive(:execute).with(anything, nil) + + subject.extract(context).first + end + end + + context 'when variables are empty hash' do + let(:query) { { query: double(to_s: 'test', variables: {}) } } + + it 'makes graphql request with empty hash' do + expect(graphql_client).to receive(:execute).with(anything, {}) + + subject.extract(context).first + end + end + end +end diff --git a/spec/lib/bulk_imports/common/transformers/graphql_cleaner_transformer_spec.rb b/spec/lib/bulk_imports/common/transformers/graphql_cleaner_transformer_spec.rb new file mode 100644 index 00000000000..8f39b6e7c93 --- /dev/null +++ b/spec/lib/bulk_imports/common/transformers/graphql_cleaner_transformer_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Common::Transformers::GraphqlCleanerTransformer do + describe '#transform' do + let_it_be(:expected_output) do + { + 'name' => 'test', + 'fullName' => 'test', + 'description' => 'test', + 'labels' => [ + { 'title' => 'label1' }, + { 'title' => 'label2' }, + { 'title' => 'label3' } + ] + } + end + + it 'deep cleans hash from GraphQL keys' do + data = { + 'data' => { + 'group' => { + 'name' => 'test', + 'fullName' => 'test', + 'description' => 'test', + 'labels' => { + 'edges' => [ + { 'node' => { 'title' => 'label1' } }, + { 'node' => { 'title' => 'label2' } }, + { 'node' => { 'title' => 'label3' } } + ] + } + } + } + } + + transformed_data = described_class.new.transform(nil, data) + + expect(transformed_data).to eq(expected_output) + end + + context 'when data does not have data/group nesting' do + it 'deep cleans hash from GraphQL keys' do + data = { + 'name' => 'test', + 'fullName' => 'test', + 'description' => 'test', + 'labels' => { + 'edges' => [ + { 'node' => { 'title' => 'label1' } }, + { 'node' => { 'title' => 'label2' } }, + { 'node' => { 'title' => 'label3' } } + ] + } + } + + transformed_data = described_class.new.transform(nil, data) + + expect(transformed_data).to eq(expected_output) + end + end + + context 'when data is not a hash' do + it 'does not perform transformation' do + data = 'test' + + transformed_data = described_class.new.transform(nil, data) + + expect(transformed_data).to eq(data) + end + end + + context 'when nested data is not an array or hash' do + it 'only removes top level data/group keys' do + data = { + 'data' => { + 'group' => 'test' + } + } + + transformed_data = described_class.new.transform(nil, data) + + expect(transformed_data).to eq('test') + end + end + end +end diff --git a/spec/lib/bulk_imports/common/transformers/underscorify_keys_transformer_spec.rb b/spec/lib/bulk_imports/common/transformers/underscorify_keys_transformer_spec.rb new file mode 100644 index 00000000000..cdffa750694 --- /dev/null +++ b/spec/lib/bulk_imports/common/transformers/underscorify_keys_transformer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Common::Transformers::UnderscorifyKeysTransformer do + describe '#transform' do + it 'deep underscorifies hash keys' do + data = { + 'fullPath' => 'Foo', + 'snakeKeys' => { + 'snakeCaseKey' => 'Bar', + 'moreKeys' => { + 'anotherSnakeCaseKey' => 'Test' + } + } + } + + transformed_data = described_class.new.transform(nil, data) + + expect(transformed_data).to have_key('full_path') + expect(transformed_data).to have_key('snake_keys') + expect(transformed_data['snake_keys']).to have_key('snake_case_key') + expect(transformed_data['snake_keys']).to have_key('more_keys') + expect(transformed_data.dig('snake_keys', 'more_keys')).to have_key('another_snake_case_key') + end + end +end diff --git a/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb b/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb new file mode 100644 index 00000000000..8c300400e11 --- /dev/null +++ b/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Loaders::GroupLoader do + describe '#load' do + let(:user) { create(:user) } + let(:data) { { foo: :bar } } + let(:service_double) { instance_double(::Groups::CreateService) } + let(:context) do + instance_double( + BulkImports::Pipeline::Context, + current_user: user + ) + end + + subject { described_class.new } + + context 'when user can create group' do + shared_examples 'calls Group Create Service to create a new group' do + it 'calls Group Create Service to create a new group' do + expect(::Groups::CreateService).to receive(:new).with(context.current_user, data).and_return(service_double) + expect(service_double).to receive(:execute) + + subject.load(context, data) + end + end + + context 'when there is no parent group' do + before do + allow(Ability).to receive(:allowed?).with(user, :create_group).and_return(true) + end + + include_examples 'calls Group Create Service to create a new group' + end + + context 'when there is parent group' do + let(:parent) { create(:group) } + let(:data) { { 'parent_id' => parent.id } } + + before do + allow(Ability).to receive(:allowed?).with(user, :create_subgroup, parent).and_return(true) + end + + include_examples 'calls Group Create Service to create a new group' + end + end + + context 'when user cannot create group' do + shared_examples 'does not create new group' do + it 'does not create new group' do + expect(::Groups::CreateService).not_to receive(:new) + + subject.load(context, data) + end + end + + context 'when there is no parent group' do + before do + allow(Ability).to receive(:allowed?).with(user, :create_group).and_return(false) + end + + include_examples 'does not create new group' + end + + context 'when there is parent group' do + let(:parent) { create(:group) } + let(:data) { { 'parent_id' => parent.id } } + + before do + allow(Ability).to receive(:allowed?).with(user, :create_subgroup, parent).and_return(false) + end + + include_examples 'does not create new group' + end + end + end +end diff --git a/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb new file mode 100644 index 00000000000..37a3bbd284a --- /dev/null +++ b/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Pipelines::GroupPipeline do + describe '#run' do + let(:user) { create(:user) } + let(:parent) { create(:group) } + let(:entity) do + instance_double( + BulkImports::Entity, + source_full_path: 'source/full/path', + destination_name: 'My Destination Group', + destination_namespace: parent.full_path + ) + end + + let(:entities) { [entity] } + let(:context) do + instance_double( + BulkImports::Pipeline::Context, + current_user: user, + entities: entities + ) + end + + let(:group_data) do + { + 'data' => { + 'group' => { + 'name' => 'source_name', + 'fullPath' => 'source/full/path', + 'visibility' => 'private', + 'projectCreationLevel' => 'developer', + 'subgroupCreationLevel' => 'maintainer', + 'description' => 'Group Description', + 'emailsDisabled' => true, + 'lfsEnabled' => false, + 'mentionsDisabled' => true + } + } + } + end + + subject { described_class.new } + + before do + allow_next_instance_of(BulkImports::Common::Extractors::GraphqlExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return([group_data]) + end + + parent.add_owner(user) + end + + it 'imports new group into destination group' do + group_path = 'my-destination-group' + + subject.run(context) + + imported_group = Group.find_by_path(group_path) + + expect(imported_group).not_to be_nil + expect(imported_group.parent).to eq(parent) + expect(imported_group.path).to eq(group_path) + expect(imported_group.description).to eq(group_data.dig('data', 'group', 'description')) + expect(imported_group.visibility).to eq(group_data.dig('data', 'group', 'visibility')) + expect(imported_group.project_creation_level).to eq(Gitlab::Access.project_creation_string_options[group_data.dig('data', 'group', 'projectCreationLevel')]) + expect(imported_group.subgroup_creation_level).to eq(Gitlab::Access.subgroup_creation_string_options[group_data.dig('data', 'group', 'subgroupCreationLevel')]) + expect(imported_group.lfs_enabled?).to eq(group_data.dig('data', 'group', 'lfsEnabled')) + expect(imported_group.emails_disabled?).to eq(group_data.dig('data', 'group', 'emailsDisabled')) + expect(imported_group.mentions_disabled?).to eq(group_data.dig('data', 'group', 'mentionsDisabled')) + end + end + + describe 'pipeline parts' do + it { expect(described_class).to include_module(BulkImports::Pipeline) } + it { expect(described_class).to include_module(BulkImports::Pipeline::Attributes) } + it { expect(described_class).to include_module(BulkImports::Pipeline::Runner) } + + it 'has extractors' do + expect(described_class.extractors) + .to contain_exactly( + { + klass: BulkImports::Common::Extractors::GraphqlExtractor, + options: { + query: BulkImports::Groups::Graphql::GetGroupQuery + } + } + ) + end + + it 'has transformers' do + expect(described_class.transformers) + .to contain_exactly( + { klass: BulkImports::Common::Transformers::GraphqlCleanerTransformer, options: nil }, + { klass: BulkImports::Common::Transformers::UnderscorifyKeysTransformer, options: nil }, + { klass: BulkImports::Groups::Transformers::GroupAttributesTransformer, options: nil }) + end + + it 'has loaders' do + expect(described_class.loaders).to contain_exactly({ klass: BulkImports::Groups::Loaders::GroupLoader, options: nil }) + end + end +end diff --git a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb new file mode 100644 index 00000000000..3fdbb34d89b --- /dev/null +++ b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do + describe '#transform' do + let(:user) { create(:user) } + let(:parent) { create(:group) } + let(:group) { create(:group, name: 'My Source Group', parent: parent) } + let(:entity) do + instance_double( + BulkImports::Entity, + source_full_path: 'source/full/path', + destination_name: group.name, + destination_namespace: parent.full_path + ) + end + + let(:entities) { [entity] } + let(:context) do + instance_double( + BulkImports::Pipeline::Context, + current_user: user, + entities: entities + ) + end + + let(:data) do + { + 'name' => 'source_name', + 'full_path' => 'source/full/path', + 'visibility' => 'private', + 'project_creation_level' => 'developer', + 'subgroup_creation_level' => 'maintainer' + } + end + + subject { described_class.new } + + it 'transforms name to destination name' do + transformed_data = subject.transform(context, data) + + expect(transformed_data['name']).not_to eq('source_name') + expect(transformed_data['name']).to eq(group.name) + end + + it 'removes full path' do + transformed_data = subject.transform(context, data) + + expect(transformed_data).not_to have_key('full_path') + end + + it 'transforms path to parameterized name' do + transformed_data = subject.transform(context, data) + + expect(transformed_data['path']).to eq(group.name.parameterize) + end + + it 'transforms visibility level' do + visibility = data['visibility'] + transformed_data = subject.transform(context, data) + + expect(transformed_data).not_to have_key('visibility') + expect(transformed_data['visibility_level']).to eq(Gitlab::VisibilityLevel.string_options[visibility]) + end + + it 'transforms project creation level' do + level = data['project_creation_level'] + transformed_data = subject.transform(context, data) + + expect(transformed_data['project_creation_level']).to eq(Gitlab::Access.project_creation_string_options[level]) + end + + it 'transforms subgroup creation level' do + level = data['subgroup_creation_level'] + transformed_data = subject.transform(context, data) + + expect(transformed_data['subgroup_creation_level']).to eq(Gitlab::Access.subgroup_creation_string_options[level]) + end + + describe 'parent group transformation' do + it 'sets parent id' do + transformed_data = subject.transform(context, data) + + expect(transformed_data['parent_id']).to eq(parent.id) + end + + context 'when destination namespace is user namespace' do + let(:entity) do + instance_double( + BulkImports::Entity, + source_full_path: 'source/full/path', + destination_name: group.name, + destination_namespace: user.namespace.full_path + ) + end + + it 'does not set parent id' do + transformed_data = subject.transform(context, data) + + expect(transformed_data).not_to have_key('parent_id') + end + end + end + end +end diff --git a/spec/lib/bulk_imports/importers/group_importer_spec.rb b/spec/lib/bulk_imports/importers/group_importer_spec.rb new file mode 100644 index 00000000000..903c8cf0398 --- /dev/null +++ b/spec/lib/bulk_imports/importers/group_importer_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Importers::GroupImporter do + let(:user) { create(:user) } + let(:bulk_import) { create(:bulk_import) } + let(:bulk_import_entity) { create(:bulk_import_entity, bulk_import: bulk_import) } + let(:bulk_import_configuration) { create(:bulk_import_configuration, bulk_import: bulk_import) } + let(:context) do + instance_double( + BulkImports::Pipeline::Context, + current_user: user, + entities: [bulk_import_entity], + configuration: bulk_import_configuration + ) + end + + subject { described_class.new(bulk_import_entity.id) } + + describe '#execute' do + before do + allow(BulkImports::Pipeline::Context).to receive(:new).and_return(context) + end + + context 'when import entity does not have parent' do + it 'executes GroupPipeline' do + expect_next_instance_of(BulkImports::Groups::Pipelines::GroupPipeline) do |pipeline| + expect(pipeline).to receive(:run).with(context) + end + + subject.execute + end + end + + context 'when import entity has parent' do + let(:bulk_import_entity_parent) { create(:bulk_import_entity, bulk_import: bulk_import) } + let(:bulk_import_entity) { create(:bulk_import_entity, bulk_import: bulk_import, parent: bulk_import_entity_parent) } + + it 'does not execute GroupPipeline' do + expect(BulkImports::Groups::Pipelines::GroupPipeline).not_to receive(:new) + + subject.execute + end + end + end +end diff --git a/spec/lib/bulk_imports/pipeline/attributes_spec.rb b/spec/lib/bulk_imports/pipeline/attributes_spec.rb new file mode 100644 index 00000000000..54c5dbd4cae --- /dev/null +++ b/spec/lib/bulk_imports/pipeline/attributes_spec.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Pipeline::Attributes do + describe 'pipeline attributes' do + before do + stub_const('BulkImports::Extractor', Class.new) + stub_const('BulkImports::Transformer', Class.new) + stub_const('BulkImports::Loader', Class.new) + + klass = Class.new do + include BulkImports::Pipeline::Attributes + + extractor BulkImports::Extractor, { foo: :bar } + transformer BulkImports::Transformer, { foo: :bar } + loader BulkImports::Loader, { foo: :bar } + end + + stub_const('BulkImports::MyPipeline', klass) + end + + describe 'getters' do + it 'retrieves class attributes' do + expect(BulkImports::MyPipeline.extractors).to contain_exactly({ klass: BulkImports::Extractor, options: { foo: :bar } }) + expect(BulkImports::MyPipeline.transformers).to contain_exactly({ klass: BulkImports::Transformer, options: { foo: :bar } }) + expect(BulkImports::MyPipeline.loaders).to contain_exactly({ klass: BulkImports::Loader, options: { foo: :bar } }) + end + end + + describe 'setters' do + it 'sets class attributes' do + klass = Class.new + options = { test: :test } + + BulkImports::MyPipeline.extractor(klass, options) + BulkImports::MyPipeline.transformer(klass, options) + BulkImports::MyPipeline.loader(klass, options) + + expect(BulkImports::MyPipeline.extractors) + .to contain_exactly( + { klass: BulkImports::Extractor, options: { foo: :bar } }, + { klass: klass, options: options }) + + expect(BulkImports::MyPipeline.transformers) + .to contain_exactly( + { klass: BulkImports::Transformer, options: { foo: :bar } }, + { klass: klass, options: options }) + + expect(BulkImports::MyPipeline.loaders) + .to contain_exactly( + { klass: BulkImports::Loader, options: { foo: :bar } }, + { klass: klass, options: options }) + end + end + end +end diff --git a/spec/lib/bulk_imports/pipeline/context_spec.rb b/spec/lib/bulk_imports/pipeline/context_spec.rb new file mode 100644 index 00000000000..0f32accd369 --- /dev/null +++ b/spec/lib/bulk_imports/pipeline/context_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Pipeline::Context do + describe '#initialize' do + it 'initializes with permitted attributes' do + args = { + current_user: create(:user), + entities: [], + configuration: create(:bulk_import_configuration) + } + + context = described_class.new(args) + + args.each do |k, v| + expect(context.public_send(k)).to eq(v) + end + end + + context 'when invalid argument is passed' do + it 'raises NoMethodError' do + expect { described_class.new(test: 'test').test }.to raise_exception(NoMethodError) + end + end + end +end diff --git a/spec/lib/bulk_imports/pipeline/runner_spec.rb b/spec/lib/bulk_imports/pipeline/runner_spec.rb new file mode 100644 index 00000000000..58ec1d1afca --- /dev/null +++ b/spec/lib/bulk_imports/pipeline/runner_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Pipeline::Runner do + describe 'pipeline runner' do + before do + extractor = Class.new do + def initialize(options = {}); end + + def extract(context); end + end + + transformer = Class.new do + def initialize(options = {}); end + + def transform(context, entry); end + end + + loader = Class.new do + def initialize(options = {}); end + + def load(context, entry); end + end + + stub_const('BulkImports::Extractor', extractor) + stub_const('BulkImports::Transformer', transformer) + stub_const('BulkImports::Loader', loader) + + pipeline = Class.new do + include BulkImports::Pipeline + + extractor BulkImports::Extractor + transformer BulkImports::Transformer + loader BulkImports::Loader + end + + stub_const('BulkImports::MyPipeline', pipeline) + end + + it 'runs pipeline extractor, transformer, loader' do + context = instance_double(BulkImports::Pipeline::Context) + entries = [{ foo: :bar }] + + expect_next_instance_of(BulkImports::Extractor) do |extractor| + expect(extractor).to receive(:extract).with(context).and_return(entries) + end + + expect_next_instance_of(BulkImports::Transformer) do |transformer| + expect(transformer).to receive(:transform).with(context, entries.first).and_return(entries.first) + end + + expect_next_instance_of(BulkImports::Loader) do |loader| + expect(loader).to receive(:load).with(context, entries.first) + end + + BulkImports::MyPipeline.new.run(context) + end + end +end diff --git a/spec/lib/gitlab/database/postgres_partition_spec.rb b/spec/lib/gitlab/database/postgres_partition_spec.rb new file mode 100644 index 00000000000..5a44090d5ae --- /dev/null +++ b/spec/lib/gitlab/database/postgres_partition_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Database::PostgresPartition, type: :model do + let(:schema) { 'gitlab_partitions_dynamic' } + let(:name) { '_test_partition_01' } + let(:identifier) { "#{schema}.#{name}" } + + before do + ActiveRecord::Base.connection.execute(<<~SQL) + CREATE TABLE public._test_partitioned_table ( + id serial NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY (id, created_at) + ) PARTITION BY RANGE(created_at); + + CREATE TABLE #{identifier} PARTITION OF public._test_partitioned_table + FOR VALUES FROM ('2020-01-01') to ('2020-02-01'); + SQL + end + + def find(identifier) + described_class.by_identifier(identifier) + end + + describe 'associations' do + it { is_expected.to belong_to(:postgres_partitioned_table).with_primary_key('identifier').with_foreign_key('parent_identifier') } + end + + it_behaves_like 'a postgres model' + + describe '.for_parent_table' do + let(:second_name) { '_test_partition_02' } + + before do + ActiveRecord::Base.connection.execute(<<~SQL) + CREATE TABLE #{schema}.#{second_name} PARTITION OF public._test_partitioned_table + FOR VALUES FROM ('2020-02-01') to ('2020-03-01'); + + CREATE TABLE #{schema}._test_other_table ( + id serial NOT NULL, + created_at timestamptz NOT NULL, + PRIMARY KEY (id, created_at) + ) PARTITION BY RANGE(created_at); + + CREATE TABLE #{schema}._test_other_partition_01 PARTITION OF #{schema}._test_other_table + FOR VALUES FROM ('2020-01-01') to ('2020-02-01'); + SQL + end + + it 'returns partitions for the parent table in the current schema' do + partitions = described_class.for_parent_table('_test_partitioned_table') + + expect(partitions.count).to eq(2) + expect(partitions.pluck(:name)).to eq([name, second_name]) + end + + it 'does not return partitions for tables not in the current schema' do + expect(described_class.for_parent_table('_test_other_table').count).to eq(0) + end + end + + describe '#parent_identifier' do + it 'returns the parent table identifier' do + expect(find(identifier).parent_identifier).to eq('public._test_partitioned_table') + end + end + + describe '#condition' do + it 'returns the condition for the partitioned values' do + expect(find(identifier).condition).to eq("FOR VALUES FROM ('2020-01-01 00:00:00+00') TO ('2020-02-01 00:00:00+00')") + end + end +end diff --git a/spec/lib/gitlab/database/postgres_partitioned_table_spec.rb b/spec/lib/gitlab/database/postgres_partitioned_table_spec.rb index 413a4fa8842..a25b90ca5a1 100644 --- a/spec/lib/gitlab/database/postgres_partitioned_table_spec.rb +++ b/spec/lib/gitlab/database/postgres_partitioned_table_spec.rb @@ -33,6 +33,10 @@ RSpec.describe Gitlab::Database::PostgresPartitionedTable, type: :model do described_class.by_identifier(identifier) end + describe 'associations' do + it { is_expected.to have_many(:postgres_partitions).with_primary_key('identifier').with_foreign_key('parent_identifier') } + end + it_behaves_like 'a postgres model' describe '#dynamic?' do diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb new file mode 100644 index 00000000000..1247156a0ed --- /dev/null +++ b/spec/services/bulk_import_service_spec.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImportService do + let(:user) { create(:user) } + let(:credentials) { { url: 'http://gitlab.example', access_token: 'token' } } + let(:params) do + [ + { + source_type: 'group_entity', + source_full_path: 'full/path/to/group1', + destination_name: 'destination group 1', + destination_namespace: 'full/path/to/destination1' + }, + { + source_type: 'group_entity', + source_full_path: 'full/path/to/group2', + destination_name: 'destination group 2', + destination_namespace: 'full/path/to/destination2' + }, + { + source_type: 'project_entity', + source_full_path: 'full/path/to/project1', + destination_name: 'destination project 1', + destination_namespace: 'full/path/to/destination1' + } + ] + end + + subject { described_class.new(user, params, credentials) } + + describe '#execute' do + it 'creates bulk import' do + expect { subject.execute }.to change { BulkImport.count }.by(1) + end + + it 'creates bulk import entities' do + expect { subject.execute }.to change { BulkImports::Entity.count }.by(3) + end + + it 'creates bulk import configuration' do + expect { subject.execute }.to change { BulkImports::Configuration.count }.by(1) + end + + it 'updates bulk import state' do + expect_next_instance_of(BulkImport) do |bulk_import| + expect(bulk_import).to receive(:start!) + end + + subject.execute + end + + it 'enqueues BulkImportWorker' do + expect(BulkImportWorker).to receive(:perform_async) + + subject.execute + end + end +end diff --git a/spec/support/helpers/features/releases_helpers.rb b/spec/support/helpers/features/releases_helpers.rb index 0d46918b05c..44087f71cfa 100644 --- a/spec/support/helpers/features/releases_helpers.rb +++ b/spec/support/helpers/features/releases_helpers.rb @@ -66,7 +66,7 @@ module Spec focused_element.send_keys(:enter) # Wait for the dropdown to be rendered - page.find('.project-milestone-combobox .dropdown-menu') + page.find('.milestone-combobox .dropdown-menu') # Clear any existing input focused_element.attribute('value').length.times { focused_element.send_keys(:backspace) } @@ -75,7 +75,7 @@ module Spec focused_element.send_keys(milestone_title, :enter) # Wait for the search to return - page.find('.project-milestone-combobox .dropdown-item', text: milestone_title, match: :first) + page.find('.milestone-combobox .dropdown-item', text: milestone_title, match: :first) focused_element.send_keys(:arrow_down, :arrow_down, :enter) diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb new file mode 100644 index 00000000000..862dbce3177 --- /dev/null +++ b/spec/workers/bulk_import_worker_spec.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImportWorker do + let!(:bulk_import) { create(:bulk_import, :started) } + let!(:entity) { create(:bulk_import_entity, bulk_import: bulk_import) } + let(:importer) { double(execute: nil) } + + subject { described_class.new } + + describe '#perform' do + before do + allow(BulkImports::Importers::GroupImporter).to receive(:new).and_return(importer) + end + + it 'executes Group Importer' do + expect(importer).to receive(:execute) + + subject.perform(bulk_import.id) + end + + it 'updates bulk import and entity state' do + subject.perform(bulk_import.id) + + expect(bulk_import.reload.human_status_name).to eq('finished') + expect(entity.reload.human_status_name).to eq('finished') + end + + context 'when bulk import could not be found' do + it 'does nothing' do + expect(bulk_import).not_to receive(:top_level_groups) + expect(bulk_import).not_to receive(:finish!) + + subject.perform(non_existing_record_id) + end + end + end +end diff --git a/yarn.lock b/yarn.lock index 2936039b37b..24bc7883dcc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -861,15 +861,15 @@ eslint-plugin-vue "^6.2.1" vue-eslint-parser "^7.0.0" -"@gitlab/svgs@1.172.0": - version "1.172.0" - resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.172.0.tgz#4a3b15d829b66073b61c52720c7728bc3066ac89" - integrity sha512-c+gsw78qzF6H5bH6JZ0MKjqtD89m/ym/F4SqEK+ywHPRHqixmgDg8CtB782dl5rrTkW6aaIue8VcJn+Vziki0A== - -"@gitlab/ui@21.35.1": - version "21.35.1" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.35.1.tgz#5317357f613f6d8c3e48448bd94526170840b4bf" - integrity sha512-v96ejB51p+yKQdfLNsOe7ZeczMqc1WXs7ZGU/E4LQgGWwq0sMrZ504463W5R+KAgVBphH/sgRRbXPTzBPC0z6Q== +"@gitlab/svgs@1.173.0": + version "1.173.0" + resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.173.0.tgz#4030cdbb8efb7386941169fc9cefda1bae75b282" + integrity sha512-EmfoLgh0Jnz7i28zHROnwvpOcdPaavAXJEpSg8JfbYw9KcWlJyr4Zm5V6h38dNU5AcUFG1/qjUtFbfgmyBx4gA== + +"@gitlab/ui@21.35.2": + version "21.35.2" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-21.35.2.tgz#2409b3f2979f470b4072e42e32393f024a70630e" + integrity sha512-2/kdx7wXVC59UJjk2tC+HViLhDGVm0Jj/FeIQLHeJYVn0R2ZaEYAkevjyXRN33TuOcRHnInQjVXwSclCdQ7K5A== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.3.0" |