2018-08-03 13:22:24 -04:00
|
|
|
# frozen_string_literal: true
|
|
|
|
|
2013-05-09 19:37:47 -04:00
|
|
|
# == Mentionable concern
|
|
|
|
#
|
2016-11-17 15:46:31 -05:00
|
|
|
# Contains functionality related to objects that can mention Users, Issues, MergeRequests, Commits or Snippets by
|
2013-05-30 19:16:49 -04:00
|
|
|
# GFM references.
|
2013-05-09 19:37:47 -04:00
|
|
|
#
|
2013-05-30 19:16:49 -04:00
|
|
|
# Used by Issue, Note, MergeRequest, and Commit.
|
2013-05-09 19:37:47 -04:00
|
|
|
#
|
|
|
|
module Mentionable
|
|
|
|
extend ActiveSupport::Concern
|
|
|
|
|
2018-08-27 08:35:31 -04:00
|
|
|
class_methods do
|
2013-05-30 19:16:49 -04:00
|
|
|
# Indicate which attributes of the Mentionable to search for GFM references.
|
2015-10-14 15:29:35 -04:00
|
|
|
def attr_mentionable(attr, options = {})
|
|
|
|
attr = attr.to_s
|
|
|
|
mentionable_attrs << [attr, options]
|
2013-05-30 19:16:49 -04:00
|
|
|
end
|
2016-07-13 23:28:58 -04:00
|
|
|
end
|
2013-05-30 19:16:49 -04:00
|
|
|
|
2016-07-13 23:28:58 -04:00
|
|
|
included do
|
2013-05-30 19:16:49 -04:00
|
|
|
# Accessor for attributes marked mentionable.
|
2016-07-13 23:28:58 -04:00
|
|
|
cattr_accessor :mentionable_attrs, instance_accessor: false do
|
|
|
|
[]
|
2013-05-30 19:16:49 -04:00
|
|
|
end
|
|
|
|
|
2015-10-14 10:20:11 -04:00
|
|
|
if self < Participable
|
2016-05-26 07:34:06 -04:00
|
|
|
participant -> (user, ext) { all_references(user, extractor: ext) }
|
2015-10-14 10:20:11 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-05-11 15:56:00 -04:00
|
|
|
# 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.
|
2017-11-22 08:20:35 -05:00
|
|
|
def gfm_reference(from = nil)
|
2015-05-14 17:09:02 -04:00
|
|
|
# "MergeRequest" > "merge_request" > "Merge request" > "merge request"
|
2015-05-11 15:56:00 -04:00
|
|
|
friendly_name = self.class.to_s.underscore.humanize.downcase
|
|
|
|
|
2017-11-22 08:20:35 -05:00
|
|
|
"#{friendly_name} #{to_reference(from)}"
|
2013-05-30 19:16:49 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
# The GFM reference to this Mentionable, which shouldn't be included in its #references.
|
|
|
|
def local_reference
|
|
|
|
self
|
|
|
|
end
|
|
|
|
|
2016-09-29 10:28:45 -04:00
|
|
|
def all_references(current_user = nil, extractor: nil)
|
2016-12-05 07:12:22 -05:00
|
|
|
# Use custom extractor if it's passed in the function parameters.
|
|
|
|
if extractor
|
2017-11-17 07:27:16 -05:00
|
|
|
extractors[current_user] = extractor
|
2016-12-05 07:12:22 -05:00
|
|
|
else
|
2017-11-17 07:27:16 -05:00
|
|
|
extractor = extractors[current_user] ||= Gitlab::ReferenceExtractor.new(project, current_user)
|
2016-12-05 07:12:22 -05:00
|
|
|
|
2017-05-10 07:13:33 -04:00
|
|
|
extractor.reset_memoized_values
|
2016-12-05 07:12:22 -05:00
|
|
|
end
|
2015-12-15 09:51:16 -05:00
|
|
|
|
2016-09-29 10:28:45 -04:00
|
|
|
self.class.mentionable_attrs.each do |attr, options|
|
2017-08-10 12:39:26 -04:00
|
|
|
text = __send__(attr) # rubocop:disable GitlabSecurity/PublicSend
|
2017-01-18 18:37:55 -05:00
|
|
|
options = options.merge(
|
|
|
|
cache_key: [self, attr],
|
|
|
|
author: author,
|
2017-01-20 05:28:40 -05:00
|
|
|
skip_project_check: skip_project_check?
|
2018-08-29 09:38:42 -04:00
|
|
|
).merge(mentionable_params)
|
2016-01-06 07:33:11 -05:00
|
|
|
|
2019-07-03 19:12:02 -04:00
|
|
|
cached_html = self.try(:updated_cached_html_for, attr.to_sym)
|
|
|
|
options[:rendered] = cached_html if cached_html
|
|
|
|
|
2017-05-10 07:13:33 -04:00
|
|
|
extractor.analyze(text, options)
|
2015-10-14 15:29:35 -04:00
|
|
|
end
|
|
|
|
|
2017-05-10 07:13:33 -04:00
|
|
|
extractor
|
2013-05-30 19:16:49 -04:00
|
|
|
end
|
|
|
|
|
2017-11-17 07:27:16 -05:00
|
|
|
def extractors
|
|
|
|
@extractors ||= {}
|
|
|
|
end
|
|
|
|
|
2015-12-15 09:51:16 -05:00
|
|
|
def mentioned_users(current_user = nil)
|
|
|
|
all_references(current_user).users
|
2013-05-09 19:37:47 -04:00
|
|
|
end
|
|
|
|
|
2019-12-10 10:07:52 -05:00
|
|
|
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
|
2020-02-13 19:09:07 -05:00
|
|
|
|
|
|
|
# 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?
|
|
|
|
|
2019-12-10 10:07:52 -05:00
|
|
|
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!
|
2020-02-13 19:09:07 -05:00
|
|
|
else
|
2019-12-10 10:07:52 -05:00
|
|
|
user_mention.destroy!
|
|
|
|
end
|
|
|
|
end
|
2020-02-13 19:09:07 -05:00
|
|
|
|
|
|
|
true
|
2019-12-10 10:07:52 -05:00
|
|
|
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
|
|
|
|
|
2016-12-05 07:12:22 -05:00
|
|
|
def directly_addressed_users(current_user = nil)
|
|
|
|
all_references(current_user).directly_addressed_users
|
|
|
|
end
|
|
|
|
|
2013-05-30 19:16:49 -04:00
|
|
|
# Extract GFM references to other Mentionables from this Mentionable. Always excludes its #local_reference.
|
2016-09-29 10:28:45 -04:00
|
|
|
def referenced_mentionables(current_user = self.author)
|
2017-04-21 22:26:58 -04:00
|
|
|
return [] unless matches_cross_reference_regex?
|
|
|
|
|
2016-09-29 10:28:45 -04:00
|
|
|
refs = all_references(current_user)
|
2015-11-30 16:43:54 -05:00
|
|
|
|
|
|
|
# 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.
|
2018-08-29 09:38:42 -04:00
|
|
|
extracted_mentionables(refs).reject { |ref| ref == local_reference }
|
2013-05-30 19:16:49 -04:00
|
|
|
end
|
2015-06-08 18:13:14 -04:00
|
|
|
|
2017-04-21 22:26:58 -04:00
|
|
|
# Uses regex to quickly determine if mentionables might be referenced
|
|
|
|
# Allows heavy processing to be skipped
|
|
|
|
def matches_cross_reference_regex?
|
2017-05-09 04:59:18 -04:00
|
|
|
reference_pattern = if !project || project.default_issues_tracker?
|
2018-11-12 07:50:31 -05:00
|
|
|
ReferenceRegexes.default_pattern
|
2017-04-21 22:26:58 -04:00
|
|
|
else
|
2018-11-12 07:50:31 -05:00
|
|
|
ReferenceRegexes.external_pattern
|
2017-04-21 22:26:58 -04:00
|
|
|
end
|
|
|
|
|
|
|
|
self.class.mentionable_attrs.any? do |attr, _|
|
2017-08-10 12:39:26 -04:00
|
|
|
__send__(attr) =~ reference_pattern # rubocop:disable GitlabSecurity/PublicSend
|
2017-04-21 22:26:58 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-10-14 16:42:59 -04:00
|
|
|
# Create a cross-reference Note for each GFM reference to another Mentionable found in the +mentionable_attrs+.
|
2016-09-29 10:28:45 -04:00
|
|
|
def create_cross_references!(author = self.author, without = [])
|
|
|
|
refs = referenced_mentionables(author)
|
2015-11-30 16:43:54 -05:00
|
|
|
|
2015-06-08 18:13:14 -04:00
|
|
|
# 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.
|
2015-10-12 05:54:46 -04:00
|
|
|
refs.reject! { |ref| without.include?(ref) || cross_reference_exists?(ref) }
|
2015-06-08 18:13:14 -04:00
|
|
|
|
2013-05-30 19:16:49 -04:00
|
|
|
refs.each do |ref|
|
2015-10-12 05:54:46 -04:00
|
|
|
SystemNoteService.cross_reference(ref, local_reference, author)
|
2013-05-09 19:37:47 -04:00
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-07-13 18:33:50 -04:00
|
|
|
# When a mentionable field is changed, creates cross-reference notes that
|
|
|
|
# don't already exist
|
2015-10-12 05:54:46 -04:00
|
|
|
def create_new_cross_references!(author = self.author)
|
2015-07-13 18:28:10 -04:00
|
|
|
changes = detect_mentionable_changes
|
|
|
|
|
|
|
|
return if changes.empty?
|
2013-05-30 19:16:49 -04:00
|
|
|
|
2016-09-29 10:28:45 -04:00
|
|
|
create_cross_references!(author)
|
2013-05-30 19:16:49 -04:00
|
|
|
end
|
2015-07-13 18:28:10 -04:00
|
|
|
|
|
|
|
private
|
|
|
|
|
2018-08-29 09:38:42 -04:00
|
|
|
def extracted_mentionables(refs)
|
|
|
|
refs.issues + refs.merge_requests + refs.commits
|
|
|
|
end
|
|
|
|
|
2015-07-13 18:28:10 -04:00
|
|
|
# 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
|
2019-09-26 05:06:04 -04:00
|
|
|
source = (changes.presence || previous_changes).dup
|
2015-07-13 18:28:10 -04:00
|
|
|
|
2015-10-14 15:29:35 -04:00
|
|
|
mentionable = self.class.mentionable_attrs.map { |attr, options| attr }
|
2015-07-13 18:28:10 -04:00
|
|
|
|
|
|
|
# Only include changed fields that are mentionable
|
|
|
|
source.select { |key, val| mentionable.include?(key) }
|
|
|
|
end
|
2015-11-30 16:43:54 -05:00
|
|
|
|
2020-02-13 19:09:07 -05:00
|
|
|
def any_mentionable_attributes_changed?
|
|
|
|
self.class.mentionable_attrs.any? do |attr|
|
|
|
|
saved_changes.key?(attr.first)
|
|
|
|
end
|
|
|
|
end
|
|
|
|
|
2015-10-14 03:27:30 -04:00
|
|
|
# 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
|
2017-01-20 05:28:40 -05:00
|
|
|
|
|
|
|
def skip_project_check?
|
|
|
|
false
|
|
|
|
end
|
2018-08-29 09:38:42 -04:00
|
|
|
|
|
|
|
def mentionable_params
|
|
|
|
{}
|
|
|
|
end
|
2019-12-10 10:07:52 -05:00
|
|
|
|
|
|
|
# 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) if self.respond_to?(:project)
|
|
|
|
return Feature.enabled?(:store_mentioned_users_to_db, self.group) if self.respond_to?(:group)
|
|
|
|
end
|
2013-05-09 19:37:47 -04:00
|
|
|
end
|
2019-09-13 09:26:31 -04:00
|
|
|
|
|
|
|
Mentionable.prepend_if_ee('EE::Mentionable')
|