mentionable.rb 5.01 KB
Newer Older
1 2
# == Mentionable concern
#
3
# Contains functionality related to objects that can mention Users, Issues, MergeRequests, Commits or Snippets by
4
# GFM references.
5
#
6
# Used by Issue, Note, MergeRequest, and Commit.
7 8 9 10
#
module Mentionable
  extend ActiveSupport::Concern

11 12
  module ClassMethods
    # Indicate which attributes of the Mentionable to search for GFM references.
13 14 15
    def attr_mentionable(attr, options = {})
      attr = attr.to_s
      mentionable_attrs << [attr, options]
16
    end
17
  end
18

19
  included do
20
    # Accessor for attributes marked mentionable.
21 22
    cattr_accessor :mentionable_attrs, instance_accessor: false do
      []
23 24
    end

25
    if self < Participable
Yorick Peterse's avatar
Yorick Peterse committed
26
      participant -> (user, ext) { all_references(user, extractor: ext) }
27 28 29
    end
  end

30 31 32 33
  # Returns the text used as the body of a Note when this object is referenced
  #
  # By default this will be the class name and the result of calling
  # `to_reference` on the object.
34
  def gfm_reference(from_project = nil)
35
    # "MergeRequest" > "merge_request" > "Merge request" > "merge request"
36 37
    friendly_name = self.class.to_s.underscore.humanize.downcase

38
    "#{friendly_name} #{to_reference(from_project)}"
39 40 41 42 43 44 45
  end

  # The GFM reference to this Mentionable, which shouldn't be included in its #references.
  def local_reference
    self
  end

46
  def all_references(current_user = nil, extractor: nil)
47 48
    @extractors ||= {}

49 50
    # Use custom extractor if it's passed in the function parameters.
    if extractor
51
      @extractors[current_user] = extractor
52
    else
53
      extractor = @extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user)
54

55
      extractor.reset_memoized_values
56
    end
57

58
    self.class.mentionable_attrs.each do |attr, options|
59
      text    = __send__(attr) # rubocop:disable GitlabSecurity/PublicSend
60 61 62
      options = options.merge(
        cache_key: [self, attr],
        author: author,
Jarka Kadlecova's avatar
Jarka Kadlecova committed
63
        skip_project_check: skip_project_check?
64
      )
65

66
      extractor.analyze(text, options)
67 68
    end

69
    extractor
70 71
  end

72 73
  def mentioned_users(current_user = nil)
    all_references(current_user).users
74 75
  end

76 77 78 79
  def directly_addressed_users(current_user = nil)
    all_references(current_user).directly_addressed_users
  end

80
  # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference.
81
  def referenced_mentionables(current_user = self.author)
82 83
    return [] unless matches_cross_reference_regex?

84
    refs = all_references(current_user)
Douwe Maan's avatar
Douwe Maan committed
85 86 87 88 89
    refs = (refs.issues + refs.merge_requests + refs.commits)

    # We're using this method instead of Array diffing because that requires
    # both of the object's `hash` values to be the same, which may not be the
    # case for otherwise identical Commit objects.
90
    refs.reject { |ref| ref == local_reference }
91
  end
92

93 94 95
  # Uses regex to quickly determine if mentionables might be referenced
  # Allows heavy processing to be skipped
  def matches_cross_reference_regex?
96
    reference_pattern = if !project || project.default_issues_tracker?
97 98 99 100 101 102
                          ReferenceRegexes::DEFAULT_PATTERN
                        else
                          ReferenceRegexes::EXTERNAL_PATTERN
                        end

    self.class.mentionable_attrs.any? do |attr, _|
103
      __send__(attr) =~ reference_pattern # rubocop:disable GitlabSecurity/PublicSend
104 105 106
    end
  end

107
  # Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+.
108 109
  def create_cross_references!(author = self.author, without = [])
    refs = referenced_mentionables(author)
Douwe Maan's avatar
Douwe Maan committed
110

111 112 113
    # We're using this method instead of Array diffing because that requires
    # both of the object's `hash` values to be the same, which may not be the
    # case for otherwise identical Commit objects.
114
    refs.reject! { |ref| without.include?(ref) || cross_reference_exists?(ref) }
115

116
    refs.each do |ref|
117
      SystemNoteService.cross_reference(ref, local_reference, author)
118 119 120
    end
  end

121 122
  # When a mentionable field is changed, creates cross-reference notes that
  # don't already exist
123
  def create_new_cross_references!(author = self.author)
124 125 126
    changes = detect_mentionable_changes

    return if changes.empty?
127

128
    create_cross_references!(author)
129
  end
130 131 132 133 134 135 136 137 138 139 140 141 142 143 144

  private

  # Returns a Hash of changed mentionable fields
  #
  # Preference is given to the `changes` Hash, but falls back to
  # `previous_changes` if it's empty (i.e., the changes have already been
  # persisted).
  #
  # See ActiveModel::Dirty.
  #
  # Returns a Hash.
  def detect_mentionable_changes
    source = (changes.present? ? changes : previous_changes).dup

145
    mentionable = self.class.mentionable_attrs.map { |attr, options| attr }
146 147 148 149

    # Only include changed fields that are mentionable
    source.select { |key, val| mentionable.include?(key) }
  end
Douwe Maan's avatar
Douwe Maan committed
150

151 152 153 154 155
  # Determine whether or not a cross-reference Note has already been created between this Mentionable and
  # the specified target.
  def cross_reference_exists?(target)
    SystemNoteService.cross_reference_exists?(target, local_reference)
  end
Jarka Kadlecova's avatar
Jarka Kadlecova committed
156 157 158 159

  def skip_project_check?
    false
  end
160
end