issuable.rb 12.7 KB
Newer Older
1 2
# frozen_string_literal: true

3
# == Issuable concern
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
4
#
5
# Contains common functionality shared between Issues and MergeRequests
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
6 7 8
#
# Used by Issue, MergeRequest
#
9
module Issuable
10
  extend ActiveSupport::Concern
11
  include Gitlab::SQL::Pattern
12
  include Redactable
13
  include CacheMarkdownField
14
  include Participable
15
  include Mentionable
16
  include Subscribable
17
  include StripAttribute
18
  include Awardable
19
  include Taskable
20
  include Importable
21
  include Editable
22
  include AfterCommitQueue
23 24
  include Sortable
  include CreatedAtFilterable
25
  include UpdatedAtFilterable
26

27
  # This object is used to gather issuable meta data for displaying
28
  # upvotes, downvotes, notes and closing merge requests count for issues and merge requests
29
  # lists avoiding n+1 queries and improving performance.
30
  IssuableMeta = Struct.new(:upvotes, :downvotes, :notes_count, :merge_requests_count)
31

32
  included do
33
    cache_markdown_field :title, pipeline: :single_line
34
    cache_markdown_field :description, issuable_state_filter_enabled: true
35

36 37
    redact_field :description

38
    belongs_to :author, class_name: "User"
39
    belongs_to :updated_by, class_name: "User"
40
    belongs_to :last_edited_by, class_name: 'User'
41
    belongs_to :milestone
Felipe Artur's avatar
Felipe Artur committed
42

43
    has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent
44
      def authors_loaded?
45
        # We check first if we're loaded to not load unnecessarily.
46 47
        loaded? && to_a.all? { |note| note.association(:author).loaded? }
      end
48 49 50 51 52

      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
53
    end
Timothy Andrew's avatar
Timothy Andrew committed
54

55
    has_many :label_links, as: :target, dependent: :destroy, inverse_of: :target # rubocop:disable Cop/ActiveRecordDependent
56
    has_many :labels, through: :label_links
57
    has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
58

59 60
    has_one :metrics

Douwe Maan's avatar
Douwe Maan committed
61 62
    delegate :name,
             :email,
63
             :public_email,
Douwe Maan's avatar
Douwe Maan committed
64
             to: :author,
65
             allow_nil: true,
Douwe Maan's avatar
Douwe Maan committed
66 67 68 69
             prefix: true

    delegate :name,
             :email,
70
             :public_email,
Douwe Maan's avatar
Douwe Maan committed
71 72 73 74
             to: :assignee,
             allow_nil: true,
             prefix: true

Andrey Kumanyaev's avatar
Andrey Kumanyaev committed
75
    validates :author, presence: true
76
    validates :title, presence: true, length: { maximum: 255 }
77
    validate :milestone_is_valid
78

79
    scope :authored, ->(user) { where(author_id: user) }
80
    scope :recent, -> { reorder(id: :desc) }
81
    scope :of_projects, ->(ids) { where(project_id: ids) }
82
    scope :of_milestones, ->(ids) { where(milestone_id: ids) }
83
    scope :any_milestone, -> { where('milestone_id IS NOT NULL') }
84
    scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
85
    scope :opened, -> { with_state(:opened) }
86
    scope :only_opened, -> { with_state(:opened) }
87
    scope :closed, -> { with_state(:closed) }
88

89
    scope :left_joins_milestones,    -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
90 91
    scope :order_milestone_due_desc, -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date DESC') }
    scope :order_milestone_due_asc,  -> { left_joins_milestones.reorder('milestones.due_date IS NULL, milestones.id IS NULL, milestones.due_date ASC') }
92

93
    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 }) }
94
    scope :any_label, -> { joins(:label_links).group(:id) }
95
    scope :join_project, -> { joins(:project) }
96
    scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
97
    scope :references_project, -> { references(:project) }
98
    scope :non_archived, -> { join_project.where(projects: { archived: false }) }
99

100
    attr_mentionable :title, pipeline: :single_line
Yorick Peterse's avatar
Yorick Peterse committed
101 102 103 104 105
    attr_mentionable :description

    participant :author
    participant :notes_with_associations

106
    strip_attributes :title
107

108 109 110 111 112
    # 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?
      title_changed? || description_changed?
    end
113 114 115 116 117 118

    def allows_multiple_assignees?
      false
    end

    def has_multiple_assignees?
119
      assignees.count > 1
120
    end
121 122 123 124 125 126 127 128 129 130

    def milestone_available?
      project_id == milestone&.project_id || project.ancestors_upto.compact.include?(milestone&.group)
    end

    private

    def milestone_is_valid
      errors.add(:milestone_id, message: "is invalid") if milestone_id.present? && !milestone_available?
    end
131 132
  end

133
  class_methods do
134 135 136 137 138 139 140
    # 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.
141
    def search(query)
142
      fuzzy_search(query, [:title])
143
    end
144

145 146 147 148 149
    # 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
150
    # matched_columns - Modify the scope of the query. 'title', 'description' or joining them with a comma.
151 152
    #
    # Returns an ActiveRecord::Relation.
153 154 155 156 157 158 159 160 161
    def full_search(query, matched_columns: 'title,description')
      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?

      fuzzy_search(query, matched_columns)
162 163
    end

164
    def sort_by_attribute(method, excluded_labels: [])
165 166
      sorted =
        case method.to_s
167 168 169 170 171 172 173 174 175 176
        when 'downvotes_desc'                       then order_downvotes_desc
        when 'label_priority'                       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', 'popularity_desc'        then order_upvotes_desc
        when 'popularity_asc'                       then order_upvotes_asc
        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)
        when 'upvotes_desc'                         then order_upvotes_desc
177 178
        else order_by(method)
        end
179 180

      # Break ties with the ID column for pagination
181
      sorted.with_order_id_desc
182
    end
183

184
    def order_due_date_and_labels_priority(direction = 'ASC', excluded_labels: [])
185 186 187 188 189 190 191 192 193 194 195 196 197 198
      # 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)'

199 200
      order_milestone_due_asc
        .order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date])
201 202
        .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, direction),
                Gitlab::Database.nulls_last_order('highest_priority', direction))
203 204
    end

205
    def order_labels_priority(direction = 'ASC', excluded_labels: [], extra_select_columns: [])
206 207 208
      params = {
        target_type: name,
        target_column: "#{table_name}.id",
209
        project_column: "#{table_name}.#{project_foreign_key}",
210 211 212 213
        excluded_labels: excluded_labels
      }

      highest_priority = highest_label_priority(params).to_sql
Felipe Artur's avatar
Felipe Artur committed
214

215 216 217 218 219
      select_columns = [
        "#{table_name}.*",
        "(#{highest_priority}) AS highest_priority"
      ] + extra_select_columns

220 221
      select(select_columns.join(', '))
        .group(arel_table[:id])
222
        .reorder(Gitlab::Database.nulls_last_order('highest_priority', direction))
223 224
    end

225
    def with_label(title, sort = nil)
226
      if title.is_a?(Array) && title.size > 1
Felipe Artur's avatar
Felipe Artur committed
227
        joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}")
228 229 230 231
      else
        joins(:labels).where(labels: { title: title })
      end
    end
232 233 234 235 236

    # Includes table keys in group by clause when sorting
    # preventing errors in postgres
    #
    # Returns an array of arel columns
Felipe Artur's avatar
Felipe Artur committed
237 238
    def grouping_columns(sort)
      grouping_columns = [arel_table[:id]]
239

240
      if %w(milestone_due_desc milestone_due_asc milestone).include?(sort)
241
        milestone_table = Milestone.arel_table
Felipe Artur's avatar
Felipe Artur committed
242 243
        grouping_columns << milestone_table[:id]
        grouping_columns << milestone_table[:due_date]
244 245
      end

Felipe Artur's avatar
Felipe Artur committed
246
      grouping_columns
247
    end
248 249 250 251

    def to_ability_name
      model_name.singular
    end
Jan Provaznik's avatar
Jan Provaznik committed
252 253 254 255

    def parent_class
      ::Project
    end
256 257 258 259 260 261 262 263 264
  end

  def today?
    Date.today == created_at.to_date
  end

  def new?
    today? && created_at == updated_at
  end
265

266
  def open?
267
    opened?
268 269
  end

270 271 272 273 274 275
  def overdue?
    return false unless respond_to?(:due_date)

    due_date.try(:past?) || false
  end

Z.J. van de Weg's avatar
Z.J. van de Weg committed
276
  def user_notes_count
277 278 279 280 281 282 283
    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
Z.J. van de Weg's avatar
Z.J. van de Weg committed
284 285
  end

286
  def subscribed_without_subscriptions?(user, project)
287 288 289
    participants(user).include?(user)
  end

290
  def to_hook_data(user, old_associations: {})
291
    changes = previous_changes
292

293 294 295
    if old_associations
      old_labels = old_associations.fetch(:labels, [])
      old_assignees = old_associations.fetch(:assignees, [])
296

297 298
      if old_labels != labels
        changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
299
      end
300

301 302 303 304 305 306 307 308 309 310
      if old_assignees != assignees
        if self.is_a?(Issue)
          changes[:assignees] = [old_assignees.map(&:hook_attrs), assignees.map(&:hook_attrs)]
        else
          changes[:assignee] = [old_assignees&.first&.hook_attrs, assignee&.hook_attrs]
        end
      end

      if self.respond_to?(:total_time_spent)
        old_total_time_spent = old_associations.fetch(:total_time_spent, nil)
311

312 313 314
        if old_total_time_spent != total_time_spent
          changes[:total_time_spent] = [old_total_time_spent, total_time_spent]
        end
315
      end
316 317
    end

318
    Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes)
319
  end
320

321 322 323 324
  def labels_array
    labels.to_a
  end

325 326 327 328
  def label_names
    labels.order('title ASC').pluck(:title)
  end

329 330 331 332 333 334 335
  # 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
336
    self.class.to_ability_name
337 338
  end

339 340 341 342 343 344 345 346
  # Returns a Hash of attributes to be used for Twitter card metadata
  def card_attributes
    {
      'Author'   => author.try(:name),
      'Assignee' => assignee.try(:name)
    }
  end

347
  def notes_with_associations
348 349 350 351 352 353
    # 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.
354 355 356
    includes = []
    includes << :author unless notes.authors_loaded?
    includes << :award_emoji unless notes.award_emojis_loaded?
357

358 359 360 361 362
    if includes.any?
      notes.includes(includes)
    else
      notes
    end
363 364
  end

365 366 367
  def updated_tasks
    Taskable.get_updated_tasks(old_content: previous_changes['description'].first,
                               new_content: description)
368
  end
369 370 371 372 373 374 375 376 377

  ##
  # Method that checks if issuable can be moved to another project.
  #
  # Should be overridden if issuable can be moved.
  #
  def can_move?(*)
    false
  end
378

379 380 381
  ##
  # Override in issuable specialization
  #
micael.bergeron's avatar
micael.bergeron committed
382
  def first_contribution?
383
    false
384
  end
385

386 387 388 389
  def ensure_metrics
    self.metrics || create_metrics
  end

390
  ##
391
  # Overridden in MergeRequest
392 393 394 395
  #
  def wipless_title_changed(old_title)
    old_title != title
  end
396
end