1# frozen_string_literal: true
2
3# == Mentionable concern
4#
5# Contains functionality related to objects that can mention Users, Issues, MergeRequests, Commits or Snippets by
6# GFM references.
7#
8# Used by Issue, Note, MergeRequest, and Commit.
9#
10module Mentionable
11  extend ActiveSupport::Concern
12
13  class_methods do
14    # Indicate which attributes of the Mentionable to search for GFM references.
15    def attr_mentionable(attr, options = {})
16      attr = attr.to_s
17      mentionable_attrs << [attr, options]
18    end
19  end
20
21  included do
22    # Accessor for attributes marked mentionable.
23    cattr_accessor :mentionable_attrs, instance_accessor: false do
24      []
25    end
26
27    if self < Participable
28      participant -> (user, ext) { all_references(user, extractor: ext) }
29    end
30  end
31
32  # Returns the text used as the body of a Note when this object is referenced
33  #
34  # By default this will be the class name and the result of calling
35  # `to_reference` on the object.
36  def gfm_reference(from = nil)
37    # "MergeRequest" > "merge_request" > "Merge request" > "merge request"
38    friendly_name = self.class.to_s.underscore.humanize.downcase
39
40    "#{friendly_name} #{to_reference(from)}"
41  end
42
43  # The GFM reference to this Mentionable, which shouldn't be included in its #references.
44  def local_reference
45    self
46  end
47
48  def all_references(current_user = nil, extractor: nil)
49    # Use custom extractor if it's passed in the function parameters.
50    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      options = options.merge(
61        cache_key: [self, attr],
62        author: author,
63        skip_project_check: skip_project_check?
64      ).merge(mentionable_params)
65
66      cached_html = self.try(:updated_cached_html_for, attr.to_sym)
67      options[:rendered] = cached_html if cached_html
68
69      extractor.analyze(text, options)
70    end
71
72    extractor
73  end
74
75  def extractors
76    @extractors ||= {}
77  end
78
79  def mentioned_users(current_user = nil)
80    all_references(current_user).users
81  end
82
83  def referenced_users
84    User.where(id: user_mentions.select("unnest(mentioned_users_ids)"))
85  end
86
87  def referenced_projects(current_user = nil)
88    Project.where(id: user_mentions.select("unnest(mentioned_projects_ids)")).public_or_visible_to_user(current_user)
89  end
90
91  def referenced_project_users(current_user = nil)
92    User.joins(:project_members).where(members: { source_id: referenced_projects(current_user) }).distinct
93  end
94
95  def referenced_groups(current_user = nil)
96    # TODO: IMPORTANT: Revisit before using it.
97    # Check DB data for max mentioned groups per mentionable:
98    #
99    # select issue_id, count(mentions_count.men_gr_id) gr_count from
100    # (select DISTINCT unnest(mentioned_groups_ids) as men_gr_id, issue_id
101    # from issue_user_mentions group by issue_id, mentioned_groups_ids) as mentions_count
102    # group by mentions_count.issue_id order by gr_count desc limit 10
103    Group.where(id: user_mentions.select("unnest(mentioned_groups_ids)")).public_or_visible_to_user(current_user)
104  end
105
106  def referenced_group_users(current_user = nil)
107    User.joins(:group_members).where(members: { source_id: referenced_groups }).distinct
108  end
109
110  def directly_addressed_users(current_user = nil)
111    all_references(current_user).directly_addressed_users
112  end
113
114  # Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference.
115  def referenced_mentionables(current_user = self.author)
116    return [] unless matches_cross_reference_regex?
117
118    refs = all_references(current_user)
119
120    # We're using this method instead of Array diffing because that requires
121    # both of the object's `hash` values to be the same, which may not be the
122    # case for otherwise identical Commit objects.
123    extracted_mentionables(refs).reject { |ref| ref == local_reference }
124  end
125
126  # Uses regex to quickly determine if mentionables might be referenced
127  # Allows heavy processing to be skipped
128  def matches_cross_reference_regex?
129    reference_pattern = if !project || project.default_issues_tracker?
130                          ReferenceRegexes.default_pattern
131                        else
132                          ReferenceRegexes.external_pattern
133                        end
134
135    self.class.mentionable_attrs.any? do |attr, _|
136      __send__(attr) =~ reference_pattern # rubocop:disable GitlabSecurity/PublicSend
137    end
138  end
139
140  # Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+.
141  def create_cross_references!(author = self.author, without = [])
142    refs = referenced_mentionables(author)
143
144    # We're using this method instead of Array diffing because that requires
145    # both of the object's `hash` values to be the same, which may not be the
146    # case for otherwise identical Commit objects.
147    refs.reject! { |ref| without.include?(ref) || cross_reference_exists?(ref) }
148
149    refs.each do |ref|
150      SystemNoteService.cross_reference(ref, local_reference, author)
151    end
152  end
153
154  # When a mentionable field is changed, creates cross-reference notes that
155  # don't already exist
156  def create_new_cross_references!(author = self.author)
157    changes = detect_mentionable_changes
158
159    return if changes.empty?
160
161    create_cross_references!(author)
162  end
163
164  def user_mention_class
165    user_mention_association.klass
166  end
167
168  # Identifier for the user mention that is parsed from model description rather then its related notes.
169  # Models that have a description attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention.
170  # Other mentionable models like DesignManagement::Design, will never have such record as those do not have
171  # a description attribute.
172  def user_mention_identifier
173    {
174      user_mention_association.foreign_key => id,
175      note_id: nil
176    }
177  end
178
179  private
180
181  def extracted_mentionables(refs)
182    refs.issues + refs.merge_requests + refs.commits
183  end
184
185  # Returns a Hash of changed mentionable fields
186  #
187  # Preference is given to the `changes` Hash, but falls back to
188  # `previous_changes` if it's empty (i.e., the changes have already been
189  # persisted).
190  #
191  # See ActiveModel::Dirty.
192  #
193  # Returns a Hash.
194  def detect_mentionable_changes
195    source = (changes.presence || previous_changes).dup
196
197    mentionable = self.class.mentionable_attrs.map { |attr, options| attr }
198
199    # Only include changed fields that are mentionable
200    source.select { |key, val| mentionable.include?(key) }
201  end
202
203  # Determine whether or not a cross-reference Note has already been created between this Mentionable and
204  # the specified target.
205  def cross_reference_exists?(target)
206    SystemNoteService.cross_reference_exists?(target, local_reference)
207  end
208
209  def skip_project_check?
210    false
211  end
212
213  def mentionable_params
214    {}
215  end
216
217  def user_mention_association
218    association(:user_mentions).reflection
219  end
220end
221
222Mentionable.prepend_mod_with('Mentionable')
223