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:
-rw-r--r--app/assets/javascripts/analytics/shared/components/metric_card.vue2
-rw-r--r--app/assets/javascripts/boards/components/board_assignee_dropdown.vue144
-rw-r--r--app/assets/javascripts/boards/components/issue_card_inner.vue15
-rw-r--r--app/assets/javascripts/boards/stores/actions.js20
-rw-r--r--app/assets/javascripts/monitoring/components/dashboard_panel_builder.vue4
-rw-r--r--app/assets/javascripts/monitoring/components/embeds/embed_group.vue2
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue4
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue1
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql13
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql17
-rw-r--r--app/controllers/import/bulk_imports_controller.rb3
-rw-r--r--app/services/bulk_import_service.rb26
-rw-r--r--app/workers/bulk_import_worker.rb14
-rw-r--r--changelogs/unreleased/ss-add-assignee-dropdown.yml5
-rw-r--r--doc/ci/yaml/README.md16
-rw-r--r--lib/bulk_imports/clients/http.rb18
-rw-r--r--lib/bulk_imports/common/extractors/graphql_extractor.rb6
-rw-r--r--lib/bulk_imports/common/loaders/entities_loader.rb19
-rw-r--r--lib/bulk_imports/groups/extractors/subgroups_extractor.rb32
-rw-r--r--lib/bulk_imports/groups/loaders/group_loader.rb6
-rw-r--r--lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline.rb15
-rw-r--r--lib/bulk_imports/groups/transformers/group_attributes_transformer.rb6
-rw-r--r--lib/bulk_imports/groups/transformers/subgroups_to_entities_transformer.rb23
-rw-r--r--lib/bulk_imports/importers/group_importer.rb22
-rw-r--r--lib/bulk_imports/importers/groups_importer.rb36
-rw-r--r--lib/bulk_imports/pipeline/context.rb2
-rw-r--r--locale/gitlab.pot5
-rw-r--r--package.json2
-rw-r--r--spec/controllers/import/bulk_imports_controller_spec.rb12
-rw-r--r--spec/features/projects/settings/registry_settings_spec.rb10
-rw-r--r--spec/frontend/boards/components/board_assignee_dropdown_spec.js229
-rw-r--r--spec/frontend/boards/mock_data.js17
-rw-r--r--spec/frontend/boards/stores/actions_spec.js43
-rw-r--r--spec/frontend/monitoring/components/embeds/embed_group_spec.js4
-rw-r--r--spec/frontend/sidebar/issuable_assignees_spec.js18
-rw-r--r--spec/lib/bulk_imports/clients/http_spec.rb57
-rw-r--r--spec/lib/bulk_imports/common/extractors/graphql_extractor_spec.rb2
-rw-r--r--spec/lib/bulk_imports/common/loaders/entities_loader_spec.rb30
-rw-r--r--spec/lib/bulk_imports/groups/loaders/group_loader_spec.rb3
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/group_pipeline_spec.rb10
-rw-r--r--spec/lib/bulk_imports/groups/pipelines/subgroup_entities_pipeline_spec.rb82
-rw-r--r--spec/lib/bulk_imports/groups/transformers/group_attributes_transformer_spec.rb3
-rw-r--r--spec/lib/bulk_imports/groups/transformers/subgroups_to_entities_transformer_spec.rb27
-rw-r--r--spec/lib/bulk_imports/importers/group_importer_spec.rb51
-rw-r--r--spec/lib/bulk_imports/importers/groups_importer_spec.rb36
-rw-r--r--spec/lib/bulk_imports/pipeline/context_spec.rb2
-rw-r--r--spec/services/bulk_import_service_spec.rb8
-rw-r--r--spec/workers/bulk_import_worker_spec.rb31
-rw-r--r--yarn.lock8
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"