note.rb 14.7 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
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 27 28
  module SpecialRole
    FIRST_TIME_CONTRIBUTOR = :first_time_contributor

    class << self
      def values
        constants.map {|const| self.const_get(const)}
      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

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

45 46
  # Attribute containing rendered and redacted Markdown as generated by
  # Banzai::ObjectRenderer.
47
  attr_accessor :redacted_note_html
48

49 50 51
  # Total of all references as generated by Banzai::ObjectRenderer
  attr_accessor :total_reference_count

52
  # Number of user visible references as generated by Banzai::ObjectRenderer
53 54
  attr_accessor :user_visible_reference_count

55
  # Attribute used to store the attributes that have been changed by quick actions.
56 57
  attr_accessor :commands_changes

58 59
  # A special role that may be displayed on issuable's discussions
  attr_accessor :special_role
micael.bergeron's avatar
micael.bergeron committed
60

61 62
  default_value_for :system, false

Yorick Peterse's avatar
Yorick Peterse committed
63
  attr_mentionable :note, pipeline: :note
64
  participant :author
65

gitlabhq's avatar
gitlabhq committed
66
  belongs_to :project
67
  belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
Nihad Abbasov's avatar
Nihad Abbasov committed
68
  belongs_to :author, class_name: "User"
69
  belongs_to :updated_by, class_name: "User"
70
  belongs_to :last_edited_by, class_name: 'User'
gitlabhq's avatar
gitlabhq committed
71

72
  has_many :todos
73 74 75 76 77 78

  # 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
79
  has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
80
  has_one :system_note_metadata
81
  has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id
82

83
  delegate :gfm_reference, :local_reference, to: :noteable
Nihad Abbasov's avatar
Nihad Abbasov committed
84 85
  delegate :name, to: :project, prefix: true
  delegate :name, :email, to: :author, prefix: true
86
  delegate :title, to: :noteable, allow_nil: true
87

88
  validates :note, presence: true
89
  validates :note, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT }
90
  validates :project, presence: true, if: :for_project_noteable?
Z.J. van de Weg's avatar
Z.J. van de Weg committed
91

92 93
  # Attachments are deprecated and are handled by Markdown uploader
  validates :attachment, file_size: { maximum: :max_attachment_size }
gitlabhq's avatar
gitlabhq committed
94

95
  validates :noteable_type, presence: true
96
  validates :noteable_id, presence: true, unless: [:for_commit?, :importing?]
97
  validates :commit_id, presence: true, if: :for_commit?
Valery Sizov's avatar
Valery Sizov committed
98
  validates :author, presence: true
99
  validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
100

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

107
  # @deprecated attachments are handler by the MarkdownUploader
108
  mount_uploader :attachment, AttachmentUploader
Andrey Kumanyaev's avatar
Andrey Kumanyaev committed
109 110

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

124 125 126 127
  scope :with_notes_filter, -> (notes_filter) do
    case notes_filter
    when UserPreference::NOTES_FILTERS[:only_comments]
      user
128 129
    when UserPreference::NOTES_FILTERS[:only_activity]
      system
130 131 132 133 134
    else
      all
    end
  end

135 136 137
  scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) }
  scope :new_diff_notes, -> { where(type: 'DiffNote') }
  scope :non_diff_notes, -> { where(type: ['Note', 'DiscussionNote', nil]) }
138

139
  scope :with_associations, -> do
140 141
    # FYI noteable cannot be loaded for LegacyDiffNote for commits
    includes(:author, :noteable, :updated_by,
142
             project: [:project_members, :namespace, { group: [:group_members] }])
143
  end
144
  scope :with_metadata, -> { includes(:system_note_metadata) }
gitlabhq's avatar
gitlabhq committed
145

146
  after_initialize :ensure_discussion_id
147
  before_validation :nullify_blank_type, :nullify_blank_line_code
148
  before_validation :set_discussion_id, on: :create
149
  after_save :keep_around_commit, if: :for_project_noteable?
150
  after_save :expire_etag_cache
151
  after_save :touch_noteable
152
  after_destroy :expire_etag_cache
153

154
  class << self
155 156 157
    def model_name
      ActiveModel::Name.new(self, nil, 'note')
    end
158

159
    def discussions(context_noteable = nil)
Douwe Maan's avatar
Douwe Maan committed
160
      Discussion.build_collection(all.includes(:noteable).fresh, context_noteable)
161
    end
162

163 164
    # Note: Where possible consider using Discussion#lazy_find to return
    # Discussions in order to benefit from having records batch loaded.
165 166
    def find_discussion(discussion_id)
      notes = where(discussion_id: discussion_id).fresh.to_a
167

168 169 170
      return if notes.empty?

      Discussion.build(notes)
171
    end
172

Felipe Artur's avatar
Felipe Artur committed
173 174 175
    # Group diff discussions by line code or file path.
    # It is not needed to group by line code when comment is
    # on an image.
176
    def grouped_diff_discussions(diff_refs = nil)
Douwe Maan's avatar
Douwe Maan committed
177
      groups = {}
178 179

      diff_notes.fresh.discussions.each do |discussion|
Felipe Artur's avatar
Felipe Artur committed
180 181 182 183 184 185 186 187 188
        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
189 190
          discussions << discussion
        end
191 192 193
      end

      groups
194
    end
195

196 197 198 199 200 201
    def positions
      where.not(position: nil)
        .select(:id, :type, :position) # ActiveRecord needs id and type for typecasting.
        .map(&:position)
    end

202
    def count_for_collection(ids, type)
203 204 205
      user.select('noteable_id', 'COUNT(*) as count')
        .group(:noteable_id)
        .where(noteable_type: type, noteable_id: ids)
206
    end
207 208 209 210

    def has_special_role?(role, note)
      note.special_role == role
    end
211 212

    def search(query)
213
      fuzzy_search(query, [:note])
214
    end
215
  end
216

217
  # rubocop: disable CodeReuse/ServiceClass
218
  def cross_reference?
219 220 221 222 223
    return unless system?

    if force_cross_reference_regex_check?
      matches_cross_reference_regex?
    else
224
      ::SystemNotes::IssuablesService.cross_reference?(note)
225
    end
226
  end
227
  # rubocop: enable CodeReuse/ServiceClass
228

229 230
  def diff_note?
    false
231 232
  end

233
  def active?
234
    true
235 236
  end

237
  def max_attachment_size
238
    Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
239 240
  end

241
  def hook_attrs
242
    Gitlab::HookData::NoteBuilder.new(self).build
243 244
  end

245 246 247 248
  def supports_suggestion?
    false
  end

249 250 251 252
  def for_commit?
    noteable_type == "Commit"
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
253 254 255 256
  def for_issue?
    noteable_type == "Issue"
  end

257 258 259 260
  def for_merge_request?
    noteable_type == "MergeRequest"
  end

261
  def for_snippet?
262 263 264
    noteable_type == "Snippet"
  end

265
  def for_personal_snippet?
Jarka Kadlecova's avatar
Jarka Kadlecova committed
266 267 268
    noteable.is_a?(PersonalSnippet)
  end

269 270 271 272
  def for_project_noteable?
    !for_personal_snippet?
  end

273 274 275 276
  def for_issuable?
    for_issue? || for_merge_request?
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
277
  def skip_project_check?
Jan Provaznik's avatar
Jan Provaznik committed
278
    !for_project_noteable?
279 280
  end

281
  def commit
282
    @commit ||= project.commit(commit_id) if commit_id.present?
283 284
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
285 286
  # override to return commits, which are not active record
  def noteable
287 288 289
    return commit if for_commit?

    super
290
  rescue
291 292
    # Temp fix to prevent app crash
    # if note commit id doesn't exist
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
293
    nil
294
  end
295

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

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

305 306 307 308
    @special_role = role
  end

  def has_special_role?(role)
309
    self.class.has_special_role?(role, self)
310 311
  end

312 313
  def specialize_for_first_contribution!(noteable)
    return unless noteable.author_id == self.author_id
micael.bergeron's avatar
micael.bergeron committed
314 315

    self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
316
  end
micael.bergeron's avatar
micael.bergeron committed
317

318 319 320 321
  def confidential?
    noteable.try(:confidential?)
  end

322
  def editable?
323
    !system?
324
  end
325

326 327 328 329 330 331 332 333
  # 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

334
  def cross_reference_not_visible_for?(user)
335
    cross_reference? && !all_referenced_mentionables_allowed?(user)
336 337
  end

338 339 340 341
  def visible_for?(user)
    !cross_reference_not_visible_for?(user)
  end

342
  def award_emoji?
343
    can_be_award_emoji? && contains_emoji_only?
344 345
  end

346 347 348 349
  def emoji_awardable?
    !system?
  end

350
  def can_be_award_emoji?
351
    noteable.is_a?(Awardable) && !part_of_discussion?
352 353
  end

354
  def contains_emoji_only?
355
    note =~ /\A#{Banzai::Filter::EmojiFilter.emoji_pattern}\s?\Z/
356 357
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
358
  def to_ability_name
359
    for_snippet? ? noteable.class.name.underscore : noteable_type.demodulize.underscore
Jarka Kadlecova's avatar
Jarka Kadlecova committed
360 361
  end

362
  def can_be_discussion_note?
363
    self.noteable.supports_discussions? && !part_of_discussion?
364 365
  end

Jan Provaznik's avatar
Jan Provaznik committed
366 367 368 369 370
  def can_create_todo?
    # Skip system notes, and notes on project snippet
    !system? && !for_snippet?
  end

371 372
  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
373 374
    # displayed in one discussion instead of individually.
    # See also `#discussion_id` and `Discussion.override_discussion_id`.
Douwe Maan's avatar
Douwe Maan committed
375 376
    if noteable && noteable != self.noteable
      OutOfContextDiscussion
377 378 379 380 381
    else
      IndividualNoteDiscussion
    end
  end

Douwe Maan's avatar
Douwe Maan committed
382
  # See `Discussion.override_discussion_id` for details.
383 384 385 386
  def discussion_id(noteable = nil)
    discussion_class(noteable).override_discussion_id(self) || super()
  end

Douwe Maan's avatar
Douwe Maan committed
387 388 389 390
  # 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.
391 392 393 394
  def to_discussion(noteable = nil)
    Discussion.build([self], noteable)
  end

Douwe Maan's avatar
Douwe Maan committed
395 396 397
  # 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.
398
  def discussion
Douwe Maan's avatar
Douwe Maan committed
399 400
    full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
    full_discussion || to_discussion
401 402 403
  end

  def part_of_discussion?
Douwe Maan's avatar
Douwe Maan committed
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421
    !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
422 423
  end

424 425 426
  def references
    refs = [noteable]

427
    if part_of_discussion?
428
      refs += discussion.notes.take_while { |n| n.id < id }
429 430
    end

431
    refs
432 433
  end

434
  def expire_etag_cache
435
    noteable&.expire_note_etag_cache
436 437
  end

438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467
  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
468

469
    # We return the noteable object so we can re-use it in EE for Elasticsearch.
470
    noteable_object
471 472
  end

473
  def banzai_render_context(field)
474
    super.merge(noteable: noteable, system_note: system?)
475 476
  end

Sean McGivern's avatar
Sean McGivern committed
477 478 479 480
  def retrieve_upload(_identifier, paths)
    Upload.find_by(model: self, path: paths)
  end

481 482 483
  def parent
    project
  end
484
  alias_method :resource_parent, :parent
485

486 487 488 489 490
  private

  def keep_around_commit
    project.repository.keep_around(self.commit_id)
  end
491 492 493 494 495 496 497 498

  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
499 500 501

  def ensure_discussion_id
    return unless self.persisted?
502 503
    # Needed in case the SELECT statement doesn't ask for `discussion_id`
    return unless self.has_attribute?(:discussion_id)
504 505 506 507 508 509 510
    return if self.discussion_id

    set_discussion_id
    update_column(:discussion_id, self.discussion_id)
  end

  def set_discussion_id
511
    self.discussion_id ||= discussion_class.discussion_id(self)
512
  end
513

514 515 516 517 518 519 520 521 522
  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
      referenced_mentionables(user).any?
    end
  end

523 524 525
  def force_cross_reference_regex_check?
    return unless system?

526
    system_note_metadata&.cross_reference_types&.include?(system_note_metadata&.action)
527
  end
gitlabhq's avatar
gitlabhq committed
528
end
529 530

Note.prepend_if_ee('EE::Note')