Implement cross-project Markdown references
Enable linking to commits, merge requests, and issues in other projects by prepending a namespaced project path to the reference.
This commit is contained in:
parent
c0bb3f5af2
commit
1b1ba6b0a5
|
@ -17,6 +17,7 @@ v 7.4.0
|
|||
- Font Awesome 4.2 integration (Sullivan Senechal)
|
||||
- Add Pushover service integration (Sullivan Senechal)
|
||||
- Add select field type for services options (Sullivan Senechal)
|
||||
- Add cross-project references to the Markdown parser (Vinnie Okada)
|
||||
|
||||
v 7.3.2
|
||||
- Fix creating new file via web editor
|
||||
|
|
|
@ -177,6 +177,12 @@ GFM will recognize the following:
|
|||
- 1234567 : for commits
|
||||
- \[file\](path/to/file) : for file references
|
||||
|
||||
GFM also recognizes references to commits, issues, and merge requests in other projects:
|
||||
|
||||
- namespace/project#123 : for issues
|
||||
- namespace/project!123 : for merge requests
|
||||
- namespace/project@1234567 : for commits
|
||||
|
||||
# Standard Markdown
|
||||
|
||||
## Headers
|
||||
|
|
|
@ -108,15 +108,18 @@ module Gitlab
|
|||
text
|
||||
end
|
||||
|
||||
NAME_STR = '[a-zA-Z][a-zA-Z0-9_\-\.]*'
|
||||
PROJ_STR = "(?<project>#{NAME_STR}/#{NAME_STR})"
|
||||
|
||||
REFERENCE_PATTERN = %r{
|
||||
(?<prefix>\W)? # Prefix
|
||||
( # Reference
|
||||
@(?<user>[a-zA-Z][a-zA-Z0-9_\-\.]*) # User name
|
||||
@(?<user>#{NAME_STR}) # User name
|
||||
|(?<issue>([A-Z\-]+-)\d+) # JIRA Issue ID
|
||||
|\#(?<issue>([a-zA-Z\-]+-)?\d+) # Issue ID
|
||||
|!(?<merge_request>\d+) # MR ID
|
||||
|#{PROJ_STR}?\#(?<issue>([a-zA-Z\-]+-)?\d+) # Issue ID
|
||||
|#{PROJ_STR}?!(?<merge_request>\d+) # MR ID
|
||||
|\$(?<snippet>\d+) # Snippet ID
|
||||
|(?<commit>[\h]{6,40}) # Commit ID
|
||||
|(#{PROJ_STR}@)?(?<commit>[\h]{6,40}) # Commit ID
|
||||
|(?<skip>gfm-extraction-[\h]{6,40}) # Skip gfm extractions. Otherwise will be parsed as commit
|
||||
)
|
||||
(?<suffix>\W)? # Suffix
|
||||
|
@ -127,25 +130,44 @@ module Gitlab
|
|||
def parse_references(text, project = @project)
|
||||
# parse reference links
|
||||
text.gsub!(REFERENCE_PATTERN) do |match|
|
||||
prefix = $~[:prefix]
|
||||
suffix = $~[:suffix]
|
||||
type = TYPES.select{|t| !$~[t].nil?}.first
|
||||
|
||||
if type
|
||||
identifier = $~[type]
|
||||
actual_project = project
|
||||
project_prefix = nil
|
||||
project_path = $LAST_MATCH_INFO[:project]
|
||||
if project_path
|
||||
actual_project = ::Project.find_with_namespace(project_path)
|
||||
project_prefix = project_path
|
||||
end
|
||||
|
||||
# Avoid HTML entities
|
||||
if prefix && suffix && prefix[0] == '&' && suffix[-1] == ';'
|
||||
match
|
||||
elsif ref_link = reference_link(type, identifier, project)
|
||||
parse_result($LAST_MATCH_INFO, type,
|
||||
actual_project, project_prefix) || match
|
||||
end
|
||||
end
|
||||
|
||||
# Called from #parse_references. Attempts to build a gitlab reference
|
||||
# link. Returns nil if either +type+ or +project+ are nil, if the match
|
||||
# string is an HTML entity, or if the reference is invalid.
|
||||
def parse_result(match_info, type, project, project_prefix)
|
||||
prefix = match_info[:prefix]
|
||||
suffix = match_info[:suffix]
|
||||
|
||||
return nil if html_entity?(prefix, suffix) || project.nil? || type.nil?
|
||||
|
||||
identifier = match_info[type]
|
||||
ref_link = reference_link(type, identifier, project, project_prefix)
|
||||
|
||||
if ref_link
|
||||
"#{prefix}#{ref_link}#{suffix}"
|
||||
else
|
||||
match
|
||||
end
|
||||
else
|
||||
match
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Return true if the +prefix+ and +suffix+ indicate that the matched string
|
||||
# is an HTML entity like &
|
||||
def html_entity?(prefix, suffix)
|
||||
prefix && suffix && prefix[0] == '&' && suffix[-1] == ';'
|
||||
end
|
||||
|
||||
# Private: Dispatches to a dedicated processing method based on reference
|
||||
|
@ -154,11 +176,11 @@ module Gitlab
|
|||
# identifier - Object identifier (Issue ID, SHA hash, etc.)
|
||||
#
|
||||
# Returns string rendered by the processing method
|
||||
def reference_link(type, identifier, project = @project)
|
||||
send("reference_#{type}", identifier, project)
|
||||
def reference_link(type, identifier, project = @project, prefix_text = nil)
|
||||
send("reference_#{type}", identifier, project, prefix_text)
|
||||
end
|
||||
|
||||
def reference_user(identifier, project = @project)
|
||||
def reference_user(identifier, project = @project, _ = nil)
|
||||
options = html_options.merge(
|
||||
class: "gfm gfm-team_member #{html_options[:class]}"
|
||||
)
|
||||
|
@ -170,17 +192,17 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
|
||||
def reference_issue(identifier, project = @project)
|
||||
def reference_issue(identifier, project = @project, prefix_text = nil)
|
||||
if project.used_default_issues_tracker? || !external_issues_tracker_enabled?
|
||||
if project.issue_exists? identifier
|
||||
url = url_for_issue(identifier, project)
|
||||
title = title_for_issue(identifier)
|
||||
title = title_for_issue(identifier, project)
|
||||
options = html_options.merge(
|
||||
title: "Issue: #{title}",
|
||||
class: "gfm gfm-issue #{html_options[:class]}"
|
||||
)
|
||||
|
||||
link_to("##{identifier}", url, options)
|
||||
link_to("#{prefix_text}##{identifier}", url, options)
|
||||
end
|
||||
else
|
||||
config = Gitlab.config
|
||||
|
@ -191,18 +213,19 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
|
||||
def reference_merge_request(identifier, project = @project)
|
||||
def reference_merge_request(identifier, project = @project,
|
||||
prefix_text = nil)
|
||||
if merge_request = project.merge_requests.find_by(iid: identifier)
|
||||
options = html_options.merge(
|
||||
title: "Merge Request: #{merge_request.title}",
|
||||
class: "gfm gfm-merge_request #{html_options[:class]}"
|
||||
)
|
||||
url = project_merge_request_url(project, merge_request)
|
||||
link_to("!#{identifier}", url, options)
|
||||
link_to("#{prefix_text}!#{identifier}", url, options)
|
||||
end
|
||||
end
|
||||
|
||||
def reference_snippet(identifier, project = @project)
|
||||
def reference_snippet(identifier, project = @project, _ = nil)
|
||||
if snippet = project.snippets.find_by(id: identifier)
|
||||
options = html_options.merge(
|
||||
title: "Snippet: #{snippet.title}",
|
||||
|
@ -213,17 +236,22 @@ module Gitlab
|
|||
end
|
||||
end
|
||||
|
||||
def reference_commit(identifier, project = @project)
|
||||
def reference_commit(identifier, project = @project, prefix_text = nil)
|
||||
if project.valid_repo? && commit = project.repository.commit(identifier)
|
||||
options = html_options.merge(
|
||||
title: commit.link_title,
|
||||
class: "gfm gfm-commit #{html_options[:class]}"
|
||||
)
|
||||
link_to(identifier, project_commit_url(project, commit), options)
|
||||
link_to(
|
||||
"#{prefix_text}#{identifier}",
|
||||
project_commit_url(project, commit),
|
||||
options
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def reference_external_issue(identifier, issue_tracker, project = @project)
|
||||
def reference_external_issue(identifier, issue_tracker, project = @project,
|
||||
_ = nil)
|
||||
url = url_for_issue(identifier, project)
|
||||
title = issue_tracker['title']
|
||||
|
||||
|
|
|
@ -181,6 +181,74 @@ describe GitlabMarkdownHelper do
|
|||
end
|
||||
end
|
||||
|
||||
# Shared examples for referencing an object in a different project
|
||||
#
|
||||
# Expects the following attributes to be available in the example group:
|
||||
#
|
||||
# - object - The object itself
|
||||
# - reference - The object reference string (e.g., #1234, $1234, !1234)
|
||||
# - other_project - The project that owns the target object
|
||||
#
|
||||
# Currently limited to Snippets, Issues and MergeRequests
|
||||
shared_examples 'cross-project referenced object' do
|
||||
let(:project_path) { @other_project.path_with_namespace }
|
||||
let(:full_reference) { "#{project_path}#{reference}" }
|
||||
let(:actual) { "Reference to #{full_reference}" }
|
||||
let(:expected) do
|
||||
if object.is_a?(Commit)
|
||||
project_commit_path(@other_project, object)
|
||||
else
|
||||
polymorphic_path([@other_project, object])
|
||||
end
|
||||
end
|
||||
|
||||
it 'should link using a valid id' do
|
||||
gfm(actual).should match(expected)
|
||||
end
|
||||
|
||||
it 'should link with adjacent text' do
|
||||
# Wrap the reference in parenthesis
|
||||
gfm(actual.gsub(full_reference, "(#{full_reference})")).should(
|
||||
match(expected)
|
||||
)
|
||||
|
||||
# Append some text to the end of the reference
|
||||
gfm(actual.gsub(full_reference, "#{full_reference}, right?")).should(
|
||||
match(expected)
|
||||
)
|
||||
end
|
||||
|
||||
it 'should keep whitespace intact' do
|
||||
actual = "Referenced #{full_reference} already."
|
||||
expected = /Referenced <a.+>[^\s]+<\/a> already/
|
||||
gfm(actual).should match(expected)
|
||||
end
|
||||
|
||||
it 'should not link with an invalid id' do
|
||||
# Modify the reference string so it's still parsed, but is invalid
|
||||
if object.is_a?(Commit)
|
||||
reference.gsub!(/^(.).+$/, '\1' + '12345abcd')
|
||||
else
|
||||
reference.gsub!(/^(.)(\d+)$/, '\1' + ('\2' * 2))
|
||||
end
|
||||
gfm(actual).should == actual
|
||||
end
|
||||
|
||||
it 'should include a title attribute' do
|
||||
if object.is_a?(Commit)
|
||||
title = object.link_title
|
||||
else
|
||||
title = "#{object.class.to_s.titlecase}: #{object.title}"
|
||||
end
|
||||
gfm(actual).should match(/title="#{title}"/)
|
||||
end
|
||||
|
||||
it 'should include standard gfm classes' do
|
||||
css = object.class.to_s.underscore
|
||||
gfm(actual).should match(/class="\s?gfm gfm-#{css}\s?"/)
|
||||
end
|
||||
end
|
||||
|
||||
describe "referencing an issue" do
|
||||
let(:object) { issue }
|
||||
let(:reference) { "##{issue.iid}" }
|
||||
|
@ -188,6 +256,38 @@ describe GitlabMarkdownHelper do
|
|||
include_examples 'referenced object'
|
||||
end
|
||||
|
||||
context 'cross-repo references' do
|
||||
before(:all) do
|
||||
@other_project = create(:project, :public)
|
||||
@commit2 = @other_project.repository.commit
|
||||
@issue2 = create(:issue, project: @other_project)
|
||||
@merge_request2 = create(:merge_request,
|
||||
source_project: @other_project,
|
||||
target_project: @other_project)
|
||||
end
|
||||
|
||||
describe 'referencing an issue in another project' do
|
||||
let(:object) { @issue2 }
|
||||
let(:reference) { "##{@issue2.iid}" }
|
||||
|
||||
include_examples 'cross-project referenced object'
|
||||
end
|
||||
|
||||
describe 'referencing an merge request in another project' do
|
||||
let(:object) { @merge_request2 }
|
||||
let(:reference) { "!#{@merge_request2.iid}" }
|
||||
|
||||
include_examples 'cross-project referenced object'
|
||||
end
|
||||
|
||||
describe 'referencing a commit in another project' do
|
||||
let(:object) { @commit2 }
|
||||
let(:reference) { "@#{@commit2.id}" }
|
||||
|
||||
include_examples 'cross-project referenced object'
|
||||
end
|
||||
end
|
||||
|
||||
describe "referencing a Jira issue" do
|
||||
let(:actual) { "Reference to JIRA-#{issue.iid}" }
|
||||
let(:expected) { "http://jira.example/browse/JIRA-#{issue.iid}" }
|
||||
|
|
Loading…
Reference in New Issue