gitlab-ce/app/models/work_item.rb

497 lines
16 KiB
Ruby

# frozen_string_literal: true
class WorkItem < Issue
include Gitlab::Utils::StrongMemoize
include Gitlab::InternalEventsTracking
include Import::HasImportSource
COMMON_QUICK_ACTIONS_COMMANDS = [
:title, :reopen, :close, :cc, :tableflip, :shrug, :type, :promote_to, :checkin_reminder,
:subscribe, :unsubscribe, :confidential, :award, :react, :move, :clone, :copy_metadata,
:duplicate, :promote_to_incident, :board_move, :convert_to_ticket, :zoom, :remove_zoom
].freeze
self.table_name = 'issues'
self.inheritance_column = :_type_disabled
strip_attributes! :title
belongs_to :namespace, inverse_of: :work_items
has_one :parent_link, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_id
has_one :work_item_parent, through: :parent_link, class_name: 'WorkItem'
has_one :weights_source, class_name: 'WorkItems::WeightsSource'
has_many :child_links, class_name: '::WorkItems::ParentLink', foreign_key: :work_item_parent_id
has_many :work_item_children, through: :child_links, class_name: 'WorkItem',
foreign_key: :work_item_id, source: :work_item
has_many :work_item_children_by_relative_position, ->(work_item) { work_item_children_keyset_order(work_item) },
through: :child_links, class_name: 'WorkItem',
foreign_key: :work_item_id, source: :work_item
scope :inc_relations_for_permission_check, -> {
includes(
:author, :work_item_type, { project: [:project_feature, { namespace: :route }, :group] }, { namespace: [:route] }
)
}
scope :within_timeframe, ->(start_date, due_date, with_namespace_cte: false) do
date_filtered_issue_ids = ::WorkItems::DatesSource
.select('issue_id')
.where('start_date IS NOT NULL OR due_date IS NOT NULL')
.where('start_date IS NULL OR start_date <= ?', due_date)
.where('due_date IS NULL OR due_date >= ?', start_date)
# The namespace_ids CTE from by_parent by timeframe helps with performance when querying across multiple namespaces.
# see: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/181904
if with_namespace_cte
date_filtered_issue_ids = date_filtered_issue_ids.where('namespace_id IN (SELECT id FROM namespace_ids)')
end
joins("INNER JOIN (#{date_filtered_issue_ids.to_sql}) AS filtered_dates ON issues.id = filtered_dates.issue_id")
end
scope :with_enabled_widget_definition, ->(type) do
joins(work_item_type: :enabled_widget_definitions)
.merge(::WorkItems::WidgetDefinition.by_enabled_widget_type(type))
end
scope :with_work_item_parent_ids, ->(parent_ids) {
joins("INNER JOIN work_item_parent_links ON work_item_parent_links.work_item_id = issues.id")
.where(work_item_parent_links: { work_item_parent_id: parent_ids })
}
class << self
def find_by_namespace_and_iid!(namespace, iid)
find_by!(namespace: namespace, iid: iid)
end
def assignee_association_name
'issue'
end
def test_reports_join_column
'issues.id'
end
def namespace_reference_pattern
%r{
(?<!#{Gitlab::PathRegex::PATH_START_CHAR})
((?<group_or_project_namespace>#{Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX}))
}xo
end
def alternative_reference_prefix_with_postfix
'[work_item:'
end
def reference_pattern
prefix_with_postfix = alternative_reference_prefix_with_postfix
if prefix_with_postfix.empty?
@reference_pattern ||= %r{
(?:
(#{namespace_reference_pattern})?#{Regexp.escape(reference_prefix)} |
#{Regexp.escape(alternative_reference_prefix_without_postfix)}
)#{Gitlab::Regex.work_item}
}x
else
%r{
((?:
(#{namespace_reference_pattern})?#{Regexp.escape(reference_prefix)} |
#{alternative_reference_prefix_without_postfix}
)#{Gitlab::Regex.work_item}) |
((?:
#{Regexp.escape(prefix_with_postfix)}(#{namespace_reference_pattern}/)?
)#{Gitlab::Regex.work_item(reference_postfix)})
}x
end
end
def link_reference_pattern
@link_reference_pattern ||= project_or_group_link_reference_pattern(
'work_items',
namespace_reference_pattern,
Gitlab::Regex.work_item
)
end
def work_item_children_keyset_order_config
Gitlab::Pagination::Keyset::Order.build(
[
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'state_id',
column_expression: WorkItem.arel_table[:state_id],
order_expression: WorkItem.arel_table[:state_id].asc
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'parent_link_relative_position',
column_expression: WorkItems::ParentLink.arel_table[:relative_position],
order_expression: WorkItems::ParentLink.arel_table[:relative_position].asc.nulls_last,
add_to_projections: true,
nullable: :nulls_last
),
Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'work_item_id',
order_expression: WorkItems::ParentLink.arel_table['work_item_id'].asc
)
]
)
end
def work_item_children_keyset_order(_work_item)
keyset_order = work_item_children_keyset_order_config
keyset_order.apply_cursor_conditions(joins(:parent_link)).reorder(keyset_order)
end
def linked_items_keyset_order
::Gitlab::Pagination::Keyset::Order.build(
[
::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new(
attribute_name: 'issue_link_id',
column_expression: IssueLink.arel_table[:id],
order_expression: IssueLink.arel_table[:id].desc,
nullable: :not_nullable
)
])
end
def linked_items_for(target_ids, preload: nil, link_type: nil)
select_query =
select('issues.*,
issue_links.id AS issue_link_id,
issue_links.link_type AS issue_link_type_value,
issue_links.target_id AS issue_link_source_id,
issue_links.source_id AS issue_link_target_id,
issue_links.created_at AS issue_link_created_at,
issue_links.updated_at AS issue_link_updated_at')
ordered_linked_items(select_query, ids: target_ids, link_type: link_type, preload: preload)
end
override :related_link_class
def related_link_class
WorkItems::RelatedWorkItemLink
end
def sync_callback_class(association_name)
::WorkItems::DataSync::NonWidgets.const_get(association_name.to_s.camelcase, false)
rescue NameError
nil
end
def non_widgets
[:pending_escalations]
end
def ordered_linked_items(select_query, ids: [], link_type: nil, preload: nil)
type_condition =
if link_type == WorkItems::RelatedWorkItemLink::TYPE_RELATES_TO
" AND issue_links.link_type = #{WorkItems::RelatedWorkItemLink.link_types[link_type]}"
else
""
end
query_ids = sanitize_sql_array(['?', Array.wrap(ids)])
select_query
.joins("INNER JOIN issue_links ON
(issue_links.source_id = issues.id AND issue_links.target_id IN (#{query_ids})#{type_condition})
OR
(issue_links.target_id = issues.id AND issue_links.source_id IN (#{query_ids})#{type_condition})")
.preload(preload)
.reorder(linked_items_keyset_order)
end
def find_on_namespaces(ids:, resource_parent:)
return none if resource_parent.nil?
group_namespaces = resource_parent.self_and_descendants.select(:id) if resource_parent.is_a?(Group)
project_namespaces =
if resource_parent.is_a?(Project)
Project.id_in(resource_parent)
else
resource_parent.all_projects
end.select('projects.project_namespace_id as id')
namespaces = Namespace.from_union(
[group_namespaces, project_namespaces].compact,
remove_duplicates: false
)
Gitlab::SQL::CTE.new(:work_item_ids_cte, id_in(ids))
.apply_to(all)
.in_namespaces_with_cte(namespaces)
.includes(:work_item_type)
end
end
def create_dates_source_from_current_dates
create_dates_source(
due_date: due_date,
start_date: start_date,
start_date_is_fixed: due_date.present? || start_date.present?,
due_date_is_fixed: due_date.present? || start_date.present?,
start_date_fixed: start_date,
due_date_fixed: due_date
)
end
def noteable_target_type_name
'issue'
end
def custom_notification_target_name
# This is needed so we match the issue events NotificationSetting::EMAIL_EVENTS
return 'issue' if work_item_type.issue?
'work_item'
end
# Todo: remove method after target_type cleanup
# See https://gitlab.com/gitlab-org/gitlab/-/issues/416009
def todoable_target_type_name
%w[Issue WorkItem]
end
def widgets(except_types: [], only_types: nil)
raise ArgumentError, 'Only one filter is allowed' if only_types.present? && except_types.present?
strong_memoize_with(:widgets, only_types, except_types) do
except_types = Array.wrap(except_types)
widget_definitions.keys.filter_map do |type|
next if except_types.include?(type)
next if only_types&.exclude?(type)
get_widget(type)
end
end
end
# Returns widget object if available
# type parameter can be a symbol, for example, `:description`.
def get_widget(type)
strong_memoize_with(type) do
break unless widget_definitions.key?(type.to_sym)
widget_definitions[type].build_widget(self)
end
end
def widget_definitions
work_item_type
.widgets(resource_parent)
.index_by(&:widget_type)
.symbolize_keys
end
strong_memoize_attr :widget_definitions
def ancestors
hierarchy.ancestors(hierarchy_order: :asc)
end
def descendants
hierarchy.descendants
end
def same_type_base_and_ancestors
hierarchy(same_type: true).base_and_ancestors(hierarchy_order: :asc)
end
def same_type_descendants_depth
hierarchy(same_type: true).max_descendants_depth.to_i
end
def supported_quick_action_commands
commands_for_widgets = work_item_type.widget_classes(resource_parent).flat_map(&:quick_action_commands).uniq
COMMON_QUICK_ACTIONS_COMMANDS + commands_for_widgets
end
# Widgets have a set of quick action params that they must process.
# Map them to widget_params so they can be picked up by widget services.
def transform_quick_action_params(command_params)
common_params = command_params.dup
widget_params = {}
work_item_type.widget_classes(resource_parent)
.filter { |widget| widget.respond_to?(:quick_action_params) }
.each do |widget|
widget.quick_action_params
.filter { |param_name| common_params.key?(param_name) }
.each do |param_name|
widget_params[widget.api_symbol] ||= {}
param_value = common_params.delete(param_name)
widget_params[widget.api_symbol].merge!(widget.process_quick_action_param(param_name, param_value))
end
end
{ common: common_params, widgets: widget_params }
end
def linked_work_items(current_user = nil, authorize: true, preload: nil, link_type: nil)
return [] if new_record?
linked_items =
self.class.ordered_linked_items(linked_issues_select, ids: id, link_type: link_type, preload: preload)
return linked_items unless authorize
cross_project_filter = ->(work_items) { work_items.where(project: project) }
Ability.work_items_readable_by_user(
linked_items,
current_user,
filters: { read_cross_project: cross_project_filter }
)
end
def linked_items_count
linked_work_items(authorize: false).size
end
def supports_time_tracking?
work_item_type.supports_time_tracking?(resource_parent)
end
def supports_parent?
return false if work_item_type.issue?
hierarchy_supports_parent?
end
def due_date
dates_source&.due_date || read_attribute(:due_date)
end
def start_date
dates_source&.start_date || read_attribute(:start_date)
end
def max_depth_reached?(child_type)
restriction = ::WorkItems::HierarchyRestriction.find_by_parent_type_id_and_child_type_id(
work_item_type_id,
child_type.id
)
return false unless restriction&.maximum_depth
if work_item_type_id == child_type.id
same_type_base_and_ancestors.count >= restriction.maximum_depth
else
hierarchy(different_type_id: child_type.id).base_and_ancestors.count >= restriction.maximum_depth
end
end
private
override :parent_link_confidentiality
def parent_link_confidentiality
if confidential? && work_item_children.public_only.exists?
errors.add(:base, _('All child items must be confidential in order to turn on confidentiality.'))
end
if !confidential? && work_item_parent&.confidential?
errors.add(:base, _('A non-confidential work item cannot have a confidential parent.'))
end
end
def record_create_action
super
track_internal_event(
'users_creating_work_items',
user: author,
project: project,
additional_properties: {
label: work_item_type.base_type
}
)
end
def hierarchy(options = {})
base = self.class.where(id: id)
base = base.where(work_item_type_id: work_item_type_id) if options[:same_type]
base = base.where(work_item_type_id: options[:different_type_id]) if options[:different_type_id]
::Gitlab::WorkItems::WorkItemHierarchy.new(base, options: options)
end
override :allowed_work_item_type_change
def allowed_work_item_type_change
return unless work_item_type_id_changed?
child_links = WorkItems::ParentLink.for_parents(id)
parent_link = ::WorkItems::ParentLink.find_by(work_item: self)
validate_parent_restrictions(parent_link)
validate_child_restrictions(child_links)
validate_depth(parent_link, child_links)
end
def validate_parent_restrictions(parent_link)
return unless parent_link
parent_link.work_item.work_item_type_id = work_item_type_id
unless parent_link.valid?
errors.add(
:work_item_type_id,
format(
_('cannot be changed to %{new_type} when linked to a parent %{parent_type}.'),
new_type: work_item_type.name.downcase,
parent_type: parent_link.work_item_parent.work_item_type.name.downcase
)
)
end
end
def validate_child_restrictions(child_links)
return if child_links.empty?
child_type_ids = child_links.joins(:work_item).select(self.class.arel_table[:work_item_type_id]).distinct
restrictions = ::WorkItems::HierarchyRestriction.where(
parent_type_id: work_item_type_id,
child_type_id: child_type_ids
)
# We expect a restriction for every child type
if restrictions.size < child_type_ids.size
errors.add(
:work_item_type_id,
format(_('cannot be changed to %{new_type} with these child item types.'), new_type: work_item_type.name)
)
end
end
def validate_depth(parent_link, child_links)
restriction = ::WorkItems::HierarchyRestriction.find_by_parent_type_id_and_child_type_id(
work_item_type_id,
work_item_type_id
)
return unless restriction&.maximum_depth
children_with_new_type = self.class.where(id: child_links.select(:work_item_id))
.where(work_item_type_id: work_item_type_id)
max_child_depth = ::Gitlab::WorkItems::WorkItemHierarchy.new(children_with_new_type).max_descendants_depth.to_i
ancestor_depth =
if parent_link&.work_item_parent && parent_link.work_item_parent.work_item_type_id == work_item_type_id
parent_link.work_item_parent.same_type_base_and_ancestors.count
else
0
end
if max_child_depth + ancestor_depth > restriction.maximum_depth - 1
errors.add(:work_item_type_id, _('reached maximum depth'))
end
end
def hierarchy_supports_parent?
::WorkItems::HierarchyRestriction.find_by_child_type_id(work_item_type_id).present?
end
end
WorkItem.prepend_mod