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