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

Support mentioning epics

parent d15e704e
......@@ -86,6 +86,8 @@ module MarkupHelper
return '' unless text.present?
context[:project] ||= @project
context[:group] ||= @group
html = markdown_unsafe(text, context)
prepare_for_rendering(html, context)
end
......
......@@ -61,7 +61,7 @@ module Mentionable
cache_key: [self, attr],
author: author,
skip_project_check: skip_project_check?
)
).merge(mentionable_params)
extractor.analyze(text, options)
end
......@@ -82,7 +82,7 @@ module Mentionable
return [] unless matches_cross_reference_regex?
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
# both of the object's `hash` values to be the same, which may not be the
......@@ -157,4 +157,8 @@ module Mentionable
def skip_project_check?
false
end
def mentionable_params
{}
end
end
......@@ -3,6 +3,7 @@ module Mentionable
def self.reference_pattern(link_patterns, issue_pattern)
Regexp.union(link_patterns,
issue_pattern,
Epic.reference_pattern,
Commit.reference_pattern,
MergeRequest.reference_pattern)
end
......
# 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
prepend EE::Epic
# TODO: this will be implemented as part of #3853
def to_reference
def self.reference_prefix
'&'
end
def self.reference_prefix_escaped
'&amp;'
end
end
---
title: Support mentioning epics
merge_request:
author:
type: added
......@@ -22,6 +22,7 @@ You can use GFM in the following areas:
- snippets (the snippet must be named with a `.md` extension)
- wiki pages
- markdown documents inside the repository
- epics
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.
......@@ -245,6 +246,7 @@ GFM will recognize the following:
| `#123` | issue |
| `!123` | merge request |
| `$123` | snippet |
| `&123` | epic |
| `~123` | label by ID |
| `~bug` | one-word label by name |
| `~"feature request"` | multi-word label by name |
......@@ -265,6 +267,7 @@ GFM also recognizes certain cross-project references:
| `namespace/project%123` | project milestone |
| `namespace/project$123` | snippet |
| `namespace/project@9ba12248` | specific commit |
| `group1/subgroup&123` | epic |
| `namespace/project@9ba12248...b19a04f5` | commit range comparison |
| `namespace/project~"Some label"` | issues with given label |
......
......@@ -6,6 +6,7 @@ module EE
include InternalId
include Issuable
include Noteable
include Referable
belongs_to :assignee, class_name: "User"
belongs_to :group
......@@ -15,6 +16,46 @@ module EE
validates :group, presence: true
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
Array(assignee)
end
......@@ -27,6 +68,18 @@ module EE
false
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
def update_project_counter_caches
end
......@@ -38,5 +91,9 @@ module EE
Ability.issues_readable_by_user(related_issues, current_user)
end
def mentionable_params
{ group: group }
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
# ref - String reference.
#
# 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
Project.find_by_full_path(ref)
......
......@@ -82,9 +82,9 @@ module Banzai
end
end
def project_from_ref_cached(ref)
cached_call(:banzai_project_refs, ref) do
project_from_ref(ref)
def from_ref_cached(ref)
cached_call("banzai_#{parent_type}_refs".to_sym, ref) do
parent_from_ref(ref)
end
end
......@@ -153,15 +153,20 @@ module Banzai
# have `gfm` and `gfm-OBJECT_NAME` class names attached for styling.
def object_link_filter(text, pattern, link_content: nil, link_reference: false)
references_in(text, pattern) do |match, id, project_ref, namespace_ref, matches|
project_path = full_project_path(namespace_ref, project_ref)
project = project_from_ref_cached(project_path)
parent_path = if parent_type == :group
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 =
if link_reference
find_object_from_link_cached(project, id)
find_object_from_link_cached(parent, id)
else
find_object_cached(project, id)
find_object_cached(parent, id)
end
end
......@@ -169,13 +174,13 @@ module Banzai
title = object_link_title(object)
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 =
if matches.names.include?("url") && matches[:url]
matches[:url]
else
url_for_object_cached(object, project)
url_for_object_cached(object, parent)
end
content = link_content || object_link_text(object, matches)
......@@ -224,17 +229,24 @@ module Banzai
# Returns a Hash containing all object references (e.g. issue IDs) per the
# project they belong to.
def references_per_project
@references_per_project ||= begin
def references_per_parent
@references_per ||= {}
@references_per[parent_type] ||= begin
refs = Hash.new { |hash, key| hash[key] = Set.new }
regex = Regexp.union(object_class.reference_pattern, object_class.link_reference_pattern)
nodes.each do |node|
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]
refs[project_path] << symbol if object_class.reference_valid?(symbol)
refs[path] << symbol if object_class.reference_valid?(symbol)
end
end
......@@ -244,35 +256,41 @@ module Banzai
# Returns a Hash containing referenced projects grouped per their full
# path.
def projects_per_reference
@projects_per_reference ||= begin
def parent_per_reference
@per_reference ||= {}
@per_reference[parent_type] ||= begin
refs = Set.new
references_per_project.each do |project_ref, _|
refs << project_ref
references_per_parent.each do |ref, _|
refs << ref
end
find_projects_for_paths(refs.to_a).index_by(&:full_path)
find_for_paths(refs.to_a).index_by(&:full_path)
end
end
def projects_relation_for_paths(paths)
Project.where_full_path_in(paths).includes(:namespace)
def relation_for_paths(paths)
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
# Returns projects for the given paths.
def find_projects_for_paths(paths)
def find_for_paths(paths)
if RequestStore.active?
cache = project_refs_cache
cache = refs_cache
to_query = paths - cache.keys
unless to_query.empty?
projects = projects_relation_for_paths(to_query)
records = relation_for_paths(to_query)
found = []
projects.each do |project|
ref = project.full_path
get_or_set_cache(cache, ref) { project }
records.each do |record|
ref = record.full_path
get_or_set_cache(cache, ref) { record }
found << ref
end
......@@ -284,33 +302,37 @@ module Banzai
cache.slice(*paths).values.compact
else
projects_relation_for_paths(paths)
relation_for_paths(paths)
end
end
def current_project_path
return unless project
@current_project_path ||= project.full_path
def current_parent_path
@current_parent_path ||= parent&.full_path
end
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
private
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}/#{project_ref}"
end
def project_refs_cache
RequestStore[:banzai_project_refs] ||= {}
def refs_cache
RequestStore["banzai_#{parent_type}_refs".to_sym] ||= {}
end
def parent_type
:project
end
def parent
parent_type == :project ? project : group
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
# When external issues tracker like Jira is activated we should not
# use issue reference pattern, but we should still be able
# to reference issues from other GitLab projects.
class IssueReferenceFilter < AbstractReferenceFilter
class IssueReferenceFilter < IssuableReferenceFilter
self.reference_type = :issue
def self.object_class
Issue
end
def find_object(project, iid)
issues_per_project[project][iid]
end
def url_for_object(issue, project)
IssuesHelper.url_for_issue(issue.iid, project, only_path: context[:only_path], internal: true)
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)
super(paths).includes(:gitlab_issue_tracker_service)
end
def parent_records(parent, ids)
parent.issues.where(iid: ids.to_a)
end
end
end
end
......@@ -33,7 +33,7 @@ module Banzai
end
def find_label(project_ref, label_id, label_name)
project = project_from_ref(project_ref)
project = parent_from_ref(project_ref)
return unless project
label_params = label_params(label_id, label_name)
......@@ -66,7 +66,7 @@ module Banzai
def object_link_text(object, matches)
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)
label_suffix = " <i>in #{reference}</i>" if reference.present?
......
......@@ -4,48 +4,19 @@ module Banzai
# to merge requests that do not exist are ignored.
#
# This filter supports cross-project references.
class MergeRequestReferenceFilter < AbstractReferenceFilter
class MergeRequestReferenceFilter < IssuableReferenceFilter
self.reference_type = :merge_request
def self.object_class
MergeRequest
end
def find_object(project, iid)
merge_requests_per_project[project][iid]
end
def url_for_object(mr, project)
h = Gitlab::Routing.url_helpers
h.project_merge_request_url(project, mr,
only_path: context[:only_path])
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)
extras = super
......@@ -61,6 +32,12 @@ module Banzai
extras
end
def parent_records(parent, ids)
parent.merge_requests
.where(iid: ids.to_a)
.includes(target_project: :namespace)
end
end
end
end
......@@ -38,7 +38,7 @@ module Banzai
def find_milestone(project_ref, namespace_ref, milestone_id, milestone_name)
project_path = full_project_path(namespace_ref, project_ref)
project = project_from_ref(project_path)
project = parent_from_ref(project_path)
return unless project
......
......@@ -28,8 +28,8 @@ module Banzai
issue_parser = Banzai::ReferenceParser::IssueParser.new(project, user)
merge_request_parser = Banzai::ReferenceParser::MergeRequestParser.new(project, user)
issuables_for_nodes = issue_parser.issues_for_nodes(nodes).merge(
merge_request_parser.merge_requests_for_nodes(nodes)
issuables_for_nodes = issue_parser.records_for_nodes(nodes).merge(
merge_request_parser.records_for_nodes(nodes)
)
# The project for the issue/MR might be pending for deletion!
......
......@@ -24,6 +24,7 @@ module Banzai
Filter::AutolinkFilter,
Filter::ExternalLinkFilter,
Filter::EpicReferenceFilter,
Filter::UserReferenceFilter,
Filter::IssueReferenceFilter,
Filter::ExternalIssueReferenceFilter,
......
......@@ -10,13 +10,14 @@ module Banzai
Filter::AutolinkFilter,
Filter::ExternalLinkFilter,
Filter::EpicReferenceFilter,
Filter::UserReferenceFilter,
Filter::IssueReferenceFilter,
Filter::ExternalIssueReferenceFilter,
Filter::MergeRequestReferenceFilter,
Filter::SnippetReferenceFilter,
Filter::CommitRangeReferenceFilter,
Filter::CommitReferenceFilter,
Filter::CommitReferenceFilter
]
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 ReferenceParser
class IssueParser < BaseParser
class IssueParser < IssuableParser
self.reference_type = :issue
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
......@@ -13,13 +13,7 @@ module Banzai
end
end
def referenced_by(nodes)
issues = issues_for_nodes(nodes)
nodes.map { |node| issues[node] }.compact.uniq
end
def issues_for_nodes(nodes)
def records_for_nodes(nodes)
@issues_for_nodes ||= grouped_objects_for_nodes(
nodes,
Issue.all.includes(
......
module Banzai
module ReferenceParser
class MergeRequestParser < BaseParser
class MergeRequestParser < IssuableParser
self.reference_type = :merge_request
def nodes_visible_to_user(user, 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)
def records_for_nodes(nodes)
@merge_requests_for_nodes ||= grouped_objects_for_nodes(
nodes,
MergeRequest.includes(
......@@ -40,10 +24,6 @@ module Banzai
self.class.data_attribute
)
end
def can_read_reference?(user, ref_project, node)
can?(user, :read_merge_request, ref_project)
end
end
end
end
module Gitlab
# Extract possible GFM references from an arbitrary String for further processing.
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
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
expect(result.map(&:epic_issue_id)).to match_array([epic_issues.first.id])
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
......@@ -42,6 +42,10 @@ describe 'GitLab Markdown' do
@doc ||= Nokogiri::HTML::DocumentFragment.parse(html)
end
before do
stub_licensed_features(epics: true)
end
# Shared behavior that all pipelines should exhibit
shared_examples 'all pipelines' do
describe 'Redcarpet extensions' do
......@@ -207,8 +211,9 @@ describe 'GitLab Markdown' do
before do
@feat = MarkdownFeature.new
# `markdown` helper expects a `@project` variable
# `markdown` helper expects a `@project` and `@group` variable
@project = @feat.project
@group = @feat.group
end
context 'default pipeline' do
......@@ -244,6 +249,7 @@ describe 'GitLab Markdown' do
expect(doc).to reference_commits
expect(doc).to reference_labels
expect(doc).to reference_milestones
expect(doc).to reference_epics
end
end
......@@ -301,6 +307,7 @@ describe 'GitLab Markdown' do
expect(doc).to reference_commits
expect(doc).to reference_labels
expect(doc).to reference_milestones
expect(doc).to reference_epics
end
end
......
......@@ -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 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
- [ ] Incomplete task 1
......
......@@ -204,7 +204,7 @@ describe IssuablesHelper do
'canUpdate' => true,
'canDestroy' => true,
'canAdmin' => true,
'issuableRef' => nil,
'issuableRef' => "&#{epic.iid}",
'markdownPreviewPath' => "/groups/#{@group.full_path}/preview_markdown",
'markdownDocsPath' => '/help/user/markdown',
'issuableTemplates' => nil,
......
......@@ -3,20 +3,20 @@ require 'spec_helper'
describe Banzai::CrossProjectReference do
include described_class
describe '#project_from_ref' do
describe '#parent_from_ref' do
context 'when no project was referenced' do
it 'returns the project from context' do
project = double
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
context 'when referenced project does not exist' 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
......@@ -27,7 +27,7 @@ describe Banzai::CrossProjectReference do
expect(Project).to receive(:find_by_full_path)
.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
......
......@@ -3,67 +3,67 @@ require 'spec_helper'
describe Banzai::Filter::AbstractReferenceFilter do
let(:project) { create(:project) }
describe '#references_per_project' do
it 'returns a Hash containing references grouped per project paths' do
describe '#references_per_parent' do
it 'returns a Hash containing references grouped per parent paths' do
doc = Nokogiri::HTML.fragment("#1 #{project.full_path}#2")
filter = described_class.new(doc, project: project)
expect(filter).to receive(:object_class).exactly(4).times.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[project.full_path]).to eq(Set.new(%w[1 2]))
end
end
describe '#projects_per_reference' do
it 'returns a Hash containing projects grouped per project paths' do
describe '#parent_per_reference' do
it 'returns a Hash containing projects grouped per parent paths' do
doc = Nokogiri::HTML.fragment('')
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]) })
expect(filter.projects_per_reference)
expect(filter.parent_per_reference)
.to eq({ project.full_path => project })
end
end
describe '#find_projects_for_paths' do
describe '#find_for_paths' do
let(:doc) { Nokogiri::HTML.fragment('') }
let(:filter) { described_class.new(doc, project: project) }
context 'with RequestStore disabled' 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])
end
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([])
end
end
context 'with RequestStore enabled', :request_store 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])
end
context "when no project with that path exists" do
it "returns no value" do
expect(filter.find_projects_for_paths(['nonexistent/project']))
expect(filter.find_for_paths(['nonexistent/project']))
.to eq([])
end
it "adds the ref to the project refs cache" do
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 })
end
......@@ -71,11 +71,11 @@ describe Banzai::Filter::AbstractReferenceFilter do
context 'when the project refs cache includes nil values' do
before do
# adds { 'nonexistent/project' => nil } to cache
filter.project_from_ref_cached('nonexistent/project')
filter.from_ref_cached('nonexistent/project')
end
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([])
end
end
......@@ -83,12 +83,12 @@ describe Banzai::Filter::AbstractReferenceFilter do
end
end
describe '#current_project_path' do
it 'returns the path of the current project' do
describe '#current_parent_path' do
it 'returns the path of the current parent' do
doc = Nokogiri::HTML.fragment('')
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
......@@ -157,6 +157,12 @@ describe Banzai::Filter::IssueReferenceFilter do
expect(doc.text).to eq("Fixed (#{project2.full_path}##{issue.iid}.)")
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
exp = act = "Fixed #{invalidate_reference(reference)}"
......@@ -201,6 +207,12 @@ describe Banzai::Filter::IssueReferenceFilter do
expect(doc.text).to eq("Fixed (#{project2.path}##{issue.iid}.)")
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
exp = act = "Fixed #{invalidate_reference(reference)}"
......@@ -245,6 +257,12 @@ describe Banzai::Filter::IssueReferenceFilter do
expect(doc.text).to eq("Fixed (#{project2.path}##{issue.iid}.)")
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
exp = act = "Fixed #{invalidate_reference(reference)}"
......@@ -269,8 +287,15 @@ describe Banzai::Filter::IssueReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference}.)")
expect(doc.to_html).to match(/\(<a.+>#{Regexp.escape(issue.to_reference(project))} \(comment 123\)<\/a>\.\)/)
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
context 'cross-project reference in link href' do
......@@ -291,8 +316,15 @@ describe Banzai::Filter::IssueReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference_link}.)")
expect(doc.to_html).to match(/\(<a.+>Reference<\/a>\.\)/)
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
context 'cross-project URL in link href' do
......@@ -313,8 +345,15 @@ describe Banzai::Filter::IssueReferenceFilter do
it 'links with adjacent text' do
doc = reference_filter("Fixed (#{reference_link}.)")
expect(doc.to_html).to match(/\(<a.+>Reference<\/a>\.\)/)
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
context 'group context' do
......@@ -387,19 +426,19 @@ describe Banzai::Filter::IssueReferenceFilter do
end
end
describe '#issues_per_project' do
describe '#records_per_parent' do
context 'using an internal issue tracker' do
it 'returns a Hash containing the issues per project' do
doc = Nokogiri::HTML.fragment('')
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 })
expect(filter).to receive(:references_per_project)
expect(filter).to receive(:references_per_parent)
.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 } })
end
end
......
......@@ -70,12 +70,12 @@ describe Banzai::ReferenceParser::IssueParser do
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
link['data-issue'] = issue.id.to_s
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
require 'spec_helper'
describe Gitlab::ReferenceExtractor do
let(:project) { create(:project) }
let(:group) { create(:group) }
let(:project) { create(:project, group: group) }
before do
project.team << [project.creator, :developer]
group.add_developer(project.creator)
end
subject { described_class.new(project, project.creator) }
......@@ -153,6 +154,20 @@ describe Gitlab::ReferenceExtractor do
expect(subject.snippets).to match_array([@s0, @s1])
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
project = create(:project, :repository) { |p| p.add_developer(p.creator) }
commit = project.commit('master')
......@@ -250,4 +265,34 @@ describe Gitlab::ReferenceExtractor do
subject { described_class.references_pattern }
it { is_expected.to be_kind_of Regexp }
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
......@@ -79,6 +79,14 @@ class MarkdownFeature
@group_milestone ||= create(:milestone, name: 'group-milestone', group: group)
end
def epic
@epic ||= create(:epic, title: 'epic', group: group)
end
def epic_other_group
@epic ||= create(:epic, title: 'epic')
end
# Cross-references -----------------------------------------------------------
def xproject
......
......@@ -159,6 +159,15 @@ module MarkdownMatchers
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
matcher :parse_task_lists do
set_default_markdown_messages
......
......@@ -5,11 +5,13 @@
# - let(:set_mentionable_text) { lambda { |txt| "block that assigns txt to the subject's mentionable_text" } }
shared_context 'mentionable context' do
let(:group) { create(:group) }
let(:project) { subject.project }
let(:author) { subject.author }
let(:mentioned_issue) { create(:issue, 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(:ext_proj) { create(:project, :public, :repository) }
......@@ -27,6 +29,7 @@ shared_context 'mentionable context' do
These references are new:
Issue: #{mentioned_issue.to_reference}
Merge: #{mentioned_mr.to_reference}
Epic: #{mentioned_epic.to_reference(project)}
Commit: #{mentioned_commit.to_reference}
This reference is a repeat and should only be mentioned once:
......@@ -43,6 +46,8 @@ shared_context 'mentionable context' do
end
before do
stub_licensed_features(epics: true)
# Wire the project's repository to return the mentioned commit, and +nil+
# for any unrecognized commits.
allow_any_instance_of(::Repository).to receive(:commit).and_call_original
......@@ -67,9 +72,10 @@ shared_examples 'a mentionable' do
it "extracts references from its reference property" do
# De-duplicate and omit itself
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_mr)
expect(refs).to include(mentioned_epic)
expect(refs).to include(mentioned_commit)
expect(refs).to include(ext_issue)
expect(refs).to include(ext_mr)
......@@ -77,7 +83,7 @@ shared_examples 'a mentionable' do
end
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]
mentioned_objects.each do |referenced|
......@@ -97,6 +103,7 @@ shared_examples 'an editable mentionable' do
let(:new_issues) do
[create(:issue, project: project), create(:issue, project: ext_proj)]
end
let(:new_epic) { create(:epic, group: group) }
it 'creates new cross-reference notes when the mentionable text is edited' do
subject.save
......@@ -107,6 +114,8 @@ shared_examples 'an editable mentionable' do
Issue: #{mentioned_issue.to_reference}
Issue: #{mentioned_epic.to_reference(project)}
Commit: #{mentioned_commit.to_reference}
---
......@@ -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}
Cross: #{new_issues[1].to_reference(project)}
Epic: #{new_epic.to_reference(project)}
MSG
# These three objects were already referenced, and should not receive new
# These four objects were already referenced, and should not receive new
# 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)
.with(oldref, any_args)
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
new_issues.each do |newref|
new_mentionables = new_issues + [new_epic]
new_mentionables.each do |newref|
expect(SystemNoteService).to receive(:cross_reference)
.with(newref, subject.local_reference, author)
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