# 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')