Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2022-07-07 15:08:37 +00:00
parent 2abeca2d92
commit 427451410d
67 changed files with 1218 additions and 217 deletions

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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,
},
}),
});
}

View File

@ -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,
},
}),
});
}

View File

@ -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,
},
}),
});
}

View File

@ -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'), {

View File

@ -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',

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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')

View File

@ -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?

View File

@ -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

View File

@ -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

View File

@ -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:)

View File

@ -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

View File

@ -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

View File

@ -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') } }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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]

View File

@ -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

View File

@ -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

View File

@ -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) }

View File

@ -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

View File

@ -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 ""

View File

@ -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) }

View File

@ -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

View File

@ -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', () => {

View File

@ -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();

View File

@ -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');
});
});
});

View File

@ -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

View File

@ -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

View File

@ -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(

View File

@ -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) }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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')