issuable.rb 11.4 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

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

88
    scope :left_joins_milestones,    -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") }
89
90
    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') }
91

92
    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 }) }
93
    scope :join_project, -> { joins(:project) }
Douwe Maan's avatar
Douwe Maan committed
94
    scope :inc_notes_with_associations, -> { includes(notes: [:project, :author, :award_emoji]) }
95
    scope :references_project, -> { references(:project) }
96
    scope :non_archived, -> { join_project.where(projects: { archived: false }) }
97

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

    participant :author
    participant :notes_with_associations

104
    strip_attributes :title
105

106
107
108
109
110
    # 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
111
112
113
114
115
116

    def allows_multiple_assignees?
      false
    end

    def has_multiple_assignees?
117
      assignees.count > 1
118
    end
119
120
  end

121
  class_methods do
122
123
124
125
126
127
128
    # 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.
129
    def search(query)
130
      fuzzy_search(query, [:title])
131
    end
132

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

144
    def sort_by_attribute(method, excluded_labels: [])
145
146
147
148
149
150
151
152
153
154
155
156
      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
157
158

      # Break ties with the ID column for pagination
159
      sorted.with_order_id_desc
160
    end
161

162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
    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)'

177
178
179
      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'),
180
181
182
183
                Gitlab::Database.nulls_last_order('highest_priority', 'ASC'))
    end

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

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

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

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

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

    # 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
215
216
    def grouping_columns(sort)
      grouping_columns = [arel_table[:id]]
217

218
      if %w(milestone_due_desc milestone_due_asc milestone).include?(sort)
219
        milestone_table = Milestone.arel_table
Felipe Artur's avatar
Felipe Artur committed
220
221
        grouping_columns << milestone_table[:id]
        grouping_columns << milestone_table[:due_date]
222
223
      end

Felipe Artur's avatar
Felipe Artur committed
224
      grouping_columns
225
    end
226
227
228
229

    def to_ability_name
      model_name.singular
    end
Jan Provaznik's avatar
Jan Provaznik committed
230
231
232
233

    def parent_class
      ::Project
    end
234
235
236
237
238
239
240
241
242
  end

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

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

244
  def open?
245
    opened?
246
247
  end

248
249
250
251
252
253
  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
254
  def user_notes_count
255
256
257
258
259
260
261
    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
262
263
  end

264
  def subscribed_without_subscriptions?(user, project)
265
266
267
    participants(user).include?(user)
  end

268
  def to_hook_data(user, old_associations: {})
269
    changes = previous_changes
270
271
    old_labels = old_associations.fetch(:labels, [])
    old_assignees = old_associations.fetch(:assignees, [])
272

273
    if old_labels != labels
274
      changes[:labels] = [old_labels.map(&:hook_attrs), labels.map(&:hook_attrs)]
275
276
    end

277
278
279
280
281
282
    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
283
    end
284

285
286
287
288
289
290
    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
291
292
    end

293
    Gitlab::HookData::IssuableBuilder.new(self).build(user: user, changes: changes)
294
  end
295

296
297
298
299
  def labels_array
    labels.to_a
  end

300
301
302
303
  def label_names
    labels.order('title ASC').pluck(:title)
  end

Robert Speicher's avatar
Robert Speicher committed
304
305
306
307
308
309
310
  # 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
311
    self.class.to_ability_name
Robert Speicher's avatar
Robert Speicher committed
312
313
  end

314
315
316
317
318
319
320
321
  # Returns a Hash of attributes to be used for Twitter card metadata
  def card_attributes
    {
      'Author'   => author.try(:name),
      'Assignee' => assignee.try(:name)
    }
  end

322
  def notes_with_associations
323
324
325
326
327
328
    # 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.
329
330
331
    includes = []
    includes << :author unless notes.authors_loaded?
    includes << :award_emoji unless notes.award_emojis_loaded?
332

333
334
335
336
337
    if includes.any?
      notes.includes(includes)
    else
      notes
    end
338
339
  end

340
341
342
  def updated_tasks
    Taskable.get_updated_tasks(old_content: previous_changes['description'].first,
                               new_content: description)
343
  end
344
345
346
347
348
349
350
351
352

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

micael.bergeron's avatar
micael.bergeron committed
354
355
356
  ##
  # Override in issuable specialization
  #
micael.bergeron's avatar
micael.bergeron committed
357
  def first_contribution?
micael.bergeron's avatar
micael.bergeron committed
358
    false
359
  end
360

361
362
363
364
  def ensure_metrics
    self.metrics || create_metrics
  end

365
  ##
366
  # Overridden in MergeRequest
367
368
369
370
  #
  def wipless_title_changed(old_title)
    old_title != title
  end
371
end