summaryrefslogtreecommitdiff
path: root/app/models/concerns/mentionable.rb
blob: 7b4485376d4ed636f45a711c9c54437f500006b5 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
# frozen_string_literal: true

# == Mentionable concern
#
# Contains functionality related to objects that can mention Users, Issues, MergeRequests, Commits or Snippets by
# GFM references.
#
# Used by Issue, Note, MergeRequest, and Commit.
#
module Mentionable
  extend ActiveSupport::Concern

  class_methods do
    # Indicate which attributes of the Mentionable to search for GFM references.
    def attr_mentionable(attr, options = {})
      attr = attr.to_s
      mentionable_attrs << [attr, options]
    end
  end

  included do
    # Accessor for attributes marked mentionable.
    cattr_accessor :mentionable_attrs, instance_accessor: false do
      []
    end

    if self < Participable
      participant -> (user, ext) { all_references(user, extractor: ext) }
    end
  end

  # 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.
  def gfm_reference(from = nil)
    # "MergeRequest" > "merge_request" > "Merge request" > "merge request"
    friendly_name = self.class.to_s.underscore.humanize.downcase

    "#{friendly_name} #{to_reference(from)}"
  end

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

  def all_references(current_user = nil, extractor: nil)
    # Use custom extractor if it's passed in the function parameters.
    if extractor
      extractors[current_user] = extractor
    else
      extractor = extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user)

      extractor.reset_memoized_values
    end

    self.class.mentionable_attrs.each do |attr, options|
      text    = __send__(attr) # rubocop:disable GitlabSecurity/PublicSend
      options = options.merge(
        cache_key: [self, attr],
        author: author,
        skip_project_check: skip_project_check?
      ).merge(mentionable_params)

      cached_html = self.try(:updated_cached_html_for, attr.to_sym)
      options[:rendered] = cached_html if cached_html

      extractor.analyze(text, options)
    end

    extractor
  end

  def extractors
    @extractors ||= {}
  end

  def mentioned_users(current_user = nil)
    all_references(current_user).users
  end

  def store_mentions!
    # if store_mentioned_users_to_db feature flag is not enabled then consider storing operation as succeeded
    # because we wrap this method in transaction with with_transaction_returning_status, and we need the status to be
    # successful if mentionable.save is successful.
    #
    # This line will get removed when we remove the feature flag.
    return true unless store_mentioned_users_to_db_enabled?

    refs = all_references(self.author)

    references = {}
    references[:mentioned_users_ids] = refs.mentioned_users&.pluck(:id).presence
    references[:mentioned_groups_ids] = refs.mentioned_groups&.pluck(:id).presence
    references[:mentioned_projects_ids] = refs.mentioned_projects&.pluck(:id).presence

    # One retry should be enough as next time `model_user_mention` should return the existing mention record, that
    # threw the `ActiveRecord::RecordNotUnique` exception in first place.
    self.class.safe_ensure_unique(retries: 1) do
      user_mention = model_user_mention

      # this may happen due to notes polymorphism, so noteable_id may point to a record that no longer exists
      # as we cannot have FK on noteable_id
      break if user_mention.blank?

      user_mention.mentioned_users_ids = references[:mentioned_users_ids]
      user_mention.mentioned_groups_ids = references[:mentioned_groups_ids]
      user_mention.mentioned_projects_ids = references[:mentioned_projects_ids]

      if user_mention.has_mentions?
        user_mention.save!
      else
        user_mention.destroy!
      end
    end

    true
  end

  def referenced_users
    User.where(id: user_mentions.select("unnest(mentioned_users_ids)"))
  end

  def referenced_projects(current_user = nil)
    Project.where(id: user_mentions.select("unnest(mentioned_projects_ids)")).public_or_visible_to_user(current_user)
  end

  def referenced_project_users(current_user = nil)
    User.joins(:project_members).where(members: { source_id: referenced_projects(current_user) }).distinct
  end

  def referenced_groups(current_user = nil)
    # TODO: IMPORTANT: Revisit before using it.
    # Check DB data for max mentioned groups per mentionable:
    #
    # select issue_id, count(mentions_count.men_gr_id) gr_count from
    # (select DISTINCT unnest(mentioned_groups_ids) as men_gr_id, issue_id
    # from issue_user_mentions group by issue_id, mentioned_groups_ids) as mentions_count
    # group by mentions_count.issue_id order by gr_count desc limit 10
    Group.where(id: user_mentions.select("unnest(mentioned_groups_ids)")).public_or_visible_to_user(current_user)
  end

  def referenced_group_users(current_user = nil)
    User.joins(:group_members).where(members: { source_id: referenced_groups }).distinct
  end

  def directly_addressed_users(current_user = nil)
    all_references(current_user).directly_addressed_users
  end

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

    refs = all_references(current_user)

    # 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.
    extracted_mentionables(refs).reject { |ref| ref == local_reference }
  end

  # Uses regex to quickly determine if mentionables might be referenced
  # Allows heavy processing to be skipped
  def matches_cross_reference_regex?
    reference_pattern = if !project || project.default_issues_tracker?
                          ReferenceRegexes.default_pattern
                        else
                          ReferenceRegexes.external_pattern
                        end

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

  # Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+.
  def create_cross_references!(author = self.author, without = [])
    refs = referenced_mentionables(author)

    # 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.
    refs.reject! { |ref| without.include?(ref) || cross_reference_exists?(ref) }

    refs.each do |ref|
      SystemNoteService.cross_reference(ref, local_reference, author)
    end
  end

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

    return if changes.empty?

    create_cross_references!(author)
  end

  private

  def extracted_mentionables(refs)
    refs.issues + refs.merge_requests + refs.commits
  end

  # 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.presence || previous_changes).dup

    mentionable = self.class.mentionable_attrs.map { |attr, options| attr }

    # Only include changed fields that are mentionable
    source.select { |key, val| mentionable.include?(key) }
  end

  def any_mentionable_attributes_changed?
    self.class.mentionable_attrs.any? do |attr|
      saved_changes.key?(attr.first)
    end
  end

  # 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

  def skip_project_check?
    false
  end

  def mentionable_params
    {}
  end

  # User mention that is parsed from model description rather then its related notes.
  # Models that have a descriprion attribute like Issue, MergeRequest, Epic, Snippet may have such a user mention.
  # Other mentionable models like Commit, DesignManagement::Design, will never have such record as those do not have
  # a description attribute.
  #
  # Using this method followed by a call to *save* may result in *ActiveRecord::RecordNotUnique* exception
  # in a multithreaded environment. Make sure to use it within a *safe_ensure_unique* block.
  def model_user_mention
    user_mentions.where(note_id: nil).first_or_initialize
  end

  # We need this method to be checking that store_mentioned_users_to_db feature flag is enabled at the group level
  # and not the project level as epics are defined at group level and we want to have epics store user mentions as well
  # for the test period.
  # During the test period the flag should be enabled at the group level.
  def store_mentioned_users_to_db_enabled?
    return Feature.enabled?(:store_mentioned_users_to_db, self.project&.group, default_enabled: true) if self.respond_to?(:project)
    return Feature.enabled?(:store_mentioned_users_to_db, self.group, default_enabled: true) if self.respond_to?(:group)
  end
end

Mentionable.prepend_if_ee('EE::Mentionable')