note.rb 14.1 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
10
  include Awardable
11
  include Importable
12
  include FasterCacheKeys
13
  include Redactable
14
  include CacheMarkdownField
15
  include AfterCommitQueue
16
  include ResolvableNote
17
  include IgnorableColumn
18
  include Editable
19
  include Gitlab::SQL::Pattern
20
  include ThrottledTouch
21
  include FromUnion
22

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

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

33
  ignore_column :original_discussion_id
34

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

37 38
  redact_field :note

39 40
  # 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
41 42 43
  alias_attribute :last_edited_at, :updated_at
  alias_attribute :last_edited_by, :updated_by

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

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

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

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

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

60 61
  default_value_for :system, false

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

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

71
  has_many :todos
72 73 74 75 76 77

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

82
  delegate :gfm_reference, :local_reference, to: :noteable
83 84
  delegate :name, to: :project, prefix: true
  delegate :name, :email, to: :author, prefix: true
85
  delegate :title, to: :noteable, allow_nil: true
86

87
  validates :note, presence: true
88
  validates :project, presence: true, if: :for_project_noteable?
Z.J. van de Weg's avatar
Z.J. van de Weg committed
89

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

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

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

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

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

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

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

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

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

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

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

161 162
    def find_discussion(discussion_id)
      notes = where(discussion_id: discussion_id).fresh.to_a
163

164 165 166
      return if notes.empty?

      Discussion.build(notes)
167
    end
168

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

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

      groups
190
    end
191 192

    def count_for_collection(ids, type)
193 194 195
      user.select('noteable_id', 'COUNT(*) as count')
        .group(:noteable_id)
        .where(noteable_type: type, noteable_id: ids)
196
    end
197 198 199 200

    def has_special_role?(role, note)
      note.special_role == role
    end
201 202

    def search(query)
203
      fuzzy_search(query, [:note])
204
    end
205
  end
206

207
  # rubocop: disable CodeReuse/ServiceClass
208
  def cross_reference?
209 210 211 212 213 214 215
    return unless system?

    if force_cross_reference_regex_check?
      matches_cross_reference_regex?
    else
      SystemNoteService.cross_reference?(note)
    end
216
  end
217
  # rubocop: enable CodeReuse/ServiceClass
218

219 220
  def diff_note?
    false
221 222
  end

223
  def active?
224
    true
225 226
  end

227
  def max_attachment_size
228
    Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
229 230
  end

231
  def hook_attrs
232
    Gitlab::HookData::NoteBuilder.new(self).build
233 234
  end

235 236 237 238
  def supports_suggestion?
    false
  end

239 240 241 242
  def for_commit?
    noteable_type == "Commit"
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
243 244 245 246
  def for_issue?
    noteable_type == "Issue"
  end

247 248 249 250
  def for_merge_request?
    noteable_type == "MergeRequest"
  end

251
  def for_snippet?
252 253 254
    noteable_type == "Snippet"
  end

255
  def for_personal_snippet?
Jarka Kadlecova's avatar
Jarka Kadlecova committed
256 257 258
    noteable.is_a?(PersonalSnippet)
  end

259 260 261 262
  def for_project_noteable?
    !for_personal_snippet?
  end

263 264 265 266
  def for_issuable?
    for_issue? || for_merge_request?
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
267
  def skip_project_check?
Jan Provaznik's avatar
Jan Provaznik committed
268
    !for_project_noteable?
269 270
  end

271
  def commit
272
    @commit ||= project.commit(commit_id) if commit_id.present?
273 274
  end

275 276
  # override to return commits, which are not active record
  def noteable
277 278 279
    return commit if for_commit?

    super
280
  rescue
281 282
    # Temp fix to prevent app crash
    # if note commit id doesn't exist
283
    nil
284
  end
285

Andrew8xx8's avatar
Andrew8xx8 committed
286
  # FIXME: Hack for polymorphic associations with STI
Steven Burgart's avatar
Steven Burgart committed
287
  #        For more information visit http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#label-Polymorphic+Associations
288 289
  def noteable_type=(noteable_type)
    super(noteable_type.to_s.classify.constantize.base_class.to_s)
Andrew8xx8's avatar
Andrew8xx8 committed
290
  end
291

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

295 296 297 298
    @special_role = role
  end

  def has_special_role?(role)
299
    self.class.has_special_role?(role, self)
300 301
  end

302 303
  def specialize_for_first_contribution!(noteable)
    return unless noteable.author_id == self.author_id
micael.bergeron's avatar
micael.bergeron committed
304 305

    self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
306
  end
micael.bergeron's avatar
micael.bergeron committed
307

308 309 310 311
  def confidential?
    noteable.try(:confidential?)
  end

312
  def editable?
313
    !system?
314
  end
315

316 317 318 319 320 321 322 323
  # 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

324
  def cross_reference_not_visible_for?(user)
325
    cross_reference? && !all_referenced_mentionables_allowed?(user)
326 327
  end

328
  def award_emoji?
329
    can_be_award_emoji? && contains_emoji_only?
330 331
  end

332 333 334 335
  def emoji_awardable?
    !system?
  end

336
  def can_be_award_emoji?
337
    noteable.is_a?(Awardable) && !part_of_discussion?
338 339
  end

340
  def contains_emoji_only?
341
    note =~ /\A#{Banzai::Filter::EmojiFilter.emoji_pattern}\s?\Z/
342 343
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
344
  def to_ability_name
345
    for_snippet? ? noteable.class.name.underscore : noteable_type.underscore
Jarka Kadlecova's avatar
Jarka Kadlecova committed
346 347
  end

348
  def can_be_discussion_note?
349
    self.noteable.supports_discussions? && !part_of_discussion?
350 351
  end

Jan Provaznik's avatar
Jan Provaznik committed
352 353 354 355 356
  def can_create_todo?
    # Skip system notes, and notes on project snippet
    !system? && !for_snippet?
  end

357 358
  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
359 360
    # displayed in one discussion instead of individually.
    # See also `#discussion_id` and `Discussion.override_discussion_id`.
Douwe Maan's avatar
Douwe Maan committed
361 362
    if noteable && noteable != self.noteable
      OutOfContextDiscussion
363 364 365 366 367
    else
      IndividualNoteDiscussion
    end
  end

Douwe Maan's avatar
Douwe Maan committed
368
  # See `Discussion.override_discussion_id` for details.
369 370 371 372
  def discussion_id(noteable = nil)
    discussion_class(noteable).override_discussion_id(self) || super()
  end

Douwe Maan's avatar
Douwe Maan committed
373 374 375 376
  # 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.
377 378 379 380
  def to_discussion(noteable = nil)
    Discussion.build([self], noteable)
  end

Douwe Maan's avatar
Douwe Maan committed
381 382 383
  # 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.
384
  def discussion
Douwe Maan's avatar
Douwe Maan committed
385 386
    full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
    full_discussion || to_discussion
387 388 389
  end

  def part_of_discussion?
Douwe Maan's avatar
Douwe Maan committed
390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407
    !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
408 409
  end

410 411 412
  def references
    refs = [noteable]

413
    if part_of_discussion?
414
      refs += discussion.notes.take_while { |n| n.id < id }
415 416
    end

417
    refs
418 419
  end

420
  def expire_etag_cache
421
    noteable&.expire_note_etag_cache
422 423
  end

424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453
  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
454 455 456

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

459 460 461 462
  def banzai_render_context(field)
    super.merge(noteable: noteable)
  end

463 464 465 466
  def retrieve_upload(_identifier, paths)
    Upload.find_by(model: self, path: paths)
  end

467 468 469 470
  def parent
    project
  end

471 472 473 474 475
  private

  def keep_around_commit
    project.repository.keep_around(self.commit_id)
  end
476 477 478 479 480 481 482 483

  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
484 485 486

  def ensure_discussion_id
    return unless self.persisted?
487 488
    # Needed in case the SELECT statement doesn't ask for `discussion_id`
    return unless self.has_attribute?(:discussion_id)
489 490 491 492 493 494 495
    return if self.discussion_id

    set_discussion_id
    update_column(:discussion_id, self.discussion_id)
  end

  def set_discussion_id
496
    self.discussion_id ||= discussion_class.discussion_id(self)
497
  end
498

499 500 501 502 503 504 505 506 507
  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

508 509 510
  def force_cross_reference_regex_check?
    return unless system?

511
    system_note_metadata&.cross_reference_types&.include?(system_note_metadata&.action)
512
  end
gitlabhq's avatar
gitlabhq committed
513
end