note.rb 16 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.
6
class Note < ApplicationRecord
7
  extend ActiveModel::Naming
8
  include Participable
9
  include Mentionable
10
  include Awardable
11
  include Importable
12
  include FasterCacheKeys
13
  include Redactable
Nick Thomas's avatar
Nick Thomas committed
14
  include CacheMarkdownField
15
  include AfterCommitQueue
16
  include ResolvableNote
17
  include Editable
18
  include Gitlab::SQL::Pattern
19
  include ThrottledTouch
20
  include FromUnion
21

22
23
24
25
26
  module SpecialRole
    FIRST_TIME_CONTRIBUTOR = :first_time_contributor

    class << self
      def values
27
        constants.map {|const| self.const_get(const, false)}
28
      end
29
30
31
32

      def value?(val)
        values.include?(val)
      end
33
34
35
    end
  end

36
  cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
37

38
39
  redact_field :note

40
41
42
43
  TYPES_RESTRICTED_BY_ABILITY = {
    branch: :download_code
  }.freeze

blackst0ne's avatar
blackst0ne committed
44
  # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes.
45
  # See https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/10392/diffs#note_28719102
blackst0ne's avatar
blackst0ne committed
46
47
48
  alias_attribute :last_edited_at, :updated_at
  alias_attribute :last_edited_by, :updated_by

Stan Hu's avatar
Stan Hu committed
49
50
  # Attribute containing rendered and redacted Markdown as generated by
  # Banzai::ObjectRenderer.
Nick Thomas's avatar
Nick Thomas committed
51
  attr_accessor :redacted_note_html
52

53
54
55
  # Total of all references as generated by Banzai::ObjectRenderer
  attr_accessor :total_reference_count

Stan Hu's avatar
Stan Hu committed
56
  # Number of user visible references as generated by Banzai::ObjectRenderer
57
58
  attr_accessor :user_visible_reference_count

59
  # Attribute used to store the attributes that have been changed by quick actions.
60
61
  attr_accessor :commands_changes

62
63
  # A special role that may be displayed on issuable's discussions
  attr_accessor :special_role
micael.bergeron's avatar
micael.bergeron committed
64

65
66
  default_value_for :system, false

Yorick Peterse's avatar
Yorick Peterse committed
67
  attr_mentionable :note, pipeline: :note
68
  participant :author
69

gitlabhq's avatar
gitlabhq committed
70
  belongs_to :project
71
  belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
Nihad Abbasov's avatar
Nihad Abbasov committed
72
  belongs_to :author, class_name: "User"
73
  belongs_to :updated_by, class_name: "User"
74
  belongs_to :last_edited_by, class_name: 'User'
gitlabhq's avatar
gitlabhq committed
75

76
  has_many :todos
77
78
79
80
81
82

  # The delete_all definition is required here in order
  # to generate the correct DELETE sql for
  # suggestions.delete_all calls
  has_many :suggestions, -> { order(:relative_order) },
    inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
83
  has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
84
  has_one :system_note_metadata
85
  has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
86

87
  delegate :gfm_reference, :local_reference, to: :noteable
Nihad Abbasov's avatar
Nihad Abbasov committed
88
89
  delegate :name, to: :project, prefix: true
  delegate :name, :email, to: :author, prefix: true
90
  delegate :title, to: :noteable, allow_nil: true
91

92
  validates :note, presence: true
93
  validates :note, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }
94
  validates :project, presence: true, if: :for_project_noteable?
Zeger-Jan van de Weg's avatar
Zeger-Jan van de Weg committed
95

96
97
  # Attachments are deprecated and are handled by Markdown uploader
  validates :attachment, file_size: { maximum: :max_attachment_size }
gitlabhq's avatar
gitlabhq committed
98

99
  validates :noteable_type, presence: true
100
  validates :noteable_id, presence: true, unless: [:for_commit?, :importing?]
101
  validates :commit_id, presence: true, if: :for_commit?
Valery Sizov's avatar
Valery Sizov committed
102
  validates :author, presence: true
Douwe Maan's avatar
Douwe Maan committed
103
  validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
104

Jan Provaznik's avatar
Jan Provaznik committed
105
  validate unless: [:for_commit?, :importing?, :skip_project_check?] do |note|
106
    unless note.noteable.try(:project) == note.project
Douwe Maan's avatar
Douwe Maan committed
107
      errors.add(:project, 'does not match noteable project')
108
109
110
    end
  end

111
112
  validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?]

113
  # @deprecated attachments are handler by the MarkdownUploader
Nihad Abbasov's avatar
Nihad Abbasov committed
114
  mount_uploader :attachment, AttachmentUploader
Andrey Kumanyaev's avatar
Andrey Kumanyaev committed
115
116

  # Scopes
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
117
  scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) }
118
119
120
121
122
123
124
  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) }
125
  scope :inc_relations_for_view, -> do
126
    includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji,
127
             :system_note_metadata, :note_diff_file, :suggestions)
128
  end
gitlabhq's avatar
gitlabhq committed
129

130
131
132
133
  scope :with_notes_filter, -> (notes_filter) do
    case notes_filter
    when UserPreference::NOTES_FILTERS[:only_comments]
      user
134
135
    when UserPreference::NOTES_FILTERS[:only_activity]
      system
136
137
138
139
140
    else
      all
    end
  end

141
142
143
  scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) }
  scope :new_diff_notes, -> { where(type: 'DiffNote') }
  scope :non_diff_notes, -> { where(type: ['Note', 'DiscussionNote', nil]) }
144

145
  scope :with_associations, -> do
146
147
    # FYI noteable cannot be loaded for LegacyDiffNote for commits
    includes(:author, :noteable, :updated_by,
148
             project: [:project_members, :namespace, { group: [:group_members] }])
149
  end
150
  scope :with_metadata, -> { includes(:system_note_metadata) }
gitlabhq's avatar
gitlabhq committed
151

152
153
154
  scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) }
  scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) }

Douwe Maan's avatar
Douwe Maan committed
155
  after_initialize :ensure_discussion_id
156
  before_validation :nullify_blank_type, :nullify_blank_line_code
Douwe Maan's avatar
Douwe Maan committed
157
  before_validation :set_discussion_id, on: :create
158
159
160
  after_save :keep_around_commit, if: :for_project_noteable?, unless: :importing?
  after_save :expire_etag_cache, unless: :importing?
  after_save :touch_noteable, unless: :importing?
161
  after_destroy :expire_etag_cache
162

163
  class << self
164
165
166
    def model_name
      ActiveModel::Name.new(self, nil, 'note')
    end
167

168
    def discussions(context_noteable = nil)
Douwe Maan's avatar
Douwe Maan committed
169
      Discussion.build_collection(all.includes(:noteable).fresh, context_noteable)
170
    end
171

172
173
    # Note: Where possible consider using Discussion#lazy_find to return
    # Discussions in order to benefit from having records batch loaded.
174
175
    def find_discussion(discussion_id)
      notes = where(discussion_id: discussion_id).fresh.to_a
176

177
178
179
      return if notes.empty?

      Discussion.build(notes)
180
    end
181

Felipe Artur's avatar
Felipe Artur committed
182
183
184
    # Group diff discussions by line code or file path.
    # It is not needed to group by line code when comment is
    # on an image.
185
    def grouped_diff_discussions(diff_refs = nil)
Douwe Maan's avatar
Douwe Maan committed
186
      groups = {}
187
188

      diff_notes.fresh.discussions.each do |discussion|
Felipe Artur's avatar
Felipe Artur committed
189
190
191
192
193
194
195
196
197
        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
198
199
          discussions << discussion
        end
200
201
202
      end

      groups
203
    end
204

205
206
207
208
209
210
    def positions
      where.not(position: nil)
        .select(:id, :type, :position) # ActiveRecord needs id and type for typecasting.
        .map(&:position)
    end

211
    def count_for_collection(ids, type)
212
213
214
      user.select('noteable_id', 'COUNT(*) as count')
        .group(:noteable_id)
        .where(noteable_type: type, noteable_id: ids)
215
    end
216
217
218
219

    def has_special_role?(role, note)
      note.special_role == role
    end
220
221

    def search(query)
222
      fuzzy_search(query, [:note])
223
    end
224
  end
225

226
  # rubocop: disable CodeReuse/ServiceClass
227
  def cross_reference?
228
229
230
231
232
    return unless system?

    if force_cross_reference_regex_check?
      matches_cross_reference_regex?
    else
233
      ::SystemNotes::IssuablesService.cross_reference?(note)
234
    end
235
  end
236
  # rubocop: enable CodeReuse/ServiceClass
237

238
239
  def diff_note?
    false
240
241
  end

242
  def active?
Douwe Maan's avatar
Douwe Maan committed
243
    true
244
245
  end

246
  def max_attachment_size
247
    Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
248
249
  end

250
  def hook_attrs
251
    Gitlab::HookData::NoteBuilder.new(self).build
252
253
  end

254
255
256
257
  def supports_suggestion?
    false
  end

258
259
260
261
  def for_commit?
    noteable_type == "Commit"
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
262
263
264
265
  def for_issue?
    noteable_type == "Issue"
  end

266
267
268
269
  def for_merge_request?
    noteable_type == "MergeRequest"
  end

270
  def for_snippet?
271
272
273
    noteable_type == "Snippet"
  end

274
  def for_personal_snippet?
Jarka Kadlecova's avatar
Jarka Kadlecova committed
275
276
277
    noteable.is_a?(PersonalSnippet)
  end

278
279
280
281
  def for_project_noteable?
    !for_personal_snippet?
  end

282
283
284
285
  def for_issuable?
    for_issue? || for_merge_request?
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
286
  def skip_project_check?
Jan Provaznik's avatar
Jan Provaznik committed
287
    !for_project_noteable?
288
289
  end

290
  def commit
micael.bergeron's avatar
micael.bergeron committed
291
    @commit ||= project.commit(commit_id) if commit_id.present?
292
293
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
294
295
  # override to return commits, which are not active record
  def noteable
micael.bergeron's avatar
micael.bergeron committed
296
297
298
    return commit if for_commit?

    super
299
  rescue
micael.bergeron's avatar
micael.bergeron committed
300
301
    # Temp fix to prevent app crash
    # if note commit id doesn't exist
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
302
    nil
303
  end
304

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

311
  def special_role=(role)
312
    raise "Role is undefined, #{role} not found in #{SpecialRole.values}" unless SpecialRole.value?(role)
313

314
315
316
317
    @special_role = role
  end

  def has_special_role?(role)
318
    self.class.has_special_role?(role, self)
319
320
  end

micael.bergeron's avatar
micael.bergeron committed
321
322
  def specialize_for_first_contribution!(noteable)
    return unless noteable.author_id == self.author_id
micael.bergeron's avatar
micael.bergeron committed
323
324

    self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
micael.bergeron's avatar
micael.bergeron committed
325
  end
micael.bergeron's avatar
micael.bergeron committed
326

327
328
329
330
  def confidential?
    noteable.try(:confidential?)
  end

331
  def editable?
332
    !system?
333
  end
334

335
336
337
338
339
340
341
342
  # Since we're using `updated_at` as `last_edited_at`, it could be touched by transforming / resolving a note.
  # This makes sure it is only marked as edited when the note body is updated.
  def edited?
    return false if updated_by.blank?

    super
  end

343
  def cross_reference_not_visible_for?(user)
344
    cross_reference? && !all_referenced_mentionables_allowed?(user)
345
346
  end

347
  def visible_for?(user)
348
    !cross_reference_not_visible_for?(user) && system_note_viewable_by?(user)
349
350
  end

351
  def award_emoji?
352
    can_be_award_emoji? && contains_emoji_only?
353
354
  end

355
356
357
358
  def emoji_awardable?
    !system?
  end

359
  def can_be_award_emoji?
360
    noteable.is_a?(Awardable) && !part_of_discussion?
361
362
  end

363
  def contains_emoji_only?
364
    note =~ /\A#{Banzai::Filter::EmojiFilter.emoji_pattern}\s?\Z/
365
366
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
367
  def to_ability_name
368
369
370
371
    model_name.singular
  end

  def noteable_ability_name
372
    for_snippet? ? noteable.class.name.underscore : noteable_type.demodulize.underscore
Jarka Kadlecova's avatar
Jarka Kadlecova committed
373
374
  end

375
  def can_be_discussion_note?
376
    self.noteable.supports_discussions? && !part_of_discussion?
377
378
  end

Jan Provaznik's avatar
Jan Provaznik committed
379
380
381
382
383
  def can_create_todo?
    # Skip system notes, and notes on project snippet
    !system? && !for_snippet?
  end

384
385
  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
386
387
    # displayed in one discussion instead of individually.
    # See also `#discussion_id` and `Discussion.override_discussion_id`.
Douwe Maan's avatar
Douwe Maan committed
388
389
    if noteable && noteable != self.noteable
      OutOfContextDiscussion
390
391
392
393
394
    else
      IndividualNoteDiscussion
    end
  end

Douwe Maan's avatar
Douwe Maan committed
395
  # See `Discussion.override_discussion_id` for details.
396
397
398
399
  def discussion_id(noteable = nil)
    discussion_class(noteable).override_discussion_id(self) || super()
  end

Douwe Maan's avatar
Douwe Maan committed
400
401
402
403
  # 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.
404
405
406
407
  def to_discussion(noteable = nil)
    Discussion.build([self], noteable)
  end

Douwe Maan's avatar
Douwe Maan committed
408
409
410
  # 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.
411
  def discussion
Douwe Maan's avatar
Douwe Maan committed
412
413
    full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
    full_discussion || to_discussion
414
415
  end

416
417
418
419
  def start_of_discussion?
    discussion.first_note == self
  end

420
  def part_of_discussion?
Douwe Maan's avatar
Douwe Maan committed
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
    !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
439
440
  end

441
442
443
  def references
    refs = [noteable]

444
    if part_of_discussion?
445
      refs += discussion.notes.take_while { |n| n.id < id }
446
447
    end

448
    refs
449
450
  end

451
  def expire_etag_cache
452
    noteable&.expire_note_etag_cache
453
454
  end

455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
  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
485

486
    # We return the noteable object so we can re-use it in EE for Elasticsearch.
487
    noteable_object
488
489
  end

490
  def banzai_render_context(field)
491
    super.merge(noteable: noteable, system_note: system?)
492
493
  end

Sean McGivern's avatar
Sean McGivern committed
494
495
496
497
  def retrieve_upload(_identifier, paths)
    Upload.find_by(model: self, path: paths)
  end

498
  def resource_parent
499
500
501
    project
  end

502
503
504
505
  def user_mentions
    noteable.user_mentions.where(note: self)
  end

506
507
  private

508
509
510
511
512
513
  # Using this method followed by a call to `save` may result in ActiveRecord::RecordNotUnique exception
  # in a multithreaded environment. Make sure to use it within a `safe_ensure_unique` block.
  def model_user_mention
    user_mentions.first_or_initialize
  end

514
515
516
517
518
519
520
521
522
  def system_note_viewable_by?(user)
    return true unless system_note_metadata

    restriction = TYPES_RESTRICTED_BY_ABILITY[system_note_metadata.action.to_sym]
    return Ability.allowed?(user, restriction, project) if restriction

    true
  end

523
524
525
  def keep_around_commit
    project.repository.keep_around(self.commit_id)
  end
526
527
528
529
530
531
532
533

  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
534
535
536

  def ensure_discussion_id
    return unless self.persisted?
537
538
    # Needed in case the SELECT statement doesn't ask for `discussion_id`
    return unless self.has_attribute?(:discussion_id)
539
540
541
542
543
544
545
    return if self.discussion_id

    set_discussion_id
    update_column(:discussion_id, self.discussion_id)
  end

  def set_discussion_id
546
    self.discussion_id ||= discussion_class.discussion_id(self)
547
  end
548

549
550
551
552
553
  def all_referenced_mentionables_allowed?(user)
    if user_visible_reference_count.present? && total_reference_count.present?
      # if they are not equal, then there are private/confidential references as well
      user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count
    else
554
555
      refs = all_references(user)
      refs.all.any? && refs.stateful_not_visible_counter == 0
556
557
558
    end
  end

559
560
561
  def force_cross_reference_regex_check?
    return unless system?

562
    system_note_metadata&.cross_reference_types&.include?(system_note_metadata&.action)
563
  end
564
565
566
567
568
569

  def does_not_exceed_notes_limit?
    return unless noteable

    errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT
  end
gitlabhq's avatar
gitlabhq committed
570
end
571
572

Note.prepend_if_ee('EE::Note')