Commit 406615ae authored by Douwe Maan's avatar Douwe Maan Committed by Rémy Coutable

Merge branch 'issue_828' into 'master'

Prevent wrong markdown on issue ids when project has Jira service activated

fixes gitlab-org/gitlab-ee#828

See merge request !6728
parent 74974f02
...@@ -13,6 +13,7 @@ Please view this file on the master branch, on stable branches it's out of date. ...@@ -13,6 +13,7 @@ Please view this file on the master branch, on stable branches it's out of date.
- Update runner version only when updating contacted_at - Update runner version only when updating contacted_at
- Add link from system note to compare with previous version - Add link from system note to compare with previous version
- Use gitlab-shell v3.6.6 - Use gitlab-shell v3.6.6
- Ignore references to internal issues when using external issues tracker
- Ability to resolve merge request conflicts with editor !6374 - Ability to resolve merge request conflicts with editor !6374
- Add `/projects/visible` API endpoint (Ben Boeckel) - Add `/projects/visible` API endpoint (Ben Boeckel)
- Fix centering of custom header logos (Ashley Dumaine) - Fix centering of custom header logos (Ashley Dumaine)
......
...@@ -29,11 +29,6 @@ class ExternalIssue ...@@ -29,11 +29,6 @@ class ExternalIssue
@project @project
end end
# Pattern used to extract `JIRA-123` issue references from text
def self.reference_pattern
@reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
end
def to_reference(_from_project = nil) def to_reference(_from_project = nil)
id id
end end
......
...@@ -668,6 +668,10 @@ class Project < ActiveRecord::Base ...@@ -668,6 +668,10 @@ class Project < ActiveRecord::Base
end end
end end
def issue_reference_pattern
issues_tracker.reference_pattern
end
def default_issues_tracker? def default_issues_tracker?
!external_issue_tracker !external_issue_tracker
end end
......
...@@ -3,6 +3,12 @@ class IssueTrackerService < Service ...@@ -3,6 +3,12 @@ class IssueTrackerService < Service
default_value_for :category, 'issue_tracker' default_value_for :category, 'issue_tracker'
# Pattern used to extract links from comments
# Override this method on services that uses different patterns
def reference_pattern
@reference_pattern ||= %r{(\b[A-Z][A-Z0-9_]+-|#{Issue.reference_prefix})(?<issue>\d+)}
end
def default? def default?
default default
end end
......
...@@ -13,6 +13,11 @@ class JiraService < IssueTrackerService ...@@ -13,6 +13,11 @@ class JiraService < IssueTrackerService
before_update :reset_password before_update :reset_password
# {PROJECT-KEY}-{NUMBER} Examples: JIRA-1, PROJECT-1
def reference_pattern
@reference_pattern ||= %r{(?<issue>\b([A-Z][A-Z0-9_]+-)\d+)}
end
def reset_password def reset_password
# don't reset the password if a new one is provided # don't reset the password if a new one is provided
if api_url_changed? && !password_touched? if api_url_changed? && !password_touched?
......
...@@ -208,8 +208,12 @@ module Banzai ...@@ -208,8 +208,12 @@ module Banzai
@references_per_project ||= begin @references_per_project ||= 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, regex =
object_class.link_reference_pattern) if uses_reference_pattern?
Regexp.union(object_class.reference_pattern, object_class.link_reference_pattern)
else
object_class.link_reference_pattern
end
nodes.each do |node| nodes.each do |node|
node.to_html.scan(regex) do node.to_html.scan(regex) do
...@@ -295,6 +299,14 @@ module Banzai ...@@ -295,6 +299,14 @@ module Banzai
value value
end end
end end
# There might be special cases like filters
# that should ignore reference pattern
# eg: IssueReferenceFilter when using a external issues tracker
# In those cases this method should be overridden on the filter subclass
def uses_reference_pattern?
true
end
end end
end end
end end
...@@ -8,7 +8,7 @@ module Banzai ...@@ -8,7 +8,7 @@ module Banzai
# Public: Find `JIRA-123` issue references in text # Public: Find `JIRA-123` issue references in text
# #
# ExternalIssueReferenceFilter.references_in(text) do |match, issue| # ExternalIssueReferenceFilter.references_in(text, pattern) do |match, issue|
# "<a href=...>##{issue}</a>" # "<a href=...>##{issue}</a>"
# end # end
# #
...@@ -17,8 +17,8 @@ module Banzai ...@@ -17,8 +17,8 @@ module Banzai
# Yields the String match and the String issue reference. # Yields the String match and the String issue reference.
# #
# Returns a String replaced with the return of the block. # Returns a String replaced with the return of the block.
def self.references_in(text) def self.references_in(text, pattern)
text.gsub(ExternalIssue.reference_pattern) do |match| text.gsub(pattern) do |match|
yield match, $~[:issue] yield match, $~[:issue]
end end
end end
...@@ -27,7 +27,7 @@ module Banzai ...@@ -27,7 +27,7 @@ module Banzai
# Early return if the project isn't using an external tracker # Early return if the project isn't using an external tracker
return doc if project.nil? || default_issues_tracker? return doc if project.nil? || default_issues_tracker?
ref_pattern = ExternalIssue.reference_pattern ref_pattern = issue_reference_pattern
ref_start_pattern = /\A#{ref_pattern}\z/ ref_start_pattern = /\A#{ref_pattern}\z/
each_node do |node| each_node do |node|
...@@ -60,7 +60,7 @@ module Banzai ...@@ -60,7 +60,7 @@ module Banzai
def issue_link_filter(text, link_text: nil) def issue_link_filter(text, link_text: nil)
project = context[:project] project = context[:project]
self.class.references_in(text) do |match, id| self.class.references_in(text, issue_reference_pattern) do |match, id|
ExternalIssue.new(id, project) ExternalIssue.new(id, project)
url = url_for_issue(id, project, only_path: context[:only_path]) url = url_for_issue(id, project, only_path: context[:only_path])
...@@ -82,18 +82,21 @@ module Banzai ...@@ -82,18 +82,21 @@ module Banzai
end end
def default_issues_tracker? def default_issues_tracker?
if RequestStore.active? external_issues_cached(:default_issues_tracker?)
default_issues_tracker_cache[project.id] ||= end
project.default_issues_tracker?
else def issue_reference_pattern
project.default_issues_tracker? external_issues_cached(:issue_reference_pattern)
end
end end
private private
def default_issues_tracker_cache def external_issues_cached(attribute)
RequestStore[:banzai_default_issues_tracker_cache] ||= {} return project.public_send(attribute) unless RequestStore.active?
cached_attributes = RequestStore[:banzai_external_issues_tracker_attributes] ||= Hash.new { |h, k| h[k] = {} }
cached_attributes[project.id][attribute] = project.public_send(attribute) if cached_attributes[project.id][attribute].nil?
cached_attributes[project.id][attribute]
end end
end end
end end
......
...@@ -4,6 +4,10 @@ module Banzai ...@@ -4,6 +4,10 @@ module Banzai
# issues that do not exist are ignored. # issues that do not exist are ignored.
# #
# This filter supports cross-project references. # This filter supports cross-project references.
#
# 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 < AbstractReferenceFilter
self.reference_type = :issue self.reference_type = :issue
...@@ -11,6 +15,10 @@ module Banzai ...@@ -11,6 +15,10 @@ module Banzai
Issue Issue
end end
def uses_reference_pattern?
context[:project].default_issues_tracker?
end
def find_object(project, iid) def find_object(project, iid)
issues_per_project[project][iid] issues_per_project[project][iid]
end end
......
...@@ -7,12 +7,7 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do ...@@ -7,12 +7,7 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do
IssuesHelper IssuesHelper
end end
let(:project) { create(:jira_project) } shared_examples_for "external issue tracker" do
context 'JIRA issue references' do
let(:issue) { ExternalIssue.new('JIRA-123', project) }
let(:reference) { issue.to_reference }
it 'requires project context' do it 'requires project context' do
expect { described_class.call('') }.to raise_error(ArgumentError, /:project/) expect { described_class.call('') }.to raise_error(ArgumentError, /:project/)
end end
...@@ -20,6 +15,7 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do ...@@ -20,6 +15,7 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do
%w(pre code a style).each do |elem| %w(pre code a style).each do |elem|
it "ignores valid references contained inside '#{elem}' element" do it "ignores valid references contained inside '#{elem}' element" do
exp = act = "<#{elem}>Issue #{reference}</#{elem}>" exp = act = "<#{elem}>Issue #{reference}</#{elem}>"
expect(filter(act).to_html).to eq exp expect(filter(act).to_html).to eq exp
end end
end end
...@@ -33,25 +29,30 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do ...@@ -33,25 +29,30 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do
it 'links to a valid reference' do it 'links to a valid reference' do
doc = filter("Issue #{reference}") doc = filter("Issue #{reference}")
issue_id = doc.css('a').first.attr("data-external-issue")
expect(doc.css('a').first.attr('href')) expect(doc.css('a').first.attr('href'))
.to eq helper.url_for_issue(reference, project) .to eq helper.url_for_issue(issue_id, project)
end end
it 'links to the external tracker' do it 'links to the external tracker' do
doc = filter("Issue #{reference}") doc = filter("Issue #{reference}")
link = doc.css('a').first.attr('href') link = doc.css('a').first.attr('href')
issue_id = doc.css('a').first.attr("data-external-issue")
expect(link).to eq "http://jira.example/browse/#{reference}" expect(link).to eq(helper.url_for_issue(issue_id, project))
end end
it 'links with adjacent text' do it 'links with adjacent text' do
doc = filter("Issue (#{reference}.)") doc = filter("Issue (#{reference}.)")
expect(doc.to_html).to match(/\(<a.+>#{reference}<\/a>\.\)/) expect(doc.to_html).to match(/\(<a.+>#{reference}<\/a>\.\)/)
end end
it 'includes a title attribute' do it 'includes a title attribute' do
doc = filter("Issue #{reference}") doc = filter("Issue #{reference}")
expect(doc.css('a').first.attr('title')).to eq "Issue in JIRA tracker" expect(doc.css('a').first.attr('title')).to include("Issue in #{project.issues_tracker.title}")
end end
it 'escapes the title attribute' do it 'escapes the title attribute' do
...@@ -69,9 +70,60 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do ...@@ -69,9 +70,60 @@ describe Banzai::Filter::ExternalIssueReferenceFilter, lib: true do
it 'supports an :only_path context' do it 'supports an :only_path context' do
doc = filter("Issue #{reference}", only_path: true) doc = filter("Issue #{reference}", only_path: true)
link = doc.css('a').first.attr('href') link = doc.css('a').first.attr('href')
issue_id = doc.css('a').first["data-external-issue"]
expect(link).to eq helper.url_for_issue(issue_id, project, only_path: true)
end
context 'with RequestStore enabled' do
let(:reference_filter) { HTML::Pipeline.new([described_class]) }
before { allow(RequestStore).to receive(:active?).and_return(true) }
it 'queries the collection on the first call' do
expect_any_instance_of(Project).to receive(:default_issues_tracker?).once.and_call_original
expect_any_instance_of(Project).to receive(:issue_reference_pattern).once.and_call_original
not_cached = reference_filter.call("look for #{reference}", { project: project })
expect_any_instance_of(Project).not_to receive(:default_issues_tracker?)
expect_any_instance_of(Project).not_to receive(:issue_reference_pattern)
cached = reference_filter.call("look for #{reference}", { project: project })
expect(link).to eq helper.url_for_issue("#{reference}", project, only_path: true) # Links must be the same
expect(cached[:output].css('a').first[:href]).to eq(not_cached[:output].css('a').first[:href])
end
end
end
context "redmine project" do
let(:project) { create(:redmine_project) }
let(:issue) { ExternalIssue.new("#123", project) }
let(:reference) { issue.to_reference }
it_behaves_like "external issue tracker"
end
context "jira project" do
let(:project) { create(:jira_project) }
let(:reference) { issue.to_reference }
context "with right markdown" do
let(:issue) { ExternalIssue.new("JIRA-123", project) }
it_behaves_like "external issue tracker"
end
context "with wrong markdown" do
let(:issue) { ExternalIssue.new("#123", project) }
it "ignores reference" do
exp = act = "Issue #{reference}"
expect(filter(act).to_html).to eq exp
end
end end
end end
end end
...@@ -25,9 +25,7 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do ...@@ -25,9 +25,7 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
let(:reference) { issue.to_reference } let(:reference) { issue.to_reference }
it 'ignores valid references when using non-default tracker' do it 'ignores valid references when using non-default tracker' do
expect_any_instance_of(described_class).to receive(:find_object). allow(project).to receive(:default_issues_tracker?).and_return(false)
with(project, issue.iid).
and_return(nil)
exp = act = "Issue #{reference}" exp = act = "Issue #{reference}"
expect(reference_filter(act).to_html).to eq exp expect(reference_filter(act).to_html).to eq exp
...@@ -199,19 +197,6 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do ...@@ -199,19 +197,6 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do
end end
end end
context 'referencing external issues' do
let(:project) { create(:redmine_project) }
it 'renders internal issue IDs as external issue links' do
doc = reference_filter('#1')
link = doc.css('a').first
expect(link.attr('data-reference-type')).to eq('external_issue')
expect(link.attr('title')).to eq('Issue in Redmine')
expect(link.attr('data-external-issue')).to eq('1')
end
end
describe '#issues_per_Project' do describe '#issues_per_Project' 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
......
...@@ -10,21 +10,6 @@ describe ExternalIssue, models: true do ...@@ -10,21 +10,6 @@ describe ExternalIssue, models: true do
it { is_expected.to include_module(Referable) } it { is_expected.to include_module(Referable) }
end end
describe '.reference_pattern' do
it 'allows underscores in the project name' do
expect(ExternalIssue.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
end
it 'allows numbers in the project name' do
expect(ExternalIssue.reference_pattern.match('EXT3_EXT-1234')[0]).to eq 'EXT3_EXT-1234'
end
it 'requires the project name to begin with A-Z' do
expect(ExternalIssue.reference_pattern.match('3EXT_EXT-1234')).to eq nil
expect(ExternalIssue.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
end
end
describe '#to_reference' do describe '#to_reference' do
it 'returns a String reference to the object' do it 'returns a String reference to the object' do
expect(issue.to_reference).to eq issue.id expect(issue.to_reference).to eq issue.id
......
...@@ -30,6 +30,15 @@ describe JiraService, models: true do ...@@ -30,6 +30,15 @@ describe JiraService, models: true do
end end
end end
describe '#reference_pattern' do
it_behaves_like 'allows project key on reference pattern'
it 'does not allow # on the code' do
expect(subject.reference_pattern.match('#123')).to be_nil
expect(subject.reference_pattern.match('1#23#12')).to be_nil
end
end
describe "Execute" do describe "Execute" do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project) } let(:project) { create(:project) }
......
...@@ -26,4 +26,12 @@ describe RedmineService, models: true do ...@@ -26,4 +26,12 @@ describe RedmineService, models: true do
it { is_expected.not_to validate_presence_of(:new_issue_url) } it { is_expected.not_to validate_presence_of(:new_issue_url) }
end end
end end
describe '#reference_pattern' do
it_behaves_like 'allows project key on reference pattern'
it 'does allow # on the reference' do
expect(subject.reference_pattern.match('#123')[:issue]).to eq('123')
end
end
end end
...@@ -415,7 +415,7 @@ describe GitPushService, services: true do ...@@ -415,7 +415,7 @@ describe GitPushService, services: true do
it "doesn't close issues when external issue tracker is in use" do it "doesn't close issues when external issue tracker is in use" do
allow_any_instance_of(Project).to receive(:default_issues_tracker?). allow_any_instance_of(Project).to receive(:default_issues_tracker?).
and_return(false) and_return(false)
external_issue_tracker = double(title: 'My Tracker', issue_path: issue.iid) external_issue_tracker = double(title: 'My Tracker', issue_path: issue.iid, reference_pattern: project.issue_reference_pattern)
allow_any_instance_of(Project).to receive(:external_issue_tracker).and_return(external_issue_tracker) allow_any_instance_of(Project).to receive(:external_issue_tracker).and_return(external_issue_tracker)
# The push still shouldn't create cross-reference notes. # The push still shouldn't create cross-reference notes.
...@@ -484,30 +484,46 @@ describe GitPushService, services: true do ...@@ -484,30 +484,46 @@ describe GitPushService, services: true do
end end
context "closing an issue" do context "closing an issue" do
let(:message) { "this is some work.\n\ncloses JIRA-1" } let(:message) { "this is some work.\n\ncloses JIRA-1" }
let(:transition_body) { { transition: { id: '2' } }.to_json }
it "initiates one api call to jira server to close the issue" do let(:comment_body) { { body: "Issue solved with [#{closing_commit.id}|http://localhost/#{project.path_with_namespace}/commit/#{closing_commit.id}]." }.to_json }
transition_body = {
transition: { context "using right markdown" do
id: '2' it "initiates one api call to jira server to close the issue" do
} execute_service(project, commit_author, @oldrev, @newrev, @ref )
}.to_json
expect(WebMock).to have_requested(:post, jira_api_transition_url).with(
execute_service(project, commit_author, @oldrev, @newrev, @ref ) body: transition_body
expect(WebMock).to have_requested(:post, jira_api_transition_url).with( ).once
body: transition_body end
).once
it "initiates one api call to jira server to comment on the issue" do
execute_service(project, commit_author, @oldrev, @newrev, @ref )
expect(WebMock).to have_requested(:post, jira_api_comment_url).with(
body: comment_body
).once
end
end end
it "initiates one api call to jira server to comment on the issue" do context "using wrong markdown" do
comment_body = { let(:message) { "this is some work.\n\ncloses #1" }
body: "Issue solved with [#{closing_commit.id}|http://localhost/#{project.path_with_namespace}/commit/#{closing_commit.id}]."
}.to_json
execute_service(project, commit_author, @oldrev, @newrev, @ref ) it "does not initiates one api call to jira server to close the issue" do
expect(WebMock).to have_requested(:post, jira_api_comment_url).with( execute_service(project, commit_author, @oldrev, @newrev, @ref )
body: comment_body
).once expect(WebMock).not_to have_requested(:post, jira_api_transition_url).with(
body: transition_body
)
end
it "does not initiates one api call to jira server to comment on the issue" do
execute_service(project, commit_author, @oldrev, @newrev, @ref )
expect(WebMock).not_to have_requested(:post, jira_api_comment_url).with(
body: comment_body
).once
end
end end
end end
end end
......
...@@ -74,6 +74,18 @@ describe MergeRequests::MergeService, services: true do ...@@ -74,6 +74,18 @@ describe MergeRequests::MergeService, services: true do
service.execute(merge_request) service.execute(merge_request)
end end
context "wrong issue markdown" do
it 'does not close issues on JIRA issue tracker' do
jira_issue = ExternalIssue.new('#123', project)
commit = double('commit', safe_message: "Fixes #{jira_issue.to_reference}")
allow(merge_request).to receive(:commits).and_return([commit])
expect_any_instance_of(JiraService).not_to receive(:close_issue)
service.execute(merge_request)
end
end
end end
end end
......
...@@ -5,3 +5,18 @@ RSpec.shared_examples 'issue tracker service URL attribute' do |url_attr| ...@@ -5,3 +5,18 @@ RSpec.shared_examples 'issue tracker service URL attribute' do |url_attr|
it { is_expected.not_to allow_value('ftp://example.com').for(url_attr) } it { is_expected.not_to allow_value('ftp://example.com').for(url_attr) }
it { is_expected.not_to allow_value('herp-and-derp').for(url_attr) } it { is_expected.not_to allow_value('herp-and-derp').for(url_attr) }
end end
RSpec.shared_examples 'allows project key on reference pattern' do |url_attr|
it 'allows underscores in the project name' do
expect(subject.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
end
it 'allows numbers in the project name' do
expect(subject.reference_pattern.match('EXT3_EXT-1234')[0]).to eq 'EXT3_EXT-1234'
end
it 'requires the project name to begin with A-Z' do
expect(subject.reference_pattern.match('3EXT_EXT-1234')).to eq nil
expect(subject.reference_pattern.match('EXT_EXT-1234')[0]).to eq 'EXT_EXT-1234'
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