2018-08-03 13:22:24 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2013-01-03 02:06:07 -05:00
|
|
|
# == Issuable concern
|
2012-12-30 09:19:31 -05:00
|
|
|
#
|
2012-10-09 15:09:46 -04:00
|
|
|
# Contains common functionality shared between Issues and MergeRequests
|
2012-12-30 09:19:31 -05:00
|
|
|
#
|
2019-10-07 02:06:10 -04:00
|
|
|
# Used by Issue, MergeRequest, Epic
|
2012-12-30 09:19:31 -05:00
|
|
|
#
|
2013-01-03 02:06:07 -05:00
|
|
|
module Issuable
|
2012-08-08 21:40:57 -04:00
|
|
|
extend ActiveSupport::Concern
|
2017-08-23 06:54:14 -04:00
|
|
|
include Gitlab::SQL::Pattern
|
2018-09-20 10:14:46 -04:00
|
|
|
include Redactable
|
2016-10-06 17:17:11 -04:00
|
|
|
include CacheMarkdownField
|
2015-04-21 09:23:20 -04:00
|
|
|
include Participable
|
2015-10-14 10:20:11 -04:00
|
|
|
include Mentionable
|
2020-01-03 04:07:33 -05:00
|
|
|
include Milestoneable
|
2016-02-12 09:58:39 -05:00
|
|
|
include Subscribable
|
2015-11-26 10:16:50 -05:00
|
|
|
include StripAttribute
|
2016-04-16 15:09:08 -04:00
|
|
|
include Awardable
|
2016-09-26 17:36:11 -04:00
|
|
|
include Taskable
|
2017-03-27 10:43:35 -04:00
|
|
|
include Importable
|
2017-05-18 08:24:34 -04:00
|
|
|
include Editable
|
2017-08-01 08:38:45 -04:00
|
|
|
include AfterCommitQueue
|
2017-11-07 08:34:12 -05:00
|
|
|
include Sortable
|
|
|
|
include CreatedAtFilterable
|
2018-02-28 06:16:29 -05:00
|
|
|
include UpdatedAtFilterable
|
2019-01-17 12:49:07 -05:00
|
|
|
include ClosedAtFilterable
|
2019-10-18 07:11:44 -04:00
|
|
|
include VersionedDescription
|
2021-09-09 05:11:16 -04:00
|
|
|
include SortableTitle
|
2012-08-08 21:40:57 -04:00
|
|
|
|
2019-10-07 02:06:10 -04:00
|
|
|
TITLE_LENGTH_MAX = 255
|
|
|
|
TITLE_HTML_LENGTH_MAX = 800
|
2019-10-15 20:06:16 -04:00
|
|
|
DESCRIPTION_LENGTH_MAX = 1.megabyte
|
|
|
|
DESCRIPTION_HTML_LENGTH_MAX = 5.megabytes
|
2021-06-29 23:07:30 -04:00
|
|
|
SEARCHABLE_FIELDS = %w(title description).freeze
|
2019-10-07 02:06:10 -04:00
|
|
|
|
2019-10-18 07:11:44 -04:00
|
|
|
STATE_ID_MAP = {
|
|
|
|
opened: 1,
|
|
|
|
closed: 2,
|
|
|
|
merged: 3,
|
|
|
|
locked: 4
|
|
|
|
}.with_indifferent_access.freeze
|
|
|
|
|
2012-08-08 21:40:57 -04:00
|
|
|
included do
|
2016-10-06 17:17:11 -04:00
|
|
|
cache_markdown_field :title, pipeline: :single_line
|
2021-11-24 07:10:21 -05:00
|
|
|
cache_markdown_field :description, issuable_reference_expansion_enabled: true
|
2016-10-06 17:17:11 -04:00
|
|
|
|
2018-09-20 10:14:46 -04:00
|
|
|
redact_field :description
|
|
|
|
|
2019-02-27 05:28:53 -05:00
|
|
|
belongs_to :author, class_name: 'User'
|
|
|
|
belongs_to :updated_by, class_name: 'User'
|
2017-05-03 01:32:21 -04:00
|
|
|
belongs_to :last_edited_by, class_name: 'User'
|
2017-07-07 11:08:49 -04:00
|
|
|
|
2017-06-08 11:16:27 -04:00
|
|
|
has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent
|
2016-06-06 16:19:39 -04:00
|
|
|
def authors_loaded?
|
2016-06-21 01:26:43 -04:00
|
|
|
# We check first if we're loaded to not load unnecessarily.
|
2016-06-06 16:19:39 -04:00
|
|
|
loaded? && to_a.all? { |note| note.association(:author).loaded? }
|
|
|
|
end
|
2016-06-21 01:26:43 -04:00
|
|
|
|
|
|
|
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
|
2021-12-15 10:15:54 -05:00
|
|
|
|
|
|
|
def projects_loaded?
|
|
|
|
# We check first if we're loaded to not load unnecessarily.
|
|
|
|
loaded? && to_a.all? { |note| note.association(:project).loaded? }
|
|
|
|
end
|
2021-12-17 19:14:00 -05:00
|
|
|
|
|
|
|
def system_note_metadata_loaded?
|
|
|
|
# We check first if we're loaded to not load unnecessarily.
|
|
|
|
loaded? && to_a.all? { |note| note.association(:system_note_metadata).loaded? }
|
|
|
|
end
|
2016-06-06 16:19:39 -04:00
|
|
|
end
|
2016-09-14 10:10:31 -04:00
|
|
|
|
2020-08-12 08:10:25 -04:00
|
|
|
has_many :note_authors, -> { distinct }, through: :notes, source: :author
|
|
|
|
|
2021-05-12 08:10:24 -04:00
|
|
|
has_many :label_links, as: :target, inverse_of: :target
|
2018-04-18 09:41:42 -04:00
|
|
|
has_many :labels, through: :label_links
|
2021-04-07 23:09:23 -04:00
|
|
|
has_many :todos, as: :target
|
2012-08-08 21:40:57 -04:00
|
|
|
|
2020-08-05 08:09:45 -04:00
|
|
|
has_one :metrics, inverse_of: model_name.singular.to_sym, autosave: true
|
2016-09-20 08:43:11 -04:00
|
|
|
|
2017-02-22 17:35:08 -05:00
|
|
|
delegate :name,
|
|
|
|
:email,
|
2017-02-16 18:41:34 -05:00
|
|
|
:public_email,
|
2017-02-22 17:35:08 -05:00
|
|
|
to: :author,
|
2017-03-17 14:48:01 -04:00
|
|
|
allow_nil: true,
|
2017-02-22 17:35:08 -05:00
|
|
|
prefix: true
|
|
|
|
|
2012-10-08 20:10:04 -04:00
|
|
|
validates :author, presence: true
|
2019-10-07 02:06:10 -04:00
|
|
|
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
|
2012-08-08 21:40:57 -04:00
|
|
|
|
2019-10-07 02:06:10 -04:00
|
|
|
before_validation :truncate_description_on_import!
|
|
|
|
|
2013-08-10 13:25:53 -04:00
|
|
|
scope :authored, ->(user) { where(author_id: user) }
|
2021-02-22 10:10:48 -05:00
|
|
|
scope :not_authored, ->(user) { where.not(author_id: user) }
|
2015-11-11 09:17:12 -05:00
|
|
|
scope :recent, -> { reorder(id: :desc) }
|
2013-08-08 10:29:31 -04:00
|
|
|
scope :of_projects, ->(ids) { where(project_id: ids) }
|
2017-07-19 10:03:50 -04:00
|
|
|
scope :opened, -> { with_state(:opened) }
|
2014-02-28 15:43:16 -05:00
|
|
|
scope :closed, -> { with_state(:closed) }
|
2013-08-19 15:10:56 -04:00
|
|
|
|
2019-04-07 14:35:16 -04:00
|
|
|
# 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
|
2021-05-26 17:10:49 -04:00
|
|
|
scope :assigned_to, ->(users) do
|
|
|
|
assignees_class = self.reflect_on_association("#{to_ability_name}_assignees").klass
|
2019-04-07 14:35:16 -04:00
|
|
|
|
2021-05-26 17:10:49 -04:00
|
|
|
condition = assignees_class.where(user_id: users).where(Arel.sql("#{to_ability_name}_id = #{to_ability_name}s.id"))
|
|
|
|
where(condition.arel.exists)
|
|
|
|
end
|
2020-05-06 23:09:46 -04:00
|
|
|
scope :not_assigned_to, ->(users) do
|
2021-05-26 17:10:49 -04:00
|
|
|
assignees_class = self.reflect_on_association("#{to_ability_name}_assignees").klass
|
|
|
|
|
|
|
|
condition = assignees_class.where(user_id: users).where(Arel.sql("#{to_ability_name}_id = #{to_ability_name}s.id"))
|
|
|
|
where(condition.arel.exists.not)
|
2020-05-06 23:09:46 -04:00
|
|
|
end
|
2021-05-26 17:10:49 -04:00
|
|
|
# rubocop:enable GitlabSecurity/SqlInjection
|
2020-05-06 23:09:46 -04:00
|
|
|
|
2016-04-27 11:35:30 -04:00
|
|
|
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 }) }
|
2020-04-21 11:21:10 -04:00
|
|
|
scope :with_label_ids, ->(label_ids) { joins(:label_links).where(label_links: { label_id: label_ids }) }
|
2015-11-11 06:50:36 -05:00
|
|
|
scope :join_project, -> { joins(:project) }
|
2017-02-21 18:33:53 -05:00
|
|
|
scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
|
2015-11-11 06:50:36 -05:00
|
|
|
scope :references_project, -> { references(:project) }
|
2016-04-02 08:36:41 -04:00
|
|
|
scope :non_archived, -> { join_project.where(projects: { archived: false }) }
|
2015-11-11 06:50:36 -05:00
|
|
|
|
2021-04-15 11:09:11 -04:00
|
|
|
scope :includes_for_bulk_update, -> do
|
|
|
|
associations = %i[author assignees epic group labels metrics project source_project target_project].select do |association|
|
|
|
|
reflect_on_association(association)
|
|
|
|
end
|
|
|
|
|
|
|
|
includes(*associations)
|
|
|
|
end
|
|
|
|
|
2015-10-22 09:40:02 -04:00
|
|
|
attr_mentionable :title, pipeline: :single_line
|
2016-05-26 07:38:28 -04:00
|
|
|
attr_mentionable :description
|
|
|
|
|
|
|
|
participant :author
|
|
|
|
participant :notes_with_associations
|
2019-04-07 14:35:16 -04:00
|
|
|
participant :assignees
|
2016-05-26 07:38:28 -04:00
|
|
|
|
2021-08-16 20:10:22 -04:00
|
|
|
strip_attributes! :title
|
2016-03-14 16:46:44 -04:00
|
|
|
|
2020-04-21 11:21:10 -04:00
|
|
|
class << self
|
|
|
|
def labels_hash
|
|
|
|
issue_labels = Hash.new { |h, k| h[k] = [] }
|
|
|
|
|
|
|
|
relation = unscoped.where(id: self.select(:id)).eager_load(:labels)
|
|
|
|
relation.pluck(:id, 'labels.title').each do |issue_id, label|
|
|
|
|
issue_labels[issue_id] << label if label.present?
|
|
|
|
end
|
|
|
|
|
|
|
|
issue_labels
|
|
|
|
end
|
|
|
|
|
|
|
|
def locking_enabled?
|
|
|
|
false
|
|
|
|
end
|
2020-01-29 13:08:47 -05:00
|
|
|
end
|
|
|
|
|
2016-08-01 11:34:17 -04:00
|
|
|
# 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-04-23 05:30:18 -04:00
|
|
|
will_save_change_to_title? || will_save_change_to_description?
|
2016-08-01 11:34:17 -04:00
|
|
|
end
|
2017-06-14 16:08:24 -04:00
|
|
|
|
|
|
|
def allows_multiple_assignees?
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
|
|
|
def has_multiple_assignees?
|
2017-06-20 15:32:49 -04:00
|
|
|
assignees.count > 1
|
2017-06-14 16:08:24 -04:00
|
|
|
end
|
2019-01-14 05:46:39 -05:00
|
|
|
|
2020-09-07 08:08:27 -04:00
|
|
|
def allows_reviewers?
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
2020-08-20 11:10:18 -04:00
|
|
|
def supports_time_tracking?
|
2020-09-22 08:09:39 -04:00
|
|
|
is_a?(TimeTrackable)
|
2020-08-20 11:10:18 -04:00
|
|
|
end
|
|
|
|
|
2020-09-10 14:08:54 -04:00
|
|
|
def supports_severity?
|
|
|
|
incident?
|
|
|
|
end
|
|
|
|
|
2021-12-17 07:16:21 -05:00
|
|
|
def supports_escalation?
|
|
|
|
incident?
|
|
|
|
end
|
|
|
|
|
2020-08-20 11:10:18 -04:00
|
|
|
def incident?
|
|
|
|
is_a?(Issue) && super
|
|
|
|
end
|
|
|
|
|
2020-09-10 11:09:10 -04:00
|
|
|
def supports_issue_type?
|
|
|
|
is_a?(Issue)
|
|
|
|
end
|
|
|
|
|
2021-01-05 07:10:36 -05:00
|
|
|
def supports_assignee?
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
2020-09-07 08:08:27 -04:00
|
|
|
def severity
|
2020-12-08 13:10:08 -05:00
|
|
|
return IssuableSeverity::DEFAULT unless supports_severity?
|
2020-09-07 08:08:27 -04:00
|
|
|
|
|
|
|
issuable_severity&.severity || IssuableSeverity::DEFAULT
|
|
|
|
end
|
|
|
|
|
2019-01-14 05:46:39 -05:00
|
|
|
private
|
|
|
|
|
2019-10-07 02:06:10 -04:00
|
|
|
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
|
2012-08-08 21:40:57 -04:00
|
|
|
end
|
|
|
|
|
2018-08-27 08:35:31 -04:00
|
|
|
class_methods do
|
2020-12-21 10:10:05 -05:00
|
|
|
def participant_includes
|
|
|
|
[:assignees, :author, { notes: [:author, :award_emoji] }]
|
|
|
|
end
|
|
|
|
|
2016-03-01 10:59:36 -05:00
|
|
|
# Searches for records with a matching title.
|
|
|
|
#
|
2020-06-15 23:08:24 -04:00
|
|
|
# This method uses ILIKE on PostgreSQL.
|
2016-03-01 10:59:36 -05:00
|
|
|
#
|
|
|
|
# query - The search query as a String
|
|
|
|
#
|
|
|
|
# Returns an ActiveRecord::Relation.
|
2012-08-09 13:45:12 -04:00
|
|
|
def search(query)
|
2017-11-24 06:24:24 -05:00
|
|
|
fuzzy_search(query, [:title])
|
2012-08-09 13:45:12 -04:00
|
|
|
end
|
2014-01-14 13:49:32 -05:00
|
|
|
|
2019-10-18 07:11:44 -04:00
|
|
|
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-02-13 14:31:14 -05:00
|
|
|
#
|
|
|
|
# Override this on subclasses if different states are needed
|
|
|
|
#
|
2019-10-18 07:11:44 -04:00
|
|
|
# Check MergeRequest.available_states_names for example
|
|
|
|
def available_state_names
|
|
|
|
[:opened, :closed]
|
2019-02-13 14:31:14 -05:00
|
|
|
end
|
|
|
|
|
2016-03-01 10:59:36 -05:00
|
|
|
# Searches for records with a matching title or description.
|
|
|
|
#
|
2020-06-15 23:08:24 -04:00
|
|
|
# This method uses ILIKE on PostgreSQL.
|
2016-03-01 10:59:36 -05:00
|
|
|
#
|
|
|
|
# query - The search query as a String
|
2019-01-13 11:24:31 -05:00
|
|
|
# matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma.
|
2016-03-01 10:59:36 -05:00
|
|
|
#
|
|
|
|
# Returns an ActiveRecord::Relation.
|
2021-06-29 23:07:30 -04:00
|
|
|
def full_search(query, matched_columns: nil, use_minimum_char_limit: true)
|
|
|
|
if matched_columns
|
|
|
|
matched_columns = matched_columns.to_s.split(',')
|
|
|
|
matched_columns &= SEARCHABLE_FIELDS
|
|
|
|
matched_columns.map!(&:to_sym)
|
|
|
|
end
|
2019-01-13 11:24:31 -05:00
|
|
|
|
2021-06-29 23:07:30 -04:00
|
|
|
search_columns = matched_columns.presence || [:title, :description]
|
2019-01-13 11:24:31 -05:00
|
|
|
|
2021-06-29 23:07:30 -04:00
|
|
|
fuzzy_search(query, search_columns, use_minimum_char_limit: use_minimum_char_limit)
|
2014-08-27 05:47:30 -04:00
|
|
|
end
|
|
|
|
|
Extend CTE search optimisation to projects
When we use the `search` param on an `IssuableFinder`, we can run into
issues. We have trigram indexes to support these searches. On
GitLab.com, we often see Postgres's optimiser prioritise the (global)
trigram indexes over the index on `project_id`. For group and project
searches, we know that it will be quicker to filter by `project_id`
first, as it returns fewer rows in most cases.
For group issues search, we ran into this issue previously, and went
through the following iterations:
1. Use a CTE on the project IDs as an optimisation fence. This prevents
the planner from disregarding the index on `project_id`.
Unfortunately it breaks some types of sorting, like priority and
popularity, as they sort on a joined table.
2. Use a subquery for listing issues, and a CTE for counts. The subquery
- in the case of group lists - didn't help as much as the CTE, but
was faster than not including it. We can safely use a CTE for counts
as they don't have sorting.
Now, however, we're seeing the same issue in a project context. The
subquery doesn't help at all there (it would only return one row, after
all). In an attempt to keep total code complexity under control, this
commit removes the subquery optimisation and applies the CTE
optimisation only for sorts we know that are safe.
This means that for more complicated sorts (like priority and
popularity), the search will continue to be very slow. If this is a
high-priority issue, we can consider introducing further optimisations,
but this finder is already very complicated and additional complexity
has a cost.
The group CTE optimisation is controlled by the same feature flag as
before, `attempt_group_search_optimizations`, which is enabled by
default. The new project CTE optimisation is controlled by a new feature
flag, `attempt_project_search_optimizations`, which is disabled by
default.
2019-04-03 05:46:13 -04:00
|
|
|
def simple_sorts
|
|
|
|
super.except('name_asc', 'name_desc')
|
|
|
|
end
|
|
|
|
|
2018-04-04 05:19:47 -04:00
|
|
|
def sort_by_attribute(method, excluded_labels: [])
|
2017-09-22 20:46:53 -04:00
|
|
|
sorted =
|
|
|
|
case method.to_s
|
2019-08-15 06:56:33 -04:00
|
|
|
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)
|
2021-09-09 05:11:16 -04:00
|
|
|
when 'title_asc' then order_title_asc.with_order_id_desc
|
|
|
|
when 'title_desc' then order_title_desc.with_order_id_desc
|
2017-09-22 20:46:53 -04:00
|
|
|
else order_by(method)
|
|
|
|
end
|
2016-06-23 09:15:46 -04:00
|
|
|
|
|
|
|
# Break ties with the ID column for pagination
|
2018-08-06 15:38:37 -04:00
|
|
|
sorted.with_order_id_desc
|
2014-01-14 13:49:32 -05:00
|
|
|
end
|
2016-05-12 03:23:21 -04:00
|
|
|
|
2018-12-06 11:57:19 -05:00
|
|
|
def order_due_date_and_labels_priority(direction = 'ASC', excluded_labels: [])
|
2017-03-10 06:10:48 -05:00
|
|
|
# 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-06-21 09:48:12 -04:00
|
|
|
order_milestone_due_asc
|
|
|
|
.order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date])
|
2018-12-06 11:57:19 -05:00
|
|
|
.reorder(Gitlab::Database.nulls_last_order(milestones_due_date, direction),
|
|
|
|
Gitlab::Database.nulls_last_order('highest_priority', direction))
|
2017-03-10 06:10:48 -05:00
|
|
|
end
|
|
|
|
|
2020-02-12 16:08:48 -05:00
|
|
|
def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [], with_cte: false)
|
2020-12-09 16:09:43 -05:00
|
|
|
highest_priority = highest_label_priority(
|
2016-10-17 16:03:06 -04:00
|
|
|
target_type: name,
|
|
|
|
target_column: "#{table_name}.id",
|
2016-10-19 09:49:07 -04:00
|
|
|
project_column: "#{table_name}.#{project_foreign_key}",
|
2016-10-17 16:03:06 -04:00
|
|
|
excluded_labels: excluded_labels
|
2020-12-09 16:09:43 -05:00
|
|
|
).to_sql
|
2016-07-26 17:21:20 -04:00
|
|
|
|
2020-03-09 08:07:45 -04:00
|
|
|
# When using CTE make sure to select the same columns that are on the group_by clause.
|
|
|
|
# This prevents errors when ignored columns are present in the database.
|
|
|
|
issuable_columns = with_cte ? issue_grouping_columns(use_cte: with_cte) : "#{table_name}.*"
|
2021-07-07 05:08:35 -04:00
|
|
|
group_columns = issue_grouping_columns(use_cte: with_cte) + ["highest_priorities.label_priority"]
|
2017-03-10 06:10:48 -05:00
|
|
|
|
2021-07-07 05:08:35 -04:00
|
|
|
extra_select_columns.unshift("highest_priorities.label_priority as highest_priority")
|
2020-03-09 08:07:45 -04:00
|
|
|
|
|
|
|
select(issuable_columns)
|
|
|
|
.select(extra_select_columns)
|
2021-07-07 05:08:35 -04:00
|
|
|
.from("#{table_name}")
|
|
|
|
.joins("JOIN LATERAL(#{highest_priority}) as highest_priorities ON TRUE")
|
|
|
|
.group(group_columns)
|
2018-12-06 11:57:19 -05:00
|
|
|
.reorder(Gitlab::Database.nulls_last_order('highest_priority', direction))
|
2016-05-13 11:26:18 -04:00
|
|
|
end
|
|
|
|
|
2020-05-06 23:09:46 -04:00
|
|
|
def with_label(title, sort = nil)
|
|
|
|
if title.is_a?(Array) && title.size > 1
|
2016-05-31 18:33:46 -04:00
|
|
|
joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}")
|
2016-05-12 03:23:21 -04:00
|
|
|
else
|
|
|
|
joins(:labels).where(labels: { title: title })
|
|
|
|
end
|
|
|
|
end
|
2016-05-27 15:53:20 -04:00
|
|
|
|
2020-05-26 05:08:06 -04:00
|
|
|
def any_label(sort = nil)
|
|
|
|
if sort
|
|
|
|
joins(:label_links).group(*grouping_columns(sort))
|
|
|
|
else
|
|
|
|
joins(:label_links).distinct
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2016-05-27 15:53:20 -04:00
|
|
|
# Includes table keys in group by clause when sorting
|
|
|
|
# preventing errors in postgres
|
|
|
|
#
|
|
|
|
# Returns an array of arel columns
|
2016-05-31 18:33:46 -04:00
|
|
|
def grouping_columns(sort)
|
2021-01-07 19:10:44 -05:00
|
|
|
sort = sort.to_s
|
2016-05-31 18:33:46 -04:00
|
|
|
grouping_columns = [arel_table[:id]]
|
2016-05-27 15:53:20 -04:00
|
|
|
|
2017-09-22 20:46:53 -04:00
|
|
|
if %w(milestone_due_desc milestone_due_asc milestone).include?(sort)
|
2016-05-27 15:53:20 -04:00
|
|
|
milestone_table = Milestone.arel_table
|
2016-05-31 18:33:46 -04:00
|
|
|
grouping_columns << milestone_table[:id]
|
|
|
|
grouping_columns << milestone_table[:due_date]
|
2021-01-07 19:10:44 -05:00
|
|
|
elsif %w(merged_at_desc merged_at_asc).include?(sort)
|
|
|
|
grouping_columns << MergeRequest::Metrics.arel_table[:merged_at]
|
2021-08-16 02:09:08 -04:00
|
|
|
elsif %w(closed_at_desc closed_at_asc).include?(sort)
|
|
|
|
grouping_columns << MergeRequest::Metrics.arel_table[:closed_at]
|
2016-05-27 15:53:20 -04:00
|
|
|
end
|
|
|
|
|
2016-05-31 18:33:46 -04:00
|
|
|
grouping_columns
|
2016-05-27 15:53:20 -04:00
|
|
|
end
|
2016-10-26 13:34:06 -04:00
|
|
|
|
2020-02-12 16:08:48 -05:00
|
|
|
# Includes all table keys in group by clause when sorting
|
|
|
|
# preventing errors in postgres when using CTE search optimisation
|
|
|
|
#
|
|
|
|
# Returns an array of arel columns
|
|
|
|
def issue_grouping_columns(use_cte: false)
|
|
|
|
if use_cte
|
2020-03-09 08:07:45 -04:00
|
|
|
attribute_names.map { |attr| arel_table[attr.to_sym] }
|
2020-02-12 16:08:48 -05:00
|
|
|
else
|
2021-07-07 05:08:35 -04:00
|
|
|
[arel_table[:id]]
|
2020-02-12 16:08:48 -05:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2016-10-26 13:34:06 -04:00
|
|
|
def to_ability_name
|
|
|
|
model_name.singular
|
|
|
|
end
|
2018-02-28 02:48:23 -05:00
|
|
|
|
|
|
|
def parent_class
|
|
|
|
::Project
|
|
|
|
end
|
2012-08-08 21:40:57 -04:00
|
|
|
end
|
|
|
|
|
2019-10-18 07:11:44 -04:00
|
|
|
def state
|
|
|
|
self.class.available_states.key(state_id)
|
|
|
|
end
|
|
|
|
|
|
|
|
def state=(value)
|
|
|
|
self.state_id = self.class.available_states[value]
|
|
|
|
end
|
|
|
|
|
2019-09-03 17:29:55 -04:00
|
|
|
def resource_parent
|
|
|
|
project
|
|
|
|
end
|
|
|
|
|
2019-04-07 14:35:16 -04:00
|
|
|
def assignee_or_author?(user)
|
|
|
|
author_id == user.id || assignees.exists?(user.id)
|
|
|
|
end
|
|
|
|
|
2012-08-08 21:40:57 -04:00
|
|
|
def today?
|
|
|
|
Date.today == created_at.to_date
|
|
|
|
end
|
|
|
|
|
2020-09-04 11:08:46 -04:00
|
|
|
def created_hours_ago
|
|
|
|
(Time.now.utc.to_i - created_at.utc.to_i) / 3600
|
|
|
|
end
|
|
|
|
|
2012-08-08 21:40:57 -04:00
|
|
|
def new?
|
2020-09-04 11:08:46 -04:00
|
|
|
created_hours_ago < 24
|
2012-08-08 21:40:57 -04:00
|
|
|
end
|
2012-10-09 18:25:29 -04:00
|
|
|
|
2015-10-13 03:41:46 -04:00
|
|
|
def open?
|
2017-07-19 10:03:50 -04:00
|
|
|
opened?
|
2015-10-13 03:41:46 -04:00
|
|
|
end
|
|
|
|
|
2018-07-16 09:35:19 -04:00
|
|
|
def overdue?
|
|
|
|
return false unless respond_to?(:due_date)
|
|
|
|
|
|
|
|
due_date.try(:past?) || false
|
|
|
|
end
|
|
|
|
|
2016-06-01 05:23:09 -04:00
|
|
|
def user_notes_count
|
2016-06-06 16:19:39 -04:00
|
|
|
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
|
2016-06-01 05:23:09 -04:00
|
|
|
end
|
|
|
|
|
2016-10-31 15:19:14 -04:00
|
|
|
def subscribed_without_subscriptions?(user, project)
|
2021-04-06 20:09:26 -04:00
|
|
|
participant?(user)
|
2016-03-01 11:33:13 -05:00
|
|
|
end
|
|
|
|
|
2020-06-09 14:08:28 -04:00
|
|
|
def can_assign_epic?(user)
|
|
|
|
false
|
|
|
|
end
|
|
|
|
|
2017-11-21 12:13:07 -05:00
|
|
|
def to_hook_data(user, old_associations: {})
|
2017-09-15 13:08:27 -04:00
|
|
|
changes = previous_changes
|
2017-09-19 13:23:15 -04:00
|
|
|
|
2019-01-25 10:41:21 -05:00
|
|
|
if old_associations
|
2020-06-26 14:09:03 -04:00
|
|
|
old_labels = old_associations.fetch(:labels, labels)
|
|
|
|
old_assignees = old_associations.fetch(:assignees, assignees)
|
2021-07-01 14:07:29 -04:00
|
|
|
old_severity = old_associations.fetch(:severity, severity)
|
2017-09-15 13:08:27 -04:00
|
|
|
|
2019-01-25 10:41:21 -05:00
|
|
|
if old_labels != labels
|
|
|
|
changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
|
2017-09-19 13:23:15 -04:00
|
|
|
end
|
2015-10-17 18:11:36 -04:00
|
|
|
|
2019-01-25 10:41:21 -05:00
|
|
|
if old_assignees != assignees
|
2019-04-07 14:35:16 -04:00
|
|
|
changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
|
2019-01-25 10:41:21 -05:00
|
|
|
end
|
|
|
|
|
2021-07-01 14:07:29 -04:00
|
|
|
if supports_severity? && old_severity != severity
|
|
|
|
changes[:severity] = [old_severity, severity]
|
|
|
|
end
|
|
|
|
|
2019-01-25 10:41:21 -05:00
|
|
|
if self.respond_to?(:total_time_spent)
|
2020-06-26 14:09:03 -04:00
|
|
|
old_total_time_spent = old_associations.fetch(:total_time_spent, total_time_spent)
|
2021-06-11 05:09:58 -04:00
|
|
|
old_time_change = old_associations.fetch(:time_change, time_change)
|
2017-11-21 12:13:07 -05:00
|
|
|
|
2019-01-25 10:41:21 -05:00
|
|
|
if old_total_time_spent != total_time_spent
|
|
|
|
changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
|
2021-06-11 05:09:58 -04:00
|
|
|
changes[:time_change] = [old_time_change, time_change]
|
2019-01-25 10:41:21 -05:00
|
|
|
end
|
2017-11-21 12:13:07 -05:00
|
|
|
end
|
2017-11-14 12:55:00 -05:00
|
|
|
end
|
|
|
|
|
2017-09-19 13:23:15 -04:00
|
|
|
Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes)
|
2013-12-03 04:31:56 -05:00
|
|
|
end
|
2014-07-30 10:17:29 -04:00
|
|
|
|
2016-06-02 07:17:54 -04:00
|
|
|
def labels_array
|
|
|
|
labels.to_a
|
|
|
|
end
|
|
|
|
|
2014-07-30 10:17:29 -04:00
|
|
|
def label_names
|
|
|
|
labels.order('title ASC').pluck(:title)
|
|
|
|
end
|
|
|
|
|
2015-07-24 15:31:15 -04:00
|
|
|
# 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-10-26 13:34:06 -04:00
|
|
|
self.class.to_ability_name
|
2015-07-24 15:31:15 -04:00
|
|
|
end
|
|
|
|
|
2015-12-24 17:03:54 -05:00
|
|
|
# Returns a Hash of attributes to be used for Twitter card metadata
|
|
|
|
def card_attributes
|
|
|
|
{
|
|
|
|
'Author' => author.try(:name),
|
2019-04-07 14:35:16 -04:00
|
|
|
'Assignee' => assignee_list
|
2015-12-24 17:03:54 -05:00
|
|
|
}
|
|
|
|
end
|
|
|
|
|
2019-04-07 14:35:16 -04:00
|
|
|
def assignee_list
|
|
|
|
assignees.map(&:name).to_sentence
|
|
|
|
end
|
|
|
|
|
|
|
|
def assignee_username_list
|
|
|
|
assignees.map(&:username).to_sentence
|
|
|
|
end
|
|
|
|
|
2015-10-13 05:49:01 -04:00
|
|
|
def notes_with_associations
|
2016-06-06 16:19:39 -04:00
|
|
|
# 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-06-21 01:26:43 -04:00
|
|
|
includes = []
|
|
|
|
includes << :author unless notes.authors_loaded?
|
|
|
|
includes << :award_emoji unless notes.award_emojis_loaded?
|
2021-12-15 10:15:54 -05:00
|
|
|
includes << :project unless notes.projects_loaded?
|
2021-12-17 19:14:00 -05:00
|
|
|
includes << :system_note_metadata unless notes.system_note_metadata_loaded?
|
2018-01-11 11:34:01 -05:00
|
|
|
|
2016-06-21 01:26:43 -04:00
|
|
|
if includes.any?
|
|
|
|
notes.includes(includes)
|
|
|
|
else
|
|
|
|
notes
|
|
|
|
end
|
2015-10-13 05:49:01 -04:00
|
|
|
end
|
|
|
|
|
2015-10-22 11:18:59 -04:00
|
|
|
def updated_tasks
|
|
|
|
Taskable.get_updated_tasks(old_content: previous_changes['description'].first,
|
|
|
|
new_content: description)
|
2014-11-05 15:45:18 -05:00
|
|
|
end
|
2016-03-18 09:48:55 -04:00
|
|
|
|
|
|
|
##
|
|
|
|
# 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-20 08:43:11 -04:00
|
|
|
|
2017-08-15 09:21:27 -04:00
|
|
|
##
|
|
|
|
# Override in issuable specialization
|
|
|
|
#
|
2017-08-29 09:46:40 -04:00
|
|
|
def first_contribution?
|
2017-08-15 09:21:27 -04:00
|
|
|
false
|
2017-07-29 11:04:42 -04:00
|
|
|
end
|
2017-11-21 14:25:37 -05:00
|
|
|
|
2017-12-07 12:41:30 -05:00
|
|
|
def ensure_metrics
|
|
|
|
self.metrics || create_metrics
|
|
|
|
end
|
|
|
|
|
2017-11-21 14:25:37 -05:00
|
|
|
##
|
2018-10-30 06:53:01 -04:00
|
|
|
# Overridden in MergeRequest
|
2017-11-21 14:25:37 -05:00
|
|
|
#
|
|
|
|
def wipless_title_changed(old_title)
|
|
|
|
old_title != title
|
|
|
|
end
|
2012-08-08 21:26:56 -04:00
|
|
|
end
|
2019-09-13 09:26:31 -04:00
|
|
|
|
2021-05-11 17:10:21 -04:00
|
|
|
Issuable.prepend_mod_with('Issuable')
|