note.rb 12.4 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.
G
gitlabhq 已提交
4
class Note < ActiveRecord::Base
D
Douwe Maan 已提交
5
  extend ActiveModel::Naming
6
  include Gitlab::CurrentSettings
7
  include Participable
8
  include Mentionable
Z
ZJ van de Weg 已提交
9
  include Awardable
10
  include Importable
11
  include FasterCacheKeys
N
Nick Thomas 已提交
12
  include CacheMarkdownField
13
  include AfterCommitQueue
14
  include ResolvableNote
15
  include IgnorableColumn
16
  include Editable
17
  include Gitlab::SQL::Pattern
18
  include ThrottledTouch
19

20 21 22 23 24 25 26 27 28 29
  module SpecialRole
    FIRST_TIME_CONTRIBUTOR = :first_time_contributor

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

30
  ignore_column :original_discussion_id
N
Nick Thomas 已提交
31

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

B
blackst0ne 已提交
34 35
  # 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
B
blackst0ne 已提交
36 37 38
  alias_attribute :last_edited_at, :updated_at
  alias_attribute :last_edited_by, :updated_by

39 40
  # Attribute containing rendered and redacted Markdown as generated by
  # Banzai::ObjectRenderer.
N
Nick Thomas 已提交
41
  attr_accessor :redacted_note_html
42

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

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

50 51
  # A special role that may be displayed on issuable's discussions
  attr_accessor :special_role
M
micael.bergeron 已提交
52

53 54
  default_value_for :system, false

Y
Yorick Peterse 已提交
55
  attr_mentionable :note, pipeline: :note
56
  participant :author
57

G
gitlabhq 已提交
58
  belongs_to :project
59
  belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations
N
Nihad Abbasov 已提交
60
  belongs_to :author, class_name: "User"
61
  belongs_to :updated_by, class_name: "User"
62
  belongs_to :last_edited_by, class_name: 'User'
G
gitlabhq 已提交
63

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

68
  delegate :gfm_reference, :local_reference, to: :noteable
N
Nihad Abbasov 已提交
69 70
  delegate :name, to: :project, prefix: true
  delegate :name, :email, to: :author, prefix: true
71
  delegate :title, to: :noteable, allow_nil: true
72

J
Jarka Kadlecova 已提交
73
  validates :note, presence: true
74
  validates :project, presence: true, if: :for_project_noteable?
Z
Z.J. van de Weg 已提交
75

76 77
  # Attachments are deprecated and are handled by Markdown uploader
  validates :attachment, file_size: { maximum: :max_attachment_size }
G
gitlabhq 已提交
78

79
  validates :noteable_type, presence: true
80
  validates :noteable_id, presence: true, unless: [:for_commit?, :importing?]
81
  validates :commit_id, presence: true, if: :for_commit?
V
Valery Sizov 已提交
82
  validates :author, presence: true
D
Douwe Maan 已提交
83
  validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ }
84

J
Jarka Kadlecova 已提交
85
  validate unless: [:for_commit?, :importing?, :for_personal_snippet?] do |note|
86
    unless note.noteable.try(:project) == note.project
D
Douwe Maan 已提交
87
      errors.add(:project, 'does not match noteable project')
88 89 90
    end
  end

N
Nihad Abbasov 已提交
91
  mount_uploader :attachment, AttachmentUploader
A
Andrey Kumanyaev 已提交
92 93

  # Scopes
D
Dmitriy Zaporozhets 已提交
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) }
J
Jarka Kadlecova 已提交
102 103 104
  scope :inc_relations_for_view, -> do
    includes(:project, :author, :updated_by, :resolved_by, :award_emoji, :system_note_metadata)
  end
G
gitlabhq 已提交
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]) }
D
Douwe Maan 已提交
109

110
  scope :with_associations, -> do
111 112
    # FYI noteable cannot be loaded for LegacyDiffNote for commits
    includes(:author, :noteable, :updated_by,
Y
Yorick Peterse 已提交
113
             project: [:project_members, { group: [:group_members] }])
114
  end
115
  scope :with_metadata, -> { includes(:system_note_metadata) }
G
gitlabhq 已提交
116

D
Douwe Maan 已提交
117
  after_initialize :ensure_discussion_id
118
  before_validation :nullify_blank_type, :nullify_blank_line_code
D
Douwe Maan 已提交
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
D
Douwe Maan 已提交
126 127 128
    def model_name
      ActiveModel::Name.new(self, nil, 'note')
    end
129

130
    def discussions(context_noteable = nil)
D
Douwe Maan 已提交
131
      Discussion.build_collection(all.includes(:noteable).fresh, context_noteable)
132
    end
133

134 135 136 137 138
    def find_discussion(discussion_id)
      notes = where(discussion_id: discussion_id).fresh.to_a
      return if notes.empty?

      Discussion.build(notes)
139
    end
140

F
Felipe Artur 已提交
141 142 143
    # Group diff discussions by line code or file path.
    # It is not needed to group by line code when comment is
    # on an image.
144
    def grouped_diff_discussions(diff_refs = nil)
D
Douwe Maan 已提交
145
      groups = {}
146 147

      diff_notes.fresh.discussions.each do |discussion|
F
Felipe Artur 已提交
148 149 150 151 152 153 154 155 156
        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] ||= []
D
Douwe Maan 已提交
157 158
          discussions << discussion
        end
159 160 161
      end

      groups
162
    end
163 164

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

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

    def search(query)
175
      fuzzy_search(query, [:note])
176
    end
R
Robert Speicher 已提交
177
  end
178

R
Robert Speicher 已提交
179
  def cross_reference?
180 181 182 183 184 185 186
    return unless system?

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

D
Douwe Maan 已提交
189 190
  def diff_note?
    false
191 192
  end

193
  def active?
D
Douwe Maan 已提交
194
    true
195 196
  end

D
Douwe Maan 已提交
197 198
  def max_attachment_size
    current_application_settings.max_attachment_size.megabytes.to_i
D
Dmitriy Zaporozhets 已提交
199 200
  end

D
Douwe Maan 已提交
201 202
  def hook_attrs
    attributes
203 204 205 206 207 208
  end

  def for_commit?
    noteable_type == "Commit"
  end

R
Riyad Preukschas 已提交
209 210 211 212
  def for_issue?
    noteable_type == "Issue"
  end

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

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

J
Jarka Kadlecova 已提交
221
  def for_personal_snippet?
J
Jarka Kadlecova 已提交
222 223 224
    noteable.is_a?(PersonalSnippet)
  end

225 226 227 228
  def for_project_noteable?
    !for_personal_snippet?
  end

J
Jarka Kadlecova 已提交
229 230
  def skip_project_check?
    for_personal_snippet?
J
Jarka Kadlecova 已提交
231 232
  end

233
  def commit
M
micael.bergeron 已提交
234
    @commit ||= project.commit(commit_id) if commit_id.present?
235 236
  end

R
Riyad Preukschas 已提交
237 238
  # override to return commits, which are not active record
  def noteable
M
micael.bergeron 已提交
239 240 241
    return commit if for_commit?

    super
242
  rescue
M
micael.bergeron 已提交
243 244
    # Temp fix to prevent app crash
    # if note commit id doesn't exist
D
Dmitriy Zaporozhets 已提交
245
    nil
246
  end
247

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

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

257 258 259 260
    @special_role = role
  end

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

M
micael.bergeron 已提交
264 265
  def specialize_for_first_contribution!(noteable)
    return unless noteable.author_id == self.author_id
M
micael.bergeron 已提交
266 267

    self.special_role = Note::SpecialRole::FIRST_TIME_CONTRIBUTOR
M
micael.bergeron 已提交
268
  end
M
micael.bergeron 已提交
269

270
  def editable?
271
    !system?
272
  end
273

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

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

290 291 292 293
  def emoji_awardable?
    !system?
  end

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

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

J
Jarka Kadlecova 已提交
302 303 304 305
  def to_ability_name
    for_personal_snippet? ? 'personal_snippet' : noteable_type.underscore
  end

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

310 311
  def discussion_class(noteable = nil)
    # When commit notes are rendered on an MR's Discussion page, they are
D
Douwe Maan 已提交
312 313
    # displayed in one discussion instead of individually.
    # See also `#discussion_id` and `Discussion.override_discussion_id`.
D
Douwe Maan 已提交
314 315
    if noteable && noteable != self.noteable
      OutOfContextDiscussion
316 317 318 319 320
    else
      IndividualNoteDiscussion
    end
  end

D
Douwe Maan 已提交
321
  # See `Discussion.override_discussion_id` for details.
322 323 324 325
  def discussion_id(noteable = nil)
    discussion_class(noteable).override_discussion_id(self) || super()
  end

D
Douwe Maan 已提交
326 327 328 329
  # 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.
330 331 332 333
  def to_discussion(noteable = nil)
    Discussion.build([self], noteable)
  end

D
Douwe Maan 已提交
334 335 336
  # 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.
337
  def discussion
D
Douwe Maan 已提交
338 339
    full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion?
    full_discussion || to_discussion
340 341 342
  end

  def part_of_discussion?
D
Douwe Maan 已提交
343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360
    !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
361 362
  end

363
  def expire_etag_cache
D
Douwe Maan 已提交
364
    return unless noteable&.discussions_rendered_on_frontend?
365 366

    key = Gitlab::Routing.url_helpers.project_noteable_notes_path(
D
Douwe Maan 已提交
367
      project,
368
      target_type: noteable_type.underscore,
D
Douwe Maan 已提交
369
      target_id: noteable_id
370 371 372 373
    )
    Gitlab::EtagCaching::Store.new.touch(key)
  end

374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403
  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
404 405 406

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

409 410 411 412
  def banzai_render_context(field)
    super.merge(noteable: noteable)
  end

413 414 415 416 417
  private

  def keep_around_commit
    project.repository.keep_around(self.commit_id)
  end
418 419 420 421 422 423 424 425

  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
426 427 428

  def ensure_discussion_id
    return unless self.persisted?
429 430
    # Needed in case the SELECT statement doesn't ask for `discussion_id`
    return unless self.has_attribute?(:discussion_id)
431 432 433 434 435 436 437
    return if self.discussion_id

    set_discussion_id
    update_column(:discussion_id, self.discussion_id)
  end

  def set_discussion_id
438
    self.discussion_id ||= discussion_class.discussion_id(self)
439
  end
440 441 442 443 444 445

  def force_cross_reference_regex_check?
    return unless system?

    SystemNoteMetadata::TYPES_WITH_CROSS_REFERENCES.include?(system_note_metadata&.action)
  end
G
gitlabhq 已提交
446
end