Commit 761bdd32 authored by Jarka Kadlecova's avatar Jarka Kadlecova

Support mentioning epics

parent d15e704e
...@@ -86,6 +86,8 @@ module MarkupHelper ...@@ -86,6 +86,8 @@ module MarkupHelper
return '' unless text.present? return '' unless text.present?
context[:project] ||= @project context[:project] ||= @project
context[:group] ||= @group
html = markdown_unsafe(text, context) html = markdown_unsafe(text, context)
prepare_for_rendering(html, context) prepare_for_rendering(html, context)
end end
......
...@@ -61,7 +61,7 @@ module Mentionable ...@@ -61,7 +61,7 @@ module Mentionable
cache_key: [self, attr], cache_key: [self, attr],
author: author, author: author,
skip_project_check: skip_project_check? skip_project_check: skip_project_check?
) ).merge(mentionable_params)
extractor.analyze(text, options) extractor.analyze(text, options)
end end
...@@ -82,7 +82,7 @@ module Mentionable ...@@ -82,7 +82,7 @@ module Mentionable
return [] unless matches_cross_reference_regex? return [] unless matches_cross_reference_regex?
refs = all_references(current_user) refs = all_references(current_user)
refs = (refs.issues + refs.merge_requests + refs.commits) refs = (refs.issues + refs.merge_requests + refs.commits + refs.epics)
# We're using this method instead of Array diffing because that requires # 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 # both of the object's `hash` values to be the same, which may not be the
...@@ -157,4 +157,8 @@ module Mentionable ...@@ -157,4 +157,8 @@ module Mentionable
def skip_project_check? def skip_project_check?
false false
end end
def mentionable_params
{}
end
end end
...@@ -3,6 +3,7 @@ module Mentionable ...@@ -3,6 +3,7 @@ module Mentionable
def self.reference_pattern(link_patterns, issue_pattern) def self.reference_pattern(link_patterns, issue_pattern)
Regexp.union(link_patterns, Regexp.union(link_patterns,
issue_pattern, issue_pattern,
Epic.reference_pattern,
Commit.reference_pattern, Commit.reference_pattern,
MergeRequest.reference_pattern) MergeRequest.reference_pattern)
end end
......
# Placeholder class for model that is implemented in EE # Placeholder class for model that is implemented in EE
# It will reserve (ee#3853) '&' as a reference prefix, but the table does not exists in CE # It reserves '&' as a reference prefix, but the table does not exists in CE
class Epic < ActiveRecord::Base class Epic < ActiveRecord::Base
prepend EE::Epic prepend EE::Epic
# TODO: this will be implemented as part of #3853 def self.reference_prefix
def to_reference '&'
end
def self.reference_prefix_escaped
'&amp;'
end end
end end
---
title: Support mentioning epics
merge_request:
author:
type: added
...@@ -22,6 +22,7 @@ You can use GFM in the following areas: ...@@ -22,6 +22,7 @@ You can use GFM in the following areas:
- snippets (the snippet must be named with a `.md` extension) - snippets (the snippet must be named with a `.md` extension)
- wiki pages - wiki pages
- markdown documents inside the repository - markdown documents inside the repository
- epics
You can also use other rich text files in GitLab. You might have to install a You can also use other rich text files in GitLab. You might have to install a
dependency to do so. Please see the [github-markup gem readme](https://github.com/gitlabhq/markup#markups) for more information. dependency to do so. Please see the [github-markup gem readme](https://github.com/gitlabhq/markup#markups) for more information.
...@@ -245,6 +246,7 @@ GFM will recognize the following: ...@@ -245,6 +246,7 @@ GFM will recognize the following:
| `#123` | issue | | `#123` | issue |
| `!123` | merge request | | `!123` | merge request |
| `$123` | snippet | | `$123` | snippet |
| `&123` | epic |
| `~123` | label by ID | | `~123` | label by ID |
| `~bug` | one-word label by name | | `~bug` | one-word label by name |
| `~"feature request"` | multi-word label by name | | `~"feature request"` | multi-word label by name |
...@@ -265,6 +267,7 @@ GFM also recognizes certain cross-project references: ...@@ -265,6 +267,7 @@ GFM also recognizes certain cross-project references:
| `namespace/project%123` | project milestone | | `namespace/project%123` | project milestone |
| `namespace/project$123` | snippet | | `namespace/project$123` | snippet |
| `namespace/project@9ba12248` | specific commit | | `namespace/project@9ba12248` | specific commit |
| `group1/subgroup&123` | epic |
| `namespace/project@9ba12248...b19a04f5` | commit range comparison | | `namespace/project@9ba12248...b19a04f5` | commit range comparison |
| `namespace/project~"Some label"` | issues with given label | | `namespace/project~"Some label"` | issues with given label |
......
...@@ -6,6 +6,7 @@ module EE ...@@ -6,6 +6,7 @@ module EE
include InternalId include InternalId
include Issuable include Issuable
include Noteable include Noteable
include Referable
belongs_to :assignee, class_name: "User" belongs_to :assignee, class_name: "User"
belongs_to :group belongs_to :group
...@@ -15,6 +16,46 @@ module EE ...@@ -15,6 +16,46 @@ module EE
validates :group, presence: true validates :group, presence: true
end end
module ClassMethods
# We support internal references (&epic_id) and cross-references (group.full_path&epic_id)
#
# Escaped versions with `&amp;` will be extracted too
#
# The parent of epic is group instead of project and therefore we have to define new patterns
def reference_pattern
@reference_pattern ||= begin
combined_prefix = Regexp.union(Regexp.escape(reference_prefix), Regexp.escape(reference_prefix_escaped))
group_regexp = %r{
(?<!\w)
(?<group>#{::Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})
}x
%r{
(#{group_regexp})?
(?:#{combined_prefix})(?<epic>\d+)
}x
end
end
def link_reference_pattern
%r{
(?<url>
#{Regexp.escape(::Gitlab.config.gitlab.url)}
\/groups\/(?<group>#{::Gitlab::PathRegex::FULL_NAMESPACE_FORMAT_REGEX})
\/-\/epics
\/(?<epic>\d+)
(?<path>
(\/[a-z0-9_=-]+)*
)?
(?<query>
\?[a-z0-9_=-]+
(&[a-z0-9_=-]+)*
)?
(?<anchor>\#[a-z0-9_-]+)?
)
}x
end
end
def assignees def assignees
Array(assignee) Array(assignee)
end end
...@@ -27,6 +68,18 @@ module EE ...@@ -27,6 +68,18 @@ module EE
false false
end end
def to_reference(from = nil, full: false)
reference = "#{self.class.reference_prefix}#{iid}"
return reference unless cross_reference?(from) || full
"#{group.full_path}#{reference}"
end
def cross_reference?(from)
from && from != group
end
# we don't support project epics for epics yet, planned in the future #4019 # we don't support project epics for epics yet, planned in the future #4019
def update_project_counter_caches def update_project_counter_caches
end end
...@@ -38,5 +91,9 @@ module EE ...@@ -38,5 +91,9 @@ module EE
Ability.issues_readable_by_user(related_issues, current_user) Ability.issues_readable_by_user(related_issues, current_user)
end end
def mentionable_params
{ group: group }
end
end end
end end
module EE
module Banzai
module Filter
# HTML filter that replaces epic references with links. References to
# epics that do not exist are ignored.
#
# This filter supports cross-project/group references.
module EpicReferenceFilter
extend ActiveSupport::Concern
module ClassMethods
def references_in(text, pattern = object_class.reference_pattern)
text.gsub(pattern) do |match|
symbol = $~[object_sym]
if object_class.reference_valid?(symbol)
yield match, symbol.to_i, nil, $~[:group], $~
else
match
end
end
end
end
def url_for_object(epic, group)
urls = ::Gitlab::Routing.url_helpers
urls.group_epic_url(group, epic, only_path: context[:only_path])
end
def data_attributes_for(text, group, object, link: false)
data_attribute(
original: text,
link: link,
group: group.id,
object_sym => object.id
)
end
def parent_records(parent, ids)
parent.epics.where(iid: ids.to_a)
end
private
def full_group_path(group_ref)
return current_parent_path unless group_ref
group_ref
end
def parent_type
:group
end
end
end
end
end
module EE
module Banzai
module ReferenceParser
module EpicParser
def records_for_nodes(nodes)
@epics_for_nodes ||= grouped_objects_for_nodes(
nodes,
::Epic.includes(
:author,
:group
),
self.class.data_attribute
)
end
end
end
end
end
...@@ -11,7 +11,7 @@ module Banzai ...@@ -11,7 +11,7 @@ module Banzai
# ref - String reference. # ref - String reference.
# #
# Returns a Project, or nil if the reference can't be found # Returns a Project, or nil if the reference can't be found
def project_from_ref(ref) def parent_from_ref(ref)
return context[:project] unless ref return context[:project] unless ref
Project.find_by_full_path(ref) Project.find_by_full_path(ref)
......
...@@ -82,9 +82,9 @@ module Banzai ...@@ -82,9 +82,9 @@ module Banzai
end end
end end
def project_from_ref_cached(ref) def from_ref_cached(ref)
cached_call(:banzai_project_refs, ref) do cached_call("banzai_#{parent_type}_refs".to_sym, ref) do
project_from_ref(ref) parent_from_ref(ref)
end end
end end
...@@ -153,15 +153,20 @@ module Banzai ...@@ -153,15 +153,20 @@ module Banzai
# have `gfm` and `gfm-OBJECT_NAME` class names attached for styling. # have `gfm` and `gfm-OBJECT_NAME` class names attached for styling.
def object_link_filter(text, pattern, link_content: nil, link_reference: false) def object_link_filter(text, pattern, link_content: nil, link_reference: false)
references_in(text, pattern) do |match, id, project_ref, namespace_ref, matches| references_in(text, pattern) do |match, id, project_ref, namespace_ref, matches|
project_path = full_project_path(namespace_ref, project_ref) parent_path = if parent_type == :group
project = project_from_ref_cached(project_path) full_group_path(namespace_ref)
else
full_project_path(namespace_ref, project_ref)
end
if project parent = from_ref_cached(parent_path)
if parent
object = object =
if link_reference if link_reference
find_object_from_link_cached(project, id) find_object_from_link_cached(parent, id)
else else
find_object_cached(project, id) find_object_cached(parent, id)
end end
end end
...@@ -169,13 +174,13 @@ module Banzai ...@@ -169,13 +174,13 @@ module Banzai
title = object_link_title(object) title = object_link_title(object)
klass = reference_class(object_sym) klass = reference_class(object_sym)
data = data_attributes_for(link_content || match, project, object, link: !!link_content) data = data_attributes_for(link_content || match, parent, object, link: !!link_content)
url = url =
if matches.names.include?("url") && matches[:url] if matches.names.include?("url") && matches[:url]
matches[:url] matches[:url]
else else
url_for_object_cached(object, project) url_for_object_cached(object, parent)
end end
content = link_content || object_link_text(object, matches) content = link_content || object_link_text(object, matches)
...@@ -224,17 +229,24 @@ module Banzai ...@@ -224,17 +229,24 @@ module Banzai
# Returns a Hash containing all object references (e.g. issue IDs) per the # Returns a Hash containing all object references (e.g. issue IDs) per the
# project they belong to. # project they belong to.
def references_per_project def references_per_parent
@references_per_project ||= begin @references_per ||= {}
@references_per[parent_type] ||= begin
refs = Hash.new { |hash, key| hash[key] = Set.new } refs = Hash.new { |hash, key| hash[key] = Set.new }
regex = Regexp.union(object_class.reference_pattern, object_class.link_reference_pattern) regex = Regexp.union(object_class.reference_pattern, object_class.link_reference_pattern)
nodes.each do |node| nodes.each do |node|
node.to_html.scan(regex) do node.to_html.scan(regex) do
project_path = full_project_path($~[:namespace], $~[:project]) path = if parent_type == :project
full_project_path($~[:namespace], $~[:project])
else
full_group_path($~[:group])
end
symbol = $~[object_sym] symbol = $~[object_sym]
refs[project_path] << symbol if object_class.reference_valid?(symbol) refs[path] << symbol if object_class.reference_valid?(symbol)
end end
end end
...@@ -244,35 +256,41 @@ module Banzai ...@@ -244,35 +256,41 @@ module Banzai
# Returns a Hash containing referenced projects grouped per their full # Returns a Hash containing referenced projects grouped per their full
# path. # path.
def projects_per_reference def parent_per_reference
@projects_per_reference ||= begin @per_reference ||= {}
@per_reference[parent_type] ||= begin
refs = Set.new refs = Set.new
references_per_project.each do |project_ref, _| references_per_parent.each do |ref, _|
refs << project_ref refs << ref
end end
find_projects_for_paths(refs.to_a).index_by(&:full_path) find_for_paths(refs.to_a).index_by(&:full_path)
end end
end end
def projects_relation_for_paths(paths) def relation_for_paths(paths)
Project.where_full_path_in(paths).includes(:namespace) klass = parent_type.to_s.camelize.constantize
result = klass.where_full_path_in(paths)
return result if parent_type == :group
result.includes(:namespace) if parent_type == :project
end end
# Returns projects for the given paths. # Returns projects for the given paths.
def find_projects_for_paths(paths) def find_for_paths(paths)
if RequestStore.active? if RequestStore.active?
cache = project_refs_cache cache = refs_cache
to_query = paths - cache.keys to_query = paths - cache.keys
unless to_query.empty? unless to_query.empty?
projects = projects_relation_for_paths(to_query) records = relation_for_paths(to_query)
found = [] found = []
projects.each do |project| records.each do |record|
ref = project.full_path ref = record.full_path
get_or_set_cache(cache, ref) { project } get_or_set_cache(cache, ref) { record }
found << ref found << ref
end end
...@@ -284,33 +302,37 @@ module Banzai ...@@ -284,33 +302,37 @@ module Banzai
cache.slice(*paths).values.compact cache.slice(*paths).values.compact
else else
projects_relation_for_paths(paths) relation_for_paths(paths)
end end
end end
def current_project_path def current_parent_path
return unless project @current_parent_path ||= parent&.full_path
@current_project_path ||= project.full_path
end end
def current_project_namespace_path def current_project_namespace_path
return unless project @current_project_namespace_path ||= project&.namespace&.full_path
@current_project_namespace_path ||= project.namespace.full_path
end end
private private
def full_project_path(namespace, project_ref) def full_project_path(namespace, project_ref)
return current_project_path unless project_ref return current_parent_path unless project_ref
namespace_ref = namespace || current_project_namespace_path namespace_ref = namespace || current_project_namespace_path
"#{namespace_ref}/#{project_ref}" "#{namespace_ref}/#{project_ref}"
end end
def project_refs_cache def refs_cache
RequestStore[:banzai_project_refs] ||= {} RequestStore["banzai_#{parent_type}_refs".to_sym] ||= {}
end
def parent_type
:project
end
def parent
parent_type == :project ? project : group
end end
end end
end end
......
module Banzai
module Filter
# The actual filter is implemented in the EE mixin
class EpicReferenceFilter < IssuableReferenceFilter
prepend EE::Banzai::Filter::EpicReferenceFilter
self.reference_type = :epic
def self.object_class
Epic
end
end
end
end
module Banzai
module Filter
class IssuableReferenceFilter < AbstractReferenceFilter
def records_per_parent
@records_per_project ||= {}
@records_per_project[object_class.to_s.underscore] ||= begin
hash = Hash.new { |h, k| h[k] = {} }
parent_per_reference.each do |path, parent|
record_ids = references_per_parent[path]
parent_records(parent, record_ids).each do |record|
hash[parent][record.iid.to_i] = record
end
end
hash
end
end
def find_object(parent, iid)
records_per_parent[parent][iid]
end
def parent_from_ref(ref)
parent_per_reference[ref || current_parent_path]
end
end
end
end
...@@ -8,46 +8,24 @@ module Banzai ...@@ -8,46 +8,24 @@ module Banzai
# When external issues tracker like Jira is activated we should not # When external issues tracker like Jira is activated we should not
# use issue reference pattern, but we should still be able # use issue reference pattern, but we should still be able
# to reference issues from other GitLab projects. # to reference issues from other GitLab projects.
class IssueReferenceFilter < AbstractReferenceFilter class IssueReferenceFilter < IssuableReferenceFilter
self.reference_type = :issue self.reference_type = :issue
def self.object_class def self.object_class
Issue Issue
end end
def find_object(project, iid)
issues_per_project[project][iid]
end
def url_for_object(issue, project) def url_for_object(issue, project)
IssuesHelper.url_for_issue(issue.iid, project, only_path: context[:only_path], internal: true) IssuesHelper.url_for_issue(issue.iid, project, only_path: context[:only_path], internal: true)
end end
def project_from_ref(ref)
projects_per_reference[ref || current_project_path]
end
# Returns a Hash containing the issues per Project instance.
def issues_per_project
@issues_per_project ||= begin
hash = Hash.new { |h, k| h[k] = {} }
projects_per_reference.each do |path, project|
issue_ids = references_per_project[path]
issues = project.issues.where(iid: issue_ids.to_a)
issues.each do |issue|
hash[project][issue.iid.to_i] = issue
end
end
hash
end
end
def projects_relation_for_paths(paths) def projects_relation_for_paths(paths)
super(paths).includes(:gitlab_issue_tracker_service) super(paths).includes(:gitlab_issue_tracker_service)
end end
def parent_records(parent, ids)
parent.issues.where(iid: ids.to_a)
end
end end
end end
end end
...@@ -33,7 +33,7 @@ module Banzai ...@@ -33,7 +33,7 @@ module Banzai
end end
def find_label(project_ref, label_id, label_name) def find_label(project_ref, label_id, label_name)
project = project_from_ref(project_ref) project = parent_from_ref(project_ref)
return unless project return unless project
label_params = label_params(label_id, label_name) label_params = label_params(label_id, label_name)
...@@ -66,7 +66,7 @@ module Banzai ...@@ -66,7 +66,7 @@ module Banzai
def object_link_text(object, matches) def object_link_text(object, matches)
project_path = full_project_path(matches[:namespace], matches[:project]) project_path = full_project_path(matches[:namespace], matches[:project])
project_from_ref = project_from_ref_cached(project_path) project_from_ref = from_ref_cached(project_path)
reference = project_from_ref.to_human_reference(project) reference = project_from_ref.to_human_reference(project)
label_suffix = " <i>in #{reference}</i>" if reference.present? label_suffix = " <i>in #{reference}</i>" if reference.present?
......
...@@ -4,48 +4,19 @@ module Banzai ...@@ -4,48 +4,19 @@ module Banzai
# to merge requests that do not exist are ignored. # to merge requests that do not exist are ignored.
# #
# This filter supports cross-project references. # This filter supports cross-project references.
class MergeRequestReferenceFilter < AbstractReferenceFilter class MergeRequestReferenceFilter < IssuableReferenceFilter
self.reference_type = :merge_request self.reference_type = :merge_request
def self.object_class def self.object_class
MergeRequest MergeRequest
end end
def find_object(project, iid)
merge_requests_per_project[project][iid]
end
def url_for_object(mr, project) def url_for_object(mr, project)
h = Gitlab::Routing.url_helpers h = Gitlab::Routing.url_helpers
h.project_merge_request_url(project, mr, h.project_merge_request_url(project, mr,
only_path: context[:only_path]) only_path: context[:only_path])
end end
def project_from_ref(ref)
projects_per_reference[ref || current_project_path]
end
# Returns a Hash containing the merge_requests per Project instance.
def merge_requests_per_project
@merge_requests_per_project ||= begin
hash = Hash.new { |h, k| h[k] = {} }
projects_per_reference.each do |path, project|
merge_request_ids = references_per_project[path]
merge_requests = project.merge_requests
.where(iid: merge_request_ids.to_a)
.includes(target_project: :namespace)
merge_requests.each do |merge_request|
hash[project][merge_request.iid.to_i] = merge_request
end
end
hash
end
end
def object_link_text_extras(object, matches) def object_link_text_extras(object, matches)
extras = super extras = super
...@@ -61,6 +32,12 @@ module Banzai ...@@ -61,6 +32,12 @@ module Banzai
extras extras
end end
def parent_records(parent, ids)
parent.merge_requests
.where(iid: ids.to_a)
.includes(target_project: :namespace)
end
end end
end end
end end
...@@ -38,7 +38,7 @@ module Banzai ...@@ -38,7 +38,7 @@ module Banzai
def find_milestone(project_ref, namespace_ref, milestone_id, milestone_name) def find_milestone(project_ref, namespace_ref, milestone_id, milestone_name)
project_path = full_project_path(namespace_ref, project_ref) project_path = full_project_path(namespace_ref, project_ref)
project = project_from_ref(project_path) project = parent_from_ref(project_path)
return unless project return unless project
......
...@@ -28,8 +28,8 @@ module Banzai ...@@ -28,8 +28,8 @@ module Banzai
issue_parser = Banzai::ReferenceParser::IssueParser.new(project, user) issue_parser = Banzai::ReferenceParser::IssueParser.new(project, user)
merge_request_parser = Banzai::ReferenceParser::MergeRequestParser.new(project, user) merge_request_parser = Banzai::ReferenceParser::MergeRequestParser.new(project, user)
issuables_for_nodes = issue_parser.issues_for_nodes(nodes).merge( issuables_for_nodes = issue_parser.records_for_nodes(nodes).merge(
merge_request_parser.merge_requests_for_nodes(nodes) merge_request_parser.records_for_nodes(nodes)
) )
# The project for the issue/MR might be pending for deletion! # The project for the issue/MR might be pending for deletion!
......
...@@ -24,6 +24,7 @@ module Banzai ...@@ -24,6 +24,7 @@ module Banzai
Filter::AutolinkFilter, Filter::AutolinkFilter,
Filter::ExternalLinkFilter, Filter::ExternalLinkFilter,
Filter::EpicReferenceFilter,
Filter::UserReferenceFilter, Filter::UserReferenceFilter,
Filter::IssueReferenceFilter, Filter::IssueReferenceFilter,
Filter::ExternalIssueReferenceFilter, Filter::ExternalIssueReferenceFilter,
......
...@@ -10,13 +10,14 @@ module Banzai ...@@ -10,13 +10,14 @@ module Banzai
Filter::AutolinkFilter, Filter::AutolinkFilter,
Filter::ExternalLinkFilter, Filter::ExternalLinkFilter,
Filter::EpicReferenceFilter,
Filter::UserReferenceFilter, Filter::UserReferenceFilter,
Filter::IssueReferenceFilter, Filter::IssueReferenceFilter,
Filter::ExternalIssueReferenceFilter, Filter::ExternalIssueReferenceFilter,
Filter::MergeRequestReferenceFilter, Filter::MergeRequestReferenceFilter,
Filter::SnippetReferenceFilter, Filter::SnippetReferenceFilter,
Filter::CommitRangeReferenceFilter, Filter::CommitRangeReferenceFilter,
Filter::CommitReferenceFilter, Filter::CommitReferenceFilter
] ]
end end
end end
......
module Banzai
module ReferenceParser
# The actual parser is implemented in the EE mixin
class EpicParser < IssuableParser
prepend EE::Banzai::ReferenceParser::EpicParser
self.reference_type = :epic
def records_for_nodes(_nodes)
{}
end
end
end
end
module Banzai
module ReferenceParser
class IssuableParser < BaseParser
def nodes_visible_to_user(user, nodes)
records = records_for_nodes(nodes)
nodes.select do |node|
issuable = records[node]
issuable && can_read_reference?(user, issuable)
end
end
def referenced_by(nodes)
records = records_for_nodes(nodes)
nodes.map { |node| records[node] }.compact.uniq
end
def can_read_reference?(user, issuable)
can?(user, "read_#{issuable.class.to_s.underscore}".to_sym, issuable)
end
end
end
end
module Banzai module Banzai
module ReferenceParser module ReferenceParser
class IssueParser < BaseParser class IssueParser < IssuableParser
self.reference_type = :issue self.reference_type = :issue
def nodes_visible_to_user(user, nodes) def nodes_visible_to_user(user, nodes)
issues = issues_for_nodes(nodes) issues = records_for_nodes(nodes)
readable_issues = Ability.issues_readable_by_user(issues.values, user).to_set readable_issues = Ability.issues_readable_by_user(issues.values, user).to_set
...@@ -13,13 +13,7 @@ module Banzai ...@@ -13,13 +13,7 @@ module Banzai
end end
end end
def referenced_by(nodes) def records_for_nodes(nodes)
issues = issues_for_nodes(nodes)
nodes.map { |node| issues[node] }.compact.uniq
end
def issues_for_nodes(nodes)
@issues_for_nodes ||= grouped_objects_for_nodes( @issues_for_nodes ||= grouped_objects_for_nodes(
nodes, nodes,
Issue.all.includes( Issue.all.includes(
......
module Banzai module Banzai
module ReferenceParser module ReferenceParser
class MergeRequestParser < BaseParser class MergeRequestParser < IssuableParser
self.reference_type = :merge_request self.reference_type = :merge_request
def nodes_visible_to_user(user, nodes) def records_for_nodes(nodes)
merge_requests = merge_requests_for_nodes(nodes)
nodes.select do |node|
merge_request = merge_requests[node]
merge_request && can?(user, :read_merge_request, merge_request.project)
end
end
def referenced_by(nodes)
merge_requests = merge_requests_for_nodes(nodes)
nodes.map { |node| merge_requests[node] }.compact.uniq
end
def merge_requests_for_nodes(nodes)
@merge_requests_for_nodes ||= grouped_objects_for_nodes( @merge_requests_for_nodes ||= grouped_objects_for_nodes(
nodes, nodes,
MergeRequest.includes( MergeRequest.includes(
...@@ -40,10 +24,6 @@ module Banzai ...@@ -40,10 +24,6 @@ module Banzai
self.class.data_attribute self.class.data_attribute
) )
end end
def can_read_reference?(user, ref_project, node)
can?(user, :read_merge_request, ref_project)
end
end end
end end
end end
module Gitlab module Gitlab
# Extract possible GFM references from an arbitrary String for further processing. # Extract possible GFM references from an arbitrary String for further processing.
class ReferenceExtractor < Banzai::ReferenceExtractor class ReferenceExtractor < Banzai::ReferenceExtractor
REFERABLES = %i(user issue label milestone merge_request snippet commit commit_range directly_addressed_user).freeze REFERABLES = %i(user issue label milestone merge_request snippet commit commit_range directly_addressed_user epic).freeze
attr_accessor :project, :current_user, :author attr_accessor :project, :current_user, :author
def initialize(project, current_user = nil) def initialize(project, current_user = nil)
......
require 'spec_helper'
describe 'Referencing Epics', :js do
let(:user) { create(:user) }
let(:group) { create(:group, :public) }
let(:epic) { create(:epic, group: group) }
let(:project) { create(:project, :public) }
let(:reference) { epic.to_reference(full: true) }
context 'reference on an issue' do
let(:issue) { create(:issue, project: project, description: "Check #{reference}") }
before do
stub_licensed_features(epics: true)
sign_in(user)
end
context 'when non group member displays the issue' do
context 'when referenced epic is in a public group' do
it 'displays link to the reference' do
visit project_issue_path(project, issue)
page.within('.issuable-details .description') do
expect(page).to have_link(reference, href: group_epic_path(group, epic))
end
end
end
context 'when referenced epic is in a private group' do
before do
group.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
end
it 'does not display link to the reference' do
visit project_issue_path(project, issue)
page.within('.issuable-details .description') do
expect(page).not_to have_link
end
end
end
end
context 'when a group member displays the issue' do
context 'when referenced epic is in a private group' do
before do
group.add_developer(user)
group.update_attribute(:visibility_level, Gitlab::VisibilityLevel::PRIVATE)
end
it 'displays link to the reference' do
visit project_issue_path(project, issue)
page.within('.issuable-details .description') do
expect(page).to have_link(reference, href: group_epic_path(group, epic))
end
end
end
end
end
end
require 'spec_helper'
describe Banzai::Filter::EpicReferenceFilter do
include FilterSpecHelper
let(:urls) { Gitlab::Routing.url_helpers }
let(:group) { create(:group) }
let(:another_group) { create(:group) }
let(:epic) { create(:epic, group: group) }
let(:full_ref_text) { "Check #{epic.group.full_path}&#{epic.iid}" }
def doc(reference = nil)
reference ||= "Check &#{epic.iid}"
context = { project: nil, group: group }
reference_filter(reference, context)
end
context 'internal reference' do
let(:reference) { "&#{epic.iid}" }
it 'links to a valid reference' do
expect(doc.css('a').first.attr('href')).to eq(urls.group_epic_url(group, epic))
end
it 'links with adjacent text' do
expect(doc.text).to eq("Check #{reference}")
end
it 'includes a title attribute' do
expect(doc.css('a').first.attr('title')).to eq(epic.title)
end
it 'escapes the title attribute' do
epic.update_attribute(:title, %{"></a>whatever<a title="})
expect(doc.text).to eq("Check #{reference}")
end
it 'includes default classes' do
expect(doc.css('a').first.attr('class')).to eq('gfm gfm-epic has-tooltip')
end
it 'includes a data-group attribute' do
link = doc.css('a').first
expect(link).to have_attribute('data-group')
expect(link.attr('data-group')).to eq(group.id.to_s)
end
it 'includes a data-epic attribute' do
link = doc.css('a').first
expect(link).to have_attribute('data-epic')
expect(link.attr('data-epic')).to eq(epic.id.to_s)
end
it 'includes a data-original attribute' do
link = doc.css('a').first
expect(link).to have_attribute('data-original')
expect(link.attr('data-original')).to eq(reference)
end
it 'ignores invalid epic IDs' do
text = "Check &9999"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
it 'does not process links containing epic numbers followed by text' do
href = "#{reference}st"
link = doc("<a href='#{href}'></a>").css('a').first.attr('href')
expect(link).to eq(href)
end
end
context 'internal escaped reference' do
let(:reference) { "&amp;#{epic.iid}" }
it 'links to a valid reference' do
expect(doc.css('a').first.attr('href')).to eq(urls.group_epic_url(group, epic))
end
it 'includes a title attribute' do
expect(doc.css('a').first.attr('title')).to eq(epic.title)
end
it 'includes default classes' do
expect(doc.css('a').first.attr('class')).to eq('gfm gfm-epic has-tooltip')
end
it 'ignores invalid epic IDs' do
text = "Check &amp;9999"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
end
context 'cross-reference' do
before do
epic.update_attribute(:group_id, another_group.id)
end
it 'ignores a shorthand reference from another group' do
text = "Check &#{epic.iid}"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
it 'links to a valid reference for full reference' do
expect(doc(full_ref_text).css('a').first.attr('href')).to eq(urls.group_epic_url(another_group, epic))
end
it 'link has valid text' do
expect(doc(full_ref_text).css('a').first.text).to eq("#{epic.group.full_path}&#{epic.iid}")
end
it 'includes default classes' do
expect(doc(full_ref_text).css('a').first.attr('class')).to eq('gfm gfm-epic has-tooltip')
end
end
context 'escaped cross-reference' do
before do
epic.update_attribute(:group_id, another_group.id)
end
it 'ignores a shorthand reference from another group' do
text = "Check &amp;#{epic.iid}"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
it 'links to a valid reference for full reference' do
expect(doc(full_ref_text).css('a').first.attr('href')).to eq(urls.group_epic_url(another_group, epic))
end
it 'link has valid text' do
expect(doc(full_ref_text).css('a').first.text).to eq("#{epic.group.full_path}&#{epic.iid}")
end
it 'includes default classes' do
expect(doc(full_ref_text).css('a').first.attr('class')).to eq('gfm gfm-epic has-tooltip')
end
end
context 'subgroup cross-reference' do
before do
subgroup = create(:group, parent: another_group)
epic.update_attribute(:group_id, subgroup.id)
end
it 'ignores a shorthand reference from another group' do
text = "Check &#{epic.iid}"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
it 'ignores reference with incomplete group path' do
text = "Check @#{epic.group.path}&#{epic.iid}"
expect(doc(text).to_s).to eq(ERB::Util.html_escape_once(text))
end
it 'links to a valid reference for full reference' do
expect(doc(full_ref_text).css('a').first.attr('href')).to eq(urls.group_epic_url(epic.group, epic))
end
it 'link has valid text' do
expect(doc(full_ref_text).css('a').first.text).to eq("#{epic.group.full_path}&#{epic.iid}")
end
it 'includes default classes' do
expect(doc(full_ref_text).css('a').first.attr('class')).to eq('gfm gfm-epic has-tooltip')
end
end
context 'url reference' do
let(:link) { urls.group_epic_url(epic.group, epic) }
let(:text) { "Check #{link}" }
before do
epic.update_attribute(:group_id, another_group.id)
end
it 'links to a valid reference' do
expect(doc(text).css('a').first.attr('href')).to eq(urls.group_epic_url(another_group, epic))
end
it 'link has valid text' do
expect(doc(text).css('a').first.text).to eq(epic.to_reference(group))
end
it 'includes default classes' do
expect(doc(text).css('a').first.attr('class')).to eq('gfm gfm-epic has-tooltip')
end
end
context 'full cross-refererence in a link href' do
let(:link) { "#{another_group.path}&#{epic.iid}" }
let(:text) do
ref = %{<a href="#{link}">Reference</a>}
"Check #{ref}"
end
before do
epic.update_attribute(:group_id, another_group.id)
end
it 'links to a valid reference for link href' do
expect(doc(text).css('a').first.attr('href')).to eq(urls.group_epic_url(another_group, epic))
end
it 'link has valid text' do
expect(doc(text).css('a').first.text).to eq('Reference')
end
it 'includes default classes' do
expect(doc(text).css('a').first.attr('class')).to eq('gfm gfm-epic has-tooltip')
end
end
context 'url in a link href' do
let(:link) { urls.group_epic_url(epic.group, epic) }
let(:text) do
ref = %{<a href="#{link}">Reference</a>}
"Check #{ref}"
end
before do
epic.update_attribute(:group_id, another_group.id)
end
it 'links to a valid reference for link href' do
expect(doc(text).css('a').first.attr('href')).to eq(urls.group_epic_url(another_group, epic))
end
it 'link has valid text' do
expect(doc(text).css('a').first.text).to eq('Reference')
end
it 'includes default classes' do
expect(doc(text).css('a').first.attr('class')).to eq('gfm gfm-epic has-tooltip')
end
end
end
require 'spec_helper'
describe Banzai::ReferenceParser::EpicParser do
include ReferenceParserHelpers
def link(epic_id)
link = empty_html_link
link['data-epic'] = epic_id.to_s
link
end
let(:user) { create(:user) }
let(:public_group) { create(:group, :public) }
let(:private_group1) { create(:group, :private) }
let(:private_group2) { create(:group, :private) }
let(:public_epic) { create(:epic, group: public_group) }
let(:private_epic1) { create(:epic, group: private_group1) }
let(:private_epic2) { create(:epic, group: private_group2) }
let(:nodes) do
[link(public_epic.id), link(private_epic1.id), link(private_epic2.id)]
end
subject { described_class.new(nil, user) }
describe '#nodes_visible_to_user' do
before do
private_group1.add_developer(user)
end
context 'when the epics feature is enabled' do
before do
stub_licensed_features(epics: true)
end
it 'returns the nodes the user can read for valid epic nodes' do
expected_result = [nodes[0], nodes[1]]
expect(subject.nodes_visible_to_user(user, nodes)).to match_array(expected_result)
end
it 'returns an empty array for nodes without required data-attributes' do
expect(subject.nodes_visible_to_user(user, [empty_html_link])).to be_empty
end
end
context 'when the epics feature is disabled' do
it 'returns an empty array' do
expect(subject.nodes_visible_to_user(user, nodes)).to be_empty
end
end
end
describe '#referenced_by' do
context 'when using an existing epics IDs' do
it 'returns an Array of epics' do
expected_result = [public_epic, private_epic1, private_epic2]
expect(subject.referenced_by(nodes)).to match_array(expected_result)
end
it 'returns an empty Array for empty list of nodes' do
expect(subject.referenced_by([])).to be_empty
end
end
context 'when epic with given ID does not exist' do
it 'returns an empty Array' do
expect(subject.referenced_by([link(9999)])).to be_empty
end
end
end
describe '#records_for_nodes' do
it 'returns a Hash containing the epics for a list of nodes' do
expected_hash = {
nodes[0] => public_epic,
nodes[1] => private_epic1,
nodes[2] => private_epic2
}
expect(subject.records_for_nodes(nodes)).to eq(expected_hash)
end
end
end
...@@ -58,4 +58,72 @@ describe Epic do ...@@ -58,4 +58,72 @@ describe Epic do
expect(result.map(&:epic_issue_id)).to match_array([epic_issues.first.id]) expect(result.map(&:epic_issue_id)).to match_array([epic_issues.first.id])
end end
end end
describe '#to_reference' do
let(:group) { create(:group, path: 'group-a') }
let(:epic) { create(:epic, iid: 1, group: group) }
context 'when nil argument' do
it 'returns epic id' do
expect(epic.to_reference).to eq('&1')
end
end
context 'when group argument equals epic group' do
it 'returns epic id' do
expect(epic.to_reference(epic.group)).to eq('&1')
end
end
context 'when group argument differs from epic group' do
it 'returns complete path to the epic' do
expect(epic.to_reference(create(:group))).to eq('group-a&1')
end
end
context 'when full is true' do
it 'returns complete path to the epic' do
expect(epic.to_reference(full: true)).to eq('group-a&1')
expect(epic.to_reference(epic.group, full: true)).to eq('group-a&1')
expect(epic.to_reference(group, full: true)).to eq('group-a&1')
end
end
end
context 'mentioning other objects' do
let(:group) { create(:group) }
let(:epic) { create(:epic, group: group) }
let(:project) { create(:project, :repository, :public) }
let(:mentioned_issue) { create(:issue, project: project) }
let(:mentioned_mr) { create(:merge_request, source_project: project) }
let(:mentioned_commit) { project.commit("HEAD~1") }
let(:backref_text) { "epic #{epic.to_reference}" }
let(:ref_text) do
<<-MSG.strip_heredoc
These are simple references:
Issue: #{mentioned_issue.to_reference(group)}
Merge Request: #{mentioned_mr.to_reference(group)}
Commit: #{mentioned_commit.to_reference(group)}
This is a self-reference and should not be mentioned at all:
Self: #{backref_text}
MSG
end
before do
epic.description = ref_text
epic.save
end
it 'creates new system notes for cross references' do
[mentioned_issue, mentioned_mr, mentioned_commit].each do |newref|
expect(SystemNoteService).to receive(:cross_reference)
.with(newref, epic, epic.author)
end
epic.create_new_cross_references!(epic.author)
end
end
end end
...@@ -42,6 +42,10 @@ describe 'GitLab Markdown' do ...@@ -42,6 +42,10 @@ describe 'GitLab Markdown' do
@doc ||= Nokogiri::HTML::DocumentFragment.parse(html) @doc ||= Nokogiri::HTML::DocumentFragment.parse(html)
end end
before do
stub_licensed_features(epics: true)
end
# Shared behavior that all pipelines should exhibit # Shared behavior that all pipelines should exhibit
shared_examples 'all pipelines' do shared_examples 'all pipelines' do
describe 'Redcarpet extensions' do describe 'Redcarpet extensions' do
...@@ -207,8 +211,9 @@ describe 'GitLab Markdown' do ...@@ -207,8 +211,9 @@ describe 'GitLab Markdown' do
before do before do
@feat = MarkdownFeature.new @feat = MarkdownFeature.new
# `markdown` helper expects a `@project` variable # `markdown` helper expects a `@project` and `@group` variable
@project = @feat.project @project = @feat.project
@group = @feat.group
end end
context 'default pipeline' do context 'default pipeline' do
...@@ -244,6 +249,7 @@ describe 'GitLab Markdown' do ...@@ -244,6 +249,7 @@ describe 'GitLab Markdown' do
expect(doc).to reference_commits expect(doc).to reference_commits
expect(doc).to reference_labels expect(doc).to reference_labels
expect(doc).to reference_milestones expect(doc).to reference_milestones
expect(doc).to reference_epics
end end
end end
...@@ -301,6 +307,7 @@ describe 'GitLab Markdown' do ...@@ -301,6 +307,7 @@ describe 'GitLab Markdown' do
expect(doc).to reference_commits expect(doc).to reference_commits
expect(doc).to reference_labels expect(doc).to reference_labels
expect(doc).to reference_milestones expect(doc).to reference_milestones
expect(doc).to reference_epics
end end
end end
......
...@@ -233,6 +233,14 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e ...@@ -233,6 +233,14 @@ References should be parseable even inside _<%= merge_request.to_reference %>_ e
- Group milestone by name in quotes: <%= group_milestone.to_reference(format: :name) %> - Group milestone by name in quotes: <%= group_milestone.to_reference(format: :name) %>
- Group milestone by URL is ignore: <%= urls.milestone_url(group_milestone) %> - Group milestone by URL is ignore: <%= urls.milestone_url(group_milestone) %>
#### EpicReferenceFilter
- Epic by ID: <%= epic.to_reference %>
- Epic in another group: <%= epic_other_group.to_reference(group) %>
- Epic by url: <%= urls.group_epic_url(epic.group, epic) %>
- Link to epic by reference: [Epic](<%= epic.to_reference(group) %>)
- Link to epic by URL: [Epic](<%= urls.group_epic_url(epic.group, epic) %>)
### Task Lists ### Task Lists
- [ ] Incomplete task 1 - [ ] Incomplete task 1
......
...@@ -204,7 +204,7 @@ describe IssuablesHelper do ...@@ -204,7 +204,7 @@ describe IssuablesHelper do
'canUpdate' => true, 'canUpdate' => true,
'canDestroy' => true, 'canDestroy' => true,
'canAdmin' => true, 'canAdmin' => true,
'issuableRef' => nil, 'issuableRef' => "&#{epic.iid}",
'markdownPreviewPath' => "/groups/#{@group.full_path}/preview_markdown", 'markdownPreviewPath' => "/groups/#{@group.full_path}/preview_markdown",
'markdownDocsPath' => '/help/user/markdown', 'markdownDocsPath' => '/help/user/markdown',
'issuableTemplates' => nil, 'issuableTemplates' => nil,
......
...@@ -3,20 +3,20 @@ require 'spec_helper' ...@@ -3,20 +3,20 @@ require 'spec_helper'
describe Banzai::CrossProjectReference do describe Banzai::CrossProjectReference do
include described_class include described_class
describe '#project_from_ref' do describe '#parent_from_ref' do
context 'when no project was referenced' do context 'when no project was referenced' do
it 'returns the project from context' do it 'returns the project from context' do
project = double project = double
allow(self).to receive(:context).and_return({ project: project }) allow(self).to receive(:context).and_return({ project: project })
expect(project_from_ref(nil)).to eq project expect(parent_from_ref(nil)).to eq project
end end
end end
context 'when referenced project does not exist' do context 'when referenced project does not exist' do
it 'returns nil' do it 'returns nil' do
expect(project_from_ref('invalid/reference')).to be_nil expect(parent_from_ref('invalid/reference')).to be_nil
end end
end end
...@@ -27,7 +27,7 @@ describe Banzai::CrossProjectReference do ...@@ -27,7 +27,7 @@ describe Banzai::CrossProjectReference do
expect(Project).to receive(:find_by_full_path) expect(Project).to receive(:find_by_full_path)
.with('cross/reference').and_return(project2) .with('cross/reference').and_return(project2)
expect(project_from_ref('cross/reference')).to eq project2 expect(parent_from_ref('cross/reference')).to eq project2
end end
end end
end end
......
...@@ -3,67 +3,67 @@ require 'spec_helper' ...@@ -3,67 +3,67 @@ require 'spec_helper'
describe Banzai::Filter::AbstractReferenceFilter do describe Banzai::Filter::AbstractReferenceFilter do
let(:project) { create(:project) } let(:project) { create(:project) }
describe '#references_per_project' do describe '#references_per_parent' do
it 'returns a Hash containing references grouped per project paths' do it 'returns a Hash containing references grouped per parent paths' do
doc = Nokogiri::HTML.fragment("#1 #{project.full_path}#2") doc = Nokogiri::HTML.fragment("#1 #{project.full_path}#2")
filter = described_class.new(doc, project: project) filter = described_class.new(doc, project: project)
expect(filter).to receive(:object_class).exactly(4).times.and_return(Issue) expect(filter).to receive(:object_class).exactly(4).times.and_return(Issue)
expect(filter).to receive(:object_sym).twice.and_return(:issue) expect(filter).to receive(:object_sym).twice.and_return(:issue)
refs = filter.references_per_project refs = filter.references_per_parent
expect(refs).to be_an_instance_of(Hash) expect(refs).to be_an_instance_of(Hash)
expect(refs[project.full_path]).to eq(Set.new(%w[1 2])) expect(refs[project.full_path]).to eq(Set.new(%w[1 2]))
end end
end end
describe '#projects_per_reference' do describe '#parent_per_reference' do
it 'returns a Hash containing projects grouped per project paths' do it 'returns a Hash containing projects grouped per parent paths' do
doc = Nokogiri::HTML.fragment('') doc = Nokogiri::HTML.fragment('')
filter = described_class.new(doc, project: project) filter = described_class.new(doc, project: project)
expect(filter).to receive(:references_per_project) expect(filter).to receive(:references_per_parent)
.and_return({ project.full_path => Set.new(%w[1]) }) .and_return({ project.full_path => Set.new(%w[1]) })
expect(filter.projects_per_reference) expect(filter.parent_per_reference)
.to eq({ project.full_path => project }) .to eq({ project.full_path => project })
end end
end end
describe '#find_projects_for_paths' do describe '#find_for_paths' do
let(:doc) { Nokogiri::HTML.fragment('') } let(:doc) { Nokogiri::HTML.fragment('') }
let(:filter) { described_class.new(doc, project: project) } let(:filter) { described_class.new(doc, project: project) }
context 'with RequestStore disabled' do context 'with RequestStore disabled' do
it 'returns a list of Projects for a list of paths' do it 'returns a list of Projects for a list of paths' do
expect(filter.find_projects_for_paths([project.full_path])) expect(filter.find_for_paths([project.full_path]))
.to eq([project]) .to eq([project])
end end
it "return an empty array for paths that don't exist" do it "return an empty array for paths that don't exist" do
expect(filter.find_projects_for_paths(['nonexistent/project'])) expect(filter.find_for_paths(['nonexistent/project']))
.to eq([]) .to eq([])
end end
end end
context 'with RequestStore enabled', :request_store do context 'with RequestStore enabled', :request_store do
it 'returns a list of Projects for a list of paths' do it 'returns a list of Projects for a list of paths' do
expect(filter.find_projects_for_paths([project.full_path])) expect(filter.find_for_paths([project.full_path]))
.to eq([project]) .to eq([project])
end end
context "when no project with that path exists" do context "when no project with that path exists" do
it "returns no value" do it "returns no value" do
expect(filter.find_projects_for_paths(['nonexistent/project'])) expect(filter.find_for_paths(['nonexistent/project']))
.to eq([]) .to eq([])
end end
it "adds the ref to the project refs cache" do it "adds the ref to the project refs cache" do
project_refs_cache = {} project_refs_cache = {}
allow(filter).to receive(:project_refs_cache).and_return(project_refs_cache) allow(filter).to receive(:refs_cache).and_return(project_refs_cache)
filter.find_projects_for_paths(['nonexistent/project']) filter.find_for_paths(['nonexistent/project'])
expect(project_refs_cache).to eq({ 'nonexistent/project' => nil }) expect(project_refs_cache).to eq({ 'nonexistent/project' => nil })
end end
...@@ -71,11 +71,11 @@ describe Banzai::Filter::AbstractReferenceFilter do ...@@ -71,11 +71,11 @@ describe Banzai::Filter::AbstractReferenceFilter do
context 'when the project refs cache includes nil values' do context 'when the project refs cache includes nil values' do
before do before do
# adds { 'nonexistent/project' => nil } to cache # adds { 'nonexistent/project' => nil } to cache
filter.project_from_ref_cached('nonexistent/project') filter.from_ref_cached('nonexistent/project')
end end
it "return an empty array for paths that don't exist" do it "return an empty array for paths that don't exist" do
expect(filter.find_projects_for_paths(['nonexistent/project'])) expect(filter.find_for_paths(['nonexistent/project']))
.to eq([]) .to eq([])
end end
end end
...@@ -83,12 +83,12 @@ describe Banzai::Filter::AbstractReferenceFilter do ...@@ -83,12 +83,12 @@ describe Banzai::Filter::AbstractReferenceFilter do
end end
end end
describe '#current_project_path' do describe '#current_parent_path' do
it 'returns the path of the current project' do it 'returns the path of the current parent' do
doc = Nokogiri::HTML.fragment('') doc = Nokogiri::HTML.fragment('')
filter = described_class.new(doc, project: project) filter = described_class.new(doc, project: project)
expect(filter.current_project_path).to eq(project.full_path) expect(filter.current_parent_path).to eq(project.full_path)
end end
end end
end end
...@@ -157,6 +157,12 @@ describe Banzai::Filter::IssueReferenceFilter do ...@@ -157,6 +157,12 @@ describe Banzai::Filter::IssueReferenceFilter do
expect(doc.text).to eq("Fixed (#{project2.full_path}##{issue.iid}.)") expect(doc.text).to eq("Fixed (#{project2.full_path}##{issue.iid}.)")
end end
it 'includes default classes' do
doc = reference_filter("Fixed (#{reference}.)")
expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
end
it 'ignores invalid issue IDs on the referenced project' do it 'ignores invalid issue IDs on the referenced project' do
exp = act = "Fixed #{invalidate_reference(reference)}" exp = act = "Fixed #{invalidate_reference(reference)}"
...@@ -201,6 +207,12 @@ describe Banzai::Filter::IssueReferenceFilter do ...@@ -201,6 +207,12 @@ describe Banzai::Filter::IssueReferenceFilter do
expect(doc.text).to eq("Fixed (#{project2.path}##{issue.iid}.)") expect(doc.text).to eq("Fixed (#{project2.path}##{issue.iid}.)")
end end
it 'includes default classes' do
doc = reference_filter("Fixed (#{reference}.)")
expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
end
it 'ignores invalid issue IDs on the referenced project' do it 'ignores invalid issue IDs on the referenced project' do
exp = act = "Fixed #{invalidate_reference(reference)}" exp = act = "Fixed #{invalidate_reference(reference)}"
...@@ -245,6 +257,12 @@ describe Banzai::Filter::IssueReferenceFilter do ...@@ -245,6 +257,12 @@ describe Banzai::Filter::IssueReferenceFilter do
expect(doc.text).to eq("Fixed (#{project2.path}##{issue.iid}.)") expect(doc.text).to eq("Fixed (#{project2.path}##{issue.iid}.)")
end end
it 'includes default classes' do
doc = reference_filter("Fixed (#{reference}.)")
expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
end
it 'ignores invalid issue IDs on the referenced project' do it 'ignores invalid issue IDs on the referenced project' do
exp = act = "Fixed #{invalidate_reference(reference)}" exp = act = "Fixed #{invalidate_reference(reference)}"
...@@ -269,8 +287,15 @@ describe Banzai::Filter::IssueReferenceFilter do ...@@ -269,8 +287,15 @@ describe Banzai::Filter::IssueReferenceFilter do
it 'links with adjacent text' do it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference}.)") doc = reference_filter("Fixed (#{reference}.)")
expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(issue.to_reference(project))} \(comment 123\)<\/a>\.\)/) expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(issue.to_reference(project))} \(comment 123\)<\/a>\.\)/)
end end
it 'includes default classes' do
doc = reference_filter("Fixed (#{reference}.)")
expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
end
end end
context 'cross-project reference in link href' do context 'cross-project reference in link href' do
...@@ -291,8 +316,15 @@ describe Banzai::Filter::IssueReferenceFilter do ...@@ -291,8 +316,15 @@ describe Banzai::Filter::IssueReferenceFilter do
it 'links with adjacent text' do it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference_link}.)") doc = reference_filter("Fixed (#{reference_link}.)")
expect(doc.to_html).to match(/\(<a.+>Reference<\/a>\.\)/) expect(doc.to_html).to match(/\(<a.+>Reference<\/a>\.\)/)
end end
it 'includes default classes' do
doc = reference_filter("Fixed (#{reference_link}.)")
expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
end
end end
context 'cross-project URL in link href' do context 'cross-project URL in link href' do
...@@ -313,8 +345,15 @@ describe Banzai::Filter::IssueReferenceFilter do ...@@ -313,8 +345,15 @@ describe Banzai::Filter::IssueReferenceFilter do
it 'links with adjacent text' do it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference_link}.)") doc = reference_filter("Fixed (#{reference_link}.)")
expect(doc.to_html).to match(/\(<a.+>Reference<\/a>\.\)/) expect(doc.to_html).to match(/\(<a.+>Reference<\/a>\.\)/)
end end
it 'includes default classes' do
doc = reference_filter("Fixed (#{reference_link}.)")
expect(doc.css('a').first.attr('class')).to eq 'gfm gfm-issue has-tooltip'
end
end end
context 'group context' do context 'group context' do
...@@ -387,19 +426,19 @@ describe Banzai::Filter::IssueReferenceFilter do ...@@ -387,19 +426,19 @@ describe Banzai::Filter::IssueReferenceFilter do
end end
end end
describe '#issues_per_project' do describe '#records_per_parent' do
context 'using an internal issue tracker' do context 'using an internal issue tracker' do
it 'returns a Hash containing the issues per project' do it 'returns a Hash containing the issues per project' do
doc = Nokogiri::HTML.fragment('') doc = Nokogiri::HTML.fragment('')
filter = described_class.new(doc, project: project) filter = described_class.new(doc, project: project)
expect(filter).to receive(:projects_per_reference) expect(filter).to receive(:parent_per_reference)
.and_return({ project.full_path => project }) .and_return({ project.full_path => project })
expect(filter).to receive(:references_per_project) expect(filter).to receive(:references_per_parent)
.and_return({ project.full_path => Set.new([issue.iid]) }) .and_return({ project.full_path => Set.new([issue.iid]) })
expect(filter.issues_per_project) expect(filter.records_per_parent)
.to eq({ project => { issue.iid => issue } }) .to eq({ project => { issue.iid => issue } })
end end
end end
......
...@@ -70,12 +70,12 @@ describe Banzai::ReferenceParser::IssueParser do ...@@ -70,12 +70,12 @@ describe Banzai::ReferenceParser::IssueParser do
end end
end end
describe '#issues_for_nodes' do describe '#records_for_nodes' do
it 'returns a Hash containing the issues for a list of nodes' do it 'returns a Hash containing the issues for a list of nodes' do
link['data-issue'] = issue.id.to_s link['data-issue'] = issue.id.to_s
nodes = [link] nodes = [link]
expect(subject.issues_for_nodes(nodes)).to eq({ link => issue }) expect(subject.records_for_nodes(nodes)).to eq({ link => issue })
end end
end end
end end
require 'spec_helper' require 'spec_helper'
describe Gitlab::ReferenceExtractor do describe Gitlab::ReferenceExtractor do
let(:project) { create(:project) } let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
before do before do
project.team << [project.creator, :developer] group.add_developer(project.creator)
end end
subject { described_class.new(project, project.creator) } subject { described_class.new(project, project.creator) }
...@@ -153,6 +154,20 @@ describe Gitlab::ReferenceExtractor do ...@@ -153,6 +154,20 @@ describe Gitlab::ReferenceExtractor do
expect(subject.snippets).to match_array([@s0, @s1]) expect(subject.snippets).to match_array([@s0, @s1])
end end
it 'accesses valid epics' do
stub_licensed_features(epics: true)
@e0 = create(:epic, group: group)
@e1 = create(:epic, group: group)
@e2 = create(:epic, group: create(:group, :private))
text = "#{@e0.to_reference(group)}, &999, #{@e1.to_reference(group)}, #{@e2.to_reference(group)}"
subject.analyze(text, { group: group })
expect(subject.epics).to match_array([@e0, @e1])
end
it 'accesses valid commits' do it 'accesses valid commits' do
project = create(:project, :repository) { |p| p.add_developer(p.creator) } project = create(:project, :repository) { |p| p.add_developer(p.creator) }
commit = project.commit('master') commit = project.commit('master')
...@@ -250,4 +265,34 @@ describe Gitlab::ReferenceExtractor do ...@@ -250,4 +265,34 @@ describe Gitlab::ReferenceExtractor do
subject { described_class.references_pattern } subject { described_class.references_pattern }
it { is_expected.to be_kind_of Regexp } it { is_expected.to be_kind_of Regexp }
end end
describe 'referables prefixes' do
def prefixes
described_class::REFERABLES.each_with_object({}) do |referable, result|
klass = referable.to_s.camelize.constantize
next unless klass.respond_to?(:reference_prefix)
prefix = klass.reference_prefix
result[prefix] ||= []
result[prefix] << referable
end
end
it 'returns all supported prefixes' do
expect(prefixes.keys.uniq).to match_array(%w(@ # ~ % ! $ &))
end
it 'does not allow one prefix for multiple referables if not allowed specificly' do
# make sure you are not overriding existing prefix before changing this hash
multiple_allowed = {
'@' => 3
}
prefixes.each do |prefix, referables|
expected_count = multiple_allowed[prefix] || 1
expect(referables.count).to eq(expected_count)
end
end
end
end end
...@@ -79,6 +79,14 @@ class MarkdownFeature ...@@ -79,6 +79,14 @@ class MarkdownFeature
@group_milestone ||= create(:milestone, name: 'group-milestone', group: group) @group_milestone ||= create(:milestone, name: 'group-milestone', group: group)
end end
def epic
@epic ||= create(:epic, title: 'epic', group: group)
end
def epic_other_group
@epic ||= create(:epic, title: 'epic')
end
# Cross-references ----------------------------------------------------------- # Cross-references -----------------------------------------------------------
def xproject def xproject
......
...@@ -159,6 +159,15 @@ module MarkdownMatchers ...@@ -159,6 +159,15 @@ module MarkdownMatchers
end end
end end
# EpicReferenceFilter
matcher :reference_epics do
set_default_markdown_messages
match do |actual|
expect(actual).to have_selector('a.gfm.gfm-epic', count: 5)
end
end
# TaskListFilter # TaskListFilter
matcher :parse_task_lists do matcher :parse_task_lists do
set_default_markdown_messages set_default_markdown_messages
......
...@@ -5,11 +5,13 @@ ...@@ -5,11 +5,13 @@
# - let(:set_mentionable_text) { lambda { |txt| "block that assigns txt to the subject's mentionable_text" } } # - let(:set_mentionable_text) { lambda { |txt| "block that assigns txt to the subject's mentionable_text" } }
shared_context 'mentionable context' do shared_context 'mentionable context' do
let(:group) { create(:group) }
let(:project) { subject.project } let(:project) { subject.project }
let(:author) { subject.author } let(:author) { subject.author }
let(:mentioned_issue) { create(:issue, project: project) } let(:mentioned_issue) { create(:issue, project: project) }
let!(:mentioned_mr) { create(:merge_request, source_project: project) } let!(:mentioned_mr) { create(:merge_request, source_project: project) }
let(:mentioned_epic) { create(:epic, group: group) }
let(:mentioned_commit) { project.commit("HEAD~1") } let(:mentioned_commit) { project.commit("HEAD~1") }
let(:ext_proj) { create(:project, :public, :repository) } let(:ext_proj) { create(:project, :public, :repository) }
...@@ -27,6 +29,7 @@ shared_context 'mentionable context' do ...@@ -27,6 +29,7 @@ shared_context 'mentionable context' do
These references are new: These references are new:
Issue: #{mentioned_issue.to_reference} Issue: #{mentioned_issue.to_reference}
Merge: #{mentioned_mr.to_reference} Merge: #{mentioned_mr.to_reference}
Epic: #{mentioned_epic.to_reference(project)}
Commit: #{mentioned_commit.to_reference} Commit: #{mentioned_commit.to_reference}
This reference is a repeat and should only be mentioned once: This reference is a repeat and should only be mentioned once:
...@@ -43,6 +46,8 @@ shared_context 'mentionable context' do ...@@ -43,6 +46,8 @@ shared_context 'mentionable context' do
end end
before do before do
stub_licensed_features(epics: true)
# Wire the project's repository to return the mentioned commit, and +nil+ # Wire the project's repository to return the mentioned commit, and +nil+
# for any unrecognized commits. # for any unrecognized commits.
allow_any_instance_of(::Repository).to receive(:commit).and_call_original allow_any_instance_of(::Repository).to receive(:commit).and_call_original
...@@ -67,9 +72,10 @@ shared_examples 'a mentionable' do ...@@ -67,9 +72,10 @@ shared_examples 'a mentionable' do
it "extracts references from its reference property" do it "extracts references from its reference property" do
# De-duplicate and omit itself # De-duplicate and omit itself
refs = subject.referenced_mentionables refs = subject.referenced_mentionables
expect(refs.size).to eq(6) expect(refs.size).to eq(7)
expect(refs).to include(mentioned_issue) expect(refs).to include(mentioned_issue)
expect(refs).to include(mentioned_mr) expect(refs).to include(mentioned_mr)
expect(refs).to include(mentioned_epic)
expect(refs).to include(mentioned_commit) expect(refs).to include(mentioned_commit)
expect(refs).to include(ext_issue) expect(refs).to include(ext_issue)
expect(refs).to include(ext_mr) expect(refs).to include(ext_mr)
...@@ -77,7 +83,7 @@ shared_examples 'a mentionable' do ...@@ -77,7 +83,7 @@ shared_examples 'a mentionable' do
end end
it 'creates cross-reference notes' do it 'creates cross-reference notes' do
mentioned_objects = [mentioned_issue, mentioned_mr, mentioned_commit, mentioned_objects = [mentioned_issue, mentioned_mr, mentioned_epic, mentioned_commit,
ext_issue, ext_mr, ext_commit] ext_issue, ext_mr, ext_commit]
mentioned_objects.each do |referenced| mentioned_objects.each do |referenced|
...@@ -97,6 +103,7 @@ shared_examples 'an editable mentionable' do ...@@ -97,6 +103,7 @@ shared_examples 'an editable mentionable' do
let(:new_issues) do let(:new_issues) do
[create(:issue, project: project), create(:issue, project: ext_proj)] [create(:issue, project: project), create(:issue, project: ext_proj)]
end end
let(:new_epic) { create(:epic, group: group) }
it 'creates new cross-reference notes when the mentionable text is edited' do it 'creates new cross-reference notes when the mentionable text is edited' do
subject.save subject.save
...@@ -107,6 +114,8 @@ shared_examples 'an editable mentionable' do ...@@ -107,6 +114,8 @@ shared_examples 'an editable mentionable' do
Issue: #{mentioned_issue.to_reference} Issue: #{mentioned_issue.to_reference}
Issue: #{mentioned_epic.to_reference(project)}
Commit: #{mentioned_commit.to_reference} Commit: #{mentioned_commit.to_reference}
--- ---
...@@ -117,23 +126,26 @@ shared_examples 'an editable mentionable' do ...@@ -117,23 +126,26 @@ shared_examples 'an editable mentionable' do
--- ---
These two references are introduced in an edit: These three references are introduced in an edit:
Issue: #{new_issues[0].to_reference} Issue: #{new_issues[0].to_reference}
Cross: #{new_issues[1].to_reference(project)} Cross: #{new_issues[1].to_reference(project)}
Epic: #{new_epic.to_reference(project)}
MSG MSG
# These three objects were already referenced, and should not receive new # These four objects were already referenced, and should not receive new
# notes # notes
[mentioned_issue, mentioned_commit, ext_issue].each do |oldref| [mentioned_issue, mentioned_commit, mentioned_epic, ext_issue].each do |oldref|
expect(SystemNoteService).not_to receive(:cross_reference) expect(SystemNoteService).not_to receive(:cross_reference)
.with(oldref, any_args) .with(oldref, any_args)
end end
# These two issues are new and should receive reference notes # These two issues and an epic are new and should receive reference notes
# In the case of MergeRequests remember that cannot mention commits included in the MergeRequest # In the case of MergeRequests remember that cannot mention commits included in the MergeRequest
new_issues.each do |newref| new_mentionables = new_issues + [new_epic]
new_mentionables.each do |newref|
expect(SystemNoteService).to receive(:cross_reference) expect(SystemNoteService).to receive(:cross_reference)
.with(newref, subject.local_reference, author) .with(newref, subject.local_reference, author)
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment