Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
2abeca2d92
commit
427451410d
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
|
@ -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'), {
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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?
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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:)
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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') } }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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) }
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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')
|
||||
|
|
Loading…
Reference in New Issue