issuable.rb 10.7 KB
Newer Older
1
# == Issuable concern
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
2
#
3
# Contains common functionality shared between Issues and MergeRequests
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
4 5 6
#
# Used by Issue, MergeRequest
#
7
module Issuable
8
  extend ActiveSupport::Concern
9
  include Gitlab::SQL::Pattern
10
  include CacheMarkdownField
11
  include Participable
12
  include Mentionable
13
  include Subscribable
14
  include StripAttribute
15
  include Awardable
16
  include Taskable
17
  include TimeTrackable
18
  include Importable
19
  include Editable
20
  include AfterCommitQueue
21

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

27
  included do
28
    cache_markdown_field :title, pipeline: :single_line
29
    cache_markdown_field :description, issuable_state_filter_enabled: true
30

31
    belongs_to :author, class_name: "User"
32
    belongs_to :updated_by, class_name: "User"
33
    belongs_to :last_edited_by, class_name: 'User'
34
    belongs_to :milestone
Felipe Artur's avatar
Felipe Artur committed
35

36
    has_many :notes, as: :noteable, inverse_of: :noteable, dependent: :destroy do # rubocop:disable Cop/ActiveRecordDependent
37
      def authors_loaded?
38
        # We check first if we're loaded to not load unnecessarily.
39 40
        loaded? && to_a.all? { |note| note.association(:author).loaded? }
      end
41 42 43 44 45

      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
46
    end
Timothy Andrew's avatar
Timothy Andrew committed
47

48
    has_many :label_links, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
49
    has_many :labels, through: :label_links
50
    has_many :todos, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
51

52 53
    has_one :metrics

Douwe Maan's avatar
Douwe Maan committed
54 55
    delegate :name,
             :email,
56
             :public_email,
Douwe Maan's avatar
Douwe Maan committed
57
             to: :author,
58
             allow_nil: true,
Douwe Maan's avatar
Douwe Maan committed
59 60 61 62
             prefix: true

    delegate :name,
             :email,
63
             :public_email,
Douwe Maan's avatar
Douwe Maan committed
64 65 66 67
             to: :assignee,
             allow_nil: true,
             prefix: true

Andrey Kumanyaev's avatar
Andrey Kumanyaev committed
68
    validates :author, presence: true
69
    validates :title, presence: true, length: { maximum: 255 }
70

71
    scope :authored, ->(user) { where(author_id: user) }
72
    scope :recent, -> { reorder(id: :desc) }
73
    scope :of_projects, ->(ids) { where(project_id: ids) }
74
    scope :of_milestones, ->(ids) { where(milestone_id: ids) }
75
    scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) }
76
    scope :opened, -> { with_state(:opened) }
77
    scope :only_opened, -> { with_state(:opened) }
78
    scope :closed, -> { with_state(:closed) }
79

80
    scope :left_joins_milestones,    -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
81 82
    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') }
83

84
    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 }) }
85
    scope :join_project, -> { joins(:project) }
86
    scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
87
    scope :references_project, -> { references(:project) }
88
    scope :non_archived, -> { join_project.where(projects: { archived: false }) }
89

90
    attr_mentionable :title, pipeline: :single_line
Yorick Peterse's avatar
Yorick Peterse committed
91 92 93 94 95
    attr_mentionable :description

    participant :author
    participant :notes_with_associations

96
    strip_attributes :title
97 98

    acts_as_paranoid
99

100
    after_save :record_metrics, unless: :imported?
101

102 103 104 105 106
    # 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
107 108 109 110 111 112

    def allows_multiple_assignees?
      false
    end

    def has_multiple_assignees?
113
      assignees.count > 1
114
    end
115 116
  end

117
  module ClassMethods
118 119 120 121 122 123 124
    # 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.
125
    def search(query)
126 127 128
      title = to_fuzzy_arel(:title, query)

      where(title)
129
    end
130

131 132 133 134 135 136 137
    # 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
    #
    # Returns an ActiveRecord::Relation.
138
    def full_search(query)
139 140
      title = to_fuzzy_arel(:title, query)
      description = to_fuzzy_arel(:description, query)
141

142
      where(title&.or(description))
143 144
    end

145
    def sort(method, excluded_labels: [])
146 147 148 149 150 151 152 153 154 155 156 157
      sorted =
        case method.to_s
        when 'downvotes_desc'     then order_downvotes_desc
        when 'label_priority'     then order_labels_priority(excluded_labels: excluded_labels)
        when 'milestone'          then order_milestone_due_asc
        when 'milestone_due_asc'  then order_milestone_due_asc
        when 'milestone_due_desc' then order_milestone_due_desc
        when 'popularity'         then order_upvotes_desc
        when 'priority'           then order_due_date_and_labels_priority(excluded_labels: excluded_labels)
        when 'upvotes_desc'       then order_upvotes_desc
        else order_by(method)
        end
158 159 160

      # Break ties with the ID column for pagination
      sorted.order(id: :desc)
161
    end
162

163 164 165 166 167 168 169 170 171 172 173 174 175 176 177
    def order_due_date_and_labels_priority(excluded_labels: [])
      # 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)'

178 179 180
      order_milestone_due_asc
        .order_labels_priority(excluded_labels: excluded_labels, extra_select_columns: [milestones_due_date])
        .reorder(Gitlab::Database.nulls_last_order(milestones_due_date, 'ASC'),
181 182 183 184
                Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
    end

    def order_labels_priority(excluded_labels: [], extra_select_columns: [])
185 186 187
      params = {
        target_type: name,
        target_column: "#{table_name}.id",
188
        project_column: "#{table_name}.#{project_foreign_key}",
189 190 191 192
        excluded_labels: excluded_labels
      }

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

194 195 196 197 198
      select_columns = [
        "#{table_name}.*",
        "(#{highest_priority}) AS highest_priority"
      ] + extra_select_columns

199 200 201
      select(select_columns.join(', '))
        .group(arel_table[:id])
        .reorder(Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
202 203
    end

204
    def with_label(title, sort = nil)
205
      if title.is_a?(Array) && title.size > 1
206
        joins(:labels).where(labels: { title: title }).group(*grouping_columns(sort)).having("COUNT(DISTINCT labels.title) = #{title.size}")
207 208 209 210
      else
        joins(:labels).where(labels: { title: title })
      end
    end
211 212 213 214 215

    # Includes table keys in group by clause when sorting
    # preventing errors in postgres
    #
    # Returns an array of arel columns
216 217
    def grouping_columns(sort)
      grouping_columns = [arel_table[:id]]
218

219
      if %w(milestone_due_desc milestone_due_asc milestone).include?(sort)
220
        milestone_table = Milestone.arel_table
221 222
        grouping_columns << milestone_table[:id]
        grouping_columns << milestone_table[:due_date]
223 224
      end

225
      grouping_columns
226
    end
227 228 229 230

    def to_ability_name
      model_name.singular
    end
231 232 233 234 235 236 237 238 239
  end

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

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

241
  def open?
242
    opened?
243 244
  end

Z.J. van de Weg's avatar
Z.J. van de Weg committed
245
  def user_notes_count
246 247 248 249 250 251 252
    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
253 254
  end

255
  def subscribed_without_subscriptions?(user, project)
256 257 258
    participants(user).include?(user)
  end

Kirill Zaitsev's avatar
Kirill Zaitsev committed
259
  def to_hook_data(user)
260
    hook_data = {
261
      object_kind: self.class.name.underscore,
Kirill Zaitsev's avatar
Kirill Zaitsev committed
262
      user: user.hook_attrs,
263 264
      project: project.hook_attrs,
      object_attributes: hook_attrs,
265
      labels: labels.map(&:hook_attrs),
266 267
      # DEPRECATED
      repository: project.hook_attrs.slice(:name, :url, :description, :homepage)
268
    }
269 270 271 272 273
    if self.is_a?(Issue)
      hook_data[:assignees] = assignees.map(&:hook_attrs) if assignees.any?
    else
      hook_data[:assignee] = assignee.hook_attrs if assignee
    end
274 275

    hook_data
276
  end
277

278 279 280 281
  def labels_array
    labels.to_a
  end

282 283 284 285
  def label_names
    labels.order('title ASC').pluck(:title)
  end

286 287 288 289 290 291 292
  # 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
293
    self.class.to_ability_name
294 295
  end

296 297 298 299 300 301 302 303
  # Returns a Hash of attributes to be used for Twitter card metadata
  def card_attributes
    {
      'Author'   => author.try(:name),
      'Assignee' => assignee.try(:name)
    }
  end

304
  def notes_with_associations
305 306 307 308 309 310
    # 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.
311 312 313 314 315 316 317 318
    includes = []
    includes << :author unless notes.authors_loaded?
    includes << :award_emoji unless notes.award_emojis_loaded?
    if includes.any?
      notes.includes(includes)
    else
      notes
    end
319 320
  end

321 322 323
  def updated_tasks
    Taskable.get_updated_tasks(old_content: previous_changes['description'].first,
                               new_content: description)
324
  end
325 326 327 328 329 330 331 332 333

  ##
  # Method that checks if issuable can be moved to another project.
  #
  # Should be overridden if issuable can be moved.
  #
  def can_move?(*)
    false
  end
334 335 336 337 338

  def record_metrics
    metrics = self.metrics || create_metrics
    metrics.record!
  end
339

340 341 342
  ##
  # Override in issuable specialization
  #
micael.bergeron's avatar
micael.bergeron committed
343
  def first_contribution?
344
    false
345
  end
346
end