2018-11-20 20:47:30 +05:30
# frozen_string_literal: true
2014-09-02 18:07:02 +05:30
# == Issuable concern
#
# Contains common functionality shared between Issues and MergeRequests
#
2019-12-21 20:55:43 +05:30
# Used by Issue, MergeRequest, Epic
2014-09-02 18:07:02 +05:30
#
module Issuable
extend ActiveSupport :: Concern
2018-03-17 18:26:18 +05:30
include Gitlab :: SQL :: Pattern
2018-11-18 11:00:15 +05:30
include Redactable
2016-11-03 12:29:30 +05:30
include CacheMarkdownField
2015-09-11 14:41:01 +05:30
include Participable
2015-10-24 18:46:33 +05:30
include Mentionable
2016-06-02 11:05:42 +05:30
include Subscribable
2015-12-23 02:04:40 +05:30
include StripAttribute
2016-06-16 23:09:34 +05:30
include Awardable
2017-08-17 22:00:37 +05:30
include Taskable
include Importable
include Editable
2017-09-10 17:25:29 +05:30
include AfterCommitQueue
2018-03-17 18:26:18 +05:30
include Sortable
include CreatedAtFilterable
2018-03-27 19:54:05 +05:30
include UpdatedAtFilterable
2019-07-07 11:18:12 +05:30
include ClosedAtFilterable
2019-12-21 20:55:43 +05:30
include VersionedDescription
TITLE_LENGTH_MAX = 255
TITLE_HTML_LENGTH_MAX = 800
DESCRIPTION_LENGTH_MAX = 1 . megabyte
DESCRIPTION_HTML_LENGTH_MAX = 5 . megabytes
STATE_ID_MAP = {
opened : 1 ,
closed : 2 ,
merged : 3 ,
locked : 4
} . with_indifferent_access . freeze
2017-08-17 22:00:37 +05:30
# This object is used to gather issuable meta data for displaying
# upvotes, downvotes, notes and closing merge requests count for issues and merge requests
# lists avoiding n+1 queries and improving performance.
2019-07-07 11:18:12 +05:30
IssuableMeta = Struct . new ( :upvotes , :downvotes , :user_notes_count , :mrs_count ) do
def merge_requests_count ( user = nil )
mrs_count
end
end
2014-09-02 18:07:02 +05:30
included do
2016-11-03 12:29:30 +05:30
cache_markdown_field :title , pipeline : :single_line
2017-08-17 22:00:37 +05:30
cache_markdown_field :description , issuable_state_filter_enabled : true
2016-11-03 12:29:30 +05:30
2018-11-18 11:00:15 +05:30
redact_field :description
2019-07-07 11:18:12 +05:30
belongs_to :author , class_name : 'User'
belongs_to :updated_by , class_name : 'User'
2017-08-17 22:00:37 +05:30
belongs_to :last_edited_by , class_name : 'User'
2014-09-02 18:07:02 +05:30
belongs_to :milestone
2017-09-10 17:25:29 +05:30
has_many :notes , as : :noteable , inverse_of : :noteable , dependent : :destroy do # rubocop:disable Cop/ActiveRecordDependent
2016-06-16 23:09:34 +05:30
def authors_loaded?
2016-08-24 12:49:21 +05:30
# We check first if we're loaded to not load unnecessarily.
2016-06-16 23:09:34 +05:30
loaded? && to_a . all? { | note | note . association ( :author ) . loaded? }
end
2016-08-24 12:49:21 +05:30
def award_emojis_loaded?
# We check first if we're loaded to not load unnecessarily.
loaded? && to_a . all? { | note | note . association ( :award_emoji ) . loaded? }
end
2016-06-16 23:09:34 +05:30
end
2016-09-29 09:46:39 +05:30
2018-11-20 20:47:30 +05:30
has_many :label_links , as : :target , dependent : :destroy , inverse_of : :target # rubocop:disable Cop/ActiveRecordDependent
2014-09-02 18:07:02 +05:30
has_many :labels , through : :label_links
2017-09-10 17:25:29 +05:30
has_many :todos , as : :target , dependent : :destroy # rubocop:disable Cop/ActiveRecordDependent
2014-09-02 18:07:02 +05:30
2016-09-29 09:46:39 +05:30
has_one :metrics
2017-08-17 22:00:37 +05:30
delegate :name ,
:email ,
:public_email ,
to : :author ,
allow_nil : true ,
prefix : true
2014-09-02 18:07:02 +05:30
validates :author , presence : true
2019-12-21 20:55:43 +05:30
validates :title , presence : true , length : { maximum : TITLE_LENGTH_MAX }
# we validate the description against DESCRIPTION_LENGTH_MAX only for Issuables being created
# to avoid breaking the existing Issuables which may have their descriptions longer
validates :description , length : { maximum : DESCRIPTION_LENGTH_MAX } , allow_blank : true , on : :create
validate :description_max_length_for_new_records_is_valid , on : :update
2019-03-13 22:55:13 +05:30
validate :milestone_is_valid
2014-09-02 18:07:02 +05:30
2019-12-21 20:55:43 +05:30
before_validation :truncate_description_on_import!
2014-09-02 18:07:02 +05:30
scope :authored , - > ( user ) { where ( author_id : user ) }
2015-11-26 14:37:03 +05:30
scope :recent , - > { reorder ( id : :desc ) }
2014-09-02 18:07:02 +05:30
scope :of_projects , - > ( ids ) { where ( project_id : ids ) }
2016-06-02 11:05:42 +05:30
scope :of_milestones , - > ( ids ) { where ( milestone_id : ids ) }
2018-12-05 23:21:45 +05:30
scope :any_milestone , - > { where ( 'milestone_id IS NOT NULL' ) }
2016-06-16 23:09:34 +05:30
scope :with_milestone , - > ( title ) { left_joins_milestones . where ( milestones : { title : title } ) }
2020-01-01 13:55:28 +05:30
scope :any_release , - > { joins_milestone_releases }
scope :with_release , - > ( tag , project_id ) { joins_milestone_releases . where ( milestones : { releases : { tag : tag , project_id : project_id } } ) }
2017-09-10 17:25:29 +05:30
scope :opened , - > { with_state ( :opened ) }
2014-09-02 18:07:02 +05:30
scope :only_opened , - > { with_state ( :opened ) }
scope :closed , - > { with_state ( :closed ) }
2019-07-31 22:56:46 +05:30
# rubocop:disable GitlabSecurity/SqlInjection
# The `to_ability_name` method is not an user input.
scope :assigned , - > do
where ( " EXISTS (SELECT TRUE FROM #{ to_ability_name } _assignees WHERE #{ to_ability_name } _id = #{ to_ability_name } s.id) " )
end
scope :unassigned , - > do
where ( " NOT EXISTS (SELECT TRUE FROM #{ to_ability_name } _assignees WHERE #{ to_ability_name } _id = #{ to_ability_name } s.id) " )
end
scope :assigned_to , - > ( u ) do
where ( " EXISTS (SELECT TRUE FROM #{ to_ability_name } _assignees WHERE user_id = ? AND #{ to_ability_name } _id = #{ to_ability_name } s.id) " , u . id )
end
# rubocop:enable GitlabSecurity/SqlInjection
2016-06-16 23:09:34 +05:30
scope :left_joins_milestones , - > { joins ( " LEFT OUTER JOIN milestones ON #{ table_name } .milestone_id = milestones.id " ) }
2019-12-26 22:10:19 +05:30
scope :order_milestone_due_desc , - > { left_joins_milestones . reorder ( Arel . sql ( 'milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC' ) ) }
scope :order_milestone_due_asc , - > { left_joins_milestones . reorder ( Arel . sql ( 'milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC' ) ) }
2016-06-16 23:09:34 +05:30
2020-01-01 13:55:28 +05:30
scope :without_release , - > do
joins ( " LEFT OUTER JOIN milestone_releases ON #{ table_name } .milestone_id = milestone_releases.milestone_id " )
. where ( 'milestone_releases.release_id IS NULL' )
end
scope :joins_milestone_releases , - > do
joins ( " JOIN milestone_releases ON #{ table_name } .milestone_id = milestone_releases.milestone_id
JOIN releases ON milestone_releases . release_id = releases . id " ).distinct
end
2016-06-16 23:09:34 +05:30
scope :without_label , - > { joins ( " LEFT OUTER JOIN label_links ON label_links.target_type = ' #{ name } ' AND label_links.target_id = #{ table_name } .id " ) . where ( label_links : { id : nil } ) }
2019-02-15 15:39:39 +05:30
scope :any_label , - > { joins ( :label_links ) . group ( :id ) }
2015-12-23 02:04:40 +05:30
scope :join_project , - > { joins ( :project ) }
2017-08-17 22:00:37 +05:30
scope :inc_notes_with_associations , - > { includes ( notes : [ :project , :author , :award_emoji ] ) }
2015-12-23 02:04:40 +05:30
scope :references_project , - > { references ( :project ) }
2016-06-02 11:05:42 +05:30
scope :non_archived , - > { join_project . where ( projects : { archived : false } ) }
2016-06-16 23:09:34 +05:30
2015-12-23 02:04:40 +05:30
attr_mentionable :title , pipeline : :single_line
2016-06-16 23:09:34 +05:30
attr_mentionable :description
participant :author
participant :notes_with_associations
2019-07-31 22:56:46 +05:30
participant :assignees
2016-06-16 23:09:34 +05:30
2015-12-23 02:04:40 +05:30
strip_attributes :title
2016-06-02 11:05:42 +05:30
2016-09-29 09:46:39 +05:30
# We want to use optimistic lock for cases when only title or description are involved
# http://api.rubyonrails.org/classes/ActiveRecord/Locking/Optimistic.html
def locking_enabled?
2019-07-31 22:56:46 +05:30
will_save_change_to_title? || will_save_change_to_description?
2016-09-29 09:46:39 +05:30
end
2017-09-10 17:25:29 +05:30
def allows_multiple_assignees?
false
end
def has_multiple_assignees?
assignees . count > 1
end
2019-03-13 22:55:13 +05:30
private
def milestone_is_valid
2020-01-01 13:55:28 +05:30
errors . add ( :milestone_id , message : " is invalid " ) if respond_to? ( :milestone_id ) && milestone_id . present? && ! milestone_available?
2019-03-13 22:55:13 +05:30
end
2019-12-21 20:55:43 +05:30
def description_max_length_for_new_records_is_valid
if new_record? && description . length > Issuable :: DESCRIPTION_LENGTH_MAX
errors . add ( :description , :too_long , count : Issuable :: DESCRIPTION_LENGTH_MAX )
end
end
def truncate_description_on_import!
self . description = description & . slice ( 0 , Issuable :: DESCRIPTION_LENGTH_MAX ) if importing?
end
2014-09-02 18:07:02 +05:30
end
2018-11-20 20:47:30 +05:30
class_methods do
2016-06-02 11:05:42 +05:30
# Searches for records with a matching title.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String
#
# Returns an ActiveRecord::Relation.
2014-09-02 18:07:02 +05:30
def search ( query )
2018-03-17 18:26:18 +05:30
fuzzy_search ( query , [ :title ] )
2014-09-02 18:07:02 +05:30
end
2019-12-21 20:55:43 +05:30
def available_states
@available_states || = STATE_ID_MAP . slice ( * available_state_names )
end
# Available state names used to persist state_id column using state machine
2019-07-07 11:18:12 +05:30
#
# Override this on subclasses if different states are needed
#
2019-12-21 20:55:43 +05:30
# Check MergeRequest.available_states_names for example
def available_state_names
[ :opened , :closed ]
2019-07-07 11:18:12 +05:30
end
2016-06-02 11:05:42 +05:30
# Searches for records with a matching title or description.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String
2019-03-02 22:35:43 +05:30
# matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma.
2016-06-02 11:05:42 +05:30
#
# Returns an ActiveRecord::Relation.
2019-09-30 21:07:59 +05:30
def full_search ( query , matched_columns : 'title,description' , use_minimum_char_limit : true )
2019-03-02 22:35:43 +05:30
allowed_columns = [ :title , :description ]
matched_columns = matched_columns . to_s . split ( ',' ) . map ( & :to_sym )
matched_columns & = allowed_columns
# Matching title or description if the matched_columns did not contain any allowed columns.
matched_columns = [ :title , :description ] if matched_columns . empty?
2019-09-30 21:07:59 +05:30
fuzzy_search ( query , matched_columns , use_minimum_char_limit : use_minimum_char_limit )
2015-04-26 12:48:37 +05:30
end
2019-07-07 11:18:12 +05:30
def simple_sorts
super . except ( 'name_asc' , 'name_desc' )
end
2018-05-09 12:01:36 +05:30
def sort_by_attribute ( method , excluded_labels : [ ] )
2018-03-17 18:26:18 +05:30
sorted =
case method . to_s
2019-12-04 20:38:33 +05:30
when 'downvotes_desc' then order_downvotes_desc
when 'label_priority' , 'label_priority_asc' then order_labels_priority ( excluded_labels : excluded_labels )
when 'label_priority_desc' then order_labels_priority ( 'DESC' , excluded_labels : excluded_labels )
when 'milestone' , 'milestone_due_asc' then order_milestone_due_asc
when 'milestone_due_desc' then order_milestone_due_desc
when 'popularity_asc' then order_upvotes_asc
when 'popularity' , 'popularity_desc' , 'upvotes_desc' then order_upvotes_desc
when 'priority' , 'priority_asc' then order_due_date_and_labels_priority ( excluded_labels : excluded_labels )
when 'priority_desc' then order_due_date_and_labels_priority ( 'DESC' , excluded_labels : excluded_labels )
2018-03-17 18:26:18 +05:30
else order_by ( method )
end
2016-08-24 12:49:21 +05:30
# Break ties with the ID column for pagination
2018-11-18 11:00:15 +05:30
sorted . with_order_id_desc
2014-09-02 18:07:02 +05:30
end
2016-04-02 18:10:28 +05:30
2019-02-15 15:39:39 +05:30
def order_due_date_and_labels_priority ( direction = 'ASC' , excluded_labels : [ ] )
2017-08-17 22:00:37 +05:30
# The order_ methods also modify the query in other ways:
#
# - For milestones, we add a JOIN.
# - For label priority, we change the SELECT, and add a GROUP BY.#
#
# After doing those, we need to reorder to the order we want. The existing
# ORDER BYs won't work because:
#
# 1. We need milestone due date first.
# 2. We can't ORDER BY a column that isn't in the GROUP BY and doesn't
# have an aggregate function applied, so we do a useless MIN() instead.
#
milestones_due_date = 'MIN(milestones.due_date)'
2017-09-10 17:25:29 +05:30
order_milestone_due_asc
. order_labels_priority ( excluded_labels : excluded_labels , extra_select_columns : [ milestones_due_date ] )
2019-02-15 15:39:39 +05:30
. reorder ( Gitlab :: Database . nulls_last_order ( milestones_due_date , direction ) ,
Gitlab :: Database . nulls_last_order ( 'highest_priority' , direction ) )
2017-08-17 22:00:37 +05:30
end
2019-02-15 15:39:39 +05:30
def order_labels_priority ( direction = 'ASC' , excluded_labels : [ ] , extra_select_columns : [ ] )
2016-11-03 12:29:30 +05:30
params = {
target_type : name ,
target_column : " #{ table_name } .id " ,
project_column : " #{ table_name } . #{ project_foreign_key } " ,
excluded_labels : excluded_labels
}
highest_priority = highest_label_priority ( params ) . to_sql
2016-09-13 17:45:13 +05:30
2017-08-17 22:00:37 +05:30
select_columns = [
" #{ table_name } .* " ,
" ( #{ highest_priority } ) AS highest_priority "
] + extra_select_columns
2017-09-10 17:25:29 +05:30
select ( select_columns . join ( ', ' ) )
. group ( arel_table [ :id ] )
2019-02-15 15:39:39 +05:30
. reorder ( Gitlab :: Database . nulls_last_order ( 'highest_priority' , direction ) )
2016-04-02 18:10:28 +05:30
end
2016-06-16 23:09:34 +05:30
def with_label ( title , sort = nil )
if title . is_a? ( Array ) && title . size > 1
joins ( :labels ) . where ( labels : { title : title } ) . group ( * grouping_columns ( sort ) ) . having ( " COUNT(DISTINCT labels.title) = #{ title . size } " )
else
joins ( :labels ) . where ( labels : { title : title } )
end
2016-04-02 18:10:28 +05:30
end
2016-06-16 23:09:34 +05:30
# Includes table keys in group by clause when sorting
# preventing errors in postgres
#
# Returns an array of arel columns
def grouping_columns ( sort )
grouping_columns = [ arel_table [ :id ] ]
2018-03-17 18:26:18 +05:30
if %w( milestone_due_desc milestone_due_asc milestone ) . include? ( sort )
2016-06-16 23:09:34 +05:30
milestone_table = Milestone . arel_table
grouping_columns << milestone_table [ :id ]
grouping_columns << milestone_table [ :due_date ]
end
2016-04-02 18:10:28 +05:30
2016-06-16 23:09:34 +05:30
grouping_columns
2016-04-02 18:10:28 +05:30
end
2016-11-24 13:41:30 +05:30
def to_ability_name
model_name . singular
end
2018-03-27 19:54:05 +05:30
def parent_class
:: Project
end
2014-09-02 18:07:02 +05:30
end
2019-12-21 20:55:43 +05:30
def state
self . class . available_states . key ( state_id )
end
def state = ( value )
self . state_id = self . class . available_states [ value ]
end
2019-12-04 20:38:33 +05:30
def resource_parent
project
end
2019-07-31 22:56:46 +05:30
def milestone_available?
project_id == milestone & . project_id || project . ancestors_upto . compact . include? ( milestone & . group )
end
def assignee_or_author? ( user )
author_id == user . id || assignees . exists? ( user . id )
end
2014-09-02 18:07:02 +05:30
def today?
Date . today == created_at . to_date
end
def new?
today? && created_at == updated_at
end
2015-10-24 18:46:33 +05:30
def open?
2017-09-10 17:25:29 +05:30
opened?
2015-10-24 18:46:33 +05:30
end
2018-11-18 11:00:15 +05:30
def overdue?
return false unless respond_to? ( :due_date )
due_date . try ( :past? ) || false
end
2016-06-02 11:05:42 +05:30
def user_notes_count
2016-06-16 23:09:34 +05:30
if notes . loaded?
# Use the in-memory association to select and count to avoid hitting the db
notes . to_a . count { | note | ! note . system? }
else
# do the count query
notes . user . count
end
2015-04-26 12:48:37 +05:30
end
2017-08-17 22:00:37 +05:30
def subscribed_without_subscriptions? ( user , project )
2016-06-02 11:05:42 +05:30
participants ( user ) . include? ( user )
2016-01-19 16:12:03 +05:30
end
2018-03-17 18:26:18 +05:30
def to_hook_data ( user , old_associations : { } )
changes = previous_changes
2019-03-02 22:35:43 +05:30
if old_associations
old_labels = old_associations . fetch ( :labels , [ ] )
old_assignees = old_associations . fetch ( :assignees , [ ] )
2018-03-17 18:26:18 +05:30
2019-03-02 22:35:43 +05:30
if old_labels != labels
changes [ :labels ] = [ old_labels . map ( & :hook_attrs ) , labels . map ( & :hook_attrs ) ]
2018-03-17 18:26:18 +05:30
end
2016-04-02 18:10:28 +05:30
2019-03-02 22:35:43 +05:30
if old_assignees != assignees
2019-07-31 22:56:46 +05:30
changes [ :assignees ] = [ old_assignees . map ( & :hook_attrs ) , assignees . map ( & :hook_attrs ) ]
2019-03-02 22:35:43 +05:30
end
2018-03-17 18:26:18 +05:30
2019-03-02 22:35:43 +05:30
if self . respond_to? ( :total_time_spent )
old_total_time_spent = old_associations . fetch ( :total_time_spent , nil )
if old_total_time_spent != total_time_spent
changes [ :total_time_spent ] = [ old_total_time_spent , total_time_spent ]
end
2018-03-17 18:26:18 +05:30
end
end
Gitlab :: HookData :: IssuableBuilder . new ( self ) . build ( user : user , changes : changes )
2014-09-02 18:07:02 +05:30
end
2016-06-16 23:09:34 +05:30
def labels_array
labels . to_a
end
2014-09-02 18:07:02 +05:30
def label_names
labels . order ( 'title ASC' ) . pluck ( :title )
end
2015-09-11 14:41:01 +05:30
# Convert this Issuable class name to a format usable by Ability definitions
#
# Examples:
#
# issuable.class # => MergeRequest
# issuable.to_ability_name # => "merge_request"
def to_ability_name
2016-11-24 13:41:30 +05:30
self . class . to_ability_name
2015-09-11 14:41:01 +05:30
end
2016-01-14 18:37:52 +05:30
# Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes
{
'Author' = > author . try ( :name ) ,
2019-07-31 22:56:46 +05:30
'Assignee' = > assignee_list
2016-01-14 18:37:52 +05:30
}
end
2019-07-31 22:56:46 +05:30
def assignee_list
assignees . map ( & :name ) . to_sentence
end
def assignee_username_list
assignees . map ( & :username ) . to_sentence
end
2015-10-24 18:46:33 +05:30
def notes_with_associations
2016-06-16 23:09:34 +05:30
# If A has_many Bs, and B has_many Cs, and you do
# `A.includes(b: :c).each { |a| a.b.includes(:c) }`, sadly ActiveRecord
# will do the inclusion again. So, we check if all notes in the relation
# already have their authors loaded (possibly because the scope
# `inc_notes_with_associations` was used) and skip the inclusion if that's
# the case.
2016-08-24 12:49:21 +05:30
includes = [ ]
includes << :author unless notes . authors_loaded?
includes << :award_emoji unless notes . award_emojis_loaded?
2018-03-17 18:26:18 +05:30
2016-08-24 12:49:21 +05:30
if includes . any?
notes . includes ( includes )
else
notes
end
2015-10-24 18:46:33 +05:30
end
2015-12-23 02:04:40 +05:30
def updated_tasks
Taskable . get_updated_tasks ( old_content : previous_changes [ 'description' ] . first ,
new_content : description )
end
2016-06-02 11:05:42 +05:30
##
# Method that checks if issuable can be moved to another project.
#
# Should be overridden if issuable can be moved.
#
def can_move? ( * )
false
end
2016-09-29 09:46:39 +05:30
2018-03-17 18:26:18 +05:30
##
# Override in issuable specialization
#
def first_contribution?
false
end
def ensure_metrics
self . metrics || create_metrics
end
##
2018-12-13 13:39:08 +05:30
# Overridden in MergeRequest
2018-03-17 18:26:18 +05:30
#
def wipless_title_changed ( old_title )
old_title != title
2016-09-29 09:46:39 +05:30
end
2019-10-12 21:52:04 +05:30
##
# Overridden on EE module
#
def supports_milestone?
respond_to? ( :milestone_id )
end
2014-09-02 18:07:02 +05:30
end
2019-12-04 20:38:33 +05:30
Issuable . prepend_if_ee ( 'EE::Issuable' ) # rubocop: disable Cop/InjectEnterpriseEditionModule
Issuable :: ClassMethods . prepend_if_ee ( 'EE::Issuable::ClassMethods' )