]> source.dussan.org Git - redmine.git/commitdiff
Allow users to be mentioned using @ in issues and wiki pages (#13919):
authorMarius Balteanu <marius.balteanu@zitec.com>
Wed, 23 Feb 2022 21:16:18 +0000 (21:16 +0000)
committerMarius Balteanu <marius.balteanu@zitec.com>
Wed, 23 Feb 2022 21:16:18 +0000 (21:16 +0000)
* the user must have add watchers permission on that object in order to mention other users
* mentioned user will receive a notification email
* only visible users who can view the object can be mentioned

git-svn-id: http://svn.redmine.org/redmine/trunk@21435 e93f8b46-1217-0410-a6f0-8f06a7374b81

16 files changed:
app/controllers/watchers_controller.rb
app/helpers/application_helper.rb
app/models/issue.rb
app/models/journal.rb
app/models/mailer.rb
app/models/wiki_content.rb
app/views/issues/_form.html.erb
app/views/wiki/edit.html.erb
config/routes.rb
lib/redmine/acts/mentionable.rb [new file with mode: 0644]
lib/redmine/preparation.rb
public/javascripts/application.js
test/functional/auto_completes_controller_test.rb
test/unit/journal_test.rb
test/unit/lib/redmine/acts/mentionable_test.rb [new file with mode: 0644]
test/unit/mailer_test.rb

index 9170acb3469dcb25dc8357fd5fa019dd2162b10a..dd537d6f68cb93a86aabf9f94d6f0c00a0056725 100644 (file)
@@ -28,7 +28,7 @@ class WatchersController < ApplicationController
     set_watcher(@watchables, User.current, false)
   end
 
-  before_action :find_project, :authorize, :only => [:new, :create, :append, :destroy, :autocomplete_for_user]
+  before_action :find_project, :authorize, :only => [:new, :create, :append, :destroy, :autocomplete_for_user, :autocomplete_for_mention]
   accept_api_auth :create, :destroy
 
   def new
@@ -93,6 +93,11 @@ class WatchersController < ApplicationController
     render :layout => false
   end
 
+  def autocomplete_for_mention
+    users = users_for_mention
+    render :json => format_users_json(users)
+  end
+
   private
 
   def find_project
@@ -155,6 +160,42 @@ class WatchersController < ApplicationController
     users
   end
 
+  def users_for_mention
+    users = []
+    q = params[:q].to_s.strip
+
+    scope = nil
+    if params[:q].blank? && @project.present?
+      scope = @project.principals.assignable_watchers
+    else
+      scope = Principal.assignable_watchers.limit(10)
+    end
+    # Exclude Group principal for now
+    scope = scope.where(:type => ['User'])
+
+    users = scope.sorted.like(params[:q]).to_a
+
+    if @watchables && @watchables.size == 1
+      object = @watchables.first
+      if object.respond_to?(:visible?)
+        users.reject! {|user| user.is_a?(User) && !object.visible?(user)}
+      end
+    end
+
+    users
+  end
+
+  def format_users_json(users)
+    users.map do |user|
+      {
+        'firstname' => user.firstname,
+        'lastname' => user.lastname,
+        'name' => user.name,
+        'login' => user.login
+      }
+    end
+  end
+
   def find_objects_from_params
     klass =
       begin
index 0e3f56c2ceb10bad7cc316fbc81307f30c28fbad..33798501aa7bc3a6e705f79a59d72c206e8ec76d 100644 (file)
@@ -1819,19 +1819,20 @@ module ApplicationHelper
     end
   end
 
-  def autocomplete_data_sources(project)
-    {
-      issues: auto_complete_issues_path(:project_id => project, :q => ''),
-      wiki_pages: auto_complete_wiki_pages_path(:project_id => project, :q => '')
-    }
-  end
-
   def heads_for_auto_complete(project)
     data_sources = autocomplete_data_sources(project)
     javascript_tag(
       "rm = window.rm || {};" \
       "rm.AutoComplete = rm.AutoComplete || {};" \
-      "rm.AutoComplete.dataSources = '#{data_sources.to_json}';"
+      "rm.AutoComplete.dataSources = JSON.parse('#{data_sources.to_json}');"
+    )
+  end
+
+  def update_data_sources_for_auto_complete(data_sources)
+    javascript_tag(
+      "const currentDataSources = rm.AutoComplete.dataSources;" \
+      "const newDataSources = JSON.parse('#{data_sources.to_json}'); " \
+      "rm.AutoComplete.dataSources = Object.assign(currentDataSources, newDataSources);"
     )
   end
 
@@ -1866,4 +1867,11 @@ module ApplicationHelper
     name = identifier.gsub(%r{^"(.*)"$}, "\\1")
     return CGI.unescapeHTML(name)
   end
+
+  def autocomplete_data_sources(project)
+    {
+      issues: auto_complete_issues_path(project_id: project, q: ''),
+      wiki_pages: auto_complete_wiki_pages_path(project_id: project, q: ''),
+    }
+  end
 end
index 02aaff33bee71beaadcf79374074530295be349d..ab9f794dbc1bc114a1a446231895f1d6f1b1e28f 100644 (file)
@@ -54,6 +54,8 @@ class Issue < ActiveRecord::Base
   acts_as_activity_provider :scope => proc {preload(:project, :author, :tracker, :status)},
                             :author_key => :author_id
 
+  acts_as_mentionable :attributes => ['description']
+
   DONE_RATIO_OPTIONS = %w(issue_field issue_status)
 
   attr_reader :transition_warning
index 333441204a503429d5ae43e62237ab41aaf655c2..3d1feb906ccc05d831b59466366097380dbeccaf 100644 (file)
@@ -58,6 +58,7 @@ class Journal < ActiveRecord::Base
                   " (#{JournalDetail.table_name}.prop_key = 'status_id' OR #{Journal.table_name}.notes <> '')").distinct
       end
   )
+  acts_as_mentionable :attributes => ['notes']
   before_create :split_private_notes
   after_create_commit :send_notification
 
@@ -172,10 +173,12 @@ class Journal < ActiveRecord::Base
 
   def notified_watchers
     notified = journalized.notified_watchers
-    if private_notes?
-      notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
-    end
-    notified
+    select_journal_visible_user(notified)
+  end
+
+  def notified_mentions
+    notified = super
+    select_journal_visible_user(notified)
   end
 
   def watcher_recipients
@@ -337,4 +340,11 @@ class Journal < ActiveRecord::Base
       Mailer.deliver_issue_edit(self)
     end
   end
+
+  def select_journal_visible_user(notified)
+    if private_notes?
+      notified = notified.select {|user| user.allowed_to?(:view_private_notes, journalized.project)}
+    end
+    notified
+  end
 end
index cdf7aedbadf16085c6773175902841b960882704..3007350b71d91c09c54d2493dbb518218bd02e16 100644 (file)
@@ -94,7 +94,7 @@ class Mailer < ActionMailer::Base
   # Example:
   #   Mailer.deliver_issue_add(issue)
   def self.deliver_issue_add(issue)
-    users = issue.notified_users | issue.notified_watchers
+    users = issue.notified_users | issue.notified_watchers | issue.notified_mentions
     users.each do |user|
       issue_add(user, issue).deliver_later
     end
@@ -129,7 +129,7 @@ class Mailer < ActionMailer::Base
   # Example:
   #   Mailer.deliver_issue_edit(journal)
   def self.deliver_issue_edit(journal)
-    users  = journal.notified_users | journal.notified_watchers
+    users  = journal.notified_users | journal.notified_watchers | journal.notified_mentions | journal.journalized.notified_mentions
     users.select! do |user|
       journal.notes? || journal.visible_details(user).any?
     end
@@ -306,7 +306,7 @@ class Mailer < ActionMailer::Base
   # Example:
   #   Mailer.deliver_wiki_content_added(wiki_content)
   def self.deliver_wiki_content_added(wiki_content)
-    users = wiki_content.notified_users | wiki_content.page.wiki.notified_watchers
+    users = wiki_content.notified_users | wiki_content.page.wiki.notified_watchers | wiki_content.notified_mentions
     users.each do |user|
       wiki_content_added(user, wiki_content).deliver_later
     end
@@ -343,6 +343,7 @@ class Mailer < ActionMailer::Base
     users  = wiki_content.notified_users
     users |= wiki_content.page.notified_watchers
     users |= wiki_content.page.wiki.notified_watchers
+    users |= wiki_content.notified_mentions
 
     users.each do |user|
       wiki_content_updated(user, wiki_content).deliver_later
index ba16b7e29e42e028b2776969ecb0dcca58c9c27d..7fa61560ffe69a2da1daeff463954514c3602593 100644 (file)
@@ -24,6 +24,9 @@ class WikiContent < ActiveRecord::Base
   belongs_to :page, :class_name => 'WikiPage'
   belongs_to :author, :class_name => 'User'
   has_many :versions, :class_name => 'WikiContentVersion', :dependent => :delete_all
+
+  acts_as_mentionable :attributes => ['text']
+
   validates_presence_of :text
   validates_length_of :comments, :maximum => 1024, :allow_nil => true
 
index 9654d631d6132ad72349891fe749cd346dc8256c..3993b9ebf5ace7c3480da3c586e7ebb0362ab59b 100644 (file)
 <% heads_for_wiki_formatter %>
 <%= heads_for_auto_complete(@issue.project) %>
 
+<% if User.current.allowed_to?(:add_issue_watchers, @issue.project)%>
+  <%= update_data_sources_for_auto_complete({users: watchers_autocomplete_for_mention_path(project_id: @issue.project, q: '', object_type: 'issue',
+   object_id: @issue.id)}) %>
+<% end %>
+
 <%= javascript_tag do %>
 $(document).ready(function(){
   $("#issue_tracker_id, #issue_status_id").each(function(){
index c501d6e284d50c29a11af0d0d957646ae792d04d..7e9cf42afbdc5add53e194a63fc2cee79de8b02f 100644 (file)
   <%= link_to l(:button_cancel), wiki_page_edit_cancel_path(@page) %>
  </p>
 <%= wikitoolbar_for 'content_text', preview_project_wiki_page_path(:project_id => @project, :id => @page.title) %>
+
+<% if User.current.allowed_to?(:add_wiki_page_watchers, @project)%>
+  <%= update_data_sources_for_auto_complete({users: watchers_autocomplete_for_mention_path(project_id: @project, q: '', object_type: 'wiki_page', object_id: @page.id)}) %>
+  <% end %>
 <% end %>
 
 <% content_for :header_tags do %>
index 9e17c65d9a3153a2c64626b523f016e53b6a702c..ccfd00195251a1564027da1fb0d08dd264ddde3a 100644 (file)
@@ -46,7 +46,7 @@ Rails.application.routes.draw do
   post 'boards/:board_id/topics/:id/edit', :to => 'messages#edit'
   post 'boards/:board_id/topics/:id/destroy', :to => 'messages#destroy'
 
-  # Auto complate routes
+  # Auto complete routes
   match '/issues/auto_complete', :to => 'auto_completes#issues', :via => :get, :as => 'auto_complete_issues'
   match '/wiki_pages/auto_complete', :to => 'auto_completes#wiki_pages', :via => :get, :as => 'auto_complete_wiki_pages'
 
@@ -119,6 +119,7 @@ Rails.application.routes.draw do
   post 'watchers', :to => 'watchers#create'
   post 'watchers/append', :to => 'watchers#append'
   delete 'watchers', :to => 'watchers#destroy'
+  get 'watchers/autocomplete_for_mention', to: 'watchers#autocomplete_for_mention', via: [:get]
   get 'watchers/autocomplete_for_user', :to => 'watchers#autocomplete_for_user'
   # Specific routes for issue watchers API
   post 'issues/:object_id/watchers', :to => 'watchers#create', :object_type => 'issue'
diff --git a/lib/redmine/acts/mentionable.rb b/lib/redmine/acts/mentionable.rb
new file mode 100644 (file)
index 0000000..264d1f5
--- /dev/null
@@ -0,0 +1,110 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-2022 Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+module Redmine
+  module Acts
+    module Mentionable
+      def self.included(base)
+        base.extend ClassMethods
+      end
+
+      module ClassMethods
+        def acts_as_mentionable(options = {})
+          class_attribute :mentionable_attributes
+          self.mentionable_attributes = options[:attributes]
+
+          attr_accessor :mentioned_users
+
+          send :include, Redmine::Acts::Mentionable::InstanceMethods
+
+          after_save :parse_mentions
+        end
+      end
+
+      module InstanceMethods
+        def self.included(base)
+          base.extend ClassMethods
+        end
+
+        def notified_mentions
+          notified = mentioned_users.to_a
+          notified.reject! {|user| user.mail.blank? || user.mail_notification == 'none'}
+          if respond_to?(:visible?)
+            notified.select! {|user| visible?(user)}
+          end
+          notified
+        end
+
+        private
+
+        def parse_mentions
+          mentionable_attrs = self.mentionable_attributes
+          saved_mentionable_attrs = self.saved_changes.select{|a| mentionable_attrs.include?(a)}
+
+          saved_mentionable_attrs.each do |key, attr|
+            old_value, new_value =  attr
+            get_mentioned_users(old_value, new_value)
+          end
+        end
+
+        def get_mentioned_users(old_content, new_content)
+          self.mentioned_users = []
+
+          previous_matches =  scan_for_mentioned_users(old_content)
+          current_matches = scan_for_mentioned_users(new_content)
+          new_matches = (current_matches - previous_matches).flatten
+
+          if new_matches.any?
+            self.mentioned_users = User.visible.active.where(login: new_matches)
+          end
+        end
+
+        def scan_for_mentioned_users(content)
+          return [] if content.nil?
+
+          # remove quoted text
+          content = content.gsub(%r{\r\n(?:\>\s)+(.*?)\r\n}m, '')
+
+          text_formatting = Setting.text_formatting
+          # Remove text wrapped in pre tags based on text formatting
+          case text_formatting
+          when 'textile'
+            content = content.gsub(%r{<pre>(.*?)</pre>}m, '')
+          when 'markdown', 'common_mark'
+            content = content.gsub(%r{(~~~|```)(.*?)(~~~|```)}m, '')
+          end
+
+          users = content.scan(MENTION_PATTERN).flatten
+        end
+
+        MENTION_PATTERN = /
+          (?:^|\W)                    # beginning of string or non-word char
+          @((?>[a-z0-9][a-z0-9-]*))   # @username
+          (?!\/)                      # without a trailing slash
+          (?=
+            \.+[ \t\W]|               # dots followed by space or non-word character
+            \.+$|                     # dots at end of line
+            [^0-9a-zA-Z_.]|           # non-word character except dot
+            $                         # end of line
+          )
+        /ix
+      end
+    end
+  end
+end
index 4c6b3afe6e2c27417a04b4dcb8679e211c4bc7e4..2ba3b5447f9c2c7951d1312a97394a18100ddf99 100644 (file)
@@ -21,6 +21,7 @@ module Redmine
   module Preparation
     def self.prepare
       ActiveRecord::Base.include Redmine::Acts::Positioned
+      ActiveRecord::Base.include Redmine::Acts::Mentionable
       ActiveRecord::Base.include Redmine::I18n
 
       Scm::Base.add "Subversion"
@@ -71,9 +72,10 @@ module Redmine
           map.permission :view_private_notes, {}, :read => true, :require => :member
           map.permission :set_notes_private, {}, :require => :member
           map.permission :delete_issues, {:issues => :destroy}, :require => :member
+          map.permission :mention_users, {}
           # Watchers
           map.permission :view_issue_watchers, {}, :read => true
-          map.permission :add_issue_watchers, {:watchers => [:new, :create, :append, :autocomplete_for_user]}
+          map.permission :add_issue_watchers, {:watchers => [:new, :create, :append, :autocomplete_for_user, :autocomplete_for_mention]}
           map.permission :delete_issue_watchers, {:watchers => :destroy}
           map.permission :import_issues, {}
           # Issue categories
@@ -123,7 +125,7 @@ module Redmine
           map.permission :delete_wiki_pages, {:wiki => [:destroy, :destroy_version]}, :require => :member
           map.permission :delete_wiki_pages_attachments, {}
           map.permission :view_wiki_page_watchers, {}, :read => true
-          map.permission :add_wiki_page_watchers, {:watchers => [:new, :create, :autocomplete_for_user]}
+          map.permission :add_wiki_page_watchers, {:watchers => [:new, :create, :autocomplete_for_user, :autocomplete_for_mention]}
           map.permission :delete_wiki_page_watchers, {:watchers => :destroy}
           map.permission :protect_wiki_pages, {:wiki => :protect}, :require => :member
           map.permission :manage_wiki, {:wikis => :destroy, :wiki => :rename}, :require => :member
@@ -145,7 +147,7 @@ module Redmine
           map.permission :delete_messages, {:messages => :destroy}, :require => :member
           map.permission :delete_own_messages, {:messages => :destroy}, :require => :loggedin
           map.permission :view_message_watchers, {}, :read => true
-          map.permission :add_message_watchers, {:watchers => [:new, :create, :autocomplete_for_user]}
+          map.permission :add_message_watchers, {:watchers => [:new, :create, :autocomplete_for_user, :autocomplete_for_mention]}
           map.permission :delete_message_watchers, {:watchers => :destroy}
           map.permission :manage_boards, {:projects => :settings, :boards => [:new, :create, :edit, :update, :destroy]}, :require => :member
         end
index 5552952cd54bc970ade6f568689048ca9d4befa2..91da192290589e7243de65d5019a0888ff78fdbc 100644 (file)
@@ -1127,9 +1127,13 @@ function inlineAutoComplete(element) {
     if (element.dataset.tribute === 'true') {return};
 
     const getDataSource = function(entity) {
-      const dataSources = JSON.parse(rm.AutoComplete.dataSources);
+      const dataSources = rm.AutoComplete.dataSources;
 
-      return dataSources[entity];
+      if (dataSources[entity]) {
+        return dataSources[entity];
+      } else {
+        return false;
+      }
     }
 
     const remoteSearch = function(url, cb) {
@@ -1187,6 +1191,26 @@ function inlineAutoComplete(element) {
           menuItemTemplate: function (wikiPage) {
             return sanitizeHTML(wikiPage.original.label);
           }
+        },
+        {
+          trigger: '@',
+          lookup: function (user, mentionText) {
+            return user.name + user.firstname + user.lastname + user.login;
+          },
+          values: function (text, cb) {
+            const url = getDataSource('users');
+            if (url) {
+              remoteSearch(url + text, function (users) {
+                return cb(users);
+              });
+            }
+          },
+          menuItemTemplate: function (user) {
+            return user.original.name;
+          },
+          selectTemplate: function (user) {
+            return '@' + user.original.login;
+          }
         }
       ],
       noMatchTemplate: ""
index adda40422b8f17b40f5bfe3180357550c781a19c..33da211a5a7af7bd5a1101fca29e182fc112d2b8 100644 (file)
@@ -79,7 +79,7 @@ class AutoCompletesControllerTest < Redmine::ControllerTest
     assert_include "Bug #13", response.body
   end
 
-  def test_auto_complete_with_scope_all_should_search_other_projects
+  def test_issues_with_scope_all_should_search_other_projects
     get(
       :issues,
       :params => {
@@ -92,13 +92,13 @@ class AutoCompletesControllerTest < Redmine::ControllerTest
     assert_include "Bug #13", response.body
   end
 
-  def test_auto_complete_without_project_should_search_all_projects
+  def test_issues_without_project_should_search_all_projects
     get(:issues, :params => {:q => '13'})
     assert_response :success
     assert_include "Bug #13", response.body
   end
 
-  def test_auto_complete_without_scope_all_should_not_search_other_projects
+  def test_issues_without_scope_all_should_not_search_other_projects
     get(
       :issues,
       :params => {
@@ -128,7 +128,7 @@ class AutoCompletesControllerTest < Redmine::ControllerTest
     assert_equal 'Bug #13: Subproject issue two', issue['label']
   end
 
-  def test_auto_complete_with_status_o_should_return_open_issues_only
+  def test_issues_with_status_o_should_return_open_issues_only
     get(
       :issues,
       :params => {
@@ -142,7 +142,7 @@ class AutoCompletesControllerTest < Redmine::ControllerTest
     assert_not_include "closed", response.body
   end
 
-  def test_auto_complete_with_status_c_should_return_closed_issues_only
+  def test_issues_with_status_c_should_return_closed_issues_only
     get(
       :issues,
       :params => {
@@ -156,7 +156,7 @@ class AutoCompletesControllerTest < Redmine::ControllerTest
     assert_not_include "Issue due today", response.body
   end
 
-  def test_auto_complete_with_issue_id_should_not_return_that_issue
+  def test_issues_with_issue_id_should_not_return_that_issue
     get(
       :issues,
       :params => {
@@ -182,7 +182,7 @@ class AutoCompletesControllerTest < Redmine::ControllerTest
     assert_include 'application/json', response.headers['Content-Type']
   end
 
-  def test_auto_complete_without_term_should_return_last_10_issues
+  def test_issue_without_term_should_return_last_10_issues
     # There are 9 issues generated by fixtures
     # and we need two more to test the 10 limit
     %w(1..2).each do
index 0e017791773c79f062cf9f41361593b1f4e7702a..f9c269291cf6f4b5e63c25c389ff8500be814561 100644 (file)
@@ -236,4 +236,12 @@ class JournalTest < ActiveSupport::TestCase
       assert_equal "image#{i}.png", attachment.filename
     end
   end
+
+  def test_notified_mentions_should_not_include_users_who_cannot_view_private_notes
+    journal = Journal.generate!(journalized: Issue.find(2), user: User.find(1), private_notes: true, notes: 'Hello @dlopper, @jsmith and @admin.')
+
+    # User "dlopper" has "Developer" role on project "eCookbook"
+    # Role "Developer" does not have the "View private notes" permission
+    assert_equal [1, 2], journal.notified_mentions.map(&:id)
+  end
 end
diff --git a/test/unit/lib/redmine/acts/mentionable_test.rb b/test/unit/lib/redmine/acts/mentionable_test.rb
new file mode 100644 (file)
index 0000000..9badcd6
--- /dev/null
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+
+# Redmine - project management software
+# Copyright (C) 2006-2022  Jean-Philippe Lang
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+
+require File.expand_path('../../../../../test_helper', __FILE__)
+
+class Redmine::Acts::MentionableTest < ActiveSupport::TestCase
+  fixtures :projects, :users, :email_addresses, :members, :member_roles, :roles,
+         :groups_users,
+         :trackers, :projects_trackers,
+         :enabled_modules,
+         :issue_statuses, :issue_categories, :issue_relations, :workflows,
+         :enumerations,
+         :issues
+
+  def test_mentioned_users_with_user_mention
+    issue = Issue.generate!(project_id: 1, description: '@dlopper')
+
+    assert_equal [User.find(3)], issue.mentioned_users
+  end
+
+  def test_mentioned_users_with_multiple_mentions
+    issue = Issue.generate!(project_id: 1, description: 'Hello @dlopper, @jsmith.')
+
+    assert_equal [User.find(2), User.find(3)], issue.mentioned_users
+  end
+
+  def test_mentioned_users_should_not_mention_same_user_multiple_times
+    issue = Issue.generate!(project_id: 1, description: '@dlopper @jsmith @dlopper')
+
+    assert_equal [User.find(2), User.find(3)], issue.mentioned_users
+  end
+
+  def test_mentioned_users_should_include_only_active_users
+    # disable dlopper account
+    user = User.find(3)
+    user.status = User::STATUS_LOCKED
+    user.save
+
+    issue = Issue.generate!(project_id: 1, description: '@dlopper @jsmith')
+
+    assert_equal [User.find(2)], issue.mentioned_users
+  end
+
+  def test_mentioned_users_should_include_only_visible_users
+    User.current = nil
+    Role.non_member.update! users_visibility: 'members_of_visible_projects'
+    Role.anonymous.update! users_visibility: 'members_of_visible_projects'
+    user = User.generate!
+
+    issue = Issue.generate!(project_id: 1, description: "@jsmith @#{user.login}")
+
+    assert_equal [User.find(2)], issue.mentioned_users
+  end
+
+  def test_mentioned_users_should_not_include_mentioned_users_in_existing_content
+    issue = Issue.generate!(project_id: 1, description: 'Hello @dlopper')
+
+    assert issue.save
+    assert_equal [User.find(3)], issue.mentioned_users
+
+    issue.description = 'Hello @dlopper and @jsmith'
+    issue.save
+
+    assert_equal [User.find(2)], issue.mentioned_users
+  end
+
+  def test_mentioned_users_should_not_include_users_wrapped_in_pre_tags_for_textile
+    description = <<~STR
+      <pre>
+      Hello @jsmith
+      </pre>
+    STR
+
+    with_settings text_formatting: 'textile' do
+      issue = Issue.generate!(project_id: 1, description: description)
+
+      assert_equal [], issue.mentioned_users
+    end
+  end
+
+  def test_mentioned_users_should_not_include_users_wrapped_in_pre_tags_for_markdown
+    description = <<~STR
+      ```
+      Hello @jsmith
+      ```
+    STR
+
+    with_settings text_formatting: 'markdown' do
+      issue = Issue.generate!(project_id: 1, description: description)
+
+      assert_equal [], issue.mentioned_users
+    end
+  end
+
+  def test_mentioned_users_should_not_include_users_wrapped_in_pre_tags_for_common_mark
+    description = <<~STR
+      ```
+      Hello @jsmith
+      ```
+    STR
+
+    with_settings text_formatting: 'common_mark' do
+      issue = Issue.generate!(project_id: 1, description: description)
+
+      assert_equal [], issue.mentioned_users
+    end
+  end
+
+  def test_notified_mentions
+    issue = Issue.generate!(project_id: 1, description: 'Hello @dlopper, @jsmith.')
+
+    assert_equal [User.find(2), User.find(3)], issue.notified_mentions
+  end
+
+  def test_notified_mentions_should_not_include_users_who_out_of_all_email
+    User.find(3).update!(mail_notification: :none)
+    issue = Issue.generate!(project_id: 1, description: "Hello @dlopper, @jsmith.")
+
+    assert_equal [User.find(2)], issue.notified_mentions
+  end
+
+  def test_notified_mentions_should_not_include_users_who_cannot_view_the_object
+    user = User.find(3)
+
+    # User dlopper does not have access to project "Private child of eCookbook"
+    issue = Issue.generate!(project_id: 5, description: "Hello @dlopper, @jsmith.")
+
+    assert !issue.notified_mentions.include?(user)
+  end
+end
index 7ee682c06c45c00b5123fa537f940ec024756930..3e214b47deb33f48bc06edffcd1b69414f42c964 100644 (file)
@@ -464,6 +464,19 @@ class MailerTest < ActiveSupport::TestCase
     assert_not_include user.mail, recipients
   end
 
+  def test_issue_add_should_notify_mentioned_users_in_issue_description
+    User.find(1).mail_notification = 'only_my_events'
+
+    issue = Issue.generate!(project_id: 1, description: 'Hello @dlopper and @admin.')
+
+    assert Mailer.deliver_issue_add(issue)
+    # @jsmith and @dlopper are members of the project
+    # admin is mentioned
+    # @dlopper won't receive duplicated notifications
+    assert_equal 3, ActionMailer::Base.deliveries.size
+    assert_include User.find(1).mail, recipients
+  end
+
   def test_issue_add_should_include_enabled_fields
     issue = Issue.find(2)
     assert Mailer.deliver_issue_add(issue)
@@ -608,6 +621,39 @@ class MailerTest < ActiveSupport::TestCase
     end
   end
 
+  def test_issue_edit_should_notify_mentioned_users_in_issue_updated_description
+    User.find(1).mail_notification = 'only_my_events'
+
+    issue = Issue.find(3)
+    issue.init_journal(User.current)
+    issue.update(description: "Hello @admin")
+    journal = issue.journals.last
+
+    ActionMailer::Base.deliveries.clear
+    Mailer.deliver_issue_edit(journal)
+
+    # @jsmith and @dlopper are members of the project
+    # admin is mentioned in the updated description
+    # @dlopper won't receive duplicated notifications
+    assert_equal 3, ActionMailer::Base.deliveries.size
+    assert_include User.find(1).mail, recipients
+  end
+
+  def test_issue_edit_should_notify_mentioned_users_in_notes
+    User.find(1).mail_notification = 'only_my_events'
+
+    journal = Journal.generate!(journalized: Issue.find(3), user: User.find(1), notes: 'Hello @admin.')
+
+    ActionMailer::Base.deliveries.clear
+    Mailer.deliver_issue_edit(journal)
+
+    # @jsmith and @dlopper are members of the project
+    # admin is mentioned in the notes
+    # @dlopper won't receive duplicated notifications
+    assert_equal 3, ActionMailer::Base.deliveries.size
+    assert_include User.find(1).mail, recipients
+  end
+
   def test_issue_should_send_email_notification_with_suppress_empty_fields
     ActionMailer::Base.deliveries.clear
     with_settings :notified_events => %w(issue_added) do
@@ -703,6 +749,20 @@ class MailerTest < ActiveSupport::TestCase
     end
   end
 
+  def test_wiki_content_added_should_notify_mentioned_users_in_content
+    content = WikiContent.new(text: 'Hello @admin.', author_id: 1, page_id: 1)
+    content.save!
+
+    ActionMailer::Base.deliveries.clear
+    Mailer.deliver_wiki_content_added(content)
+
+    # @jsmith and @dlopper are members of the project
+    # admin is mentioned in the notes
+    # @dlopper won't receive duplicated notifications
+    assert_equal 3, ActionMailer::Base.deliveries.size
+    assert_include User.find(1).mail, recipients
+  end
+
   def test_wiki_content_updated
     content = WikiContent.find(1)
     assert Mailer.deliver_wiki_content_updated(content)
@@ -713,6 +773,21 @@ class MailerTest < ActiveSupport::TestCase
     end
   end
 
+  def test_wiki_content_updated_should_notify_mentioned_users_in_updated_content
+    content = WikiContent.find(1)
+    content.update(text: 'Hello @admin.')
+    content.save!
+
+    ActionMailer::Base.deliveries.clear
+    Mailer.deliver_wiki_content_updated(content)
+
+    # @jsmith and @dlopper are members of the project
+    # admin is mentioned in the notes
+    # @dlopper won't receive duplicated notifications
+    assert_equal 3, ActionMailer::Base.deliveries.size
+    assert_include User.find(1).mail, recipients
+  end
+
   def test_register
     token = Token.find(1)
     assert Mailer.deliver_register(token.user, token)