From d4d564c8e7d2cbc3e6742475a793ba0f630167e3 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Thu, 1 Feb 2018 18:48:32 +0800 Subject: Try not to hold env and release the controller after the request. This way, we could release the project referred from the controller, which potentially referred a repository which potentially allocated a lot of memories. Before this change, we could hold the last request data and cannot release the memory. After this change, the largest request data should be able to be collected from GC. This might not impact the instances having heavy load, as the last request should be changing all the time, and GC won't kick in for each request anyway. However it could still potentially allow us to free more memories for each GC runs, because now we could free one more request anyway. --- config.ru | 1 + lib/gitlab/middleware/read_only.rb | 2 +- lib/gitlab/middleware/release_controller.rb | 9 +++++++++ .../lib/gitlab/middleware/release_controller_spec.rb | 20 ++++++++++++++++++++ 4 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 lib/gitlab/middleware/release_controller.rb create mode 100644 spec/lib/gitlab/middleware/release_controller_spec.rb diff --git a/config.ru b/config.ru index de0400f4f67..c4bef72308e 100644 --- a/config.ru +++ b/config.ru @@ -23,5 +23,6 @@ warmup do |app| end map ENV['RAILS_RELATIVE_URL_ROOT'] || "/" do + use Gitlab::ReleaseController run Gitlab::Application end diff --git a/lib/gitlab/middleware/read_only.rb b/lib/gitlab/middleware/read_only.rb index c26656704d7..a68c6c3d15c 100644 --- a/lib/gitlab/middleware/read_only.rb +++ b/lib/gitlab/middleware/read_only.rb @@ -28,7 +28,7 @@ module Gitlab end end - @app.call(env) + @app.call(env).tap { @env = nil } end private diff --git a/lib/gitlab/middleware/release_controller.rb b/lib/gitlab/middleware/release_controller.rb new file mode 100644 index 00000000000..a21d718d51c --- /dev/null +++ b/lib/gitlab/middleware/release_controller.rb @@ -0,0 +1,9 @@ +module Gitlab + module Middleware + ReleaseController = Struct.new(:app) do + def call(env) + app.call(env).tap { env.delete('action_controller.instance') } + end + end + end +end diff --git a/spec/lib/gitlab/middleware/release_controller_spec.rb b/spec/lib/gitlab/middleware/release_controller_spec.rb new file mode 100644 index 00000000000..854bac6e751 --- /dev/null +++ b/spec/lib/gitlab/middleware/release_controller_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Gitlab::Middleware::ReleaseController do + let(:inner_app) { double(:app) } + let(:app) { described_class.new(inner_app) } + let(:env) { { 'action_controller.instance' => 'something' } } + + before do + expect(inner_app).to receive(:call).with(env).and_return('yay') + end + + describe '#call' do + it 'calls the app and delete the controller' do + result = app.call(env) + + expect(result).to eq('yay') + expect(env).to be_empty + end + end +end -- cgit v1.2.1 From bbfce29ba8d75df5344dae34dc472dfb3b3acf4b Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Fri, 2 Feb 2018 22:12:28 +0800 Subject: Use a controller to hold request values So that we don't need to hold env after the request. This makes it much harder to test, especially Rails session is acting weirdly, so we need `dig('flash', 'flashes', 'alert')` to dig the actual flash value. --- lib/gitlab/middleware/read_only.rb | 125 ++++++++++++++------------- spec/lib/gitlab/middleware/read_only_spec.rb | 23 ++++- 2 files changed, 87 insertions(+), 61 deletions(-) diff --git a/lib/gitlab/middleware/read_only.rb b/lib/gitlab/middleware/read_only.rb index a68c6c3d15c..b7649ea01db 100644 --- a/lib/gitlab/middleware/read_only.rb +++ b/lib/gitlab/middleware/read_only.rb @@ -5,86 +5,95 @@ module Gitlab APPLICATION_JSON = 'application/json'.freeze API_VERSIONS = (3..4) - def initialize(app) - @app = app - @whitelisted = internal_routes - end - - def call(env) - @env = env - @route_hash = nil + class Controller + def initialize(app, env) + @app = app + @env = env + end - if disallowed_request? && Gitlab::Database.read_only? - Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation') - error_message = 'You cannot do writing operations on a read-only GitLab instance' + def call + if disallowed_request? && Gitlab::Database.read_only? + Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation') + error_message = 'You cannot do writing operations on a read-only GitLab instance' - if json_request? - return [403, { 'Content-Type' => 'application/json' }, [{ 'message' => error_message }.to_json]] - else - rack_flash.alert = error_message - rack_session['flash'] = rack_flash.to_session_value + if json_request? + return [403, { 'Content-Type' => 'application/json' }, [{ 'message' => error_message }.to_json]] + else + rack_flash.alert = error_message + rack_session['flash'] = rack_flash.to_session_value - return [301, { 'Location' => last_visited_url }, []] + return [301, { 'Location' => last_visited_url }, []] + end end + + @app.call(@env) end - @app.call(env).tap { @env = nil } - end + private - private + def disallowed_request? + DISALLOWED_METHODS.include?(@env['REQUEST_METHOD']) && + !whitelisted_routes + end - def internal_routes - API_VERSIONS.flat_map { |version| "api/v#{version}/internal" } - end + def json_request? + request.media_type == APPLICATION_JSON + end - def disallowed_request? - DISALLOWED_METHODS.include?(@env['REQUEST_METHOD']) && !whitelisted_routes - end + def rack_flash + @rack_flash ||= ActionDispatch::Flash::FlashHash.from_session_value(rack_session) + end - def json_request? - request.media_type == APPLICATION_JSON - end + def rack_session + @env['rack.session'] + end - def rack_flash - @rack_flash ||= ActionDispatch::Flash::FlashHash.from_session_value(rack_session) - end + def request + @env['rack.request'] ||= Rack::Request.new(@env) + end - def rack_session - @env['rack.session'] - end + def last_visited_url + @env['HTTP_REFERER'] || rack_session['user_return_to'] || Gitlab::Routing.url_helpers.root_url + end - def request - @env['rack.request'] ||= Rack::Request.new(@env) - end + def route_hash + @route_hash ||= Rails.application.routes.recognize_path(request.url, { method: request.request_method }) rescue {} + end - def last_visited_url - @env['HTTP_REFERER'] || rack_session['user_return_to'] || Gitlab::Routing.url_helpers.root_url - end + def whitelisted_routes + grack_route || ReadOnly.internal_routes.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route + end - def route_hash - @route_hash ||= Rails.application.routes.recognize_path(request.url, { method: request.request_method }) rescue {} - end + def sidekiq_route + request.path.start_with?('/admin/sidekiq') + end - def whitelisted_routes - grack_route || @whitelisted.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route - end + def grack_route + # Calling route_hash may be expensive. Only do it if we think there's a possible match + return false unless request.path.end_with?('.git/git-upload-pack') - def sidekiq_route - request.path.start_with?('/admin/sidekiq') - end + route_hash[:controller] == 'projects/git_http' && route_hash[:action] == 'git_upload_pack' + end + + def lfs_route + # Calling route_hash may be expensive. Only do it if we think there's a possible match + return false unless request.path.end_with?('/info/lfs/objects/batch') - def grack_route - # Calling route_hash may be expensive. Only do it if we think there's a possible match - return false unless request.path.end_with?('.git/git-upload-pack') + route_hash[:controller] == 'projects/lfs_api' && route_hash[:action] == 'batch' + end + end - route_hash[:controller] == 'projects/git_http' && route_hash[:action] == 'git_upload_pack' + def self.internal_routes + @internal_routes ||= + API_VERSIONS.map { |version| "api/v#{version}/internal" } end - def lfs_route - # Calling route_hash may be expensive. Only do it if we think there's a possible match - return false unless request.path.end_with?('/info/lfs/objects/batch') + def initialize(app) + @app = app + end - route_hash[:controller] == 'projects/lfs_api' && route_hash[:action] == 'batch' + def call(env) + Controller.new(@app, env).call end end end diff --git a/spec/lib/gitlab/middleware/read_only_spec.rb b/spec/lib/gitlab/middleware/read_only_spec.rb index 07ba11b93a3..b3c85142b82 100644 --- a/spec/lib/gitlab/middleware/read_only_spec.rb +++ b/spec/lib/gitlab/middleware/read_only_spec.rb @@ -11,8 +11,10 @@ describe Gitlab::Middleware::ReadOnly do RSpec::Matchers.define :disallow_request do match do |middleware| - flash = middleware.send(:rack_flash) - flash['alert'] && flash['alert'].include?('You cannot do writing operations') + alert = middleware.env['rack.session'].to_hash + .dig('flash', 'flashes', 'alert') + + alert&.include?('You cannot do writing operations') end end @@ -34,7 +36,22 @@ describe Gitlab::Middleware::ReadOnly do rack.to_app end - subject { described_class.new(fake_app) } + let(:observe_env) do + Module.new do + attr_reader :env + + def call(env) + @env = env + super + end + end + end + + subject do + app = described_class.new(fake_app) + app.extend(observe_env) + app + end let(:request) { Rack::MockRequest.new(rack_stack) } -- cgit v1.2.1 From 31f1ec59a7cf7517cd5935ef3af540aceba07bb3 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Wed, 7 Feb 2018 22:44:05 +0800 Subject: Release the entire env --- config.ru | 2 +- lib/gitlab/middleware/release_controller.rb | 9 --------- lib/gitlab/middleware/release_env.rb | 14 ++++++++++++++ .../lib/gitlab/middleware/release_controller_spec.rb | 20 -------------------- spec/lib/gitlab/middleware/release_env_spec.rb | 20 ++++++++++++++++++++ 5 files changed, 35 insertions(+), 30 deletions(-) delete mode 100644 lib/gitlab/middleware/release_controller.rb create mode 100644 lib/gitlab/middleware/release_env.rb delete mode 100644 spec/lib/gitlab/middleware/release_controller_spec.rb create mode 100644 spec/lib/gitlab/middleware/release_env_spec.rb diff --git a/config.ru b/config.ru index c4bef72308e..7b15939c6ff 100644 --- a/config.ru +++ b/config.ru @@ -23,6 +23,6 @@ warmup do |app| end map ENV['RAILS_RELATIVE_URL_ROOT'] || "/" do - use Gitlab::ReleaseController + use Gitlab::Middleware::ReleaseEnv run Gitlab::Application end diff --git a/lib/gitlab/middleware/release_controller.rb b/lib/gitlab/middleware/release_controller.rb deleted file mode 100644 index a21d718d51c..00000000000 --- a/lib/gitlab/middleware/release_controller.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Gitlab - module Middleware - ReleaseController = Struct.new(:app) do - def call(env) - app.call(env).tap { env.delete('action_controller.instance') } - end - end - end -end diff --git a/lib/gitlab/middleware/release_env.rb b/lib/gitlab/middleware/release_env.rb new file mode 100644 index 00000000000..f8d0a135965 --- /dev/null +++ b/lib/gitlab/middleware/release_env.rb @@ -0,0 +1,14 @@ +module Gitlab + module Middleware + # Some of middleware would hold env for no good reason even after the + # request had already been processed, and we could not garbage collect + # them due to this. Put this middleware as the first middleware so that + # it would clear the env after the request is done, allowing GC gets a + # chance to release memory for the last request. + ReleaseEnv = Struct.new(:app) do + def call(env) + app.call(env).tap { env.clear } + end + end + end +end diff --git a/spec/lib/gitlab/middleware/release_controller_spec.rb b/spec/lib/gitlab/middleware/release_controller_spec.rb deleted file mode 100644 index 854bac6e751..00000000000 --- a/spec/lib/gitlab/middleware/release_controller_spec.rb +++ /dev/null @@ -1,20 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Middleware::ReleaseController do - let(:inner_app) { double(:app) } - let(:app) { described_class.new(inner_app) } - let(:env) { { 'action_controller.instance' => 'something' } } - - before do - expect(inner_app).to receive(:call).with(env).and_return('yay') - end - - describe '#call' do - it 'calls the app and delete the controller' do - result = app.call(env) - - expect(result).to eq('yay') - expect(env).to be_empty - end - end -end diff --git a/spec/lib/gitlab/middleware/release_env_spec.rb b/spec/lib/gitlab/middleware/release_env_spec.rb new file mode 100644 index 00000000000..657b705502a --- /dev/null +++ b/spec/lib/gitlab/middleware/release_env_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +describe Gitlab::Middleware::ReleaseEnv do + let(:inner_app) { double(:app) } + let(:app) { described_class.new(inner_app) } + let(:env) { { 'action_controller.instance' => 'something' } } + + before do + expect(inner_app).to receive(:call).with(env).and_return('yay') + end + + describe '#call' do + it 'calls the app and delete the controller' do + result = app.call(env) + + expect(result).to eq('yay') + expect(env).to be_empty + end + end +end -- cgit v1.2.1 From 5309d4457aea74729a8c6be9ec76d535f922bf8a Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Wed, 7 Feb 2018 22:44:18 +0800 Subject: Put controller in its separate file --- lib/gitlab/middleware/read_only.rb | 80 +------------------------- lib/gitlab/middleware/read_only/controller.rb | 83 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 79 deletions(-) create mode 100644 lib/gitlab/middleware/read_only/controller.rb diff --git a/lib/gitlab/middleware/read_only.rb b/lib/gitlab/middleware/read_only.rb index b7649ea01db..19b74c0c122 100644 --- a/lib/gitlab/middleware/read_only.rb +++ b/lib/gitlab/middleware/read_only.rb @@ -5,84 +5,6 @@ module Gitlab APPLICATION_JSON = 'application/json'.freeze API_VERSIONS = (3..4) - class Controller - def initialize(app, env) - @app = app - @env = env - end - - def call - if disallowed_request? && Gitlab::Database.read_only? - Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation') - error_message = 'You cannot do writing operations on a read-only GitLab instance' - - if json_request? - return [403, { 'Content-Type' => 'application/json' }, [{ 'message' => error_message }.to_json]] - else - rack_flash.alert = error_message - rack_session['flash'] = rack_flash.to_session_value - - return [301, { 'Location' => last_visited_url }, []] - end - end - - @app.call(@env) - end - - private - - def disallowed_request? - DISALLOWED_METHODS.include?(@env['REQUEST_METHOD']) && - !whitelisted_routes - end - - def json_request? - request.media_type == APPLICATION_JSON - end - - def rack_flash - @rack_flash ||= ActionDispatch::Flash::FlashHash.from_session_value(rack_session) - end - - def rack_session - @env['rack.session'] - end - - def request - @env['rack.request'] ||= Rack::Request.new(@env) - end - - def last_visited_url - @env['HTTP_REFERER'] || rack_session['user_return_to'] || Gitlab::Routing.url_helpers.root_url - end - - def route_hash - @route_hash ||= Rails.application.routes.recognize_path(request.url, { method: request.request_method }) rescue {} - end - - def whitelisted_routes - grack_route || ReadOnly.internal_routes.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route - end - - def sidekiq_route - request.path.start_with?('/admin/sidekiq') - end - - def grack_route - # Calling route_hash may be expensive. Only do it if we think there's a possible match - return false unless request.path.end_with?('.git/git-upload-pack') - - route_hash[:controller] == 'projects/git_http' && route_hash[:action] == 'git_upload_pack' - end - - def lfs_route - # Calling route_hash may be expensive. Only do it if we think there's a possible match - return false unless request.path.end_with?('/info/lfs/objects/batch') - - route_hash[:controller] == 'projects/lfs_api' && route_hash[:action] == 'batch' - end - end - def self.internal_routes @internal_routes ||= API_VERSIONS.map { |version| "api/v#{version}/internal" } @@ -93,7 +15,7 @@ module Gitlab end def call(env) - Controller.new(@app, env).call + ReadOnly::Controller.new(@app, env).call end end end diff --git a/lib/gitlab/middleware/read_only/controller.rb b/lib/gitlab/middleware/read_only/controller.rb new file mode 100644 index 00000000000..053cb6f0a9f --- /dev/null +++ b/lib/gitlab/middleware/read_only/controller.rb @@ -0,0 +1,83 @@ +module Gitlab + module Middleware + class ReadOnly + class Controller + def initialize(app, env) + @app = app + @env = env + end + + def call + if disallowed_request? && Gitlab::Database.read_only? + Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation') + error_message = 'You cannot do writing operations on a read-only GitLab instance' + + if json_request? + return [403, { 'Content-Type' => 'application/json' }, [{ 'message' => error_message }.to_json]] + else + rack_flash.alert = error_message + rack_session['flash'] = rack_flash.to_session_value + + return [301, { 'Location' => last_visited_url }, []] + end + end + + @app.call(@env) + end + + private + + def disallowed_request? + DISALLOWED_METHODS.include?(@env['REQUEST_METHOD']) && + !whitelisted_routes + end + + def json_request? + request.media_type == APPLICATION_JSON + end + + def rack_flash + @rack_flash ||= ActionDispatch::Flash::FlashHash.from_session_value(rack_session) + end + + def rack_session + @env['rack.session'] + end + + def request + @env['rack.request'] ||= Rack::Request.new(@env) + end + + def last_visited_url + @env['HTTP_REFERER'] || rack_session['user_return_to'] || Gitlab::Routing.url_helpers.root_url + end + + def route_hash + @route_hash ||= Rails.application.routes.recognize_path(request.url, { method: request.request_method }) rescue {} + end + + def whitelisted_routes + grack_route || ReadOnly.internal_routes.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route + end + + def sidekiq_route + request.path.start_with?('/admin/sidekiq') + end + + def grack_route + # Calling route_hash may be expensive. Only do it if we think there's a possible match + return false unless request.path.end_with?('.git/git-upload-pack') + + route_hash[:controller] == 'projects/git_http' && route_hash[:action] == 'git_upload_pack' + end + + def lfs_route + # Calling route_hash may be expensive. Only do it if we think there's a possible match + return false unless request.path.end_with?('/info/lfs/objects/batch') + + route_hash[:controller] == 'projects/lfs_api' && route_hash[:action] == 'batch' + end + end + end + end +end -- cgit v1.2.1 From 461ecbcf07f0785b5ea50c62b114bf8217ac5199 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Wed, 7 Feb 2018 22:55:50 +0800 Subject: Update peek-performance_bar which doesn't hold env --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index e78c3c5f794..546ba2f2a18 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -601,7 +601,7 @@ GEM atomic (>= 1.0.0) mysql2 peek - peek-performance_bar (1.3.0) + peek-performance_bar (1.3.1) peek (>= 0.1.0) peek-pg (1.3.0) concurrent-ruby -- cgit v1.2.1 From 1a09d5cda8e9f6b90b85351a16fcddea351b869f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jarka=20Kadlecov=C3=A1?= Date: Fri, 16 Feb 2018 14:33:50 +0100 Subject: Render htmlentities correctly for links not supported by Rinku --- changelogs/unreleased/41719-mr-title-fix.yml | 5 ++ lib/banzai/filter/autolink_filter.rb | 36 ++------------ lib/gitlab/string_range_marker.rb | 2 +- lib/gitlab/string_regex_marker.rb | 12 +++-- spec/lib/banzai/filter/autolink_filter_spec.rb | 65 +++++++++++++++++--------- spec/lib/gitlab/string_regex_marker_spec.rb | 35 ++++++++++---- 6 files changed, 89 insertions(+), 66 deletions(-) create mode 100644 changelogs/unreleased/41719-mr-title-fix.yml diff --git a/changelogs/unreleased/41719-mr-title-fix.yml b/changelogs/unreleased/41719-mr-title-fix.yml new file mode 100644 index 00000000000..92388f30cb2 --- /dev/null +++ b/changelogs/unreleased/41719-mr-title-fix.yml @@ -0,0 +1,5 @@ +--- +title: Render htmlentities correctly for links not supported by Rinku +merge_request: +author: +type: fixed diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb index b8d2673c1a6..c4990637971 100644 --- a/lib/banzai/filter/autolink_filter.rb +++ b/lib/banzai/filter/autolink_filter.rb @@ -26,7 +26,7 @@ module Banzai # in the generated link. # # Rubular: http://rubular.com/r/cxjPyZc7Sb - LINK_PATTERN = %r{([a-z][a-z0-9\+\.-]+://\S+)(?]+)(?See #{link}" - expect(filter(act).to_html).to eq exp - end - end + found_links = doc.css('a') - context 'when the input contains link' do - it 'does parse_html back the rinku returned value' do - act = HTML::Pipeline.parse("

See #{link}

") + expect(found_links.size).to eq(2) + expect(found_links[0].text).to eq(link1) + expect(found_links[0]['href']).to eq(link1) + expect(found_links[1].text).to eq(link2) + expect(found_links[1]['href']).to eq(link2) + end - expect_any_instance_of(described_class).to receive(:parse_html).at_least(:once).and_call_original + it 'accepts link_attr options' do + doc = filter("See #{link}", link_attr: { class: 'custom' }) - filter(act).to_html - end + expect(doc.at_css('a')['class']).to eq 'custom' end - end - - context 'other schemes' do - let(:link) { 'foo://bar.baz/' } it 'autolinks smb' do link = 'smb:///Volumes/shared/foo.pdf' @@ -91,6 +85,21 @@ describe Banzai::Filter::AutolinkFilter do expect(doc.at_css('a')['href']).to eq link end + it 'autolinks multiple occurences of smb' do + link1 = 'smb:///Volumes/shared/foo.pdf' + link2 = 'smb:///Volumes/shared/bar.pdf' + + doc = filter("See #{link1} and #{link2}") + + found_links = doc.css('a') + + expect(found_links.size).to eq(2) + expect(found_links[0].text).to eq(link1) + expect(found_links[0]['href']).to eq(link1) + expect(found_links[1].text).to eq(link2) + expect(found_links[1]['href']).to eq(link2) + end + it 'autolinks irc' do link = 'irc://irc.freenode.net/git' doc = filter("See #{link}") @@ -151,4 +160,18 @@ describe Banzai::Filter::AutolinkFilter do end end end + + context 'when the link is inside a tag' do + it 'renders text after the link correctly for http' do + doc = filter(ERB::Util.html_escape_once("")) + + expect(doc.children.last.text).to include('') + end + + it 'renders text after the link correctly for not other protocol' do + doc = filter(ERB::Util.html_escape_once("")) + + expect(doc.children.last.text).to include('') + end + end end diff --git a/spec/lib/gitlab/string_regex_marker_spec.rb b/spec/lib/gitlab/string_regex_marker_spec.rb index d715f9bd641..37b1298b962 100644 --- a/spec/lib/gitlab/string_regex_marker_spec.rb +++ b/spec/lib/gitlab/string_regex_marker_spec.rb @@ -2,17 +2,36 @@ require 'spec_helper' describe Gitlab::StringRegexMarker do describe '#mark' do - let(:raw) { %{"name": "AFNetworking"} } - let(:rich) { %{"name": "AFNetworking"}.html_safe } - subject do - described_class.new(raw, rich).mark(/"[^"]+":\s*"(?[^"]+)"/, group: :name) do |text, left:, right:| - %{#{text}} + context 'with a single occurrence' do + let(:raw) { %{"name": "AFNetworking"} } + let(:rich) { %{"name": "AFNetworking"}.html_safe } + + subject do + described_class.new(raw, rich).mark(/"[^"]+":\s*"(?[^"]+)"/, group: :name) do |text, left:, right:| + %{#{text}} + end + end + + it 'marks the match' do + expect(subject).to eq(%{"name": "AFNetworking"}) + expect(subject).to be_html_safe end end - it 'marks the inline diffs' do - expect(subject).to eq(%{"name": "AFNetworking"}) - expect(subject).to be_html_safe + context 'with multiple occurrences' do + let(:raw) { %{a d} } + let(:rich) { %{a <b> <c> d}.html_safe } + + subject do + described_class.new(raw, rich).mark(/<[a-z]>/) do |text, left:, right:| + %{#{text}} + end + end + + it 'marks the matches' do + expect(subject).to eq(%{a <b> <c> d}) + expect(subject).to be_html_safe + end end end end -- cgit v1.2.1 From 7b31095ef85982af2aa3bcc5861c5c22e284b15c Mon Sep 17 00:00:00 2001 From: Adam Pahlevi Date: Sun, 5 Feb 2017 14:25:06 +0700 Subject: /wip slash command on MR creation change to symbol add complete changelog add test for /wip unwip as sym test for work in progress separate from issuable --- app/services/issuable_base_service.rb | 15 + app/services/merge_requests/update_service.rb | 11 - app/services/slash_commands/interpret_service.rb | 336 ++++++++++ changelogs/unreleased/wip-new-mr-cmd.yml | 4 + .../services/merge_requests/create_service_spec.rb | 35 ++ .../slash_commands/interpret_service_spec.rb | 689 +++++++++++++++++++++ 6 files changed, 1079 insertions(+), 11 deletions(-) create mode 100644 app/services/slash_commands/interpret_service.rb create mode 100644 changelogs/unreleased/wip-new-mr-cmd.yml create mode 100644 spec/services/slash_commands/interpret_service_spec.rb diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index e87fd49d193..c552bf6ea41 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -132,6 +132,7 @@ class IssuableBaseService < BaseService def create(issuable) merge_quick_actions_into_params!(issuable) + handle_wip_event(issuable) filter_params(issuable) params.delete(:state_event) @@ -311,4 +312,18 @@ class IssuableBaseService < BaseService def parent project end + + def handle_wip_event(issuable) + if wip_event = params.delete(:wip_event) + case issuable + when MergeRequest + # We update the title that is provided in the params or we use the mr title + title = params[:title] || issuable.title + params[:title] = case wip_event + when :wip then MergeRequest.wip_title(title) + when :unwip then MergeRequest.wipless_title(title) + end + end + end + end end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index c153872c874..8a40ad88182 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -98,17 +98,6 @@ module MergeRequests private - def handle_wip_event(merge_request) - if wip_event = params.delete(:wip_event) - # We update the title that is provided in the params or we use the mr title - title = params[:title] || merge_request.title - params[:title] = case wip_event - when 'wip' then MergeRequest.wip_title(title) - when 'unwip' then MergeRequest.wipless_title(title) - end - end - end - def create_branch_change_note(issuable, branch_type, old_branch, new_branch) SystemNoteService.change_branch( issuable, issuable.project, current_user, branch_type, diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb new file mode 100644 index 00000000000..0beb173a13d --- /dev/null +++ b/app/services/slash_commands/interpret_service.rb @@ -0,0 +1,336 @@ +module SlashCommands + class InterpretService < BaseService + include Gitlab::SlashCommands::Dsl + + attr_reader :issuable, :options + + # Takes a text and interprets the commands that are extracted from it. + # Returns the content without commands, and hash of changes to be applied to a record. + def execute(content, issuable) + @issuable = issuable + @updates = {} + + opts = { + issuable: issuable, + current_user: current_user, + project: project, + params: params + } + + content, commands = extractor.extract_commands(content, opts) + + commands.each do |name, arg| + definition = self.class.command_definitions_by_name[name.to_sym] + next unless definition + + definition.execute(self, opts, arg) + end + + [content, @updates] + end + + private + + def extractor + Gitlab::SlashCommands::Extractor.new(self.class.command_definitions) + end + + desc do + "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" + end + condition do + issuable.persisted? && + issuable.open? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :close do + @updates[:state_event] = 'close' + end + + desc do + "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}" + end + condition do + issuable.persisted? && + issuable.closed? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :reopen do + @updates[:state_event] = 'reopen' + end + + desc 'Merge (when build succeeds)' + condition do + last_diff_sha = params && params[:merge_request_diff_head_sha] + issuable.is_a?(MergeRequest) && + issuable.persisted? && + issuable.mergeable_with_slash_command?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha) + end + command :merge do + @updates[:merge] = params[:merge_request_diff_head_sha] + end + + desc 'Change title' + params '' + condition do + issuable.persisted? && + current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + end + command :title do |title_param| + @updates[:title] = title_param + end + + desc 'Assign' + params '@user' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :assign do |assignee_param| + user = extract_references(assignee_param, :user).first + user ||= User.find_by(username: assignee_param) + + @updates[:assignee_id] = user.id if user + end + + desc 'Remove assignee' + condition do + issuable.persisted? && + issuable.assignee_id? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :unassign do + @updates[:assignee_id] = nil + end + + desc 'Set milestone' + params '%"milestone"' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && + project.milestones.active.any? + end + command :milestone do |milestone_param| + milestone = extract_references(milestone_param, :milestone).first + milestone ||= project.milestones.find_by(title: milestone_param.strip) + + @updates[:milestone_id] = milestone.id if milestone + end + + desc 'Remove milestone' + condition do + issuable.persisted? && + issuable.milestone_id? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_milestone do + @updates[:milestone_id] = nil + end + + desc 'Add label(s)' + params '~label1 ~"label 2"' + condition do + available_labels = LabelsFinder.new(current_user, project_id: project.id).execute + + current_user.can?(:"admin_#{issuable.to_ability_name}", project) && + available_labels.any? + end + command :label do |labels_param| + label_ids = find_label_ids(labels_param) + + if label_ids.any? + @updates[:add_label_ids] ||= [] + @updates[:add_label_ids] += label_ids + + @updates[:add_label_ids].uniq! + end + end + + desc 'Remove all or specific label(s)' + params '~label1 ~"label 2"' + condition do + issuable.persisted? && + issuable.labels.any? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :unlabel do |labels_param = nil| + if labels_param.present? + label_ids = find_label_ids(labels_param) + + if label_ids.any? + @updates[:remove_label_ids] ||= [] + @updates[:remove_label_ids] += label_ids + + @updates[:remove_label_ids].uniq! + end + else + @updates[:label_ids] = [] + end + end + + desc 'Replace all label(s)' + params '~label1 ~"label 2"' + condition do + issuable.persisted? && + issuable.labels.any? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :relabel do |labels_param| + label_ids = find_label_ids(labels_param) + + if label_ids.any? + @updates[:label_ids] ||= [] + @updates[:label_ids] += label_ids + + @updates[:label_ids].uniq! + end + end + + desc 'Add a todo' + condition do + issuable.persisted? && + !TodoService.new.todo_exist?(issuable, current_user) + end + command :todo do + @updates[:todo_event] = 'add' + end + + desc 'Mark todo as done' + condition do + issuable.persisted? && + TodoService.new.todo_exist?(issuable, current_user) + end + command :done do + @updates[:todo_event] = 'done' + end + + desc 'Subscribe' + condition do + issuable.persisted? && + !issuable.subscribed?(current_user, project) + end + command :subscribe do + @updates[:subscription_event] = 'subscribe' + end + + desc 'Unsubscribe' + condition do + issuable.persisted? && + issuable.subscribed?(current_user, project) + end + command :unsubscribe do + @updates[:subscription_event] = 'unsubscribe' + end + + desc 'Set due date' + params '' + condition do + issuable.respond_to?(:due_date) && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :due do |due_date_param| + due_date = Chronic.parse(due_date_param).try(:to_date) + + @updates[:due_date] = due_date if due_date + end + + desc 'Remove due date' + condition do + issuable.persisted? && + issuable.respond_to?(:due_date) && + issuable.due_date? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_due_date do + @updates[:due_date] = nil + end + + desc do + "Toggle the Work In Progress status" + end + condition do + issuable.respond_to?(:work_in_progress?) && ( + # /wip on comment text on MR page + (issuable.persisted? && current_user.can?(:"update_#{issuable.to_ability_name}", issuable)) || + # /wip on create MR page + issuable.new_record? + ) + end + command :wip do + @updates[:wip_event] = issuable.work_in_progress? ? :unwip : :wip + end + + desc 'Set time estimate' + params '<1w 3d 2h 14m>' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :estimate do |raw_duration| + time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration) + + if time_estimate + @updates[:time_estimate] = time_estimate + end + end + + desc 'Add or substract spent time' + params '<1h 30m | -1h 30m>' + condition do + current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) + end + command :spend do |raw_duration| + time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration) + + if time_spent + @updates[:spend_time] = { duration: time_spent, user: current_user } + end + end + + desc 'Remove time estimate' + condition do + issuable.persisted? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_estimate do + @updates[:time_estimate] = 0 + end + + desc 'Remove spent time' + condition do + issuable.persisted? && + current_user.can?(:"admin_#{issuable.to_ability_name}", project) + end + command :remove_time_spent do + @updates[:spend_time] = { duration: :reset, user: current_user } + end + + # This is a dummy command, so that it appears in the autocomplete commands + desc 'CC' + params '@user' + command :cc + + desc 'Defines target branch for MR' + params '' + condition do + issuable.respond_to?(:target_branch) && + (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) || + issuable.new_record?) + end + command :target_branch do |target_branch_param| + branch_name = target_branch_param.strip + @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name) + end + + def find_label_ids(labels_param) + label_ids_by_reference = extract_references(labels_param, :label).map(&:id) + labels_ids_by_name = LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute.select(:id) + + label_ids_by_reference | labels_ids_by_name + end + + def extract_references(arg, type) + ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze(arg, author: current_user) + + ext.references(type) + end + end +end diff --git a/changelogs/unreleased/wip-new-mr-cmd.yml b/changelogs/unreleased/wip-new-mr-cmd.yml new file mode 100644 index 00000000000..08cbe84ea05 --- /dev/null +++ b/changelogs/unreleased/wip-new-mr-cmd.yml @@ -0,0 +1,4 @@ +--- +title: wip slash command option on merge request creation +merge_request: 8982 +author: Adam Pahlevi diff --git a/spec/services/merge_requests/create_service_spec.rb b/spec/services/merge_requests/create_service_spec.rb index 5d226f34d2d..44a83c436cb 100644 --- a/spec/services/merge_requests/create_service_spec.rb +++ b/spec/services/merge_requests/create_service_spec.rb @@ -28,6 +28,7 @@ describe MergeRequests::CreateService do it 'creates an MR' do expect(merge_request).to be_valid + expect(merge_request.work_in_progress?).to be(false) expect(merge_request.title).to eq('Awesome merge_request') expect(merge_request.assignee).to be_nil expect(merge_request.merge_params['force_remove_source_branch']).to eq('1') @@ -62,6 +63,40 @@ describe MergeRequests::CreateService do expect(Event.where(attributes).count).to eq(1) end + describe 'when marked with /wip' do + context 'in title and in description' do + let(:opts) do + { + title: 'WIP: Awesome merge_request', + description: "well this is not done yet\n/wip", + source_branch: 'feature', + target_branch: 'master', + assignee: assignee + } + end + + it 'sets MR to WIP' do + expect(merge_request.work_in_progress?).to be(true) + end + end + + context 'in description only' do + let(:opts) do + { + title: 'Awesome merge_request', + description: "well this is not done yet\n/wip", + source_branch: 'feature', + target_branch: 'master', + assignee: assignee + } + end + + it 'sets MR to WIP' do + expect(merge_request.work_in_progress?).to be(true) + end + end + end + context 'when merge request is assigned to someone' do let(:opts) do { diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb new file mode 100644 index 00000000000..7b247397b15 --- /dev/null +++ b/spec/services/slash_commands/interpret_service_spec.rb @@ -0,0 +1,689 @@ +require 'spec_helper' + +describe SlashCommands::InterpretService, services: true do + let(:project) { create(:project, :public) } + let(:developer) { create(:user) } + let(:issue) { create(:issue, project: project) } + let(:milestone) { create(:milestone, project: project, title: '9.10') } + let(:inprogress) { create(:label, project: project, title: 'In Progress') } + let(:bug) { create(:label, project: project, title: 'Bug') } + let(:note) { build(:note, commit_id: merge_request.diff_head_sha) } + + before do + project.team << [developer, :developer] + end + + describe '#execute' do + let(:service) { described_class.new(project, developer) } + let(:merge_request) { create(:merge_request, source_project: project) } + + shared_examples 'reopen command' do + it 'returns state_event: "reopen" if content contains /reopen' do + issuable.close! + _, updates = service.execute(content, issuable) + + expect(updates).to eq(state_event: 'reopen') + end + end + + shared_examples 'close command' do + it 'returns state_event: "close" if content contains /close' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(state_event: 'close') + end + end + + shared_examples 'title command' do + it 'populates title: "A brand new title" if content contains /title A brand new title' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(title: 'A brand new title') + end + end + + shared_examples 'assign command' do + it 'fetches assignee and populates assignee_id if content contains /assign' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(assignee_id: developer.id) + end + end + + shared_examples 'unassign command' do + it 'populates assignee_id: nil if content contains /unassign' do + issuable.update(assignee_id: developer.id) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(assignee_id: nil) + end + end + + shared_examples 'milestone command' do + it 'fetches milestone and populates milestone_id if content contains /milestone' do + milestone # populate the milestone + _, updates = service.execute(content, issuable) + + expect(updates).to eq(milestone_id: milestone.id) + end + end + + shared_examples 'remove_milestone command' do + it 'populates milestone_id: nil if content contains /remove_milestone' do + issuable.update(milestone_id: milestone.id) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(milestone_id: nil) + end + end + + shared_examples 'label command' do + it 'fetches label ids and populates add_label_ids if content contains /label' do + bug # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(add_label_ids: [bug.id, inprogress.id]) + end + end + + shared_examples 'multiple label command' do + it 'fetches label ids and populates add_label_ids if content contains multiple /label' do + bug # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(add_label_ids: [inprogress.id, bug.id]) + end + end + + shared_examples 'multiple label with same argument' do + it 'prevents duplicate label ids and populates add_label_ids if content contains multiple /label' do + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(add_label_ids: [inprogress.id]) + end + end + + shared_examples 'unlabel command' do + it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do + issuable.update(label_ids: [inprogress.id]) # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(remove_label_ids: [inprogress.id]) + end + end + + shared_examples 'multiple unlabel command' do + it 'fetches label ids and populates remove_label_ids if content contains mutiple /unlabel' do + issuable.update(label_ids: [inprogress.id, bug.id]) # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(remove_label_ids: [inprogress.id, bug.id]) + end + end + + shared_examples 'unlabel command with no argument' do + it 'populates label_ids: [] if content contains /unlabel with no arguments' do + issuable.update(label_ids: [inprogress.id]) # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(label_ids: []) + end + end + + shared_examples 'relabel command' do + it 'populates label_ids: [] if content contains /relabel' do + issuable.update(label_ids: [bug.id]) # populate the label + inprogress # populate the label + _, updates = service.execute(content, issuable) + + expect(updates).to eq(label_ids: [inprogress.id]) + end + end + + shared_examples 'todo command' do + it 'populates todo_event: "add" if content contains /todo' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(todo_event: 'add') + end + end + + shared_examples 'done command' do + it 'populates todo_event: "done" if content contains /done' do + TodoService.new.mark_todo(issuable, developer) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(todo_event: 'done') + end + end + + shared_examples 'subscribe command' do + it 'populates subscription_event: "subscribe" if content contains /subscribe' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(subscription_event: 'subscribe') + end + end + + shared_examples 'unsubscribe command' do + it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do + issuable.subscribe(developer, project) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(subscription_event: 'unsubscribe') + end + end + + shared_examples 'due command' do + it 'populates due_date: Date.new(2016, 8, 28) if content contains /due 2016-08-28' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28)) + end + end + + shared_examples 'remove_due_date command' do + it 'populates due_date: nil if content contains /remove_due_date' do + issuable.update(due_date: Date.today) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(due_date: nil) + end + end + + shared_examples 'wip command' do + it 'returns wip_event: "wip" if content contains /wip' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(wip_event: :wip) + end + end + + shared_examples 'unwip command' do + it 'returns wip_event: "unwip" if content contains /wip' do + issuable.update(title: issuable.wip_title) + _, updates = service.execute(content, issuable) + + expect(updates).to eq(wip_event: :unwip) + end + end + + shared_examples 'estimate command' do + it 'populates time_estimate: 3600 if content contains /estimate 1h' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(time_estimate: 3600) + end + end + + shared_examples 'spend command' do + it 'populates spend_time: 3600 if content contains /spend 1h' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(spend_time: { duration: 3600, user: developer }) + end + end + + shared_examples 'spend command with negative time' do + it 'populates spend_time: -1800 if content contains /spend -30m' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(spend_time: { duration: -1800, user: developer }) + end + end + + shared_examples 'remove_estimate command' do + it 'populates time_estimate: 0 if content contains /remove_estimate' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(time_estimate: 0) + end + end + + shared_examples 'remove_time_spent command' do + it 'populates spend_time: :reset if content contains /remove_time_spent' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(spend_time: { duration: :reset, user: developer }) + end + end + + shared_examples 'empty command' do + it 'populates {} if content contains an unsupported command' do + _, updates = service.execute(content, issuable) + + expect(updates).to be_empty + end + end + + shared_examples 'merge command' do + it 'runs merge command if content contains /merge' do + _, updates = service.execute(content, issuable) + + expect(updates).to eq(merge: merge_request.diff_head_sha) + end + end + + it_behaves_like 'reopen command' do + let(:content) { '/reopen' } + let(:issuable) { issue } + end + + it_behaves_like 'reopen command' do + let(:content) { '/reopen' } + let(:issuable) { merge_request } + end + + it_behaves_like 'close command' do + let(:content) { '/close' } + let(:issuable) { issue } + end + + it_behaves_like 'close command' do + let(:content) { '/close' } + let(:issuable) { merge_request } + end + + context 'merge command' do + let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: merge_request.diff_head_sha }) } + + it_behaves_like 'merge command' do + let(:content) { '/merge' } + let(:issuable) { merge_request } + end + + context 'can not be merged when logged user does not have permissions' do + let(:service) { described_class.new(project, create(:user)) } + + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { merge_request } + end + end + + context 'can not be merged when sha does not match' do + let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: 'othersha' }) } + + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { merge_request } + end + end + + context 'when sha is missing' do + let(:service) { described_class.new(project, developer, {}) } + + it 'precheck passes and returns merge command' do + _, updates = service.execute('/merge', merge_request) + + expect(updates).to eq(merge: nil) + end + end + + context 'issue can not be merged' do + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { issue } + end + end + + context 'non persisted merge request cant be merged' do + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { build(:merge_request) } + end + end + + context 'not persisted merge request can not be merged' do + it_behaves_like 'empty command' do + let(:content) { "/merge" } + let(:issuable) { build(:merge_request, source_project: project) } + end + end + end + + it_behaves_like 'title command' do + let(:content) { '/title A brand new title' } + let(:issuable) { issue } + end + + it_behaves_like 'title command' do + let(:content) { '/title A brand new title' } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/title' } + let(:issuable) { issue } + end + + it_behaves_like 'assign command' do + let(:content) { "/assign @#{developer.username}" } + let(:issuable) { issue } + end + + it_behaves_like 'assign command' do + let(:content) { "/assign @#{developer.username}" } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/assign @abcd1234' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/assign' } + let(:issuable) { issue } + end + + it_behaves_like 'unassign command' do + let(:content) { '/unassign' } + let(:issuable) { issue } + end + + it_behaves_like 'unassign command' do + let(:content) { '/unassign' } + let(:issuable) { merge_request } + end + + it_behaves_like 'milestone command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { issue } + end + + it_behaves_like 'milestone command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { merge_request } + end + + it_behaves_like 'remove_milestone command' do + let(:content) { '/remove_milestone' } + let(:issuable) { issue } + end + + it_behaves_like 'remove_milestone command' do + let(:content) { '/remove_milestone' } + let(:issuable) { merge_request } + end + + it_behaves_like 'label command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { issue } + end + + it_behaves_like 'label command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { merge_request } + end + + it_behaves_like 'multiple label command' do + let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{bug.title}) } + let(:issuable) { issue } + end + + it_behaves_like 'multiple label with same argument' do + let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { merge_request } + end + + it_behaves_like 'multiple unlabel command' do + let(:content) { %(/unlabel ~"#{inprogress.title}" \n/unlabel ~#{bug.title}) } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command with no argument' do + let(:content) { %(/unlabel) } + let(:issuable) { issue } + end + + it_behaves_like 'unlabel command with no argument' do + let(:content) { %(/unlabel) } + let(:issuable) { merge_request } + end + + it_behaves_like 'relabel command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'relabel command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { merge_request } + end + + it_behaves_like 'todo command' do + let(:content) { '/todo' } + let(:issuable) { issue } + end + + it_behaves_like 'todo command' do + let(:content) { '/todo' } + let(:issuable) { merge_request } + end + + it_behaves_like 'done command' do + let(:content) { '/done' } + let(:issuable) { issue } + end + + it_behaves_like 'done command' do + let(:content) { '/done' } + let(:issuable) { merge_request } + end + + it_behaves_like 'subscribe command' do + let(:content) { '/subscribe' } + let(:issuable) { issue } + end + + it_behaves_like 'subscribe command' do + let(:content) { '/subscribe' } + let(:issuable) { merge_request } + end + + it_behaves_like 'unsubscribe command' do + let(:content) { '/unsubscribe' } + let(:issuable) { issue } + end + + it_behaves_like 'unsubscribe command' do + let(:content) { '/unsubscribe' } + let(:issuable) { merge_request } + end + + it_behaves_like 'due command' do + let(:content) { '/due 2016-08-28' } + let(:issuable) { issue } + end + + it_behaves_like 'due command' do + let(:content) { '/due tomorrow' } + let(:issuable) { issue } + let(:expected_date) { Date.tomorrow } + end + + it_behaves_like 'due command' do + let(:content) { '/due 5 days from now' } + let(:issuable) { issue } + let(:expected_date) { 5.days.from_now.to_date } + end + + it_behaves_like 'due command' do + let(:content) { '/due in 2 days' } + let(:issuable) { issue } + let(:expected_date) { 2.days.from_now.to_date } + end + + it_behaves_like 'empty command' do + let(:content) { '/due foo bar' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/due 2016-08-28' } + let(:issuable) { merge_request } + end + + it_behaves_like 'remove_due_date command' do + let(:content) { '/remove_due_date' } + let(:issuable) { issue } + end + + it_behaves_like 'wip command' do + let(:content) { '/wip' } + let(:issuable) { merge_request } + end + + it_behaves_like 'unwip command' do + let(:content) { '/wip' } + let(:issuable) { merge_request } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_due_date' } + let(:issuable) { merge_request } + end + + it_behaves_like 'estimate command' do + let(:content) { '/estimate 1h' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/estimate' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/estimate abc' } + let(:issuable) { issue } + end + + it_behaves_like 'spend command' do + let(:content) { '/spend 1h' } + let(:issuable) { issue } + end + + it_behaves_like 'spend command with negative time' do + let(:content) { '/spend -30m' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/spend' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/spend abc' } + let(:issuable) { issue } + end + + it_behaves_like 'remove_estimate command' do + let(:content) { '/remove_estimate' } + let(:issuable) { issue } + end + + it_behaves_like 'remove_time_spent command' do + let(:content) { '/remove_time_spent' } + let(:issuable) { issue } + end + + context 'when current_user cannot :admin_issue' do + let(:visitor) { create(:user) } + let(:issue) { create(:issue, project: project, author: visitor) } + let(:service) { described_class.new(project, visitor) } + + it_behaves_like 'empty command' do + let(:content) { "/assign @#{developer.username}" } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/unassign' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { "/milestone %#{milestone.title}" } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_milestone' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { %(/unlabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { %(/relabel ~"#{inprogress.title}") } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/due tomorrow' } + let(:issuable) { issue } + end + + it_behaves_like 'empty command' do + let(:content) { '/remove_due_date' } + let(:issuable) { issue } + end + end + + context '/target_branch command' do + let(:non_empty_project) { create(:project) } + let(:another_merge_request) { create(:merge_request, author: developer, source_project: non_empty_project) } + let(:service) { described_class.new(non_empty_project, developer)} + + it 'updates target_branch if /target_branch command is executed' do + _, updates = service.execute('/target_branch merge-test', merge_request) + + expect(updates).to eq(target_branch: 'merge-test') + end + + it 'handles blanks around param' do + _, updates = service.execute('/target_branch merge-test ', merge_request) + + expect(updates).to eq(target_branch: 'merge-test') + end + + context 'ignores command with no argument' do + it_behaves_like 'empty command' do + let(:content) { '/target_branch' } + let(:issuable) { another_merge_request } + end + end + + context 'ignores non-existing target branch' do + it_behaves_like 'empty command' do + let(:content) { '/target_branch totally_non_existing_branch' } + let(:issuable) { another_merge_request } + end + end + end + end +end -- cgit v1.2.1 From 6297446d1773c95d86ecd31f591e1829b431f378 Mon Sep 17 00:00:00 2001 From: Oswaldo Ferreira Date: Thu, 1 Mar 2018 11:32:39 -0300 Subject: Move wip handling to MergeRequest::BaseService --- app/services/issuable_base_service.rb | 21 +- app/services/merge_requests/base_service.rb | 11 + app/services/merge_requests/create_service.rb | 6 + app/services/quick_actions/interpret_service.rb | 6 +- app/services/slash_commands/interpret_service.rb | 336 ---------- .../slash_commands/interpret_service_spec.rb | 689 --------------------- 6 files changed, 25 insertions(+), 1044 deletions(-) delete mode 100644 app/services/slash_commands/interpret_service.rb delete mode 100644 spec/services/slash_commands/interpret_service_spec.rb diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index c552bf6ea41..5044a3651cf 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -109,6 +109,10 @@ class IssuableBaseService < BaseService @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute end + def handle_quick_actions(issuable) + merge_quick_actions_into_params!(issuable) + end + def merge_quick_actions_into_params!(issuable) original_description = params.fetch(:description, issuable.description) @@ -131,8 +135,7 @@ class IssuableBaseService < BaseService end def create(issuable) - merge_quick_actions_into_params!(issuable) - handle_wip_event(issuable) + handle_quick_actions(issuable) filter_params(issuable) params.delete(:state_event) @@ -312,18 +315,4 @@ class IssuableBaseService < BaseService def parent project end - - def handle_wip_event(issuable) - if wip_event = params.delete(:wip_event) - case issuable - when MergeRequest - # We update the title that is provided in the params or we use the mr title - title = params[:title] || issuable.title - params[:title] = case wip_event - when :wip then MergeRequest.wip_title(title) - when :unwip then MergeRequest.wipless_title(title) - end - end - end - end end diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 20a2b50d3de..23262b62615 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -24,6 +24,17 @@ module MergeRequests private + def handle_wip_event(merge_request) + if wip_event = params.delete(:wip_event) + # We update the title that is provided in the params or we use the mr title + title = params[:title] || merge_request.title + params[:title] = case wip_event + when 'wip' then MergeRequest.wip_title(title) + when 'unwip' then MergeRequest.wipless_title(title) + end + end + end + def merge_request_metrics_service(merge_request) MergeRequestMetricsService.new(merge_request.metrics) end diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index a18b1c90765..0ed7ee6c57a 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -34,6 +34,12 @@ module MergeRequests super end + # Override from IssuableBaseService + def handle_quick_actions(merge_request) + super + handle_wip_event(merge_request) + end + private def update_merge_requests_head_pipeline(merge_request) diff --git a/app/services/quick_actions/interpret_service.rb b/app/services/quick_actions/interpret_service.rb index 1e9bd84e749..cba49faac31 100644 --- a/app/services/quick_actions/interpret_service.rb +++ b/app/services/quick_actions/interpret_service.rb @@ -347,9 +347,9 @@ module QuickActions "#{verb} this #{noun} as Work In Progress." end condition do - issuable.persisted? && - issuable.respond_to?(:work_in_progress?) && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) + issuable.respond_to?(:work_in_progress?) && + # Allow it to mark as WIP on MR creation page _or_ through MR notes. + (issuable.new_record? || current_user.can?(:"update_#{issuable.to_ability_name}", issuable)) end command :wip do @updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip' diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb deleted file mode 100644 index 0beb173a13d..00000000000 --- a/app/services/slash_commands/interpret_service.rb +++ /dev/null @@ -1,336 +0,0 @@ -module SlashCommands - class InterpretService < BaseService - include Gitlab::SlashCommands::Dsl - - attr_reader :issuable, :options - - # Takes a text and interprets the commands that are extracted from it. - # Returns the content without commands, and hash of changes to be applied to a record. - def execute(content, issuable) - @issuable = issuable - @updates = {} - - opts = { - issuable: issuable, - current_user: current_user, - project: project, - params: params - } - - content, commands = extractor.extract_commands(content, opts) - - commands.each do |name, arg| - definition = self.class.command_definitions_by_name[name.to_sym] - next unless definition - - definition.execute(self, opts, arg) - end - - [content, @updates] - end - - private - - def extractor - Gitlab::SlashCommands::Extractor.new(self.class.command_definitions) - end - - desc do - "Close this #{issuable.to_ability_name.humanize(capitalize: false)}" - end - condition do - issuable.persisted? && - issuable.open? && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) - end - command :close do - @updates[:state_event] = 'close' - end - - desc do - "Reopen this #{issuable.to_ability_name.humanize(capitalize: false)}" - end - condition do - issuable.persisted? && - issuable.closed? && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) - end - command :reopen do - @updates[:state_event] = 'reopen' - end - - desc 'Merge (when build succeeds)' - condition do - last_diff_sha = params && params[:merge_request_diff_head_sha] - issuable.is_a?(MergeRequest) && - issuable.persisted? && - issuable.mergeable_with_slash_command?(current_user, autocomplete_precheck: !last_diff_sha, last_diff_sha: last_diff_sha) - end - command :merge do - @updates[:merge] = params[:merge_request_diff_head_sha] - end - - desc 'Change title' - params '' - condition do - issuable.persisted? && - current_user.can?(:"update_#{issuable.to_ability_name}", issuable) - end - command :title do |title_param| - @updates[:title] = title_param - end - - desc 'Assign' - params '@user' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :assign do |assignee_param| - user = extract_references(assignee_param, :user).first - user ||= User.find_by(username: assignee_param) - - @updates[:assignee_id] = user.id if user - end - - desc 'Remove assignee' - condition do - issuable.persisted? && - issuable.assignee_id? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :unassign do - @updates[:assignee_id] = nil - end - - desc 'Set milestone' - params '%"milestone"' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", project) && - project.milestones.active.any? - end - command :milestone do |milestone_param| - milestone = extract_references(milestone_param, :milestone).first - milestone ||= project.milestones.find_by(title: milestone_param.strip) - - @updates[:milestone_id] = milestone.id if milestone - end - - desc 'Remove milestone' - condition do - issuable.persisted? && - issuable.milestone_id? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_milestone do - @updates[:milestone_id] = nil - end - - desc 'Add label(s)' - params '~label1 ~"label 2"' - condition do - available_labels = LabelsFinder.new(current_user, project_id: project.id).execute - - current_user.can?(:"admin_#{issuable.to_ability_name}", project) && - available_labels.any? - end - command :label do |labels_param| - label_ids = find_label_ids(labels_param) - - if label_ids.any? - @updates[:add_label_ids] ||= [] - @updates[:add_label_ids] += label_ids - - @updates[:add_label_ids].uniq! - end - end - - desc 'Remove all or specific label(s)' - params '~label1 ~"label 2"' - condition do - issuable.persisted? && - issuable.labels.any? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :unlabel do |labels_param = nil| - if labels_param.present? - label_ids = find_label_ids(labels_param) - - if label_ids.any? - @updates[:remove_label_ids] ||= [] - @updates[:remove_label_ids] += label_ids - - @updates[:remove_label_ids].uniq! - end - else - @updates[:label_ids] = [] - end - end - - desc 'Replace all label(s)' - params '~label1 ~"label 2"' - condition do - issuable.persisted? && - issuable.labels.any? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :relabel do |labels_param| - label_ids = find_label_ids(labels_param) - - if label_ids.any? - @updates[:label_ids] ||= [] - @updates[:label_ids] += label_ids - - @updates[:label_ids].uniq! - end - end - - desc 'Add a todo' - condition do - issuable.persisted? && - !TodoService.new.todo_exist?(issuable, current_user) - end - command :todo do - @updates[:todo_event] = 'add' - end - - desc 'Mark todo as done' - condition do - issuable.persisted? && - TodoService.new.todo_exist?(issuable, current_user) - end - command :done do - @updates[:todo_event] = 'done' - end - - desc 'Subscribe' - condition do - issuable.persisted? && - !issuable.subscribed?(current_user, project) - end - command :subscribe do - @updates[:subscription_event] = 'subscribe' - end - - desc 'Unsubscribe' - condition do - issuable.persisted? && - issuable.subscribed?(current_user, project) - end - command :unsubscribe do - @updates[:subscription_event] = 'unsubscribe' - end - - desc 'Set due date' - params '' - condition do - issuable.respond_to?(:due_date) && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :due do |due_date_param| - due_date = Chronic.parse(due_date_param).try(:to_date) - - @updates[:due_date] = due_date if due_date - end - - desc 'Remove due date' - condition do - issuable.persisted? && - issuable.respond_to?(:due_date) && - issuable.due_date? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_due_date do - @updates[:due_date] = nil - end - - desc do - "Toggle the Work In Progress status" - end - condition do - issuable.respond_to?(:work_in_progress?) && ( - # /wip on comment text on MR page - (issuable.persisted? && current_user.can?(:"update_#{issuable.to_ability_name}", issuable)) || - # /wip on create MR page - issuable.new_record? - ) - end - command :wip do - @updates[:wip_event] = issuable.work_in_progress? ? :unwip : :wip - end - - desc 'Set time estimate' - params '<1w 3d 2h 14m>' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :estimate do |raw_duration| - time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration) - - if time_estimate - @updates[:time_estimate] = time_estimate - end - end - - desc 'Add or substract spent time' - params '<1h 30m | -1h 30m>' - condition do - current_user.can?(:"admin_#{issuable.to_ability_name}", issuable) - end - command :spend do |raw_duration| - time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration) - - if time_spent - @updates[:spend_time] = { duration: time_spent, user: current_user } - end - end - - desc 'Remove time estimate' - condition do - issuable.persisted? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_estimate do - @updates[:time_estimate] = 0 - end - - desc 'Remove spent time' - condition do - issuable.persisted? && - current_user.can?(:"admin_#{issuable.to_ability_name}", project) - end - command :remove_time_spent do - @updates[:spend_time] = { duration: :reset, user: current_user } - end - - # This is a dummy command, so that it appears in the autocomplete commands - desc 'CC' - params '@user' - command :cc - - desc 'Defines target branch for MR' - params '' - condition do - issuable.respond_to?(:target_branch) && - (current_user.can?(:"update_#{issuable.to_ability_name}", issuable) || - issuable.new_record?) - end - command :target_branch do |target_branch_param| - branch_name = target_branch_param.strip - @updates[:target_branch] = branch_name if project.repository.branch_names.include?(branch_name) - end - - def find_label_ids(labels_param) - label_ids_by_reference = extract_references(labels_param, :label).map(&:id) - labels_ids_by_name = LabelsFinder.new(current_user, project_id: project.id, name: labels_param.split).execute.select(:id) - - label_ids_by_reference | labels_ids_by_name - end - - def extract_references(arg, type) - ext = Gitlab::ReferenceExtractor.new(project, current_user) - ext.analyze(arg, author: current_user) - - ext.references(type) - end - end -end diff --git a/spec/services/slash_commands/interpret_service_spec.rb b/spec/services/slash_commands/interpret_service_spec.rb deleted file mode 100644 index 7b247397b15..00000000000 --- a/spec/services/slash_commands/interpret_service_spec.rb +++ /dev/null @@ -1,689 +0,0 @@ -require 'spec_helper' - -describe SlashCommands::InterpretService, services: true do - let(:project) { create(:project, :public) } - let(:developer) { create(:user) } - let(:issue) { create(:issue, project: project) } - let(:milestone) { create(:milestone, project: project, title: '9.10') } - let(:inprogress) { create(:label, project: project, title: 'In Progress') } - let(:bug) { create(:label, project: project, title: 'Bug') } - let(:note) { build(:note, commit_id: merge_request.diff_head_sha) } - - before do - project.team << [developer, :developer] - end - - describe '#execute' do - let(:service) { described_class.new(project, developer) } - let(:merge_request) { create(:merge_request, source_project: project) } - - shared_examples 'reopen command' do - it 'returns state_event: "reopen" if content contains /reopen' do - issuable.close! - _, updates = service.execute(content, issuable) - - expect(updates).to eq(state_event: 'reopen') - end - end - - shared_examples 'close command' do - it 'returns state_event: "close" if content contains /close' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(state_event: 'close') - end - end - - shared_examples 'title command' do - it 'populates title: "A brand new title" if content contains /title A brand new title' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(title: 'A brand new title') - end - end - - shared_examples 'assign command' do - it 'fetches assignee and populates assignee_id if content contains /assign' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: developer.id) - end - end - - shared_examples 'unassign command' do - it 'populates assignee_id: nil if content contains /unassign' do - issuable.update(assignee_id: developer.id) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(assignee_id: nil) - end - end - - shared_examples 'milestone command' do - it 'fetches milestone and populates milestone_id if content contains /milestone' do - milestone # populate the milestone - _, updates = service.execute(content, issuable) - - expect(updates).to eq(milestone_id: milestone.id) - end - end - - shared_examples 'remove_milestone command' do - it 'populates milestone_id: nil if content contains /remove_milestone' do - issuable.update(milestone_id: milestone.id) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(milestone_id: nil) - end - end - - shared_examples 'label command' do - it 'fetches label ids and populates add_label_ids if content contains /label' do - bug # populate the label - inprogress # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(add_label_ids: [bug.id, inprogress.id]) - end - end - - shared_examples 'multiple label command' do - it 'fetches label ids and populates add_label_ids if content contains multiple /label' do - bug # populate the label - inprogress # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(add_label_ids: [inprogress.id, bug.id]) - end - end - - shared_examples 'multiple label with same argument' do - it 'prevents duplicate label ids and populates add_label_ids if content contains multiple /label' do - inprogress # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(add_label_ids: [inprogress.id]) - end - end - - shared_examples 'unlabel command' do - it 'fetches label ids and populates remove_label_ids if content contains /unlabel' do - issuable.update(label_ids: [inprogress.id]) # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(remove_label_ids: [inprogress.id]) - end - end - - shared_examples 'multiple unlabel command' do - it 'fetches label ids and populates remove_label_ids if content contains mutiple /unlabel' do - issuable.update(label_ids: [inprogress.id, bug.id]) # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(remove_label_ids: [inprogress.id, bug.id]) - end - end - - shared_examples 'unlabel command with no argument' do - it 'populates label_ids: [] if content contains /unlabel with no arguments' do - issuable.update(label_ids: [inprogress.id]) # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(label_ids: []) - end - end - - shared_examples 'relabel command' do - it 'populates label_ids: [] if content contains /relabel' do - issuable.update(label_ids: [bug.id]) # populate the label - inprogress # populate the label - _, updates = service.execute(content, issuable) - - expect(updates).to eq(label_ids: [inprogress.id]) - end - end - - shared_examples 'todo command' do - it 'populates todo_event: "add" if content contains /todo' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(todo_event: 'add') - end - end - - shared_examples 'done command' do - it 'populates todo_event: "done" if content contains /done' do - TodoService.new.mark_todo(issuable, developer) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(todo_event: 'done') - end - end - - shared_examples 'subscribe command' do - it 'populates subscription_event: "subscribe" if content contains /subscribe' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(subscription_event: 'subscribe') - end - end - - shared_examples 'unsubscribe command' do - it 'populates subscription_event: "unsubscribe" if content contains /unsubscribe' do - issuable.subscribe(developer, project) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(subscription_event: 'unsubscribe') - end - end - - shared_examples 'due command' do - it 'populates due_date: Date.new(2016, 8, 28) if content contains /due 2016-08-28' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(due_date: defined?(expected_date) ? expected_date : Date.new(2016, 8, 28)) - end - end - - shared_examples 'remove_due_date command' do - it 'populates due_date: nil if content contains /remove_due_date' do - issuable.update(due_date: Date.today) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(due_date: nil) - end - end - - shared_examples 'wip command' do - it 'returns wip_event: "wip" if content contains /wip' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(wip_event: :wip) - end - end - - shared_examples 'unwip command' do - it 'returns wip_event: "unwip" if content contains /wip' do - issuable.update(title: issuable.wip_title) - _, updates = service.execute(content, issuable) - - expect(updates).to eq(wip_event: :unwip) - end - end - - shared_examples 'estimate command' do - it 'populates time_estimate: 3600 if content contains /estimate 1h' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(time_estimate: 3600) - end - end - - shared_examples 'spend command' do - it 'populates spend_time: 3600 if content contains /spend 1h' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(spend_time: { duration: 3600, user: developer }) - end - end - - shared_examples 'spend command with negative time' do - it 'populates spend_time: -1800 if content contains /spend -30m' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(spend_time: { duration: -1800, user: developer }) - end - end - - shared_examples 'remove_estimate command' do - it 'populates time_estimate: 0 if content contains /remove_estimate' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(time_estimate: 0) - end - end - - shared_examples 'remove_time_spent command' do - it 'populates spend_time: :reset if content contains /remove_time_spent' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(spend_time: { duration: :reset, user: developer }) - end - end - - shared_examples 'empty command' do - it 'populates {} if content contains an unsupported command' do - _, updates = service.execute(content, issuable) - - expect(updates).to be_empty - end - end - - shared_examples 'merge command' do - it 'runs merge command if content contains /merge' do - _, updates = service.execute(content, issuable) - - expect(updates).to eq(merge: merge_request.diff_head_sha) - end - end - - it_behaves_like 'reopen command' do - let(:content) { '/reopen' } - let(:issuable) { issue } - end - - it_behaves_like 'reopen command' do - let(:content) { '/reopen' } - let(:issuable) { merge_request } - end - - it_behaves_like 'close command' do - let(:content) { '/close' } - let(:issuable) { issue } - end - - it_behaves_like 'close command' do - let(:content) { '/close' } - let(:issuable) { merge_request } - end - - context 'merge command' do - let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: merge_request.diff_head_sha }) } - - it_behaves_like 'merge command' do - let(:content) { '/merge' } - let(:issuable) { merge_request } - end - - context 'can not be merged when logged user does not have permissions' do - let(:service) { described_class.new(project, create(:user)) } - - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { merge_request } - end - end - - context 'can not be merged when sha does not match' do - let(:service) { described_class.new(project, developer, { merge_request_diff_head_sha: 'othersha' }) } - - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { merge_request } - end - end - - context 'when sha is missing' do - let(:service) { described_class.new(project, developer, {}) } - - it 'precheck passes and returns merge command' do - _, updates = service.execute('/merge', merge_request) - - expect(updates).to eq(merge: nil) - end - end - - context 'issue can not be merged' do - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { issue } - end - end - - context 'non persisted merge request cant be merged' do - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { build(:merge_request) } - end - end - - context 'not persisted merge request can not be merged' do - it_behaves_like 'empty command' do - let(:content) { "/merge" } - let(:issuable) { build(:merge_request, source_project: project) } - end - end - end - - it_behaves_like 'title command' do - let(:content) { '/title A brand new title' } - let(:issuable) { issue } - end - - it_behaves_like 'title command' do - let(:content) { '/title A brand new title' } - let(:issuable) { merge_request } - end - - it_behaves_like 'empty command' do - let(:content) { '/title' } - let(:issuable) { issue } - end - - it_behaves_like 'assign command' do - let(:content) { "/assign @#{developer.username}" } - let(:issuable) { issue } - end - - it_behaves_like 'assign command' do - let(:content) { "/assign @#{developer.username}" } - let(:issuable) { merge_request } - end - - it_behaves_like 'empty command' do - let(:content) { '/assign @abcd1234' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/assign' } - let(:issuable) { issue } - end - - it_behaves_like 'unassign command' do - let(:content) { '/unassign' } - let(:issuable) { issue } - end - - it_behaves_like 'unassign command' do - let(:content) { '/unassign' } - let(:issuable) { merge_request } - end - - it_behaves_like 'milestone command' do - let(:content) { "/milestone %#{milestone.title}" } - let(:issuable) { issue } - end - - it_behaves_like 'milestone command' do - let(:content) { "/milestone %#{milestone.title}" } - let(:issuable) { merge_request } - end - - it_behaves_like 'remove_milestone command' do - let(:content) { '/remove_milestone' } - let(:issuable) { issue } - end - - it_behaves_like 'remove_milestone command' do - let(:content) { '/remove_milestone' } - let(:issuable) { merge_request } - end - - it_behaves_like 'label command' do - let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } - let(:issuable) { issue } - end - - it_behaves_like 'label command' do - let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } - let(:issuable) { merge_request } - end - - it_behaves_like 'multiple label command' do - let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{bug.title}) } - let(:issuable) { issue } - end - - it_behaves_like 'multiple label with same argument' do - let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) } - let(:issuable) { issue } - end - - it_behaves_like 'unlabel command' do - let(:content) { %(/unlabel ~"#{inprogress.title}") } - let(:issuable) { issue } - end - - it_behaves_like 'unlabel command' do - let(:content) { %(/unlabel ~"#{inprogress.title}") } - let(:issuable) { merge_request } - end - - it_behaves_like 'multiple unlabel command' do - let(:content) { %(/unlabel ~"#{inprogress.title}" \n/unlabel ~#{bug.title}) } - let(:issuable) { issue } - end - - it_behaves_like 'unlabel command with no argument' do - let(:content) { %(/unlabel) } - let(:issuable) { issue } - end - - it_behaves_like 'unlabel command with no argument' do - let(:content) { %(/unlabel) } - let(:issuable) { merge_request } - end - - it_behaves_like 'relabel command' do - let(:content) { %(/relabel ~"#{inprogress.title}") } - let(:issuable) { issue } - end - - it_behaves_like 'relabel command' do - let(:content) { %(/relabel ~"#{inprogress.title}") } - let(:issuable) { merge_request } - end - - it_behaves_like 'todo command' do - let(:content) { '/todo' } - let(:issuable) { issue } - end - - it_behaves_like 'todo command' do - let(:content) { '/todo' } - let(:issuable) { merge_request } - end - - it_behaves_like 'done command' do - let(:content) { '/done' } - let(:issuable) { issue } - end - - it_behaves_like 'done command' do - let(:content) { '/done' } - let(:issuable) { merge_request } - end - - it_behaves_like 'subscribe command' do - let(:content) { '/subscribe' } - let(:issuable) { issue } - end - - it_behaves_like 'subscribe command' do - let(:content) { '/subscribe' } - let(:issuable) { merge_request } - end - - it_behaves_like 'unsubscribe command' do - let(:content) { '/unsubscribe' } - let(:issuable) { issue } - end - - it_behaves_like 'unsubscribe command' do - let(:content) { '/unsubscribe' } - let(:issuable) { merge_request } - end - - it_behaves_like 'due command' do - let(:content) { '/due 2016-08-28' } - let(:issuable) { issue } - end - - it_behaves_like 'due command' do - let(:content) { '/due tomorrow' } - let(:issuable) { issue } - let(:expected_date) { Date.tomorrow } - end - - it_behaves_like 'due command' do - let(:content) { '/due 5 days from now' } - let(:issuable) { issue } - let(:expected_date) { 5.days.from_now.to_date } - end - - it_behaves_like 'due command' do - let(:content) { '/due in 2 days' } - let(:issuable) { issue } - let(:expected_date) { 2.days.from_now.to_date } - end - - it_behaves_like 'empty command' do - let(:content) { '/due foo bar' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/due 2016-08-28' } - let(:issuable) { merge_request } - end - - it_behaves_like 'remove_due_date command' do - let(:content) { '/remove_due_date' } - let(:issuable) { issue } - end - - it_behaves_like 'wip command' do - let(:content) { '/wip' } - let(:issuable) { merge_request } - end - - it_behaves_like 'unwip command' do - let(:content) { '/wip' } - let(:issuable) { merge_request } - end - - it_behaves_like 'empty command' do - let(:content) { '/remove_due_date' } - let(:issuable) { merge_request } - end - - it_behaves_like 'estimate command' do - let(:content) { '/estimate 1h' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/estimate' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/estimate abc' } - let(:issuable) { issue } - end - - it_behaves_like 'spend command' do - let(:content) { '/spend 1h' } - let(:issuable) { issue } - end - - it_behaves_like 'spend command with negative time' do - let(:content) { '/spend -30m' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/spend' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/spend abc' } - let(:issuable) { issue } - end - - it_behaves_like 'remove_estimate command' do - let(:content) { '/remove_estimate' } - let(:issuable) { issue } - end - - it_behaves_like 'remove_time_spent command' do - let(:content) { '/remove_time_spent' } - let(:issuable) { issue } - end - - context 'when current_user cannot :admin_issue' do - let(:visitor) { create(:user) } - let(:issue) { create(:issue, project: project, author: visitor) } - let(:service) { described_class.new(project, visitor) } - - it_behaves_like 'empty command' do - let(:content) { "/assign @#{developer.username}" } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/unassign' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { "/milestone %#{milestone.title}" } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/remove_milestone' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { %(/label ~"#{inprogress.title}" ~#{bug.title} ~unknown) } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { %(/unlabel ~"#{inprogress.title}") } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { %(/relabel ~"#{inprogress.title}") } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/due tomorrow' } - let(:issuable) { issue } - end - - it_behaves_like 'empty command' do - let(:content) { '/remove_due_date' } - let(:issuable) { issue } - end - end - - context '/target_branch command' do - let(:non_empty_project) { create(:project) } - let(:another_merge_request) { create(:merge_request, author: developer, source_project: non_empty_project) } - let(:service) { described_class.new(non_empty_project, developer)} - - it 'updates target_branch if /target_branch command is executed' do - _, updates = service.execute('/target_branch merge-test', merge_request) - - expect(updates).to eq(target_branch: 'merge-test') - end - - it 'handles blanks around param' do - _, updates = service.execute('/target_branch merge-test ', merge_request) - - expect(updates).to eq(target_branch: 'merge-test') - end - - context 'ignores command with no argument' do - it_behaves_like 'empty command' do - let(:content) { '/target_branch' } - let(:issuable) { another_merge_request } - end - end - - context 'ignores non-existing target branch' do - it_behaves_like 'empty command' do - let(:content) { '/target_branch totally_non_existing_branch' } - let(:issuable) { another_merge_request } - end - end - end - end -end -- cgit v1.2.1 From 30ef73afac3d57af87ee948bb7c90743f49e6da8 Mon Sep 17 00:00:00 2001 From: julien MILLAU Date: Fri, 2 Mar 2018 10:10:39 +0000 Subject: Remove impersonate token from flash scope --- app/controllers/admin/impersonation_tokens_controller.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/app/controllers/admin/impersonation_tokens_controller.rb b/app/controllers/admin/impersonation_tokens_controller.rb index 7a2c7234a1e..a7b562b1d8e 100644 --- a/app/controllers/admin/impersonation_tokens_controller.rb +++ b/app/controllers/admin/impersonation_tokens_controller.rb @@ -9,7 +9,6 @@ class Admin::ImpersonationTokensController < Admin::ApplicationController @impersonation_token = finder.build(impersonation_token_params) if @impersonation_token.save - flash[:impersonation_token] = @impersonation_token.token redirect_to admin_user_impersonation_tokens_path, notice: "A new impersonation token has been created." else set_index_vars -- cgit v1.2.1 From 960f981db7e5f3a1bd9ee2ad8682435e1bc71a46 Mon Sep 17 00:00:00 2001 From: Andrew Newdigate Date: Fri, 2 Mar 2018 10:25:11 +0000 Subject: Upgrade GitLab Workhorse to 3.8.0 for structured logging support --- GITLAB_WORKHORSE_VERSION | 2 +- changelogs/unreleased/an-workhorse-3-8-0.yml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/an-workhorse-3-8-0.yml diff --git a/GITLAB_WORKHORSE_VERSION b/GITLAB_WORKHORSE_VERSION index 40c341bdcdb..19811903a7f 100644 --- a/GITLAB_WORKHORSE_VERSION +++ b/GITLAB_WORKHORSE_VERSION @@ -1 +1 @@ -3.6.0 +3.8.0 diff --git a/changelogs/unreleased/an-workhorse-3-8-0.yml b/changelogs/unreleased/an-workhorse-3-8-0.yml new file mode 100644 index 00000000000..5e2a72e1eda --- /dev/null +++ b/changelogs/unreleased/an-workhorse-3-8-0.yml @@ -0,0 +1,5 @@ +--- +title: Upgrade Workhorse to version 3.8.0 to support structured logging +merge_request: +author: +type: other -- cgit v1.2.1 From cb55bc3c0770adf7122d7cf49b12cb45c43de7ec Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Thu, 22 Feb 2018 12:09:27 +0000 Subject: Match Rinku's behaviour for closing punctuation in links Rinku 2.0.0 (the version we use) will remove the last character of a link if it's a closing part of a punctuation pair (different types of parentheses and quotes), unless both of the below are true: 1. The matching pair has different start and end characters. 2. There are equal numbers of both in the matched string (they don't have to be balanced). --- lib/banzai/filter/autolink_filter.rb | 50 ++++++++++++------ spec/lib/banzai/filter/autolink_filter_spec.rb | 73 ++++++++++++++++++++------ 2 files changed, 92 insertions(+), 31 deletions(-) diff --git a/lib/banzai/filter/autolink_filter.rb b/lib/banzai/filter/autolink_filter.rb index c4990637971..75b64ae9af2 100644 --- a/lib/banzai/filter/autolink_filter.rb +++ b/lib/banzai/filter/autolink_filter.rb @@ -25,7 +25,7 @@ module Banzai # period or comma for punctuation without those characters being included # in the generated link. # - # Rubular: http://rubular.com/r/cxjPyZc7Sb + # Rubular: http://rubular.com/r/JzPhi6DCZp LINK_PATTERN = %r{([a-z][a-z0-9\+\.-]+://[^\s>]+)(? "'", + '"' => '"', + ')' => '(', + ']' => '[', + '}' => '{' + }.freeze + def call return doc if context[:autolink] == false - text_parse - end - - private - - # Return true if any of the UNSAFE_PROTOCOLS strings are included in the URI scheme - def contains_unsafe?(scheme) - return false unless scheme - - scheme = scheme.strip.downcase - Banzai::Filter::SanitizationFilter::UNSAFE_PROTOCOLS.any? { |protocol| scheme.include?(protocol) } - end - - def text_parse doc.xpath(TEXT_QUERY).each do |node| content = node.to_html @@ -69,6 +63,16 @@ module Banzai doc end + private + + # Return true if any of the UNSAFE_PROTOCOLS strings are included in the URI scheme + def contains_unsafe?(scheme) + return false unless scheme + + scheme = scheme.strip.downcase + Banzai::Filter::SanitizationFilter::UNSAFE_PROTOCOLS.any? { |protocol| scheme.include?(protocol) } + end + def autolink_match(match) # start by stripping out dangerous links begin @@ -84,6 +88,22 @@ module Banzai match.gsub!(/((?:&[\w#]+;)+)\z/, '') dropped = ($1 || '').html_safe + # To match the behaviour of Rinku, if the matched link ends with a + # closing part of a matched pair of punctuation, we remove that trailing + # character unless there are an equal number of closing and opening + # characters in the link. + if match.end_with?(*PUNCTUATION_PAIRS.keys) + close_character = match[-1] + close_count = match.count(close_character) + open_character = PUNCTUATION_PAIRS[close_character] + open_count = match.count(open_character) + + if open_count != close_count || open_character == close_character + dropped += close_character + match = match[0..-2] + end + end + options = link_options.merge(href: match) content_tag(:a, match.html_safe, options) + dropped end diff --git a/spec/lib/banzai/filter/autolink_filter_spec.rb b/spec/lib/banzai/filter/autolink_filter_spec.rb index 0498b99ccf3..b502daea418 100644 --- a/spec/lib/banzai/filter/autolink_filter_spec.rb +++ b/spec/lib/banzai/filter/autolink_filter_spec.rb @@ -4,6 +4,7 @@ describe Banzai::Filter::AutolinkFilter do include FilterSpecHelper let(:link) { 'http://about.gitlab.com/' } + let(:quotes) { ['"', "'"] } it 'does nothing when :autolink is false' do exp = act = link @@ -15,16 +16,6 @@ describe Banzai::Filter::AutolinkFilter do expect(filter(act).to_html).to eq exp end - context 'when the input contains no links' do - it 'does not parse_html back the rinku returned value' do - act = HTML::Pipeline.parse('

This text contains no links to autolink

') - - expect_any_instance_of(described_class).not_to receive(:parse_html) - - filter(act).to_html - end - end - context 'Various schemes' do it 'autolinks http' do doc = filter("See #{link}") @@ -141,6 +132,45 @@ describe Banzai::Filter::AutolinkFilter do expect(doc.at_css('a').text).to eq link end + it 'includes trailing punctuation when part of a balanced pair' do + described_class::PUNCTUATION_PAIRS.each do |close, open| + next if open.in?(quotes) + + balanced_link = "#{link}#{open}abc#{close}" + balanced_actual = filter("See #{balanced_link}...") + unbalanced_link = "#{link}#{close}" + unbalanced_actual = filter("See #{unbalanced_link}...") + + expect(balanced_actual.at_css('a').text).to eq(balanced_link) + expect(unescape(balanced_actual.to_html)).to eq(Rinku.auto_link("See #{balanced_link}...")) + expect(unbalanced_actual.at_css('a').text).to eq(link) + expect(unescape(unbalanced_actual.to_html)).to eq(Rinku.auto_link("See #{unbalanced_link}...")) + end + end + + it 'removes trailing quotes' do + quotes.each do |quote| + balanced_link = "#{link}#{quote}abc#{quote}" + balanced_actual = filter("See #{balanced_link}...") + unbalanced_link = "#{link}#{quote}" + unbalanced_actual = filter("See #{unbalanced_link}...") + + expect(balanced_actual.at_css('a').text).to eq(balanced_link[0...-1]) + expect(unescape(balanced_actual.to_html)).to eq(Rinku.auto_link("See #{balanced_link}...")) + expect(unbalanced_actual.at_css('a').text).to eq(link) + expect(unescape(unbalanced_actual.to_html)).to eq(Rinku.auto_link("See #{unbalanced_link}...")) + end + end + + it 'removes one closing punctuation mark when the punctuation in the link is unbalanced' do + complicated_link = "(#{link}(a'b[c'd]))'" + expected_complicated_link = %Q{(#{link}(a'b[c'd]))'} + actual = unescape(filter(complicated_link).to_html) + + expect(actual).to eq(Rinku.auto_link(complicated_link)) + expect(actual).to eq(expected_complicated_link) + end + it 'does not include trailing HTML entities' do doc = filter("See <<<#{link}>>>") @@ -162,16 +192,27 @@ describe Banzai::Filter::AutolinkFilter do end context 'when the link is inside a tag' do - it 'renders text after the link correctly for http' do - doc = filter(ERB::Util.html_escape_once("")) + %w[http rdar].each do |protocol| + it "renders text after the link correctly for #{protocol}" do + doc = filter(ERB::Util.html_escape_once("<#{protocol}://link>")) - expect(doc.children.last.text).to include('') + expect(doc.children.last.text).to include('') + end end + end - it 'renders text after the link correctly for not other protocol' do - doc = filter(ERB::Util.html_escape_once("")) + # Rinku does not escape these characters in HTML attributes, but content_tag + # does. We don't care about that difference for these specs, though. + def unescape(html) + %w([ ] { }).each do |cgi_escape| + html.sub!(CGI.escape(cgi_escape), cgi_escape) + end - expect(doc.children.last.text).to include('') + quotes.each do |html_escape| + html.sub!(CGI.escape_html(html_escape), html_escape) + html.sub!(CGI.escape(html_escape), CGI.escape_html(html_escape)) end + + html end end -- cgit v1.2.1 From f09fe848da02189a3dafe2d532fefb1379eacac4 Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Thu, 1 Mar 2018 17:16:39 +0100 Subject: Don't use ProjectsFinder in TodosFinder Using ProjectsFinder in TodosFinder to limit todos to the right projects leads to overly complicated and slow database queries. This commit removes the use of ProjectsFinder in favour of using Project.public_or_visible_to_current_user directly. Fixes https://gitlab.com/gitlab-org/gitlab-ce/issues/43767 --- app/finders/todos_finder.rb | 15 ++++++--------- .../remove-projects-finder-from-todos-finder.yml | 5 +++++ 2 files changed, 11 insertions(+), 9 deletions(-) create mode 100644 changelogs/unreleased/remove-projects-finder-from-todos-finder.yml diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index edb17843002..47c8b9b60ed 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -110,10 +110,6 @@ class TodosFinder ids end - def projects(items) - ProjectsFinder.new(current_user: current_user, project_ids_relation: project_ids(items)).execute - end - def type? type.present? && %w(Issue MergeRequest).include?(type) end @@ -152,13 +148,14 @@ class TodosFinder def by_project(items) if project? - items = items.where(project: project) + items.where(project: project) else - item_projects = projects(items) - items = items.merge(item_projects).joins(:project) - end + projects = Project + .public_or_visible_to_user(current_user) + .order_id_desc - items + items.joins(:project).merge(projects) + end end def by_state(items) diff --git a/changelogs/unreleased/remove-projects-finder-from-todos-finder.yml b/changelogs/unreleased/remove-projects-finder-from-todos-finder.yml new file mode 100644 index 00000000000..0a3fc751edb --- /dev/null +++ b/changelogs/unreleased/remove-projects-finder-from-todos-finder.yml @@ -0,0 +1,5 @@ +--- +title: Don't use ProjectsFinder in TodosFinder +merge_request: +author: +type: performance -- cgit v1.2.1 From cd770946c420b92535bd6000d0b8ea2fc89cbd0a Mon Sep 17 00:00:00 2001 From: Ahmad Sherif Date: Thu, 1 Mar 2018 17:50:07 +0100 Subject: Add support for :all option to {count,find}_commits --- GITALY_SERVER_VERSION | 2 +- Gemfile | 2 +- Gemfile.lock | 4 +- lib/gitlab/git/repository.rb | 6 +- lib/gitlab/gitaly_client/commit_service.rb | 4 +- spec/lib/gitlab/git/repository_spec.rb | 360 +++++++++++++++-------------- 6 files changed, 193 insertions(+), 185 deletions(-) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 359ee08a7ce..fe6d01c1a45 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.87.0 +0.88.0 diff --git a/Gemfile b/Gemfile index d8cb5267d81..a3352b8923c 100644 --- a/Gemfile +++ b/Gemfile @@ -411,7 +411,7 @@ group :ed25519 do end # Gitaly GRPC client -gem 'gitaly-proto', '~> 0.87.0', require: 'gitaly' +gem 'gitaly-proto', '~> 0.88.0', require: 'gitaly' # Locked until https://github.com/google/protobuf/issues/4210 is closed gem 'google-protobuf', '= 3.5.1' diff --git a/Gemfile.lock b/Gemfile.lock index 6918f92aa84..70f86a45043 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -285,7 +285,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly-proto (0.87.0) + gitaly-proto (0.88.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (5.3.3) @@ -1057,7 +1057,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly-proto (~> 0.87.0) + gitaly-proto (~> 0.88.0) github-linguist (~> 5.3.3) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.6.2) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index d7c373ccd6f..21c79a7a550 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -479,9 +479,8 @@ module Gitlab raise ArgumentError.new("invalid Repository#log limit: #{limit.inspect}") end - # TODO support options[:all] in Gitaly https://gitlab.com/gitlab-org/gitaly/issues/1049 gitaly_migrate(:find_commits) do |is_enabled| - if is_enabled && !options[:all] + if is_enabled gitaly_commit_client.find_commits(options) else raw_log(options).map { |c| Commit.decorate(self, c) } @@ -508,9 +507,8 @@ module Gitlab def count_commits(options) count_commits_options = process_count_commits_options(options) - # TODO add support for options[:all] in Gitaly https://gitlab.com/gitlab-org/gitaly/issues/1050 gitaly_migrate(:count_commits) do |is_enabled| - if is_enabled && !options[:all] + if is_enabled count_commits_by_gitaly(count_commits_options) else count_commits_by_shelling_out(count_commits_options) diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index 1ad0bf1d060..456a8a1a2d6 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -134,7 +134,8 @@ module Gitlab def commit_count(ref, options = {}) request = Gitaly::CountCommitsRequest.new( repository: @gitaly_repo, - revision: encode_binary(ref) + revision: encode_binary(ref), + all: !!options[:all] ) request.after = Google::Protobuf::Timestamp.new(seconds: options[:after].to_i) if options[:after].present? request.before = Google::Protobuf::Timestamp.new(seconds: options[:before].to_i) if options[:before].present? @@ -269,6 +270,7 @@ module Gitlab offset: options[:offset], follow: options[:follow], skip_merges: options[:skip_merges], + all: !!options[:all], disable_walk: true # This option is deprecated. The 'walk' implementation is being removed. ) request.after = GitalyClient.timestamp(options[:after]) if options[:after] diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 25defb98b7c..52c9876cbb6 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -751,255 +751,263 @@ describe Gitlab::Git::Repository, seed_helper: true do end describe "#log" do - let(:commit_with_old_name) do - Gitlab::Git::Commit.decorate(repository, @commit_with_old_name_id) - end - let(:commit_with_new_name) do - Gitlab::Git::Commit.decorate(repository, @commit_with_new_name_id) - end - let(:rename_commit) do - Gitlab::Git::Commit.decorate(repository, @rename_commit_id) - end - - before(:context) do - # Add new commits so that there's a renamed file in the commit history - repo = Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '').rugged - @commit_with_old_name_id = new_commit_edit_old_file(repo) - @rename_commit_id = new_commit_move_file(repo) - @commit_with_new_name_id = new_commit_edit_new_file(repo) - end - - after(:context) do - # Erase our commits so other tests get the original repo - repo = Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '').rugged - repo.references.update("refs/heads/master", SeedRepo::LastCommit::ID) - end - - context "where 'follow' == true" do - let(:options) { { ref: "master", follow: true } } + shared_examples 'repository log' do + let(:commit_with_old_name) do + Gitlab::Git::Commit.decorate(repository, @commit_with_old_name_id) + end + let(:commit_with_new_name) do + Gitlab::Git::Commit.decorate(repository, @commit_with_new_name_id) + end + let(:rename_commit) do + Gitlab::Git::Commit.decorate(repository, @rename_commit_id) + end - context "and 'path' is a directory" do - it "does not follow renames" do - log_commits = repository.log(options.merge(path: "encoding")) + before(:context) do + # Add new commits so that there's a renamed file in the commit history + repo = Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '').rugged + @commit_with_old_name_id = new_commit_edit_old_file(repo) + @rename_commit_id = new_commit_move_file(repo) + @commit_with_new_name_id = new_commit_edit_new_file(repo) + end - aggregate_failures do - expect(log_commits).to include(commit_with_new_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).not_to include(commit_with_old_name) - end - end + after(:context) do + # Erase our commits so other tests get the original repo + repo = Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '').rugged + repo.references.update("refs/heads/master", SeedRepo::LastCommit::ID) end - context "and 'path' is a file that matches the new filename" do - context 'without offset' do - it "follows renames" do - log_commits = repository.log(options.merge(path: "encoding/CHANGELOG")) + context "where 'follow' == true" do + let(:options) { { ref: "master", follow: true } } + + context "and 'path' is a directory" do + it "does not follow renames" do + log_commits = repository.log(options.merge(path: "encoding")) aggregate_failures do expect(log_commits).to include(commit_with_new_name) expect(log_commits).to include(rename_commit) - expect(log_commits).to include(commit_with_old_name) + expect(log_commits).not_to include(commit_with_old_name) end end end - context 'with offset=1' do - it "follows renames and skip the latest commit" do - log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1)) + context "and 'path' is a file that matches the new filename" do + context 'without offset' do + it "follows renames" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG")) - aggregate_failures do - expect(log_commits).not_to include(commit_with_new_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).to include(commit_with_old_name) + aggregate_failures do + expect(log_commits).to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end end end - end - context 'with offset=1', 'and limit=1' do - it "follows renames, skip the latest commit and return only one commit" do - log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 1)) + context 'with offset=1' do + it "follows renames and skip the latest commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1)) - expect(log_commits).to contain_exactly(rename_commit) + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end end - end - context 'with offset=1', 'and limit=2' do - it "follows renames, skip the latest commit and return only two commits" do - log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 2)) + context 'with offset=1', 'and limit=1' do + it "follows renames, skip the latest commit and return only one commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 1)) - aggregate_failures do - expect(log_commits).to contain_exactly(rename_commit, commit_with_old_name) + expect(log_commits).to contain_exactly(rename_commit) end end - end - context 'with offset=2' do - it "follows renames and skip the latest commit" do - log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2)) + context 'with offset=1', 'and limit=2' do + it "follows renames, skip the latest commit and return only two commits" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 1, limit: 2)) - aggregate_failures do - expect(log_commits).not_to include(commit_with_new_name) - expect(log_commits).not_to include(rename_commit) - expect(log_commits).to include(commit_with_old_name) + aggregate_failures do + expect(log_commits).to contain_exactly(rename_commit, commit_with_old_name) + end end end - end - context 'with offset=2', 'and limit=1' do - it "follows renames, skip the two latest commit and return only one commit" do - log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 1)) + context 'with offset=2' do + it "follows renames and skip the latest commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2)) - expect(log_commits).to contain_exactly(commit_with_old_name) + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).not_to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end + end + + context 'with offset=2', 'and limit=1' do + it "follows renames, skip the two latest commit and return only one commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 1)) + + expect(log_commits).to contain_exactly(commit_with_old_name) + end + end + + context 'with offset=2', 'and limit=2' do + it "follows renames, skip the two latest commit and return only one commit" do + log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 2)) + + aggregate_failures do + expect(log_commits).not_to include(commit_with_new_name) + expect(log_commits).not_to include(rename_commit) + expect(log_commits).to include(commit_with_old_name) + end + end end end - context 'with offset=2', 'and limit=2' do - it "follows renames, skip the two latest commit and return only one commit" do - log_commits = repository.log(options.merge(path: "encoding/CHANGELOG", offset: 2, limit: 2)) + context "and 'path' is a file that matches the old filename" do + it "does not follow renames" do + log_commits = repository.log(options.merge(path: "CHANGELOG")) aggregate_failures do expect(log_commits).not_to include(commit_with_new_name) - expect(log_commits).not_to include(rename_commit) + expect(log_commits).to include(rename_commit) expect(log_commits).to include(commit_with_old_name) end end end - end - context "and 'path' is a file that matches the old filename" do - it "does not follow renames" do - log_commits = repository.log(options.merge(path: "CHANGELOG")) + context "unknown ref" do + it "returns an empty array" do + log_commits = repository.log(options.merge(ref: 'unknown')) - aggregate_failures do - expect(log_commits).not_to include(commit_with_new_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).to include(commit_with_old_name) + expect(log_commits).to eq([]) end end end - context "unknown ref" do - it "returns an empty array" do - log_commits = repository.log(options.merge(ref: 'unknown')) - - expect(log_commits).to eq([]) - end - end - end + context "where 'follow' == false" do + options = { follow: false } - context "where 'follow' == false" do - options = { follow: false } + context "and 'path' is a directory" do + let(:log_commits) do + repository.log(options.merge(path: "encoding")) + end - context "and 'path' is a directory" do - let(:log_commits) do - repository.log(options.merge(path: "encoding")) + it "does not follow renames" do + expect(log_commits).to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).not_to include(commit_with_old_name) + end end - it "does not follow renames" do - expect(log_commits).to include(commit_with_new_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).not_to include(commit_with_old_name) - end - end + context "and 'path' is a file that matches the new filename" do + let(:log_commits) do + repository.log(options.merge(path: "encoding/CHANGELOG")) + end - context "and 'path' is a file that matches the new filename" do - let(:log_commits) do - repository.log(options.merge(path: "encoding/CHANGELOG")) + it "does not follow renames" do + expect(log_commits).to include(commit_with_new_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).not_to include(commit_with_old_name) + end end - it "does not follow renames" do - expect(log_commits).to include(commit_with_new_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).not_to include(commit_with_old_name) - end - end + context "and 'path' is a file that matches the old filename" do + let(:log_commits) do + repository.log(options.merge(path: "CHANGELOG")) + end - context "and 'path' is a file that matches the old filename" do - let(:log_commits) do - repository.log(options.merge(path: "CHANGELOG")) + it "does not follow renames" do + expect(log_commits).to include(commit_with_old_name) + expect(log_commits).to include(rename_commit) + expect(log_commits).not_to include(commit_with_new_name) + end end - it "does not follow renames" do - expect(log_commits).to include(commit_with_old_name) - expect(log_commits).to include(rename_commit) - expect(log_commits).not_to include(commit_with_new_name) + context "and 'path' includes a directory that used to be a file" do + let(:log_commits) do + repository.log(options.merge(ref: "refs/heads/fix-blob-path", path: "files/testdir/file.txt")) + end + + it "returns a list of commits" do + expect(log_commits.size).to eq(1) + end end end - context "and 'path' includes a directory that used to be a file" do - let(:log_commits) do - repository.log(options.merge(ref: "refs/heads/fix-blob-path", path: "files/testdir/file.txt")) - end + context "where provides 'after' timestamp" do + options = { after: Time.iso8601('2014-03-03T20:15:01+00:00') } - it "returns a list of commits" do - expect(log_commits.size).to eq(1) + it "should returns commits on or after that timestamp" do + commits = repository.log(options) + + expect(commits.size).to be > 0 + expect(commits).to satisfy do |commits| + commits.all? { |commit| commit.committed_date >= options[:after] } + end end end - end - context "where provides 'after' timestamp" do - options = { after: Time.iso8601('2014-03-03T20:15:01+00:00') } + context "where provides 'before' timestamp" do + options = { before: Time.iso8601('2014-03-03T20:15:01+00:00') } - it "should returns commits on or after that timestamp" do - commits = repository.log(options) + it "should returns commits on or before that timestamp" do + commits = repository.log(options) - expect(commits.size).to be > 0 - expect(commits).to satisfy do |commits| - commits.all? { |commit| commit.committed_date >= options[:after] } + expect(commits.size).to be > 0 + expect(commits).to satisfy do |commits| + commits.all? { |commit| commit.committed_date <= options[:before] } + end end end - end - context "where provides 'before' timestamp" do - options = { before: Time.iso8601('2014-03-03T20:15:01+00:00') } + context 'when multiple paths are provided' do + let(:options) { { ref: 'master', path: ['PROCESS.md', 'README.md'] } } - it "should returns commits on or before that timestamp" do - commits = repository.log(options) - - expect(commits.size).to be > 0 - expect(commits).to satisfy do |commits| - commits.all? { |commit| commit.committed_date <= options[:before] } + def commit_files(commit) + commit.rugged_diff_from_parent.deltas.flat_map do |delta| + [delta.old_file[:path], delta.new_file[:path]].uniq.compact + end end - end - end - context 'when multiple paths are provided' do - let(:options) { { ref: 'master', path: ['PROCESS.md', 'README.md'] } } + it 'only returns commits matching at least one path' do + commits = repository.log(options) - def commit_files(commit) - commit.rugged_diff_from_parent.deltas.flat_map do |delta| - [delta.old_file[:path], delta.new_file[:path]].uniq.compact + expect(commits.size).to be > 0 + expect(commits).to satisfy do |commits| + commits.none? { |commit| (commit_files(commit) & options[:path]).empty? } + end end end - it 'only returns commits matching at least one path' do - commits = repository.log(options) + context 'limit validation' do + where(:limit) do + [0, nil, '', 'foo'] + end - expect(commits.size).to be > 0 - expect(commits).to satisfy do |commits| - commits.none? { |commit| (commit_files(commit) & options[:path]).empty? } + with_them do + it { expect { repository.log(limit: limit) }.to raise_error(ArgumentError) } end end - end - context 'limit validation' do - where(:limit) do - [0, nil, '', 'foo'] - end + context 'with all' do + it 'returns a list of commits' do + commits = repository.log({ all: true, limit: 50 }) - with_them do - it { expect { repository.log(limit: limit) }.to raise_error(ArgumentError) } + expect(commits.size).to eq(37) + end end end - context 'with all' do - let(:options) { { all: true, limit: 50 } } - - it 'returns a list of commits' do - commits = repository.log(options) + context 'when Gitaly find_commits feature is enabled' do + it_behaves_like 'repository log' + end - expect(commits.size).to eq(37) - end + context 'when Gitaly find_commits feature is disabled', :disable_gitaly do + it_behaves_like 'repository log' end end @@ -1136,14 +1144,6 @@ describe Gitlab::Git::Repository, seed_helper: true do expect(repository.count_commits(options)).to eq(10) end end - end - - context 'when Gitaly count_commits feature is enabled' do - it_behaves_like 'extended commit counting' - end - - context 'when Gitaly count_commits feature is disabled', :skip_gitaly_mock do - it_behaves_like 'extended commit counting' context "with all" do it "returns the number of commits in the whole repository" do @@ -1155,10 +1155,18 @@ describe Gitlab::Git::Repository, seed_helper: true do context 'without all or ref being specified' do it "raises an ArgumentError" do - expect { repository.count_commits({}) }.to raise_error(ArgumentError, "Please specify a valid ref or set the 'all' attribute to true") + expect { repository.count_commits({}) }.to raise_error(ArgumentError) end end end + + context 'when Gitaly count_commits feature is enabled' do + it_behaves_like 'extended commit counting' + end + + context 'when Gitaly count_commits feature is disabled', :disable_gitaly do + it_behaves_like 'extended commit counting' + end end describe '#autocrlf' do -- cgit v1.2.1 From 6f945f20b4c3683bc862ebc476bad9331d72784e Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Tue, 27 Feb 2018 19:15:25 +0000 Subject: Foreground verification of uploads and LFS objects --- app/models/lfs_object.rb | 4 ++ .../unreleased/ee-4862-verify-file-checksums.yml | 5 ++ doc/administration/raketasks/check.md | 27 +++++---- lib/gitlab/verify/batch_verifier.rb | 64 ++++++++++++++++++++++ lib/gitlab/verify/lfs_objects.rb | 27 +++++++++ lib/gitlab/verify/rake_task.rb | 53 ++++++++++++++++++ lib/gitlab/verify/uploads.rb | 27 +++++++++ lib/tasks/gitlab/lfs/check.rake | 8 +++ lib/tasks/gitlab/uploads.rake | 44 --------------- lib/tasks/gitlab/uploads/check.rake | 8 +++ spec/factories/lfs_objects.rb | 6 ++ spec/lib/gitlab/verify/lfs_objects_spec.rb | 35 ++++++++++++ spec/lib/gitlab/verify/uploads_spec.rb | 44 +++++++++++++++ spec/support/gitlab_verify.rb | 45 +++++++++++++++ spec/tasks/gitlab/lfs/check_rake_spec.rb | 28 ++++++++++ spec/tasks/gitlab/uploads/check_rake_spec.rb | 28 ++++++++++ spec/tasks/gitlab/uploads_rake_spec.rb | 27 --------- 17 files changed, 399 insertions(+), 81 deletions(-) create mode 100644 changelogs/unreleased/ee-4862-verify-file-checksums.yml create mode 100644 lib/gitlab/verify/batch_verifier.rb create mode 100644 lib/gitlab/verify/lfs_objects.rb create mode 100644 lib/gitlab/verify/rake_task.rb create mode 100644 lib/gitlab/verify/uploads.rb create mode 100644 lib/tasks/gitlab/lfs/check.rake delete mode 100644 lib/tasks/gitlab/uploads.rake create mode 100644 lib/tasks/gitlab/uploads/check.rake create mode 100644 spec/lib/gitlab/verify/lfs_objects_spec.rb create mode 100644 spec/lib/gitlab/verify/uploads_spec.rb create mode 100644 spec/support/gitlab_verify.rb create mode 100644 spec/tasks/gitlab/lfs/check_rake_spec.rb create mode 100644 spec/tasks/gitlab/uploads/check_rake_spec.rb delete mode 100644 spec/tasks/gitlab/uploads_rake_spec.rb diff --git a/app/models/lfs_object.rb b/app/models/lfs_object.rb index fc586fa216e..b444812a4cf 100644 --- a/app/models/lfs_object.rb +++ b/app/models/lfs_object.rb @@ -15,4 +15,8 @@ class LfsObject < ActiveRecord::Base .where(lfs_objects_projects: { id: nil }) .destroy_all end + + def self.calculate_oid(path) + Digest::SHA256.file(path).hexdigest + end end diff --git a/changelogs/unreleased/ee-4862-verify-file-checksums.yml b/changelogs/unreleased/ee-4862-verify-file-checksums.yml new file mode 100644 index 00000000000..392c766ab37 --- /dev/null +++ b/changelogs/unreleased/ee-4862-verify-file-checksums.yml @@ -0,0 +1,5 @@ +--- +title: Foreground verification of uploads and LFS objects +merge_request: 17402 +author: +type: added diff --git a/doc/administration/raketasks/check.md b/doc/administration/raketasks/check.md index d1ed152b58c..d73d9422d2c 100644 --- a/doc/administration/raketasks/check.md +++ b/doc/administration/raketasks/check.md @@ -78,34 +78,41 @@ Example output: ## Uploaded Files Integrity -The uploads check Rake task will loop through all uploads in the database -and run two checks to determine the integrity of each file: +Various types of file can be uploaded to a GitLab installation by users. +Checksums are generated and stored in the database upon upload, and integrity +checks using those checksums can be run. These checks also detect missing files. -1. Check if the file exist on the file system. -1. Check if the checksum of the file on the file system matches the checksum in the database. +Currently, integrity checks are supported for the following types of file: + +* LFS objects +* User uploads **Omnibus Installation** ``` +sudo gitlab-rake gitlab:lfs:check sudo gitlab-rake gitlab:uploads:check ``` **Source Installation** ```bash +sudo -u git -H bundle exec rake gitlab:lfs:check RAILS_ENV=production sudo -u git -H bundle exec rake gitlab:uploads:check RAILS_ENV=production ``` -This task also accepts some environment variables which you can use to override +These tasks also accept some environment variables which you can use to override certain values: -Variable | Type | Description --------- | ---- | ----------- -`BATCH` | integer | Specifies the size of the batch. Defaults to 200. -`ID_FROM` | integer | Specifies the ID to start from, inclusive of the value. -`ID_TO` | integer | Specifies the ID value to end at, inclusive of the value. +Variable | Type | Description +--------- | ------- | ----------- +`BATCH` | integer | Specifies the size of the batch. Defaults to 200. +`ID_FROM` | integer | Specifies the ID to start from, inclusive of the value. +`ID_TO` | integer | Specifies the ID value to end at, inclusive of the value. +`VERBOSE` | boolean | Causes failures to be listed individually, rather than being summarized. ```bash +sudo gitlab-rake gitlab:lfs:check BATCH=100 ID_FROM=50 ID_TO=250 sudo gitlab-rake gitlab:uploads:check BATCH=100 ID_FROM=50 ID_TO=250 ``` diff --git a/lib/gitlab/verify/batch_verifier.rb b/lib/gitlab/verify/batch_verifier.rb new file mode 100644 index 00000000000..1ef369a4b67 --- /dev/null +++ b/lib/gitlab/verify/batch_verifier.rb @@ -0,0 +1,64 @@ +module Gitlab + module Verify + class BatchVerifier + attr_reader :batch_size, :start, :finish + + def initialize(batch_size:, start: nil, finish: nil) + @batch_size = batch_size + @start = start + @finish = finish + end + + # Yields a Range of IDs and a Hash of failed verifications (object => error) + def run_batches(&blk) + relation.in_batches(of: batch_size, start: start, finish: finish) do |relation| # rubocop: disable Cop/InBatches + range = relation.first.id..relation.last.id + failures = run_batch(relation) + + yield(range, failures) + end + end + + def name + raise NotImplementedError.new + end + + def describe(_object) + raise NotImplementedError.new + end + + private + + def run_batch(relation) + relation.map { |upload| verify(upload) }.compact.to_h + end + + def verify(object) + expected = expected_checksum(object) + actual = actual_checksum(object) + + raise 'Checksum missing' unless expected.present? + raise 'Checksum mismatch' unless expected == actual + + nil + rescue => err + [object, err] + end + + # This should return an ActiveRecord::Relation suitable for calling #in_batches on + def relation + raise NotImplementedError.new + end + + # The checksum we expect the object to have + def expected_checksum(_object) + raise NotImplementedError.new + end + + # The freshly-recalculated checksum of the object + def actual_checksum(_object) + raise NotImplementedError.new + end + end + end +end diff --git a/lib/gitlab/verify/lfs_objects.rb b/lib/gitlab/verify/lfs_objects.rb new file mode 100644 index 00000000000..fe51edbdeeb --- /dev/null +++ b/lib/gitlab/verify/lfs_objects.rb @@ -0,0 +1,27 @@ +module Gitlab + module Verify + class LfsObjects < BatchVerifier + def name + 'LFS objects' + end + + def describe(object) + "LFS object: #{object.oid}" + end + + private + + def relation + LfsObject.all + end + + def expected_checksum(lfs_object) + lfs_object.oid + end + + def actual_checksum(lfs_object) + LfsObject.calculate_oid(lfs_object.file.path) + end + end + end +end diff --git a/lib/gitlab/verify/rake_task.rb b/lib/gitlab/verify/rake_task.rb new file mode 100644 index 00000000000..dd138e6b92b --- /dev/null +++ b/lib/gitlab/verify/rake_task.rb @@ -0,0 +1,53 @@ +module Gitlab + module Verify + class RakeTask + def self.run!(verify_kls) + verifier = verify_kls.new( + batch_size: ENV.fetch('BATCH', 200).to_i, + start: ENV['ID_FROM'], + finish: ENV['ID_TO'] + ) + + verbose = Gitlab::Utils.to_boolean(ENV['VERBOSE']) + + new(verifier, verbose).run! + end + + attr_reader :verifier, :output + + def initialize(verifier, verbose) + @verifier = verifier + @verbose = verbose + end + + def run! + say "Checking integrity of #{verifier.name}" + + verifier.run_batches { |*args| run_batch(*args) } + + say 'Done!' + end + + def verbose? + !!@verbose + end + + private + + def say(text) + puts(text) # rubocop:disable Rails/Output + end + + def run_batch(range, failures) + status_color = failures.empty? ? :green : :red + say "- #{range}: Failures: #{failures.count}".color(status_color) + + return unless verbose? + + failures.each do |object, error| + say " - #{verifier.describe(object)}: #{error.inspect}".color(:red) + end + end + end + end +end diff --git a/lib/gitlab/verify/uploads.rb b/lib/gitlab/verify/uploads.rb new file mode 100644 index 00000000000..6972e517ea5 --- /dev/null +++ b/lib/gitlab/verify/uploads.rb @@ -0,0 +1,27 @@ +module Gitlab + module Verify + class Uploads < BatchVerifier + def name + 'Uploads' + end + + def describe(object) + "Upload: #{object.id}" + end + + private + + def relation + Upload.all + end + + def expected_checksum(upload) + upload.checksum + end + + def actual_checksum(upload) + Upload.hexdigest(upload.absolute_path) + end + end + end +end diff --git a/lib/tasks/gitlab/lfs/check.rake b/lib/tasks/gitlab/lfs/check.rake new file mode 100644 index 00000000000..869463d4e5d --- /dev/null +++ b/lib/tasks/gitlab/lfs/check.rake @@ -0,0 +1,8 @@ +namespace :gitlab do + namespace :lfs do + desc 'GitLab | LFS | Check integrity of uploaded LFS objects' + task check: :environment do + Gitlab::Verify::RakeTask.run!(Gitlab::Verify::LfsObjects) + end + end +end diff --git a/lib/tasks/gitlab/uploads.rake b/lib/tasks/gitlab/uploads.rake deleted file mode 100644 index df31567ce64..00000000000 --- a/lib/tasks/gitlab/uploads.rake +++ /dev/null @@ -1,44 +0,0 @@ -namespace :gitlab do - namespace :uploads do - desc 'GitLab | Uploads | Check integrity of uploaded files' - task check: :environment do - puts 'Checking integrity of uploaded files' - - uploads_batches do |batch| - batch.each do |upload| - puts "- Checking file (#{upload.id}): #{upload.absolute_path}".color(:green) - - if upload.exist? - check_checksum(upload) - else - puts " * File does not exist on the file system".color(:red) - end - end - end - - puts 'Done!' - end - - def batch_size - ENV.fetch('BATCH', 200).to_i - end - - def calculate_checksum(absolute_path) - Digest::SHA256.file(absolute_path).hexdigest - end - - def check_checksum(upload) - checksum = calculate_checksum(upload.absolute_path) - - if checksum != upload.checksum - puts " * File checksum (#{checksum}) does not match the one in the database (#{upload.checksum})".color(:red) - end - end - - def uploads_batches(&block) - Upload.all.in_batches(of: batch_size, start: ENV['ID_FROM'], finish: ENV['ID_TO']) do |relation| # rubocop: disable Cop/InBatches - yield relation - end - end - end -end diff --git a/lib/tasks/gitlab/uploads/check.rake b/lib/tasks/gitlab/uploads/check.rake new file mode 100644 index 00000000000..2be2ec7f9c9 --- /dev/null +++ b/lib/tasks/gitlab/uploads/check.rake @@ -0,0 +1,8 @@ +namespace :gitlab do + namespace :uploads do + desc 'GitLab | Uploads | Check integrity of uploaded files' + task check: :environment do + Gitlab::Verify::RakeTask.run!(Gitlab::Verify::Uploads) + end + end +end diff --git a/spec/factories/lfs_objects.rb b/spec/factories/lfs_objects.rb index 8eb709022ce..caaed4d5246 100644 --- a/spec/factories/lfs_objects.rb +++ b/spec/factories/lfs_objects.rb @@ -9,4 +9,10 @@ FactoryBot.define do trait :with_file do file { fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "`/png") } end + + # The uniqueness constraint means we can't use the correct OID for all LFS + # objects, so the test needs to decide which (if any) object gets it + trait :correct_oid do + oid 'b804383982bb89b00e828e3f44c038cc991d3d1768009fc39ba8e2c081b9fb75' + end end diff --git a/spec/lib/gitlab/verify/lfs_objects_spec.rb b/spec/lib/gitlab/verify/lfs_objects_spec.rb new file mode 100644 index 00000000000..64f3a9660e0 --- /dev/null +++ b/spec/lib/gitlab/verify/lfs_objects_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Gitlab::Verify::LfsObjects do + include GitlabVerifyHelpers + + it_behaves_like 'Gitlab::Verify::BatchVerifier subclass' do + let!(:objects) { create_list(:lfs_object, 3, :with_file) } + end + + describe '#run_batches' do + let(:failures) { collect_failures } + let(:failure) { failures[lfs_object] } + + let!(:lfs_object) { create(:lfs_object, :with_file, :correct_oid) } + + it 'passes LFS objects with the correct file' do + expect(failures).to eq({}) + end + + it 'fails LFS objects with a missing file' do + FileUtils.rm_f(lfs_object.file.path) + + expect(failures.keys).to contain_exactly(lfs_object) + expect(failure).to be_a(Errno::ENOENT) + expect(failure.to_s).to include(lfs_object.file.path) + end + + it 'fails LFS objects with a mismatched oid' do + File.truncate(lfs_object.file.path, 0) + + expect(failures.keys).to contain_exactly(lfs_object) + expect(failure.to_s).to include('Checksum mismatch') + end + end +end diff --git a/spec/lib/gitlab/verify/uploads_spec.rb b/spec/lib/gitlab/verify/uploads_spec.rb new file mode 100644 index 00000000000..6146ce61226 --- /dev/null +++ b/spec/lib/gitlab/verify/uploads_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +describe Gitlab::Verify::Uploads do + include GitlabVerifyHelpers + + it_behaves_like 'Gitlab::Verify::BatchVerifier subclass' do + let(:projects) { create_list(:project, 3, :with_avatar) } + let!(:objects) { projects.flat_map(&:uploads) } + end + + describe '#run_batches' do + let(:project) { create(:project, :with_avatar) } + let(:failures) { collect_failures } + let(:failure) { failures[upload] } + + let!(:upload) { project.uploads.first } + + it 'passes uploads with the correct file' do + expect(failures).to eq({}) + end + + it 'fails uploads with a missing file' do + FileUtils.rm_f(upload.absolute_path) + + expect(failures.keys).to contain_exactly(upload) + expect(failure).to be_a(Errno::ENOENT) + expect(failure.to_s).to include(upload.absolute_path) + end + + it 'fails uploads with a mismatched checksum' do + upload.update!(checksum: 'something incorrect') + + expect(failures.keys).to contain_exactly(upload) + expect(failure.to_s).to include('Checksum mismatch') + end + + it 'fails uploads with a missing precalculated checksum' do + upload.update!(checksum: '') + + expect(failures.keys).to contain_exactly(upload) + expect(failure.to_s).to include('Checksum missing') + end + end +end diff --git a/spec/support/gitlab_verify.rb b/spec/support/gitlab_verify.rb new file mode 100644 index 00000000000..13e2e37624d --- /dev/null +++ b/spec/support/gitlab_verify.rb @@ -0,0 +1,45 @@ +RSpec.shared_examples 'Gitlab::Verify::BatchVerifier subclass' do + describe 'batching' do + let(:first_batch) { objects[0].id..objects[0].id } + let(:second_batch) { objects[1].id..objects[1].id } + let(:third_batch) { objects[2].id..objects[2].id } + + it 'iterates through objects in batches' do + expect(collect_ranges).to eq([first_batch, second_batch, third_batch]) + end + + it 'allows the starting ID to be specified' do + expect(collect_ranges(start: second_batch.first)).to eq([second_batch, third_batch]) + end + + it 'allows the finishing ID to be specified' do + expect(collect_ranges(finish: second_batch.last)).to eq([first_batch, second_batch]) + end + end +end + +module GitlabVerifyHelpers + def collect_ranges(args = {}) + verifier = described_class.new(args.merge(batch_size: 1)) + + collect_results(verifier).map { |range, _| range } + end + + def collect_failures + verifier = described_class.new(batch_size: 1) + + out = {} + + collect_results(verifier).map { |_, failures| out.merge!(failures) } + + out + end + + def collect_results(verifier) + out = [] + + verifier.run_batches { |*args| out << args } + + out + end +end diff --git a/spec/tasks/gitlab/lfs/check_rake_spec.rb b/spec/tasks/gitlab/lfs/check_rake_spec.rb new file mode 100644 index 00000000000..2610edf8bac --- /dev/null +++ b/spec/tasks/gitlab/lfs/check_rake_spec.rb @@ -0,0 +1,28 @@ +require 'rake_helper' + +describe 'gitlab:lfs rake tasks' do + describe 'check' do + let!(:lfs_object) { create(:lfs_object, :with_file, :correct_oid) } + + before do + Rake.application.rake_require('tasks/gitlab/lfs/check') + stub_env('VERBOSE' => 'true') + end + + it 'outputs the integrity check for each batch' do + expect { run_rake_task('gitlab:lfs:check') }.to output(/Failures: 0/).to_stdout + end + + it 'errors out about missing files on the file system' do + FileUtils.rm_f(lfs_object.file.path) + + expect { run_rake_task('gitlab:lfs:check') }.to output(/No such file.*#{Regexp.quote(lfs_object.file.path)}/).to_stdout + end + + it 'errors out about invalid checksum' do + File.truncate(lfs_object.file.path, 0) + + expect { run_rake_task('gitlab:lfs:check') }.to output(/Checksum mismatch/).to_stdout + end + end +end diff --git a/spec/tasks/gitlab/uploads/check_rake_spec.rb b/spec/tasks/gitlab/uploads/check_rake_spec.rb new file mode 100644 index 00000000000..5d597c66133 --- /dev/null +++ b/spec/tasks/gitlab/uploads/check_rake_spec.rb @@ -0,0 +1,28 @@ +require 'rake_helper' + +describe 'gitlab:uploads rake tasks' do + describe 'check' do + let!(:upload) { create(:upload, path: Rails.root.join('spec/fixtures/banana_sample.gif')) } + + before do + Rake.application.rake_require('tasks/gitlab/uploads/check') + stub_env('VERBOSE' => 'true') + end + + it 'outputs the integrity check for each batch' do + expect { run_rake_task('gitlab:uploads:check') }.to output(/Failures: 0/).to_stdout + end + + it 'errors out about missing files on the file system' do + missing_upload = create(:upload) + + expect { run_rake_task('gitlab:uploads:check') }.to output(/No such file.*#{Regexp.quote(missing_upload.absolute_path)}/).to_stdout + end + + it 'errors out about invalid checksum' do + upload.update_column(:checksum, '01a3156db2cf4f67ec823680b40b7302f89ab39179124ad219f94919b8a1769e') + + expect { run_rake_task('gitlab:uploads:check') }.to output(/Checksum mismatch/).to_stdout + end + end +end diff --git a/spec/tasks/gitlab/uploads_rake_spec.rb b/spec/tasks/gitlab/uploads_rake_spec.rb deleted file mode 100644 index ac0005e51e0..00000000000 --- a/spec/tasks/gitlab/uploads_rake_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -require 'rake_helper' - -describe 'gitlab:uploads rake tasks' do - describe 'check' do - let!(:upload) { create(:upload, path: Rails.root.join('spec/fixtures/banana_sample.gif')) } - - before do - Rake.application.rake_require 'tasks/gitlab/uploads' - end - - it 'outputs the integrity check for each uploaded file' do - expect { run_rake_task('gitlab:uploads:check') }.to output(/Checking file \(#{upload.id}\): #{Regexp.quote(upload.absolute_path)}/).to_stdout - end - - it 'errors out about missing files on the file system' do - create(:upload) - - expect { run_rake_task('gitlab:uploads:check') }.to output(/File does not exist on the file system/).to_stdout - end - - it 'errors out about invalid checksum' do - upload.update_column(:checksum, '01a3156db2cf4f67ec823680b40b7302f89ab39179124ad219f94919b8a1769e') - - expect { run_rake_task('gitlab:uploads:check') }.to output(/File checksum \(9e697aa09fe196909813ee36103e34f721fe47a5fdc8aac0e4e4ac47b9b38282\) does not match the one in the database \(#{upload.checksum}\)/).to_stdout - end - end -end -- cgit v1.2.1 From daeeb7f8480d747d500ea3aeddb479a29e890562 Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Fri, 2 Mar 2018 12:03:03 +0000 Subject: Fix quick actions for users who cannot update issues and MRs There are several quick actions now that don't need this access - /todo and /unsubscribe for instance - but when these were first added, there weren't. Quick actions are now responsible for checking their own permissions. --- app/helpers/notes_helper.rb | 2 +- app/services/notes/quick_actions_service.rb | 8 ++-- ...il-did-not-pick-up-unsubscribe-quick-action.yml | 5 +++ .../issues/user_uses_slash_commands_spec.rb | 3 -- .../fixtures/emails/update_commands_only_reply.eml | 38 ++++++++++++++++ .../email/handler/create_note_handler_spec.rb | 27 +++++------- spec/services/notes/create_service_spec.rb | 51 ++++++++++++++++------ spec/services/notes/quick_actions_service_spec.rb | 30 ++++--------- .../issuable_slash_commands_shared_examples.rb | 5 +-- 9 files changed, 104 insertions(+), 65 deletions(-) create mode 100644 changelogs/unreleased/43334-reply-by-email-did-not-pick-up-unsubscribe-quick-action.yml create mode 100644 spec/fixtures/emails/update_commands_only_reply.eml diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index e86e43b5ebf..a70e73a6da9 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -11,7 +11,7 @@ module NotesHelper end def note_supports_quick_actions?(note) - Notes::QuickActionsService.supported?(note, current_user) + Notes::QuickActionsService.supported?(note) end def noteable_json(noteable) diff --git a/app/services/notes/quick_actions_service.rb b/app/services/notes/quick_actions_service.rb index a8d0cc15527..0a33d5f3f3d 100644 --- a/app/services/notes/quick_actions_service.rb +++ b/app/services/notes/quick_actions_service.rb @@ -9,14 +9,12 @@ module Notes UPDATE_SERVICES[note.noteable_type] end - def self.supported?(note, current_user) - noteable_update_service(note) && - current_user && - current_user.can?(:"update_#{note.to_ability_name}", note.noteable) + def self.supported?(note) + !!noteable_update_service(note) end def supported?(note) - self.class.supported?(note, current_user) + self.class.supported?(note) end def extract_commands(note, options = {}) diff --git a/changelogs/unreleased/43334-reply-by-email-did-not-pick-up-unsubscribe-quick-action.yml b/changelogs/unreleased/43334-reply-by-email-did-not-pick-up-unsubscribe-quick-action.yml new file mode 100644 index 00000000000..86be5ee1804 --- /dev/null +++ b/changelogs/unreleased/43334-reply-by-email-did-not-pick-up-unsubscribe-quick-action.yml @@ -0,0 +1,5 @@ +--- +title: Fix quick actions for users who cannot update issues and merge requests +merge_request: 17482 +author: +type: fixed diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index e711a191db2..ea7a97d02a0 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -59,7 +59,6 @@ feature 'Issues > User uses quick actions', :js do it 'does not create a note, and sets the due date accordingly' do write_note("/due 2016-08-28") - expect(page).to have_content '/due 2016-08-28' expect(page).not_to have_content 'Commands applied' issue.reload @@ -99,7 +98,6 @@ feature 'Issues > User uses quick actions', :js do it 'does not create a note, and sets the due date accordingly' do write_note("/remove_due_date") - expect(page).to have_content '/remove_due_date' expect(page).not_to have_content 'Commands applied' issue.reload @@ -147,7 +145,6 @@ feature 'Issues > User uses quick actions', :js do it 'does not create a note, and does not mark the issue as a duplicate' do write_note("/duplicate ##{original_issue.to_reference}") - expect(page).to have_content "/duplicate ##{original_issue.to_reference}" expect(page).not_to have_content 'Commands applied' expect(page).not_to have_content "marked this issue as a duplicate of #{original_issue.to_reference}" diff --git a/spec/fixtures/emails/update_commands_only_reply.eml b/spec/fixtures/emails/update_commands_only_reply.eml new file mode 100644 index 00000000000..bb0d2b0e03a --- /dev/null +++ b/spec/fixtures/emails/update_commands_only_reply.eml @@ -0,0 +1,38 @@ +Return-Path: +Received: from iceking.adventuretime.ooo ([unix socket]) by iceking (Cyrus v2.2.13-Debian-2.2.13-19+squeeze3) with LMTPA; Thu, 13 Jun 2013 17:03:50 -0400 +Received: from mail-ie0-x234.google.com (mail-ie0-x234.google.com [IPv6:2607:f8b0:4001:c03::234]) by iceking.adventuretime.ooo (8.14.3/8.14.3/Debian-9.4) with ESMTP id r5DL3nFJ016967 (version=TLSv1/SSLv3 cipher=RC4-SHA bits=128 verify=NOT) for ; Thu, 13 Jun 2013 17:03:50 -0400 +Received: by mail-ie0-f180.google.com with SMTP id f4so21977375iea.25 for ; Thu, 13 Jun 2013 14:03:48 -0700 +Received: by 10.0.0.1 with HTTP; Thu, 13 Jun 2013 14:03:48 -0700 +Date: Thu, 13 Jun 2013 17:03:48 -0400 +From: Jake the Dog +To: reply+59d8df8370b7e95c5a49fbf86aeb2c93@appmail.adventuretime.ooo +Message-ID: +In-Reply-To: +References: +Subject: re: [Discourse Meta] eviltrout posted in 'Adventure Time Sux' +Mime-Version: 1.0 +Content-Type: text/plain; + charset=ISO-8859-1 +Content-Transfer-Encoding: 7bit +X-Sieve: CMU Sieve 2.2 +X-Received: by 10.0.0.1 with SMTP id n7mr11234144ipb.85.1371157428600; Thu, + 13 Jun 2013 14:03:48 -0700 (PDT) +X-Scanned-By: MIMEDefang 2.69 on IPv6:2001:470:1d:165::1 + +/close + +On Sun, Jun 9, 2013 at 1:39 PM, eviltrout via Discourse Meta + wrote: +> +> +> +> eviltrout posted in 'Adventure Time Sux' on Discourse Meta: +> +> --- +> hey guys everyone knows adventure time sucks! +> +> --- +> Please visit this link to respond: http://localhost:3000/t/adventure-time-sux/1234/3 +> +> To unsubscribe from these emails, visit your [user preferences](http://localhost:3000/user_preferences). +> diff --git a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb index 031efcf1291..53899e00b53 100644 --- a/spec/lib/gitlab/email/handler/create_note_handler_spec.rb +++ b/spec/lib/gitlab/email/handler/create_note_handler_spec.rb @@ -55,8 +55,8 @@ describe Gitlab::Email::Handler::CreateNoteHandler do expect { receiver.execute }.to raise_error(Gitlab::Email::InvalidNoteError) end - context 'because the note was commands only' do - let!(:email_raw) { fixture_file("emails/commands_only_reply.eml") } + context 'because the note was update commands only' do + let!(:email_raw) { fixture_file("emails/update_commands_only_reply.eml") } context 'and current user cannot update noteable' do it 'raises a CommandsOnlyNoteError' do @@ -70,13 +70,10 @@ describe Gitlab::Email::Handler::CreateNoteHandler do end it 'does not raise an error' do - expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy - # One system note is created for the 'close' event expect { receiver.execute }.to change { noteable.notes.count }.by(1) expect(noteable.reload).to be_closed - expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy end end end @@ -85,15 +82,13 @@ describe Gitlab::Email::Handler::CreateNoteHandler do context 'when the note contains quick actions' do let!(:email_raw) { fixture_file("emails/commands_in_reply.eml") } - context 'and current user cannot update noteable' do - it 'post a note and does not update the noteable' do - expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy - - # One system note is created for the new note - expect { receiver.execute }.to change { noteable.notes.count }.by(1) + context 'and current user cannot update the noteable' do + it 'only executes the commands that the user can perform' do + expect { receiver.execute } + .to change { noteable.notes.user.count }.by(1) + .and change { user.todos_pending_count }.from(0).to(1) expect(noteable.reload).to be_open - expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy end end @@ -102,14 +97,14 @@ describe Gitlab::Email::Handler::CreateNoteHandler do project.add_developer(user) end - it 'post a note and updates the noteable' do + it 'posts a note and updates the noteable' do expect(TodoService.new.todo_exist?(noteable, user)).to be_falsy - # One system note is created for the new note, one for the 'close' event - expect { receiver.execute }.to change { noteable.notes.count }.by(2) + expect { receiver.execute } + .to change { noteable.notes.user.count }.by(1) + .and change { user.todos_pending_count }.from(0).to(1) expect(noteable.reload).to be_closed - expect(TodoService.new.todo_exist?(noteable, user)).to be_truthy end end end diff --git a/spec/services/notes/create_service_spec.rb b/spec/services/notes/create_service_spec.rb index 0ae26e87154..f5cff66de6d 100644 --- a/spec/services/notes/create_service_spec.rb +++ b/spec/services/notes/create_service_spec.rb @@ -57,32 +57,55 @@ describe Notes::CreateService do end end - describe 'note with commands' do - describe '/close, /label, /assign & /milestone' do - let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) } + context 'note with commands' do + context 'as a user who can update the target' do + context '/close, /label, /assign & /milestone' do + let(:note_text) { %(HELLO\n/close\n/assign @#{user.username}\nWORLD) } - it 'saves the note and does not alter the note text' do - expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original + it 'saves the note and does not alter the note text' do + expect_any_instance_of(Issues::UpdateService).to receive(:execute).and_call_original - note = described_class.new(project, user, opts.merge(note: note_text)).execute + note = described_class.new(project, user, opts.merge(note: note_text)).execute - expect(note.note).to eq "HELLO\nWORLD" + expect(note.note).to eq "HELLO\nWORLD" + end + end + + context '/merge with sha option' do + let(:note_text) { %(HELLO\n/merge\nWORLD) } + let(:params) { opts.merge(note: note_text, merge_request_diff_head_sha: 'sha') } + + it 'saves the note and exectues merge command' do + note = described_class.new(project, user, params).execute + + expect(note.note).to eq "HELLO\nWORLD" + end end end - describe '/merge with sha option' do - let(:note_text) { %(HELLO\n/merge\nWORLD) } - let(:params) { opts.merge(note: note_text, merge_request_diff_head_sha: 'sha') } + context 'as a user who cannot update the target' do + let(:note_text) { "HELLO\n/todo\n/assign #{user.to_reference}\nWORLD" } + let(:note) { described_class.new(project, user, opts.merge(note: note_text)).execute } - it 'saves the note and exectues merge command' do - note = described_class.new(project, user, params).execute + before do + project.team.find_member(user.id).update!(access_level: Gitlab::Access::GUEST) + end + + it 'applies commands the user can execute' do + expect { note }.to change { user.todos_pending_count }.from(0).to(1) + end + + it 'does not apply commands the user cannot execute' do + expect { note }.not_to change { issue.assignees } + end + it 'saves the note' do expect(note.note).to eq "HELLO\nWORLD" end end end - describe 'personal snippet note' do + context 'personal snippet note' do subject { described_class.new(nil, user, params).execute } let(:snippet) { create(:personal_snippet) } @@ -103,7 +126,7 @@ describe Notes::CreateService do end end - describe 'note with emoji only' do + context 'note with emoji only' do it 'creates regular note' do opts = { note: ':smile: ', diff --git a/spec/services/notes/quick_actions_service_spec.rb b/spec/services/notes/quick_actions_service_spec.rb index 5eafe56c99d..b1e218821d2 100644 --- a/spec/services/notes/quick_actions_service_spec.rb +++ b/spec/services/notes/quick_actions_service_spec.rb @@ -165,31 +165,17 @@ describe Notes::QuickActionsService do let(:note) { create(:note_on_issue, project: project) } - context 'with no current_user' do - it 'returns false' do - expect(described_class.supported?(note, nil)).to be_falsy - end - end - - context 'when current_user cannot update the noteable' do - it 'returns false' do - user = create(:user) - - expect(described_class.supported?(note, user)).to be_falsy - end - end - - context 'when current_user can update the noteable' do + context 'with a note on an issue' do it 'returns true' do - expect(described_class.supported?(note, master)).to be_truthy + expect(described_class.supported?(note)).to be_truthy end + end - context 'with a note on a commit' do - let(:note) { create(:note_on_commit, project: project) } + context 'with a note on a commit' do + let(:note) { create(:note_on_commit, project: project) } - it 'returns false' do - expect(described_class.supported?(note, nil)).to be_falsy - end + it 'returns false' do + expect(described_class.supported?(note)).to be_falsy end end end @@ -201,7 +187,7 @@ describe Notes::QuickActionsService do service = described_class.new(project, master) note = create(:note_on_issue, project: project) - expect(described_class).to receive(:supported?).with(note, master) + expect(described_class).to receive(:supported?).with(note) service.supported?(note) end diff --git a/spec/support/features/issuable_slash_commands_shared_examples.rb b/spec/support/features/issuable_slash_commands_shared_examples.rb index 2c20821ac3f..f61469f673d 100644 --- a/spec/support/features/issuable_slash_commands_shared_examples.rb +++ b/spec/support/features/issuable_slash_commands_shared_examples.rb @@ -127,7 +127,6 @@ shared_examples 'issuable record that supports quick actions in its description it "does not close the #{issuable_type}" do write_note("/close") - expect(page).to have_content '/close' expect(page).not_to have_content 'Commands applied' expect(issuable).to be_open @@ -165,7 +164,6 @@ shared_examples 'issuable record that supports quick actions in its description it "does not reopen the #{issuable_type}" do write_note("/reopen") - expect(page).to have_content '/reopen' expect(page).not_to have_content 'Commands applied' expect(issuable).to be_closed @@ -195,10 +193,9 @@ shared_examples 'issuable record that supports quick actions in its description visit public_send("namespace_project_#{issuable_type}_path", project.namespace, project, issuable) end - it "does not reopen the #{issuable_type}" do + it "does not change the #{issuable_type} title" do write_note("/title Awesome new title") - expect(page).to have_content '/title' expect(page).not_to have_content 'Commands applied' expect(issuable.reload.title).not_to eq 'Awesome new title' -- cgit v1.2.1 From bb4fcb7809aa9d14a60e5c90f11f07fac8f584a8 Mon Sep 17 00:00:00 2001 From: Lin Jen-Shin Date: Sat, 3 Mar 2018 00:39:42 +0800 Subject: Move constants and update for feedback --- lib/gitlab/middleware/read_only.rb | 2 -- lib/gitlab/middleware/read_only/controller.rb | 9 ++++++--- spec/lib/gitlab/middleware/read_only_spec.rb | 14 +++++++------- spec/lib/gitlab/middleware/release_env_spec.rb | 8 ++------ 4 files changed, 15 insertions(+), 18 deletions(-) diff --git a/lib/gitlab/middleware/read_only.rb b/lib/gitlab/middleware/read_only.rb index 19b74c0c122..d9d5f90596f 100644 --- a/lib/gitlab/middleware/read_only.rb +++ b/lib/gitlab/middleware/read_only.rb @@ -1,8 +1,6 @@ module Gitlab module Middleware class ReadOnly - DISALLOWED_METHODS = %w(POST PATCH PUT DELETE).freeze - APPLICATION_JSON = 'application/json'.freeze API_VERSIONS = (3..4) def self.internal_routes diff --git a/lib/gitlab/middleware/read_only/controller.rb b/lib/gitlab/middleware/read_only/controller.rb index 053cb6f0a9f..45b644e6510 100644 --- a/lib/gitlab/middleware/read_only/controller.rb +++ b/lib/gitlab/middleware/read_only/controller.rb @@ -2,6 +2,10 @@ module Gitlab module Middleware class ReadOnly class Controller + DISALLOWED_METHODS = %w(POST PATCH PUT DELETE).freeze + APPLICATION_JSON = 'application/json'.freeze + ERROR_MESSAGE = 'You cannot perform write operations on a read-only instance'.freeze + def initialize(app, env) @app = app @env = env @@ -10,12 +14,11 @@ module Gitlab def call if disallowed_request? && Gitlab::Database.read_only? Rails.logger.debug('GitLab ReadOnly: preventing possible non read-only operation') - error_message = 'You cannot do writing operations on a read-only GitLab instance' if json_request? - return [403, { 'Content-Type' => 'application/json' }, [{ 'message' => error_message }.to_json]] + return [403, { 'Content-Type' => APPLICATION_JSON }, [{ 'message' => ERROR_MESSAGE }.to_json]] else - rack_flash.alert = error_message + rack_flash.alert = ERROR_MESSAGE rack_session['flash'] = rack_flash.to_session_value return [301, { 'Location' => last_visited_url }, []] diff --git a/spec/lib/gitlab/middleware/read_only_spec.rb b/spec/lib/gitlab/middleware/read_only_spec.rb index b3c85142b82..39ec2f37a83 100644 --- a/spec/lib/gitlab/middleware/read_only_spec.rb +++ b/spec/lib/gitlab/middleware/read_only_spec.rb @@ -14,14 +14,14 @@ describe Gitlab::Middleware::ReadOnly do alert = middleware.env['rack.session'].to_hash .dig('flash', 'flashes', 'alert') - alert&.include?('You cannot do writing operations') + alert&.include?('You cannot perform write operations') end end RSpec::Matchers.define :disallow_request_in_json do match do |response| json_response = JSON.parse(response.body) - response.body.include?('You cannot do writing operations') && json_response.key?('message') + response.body.include?('You cannot perform write operations') && json_response.key?('message') end end @@ -47,14 +47,14 @@ describe Gitlab::Middleware::ReadOnly do end end + let(:request) { Rack::MockRequest.new(rack_stack) } + subject do - app = described_class.new(fake_app) - app.extend(observe_env) - app + described_class.new(fake_app).tap do |app| + app.extend(observe_env) + end end - let(:request) { Rack::MockRequest.new(rack_stack) } - context 'normal requests to a read-only Gitlab instance' do let(:fake_app) { lambda { |env| [200, { 'Content-Type' => 'text/plain' }, ['OK']] } } diff --git a/spec/lib/gitlab/middleware/release_env_spec.rb b/spec/lib/gitlab/middleware/release_env_spec.rb index 657b705502a..5e3aa877409 100644 --- a/spec/lib/gitlab/middleware/release_env_spec.rb +++ b/spec/lib/gitlab/middleware/release_env_spec.rb @@ -1,16 +1,12 @@ require 'spec_helper' describe Gitlab::Middleware::ReleaseEnv do - let(:inner_app) { double(:app) } + let(:inner_app) { double(:app, call: 'yay') } let(:app) { described_class.new(inner_app) } let(:env) { { 'action_controller.instance' => 'something' } } - before do - expect(inner_app).to receive(:call).with(env).and_return('yay') - end - describe '#call' do - it 'calls the app and delete the controller' do + it 'calls the app and clears the env' do result = app.call(env) expect(result).to eq('yay') -- cgit v1.2.1 From 57ce7c655cc58ee793cf6514fae590fa53850884 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Fri, 2 Mar 2018 17:10:50 +0000 Subject: codequality: Install jq directly instead of pulling it via docker --- .gitlab-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 8a0c9802c15..8b489f1a07c 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -619,9 +619,10 @@ codequality: cache: {} dependencies: [] script: + - apk update && apk add jq - ./scripts/codequality analyze -f json > raw_codeclimate.json || true # The following line keeps only the fields used in the MR widget, reducing the JSON artifact size - - cat raw_codeclimate.json | docker run -i stedolan/jq -c 'map({check_name,description,fingerprint,location})' > codeclimate.json + - jq -c 'map({check_name,description,fingerprint,location})' raw_codeclimate.json > codeclimate.json artifacts: paths: [codeclimate.json] expire_in: 1 week -- cgit v1.2.1 From a0a7b551ae117f481ec2d920d47ef55043119313 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Rodr=C3=ADguez?= Date: Tue, 6 Feb 2018 19:49:33 -0300 Subject: Incorporate Gitaly's RPCs for Gitlab::Git::LfsChanges --- lib/gitlab/git/lfs_changes.rb | 26 ++++++++-- lib/gitlab/gitaly_client/blob_service.rb | 55 ++++++++++++++++---- spec/lib/gitlab/checks/lfs_integrity_spec.rb | 22 ++++---- spec/lib/gitlab/git/lfs_changes_spec.rb | 38 +++++++------- spec/lib/gitlab/gitaly_client/blob_service_spec.rb | 60 ++++++++++++++++++++++ spec/support/bare_repo_operations.rb | 14 +++-- 6 files changed, 170 insertions(+), 45 deletions(-) create mode 100644 spec/lib/gitlab/gitaly_client/blob_service_spec.rb diff --git a/lib/gitlab/git/lfs_changes.rb b/lib/gitlab/git/lfs_changes.rb index 48434047fce..b9e5cf258f4 100644 --- a/lib/gitlab/git/lfs_changes.rb +++ b/lib/gitlab/git/lfs_changes.rb @@ -7,6 +7,28 @@ module Gitlab end def new_pointers(object_limit: nil, not_in: nil) + @repository.gitaly_migrate(:blob_get_new_lfs_pointers) do |is_enabled| + if is_enabled + @repository.gitaly_blob_client.get_new_lfs_pointers(@newrev, object_limit, not_in) + else + git_new_pointers(object_limit, not_in) + end + end + end + + def all_pointers + @repository.gitaly_migrate(:blob_get_all_lfs_pointers) do |is_enabled| + if is_enabled + @repository.gitaly_blob_client.get_all_lfs_pointers(@newrev) + else + git_all_pointers + end + end + end + + private + + def git_new_pointers(object_limit, not_in) @new_pointers ||= begin rev_list.new_objects(not_in: not_in, require_path: true) do |object_ids| object_ids = object_ids.take(object_limit) if object_limit @@ -16,14 +38,12 @@ module Gitlab end end - def all_pointers + def git_all_pointers rev_list.all_objects(require_path: true) do |object_ids| Gitlab::Git::Blob.batch_lfs_pointers(@repository, object_ids) end end - private - def rev_list Gitlab::Git::RevList.new(@repository, newrev: @newrev) end diff --git a/lib/gitlab/gitaly_client/blob_service.rb b/lib/gitlab/gitaly_client/blob_service.rb index dfa0fa43b0f..28554208984 100644 --- a/lib/gitlab/gitaly_client/blob_service.rb +++ b/lib/gitlab/gitaly_client/blob_service.rb @@ -45,16 +45,7 @@ module Gitlab response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_lfs_pointers, request) - response.flat_map do |message| - message.lfs_pointers.map do |lfs_pointer| - Gitlab::Git::Blob.new( - id: lfs_pointer.oid, - size: lfs_pointer.size, - data: lfs_pointer.data, - binary: Gitlab::Git::Blob.binary?(lfs_pointer.data) - ) - end - end + map_lfs_pointers(response) end def get_blobs(revision_paths, limit = -1) @@ -80,6 +71,50 @@ module Gitlab GitalyClient::BlobsStitcher.new(response) end + + def get_new_lfs_pointers(revision, limit, not_in) + request = Gitaly::GetNewLFSPointersRequest.new( + repository: @gitaly_repo, + revision: encode_binary(revision), + limit: limit || 0 + ) + + if not_in.nil? || not_in == :all + request.not_in_all = true + else + request.not_in_refs += not_in + end + + response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_new_lfs_pointers, request) + + map_lfs_pointers(response) + end + + def get_all_lfs_pointers(revision) + request = Gitaly::GetNewLFSPointersRequest.new( + repository: @gitaly_repo, + revision: encode_binary(revision) + ) + + response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_all_lfs_pointers, request) + + map_lfs_pointers(response) + end + + private + + def map_lfs_pointers(response) + response.flat_map do |message| + message.lfs_pointers.map do |lfs_pointer| + Gitlab::Git::Blob.new( + id: lfs_pointer.oid, + size: lfs_pointer.size, + data: lfs_pointer.data, + binary: Gitlab::Git::Blob.binary?(lfs_pointer.data) + ) + end + end + end end end end diff --git a/spec/lib/gitlab/checks/lfs_integrity_spec.rb b/spec/lib/gitlab/checks/lfs_integrity_spec.rb index 17756621221..7201e4f7bf6 100644 --- a/spec/lib/gitlab/checks/lfs_integrity_spec.rb +++ b/spec/lib/gitlab/checks/lfs_integrity_spec.rb @@ -2,23 +2,25 @@ require 'spec_helper' describe Gitlab::Checks::LfsIntegrity do include ProjectForksHelper + let(:project) { create(:project, :repository) } - let(:newrev) { '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51' } + let(:repository) { project.repository } + let(:newrev) do + operations = BareRepoOperations.new(repository.path) + + # Create a commit not pointed at by any ref to emulate being in the + # pre-receive hook so that `--not --all` returns some objects + operations.commit_tree('8856a329dd38ca86dfb9ce5aa58a16d88cc119bd', "New LFS objects") + end subject { described_class.new(project, newrev) } describe '#objects_missing?' do - let(:blob_object) { project.repository.blob_at_branch('lfs', 'files/lfs/lfs_object.iso') } - - before do - allow_any_instance_of(Gitlab::Git::RevList).to receive(:new_objects) do |&lazy_block| - lazy_block.call([blob_object.id]) - end - end + let(:blob_object) { repository.blob_at_branch('lfs', 'files/lfs/lfs_object.iso') } context 'with LFS not enabled' do it 'skips integrity check' do - expect_any_instance_of(Gitlab::Git::RevList).not_to receive(:new_objects) + expect_any_instance_of(Gitlab::Git::LfsChanges).not_to receive(:new_pointers) subject.objects_missing? end @@ -33,7 +35,7 @@ describe Gitlab::Checks::LfsIntegrity do let(:newrev) { nil } it 'skips integrity check' do - expect_any_instance_of(Gitlab::Git::RevList).not_to receive(:new_objects) + expect_any_instance_of(Gitlab::Git::LfsChanges).not_to receive(:new_pointers) expect(subject.objects_missing?).to be_falsey end diff --git a/spec/lib/gitlab/git/lfs_changes_spec.rb b/spec/lib/gitlab/git/lfs_changes_spec.rb index c9007d7d456..d0dd8c6303f 100644 --- a/spec/lib/gitlab/git/lfs_changes_spec.rb +++ b/spec/lib/gitlab/git/lfs_changes_spec.rb @@ -7,34 +7,36 @@ describe Gitlab::Git::LfsChanges do subject { described_class.new(project.repository, newrev) } - describe 'new_pointers' do - before do - allow_any_instance_of(Gitlab::Git::RevList).to receive(:new_objects).and_yield([blob_object_id]) + describe '#new_pointers' do + shared_examples 'new pointers' do + it 'filters new objects to find lfs pointers' do + expect(subject.new_pointers(not_in: []).first.id).to eq(blob_object_id) + end + + it 'limits new_objects using object_limit' do + expect(subject.new_pointers(object_limit: 1)).to eq([]) + end end - it 'uses rev-list to find new objects' do - rev_list = double - allow(Gitlab::Git::RevList).to receive(:new).and_return(rev_list) - - expect(rev_list).to receive(:new_objects).and_return([]) - - subject.new_pointers + context 'with gitaly enabled' do + it_behaves_like 'new pointers' end - it 'filters new objects to find lfs pointers' do - expect(Gitlab::Git::Blob).to receive(:batch_lfs_pointers).with(project.repository, [blob_object_id]) + context 'with gitaly disabled', :skip_gitaly_mock do + it_behaves_like 'new pointers' - subject.new_pointers(object_limit: 1) - end + it 'uses rev-list to find new objects' do + rev_list = double + allow(Gitlab::Git::RevList).to receive(:new).and_return(rev_list) - it 'limits new_objects using object_limit' do - expect(Gitlab::Git::Blob).to receive(:batch_lfs_pointers).with(project.repository, []) + expect(rev_list).to receive(:new_objects).and_return([]) - subject.new_pointers(object_limit: 0) + subject.new_pointers + end end end - describe 'all_pointers' do + describe '#all_pointers', :skip_gitaly_mock do it 'uses rev-list to find all objects' do rev_list = double allow(Gitlab::Git::RevList).to receive(:new).and_return(rev_list) diff --git a/spec/lib/gitlab/gitaly_client/blob_service_spec.rb b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb new file mode 100644 index 00000000000..a2770ef2fe4 --- /dev/null +++ b/spec/lib/gitlab/gitaly_client/blob_service_spec.rb @@ -0,0 +1,60 @@ +require 'spec_helper' + +describe Gitlab::GitalyClient::BlobService do + let(:project) { create(:project, :repository) } + let(:storage_name) { project.repository_storage } + let(:relative_path) { project.disk_path + '.git' } + let(:repository) { project.repository } + let(:client) { described_class.new(repository) } + + describe '#get_new_lfs_pointers' do + let(:revision) { 'master' } + let(:limit) { 5 } + let(:not_in) { ['branch-a', 'branch-b'] } + let(:expected_params) do + { revision: revision, limit: limit, not_in_refs: not_in, not_in_all: false } + end + + subject { client.get_new_lfs_pointers(revision, limit, not_in) } + + it 'sends a get_new_lfs_pointers message' do + expect_any_instance_of(Gitaly::BlobService::Stub) + .to receive(:get_new_lfs_pointers) + .with(gitaly_request_with_params(expected_params), kind_of(Hash)) + .and_return([]) + + subject + end + + context 'with not_in = :all' do + let(:not_in) { :all } + let(:expected_params) do + { revision: revision, limit: limit, not_in_refs: [], not_in_all: true } + end + + it 'sends the correct message' do + expect_any_instance_of(Gitaly::BlobService::Stub) + .to receive(:get_new_lfs_pointers) + .with(gitaly_request_with_params(expected_params), kind_of(Hash)) + .and_return([]) + + subject + end + end + end + + describe '#get_all_lfs_pointers' do + let(:revision) { 'master' } + + subject { client.get_all_lfs_pointers(revision) } + + it 'sends a get_all_lfs_pointers message' do + expect_any_instance_of(Gitaly::BlobService::Stub) + .to receive(:get_all_lfs_pointers) + .with(gitaly_request_with_params(revision: revision), kind_of(Hash)) + .and_return([]) + + subject + end + end +end diff --git a/spec/support/bare_repo_operations.rb b/spec/support/bare_repo_operations.rb index 38d11992dc2..8eeaa37d3c5 100644 --- a/spec/support/bare_repo_operations.rb +++ b/spec/support/bare_repo_operations.rb @@ -11,6 +11,14 @@ class BareRepoOperations @path_to_repo = path_to_repo end + def commit_tree(tree_id, msg, parent: EMPTY_TREE_ID) + commit_tree_args = ['commit-tree', tree_id, '-m', msg] + commit_tree_args += ['-p', parent] unless parent == EMPTY_TREE_ID + commit_id = execute(commit_tree_args) + + commit_id[0] + end + # Based on https://stackoverflow.com/a/25556917/1856239 def commit_file(file, dst_path, branch = 'master') head_id = execute(['show', '--format=format:%H', '--no-patch', branch], allow_failure: true)[0] || EMPTY_TREE_ID @@ -26,11 +34,9 @@ class BareRepoOperations tree_id = execute(['write-tree']) - commit_tree_args = ['commit-tree', tree_id[0], '-m', "Add #{dst_path}"] - commit_tree_args += ['-p', head_id] unless head_id == EMPTY_TREE_ID - commit_id = execute(commit_tree_args) + commit_id = commit_tree(tree_id[0], "Add #{dst_path}", parent: head_id) - execute(['update-ref', "refs/heads/#{branch}", commit_id[0]]) + execute(['update-ref', "refs/heads/#{branch}", commit_id]) end private -- cgit v1.2.1 From 94dc43f37ce20347b5ad67aee0cf90f5c86a814f Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 2 Mar 2018 10:14:19 -0800 Subject: Clean up backup/restore temporary directory --- spec/lib/backup/repository_spec.rb | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb index f7b1a61f4f8..a9b5ed1112a 100644 --- a/spec/lib/backup/repository_spec.rb +++ b/spec/lib/backup/repository_spec.rb @@ -28,6 +28,23 @@ describe Backup::Repository do end describe '#restore' do + subject { described_class.new } + + let(:timestamp) { Time.utc(2017, 3, 22) } + let(:temp_dirs) do + Gitlab.config.repositories.storages.map do |name, storage| + File.join(storage['path'], '..', 'repositories.old.' + timestamp.to_i.to_s) + end + end + + around do |example| + Timecop.freeze(timestamp) { example.run } + end + + after do + temp_dirs.each { |path| FileUtils.rm_rf(path) } + end + describe 'command failure' do before do allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1]) @@ -35,7 +52,7 @@ describe Backup::Repository do context 'hashed storage' do it 'shows the appropriate error' do - described_class.new.restore + subject.restore expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} (#{project.disk_path}) - error") end @@ -45,7 +62,7 @@ describe Backup::Repository do let!(:project) { create(:project, :legacy_storage) } it 'shows the appropriate error' do - described_class.new.restore + subject.restore expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error") end -- cgit v1.2.1 From bb216e2b7cb15054381211844103d29c60f15424 Mon Sep 17 00:00:00 2001 From: Takuya Noguchi Date: Sun, 4 Mar 2018 20:15:43 +0900 Subject: Stop loading spinner on error of milestone update on issue --- app/assets/javascripts/milestone_select.js | 3 +++ .../unreleased/43837-error-handle-in-updating-milestone-on-issue.yml | 5 +++++ 2 files changed, 8 insertions(+) create mode 100644 changelogs/unreleased/43837-error-handle-in-updating-milestone-on-issue.yml diff --git a/app/assets/javascripts/milestone_select.js b/app/assets/javascripts/milestone_select.js index 2841ecb558b..c259d5405bd 100644 --- a/app/assets/javascripts/milestone_select.js +++ b/app/assets/javascripts/milestone_select.js @@ -216,6 +216,9 @@ export default class MilestoneSelect { $value.html(milestoneLinkNoneTemplate); return $sidebarCollapsedValue.find('span').text('No'); } + }) + .catch(() => { + $loading.fadeOut(); }); } } diff --git a/changelogs/unreleased/43837-error-handle-in-updating-milestone-on-issue.yml b/changelogs/unreleased/43837-error-handle-in-updating-milestone-on-issue.yml new file mode 100644 index 00000000000..526523964c3 --- /dev/null +++ b/changelogs/unreleased/43837-error-handle-in-updating-milestone-on-issue.yml @@ -0,0 +1,5 @@ +--- +title: Stop loading spinner on error of milestone update on issue +merge_request: 17507 +author: Takuya Noguchi +type: fixed -- cgit v1.2.1 From 95cb9b229f8486a38c6a2e47db8fcc24e06c4ecd Mon Sep 17 00:00:00 2001 From: Victor Wu Date: Mon, 26 Feb 2018 15:54:52 +0000 Subject: Add severity labels to contributing.md --- CONTRIBUTING.md | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dfe4bf65f9f..76ee6265c5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -196,6 +196,17 @@ release. There are two levels of priority labels: milestone. If these issues are not done in the current release, they will strongly be considered for the next release. +### Severity labels (~S1, ~S2, etc.) + +Severity labels help us clearly communicate the impact of a ~bug on users. + +| Label | Meaning | Example | +|-------|------------------------------------------|---------| +| ~S1 | Feature broken, no workaround | Unable to create an issue | +| ~S2 | Feature broken, workaround unacceptable | Can push commits, but only via the command line | +| ~S3 | Feature broken, workaround acceptable | Can create merge requests only from the Merge Requests page, not through the Issue | +| ~S4 | Cosmetic issue | Label colors are incorrect / not being displayed | + ### Label for community contributors (~"Accepting Merge Requests") Issues that are beneficial to our users, 'nice to haves', that we currently do @@ -397,9 +408,9 @@ For issues related to the open source stewardship of GitLab, there is the ~"stewardship" label. This label is to be used for issues in which the stewardship of GitLab -is a topic of discussion. For instance if GitLab Inc. is planning to remove -features from GitLab CE to make exclusive in GitLab EE, related issues -would be labelled with ~"stewardship". +is a topic of discussion. For instance if GitLab Inc. is planning to add +features from GitLab EE to GitLab CE, related issues would be labelled with +~"stewardship". A recent example of this was the issue for [bringing the time tracking API to GitLab CE][time-tracking-issue]. -- cgit v1.2.1 From 477c83f6bbbc9d0a447826900d45207e145e7261 Mon Sep 17 00:00:00 2001 From: Tiago Botelho Date: Mon, 5 Mar 2018 11:31:23 +0000 Subject: Ports https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/4747 to CE --- config/initializers/forbid_sidekiq_in_transactions.rb | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/config/initializers/forbid_sidekiq_in_transactions.rb b/config/initializers/forbid_sidekiq_in_transactions.rb index cb611aa21df..4cf1d455eb4 100644 --- a/config/initializers/forbid_sidekiq_in_transactions.rb +++ b/config/initializers/forbid_sidekiq_in_transactions.rb @@ -18,13 +18,26 @@ module Sidekiq %i(perform_async perform_at perform_in).each do |name| define_method(name) do |*args| if !Sidekiq::Worker.skip_transaction_check && AfterCommitQueue.inside_transaction? - raise Sidekiq::Worker::EnqueueFromTransactionError, <<~MSG + begin + raise Sidekiq::Worker::EnqueueFromTransactionError, <<~MSG `#{self}.#{name}` cannot be called inside a transaction as this can lead to race conditions when the worker runs before the transaction is committed and tries to access a model that has not been saved yet. Use an `after_commit` hook, or include `AfterCommitQueue` and use a `run_after_commit` block instead. - MSG + MSG + rescue Sidekiq::Worker::EnqueueFromTransactionError => e + if Rails.env.production? + Rails.logger.error(e.message) + + if Gitlab::Sentry.enabled? + Gitlab::Sentry.context + Raven.capture_exception(e) + end + else + raise + end + end end super(*args) -- cgit v1.2.1 From 462f9e93875845eb46853ad47a60b37ccce2771c Mon Sep 17 00:00:00 2001 From: Marcia Ramos Date: Mon, 5 Mar 2018 11:44:42 +0000 Subject: Docs: update ssh doc --- doc/ssh/README.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 47 insertions(+), 4 deletions(-) diff --git a/doc/ssh/README.md b/doc/ssh/README.md index 33a2d7a88a7..aa14a39e4c9 100644 --- a/doc/ssh/README.md +++ b/doc/ssh/README.md @@ -35,8 +35,8 @@ to clipboard step. If you don't see the string or would like to generate a SSH key pair with a custom name continue onto the next step. -> -**Note:** Public SSH key may also be named as follows: +Note that Public SSH key may also be named as follows: + - `id_dsa.pub` - `id_ecdsa.pub` - `id_ed25519.pub` @@ -73,7 +73,7 @@ custom name continue onto the next step. key pair, but it is not required and you can skip creating a password by pressing enter. - >**Note:** + NOTE: **Note:** If you want to change the password of your SSH key pair, you can use `ssh-keygen -p `. @@ -162,11 +162,13 @@ That's why it needs to uniquely map to a single user. ## Deploy keys +### Per-repository deploy keys + Deploy keys allow read-only or read-write (if enabled) access to one or multiple projects with a single SSH key pair. This is really useful for cloning repositories to your Continuous -Integration (CI) server. By using deploy keys, you don't have to setup a +Integration (CI) server. By using deploy keys, you don't have to set up a dummy user account. If you are a project master or owner, you can add a deploy key in the @@ -185,6 +187,47 @@ a group. Deploy keys can be shared between projects, you just need to add them to each project. +### Global shared deploy keys + +Global Shared Deploy keys allow read-only or read-write (if enabled) access to +be configured on any repository in the entire GitLab installation. + +This is really useful for integrating repositories to secured, shared Continuous +Integration (CI) services or other shared services. +GitLab administrators can set up the Global Shared Deploy key in GitLab and +add the private key to any shared systems. Individual repositories opt into +exposing their repsitory using these keys when a project masters (or higher) +authorizes a Global Shared Deploy key to be used with their project. + +Global Shared Keys can provide greater security compared to Per-Project Deploy +Keys since an administrator of the target integrated system is the only one +who needs to know and configure the private key. + +GitLab administrators set up Global Deploy keys in the Admin area under the +section **Deploy Keys**. Ensure keys have a meaningful title as that will be +the primary way for project masters and owners to identify the correct Global +Deploy key to add. For instance, if the key gives access to a SaaS CI instance, +use the name of that service in the key name if that is all it is used for. +When creating Global Shared Deploy keys, give some thought to the granularity +of keys - they could be of very narrow usage such as just a specific service or +of broader usage for something like "Anywhere you need to give read access to +your repository". + +Once a GitLab administrator adds the Global Deployment key, project masters +and owners can add it in project's **Settings > Repository** section by expanding the +**Deploy Key** section and clicking **Enable** next to the appropriate key listed +under **Public deploy keys available to any project**. + +NOTE: **Note:** +The heading **Public deploy keys available to any project** only appears +if there is at least one Global Deploy Key configured. + +CAUTION: **Warning:** +Defining Global Deploy Keys does not expose any given repository via +the key until that respository adds the Global Deploy Key to their project. +In this way the Global Deploy Keys enable access by other systems, but do +not implicitly give any access just by setting them up. + ## Applications ### Eclipse -- cgit v1.2.1 From a83db3c9ea7b719f7839438709666ac8f745b8d4 Mon Sep 17 00:00:00 2001 From: Jakub Kadlubiec Date: Mon, 5 Mar 2018 11:56:17 +0000 Subject: Add a note about the "List MR pipelines" endpoint only being available from 10.5.0 --- doc/api/merge_requests.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index cb9b0618767..3e60e001bc1 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -494,6 +494,8 @@ Parameters: ## List MR pipelines +> [Introduced][ce-15454] in GitLab 10.5.0. + Get a list of merge request pipelines. ``` @@ -1449,3 +1451,4 @@ Example response: [ce-13060]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/13060 [ce-14016]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14016 +[ce-15454]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15454 -- cgit v1.2.1 From 53de2737fd322332b67cbab1cb2df403ec7272a3 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:03:03 +0530 Subject: Allow exposing methods to `gon_helper` --- app/helpers/labels_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index c1c19062c91..b2c641a5dbd 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -1,4 +1,5 @@ module LabelsHelper + extend self include ActionView::Helpers::TagHelper def show_label_issuables_link?(label, issuables_type, current_user: nil, project: nil) -- cgit v1.2.1 From e7789ed91934360f7d7ffda4a15c6cfe6ee0101a Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:03:25 +0530 Subject: Add `suggest_colors` from LabelsHelper --- lib/gitlab/gon_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/gitlab/gon_helper.rb b/lib/gitlab/gon_helper.rb index 92f0e0402a8..a7e055ac444 100644 --- a/lib/gitlab/gon_helper.rb +++ b/lib/gitlab/gon_helper.rb @@ -20,6 +20,7 @@ module Gitlab gon.sprite_icons = IconsHelper.sprite_icon_path gon.sprite_file_icons = IconsHelper.sprite_file_icons_path gon.test_env = Rails.env.test? + gon.suggested_label_colors = LabelsHelper.suggested_colors if current_user gon.current_user_id = current_user.id -- cgit v1.2.1 From 3005d6046484d6cf42fe02aa7577ad123e4d240d Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:04:59 +0530 Subject: ListLabel Shared Model --- app/assets/javascripts/boards/models/label.js | 15 --------------- app/assets/javascripts/vue_shared/models/label.js | 13 +++++++++++++ 2 files changed, 13 insertions(+), 15 deletions(-) delete mode 100644 app/assets/javascripts/boards/models/label.js create mode 100644 app/assets/javascripts/vue_shared/models/label.js diff --git a/app/assets/javascripts/boards/models/label.js b/app/assets/javascripts/boards/models/label.js deleted file mode 100644 index 98c1ec014c4..00000000000 --- a/app/assets/javascripts/boards/models/label.js +++ /dev/null @@ -1,15 +0,0 @@ -/* eslint-disable no-unused-vars, space-before-function-paren */ - -class ListLabel { - constructor (obj) { - this.id = obj.id; - this.title = obj.title; - this.type = obj.type; - this.color = obj.color; - this.textColor = obj.text_color; - this.description = obj.description; - this.priority = (obj.priority !== null) ? obj.priority : Infinity; - } -} - -window.ListLabel = ListLabel; diff --git a/app/assets/javascripts/vue_shared/models/label.js b/app/assets/javascripts/vue_shared/models/label.js new file mode 100644 index 00000000000..70b9efe0c68 --- /dev/null +++ b/app/assets/javascripts/vue_shared/models/label.js @@ -0,0 +1,13 @@ +class ListLabel { + constructor(obj) { + this.id = obj.id; + this.title = obj.title; + this.type = obj.type; + this.color = obj.color; + this.textColor = obj.text_color; + this.description = obj.description; + this.priority = (obj.priority !== null) ? obj.priority : Infinity; + } +} + +window.ListLabel = ListLabel; -- cgit v1.2.1 From 3fe4ea8018fa3f4fa8482f03d228b409be774571 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:05:58 +0530 Subject: Update path for ListLabel model --- app/assets/javascripts/boards/index.js | 2 +- spec/javascripts/boards/board_card_spec.js | 2 +- spec/javascripts/boards/boards_store_spec.js | 2 +- spec/javascripts/boards/issue_card_spec.js | 2 +- spec/javascripts/boards/issue_spec.js | 2 +- spec/javascripts/boards/list_spec.js | 2 +- spec/javascripts/boards/modal_store_spec.js | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 8e31f1865f0..8b34fe232c2 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -5,12 +5,12 @@ import Vue from 'vue'; import Flash from '~/flash'; import { __ } from '~/locale'; +import '~/vue_shared/models/label'; import FilteredSearchBoards from './filtered_search_boards'; import eventHub from './eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; // eslint-disable-line import/first import './models/issue'; -import './models/label'; import './models/list'; import './models/milestone'; import './models/assignee'; diff --git a/spec/javascripts/boards/board_card_spec.js b/spec/javascripts/boards/board_card_spec.js index 80a598e63bd..13d607a06d2 100644 --- a/spec/javascripts/boards/board_card_spec.js +++ b/spec/javascripts/boards/board_card_spec.js @@ -9,8 +9,8 @@ import axios from '~/lib/utils/axios_utils'; import '~/boards/models/assignee'; import eventHub from '~/boards/eventhub'; +import '~/vue_shared/models/label'; import '~/boards/models/list'; -import '~/boards/models/label'; import '~/boards/stores/boards_store'; import boardCard from '~/boards/components/board_card.vue'; import { listObj, boardsMockInterceptor, mockBoardService } from './mock_data'; diff --git a/spec/javascripts/boards/boards_store_spec.js b/spec/javascripts/boards/boards_store_spec.js index 8411f4dd8a6..0cf9e4c9ba1 100644 --- a/spec/javascripts/boards/boards_store_spec.js +++ b/spec/javascripts/boards/boards_store_spec.js @@ -7,8 +7,8 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import Cookies from 'js-cookie'; +import '~/vue_shared/models/label'; import '~/boards/models/issue'; -import '~/boards/models/label'; import '~/boards/models/list'; import '~/boards/models/assignee'; import '~/boards/services/board_service'; diff --git a/spec/javascripts/boards/issue_card_spec.js b/spec/javascripts/boards/issue_card_spec.js index 278155c585e..37088a6421c 100644 --- a/spec/javascripts/boards/issue_card_spec.js +++ b/spec/javascripts/boards/issue_card_spec.js @@ -4,8 +4,8 @@ import Vue from 'vue'; +import '~/vue_shared/models/label'; import '~/boards/models/issue'; -import '~/boards/models/label'; import '~/boards/models/list'; import '~/boards/models/assignee'; import '~/boards/stores/boards_store'; diff --git a/spec/javascripts/boards/issue_spec.js b/spec/javascripts/boards/issue_spec.js index dbbe14fe3e0..4a11131b55c 100644 --- a/spec/javascripts/boards/issue_spec.js +++ b/spec/javascripts/boards/issue_spec.js @@ -3,8 +3,8 @@ /* global ListIssue */ import Vue from 'vue'; +import '~/vue_shared/models/label'; import '~/boards/models/issue'; -import '~/boards/models/label'; import '~/boards/models/list'; import '~/boards/models/assignee'; import '~/boards/services/board_service'; diff --git a/spec/javascripts/boards/list_spec.js b/spec/javascripts/boards/list_spec.js index 34964b20b05..d9a1d692949 100644 --- a/spec/javascripts/boards/list_spec.js +++ b/spec/javascripts/boards/list_spec.js @@ -6,8 +6,8 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import _ from 'underscore'; +import '~/vue_shared/models/label'; import '~/boards/models/issue'; -import '~/boards/models/label'; import '~/boards/models/list'; import '~/boards/models/assignee'; import '~/boards/services/board_service'; diff --git a/spec/javascripts/boards/modal_store_spec.js b/spec/javascripts/boards/modal_store_spec.js index 7eecb58a4c3..e9d77f035e3 100644 --- a/spec/javascripts/boards/modal_store_spec.js +++ b/spec/javascripts/boards/modal_store_spec.js @@ -1,7 +1,7 @@ /* global ListIssue */ +import '~/vue_shared/models/label'; import '~/boards/models/issue'; -import '~/boards/models/label'; import '~/boards/models/list'; import '~/boards/models/assignee'; import '~/boards/stores/modal_store'; -- cgit v1.2.1 From 8c9094cbaf9fa1f5eda5039448f759f745123186 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:07:01 +0530 Subject: LabelsSelectComponent tests mock data --- .../components/sidebar/labels_select/mock_data.js | 49 ++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js new file mode 100644 index 00000000000..e9008c29b22 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/mock_data.js @@ -0,0 +1,49 @@ +export const mockLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, +]; + +export const mockSuggestedColors = [ + '#0033CC', + '#428BCA', + '#44AD8E', + '#A8D695', + '#5CB85C', + '#69D100', + '#004E00', + '#34495E', + '#7F8C8D', + '#A295D6', + '#5843AD', + '#8E44AD', + '#FFECDB', + '#AD4363', + '#D10069', + '#CC0033', + '#FF0000', + '#D9534F', + '#D1D100', + '#F0AD4E', + '#AD8D43', +]; + +export const mockConfig = { + showCreate: true, + abilityName: 'issue', + context: { + labels: mockLabels, + }, + namespace: 'gitlab-org', + updatePath: '/gitlab-org/my-project/issue/1', + labelsPath: '/gitlab-org/my-project/labels.json', + labelsWebUrl: '/gitlab-org/my-project/labels', + labelFilterBasePath: '/gitlab-org/my-project/issues', + canEdit: true, + suggestedColors: mockSuggestedColors, + emptyValueText: 'None', +}; -- cgit v1.2.1 From 7d58f0e088431b04639639018c36434fb95c2cb9 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:07:33 +0530 Subject: LabelsSelect Base Component --- .../components/sidebar/labels_select/base.vue | 149 +++++++++++++++++++++ .../components/sidebar/labels_select/base_spec.js | 81 +++++++++++ 2 files changed, 230 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue new file mode 100644 index 00000000000..3b17135f0e5 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue @@ -0,0 +1,149 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js new file mode 100644 index 00000000000..67056793a20 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/base_spec.js @@ -0,0 +1,81 @@ +import Vue from 'vue'; + +import LabelsSelect from '~/labels_select'; +import baseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue'; + +import { mockConfig, mockLabels } from './mock_data'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const createComponent = (config = mockConfig) => { + const Component = Vue.extend(baseComponent); + + return mountComponent(Component, config); +}; + +describe('BaseComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('hiddenInputName', () => { + it('returns correct string when showCreate prop is `true`', () => { + expect(vm.hiddenInputName).toBe('issue[label_names][]'); + }); + + it('returns correct string when showCreate prop is `false`', () => { + const mockConfigNonEditable = Object.assign({}, mockConfig, { showCreate: false }); + const vmNonEditable = createComponent(mockConfigNonEditable); + expect(vmNonEditable.hiddenInputName).toBe('label_id[]'); + vmNonEditable.$destroy(); + }); + }); + }); + + describe('methods', () => { + describe('handleClick', () => { + it('emits onLabelClick event with label and list of labels as params', () => { + spyOn(vm, '$emit'); + vm.handleClick(mockLabels[0]); + expect(vm.$emit).toHaveBeenCalledWith('onLabelClick', mockLabels[0]); + }); + }); + }); + + describe('mounted', () => { + it('creates LabelsSelect object and assigns it to `labelsDropdon` as prop', () => { + expect(vm.labelsDropdown instanceof LabelsSelect).toBe(true); + }); + }); + + describe('template', () => { + it('renders component container element with classes `block labels`', () => { + expect(vm.$el.classList.contains('block')).toBe(true); + expect(vm.$el.classList.contains('labels')).toBe(true); + }); + + it('renders `.selectbox` element', () => { + expect(vm.$el.querySelector('.selectbox')).not.toBeNull(); + expect(vm.$el.querySelector('.selectbox').getAttribute('style')).toBe('display: none;'); + }); + + it('renders `.dropdown` element', () => { + expect(vm.$el.querySelector('.dropdown')).not.toBeNull(); + }); + + it('renders `.dropdown-menu` element', () => { + const dropdownMenuEl = vm.$el.querySelector('.dropdown-menu'); + expect(dropdownMenuEl).not.toBeNull(); + expect(dropdownMenuEl.querySelector('.dropdown-page-one')).not.toBeNull(); + expect(dropdownMenuEl.querySelector('.dropdown-content')).not.toBeNull(); + expect(dropdownMenuEl.querySelector('.dropdown-loading')).not.toBeNull(); + }); + }); +}); -- cgit v1.2.1 From 5c8854864ae4886c8a642536f6b4297a633144d2 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:07:52 +0530 Subject: LabelsSelect DropdownButton Component --- .../sidebar/labels_select/dropdown_button.vue | 78 ++++++++++++++++++++ .../sidebar/labels_select/dropdown_button_spec.js | 82 ++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue new file mode 100644 index 00000000000..47497c1de98 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue @@ -0,0 +1,78 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js new file mode 100644 index 00000000000..ec63ac306d0 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js @@ -0,0 +1,82 @@ +import Vue from 'vue'; + +import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue'; + +import { mockConfig, mockLabels } from './mock_data'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const componentConfig = Object.assign({}, mockConfig, { + fieldName: 'label_id[]', + labels: mockLabels, + showExtraOptions: false, +}); + +const createComponent = (config = componentConfig) => { + const Component = Vue.extend(dropdownButtonComponent); + + return mountComponent(Component, config); +}; + +describe('DropdownButtonComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('dropdownToggleText', () => { + it('returns text as `Label` when `labels` prop is empty array', () => { + const mockEmptyLabels = Object.assign({}, componentConfig, { labels: [] }); + const vmEmptyLabels = createComponent(mockEmptyLabels); + expect(vmEmptyLabels.dropdownToggleText).toBe('Label'); + vmEmptyLabels.$destroy(); + }); + + it('returns first label name with remaining label count when `labels` prop has more than one item', () => { + const mockMoreLabels = Object.assign({}, componentConfig, { + labels: mockLabels.concat(mockLabels), + }); + const vmMoreLabels = createComponent(mockMoreLabels); + expect(vmMoreLabels.dropdownToggleText).toBe('Foo Label +1 more'); + vmMoreLabels.$destroy(); + }); + + it('returns first label name when `labels` prop has only one item present', () => { + expect(vm.dropdownToggleText).toBe('Foo Label'); + }); + }); + }); + + describe('template', () => { + it('renders component container element of type `button`', () => { + expect(vm.$el.nodeName).toBe('BUTTON'); + }); + + it('renders component container element with required data attributes', () => { + expect(vm.$el.dataset.abilityName).toBe(vm.abilityName); + expect(vm.$el.dataset.fieldName).toBe(vm.fieldName); + expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath); + expect(vm.$el.dataset.labels).toBe(vm.labelsPath); + expect(vm.$el.dataset.namespacePath).toBe(vm.namespace); + expect(vm.$el.dataset.showAny).not.toBeDefined(); + }); + + it('renders dropdown toggle text element', () => { + const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text'); + expect(dropdownToggleTextEl).not.toBeNull(); + expect(dropdownToggleTextEl.innerText.trim()).toBe('Foo Label'); + }); + + it('renders dropdown button icon', () => { + const dropdownIconEl = vm.$el.querySelector('i.fa'); + expect(dropdownIconEl).not.toBeNull(); + expect(dropdownIconEl.classList.contains('fa-chevron-down')).toBe(true); + }); + }); +}); -- cgit v1.2.1 From 523093220beab3c2768a748542829242f6a22c06 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:08:13 +0530 Subject: LabelsSelect DropdownCreateLabel Component --- .../labels_select/dropdown_create_label.vue | 84 ++++++++++++++++++++++ .../labels_select/dropdown_create_label_spec.js | 84 ++++++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue new file mode 100644 index 00000000000..4200d1e8473 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue @@ -0,0 +1,84 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js new file mode 100644 index 00000000000..f07aefb2f87 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; + +import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue'; + +import { mockSuggestedColors } from './mock_data'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const createComponent = () => { + const Component = Vue.extend(dropdownCreateLabelComponent); + + return mountComponent(Component); +}; + +describe('DropdownCreateLabelComponent', () => { + let vm; + + beforeEach(() => { + gon.suggested_label_colors = mockSuggestedColors; + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('created', () => { + it('initializes `suggestedColors` prop on component from `gon.suggested_color_labels` object', () => { + expect(vm.suggestedColors.length).toBe(mockSuggestedColors.length); + }); + }); + + describe('template', () => { + it('renders component container element with classes `dropdown-page-two dropdown-new-label`', () => { + expect(vm.$el.classList.contains('dropdown-page-two', 'dropdown-new-label')).toBe(true); + }); + + it('renders `Go back` button on component header', () => { + const backButtonEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-back'); + expect(backButtonEl).not.toBe(null); + expect(backButtonEl.querySelector('.fa-arrow-left')).not.toBe(null); + }); + + it('renders component header element', () => { + const headerEl = vm.$el.querySelector('.dropdown-title'); + expect(headerEl.innerText.trim()).toContain('Create new label'); + }); + + it('renders `Close` button on component header', () => { + const closeButtonEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-close'); + expect(closeButtonEl).not.toBe(null); + expect(closeButtonEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBe(null); + }); + + it('renders `Name new label` input element', () => { + expect(vm.$el.querySelector('.dropdown-labels-error.js-label-error')).not.toBe(null); + expect(vm.$el.querySelector('input#new_label_name.default-dropdown-input')).not.toBe(null); + }); + + it('renders suggested colors list elements', () => { + const colorsListContainerEl = vm.$el.querySelector('.suggest-colors.suggest-colors-dropdown'); + expect(colorsListContainerEl).not.toBe(null); + expect(colorsListContainerEl.querySelectorAll('a').length).toBe(mockSuggestedColors.length); + + const colorItemEl = colorsListContainerEl.querySelectorAll('a')[0]; + expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0]); + expect(colorItemEl.getAttribute('style')).toBe('background-color: rgb(0, 51, 204);'); + }); + + it('renders color input element', () => { + expect(vm.$el.querySelector('.dropdown-label-color-input')).not.toBe(null); + expect(vm.$el.querySelector('.dropdown-label-color-preview.js-dropdown-label-color-preview')).not.toBe(null); + expect(vm.$el.querySelector('input#new_label_color.default-dropdown-input')).not.toBe(null); + }); + + it('renders component action buttons', () => { + const createBtnEl = vm.$el.querySelector('button.js-new-label-btn'); + const cancelBtnEl = vm.$el.querySelector('button.js-cancel-label-btn'); + expect(createBtnEl).not.toBe(null); + expect(createBtnEl.innerText.trim()).toBe('Create'); + expect(cancelBtnEl.innerText.trim()).toBe('Cancel'); + }); + }); +}); -- cgit v1.2.1 From ab1bc5c7f76bcb893004749918a2913c2c965a1b Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:08:44 +0530 Subject: LabelsSelect DropdownFooter Component --- .../sidebar/labels_select/dropdown_footer.vue | 34 ++++++++++++++++++ .../sidebar/labels_select/dropdown_footer_spec.js | 42 ++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue new file mode 100644 index 00000000000..e951a863811 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue @@ -0,0 +1,34 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js new file mode 100644 index 00000000000..809e0327b1c --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; + +import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue'; + +import { mockConfig } from './mock_data'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const createComponent = (labelsWebUrl = mockConfig.labelsWebUrl) => { + const Component = Vue.extend(dropdownFooterComponent); + + return mountComponent(Component, { + labelsWebUrl, + }); +}; + +describe('DropdownFooterComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('renders `Create new label` link element', () => { + const createLabelEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-toggle-page'); + expect(createLabelEl).not.toBeNull(); + expect(createLabelEl.innerText.trim()).toBe('Create new label'); + }); + + it('renders `Manage labels` link element', () => { + const manageLabelsEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-external-link'); + expect(manageLabelsEl).not.toBeNull(); + expect(manageLabelsEl.getAttribute('href')).toBe(vm.labelsWebUrl); + expect(manageLabelsEl.innerText.trim()).toBe('Manage labels'); + }); + }); +}); -- cgit v1.2.1 From 5989aa18f4ec49973e4d42015c1af8c93d3aed1f Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:08:56 +0530 Subject: LabelsSelect DropdownHeader Component --- .../sidebar/labels_select/dropdown_header.vue | 21 +++++++++++++ .../sidebar/labels_select/dropdown_header_spec.js | 36 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue new file mode 100644 index 00000000000..7664acdf19c --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue @@ -0,0 +1,21 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js new file mode 100644 index 00000000000..325fa47c957 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js @@ -0,0 +1,36 @@ +import Vue from 'vue'; + +import dropdownHeaderComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_header.vue'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const createComponent = () => { + const Component = Vue.extend(dropdownHeaderComponent); + + return mountComponent(Component); +}; + +describe('DropdownHeaderComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('renders header text element', () => { + const headerEl = vm.$el.querySelector('.dropdown-title span'); + expect(headerEl.innerText.trim()).toBe('Assign labels'); + }); + + it('renders `Close` button element', () => { + const closeBtnEl = vm.$el.querySelector('.dropdown-title button.dropdown-title-button.dropdown-menu-close'); + expect(closeBtnEl).not.toBeNull(); + expect(closeBtnEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBeNull(); + }); + }); +}); -- cgit v1.2.1 From b4bd9777fb519a537c011d87651d29683587893e Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:09:12 +0530 Subject: LabelsSelect DropdownHiddenInput Component --- .../labels_select/dropdown_hidden_input.vue | 22 +++++++++++++ .../labels_select/dropdown_hidden_input_spec.js | 37 ++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue new file mode 100644 index 00000000000..1832c3c1757 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue @@ -0,0 +1,22 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js new file mode 100644 index 00000000000..703b87498c7 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_hidden_input_spec.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; + +import dropdownHiddenInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_hidden_input.vue'; + +import { mockLabels } from './mock_data'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const createComponent = (name = 'label_id[]', label = mockLabels[0]) => { + const Component = Vue.extend(dropdownHiddenInputComponent); + + return mountComponent(Component, { + name, + label, + }); +}; + +describe('DropdownHiddenInputComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('renders input element of type `hidden`', () => { + expect(vm.$el.nodeName).toBe('INPUT'); + expect(vm.$el.getAttribute('type')).toBe('hidden'); + expect(vm.$el.getAttribute('name')).toBe(vm.name); + expect(vm.$el.getAttribute('value')).toBe(`${vm.label.id}`); + }); + }); +}); -- cgit v1.2.1 From 2e5497f0057021daa57d3e8958c840f6b8a82098 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:09:27 +0530 Subject: LabelsSelect DropdownSearchInput Component --- .../labels_select/dropdown_search_input.vue | 27 +++++++++++++++ .../labels_select/dropdown_search_input_spec.js | 39 ++++++++++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue new file mode 100644 index 00000000000..ae633460c95 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue @@ -0,0 +1,27 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js new file mode 100644 index 00000000000..69e11d966c2 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js @@ -0,0 +1,39 @@ +import Vue from 'vue'; + +import dropdownSearchInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const createComponent = () => { + const Component = Vue.extend(dropdownSearchInputComponent); + + return mountComponent(Component); +}; + +describe('DropdownSearchInputComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('renders input element with type `search`', () => { + const inputEl = vm.$el.querySelector('input.dropdown-input-field'); + expect(inputEl).not.toBeNull(); + expect(inputEl.getAttribute('type')).toBe('search'); + }); + + it('renders search icon element', () => { + expect(vm.$el.querySelector('.fa-search.dropdown-input-search')).not.toBeNull(); + }); + + it('renders clear search icon element', () => { + expect(vm.$el.querySelector('.fa-times.dropdown-input-clear.js-dropdown-input-clear')).not.toBeNull(); + }); + }); +}); -- cgit v1.2.1 From 8b44ad6e6ab5408dca800b9cdb6a29335704109a Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:09:43 +0530 Subject: LabelsSelect DropdownTitle Component --- .../sidebar/labels_select/dropdown_title.vue | 30 ++++++++++++++++ .../sidebar/labels_select/dropdown_title_spec.js | 42 ++++++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue new file mode 100644 index 00000000000..7da82e90e29 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue @@ -0,0 +1,30 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js new file mode 100644 index 00000000000..c3580933072 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js @@ -0,0 +1,42 @@ +import Vue from 'vue'; + +import dropdownTitleComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_title.vue'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const createComponent = (canEdit = true) => { + const Component = Vue.extend(dropdownTitleComponent); + + return mountComponent(Component, { + canEdit, + }); +}; + +describe('DropdownTitleComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('template', () => { + it('renders title text', () => { + expect(vm.$el.classList.contains('title', 'hide-collapsed')).toBe(true); + expect(vm.$el.innerText.trim()).toContain('Labels'); + }); + + it('renders spinner icon element', () => { + expect(vm.$el.querySelector('.fa-spinner.fa-spin.block-loading')).not.toBeNull(); + }); + + it('renders `Edit` button element', () => { + const editBtnEl = vm.$el.querySelector('button.edit-link.js-sidebar-dropdown-toggle'); + expect(editBtnEl).not.toBeNull(); + expect(editBtnEl.innerText.trim()).toBe('Edit'); + }); + }); +}); -- cgit v1.2.1 From c1ed7f3f961348ca9897beadc75ea9cf2494f344 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:09:55 +0530 Subject: LabelsSelect DropdownValue Component --- .../sidebar/labels_select/dropdown_value.vue | 63 +++++++++++++++ .../sidebar/labels_select/dropdown_value_spec.js | 94 ++++++++++++++++++++++ 2 files changed, 157 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue new file mode 100644 index 00000000000..ba4c8fba5ec --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue @@ -0,0 +1,63 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js new file mode 100644 index 00000000000..66e0957b431 --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js @@ -0,0 +1,94 @@ +import Vue from 'vue'; + +import dropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; + +import { mockConfig, mockLabels } from './mock_data'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const createComponent = ( + labels = mockLabels, + labelFilterBasePath = mockConfig.labelFilterBasePath, +) => { + const Component = Vue.extend(dropdownValueComponent); + + return mountComponent(Component, { + labels, + labelFilterBasePath, + }); +}; + +describe('DropdownValueComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('isEmpty', () => { + it('returns true if `labels` prop is empty', () => { + const vmEmptyLabels = createComponent([]); + expect(vmEmptyLabels.isEmpty).toBe(true); + vmEmptyLabels.$destroy(); + }); + + it('returns false if `labels` prop is empty', () => { + expect(vm.isEmpty).toBe(false); + }); + }); + }); + + describe('methods', () => { + describe('labelFilterUrl', () => { + it('returns URL string starting with labelFilterBasePath and encoded label.title', () => { + expect(vm.labelFilterUrl({ + title: 'Foo bar', + })).toBe('/gitlab-org/my-project/issues?label_name[]=Foo%20bar'); + }); + }); + + describe('labelStyle', () => { + it('returns object with `color` & `backgroundColor` properties from label.textColor & label.color', () => { + const label = { + textColor: '#FFFFFF', + color: '#BADA55', + }; + const styleObj = vm.labelStyle(label); + + expect(styleObj.color).toBe(label.textColor); + expect(styleObj.backgroundColor).toBe(label.color); + }); + }); + }); + + describe('template', () => { + it('renders component container element with classes `hide-collapsed value issuable-show-labels`', () => { + expect(vm.$el.classList.contains('hide-collapsed', 'value', 'issuable-show-labels')).toBe(true); + }); + + it('render slot content inside component when `labels` prop is empty', () => { + const vmEmptyLabels = createComponent([]); + expect(vmEmptyLabels.$el.querySelector('.text-secondary').innerText.trim()).toBe(mockConfig.emptyValueText); + vmEmptyLabels.$destroy(); + }); + + it('renders label element with filter URL', () => { + expect(vm.$el.querySelector('a').getAttribute('href')).toBe('/gitlab-org/my-project/issues?label_name[]=Foo%20Label'); + }); + + it('renders label element with tooltip and styles based on label details', () => { + const labelEl = vm.$el.querySelector('a span.label.color-label'); + expect(labelEl).not.toBeNull(); + expect(labelEl.dataset.placement).toBe('bottom'); + expect(labelEl.dataset.container).toBe('body'); + expect(labelEl.dataset.originalTitle).toBe(mockLabels[0].description); + expect(labelEl.getAttribute('style')).toBe('background-color: rgb(186, 218, 85);'); + expect(labelEl.innerText.trim()).toBe(mockLabels[0].title); + }); + }); +}); -- cgit v1.2.1 From 00db4cb733e5c1314d63972a280108a1d81c7301 Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:10:13 +0530 Subject: LabelsSelect DropdownValueCollapsed Component --- .../labels_select/dropdown_value_collapsed.vue | 48 ++++++++++++++ .../labels_select/dropdown_value_collapsed_spec.js | 74 ++++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue create mode 100644 spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue new file mode 100644 index 00000000000..5cf728fe050 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue @@ -0,0 +1,48 @@ + + + diff --git a/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js new file mode 100644 index 00000000000..93b42795bea --- /dev/null +++ b/spec/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -0,0 +1,74 @@ +import Vue from 'vue'; + +import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; + +import { mockLabels } from './mock_data'; + +import mountComponent from '../../../../helpers/vue_mount_component_helper'; + +const createComponent = (labels = mockLabels) => { + const Component = Vue.extend(dropdownValueCollapsedComponent); + + return mountComponent(Component, { + labels, + }); +}; + +describe('DropdownValueCollapsedComponent', () => { + let vm; + + beforeEach(() => { + vm = createComponent(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('computed', () => { + describe('labelsList', () => { + it('returns empty text when `labels` prop is empty array', () => { + const vmEmptyLabels = createComponent([]); + expect(vmEmptyLabels.labelsList).toBe(''); + vmEmptyLabels.$destroy(); + }); + + it('returns labels names separated by coma when `labels` prop has more than one item', () => { + const vmMoreLabels = createComponent(mockLabels.concat(mockLabels)); + expect(vmMoreLabels.labelsList).toBe('Foo Label, Foo Label'); + vmMoreLabels.$destroy(); + }); + + it('returns labels names separated by coma with remaining labels count and `and more` phrase when `labels` prop has more than five items', () => { + const mockMoreLabels = Object.assign([], mockLabels); + for (let i = 0; i < 6; i += 1) { + mockMoreLabels.unshift(mockLabels[0]); + } + + const vmMoreLabels = createComponent(mockMoreLabels); + expect(vmMoreLabels.labelsList).toBe('Foo Label, Foo Label, Foo Label, Foo Label, Foo Label, and 2 more'); + vmMoreLabels.$destroy(); + }); + + it('returns first label name when `labels` prop has only one item present', () => { + expect(vm.labelsList).toBe('Foo Label'); + }); + }); + }); + + describe('template', () => { + it('renders component container element with tooltip`', () => { + expect(vm.$el.dataset.placement).toBe('left'); + expect(vm.$el.dataset.container).toBe('body'); + expect(vm.$el.dataset.originalTitle).toBe(vm.labelsList); + }); + + it('renders tags icon element', () => { + expect(vm.$el.querySelector('.fa-tags')).not.toBeNull(); + }); + + it('renders labels count', () => { + expect(vm.$el.querySelector('span').innerText.trim()).toBe(`${vm.labels.length}`); + }); + }); +}); -- cgit v1.2.1 From 4051d01c9cbd867b2478743ffe1ad50dfb030f6d Mon Sep 17 00:00:00 2001 From: Kushal Pandya Date: Mon, 5 Mar 2018 18:10:23 +0530 Subject: Add changelog entry --- changelogs/unreleased/kp-label-select-vue.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/kp-label-select-vue.yml diff --git a/changelogs/unreleased/kp-label-select-vue.yml b/changelogs/unreleased/kp-label-select-vue.yml new file mode 100644 index 00000000000..1f5952f2554 --- /dev/null +++ b/changelogs/unreleased/kp-label-select-vue.yml @@ -0,0 +1,5 @@ +--- +title: Port Labels Select dropdown to Vue +merge_request: 17411 +author: +type: other -- cgit v1.2.1 From 47d4890d3ad8a1c2ecb2b9c497d537c044c76e25 Mon Sep 17 00:00:00 2001 From: bunufi Date: Mon, 5 Mar 2018 12:57:47 +0000 Subject: Update API: add search param to branches --- ...42712_api_branches_add_search_param_20180207.yml | 5 +++++ doc/api/branches.md | 1 + lib/api/branches.rb | 16 ++++++++++++++-- spec/requests/api/branches_spec.rb | 21 +++++++++++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/42712_api_branches_add_search_param_20180207.yml diff --git a/changelogs/unreleased/42712_api_branches_add_search_param_20180207.yml b/changelogs/unreleased/42712_api_branches_add_search_param_20180207.yml new file mode 100644 index 00000000000..609b5ce48ef --- /dev/null +++ b/changelogs/unreleased/42712_api_branches_add_search_param_20180207.yml @@ -0,0 +1,5 @@ +--- +title: Add search param to Branches API +merge_request: 17005 +author: bunufi +type: added diff --git a/doc/api/branches.md b/doc/api/branches.md index 80744258acb..01bb30c3859 100644 --- a/doc/api/branches.md +++ b/doc/api/branches.md @@ -13,6 +13,7 @@ GET /projects/:id/repository/branches | Attribute | Type | Required | Description | | --------- | ---- | -------- | ----------- | | `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `search` | string | no | Return list of branches matching the search criteria. | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/repository/branches diff --git a/lib/api/branches.rb b/lib/api/branches.rb index 1794207e29b..13cfba728fa 100644 --- a/lib/api/branches.rb +++ b/lib/api/branches.rb @@ -16,6 +16,10 @@ module API render_api_error!('The branch refname is invalid', 400) end end + + params :filter_params do + optional :search, type: String, desc: 'Return list of branches matching the search criteria' + end end params do @@ -27,15 +31,23 @@ module API end params do use :pagination + use :filter_params end get ':id/repository/branches' do Gitlab::QueryLimiting.whitelist('https://gitlab.com/gitlab-org/gitlab-ce/issues/42329') repository = user_project.repository - branches = ::Kaminari.paginate_array(repository.branches.sort_by(&:name)) + + branches = BranchesFinder.new(repository, declared_params(include_missing: false)).execute + merged_branch_names = repository.merged_branch_names(branches.map(&:name)) - present paginate(branches), with: Entities::Branch, project: user_project, merged_branch_names: merged_branch_names + present( + paginate(::Kaminari.paginate_array(branches)), + with: Entities::Branch, + project: user_project, + merged_branch_names: merged_branch_names + ) end resource ':id/repository/branches/:branch', requirements: BRANCH_ENDPOINT_REQUIREMENTS do diff --git a/spec/requests/api/branches_spec.rb b/spec/requests/api/branches_spec.rb index e433597f58b..64f51d9843d 100644 --- a/spec/requests/api/branches_spec.rb +++ b/spec/requests/api/branches_spec.rb @@ -39,6 +39,27 @@ describe API::Branches do end end + context 'when search parameter is passed' do + context 'and branch exists' do + it 'returns correct branches' do + get api(route, user), per_page: 100, search: branch_name + + searched_branch_names = json_response.map { |branch| branch['name'] } + project_branch_names = project.repository.branch_names.grep(/#{branch_name}/) + + expect(searched_branch_names).to match_array(project_branch_names) + end + end + + context 'and branch does not exist' do + it 'returns an empty array' do + get api(route, user), per_page: 100, search: 'no_such_branch_name_entropy_of_jabadabadu' + + expect(json_response).to eq [] + end + end + end + context 'when unauthenticated', 'and project is public' do before do project.update(visibility_level: Gitlab::VisibilityLevel::PUBLIC) -- cgit v1.2.1 From 168ff28506dfe66a13a6c1bc5e3b772445a5bd18 Mon Sep 17 00:00:00 2001 From: Jacopo Date: Wed, 28 Feb 2018 12:16:29 +0100 Subject: Adds updated_at filter to issues and merge_requests API --- app/finders/issuable_finder.rb | 12 +++++ app/finders/issues_finder.rb | 4 ++ app/finders/merge_requests_finder.rb | 4 ++ app/models/concerns/issuable.rb | 1 + app/models/concerns/updated_at_filterable.rb | 12 +++++ .../unreleased/41616-api-issues-between-date.yml | 5 +++ doc/api/issues.md | 14 +++++- doc/api/merge_requests.md | 12 +++-- lib/api/issues.rb | 2 + lib/api/merge_requests.rb | 2 + spec/finders/issues_finder_spec.rb | 42 ++++++++++++++++-- spec/finders/merge_requests_finder_spec.rb | 51 ++++++++++++++++++++-- spec/requests/api/issues_spec.rb | 36 +++++++++++++++ spec/requests/api/merge_requests_spec.rb | 36 +++++++++++++++ 14 files changed, 219 insertions(+), 14 deletions(-) create mode 100644 app/models/concerns/updated_at_filterable.rb create mode 100644 changelogs/unreleased/41616-api-issues-between-date.yml diff --git a/app/finders/issuable_finder.rb b/app/finders/issuable_finder.rb index 9dd6634b38f..b2d4f9938ff 100644 --- a/app/finders/issuable_finder.rb +++ b/app/finders/issuable_finder.rb @@ -19,6 +19,10 @@ # non_archived: boolean # iids: integer[] # my_reaction_emoji: string +# created_after: datetime +# created_before: datetime +# updated_after: datetime +# updated_before: datetime # class IssuableFinder prepend FinderWithCrossProjectAccess @@ -79,6 +83,7 @@ class IssuableFinder def filter_items(items) items = by_scope(items) items = by_created_at(items) + items = by_updated_at(items) items = by_state(items) items = by_group(items) items = by_search(items) @@ -283,6 +288,13 @@ class IssuableFinder end end + def by_updated_at(items) + items = items.updated_after(params[:updated_after]) if params[:updated_after].present? + items = items.updated_before(params[:updated_before]) if params[:updated_before].present? + + items + end + def by_state(items) case params[:state].to_s when 'closed' diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index d65c620e75a..2a27ff0e386 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -17,6 +17,10 @@ # my_reaction_emoji: string # public_only: boolean # due_date: date or '0', '', 'overdue', 'week', or 'month' +# created_after: datetime +# created_before: datetime +# updated_after: datetime +# updated_before: datetime # class IssuesFinder < IssuableFinder CONFIDENTIAL_ACCESS_LEVEL = Gitlab::Access::REPORTER diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 068ae7f8c89..64dc1e6af0f 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -19,6 +19,10 @@ # my_reaction_emoji: string # source_branch: string # target_branch: string +# created_after: datetime +# created_before: datetime +# updated_after: datetime +# updated_before: datetime # class MergeRequestsFinder < IssuableFinder def klass diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 7049f340c9d..4560bc23193 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -19,6 +19,7 @@ module Issuable include AfterCommitQueue include Sortable include CreatedAtFilterable + include UpdatedAtFilterable # This object is used to gather issuable meta data for displaying # upvotes, downvotes, notes and closing merge requests count for issues and merge requests diff --git a/app/models/concerns/updated_at_filterable.rb b/app/models/concerns/updated_at_filterable.rb new file mode 100644 index 00000000000..edb423b7828 --- /dev/null +++ b/app/models/concerns/updated_at_filterable.rb @@ -0,0 +1,12 @@ +module UpdatedAtFilterable + extend ActiveSupport::Concern + + included do + scope :updated_before, ->(date) { where(scoped_table[:updated_at].lteq(date)) } + scope :updated_after, ->(date) { where(scoped_table[:updated_at].gteq(date)) } + + def self.scoped_table + arel_table.alias(table_name) + end + end +end diff --git a/changelogs/unreleased/41616-api-issues-between-date.yml b/changelogs/unreleased/41616-api-issues-between-date.yml new file mode 100644 index 00000000000..d8a23f48699 --- /dev/null +++ b/changelogs/unreleased/41616-api-issues-between-date.yml @@ -0,0 +1,5 @@ +--- +title: Adds updated_at filter to issues and merge_requests API +merge_request: 17417 +author: Jacopo Beschi @jacopo-beschi +type: added diff --git a/doc/api/issues.md b/doc/api/issues.md index da89db17cd9..a4a51101297 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -46,6 +46,10 @@ GET /issues?my_reaction_emoji=star | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `search` | string | no | Search issues against their `title` and `description` | +| `created_after` | datetime | no | Return issues created on or after the given time | +| `created_before` | datetime | no | Return issues created on or before the given time | +| `updated_after` | datetime | no | Return issues updated on or after the given time | +| `updated_before` | datetime | no | Return issues updated on or before the given time | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/issues @@ -152,6 +156,10 @@ GET /groups/:id/issues?my_reaction_emoji=star | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `search` | string | no | Search group issues against their `title` and `description` | +| `created_after` | datetime | no | Return issues created on or after the given time | +| `created_before` | datetime | no | Return issues created on or before the given time | +| `updated_after` | datetime | no | Return issues updated on or after the given time | +| `updated_before` | datetime | no | Return issues updated on or before the given time | ```bash @@ -259,8 +267,10 @@ GET /projects/:id/issues?my_reaction_emoji=star | `order_by` | string | no | Return issues ordered by `created_at` or `updated_at` fields. Default is `created_at` | | `sort` | string | no | Return issues sorted in `asc` or `desc` order. Default is `desc` | | `search` | string | no | Search project issues against their `title` and `description` | -| `created_after` | datetime | no | Return issues created after the given time (inclusive) | -| `created_before` | datetime | no | Return issues created before the given time (inclusive) | +| `created_after` | datetime | no | Return issues created on or after the given time | +| `created_before` | datetime | no | Return issues created on or before the given time | +| `updated_after` | datetime | no | Return issues updated on or after the given time | +| `updated_before` | datetime | no | Return issues updated on or before the given time | ```bash curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/4/issues diff --git a/doc/api/merge_requests.md b/doc/api/merge_requests.md index 6ce021cb4bf..b7f095da50a 100644 --- a/doc/api/merge_requests.md +++ b/doc/api/merge_requests.md @@ -41,8 +41,10 @@ Parameters: | `milestone` | string | no | Return merge requests for a specific milestone | | `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | | `labels` | string | no | Return merge requests matching a comma separated list of labels | -| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) | -| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) | +| `created_after` | datetime | no | Return merge requests created on or after the given time | +| `created_before` | datetime | no | Return merge requests created on or before the given time | +| `updated_after` | datetime | no | Return merge requests updated on or after the given time | +| `updated_before` | datetime | no | Return merge requests updated on or before the given time | | `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all`. Defaults to `created-by-me` | | `author_id` | integer | no | Returns merge requests created by the given user `id`. Combine with `scope=all` or `scope=assigned-to-me` | | `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` | @@ -158,8 +160,10 @@ Parameters: | `milestone` | string | no | Return merge requests for a specific milestone | | `view` | string | no | If `simple`, returns the `iid`, URL, title, description, and basic state of merge request | | `labels` | string | no | Return merge requests matching a comma separated list of labels | -| `created_after` | datetime | no | Return merge requests created after the given time (inclusive) | -| `created_before` | datetime | no | Return merge requests created before the given time (inclusive) | +| `created_after` | datetime | no | Return merge requests created on or after the given time | +| `created_before` | datetime | no | Return merge requests created on or before the given time | +| `updated_after` | datetime | no | Return merge requests updated on or after the given time | +| `updated_before` | datetime | no | Return merge requests updated on or before the given time | | `scope` | string | no | Return merge requests for the given scope: `created-by-me`, `assigned-to-me` or `all` _([Introduced][ce-13060] in GitLab 9.5)_ | | `author_id` | integer | no | Returns merge requests created by the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | | `assignee_id` | integer | no | Returns merge requests assigned to the given user `id` _([Introduced][ce-13060] in GitLab 9.5)_ | diff --git a/lib/api/issues.rb b/lib/api/issues.rb index b6c278c89d0..f74b3b26802 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -32,6 +32,8 @@ module API optional :search, type: String, desc: 'Search issues for text present in the title or description' optional :created_after, type: DateTime, desc: 'Return issues created after the specified time' optional :created_before, type: DateTime, desc: 'Return issues created before the specified time' + optional :updated_after, type: DateTime, desc: 'Return issues updated after the specified time' + optional :updated_before, type: DateTime, desc: 'Return issues updated before the specified time' optional :author_id, type: Integer, desc: 'Return issues which are authored by the user with the given ID' optional :assignee_id, type: Integer, desc: 'Return issues which are assigned to the user with the given ID' optional :scope, type: String, values: %w[created-by-me assigned-to-me all], diff --git a/lib/api/merge_requests.rb b/lib/api/merge_requests.rb index 4ffd4895c7e..0692cfa09e9 100644 --- a/lib/api/merge_requests.rb +++ b/lib/api/merge_requests.rb @@ -42,6 +42,8 @@ module API optional :labels, type: String, desc: 'Comma-separated list of label names' optional :created_after, type: DateTime, desc: 'Return merge requests created after the specified time' optional :created_before, type: DateTime, desc: 'Return merge requests created before the specified time' + optional :updated_after, type: DateTime, desc: 'Return merge requests updated after the specified time' + optional :updated_before, type: DateTime, desc: 'Return merge requests updated before the specified time' optional :view, type: String, values: %w[simple], desc: 'If simple, returns the `iid`, URL, title, description, and basic state of merge request' optional :author_id, type: Integer, desc: 'Return merge requests which are authored by the user with the given ID' optional :assignee_id, type: Integer, desc: 'Return merge requests which are assigned to the user with the given ID' diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index abb7631d7d7..45439640ea3 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -10,9 +10,9 @@ describe IssuesFinder do set(:project3) { create(:project, group: subgroup) } set(:milestone) { create(:milestone, project: project1) } set(:label) { create(:label, project: project2) } - set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab', created_at: 1.week.ago) } - set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab') } - set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki', created_at: 1.week.from_now) } + set(:issue1) { create(:issue, author: user, assignees: [user], project: project1, milestone: milestone, title: 'gitlab', created_at: 1.week.ago, updated_at: 1.week.ago) } + set(:issue2) { create(:issue, author: user, assignees: [user], project: project2, description: 'gitlab', created_at: 1.week.from_now, updated_at: 1.week.from_now) } + set(:issue3) { create(:issue, author: user2, assignees: [user2], project: project2, title: 'tanuki', description: 'tanuki', created_at: 2.weeks.from_now, updated_at: 2.weeks.from_now) } set(:issue4) { create(:issue, project: project3) } set(:award_emoji1) { create(:award_emoji, name: 'thumbsup', user: user, awardable: issue1) } set(:award_emoji2) { create(:award_emoji, name: 'thumbsup', user: user2, awardable: issue2) } @@ -275,12 +275,46 @@ describe IssuesFinder do end context 'through created_before' do - let(:params) { { created_before: issue1.created_at + 1.second } } + let(:params) { { created_before: issue1.created_at } } it 'returns issues created on or before the given date' do expect(issues).to contain_exactly(issue1) end end + + context 'through created_after and created_before' do + let(:params) { { created_after: issue2.created_at, created_before: issue3.created_at } } + + it 'returns issues created between the given dates' do + expect(issues).to contain_exactly(issue2, issue3) + end + end + end + + context 'filtering by updated_at' do + context 'through updated_after' do + let(:params) { { updated_after: issue3.updated_at } } + + it 'returns issues updated on or after the given date' do + expect(issues).to contain_exactly(issue3) + end + end + + context 'through updated_before' do + let(:params) { { updated_before: issue1.updated_at } } + + it 'returns issues updated on or before the given date' do + expect(issues).to contain_exactly(issue1) + end + end + + context 'through updated_after and updated_before' do + let(:params) { { updated_after: issue2.updated_at, updated_before: issue3.updated_at } } + + it 'returns issues updated between the given dates' do + expect(issues).to contain_exactly(issue2, issue3) + end + end end context 'filtering by reaction name' do diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 7917a00fc50..c8a43ddf410 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -109,7 +109,7 @@ describe MergeRequestsFinder do end end - context 'with created_after and created_before params' do + context 'filtering by created_at/updated_at' do let(:new_project) { create(:project, forked_from_project: project1) } let!(:new_merge_request) do @@ -117,15 +117,18 @@ describe MergeRequestsFinder do :simple, author: user, created_at: 1.week.from_now, + updated_at: 1.week.from_now, source_project: new_project, - target_project: project1) + target_project: new_project) end let!(:old_merge_request) do create(:merge_request, :simple, author: user, + source_branch: 'feature_1', created_at: 1.week.ago, + updated_at: 1.week.ago, source_project: new_project, target_project: new_project) end @@ -135,7 +138,7 @@ describe MergeRequestsFinder do end it 'filters by created_after' do - params = { project_id: project1.id, created_after: new_merge_request.created_at } + params = { project_id: new_project.id, created_after: new_merge_request.created_at } merge_requests = described_class.new(user, params).execute @@ -143,12 +146,52 @@ describe MergeRequestsFinder do end it 'filters by created_before' do - params = { project_id: new_project.id, created_before: old_merge_request.created_at + 1.second } + params = { project_id: new_project.id, created_before: old_merge_request.created_at } merge_requests = described_class.new(user, params).execute expect(merge_requests).to contain_exactly(old_merge_request) end + + it 'filters by created_after and created_before' do + params = { + project_id: new_project.id, + created_after: old_merge_request.created_at, + created_before: new_merge_request.created_at + } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to contain_exactly(old_merge_request, new_merge_request) + end + + it 'filters by updated_after' do + params = { project_id: new_project.id, updated_after: new_merge_request.updated_at } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to contain_exactly(new_merge_request) + end + + it 'filters by updated_before' do + params = { project_id: new_project.id, updated_before: old_merge_request.updated_at } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to contain_exactly(old_merge_request) + end + + it 'filters by updated_after and updated_before' do + params = { + project_id: new_project.id, + updated_after: old_merge_request.updated_at, + updated_before: new_merge_request.updated_at + } + + merge_requests = described_class.new(user, params).execute + + expect(merge_requests).to contain_exactly(old_merge_request, new_merge_request) + end end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index d1569e5d650..6614e8cea43 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -163,6 +163,42 @@ describe API::Issues do expect(first_issue['id']).to eq(issue.id) end + context 'filtering before a specific date' do + let!(:issue2) { create(:issue, project: project, author: user, created_at: Date.new(2000, 1, 1), updated_at: Date.new(2000, 1, 1)) } + + it 'returns issues created before a specific date' do + get api('/issues?created_before=2000-01-02T00:00:00.060Z', user) + + expect(json_response.size).to eq(1) + expect(first_issue['id']).to eq(issue2.id) + end + + it 'returns issues updated before a specific date' do + get api('/issues?updated_before=2000-01-02T00:00:00.060Z', user) + + expect(json_response.size).to eq(1) + expect(first_issue['id']).to eq(issue2.id) + end + end + + context 'filtering after a specific date' do + let!(:issue2) { create(:issue, project: project, author: user, created_at: 1.week.from_now, updated_at: 1.week.from_now) } + + it 'returns issues created after a specific date' do + get api("/issues?created_after=#{issue2.created_at}", user) + + expect(json_response.size).to eq(1) + expect(first_issue['id']).to eq(issue2.id) + end + + it 'returns issues updated after a specific date' do + get api("/issues?updated_after=#{issue2.updated_at}", user) + + expect(json_response.size).to eq(1) + expect(first_issue['id']).to eq(issue2.id) + end + end + it 'returns an array of labeled issues' do get api("/issues", user), labels: label.title diff --git a/spec/requests/api/merge_requests_spec.rb b/spec/requests/api/merge_requests_spec.rb index 658cedd6b5f..5c980853318 100644 --- a/spec/requests/api/merge_requests_spec.rb +++ b/spec/requests/api/merge_requests_spec.rb @@ -171,6 +171,42 @@ describe API::MergeRequests do end end + it 'returns merge requests created before a specific date' do + merge_request2 = create(:merge_request, :simple, source_project: project, target_project: project, source_branch: 'feature_1', created_at: Date.new(2000, 1, 1)) + + get api('/merge_requests?created_before=2000-01-02T00:00:00.060Z', user) + + expect(json_response.size).to eq(1) + expect(json_response.first['id']).to eq(merge_request2.id) + end + + it 'returns merge requests created after a specific date' do + merge_request2 = create(:merge_request, :simple, source_project: project, target_project: project, source_branch: 'feature_1', created_at: 1.week.from_now) + + get api("/merge_requests?created_after=#{merge_request2.created_at}", user) + + expect(json_response.size).to eq(1) + expect(json_response.first['id']).to eq(merge_request2.id) + end + + it 'returns merge requests updated before a specific date' do + merge_request2 = create(:merge_request, :simple, source_project: project, target_project: project, source_branch: 'feature_1', updated_at: Date.new(2000, 1, 1)) + + get api('/merge_requests?updated_before=2000-01-02T00:00:00.060Z', user) + + expect(json_response.size).to eq(1) + expect(json_response.first['id']).to eq(merge_request2.id) + end + + it 'returns merge requests updated after a specific date' do + merge_request2 = create(:merge_request, :simple, source_project: project, target_project: project, source_branch: 'feature_1', updated_at: 1.week.from_now) + + get api("/merge_requests?updated_after=#{merge_request2.updated_at}", user) + + expect(json_response.size).to eq(1) + expect(json_response.first['id']).to eq(merge_request2.id) + end + context 'search params' do before do merge_request.update(title: 'Search title', description: 'Search description') -- cgit v1.2.1 From 4ed79b4d4898221dee1b075ddfd17f8561699172 Mon Sep 17 00:00:00 2001 From: Oswaldo Ferreira Date: Mon, 5 Mar 2018 10:24:18 -0300 Subject: Rename quick actions handler --- app/services/issuable_base_service.rb | 4 ++-- app/services/merge_requests/create_service.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 5044a3651cf..02fb48108fb 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -109,7 +109,7 @@ class IssuableBaseService < BaseService @available_labels ||= LabelsFinder.new(current_user, project_id: @project.id).execute end - def handle_quick_actions(issuable) + def handle_quick_actions_on_create(issuable) merge_quick_actions_into_params!(issuable) end @@ -135,7 +135,7 @@ class IssuableBaseService < BaseService end def create(issuable) - handle_quick_actions(issuable) + handle_quick_actions_on_create(issuable) filter_params(issuable) params.delete(:state_event) diff --git a/app/services/merge_requests/create_service.rb b/app/services/merge_requests/create_service.rb index 0ed7ee6c57a..c57a2445341 100644 --- a/app/services/merge_requests/create_service.rb +++ b/app/services/merge_requests/create_service.rb @@ -35,7 +35,7 @@ module MergeRequests end # Override from IssuableBaseService - def handle_quick_actions(merge_request) + def handle_quick_actions_on_create(merge_request) super handle_wip_event(merge_request) end -- cgit v1.2.1 From 8403d35b2cf2d3c3a6c3c20571e286d9619bb938 Mon Sep 17 00:00:00 2001 From: Oswaldo Ferreira Date: Mon, 5 Mar 2018 10:25:44 -0300 Subject: Update changelog --- changelogs/unreleased/wip-new-mr-cmd.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/changelogs/unreleased/wip-new-mr-cmd.yml b/changelogs/unreleased/wip-new-mr-cmd.yml index 08cbe84ea05..2192ed1b43a 100644 --- a/changelogs/unreleased/wip-new-mr-cmd.yml +++ b/changelogs/unreleased/wip-new-mr-cmd.yml @@ -1,4 +1,3 @@ ---- -title: wip slash command option on merge request creation -merge_request: 8982 +title: Port /wip quick action command to Merge Request creation (on description) +merge_request: 17463 author: Adam Pahlevi -- cgit v1.2.1 From 741caf93e14758c223b6ef819390f5889bdd108b Mon Sep 17 00:00:00 2001 From: Jan Provaznik Date: Mon, 5 Mar 2018 13:25:56 +0000 Subject: Use limited count queries also for scoped searches --- app/finders/notes_finder.rb | 12 ++++++++ app/views/search/_category.html.haml | 8 ++--- changelogs/unreleased/jprovazn-scoped-limit.yml | 6 ++++ lib/gitlab/project_search_results.rb | 29 ++++++++++++----- lib/gitlab/search_results.rb | 16 ---------- spec/finders/notes_finder_spec.rb | 12 ++++++++ spec/lib/gitlab/project_search_results_spec.rb | 41 +++++++++++++++++++++---- spec/lib/gitlab/search_results_spec.rb | 36 ++++------------------ 8 files changed, 97 insertions(+), 63 deletions(-) create mode 100644 changelogs/unreleased/jprovazn-scoped-limit.yml diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index 33ee1e975b9..35f4ff2f62f 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -48,11 +48,23 @@ class NotesFinder def init_collection if target notes_on_target + elsif target_type + notes_of_target_type else notes_of_any_type end end + def notes_of_target_type + notes = notes_for_type(target_type) + + search(notes) + end + + def target_type + @params[:target_type] + end + def notes_of_any_type types = %w(commit issue merge_request snippet) note_relations = types.map { |t| notes_for_type(t) } diff --git a/app/views/search/_category.html.haml b/app/views/search/_category.html.haml index 915e648a5d3..7d43fd61081 100644 --- a/app/views/search/_category.html.haml +++ b/app/views/search/_category.html.haml @@ -14,25 +14,25 @@ = link_to search_filter_path(scope: 'issues') do Issues %span.badge - = @search_results.issues_count + = limited_count(@search_results.limited_issues_count) - if project_search_tabs?(:merge_requests) %li{ class: active_when(@scope == 'merge_requests') } = link_to search_filter_path(scope: 'merge_requests') do Merge requests %span.badge - = @search_results.merge_requests_count + = limited_count(@search_results.limited_merge_requests_count) - if project_search_tabs?(:milestones) %li{ class: active_when(@scope == 'milestones') } = link_to search_filter_path(scope: 'milestones') do Milestones %span.badge - = @search_results.milestones_count + = limited_count(@search_results.limited_milestones_count) - if project_search_tabs?(:notes) %li{ class: active_when(@scope == 'notes') } = link_to search_filter_path(scope: 'notes') do Comments %span.badge - = @search_results.notes_count + = limited_count(@search_results.limited_notes_count) - if project_search_tabs?(:wiki) %li{ class: active_when(@scope == 'wiki_blobs') } = link_to search_filter_path(scope: 'wiki_blobs') do diff --git a/changelogs/unreleased/jprovazn-scoped-limit.yml b/changelogs/unreleased/jprovazn-scoped-limit.yml new file mode 100644 index 00000000000..45724bb3479 --- /dev/null +++ b/changelogs/unreleased/jprovazn-scoped-limit.yml @@ -0,0 +1,6 @@ +--- +title: Optimize search queries on the search page by setting a limit for matching + records in project scope +merge_request: +author: +type: performance diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index cf0935dbd9a..29277ec6481 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -29,8 +29,18 @@ module Gitlab @blobs_count ||= blobs.count end - def notes_count - @notes_count ||= notes.count + def limited_notes_count + return @limited_notes_count if defined?(@limited_notes_count) + + types = %w(issue merge_request commit snippet) + @limited_notes_count = 0 + + types.each do |type| + @limited_notes_count += notes_finder(type).limit(count_limit).count + break if @limited_notes_count >= count_limit + end + + @limited_notes_count end def wiki_blobs_count @@ -72,11 +82,12 @@ module Gitlab end def single_commit_result? - commits_count == 1 && total_result_count == 1 - end + return false if commits_count != 1 - def total_result_count - issues_count + merge_requests_count + milestones_count + notes_count + blobs_count + wiki_blobs_count + commits_count + counts = %i(limited_milestones_count limited_notes_count + limited_merge_requests_count limited_issues_count + blobs_count wiki_blobs_count) + counts.all? { |count_method| public_send(count_method).zero? } # rubocop:disable GitlabSecurity/PublicSend end private @@ -106,7 +117,11 @@ module Gitlab end def notes - @notes ||= NotesFinder.new(project, @current_user, search: query).execute.user.order('updated_at DESC') + @notes ||= notes_finder(nil) + end + + def notes_finder(type) + NotesFinder.new(project, @current_user, search: query, target_type: type).execute.user.order('updated_at DESC') end def commits diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index 781783f4d97..757ef71b95a 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -62,22 +62,6 @@ module Gitlab without_count ? collection.without_count : collection end - def projects_count - @projects_count ||= projects.count - end - - def issues_count - @issues_count ||= issues.count - end - - def merge_requests_count - @merge_requests_count ||= merge_requests.count - end - - def milestones_count - @milestones_count ||= milestones.count - end - def limited_projects_count @limited_projects_count ||= projects.limit(count_limit).count end diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 7b43494eea2..f1ae2c7ab65 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -75,6 +75,18 @@ describe NotesFinder do end end + context 'for target type' do + let(:project) { create(:project, :repository) } + let!(:note1) { create :note_on_issue, project: project } + let!(:note2) { create :note_on_commit, project: project } + + it 'finds only notes for the selected type' do + notes = described_class.new(project, user, target_type: 'issue').execute + + expect(notes).to eq([note1]) + end + end + context 'for target' do let(:project) { create(:project, :repository) } let(:note1) { create :note_on_commit, project: project } diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index d8250e4b4c6..c46bb8edebf 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -217,7 +217,7 @@ describe Gitlab::ProjectSearchResults do expect(issues).to include issue expect(issues).not_to include security_issue_1 expect(issues).not_to include security_issue_2 - expect(results.issues_count).to eq 1 + expect(results.limited_issues_count).to eq 1 end it 'does not list project confidential issues for project members with guest role' do @@ -229,7 +229,7 @@ describe Gitlab::ProjectSearchResults do expect(issues).to include issue expect(issues).not_to include security_issue_1 expect(issues).not_to include security_issue_2 - expect(results.issues_count).to eq 1 + expect(results.limited_issues_count).to eq 1 end it 'lists project confidential issues for author' do @@ -239,7 +239,7 @@ describe Gitlab::ProjectSearchResults do expect(issues).to include issue expect(issues).to include security_issue_1 expect(issues).not_to include security_issue_2 - expect(results.issues_count).to eq 2 + expect(results.limited_issues_count).to eq 2 end it 'lists project confidential issues for assignee' do @@ -249,7 +249,7 @@ describe Gitlab::ProjectSearchResults do expect(issues).to include issue expect(issues).not_to include security_issue_1 expect(issues).to include security_issue_2 - expect(results.issues_count).to eq 2 + expect(results.limited_issues_count).to eq 2 end it 'lists project confidential issues for project members' do @@ -261,7 +261,7 @@ describe Gitlab::ProjectSearchResults do expect(issues).to include issue expect(issues).to include security_issue_1 expect(issues).to include security_issue_2 - expect(results.issues_count).to eq 3 + expect(results.limited_issues_count).to eq 3 end it 'lists all project issues for admin' do @@ -271,7 +271,7 @@ describe Gitlab::ProjectSearchResults do expect(issues).to include issue expect(issues).to include security_issue_1 expect(issues).to include security_issue_2 - expect(results.issues_count).to eq 3 + expect(results.limited_issues_count).to eq 3 end end @@ -304,6 +304,35 @@ describe Gitlab::ProjectSearchResults do end end + describe '#limited_notes_count' do + let(:project) { create(:project, :public) } + let(:note) { create(:note_on_issue, project: project) } + let(:results) { described_class.new(user, project, note.note) } + + context 'when count_limit is lower than total amount' do + before do + allow(results).to receive(:count_limit).and_return(1) + end + + it 'calls note finder once to get the limited amount of notes' do + expect(results).to receive(:notes_finder).once.and_call_original + expect(results.limited_notes_count).to eq(1) + end + end + + context 'when count_limit is higher than total amount' do + it 'calls note finder multiple times to get the limited amount of notes' do + project = create(:project, :public) + note = create(:note_on_issue, project: project) + + results = described_class.new(user, project, note.note) + + expect(results).to receive(:notes_finder).exactly(4).times.and_call_original + expect(results.limited_notes_count).to eq(1) + end + end + end + # Examples for commit access level test # # params: diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index 9dbab95f70e..87288baedb0 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -29,30 +29,6 @@ describe Gitlab::SearchResults do end end - describe '#projects_count' do - it 'returns the total amount of projects' do - expect(results.projects_count).to eq(1) - end - end - - describe '#issues_count' do - it 'returns the total amount of issues' do - expect(results.issues_count).to eq(1) - end - end - - describe '#merge_requests_count' do - it 'returns the total amount of merge requests' do - expect(results.merge_requests_count).to eq(1) - end - end - - describe '#milestones_count' do - it 'returns the total amount of milestones' do - expect(results.milestones_count).to eq(1) - end - end - context "when count_limit is lower than total amount" do before do allow(results).to receive(:count_limit).and_return(1) @@ -183,7 +159,7 @@ describe Gitlab::SearchResults do expect(issues).not_to include security_issue_3 expect(issues).not_to include security_issue_4 expect(issues).not_to include security_issue_5 - expect(results.issues_count).to eq 1 + expect(results.limited_issues_count).to eq 1 end it 'does not list confidential issues for project members with guest role' do @@ -199,7 +175,7 @@ describe Gitlab::SearchResults do expect(issues).not_to include security_issue_3 expect(issues).not_to include security_issue_4 expect(issues).not_to include security_issue_5 - expect(results.issues_count).to eq 1 + expect(results.limited_issues_count).to eq 1 end it 'lists confidential issues for author' do @@ -212,7 +188,7 @@ describe Gitlab::SearchResults do expect(issues).to include security_issue_3 expect(issues).not_to include security_issue_4 expect(issues).not_to include security_issue_5 - expect(results.issues_count).to eq 3 + expect(results.limited_issues_count).to eq 3 end it 'lists confidential issues for assignee' do @@ -225,7 +201,7 @@ describe Gitlab::SearchResults do expect(issues).not_to include security_issue_3 expect(issues).to include security_issue_4 expect(issues).not_to include security_issue_5 - expect(results.issues_count).to eq 3 + expect(results.limited_issues_count).to eq 3 end it 'lists confidential issues for project members' do @@ -241,7 +217,7 @@ describe Gitlab::SearchResults do expect(issues).to include security_issue_3 expect(issues).not_to include security_issue_4 expect(issues).not_to include security_issue_5 - expect(results.issues_count).to eq 4 + expect(results.limited_issues_count).to eq 4 end it 'lists all issues for admin' do @@ -254,7 +230,7 @@ describe Gitlab::SearchResults do expect(issues).to include security_issue_3 expect(issues).to include security_issue_4 expect(issues).not_to include security_issue_5 - expect(results.issues_count).to eq 5 + expect(results.limited_issues_count).to eq 5 end end -- cgit v1.2.1 From 25ed4a7e324f3d08f121436296efd41365e01258 Mon Sep 17 00:00:00 2001 From: Oswaldo Ferreira Date: Mon, 5 Mar 2018 10:26:54 -0300 Subject: Add "added" type on changelog --- changelogs/unreleased/wip-new-mr-cmd.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/changelogs/unreleased/wip-new-mr-cmd.yml b/changelogs/unreleased/wip-new-mr-cmd.yml index 2192ed1b43a..ce7072631dd 100644 --- a/changelogs/unreleased/wip-new-mr-cmd.yml +++ b/changelogs/unreleased/wip-new-mr-cmd.yml @@ -1,3 +1,4 @@ title: Port /wip quick action command to Merge Request creation (on description) merge_request: 17463 author: Adam Pahlevi +type: added -- cgit v1.2.1 From 0a4ee10eda01e23ddea0e6b4f80f61df3ffaabde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Rodr=C3=ADguez?= Date: Wed, 28 Feb 2018 19:22:44 -0300 Subject: Fix n+1 issue by not reloading fully loaded blobs --- app/controllers/projects/compare_controller.rb | 6 ++---- lib/gitlab/git/blob.rb | 14 ++++++++----- spec/lib/gitlab/git/blob_spec.rb | 29 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 9 deletions(-) diff --git a/app/controllers/projects/compare_controller.rb b/app/controllers/projects/compare_controller.rb index 3cb4eb23981..2b0c2ca97c0 100644 --- a/app/controllers/projects/compare_controller.rb +++ b/app/controllers/projects/compare_controller.rb @@ -17,10 +17,8 @@ class Projects::CompareController < Projects::ApplicationController def show apply_diff_view_cookie! - # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/37430 - Gitlab::GitalyClient.allow_n_plus_1_calls do - render - end + + render end def diff_for_path diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index b2fca2c16de..eabcf46cf58 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -238,9 +238,9 @@ module Gitlab self.__send__("#{key}=", options[key.to_sym]) # rubocop:disable GitlabSecurity/PublicSend end - @loaded_all_data = false # Retain the actual size before it is encoded @loaded_size = @data.bytesize if @data + @loaded_all_data = @loaded_size == size end def binary? @@ -255,10 +255,15 @@ module Gitlab # memory as a Ruby string. def load_all_data!(repository) return if @data == '' # don't mess with submodule blobs - return @data if @loaded_all_data - Gitlab::GitalyClient.migrate(:git_blob_load_all_data) do |is_enabled| - @data = begin + # Even if we return early, recalculate wether this blob is binary in + # case a blob was initialized as text but the full data isn't + @binary = nil + + return if @loaded_all_data + + @data = Gitlab::GitalyClient.migrate(:git_blob_load_all_data) do |is_enabled| + begin if is_enabled repository.gitaly_blob_client.get_blob(oid: id, limit: -1).data else @@ -269,7 +274,6 @@ module Gitlab @loaded_all_data = true @loaded_size = @data.bytesize - @binary = nil end def name diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb index a6341cd509b..67d898e787e 100644 --- a/spec/lib/gitlab/git/blob_spec.rb +++ b/spec/lib/gitlab/git/blob_spec.rb @@ -500,4 +500,33 @@ describe Gitlab::Git::Blob, seed_helper: true do end end end + + describe '#load_all_data!' do + let(:full_data) { 'abcd' } + let(:blob) { Gitlab::Git::Blob.new(name: 'test', size: 4, data: 'abc') } + + subject { blob.load_all_data!(repository) } + + it 'loads missing data' do + expect(Gitlab::GitalyClient).to receive(:migrate) + .with(:git_blob_load_all_data).and_return(full_data) + + subject + + expect(blob.data).to eq(full_data) + end + + context 'with a fully loaded blob' do + let(:blob) { Gitlab::Git::Blob.new(name: 'test', size: 4, data: full_data) } + + it "doesn't perform any loading" do + expect(Gitlab::GitalyClient).not_to receive(:migrate) + .with(:git_blob_load_all_data) + + subject + + expect(blob.data).to eq(full_data) + end + end + end end -- cgit v1.2.1 From 641b058b9e8c0faeb6d0de9e0c97b97e7a423c97 Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Mon, 5 Mar 2018 14:15:58 +0000 Subject: Fix MR merge commit cross-references to the MR itself --- app/workers/process_commit_worker.rb | 7 ++- spec/workers/process_commit_worker_spec.rb | 74 ++++++++++++++++++------------ 2 files changed, 47 insertions(+), 34 deletions(-) diff --git a/app/workers/process_commit_worker.rb b/app/workers/process_commit_worker.rb index 5b25d980bdb..201e7f332b4 100644 --- a/app/workers/process_commit_worker.rb +++ b/app/workers/process_commit_worker.rb @@ -30,10 +30,9 @@ class ProcessCommitWorker end def process_commit_message(project, commit, user, author, default = false) - # this is a GitLab generated commit message, ignore it. - return if commit.merged_merge_request?(user) - - closed_issues = default ? commit.closes_issues(user) : [] + # Ignore closing references from GitLab-generated commit messages. + find_closing_issues = default && !commit.merged_merge_request?(user) + closed_issues = find_closing_issues ? commit.closes_issues(user) : [] close_issues(project, user, author, commit, closed_issues) if closed_issues.any? commit.create_cross_references!(author, closed_issues) diff --git a/spec/workers/process_commit_worker_spec.rb b/spec/workers/process_commit_worker_spec.rb index 76ef57b6b1e..ac79d9c0ac1 100644 --- a/spec/workers/process_commit_worker_spec.rb +++ b/spec/workers/process_commit_worker_spec.rb @@ -20,32 +20,6 @@ describe ProcessCommitWorker do worker.perform(project.id, -1, commit.to_hash) end - context 'when commit is a merge request merge commit' do - let(:merge_request) do - create(:merge_request, - description: "Closes #{issue.to_reference}", - source_branch: 'feature-merged', - target_branch: 'master', - source_project: project) - end - - let(:commit) do - project.repository.create_branch('feature-merged', 'feature') - - sha = project.repository.merge(user, - merge_request.diff_head_sha, - merge_request, - "Closes #{issue.to_reference}") - project.repository.commit(sha) - end - - it 'it does not close any issues from the commit message' do - expect(worker).not_to receive(:close_issues) - - worker.perform(project.id, user.id, commit.to_hash) - end - end - it 'processes the commit message' do expect(worker).to receive(:process_commit_message).and_call_original @@ -73,13 +47,21 @@ describe ProcessCommitWorker do describe '#process_commit_message' do context 'when pushing to the default branch' do - it 'closes issues that should be closed per the commit message' do + before do allow(commit).to receive(:safe_message).and_return("Closes #{issue.to_reference}") + end + it 'closes issues that should be closed per the commit message' do expect(worker).to receive(:close_issues).with(project, user, user, commit, [issue]) worker.process_commit_message(project, commit, user, user, true) end + + it 'creates cross references' do + expect(commit).to receive(:create_cross_references!).with(user, [issue]) + + worker.process_commit_message(project, commit, user, user, true) + end end context 'when pushing to a non-default branch' do @@ -90,12 +72,44 @@ describe ProcessCommitWorker do worker.process_commit_message(project, commit, user, user, false) end + + it 'does not create cross references' do + expect(commit).to receive(:create_cross_references!).with(user, []) + + worker.process_commit_message(project, commit, user, user, false) + end end - it 'creates cross references' do - expect(commit).to receive(:create_cross_references!) + context 'when commit is a merge request merge commit to the default branch' do + let(:merge_request) do + create(:merge_request, + description: "Closes #{issue.to_reference}", + source_branch: 'feature-merged', + target_branch: 'master', + source_project: project) + end - worker.process_commit_message(project, commit, user, user) + let(:commit) do + project.repository.create_branch('feature-merged', 'feature') + + MergeRequests::MergeService + .new(project, merge_request.author) + .execute(merge_request) + + merge_request.reload.merge_commit + end + + it 'does not close any issues from the commit message' do + expect(worker).not_to receive(:close_issues) + + worker.process_commit_message(project, commit, user, user, true) + end + + it 'still creates cross references' do + expect(commit).to receive(:create_cross_references!).with(user, []) + + worker.process_commit_message(project, commit, user, user, true) + end end end -- cgit v1.2.1 From 0b7d10851456018328da137beeca931767b4fd0a Mon Sep 17 00:00:00 2001 From: Jan Provaznik Date: Mon, 5 Mar 2018 08:25:27 +0100 Subject: Cleanup after adding MR diff's commit_count * processes any pending records which are not migrated yet * bumps import_export version because of new commits_count attribute * removes commits_count fallback method --- app/models/merge_request_diff.rb | 4 ---- ...20180304204842_clean_commits_count_migration.rb | 14 ++++++++++++++ db/schema.rb | 2 +- doc/user/project/settings/import_export.md | 3 ++- lib/gitlab/import_export.rb | 2 +- .../import_export/test_project_export.tar.gz | Bin 343092 -> 343087 bytes vendor/project_templates/express.tar.gz | Bin 5614 -> 5608 bytes vendor/project_templates/rails.tar.gz | Bin 25007 -> 25004 bytes vendor/project_templates/spring.tar.gz | Bin 50945 -> 50938 bytes 9 files changed, 18 insertions(+), 7 deletions(-) create mode 100644 db/migrate/20180304204842_clean_commits_count_migration.rb diff --git a/app/models/merge_request_diff.rb b/app/models/merge_request_diff.rb index c1c27ccf3e5..06aa67c600f 100644 --- a/app/models/merge_request_diff.rb +++ b/app/models/merge_request_diff.rb @@ -197,10 +197,6 @@ class MergeRequestDiff < ActiveRecord::Base CompareService.new(project, head_commit_sha).execute(project, sha, straight: true) end - def commits_count - super || merge_request_diff_commits.size - end - private def create_merge_request_diff_files(diffs) diff --git a/db/migrate/20180304204842_clean_commits_count_migration.rb b/db/migrate/20180304204842_clean_commits_count_migration.rb new file mode 100644 index 00000000000..ace4c6aa1cf --- /dev/null +++ b/db/migrate/20180304204842_clean_commits_count_migration.rb @@ -0,0 +1,14 @@ +class CleanCommitsCountMigration < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + Gitlab::BackgroundMigration.steal('AddMergeRequestDiffCommitsCount') + end + + def down + end +end diff --git a/db/schema.rb b/db/schema.rb index db8bafe9677..4937fbd3df1 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20180301084653) do +ActiveRecord::Schema.define(version: 20180304204842) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" diff --git a/doc/user/project/settings/import_export.md b/doc/user/project/settings/import_export.md index dedf102fc37..5ddeb014b30 100644 --- a/doc/user/project/settings/import_export.md +++ b/doc/user/project/settings/import_export.md @@ -31,7 +31,8 @@ with all their related data and be moved into a new GitLab instance. | GitLab version | Import/Export version | | ---------------- | --------------------- | -| 10.4 to current | 0.2.2 | +| 10.6 to current | 0.2.3 | +| 10.4 | 0.2.2 | | 10.3 | 0.2.1 | | 10.0 | 0.2.0 | | 9.4.0 | 0.1.8 | diff --git a/lib/gitlab/import_export.rb b/lib/gitlab/import_export.rb index af203ff711d..b713fa7e1cd 100644 --- a/lib/gitlab/import_export.rb +++ b/lib/gitlab/import_export.rb @@ -3,7 +3,7 @@ module Gitlab extend self # For every version update, the version history in import_export.md has to be kept up to date. - VERSION = '0.2.2'.freeze + VERSION = '0.2.3'.freeze FILENAME_LIMIT = 50 def export_path(relative_path:) diff --git a/spec/features/projects/import_export/test_project_export.tar.gz b/spec/features/projects/import_export/test_project_export.tar.gz index 0cc68aff494..12bfcc177c7 100644 Binary files a/spec/features/projects/import_export/test_project_export.tar.gz and b/spec/features/projects/import_export/test_project_export.tar.gz differ diff --git a/vendor/project_templates/express.tar.gz b/vendor/project_templates/express.tar.gz index dcf5e4a0416..06093deb459 100644 Binary files a/vendor/project_templates/express.tar.gz and b/vendor/project_templates/express.tar.gz differ diff --git a/vendor/project_templates/rails.tar.gz b/vendor/project_templates/rails.tar.gz index d4856090ed9..85cc1b6bb78 100644 Binary files a/vendor/project_templates/rails.tar.gz and b/vendor/project_templates/rails.tar.gz differ diff --git a/vendor/project_templates/spring.tar.gz b/vendor/project_templates/spring.tar.gz index 6ee7e76f676..e98d3ce7b8f 100644 Binary files a/vendor/project_templates/spring.tar.gz and b/vendor/project_templates/spring.tar.gz differ -- cgit v1.2.1 From 5c5fc89b6c2ca4a65bd007dfedb1a026d66c7bff Mon Sep 17 00:00:00 2001 From: Riccardo Padovani Date: Mon, 5 Mar 2018 14:51:20 +0000 Subject: #43691: DiffNotes not counted by ContributionsCalendar --- .../feature--43691-count-diff-note-calendar-activity.yml | 5 +++++ lib/gitlab/contributions_calendar.rb | 2 +- spec/lib/gitlab/contributions_calendar_spec.rb | 14 ++++++++++---- 3 files changed, 16 insertions(+), 5 deletions(-) create mode 100644 changelogs/unreleased/feature--43691-count-diff-note-calendar-activity.yml diff --git a/changelogs/unreleased/feature--43691-count-diff-note-calendar-activity.yml b/changelogs/unreleased/feature--43691-count-diff-note-calendar-activity.yml new file mode 100644 index 00000000000..768686aeda8 --- /dev/null +++ b/changelogs/unreleased/feature--43691-count-diff-note-calendar-activity.yml @@ -0,0 +1,5 @@ +--- +title: Count comments on diffs as contributions for the contributions calendar +merge_request: 17418 +author: Riccardo Padovani +type: fixed diff --git a/lib/gitlab/contributions_calendar.rb b/lib/gitlab/contributions_calendar.rb index 9576d5a3fd8..02d3763514e 100644 --- a/lib/gitlab/contributions_calendar.rb +++ b/lib/gitlab/contributions_calendar.rb @@ -23,7 +23,7 @@ module Gitlab mr_events = event_counts(date_from, :merge_requests) .having(action: [Event::MERGED, Event::CREATED, Event::CLOSED], target_type: "MergeRequest") note_events = event_counts(date_from, :merge_requests) - .having(action: [Event::COMMENTED], target_type: "Note") + .having(action: [Event::COMMENTED], target_type: %w(Note DiffNote)) union = Gitlab::SQL::Union.new([repo_events, issue_events, mr_events, note_events]) events = Event.find_by_sql(union.to_sql).map(&:attributes) diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb index 49a179ba875..167876ca158 100644 --- a/spec/lib/gitlab/contributions_calendar_spec.rb +++ b/spec/lib/gitlab/contributions_calendar_spec.rb @@ -11,7 +11,7 @@ describe Gitlab::ContributionsCalendar do end let(:public_project) do - create(:project, :public) do |project| + create(:project, :public, :repository) do |project| create(:project_member, user: contributor, project: project) end end @@ -40,13 +40,13 @@ describe Gitlab::ContributionsCalendar do described_class.new(contributor, current_user) end - def create_event(project, day, hour = 0) + def create_event(project, day, hour = 0, action = Event::CREATED, target_symbol = :issue) @targets ||= {} - @targets[project] ||= create(:issue, project: project, author: contributor) + @targets[project] ||= create(target_symbol, project: project, author: contributor) Event.create!( project: project, - action: Event::CREATED, + action: action, target: @targets[project], author: contributor, created_at: DateTime.new(day.year, day.month, day.day, hour) @@ -71,6 +71,12 @@ describe Gitlab::ContributionsCalendar do expect(calendar(contributor).activity_dates[today]).to eq(2) end + it "counts the diff notes on merge request" do + create_event(public_project, today, 0, Event::COMMENTED, :diff_note_on_merge_request) + + expect(calendar(contributor).activity_dates[today]).to eq(1) + end + context "when events fall under different dates depending on the time zone" do before do create_event(public_project, today, 1) -- cgit v1.2.1 From 2cc43aaaf3162db8c584df3bb9d1a42d92084fae Mon Sep 17 00:00:00 2001 From: Ahmad Sherif Date: Fri, 2 Mar 2018 21:04:32 +0100 Subject: Keep a commit around if its sha is present Closes gitaly#1054 --- app/models/repository.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/repository.rb b/app/models/repository.rb index 242d9d5f125..1a14afb951a 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -253,7 +253,7 @@ class Repository # branches or tags, but we want to keep some of these commits around, for # example if they have comments or CI builds. def keep_around(sha) - return unless sha && commit_by(oid: sha) + return unless sha.present? && commit_by(oid: sha) return if kept_around?(sha) -- cgit v1.2.1 From 8fe880dc064e0e6cd10f7176ade7c312cfb37b90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Javier=20L=C3=B3pez?= Date: Mon, 5 Mar 2018 17:51:40 +0000 Subject: Projects and groups badges API --- app/models/badge.rb | 51 ++ app/models/badges/group_badge.rb | 5 + app/models/badges/project_badge.rb | 15 + app/models/group.rb | 2 + app/models/project.rb | 13 + app/services/badges/base_service.rb | 11 + app/services/badges/build_service.rb | 12 + app/services/badges/create_service.rb | 10 + app/services/badges/update_service.rb | 12 + app/validators/url_placeholder_validator.rb | 32 + app/views/projects/_home_panel.html.haml | 6 + .../fj-41174-projects-groups-badges-api.yml | 5 + config/application.rb | 1 + db/migrate/20180214093516_create_badges.rb | 17 + db/schema.rb | 15 + doc/api/README.md | 2 + doc/api/group_badges.md | 191 +++++ doc/api/groups.md | 4 + doc/api/project_badges.md | 188 +++++ doc/api/projects.md | 4 + lib/api/api.rb | 1 + lib/api/badges.rb | 134 ++++ lib/api/entities.rb | 18 + lib/api/helpers/badges_helpers.rb | 28 + lib/gitlab/import_export/import_export.yml | 5 + lib/gitlab/import_export/relation_factory.rb | 3 +- lib/gitlab/string_placeholder_replacer.rb | 27 + spec/factories/badge.rb | 14 + spec/lib/gitlab/import_export/all_models.yml | 3 + spec/lib/gitlab/import_export/project.json | 767 ++++++--------------- .../import_export/project_tree_restorer_spec.rb | 4 + .../import_export/project_tree_saver_spec.rb | 7 + .../gitlab/import_export/safe_model_attributes.yml | 9 + .../lib/gitlab/string_placeholder_replacer_spec.rb | 38 + spec/models/badge_spec.rb | 94 +++ spec/models/badges/group_badge_spec.rb | 11 + spec/models/badges/project_badge_spec.rb | 43 ++ spec/models/group_spec.rb | 1 + spec/models/project_spec.rb | 33 + spec/requests/api/badges_spec.rb | 367 ++++++++++ spec/validators/url_placeholder_validator_spec.rb | 39 ++ spec/validators/url_validator_spec.rb | 46 ++ spec/views/projects/_home_panel.html.haml_spec.rb | 54 +- 43 files changed, 1793 insertions(+), 549 deletions(-) create mode 100644 app/models/badge.rb create mode 100644 app/models/badges/group_badge.rb create mode 100644 app/models/badges/project_badge.rb create mode 100644 app/services/badges/base_service.rb create mode 100644 app/services/badges/build_service.rb create mode 100644 app/services/badges/create_service.rb create mode 100644 app/services/badges/update_service.rb create mode 100644 app/validators/url_placeholder_validator.rb create mode 100644 changelogs/unreleased/fj-41174-projects-groups-badges-api.yml create mode 100644 db/migrate/20180214093516_create_badges.rb create mode 100644 doc/api/group_badges.md create mode 100644 doc/api/project_badges.md create mode 100644 lib/api/badges.rb create mode 100644 lib/api/helpers/badges_helpers.rb create mode 100644 lib/gitlab/string_placeholder_replacer.rb create mode 100644 spec/factories/badge.rb create mode 100644 spec/lib/gitlab/string_placeholder_replacer_spec.rb create mode 100644 spec/models/badge_spec.rb create mode 100644 spec/models/badges/group_badge_spec.rb create mode 100644 spec/models/badges/project_badge_spec.rb create mode 100644 spec/requests/api/badges_spec.rb create mode 100644 spec/validators/url_placeholder_validator_spec.rb create mode 100644 spec/validators/url_validator_spec.rb diff --git a/app/models/badge.rb b/app/models/badge.rb new file mode 100644 index 00000000000..f7e10c2ebfc --- /dev/null +++ b/app/models/badge.rb @@ -0,0 +1,51 @@ +class Badge < ActiveRecord::Base + # This structure sets the placeholders that the urls + # can have. This hash also sets which action to ask when + # the placeholder is found. + PLACEHOLDERS = { + 'project_path' => :full_path, + 'project_id' => :id, + 'default_branch' => :default_branch, + 'commit_sha' => ->(project) { project.commit&.sha } + }.freeze + + # This regex is built dynamically using the keys from the PLACEHOLDER struct. + # So, we can easily add new placeholder just by modifying the PLACEHOLDER hash. + # This regex will build the new PLACEHOLDER_REGEX with the new information + PLACEHOLDERS_REGEX = /(#{PLACEHOLDERS.keys.join('|')})/.freeze + + default_scope { order_created_at_asc } + + scope :order_created_at_asc, -> { reorder(created_at: :asc) } + + validates :link_url, :image_url, url_placeholder: { protocols: %w(http https), placeholder_regex: PLACEHOLDERS_REGEX } + validates :type, presence: true + + def rendered_link_url(project = nil) + build_rendered_url(link_url, project) + end + + def rendered_image_url(project = nil) + build_rendered_url(image_url, project) + end + + private + + def build_rendered_url(url, project = nil) + return url unless valid? && project + + Gitlab::StringPlaceholderReplacer.replace_string_placeholders(url, PLACEHOLDERS_REGEX) do |arg| + replace_placeholder_action(PLACEHOLDERS[arg], project) + end + end + + # The action param represents the :symbol or Proc to call in order + # to retrieve the return value from the project. + # This method checks if it is a Proc and use the call method, and if it is + # a symbol just send the action + def replace_placeholder_action(action, project) + return unless project + + action.is_a?(Proc) ? action.call(project) : project.public_send(action) # rubocop:disable GitlabSecurity/PublicSend + end +end diff --git a/app/models/badges/group_badge.rb b/app/models/badges/group_badge.rb new file mode 100644 index 00000000000..f4b2bdecdcc --- /dev/null +++ b/app/models/badges/group_badge.rb @@ -0,0 +1,5 @@ +class GroupBadge < Badge + belongs_to :group + + validates :group, presence: true +end diff --git a/app/models/badges/project_badge.rb b/app/models/badges/project_badge.rb new file mode 100644 index 00000000000..3945b376052 --- /dev/null +++ b/app/models/badges/project_badge.rb @@ -0,0 +1,15 @@ +class ProjectBadge < Badge + belongs_to :project + + validates :project, presence: true + + def rendered_link_url(project = nil) + project ||= self.project + super + end + + def rendered_image_url(project = nil) + project ||= self.project + super + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 75bf013ecd2..201505c3d3c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -31,6 +31,8 @@ class Group < Namespace has_many :uploads, as: :model, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :badges, class_name: 'GroupBadge' + accepts_nested_attributes_for :variables, allow_destroy: true validate :visibility_level_allowed_by_projects diff --git a/app/models/project.rb b/app/models/project.rb index 5b1f8b2658b..a11b1e4f554 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -221,6 +221,8 @@ class Project < ActiveRecord::Base has_one :auto_devops, class_name: 'ProjectAutoDevops' has_many :custom_attributes, class_name: 'ProjectCustomAttribute' + has_many :project_badges, class_name: 'ProjectBadge' + accepts_nested_attributes_for :variables, allow_destroy: true accepts_nested_attributes_for :project_feature, update_only: true accepts_nested_attributes_for :import_data @@ -1766,6 +1768,17 @@ class Project < ActiveRecord::Base .set(import_jid, StuckImportJobsWorker::IMPORT_JOBS_EXPIRATION) end + def badges + return project_badges unless group + + group_badges_rel = GroupBadge.where(group: group.self_and_ancestors) + + union = Gitlab::SQL::Union.new([project_badges.select(:id), + group_badges_rel.select(:id)]) + + Badge.where("id IN (#{union.to_sql})") # rubocop:disable GitlabSecurity/SqlInjection + end + private def storage diff --git a/app/services/badges/base_service.rb b/app/services/badges/base_service.rb new file mode 100644 index 00000000000..4f87426bd38 --- /dev/null +++ b/app/services/badges/base_service.rb @@ -0,0 +1,11 @@ +module Badges + class BaseService + protected + + attr_accessor :params + + def initialize(params = {}) + @params = params.dup + end + end +end diff --git a/app/services/badges/build_service.rb b/app/services/badges/build_service.rb new file mode 100644 index 00000000000..6267e571838 --- /dev/null +++ b/app/services/badges/build_service.rb @@ -0,0 +1,12 @@ +module Badges + class BuildService < Badges::BaseService + # returns the created badge + def execute(source) + if source.is_a?(Group) + GroupBadge.new(params.merge(group: source)) + else + ProjectBadge.new(params.merge(project: source)) + end + end + end +end diff --git a/app/services/badges/create_service.rb b/app/services/badges/create_service.rb new file mode 100644 index 00000000000..aafb87f7dcd --- /dev/null +++ b/app/services/badges/create_service.rb @@ -0,0 +1,10 @@ +module Badges + class CreateService < Badges::BaseService + # returns the created badge + def execute(source) + badge = Badges::BuildService.new(params).execute(source) + + badge.tap { |b| b.save } + end + end +end diff --git a/app/services/badges/update_service.rb b/app/services/badges/update_service.rb new file mode 100644 index 00000000000..7ca84b5df31 --- /dev/null +++ b/app/services/badges/update_service.rb @@ -0,0 +1,12 @@ +module Badges + class UpdateService < Badges::BaseService + # returns the updated badge + def execute(badge) + if params.present? + badge.update_attributes(params) + end + + badge + end + end +end diff --git a/app/validators/url_placeholder_validator.rb b/app/validators/url_placeholder_validator.rb new file mode 100644 index 00000000000..dd681218b6b --- /dev/null +++ b/app/validators/url_placeholder_validator.rb @@ -0,0 +1,32 @@ +# UrlValidator +# +# Custom validator for URLs. +# +# By default, only URLs for the HTTP(S) protocols will be considered valid. +# Provide a `:protocols` option to configure accepted protocols. +# +# Also, this validator can help you validate urls with placeholders inside. +# Usually, if you have a url like 'http://www.example.com/%{project_path}' the +# URI parser will reject that URL format. Provide a `:placeholder_regex` option +# to configure accepted placeholders. +# +# Example: +# +# class User < ActiveRecord::Base +# validates :personal_url, url: true +# +# validates :ftp_url, url: { protocols: %w(ftp) } +# +# validates :git_url, url: { protocols: %w(http https ssh git) } +# +# validates :placeholder_url, url: { placeholder_regex: /(project_path|project_id|default_branch)/ } +# end +# +class UrlPlaceholderValidator < UrlValidator + def validate_each(record, attribute, value) + placeholder_regex = self.options[:placeholder_regex] + value = value.gsub(/%{#{placeholder_regex}}/, 'foo') if placeholder_regex && value + + super(record, attribute, value) + end +end diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index b565f14747a..a2ecfddb163 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -23,6 +23,12 @@ - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') = deleted_message % { project_name: fork_source_name(@project) } + .project-badges + - @project.badges.each do |badge| + - badge_link_url = badge.rendered_link_url(@project) + %a{ href: badge_link_url, target: '_blank', rel: 'noopener noreferrer' } + %img{ src: badge.rendered_image_url(@project), alt: badge_link_url } + .project-repo-buttons .count-buttons = render 'projects/buttons/star' diff --git a/changelogs/unreleased/fj-41174-projects-groups-badges-api.yml b/changelogs/unreleased/fj-41174-projects-groups-badges-api.yml new file mode 100644 index 00000000000..7cb12e26332 --- /dev/null +++ b/changelogs/unreleased/fj-41174-projects-groups-badges-api.yml @@ -0,0 +1,5 @@ +--- +title: Implemented badge API endpoints +merge_request: 17082 +author: +type: added diff --git a/config/application.rb b/config/application.rb index 918bd4d57cf..74fe3e439ed 100644 --- a/config/application.rb +++ b/config/application.rb @@ -26,6 +26,7 @@ module Gitlab # This is a nice reference article on autoloading/eager loading: # http://blog.arkency.com/2014/11/dont-forget-about-eager-load-when-extending-autoload config.eager_load_paths.push(*%W[#{config.root}/lib + #{config.root}/app/models/badges #{config.root}/app/models/hooks #{config.root}/app/models/members #{config.root}/app/models/project_services diff --git a/db/migrate/20180214093516_create_badges.rb b/db/migrate/20180214093516_create_badges.rb new file mode 100644 index 00000000000..6559f834484 --- /dev/null +++ b/db/migrate/20180214093516_create_badges.rb @@ -0,0 +1,17 @@ +class CreateBadges < ActiveRecord::Migration + DOWNTIME = false + + def change + create_table :badges do |t| + t.string :link_url, null: false + t.string :image_url, null: false + t.references :project, index: true, foreign_key: { on_delete: :cascade }, null: true + t.integer :group_id, index: true, null: true + t.string :type, null: false + + t.timestamps_with_timezone null: false + end + + add_foreign_key :badges, :namespaces, column: :group_id, on_delete: :cascade + end +end diff --git a/db/schema.rb b/db/schema.rb index 4937fbd3df1..9e117440ed2 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -183,6 +183,19 @@ ActiveRecord::Schema.define(version: 20180304204842) do add_index "award_emoji", ["awardable_type", "awardable_id"], name: "index_award_emoji_on_awardable_type_and_awardable_id", using: :btree add_index "award_emoji", ["user_id", "name"], name: "index_award_emoji_on_user_id_and_name", using: :btree + create_table "badges", force: :cascade do |t| + t.string "link_url", null: false + t.string "image_url", null: false + t.integer "project_id" + t.integer "group_id" + t.string "type", null: false + t.datetime_with_timezone "created_at", null: false + t.datetime_with_timezone "updated_at", null: false + end + + add_index "badges", ["group_id"], name: "index_badges_on_group_id", using: :btree + add_index "badges", ["project_id"], name: "index_badges_on_project_id", using: :btree + create_table "boards", force: :cascade do |t| t.integer "project_id", null: false t.datetime "created_at", null: false @@ -1969,6 +1982,8 @@ ActiveRecord::Schema.define(version: 20180304204842) do add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree add_index "web_hooks", ["type"], name: "index_web_hooks_on_type", using: :btree + add_foreign_key "badges", "namespaces", column: "group_id", on_delete: :cascade + add_foreign_key "badges", "projects", on_delete: :cascade add_foreign_key "boards", "projects", name: "fk_f15266b5f9", on_delete: :cascade add_foreign_key "chat_teams", "namespaces", on_delete: :cascade add_foreign_key "ci_build_trace_section_names", "projects", on_delete: :cascade diff --git a/doc/api/README.md b/doc/api/README.md index b193ef4ab7f..53f1a70c1aa 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -24,6 +24,7 @@ following locations: - [GitLab CI Config templates](templates/gitlab_ci_ymls.md) - [Groups](groups.md) - [Group Access Requests](access_requests.md) +- [Group Badges](group_badges.md) - [Group Members](members.md) - [Issues](issues.md) - [Issue Boards](boards.md) @@ -43,6 +44,7 @@ following locations: - [Pipeline Schedules](pipeline_schedules.md) - [Projects](projects.md) including setting Webhooks - [Project Access Requests](access_requests.md) +- [Project Badges](project_badges.md) - [Project import/export](project_import_export.md) - [Project Members](members.md) - [Project Snippets](project_snippets.md) diff --git a/doc/api/group_badges.md b/doc/api/group_badges.md new file mode 100644 index 00000000000..3e0683f378d --- /dev/null +++ b/doc/api/group_badges.md @@ -0,0 +1,191 @@ +# Group badges API + +## Placeholder tokens + +Badges support placeholders that will be replaced in real time in both the link and image URL. The allowed placeholders are: + +- **%{project_path}**: will be replaced by the project path. +- **%{project_id}**: will be replaced by the project id. +- **%{default_branch}**: will be replaced by the project default branch. +- **%{commit_sha}**: will be replaced by the last project's commit sha. + +Because these enpoints aren't inside a project's context, the information used to replace the placeholders will be +from the first group's project by creation date. If the group hasn't got any project the original URL with the placeholders will be returned. + +## List all badges of a group + +Gets a list of a group's badges. + +``` +GET /groups/:id/badges +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges +``` + +Example response: + +```json +[ + { + "id": 1, + "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master", + "rendered_image_url": "https://shields.io/my/badge", + "kind": "group" + }, + { + "id": 2, + "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master", + "rendered_image_url": "https://shields.io/my/badge", + "kind": "group" + }, +] +``` + +## Get a badge of a group + +Gets a badge of a group. + +``` +GET /groups/:id/badges/:badge_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `badge_id` | integer | yes | The badge ID | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id +``` + +Example response: + +```json +{ + "id": 1, + "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master", + "rendered_image_url": "https://shields.io/my/badge", + "kind": "group" +} +``` + +## Add a badge to a group + +Adds a badge to a group. + +``` +POST /groups/:id/badges +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `link_url` | string | yes | URL of the badge link | +| `image_url` | string | yes | URL of the badge image | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "link_url=https://gitlab.com/gitlab-org/gitlab-ce/commits/master&image_url=https://shields.io/my/badge1&position=0" https://gitlab.example.com/api/v4/groups/:id/badges +``` + +Example response: + +```json +{ + "id": 1, + "link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master", + "image_url": "https://shields.io/my/badge1", + "rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master", + "rendered_image_url": "https://shields.io/my/badge1", + "kind": "group" +} +``` + +## Edit a badge of a group + +Updates a badge of a group. + +``` +PUT /groups/:id/badges/:badge_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `badge_id` | integer | yes | The badge ID | +| `link_url` | string | no | URL of the badge link | +| `image_url` | string | no | URL of the badge image | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id +``` + +Example response: + +```json +{ + "id": 1, + "link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master", + "rendered_image_url": "https://shields.io/my/badge", + "kind": "group" +} +``` + +## Remove a badge from a group + +Removes a badge from a group. + +``` +DELETE /groups/:id/badges/:badge_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `badge_id` | integer | yes | The badge ID | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/:badge_id +``` + +## Preview a badge from a group + +Returns how the `link_url` and `image_url` final URLs would be after resolving the placeholder interpolation. + +``` +GET /groups/:id/badges/render +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) owned by the authenticated user | +| `link_url` | string | yes | URL of the badge link| +| `image_url` | string | yes | URL of the badge image | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/groups/:id/badges/render?link_url=http%3A%2F%2Fexample.com%2Fci_status.svg%3Fproject%3D%25%7Bproject_path%7D%26ref%3D%25%7Bdefault_branch%7D&image_url=https%3A%2F%2Fshields.io%2Fmy%2Fbadge +``` + +Example response: + +```json +{ + "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master", + "rendered_image_url": "https://shields.io/my/badge", +} +``` diff --git a/doc/api/groups.md b/doc/api/groups.md index f50558b58a6..1aed8aac64e 100644 --- a/doc/api/groups.md +++ b/doc/api/groups.md @@ -525,3 +525,7 @@ And to switch pages add: ``` [ce-15142]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/15142 + +## Group badges + +Read more in the [Group Badges](group_badges.md) documentation. diff --git a/doc/api/project_badges.md b/doc/api/project_badges.md new file mode 100644 index 00000000000..3f6e348b5b4 --- /dev/null +++ b/doc/api/project_badges.md @@ -0,0 +1,188 @@ +# Project badges API + +## Placeholder tokens + +Badges support placeholders that will be replaced in real time in both the link and image URL. The allowed placeholders are: + +- **%{project_path}**: will be replaced by the project path. +- **%{project_id}**: will be replaced by the project id. +- **%{default_branch}**: will be replaced by the project default branch. +- **%{commit_sha}**: will be replaced by the last project's commit sha. + +## List all badges of a project + +Gets a list of a project's badges and its group badges. + +``` +GET /projects/:id/badges +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges +``` + +Example response: + +```json +[ + { + "id": 1, + "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master", + "rendered_image_url": "https://shields.io/my/badge", + "kind": "project" + }, + { + "id": 2, + "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master", + "rendered_image_url": "https://shields.io/my/badge", + "kind": "group" + }, +] +``` + +## Get a badge of a project + +Gets a badge of a project. + +``` +GET /projects/:id/badges/:badge_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `badge_id` | integer | yes | The badge ID | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id +``` + +Example response: + +```json +{ + "id": 1, + "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master", + "rendered_image_url": "https://shields.io/my/badge", + "kind": "project" +} +``` + +## Add a badge to a project + +Adds a badge to a project. + +``` +POST /projects/:id/badges +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project ](README.md#namespaced-path-encoding) owned by the authenticated user | +| `link_url` | string | yes | URL of the badge link | +| `image_url` | string | yes | URL of the badge image | + +```bash +curl --request POST --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" --data "link_url=https://gitlab.com/gitlab-org/gitlab-ce/commits/master&image_url=https://shields.io/my/badge1&position=0" https://gitlab.example.com/api/v4/projects/:id/badges +``` + +Example response: + +```json +{ + "id": 1, + "link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master", + "image_url": "https://shields.io/my/badge1", + "rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master", + "rendered_image_url": "https://shields.io/my/badge1", + "kind": "project" +} +``` + +## Edit a badge of a project + +Updates a badge of a project. + +``` +PUT /projects/:id/badges/:badge_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `badge_id` | integer | yes | The badge ID | +| `link_url` | string | no | URL of the badge link | +| `image_url` | string | no | URL of the badge image | + +```bash +curl --request PUT --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id +``` + +Example response: + +```json +{ + "id": 1, + "link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "https://gitlab.com/gitlab-org/gitlab-ce/commits/master", + "rendered_image_url": "https://shields.io/my/badge", + "kind": "project" +} +``` + +## Remove a badge from a project + +Removes a badge from a project. Only project's badges will be removed by using this endpoint. + +``` +DELETE /projects/:id/badges/:badge_id +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `badge_id` | integer | yes | The badge ID | + +```bash +curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/:badge_id +``` + +## Preview a badge from a project + +Returns how the `link_url` and `image_url` final URLs would be after resolving the placeholder interpolation. + +``` +GET /projects/:id/badges/render +``` + +| Attribute | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `link_url` | string | yes | URL of the badge link| +| `image_url` | string | yes | URL of the badge image | + +```bash +curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/:id/badges/render?link_url=http%3A%2F%2Fexample.com%2Fci_status.svg%3Fproject%3D%25%7Bproject_path%7D%26ref%3D%25%7Bdefault_branch%7D&image_url=https%3A%2F%2Fshields.io%2Fmy%2Fbadge +``` + +Example response: + +```json +{ + "link_url": "http://example.com/ci_status.svg?project=%{project_path}&ref=%{default_branch}", + "image_url": "https://shields.io/my/badge", + "rendered_link_url": "http://example.com/ci_status.svg?project=example-org/example-project&ref=master", + "rendered_image_url": "https://shields.io/my/badge", +} +``` diff --git a/doc/api/projects.md b/doc/api/projects.md index b6442cfac22..271ee91dc72 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -1340,3 +1340,7 @@ Read more in the [Project import/export](project_import_export.md) documentation ## Project members Read more in the [Project members](members.md) documentation. + +## Project badges + +Read more in the [Project Badges](project_badges.md) documentation. diff --git a/lib/api/api.rb b/lib/api/api.rb index 754549f72f0..b1b247b70b9 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -108,6 +108,7 @@ module API mount ::API::AccessRequests mount ::API::Applications mount ::API::AwardEmoji + mount ::API::Badges mount ::API::Boards mount ::API::Branches mount ::API::BroadcastMessages diff --git a/lib/api/badges.rb b/lib/api/badges.rb new file mode 100644 index 00000000000..334948b2995 --- /dev/null +++ b/lib/api/badges.rb @@ -0,0 +1,134 @@ +module API + class Badges < Grape::API + include PaginationParams + + before { authenticate_non_get! } + + helpers ::API::Helpers::BadgesHelpers + + helpers do + def find_source_if_admin(source_type) + source = find_source(source_type, params[:id]) + + authorize_admin_source!(source_type, source) + + source + end + end + + %w[group project].each do |source_type| + params do + requires :id, type: String, desc: "The ID of a #{source_type}" + end + resource source_type.pluralize, requirements: API::PROJECT_ENDPOINT_REQUIREMENTS do + desc "Gets a list of #{source_type} badges viewable by the authenticated user." do + detail 'This feature was introduced in GitLab 10.6.' + success Entities::Badge + end + params do + use :pagination + end + get ":id/badges" do + source = find_source(source_type, params[:id]) + + present_badges(source, paginate(source.badges)) + end + + desc "Preview a badge from a #{source_type}." do + detail 'This feature was introduced in GitLab 10.6.' + success Entities::BasicBadgeDetails + end + params do + requires :link_url, type: String, desc: 'URL of the badge link' + requires :image_url, type: String, desc: 'URL of the badge image' + end + get ":id/badges/render" do + authenticate! + + source = find_source_if_admin(source_type) + + badge = ::Badges::BuildService.new(declared_params(include_missing: false)) + .execute(source) + + if badge.valid? + present_badges(source, badge, with: Entities::BasicBadgeDetails) + else + render_validation_error!(badge) + end + end + + desc "Gets a badge of a #{source_type}." do + detail 'This feature was introduced in GitLab 10.6.' + success Entities::Badge + end + params do + requires :badge_id, type: Integer, desc: 'The badge ID' + end + get ":id/badges/:badge_id" do + source = find_source(source_type, params[:id]) + badge = find_badge(source) + + present_badges(source, badge) + end + + desc "Adds a badge to a #{source_type}." do + detail 'This feature was introduced in GitLab 10.6.' + success Entities::Badge + end + params do + requires :link_url, type: String, desc: 'URL of the badge link' + requires :image_url, type: String, desc: 'URL of the badge image' + end + post ":id/badges" do + source = find_source_if_admin(source_type) + + badge = ::Badges::CreateService.new(declared_params(include_missing: false)).execute(source) + + if badge.persisted? + present_badges(source, badge) + else + render_validation_error!(badge) + end + end + + desc "Updates a badge of a #{source_type}." do + detail 'This feature was introduced in GitLab 10.6.' + success Entities::Badge + end + params do + optional :link_url, type: String, desc: 'URL of the badge link' + optional :image_url, type: String, desc: 'URL of the badge image' + end + put ":id/badges/:badge_id" do + source = find_source_if_admin(source_type) + + badge = ::Badges::UpdateService.new(declared_params(include_missing: false)) + .execute(find_badge(source)) + + if badge.valid? + present_badges(source, badge) + else + render_validation_error!(badge) + end + end + + desc 'Removes a badge from a project or group.' do + detail 'This feature was introduced in GitLab 10.6.' + end + params do + requires :badge_id, type: Integer, desc: 'The badge ID' + end + delete ":id/badges/:badge_id" do + source = find_source_if_admin(source_type) + badge = find_badge(source) + + if badge.is_a?(GroupBadge) && source.is_a?(Project) + error!('To delete a Group badge please use the Group endpoint', 403) + end + + destroy_conditionally!(badge) + end + end + end + end +end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 0c8ec7dd5f5..e5bcbface6b 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -1235,5 +1235,23 @@ module API expose :startline expose :project_id end + + class BasicBadgeDetails < Grape::Entity + expose :link_url + expose :image_url + expose :rendered_link_url do |badge, options| + badge.rendered_link_url(options.fetch(:project, nil)) + end + expose :rendered_image_url do |badge, options| + badge.rendered_image_url(options.fetch(:project, nil)) + end + end + + class Badge < BasicBadgeDetails + expose :id + expose :kind do |badge| + badge.type == 'ProjectBadge' ? 'project' : 'group' + end + end end end diff --git a/lib/api/helpers/badges_helpers.rb b/lib/api/helpers/badges_helpers.rb new file mode 100644 index 00000000000..1f8afbf3c90 --- /dev/null +++ b/lib/api/helpers/badges_helpers.rb @@ -0,0 +1,28 @@ +module API + module Helpers + module BadgesHelpers + include ::API::Helpers::MembersHelpers + + def find_badge(source) + source.badges.find(params[:badge_id]) + end + + def present_badges(source, records, options = {}) + entity_type = options[:with] || Entities::Badge + badge_params = badge_source_params(source).merge(with: entity_type) + + present records, badge_params + end + + def badge_source_params(source) + project = if source.is_a?(Project) + source + else + GroupProjectsFinder.new(group: source, current_user: current_user).execute.first + end + + { project: project } + end + end + end +end diff --git a/lib/gitlab/import_export/import_export.yml b/lib/gitlab/import_export/import_export.yml index 9f404003125..4bdd01f5e94 100644 --- a/lib/gitlab/import_export/import_export.yml +++ b/lib/gitlab/import_export/import_export.yml @@ -65,6 +65,7 @@ project_tree: - :create_access_levels - :project_feature - :custom_attributes + - :project_badges # Only include the following attributes for the models specified. included_attributes: @@ -125,6 +126,8 @@ excluded_attributes: - :when push_event_payload: - :event_id + project_badges: + - :group_id methods: labels: @@ -147,3 +150,5 @@ methods: - :action push_event_payload: - :action + project_badges: + - :type diff --git a/lib/gitlab/import_export/relation_factory.rb b/lib/gitlab/import_export/relation_factory.rb index 759833a5ee5..cf6b7e306dd 100644 --- a/lib/gitlab/import_export/relation_factory.rb +++ b/lib/gitlab/import_export/relation_factory.rb @@ -16,7 +16,8 @@ module Gitlab priorities: :label_priorities, auto_devops: :project_auto_devops, label: :project_label, - custom_attributes: 'ProjectCustomAttribute' }.freeze + custom_attributes: 'ProjectCustomAttribute', + project_badges: 'Badge' }.freeze USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id].freeze diff --git a/lib/gitlab/string_placeholder_replacer.rb b/lib/gitlab/string_placeholder_replacer.rb new file mode 100644 index 00000000000..9a2219b7d77 --- /dev/null +++ b/lib/gitlab/string_placeholder_replacer.rb @@ -0,0 +1,27 @@ +module Gitlab + class StringPlaceholderReplacer + # This method accepts the following paras + # - string: the string to be analyzed + # - placeholder_regex: i.e. /%{project_path|project_id|default_branch|commit_sha}/ + # - block: this block will be called with each placeholder found in the string using + # the placeholder regex. If the result of the block is nil, the original + # placeholder will be returned. + + def self.replace_string_placeholders(string, placeholder_regex = nil, &block) + return string if string.blank? || placeholder_regex.blank? || !block_given? + + replace_placeholders(string, placeholder_regex, &block) + end + + class << self + private + + # If the result of the block is nil, then the placeholder is returned + def replace_placeholders(string, placeholder_regex, &block) + string.gsub(/%{(#{placeholder_regex})}/) do |arg| + yield($~[1]) || arg + end + end + end + end +end diff --git a/spec/factories/badge.rb b/spec/factories/badge.rb new file mode 100644 index 00000000000..b87ece946cb --- /dev/null +++ b/spec/factories/badge.rb @@ -0,0 +1,14 @@ +FactoryBot.define do + trait :base_badge do + link_url { generate(:url) } + image_url { generate(:url) } + end + + factory :project_badge, traits: [:base_badge], class: ProjectBadge do + project + end + + factory :group_badge, aliases: [:badge], traits: [:base_badge], class: GroupBadge do + group + end +end diff --git a/spec/lib/gitlab/import_export/all_models.yml b/spec/lib/gitlab/import_export/all_models.yml index 41a55027f4d..b20cc34dd5c 100644 --- a/spec/lib/gitlab/import_export/all_models.yml +++ b/spec/lib/gitlab/import_export/all_models.yml @@ -277,6 +277,7 @@ project: - fork_network - custom_attributes - lfs_file_locks +- project_badges award_emoji: - awardable - user @@ -293,3 +294,5 @@ issue_assignees: - assignee lfs_file_locks: - user +project_badges: +- project diff --git a/spec/lib/gitlab/import_export/project.json b/spec/lib/gitlab/import_export/project.json index b6c1f0c81cb..62ef93f847a 100644 --- a/spec/lib/gitlab/import_export/project.json +++ b/spec/lib/gitlab/import_export/project.json @@ -14,8 +14,7 @@ "template": false, "description": "", "type": "ProjectLabel", - "priorities": [ - ] + "priorities": [] }, { "id": 3, @@ -160,9 +159,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 352, @@ -184,9 +181,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 353, @@ -208,9 +203,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 354, @@ -232,9 +225,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 355, @@ -256,9 +247,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 356, @@ -280,9 +269,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 357, @@ -304,9 +291,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 358, @@ -328,9 +313,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] }, @@ -395,9 +378,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 360, @@ -419,9 +400,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 361, @@ -443,9 +422,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 362, @@ -467,9 +444,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 363, @@ -491,9 +466,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 364, @@ -515,9 +488,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 365, @@ -539,9 +510,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 366, @@ -563,9 +532,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] }, @@ -628,9 +595,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 368, @@ -652,9 +617,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 369, @@ -676,9 +639,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 370, @@ -700,9 +661,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 371, @@ -724,9 +683,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 372, @@ -748,9 +705,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 373, @@ -772,9 +727,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 374, @@ -796,9 +749,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] }, @@ -840,9 +791,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 376, @@ -864,9 +813,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 377, @@ -888,9 +835,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 378, @@ -912,9 +857,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 379, @@ -936,9 +879,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 380, @@ -960,9 +901,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 381, @@ -984,9 +923,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 382, @@ -1008,9 +945,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] }, @@ -1052,9 +987,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 384, @@ -1076,9 +1009,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 385, @@ -1100,9 +1031,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 386, @@ -1124,9 +1053,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 387, @@ -1148,9 +1075,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 388, @@ -1172,9 +1097,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 389, @@ -1196,9 +1119,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 390, @@ -1220,9 +1141,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] }, @@ -1264,9 +1183,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 392, @@ -1288,9 +1205,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 393, @@ -1312,9 +1227,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 394, @@ -1336,9 +1249,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 395, @@ -1360,9 +1271,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 396, @@ -1384,9 +1293,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 397, @@ -1408,9 +1315,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 398, @@ -1432,9 +1337,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] }, @@ -1476,9 +1379,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 400, @@ -1500,9 +1401,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 401, @@ -1524,9 +1423,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 402, @@ -1548,9 +1445,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 403, @@ -1572,9 +1467,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 404, @@ -1596,9 +1489,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 405, @@ -1620,9 +1511,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 406, @@ -1644,9 +1533,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] }, @@ -1688,9 +1575,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 408, @@ -1712,9 +1597,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 409, @@ -1736,9 +1619,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 410, @@ -1760,9 +1641,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 411, @@ -1784,9 +1663,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 412, @@ -1808,9 +1685,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 413, @@ -1832,9 +1707,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 414, @@ -1856,9 +1729,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] }, @@ -1900,9 +1771,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 416, @@ -1924,9 +1793,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 417, @@ -1948,9 +1815,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 418, @@ -1972,9 +1837,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 419, @@ -1996,9 +1859,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 420, @@ -2020,9 +1881,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 421, @@ -2044,9 +1903,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 422, @@ -2068,9 +1925,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] }, @@ -2112,9 +1967,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 424, @@ -2136,9 +1989,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 425, @@ -2160,9 +2011,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 426, @@ -2184,9 +2033,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 427, @@ -2208,9 +2055,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 428, @@ -2232,9 +2077,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 429, @@ -2256,9 +2099,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 430, @@ -2280,9 +2121,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ] } @@ -2378,12 +2217,8 @@ ] } ], - "snippets": [ - - ], - "releases": [ - - ], + "snippets": [], + "releases": [], "project_members": [ { "id": 36, @@ -2515,9 +2350,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 672, @@ -2539,9 +2372,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 673, @@ -2563,9 +2394,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 674, @@ -2587,9 +2416,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 675, @@ -2611,9 +2438,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 676, @@ -2635,9 +2460,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 677, @@ -2659,9 +2482,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 678, @@ -2683,9 +2504,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ], "merge_request_diff": { @@ -2696,7 +2515,7 @@ "merge_request_diff_id": 27, "relative_order": 0, "sha": "bb5206fee213d983da88c47f9cf4cc6caf9c66dc", - "message": "Feature conflcit added\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Feature conflcit added\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-08-06T08:35:52.000+02:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -2708,7 +2527,7 @@ "merge_request_diff_id": 27, "relative_order": 1, "sha": "5937ac0a7beb003549fc5fd26fc247adbce4a52e", - "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T10:01:38.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -2720,7 +2539,7 @@ "merge_request_diff_id": 27, "relative_order": 2, "sha": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d", - "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:57:31.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -2732,7 +2551,7 @@ "merge_request_diff_id": 27, "relative_order": 3, "sha": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9", - "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:54:21.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -2744,7 +2563,7 @@ "merge_request_diff_id": 27, "relative_order": 4, "sha": "d14d6c0abdd253381df51a723d58691b2ee1ab08", - "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:49:50.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -2756,7 +2575,7 @@ "merge_request_diff_id": 27, "relative_order": 5, "sha": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8", - "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:48:32.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -2834,7 +2653,7 @@ { "merge_request_diff_id": 27, "relative_order": 5, - "utf8_diff": "--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" =\u003e path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" =\u003e path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output \u003c\u003c stdout.read\n @cmd_output \u003c\u003c stderr.read\n", + "utf8_diff": "--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n", "new_path": "files/ruby/popen.rb", "old_path": "files/ruby/popen.rb", "a_mode": "100644", @@ -2958,9 +2777,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 680, @@ -2982,9 +2799,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 681, @@ -3006,9 +2821,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 682, @@ -3030,9 +2843,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 683, @@ -3054,9 +2865,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 684, @@ -3078,9 +2887,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 685, @@ -3102,9 +2909,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 686, @@ -3126,9 +2931,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ], "merge_request_diff": { @@ -3139,7 +2942,7 @@ "merge_request_diff_id": 26, "sha": "0b4bc9a49b562e85de7cc9e834518ea6828729b9", "relative_order": 0, - "message": "Feature added\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Feature added\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:26:01.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -3237,9 +3040,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 778, @@ -3261,9 +3062,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 779, @@ -3285,9 +3084,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 780, @@ -3309,9 +3106,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 781, @@ -3333,9 +3128,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 782, @@ -3357,9 +3150,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 783, @@ -3381,9 +3172,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 784, @@ -3405,9 +3194,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ], "merge_request_diff": { @@ -3516,9 +3303,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 786, @@ -3540,9 +3325,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 787, @@ -3564,9 +3347,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 788, @@ -3588,9 +3369,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 789, @@ -3612,9 +3391,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 790, @@ -3636,9 +3413,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 791, @@ -3660,9 +3435,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 792, @@ -3684,9 +3457,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ], "merge_request_diff": { @@ -3877,7 +3648,7 @@ "merge_request_diff_id": 14, "relative_order": 15, "sha": "5937ac0a7beb003549fc5fd26fc247adbce4a52e", - "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T10:01:38.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -3889,7 +3660,7 @@ "merge_request_diff_id": 14, "relative_order": 16, "sha": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d", - "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:57:31.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -3901,7 +3672,7 @@ "merge_request_diff_id": 14, "relative_order": 17, "sha": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9", - "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:54:21.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -3913,7 +3684,7 @@ "merge_request_diff_id": 14, "relative_order": 18, "sha": "d14d6c0abdd253381df51a723d58691b2ee1ab08", - "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:49:50.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -3925,7 +3696,7 @@ "merge_request_diff_id": 14, "relative_order": 19, "sha": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8", - "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:48:32.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -4016,7 +3787,7 @@ { "merge_request_diff_id": 14, "relative_order": 6, - "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\u003c?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?\u003e\n+\u003csvg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\"\u003e\n+ \u003c!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch --\u003e\n+ \u003ctitle\u003ewm\u003c/title\u003e\n+ \u003cdesc\u003eCreated with Sketch.\u003c/desc\u003e\n+ \u003cdefs\u003e\n+ \u003cpath id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"\u003e\u003c/path\u003e\n+ \u003c/defs\u003e\n+ \u003cg id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\"\u003e\n+ \u003cpath d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\"\u003e\n+ \u003cg id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\"\u003e\n+ \u003cg id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\n+ \u003cpath d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g16\"\u003e\n+ \u003cg id=\"g18-Clipped\"\u003e\n+ \u003cmask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\"\u003e\n+ \u003cuse xlink:href=\"#path-1\"\u003e\u003c/use\u003e\n+ \u003c/mask\u003e\n+ \u003cg id=\"path22\"\u003e\u003c/g\u003e\n+ \u003cg id=\"g18\" mask=\"url(#mask-2)\"\u003e\n+ \u003cg transform=\"translate(382.736659, 312.879425)\"\u003e\n+ \u003cg id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\"\u003e\n+ \u003cpath d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\"\u003e\n+ \u003cpath d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\"\u003e\n+ \u003cpath d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\"\u003e\n+ \u003cpath d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cpath d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cpath d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\"\u003e\n+ \u003cpath d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\"\u003e\n+ \u003cpath d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path54\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\"\u003e\n+ \u003cpath d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cg id=\"path62\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\"\u003e\n+ \u003cpath d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path70\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\"\u003e\n+ \u003cpath d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cpath d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\"\u003e\n+ \u003cpath d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\"\u003e\n+ \u003cpath d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+\u003c/svg\u003e\n\\ No newline at end of file\n", + "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n", "new_path": "files/images/wm.svg", "old_path": "files/images/wm.svg", "a_mode": "0", @@ -4042,7 +3813,7 @@ { "merge_request_diff_id": 14, "relative_order": 8, - "utf8_diff": "--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" =\u003e path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" =\u003e path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output \u003c\u003c stdout.read\n @cmd_output \u003c\u003c stderr.read\n", + "utf8_diff": "--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n", "new_path": "files/ruby/popen.rb", "old_path": "files/ruby/popen.rb", "a_mode": "100644", @@ -4207,7 +3978,7 @@ }, "events": [ { - "merge_request_diff_id": 14, + "merge_request_diff_id": 14, "id": 529, "target_type": "Note", "target_id": 793, @@ -4239,9 +4010,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 795, @@ -4263,9 +4032,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 796, @@ -4287,9 +4054,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 797, @@ -4311,9 +4076,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 798, @@ -4335,9 +4098,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 799, @@ -4359,9 +4120,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 800, @@ -4383,9 +4142,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ], "merge_request_diff": { @@ -4603,7 +4360,7 @@ { "merge_request_diff_id": 13, "relative_order": 2, - "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\u003c?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?\u003e\n+\u003csvg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\"\u003e\n+ \u003c!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch --\u003e\n+ \u003ctitle\u003ewm\u003c/title\u003e\n+ \u003cdesc\u003eCreated with Sketch.\u003c/desc\u003e\n+ \u003cdefs\u003e\n+ \u003cpath id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"\u003e\u003c/path\u003e\n+ \u003c/defs\u003e\n+ \u003cg id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\"\u003e\n+ \u003cpath d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\"\u003e\n+ \u003cg id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\"\u003e\n+ \u003cg id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\n+ \u003cpath d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g16\"\u003e\n+ \u003cg id=\"g18-Clipped\"\u003e\n+ \u003cmask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\"\u003e\n+ \u003cuse xlink:href=\"#path-1\"\u003e\u003c/use\u003e\n+ \u003c/mask\u003e\n+ \u003cg id=\"path22\"\u003e\u003c/g\u003e\n+ \u003cg id=\"g18\" mask=\"url(#mask-2)\"\u003e\n+ \u003cg transform=\"translate(382.736659, 312.879425)\"\u003e\n+ \u003cg id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\"\u003e\n+ \u003cpath d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\"\u003e\n+ \u003cpath d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\"\u003e\n+ \u003cpath d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\"\u003e\n+ \u003cpath d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cpath d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cpath d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\"\u003e\n+ \u003cpath d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\"\u003e\n+ \u003cpath d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path54\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\"\u003e\n+ \u003cpath d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cg id=\"path62\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\"\u003e\n+ \u003cpath d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path70\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\"\u003e\n+ \u003cpath d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cpath d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\"\u003e\n+ \u003cpath d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\"\u003e\n+ \u003cpath d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+\u003c/svg\u003e\n\\ No newline at end of file\n", + "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n", "new_path": "files/images/wm.svg", "old_path": "files/images/wm.svg", "a_mode": "0", @@ -4740,9 +4497,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 802, @@ -4764,9 +4519,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 803, @@ -4788,9 +4541,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 804, @@ -4812,9 +4563,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 805, @@ -4836,9 +4585,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 806, @@ -4860,9 +4607,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 807, @@ -4884,9 +4629,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 808, @@ -4908,9 +4651,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ], "merge_request_diff": { @@ -5104,7 +4845,7 @@ { "merge_request_diff_id": 12, "relative_order": 2, - "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\u003c?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?\u003e\n+\u003csvg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\"\u003e\n+ \u003c!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch --\u003e\n+ \u003ctitle\u003ewm\u003c/title\u003e\n+ \u003cdesc\u003eCreated with Sketch.\u003c/desc\u003e\n+ \u003cdefs\u003e\n+ \u003cpath id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"\u003e\u003c/path\u003e\n+ \u003c/defs\u003e\n+ \u003cg id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\"\u003e\n+ \u003cpath d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\"\u003e\n+ \u003cg id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\"\u003e\n+ \u003cg id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\n+ \u003cpath d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g16\"\u003e\n+ \u003cg id=\"g18-Clipped\"\u003e\n+ \u003cmask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\"\u003e\n+ \u003cuse xlink:href=\"#path-1\"\u003e\u003c/use\u003e\n+ \u003c/mask\u003e\n+ \u003cg id=\"path22\"\u003e\u003c/g\u003e\n+ \u003cg id=\"g18\" mask=\"url(#mask-2)\"\u003e\n+ \u003cg transform=\"translate(382.736659, 312.879425)\"\u003e\n+ \u003cg id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\"\u003e\n+ \u003cpath d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\"\u003e\n+ \u003cpath d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\"\u003e\n+ \u003cpath d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\"\u003e\n+ \u003cpath d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cpath d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cpath d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\"\u003e\n+ \u003cpath d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\"\u003e\n+ \u003cpath d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path54\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\"\u003e\n+ \u003cpath d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cg id=\"path62\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\"\u003e\n+ \u003cpath d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path70\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\"\u003e\n+ \u003cpath d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cpath d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\"\u003e\n+ \u003cpath d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\"\u003e\n+ \u003cpath d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+\u003c/svg\u003e\n\\ No newline at end of file\n", + "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n", "new_path": "files/images/wm.svg", "old_path": "files/images/wm.svg", "a_mode": "0", @@ -5228,9 +4969,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 810, @@ -5252,9 +4991,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 811, @@ -5276,9 +5013,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 812, @@ -5300,9 +5035,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 813, @@ -5324,9 +5057,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 814, @@ -5348,9 +5079,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 815, @@ -5372,9 +5101,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 816, @@ -5396,18 +5123,14 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ], "merge_request_diff": { "id": 11, "state": "empty", - "merge_request_diff_commits": [ - ], - "merge_request_diff_files": [ - ], + "merge_request_diff_commits": [], + "merge_request_diff_files": [], "merge_request_id": 11, "created_at": "2016-06-14T15:02:23.772Z", "updated_at": "2016-06-14T15:02:23.833Z", @@ -5482,9 +5205,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 818, @@ -5506,9 +5227,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 819, @@ -5530,9 +5249,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 820, @@ -5554,9 +5271,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 821, @@ -5578,9 +5293,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 822, @@ -5602,9 +5315,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 823, @@ -5626,9 +5337,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 824, @@ -5650,9 +5359,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ], "merge_request_diff": { @@ -5843,7 +5550,7 @@ "merge_request_diff_id": 10, "relative_order": 16, "sha": "5937ac0a7beb003549fc5fd26fc247adbce4a52e", - "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Add submodule from gitlab.com\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T10:01:38.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -5855,7 +5562,7 @@ "merge_request_diff_id": 10, "relative_order": 17, "sha": "570e7b2abdd848b95f2f578043fc23bd6f6fd24d", - "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Change some files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:57:31.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -5867,7 +5574,7 @@ "merge_request_diff_id": 10, "relative_order": 18, "sha": "6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9", - "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "More submodules\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:54:21.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -5879,7 +5586,7 @@ "merge_request_diff_id": 10, "relative_order": 19, "sha": "d14d6c0abdd253381df51a723d58691b2ee1ab08", - "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Remove ds_store files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:49:50.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -5891,7 +5598,7 @@ "merge_request_diff_id": 10, "relative_order": 20, "sha": "c1acaa58bbcbc3eafe538cb8274ba387047b69f8", - "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \u003cdmitriy.zaporozhets@gmail.com\u003e\n", + "message": "Ignore DS files\n\nSigned-off-by: Dmitriy Zaporozhets \n", "authored_date": "2014-02-27T09:48:32.000+01:00", "author_name": "Dmitriy Zaporozhets", "author_email": "dmitriy.zaporozhets@gmail.com", @@ -5982,7 +5689,7 @@ { "merge_request_diff_id": 10, "relative_order": 6, - "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\u003c?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"no\"?\u003e\n+\u003csvg width=\"1300px\" height=\"680px\" viewBox=\"0 0 1300 680\" version=\"1.1\" xmlns=\"http://www.w3.org/2000/svg\" xmlns:xlink=\"http://www.w3.org/1999/xlink\" xmlns:sketch=\"http://www.bohemiancoding.com/sketch/ns\"\u003e\n+ \u003c!-- Generator: Sketch 3.2.2 (9983) - http://www.bohemiancoding.com/sketch --\u003e\n+ \u003ctitle\u003ewm\u003c/title\u003e\n+ \u003cdesc\u003eCreated with Sketch.\u003c/desc\u003e\n+ \u003cdefs\u003e\n+ \u003cpath id=\"path-1\" d=\"M-69.8,1023.54607 L1675.19996,1023.54607 L1675.19996,0 L-69.8,0 L-69.8,1023.54607 L-69.8,1023.54607 Z\"\u003e\u003c/path\u003e\n+ \u003c/defs\u003e\n+ \u003cg id=\"Page-1\" stroke=\"none\" stroke-width=\"1\" fill=\"none\" fill-rule=\"evenodd\" sketch:type=\"MSPage\"\u003e\n+ \u003cpath d=\"M1300,680 L0,680 L0,0 L1300,0 L1300,680 L1300,680 Z\" id=\"bg\" fill=\"#30353E\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"gitlab_logo\" sketch:type=\"MSLayerGroup\" transform=\"translate(-262.000000, -172.000000)\"\u003e\n+ \u003cg id=\"g10\" transform=\"translate(872.500000, 512.354581) scale(1, -1) translate(-872.500000, -512.354581) translate(0.000000, 0.290751)\"\u003e\n+ \u003cg id=\"g12\" transform=\"translate(1218.022652, 440.744871)\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\n+ \u003cpath d=\"M-50.0233338,141.900706 L-69.07059,141.900706 L-69.0100967,0.155858152 L8.04444805,0.155858152 L8.04444805,17.6840847 L-49.9628405,17.6840847 L-50.0233338,141.900706 L-50.0233338,141.900706 Z\" id=\"path14\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g16\"\u003e\n+ \u003cg id=\"g18-Clipped\"\u003e\n+ \u003cmask id=\"mask-2\" sketch:name=\"path22\" fill=\"white\"\u003e\n+ \u003cuse xlink:href=\"#path-1\"\u003e\u003c/use\u003e\n+ \u003c/mask\u003e\n+ \u003cg id=\"path22\"\u003e\u003c/g\u003e\n+ \u003cg id=\"g18\" mask=\"url(#mask-2)\"\u003e\n+ \u003cg transform=\"translate(382.736659, 312.879425)\"\u003e\n+ \u003cg id=\"g24\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(852.718192, 124.992771)\"\u003e\n+ \u003cpath d=\"M63.9833317,27.9148929 C59.2218085,22.9379001 51.2134221,17.9597442 40.3909323,17.9597442 C25.8888194,17.9597442 20.0453962,25.1013043 20.0453962,34.4074318 C20.0453962,48.4730484 29.7848226,55.1819277 50.5642821,55.1819277 C54.4602853,55.1819277 60.7364685,54.7492469 63.9833317,54.1002256 L63.9833317,27.9148929 L63.9833317,27.9148929 Z M44.2869356,113.827628 C28.9053426,113.827628 14.7975996,108.376082 3.78897657,99.301416 L10.5211864,87.6422957 C18.3131929,92.1866076 27.8374026,96.7320827 41.4728323,96.7320827 C57.0568452,96.7320827 63.9833317,88.7239978 63.9833317,75.3074024 L63.9833317,68.3821827 C60.9528485,69.0312039 54.6766653,69.4650479 50.7806621,69.4650479 C17.4476729,69.4650479 0.565379986,57.7791759 0.565379986,33.3245665 C0.565379986,11.4683685 13.9844297,0.43151772 34.3299658,0.43151772 C48.0351955,0.43151772 61.1692285,6.70771614 65.7143717,16.8780421 L69.1776149,3.02876588 L82.5978279,3.02876588 L82.5978279,75.5237428 C82.5978279,98.462806 72.6408582,113.827628 44.2869356,113.827628 L44.2869356,113.827628 Z\" id=\"path26\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g28\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(959.546624, 124.857151)\"\u003e\n+ \u003cpath d=\"M37.2266657,17.4468081 C30.0837992,17.4468081 23.8064527,18.3121698 19.0449295,20.4767371 L19.0449295,79.2306079 L19.0449295,86.0464943 C25.538656,91.457331 33.5470425,95.3526217 43.7203922,95.3526217 C62.1173451,95.3526217 69.2602116,82.3687072 69.2602116,61.3767077 C69.2602116,31.5135879 57.7885819,17.4468081 37.2266657,17.4468081 M45.2315622,113.963713 C28.208506,113.963713 19.0449295,102.384849 19.0449295,102.384849 L19.0449295,120.67143 L18.9844362,144.908535 L10.3967097,144.908535 L0.371103324,144.908535 L0.431596656,6.62629771 C9.73826309,2.73100702 22.5081728,0.567602823 36.3611458,0.567602823 C71.8579349,0.567602823 88.9566078,23.2891625 88.9566078,62.4584098 C88.9566078,93.4043948 73.1527248,113.963713 45.2315622,113.963713\" id=\"path30\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g32\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(509.576747, 125.294950)\"\u003e\n+ \u003cpath d=\"M68.636665,129.10638 C85.5189579,129.10638 96.3414476,123.480366 103.484314,117.853189 L111.669527,132.029302 C100.513161,141.811145 85.5073245,147.06845 69.5021849,147.06845 C29.0274926,147.06845 0.673569983,122.3975 0.673569983,72.6252464 C0.673569983,20.4709215 31.2622559,0.12910638 66.2553217,0.12910638 C83.7879179,0.12910638 98.7227909,4.24073748 108.462217,8.35236859 L108.063194,64.0763105 L108.063194,70.6502677 L108.063194,81.6057001 L56.1168719,81.6057001 L56.1168719,64.0763105 L89.2323178,64.0763105 L89.6313411,21.7701271 C85.3025779,19.6055598 77.7269514,17.8748364 67.554765,17.8748364 C39.4172223,17.8748364 20.5863462,35.5717154 20.5863462,72.8415868 C20.5863462,110.711628 40.0663623,129.10638 68.636665,129.10638\" id=\"path34\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g36\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(692.388992, 124.376085)\"\u003e\n+ \u003cpath d=\"M19.7766662,145.390067 L1.16216997,145.390067 L1.2226633,121.585642 L1.2226633,111.846834 L1.2226633,106.170806 L1.2226633,96.2656714 L1.2226633,39.5681976 L1.2226633,39.3518572 C1.2226633,16.4127939 11.1796331,1.04797161 39.5335557,1.04797161 C43.4504989,1.04797161 47.2836822,1.40388649 51.0051854,2.07965952 L51.0051854,18.7925385 C48.3109055,18.3796307 45.4351455,18.1446804 42.3476589,18.1446804 C26.763646,18.1446804 19.8371595,26.1516022 19.8371595,39.5681976 L19.8371595,96.2656714 L51.0051854,96.2656714 L51.0051854,111.846834 L19.8371595,111.846834 L19.7766662,145.390067 L19.7766662,145.390067 Z\" id=\"path38\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cpath d=\"M646.318899,128.021188 L664.933395,128.021188 L664.933395,236.223966 L646.318899,236.223966 L646.318899,128.021188 L646.318899,128.021188 Z\" id=\"path40\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cpath d=\"M646.318899,251.154944 L664.933395,251.154944 L664.933395,269.766036 L646.318899,269.766036 L646.318899,251.154944 L646.318899,251.154944 Z\" id=\"path42\" fill=\"#8C929D\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003cg id=\"g44\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.464170, 0.676006)\"\u003e\n+ \u003cpath d=\"M429.269989,169.815599 L405.225053,243.802859 L357.571431,390.440955 C355.120288,397.984955 344.444378,397.984955 341.992071,390.440955 L294.337286,243.802859 L136.094873,243.802859 L88.4389245,390.440955 C85.9877812,397.984955 75.3118715,397.984955 72.8595648,390.440955 L25.2059427,243.802859 L1.16216997,169.815599 C-1.03187664,163.067173 1.37156997,155.674379 7.11261982,151.503429 L215.215498,0.336141836 L423.319539,151.503429 C429.060589,155.674379 431.462873,163.067173 429.269989,169.815599\" id=\"path46\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g48\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(135.410135, 1.012147)\"\u003e\n+ \u003cpath d=\"M80.269998,0 L80.269998,0 L159.391786,243.466717 L1.14820997,243.466717 L80.269998,0 L80.269998,0 Z\" id=\"path50\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g52\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path54\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g56\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(24.893471, 1.012613)\"\u003e\n+ \u003cpath d=\"M190.786662,0 L111.664874,243.465554 L0.777106647,243.465554 L190.786662,0 L190.786662,0 Z\" id=\"path58\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g60\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cg id=\"path62\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g64\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(0.077245, 0.223203)\"\u003e\n+ \u003cpath d=\"M25.5933327,244.255313 L25.5933327,244.255313 L1.54839663,170.268052 C-0.644486651,163.519627 1.75779662,156.126833 7.50000981,151.957046 L215.602888,0.789758846 L25.5933327,244.255313 L25.5933327,244.255313 Z\" id=\"path66\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g68\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012147)\"\u003e\n+ \u003cg id=\"path70\"\u003e\u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g72\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(25.670578, 244.478283)\"\u003e\n+ \u003cpath d=\"M0,0 L110.887767,0 L63.2329818,146.638096 C60.7806751,154.183259 50.1047654,154.183259 47.6536221,146.638096 L0,0 L0,0 Z\" id=\"path74\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g76\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(215.680133, 1.012613)\"\u003e\n+ \u003cpath d=\"M0,0 L79.121788,243.465554 L190.009555,243.465554 L0,0 L0,0 Z\" id=\"path78\" fill=\"#FC6D26\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g80\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(214.902910, 0.223203)\"\u003e\n+ \u003cpath d=\"M190.786662,244.255313 L190.786662,244.255313 L214.831598,170.268052 C217.024481,163.519627 214.622198,156.126833 208.879985,151.957046 L0.777106647,0.789758846 L190.786662,244.255313 L190.786662,244.255313 Z\" id=\"path82\" fill=\"#FCA326\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003cg id=\"g84\" stroke-width=\"1\" fill=\"none\" sketch:type=\"MSLayerGroup\" transform=\"translate(294.009575, 244.478283)\"\u003e\n+ \u003cpath d=\"M111.679997,0 L0.79222998,0 L48.4470155,146.638096 C50.8993221,154.183259 61.5752318,154.183259 64.0263751,146.638096 L111.679997,0 L111.679997,0 Z\" id=\"path86\" fill=\"#E24329\" sketch:type=\"MSShapeGroup\"\u003e\u003c/path\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+ \u003c/g\u003e\n+\u003c/svg\u003e\n\\ No newline at end of file\n", + "utf8_diff": "--- /dev/null\n+++ b/files/images/wm.svg\n@@ -0,0 +1,78 @@\n+\n+\n+ \n+ wm\n+ Created with Sketch.\n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+ \n+\n\\ No newline at end of file\n", "new_path": "files/images/wm.svg", "old_path": "files/images/wm.svg", "a_mode": "0", @@ -6008,7 +5715,7 @@ { "merge_request_diff_id": 10, "relative_order": 8, - "utf8_diff": "--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" =\u003e path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" =\u003e path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output \u003c\u003c stdout.read\n @cmd_output \u003c\u003c stderr.read\n", + "utf8_diff": "--- a/files/ruby/popen.rb\n+++ b/files/ruby/popen.rb\n@@ -6,12 +6,18 @@ module Popen\n \n def popen(cmd, path=nil)\n unless cmd.is_a?(Array)\n- raise \"System commands must be given as an array of strings\"\n+ raise RuntimeError, \"System commands must be given as an array of strings\"\n end\n \n path ||= Dir.pwd\n- vars = { \"PWD\" => path }\n- options = { chdir: path }\n+\n+ vars = {\n+ \"PWD\" => path\n+ }\n+\n+ options = {\n+ chdir: path\n+ }\n \n unless File.directory?(path)\n FileUtils.mkdir_p(path)\n@@ -19,6 +25,7 @@ module Popen\n \n @cmd_output = \"\"\n @cmd_status = 0\n+\n Open3.popen3(vars, *cmd, options) do |stdin, stdout, stderr, wait_thr|\n @cmd_output << stdout.read\n @cmd_output << stderr.read\n", "new_path": "files/ruby/popen.rb", "old_path": "files/ruby/popen.rb", "a_mode": "100644", @@ -6171,9 +5878,7 @@ "author": { "name": "User 4" }, - "events": [ - - ] + "events": [] }, { "id": 826, @@ -6195,9 +5900,7 @@ "author": { "name": "User 3" }, - "events": [ - - ] + "events": [] }, { "id": 827, @@ -6219,9 +5922,7 @@ "author": { "name": "User 0" }, - "events": [ - - ] + "events": [] }, { "id": 828, @@ -6243,9 +5944,7 @@ "author": { "name": "Ottis Schuster II" }, - "events": [ - - ] + "events": [] }, { "id": 829, @@ -6267,9 +5966,7 @@ "author": { "name": "Rhett Emmerich IV" }, - "events": [ - - ] + "events": [] }, { "id": 830, @@ -6291,9 +5988,7 @@ "author": { "name": "Burdette Bernier" }, - "events": [ - - ] + "events": [] }, { "id": 831, @@ -6315,9 +6010,7 @@ "author": { "name": "Ari Wintheiser" }, - "events": [ - - ] + "events": [] }, { "id": 832, @@ -6339,9 +6032,7 @@ "author": { "name": "Administrator" }, - "events": [ - - ] + "events": [] } ], "merge_request_diff": { @@ -6953,9 +6644,7 @@ "updated_at": "2017-01-16T15:25:28.637Z" } ], - "deploy_keys": [ - - ], + "deploy_keys": [], "services": [ { "id": 100, @@ -6964,9 +6653,7 @@ "created_at": "2016-06-14T15:01:51.315Z", "updated_at": "2016-06-14T15:01:51.315Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7008,9 +6695,7 @@ "created_at": "2016-06-14T15:01:51.289Z", "updated_at": "2016-06-14T15:01:51.289Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7030,9 +6715,7 @@ "created_at": "2016-06-14T15:01:51.277Z", "updated_at": "2016-06-14T15:01:51.277Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7052,9 +6735,7 @@ "created_at": "2016-06-14T15:01:51.267Z", "updated_at": "2016-06-14T15:01:51.267Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7097,9 +6778,7 @@ "created_at": "2016-06-14T15:01:51.232Z", "updated_at": "2016-06-14T15:01:51.232Z", "active": true, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7141,9 +6820,7 @@ "created_at": "2016-06-14T15:01:51.202Z", "updated_at": "2016-06-14T15:01:51.202Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7163,9 +6840,7 @@ "created_at": "2016-06-14T15:01:51.182Z", "updated_at": "2016-06-14T15:01:51.182Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7185,9 +6860,7 @@ "created_at": "2016-06-14T15:01:51.166Z", "updated_at": "2016-06-14T15:01:51.166Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7207,9 +6880,7 @@ "created_at": "2016-06-14T15:01:51.153Z", "updated_at": "2016-06-14T15:01:51.153Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7229,9 +6900,7 @@ "created_at": "2016-06-14T15:01:51.139Z", "updated_at": "2016-06-14T15:01:51.139Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7251,9 +6920,7 @@ "created_at": "2016-06-14T15:01:51.125Z", "updated_at": "2016-06-14T15:01:51.125Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7273,9 +6940,7 @@ "created_at": "2016-06-14T15:01:51.113Z", "updated_at": "2016-06-14T15:01:51.113Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7295,9 +6960,7 @@ "created_at": "2016-06-14T15:01:51.080Z", "updated_at": "2016-06-14T15:01:51.080Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7317,9 +6980,7 @@ "created_at": "2016-06-14T15:01:51.067Z", "updated_at": "2016-06-14T15:01:51.067Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7339,9 +7000,7 @@ "created_at": "2016-06-14T15:01:51.047Z", "updated_at": "2016-06-14T15:01:51.047Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7361,9 +7020,7 @@ "created_at": "2016-06-14T15:01:51.031Z", "updated_at": "2016-06-14T15:01:51.031Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7383,9 +7040,7 @@ "created_at": "2016-06-14T15:01:51.031Z", "updated_at": "2016-06-14T15:01:51.031Z", "active": false, - "properties": { - - }, + "properties": {}, "template": false, "push_events": true, "issues_events": true, @@ -7399,9 +7054,7 @@ "type": "JenkinsDeprecatedService" } ], - "hooks": [ - - ], + "hooks": [], "protected_branches": [ { "id": 1, @@ -7475,5 +7128,25 @@ "key": "bar", "value": "bar" } + ], + "project_badges": [ + { + "id": 1, + "created_at": "2017-10-19T15:36:23.466Z", + "updated_at": "2017-10-19T15:36:23.466Z", + "project_id": 5, + "type": "ProjectBadge", + "link_url": "http://www.example.com", + "image_url": "http://www.example.com" + }, + { + "id": 2, + "created_at": "2017-10-19T15:36:23.466Z", + "updated_at": "2017-10-19T15:36:23.466Z", + "project_id": 5, + "type": "ProjectBadge", + "link_url": "http://www.example.com", + "image_url": "http://www.example.com" + } ] } diff --git a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb index d076007e4bc..1a4d09724fc 100644 --- a/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_restorer_spec.rb @@ -129,6 +129,10 @@ describe Gitlab::ImportExport::ProjectTreeRestorer do expect(@project.custom_attributes.count).to eq(2) end + it 'has badges' do + expect(@project.project_badges.count).to eq(2) + end + it 'restores the correct service' do expect(CustomIssueTrackerService.first).not_to be_nil end diff --git a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb index 5804c45871e..d6bd5f5c81d 100644 --- a/spec/lib/gitlab/import_export/project_tree_saver_spec.rb +++ b/spec/lib/gitlab/import_export/project_tree_saver_spec.rb @@ -180,6 +180,10 @@ describe Gitlab::ImportExport::ProjectTreeSaver do expect(saved_project_json['custom_attributes'].count).to eq(2) end + it 'has badges' do + expect(saved_project_json['project_badges'].count).to eq(2) + end + it 'does not complain about non UTF-8 characters in MR diff files' do ActiveRecord::Base.connection.execute("UPDATE merge_request_diff_files SET diff = '---\n- :diff: !binary |-\n LS0tIC9kZXYvbnVsbAorKysgYi9pbWFnZXMvbnVjb3IucGRmCkBAIC0wLDAg\n KzEsMTY3OSBAQAorJVBERi0xLjUNJeLjz9MNCisxIDAgb2JqDTw8L01ldGFk\n YXR'") @@ -288,6 +292,9 @@ describe Gitlab::ImportExport::ProjectTreeSaver do create(:project_custom_attribute, project: project) create(:project_custom_attribute, project: project) + create(:project_badge, project: project) + create(:project_badge, project: project) + project end diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index feaab6673cd..ddcbb7a0033 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -536,3 +536,12 @@ LfsFileLock: - user_id - project_id - created_at +Badge: +- id +- link_url +- image_url +- project_id +- group_id +- created_at +- updated_at +- type diff --git a/spec/lib/gitlab/string_placeholder_replacer_spec.rb b/spec/lib/gitlab/string_placeholder_replacer_spec.rb new file mode 100644 index 00000000000..7a03ea4154c --- /dev/null +++ b/spec/lib/gitlab/string_placeholder_replacer_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe Gitlab::StringPlaceholderReplacer do + describe '.render_url' do + it 'returns the nil if the string is blank' do + expect(described_class.replace_string_placeholders(nil, /whatever/)).to be_blank + end + + it 'returns the string if the placeholder regex' do + expect(described_class.replace_string_placeholders('whatever')).to eq 'whatever' + end + + it 'returns the string if no block given' do + expect(described_class.replace_string_placeholders('whatever', /whatever/)).to eq 'whatever' + end + + context 'when all params are valid' do + let(:string) { '%{path}/%{id}/%{branch}' } + let(:regex) { /(path|id)/ } + + it 'replaces each placeholders with the block result' do + result = described_class.replace_string_placeholders(string, regex) do |arg| + 'WHATEVER' + end + + expect(result).to eq 'WHATEVER/WHATEVER/%{branch}' + end + + it 'does not replace the placeholder if the block result is nil' do + result = described_class.replace_string_placeholders(string, regex) do |arg| + arg == 'path' ? nil : 'WHATEVER' + end + + expect(result).to eq '%{path}/WHATEVER/%{branch}' + end + end + end +end diff --git a/spec/models/badge_spec.rb b/spec/models/badge_spec.rb new file mode 100644 index 00000000000..33dc19e3432 --- /dev/null +++ b/spec/models/badge_spec.rb @@ -0,0 +1,94 @@ +require 'spec_helper' + +describe Badge do + let(:placeholder_url) { 'http://www.example.com/%{project_path}/%{project_id}/%{default_branch}/%{commit_sha}' } + + describe 'validations' do + # Requires the let variable url_sym + shared_examples 'placeholder url' do + let(:badge) { build(:badge) } + + it 'allows url with http protocol' do + badge[url_sym] = 'http://www.example.com' + + expect(badge).to be_valid + end + + it 'allows url with https protocol' do + badge[url_sym] = 'https://www.example.com' + + expect(badge).to be_valid + end + + it 'cannot be empty' do + badge[url_sym] = '' + + expect(badge).not_to be_valid + end + + it 'cannot be nil' do + badge[url_sym] = nil + + expect(badge).not_to be_valid + end + + it 'accept badges placeholders' do + badge[url_sym] = placeholder_url + + expect(badge).to be_valid + end + + it 'sanitize url' do + badge[url_sym] = 'javascript:alert(1)' + + expect(badge).not_to be_valid + end + end + + context 'link_url format' do + let(:url_sym) { :link_url } + + it_behaves_like 'placeholder url' + end + + context 'image_url format' do + let(:url_sym) { :image_url } + + it_behaves_like 'placeholder url' + end + end + + shared_examples 'rendered_links' do + it 'should use the project information to populate the url placeholders' do + stub_project_commit_info(project) + + expect(badge.public_send("rendered_#{method}", project)).to eq "http://www.example.com/#{project.full_path}/#{project.id}/master/whatever" + end + + it 'returns the url if the project used is nil' do + expect(badge.public_send("rendered_#{method}", nil)).to eq placeholder_url + end + + def stub_project_commit_info(project) + allow(project).to receive(:commit).and_return(double('Commit', sha: 'whatever')) + allow(project).to receive(:default_branch).and_return('master') + end + end + + context 'methods' do + let(:badge) { build(:badge, link_url: placeholder_url, image_url: placeholder_url) } + let!(:project) { create(:project) } + + context '#rendered_link_url' do + let(:method) { :link_url } + + it_behaves_like 'rendered_links' + end + + context '#rendered_image_url' do + let(:method) { :image_url } + + it_behaves_like 'rendered_links' + end + end +end diff --git a/spec/models/badges/group_badge_spec.rb b/spec/models/badges/group_badge_spec.rb new file mode 100644 index 00000000000..ed7f83d0489 --- /dev/null +++ b/spec/models/badges/group_badge_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe GroupBadge do + describe 'associations' do + it { is_expected.to belong_to(:group) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:group) } + end +end diff --git a/spec/models/badges/project_badge_spec.rb b/spec/models/badges/project_badge_spec.rb new file mode 100644 index 00000000000..0e1a8159cb6 --- /dev/null +++ b/spec/models/badges/project_badge_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe ProjectBadge do + let(:placeholder_url) { 'http://www.example.com/%{project_path}/%{project_id}/%{default_branch}/%{commit_sha}' } + + describe 'associations' do + it { is_expected.to belong_to(:project) } + end + + describe 'validations' do + it { is_expected.to validate_presence_of(:project) } + end + + shared_examples 'rendered_links' do + it 'should use the badge project information to populate the url placeholders' do + stub_project_commit_info(project) + + expect(badge.public_send("rendered_#{method}")).to eq "http://www.example.com/#{project.full_path}/#{project.id}/master/whatever" + end + + def stub_project_commit_info(project) + allow(project).to receive(:commit).and_return(double('Commit', sha: 'whatever')) + allow(project).to receive(:default_branch).and_return('master') + end + end + + context 'methods' do + let(:badge) { build(:project_badge, link_url: placeholder_url, image_url: placeholder_url) } + let!(:project) { badge.project } + + context '#rendered_link_url' do + let(:method) { :link_url } + + it_behaves_like 'rendered_links' + end + + context '#rendered_image_url' do + let(:method) { :image_url } + + it_behaves_like 'rendered_links' + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 4f16b73ef38..abfc0896a41 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -18,6 +18,7 @@ describe Group do it { is_expected.to have_many(:uploads).dependent(:destroy) } it { is_expected.to have_one(:chat_team) } it { is_expected.to have_many(:custom_attributes).class_name('GroupCustomAttribute') } + it { is_expected.to have_many(:badges).class_name('GroupBadge') } describe '#members & #requesters' do let(:requester) { create(:user) } diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index f4faec9e52a..92ea8841123 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -80,6 +80,7 @@ describe Project do it { is_expected.to have_many(:members_and_requesters) } it { is_expected.to have_many(:clusters) } it { is_expected.to have_many(:custom_attributes).class_name('ProjectCustomAttribute') } + it { is_expected.to have_many(:project_badges).class_name('ProjectBadge') } it { is_expected.to have_many(:lfs_file_locks) } context 'after initialized' do @@ -3331,4 +3332,36 @@ describe Project do end.not_to raise_error # Sidekiq::Worker::EnqueueFromTransactionError end end + + describe '#badges' do + let(:project_group) { create(:group) } + let(:project) { create(:project, path: 'avatar', namespace: project_group) } + + before do + create_list(:project_badge, 2, project: project) + create(:group_badge, group: project_group) + end + + it 'returns the project and the project group badges' do + create(:group_badge, group: create(:group)) + + expect(Badge.count).to eq 4 + expect(project.badges.count).to eq 3 + end + + if Group.supports_nested_groups? + context 'with nested_groups' do + let(:parent_group) { create(:group) } + + before do + create_list(:group_badge, 2, group: project_group) + project_group.update(parent: parent_group) + end + + it 'returns the project and the project nested groups badges' do + expect(project.badges.count).to eq 5 + end + end + end + end end diff --git a/spec/requests/api/badges_spec.rb b/spec/requests/api/badges_spec.rb new file mode 100644 index 00000000000..ae64a9ca162 --- /dev/null +++ b/spec/requests/api/badges_spec.rb @@ -0,0 +1,367 @@ +require 'spec_helper' + +describe API::Badges do + let(:master) { create(:user, username: 'master_user') } + let(:developer) { create(:user) } + let(:access_requester) { create(:user) } + let(:stranger) { create(:user) } + let(:project_group) { create(:group) } + let(:project) { setup_project } + let!(:group) { setup_group } + + shared_context 'source helpers' do + def get_source(source_type) + source_type == 'project' ? project : group + end + end + + shared_examples 'GET /:sources/:id/badges' do |source_type| + include_context 'source helpers' + + let(:source) { get_source(source_type) } + + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { get api("/#{source_type.pluralize}/#{source.id}/badges", stranger) } + end + + %i[master developer access_requester stranger].each do |type| + context "when authenticated as a #{type}" do + it 'returns 200' do + user = public_send(type) + badges_count = source_type == 'project' ? 3 : 2 + + get api("/#{source_type.pluralize}/#{source.id}/badges", user) + + expect(response).to have_gitlab_http_status(200) + expect(response).to include_pagination_headers + expect(json_response).to be_an Array + expect(json_response.size).to eq(badges_count) + end + end + end + + it 'avoids N+1 queries' do + # Establish baseline + get api("/#{source_type.pluralize}/#{source.id}/badges", master) + + control = ActiveRecord::QueryRecorder.new do + get api("/#{source_type.pluralize}/#{source.id}/badges", master) + end + + project.add_developer(create(:user)) + + expect do + get api("/#{source_type.pluralize}/#{source.id}/badges", master) + end.not_to exceed_query_limit(control) + end + end + end + + shared_examples 'GET /:sources/:id/badges/:badge_id' do |source_type| + include_context 'source helpers' + + let(:source) { get_source(source_type) } + + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) { get api("/#{source_type.pluralize}/#{source.id}/badges/#{developer.id}", stranger) } + end + + context 'when authenticated as a non-member' do + %i[master developer access_requester stranger].each do |type| + let(:badge) { source.badges.first } + + context "as a #{type}" do + it 'returns 200' do + user = public_send(type) + + get api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", user) + + expect(response).to have_gitlab_http_status(200) + expect(json_response['id']).to eq(badge.id) + expect(json_response['link_url']).to eq(badge.link_url) + expect(json_response['rendered_link_url']).to eq(badge.rendered_link_url) + expect(json_response['image_url']).to eq(badge.image_url) + expect(json_response['rendered_image_url']).to eq(badge.rendered_image_url) + expect(json_response['kind']).to eq source_type + end + end + end + end + end + end + + shared_examples 'POST /:sources/:id/badges' do |source_type| + include_context 'source helpers' + + let(:source) { get_source(source_type) } + let(:example_url) { 'http://www.example.com' } + let(:example_url2) { 'http://www.example1.com' } + + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) do + post api("/#{source_type.pluralize}/#{source.id}/badges", stranger), + link_url: example_url, image_url: example_url2 + end + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger developer].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + + post api("/#{source_type.pluralize}/#{source.id}/badges", user), + link_url: example_url, image_url: example_url2 + + expect(response).to have_gitlab_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'creates a new badge' do + expect do + post api("/#{source_type.pluralize}/#{source.id}/badges", master), + link_url: example_url, image_url: example_url2 + + expect(response).to have_gitlab_http_status(201) + end.to change { source.badges.count }.by(1) + + expect(json_response['link_url']).to eq(example_url) + expect(json_response['image_url']).to eq(example_url2) + expect(json_response['kind']).to eq source_type + end + end + + it 'returns 400 when link_url is not given' do + post api("/#{source_type.pluralize}/#{source.id}/badges", master), + link_url: example_url + + expect(response).to have_gitlab_http_status(400) + end + + it 'returns 400 when image_url is not given' do + post api("/#{source_type.pluralize}/#{source.id}/badges", master), + image_url: example_url2 + + expect(response).to have_gitlab_http_status(400) + end + + it 'returns 400 when link_url or image_url is not valid' do + post api("/#{source_type.pluralize}/#{source.id}/badges", master), + link_url: 'whatever', image_url: 'whatever' + + expect(response).to have_gitlab_http_status(400) + end + end + end + + shared_examples 'PUT /:sources/:id/badges/:badge_id' do |source_type| + include_context 'source helpers' + + let(:source) { get_source(source_type) } + + context "with :sources == #{source_type.pluralize}" do + let(:badge) { source.badges.first } + let(:example_url) { 'http://www.example.com' } + let(:example_url2) { 'http://www.example1.com' } + + it_behaves_like 'a 404 response when source is private' do + let(:route) do + put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", stranger), + link_url: example_url + end + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger developer].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + + put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", user), + link_url: example_url + + expect(response).to have_gitlab_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'updates the member' do + put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master), + link_url: example_url, image_url: example_url2 + + expect(response).to have_gitlab_http_status(200) + expect(json_response['link_url']).to eq(example_url) + expect(json_response['image_url']).to eq(example_url2) + expect(json_response['kind']).to eq source_type + end + end + + it 'returns 400 when link_url or image_url is not valid' do + put api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master), + link_url: 'whatever', image_url: 'whatever' + + expect(response).to have_gitlab_http_status(400) + end + end + end + + shared_examples 'DELETE /:sources/:id/badges/:badge_id' do |source_type| + include_context 'source helpers' + + let(:source) { get_source(source_type) } + + context "with :sources == #{source_type.pluralize}" do + let(:badge) { source.badges.first } + + it_behaves_like 'a 404 response when source is private' do + let(:route) { delete api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", stranger) } + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester developer stranger].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + + delete api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", user) + + expect(response).to have_gitlab_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'deletes the badge' do + expect do + delete api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master) + + expect(response).to have_gitlab_http_status(204) + end.to change { source.badges.count }.by(-1) + end + + it_behaves_like '412 response' do + let(:request) { api("/#{source_type.pluralize}/#{source.id}/badges/#{badge.id}", master) } + end + end + + it 'returns 404 if badge does not exist' do + delete api("/#{source_type.pluralize}/#{source.id}/badges/123", master) + + expect(response).to have_gitlab_http_status(404) + end + end + end + + shared_examples 'GET /:sources/:id/badges/render' do |source_type| + include_context 'source helpers' + + let(:source) { get_source(source_type) } + let(:example_url) { 'http://www.example.com' } + let(:example_url2) { 'http://www.example1.com' } + + context "with :sources == #{source_type.pluralize}" do + it_behaves_like 'a 404 response when source is private' do + let(:route) do + get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}&image_url=#{example_url2}", stranger) + end + end + + context 'when authenticated as a non-member or member with insufficient rights' do + %i[access_requester stranger developer].each do |type| + context "as a #{type}" do + it 'returns 403' do + user = public_send(type) + + get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}&image_url=#{example_url2}", user) + + expect(response).to have_gitlab_http_status(403) + end + end + end + end + + context 'when authenticated as a master/owner' do + it 'gets the rendered badge values' do + get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}&image_url=#{example_url2}", master) + + expect(response).to have_gitlab_http_status(200) + + expect(json_response.keys).to contain_exactly('link_url', 'rendered_link_url', 'image_url', 'rendered_image_url') + expect(json_response['link_url']).to eq(example_url) + expect(json_response['image_url']).to eq(example_url2) + expect(json_response['rendered_link_url']).to eq(example_url) + expect(json_response['rendered_image_url']).to eq(example_url2) + end + end + + it 'returns 400 when link_url is not given' do + get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=#{example_url}", master) + + expect(response).to have_gitlab_http_status(400) + end + + it 'returns 400 when image_url is not given' do + get api("/#{source_type.pluralize}/#{source.id}/badges/render?image_url=#{example_url}", master) + + expect(response).to have_gitlab_http_status(400) + end + + it 'returns 400 when link_url or image_url is not valid' do + get api("/#{source_type.pluralize}/#{source.id}/badges/render?link_url=whatever&image_url=whatever", master) + + expect(response).to have_gitlab_http_status(400) + end + end + end + + context 'when deleting a badge' do + context 'and the source is a project' do + it 'cannot delete badges owned by the project group' do + delete api("/projects/#{project.id}/badges/#{project_group.badges.first.id}", master) + + expect(response).to have_gitlab_http_status(403) + end + end + end + + describe 'Endpoints' do + %w(project group).each do |source_type| + it_behaves_like 'GET /:sources/:id/badges', source_type + it_behaves_like 'GET /:sources/:id/badges/:badge_id', source_type + it_behaves_like 'GET /:sources/:id/badges/render', source_type + it_behaves_like 'POST /:sources/:id/badges', source_type + it_behaves_like 'PUT /:sources/:id/badges/:badge_id', source_type + it_behaves_like 'DELETE /:sources/:id/badges/:badge_id', source_type + end + end + + def setup_project + create(:project, :public, :access_requestable, creator_id: master.id, namespace: project_group) do |project| + project.add_developer(developer) + project.add_master(master) + project.request_access(access_requester) + project.project_badges << build(:project_badge, project: project) + project.project_badges << build(:project_badge, project: project) + project_group.badges << build(:group_badge, group: group) + end + end + + def setup_group + create(:group, :public, :access_requestable) do |group| + group.add_developer(developer) + group.add_owner(master) + group.request_access(access_requester) + group.badges << build(:group_badge, group: group) + group.badges << build(:group_badge, group: group) + end + end +end diff --git a/spec/validators/url_placeholder_validator_spec.rb b/spec/validators/url_placeholder_validator_spec.rb new file mode 100644 index 00000000000..b76d8acdf88 --- /dev/null +++ b/spec/validators/url_placeholder_validator_spec.rb @@ -0,0 +1,39 @@ +require 'spec_helper' + +describe UrlPlaceholderValidator do + let(:validator) { described_class.new(attributes: [:link_url], **options) } + let!(:badge) { build(:badge) } + let(:placeholder_url) { 'http://www.example.com/%{project_path}/%{project_id}/%{default_branch}/%{commit_sha}' } + + subject { validator.validate_each(badge, :link_url, badge.link_url) } + + describe '#validates_each' do + context 'with no options' do + let(:options) { {} } + + it 'allows http and https protocols by default' do + expect(validator.send(:default_options)[:protocols]).to eq %w(http https) + end + + it 'checks that the url structure is valid' do + badge.link_url = placeholder_url + + subject + + expect(badge.errors.empty?).to be false + end + end + + context 'with placeholder regex' do + let(:options) { { placeholder_regex: /(project_path|project_id|commit_sha|default_branch)/ } } + + it 'checks that the url is valid and obviate placeholders that match regex' do + badge.link_url = placeholder_url + + subject + + expect(badge.errors.empty?).to be true + end + end + end +end diff --git a/spec/validators/url_validator_spec.rb b/spec/validators/url_validator_spec.rb new file mode 100644 index 00000000000..763dff181d2 --- /dev/null +++ b/spec/validators/url_validator_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe UrlValidator do + let(:validator) { described_class.new(attributes: [:link_url], **options) } + let!(:badge) { build(:badge) } + + subject { validator.validate_each(badge, :link_url, badge.link_url) } + + describe '#validates_each' do + context 'with no options' do + let(:options) { {} } + + it 'allows http and https protocols by default' do + expect(validator.send(:default_options)[:protocols]).to eq %w(http https) + end + + it 'checks that the url structure is valid' do + badge.link_url = 'http://www.google.es/%{whatever}' + + subject + + expect(badge.errors.empty?).to be false + end + end + + context 'with protocols' do + let(:options) { { protocols: %w(http) } } + + it 'allows urls with the defined protocols' do + badge.link_url = 'http://www.example.com' + + subject + + expect(badge.errors.empty?).to be true + end + + it 'add error if the url protocol does not match the selected ones' do + badge.link_url = 'https://www.example.com' + + subject + + expect(badge.errors.empty?).to be false + end + end + end +end diff --git a/spec/views/projects/_home_panel.html.haml_spec.rb b/spec/views/projects/_home_panel.html.haml_spec.rb index 62af946dcab..15fce65979b 100644 --- a/spec/views/projects/_home_panel.html.haml_spec.rb +++ b/spec/views/projects/_home_panel.html.haml_spec.rb @@ -1,7 +1,8 @@ require 'spec_helper' describe 'projects/_home_panel' do - let(:project) { create(:project, :public) } + let(:group) { create(:group) } + let(:project) { create(:project, :public, namespace: group) } let(:notification_settings) do user&.notification_settings_for(project) @@ -35,4 +36,55 @@ describe 'projects/_home_panel' do expect(rendered).not_to have_selector('.notification_dropdown') end end + + context 'when project' do + let!(:user) { create(:user) } + let(:badges) { project.badges } + + context 'has no badges' do + it 'should not render any badge' do + render + + expect(rendered).to have_selector('.project-badges') + expect(rendered).not_to have_selector('.project-badges > a') + end + end + + shared_examples 'show badges' do + it 'should render the all badges' do + render + + expect(rendered).to have_selector('.project-badges a') + + badges.each do |badge| + expect(rendered).to have_link(href: badge.rendered_link_url) + end + end + end + + context 'only has group badges' do + before do + create(:group_badge, group: project.group) + end + + it_behaves_like 'show badges' + end + + context 'only has project badges' do + before do + create(:project_badge, project: project) + end + + it_behaves_like 'show badges' + end + + context 'has both group and project badges' do + before do + create(:project_badge, project: project) + create(:group_badge, group: project.group) + end + + it_behaves_like 'show badges' + end + end end -- cgit v1.2.1