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/ide/components/new_dropdown/modal.vue24
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_modal.vue (renamed from app/assets/javascripts/invite_members/components/import_a_project_modal.vue)122
-rw-r--r--app/assets/javascripts/invite_members/components/import_project_members_trigger.vue34
-rw-r--r--app/assets/javascripts/invite_members/init_import_a_project_modal.js23
-rw-r--r--app/assets/javascripts/invite_members/init_import_project_members_modal.js23
-rw-r--r--app/assets/javascripts/invite_members/init_import_project_members_trigger.js20
-rw-r--r--app/assets/javascripts/pages/projects/project_members/index.js6
-rw-r--r--app/assets/javascripts/vue_shared/components/source_viewer/constants.js2
-rw-r--r--app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb3
-rw-r--r--app/graphql/mutations/work_items/update.rb6
-rw-r--r--app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb21
-rw-r--r--app/services/audit_event_service.rb7
-rw-r--r--app/services/issuable_base_service.rb15
-rw-r--r--app/services/issuable_links/create_service.rb2
-rw-r--r--app/services/work_items/parent_links/create_service.rb65
-rw-r--r--app/services/work_items/update_service.rb36
-rw-r--r--app/services/work_items/widgets/base_service.rb2
-rw-r--r--app/services/work_items/widgets/hierarchy_service/base_service.rb33
-rw-r--r--app/services/work_items/widgets/hierarchy_service/update_service.rb51
-rw-r--r--app/views/projects/project_members/index.html.haml3
-rw-r--r--config/metrics/counts_28d/20210216183203_product_analytics_test_metrics_union.yml4
-rw-r--r--config/metrics/counts_28d/20210216183205_product_analytics_test_metrics_intersection.yml4
-rw-r--r--config/metrics/counts_28d/20210216184140_testing_total_unique_counts_monthly.yml4
-rw-r--r--config/metrics/counts_28d/20210916080405_promoted_issues.yml4
-rw-r--r--config/metrics/counts_7d/20210216183213_product_analytics_test_metrics_union.yml4
-rw-r--r--config/metrics/counts_7d/20210216183215_product_analytics_test_metrics_intersection.yml4
-rw-r--r--config/metrics/counts_all/20220314362302_service_usage_data_download_payload.yml6
-rw-r--r--config/metrics/license/20210201124932_recorded_at.yml4
-rw-r--r--config/metrics/license/20210201124933_uuid.yml4
-rw-r--r--config/metrics/license/20210204124827_hostname.yml4
-rw-r--r--config/metrics/license/20210204124829_active_user_count.yml4
-rw-r--r--config/metrics/license/20210204124938_recording_ce_finished_at.yml4
-rw-r--r--config/metrics/settings/20210702140138_collected_data_categories.yml4
-rw-r--r--config/metrics/settings/20210915152326_service_ping_features_enabled.yml4
-rw-r--r--config/metrics/settings/20211124061450_snowplow_enabled.yml4
-rw-r--r--config/metrics/settings/20211124085521_snowplow_configured_to_gitlab_collector_hostname.yml4
-rw-r--r--doc/administration/reference_architectures/10k_users.md3
-rw-r--r--doc/administration/reference_architectures/25k_users.md3
-rw-r--r--doc/administration/reference_architectures/2k_users.md3
-rw-r--r--doc/administration/reference_architectures/3k_users.md3
-rw-r--r--doc/administration/reference_architectures/50k_users.md3
-rw-r--r--doc/administration/reference_architectures/5k_users.md3
-rw-r--r--doc/api/graphql/reference/index.md11
-rw-r--r--doc/ci/migration/circleci.md4
-rw-r--r--doc/user/application_security/dependency_scanning/index.md4
-rw-r--r--doc/user/clusters/agent/index.md8
-rw-r--r--lib/gitlab/ci/templates/Jobs/Dependency-Scanning.gitlab-ci.yml1
-rw-r--r--lib/gitlab/database/background_migration/batched_job.rb1
-rw-r--r--lib/gitlab/database/background_migration/batched_migration.rb6
-rw-r--r--lib/gitlab/database/background_migration/batched_migration_runner.rb2
-rw-r--r--lib/gitlab/usage/service_ping/instrumented_payload.rb7
-rw-r--r--locale/gitlab.pot15
-rw-r--r--qa/qa/specs/features/api/1_manage/import_large_github_repo_spec.rb2
-rw-r--r--qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb6
-rw-r--r--spec/frontend/ide/components/new_dropdown/modal_spec.js94
-rw-r--r--spec/frontend/invite_members/components/import_project_members_modal_spec.js (renamed from spec/frontend/invite_members/components/import_a_project_modal_spec.js)44
-rw-r--r--spec/frontend/invite_members/components/import_project_members_trigger_spec.js49
-rw-r--r--spec/graphql/types/work_items/widgets/hierarchy_update_input_type_spec.rb9
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_job_spec.rb6
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb6
-rw-r--r--spec/lib/gitlab/database/background_migration/batched_migration_spec.rb21
-rw-r--r--spec/lib/gitlab/usage/service_ping/instrumented_payload_spec.rb50
-rw-r--r--spec/requests/api/graphql/mutations/work_items/update_spec.rb132
-rw-r--r--spec/services/work_items/parent_links/create_service_spec.rb161
-rw-r--r--spec/services/work_items/update_service_spec.rb60
-rw-r--r--spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb148
-rw-r--r--spec/views/projects/project_members/index.html.haml_spec.rb6
67 files changed, 1218 insertions, 217 deletions
diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
index e3c230f7660..d6207d4a557 100644
--- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue
+++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue
@@ -68,6 +68,10 @@ export default {
},
methods: {
...mapActions(['createTempEntry', 'renameEntry']),
+ submitAndClose() {
+ this.submitForm();
+ this.close();
+ },
submitForm() {
this.entryName = trimPathComponents(this.entryName);
@@ -161,15 +165,17 @@ export default {
<div class="form-group row">
<label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label>
<div class="col-sm-10">
- <input
- ref="fieldName"
- v-model.trim="entryName"
- type="text"
- class="form-control"
- data-testid="file-name-field"
- data-qa-selector="file_name_field"
- :placeholder="placeholder"
- />
+ <form data-testid="file-name-form" @submit.prevent="submitAndClose">
+ <input
+ ref="fieldName"
+ v-model.trim="entryName"
+ type="text"
+ class="form-control"
+ data-testid="file-name-field"
+ data-qa-selector="file_name_field"
+ :placeholder="placeholder"
+ />
+ </form>
<ul v-if="isCreatingNewFile" class="file-templates gl-mt-3 list-inline qa-template-list">
<li v-for="(template, index) in templateTypes" :key="index" class="list-inline-item">
<gl-button
diff --git a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
index 3789e3f7795..31b7fd4cc42 100644
--- a/app/assets/javascripts/invite_members/components/import_a_project_modal.vue
+++ b/app/assets/javascripts/invite_members/components/import_project_members_modal.vue
@@ -1,21 +1,20 @@
<script>
-import { GlButton, GlFormGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
+import { GlFormGroup, GlModal, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { importProjectMembers } from '~/api/projects_api';
+import { BV_SHOW_MODAL } from '~/lib/utils/constants';
import { s__, __, sprintf } from '~/locale';
+import eventHub from '../event_hub';
import ProjectSelect from './project_select.vue';
export default {
+ name: 'ImportProjectMembersModal',
components: {
- GlButton,
GlFormGroup,
GlModal,
GlSprintf,
ProjectSelect,
},
- directives: {
- GlModal: GlModalDirective,
- },
props: {
projectId: {
type: String,
@@ -45,8 +44,33 @@ export default {
validationState() {
return this.invalidFeedbackMessage === '' ? null : false;
},
+ actionPrimary() {
+ return {
+ text: this.$options.i18n.modalPrimaryButton,
+ attributes: {
+ variant: 'confirm',
+ disabled: this.importDisabled,
+ loading: this.isLoading,
+ },
+ };
+ },
+ actionCancel() {
+ return { text: this.$options.i18n.modalCancelButton };
+ },
+ },
+ mounted() {
+ eventHub.$on('openProjectMembersModal', () => {
+ this.openModal();
+ });
},
methods: {
+ openModal() {
+ this.$root.$emit(BV_SHOW_MODAL, this.$options.modalId);
+ },
+ resetFields() {
+ this.invalidFeedbackMessage = '';
+ this.projectToBeImported = {};
+ },
submitImport() {
this.isLoading = true;
return importProjectMembers(this.projectId, this.projectToBeImported.id)
@@ -57,11 +81,6 @@ export default {
this.projectToBeImported = {};
});
},
- closeModal() {
- this.invalidFeedbackMessage = '';
-
- this.$refs.modal.hide();
- },
showToastMessage() {
this.$toast.show(this.$options.i18n.successMessage, this.$options.toastOptions);
@@ -79,7 +98,6 @@ export default {
};
},
i18n: {
- buttonText: s__('ImportAProjectModal|Import from a project'),
projectLabel: __('Project'),
modalTitle: s__('ImportAProjectModal|Import members from another project'),
modalIntro: s__(
@@ -95,63 +113,37 @@ export default {
},
projectSelectLabelId: 'project-select',
modalId: uniqueId('import-a-project-modal-'),
- formClasses: 'gl-md-w-auto gl-w-full',
- buttonClasses: 'gl-w-full',
};
</script>
<template>
- <form :class="$options.formClasses">
- <gl-button v-gl-modal="$options.modalId" :class="$options.buttonClasses" variant="default">{{
- $options.i18n.buttonText
- }}</gl-button>
-
- <gl-modal
- ref="modal"
- :modal-id="$options.modalId"
- size="sm"
- :title="$options.i18n.modalTitle"
- ok-variant="danger"
- footer-class="gl-bg-gray-10 gl-p-5"
+ <gl-modal
+ ref="modal"
+ :modal-id="$options.modalId"
+ size="sm"
+ :title="$options.i18n.modalTitle"
+ :action-primary="actionPrimary"
+ :action-cancel="actionCancel"
+ @primary="submitImport"
+ @hidden="resetFields"
+ >
+ <p ref="modalIntro">
+ <gl-sprintf :message="modalIntro">
+ <template #strong="{ content }">
+ <strong>{{ content }}</strong>
+ </template>
+ </gl-sprintf>
+ </p>
+ <gl-form-group
+ :invalid-feedback="invalidFeedbackMessage"
+ :state="validationState"
+ data-testid="form-group"
>
- <div>
- <p ref="modalIntro">
- <gl-sprintf :message="modalIntro">
- <template #strong="{ content }">
- <strong>{{ content }}</strong>
- </template>
- </gl-sprintf>
- </p>
- <gl-form-group
- :invalid-feedback="invalidFeedbackMessage"
- :state="validationState"
- data-testid="form-group"
- >
- <label :id="$options.projectSelectLabelId" class="col-form-label">{{
- $options.i18n.projectLabel
- }}</label>
- <project-select v-model="projectToBeImported" />
- </gl-form-group>
- <p>{{ $options.i18n.modalHelpText }}</p>
- </div>
- <template #modal-footer>
- <div
- class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"
- >
- <gl-button data-testid="cancel-button" @click="closeModal">
- {{ $options.i18n.modalCancelButton }}
- </gl-button>
- <div class="gl-mr-3"></div>
- <gl-button
- :disabled="importDisabled"
- :loading="isLoading"
- variant="confirm"
- data-testid="import-button"
- @click="submitImport"
- >{{ $options.i18n.modalPrimaryButton }}</gl-button
- >
- </div>
- </template>
- </gl-modal>
- </form>
+ <label :id="$options.projectSelectLabelId" class="col-form-label">{{
+ $options.i18n.projectLabel
+ }}</label>
+ <project-select v-model="projectToBeImported" />
+ </gl-form-group>
+ <p>{{ $options.i18n.modalHelpText }}</p>
+ </gl-modal>
</template>
diff --git a/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue
new file mode 100644
index 00000000000..5781abb41b7
--- /dev/null
+++ b/app/assets/javascripts/invite_members/components/import_project_members_trigger.vue
@@ -0,0 +1,34 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import { s__ } from '~/locale';
+import eventHub from '../event_hub';
+
+export default {
+ components: {
+ GlButton,
+ },
+ props: {
+ displayText: {
+ type: String,
+ required: false,
+ default: s__('ImportAProjectModal|Import from a project'),
+ },
+ classes: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ methods: {
+ openModal() {
+ eventHub.$emit('openProjectMembersModal');
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button :class="classes" @click="openModal">
+ {{ displayText }}
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/invite_members/init_import_a_project_modal.js b/app/assets/javascripts/invite_members/init_import_a_project_modal.js
deleted file mode 100644
index 954347467de..00000000000
--- a/app/assets/javascripts/invite_members/init_import_a_project_modal.js
+++ /dev/null
@@ -1,23 +0,0 @@
-import Vue from 'vue';
-import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue';
-
-export default function initImportAProjectModal() {
- const el = document.querySelector('.js-import-a-project-modal');
-
- if (!el) {
- return false;
- }
-
- const { projectId, projectName } = el.dataset;
-
- return new Vue({
- el,
- render: (createElement) =>
- createElement(ImportAProjectModal, {
- props: {
- projectId,
- projectName,
- },
- }),
- });
-}
diff --git a/app/assets/javascripts/invite_members/init_import_project_members_modal.js b/app/assets/javascripts/invite_members/init_import_project_members_modal.js
new file mode 100644
index 00000000000..daaa1315884
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_import_project_members_modal.js
@@ -0,0 +1,23 @@
+import Vue from 'vue';
+import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue';
+
+export default function initImportProjectMembersModal() {
+ const el = document.querySelector('.js-import-project-members-modal');
+
+ if (!el) {
+ return false;
+ }
+
+ const { projectId, projectName } = el.dataset;
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(ImportProjectMembersModal, {
+ props: {
+ projectId,
+ projectName,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/invite_members/init_import_project_members_trigger.js b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js
new file mode 100644
index 00000000000..66a9bf118d2
--- /dev/null
+++ b/app/assets/javascripts/invite_members/init_import_project_members_trigger.js
@@ -0,0 +1,20 @@
+import Vue from 'vue';
+import ImportProjectMembersTrigger from '~/invite_members/components/import_project_members_trigger.vue';
+
+export default function initImportProjectMembersTrigger() {
+ const el = document.querySelector('.js-import-project-members-trigger');
+
+ if (!el) {
+ return false;
+ }
+
+ return new Vue({
+ el,
+ render: (createElement) =>
+ createElement(ImportProjectMembersTrigger, {
+ props: {
+ ...el.dataset,
+ },
+ }),
+ });
+}
diff --git a/app/assets/javascripts/pages/projects/project_members/index.js b/app/assets/javascripts/pages/projects/project_members/index.js
index 595a285032c..9a7fd74fd8c 100644
--- a/app/assets/javascripts/pages/projects/project_members/index.js
+++ b/app/assets/javascripts/pages/projects/project_members/index.js
@@ -1,4 +1,5 @@
-import initImportAProjectModal from '~/invite_members/init_import_a_project_modal';
+import initImportProjectMembersTrigger from '~/invite_members/init_import_project_members_trigger';
+import initImportProjectMembersModal from '~/invite_members/init_import_project_members_modal';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
import initInviteGroupsModal from '~/invite_members/init_invite_groups_modal';
@@ -9,11 +10,12 @@ import { MEMBER_TYPES } from '~/members/constants';
import { groupLinkRequestFormatter } from '~/members/utils';
import { projectMemberRequestFormatter } from '~/projects/members/utils';
-initImportAProjectModal();
+initImportProjectMembersModal();
initInviteMembersModal();
initInviteGroupsModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
+initImportProjectMembersTrigger();
const SHARED_FIELDS = ['account', 'maxRole', 'expiration', 'actions'];
initMembersApp(document.querySelector('.js-project-members-list-app'), {
diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
index 1ce077e81d4..94ebbccb014 100644
--- a/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
+++ b/app/assets/javascripts/vue_shared/components/source_viewer/constants.js
@@ -91,7 +91,7 @@ export const ROUGE_TO_HLJS_LANGUAGE_MAP = {
scala: 'scala',
scheme: 'scheme',
scss: 'scss',
- shell: 'shell',
+ shell: 'sh',
smalltalk: 'smalltalk',
sml: 'sml',
sqf: 'sqf',
diff --git a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
index cc15bff0916..cbe1cfb4099 100644
--- a/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
+++ b/app/graphql/mutations/concerns/mutations/work_items/update_arguments.rb
@@ -21,6 +21,9 @@ module Mutations
argument :weight_widget, ::Types::WorkItems::Widgets::WeightInputType,
required: false,
description: 'Input for weight widget.'
+ argument :hierarchy_widget, ::Types::WorkItems::Widgets::HierarchyUpdateInputType,
+ required: false,
+ description: 'Input for hierarchy widget.'
end
end
end
diff --git a/app/graphql/mutations/work_items/update.rb b/app/graphql/mutations/work_items/update.rb
index ff4aba4830f..8b1968dbad2 100644
--- a/app/graphql/mutations/work_items/update.rb
+++ b/app/graphql/mutations/work_items/update.rb
@@ -26,7 +26,7 @@ module Mutations
spam_params = ::Spam::SpamParams.new_from_request(request: context[:request])
widget_params = extract_widget_params(work_item, attributes)
- ::WorkItems::UpdateService.new(
+ update_result = ::WorkItems::UpdateService.new(
project: work_item.project,
current_user: current_user,
params: attributes,
@@ -37,8 +37,8 @@ module Mutations
check_spam_action_response!(work_item)
{
- work_item: work_item.valid? ? work_item : nil,
- errors: errors_on_object(work_item)
+ work_item: (update_result[:work_item] if update_result[:status] == :success),
+ errors: Array.wrap(update_result[:message])
}
end
diff --git a/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb b/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb
new file mode 100644
index 00000000000..8870d27ddce
--- /dev/null
+++ b/app/graphql/types/work_items/widgets/hierarchy_update_input_type.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+module Types
+ module WorkItems
+ module Widgets
+ class HierarchyUpdateInputType < BaseInputObject
+ graphql_name 'WorkItemWidgetHierarchyUpdateInput'
+
+ argument :parent_id, ::Types::GlobalIDType[::WorkItem],
+ required: false,
+ description: 'Global ID of the parent work item.',
+ prepare: ->(id, _) { id&.model_id }
+
+ argument :children_ids, [::Types::GlobalIDType[::WorkItem]],
+ required: false,
+ description: 'Global IDs of children work items.',
+ prepare: ->(ids, _) { ids.map(&:model_id) }
+ end
+ end
+ end
+end
diff --git a/app/services/audit_event_service.rb b/app/services/audit_event_service.rb
index 97debccfb18..26244a8bcc5 100644
--- a/app/services/audit_event_service.rb
+++ b/app/services/audit_event_service.rb
@@ -121,12 +121,15 @@ class AuditEventService
def log_security_event_to_database
return if Gitlab::Database.read_only?
- event = AuditEvent.new(base_payload.merge(details: @details))
+ event = build_event
save_or_track event
-
event
end
+ def build_event
+ AuditEvent.new(base_payload.merge(details: @details))
+ end
+
def stream_event_to_external_destinations(_event)
# Defined in EE
end
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 03115416607..a00a9a2021b 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -282,8 +282,9 @@ class IssuableBaseService < ::BaseProjectService
assign_requested_labels(issuable)
assign_requested_assignees(issuable)
assign_requested_crm_contacts(issuable)
+ widget_params = filter_widget_params
- if issuable.changed? || params.present?
+ if issuable.changed? || params.present? || widget_params.present?
issuable.assign_attributes(allowed_update_params(params))
if has_title_or_description_changed?(issuable)
@@ -303,7 +304,7 @@ class IssuableBaseService < ::BaseProjectService
ensure_milestone_available(issuable)
issuable_saved = issuable.with_transaction_returning_status do
- issuable.save(touch: should_touch)
+ transaction_update(issuable, { save_with_touch: should_touch })
end
if issuable_saved
@@ -332,6 +333,12 @@ class IssuableBaseService < ::BaseProjectService
issuable
end
+ def transaction_update(issuable, opts = {})
+ touch = opts[:save_with_touch] || false
+
+ issuable.save(touch: touch)
+ end
+
def update_task(issuable)
filter_params(issuable)
@@ -590,6 +597,10 @@ class IssuableBaseService < ::BaseProjectService
issuable_sla.update(issuable_closed: issuable.closed?)
end
+
+ def filter_widget_params
+ params.delete(:widget_params)
+ end
end
IssuableBaseService.prepend_mod_with('IssuableBaseService')
diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb
index 0887f04760c..aca98596a02 100644
--- a/app/services/issuable_links/create_service.rb
+++ b/app/services/issuable_links/create_service.rb
@@ -8,6 +8,7 @@ module IssuableLinks
@issuable = issuable
@current_user = user
@params = params.dup
+ @errors = []
end
def execute
@@ -22,7 +23,6 @@ module IssuableLinks
return error(issuables_not_found_message, 404)
end
- @errors = []
references = create_links
if @errors.present?
diff --git a/app/services/work_items/parent_links/create_service.rb b/app/services/work_items/parent_links/create_service.rb
new file mode 100644
index 00000000000..87995cc6550
--- /dev/null
+++ b/app/services/work_items/parent_links/create_service.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module ParentLinks
+ class CreateService < IssuableLinks::CreateService
+ private
+
+ # rubocop: disable CodeReuse/ActiveRecord
+ def relate_issuables(work_item)
+ link = WorkItems::ParentLink.find_or_initialize_by(work_item: work_item)
+ link.work_item_parent = issuable
+
+ if link.changed? && link.save
+ create_notes(work_item)
+ end
+
+ link
+ end
+ # rubocop: enable CodeReuse/ActiveRecord
+
+ def linkable_issuables(work_items)
+ @linkable_issuables ||= begin
+ return [] unless can?(current_user, :read_work_item, issuable.project)
+
+ work_items.select do |work_item|
+ linkable?(work_item)
+ end
+ end
+ end
+
+ def linkable?(work_item)
+ can?(current_user, :update_work_item, work_item) &&
+ !previous_related_issuables.include?(work_item)
+ end
+
+ def previous_related_issuables
+ @related_issues ||= issuable.work_item_children.to_a
+ end
+
+ def extract_references
+ params[:issuable_references].map do |id|
+ ::WorkItem.find(id)
+ rescue ActiveRecord::RecordNotFound
+ @errors << _("Task with ID: %{id} could not be found.") % { id: id }
+ nil
+ end
+ end
+
+ # TODO: Create system notes when work item's parent or children are updated
+ # See https://gitlab.com/gitlab-org/gitlab/-/issues/362213
+ def create_notes(work_item)
+ # no-op
+ end
+
+ def target_issuable_type
+ issuable.issue_type == 'issue' ? 'task' : issuable.issue_type
+ end
+
+ def issuables_not_found_message
+ _('No matching %{issuable} found. Make sure that you are adding a valid %{issuable} ID.' %
+ { issuable: target_issuable_type })
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/update_service.rb b/app/services/work_items/update_service.rb
index 7b50040a716..4d34b32096c 100644
--- a/app/services/work_items/update_service.rb
+++ b/app/services/work_items/update_service.rb
@@ -3,12 +3,26 @@
module WorkItems
class UpdateService < ::Issues::UpdateService
def initialize(project:, current_user: nil, params: {}, spam_params: nil, widget_params: {})
+ params[:widget_params] = true if widget_params.present?
+
super(project: project, current_user: current_user, params: params, spam_params: nil)
@widget_params = widget_params
@widget_services = {}
end
+ def execute(work_item)
+ updated_work_item = super
+
+ if updated_work_item.valid?
+ success(payload(work_item))
+ else
+ error(updated_work_item.errors.full_messages, :unprocessable_entity, pass_back: payload(updated_work_item))
+ end
+ rescue ::WorkItems::Widgets::BaseService::WidgetError => e
+ error(e.message, :unprocessable_entity)
+ end
+
private
def update(work_item)
@@ -17,6 +31,12 @@ module WorkItems
super
end
+ def transaction_update(work_item, opts = {})
+ execute_widgets(work_item: work_item, callback: :before_update_in_transaction)
+
+ super
+ end
+
def after_update(work_item)
super
@@ -30,15 +50,17 @@ module WorkItems
end
def widget_service(widget)
- service_class = begin
- "WorkItems::Widgets::#{widget.type.capitalize}Service::UpdateService".constantize
- rescue NameError
- nil
- end
+ @widget_services[widget] ||= widget_service_class(widget)&.new(widget: widget, current_user: current_user)
+ end
- return unless service_class
+ def widget_service_class(widget)
+ "WorkItems::Widgets::#{widget.type.capitalize}Service::UpdateService".constantize
+ rescue NameError
+ nil
+ end
- @widget_services[widget] ||= service_class.new(widget: widget, current_user: current_user)
+ def payload(work_item)
+ { work_item: work_item }
end
end
end
diff --git a/app/services/work_items/widgets/base_service.rb b/app/services/work_items/widgets/base_service.rb
index 72debc272bd..037733bbed5 100644
--- a/app/services/work_items/widgets/base_service.rb
+++ b/app/services/work_items/widgets/base_service.rb
@@ -3,6 +3,8 @@
module WorkItems
module Widgets
class BaseService < ::BaseService
+ WidgetError = Class.new(StandardError)
+
attr_reader :widget, :current_user
def initialize(widget:, current_user:)
diff --git a/app/services/work_items/widgets/hierarchy_service/base_service.rb b/app/services/work_items/widgets/hierarchy_service/base_service.rb
new file mode 100644
index 00000000000..d385074a9a6
--- /dev/null
+++ b/app/services/work_items/widgets/hierarchy_service/base_service.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module HierarchyService
+ class BaseService < WorkItems::Widgets::BaseService
+ private
+
+ def update_work_item_parent(parent_id)
+ begin
+ parent = ::WorkItem.find(parent_id)
+ rescue ActiveRecord::RecordNotFound
+ return parent_not_found_error(parent_id)
+ end
+
+ ::WorkItems::ParentLinks::CreateService
+ .new(parent, current_user, { target_issuable: widget.work_item })
+ .execute
+ end
+
+ def update_work_item_children(children_ids)
+ ::WorkItems::ParentLinks::CreateService
+ .new(widget.work_item, current_user, { issuable_references: children_ids })
+ .execute
+ end
+
+ def parent_not_found_error(id)
+ error(_('No Work Item found with ID: %{id}.' % { id: id }))
+ end
+ end
+ end
+ end
+end
diff --git a/app/services/work_items/widgets/hierarchy_service/update_service.rb b/app/services/work_items/widgets/hierarchy_service/update_service.rb
new file mode 100644
index 00000000000..5b6fea6aa1e
--- /dev/null
+++ b/app/services/work_items/widgets/hierarchy_service/update_service.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+module WorkItems
+ module Widgets
+ module HierarchyService
+ class UpdateService < WorkItems::Widgets::HierarchyService::BaseService
+ def before_update_in_transaction(params:)
+ return unless params.present?
+
+ result = handle_hierarchy_changes(params)
+
+ raise WidgetError, result[:message] if result[:status] == :error
+ end
+
+ private
+
+ def handle_hierarchy_changes(params)
+ return feature_flag_error unless feature_flag_enabled?
+ return incompatible_args_error if incompatible_args?(params)
+
+ update_hierarchy(params)
+ end
+
+ def update_hierarchy(params)
+ parent_id = params.delete(:parent_id)
+ children_ids = params.delete(:children_ids)
+
+ return update_work_item_parent(parent_id) if parent_id
+
+ update_work_item_children(children_ids) if children_ids
+ end
+
+ def feature_flag_enabled?
+ Feature.enabled?(:work_items_hierarchy, widget.work_item&.project)
+ end
+
+ def incompatible_args?(params)
+ params[:parent_id] && params[:children_ids]
+ end
+
+ def feature_flag_error
+ error(_('`work_items_hierarchy` feature flag disabled for this project'))
+ end
+
+ def incompatible_args_error
+ error(_('A Work Item can be a parent or a child, but not both.'))
+ end
+ end
+ end
+ end
+end
diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml
index fd91786e9f9..7e2dfc94bb4 100644
--- a/app/views/projects/project_members/index.html.haml
+++ b/app/views/projects/project_members/index.html.haml
@@ -15,7 +15,8 @@
.gl-display-flex.gl-flex-wrap.gl-align-items-flex-start.gl-ml-auto.gl-md-w-auto.gl-w-full.gl-mt-3
- invite_group_top_margin = ''
- if can_admin_project_member?(@project)
- .js-import-a-project-modal{ data: { project_id: @project.id, project_name: @project.name } }
+ .js-import-project-members-trigger{ data: { classes: 'gl-md-w-auto gl-w-full' } }
+ .js-import-project-members-modal{ data: { project_id: @project.id, project_name: @project.name } }
- invite_group_top_margin = 'gl-md-mt-0 gl-mt-3'
- if @project.allowed_to_share_with_group?
.js-invite-group-trigger{ data: { classes: "gl-md-w-auto gl-w-full gl-md-ml-3 #{invite_group_top_margin}", display_text: _('Invite a group') } }
diff --git a/config/metrics/counts_28d/20210216183203_product_analytics_test_metrics_union.yml b/config/metrics/counts_28d/20210216183203_product_analytics_test_metrics_union.yml
index e51ac640710..d617f3dcebd 100644
--- a/config/metrics/counts_28d/20210216183203_product_analytics_test_metrics_union.yml
+++ b/config/metrics/counts_28d/20210216183203_product_analytics_test_metrics_union.yml
@@ -2,8 +2,8 @@
data_category: optional
key_path: counts_monthly.aggregated_metrics.product_analytics_test_metrics_union
description: This was test metric used for purpose of assuring correct implementation of aggregated metrics feature
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: number
diff --git a/config/metrics/counts_28d/20210216183205_product_analytics_test_metrics_intersection.yml b/config/metrics/counts_28d/20210216183205_product_analytics_test_metrics_intersection.yml
index 195f14de62f..b5c1114ddbb 100644
--- a/config/metrics/counts_28d/20210216183205_product_analytics_test_metrics_intersection.yml
+++ b/config/metrics/counts_28d/20210216183205_product_analytics_test_metrics_intersection.yml
@@ -2,8 +2,8 @@
data_category: optional
key_path: counts_monthly.aggregated_metrics.product_analytics_test_metrics_intersection
description: This was test metric used for purpose of assuring correct implementation of aggregated metrics feature
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: number
diff --git a/config/metrics/counts_28d/20210216184140_testing_total_unique_counts_monthly.yml b/config/metrics/counts_28d/20210216184140_testing_total_unique_counts_monthly.yml
index 5603a8f0885..9c8ce1a1f06 100644
--- a/config/metrics/counts_28d/20210216184140_testing_total_unique_counts_monthly.yml
+++ b/config/metrics/counts_28d/20210216184140_testing_total_unique_counts_monthly.yml
@@ -2,8 +2,8 @@
data_category: optional
key_path: redis_hll_counters.testing.testing_total_unique_counts_monthly
description: Total users for events under testing category
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
value_type: number
status: removed
diff --git a/config/metrics/counts_28d/20210916080405_promoted_issues.yml b/config/metrics/counts_28d/20210916080405_promoted_issues.yml
index feef83f3e04..5c1ad6446f0 100644
--- a/config/metrics/counts_28d/20210916080405_promoted_issues.yml
+++ b/config/metrics/counts_28d/20210916080405_promoted_issues.yml
@@ -2,8 +2,8 @@
key_path: counts_monthly.promoted_issues
name: count_promoted_issues
description: Count of issues promoted to epics
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: number
diff --git a/config/metrics/counts_7d/20210216183213_product_analytics_test_metrics_union.yml b/config/metrics/counts_7d/20210216183213_product_analytics_test_metrics_union.yml
index d268d1260d5..6d7b16b9f3d 100644
--- a/config/metrics/counts_7d/20210216183213_product_analytics_test_metrics_union.yml
+++ b/config/metrics/counts_7d/20210216183213_product_analytics_test_metrics_union.yml
@@ -2,8 +2,8 @@
data_category: optional
key_path: counts_weekly.aggregated_metrics.product_analytics_test_metrics_union
description: This was test metric used for purpose of assuring correct implementation of aggregated metrics feature
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: number
diff --git a/config/metrics/counts_7d/20210216183215_product_analytics_test_metrics_intersection.yml b/config/metrics/counts_7d/20210216183215_product_analytics_test_metrics_intersection.yml
index 3a8d6c7e74e..46295dcdb00 100644
--- a/config/metrics/counts_7d/20210216183215_product_analytics_test_metrics_intersection.yml
+++ b/config/metrics/counts_7d/20210216183215_product_analytics_test_metrics_intersection.yml
@@ -2,8 +2,8 @@
data_category: optional
key_path: counts_weekly.aggregated_metrics.product_analytics_test_metrics_intersection
description: This was test metric used for purpose of assuring correct implementation of aggregated metrics feature
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: number
diff --git a/config/metrics/counts_all/20220314362302_service_usage_data_download_payload.yml b/config/metrics/counts_all/20220314362302_service_usage_data_download_payload.yml
index 69303f9e016..1b26202c5ef 100644
--- a/config/metrics/counts_all/20220314362302_service_usage_data_download_payload.yml
+++ b/config/metrics/counts_all/20220314362302_service_usage_data_download_payload.yml
@@ -2,9 +2,9 @@
key_path: counts.service_usage_data_download_payload_click
description: Count Download Payload button clicks
data_category: optional
-name: count_promoted_issues
-product_section: growth
-product_stage: growth
+name: service_usage_data_download_payload_click
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: number
diff --git a/config/metrics/license/20210201124932_recorded_at.yml b/config/metrics/license/20210201124932_recorded_at.yml
index d98bc41aa75..c9d98a96727 100644
--- a/config/metrics/license/20210201124932_recorded_at.yml
+++ b/config/metrics/license/20210201124932_recorded_at.yml
@@ -2,8 +2,8 @@
data_category: standard
key_path: recorded_at
description: When the Usage Ping computation was started
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: string
diff --git a/config/metrics/license/20210201124933_uuid.yml b/config/metrics/license/20210201124933_uuid.yml
index f1fb9ac8005..f864c2be37a 100644
--- a/config/metrics/license/20210201124933_uuid.yml
+++ b/config/metrics/license/20210201124933_uuid.yml
@@ -1,8 +1,8 @@
---
key_path: uuid
description: GitLab instance unique identifier
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: string
diff --git a/config/metrics/license/20210204124827_hostname.yml b/config/metrics/license/20210204124827_hostname.yml
index 5959c7d1496..f545ae0c061 100644
--- a/config/metrics/license/20210204124827_hostname.yml
+++ b/config/metrics/license/20210204124827_hostname.yml
@@ -1,8 +1,8 @@
---
key_path: hostname
description: Host name of GitLab instance
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: string
diff --git a/config/metrics/license/20210204124829_active_user_count.yml b/config/metrics/license/20210204124829_active_user_count.yml
index 38bc756b9a7..037ac7b5a03 100644
--- a/config/metrics/license/20210204124829_active_user_count.yml
+++ b/config/metrics/license/20210204124829_active_user_count.yml
@@ -1,8 +1,8 @@
---
key_path: active_user_count
description: The number of active users existing in the instance. This is named the instance_user_count in the Versions application.
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: number
diff --git a/config/metrics/license/20210204124938_recording_ce_finished_at.yml b/config/metrics/license/20210204124938_recording_ce_finished_at.yml
index 8ef26361838..54728ff8206 100644
--- a/config/metrics/license/20210204124938_recording_ce_finished_at.yml
+++ b/config/metrics/license/20210204124938_recording_ce_finished_at.yml
@@ -2,8 +2,8 @@
data_category: standard
key_path: recording_ce_finished_at
description: When the core features were computed
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: string
diff --git a/config/metrics/settings/20210702140138_collected_data_categories.yml b/config/metrics/settings/20210702140138_collected_data_categories.yml
index 6fed28feff7..006a77f8b29 100644
--- a/config/metrics/settings/20210702140138_collected_data_categories.yml
+++ b/config/metrics/settings/20210702140138_collected_data_categories.yml
@@ -2,8 +2,8 @@
key_path: settings.collected_data_categories
name: collected_data_categories
description: List of collected data categories corresponding to instance settings
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: object
diff --git a/config/metrics/settings/20210915152326_service_ping_features_enabled.yml b/config/metrics/settings/20210915152326_service_ping_features_enabled.yml
index 6412e97d8a8..d7e1a7f68bf 100644
--- a/config/metrics/settings/20210915152326_service_ping_features_enabled.yml
+++ b/config/metrics/settings/20210915152326_service_ping_features_enabled.yml
@@ -2,8 +2,8 @@
key_path: settings.service_ping_features_enabled
name: "service_ping_features_enabled"
description: Whether Service Ping features are enabled
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: collection
value_type: boolean
diff --git a/config/metrics/settings/20211124061450_snowplow_enabled.yml b/config/metrics/settings/20211124061450_snowplow_enabled.yml
index dc75a4a6e1e..14662665cf7 100644
--- a/config/metrics/settings/20211124061450_snowplow_enabled.yml
+++ b/config/metrics/settings/20211124061450_snowplow_enabled.yml
@@ -2,8 +2,8 @@
key_path: settings.snowplow_enabled
name: snowplow_enabled_gitlab_instance
description: Whether snowplow is enabled for the GitLab instance
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: product intelligence
value_type: boolean
diff --git a/config/metrics/settings/20211124085521_snowplow_configured_to_gitlab_collector_hostname.yml b/config/metrics/settings/20211124085521_snowplow_configured_to_gitlab_collector_hostname.yml
index 49c1a2ae448..c634facdd92 100644
--- a/config/metrics/settings/20211124085521_snowplow_configured_to_gitlab_collector_hostname.yml
+++ b/config/metrics/settings/20211124085521_snowplow_configured_to_gitlab_collector_hostname.yml
@@ -2,8 +2,8 @@
key_path: settings.snowplow_configured_to_gitlab_collector
name: snowplow_configured_to_gitlab_collector
description: Metric informs if currently configured Snowplow collector hostname points towards Gitlab Snowplow collection pipeline.
-product_section: growth
-product_stage: growth
+product_section: analytics
+product_stage: analytics
product_group: product_intelligence
product_category: product intelligence
value_type: boolean
diff --git a/doc/administration/reference_architectures/10k_users.md b/doc/administration/reference_architectures/10k_users.md
index 7901fcaf78d..6e92c9a3c2b 100644
--- a/doc/administration/reference_architectures/10k_users.md
+++ b/doc/administration/reference_architectures/10k_users.md
@@ -1351,6 +1351,9 @@ To configure the Praefect nodes, on each one:
on the page.
1. Edit the `/etc/gitlab/gitlab.rb` file to configure Praefect:
+ NOTE:
+ You can't remove the `default` entry from `virtual_storages` because [GitLab requires it](../gitaly/configure_gitaly.md#gitlab-requires-a-default-repository-storage).
+
<!--
Updates to example must be made at:
- https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/administration/gitaly/praefect.md
diff --git a/doc/administration/reference_architectures/25k_users.md b/doc/administration/reference_architectures/25k_users.md
index 3ebe37efe4e..5fa3cbccff1 100644
--- a/doc/administration/reference_architectures/25k_users.md
+++ b/doc/administration/reference_architectures/25k_users.md
@@ -1355,6 +1355,9 @@ To configure the Praefect nodes, on each one:
on the page.
1. Edit the `/etc/gitlab/gitlab.rb` file to configure Praefect:
+ NOTE:
+ You can't remove the `default` entry from `virtual_storages` because [GitLab requires it](../gitaly/configure_gitaly.md#gitlab-requires-a-default-repository-storage).
+
<!--
Updates to example must be made at:
- https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/administration/gitaly/praefect.md
diff --git a/doc/administration/reference_architectures/2k_users.md b/doc/administration/reference_architectures/2k_users.md
index 30af94cd745..cbb627e934e 100644
--- a/doc/administration/reference_architectures/2k_users.md
+++ b/doc/administration/reference_architectures/2k_users.md
@@ -444,6 +444,9 @@ To configure the Gitaly server, on the server node you want to use for Gitaly:
1. Edit the Gitaly server node's `/etc/gitlab/gitlab.rb` file to configure
storage paths, enable the network listener, and to configure the token:
+ NOTE:
+ You can't remove the `default` entry from `git_data_dirs` because [GitLab requires it](../gitaly/configure_gitaly.md#gitlab-requires-a-default-repository-storage).
+
<!--
Updates to example must be made at:
- https://gitlab.com/gitlab-org/charts/gitlab/blob/master/doc/advanced/external-gitaly/external-omnibus-gitaly.md#configure-omnibus-gitlab
diff --git a/doc/administration/reference_architectures/3k_users.md b/doc/administration/reference_architectures/3k_users.md
index 0dbeb480ed0..3e879de88ff 100644
--- a/doc/administration/reference_architectures/3k_users.md
+++ b/doc/administration/reference_architectures/3k_users.md
@@ -1295,6 +1295,9 @@ To configure the Praefect nodes, on each one:
on the page.
1. Edit the `/etc/gitlab/gitlab.rb` file to configure Praefect:
+ NOTE:
+ You can't remove the `default` entry from `virtual_storages` because [GitLab requires it](../gitaly/configure_gitaly.md#gitlab-requires-a-default-repository-storage).
+
<!--
Updates to example must be made at:
- https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/administration/gitaly/praefect.md
diff --git a/doc/administration/reference_architectures/50k_users.md b/doc/administration/reference_architectures/50k_users.md
index ac41a6fbf53..92df6f97395 100644
--- a/doc/administration/reference_architectures/50k_users.md
+++ b/doc/administration/reference_architectures/50k_users.md
@@ -1364,6 +1364,9 @@ To configure the Praefect nodes, on each one:
on the page.
1. Edit the `/etc/gitlab/gitlab.rb` file to configure Praefect:
+ NOTE:
+ You can't remove the `default` entry from `virtual_storages` because [GitLab requires it](../gitaly/configure_gitaly.md#gitlab-requires-a-default-repository-storage).
+
<!--
Updates to example must be made at:
- https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/administration/gitaly/praefect.md
diff --git a/doc/administration/reference_architectures/5k_users.md b/doc/administration/reference_architectures/5k_users.md
index 2751443ca65..09a01dfbb1d 100644
--- a/doc/administration/reference_architectures/5k_users.md
+++ b/doc/administration/reference_architectures/5k_users.md
@@ -1293,6 +1293,9 @@ To configure the Praefect nodes, on each one:
on the page.
1. Edit the `/etc/gitlab/gitlab.rb` file to configure Praefect:
+ NOTE:
+ You can't remove the `default` entry from `virtual_storages` because [GitLab requires it](../gitaly/configure_gitaly.md#gitlab-requires-a-default-repository-storage).
+
<!--
Updates to example must be made at:
- https://gitlab.com/gitlab-org/gitlab/-/blob/master/doc/administration/gitaly/praefect.md
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 4e62d0bf5d3..4dcae2ccc4c 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -5652,6 +5652,7 @@ Input type: `WorkItemUpdateInput`
| ---- | ---- | ----------- |
| <a id="mutationworkitemupdateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. |
| <a id="mutationworkitemupdatedescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. |
+| <a id="mutationworkitemupdatehierarchywidget"></a>`hierarchyWidget` | [`WorkItemWidgetHierarchyUpdateInput`](#workitemwidgethierarchyupdateinput) | Input for hierarchy widget. |
| <a id="mutationworkitemupdateid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
| <a id="mutationworkitemupdatestateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. |
| <a id="mutationworkitemupdatetitle"></a>`title` | [`String`](#string) | Title of the work item. |
@@ -22094,6 +22095,7 @@ A time-frame defined as a closed inclusive range of two dates.
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="workitemupdatedtaskinputdescriptionwidget"></a>`descriptionWidget` | [`WorkItemWidgetDescriptionInput`](#workitemwidgetdescriptioninput) | Input for description widget. |
+| <a id="workitemupdatedtaskinputhierarchywidget"></a>`hierarchyWidget` | [`WorkItemWidgetHierarchyUpdateInput`](#workitemwidgethierarchyupdateinput) | Input for hierarchy widget. |
| <a id="workitemupdatedtaskinputid"></a>`id` | [`WorkItemID!`](#workitemid) | Global ID of the work item. |
| <a id="workitemupdatedtaskinputstateevent"></a>`stateEvent` | [`WorkItemStateEvent`](#workitemstateevent) | Close or reopen a work item. |
| <a id="workitemupdatedtaskinputtitle"></a>`title` | [`String`](#string) | Title of the work item. |
@@ -22107,6 +22109,15 @@ A time-frame defined as a closed inclusive range of two dates.
| ---- | ---- | ----------- |
| <a id="workitemwidgetdescriptioninputdescription"></a>`description` | [`String!`](#string) | Description of the work item. |
+### `WorkItemWidgetHierarchyUpdateInput`
+
+#### Arguments
+
+| Name | Type | Description |
+| ---- | ---- | ----------- |
+| <a id="workitemwidgethierarchyupdateinputchildrenids"></a>`childrenIds` | [`[WorkItemID!]`](#workitemid) | Global IDs of children work items. |
+| <a id="workitemwidgethierarchyupdateinputparentid"></a>`parentId` | [`WorkItemID`](#workitemid) | Global ID of the parent work item. |
+
### `WorkItemWidgetWeightInput`
#### Arguments
diff --git a/doc/ci/migration/circleci.md b/doc/ci/migration/circleci.md
index b1c4c62c465..3b890458e56 100644
--- a/doc/ci/migration/circleci.md
+++ b/doc/ci/migration/circleci.md
@@ -289,8 +289,8 @@ Self-managed runners:
GitLab.com shared runners:
- Linux
-- Windows
-- [Planned: macOS](https://gitlab.com/gitlab-com/gl-infra/infrastructure/-/issues/5720)
+- [Windows](../runners/saas/windows_saas_runner.md) ([Beta](../../policy/alpha-beta-support.md#beta-features)).
+- [macOS](../runners/saas/macos_saas_runner.md) ([Beta](../../policy/alpha-beta-support.md#beta-features)).
### Machine and specific build environments
diff --git a/doc/user/application_security/dependency_scanning/index.md b/doc/user/application_security/dependency_scanning/index.md
index 4449cf47431..0da9982e1a4 100644
--- a/doc/user/application_security/dependency_scanning/index.md
+++ b/doc/user/application_security/dependency_scanning/index.md
@@ -616,7 +616,7 @@ The following variables are used for configuring specific analyzers (used for a
| `GEMNASIUM_DB_UPDATE_DISABLED` | `gemnasium` | `"false"` | Disable automatic updates for the `gemnasium-db` advisory database (For usage see: [examples](#hosting-a-copy-of-the-gemnasium_db-advisory-database))|
| `GEMNASIUM_DB_REMOTE_URL` | `gemnasium` | `https://gitlab.com/gitlab-org/security-products/gemnasium-db.git` | Repository URL for fetching the Gemnasium database. |
| `GEMNASIUM_DB_REF_NAME` | `gemnasium` | `master` | Branch name for remote repository database. `GEMNASIUM_DB_REMOTE_URL` is required. |
-| `DS_REMEDIATE` | `gemnasium` | `"true"` | Enable automatic remediation of vulnerable dependencies. |
+| `DS_REMEDIATE` | `gemnasium` | `"true"`, `"false"` in FIPS mode | Enable automatic remediation of vulnerable dependencies. Not supported in FIPS mode. |
| `GEMNASIUM_LIBRARY_SCAN_ENABLED` | `gemnasium` | `"true"` | Enable detecting vulnerabilities in vendored JavaScript libraries. For now, `gemnasium` leverages [`Retire.js`](https://github.com/RetireJS/retire.js) to do this job. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/350512) in GitLab 14.8. |
| `DS_JAVA_VERSION` | `gemnasium-maven` | `17` | Version of Java. Available versions: `8`, `11`, `13`, `14`, `15`, `16`, `17`. Available versions in FIPS-enabled image: `8`, `11`, `17`. |
| `MAVEN_CLI_OPTS` | `gemnasium-maven` | `"-DskipTests --batch-mode"` | List of command line arguments that are passed to `maven` by the analyzer. See an example for [using private repositories](../index.md#using-private-maven-repositories). |
@@ -693,6 +693,8 @@ To manually switch to FIPS-enabled images, set the variable `DS_IMAGE_SUFFIX` to
To ensure compliance with FIPS, the FIPS-enabled image of `gemnasium-maven` uses the OpenJDK packages for RedHat UBI.
As a result, it only supports Java 8, 11, and 17.
+Auto-remediation for Yarn projects isn't supported in FIPS mode.
+
## Interacting with the vulnerabilities
Once a vulnerability is found, you can interact with it. Read more on how to
diff --git a/doc/user/clusters/agent/index.md b/doc/user/clusters/agent/index.md
index e714829fa16..4ec3c8d0297 100644
--- a/doc/user/clusters/agent/index.md
+++ b/doc/user/clusters/agent/index.md
@@ -43,10 +43,10 @@ This workflow is considered push-based, because GitLab is pushing requests from
GitLab supports the following Kubernetes versions. You can upgrade your
Kubernetes version to a supported version at any time:
-- 1.23 (support ends on October 22, 2023)
-- 1.22 (support ends on March 22, 2023)
-- 1.21 (support ends on November 22, 2022)
-- 1.20 (support ends on July 22, 2022)
+- 1.24 (support ends on September 22, 2023)
+- 1.23 (support ends on February 22, 2023)
+- 1.22 (support ends on October 22, 2022)
+- 1.21 (support ends on September 22, 2022)
GitLab supports at least two production-ready Kubernetes minor
versions at any given time. GitLab regularly reviews the supported versions and
diff --git a/lib/gitlab/ci/templates/Jobs/Dependency-Scanning.gitlab-ci.yml b/lib/gitlab/ci/templates/Jobs/Dependency-Scanning.gitlab-ci.yml
index 42cfb83bb7f..a9d9c400a34 100644
--- a/lib/gitlab/ci/templates/Jobs/Dependency-Scanning.gitlab-ci.yml
+++ b/lib/gitlab/ci/templates/Jobs/Dependency-Scanning.gitlab-ci.yml
@@ -81,6 +81,7 @@ gemnasium-dependency_scanning:
exists: !reference [.gemnasium-shared-rule, exists]
variables:
DS_IMAGE_SUFFIX: "-fips"
+ DS_REMEDIATE: "false"
- if: $CI_COMMIT_BRANCH &&
$GITLAB_FEATURES =~ /\bdependency_scanning\b/
exists: !reference [.gemnasium-shared-rule, exists]
diff --git a/lib/gitlab/database/background_migration/batched_job.rb b/lib/gitlab/database/background_migration/batched_job.rb
index 436403e39ab..72aa1cfe00b 100644
--- a/lib/gitlab/database/background_migration/batched_job.rb
+++ b/lib/gitlab/database/background_migration/batched_job.rb
@@ -26,6 +26,7 @@ module Gitlab
scope :successful_in_execution_order, -> { where.not(finished_at: nil).with_status(:succeeded).order(:finished_at) }
scope :with_preloads, -> { preload(:batched_migration) }
scope :created_since, ->(date_time) { where('created_at >= ?', date_time) }
+ scope :blocked_by_max_attempts, -> { where('attempts >= ?', MAX_ATTEMPTS) }
state_machine :status, initial: :pending do
state :pending, value: 0
diff --git a/lib/gitlab/database/background_migration/batched_migration.rb b/lib/gitlab/database/background_migration/batched_migration.rb
index 29425145352..9c8db2243f9 100644
--- a/lib/gitlab/database/background_migration/batched_migration.rb
+++ b/lib/gitlab/database/background_migration/batched_migration.rb
@@ -104,6 +104,12 @@ module Gitlab
.sum(:batch_size)
end
+ def reset_attempts_of_blocked_jobs!
+ batched_jobs.blocked_by_max_attempts.each_batch(of: 100) do |batch|
+ batch.update_all(attempts: 0)
+ end
+ end
+
def interval_elapsed?(variance: 0)
return true unless last_job
diff --git a/lib/gitlab/database/background_migration/batched_migration_runner.rb b/lib/gitlab/database/background_migration/batched_migration_runner.rb
index 7936cf62282..1bc2e931391 100644
--- a/lib/gitlab/database/background_migration/batched_migration_runner.rb
+++ b/lib/gitlab/database/background_migration/batched_migration_runner.rb
@@ -72,6 +72,8 @@ module Gitlab
elsif migration.finished?
Gitlab::AppLogger.warn "Batched background migration for the given configuration is already finished: #{configuration}"
else
+ migration.reset_attempts_of_blocked_jobs!
+
migration.finalize!
migration.batched_jobs.with_status(:pending).each { |job| migration_wrapper.perform(job) }
diff --git a/lib/gitlab/usage/service_ping/instrumented_payload.rb b/lib/gitlab/usage/service_ping/instrumented_payload.rb
index 6cc67321ba1..3aa6789a010 100644
--- a/lib/gitlab/usage/service_ping/instrumented_payload.rb
+++ b/lib/gitlab/usage/service_ping/instrumented_payload.rb
@@ -34,6 +34,13 @@ module Gitlab
return {} unless definition.present?
Gitlab::Usage::Metric.new(definition).method(output_method).call
+ rescue StandardError => error
+ Gitlab::ErrorTracking.track_and_raise_for_dev_exception(error)
+ metric_fallback(key_path)
+ end
+
+ def metric_fallback(key_path)
+ ::Gitlab::Usage::Metrics::KeyPathProcessor.process(key_path, ::Gitlab::Utils::UsageData::FALLBACK)
end
end
end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index ec44e6c7b44..4c678865d3b 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1571,6 +1571,9 @@ msgstr ""
msgid "A Let's Encrypt SSL certificate can not be obtained until your domain is verified."
msgstr ""
+msgid "A Work Item can be a parent or a child, but not both."
+msgstr ""
+
msgid "A basic page and serverless function that uses AWS Lambda, AWS API Gateway, and GitLab Pages"
msgstr ""
@@ -25854,6 +25857,9 @@ msgstr ""
msgid "No Scopes"
msgstr ""
+msgid "No Work Item found with ID: %{id}."
+msgstr ""
+
msgid "No active admin user found"
msgstr ""
@@ -26001,6 +26007,9 @@ msgstr ""
msgid "No matches found"
msgstr ""
+msgid "No matching %{issuable} found. Make sure that you are adding a valid %{issuable} ID."
+msgstr ""
+
msgid "No matching %{issuable} found. Make sure that you are adding a valid %{issuable} URL."
msgstr ""
@@ -37968,6 +37977,9 @@ msgstr ""
msgid "Task list"
msgstr ""
+msgid "Task with ID: %{id} could not be found."
+msgstr ""
+
msgid "TasksToBeDone|Create/import code into a project (repository)"
msgstr ""
@@ -44910,6 +44922,9 @@ msgstr ""
msgid "`start_time` should precede `end_time`"
msgstr ""
+msgid "`work_items_hierarchy` feature flag disabled for this project"
+msgstr ""
+
msgid "a deleted user"
msgstr ""
diff --git a/qa/qa/specs/features/api/1_manage/import_large_github_repo_spec.rb b/qa/qa/specs/features/api/1_manage/import_large_github_repo_spec.rb
index 233a1f5bf42..c47afbd23f0 100644
--- a/qa/qa/specs/features/api/1_manage/import_large_github_repo_spec.rb
+++ b/qa/qa/specs/features/api/1_manage/import_large_github_repo_spec.rb
@@ -4,7 +4,7 @@
# rubocop:disable Rails/Pluck
module QA
- RSpec.describe 'Manage', :github, :requires_admin, only: { job: 'large-github-import' } do
+ RSpec.describe 'Manage', :github, requires_admin: 'creates users', only: { job: 'large-github-import' } do
describe 'Project import' do
let(:logger) { Runtime::Logger.logger }
let(:differ) { RSpec::Support::Differ.new(color: true) }
diff --git a/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb b/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb
index b96a002f485..83cc44f9958 100644
--- a/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb
+++ b/qa/qa/specs/features/api/1_manage/migration/gitlab_migration_large_project_spec.rb
@@ -4,9 +4,7 @@
# rubocop:disable Rails/Pluck, Layout/LineLength, RSpec/MultipleMemoizedHelpers
module QA
- RSpec.describe "Manage", requires_admin: 'uses admin API client for resource creation',
- feature_flag: { name: 'bulk_import_projects', scope: :global },
- only: { job: 'large-gitlab-import' } do
+ RSpec.describe "Manage", requires_admin: 'creates users', only: { job: 'large-gitlab-import' } do
describe "Gitlab migration" do
let(:logger) { Runtime::Logger.logger }
let(:differ) { RSpec::Support::Differ.new(color: true) }
@@ -101,8 +99,6 @@ module QA
let(:issues) { fetch_issues(imported_project, target_api_client) }
before do
- Runtime::Feature.enable(:bulk_import_projects)
-
destination_group.add_member(user, Resource::Members::AccessLevel::MAINTAINER)
end
diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js
index 8c72a37ec84..68cc08d2ebc 100644
--- a/spec/frontend/ide/components/new_dropdown/modal_spec.js
+++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js
@@ -18,6 +18,7 @@ describe('new file modal component', () => {
let store;
let wrapper;
+ const findForm = () => wrapper.findByTestId('file-name-form');
const findGlModal = () => wrapper.findComponent(GlModal);
const findInput = () => wrapper.findByTestId('file-name-field');
const findTemplateButtons = () => wrapper.findAllComponents(GlButton);
@@ -33,7 +34,10 @@ describe('new file modal component', () => {
// We have to interact with the open() method?
wrapper.vm.open(type, path);
};
- const triggerSubmit = () => {
+ const triggerSubmitForm = () => {
+ findForm().trigger('submit');
+ };
+ const triggerSubmitModal = () => {
findGlModal().vm.$emit('primary');
};
const triggerCancel = () => {
@@ -211,20 +215,41 @@ describe('new file modal component', () => {
${'tree'} | ${'foo/dir'} | ${'foo/dir'}
${'tree'} | ${'foo /dir'} | ${'foo/dir'}
`('when submitting as $modalType with "$name"', ({ modalType, name, expectedName }) => {
- beforeEach(async () => {
- mountComponent();
+ describe('when using the modal primary button', () => {
+ beforeEach(async () => {
+ mountComponent();
- open(modalType, '');
- await nextTick();
+ open(modalType, '');
+ await nextTick();
- findInput().setValue(name);
- triggerSubmit();
+ findInput().setValue(name);
+ triggerSubmitModal();
+ });
+
+ it('triggers createTempEntry action', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', {
+ name: expectedName,
+ type: modalType,
+ });
+ });
});
- it('triggers createTempEntry action', () => {
- expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', {
- name: expectedName,
- type: modalType,
+ describe('when triggering form submit (pressing enter)', () => {
+ beforeEach(async () => {
+ mountComponent();
+
+ open(modalType, '');
+ await nextTick();
+
+ findInput().setValue(name);
+ triggerSubmitForm();
+ });
+
+ it('triggers createTempEntry action', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', {
+ name: expectedName,
+ type: modalType,
+ });
});
});
});
@@ -301,21 +326,42 @@ describe('new file modal component', () => {
});
describe('when renames is submitted successfully', () => {
- beforeEach(() => {
- findInput().setValue(NEW_NAME);
- triggerSubmit();
- });
+ describe('when using the modal primary button', () => {
+ beforeEach(() => {
+ findInput().setValue(NEW_NAME);
+ triggerSubmitModal();
+ });
- it('dispatches renameEntry event', () => {
- expect(store.dispatch).toHaveBeenCalledWith('renameEntry', {
- path: origPath,
- parentPath: '',
- name: NEW_NAME,
+ it('dispatches renameEntry event', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('renameEntry', {
+ path: origPath,
+ parentPath: '',
+ name: NEW_NAME,
+ });
+ });
+
+ it('does not trigger flash', () => {
+ expect(createFlash).not.toHaveBeenCalled();
});
});
- it('does not trigger flash', () => {
- expect(createFlash).not.toHaveBeenCalled();
+ describe('when triggering form submit (pressing enter)', () => {
+ beforeEach(() => {
+ findInput().setValue(NEW_NAME);
+ triggerSubmitForm();
+ });
+
+ it('dispatches renameEntry event', () => {
+ expect(store.dispatch).toHaveBeenCalledWith('renameEntry', {
+ path: origPath,
+ parentPath: '',
+ name: NEW_NAME,
+ });
+ });
+
+ it('does not trigger flash', () => {
+ expect(createFlash).not.toHaveBeenCalled();
+ });
});
});
});
@@ -330,7 +376,7 @@ describe('new file modal component', () => {
// Set to something that already exists!
findInput().setValue('src');
- triggerSubmit();
+ triggerSubmitModal();
});
it('creates flash', () => {
@@ -355,7 +401,7 @@ describe('new file modal component', () => {
await nextTick();
findInput().setValue('src/deleted.js');
- triggerSubmit();
+ triggerSubmitModal();
});
it('does not create flash', () => {
diff --git a/spec/frontend/invite_members/components/import_a_project_modal_spec.js b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
index 6db881d5c75..b4d42d90d99 100644
--- a/spec/frontend/invite_members/components/import_a_project_modal_spec.js
+++ b/spec/frontend/invite_members/components/import_project_members_modal_spec.js
@@ -5,7 +5,7 @@ import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import * as ProjectsApi from '~/api/projects_api';
-import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue';
+import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue';
import ProjectSelect from '~/invite_members/components/project_select.vue';
import axios from '~/lib/utils/axios_utils';
@@ -20,7 +20,7 @@ const $toast = {
};
const createComponent = () => {
- wrapper = shallowMountExtended(ImportAProjectModal, {
+ wrapper = shallowMountExtended(ImportProjectMembersModal, {
propsData: {
projectId,
projectName,
@@ -51,12 +51,11 @@ afterEach(() => {
mock.restore();
});
-describe('ImportAProjectModal', () => {
+describe('ImportProjectMembersModal', () => {
+ const findGlModal = () => wrapper.findComponent(GlModal);
const findIntroText = () => wrapper.find({ ref: 'modalIntro' }).text();
- const findCancelButton = () => wrapper.findByTestId('cancel-button');
- const findImportButton = () => wrapper.findByTestId('import-button');
- const clickImportButton = () => findImportButton().vm.$emit('click');
- const clickCancelButton = () => findCancelButton().vm.$emit('click');
+ const clickImportButton = () => findGlModal().vm.$emit('primary', { preventDefault: jest.fn() });
+ const closeModal = () => findGlModal().vm.$emit('hidden', { preventDefault: jest.fn() });
const findFormGroup = () => wrapper.findByTestId('form-group');
const formGroupInvalidFeedback = () => findFormGroup().props('invalidFeedback');
const formGroupErrorState = () => findFormGroup().props('state');
@@ -68,37 +67,40 @@ describe('ImportAProjectModal', () => {
});
it('renders the modal with the correct title', () => {
- expect(wrapper.findComponent(GlModal).props('title')).toBe(
- 'Import members from another project',
- );
+ expect(findGlModal().props('title')).toBe('Import members from another project');
});
it('renders the Cancel button text correctly', () => {
- expect(findCancelButton().text()).toBe('Cancel');
+ expect(findGlModal().props('actionCancel')).toMatchObject({
+ text: 'Cancel',
+ });
});
it('renders the Import button text correctly', () => {
- expect(findImportButton().text()).toBe('Import project members');
+ expect(findGlModal().props('actionPrimary')).toMatchObject({
+ text: 'Import project members',
+ attributes: {
+ variant: 'confirm',
+ disabled: true,
+ loading: false,
+ },
+ });
});
it('renders the modal intro text correctly', () => {
expect(findIntroText()).toBe("You're importing members to the test name project.");
});
- it('renders the Import button modal without isLoading', () => {
- expect(findImportButton().props('loading')).toBe(false);
- });
-
it('sets isLoading to true when the Invite button is clicked', async () => {
clickImportButton();
await nextTick();
- expect(findImportButton().props('loading')).toBe(true);
+ expect(findGlModal().props('actionPrimary').attributes.loading).toBe(true);
});
});
- describe('submitting the import form', () => {
+ describe('submitting the import', () => {
describe('when the import is successful', () => {
beforeEach(() => {
createComponent();
@@ -125,7 +127,7 @@ describe('ImportAProjectModal', () => {
});
it('sets isLoading to false after success', () => {
- expect(findImportButton().props('loading')).toBe(false);
+ expect(findGlModal().props('actionPrimary').attributes.loading).toBe(false);
});
});
@@ -149,14 +151,14 @@ describe('ImportAProjectModal', () => {
});
it('sets isLoading to false after error', () => {
- expect(findImportButton().props('loading')).toBe(false);
+ expect(findGlModal().props('actionPrimary').attributes.loading).toBe(false);
});
it('clears the error when the modal is closed with an error', async () => {
expect(formGroupInvalidFeedback()).toBe('Unable to import project members');
expect(formGroupErrorState()).toBe(false);
- clickCancelButton();
+ closeModal();
await nextTick();
diff --git a/spec/frontend/invite_members/components/import_project_members_trigger_spec.js b/spec/frontend/invite_members/components/import_project_members_trigger_spec.js
new file mode 100644
index 00000000000..b6375fcfa22
--- /dev/null
+++ b/spec/frontend/invite_members/components/import_project_members_trigger_spec.js
@@ -0,0 +1,49 @@
+import { GlButton } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import ImportProjectMembersTrigger from '~/invite_members/components/import_project_members_trigger.vue';
+import eventHub from '~/invite_members/event_hub';
+
+const displayText = 'Import Project Members';
+
+const createComponent = (props = {}) => {
+ return mount(ImportProjectMembersTrigger, {
+ propsData: {
+ displayText,
+ ...props,
+ },
+ });
+};
+
+describe('ImportProjectMembersTrigger', () => {
+ let wrapper;
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ describe('displayText', () => {
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ it('includes the correct displayText for the link', () => {
+ expect(findButton().text()).toBe(displayText);
+ });
+ });
+
+ describe('when button is clicked', () => {
+ beforeEach(() => {
+ eventHub.$emit = jest.fn();
+
+ wrapper = createComponent();
+
+ findButton().trigger('click');
+ });
+
+ it('emits event that triggers opening the modal', () => {
+ expect(eventHub.$emit).toHaveBeenLastCalledWith('openProjectMembersModal');
+ });
+ });
+});
diff --git a/spec/graphql/types/work_items/widgets/hierarchy_update_input_type_spec.rb b/spec/graphql/types/work_items/widgets/hierarchy_update_input_type_spec.rb
new file mode 100644
index 00000000000..6221580605e
--- /dev/null
+++ b/spec/graphql/types/work_items/widgets/hierarchy_update_input_type_spec.rb
@@ -0,0 +1,9 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ::Types::WorkItems::Widgets::HierarchyUpdateInputType do
+ it { expect(described_class.graphql_name).to eq('WorkItemWidgetHierarchyUpdateInput') }
+
+ it { expect(described_class.arguments.keys).to match_array(%w[parentId childrenIds]) }
+end
diff --git a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
index c39f6a78e93..a7b3670da7c 100644
--- a/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_job_spec.rb
@@ -220,6 +220,12 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedJob, type: :model d
expect(described_class.created_since(fixed_time)).to contain_exactly(stuck_job, failed_job, max_attempts_failed_job)
end
end
+
+ describe '.blocked_by_max_attempts' do
+ it 'returns blocked jobs' do
+ expect(described_class.blocked_by_max_attempts).to contain_exactly(max_attempts_failed_job)
+ end
+ end
end
describe 'delegated batched_migration attributes' do
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb
index 80a236dc51a..b8ff78be333 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_runner_spec.rb
@@ -402,6 +402,8 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
.with(gitlab_schemas, 'CopyColumnUsingBackgroundMigrationJob', table_name, column_name, job_arguments)
.and_return(batched_migration)
+ expect(batched_migration).to receive(:reset_attempts_of_blocked_jobs!).and_call_original
+
expect(batched_migration).to receive(:finalize!).and_call_original
expect do
@@ -426,7 +428,9 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigrationRunner do
end
it 'raises an error' do
- batched_migration.batched_jobs.with_status(:failed).update_all(attempts: Gitlab::Database::BackgroundMigration::BatchedJob::MAX_ATTEMPTS)
+ allow(Gitlab::Database::BackgroundMigration::BatchedMigration).to receive(:find_for_configuration).and_return(batched_migration)
+
+ allow(batched_migration).to receive(:finished?).and_return(false)
expect do
runner.finalize(
diff --git a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
index 2561e99464e..55f607c0cb0 100644
--- a/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
+++ b/spec/lib/gitlab/database/background_migration/batched_migration_spec.rb
@@ -157,6 +157,27 @@ RSpec.describe Gitlab::Database::BackgroundMigration::BatchedMigration, type: :m
end
end
+ describe '#reset_attempts_of_blocked_jobs!' do
+ let!(:migration) { create(:batched_background_migration) }
+ let(:max_attempts) { Gitlab::Database::BackgroundMigration::BatchedJob::MAX_ATTEMPTS }
+
+ before do
+ create(:batched_background_migration_job, attempts: max_attempts - 1, batched_migration: migration)
+ create(:batched_background_migration_job, attempts: max_attempts + 1, batched_migration: migration)
+ create(:batched_background_migration_job, attempts: max_attempts + 1, batched_migration: migration)
+ end
+
+ it 'sets the number of attempts to zero for blocked jobs' do
+ migration.reset_attempts_of_blocked_jobs!
+
+ expect(migration.batched_jobs.size).to eq(3)
+
+ migration.batched_jobs.blocked_by_max_attempts.each do |job|
+ expect(job.attempts).to be_zero
+ end
+ end
+ end
+
describe '#interval_elapsed?' do
context 'when the migration has no last_job' do
let(:batched_migration) { build(:batched_background_migration) }
diff --git a/spec/lib/gitlab/usage/service_ping/instrumented_payload_spec.rb b/spec/lib/gitlab/usage/service_ping/instrumented_payload_spec.rb
index 76548483cfa..9d2711c49c6 100644
--- a/spec/lib/gitlab/usage/service_ping/instrumented_payload_spec.rb
+++ b/spec/lib/gitlab/usage/service_ping/instrumented_payload_spec.rb
@@ -46,4 +46,54 @@ RSpec.describe Gitlab::Usage::ServicePing::InstrumentedPayload do
expect(described_class.new(['counts.ci_builds'], :with_value).build).to eq({})
end
end
+
+ context 'with broken metric definition file' do
+ let(:key_path) { 'counts.broken_metric_definition_test' }
+ let(:definitions) { [Gitlab::Usage::MetricDefinition.new(key_path, key_path: key_path)] }
+
+ subject(:build_metric) { described_class.new([key_path], :with_value).build }
+
+ before do
+ allow(Gitlab::Usage::MetricDefinition).to receive(:with_instrumentation_class).and_return(definitions)
+ allow_next_instance_of(Gitlab::Usage::Metric) do |instance|
+ allow(instance).to receive(:with_value).and_raise(error)
+ end
+ end
+
+ context 'when instrumentation class name is incorrect' do
+ let(:error) { NameError.new("uninitialized constant Gitlab::Usage::Metrics::Instrumentations::IDontExists") }
+
+ it 'tracks error and return fallback', :aggregate_failures do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(error)
+ expect(build_metric).to eql(counts: { broken_metric_definition_test: -1 })
+ end
+ end
+
+ context 'when instrumentation class raises TypeError' do
+ let(:error) { TypeError.new("nil can't be coerced into BigDecimal") }
+
+ it 'tracks error and return fallback', :aggregate_failures do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(error)
+ expect(build_metric).to eql(counts: { broken_metric_definition_test: -1 })
+ end
+ end
+
+ context 'when instrumentation class raises ArgumentError' do
+ let(:error) { ArgumentError.new("wrong number of arguments (given 2, expected 0)") }
+
+ it 'tracks error and return fallback', :aggregate_failures do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(error)
+ expect(build_metric).to eql(counts: { broken_metric_definition_test: -1 })
+ end
+ end
+
+ context 'when instrumentation class raises StandardError' do
+ let(:error) { StandardError.new("something went very wrong") }
+
+ it 'tracks error and return fallback', :aggregate_failures do
+ expect(Gitlab::ErrorTracking).to receive(:track_and_raise_for_dev_exception).with(error)
+ expect(build_metric).to eql(counts: { broken_metric_definition_test: -1 })
+ end
+ end
+ end
end
diff --git a/spec/requests/api/graphql/mutations/work_items/update_spec.rb b/spec/requests/api/graphql/mutations/work_items/update_spec.rb
index 8e801eec74d..58549c6e3a0 100644
--- a/spec/requests/api/graphql/mutations/work_items/update_spec.rb
+++ b/spec/requests/api/graphql/mutations/work_items/update_spec.rb
@@ -137,5 +137,137 @@ RSpec.describe 'Update a work item' do
end
end
end
+
+ context 'with hierarchy widget input' do
+ let(:widgets_response) { mutation_response['workItem']['widgets'] }
+ let(:fields) do
+ <<~FIELDS
+ workItem {
+ description
+ widgets {
+ type
+ ... on WorkItemWidgetHierarchy {
+ parent {
+ id
+ }
+ children {
+ edges {
+ node {
+ id
+ }
+ }
+ }
+ }
+ }
+ }
+ errors
+ FIELDS
+ end
+
+ context 'when updating parent' do
+ let_it_be(:work_item) { create(:work_item, :task, project: project) }
+
+ context 'when parent work item type is invalid' do
+ let_it_be(:parent_task) { create(:work_item, :task, project: project) }
+
+ let(:error) { "#{work_item.to_reference} cannot be added: Only Issue can be parent of Task." }
+ let(:input) do
+ { 'hierarchyWidget' => { 'parentId' => parent_task.to_global_id.to_s }, 'title' => 'new title' }
+ end
+
+ it 'returns response with errors' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to not_change(work_item, :work_item_parent).and(not_change(work_item, :title))
+
+ expect(mutation_response['workItem']).to be_nil
+ expect(mutation_response['errors']).to match_array([error])
+ end
+ end
+
+ context 'when parent work item has a valid type' do
+ let_it_be(:parent) { create(:work_item, project: project) }
+
+ let(:input) { { 'hierarchyWidget' => { 'parentId' => parent.to_global_id.to_s } } }
+
+ it 'sets the parent for the work item' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to change(work_item, :work_item_parent).from(nil).to(parent)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(widgets_response).to include(
+ {
+ 'children' => { 'edges' => [] },
+ 'parent' => { 'id' => parent.to_global_id.to_s },
+ 'type' => 'HIERARCHY'
+ }
+ )
+ end
+
+ context 'when a parent is already present' do
+ let_it_be(:existing_parent) { create(:work_item, project: project) }
+
+ before do
+ work_item.update!(work_item_parent: existing_parent)
+ end
+
+ it 'is replaced with new parent' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to change(work_item, :work_item_parent).from(existing_parent).to(parent)
+ end
+ end
+ end
+ end
+
+ context 'when updating children' do
+ let_it_be(:valid_child1) { create(:work_item, :task, project: project) }
+ let_it_be(:valid_child2) { create(:work_item, :task, project: project) }
+ let_it_be(:invalid_child) { create(:work_item, project: project) }
+
+ let(:input) { { 'hierarchyWidget' => { 'childrenIds' => children_ids } } }
+ let(:error) do
+ "#{invalid_child.to_reference} cannot be added: Only Task can be assigned as a child in hierarchy."
+ end
+
+ context 'when child work item type is invalid' do
+ let(:children_ids) { [invalid_child.to_global_id.to_s] }
+
+ it 'returns response with errors' do
+ post_graphql_mutation(mutation, current_user: current_user)
+
+ expect(mutation_response['workItem']).to be_nil
+ expect(mutation_response['errors']).to match_array([error])
+ end
+ end
+
+ context 'when child work item type is valid' do
+ let(:children_ids) { [valid_child1.to_global_id.to_s, valid_child2.to_global_id.to_s] }
+
+ it 'updates the work item children' do
+ expect do
+ post_graphql_mutation(mutation, current_user: current_user)
+ work_item.reload
+ end.to change(work_item.work_item_children, :count).by(2)
+
+ expect(response).to have_gitlab_http_status(:success)
+ expect(widgets_response).to include(
+ {
+ 'children' => { 'edges' => [
+ { 'node' => { 'id' => valid_child2.to_global_id.to_s } },
+ { 'node' => { 'id' => valid_child1.to_global_id.to_s } }
+ ] },
+ 'parent' => nil,
+ 'type' => 'HIERARCHY'
+ }
+ )
+ end
+ end
+ end
+ end
end
end
diff --git a/spec/services/work_items/parent_links/create_service_spec.rb b/spec/services/work_items/parent_links/create_service_spec.rb
new file mode 100644
index 00000000000..3e3d65a8615
--- /dev/null
+++ b/spec/services/work_items/parent_links/create_service_spec.rb
@@ -0,0 +1,161 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::ParentLinks::CreateService do
+ describe '#execute' do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+ let_it_be(:work_item) { create(:work_item, project: project) }
+ let_it_be(:task) { create(:work_item, :task, project: project) }
+ let_it_be(:task1) { create(:work_item, :task, project: project) }
+ let_it_be(:task2) { create(:work_item, :task, project: project) }
+ let_it_be(:guest_task) { create(:work_item, :task) }
+ let_it_be(:invalid_task) { build_stubbed(:work_item, :task, id: non_existing_record_id)}
+ let_it_be(:another_project) { (create :project) }
+ let_it_be(:other_project_task) { create(:work_item, :task, project: another_project) }
+ let_it_be(:existing_parent_link) { create(:parent_link, work_item: task, work_item_parent: work_item)}
+
+ let(:parent_link_class) { WorkItems::ParentLink }
+ let(:issuable_type) { :task }
+ let(:params) { {} }
+
+ before do
+ project.add_developer(user)
+ guest_task.project.add_guest(user)
+ another_project.add_developer(user)
+ end
+
+ shared_examples 'returns not found error' do
+ it 'returns error' do
+ error = "No matching #{issuable_type} found. Make sure that you are adding a valid #{issuable_type} ID."
+
+ is_expected.to eq(service_error(error))
+ end
+
+ it 'no relationship is created' do
+ expect { subject }.not_to change(parent_link_class, :count)
+ end
+ end
+
+ subject { described_class.new(work_item, user, params).execute }
+
+ context 'when the reference list is empty' do
+ let(:params) { { issuable_references: [] } }
+
+ it_behaves_like 'returns not found error'
+ end
+
+ context 'when work item not found' do
+ let(:params) { { issuable_references: [invalid_task.id] } }
+
+ it_behaves_like 'returns not found error'
+ end
+
+ context 'when user has no permission to link work item' do
+ let(:params) { { issuable_references: [guest_task.id] } }
+
+ it_behaves_like 'returns not found error'
+ end
+
+ context 'child and parent are the same work item' do
+ let(:params) { { issuable_references: [work_item.id] } }
+
+ it 'no relationship is created' do
+ expect { subject }.not_to change(parent_link_class, :count)
+ end
+ end
+
+ context 'when there are tasks to relate' do
+ let(:params) { { issuable_references: [task1.id, task2.id] } }
+
+ it 'creates relationships', :aggregate_failures do
+ expect { subject }.to change(parent_link_class, :count).by(2)
+
+ tasks_parent = parent_link_class.where(work_item: [task1, task2]).map(&:work_item_parent).uniq
+ expect(tasks_parent).to match_array([work_item])
+ end
+
+ it 'returns success status and created links', :aggregate_failures do
+ expect(subject.keys).to match_array([:status, :created_references])
+ expect(subject[:status]).to eq(:success)
+ expect(subject[:created_references].map(&:work_item_id)).to match_array([task1.id, task2.id])
+ end
+
+ context 'when task is already assigned' do
+ let(:params) { { issuable_references: [task.id, task2.id] } }
+
+ it 'creates links only for non related tasks' do
+ expect { subject }.to change(parent_link_class, :count).by(1)
+
+ expect(subject[:created_references].map(&:work_item_id)).to match_array([task2.id])
+ end
+ end
+
+ context 'when there are invalid children' do
+ let_it_be(:issue) { create(:work_item, project: project) }
+
+ let(:params) { { issuable_references: [task1.id, issue.id, other_project_task.id] } }
+
+ it 'creates links only for valid children' do
+ expect { subject }.to change { parent_link_class.count }.by(1)
+ end
+
+ it 'returns error status' do
+ error = "#{issue.to_reference} cannot be added: Only Task can be assigned as a child in hierarchy.. " \
+ "#{other_project_task.to_reference} cannot be added: Parent must be in the same project as child."
+
+ is_expected.to eq(service_error(error, http_status: 422))
+ end
+ end
+
+ context 'when parent type is invalid' do
+ let(:work_item) { create :work_item, :task, project: project }
+
+ let(:params) { { target_issuable: task1 } }
+
+ it 'returns error status' do
+ error = "#{task1.to_reference} cannot be added: Only Issue can be parent of Task."
+
+ is_expected.to eq(service_error(error, http_status: 422))
+ end
+ end
+
+ context 'when max depth is reached' do
+ let(:params) { { issuable_references: [task2.id] } }
+
+ before do
+ stub_const("#{parent_link_class}::MAX_CHILDREN", 1)
+ end
+
+ it 'returns error status' do
+ error = "#{task2.to_reference} cannot be added: Parent already has maximum number of children."
+
+ is_expected.to eq(service_error(error, http_status: 422))
+ end
+ end
+
+ context 'when params include invalid ids' do
+ let(:params) { { issuable_references: [task1.id, invalid_task.id] } }
+
+ it 'creates links only for valid IDs' do
+ expect { subject }.to change(parent_link_class, :count).by(1)
+ end
+
+ it 'returns error for invalid ID' do
+ message = "Task with ID: #{invalid_task.id} could not be found."
+
+ expect(subject).to eq(service_error(message, http_status: 422))
+ end
+ end
+ end
+ end
+
+ def service_error(message, http_status: 404)
+ {
+ message: message,
+ status: :error,
+ http_status: http_status
+ }
+ end
+end
diff --git a/spec/services/work_items/update_service_spec.rb b/spec/services/work_items/update_service_spec.rb
index 9030326dadb..d7c5dd71503 100644
--- a/spec/services/work_items/update_service_spec.rb
+++ b/spec/services/work_items/update_service_spec.rb
@@ -13,7 +13,15 @@ RSpec.describe WorkItems::UpdateService do
let(:current_user) { developer }
describe '#execute' do
- subject(:update_work_item) { described_class.new(project: project, current_user: current_user, params: opts, spam_params: spam_params, widget_params: widget_params).execute(work_item) }
+ subject(:update_work_item) do
+ described_class.new(
+ project: project,
+ current_user: current_user,
+ params: opts,
+ spam_params: spam_params,
+ widget_params: widget_params
+ ).execute(work_item)
+ end
before do
stub_spam_services
@@ -27,8 +35,7 @@ RSpec.describe WorkItems::UpdateService do
expect(Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter).to receive(:track_work_item_title_changed_action).with(author: current_user)
# During the work item transition we also want to track work items as issues
expect(Gitlab::UsageDataCounters::IssueActivityUniqueCounter).to receive(:track_issue_title_changed_action)
-
- update_work_item
+ expect(update_work_item[:status]).to eq(:success)
end
end
@@ -38,8 +45,7 @@ RSpec.describe WorkItems::UpdateService do
it 'does not trigger issuable_title_updated graphql subscription' do
expect(GraphqlTriggers).not_to receive(:issuable_title_updated)
expect(Gitlab::UsageDataCounters::WorkItemActivityUniqueCounter).not_to receive(:track_work_item_title_changed_action)
-
- update_work_item
+ expect(update_work_item[:status]).to eq(:success)
end
end
@@ -72,15 +78,55 @@ RSpec.describe WorkItems::UpdateService do
end
context 'when updating widgets' do
- context 'for the description widget' do
- let(:widget_params) { { description_widget: { description: 'changed' } } }
+ let(:widget_service_class) { WorkItems::Widgets::DescriptionService::UpdateService }
+ let(:widget_params) { { description_widget: { description: 'changed' } } }
+
+ context 'when widget service is not present' do
+ before do
+ allow(widget_service_class).to receive(:new).and_return(nil)
+ end
+
+ it 'ignores widget param' do
+ expect { update_work_item }.not_to change(work_item, :description)
+ end
+ end
+
+ context 'when the widget does not support update callback' do
+ before do
+ allow_next_instance_of(widget_service_class) do |instance|
+ allow(instance)
+ .to receive(:update)
+ .with(params: { description: 'changed' }).and_return(nil)
+ end
+ end
+ it 'ignores widget param' do
+ expect { update_work_item }.not_to change(work_item, :description)
+ end
+ end
+
+ context 'for the description widget' do
it 'updates the description of the work item' do
update_work_item
expect(work_item.description).to eq('changed')
end
end
+
+ context 'for the hierarchy widget' do
+ let_it_be(:child_work_item) { create(:work_item, :task, project: project) }
+
+ let(:widget_params) { { hierarchy_widget: { children_ids: [child_work_item.id] } } }
+
+ it 'updates the children of the work item' do
+ expect do
+ update_work_item
+ work_item.reload
+ end.to change(WorkItems::ParentLink, :count).by(1)
+
+ expect(work_item.work_item_children).to include(child_work_item)
+ end
+ end
end
end
end
diff --git a/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb b/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb
new file mode 100644
index 00000000000..4765f185a56
--- /dev/null
+++ b/spec/services/work_items/widgets/hierarchy_service/update_service_spec.rb
@@ -0,0 +1,148 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe WorkItems::Widgets::HierarchyService::UpdateService do
+ let_it_be(:user) { create(:user) }
+ let_it_be(:project) { create(:project) }
+
+ let_it_be(:work_item) { create(:work_item, project: project) }
+ let_it_be(:parent_work_item) { create(:work_item, project: project) }
+ let_it_be(:child_work_item) { create(:work_item, :task, project: project) }
+ let_it_be(:existing_link) { create(:parent_link, work_item: child_work_item, work_item_parent: work_item) }
+
+ let(:widget) { work_item.widgets.find {|widget| widget.is_a?(WorkItems::Widgets::Hierarchy) } }
+ let(:not_found_error) { 'No matching task found. Make sure that you are adding a valid task ID.' }
+
+ shared_examples 'raises a WidgetError' do
+ it { expect { subject }.to raise_error(described_class::WidgetError, message) }
+ end
+
+ describe '#update' do
+ subject { described_class.new(widget: widget, current_user: user).before_update_in_transaction(params: params) }
+
+ context 'when parent_id and children_ids params are present' do
+ let(:params) { { parent_id: parent_work_item.id, children_ids: [child_work_item.id] } }
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { 'A Work Item can be a parent or a child, but not both.' }
+ end
+ end
+
+ context 'when updating children' do
+ let_it_be(:child_work_item2) { create(:work_item, :task, project: project) }
+ let_it_be(:child_work_item3) { create(:work_item, :task, project: project) }
+ let_it_be(:child_work_item4) { create(:work_item, :task, project: project) }
+
+ context 'when work_items_hierarchy feature flag is disabled' do
+ let(:params) { { children_ids: [child_work_item4.id] }}
+
+ before do
+ stub_feature_flags(work_items_hierarchy: false)
+ end
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { '`work_items_hierarchy` feature flag disabled for this project' }
+ end
+ end
+
+ context 'when user has insufficient permissions to link work items' do
+ let(:params) { { children_ids: [child_work_item4.id] }}
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { not_found_error }
+ end
+ end
+
+ context 'when user has sufficient permissions to link work item' do
+ before do
+ project.add_developer(user)
+ end
+
+ context 'with valid params' do
+ let(:params) { { children_ids: [child_work_item2.id, child_work_item3.id] }}
+
+ it 'correctly sets work item parent' do
+ subject
+
+ expect(work_item.reload.work_item_children)
+ .to contain_exactly(child_work_item, child_work_item2, child_work_item3)
+ end
+ end
+
+ context 'when child is already assigned' do
+ let(:params) { { children_ids: [child_work_item.id] }}
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { 'Task(s) already assigned' }
+ end
+ end
+
+ context 'when child type is invalid' do
+ let_it_be(:child_issue) { create(:work_item, project: project) }
+
+ let(:params) { { children_ids: [child_issue.id] }}
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) do
+ "#{child_issue.to_reference} cannot be added: Only Task can be assigned as a child in hierarchy."
+ end
+ end
+ end
+ end
+ end
+
+ context 'when updating parent' do
+ let_it_be(:work_item) { create(:work_item, :task, project: project) }
+
+ let(:params) {{ parent_id: parent_work_item.id } }
+
+ context 'when work_items_hierarchy feature flag is disabled' do
+ before do
+ stub_feature_flags(work_items_hierarchy: false)
+ end
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { '`work_items_hierarchy` feature flag disabled for this project' }
+ end
+ end
+
+ context 'when parent_id does not match an existing work item' do
+ let(:invalid_id) { non_existing_record_iid }
+ let(:params) {{ parent_id: invalid_id } }
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { "No Work Item found with ID: #{invalid_id}." }
+ end
+ end
+
+ context 'when user has insufficient permissions to link work items' do
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { not_found_error }
+ end
+ end
+
+ context 'when user has sufficient permissions to link work item' do
+ before do
+ project.add_developer(user)
+ end
+
+ it 'correctly sets work item parent' do
+ subject
+
+ expect(work_item.work_item_parent).to eq(parent_work_item)
+ end
+
+ context 'when type is invalid' do
+ let_it_be(:parent_task) { create(:work_item, :task, project: project)}
+
+ let(:params) {{ parent_id: parent_task.id } }
+
+ it_behaves_like 'raises a WidgetError' do
+ let(:message) { "#{work_item.to_reference} cannot be added: Only Issue can be parent of Task." }
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/spec/views/projects/project_members/index.html.haml_spec.rb b/spec/views/projects/project_members/index.html.haml_spec.rb
index 0446e1a7fc8..382d400b961 100644
--- a/spec/views/projects/project_members/index.html.haml_spec.rb
+++ b/spec/views/projects/project_members/index.html.haml_spec.rb
@@ -23,7 +23,8 @@ RSpec.describe 'projects/project_members/index', :aggregate_failures do
expect(rendered).to have_content('Project members')
expect(rendered).to have_content('You can invite a new member')
- expect(rendered).to have_selector('.js-import-a-project-modal')
+ expect(rendered).to have_selector('.js-import-project-members-trigger')
+ expect(rendered).to have_selector('.js-import-project-members-modal')
expect(rendered).to have_selector('.js-invite-group-trigger')
expect(rendered).to have_selector('.js-invite-members-trigger')
expect(rendered).not_to have_content('Members can be added by project')
@@ -51,7 +52,8 @@ RSpec.describe 'projects/project_members/index', :aggregate_failures do
expect(rendered).to have_content('Project members')
expect(rendered).not_to have_content('You can invite a new member')
- expect(rendered).not_to have_selector('.js-import-a-project-modal')
+ expect(rendered).not_to have_selector('.js-import-project-members-trigger')
+ expect(rendered).not_to have_selector('.js-import-project-members-modal')
expect(rendered).not_to have_selector('.js-invite-group-trigger')
expect(rendered).not_to have_selector('.js-invite-members-trigger')
expect(rendered).to have_content('Members can be added by project')