Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-10-23 18:08:42 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-10-23 18:08:42 +0300
commit9086e66ee72527839053ec6db19ed321a3b3a61b (patch)
treef2904493d8539228823f15cf4126eb8c4ffa79e3
parentb17c74a7e2cf516ed189e525291cb096411b7ac5 (diff)
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile1
-rw-r--r--Gemfile.lock8
-rw-r--r--README.md2
-rw-r--r--app/assets/javascripts/diffs/components/diff_expansion_cell.vue4
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue216
-rw-r--r--app/assets/javascripts/milestones/components/milestone_results_section.vue93
-rw-r--r--app/assets/javascripts/milestones/project_milestone_combobox.vue249
-rw-r--r--app/assets/javascripts/milestones/stores/actions.js8
-rw-r--r--app/assets/javascripts/milestones/stores/mutation_types.js3
-rw-r--r--app/assets/javascripts/milestones/stores/mutations.js7
-rw-r--r--app/assets/javascripts/milestones/stores/state.js2
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue2
-rw-r--r--app/assets/javascripts/releases/util.js4
-rw-r--r--app/controllers/import/bulk_imports_controller.rb39
-rw-r--r--app/graphql/resolvers/alert_management/alert_resolver.rb2
-rw-r--r--app/graphql/resolvers/alert_management/alert_status_counts_resolver.rb2
-rw-r--r--app/models/bulk_import.rb15
-rw-r--r--app/models/bulk_imports/entity.rb15
-rw-r--r--app/services/bulk_import_service.rb39
-rw-r--r--app/views/import/_project_status.html.haml11
-rw-r--r--app/workers/all_queues.yml8
-rw-r--r--app/workers/bulk_import_worker.rb27
-rw-r--r--changelogs/unreleased/232569-update-the-milestone-dropdown-combobox-to-display-separated-sectio.yml6
-rw-r--r--changelogs/unreleased/241267-add-postgres-partition-model.yml5
-rw-r--r--changelogs/unreleased/dast-site-validation-create-mutation-233020.yml5
-rw-r--r--config/routes/import.rb2
-rw-r--r--config/sidekiq_queues.yml2
-rw-r--r--db/migrate/20201015194852_add_index_on_state_for_dast_site_validations.rb22
-rw-r--r--db/migrate/20201019172704_add_partitions_view.rb39
-rw-r--r--db/schema_migrations/202010151948521
-rw-r--r--db/schema_migrations/202010191727041
-rw-r--r--db/structure.sql16
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql79
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json220
-rw-r--r--doc/api/graphql/reference/index.md18
-rw-r--r--doc/api/oauth2.md31
-rw-r--r--doc/development/secure_coding_guidelines.md1
-rw-r--r--doc/topics/git/partial_clone.md2
-rw-r--r--doc/user/index.md17
-rw-r--r--doc/user/project/clusters/add_eks_clusters.md9
-rw-r--r--doc/user/project/service_desk.md2
-rw-r--r--lib/bulk_imports/clients/graphql.rb31
-rw-r--r--lib/bulk_imports/clients/http.rb (renamed from lib/gitlab/bulk_import/client.rb)6
-rw-r--r--lib/bulk_imports/common/extractors/graphql_extractor.rb48
-rw-r--r--lib/bulk_imports/common/transformers/graphql_cleaner_transformer.rb54
-rw-r--r--lib/bulk_imports/common/transformers/underscorify_keys_transformer.rb19
-rw-r--r--lib/bulk_imports/groups/graphql/get_group_query.rb38
-rw-r--r--lib/bulk_imports/groups/loaders/group_loader.rb31
-rw-r--r--lib/bulk_imports/groups/pipelines/group_pipeline.rb19
-rw-r--r--lib/bulk_imports/groups/transformers/group_attributes_transformer.rb85
-rw-r--r--lib/bulk_imports/importers/group_importer.rb34
-rw-r--r--lib/bulk_imports/pipeline.rb12
-rw-r--r--lib/bulk_imports/pipeline/attributes.rb41
-rw-r--r--lib/bulk_imports/pipeline/context.rb33
-rw-r--r--lib/bulk_imports/pipeline/runner.rb39
-rw-r--r--lib/gitlab/database/partitioning/monthly_strategy.rb19
-rw-r--r--lib/gitlab/database/postgres_partition.rb23
-rw-r--r--lib/gitlab/database/postgres_partitioned_table.rb2
-rw-r--r--locale/gitlab.pot33
-rw-r--r--package.json4
-rw-r--r--spec/controllers/import/bulk_imports_controller_spec.rb24
-rw-r--r--spec/factories/bulk_import.rb16
-rw-r--r--spec/factories/bulk_import/entities.rb20
-rw-r--r--spec/factories/import_configurations.rb10
-rw-r--r--spec/frontend/milestones/milestone_combobox_spec.js354
-rw-r--r--spec/frontend/milestones/project_milestone_combobox_spec.js186
-rw-r--r--spec/frontend/milestones/stores/actions_spec.js16
-rw-r--r--spec/frontend/milestones/stores/mutations_spec.js22
-rw-r--r--spec/frontend/releases/util_spec.js14
-rw-r--r--spec/lib/bulk_imports/clients/http_spec.rb (renamed from spec/lib/gitlab/bulk_import/client_spec.rb)2
-rw-r--r--spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb75
-rw-r--r--spec/lib/bulk_imports/common/transformers/graphql_cleaner_transformer_spec.rb88
-rw-r--r--spec/lib/bulk_imports/common/transformers/underscorify_keys_transformer_spec.rb27
-rw-r--r--spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb78
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb104
-rw-r--r--spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb106
-rw-r--r--spec/lib/bulk_imports/importers/group_importer_spec.rb47
-rw-r--r--spec/lib/bulk_imports/pipeline/attributes_spec.rb57
-rw-r--r--spec/lib/bulk_imports/pipeline/context_spec.rb27
-rw-r--r--spec/lib/bulk_imports/pipeline/runner_spec.rb60
-rw-r--r--spec/lib/gitlab/database/postgres_partition_spec.rb75
-rw-r--r--spec/lib/gitlab/database/postgres_partitioned_table_spec.rb4
-rw-r--r--spec/services/bulk_import_service_spec.rb60
-rw-r--r--spec/support/helpers/features/releases_helpers.rb4
-rw-r--r--spec/workers/bulk_import_worker_spec.rb39
-rw-r--r--yarn.lock18
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
diff --git a/Gemfile b/Gemfile
index 64d2179cbce..213e5a86130 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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"