1# frozen_string_literal: true 2 3# A note on the root of an issue, merge request, commit, or snippet. 4# 5# A note of this type is never resolvable. 6class Note < ApplicationRecord 7 extend ActiveModel::Naming 8 extend Gitlab::Utils::Override 9 10 include Gitlab::Utils::StrongMemoize 11 include Participable 12 include Mentionable 13 include Awardable 14 include Importable 15 include FasterCacheKeys 16 include Redactable 17 include CacheMarkdownField 18 include AfterCommitQueue 19 include ResolvableNote 20 include Editable 21 include Gitlab::SQL::Pattern 22 include ThrottledTouch 23 include FromUnion 24 include Sortable 25 26 cache_markdown_field :note, pipeline: :note, issuable_reference_expansion_enabled: true 27 28 redact_field :note 29 30 TYPES_RESTRICTED_BY_ABILITY = { 31 branch: :download_code 32 }.freeze 33 34 # Aliases to make application_helper#edited_time_ago_with_tooltip helper work properly with notes. 35 # See https://gitlab.com/gitlab-org/gitlab-foss/merge_requests/10392/diffs#note_28719102 36 alias_attribute :last_edited_by, :updated_by 37 38 # Attribute containing rendered and redacted Markdown as generated by 39 # Banzai::ObjectRenderer. 40 attr_accessor :redacted_note_html 41 42 # Total of all references as generated by Banzai::ObjectRenderer 43 attr_accessor :total_reference_count 44 45 # Number of user visible references as generated by Banzai::ObjectRenderer 46 attr_accessor :user_visible_reference_count 47 48 # Attribute used to store the attributes that have been changed by quick actions. 49 attr_accessor :commands_changes 50 51 # Attribute used to determine whether keep_around_commits will be skipped for diff notes. 52 attr_accessor :skip_keep_around_commits 53 54 default_value_for :system, false 55 56 attr_mentionable :note, pipeline: :note 57 participant :author 58 59 belongs_to :project 60 belongs_to :noteable, polymorphic: true # rubocop:disable Cop/PolymorphicAssociations 61 belongs_to :author, class_name: "User" 62 belongs_to :updated_by, class_name: "User" 63 belongs_to :last_edited_by, class_name: 'User' 64 belongs_to :review, inverse_of: :notes 65 66 has_many :todos 67 68 # The delete_all definition is required here in order 69 # to generate the correct DELETE sql for 70 # suggestions.delete_all calls 71 has_many :suggestions, -> { order(:relative_order) }, 72 inverse_of: :note, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent 73 has_many :events, as: :target, dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent 74 has_one :system_note_metadata 75 has_one :note_diff_file, inverse_of: :diff_note, foreign_key: :diff_note_id 76 has_many :diff_note_positions 77 78 delegate :gfm_reference, :local_reference, to: :noteable 79 delegate :name, to: :project, prefix: true 80 delegate :name, :email, to: :author, prefix: true 81 delegate :title, to: :noteable, allow_nil: true 82 83 validates :note, presence: true 84 validates :note, length: { maximum: Gitlab::Database::MAX_TEXT_SIZE_LIMIT } 85 validates :project, presence: true, if: :for_project_noteable? 86 87 # Attachments are deprecated and are handled by Markdown uploader 88 validates :attachment, file_size: { maximum: :max_attachment_size } 89 90 validates :noteable_type, presence: true 91 validates :noteable_id, presence: true, unless: [:for_commit?, :importing?] 92 validates :commit_id, presence: true, if: :for_commit? 93 validates :author, presence: true 94 validates :discussion_id, presence: true, format: { with: /\A\h{40}\z/ } 95 96 validate unless: [:for_commit?, :importing?, :skip_project_check?] do |note| 97 unless note.noteable.try(:project) == note.project 98 errors.add(:project, 'does not match noteable project') 99 end 100 end 101 102 validate :does_not_exceed_notes_limit?, on: :create, unless: [:system?, :importing?] 103 104 # @deprecated attachments are handled by the Upload model. 105 # 106 # https://gitlab.com/gitlab-org/gitlab/-/issues/20830 107 mount_uploader :attachment, AttachmentUploader 108 109 # Scopes 110 scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) } 111 scope :system, -> { where(system: true) } 112 scope :user, -> { where(system: false) } 113 scope :common, -> { where(noteable_type: ["", nil]) } 114 scope :fresh, -> { order_created_asc.with_order_id_asc } 115 scope :updated_after, ->(time) { where('updated_at > ?', time) } 116 scope :with_updated_at, ->(time) { where(updated_at: time) } 117 scope :with_discussion_ids, ->(discussion_ids) { where(discussion_id: discussion_ids) } 118 scope :with_suggestions, -> { joins(:suggestions) } 119 scope :inc_author, -> { includes(:author) } 120 scope :with_api_entity_associations, -> { preload(:note_diff_file, :author) } 121 scope :inc_relations_for_view, -> do 122 includes(:project, { author: :status }, :updated_by, :resolved_by, :award_emoji, 123 { system_note_metadata: :description_version }, :note_diff_file, :diff_note_positions, :suggestions) 124 end 125 126 scope :with_notes_filter, -> (notes_filter) do 127 case notes_filter 128 when UserPreference::NOTES_FILTERS[:only_comments] 129 user 130 when UserPreference::NOTES_FILTERS[:only_activity] 131 system 132 else 133 all 134 end 135 end 136 137 scope :diff_notes, -> { where(type: %w(LegacyDiffNote DiffNote)) } 138 scope :new_diff_notes, -> { where(type: 'DiffNote') } 139 scope :non_diff_notes, -> { where(type: ['Note', 'DiscussionNote', nil]) } 140 141 scope :with_associations, -> do 142 # FYI noteable cannot be loaded for LegacyDiffNote for commits 143 includes(:author, :noteable, :updated_by, 144 project: [:project_members, :namespace, { group: [:group_members] }]) 145 end 146 scope :with_metadata, -> { includes(:system_note_metadata) } 147 scope :with_web_entity_associations, -> { preload(:project, :author, :noteable) } 148 149 scope :for_note_or_capitalized_note, ->(text) { where(note: [text, text.capitalize]) } 150 scope :like_note_or_capitalized_note, ->(text) { where('(note LIKE ? OR note LIKE ?)', text, text.capitalize) } 151 152 before_validation :nullify_blank_type, :nullify_blank_line_code 153 after_save :keep_around_commit, if: :for_project_noteable?, unless: -> { importing? || skip_keep_around_commits } 154 after_save :expire_etag_cache, unless: :importing? 155 after_save :touch_noteable, unless: :importing? 156 after_destroy :expire_etag_cache 157 after_commit :notify_after_create, on: :create 158 after_commit :notify_after_destroy, on: :destroy 159 160 class << self 161 extend Gitlab::Utils::Override 162 163 def model_name 164 ActiveModel::Name.new(self, nil, 'note') 165 end 166 167 def discussions(context_noteable = nil) 168 Discussion.build_collection(all.includes(:noteable).fresh, context_noteable) 169 end 170 171 # Note: Where possible consider using Discussion#lazy_find to return 172 # Discussions in order to benefit from having records batch loaded. 173 def find_discussion(discussion_id) 174 notes = where(discussion_id: discussion_id).fresh.to_a 175 176 return if notes.empty? 177 178 Discussion.build(notes) 179 end 180 181 # Group diff discussions by line code or file path. 182 # It is not needed to group by line code when comment is 183 # on an image. 184 def grouped_diff_discussions(diff_refs = nil) 185 groups = {} 186 187 diff_notes.fresh.discussions.each do |discussion| 188 group_key = 189 if discussion.on_image? 190 discussion.file_new_path 191 else 192 discussion.line_code_in_diffs(diff_refs) 193 end 194 195 if group_key 196 discussions = groups[group_key] ||= [] 197 discussions << discussion 198 end 199 end 200 201 groups 202 end 203 204 def positions 205 where.not(position: nil) 206 .select(:id, :type, :position) # ActiveRecord needs id and type for typecasting. 207 .map(&:position) 208 end 209 210 def count_for_collection(ids, type, count_column = 'COUNT(*) as count') 211 user.select(:noteable_id, count_column) 212 .group(:noteable_id) 213 .where(noteable_type: type, noteable_id: ids) 214 end 215 216 def search(query) 217 fuzzy_search(query, [:note]) 218 end 219 220 # Override the `Sortable` module's `.simple_sorts` to remove name sorting, 221 # as a `Note` does not have any property that correlates to a "name". 222 override :simple_sorts 223 def simple_sorts 224 super.except('name_asc', 'name_desc') 225 end 226 227 def cherry_picked_merge_requests(shas) 228 where(noteable_type: 'MergeRequest', commit_id: shas).select(:noteable_id) 229 end 230 end 231 232 # rubocop: disable CodeReuse/ServiceClass 233 def system_note_with_references? 234 return unless system? 235 236 if force_cross_reference_regex_check? 237 matches_cross_reference_regex? 238 else 239 ::SystemNotes::IssuablesService.cross_reference?(note) 240 end 241 end 242 # rubocop: enable CodeReuse/ServiceClass 243 244 def diff_note? 245 false 246 end 247 248 def active? 249 true 250 end 251 252 def max_attachment_size 253 Gitlab::CurrentSettings.max_attachment_size.megabytes.to_i 254 end 255 256 def hook_attrs 257 Gitlab::HookData::NoteBuilder.new(self).build 258 end 259 260 def supports_suggestion? 261 false 262 end 263 264 def for_commit? 265 noteable_type == "Commit" 266 end 267 268 def for_issue? 269 noteable_type == "Issue" 270 end 271 272 def for_merge_request? 273 noteable_type == "MergeRequest" 274 end 275 276 def for_snippet? 277 noteable_type == "Snippet" 278 end 279 280 def for_alert_mangement_alert? 281 noteable_type == 'AlertManagement::Alert' 282 end 283 284 def for_vulnerability? 285 noteable_type == "Vulnerability" 286 end 287 288 def for_project_snippet? 289 noteable.is_a?(ProjectSnippet) 290 end 291 292 def for_personal_snippet? 293 noteable.is_a?(PersonalSnippet) 294 end 295 296 def for_project_noteable? 297 !for_personal_snippet? 298 end 299 300 def for_design? 301 noteable_type == DesignManagement::Design.name 302 end 303 304 def for_issuable? 305 for_issue? || for_merge_request? 306 end 307 308 def skip_project_check? 309 !for_project_noteable? 310 end 311 312 def commit 313 @commit ||= project.commit(commit_id) if commit_id.present? 314 end 315 316 # Notes on merge requests and commits can be traced back to one or several 317 # MRs. This method returns a relation if the note is for one of these types, 318 # or nil if it is a note on some other object. 319 def merge_requests 320 if for_commit? 321 project.merge_requests.by_commit_sha(commit_id) 322 elsif for_merge_request? 323 MergeRequest.id_in(noteable_id) 324 else 325 nil 326 end 327 end 328 329 # override to return commits, which are not active record 330 def noteable 331 return commit if for_commit? 332 333 super 334 rescue StandardError 335 # Temp fix to prevent app crash 336 # if note commit id doesn't exist 337 nil 338 end 339 340 # FIXME: Hack for polymorphic associations with STI 341 # For more information visit http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#label-Polymorphic+Associations 342 def noteable_type=(noteable_type) 343 super(noteable_type.to_s.classify.constantize.base_class.to_s) 344 end 345 346 def noteable_assignee_or_author?(user) 347 return false unless user 348 return false unless noteable.respond_to?(:author_id) 349 return noteable.assignee_or_author?(user) if [MergeRequest, Issue].include?(noteable.class) 350 351 noteable.author_id == user.id 352 end 353 354 def contributor? 355 project&.team&.contributor?(self.author_id) 356 end 357 358 def noteable_author?(noteable) 359 noteable.author == self.author 360 end 361 362 def project_name 363 project&.name 364 end 365 366 def confidential?(include_noteable: false) 367 return true if confidential 368 369 include_noteable && noteable.try(:confidential?) 370 end 371 372 def editable? 373 !system? 374 end 375 376 # We used `last_edited_at` as an alias of `updated_at` before. 377 # This makes it compatible with the previous way without data migration. 378 def last_edited_at 379 super || updated_at 380 end 381 382 # Since we used `updated_at` as `last_edited_at`, it could be touched by transforming / resolving a note. 383 # This makes sure it is only marked as edited when the note body is updated. 384 def edited? 385 return false if updated_by.blank? 386 387 super 388 end 389 390 def award_emoji? 391 can_be_award_emoji? && contains_emoji_only? 392 end 393 394 def emoji_awardable? 395 !system? 396 end 397 398 def can_be_award_emoji? 399 noteable.is_a?(Awardable) && !part_of_discussion? 400 end 401 402 def contains_emoji_only? 403 note =~ /\A#{Banzai::Filter::EmojiFilter.emoji_pattern}\s?\Z/ 404 end 405 406 def noteable_ability_name 407 if for_snippet? 408 'snippet' 409 elsif for_alert_mangement_alert? 410 'alert_management_alert' 411 elsif for_vulnerability? 412 'security_resource' 413 else 414 noteable_type.demodulize.underscore 415 end 416 end 417 418 def can_be_discussion_note? 419 self.noteable.supports_discussions? && !part_of_discussion? 420 end 421 422 def can_create_todo? 423 # Skip system notes, and notes on snippets 424 !system? && !for_snippet? 425 end 426 427 def discussion_class(noteable = nil) 428 # When commit notes are rendered on an MR's Discussion page, they are 429 # displayed in one discussion instead of individually. 430 # See also `#discussion_id` and `Discussion.override_discussion_id`. 431 if noteable && noteable != self.noteable 432 OutOfContextDiscussion 433 else 434 IndividualNoteDiscussion 435 end 436 end 437 438 # See `Discussion.override_discussion_id` for details. 439 def discussion_id(noteable = nil) 440 discussion_class(noteable).override_discussion_id(self) || super() || ensure_discussion_id 441 end 442 443 # Returns a discussion containing just this note. 444 # This method exists as an alternative to `#discussion` to use when the methods 445 # we intend to call on the Discussion object don't require it to have all of its notes, 446 # and just depend on the first note or the type of discussion. This saves us a DB query. 447 def to_discussion(noteable = nil) 448 Discussion.build([self], noteable) 449 end 450 451 # Returns the entire discussion this note is part of. 452 # Consider using `#to_discussion` if we do not need to render the discussion 453 # and all its notes and if we don't care about the discussion's resolvability status. 454 def discussion 455 strong_memoize(:discussion) do 456 full_discussion = self.noteable.notes.find_discussion(self.discussion_id) if part_of_discussion? 457 full_discussion || to_discussion 458 end 459 end 460 461 def start_of_discussion? 462 discussion.first_note == self 463 end 464 465 def part_of_discussion? 466 !to_discussion.individual_note? 467 end 468 469 def in_reply_to?(other) 470 case other 471 when Note 472 if part_of_discussion? 473 in_reply_to?(other.noteable) && in_reply_to?(other.to_discussion) 474 else 475 in_reply_to?(other.noteable) 476 end 477 when Discussion 478 self.discussion_id == other.id 479 when Noteable 480 self.noteable == other 481 else 482 false 483 end 484 end 485 486 def references 487 refs = [noteable] 488 489 if part_of_discussion? 490 refs += discussion.notes.take_while { |n| n.id < id } 491 end 492 493 refs 494 end 495 496 def bump_updated_at 497 # Instead of calling touch which is throttled via ThrottledTouch concern, 498 # we bump the updated_at column directly. This also prevents executing 499 # after_commit callbacks that we don't need. 500 update_column(:updated_at, Time.current) 501 end 502 503 def expire_etag_cache 504 noteable&.expire_note_etag_cache 505 end 506 507 def touch(*args, **kwargs) 508 # We're not using an explicit transaction here because this would in all 509 # cases result in all future queries going to the primary, even if no writes 510 # are performed. 511 # 512 # We touch the noteable first so its SELECT query can run before our writes, 513 # ensuring it runs on a secondary (if no prior write took place). 514 touch_noteable 515 super 516 end 517 518 # By default Rails will issue an "SELECT *" for the relation, which is 519 # overkill for just updating the timestamps. To work around this we manually 520 # touch the data so we can SELECT only the columns we need. 521 def touch_noteable 522 # Commits are not stored in the DB so we can't touch them. 523 return if for_commit? 524 525 assoc = association(:noteable) 526 527 noteable_object = 528 if assoc.loaded? 529 noteable 530 else 531 # If the object is not loaded (e.g. when notes are loaded async) we 532 # _only_ want the data we actually need. 533 assoc.scope.select(:id, :updated_at).take 534 end 535 536 noteable_object&.touch 537 538 # We return the noteable object so we can re-use it in EE for Elasticsearch. 539 noteable_object 540 end 541 542 def notify_after_create 543 noteable&.after_note_created(self) 544 end 545 546 def notify_after_destroy 547 noteable&.after_note_destroyed(self) 548 end 549 550 def banzai_render_context(field) 551 super.merge(noteable: noteable, system_note: system?, label_url_method: noteable_label_url_method) 552 end 553 554 def retrieve_upload(_identifier, paths) 555 Upload.find_by(model: self, path: paths) 556 end 557 558 def resource_parent 559 project 560 end 561 562 def user_mentions 563 return Note.none unless noteable.present? 564 565 noteable.user_mentions.where(note: self) 566 end 567 568 def system_note_with_references_visible_for?(user) 569 return true unless system? 570 571 (!system_note_with_references? || all_referenced_mentionables_allowed?(user)) && system_note_viewable_by?(user) 572 end 573 574 def parent_user 575 noteable.author if for_personal_snippet? 576 end 577 578 def skip_notification? 579 review.present? || !author.can_trigger_notifications? 580 end 581 582 def post_processed_cache_key 583 cache_key_items = [cache_key, author&.cache_key] 584 cache_key_items << project.team.human_max_access(author&.id) if author.present? 585 cache_key_items << Digest::SHA1.hexdigest(redacted_note_html) if redacted_note_html.present? 586 587 cache_key_items.join(':') 588 end 589 590 override :user_mention_class 591 def user_mention_class 592 return if noteable.blank? 593 594 noteable.user_mention_class 595 end 596 597 override :user_mention_identifier 598 def user_mention_identifier 599 return if noteable.blank? 600 601 noteable.user_mention_identifier.merge({ 602 note_id: id 603 }) 604 end 605 606 def show_outdated_changes? 607 return false unless for_merge_request? 608 return false unless Feature.enabled?(:display_outdated_line_diff, noteable.source_project, default_enabled: :yaml) 609 return false unless system? 610 return false unless change_position&.line_range 611 612 change_position.line_range["end"] || change_position.line_range["start"] 613 end 614 615 private 616 617 def system_note_viewable_by?(user) 618 return true unless system_note_metadata 619 620 restriction = TYPES_RESTRICTED_BY_ABILITY[system_note_metadata.action.to_sym] 621 return Ability.allowed?(user, restriction, project) if restriction 622 623 true 624 end 625 626 def keep_around_commit 627 project.repository.keep_around(self.commit_id) 628 end 629 630 def nullify_blank_type 631 self.type = nil if self.type.blank? 632 end 633 634 def nullify_blank_line_code 635 self.line_code = nil if self.line_code.blank? 636 end 637 638 def ensure_discussion_id 639 return if self.attribute_present?(:discussion_id) 640 641 self.discussion_id = derive_discussion_id 642 end 643 644 def derive_discussion_id 645 discussion_class.discussion_id(self) 646 end 647 648 def all_referenced_mentionables_allowed?(user) 649 if user_visible_reference_count.present? && total_reference_count.present? 650 # if they are not equal, then there are private/confidential references as well 651 user_visible_reference_count > 0 && user_visible_reference_count == total_reference_count 652 else 653 refs = all_references(user) 654 refs.all.any? && refs.all_visible? 655 end 656 end 657 658 def force_cross_reference_regex_check? 659 return unless system? 660 661 system_note_metadata&.cross_reference_types&.include?(system_note_metadata&.action) 662 end 663 664 def does_not_exceed_notes_limit? 665 return unless noteable 666 667 errors.add(:base, _('Maximum number of comments exceeded')) if noteable.notes.count >= Noteable::MAX_NOTES_LIMIT 668 end 669 670 def noteable_label_url_method 671 for_merge_request? ? :project_merge_requests_url : :project_issues_url 672 end 673end 674 675Note.prepend_mod_with('Note') 676