note.rb 12.6 KB
Newer Older
1 2 3
# 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
4
class Note < ActiveRecord::Base
5
  extend ActiveModel::Naming
6
  include Participable
7
  include Mentionable
8
  include Awardable
9
  include Importable
10
  include FasterCacheKeys
11
  include CacheMarkdownField
12
  include AfterCommitQueue
13
  include ResolvableNote
14
  include IgnorableColumn
15
  include Editable
16
  include Gitlab::SQL::Pattern
17
  include ThrottledTouch
18

19 20 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
    end
  end

29
  ignore_column :original_discussion_id
30

31
  cache_markdown_field :note, pipeline: :note, issuable_state_filter_enabled: true
32

blackst0ne's avatar
blackst0ne committed
33 34
  # 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
35 36 37
  alias_attribute :last_edited_at, :updated_at
  alias_attribute :last_edited_by, :updated_by

38 39
  # Attribute containing rendered and redacted Markdown as generated by
  # Banzai::ObjectRenderer.
40
  attr_accessor :redacted_note_html
41

42 43 44 45
  # An Array containing the number of visible references as generated by
  # Banzai::ObjectRenderer
  attr_accessor :user_visible_reference_count

46
  # Attribute used to store the attributes that have been changed by quick actions.
47 48
  attr_accessor :commands_changes

49 50
  # A special role that may be displayed on issuable's discussions
  attr_accessor :special_role
micael.bergeron's avatar
micael.bergeron committed
51

52 53
  default_value_for :system, false

Yorick Peterse's avatar
Yorick Peterse committed
54
  attr_mentionable :note, pipeline: :note
55
  participant :author
56

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

63
  has_many :todos
64
  has_many :events, as: :target, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
65
  has_one :system_note_metadata
66

67
  delegate :gfm_reference, :local_reference, to: :noteable
Nihad Abbasov's avatar
Nihad Abbasov committed
68 69
  delegate :name, to: :project, prefix: true
  delegate :name, :email, to: :author, prefix: true
70
  delegate :title, to: :noteable, allow_nil: true
71

72
  validates :note, presence: true
73
  validates :project, presence: true, if: :for_project_noteable?
Z.J. van de Weg's avatar
Z.J. van de Weg committed
74

75 76
  # Attachments are deprecated and are handled by Markdown uploader
  validates :attachment, file_size: { maximum: :max_attachment_size }
gitlabhq's avatar
gitlabhq committed
77

78
  validates :noteable_type, presence: true
79
  validates :noteable_id, presence: true, unless: [:for_commit?, :importing?]
80
  validates :commit_id, presence: true, if: :for_commit?
Valery Sizov's avatar
Valery Sizov committed
81
  validates :author, presence: true
82
  validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
83

84
  validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note|
85
    unless note.noteable.try(:project) == note.project
Douwe Maan's avatar
Douwe Maan committed
86
      errors.add(:project, 'does not match noteable project')
87 88 89
    end
  end

90
  # @deprecated attachments are handler by the MarkdownUploader
91
  mount_uploader :attachment, AttachmentUploader
Andrey Kumanyaev's avatar
Andrey Kumanyaev committed
92 93

  # Scopes
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
94
  scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) }
95 96 97 98 99 100 101
  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) }
102 103 104
  scope :inc_relations_for_view, -> do
    includes(:project, :author, :updated_by, :resolved_by, :award_emoji, :system_note_metadata)
  end
gitlabhq's avatar
gitlabhq committed
105

106 107 108
  scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) }
  scope :new_diff_notes, -> { where(type: 'DiffNote') }
  scope :non_diff_notes, -> { where(type: ['Note', 'DiscussionNote', nil]) }
109

110
  scope :with_associations, -> do
111 112
    # FYI noteable cannot be loaded for LegacyDiffNote for commits
    includes(:author, :noteable, :updated_by,
113
             project: [:project_members, { group: [:group_members] }])
114
  end
115
  scope :with_metadata, -> { includes(:system_note_metadata) }
gitlabhq's avatar
gitlabhq committed
116

117
  after_initialize :ensure_discussion_id
118
  before_validation :nullify_blank_type, :nullify_blank_line_code
119
  before_validation :set_discussion_id, on: :create
120
  after_save :keep_around_commit, if: :for_project_noteable?
121
  after_save :expire_etag_cache
122
  after_save :touch_noteable
123
  after_destroy :expire_etag_cache
124

125
  class << self
126 127 128
    def model_name
      ActiveModel::Name.new(self, nil, 'note')
    end
129

130
    def discussions(context_noteable = nil)
Douwe Maan's avatar
Douwe Maan committed
131
      Discussion.build_collection(all.includes(:noteable).fresh, context_noteable)
132
    end
133

134 135
    def find_discussion(discussion_id)
      notes = where(discussion_id: discussion_id).fresh.to_a
136

137 138 139
      return if notes.empty?

      Discussion.build(notes)
140
    end
141

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

      diff_notes.fresh.discussions.each do |discussion|
Felipe Artur's avatar
Felipe Artur committed
149 150 151 152 153 154 155 156 157
        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
158 159
          discussions << discussion
        end
160 161 162
      end

      groups
163
    end
164 165

    def count_for_collection(ids, type)
166 167 168
      user.select('noteable_id', 'COUNT(*) as count')
        .group(:noteable_id)
        .where(noteable_type: type, noteable_id: ids)
169
    end
170 171 172 173

    def has_special_role?(role, note)
      note.special_role == role
    end
174 175

    def search(query)
176
      fuzzy_search(query, [:note])
177
    end
178
  end
179

180
  def cross_reference?
181 182 183 184 185 186 187
    return unless system?

    if force_cross_reference_regex_check?
      matches_cross_reference_regex?
    else
      SystemNoteService.cross_reference?(note)
    end
188 189
  end

190 191
  def diff_note?
    false
192 193
  end

194
  def active?
195
    true
196 197
  end

198
  def max_attachment_size
199
    Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i
200 201
  end

202 203
  def hook_attrs
    attributes
204 205 206 207 208 209
  end

  def for_commit?
    noteable_type == "Commit"
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
210 211 212 213
  def for_issue?
    noteable_type == "Issue"
  end

214 215 216 217
  def for_merge_request?
    noteable_type == "MergeRequest"
  end

218
  def for_snippet?
219 220 221
    noteable_type == "Snippet"
  end

222
  def for_personal_snippet?
Jarka Kadlecova's avatar
Jarka Kadlecova committed
223 224 225
    noteable.is_a?(PersonalSnippet)
  end

226 227 228 229
  def for_project_noteable?
    !for_personal_snippet?
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
230 231
  def skip_project_check?
    for_personal_snippet?
232 233
  end

234
  def commit
235
    @commit ||= project.commit(commit_id) if commit_id.present?
236 237
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
238 239
  # override to return commits, which are not active record
  def noteable
240 241 242
    return commit if for_commit?

    super
243
  rescue
244 245
    # Temp fix to prevent app crash
    # if note commit id doesn't exist
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
246
    nil
247
  end
248

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

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

258 259 260 261
    @special_role = role
  end

  def has_special_role?(role)
262
    self.class.has_special_role?(role, self)
263 264
  end

265 266
  def specialize_for_first_contribution!(noteable)
    return unless noteable.author_id == self.author_id
micael.bergeron's avatar
micael.bergeron committed
267 268

    self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
269
  end
micael.bergeron's avatar
micael.bergeron committed
270

271
  def editable?
272
    !system?
273
  end
274

275
  def cross_reference_not_visible_for?(user)
276 277 278 279 280 281 282 283 284
    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
285 286
  end

287
  def award_emoji?
288
    can_be_award_emoji? && contains_emoji_only?
289 290
  end

291 292 293 294
  def emoji_awardable?
    !system?
  end

295
  def can_be_award_emoji?
296
    noteable.is_a?(Awardable) && !part_of_discussion?
297 298
  end

299
  def contains_emoji_only?
300
    note =~ /\A#{Banzai::Filter::EmojiFilter.emoji_pattern}\s?\Z/
301 302
  end

Jarka Kadlecova's avatar
Jarka Kadlecova committed
303 304 305 306
  def to_ability_name
    for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore
  end

307
  def can_be_discussion_note?
308
    self.noteable.supports_discussions? && !part_of_discussion?
309 310
  end

311 312
  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
313 314
    # displayed in one discussion instead of individually.
    # See also `#discussion_id` and `Discussion.override_discussion_id`.
Douwe Maan's avatar
Douwe Maan committed
315 316
    if noteable && noteable != self.noteable
      OutOfContextDiscussion
317 318 319 320 321
    else
      IndividualNoteDiscussion
    end
  end

Douwe Maan's avatar
Douwe Maan committed
322
  # See `Discussion.override_discussion_id` for details.
323 324 325 326
  def discussion_id(noteable = nil)
    discussion_class(noteable).override_discussion_id(self) || super()
  end

Douwe Maan's avatar
Douwe Maan committed
327 328 329 330
  # 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.
331 332 333 334
  def to_discussion(noteable = nil)
    Discussion.build([self], noteable)
  end

Douwe Maan's avatar
Douwe Maan committed
335 336 337
  # 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.
338
  def discussion
Douwe Maan's avatar
Douwe Maan committed
339 340
    full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
    full_discussion || to_discussion
341 342 343
  end

  def part_of_discussion?
Douwe Maan's avatar
Douwe Maan committed
344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361
    !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
362 363
  end

364 365 366
  def references
    refs = [noteable]

367
    if part_of_discussion?
368
      refs += discussion.notes.take_while { |n| n.id < id }
369 370
    end

371
    refs
372 373
  end

374
  def expire_etag_cache
Douwe Maan's avatar
Douwe Maan committed
375
    return unless noteable&.discussions_rendered_on_frontend?
376 377

    key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
378
      project,
379
      target_type: noteable_type.underscore,
380
      target_id: noteable_id
381 382 383 384
    )
    Gitlab::EtagCaching::Store.new.touch(key)
  end

385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414
  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
415 416 417

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

420 421 422 423
  def banzai_render_context(field)
    super.merge(noteable: noteable)
  end

424 425 426 427 428
  private

  def keep_around_commit
    project.repository.keep_around(self.commit_id)
  end
429 430 431 432 433 434 435 436

  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
437 438 439

  def ensure_discussion_id
    return unless self.persisted?
440 441
    # Needed in case the SELECT statement doesn't ask for `discussion_id`
    return unless self.has_attribute?(:discussion_id)
442 443 444 445 446 447 448
    return if self.discussion_id

    set_discussion_id
    update_column(:discussion_id, self.discussion_id)
  end

  def set_discussion_id
449
    self.discussion_id ||= discussion_class.discussion_id(self)
450
  end
451 452 453 454 455 456

  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
457
end