Read about our upcoming Code of Conduct on this issue

note.rb 12.9 KB
Newer Older
1
2
# frozen_string_literal: true

3
4
5
# A note on the root of an issue, merge request, commit, or snippet.
#
# A note of this type is never resolvable.
gitlabhq's avatar
gitlabhq committed
6
class Note < ActiveRecord::Base
7
  extend ActiveModel::Naming
8
  include Participable
9
  include Mentionable
ZJ van de Weg's avatar
ZJ van de Weg committed
10
  include Awardable
11
  include Importable
12
  include FasterCacheKeys
Nick Thomas's avatar
Nick Thomas committed
13
  include CacheMarkdownField
14
  include AfterCommitQueue
15
  include ResolvableNote
16
  include IgnorableColumn
17
  include Editable
18
  include Gitlab::SQL::Pattern
19
  include ThrottledTouch
20
  include FromUnion
21

22
23
24
25
26
27
28
29
30
31
  module SpecialRole
    FIRST_TIME_CONTRIBUTOR = :first_time_contributor

    class << self
      def values
        constants.map {|const| self.const_get(const)}
      end
    end
  end

32
  ignore_column :original_discussion_id
Nick Thomas's avatar
Nick Thomas committed
33

34
  cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
35

blackst0ne's avatar
blackst0ne committed
36
37
  # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes.
  # See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/10392/diffs#note_28719102
blackst0ne's avatar
blackst0ne committed
38
39
40
  alias_attribute :last_edited_at, :updated_at
  alias_attribute :last_edited_by, :updated_by

41
42
  # Attribute containing rendered and redacted Markdown as generated by
  # Banzai::ObjectRenderer.
Nick Thomas's avatar
Nick Thomas committed
43
  attr_accessor :redacted_note_html
44

45
46
47
48
  # An Array containing the number of visible references as generated by
  # Banzai::ObjectRenderer
  attr_accessor :user_visible_reference_count

49
  # Attribute used to store the attributes that have been changed by quick actions.
50
51
  attr_accessor :commands_changes

52
53
  # A special role that may be displayed on issuable's discussions
  attr_accessor :special_role
micael.bergeron's avatar
micael.bergeron committed
54

55
56
  default_value_for :system, false

Yorick Peterse's avatar
Yorick Peterse committed
57
  attr_mentionable :note, pipeline: :note
58
  participant :author
59

gitlabhq's avatar
gitlabhq committed
60
  belongs_to :project
61
  belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
Nihad Abbasov's avatar
Nihad Abbasov committed
62
  belongs_to :author, class_name: "User"
63
  belongs_to :updated_by, class_name: "User"
64
  belongs_to :last_edited_by, class_name: 'User'
gitlabhq's avatar
gitlabhq committed
65

66
  has_many :todos
67
  has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
68
  has_one :system_note_metadata
69
  has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
70

71
  delegate :gfm_reference, :local_reference, to: :noteable
Nihad Abbasov's avatar
Nihad Abbasov committed
72
73
  delegate :name, to: :project, prefix: true
  delegate :name, :email, to: :author, prefix: true
74
  delegate :title, to: :noteable, allow_nil: true
75

76
  validates :note, presence: true
77
  validates :project, presence: true, if: :for_project_noteable?
Z.J. van de Weg's avatar
Z.J. van de Weg committed
78

79
80
  # Attachments are deprecated and are handled by Markdown uploader
  validates :attachment, file_size: { maximum: :max_attachment_size }
gitlabhq's avatar
gitlabhq committed
81

82
  validates :noteable_type, presence: true
83
  validates :noteable_id, presence: true, unless: [:for_commit?, :importing?]
84
  validates :commit_id, presence: true, if: :for_commit?
Valery Sizov's avatar
Valery Sizov committed
85
  validates :author, presence: true
Douwe Maan's avatar
Douwe Maan committed
86
  validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
87

Jan Provaznik's avatar
Jan Provaznik committed
88
  validate unless: [:for_commit?, :importing?, :skip_project_check?] do |note|
89
    unless note.noteable.try(:project) == note.project
Douwe Maan's avatar
Douwe Maan committed
90
      errors.add(:project, 'does not match noteable project')
91
92
93
    end
  end

94
  # @deprecated attachments are handler by the MarkdownUploader
Nihad Abbasov's avatar
Nihad Abbasov committed
95
  mount_uploader :attachment, AttachmentUploader
Andrey Kumanyaev's avatar
Andrey Kumanyaev committed
96
97

  # Scopes
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
98
  scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) }
99
100
101
102
103
104
105
  scope :system, -> { where(system: true) }
  scope :user, -> { where(system: false) }
  scope :common, -> { where(noteable_type: ["", nil]) }
  scope :fresh, -> { order(created_at: :asc, id: :asc) }
  scope :updated_after, ->(time) { where('updated_at > ?', time) }
  scope :inc_author_project, -> { includes(:project, :author) }
  scope :inc_author, -> { includes(:author) }
106
  scope :inc_relations_for_view, -> do
107
    includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
108
             :system_note_metadata, :note_diff_file)
109
  end
gitlabhq's avatar
gitlabhq committed
110

111
112
113
  scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) }
  scope :new_diff_notes, -> { where(type: 'DiffNote') }
  scope :non_diff_notes, -> { where(type: ['Note', 'DiscussionNote', nil]) }
114

115
  scope :with_associations, -> do
116
117
    # FYI noteable cannot be loaded for LegacyDiffNote for commits
    includes(:author, :noteable, :updated_by,
Yorick Peterse's avatar
Yorick Peterse committed
118
             project: [:project_members, { group: [:group_members] }])
119
  end
120
  scope :with_metadata, -> { includes(:system_note_metadata) }
gitlabhq's avatar
gitlabhq committed
121

Douwe Maan's avatar
Douwe Maan committed
122
  after_initialize :ensure_discussion_id
123
  before_validation :nullify_blank_type, :nullify_blank_line_code
Douwe Maan's avatar
Douwe Maan committed
124
  before_validation :set_discussion_id, on: :create
125
  after_save :keep_around_commit, if: :for_project_noteable?
126
  after_save :expire_etag_cache
127
  after_save :touch_noteable
128
  after_destroy :expire_etag_cache
129

130
  class << self
131
132
133
    def model_name
      ActiveModel::Name.new(self, nil, 'note')
    end
134

135
    def discussions(context_noteable = nil)
Douwe Maan's avatar
Douwe Maan committed
136
      Discussion.build_collection(all.includes(:noteable).fresh, context_noteable)
137
    end
138

139
140
    def find_discussion(discussion_id)
      notes = where(discussion_id: discussion_id).fresh.to_a
141

142
143
144
      return if notes.empty?

      Discussion.build(notes)
145
    end
146

Felipe Artur's avatar
Felipe Artur committed
147
148
149
    # Group diff discussions by line code or file path.
    # It is not needed to group by line code when comment is
    # on an image.
150
    def grouped_diff_discussions(diff_refs = nil)
Douwe Maan's avatar
Douwe Maan committed
151
      groups = {}
152
153

      diff_notes.fresh.discussions.each do |discussion|
Felipe Artur's avatar
Felipe Artur committed
154
155
156
157
158
159
160
161
162
        group_key =
          if discussion.on_image?
            discussion.file_new_path
          else
            discussion.line_code_in_diffs(diff_refs)
          end

        if group_key
          discussions = groups[group_key] ||= []
Douwe Maan's avatar
Douwe Maan committed
163
164
          discussions << discussion
        end
165
166
167
      end

      groups
168
    end
169
170

    def count_for_collection(ids, type)
171
172
173
      user.select('noteable_id', 'COUNT(*) as count')
        .group(:noteable_id)
        .where(noteable_type: type, noteable_id: ids)
174
    end
175
176
177
178

    def has_special_role?(role, note)
      note.special_role == role
    end
179
180

    def search(query)
181
      fuzzy_search(query, [:note])
182
    end
183
  end
184

185
  # rubocop: disable CodeReuse/ServiceClass
186
  def cross_reference?
187
188
189
190
191
192
193
    return unless system?

    if force_cross_reference_regex_check?
      matches_cross_reference_regex?
    else
      SystemNoteService.cross_reference?(note)
    end
194
  end
195
  # rubocop: enable CodeReuse/ServiceClass
196

197
198
  def diff_note?
    false
199
200
  end

201
  def active?
Douwe Maan's avatar
Douwe Maan committed
202
    true
203
204
  end

205
  def max_attachment_size
206
    Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
207
208
  end

209
  def hook_attrs
210
    Gitlab::HookData::NoteBuilder.new(self).build
211
212
213
214
215
216
  end

  def for_commit?
    noteable_type == "Commit"
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
217
218
219
220
  def for_issue?
    noteable_type == "Issue"
  end

221
222
223
224
  def for_merge_request?
    noteable_type == "MergeRequest"
  end

225
  def for_snippet?
226
227
228
    noteable_type == "Snippet"
  end

229
  def for_personal_snippet?
Jarka Kadlecova's avatar
Jarka Kadlecova committed
230
231
232
    noteable.is_a?(PersonalSnippet)
  end

233
234
235
236
  def for_project_noteable?
    !for_personal_snippet?
  end

237
238
239
240
  def for_issuable?
    for_issue? || for_merge_request?
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
241
  def skip_project_check?
Jan Provaznik's avatar
Jan Provaznik committed
242
    !for_project_noteable?
243
244
  end

245
  def commit
micael.bergeron's avatar
micael.bergeron committed
246
    @commit ||= project.commit(commit_id) if commit_id.present?
247
248
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
249
250
  # override to return commits, which are not active record
  def noteable
micael.bergeron's avatar
micael.bergeron committed
251
252
253
    return commit if for_commit?

    super
254
  rescue
micael.bergeron's avatar
micael.bergeron committed
255
256
    # Temp fix to prevent app crash
    # if note commit id doesn't exist
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
257
    nil
258
  end
259

Andrew8xx8's avatar
Andrew8xx8 committed
260
  # FIXME: Hack for polymorphic associations with STI
Steven Burgart's avatar
Steven Burgart committed
261
  #        For more information visit http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#label-Polymorphic+Associations
262
263
  def noteable_type=(noteable_type)
    super(noteable_type.to_s.classify.constantize.base_class.to_s)
Andrew8xx8's avatar
Andrew8xx8 committed
264
  end
Drew Blessing's avatar
Drew Blessing committed
265

266
  def special_role=(role)
267
268
    raise "Role is undefined, #{role} not found in #{SpecialRole.values}" unless SpecialRole.values.include?(role)

269
270
271
272
    @special_role = role
  end

  def has_special_role?(role)
273
    self.class.has_special_role?(role, self)
274
275
  end

micael.bergeron's avatar
micael.bergeron committed
276
277
  def specialize_for_first_contribution!(noteable)
    return unless noteable.author_id == self.author_id
micael.bergeron's avatar
micael.bergeron committed
278
279

    self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
micael.bergeron's avatar
micael.bergeron committed
280
  end
micael.bergeron's avatar
micael.bergeron committed
281

282
283
284
285
  def confidential?
    noteable.try(:confidential?)
  end

286
  def editable?
287
    !system?
288
  end
289

290
  def cross_reference_not_visible_for?(user)
291
292
293
294
295
296
297
298
299
    cross_reference? && !has_referenced_mentionables?(user)
  end

  def has_referenced_mentionables?(user)
    if user_visible_reference_count.present?
      user_visible_reference_count > 0
    else
      referenced_mentionables(user).any?
    end
300
301
  end

302
  def award_emoji?
303
    can_be_award_emoji? && contains_emoji_only?
304
305
  end

306
307
308
309
  def emoji_awardable?
    !system?
  end

310
  def can_be_award_emoji?
311
    noteable.is_a?(Awardable) && !part_of_discussion?
312
313
  end

314
  def contains_emoji_only?
315
    note =~ /\A#{Banzai::Filter::EmojiFilter.emoji_pattern}\s?\Z/
316
317
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
318
319
320
321
  def to_ability_name
    for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore
  end

322
  def can_be_discussion_note?
323
    self.noteable.supports_discussions? && !part_of_discussion?
324
325
  end

Jan Provaznik's avatar
Jan Provaznik committed
326
327
328
329
330
  def can_create_todo?
    # Skip system notes, and notes on project snippet
    !system? && !for_snippet?
  end

331
332
  def discussion_class(noteable = nil)
    # When commit notes are rendered on an MR's Discussion page, they are
Douwe Maan's avatar
Douwe Maan committed
333
334
    # displayed in one discussion instead of individually.
    # See also `#discussion_id` and `Discussion.override_discussion_id`.
Douwe Maan's avatar
Douwe Maan committed
335
336
    if noteable && noteable != self.noteable
      OutOfContextDiscussion
337
338
339
340
341
    else
      IndividualNoteDiscussion
    end
  end

Douwe Maan's avatar
Douwe Maan committed
342
  # See `Discussion.override_discussion_id` for details.
343
344
345
346
  def discussion_id(noteable = nil)
    discussion_class(noteable).override_discussion_id(self) || super()
  end

Douwe Maan's avatar
Douwe Maan committed
347
348
349
350
  # Returns a discussion containing just this note.
  # This method exists as an alternative to `#discussion` to use when the methods
  # we intend to call on the Discussion object don't require it to have all of its notes,
  # and just depend on the first note or the type of discussion. This saves us a DB query.
351
352
353
354
  def to_discussion(noteable = nil)
    Discussion.build([self], noteable)
  end

Douwe Maan's avatar
Douwe Maan committed
355
356
357
  # Returns the entire discussion this note is part of.
  # Consider using `#to_discussion` if we do not need to render the discussion
  # and all its notes and if we don't care about the discussion's resolvability status.
358
  def discussion
Douwe Maan's avatar
Douwe Maan committed
359
360
    full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
    full_discussion || to_discussion
361
362
363
  end

  def part_of_discussion?
Douwe Maan's avatar
Douwe Maan committed
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
    !to_discussion.individual_note?
  end

  def in_reply_to?(other)
    case other
    when Note
      if part_of_discussion?
        in_reply_to?(other.noteable) && in_reply_to?(other.to_discussion)
      else
        in_reply_to?(other.noteable)
      end
    when Discussion
      self.discussion_id == other.id
    when Noteable
      self.noteable == other
    else
      false
    end
382
383
  end

384
385
386
  def references
    refs = [noteable]

387
    if part_of_discussion?
388
      refs += discussion.notes.take_while { |n| n.id < id }
389
390
    end

391
    refs
392
393
  end

394
  def expire_etag_cache
395
    noteable&.expire_note_etag_cache
396
397
  end

398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
  def touch(*args)
    # We're not using an explicit transaction here because this would in all
    # cases result in all future queries going to the primary, even if no writes
    # are performed.
    #
    # We touch the noteable first so its SELECT query can run before our writes,
    # ensuring it runs on a secondary (if no prior write took place).
    touch_noteable
    super
  end

  # By default Rails will issue an "SELECT *" for the relation, which is
  # overkill for just updating the timestamps. To work around this we manually
  # touch the data so we can SELECT only the columns we need.
  def touch_noteable
    # Commits are not stored in the DB so we can't touch them.
    return if for_commit?

    assoc = association(:noteable)

    noteable_object =
      if assoc.loaded?
        noteable
      else
        # If the object is not loaded (e.g. when notes are loaded async) we
        # _only_ want the data we actually need.
        assoc.scope.select(:id, :updated_at).take
      end

    noteable_object&.touch
428
429
430

    # We return the noteable object so we can re-use it in EE for ElasticSearch.
    noteable_object
431
432
  end

433
434
435
436
  def banzai_render_context(field)
    super.merge(noteable: noteable)
  end

Sean McGivern's avatar
Sean McGivern committed
437
438
439
440
  def retrieve_upload(_identifier, paths)
    Upload.find_by(model: self, path: paths)
  end

441
442
443
444
445
  private

  def keep_around_commit
    project.repository.keep_around(self.commit_id)
  end
446
447
448
449
450
451
452
453

  def nullify_blank_type
    self.type = nil if self.type.blank?
  end

  def nullify_blank_line_code
    self.line_code = nil if self.line_code.blank?
  end
454
455
456

  def ensure_discussion_id
    return unless self.persisted?
457
458
    # Needed in case the SELECT statement doesn't ask for `discussion_id`
    return unless self.has_attribute?(:discussion_id)
459
460
461
462
463
464
465
    return if self.discussion_id

    set_discussion_id
    update_column(:discussion_id, self.discussion_id)
  end

  def set_discussion_id
466
    self.discussion_id ||= discussion_class.discussion_id(self)
467
  end
468
469
470
471
472
473

  def force_cross_reference_regex_check?
    return unless system?

    SystemNoteMetadata::TYPES_WITH_CROSS_REFERENCES.include?(system_note_metadata&.action)
  end
gitlabhq's avatar
gitlabhq committed
474
end