diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-06 12:08:56 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-06 12:08:56 +0300 |
commit | 0dce1c285f8d6487daf4b83be1ca9585e3a084e6 (patch) | |
tree | f617aa00d3994c2733baaed0205dba0d3bc0413d | |
parent | eefbee4451565989727256d36176dc2950e3a0b7 (diff) |
Add latest changes from gitlab-org/gitlab@master
49 files changed, 995 insertions, 166 deletions
diff --git a/app/assets/javascripts/analytics/shared/components/metric_card.vue b/app/assets/javascripts/analytics/shared/components/metric_card.vue index cee186c057c..e6e12821bec 100644 --- a/app/assets/javascripts/analytics/shared/components/metric_card.vue +++ b/app/assets/javascripts/analytics/shared/components/metric_card.vue @@ -43,7 +43,7 @@ export default { }; </script> <template> - <gl-card> + <gl-card class="gl-mb-5"> <template #header> <strong ref="title">{{ title }}</strong> </template> diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue new file mode 100644 index 00000000000..a04b1361d4e --- /dev/null +++ b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue @@ -0,0 +1,144 @@ +<script> +import { mapActions, mapGetters } from 'vuex'; +import { GlDropdownItem, GlDropdownDivider, GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; +import { __, n__ } from '~/locale'; +import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import AssigneesDropdown from '~/vue_shared/components/sidebar/assignees_dropdown.vue'; +import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql'; + +export default { + i18n: { + unassigned: __('Unassigned'), + assignee: __('Assignee'), + assignees: __('Assignees'), + assignTo: __('Assign to'), + }, + components: { + BoardEditableItem, + IssuableAssignees, + AssigneesDropdown, + GlDropdownItem, + GlDropdownDivider, + GlAvatarLabeled, + GlAvatarLink, + }, + data() { + return { + participants: [], + selected: this.$store.getters.getActiveIssue.assignees, + }; + }, + apollo: { + participants: { + query: getIssueParticipants, + variables() { + return { + id: `gid://gitlab/Issue/${this.getActiveIssue.iid}`, + }; + }, + update(data) { + return data.issue?.participants?.nodes || []; + }, + }, + }, + computed: { + ...mapGetters(['getActiveIssue']), + assigneeText() { + return n__('Assignee', '%d Assignees', this.selected.length); + }, + unSelectedFiltered() { + return this.participants.filter(({ username }) => { + return !this.selectedUserNames.includes(username); + }); + }, + selectedIsEmpty() { + return this.selected.length === 0; + }, + selectedUserNames() { + return this.selected.map(({ username }) => username); + }, + }, + methods: { + ...mapActions(['setAssignees']), + clearSelected() { + this.selected = []; + }, + selectAssignee(name) { + if (name === undefined) { + this.clearSelected(); + return; + } + + this.selected = this.selected.concat(name); + }, + unselect(name) { + this.selected = this.selected.filter(user => user.username !== name); + }, + saveAssignees() { + this.setAssignees(this.selectedUserNames); + }, + isChecked(id) { + return this.selectedUserNames.includes(id); + }, + }, +}; +</script> + +<template> + <board-editable-item :title="assigneeText" @close="saveAssignees"> + <template #collapsed> + <issuable-assignees :users="getActiveIssue.assignees" /> + </template> + + <template #default> + <assignees-dropdown + class="w-100" + :text="$options.i18n.assignees" + :header-text="$options.i18n.assignTo" + > + <template #items> + <gl-dropdown-item + :is-checked="selectedIsEmpty" + data-testid="unassign" + class="mt-2" + @click="selectAssignee()" + >{{ $options.i18n.unassigned }}</gl-dropdown-item + > + <gl-dropdown-divider data-testid="unassign-divider" /> + <gl-dropdown-item + v-for="item in selected" + :key="item.id" + :is-checked="isChecked(item.username)" + @click="unselect(item.username)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="item.name" + :sub-label="item.username" + :src="item.avatarUrl || item.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + <gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" /> + <gl-dropdown-item + v-for="unselectedUser in unSelectedFiltered" + :key="unselectedUser.id" + :data-testid="`item_${unselectedUser.name}`" + @click="selectAssignee(unselectedUser)" + > + <gl-avatar-link> + <gl-avatar-labeled + :size="32" + :label="unselectedUser.name" + :sub-label="unselectedUser.username" + :src="unselectedUser.avatarUrl || unselectedUser.avatar" + /> + </gl-avatar-link> + </gl-dropdown-item> + </template> + </assignees-dropdown> + </template> + </board-editable-item> +</template> diff --git a/app/assets/javascripts/boards/components/issue_card_inner.vue b/app/assets/javascripts/boards/components/issue_card_inner.vue index e7926c176a9..45ce1e51489 100644 --- a/app/assets/javascripts/boards/components/issue_card_inner.vue +++ b/app/assets/javascripts/boards/components/issue_card_inner.vue @@ -3,7 +3,7 @@ import { sortBy } from 'lodash'; import { mapState } from 'vuex'; import { GlLabel, GlTooltipDirective, GlIcon } from '@gitlab/ui'; import issueCardInner from 'ee_else_ce/boards/mixins/issue_card_inner'; -import { sprintf, __ } from '~/locale'; +import { sprintf, __, n__ } from '~/locale'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; import UserAvatarLink from '../../vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueDueDate from './issue_due_date.vue'; @@ -89,6 +89,12 @@ export default { orderedLabels() { return sortBy(this.issue.labels.filter(this.isNonListLabel), 'title'); }, + blockedLabel() { + if (this.issue.blockedByCount) { + return n__(`Blocked by %d issue`, `Blocked by %d issues`, this.issue.blockedByCount); + } + return __('Blocked issue'); + }, }, methods: { isIndexLessThanlimit(index) { @@ -139,9 +145,10 @@ export default { v-if="issue.blocked" v-gl-tooltip name="issue-block" - :title="__('Blocked issue')" + :title="blockedLabel" class="issue-blocked-icon gl-mr-2" - :aria-label="__('Blocked issue')" + :aria-label="blockedLabel" + data-testid="issue-blocked-icon" /> <gl-icon v-if="issue.confidential" @@ -205,7 +212,7 @@ export default { :key="assignee.id" :link-href="assigneeUrl(assignee)" :img-alt="avatarUrlTitle(assignee)" - :img-src="assignee.avatar || assignee.avatar_url" + :img-src="assignee.avatarUrl || assignee.avatar || assignee.avatar_url" :img-size="24" class="js-no-trigger" tooltip-placement="bottom" diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 9882963b580..df5b84a974a 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -18,6 +18,7 @@ import boardLabelsQuery from '../queries/board_labels.query.graphql'; import createBoardListMutation from '../queries/board_list_create.mutation.graphql'; import updateBoardListMutation from '../queries/board_list_update.mutation.graphql'; import issueMoveListMutation from '../queries/issue_move_list.mutation.graphql'; +import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import issueSetLabels from '../queries/issue_set_labels.mutation.graphql'; import issueSetDueDate from '../queries/issue_set_due_date.mutation.graphql'; @@ -291,6 +292,25 @@ export default { ); }, + setAssignees: ({ commit, getters }, assigneeUsernames) => { + return gqlClient + .mutate({ + mutation: updateAssignees, + variables: { + iid: getters.getActiveIssue.iid, + projectPath: getters.getActiveIssue.referencePath.split('#')[0], + assigneeUsernames, + }, + }) + .then(({ data }) => { + commit('UPDATE_ISSUE_BY_ID', { + issueId: getters.getActiveIssue.id, + prop: 'assignees', + value: data.issueSetAssignees.issue.assignees.nodes, + }); + }); + }, + createNewIssue: () => { notImplemented(); }, diff --git a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue index 88d5a35146f..0a1b1cd2c08 100644 --- a/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue +++ b/app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue @@ -85,7 +85,7 @@ export default { <template> <div class="prometheus-panel-builder"> <div class="gl-xs-flex-direction-column gl-display-flex gl-mx-n3"> - <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3"> + <gl-card class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5"> <template #header> <h2 class="gl-font-size-h2 gl-my-3">{{ s__('Metrics|1. Define and preview panel') }}</h2> </template> @@ -124,7 +124,7 @@ export default { </gl-card> <gl-card - class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3" + class="gl-flex-grow-1 gl-flex-basis-0 gl-mx-3 gl-mb-5" body-class="gl-display-flex gl-flex-direction-column" > <template #header> diff --git a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue b/app/assets/javascripts/monitoring/components/embeds/embed_group.vue index f07483c34b8..481ba3636cb 100644 --- a/app/assets/javascripts/monitoring/components/embeds/embed_group.vue +++ b/app/assets/javascripts/monitoring/components/embeds/embed_group.vue @@ -73,7 +73,7 @@ export default { <template> <gl-card v-show="numCharts > 0" - class="collapsible-card border p-0 mb-3" + class="collapsible-card border p-0 gl-mb-5" header-class="d-flex align-items-center border-bottom-0 py-2" :body-class="bodyClass" > diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index 052bb3dcb53..00f1339d7f2 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -22,7 +22,9 @@ export default { return sprintf(__("%{userName}'s avatar"), { userName: this.user.name }); }, avatarUrl() { - return this.user.avatar || this.user.avatar_url || gon.default_avatar_url; + return ( + this.user.avatarUrl || this.user.avatar || this.user.avatar_url || gon.default_avatar_url + ); }, isMergeRequest() { return this.issuableType === 'merge_request'; diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index 4697d85472b..cf6a0a4a151 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -26,7 +26,6 @@ export default { <template> <div class="gl-display-flex gl-flex-direction-column"> - <label data-testid="assigneeLabel">{{ assigneesText }}</label> <div v-if="emptyUsers" data-testid="none"> <span> {{ __('None') }} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql new file mode 100644 index 00000000000..612a0c02e82 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql @@ -0,0 +1,13 @@ +query issueParticipants($id: IssueID!) { + issue(id: $id) { + participants { + nodes { + username + name + webUrl + avatarUrl + id + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql new file mode 100644 index 00000000000..9ead95a3801 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql @@ -0,0 +1,17 @@ +mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $projectPath: ID!) { + issueSetAssignees( + input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath } + ) { + issue { + assignees { + nodes { + username + id + name + webUrl + avatarUrl + } + } + } + } +} diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index d4d85ef6bf4..78f4a0cffca 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -42,7 +42,7 @@ class Import::BulkImportsController < ApplicationController end def importable_data - client.get('groups', top_level_only: true) + client.get('groups', top_level_only: true).parsed_response end def client @@ -63,7 +63,6 @@ class Import::BulkImportsController < ApplicationController def bulk_import_params %i[ source_type - source_name source_full_path destination_name destination_namespace diff --git a/app/services/bulk_import_service.rb b/app/services/bulk_import_service.rb index 1a1eef092f3..bebf9153ce7 100644 --- a/app/services/bulk_import_service.rb +++ b/app/services/bulk_import_service.rb @@ -1,5 +1,30 @@ # frozen_string_literal: true +# Entry point of the BulkImport feature. +# This service receives a Gitlab Instance connection params +# and a list of groups to be imported. +# +# Process topography: +# +# sync | async +# | +# User +--> P1 +----> Pn +---+ +# | ^ | Enqueue new job +# | +-----+ +# +# P1 (sync) +# +# - Create a BulkImport record +# - Create a BulkImport::Entity for each group to be imported +# - Enqueue a BulkImportWorker job (P2) to import the given groups (entities) +# +# Pn (async) +# +# - For each group to be imported (BulkImport::Entity.with_status(:created)) +# - Import the group data +# - Create entities for each subgroup of the imported group +# - Enqueue a BulkImportService job (Pn) to import the new entities (subgroups) +# class BulkImportService attr_reader :current_user, :params, :credentials @@ -11,7 +36,6 @@ class BulkImportService def execute bulk_import = create_bulk_import - bulk_import.start! BulkImportWorker.perform_async(bulk_import.id) end diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index df845a2ca37..7828d046036 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -10,18 +10,6 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker 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! + BulkImports::Importers::GroupsImporter.new(bulk_import_id).execute end end diff --git a/changelogs/unreleased/ss-add-assignee-dropdown.yml b/changelogs/unreleased/ss-add-assignee-dropdown.yml new file mode 100644 index 00000000000..76deb9cf0fd --- /dev/null +++ b/changelogs/unreleased/ss-add-assignee-dropdown.yml @@ -0,0 +1,5 @@ +--- +title: Add assignee dropdown to group issue boards +merge_request: 44830 +author: +type: added diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 4aea6142801..736a0414a3c 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1408,10 +1408,18 @@ To determine if jobs should be added to a pipeline, `rules: changes` clauses che the files changed by Git push events. `rules: changes` works exactly the same way as [`only: changes` and `except: changes`](#onlychangesexceptchanges), -accepting an array of paths. Similarly, it always returns true if there is no -Git push event, for example, when a new tag is created. It's recommended to use it -only with branch pipelines or merge request pipelines. For example, it's common to -use `rules: changes` with one of the following `if` clauses: +accepting an array of paths. + +It always returns true and adds jobs to the pipeline if there is no Git push event. +For example, jobs with `rules: changes` always run on scheduled and tag pipelines, +because they are not associated with a Git push event. Only certain pipelines have +a Git push event associated with them: + +- All pipelines with a `$CI_PIPELINE_SOURCE` of `merge_request` or `external_merge_request`. +- Branch pipelines, which have the `$CI_COMMIT_BRANCH` variable present and a `$CI_PIPELINE_SOURCE` of `push`. + +It's recommended to use it only with branch pipelines or merge request pipelines. +For example, it's common to use `rules: changes` with one of the following `if` clauses: - `if: $CI_COMMIT_BRANCH` - `if: '$CI_PIPELINE_SOURCE == "merge_request_event"'` diff --git a/lib/bulk_imports/clients/http.rb b/lib/bulk_imports/clients/http.rb index 39f56fcc114..2e81863e53a 100644 --- a/lib/bulk_imports/clients/http.rb +++ b/lib/bulk_imports/clients/http.rb @@ -18,7 +18,7 @@ module BulkImports end def get(resource, query = {}) - response = with_error_handling do + with_error_handling do Gitlab::HTTP.get( resource_url(resource), headers: request_headers, @@ -26,8 +26,22 @@ module BulkImports query: query.merge(request_query) ) end + end + + def each_page(method, resource, query = {}, &block) + return to_enum(__method__, method, resource, query) unless block_given? + + next_page = @page - response.parsed_response + while next_page + @page = next_page.to_i + + response = self.public_send(method, resource, query) # rubocop: disable GitlabSecurity/PublicSend + collection = response.parsed_response + next_page = response.headers['x-next-page'].presence + + yield collection + end end private diff --git a/lib/bulk_imports/common/extractors/graphql_extractor.rb b/lib/bulk_imports/common/extractors/graphql_extractor.rb index 571be747dca..7d58032cfcc 100644 --- a/lib/bulk_imports/common/extractors/graphql_extractor.rb +++ b/lib/bulk_imports/common/extractors/graphql_extractor.rb @@ -14,11 +14,9 @@ module BulkImports @context = context Enumerator.new do |yielder| - context.entities.each do |entity| - result = graphql_client.execute(parsed_query, query_variables(entity)) + result = graphql_client.execute(parsed_query, query_variables(context.entity)) - yielder << result.original_hash.deep_dup - end + yielder << result.original_hash.deep_dup end end diff --git a/lib/bulk_imports/common/loaders/entities_loader.rb b/lib/bulk_imports/common/loaders/entities_loader.rb new file mode 100644 index 00000000000..19926fa9a3e --- /dev/null +++ b/lib/bulk_imports/common/loaders/entities_loader.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module BulkImports + module Common + module Loaders + class EntitiesLoader + def initialize(*args); end + + def load(context, entities) + bulk_import = context.entity.bulk_import + + entities.each do |entity| + bulk_import.entities.create!(entity) + end + end + end + end + end +end diff --git a/lib/bulk_imports/groups/extractors/subgroups_extractor.rb b/lib/bulk_imports/groups/extractors/subgroups_extractor.rb new file mode 100644 index 00000000000..c33bae6ada4 --- /dev/null +++ b/lib/bulk_imports/groups/extractors/subgroups_extractor.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Extractors + class SubgroupsExtractor + def initialize(*args); end + + def extract(context) + encoded_parent_path = ERB::Util.url_encode(context.entity.source_full_path) + + subgroups = [] + http_client(context.entity.bulk_import.configuration) + .each_page(:get, "groups/#{encoded_parent_path}/subgroups") do |page| + subgroups << page + end + subgroups + end + + private + + def http_client(configuration) + @http_client ||= BulkImports::Clients::Http.new( + uri: configuration.url, + token: configuration.access_token, + per_page: 100 + ) + 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 index 394f0ee10ec..386fc695182 100644 --- a/lib/bulk_imports/groups/loaders/group_loader.rb +++ b/lib/bulk_imports/groups/loaders/group_loader.rb @@ -11,7 +11,11 @@ module BulkImports def load(context, data) return unless user_can_create_group?(context.current_user, data) - ::Groups::CreateService.new(context.current_user, data).execute + group = ::Groups::CreateService.new(context.current_user, data).execute + + context.entity.update!(group: group) + + group end private diff --git a/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline.rb b/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline.rb new file mode 100644 index 00000000000..30b46a3fa24 --- /dev/null +++ b/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Pipelines + class SubgroupEntitiesPipeline + include Pipeline + + extractor BulkImports::Groups::Extractors::SubgroupsExtractor + transformer BulkImports::Groups::Transformers::SubgroupsToEntitiesTransformer + loader BulkImports::Common::Loaders::EntitiesLoader + 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 index c3937cfe652..7de9a430421 100644 --- a/lib/bulk_imports/groups/transformers/group_attributes_transformer.rb +++ b/lib/bulk_imports/groups/transformers/group_attributes_transformer.rb @@ -9,7 +9,7 @@ module BulkImports end def transform(context, data) - import_entity = find_by_full_path(data['full_path'], context.entities) + import_entity = context.entity data .then { |data| transform_name(import_entity, data) } @@ -75,10 +75,6 @@ module BulkImports 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 diff --git a/lib/bulk_imports/groups/transformers/subgroups_to_entities_transformer.rb b/lib/bulk_imports/groups/transformers/subgroups_to_entities_transformer.rb new file mode 100644 index 00000000000..090b3552338 --- /dev/null +++ b/lib/bulk_imports/groups/transformers/subgroups_to_entities_transformer.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module BulkImports + module Groups + module Transformers + class SubgroupsToEntitiesTransformer + def initialize(*args); end + + def transform(context, data) + data.map do |entry| + { + source_type: :group_entity, + source_full_path: entry['full_path'], + destination_name: entry['name'], + destination_namespace: context.entity.group.full_path, + parent_id: context.entity.id + } + end + end + end + end + end +end diff --git a/lib/bulk_imports/importers/group_importer.rb b/lib/bulk_imports/importers/group_importer.rb index f053177d9fb..c7253590c87 100644 --- a/lib/bulk_imports/importers/group_importer.rb +++ b/lib/bulk_imports/importers/group_importer.rb @@ -1,34 +1,32 @@ # 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 + def initialize(entity) + @entity = entity end def execute - return if entity.parent - + entity.start! bulk_import = entity.bulk_import configuration = bulk_import.configuration context = BulkImports::Pipeline::Context.new( current_user: bulk_import.user, - entities: [entity], + entity: entity, configuration: configuration ) BulkImports::Groups::Pipelines::GroupPipeline.new.run(context) - end + BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline.new.run(context) - def entity - @entity ||= BulkImports::Entity.find(@entity_id) + entity.finish! end + + private + + attr_reader :entity end end end diff --git a/lib/bulk_imports/importers/groups_importer.rb b/lib/bulk_imports/importers/groups_importer.rb new file mode 100644 index 00000000000..8641577ff47 --- /dev/null +++ b/lib/bulk_imports/importers/groups_importer.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module BulkImports + module Importers + class GroupsImporter + def initialize(bulk_import_id) + @bulk_import = BulkImport.find(bulk_import_id) + end + + def execute + bulk_import.start! unless bulk_import.started? + + if entities_to_import.empty? + bulk_import.finish! + else + entities_to_import.each do |entity| + BulkImports::Importers::GroupImporter.new(entity).execute + end + + # A new BulkImportWorker job is enqueued to either + # - Process the new BulkImports::Entity created for the subgroups + # - Or to mark the `bulk_import` as finished. + BulkImportWorker.perform_async(bulk_import.id) + end + end + + private + + attr_reader :bulk_import + + def entities_to_import + @entities_to_import ||= bulk_import.entities.with_status(:created) + end + end + end +end diff --git a/lib/bulk_imports/pipeline/context.rb b/lib/bulk_imports/pipeline/context.rb index 903f474ebbb..ad19f5cad7d 100644 --- a/lib/bulk_imports/pipeline/context.rb +++ b/lib/bulk_imports/pipeline/context.rb @@ -9,7 +9,7 @@ module BulkImports PIPELINE_ATTRIBUTES = [ Attribute.new(:current_user, User), - Attribute.new(:entities, Array), + Attribute.new(:entity, ::BulkImports::Entity), Attribute.new(:configuration, ::BulkImports::Configuration) ].freeze diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 2f9d3b64fc7..30f4b38412b 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -4320,6 +4320,11 @@ msgstr "" msgid "Blocked" msgstr "" +msgid "Blocked by %d issue" +msgid_plural "Blocked by %d issues" +msgstr[0] "" +msgstr[1] "" + msgid "Blocked issue" msgstr "" diff --git a/package.json b/package.json index a7709be064d..644b326146d 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@babel/preset-env": "^7.10.1", "@gitlab/at.js": "1.5.5", "@gitlab/svgs": "1.175.0", - "@gitlab/ui": "22.0.3", + "@gitlab/ui": "23.0.0", "@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 9f0e6ea1a20..dd850a86227 100644 --- a/spec/controllers/import/bulk_imports_controller_spec.rb +++ b/spec/controllers/import/bulk_imports_controller_spec.rb @@ -55,10 +55,12 @@ RSpec.describe Import::BulkImportsController do describe 'serialized group data' do let(:client_response) do - [ - { 'id' => 1, 'full_name' => 'group1', 'full_path' => 'full/path/group1' }, - { 'id' => 2, 'full_name' => 'group2', 'full_path' => 'full/path/group2' } - ] + double( + parsed_response: [ + { 'id' => 1, 'full_name' => 'group1', 'full_path' => 'full/path/group1' }, + { 'id' => 2, 'full_name' => 'group2', 'full_path' => 'full/path/group2' } + ] + ) end before do @@ -69,7 +71,7 @@ RSpec.describe Import::BulkImportsController do it 'returns serialized group data' do get :status, format: :json - expect(response.parsed_body).to eq({ importable_data: client_response }.as_json) + expect(json_response).to eq({ importable_data: client_response.parsed_response }.as_json) end end diff --git a/spec/features/projects/settings/registry_settings_spec.rb b/spec/features/projects/settings/registry_settings_spec.rb index 2a4c4a3fba7..cb333bdb428 100644 --- a/spec/features/projects/settings/registry_settings_spec.rb +++ b/spec/features/projects/settings/registry_settings_spec.rb @@ -33,13 +33,13 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p subject within '#js-registry-policies' do - within '.card-body' do + within '.gl-card-body' do select('7 days until tags are automatically removed', from: 'Expiration interval:') select('Every day', from: 'Expiration schedule:') select('50 tags per image name', from: 'Number of tags to retain:') fill_in('Tags with names matching this regex pattern will expire:', with: '.*-production') end - submit_button = find('.card-footer .btn.btn-success') + submit_button = find('.gl-card-footer .btn.btn-success') expect(submit_button).not_to be_disabled submit_button.click end @@ -51,10 +51,10 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p subject within '#js-registry-policies' do - within '.card-body' do + within '.gl-card-body' do fill_in('Tags with names matching this regex pattern will expire:', with: '*-production') end - submit_button = find('.card-footer .btn.btn-success') + submit_button = find('.gl-card-footer .btn.btn-success') expect(submit_button).not_to be_disabled submit_button.click end @@ -85,7 +85,7 @@ RSpec.describe 'Project > Settings > CI/CD > Container registry tag expiration p within '#js-registry-policies' do case result when :available_section - expect(find('.card-header')).to have_content('Tag expiration policy') + expect(find('.gl-card-header')).to have_content('Tag expiration policy') when :disabled_message expect(find('.gl-alert-title')).to have_content('Cleanup policy for tags is disabled') end diff --git a/spec/frontend/boards/components/board_assignee_dropdown_spec.js b/spec/frontend/boards/components/board_assignee_dropdown_spec.js new file mode 100644 index 00000000000..4d3129da11a --- /dev/null +++ b/spec/frontend/boards/components/board_assignee_dropdown_spec.js @@ -0,0 +1,229 @@ +import { mount } from '@vue/test-utils'; +import { GlDropdownItem, GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; +import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue'; +import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; +import AssigneesDropdown from '~/vue_shared/components/sidebar/assignees_dropdown.vue'; +import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue'; +import store from '~/boards/stores'; +import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql'; +import { participants } from '../mock_data'; + +describe('BoardCardAssigneeDropdown', () => { + let wrapper; + const iid = '111'; + const activeIssueName = 'test'; + const anotherIssueName = 'hello'; + + const createComponent = () => { + wrapper = mount(BoardAssigneeDropdown, { + data() { + return { + selected: store.getters.getActiveIssue.assignees, + participants, + }; + }, + store, + provide: { + canUpdate: true, + rootPath: '', + }, + }); + }; + + const unassign = async () => { + wrapper.find('[data-testid="unassign"]').trigger('click'); + + await wrapper.vm.$nextTick(); + }; + + const openDropdown = async () => { + wrapper.find('[data-testid="edit-button"]').trigger('click'); + + await wrapper.vm.$nextTick(); + }; + + const findByText = text => { + return wrapper.findAll(GlDropdownItem).wrappers.find(x => x.text().indexOf(text) === 0); + }; + + beforeEach(() => { + store.state.activeId = '1'; + store.state.issues = { + '1': { + iid, + assignees: [{ username: activeIssueName, name: activeIssueName, id: activeIssueName }], + }, + }; + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when mounted', () => { + beforeEach(() => { + createComponent(); + }); + + it.each` + text + ${anotherIssueName} + ${activeIssueName} + `('finds item with $text', ({ text }) => { + const item = findByText(text); + + expect(item.exists()).toBe(true); + }); + + it('renders gl-avatar-link in gl-dropdown-item', () => { + const item = findByText('hello'); + + expect(item.find(GlAvatarLink).exists()).toBe(true); + }); + + it('renders gl-avatar-labeled in gl-avatar-link', () => { + const item = findByText('hello'); + + expect( + item + .find(GlAvatarLink) + .find(GlAvatarLabeled) + .exists(), + ).toBe(true); + }); + }); + + describe('when selected users are present', () => { + it('renders a divider', () => { + createComponent(); + + expect(wrapper.find('[data-testid="selected-user-divider"]').exists()).toBe(true); + }); + }); + + describe('when collapsed', () => { + it('renders IssuableAssignees', () => { + createComponent(); + + expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true); + expect(wrapper.find(AssigneesDropdown).isVisible()).toBe(false); + }); + }); + + describe('when dropdown is open', () => { + beforeEach(async () => { + createComponent(); + + await openDropdown(); + }); + + it('shows assignees dropdown', async () => { + expect(wrapper.find(IssuableAssignees).isVisible()).toBe(false); + expect(wrapper.find(AssigneesDropdown).isVisible()).toBe(true); + }); + + it('shows the issue returned as the activeIssue', async () => { + expect(findByText(activeIssueName).props('isChecked')).toBe(true); + }); + + describe('when "Unassign" is clicked', () => { + it('unassigns assignees', async () => { + await unassign(); + + expect(findByText('Unassign').props('isChecked')).toBe(true); + }); + }); + + describe('when an unselected item is clicked', () => { + beforeEach(async () => { + await unassign(); + }); + + it('assigns assignee in the dropdown', async () => { + wrapper.find('[data-testid="item_test"]').trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findByText(activeIssueName).props('isChecked')).toBe(true); + }); + + it('calls setAssignees with username list', async () => { + wrapper.find('[data-testid="item_test"]').trigger('click'); + + await wrapper.vm.$nextTick(); + + document.body.click(); + + await wrapper.vm.$nextTick(); + + expect(store.dispatch).toHaveBeenCalledWith('setAssignees', [activeIssueName]); + }); + }); + + describe('when the user off clicks', () => { + beforeEach(async () => { + await unassign(); + + document.body.click(); + + await wrapper.vm.$nextTick(); + }); + + it('calls setAssignees with username list', async () => { + expect(store.dispatch).toHaveBeenCalledWith('setAssignees', []); + }); + + it('closes the dropdown', async () => { + expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true); + }); + }); + }); + + it('renders divider after unassign', () => { + createComponent(); + + expect(wrapper.find('[data-testid="unassign-divider"]').exists()).toBe(true); + }); + + it.each` + assignees | expected + ${[{ id: 5, username: '', name: '' }]} | ${'Assignee'} + ${[{ id: 6, username: '', name: '' }, { id: 7, username: '', name: '' }]} | ${'2 Assignees'} + `( + 'when assignees have a length of $assignees.length, it renders $expected', + ({ assignees, expected }) => { + store.state.issues['1'].assignees = assignees; + + createComponent(); + + expect(wrapper.find(BoardEditableItem).props('title')).toBe(expected); + }, + ); + + describe('Apollo Schema', () => { + beforeEach(() => { + createComponent(); + }); + + it('returns the correct query', () => { + expect(wrapper.vm.$options.apollo.participants.query).toEqual(getIssueParticipants); + }); + + it('contains the correct variables', () => { + const { variables } = wrapper.vm.$options.apollo.participants; + const boundVariable = variables.bind(wrapper.vm); + + expect(boundVariable()).toEqual({ id: 'gid://gitlab/Issue/111' }); + }); + + it('returns the correct data from update', () => { + const node = { test: 1 }; + const { update } = wrapper.vm.$options.apollo.participants; + + expect(update({ issue: { participants: { nodes: [node] } } })).toEqual([node]); + }); + }); +}); diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 50c0a85fc70..d83b39a5594 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -319,6 +319,23 @@ export const mockIssuesByListId = { 'gid://gitlab/List/2': mockIssues.map(({ id }) => id), }; +export const participants = [ + { + id: '1', + username: 'test', + name: 'test', + avatar: '', + avatarUrl: '', + }, + { + id: '2', + username: 'hello', + name: 'hello', + avatar: '', + avatarUrl: '', + }, +]; + export const issues = { [mockIssue.id]: mockIssue, [mockIssue2.id]: mockIssue2, diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index e6315d63364..3b204c3cf70 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -13,6 +13,7 @@ import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; import { inactiveId } from '~/boards/constants'; import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql'; +import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql'; import { fullBoardId, formatListIssues, formatBoardLists } from '~/boards/boards_util'; const expectNotImplemented = action => { @@ -554,6 +555,48 @@ describe('moveIssue', () => { }); }); +describe('setAssignees', () => { + const node = { username: 'name' }; + const name = 'username'; + const projectPath = 'h/h'; + const refPath = `${projectPath}#3`; + const iid = '1'; + + beforeEach(() => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { issueSetAssignees: { issue: { assignees: { nodes: [{ ...node }] } } } }, + }); + }); + + it('calls mutate with the correct values', async () => { + await actions.setAssignees( + { commit: () => {}, getters: { getActiveIssue: { iid, referencePath: refPath } } }, + [name], + ); + + expect(gqlClient.mutate).toHaveBeenCalledWith({ + mutation: updateAssignees, + variables: { iid, assigneeUsernames: [name], projectPath }, + }); + }); + + it('calls the correct mutation with the correct values', done => { + testAction( + actions.setAssignees, + {}, + { getActiveIssue: { iid, referencePath: refPath }, commit: () => {} }, + [ + { + type: 'UPDATE_ISSUE_BY_ID', + payload: { prop: 'assignees', issueId: undefined, value: [node] }, + }, + ], + [], + done, + ); + }); +}); + describe('createNewIssue', () => { expectNotImplemented(actions.createNewIssue); }); diff --git a/spec/frontend/monitoring/components/embeds/embed_group_spec.js b/spec/frontend/monitoring/components/embeds/embed_group_spec.js index b63995ec2d4..01089752933 100644 --- a/spec/frontend/monitoring/components/embeds/embed_group_spec.js +++ b/spec/frontend/monitoring/components/embeds/embed_group_spec.js @@ -73,7 +73,7 @@ describe('Embed Group', () => { metricsWithDataGetter.mockReturnValue([1]); mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); - expect(wrapper.find('.card-body').classes()).not.toContain('d-none'); + expect(wrapper.find('.gl-card-body').classes()).not.toContain('d-none'); }); it('collapses when clicked', done => { @@ -83,7 +83,7 @@ describe('Embed Group', () => { wrapper.find(GlButton).trigger('click'); wrapper.vm.$nextTick(() => { - expect(wrapper.find('.card-body').classes()).toContain('d-none'); + expect(wrapper.find('.gl-card-body').classes()).toContain('d-none'); done(); }); }); diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/issuable_assignees_spec.js index aa930bd4198..076616de040 100644 --- a/spec/frontend/sidebar/issuable_assignees_spec.js +++ b/spec/frontend/sidebar/issuable_assignees_spec.js @@ -13,7 +13,6 @@ describe('IssuableAssignees', () => { propsData: { ...props }, }); }; - const findLabel = () => wrapper.find('[data-testid="assigneeLabel"'); const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList); const findEmptyAssignee = () => wrapper.find('[data-testid="none"]'); @@ -30,10 +29,6 @@ describe('IssuableAssignees', () => { it('renders "None"', () => { expect(findEmptyAssignee().text()).toBe('None'); }); - - it('renders "0 assignees"', () => { - expect(findLabel().text()).toBe('0 Assignees'); - }); }); describe('when assignees are present', () => { @@ -42,18 +37,5 @@ describe('IssuableAssignees', () => { expect(findUncollapsedAssigneeList().exists()).toBe(true); }); - - it.each` - assignees | expected - ${[{ id: 1 }]} | ${'Assignee'} - ${[{ id: 1 }, { id: 2 }]} | ${'2 Assignees'} - `( - 'when assignees have a length of $assignees.length, it renders $expected', - ({ assignees, expected }) => { - createComponent({ users: assignees }); - - expect(findLabel().text()).toBe(expected); - }, - ); }); }); diff --git a/spec/lib/bulk_imports/clients/http_spec.rb b/spec/lib/bulk_imports/clients/http_spec.rb index 5bf386a8ce2..2d841b7fac2 100644 --- a/spec/lib/bulk_imports/clients/http_spec.rb +++ b/spec/lib/bulk_imports/clients/http_spec.rb @@ -22,16 +22,6 @@ RSpec.describe BulkImports::Clients::Http do end end - describe 'parsed response' do - it 'returns parsed response' do - response_double = double(code: 200, success?: true, parsed_response: [{ id: 1 }, { id: 2 }]) - - allow(Gitlab::HTTP).to receive(:get).and_return(response_double) - - expect(subject.get(resource)).to eq(response_double.parsed_response) - end - end - describe 'request query' do include_examples 'performs network request' do let(:expected_args) do @@ -91,5 +81,52 @@ RSpec.describe BulkImports::Clients::Http do end end end + + describe '#each_page' do + let(:objects1) { [{ object: 1 }, { object: 2 }] } + let(:objects2) { [{ object: 3 }, { object: 4 }] } + let(:response1) { double(success?: true, headers: { 'x-next-page' => 2 }, parsed_response: objects1) } + let(:response2) { double(success?: true, headers: {}, parsed_response: objects2) } + + before do + stub_http_get('groups', { page: 1, per_page: 30 }, response1) + stub_http_get('groups', { page: 2, per_page: 30 }, response2) + end + + context 'with a block' do + it 'yields every retrieved page to the supplied block' do + pages = [] + + subject.each_page(:get, 'groups') { |page| pages << page } + + expect(pages[0]).to be_an_instance_of(Array) + expect(pages[1]).to be_an_instance_of(Array) + + expect(pages[0]).to eq(objects1) + expect(pages[1]).to eq(objects2) + end + end + + context 'without a block' do + it 'returns an Enumerator' do + expect(subject.each_page(:get, :foo)).to be_an_instance_of(Enumerator) + end + end + + private + + def stub_http_get(path, query, response) + uri = "http://gitlab.example:80/api/v4/#{path}" + params = { + follow_redirects: false, + headers: { + "Authorization" => "Bearer token", + "Content-Type" => "application/json" + } + }.merge(query: query) + + allow(Gitlab::HTTP).to receive(:get).with(uri, params).and_return(response) + end + end end end diff --git a/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb b/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb index 885e99d1fd3..cde8e2d5c18 100644 --- a/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb +++ b/spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb @@ -10,7 +10,7 @@ RSpec.describe BulkImports::Common::Extractors::GraphqlExtractor do let(:context) do instance_double( BulkImports::Pipeline::Context, - entities: [import_entity] + entity: import_entity ) end diff --git a/spec/lib/bulk_imports/common/loaders/entities_loader_spec.rb b/spec/lib/bulk_imports/common/loaders/entities_loader_spec.rb new file mode 100644 index 00000000000..9699fc00074 --- /dev/null +++ b/spec/lib/bulk_imports/common/loaders/entities_loader_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Common::Loaders::EntitiesLoader do + describe '#load' do + it "creates entities for the given data" do + group = create(:group, path: "imported-group") + parent_entity = create(:bulk_import_entity, group: group, bulk_import: create(:bulk_import)) + context = instance_double(BulkImports::Pipeline::Context, entity: parent_entity) + + data = [{ + source_type: :group_entity, + source_full_path: "parent/subgroup", + destination_name: "subgroup", + destination_namespace: parent_entity.group.full_path, + parent_id: parent_entity.id + }] + + expect { subject.load(context, data) }.to change(BulkImports::Entity, :count).by(1) + + subgroup_entity = BulkImports::Entity.last + + expect(subgroup_entity.source_full_path).to eq 'parent/subgroup' + expect(subgroup_entity.destination_namespace).to eq 'imported-group' + expect(subgroup_entity.destination_name).to eq 'subgroup' + expect(subgroup_entity.parent_id).to eq parent_entity.id + 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 index 8c300400e11..b14dfc615a9 100644 --- a/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb +++ b/spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb @@ -7,9 +7,11 @@ RSpec.describe BulkImports::Groups::Loaders::GroupLoader do let(:user) { create(:user) } let(:data) { { foo: :bar } } let(:service_double) { instance_double(::Groups::CreateService) } + let(:entity) { create(:bulk_import_entity) } let(:context) do instance_double( BulkImports::Pipeline::Context, + entity: entity, current_user: user ) end @@ -21,6 +23,7 @@ RSpec.describe BulkImports::Groups::Loaders::GroupLoader 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) + expect(entity).to receive(:update!) subject.load(context, data) 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 index 37a3bbd284a..3949dd23b49 100644 --- a/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb +++ b/spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb @@ -7,20 +7,18 @@ RSpec.describe BulkImports::Groups::Pipelines::GroupPipeline do let(:user) { create(:user) } let(:parent) { create(:group) } let(:entity) do - instance_double( - BulkImports::Entity, + create( + :bulk_import_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, + BulkImports::Pipeline::Context.new( current_user: user, - entities: entities + entity: entity ) end diff --git a/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb new file mode 100644 index 00000000000..98ae8d9c53e --- /dev/null +++ b/spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline do + describe '#run' do + let_it_be(:user) { create(:user) } + let(:parent) { create(:group, name: 'imported-group', path: 'imported-group') } + let!(:parent_entity) do + create( + :bulk_import_entity, + destination_namespace: parent.full_path, + group: parent + ) + end + + let(:context) do + instance_double( + BulkImports::Pipeline::Context, + current_user: user, + entity: parent_entity + ) + end + + let(:subgroup_data) do + [ + { + "name" => "subgroup", + "full_path" => "parent/subgroup" + } + ] + end + + subject { described_class.new } + + before do + allow_next_instance_of(BulkImports::Groups::Extractors::SubgroupsExtractor) do |extractor| + allow(extractor).to receive(:extract).and_return([subgroup_data]) + end + + parent.add_owner(user) + end + + it 'creates entities for the subgroups' do + expect { subject.run(context) }.to change(BulkImports::Entity, :count).by(1) + + subgroup_entity = BulkImports::Entity.last + + expect(subgroup_entity.source_full_path).to eq 'parent/subgroup' + expect(subgroup_entity.destination_namespace).to eq 'imported-group' + expect(subgroup_entity.destination_name).to eq 'subgroup' + expect(subgroup_entity.parent_id).to eq parent_entity.id + 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::Groups::Extractors::SubgroupsExtractor, + options: nil + ) + end + + it 'has transformers' do + expect(described_class.transformers).to contain_exactly( + klass: BulkImports::Groups::Transformers::SubgroupsToEntitiesTransformer, + options: nil + ) + end + + it 'has loaders' do + expect(described_class.loaders).to contain_exactly( + klass: BulkImports::Common::Loaders::EntitiesLoader, + 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 index 3fdbb34d89b..28a7859915d 100644 --- a/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb +++ b/spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb @@ -16,12 +16,11 @@ RSpec.describe BulkImports::Groups::Transformers::GroupAttributesTransformer do ) end - let(:entities) { [entity] } let(:context) do instance_double( BulkImports::Pipeline::Context, current_user: user, - entities: entities + entity: entity ) end diff --git a/spec/lib/bulk_imports/groups/transformers/subgroups_to_entities_transformer_spec.rb b/spec/lib/bulk_imports/groups/transformers/subgroups_to_entities_transformer_spec.rb new file mode 100644 index 00000000000..bcd65e218f6 --- /dev/null +++ b/spec/lib/bulk_imports/groups/transformers/subgroups_to_entities_transformer_spec.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Groups::Transformers::SubgroupsToEntitiesTransformer do + describe "#transform" do + it "transforms subgroups data in entity params" do + parent = create(:group) + parent_entity = instance_double(BulkImports::Entity, group: parent, id: 1) + context = instance_double(BulkImports::Pipeline::Context, entity: parent_entity) + subgroup_data = [ + { + "name" => "subgroup", + "full_path" => "parent/subgroup" + } + ] + + expect(subject.transform(context, subgroup_data)).to contain_exactly( + source_type: :group_entity, + source_full_path: "parent/subgroup", + destination_name: "subgroup", + destination_namespace: parent.full_path, + parent_id: 1 + ) + 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 index 903c8cf0398..95ac5925c97 100644 --- a/spec/lib/bulk_imports/importers/group_importer_spec.rb +++ b/spec/lib/bulk_imports/importers/group_importer_spec.rb @@ -8,40 +8,49 @@ RSpec.describe BulkImports::Importers::GroupImporter do 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, + BulkImports::Pipeline::Context.new( current_user: user, - entities: [bulk_import_entity], + entity: bulk_import_entity, configuration: bulk_import_configuration ) end - subject { described_class.new(bulk_import_entity.id) } + subject { described_class.new(bulk_import_entity) } + + before do + allow(BulkImports::Pipeline::Context).to receive(:new).and_return(context) + stub_http_requests + end describe '#execute' do - before do - allow(BulkImports::Pipeline::Context).to receive(:new).and_return(context) - end + it "starts the entity and run its pipelines" do + expect(bulk_import_entity).to receive(:start).and_call_original + expect_to_run_pipeline BulkImports::Groups::Pipelines::GroupPipeline, context: context + expect_to_run_pipeline BulkImports::Groups::Pipelines::SubgroupEntitiesPipeline, context: context - 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 - subject.execute - end + expect(bulk_import_entity.reload).to be_finished 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) } + def expect_to_run_pipeline(klass, context:) + expect_next_instance_of(klass) do |pipeline| + expect(pipeline).to receive(:run).with(context) + end + end - it 'does not execute GroupPipeline' do - expect(BulkImports::Groups::Pipelines::GroupPipeline).not_to receive(:new) + def stub_http_requests + double_response = double( + code: 200, + success?: true, + parsed_response: {}, + headers: {} + ) - subject.execute - end + allow_next_instance_of(BulkImports::Clients::Http) do |client| + allow(client).to receive(:get).and_return(double_response) + allow(client).to receive(:post).and_return(double_response) end end end diff --git a/spec/lib/bulk_imports/importers/groups_importer_spec.rb b/spec/lib/bulk_imports/importers/groups_importer_spec.rb new file mode 100644 index 00000000000..4865034b0cd --- /dev/null +++ b/spec/lib/bulk_imports/importers/groups_importer_spec.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe BulkImports::Importers::GroupsImporter do + let_it_be(:bulk_import) { create(:bulk_import) } + + subject { described_class.new(bulk_import.id) } + + describe '#execute' do + context "when there is entities to be imported" do + let!(:bulk_import_entity) { create(:bulk_import_entity, bulk_import: bulk_import) } + + it "starts the bulk_import and imports its entities" do + expect(BulkImports::Importers::GroupImporter).to receive(:new) + .with(bulk_import_entity).and_return(double(execute: true)) + expect(BulkImportWorker).to receive(:perform_async).with(bulk_import.id) + + subject.execute + + expect(bulk_import.reload).to be_started + end + end + + context "when there is no entities to be imported" do + it "starts the bulk_import and imports its entities" do + expect(BulkImports::Importers::GroupImporter).not_to receive(:new) + expect(BulkImportWorker).not_to receive(:perform_async) + + subject.execute + + expect(bulk_import.reload).to be_finished + end + end + end +end diff --git a/spec/lib/bulk_imports/pipeline/context_spec.rb b/spec/lib/bulk_imports/pipeline/context_spec.rb index 0f32accd369..e9af6313ca4 100644 --- a/spec/lib/bulk_imports/pipeline/context_spec.rb +++ b/spec/lib/bulk_imports/pipeline/context_spec.rb @@ -7,7 +7,7 @@ RSpec.describe BulkImports::Pipeline::Context do it 'initializes with permitted attributes' do args = { current_user: create(:user), - entities: [], + entity: create(:bulk_import_entity), configuration: create(:bulk_import_configuration) } diff --git a/spec/services/bulk_import_service_spec.rb b/spec/services/bulk_import_service_spec.rb index 1247156a0ed..e4a50b9d523 100644 --- a/spec/services/bulk_import_service_spec.rb +++ b/spec/services/bulk_import_service_spec.rb @@ -43,14 +43,6 @@ RSpec.describe BulkImportService 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) diff --git a/spec/workers/bulk_import_worker_spec.rb b/spec/workers/bulk_import_worker_spec.rb index 862dbce3177..12783f40528 100644 --- a/spec/workers/bulk_import_worker_spec.rb +++ b/spec/workers/bulk_import_worker_spec.rb @@ -3,37 +3,14 @@ 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 + bulk_import_id = 1 - 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!) + expect(BulkImports::Importers::GroupsImporter) + .to receive(:new).with(bulk_import_id).and_return(double(execute: true)) - subject.perform(non_existing_record_id) - end + described_class.new.perform(bulk_import_id) end end end diff --git a/yarn.lock b/yarn.lock index 71fe6d7d750..d26e14eb03a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -866,10 +866,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.175.0.tgz#734f341784af1cd1d62d160a17bcdfb61ff7b04d" integrity sha512-gXpc87TGSXIzfAr4QER1Qw1v3P47pBO6BXkma52blgwXVmcFNe3nhQzqsqt66wKNzrIrk3lAcB4GUyPHbPVXpg== -"@gitlab/ui@22.0.3": - version "22.0.3" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-22.0.3.tgz#5c2cf8fa6cb95dea63b7ad390a310eb4b1dfc793" - integrity sha512-mTVNQTZwWtHJW03EpJEhdP+ZHYHFuIzamrskH7Sa1VdLce86zIeagi79tu2xZvf70CaQ7QhVZfVZIl5kJYMtfg== +"@gitlab/ui@23.0.0": + version "23.0.0" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-23.0.0.tgz#c6b96220c0a7f8f249e06b8ad67ddf53ded89e33" + integrity sha512-y5WIkDHq4VS3eFQDOkQNrfroDu8rNyWLVhTE89oZ1zxlOh228yXdql8FccNTnRaPhC1YbZLGcvDJzYsWXht58A== dependencies: "@babel/standalone" "^7.0.0" "@gitlab/vue-toasted" "^1.3.0" |