note.rb 15.8 KB
Newer Older
1 2 3 4 5 6 7 8
# == Schema Information
#
# Table name: notes
#
#  id            :integer          not null, primary key
#  note          :text
#  noteable_type :string(255)
#  author_id     :integer
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
9 10
#  created_at    :datetime
#  updated_at    :datetime
11 12 13
#  project_id    :integer
#  attachment    :string(255)
#  line_code     :string(255)
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
14 15
#  commit_id     :string(255)
#  noteable_id   :integer
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
16
#  system        :boolean          default(FALSE), not null
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
17
#  st_diff       :text
18 19
#

gitlabhq's avatar
gitlabhq committed
20 21 22 23
require 'carrierwave/orm/activerecord'
require 'file_size_validator'

class Note < ActiveRecord::Base
24
  include Mentionable
25
  include Gitlab::CurrentSettings
26

27 28
  default_value_for :system, false

29
  attr_mentionable :note
30

gitlabhq's avatar
gitlabhq committed
31
  belongs_to :project
32
  belongs_to :noteable, polymorphic: true
Nihad Abbasov's avatar
Nihad Abbasov committed
33
  belongs_to :author, class_name: "User"
gitlabhq's avatar
gitlabhq committed
34

Nihad Abbasov's avatar
Nihad Abbasov committed
35 36
  delegate :name, to: :project, prefix: true
  delegate :name, :email, to: :author, prefix: true
37

38
  validates :note, :project, presence: true
39
  validates :line_code, format: { with: /\A[a-z0-9]+_\d+_\d+\Z/ }, allow_blank: true
40 41
  # Attachments are deprecated and are handled by Markdown uploader
  validates :attachment, file_size: { maximum: :max_attachment_size }
gitlabhq's avatar
gitlabhq committed
42

43 44 45
  validates :noteable_id, presence: true, if: ->(n) { n.noteable_type.present? && n.noteable_type != 'Commit' }
  validates :commit_id, presence: true, if: ->(n) { n.noteable_type == 'Commit' }

46
  mount_uploader :attachment, AttachmentUploader
Andrey Kumanyaev's avatar
Andrey Kumanyaev committed
47 48

  # Scopes
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
49
  scope :for_commit_id, ->(commit_id) { where(noteable_type: "Commit", commit_id: commit_id) }
50 51
  scope :inline, ->{ where("line_code IS NOT NULL") }
  scope :not_inline, ->{ where(line_code: [nil, '']) }
52
  scope :system, ->{ where(system: true) }
53
  scope :user, ->{ where(system: false) }
54
  scope :common, ->{ where(noteable_type: ["", nil]) }
55
  scope :fresh, ->{ order(created_at: :asc, id: :asc) }
56 57
  scope :inc_author_project, ->{ includes(:project, :author) }
  scope :inc_author, ->{ includes(:author) }
gitlabhq's avatar
gitlabhq committed
58

59
  serialize :st_diff
60
  before_create :set_diff, if: ->(n) { n.line_code.present? }
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
61
  after_update :set_references
62

63 64
  class << self
    def create_status_change_note(noteable, project, author, status, source)
65
      body = "Status changed to #{status}#{' by ' + source.gfm_reference if source}"
66

67
      create(
68 69 70 71 72
        noteable: noteable,
        project: project,
        author: author,
        note: body,
        system: true
73
      )
74
    end
75

76 77 78 79
    # +noteable+ was referenced from +mentioner+, by including GFM in either
    # +mentioner+'s description or an associated Note.
    # Create a system Note associated with +noteable+ with a GFM back-reference
    # to +mentioner+.
80
    def create_cross_reference_note(noteable, mentioner, author, project)
81 82
      gfm_reference = mentioner_gfm_ref(noteable, mentioner, project)

83
      note_options = {
84 85
        project: project,
        author: author,
86
        note: cross_reference_note_content(gfm_reference),
87
        system: true
88 89 90 91 92 93 94 95
      }

      if noteable.kind_of?(Commit)
        note_options.merge!(noteable_type: 'Commit', commit_id: noteable.id)
      else
        note_options.merge!(noteable: noteable)
      end

96
      create(note_options) unless cross_reference_disallowed?(noteable, mentioner)
97 98
    end

99 100
    def create_milestone_change_note(noteable, project, author, milestone)
      body = if milestone.nil?
101
               'Milestone removed'
102
             else
103
               "Milestone changed to #{milestone.title}"
104 105
             end

106
      create(
107 108 109 110 111
        noteable: noteable,
        project: project,
        author: author,
        note: body,
        system: true
112
      )
113 114
    end

115
    def create_assignee_change_note(noteable, project, author, assignee)
116
      body = assignee.nil? ? 'Assignee removed' : "Reassigned to @#{assignee.username}"
117 118 119 120 121 122 123

      create({
        noteable: noteable,
        project: project,
        author: author,
        note: body,
        system: true
124
      })
125 126
    end

Nikita Verkhovin's avatar
Nikita Verkhovin committed
127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145
    def create_labels_change_note(noteable, project, author, added_labels, removed_labels)
      labels_count = added_labels.count + removed_labels.count
      added_labels = added_labels.map{ |label| "~#{label.id}" }.join(' ')
      removed_labels = removed_labels.map{ |label| "~#{label.id}" }.join(' ')
      message = ''

      if added_labels.present?
        message << "added #{added_labels}"
      end

      if added_labels.present? && removed_labels.present?
        message << ' and '
      end

      if removed_labels.present?
        message << "removed #{removed_labels}"
      end

      message << ' ' << 'label'.pluralize(labels_count)
146
      body = "#{message.capitalize}"
Nikita Verkhovin's avatar
Nikita Verkhovin committed
147 148 149 150 151 152 153 154 155 156

      create(
        noteable: noteable,
        project: project,
        author: author,
        note: body,
        system: true
      )
    end

157
    def create_new_commits_note(merge_request, project, author, new_commits, existing_commits = [], oldrev = nil)
158 159
      total_count = new_commits.length + existing_commits.length
      commits_text = ActionController::Base.helpers.pluralize(total_count, 'commit')
160
      body = "Added #{commits_text}:\n\n"
161

162 163 164 165 166
      if existing_commits.length > 0
        commit_ids =
          if existing_commits.length == 1
            existing_commits.first.short_id
          else
167 168 169 170 171
            if oldrev
              "#{Commit.truncate_sha(oldrev)}...#{existing_commits.last.short_id}"
            else
              "#{existing_commits.first.short_id}..#{existing_commits.last.short_id}"
            end
172 173 174 175
          end

        commits_text = ActionController::Base.helpers.pluralize(existing_commits.length, 'commit')

176
        branch =
177 178 179 180 181 182
          if merge_request.for_fork?
            "#{merge_request.target_project_namespace}:#{merge_request.target_branch}"
          else
            merge_request.target_branch
          end

183
        message = "* #{commit_ids} - #{commits_text} from branch `#{branch}`"
184 185 186 187 188
        body << message
        body << "\n"
      end

      new_commits.each do |commit|
189 190 191 192 193 194
        message = "* #{commit.short_id} - #{commit.title}"
        body << message
        body << "\n"
      end

      create(
195
        noteable: merge_request,
196 197 198 199 200 201 202
        project: project,
        author: author,
        note: body,
        system: true
      )
    end

203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222
    def discussions_from_notes(notes)
      discussion_ids = []
      discussions = []

      notes.each do |note|
        next if discussion_ids.include?(note.discussion_id)

        # don't group notes for the main target
        if !note.for_diff_line? && note.noteable_type == "MergeRequest"
          discussions << [note]
        else
          discussions << notes.select do |other_note|
            note.discussion_id == other_note.discussion_id
          end
          discussion_ids << note.discussion_id
        end
      end

      discussions
    end
223

224 225 226 227
    def build_discussion_id(type, id, line_code)
      [:discussion, type.try(:underscore), id, line_code].join("-").to_sym
    end

228 229 230 231 232 233 234 235 236
    # Determine if cross reference note should be created.
    # eg. mentioning a commit in MR comments which exists inside a MR
    # should not create "mentioned in" note.
    def cross_reference_disallowed?(noteable, mentioner)
      if mentioner.kind_of?(MergeRequest)
        mentioner.commits.map(&:id).include? noteable.id
      end
    end

237 238
    # Determine whether or not a cross-reference note already exists.
    def cross_reference_exists?(noteable, mentioner)
239
      gfm_reference = mentioner_gfm_ref(noteable, mentioner)
240 241 242 243 244 245
      notes = if noteable.is_a?(Commit)
                where(commit_id: noteable.id)
              else
                where(noteable_id: noteable.id)
              end

246
      notes.where('note like ?', cross_reference_note_pattern(gfm_reference)).
247
        system.any?
248
    end
249 250 251 252

    def search(query)
      where("note like :query", query: "%#{query}%")
    end
253

254
    def cross_reference_note_prefix
255
      'mentioned in '
256 257
    end

258 259
    private

260
    def cross_reference_note_content(gfm_reference)
261 262 263 264 265 266
      cross_reference_note_prefix + "#{gfm_reference}"
    end

    def cross_reference_note_pattern(gfm_reference)
      # Older cross reference notes contained underscores for emphasis
      "%" + cross_reference_note_content(gfm_reference) + "%"
267 268
    end

269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323
    # Prepend the mentioner's namespaced project path to the GFM reference for
    # cross-project references.  For same-project references, return the
    # unmodified GFM reference.
    def mentioner_gfm_ref(noteable, mentioner, project = nil)
      if mentioner.is_a?(Commit)
        if project.nil?
          return mentioner.gfm_reference.sub('commit ', 'commit %')
        else
          mentioning_project = project
        end
      else
        mentioning_project = mentioner.project
      end

      noteable_project_id = noteable_project_id(noteable, mentioning_project)

      full_gfm_reference(mentioning_project, noteable_project_id, mentioner)
    end

    # Return the ID of the project that +noteable+ belongs to, or nil if
    # +noteable+ is a commit and is not part of the project that owns
    # +mentioner+.
    def noteable_project_id(noteable, mentioning_project)
      if noteable.is_a?(Commit)
        if mentioning_project.repository.commit(noteable.id)
          # The noteable commit belongs to the mentioner's project
          mentioning_project.id
        else
          nil
        end
      else
        noteable.project.id
      end
    end

    # Return the +mentioner+ GFM reference.  If the mentioner and noteable
    # projects are not the same, add the mentioning project's path to the
    # returned value.
    def full_gfm_reference(mentioning_project, noteable_project_id, mentioner)
      if mentioning_project.id == noteable_project_id
        mentioner.gfm_reference
      else
        if mentioner.is_a?(Commit)
          mentioner.gfm_reference.sub(
            /(commit )/,
            "\\1#{mentioning_project.path_with_namespace}@"
          )
        else
          mentioner.gfm_reference.sub(
            /(issue |merge request )/,
            "\\1#{mentioning_project.path_with_namespace}"
          )
        end
      end
    end
324 325
  end

326 327 328 329
  def max_attachment_size
    current_application_settings.max_attachment_size.megabytes.to_i
  end

330 331
  def commit_author
    @commit_author ||=
332 333
      project.team.users.find_by(email: noteable.author_email) ||
      project.team.users.find_by(name: noteable.author_name)
334 335
  rescue
    nil
Valery Sizov's avatar
Valery Sizov committed
336
  end
Cedric Gatay's avatar
Cedric Gatay committed
337

338 339 340 341
  def cross_reference?
    note.start_with?(self.class.cross_reference_note_prefix)
  end

342
  def find_diff
343
    return nil unless noteable && noteable.diffs.present?
344 345 346

    @diff ||= noteable.diffs.find do |d|
      Digest::SHA1.hexdigest(d.new_path) == diff_file_index if d.new_path
347
    end
348 349
  end

350 351 352 353
  def hook_attrs
    attributes
  end

354 355 356
  def set_diff
    # First lets find notes with same diff
    # before iterating over all mr diffs
357
    diff = Note.where(noteable_id: self.noteable_id, noteable_type: self.noteable_type, line_code: self.line_code).last.try(:diff)
358 359 360 361 362 363 364 365 366
    diff ||= find_diff

    self.st_diff = diff.to_hash if diff
  end

  def diff
    @diff ||= Gitlab::Git::Diff.new(st_diff) if st_diff.respond_to?(:map)
  end

Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
367 368 369
  # Check if such line of code exists in merge request diff
  # If exists - its active discussion
  # If not - its outdated diff
370
  def active?
371
    return true unless self.diff
372
    return false unless noteable
373

Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
374 375 376
    noteable.diffs.each do |mr_diff|
      next unless mr_diff.new_path == self.diff.new_path

377
      lines = Gitlab::Diff::Parser.new.parse(mr_diff.diff.lines.to_a)
378 379 380

      lines.each do |line|
        if line.text == diff_line
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
381 382 383 384 385 386 387 388 389 390
          return true
        end
      end
    end

    false
  end

  def outdated?
    !active?
391 392
  end

393
  def diff_file_index
394
    line_code.split('_')[0] if line_code
395 396 397
  end

  def diff_file_name
398
    diff.new_path if diff
399 400
  end

401 402 403 404 405 406 407 408
  def file_path
    if diff.new_path.present?
      diff.new_path
    elsif diff.old_path.present?
      diff.old_path
    end
  end

409
  def diff_old_line
410
    line_code.split('_')[1].to_i if line_code
411 412 413
  end

  def diff_new_line
414
    line_code.split('_')[2].to_i if line_code
415 416
  end

417 418 419 420
  def generate_line_code(line)
    Gitlab::Diff::LineCode.generate(file_path, line.new_pos, line.old_pos)
  end

421
  def diff_line
422 423
    return @diff_line if @diff_line

424
    if diff
425
      diff_lines.each do |line|
426 427 428
        if generate_line_code(line) == self.line_code
          @diff_line = line.text
        end
429
      end
430
    end
431 432

    @diff_line
433 434
  end

435 436 437 438 439 440 441 442 443 444 445 446 447 448
  def diff_line_type
    return @diff_line_type if @diff_line_type

    if diff
      diff_lines.each do |line|
        if generate_line_code(line) == self.line_code
          @diff_line_type = line.type
        end
      end
    end

    @diff_line_type
  end

449 450 451 452 453 454
  def truncated_diff_lines
    max_number_of_lines = 16
    prev_match_line = nil
    prev_lines = []

    diff_lines.each do |line|
455 456 457
      if line.type == "match"
        prev_lines.clear
        prev_match_line = line
458 459
      else
        prev_lines << line
460

461 462 463
        break if generate_line_code(line) == self.line_code

        prev_lines.shift if prev_lines.length >= max_number_of_lines
464 465
      end
    end
466 467

    prev_lines
468 469 470
  end

  def diff_lines
471
    @diff_lines ||= Gitlab::Diff::Parser.new.parse(diff.diff.lines.to_a)
472 473
  end

474
  def discussion_id
475
    @discussion_id ||= Note.build_discussion_id(noteable_type, noteable_id || commit_id, line_code)
476 477 478 479 480
  end

  # Returns true if this is a downvote note,
  # otherwise false is returned
  def downvote?
Riyad Preukschas's avatar
Riyad Preukschas committed
481
    votable? && (note.start_with?('-1') ||
482
                 note.start_with?(':-1:') ||
483 484
                 note.start_with?(':thumbsdown:') ||
                 note.start_with?(':thumbs_down_sign:')
485
                )
486 487 488 489 490 491 492 493 494 495 496 497 498 499
  end

  def for_commit?
    noteable_type == "Commit"
  end

  def for_commit_diff_line?
    for_commit? && for_diff_line?
  end

  def for_diff_line?
    line_code.present?
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
500 501 502 503
  def for_issue?
    noteable_type == "Issue"
  end

504 505 506 507 508 509
  def for_merge_request?
    noteable_type == "MergeRequest"
  end

  def for_merge_request_diff_line?
    for_merge_request? && for_diff_line?
Cedric Gatay's avatar
Cedric Gatay committed
510
  end
511

512 513 514 515
  def for_project_snippet?
    noteable_type == "Snippet"
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
516 517 518
  # override to return commits, which are not active record
  def noteable
    if for_commit?
519
      project.repository.commit(commit_id)
520
    else
Riyad Preukschas's avatar
Riyad Preukschas committed
521
      super
522
    end
523 524
  # Temp fix to prevent app crash
  # if note commit id doesn't exist
525
  rescue
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
526
    nil
527
  end
528

529 530 531
  # Returns true if this is an upvote note,
  # otherwise false is returned
  def upvote?
Riyad Preukschas's avatar
Riyad Preukschas committed
532
    votable? && (note.start_with?('+1') ||
533
                 note.start_with?(':+1:') ||
534 535
                 note.start_with?(':thumbsup:') ||
                 note.start_with?(':thumbs_up_sign:')
536
                )
Riyad Preukschas's avatar
Riyad Preukschas committed
537 538
  end

539 540
  def superceded?(notes)
    return false unless vote?
541

542 543
    notes.each do |note|
      next if note == self
544

545
      if note.vote? &&
546 547
        self[:author_id] == note[:author_id] &&
        self[:created_at] <= note[:created_at]
548 549 550
        return true
      end
    end
551

552 553 554 555 556 557 558
    false
  end

  def vote?
    upvote? || downvote?
  end

Riyad Preukschas's avatar
Riyad Preukschas committed
559 560
  def votable?
    for_issue? || (for_merge_request? && !for_diff_line?)
561
  end
562

563 564 565 566 567 568 569 570 571 572
  # Mentionable override.
  def gfm_reference
    noteable.gfm_reference
  end

  # Mentionable override.
  def local_reference
    noteable
  end

573 574 575 576 577
  def noteable_type_name
    if noteable_type.present?
      noteable_type.downcase
    end
  end
Andrew8xx8's avatar
Andrew8xx8 committed
578 579

  # FIXME: Hack for polymorphic associations with STI
Steven Burgart's avatar
Steven Burgart committed
580
  #        For more information visit http://api.rubyonrails.org/classes/ActiveRecord/Associations/ClassMethods.html#label-Polymorphic+Associations
Andrew8xx8's avatar
Andrew8xx8 committed
581 582 583
  def noteable_type=(sType)
    super(sType.to_s.classify.constantize.base_class.to_s)
  end
Drew Blessing's avatar
Drew Blessing committed
584 585 586 587 588 589 590 591 592 593 594

  # Reset notes events cache
  #
  # Since we do cache @event we need to reset cache in special cases:
  # * when a note is updated
  # * when a note is removed
  # Events cache stored like  events/23-20130109142513.
  # The cache key includes updated_at timestamp.
  # Thus it will automatically generate a new fragment
  # when the event is updated because the key changes.
  def reset_events_cache
595
    Event.reset_event_cache_for(self)
Drew Blessing's avatar
Drew Blessing committed
596
  end
Dmitriy Zaporozhets's avatar
Dmitriy Zaporozhets committed
597 598 599 600

  def set_references
    notice_added_references(project, author)
  end
601 602

  def editable?
603
    !read_attribute(:system)
604
  end
gitlabhq's avatar
gitlabhq committed
605
end