From 6609589b935147886fbaba187231af7ada846d43 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 09:05:00 +0200 Subject: [PATCH 001/318] Add ci config global and before_script entries --- lib/gitlab/ci/config/entry/base_entry.rb | 15 +++++++++++++++ lib/gitlab/ci/config/entry/before_script.rb | 13 +++++++++++++ lib/gitlab/ci/config/entry/global.rb | 13 +++++++++++++ .../gitlab/ci/config/entry/before_script_spec.rb | 11 +++++++++++ spec/lib/gitlab/ci/config/entry/global_spec.rb | 5 +++++ spec/lib/gitlab/ci/config_spec.rb | 3 ++- 6 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 lib/gitlab/ci/config/entry/base_entry.rb create mode 100644 lib/gitlab/ci/config/entry/before_script.rb create mode 100644 lib/gitlab/ci/config/entry/global.rb create mode 100644 spec/lib/gitlab/ci/config/entry/before_script_spec.rb create mode 100644 spec/lib/gitlab/ci/config/entry/global_spec.rb diff --git a/lib/gitlab/ci/config/entry/base_entry.rb b/lib/gitlab/ci/config/entry/base_entry.rb new file mode 100644 index 00000000000..3a41487d897 --- /dev/null +++ b/lib/gitlab/ci/config/entry/base_entry.rb @@ -0,0 +1,15 @@ +module Gitlab + module Ci + class Config + module Entry + class BaseEntry + def initialize(hash, config, parent = nil) + @hash = hash + @config = config + @parent = parent + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/before_script.rb b/lib/gitlab/ci/config/entry/before_script.rb new file mode 100644 index 00000000000..b7f15355a51 --- /dev/null +++ b/lib/gitlab/ci/config/entry/before_script.rb @@ -0,0 +1,13 @@ +module Gitlab + module Ci + class Config + module Entry + class BeforeScript < BaseEntry + def leaf? + true + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/entry/global.rb b/lib/gitlab/ci/config/entry/global.rb new file mode 100644 index 00000000000..e333ecb9495 --- /dev/null +++ b/lib/gitlab/ci/config/entry/global.rb @@ -0,0 +1,13 @@ +module Gitlab + module Ci + class Config + module Entry + class Global < BaseEntry + def allowed_keys + [] + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/before_script_spec.rb b/spec/lib/gitlab/ci/config/entry/before_script_spec.rb new file mode 100644 index 00000000000..69573af5548 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/before_script_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::BeforeScript do + let(:entry) { described_class.new(hash, config) } + + describe '#leaf?' do + it 'is a leaf entry' do + expect(entry).to be_leaf + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb new file mode 100644 index 00000000000..8be956bb0e4 --- /dev/null +++ b/spec/lib/gitlab/ci/config/entry/global_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Entry::Global do + +end diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb index 52aafbcaaa6..211226f9f7d 100644 --- a/spec/lib/gitlab/ci/config_spec.rb +++ b/spec/lib/gitlab/ci/config_spec.rb @@ -37,7 +37,8 @@ describe Gitlab::Ci::Config do describe '.new' do it 'raises error' do expect { config }.to raise_error( - Gitlab::Ci::Config::LoaderError, /Invalid configuration format/ + Gitlab::Ci::Config::LoaderError, + /Invalid configuration format/ ) end end From 7f2f683eeb2c3b443f519e2e83dbb3d789a00cf8 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 09:24:16 +0200 Subject: [PATCH 002/318] Rename ci config module that holds nodes to Node --- lib/gitlab/ci/config/entry/global.rb | 13 ------------- .../ci/config/{entry => node}/before_script.rb | 4 ++-- .../config/{entry/base_entry.rb => node/entry.rb} | 8 ++++++-- lib/gitlab/ci/config/node/global.rb | 10 ++++++++++ spec/lib/gitlab/ci/config/entry/global_spec.rb | 5 ----- .../ci/config/{entry => node}/before_script_spec.rb | 2 +- spec/lib/gitlab/ci/config/node/global_spec.rb | 5 +++++ 7 files changed, 24 insertions(+), 23 deletions(-) delete mode 100644 lib/gitlab/ci/config/entry/global.rb rename lib/gitlab/ci/config/{entry => node}/before_script.rb (69%) rename lib/gitlab/ci/config/{entry/base_entry.rb => node/entry.rb} (71%) create mode 100644 lib/gitlab/ci/config/node/global.rb delete mode 100644 spec/lib/gitlab/ci/config/entry/global_spec.rb rename spec/lib/gitlab/ci/config/{entry => node}/before_script_spec.rb (77%) create mode 100644 spec/lib/gitlab/ci/config/node/global_spec.rb diff --git a/lib/gitlab/ci/config/entry/global.rb b/lib/gitlab/ci/config/entry/global.rb deleted file mode 100644 index e333ecb9495..00000000000 --- a/lib/gitlab/ci/config/entry/global.rb +++ /dev/null @@ -1,13 +0,0 @@ -module Gitlab - module Ci - class Config - module Entry - class Global < BaseEntry - def allowed_keys - [] - end - end - end - end - end -end diff --git a/lib/gitlab/ci/config/entry/before_script.rb b/lib/gitlab/ci/config/node/before_script.rb similarity index 69% rename from lib/gitlab/ci/config/entry/before_script.rb rename to lib/gitlab/ci/config/node/before_script.rb index b7f15355a51..bf73c01efbc 100644 --- a/lib/gitlab/ci/config/entry/before_script.rb +++ b/lib/gitlab/ci/config/node/before_script.rb @@ -1,8 +1,8 @@ module Gitlab module Ci class Config - module Entry - class BeforeScript < BaseEntry + module Node + class BeforeScript < Entry def leaf? true end diff --git a/lib/gitlab/ci/config/entry/base_entry.rb b/lib/gitlab/ci/config/node/entry.rb similarity index 71% rename from lib/gitlab/ci/config/entry/base_entry.rb rename to lib/gitlab/ci/config/node/entry.rb index 3a41487d897..eb1b52a3e5d 100644 --- a/lib/gitlab/ci/config/entry/base_entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -1,13 +1,17 @@ module Gitlab module Ci class Config - module Entry - class BaseEntry + module Node + class Entry def initialize(hash, config, parent = nil) @hash = hash @config = config @parent = parent end + + def allowed_keys + [] + end end end end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb new file mode 100644 index 00000000000..b3dd6df0a44 --- /dev/null +++ b/lib/gitlab/ci/config/node/global.rb @@ -0,0 +1,10 @@ +module Gitlab + module Ci + class Config + module Node + class Global < Entry + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/entry/global_spec.rb b/spec/lib/gitlab/ci/config/entry/global_spec.rb deleted file mode 100644 index 8be956bb0e4..00000000000 --- a/spec/lib/gitlab/ci/config/entry/global_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Ci::Config::Entry::Global do - -end diff --git a/spec/lib/gitlab/ci/config/entry/before_script_spec.rb b/spec/lib/gitlab/ci/config/node/before_script_spec.rb similarity index 77% rename from spec/lib/gitlab/ci/config/entry/before_script_spec.rb rename to spec/lib/gitlab/ci/config/node/before_script_spec.rb index 69573af5548..eb86931c586 100644 --- a/spec/lib/gitlab/ci/config/entry/before_script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/before_script_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Entry::BeforeScript do +describe Gitlab::Ci::Config::Node::BeforeScript do let(:entry) { described_class.new(hash, config) } describe '#leaf?' do diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb new file mode 100644 index 00000000000..89594fa20ce --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Global do + +end From 8048dcc8e693d713a94a7b9361672692f4e5932f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 10:43:11 +0200 Subject: [PATCH 003/318] Implement CI configuration nodes tree processing --- lib/gitlab/ci/config/node/before_script.rb | 7 +++-- lib/gitlab/ci/config/node/entry.rb | 22 ++++++++++++-- lib/gitlab/ci/config/node/global.rb | 3 ++ .../ci/config/node/before_script_spec.rb | 6 ---- spec/lib/gitlab/ci/config/node/global_spec.rb | 30 +++++++++++++++++++ 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/lib/gitlab/ci/config/node/before_script.rb b/lib/gitlab/ci/config/node/before_script.rb index bf73c01efbc..88ebd6bb304 100644 --- a/lib/gitlab/ci/config/node/before_script.rb +++ b/lib/gitlab/ci/config/node/before_script.rb @@ -3,8 +3,11 @@ module Gitlab class Config module Node class BeforeScript < Entry - def leaf? - true + def keys + {} + end + + def validate! end end end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index eb1b52a3e5d..6336535bc03 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -3,14 +3,32 @@ module Gitlab class Config module Node class Entry + attr_reader :hash, :config, :parent, :nodes, :errors + def initialize(hash, config, parent = nil) @hash = hash @config = config @parent = parent + @nodes = {} + @errors = [] end - def allowed_keys - [] + def process! + keys.each_pair do |key, entry| + next unless hash.include?(key) + @nodes[key] = entry.new(hash[key], config, self) + end + + @nodes.values.each(&:process!) + @nodes.values.each(&:validate!) + end + + def keys + raise NotImplementedError + end + + def validate! + raise NotImplementedError end end end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index b3dd6df0a44..81a9d0667be 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -3,6 +3,9 @@ module Gitlab class Config module Node class Global < Entry + def keys + { before_script: BeforeScript } + end end end end diff --git a/spec/lib/gitlab/ci/config/node/before_script_spec.rb b/spec/lib/gitlab/ci/config/node/before_script_spec.rb index eb86931c586..d4a8eea3fff 100644 --- a/spec/lib/gitlab/ci/config/node/before_script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/before_script_spec.rb @@ -2,10 +2,4 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::BeforeScript do let(:entry) { described_class.new(hash, config) } - - describe '#leaf?' do - it 'is a leaf entry' do - expect(entry).to be_leaf - end - end end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 89594fa20ce..e2e8fcfabd3 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -1,5 +1,35 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Global do + let(:global) { described_class.new(hash, config) } + let(:config) { double('Config') } + describe '#keys' do + it 'can contain global config keys' do + expect(global.keys).to include :before_script + end + end + + context 'when hash is valid' do + let(:hash) do + { before_script: ['ls', 'pwd'] } + end + + describe '#process!' do + before { global.process! } + + it 'creates nodes hash' do + expect(global.nodes).to be_a Hash + end + + it 'creates node object for each entry' do + expect(global.nodes.count).to eq 1 + end + + it 'creates node object using valid class' do + expect(global.nodes[:before_script]) + .to be_an_instance_of Gitlab::Ci::Config::Node::BeforeScript + end + end + end end From 251dd571dfc3e6261ed075ecf725dd98ee176b69 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 11:05:15 +0200 Subject: [PATCH 004/318] Extract CI config validation helpers to mixin --- lib/ci/gitlab_ci_yaml_processor.rb | 18 ++------------- lib/gitlab/ci/config/validation_helpers.rb | 26 ++++++++++++++++++++++ 2 files changed, 28 insertions(+), 16 deletions(-) create mode 100644 lib/gitlab/ci/config/validation_helpers.rb diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 46a923161c8..e470ec56b79 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -2,6 +2,8 @@ module Ci class GitlabCiYamlProcessor class ValidationError < StandardError; end + include Gitlab::Ci::Config::ValidationHelpers + DEFAULT_STAGES = %w(build test deploy) DEFAULT_STAGE = 'test' ALLOWED_YAML_KEYS = [:before_script, :after_script, :image, :services, :types, :stages, :variables, :cache] @@ -276,22 +278,6 @@ module Ci end end - def validate_array_of_strings(values) - values.is_a?(Array) && values.all? { |value| validate_string(value) } - end - - def validate_variables(variables) - variables.is_a?(Hash) && variables.all? { |key, value| validate_string(key) && validate_string(value) } - end - - def validate_string(value) - value.is_a?(String) || value.is_a?(Symbol) - end - - def validate_boolean(value) - value.in?([true, false]) - end - def process?(only_params, except_params, ref, tag, trigger_request) if only_params.present? return false unless matching?(only_params, ref, tag, trigger_request) diff --git a/lib/gitlab/ci/config/validation_helpers.rb b/lib/gitlab/ci/config/validation_helpers.rb new file mode 100644 index 00000000000..9e4e9a83323 --- /dev/null +++ b/lib/gitlab/ci/config/validation_helpers.rb @@ -0,0 +1,26 @@ +module Gitlab + module Ci + class Config + module ValidationHelpers + private + + def validate_array_of_strings(values) + values.is_a?(Array) && values.all? { |value| validate_string(value) } + end + + def validate_variables(variables) + variables.is_a?(Hash) && + variables.all? { |key, value| validate_string(key) && validate_string(value) } + end + + def validate_string(value) + value.is_a?(String) || value.is_a?(Symbol) + end + + def validate_boolean(value) + value.in?([true, false]) + end + end + end + end +end From 6dbd1c86a82156dd5ad39b0e2ad119a493dadeae Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 11:20:47 +0200 Subject: [PATCH 005/318] Validate new before script CI configuration entry --- lib/gitlab/ci/config/node/before_script.rb | 3 +++ lib/gitlab/ci/config/node/entry.rb | 12 ++++++---- .../ci/config/node/before_script_spec.rb | 24 ++++++++++++++++++- 3 files changed, 33 insertions(+), 6 deletions(-) diff --git a/lib/gitlab/ci/config/node/before_script.rb b/lib/gitlab/ci/config/node/before_script.rb index 88ebd6bb304..204e0970a9e 100644 --- a/lib/gitlab/ci/config/node/before_script.rb +++ b/lib/gitlab/ci/config/node/before_script.rb @@ -8,6 +8,9 @@ module Gitlab end def validate! + unless validate_array_of_strings(@value) + @errors << 'before_script should be an array of strings' + end end end end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 6336535bc03..3220b01ca1a 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -3,10 +3,12 @@ module Gitlab class Config module Node class Entry - attr_reader :hash, :config, :parent, :nodes, :errors + include Config::ValidationHelpers - def initialize(hash, config, parent = nil) - @hash = hash + attr_reader :value, :config, :parent, :nodes, :errors + + def initialize(value, config, parent = nil) + @value = value @config = config @parent = parent @nodes = {} @@ -15,8 +17,8 @@ module Gitlab def process! keys.each_pair do |key, entry| - next unless hash.include?(key) - @nodes[key] = entry.new(hash[key], config, self) + next unless @value.include?(key) + @nodes[key] = entry.new(@value[key], config, self) end @nodes.values.each(&:process!) diff --git a/spec/lib/gitlab/ci/config/node/before_script_spec.rb b/spec/lib/gitlab/ci/config/node/before_script_spec.rb index d4a8eea3fff..e6d0bfd5eaa 100644 --- a/spec/lib/gitlab/ci/config/node/before_script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/before_script_spec.rb @@ -1,5 +1,27 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::BeforeScript do - let(:entry) { described_class.new(hash, config) } + let(:entry) { described_class.new(value, config) } + let(:config) { double('config') } + + describe '#validate!' do + before { entry.validate! } + + context 'when entry value is correct' do + let(:value) { ['ls', 'pwd'] } + + it 'does not append errors' do + expect(entry.errors).to be_empty + end + end + + context 'when entry value is not correct' do + let(:value) { 'ls' } + + it 'saves errors' do + expect(entry.errors) + .to include /should be an array of strings/ + end + end + end end From a3c0745514ad98df1fbb8a6142f6cc50df76edae Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 11:54:54 +0200 Subject: [PATCH 006/318] Collect errors from all nodes in new CI config --- lib/gitlab/ci/config/node/entry.rb | 18 ++++++++++--- spec/lib/gitlab/ci/config/node/global_spec.rb | 25 +++++++++++++++++-- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 3220b01ca1a..45475316539 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -5,7 +5,7 @@ module Gitlab class Entry include Config::ValidationHelpers - attr_reader :value, :config, :parent, :nodes, :errors + attr_reader :value, :parent def initialize(value, config, parent = nil) @value = value @@ -21,8 +21,20 @@ module Gitlab @nodes[key] = entry.new(@value[key], config, self) end - @nodes.values.each(&:process!) - @nodes.values.each(&:validate!) + nodes.each(&:process!) + nodes.each(&:validate!) + end + + def errors + @errors + nodes.map(&:errors).flatten + end + + def valid? + errors.none? + end + + def nodes + @nodes.values end def keys diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index e2e8fcfabd3..4b464db35be 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -19,7 +19,7 @@ describe Gitlab::Ci::Config::Node::Global do before { global.process! } it 'creates nodes hash' do - expect(global.nodes).to be_a Hash + expect(global.nodes).to be_an Array end it 'creates node object for each entry' do @@ -27,9 +27,30 @@ describe Gitlab::Ci::Config::Node::Global do end it 'creates node object using valid class' do - expect(global.nodes[:before_script]) + expect(global.nodes.first) .to be_an_instance_of Gitlab::Ci::Config::Node::BeforeScript end end end + + context 'when hash is not valid' do + let(:hash) do + { before_script: 'ls' } + end + + before { global.process! } + + describe '#valid?' do + it 'is not valid' do + expect(global).not_to be_valid + end + end + + describe '#errors' do + it 'reports errors from child nodes' do + expect(global.errors) + .to include 'before_script should be an array of strings' + end + end + end end From 3b21174d32695d10124bd4d582db14947bf4162d Mon Sep 17 00:00:00 2001 From: Paco Guzman Date: Mon, 6 Jun 2016 15:50:58 +0200 Subject: [PATCH 007/318] Check if the Users table has exactly one user limiting the whole set --- app/controllers/sessions_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index f6eedb1773c..fd57478fc9e 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -39,7 +39,7 @@ class SessionsController < Devise::SessionsController # Handle an "initial setup" state, where there's only one user, it's an admin, # and they require a password change. def check_initial_setup - return unless User.count == 1 + return unless User.limit(2).count == 1 # Count as much 2 to know if we have exactly one user = User.admins.last From 940763e0e72a7f71c6e60f2a1a848f8fe4afaf33 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 12:23:27 +0200 Subject: [PATCH 008/318] Use CI config errors from new processor in legacy one --- lib/ci/gitlab_ci_yaml_processor.rb | 12 +++++---- lib/gitlab/ci/config.rb | 4 +++ lib/gitlab/ci/config/node/entry.rb | 2 +- spec/lib/gitlab/ci/config_spec.rb | 40 ++++++++++++++++++++++++------ 4 files changed, 45 insertions(+), 13 deletions(-) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index e470ec56b79..4bd2ac4f2db 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -14,7 +14,9 @@ module Ci attr_reader :before_script, :after_script, :image, :services, :path, :cache def initialize(config, path = nil) - @config = Gitlab::Ci::Config.new(config).to_hash + @ci_config = Gitlab::Ci::Config.new(config) + @config = @ci_config.to_hash + @path = path initial_parsing @@ -99,6 +101,10 @@ module Ci end def validate! + unless @ci_config.valid? + raise ValidationError, @ci_config.errors.first + end + validate_global! @jobs.each do |name, job| @@ -109,10 +115,6 @@ module Ci end def validate_global! - unless validate_array_of_strings(@before_script) - raise ValidationError, "before_script should be an array of strings" - end - unless @after_script.nil? || validate_array_of_strings(@after_script) raise ValidationError, "after_script should be an array of strings" end diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index 5fc4894311f..a042c49add7 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -3,6 +3,8 @@ module Gitlab class Config class LoaderError < StandardError; end + delegate :valid?, :errors, to: :@global + def initialize(config) loader = Loader.new(config) @@ -11,6 +13,8 @@ module Gitlab end @config = loader.load + @global = Node::Global.new(@config, self) + @global.process! end def to_hash diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 45475316539..e8ed5f54c5b 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -18,7 +18,7 @@ module Gitlab def process! keys.each_pair do |key, entry| next unless @value.include?(key) - @nodes[key] = entry.new(@value[key], config, self) + @nodes[key] = entry.new(@value[key], @config, self) end nodes.each(&:process!) diff --git a/spec/lib/gitlab/ci/config_spec.rb b/spec/lib/gitlab/ci/config_spec.rb index 211226f9f7d..ba8a44f4fcf 100644 --- a/spec/lib/gitlab/ci/config_spec.rb +++ b/spec/lib/gitlab/ci/config_spec.rb @@ -29,17 +29,43 @@ describe Gitlab::Ci::Config do expect(config.to_hash).to eq hash end + + describe '#valid?' do + it 'is valid' do + expect(config).to be_valid + end + + it 'has no errors' do + expect(config.errors).to be_empty + end + end end context 'when config is invalid' do - let(:yml) { '// invalid' } + context 'when yml is incorrect' do + let(:yml) { '// invalid' } - describe '.new' do - it 'raises error' do - expect { config }.to raise_error( - Gitlab::Ci::Config::LoaderError, - /Invalid configuration format/ - ) + describe '.new' do + it 'raises error' do + expect { config }.to raise_error( + Gitlab::Ci::Config::LoaderError, + /Invalid configuration format/ + ) + end + end + end + + context 'when config logic is incorrect' do + let(:yml) { 'before_script: "ls"' } + + describe '#valid?' do + it 'is not valid' do + expect(config).not_to be_valid + end + + it 'has errors' do + expect(config.errors).not_to be_empty + end end end end From 8aad78838374c761a69d7f0e9727706a611ebcaf Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:10:28 +0300 Subject: [PATCH 009/318] Added data-project attribute to body tag. --- app/views/layouts/application.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 2b86b289bbe..504abd8f3e4 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: "en"} = render "layouts/head" - %body{class: "#{user_application_theme}", 'data-page' => body_data_page} + %body{class: "#{user_application_theme}", 'data-page' => body_data_page, 'data-project' => "#{@project.path if @project}"} = Gon::Base.render_data -# Ideally this would be inside the head, but turbolinks only evaluates page-specific JS in the body. From b13f6fa99dace85844db2591c922fe7158e7baac Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:10:47 +0300 Subject: [PATCH 010/318] Added a common util to get project slug. --- app/assets/javascripts/lib/common_utils.js.coffee | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 app/assets/javascripts/lib/common_utils.js.coffee diff --git a/app/assets/javascripts/lib/common_utils.js.coffee b/app/assets/javascripts/lib/common_utils.js.coffee new file mode 100644 index 00000000000..3ec569f73ea --- /dev/null +++ b/app/assets/javascripts/lib/common_utils.js.coffee @@ -0,0 +1,13 @@ +((w) -> + + w.gl or= {} + w.gl.utils or= {} + + w.gl.utils.getProjectSlug = -> + + $body = $ 'body' + isInProjectPage = $body.data('page').split(':')[0] is 'projects' + + return if isInProjectPage then $body.data 'project' else null + +) window From 5b64e486cceda778161ee99da6f60a06c3ba4d08 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:11:23 +0300 Subject: [PATCH 011/318] Added projectOptions and dashboardOptions into gl object. --- app/views/layouts/_search.html.haml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index b49207fc315..b76e31f7dc8 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -36,6 +36,21 @@ - else = hidden_field_tag :search_code, true + :javascript + gl.projectOptions = gl.projectOptions || {}; + gl.projectOptions["#{@project.path}"] = { + issuesPath: "#{namespace_project_issues_path(@project.namespace, @project)}", + mrPath: "#{namespace_project_merge_requests_path(@project.namespace, @project)}", + projectName: "#{@project.name}" + }; + + :javascript + gl.dashboardOptions = { + issuesPath: "#{issues_dashboard_url}", + mrPath: "#{merge_requests_dashboard_url}" + }; + + - if @snippet || @snippets = hidden_field_tag :snippets, true = hidden_field_tag :repository_ref, @ref From 495d27be382266513f159a5babc334a93b540a95 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:13:02 +0300 Subject: [PATCH 012/318] Show category search content in the search dropdown. --- .../javascripts/search_autocomplete.js.coffee | 49 +++++++++++++++++-- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 5eb915a51ea..0ba2c4958a6 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -67,8 +67,14 @@ class @SearchAutocomplete getData: (term, callback) -> _this = @ - # Do not trigger request if input is empty - return if @searchInput.val() is '' + unless term + return unless @hasLocationBadge() + + if contents = @getCategoryContents() + @searchInput.data('glDropdown').filter.options.callback contents + @enableAutocomplete() + + return # Prevent multiple ajax calls return if @loadingSuggestions @@ -122,6 +128,27 @@ class @SearchAutocomplete ).always -> _this.loadingSuggestions = false + + getCategoryContents: -> + + userId = gon.current_user_id + projectSlug = gl.utils.getProjectSlug() + projectOptions = gl.projectOptions[projectSlug] + + return null if not projectSlug or not projectOptions + + { issuesPath, mrPath, projectName } = projectOptions + + return [ + { header: "Go to in #{projectName}" } + { text: 'Issues assigned to me', url: "#{issuesPath}/?assignee_id=#{userId}" } + { text: "Issues I've created", url: "#{issuesPath}/?author_id=#{userId}" } + 'separator' + { text: 'Merge requests assigned to me', url: "#{mrPath}/?assignee_id=#{userId}" } + { text: "Merge requests I've created", url: "#{mrPath}/?author_id=#{userId}" } + ] + + serializeState: -> { # Search Criteria @@ -209,6 +236,13 @@ class @SearchAutocomplete @isFocused = true @wrap.addClass('search-active') + if @hasLocationBadge() and @getValue() is '' + @getData() + + + getValue: -> return @searchInput.val() + + onClearInputClick: (e) => e.preventDefault() @searchInput.val('').focus() @@ -229,6 +263,10 @@ class @SearchAutocomplete @locationBadgeEl.text(badgeText).show() @wrap.addClass('has-location-badge') + + hasLocationBadge: -> return @wrap.is '.has-location-badge' + + restoreOriginalState: -> inputs = Object.keys @originalState @@ -257,13 +295,14 @@ class @SearchAutocomplete @getElement("##{input}").val('') + removeLocationBadge: -> + @locationBadgeEl.hide() - - # Reset state @resetSearchState() - @wrap.removeClass('has-location-badge') + @disableAutocomplete() + disableAutocomplete: -> @searchInput.addClass('disabled') From b95c60a0715b5639e70b64e04fd4923e8bdd1923 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 11:26:39 +0200 Subject: [PATCH 013/318] Do not process Ci config node when node is a leaf --- lib/gitlab/ci/config/node/entry.rb | 10 ++++++++-- spec/lib/gitlab/ci/config/node/global_spec.rb | 6 ++++++ 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index e8ed5f54c5b..007585d4019 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -16,6 +16,8 @@ module Gitlab end def process! + return if leaf? + keys.each_pair do |key, entry| next unless @value.include?(key) @nodes[key] = entry.new(@value[key], @config, self) @@ -29,12 +31,16 @@ module Gitlab @errors + nodes.map(&:errors).flatten end + def nodes + @nodes.values + end + def valid? errors.none? end - def nodes - @nodes.values + def leaf? + keys.none? end def keys diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 4b464db35be..06c88b61f0c 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -31,6 +31,12 @@ describe Gitlab::Ci::Config::Node::Global do .to be_an_instance_of Gitlab::Ci::Config::Node::BeforeScript end end + + describe '#leaf?' do + it 'is not leaf' do + expect(global).not_to be_leaf + end + end end context 'when hash is not valid' do From 69a3755c5a93395fd2fdfd5bee00e6064d1670f8 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 11:58:02 +0200 Subject: [PATCH 014/318] Add Ci config entry that implements Null Object --- lib/gitlab/ci/config/node/entry.rb | 22 +++++++++++---------- lib/gitlab/ci/config/node/null.rb | 17 ++++++++++++++++ spec/lib/gitlab/ci/config/node/null_spec.rb | 17 ++++++++++++++++ 3 files changed, 46 insertions(+), 10 deletions(-) create mode 100644 lib/gitlab/ci/config/node/null.rb create mode 100644 spec/lib/gitlab/ci/config/node/null_spec.rb diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 007585d4019..af92899af40 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -5,22 +5,28 @@ module Gitlab class Entry include Config::ValidationHelpers - attr_reader :value, :parent + attr_reader :value, :nodes, :parent def initialize(value, config, parent = nil) @value = value @config = config @parent = parent - @nodes = {} - @errors = [] + @nodes, @errors = [], [] + + keys.each_key do |key| + instance_variable_set("@#{key}", Null.new(nil, config, self)) + end end def process! return if leaf? - keys.each_pair do |key, entry| - next unless @value.include?(key) - @nodes[key] = entry.new(@value[key], @config, self) + keys.each do |key, entry_class| + next unless @value.has_key?(key) + + entry = entry_class.new(@value[key], @config, self) + instance_variable_set("@#{key}", entry) + @nodes.append(entry) end nodes.each(&:process!) @@ -31,10 +37,6 @@ module Gitlab @errors + nodes.map(&:errors).flatten end - def nodes - @nodes.values - end - def valid? errors.none? end diff --git a/lib/gitlab/ci/config/node/null.rb b/lib/gitlab/ci/config/node/null.rb new file mode 100644 index 00000000000..6147b0d882f --- /dev/null +++ b/lib/gitlab/ci/config/node/null.rb @@ -0,0 +1,17 @@ +module Gitlab + module Ci + class Config + module Node + class Null < Entry + def keys + {} + end + + def method_missing(*) + nil + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/null_spec.rb b/spec/lib/gitlab/ci/config/node/null_spec.rb new file mode 100644 index 00000000000..42a67892966 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/null_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Null do + let(:entry) { described_class.new(double, double) } + + describe '#leaf?' do + it 'is leaf node' do + expect(entry).to be_leaf + end + end + + describe '#any_method' do + it 'responds with nil' do + expect(entry.any_method).to be nil + end + end +end From e8f995ef2631983ffe960464d0fd13a4c5ed8e09 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 12:13:22 +0200 Subject: [PATCH 015/318] Pass root Ci config entry to each subsequent entry --- lib/gitlab/ci/config.rb | 2 +- lib/gitlab/ci/config/node/entry.rb | 10 +++++----- spec/lib/gitlab/ci/config/node/before_script_spec.rb | 3 +-- spec/lib/gitlab/ci/config/node/global_spec.rb | 3 +-- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index a042c49add7..62cd514a72d 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -13,7 +13,7 @@ module Gitlab end @config = loader.load - @global = Node::Global.new(@config, self) + @global = Node::Global.new(@config) @global.process! end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index af92899af40..e2afeb1b3cf 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -7,14 +7,14 @@ module Gitlab attr_reader :value, :nodes, :parent - def initialize(value, config, parent = nil) + def initialize(value, root = nil, parent = nil) @value = value - @config = config + @root = root @parent = parent @nodes, @errors = [], [] keys.each_key do |key| - instance_variable_set("@#{key}", Null.new(nil, config, self)) + instance_variable_set("@#{key}", Null.new(nil, root, self)) end end @@ -24,7 +24,7 @@ module Gitlab keys.each do |key, entry_class| next unless @value.has_key?(key) - entry = entry_class.new(@value[key], @config, self) + entry = entry_class.new(@value[key], @root, self) instance_variable_set("@#{key}", entry) @nodes.append(entry) end @@ -42,7 +42,7 @@ module Gitlab end def leaf? - keys.none? + keys.none? # TODO || !@value.is_a?(Hash) end def keys diff --git a/spec/lib/gitlab/ci/config/node/before_script_spec.rb b/spec/lib/gitlab/ci/config/node/before_script_spec.rb index e6d0bfd5eaa..80c05f3de2f 100644 --- a/spec/lib/gitlab/ci/config/node/before_script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/before_script_spec.rb @@ -1,8 +1,7 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::BeforeScript do - let(:entry) { described_class.new(value, config) } - let(:config) { double('config') } + let(:entry) { described_class.new(value, double)} describe '#validate!' do before { entry.validate! } diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 06c88b61f0c..c920dd3584c 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -1,8 +1,7 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Global do - let(:global) { described_class.new(hash, config) } - let(:config) { double('Config') } + let(:global) { described_class.new(hash) } describe '#keys' do it 'can contain global config keys' do From 6bd67f5212de739b3016b0941853ce42f523a0f1 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 12:48:26 +0200 Subject: [PATCH 016/318] Do not process new Ci config entry when invalid --- lib/gitlab/ci/config/node/entry.rb | 8 ++++++-- spec/lib/gitlab/ci/config/node/global_spec.rb | 12 ++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index e2afeb1b3cf..c07e7cf652e 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -16,10 +16,14 @@ module Gitlab keys.each_key do |key| instance_variable_set("@#{key}", Null.new(nil, root, self)) end + + unless leaf? || value.is_a?(Hash) + @errors << 'should be a configuration entry with hash value' + end end def process! - return if leaf? + return if leaf? || !valid? keys.each do |key, entry_class| next unless @value.has_key?(key) @@ -42,7 +46,7 @@ module Gitlab end def leaf? - keys.none? # TODO || !@value.is_a?(Hash) + keys.none? end def keys diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index c920dd3584c..f277c457a3b 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -58,4 +58,16 @@ describe Gitlab::Ci::Config::Node::Global do end end end + + context 'when value is not a hash' do + let(:hash) { [] } + + before { global.process! } + + describe '#valid?' do + it 'is not valid' do + expect(global).not_to be_valid + end + end + end end From df25c19699ba35682fd92da2b9c451bb4ba1c775 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 12:58:32 +0200 Subject: [PATCH 017/318] Use Ci config validation helpers only where needed --- lib/ci/gitlab_ci_yaml_processor.rb | 2 +- lib/gitlab/ci/config/node/before_script.rb | 2 ++ lib/gitlab/ci/config/node/entry.rb | 2 -- .../ci/config/node/validation_helpers.rb | 28 +++++++++++++++++++ lib/gitlab/ci/config/validation_helpers.rb | 26 ----------------- 5 files changed, 31 insertions(+), 29 deletions(-) create mode 100644 lib/gitlab/ci/config/node/validation_helpers.rb delete mode 100644 lib/gitlab/ci/config/validation_helpers.rb diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 4bd2ac4f2db..c2b941a270a 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -2,7 +2,7 @@ module Ci class GitlabCiYamlProcessor class ValidationError < StandardError; end - include Gitlab::Ci::Config::ValidationHelpers + include Gitlab::Ci::Config::Node::ValidationHelpers DEFAULT_STAGES = %w(build test deploy) DEFAULT_STAGE = 'test' diff --git a/lib/gitlab/ci/config/node/before_script.rb b/lib/gitlab/ci/config/node/before_script.rb index 204e0970a9e..586eab12a08 100644 --- a/lib/gitlab/ci/config/node/before_script.rb +++ b/lib/gitlab/ci/config/node/before_script.rb @@ -3,6 +3,8 @@ module Gitlab class Config module Node class BeforeScript < Entry + include ValidationHelpers + def keys {} end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index c07e7cf652e..e95bc7bad4b 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -3,8 +3,6 @@ module Gitlab class Config module Node class Entry - include Config::ValidationHelpers - attr_reader :value, :nodes, :parent def initialize(value, root = nil, parent = nil) diff --git a/lib/gitlab/ci/config/node/validation_helpers.rb b/lib/gitlab/ci/config/node/validation_helpers.rb new file mode 100644 index 00000000000..4ea26492b6a --- /dev/null +++ b/lib/gitlab/ci/config/node/validation_helpers.rb @@ -0,0 +1,28 @@ +module Gitlab + module Ci + class Config + module Node + module ValidationHelpers + private + + def validate_array_of_strings(values) + values.is_a?(Array) && values.all? { |value| validate_string(value) } + end + + def validate_variables(variables) + variables.is_a?(Hash) && + variables.all? { |key, value| validate_string(key) && validate_string(value) } + end + + def validate_string(value) + value.is_a?(String) || value.is_a?(Symbol) + end + + def validate_boolean(value) + value.in?([true, false]) + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/validation_helpers.rb b/lib/gitlab/ci/config/validation_helpers.rb deleted file mode 100644 index 9e4e9a83323..00000000000 --- a/lib/gitlab/ci/config/validation_helpers.rb +++ /dev/null @@ -1,26 +0,0 @@ -module Gitlab - module Ci - class Config - module ValidationHelpers - private - - def validate_array_of_strings(values) - values.is_a?(Array) && values.all? { |value| validate_string(value) } - end - - def validate_variables(variables) - variables.is_a?(Hash) && - variables.all? { |key, value| validate_string(key) && validate_string(value) } - end - - def validate_string(value) - value.is_a?(String) || value.is_a?(Symbol) - end - - def validate_boolean(value) - value.in?([true, false]) - end - end - end - end -end From c2d6d61dac2bf04b649c84ab0f4fe98da906c2c4 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 13:19:22 +0200 Subject: [PATCH 018/318] Add DSL for adding nodes in Ci config interface --- lib/gitlab/ci/config/node/before_script.rb | 4 ---- lib/gitlab/ci/config/node/entry.rb | 12 +++++++++++- lib/gitlab/ci/config/node/global.rb | 4 +--- lib/gitlab/ci/config/node/null.rb | 4 ---- spec/lib/gitlab/ci/config/node/global_spec.rb | 4 ++++ 5 files changed, 16 insertions(+), 12 deletions(-) diff --git a/lib/gitlab/ci/config/node/before_script.rb b/lib/gitlab/ci/config/node/before_script.rb index 586eab12a08..a8c350f3c7d 100644 --- a/lib/gitlab/ci/config/node/before_script.rb +++ b/lib/gitlab/ci/config/node/before_script.rb @@ -5,10 +5,6 @@ module Gitlab class BeforeScript < Entry include ValidationHelpers - def keys - {} - end - def validate! unless validate_array_of_strings(@value) @errors << 'before_script should be an array of strings' diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index e95bc7bad4b..3043dc4c61f 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -48,12 +48,22 @@ module Gitlab end def keys - raise NotImplementedError + self.class.nodes || {} end def validate! raise NotImplementedError end + + class << self + attr_reader :nodes + + private + + def add_node(symbol, entry_class) + (@nodes ||= {}).merge!(symbol.to_sym => entry_class) + end + end end end end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index 81a9d0667be..cfa506c28b7 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -3,9 +3,7 @@ module Gitlab class Config module Node class Global < Entry - def keys - { before_script: BeforeScript } - end + add_node :before_script, BeforeScript end end end diff --git a/lib/gitlab/ci/config/node/null.rb b/lib/gitlab/ci/config/node/null.rb index 6147b0d882f..fc240e16f55 100644 --- a/lib/gitlab/ci/config/node/null.rb +++ b/lib/gitlab/ci/config/node/null.rb @@ -3,10 +3,6 @@ module Gitlab class Config module Node class Null < Entry - def keys - {} - end - def method_missing(*) nil end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index f277c457a3b..05e035ada39 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -7,6 +7,10 @@ describe Gitlab::Ci::Config::Node::Global do it 'can contain global config keys' do expect(global.keys).to include :before_script end + + it 'returns a hash' do + expect(global.keys).to be_a Hash + end end context 'when hash is valid' do From 36f67b305f37cdf4eb9f75f12cfde3b0dfc01183 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 14:49:25 +0300 Subject: [PATCH 019/318] Show dashboard related options in the search dropdown. --- app/assets/javascripts/search_autocomplete.js.coffee | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 0ba2c4958a6..943dba9bcba 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -132,12 +132,14 @@ class @SearchAutocomplete getCategoryContents: -> userId = gon.current_user_id + projectName = 'Dashboard' projectSlug = gl.utils.getProjectSlug() projectOptions = gl.projectOptions[projectSlug] - return null if not projectSlug or not projectOptions - - { issuesPath, mrPath, projectName } = projectOptions + if projectSlug and projectOptions + { issuesPath, mrPath, projectName } = projectOptions + else + { issuesPath, mrPath } = gl.dashboardOptions return [ { header: "Go to in #{projectName}" } From 70bda3e89bc3828fc8771496ec6d61e41ac3d3ed Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 14:23:47 +0200 Subject: [PATCH 020/318] Implement script in Ci config and use in legacy one --- lib/ci/gitlab_ci_yaml_processor.rb | 2 +- lib/gitlab/ci/config.rb | 5 ++++ lib/gitlab/ci/config/node/before_script.rb | 10 ++++++++ lib/gitlab/ci/config/node/entry.rb | 4 +++ lib/gitlab/ci/config/node/global.rb | 4 +++ .../ci/config/node/before_script_spec.rb | 25 ++++++++++++++----- spec/lib/gitlab/ci/config/node/global_spec.rb | 14 ++++++----- 7 files changed, 51 insertions(+), 13 deletions(-) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index c2b941a270a..0483e13b098 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -82,7 +82,7 @@ module Ci { stage_idx: stages.index(job[:stage]), stage: job[:stage], - commands: [job[:before_script] || @before_script, job[:script]].flatten.join("\n"), + commands: [job[:before_script] || [@ci_config.before_script], job[:script]].flatten.compact.join("\n"), tag_list: job[:tags] || [], name: name, only: job[:only], diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index 62cd514a72d..6e3fd2aa604 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -5,6 +5,11 @@ module Gitlab delegate :valid?, :errors, to: :@global + ## + # Temporary delegations that should be removed after refactoring + # + delegate :before_script, to: :@global + def initialize(config) loader = Loader.new(config) diff --git a/lib/gitlab/ci/config/node/before_script.rb b/lib/gitlab/ci/config/node/before_script.rb index a8c350f3c7d..271cb7b5da1 100644 --- a/lib/gitlab/ci/config/node/before_script.rb +++ b/lib/gitlab/ci/config/node/before_script.rb @@ -5,6 +5,16 @@ module Gitlab class BeforeScript < Entry include ValidationHelpers + def description + 'Script that is executed before the one defined in a job.' + end + + def script + raise unless valid? + + @value.join("\n") + end + def validate! unless validate_array_of_strings(@value) @errors << 'before_script should be an array of strings' diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 3043dc4c61f..f8f2d0be23a 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -55,6 +55,10 @@ module Gitlab raise NotImplementedError end + def description + raise NotImplementedError + end + class << self attr_reader :nodes diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index cfa506c28b7..5912ead21c6 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -4,6 +4,10 @@ module Gitlab module Node class Global < Entry add_node :before_script, BeforeScript + + def before_script + @before_script.script + end end end end diff --git a/spec/lib/gitlab/ci/config/node/before_script_spec.rb b/spec/lib/gitlab/ci/config/node/before_script_spec.rb index 80c05f3de2f..8ccefb9b9b9 100644 --- a/spec/lib/gitlab/ci/config/node/before_script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/before_script_spec.rb @@ -2,25 +2,38 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::BeforeScript do let(:entry) { described_class.new(value, double)} + before { entry.validate! } - describe '#validate!' do - before { entry.validate! } + context 'when entry value is correct' do + let(:value) { ['ls', 'pwd'] } - context 'when entry value is correct' do - let(:value) { ['ls', 'pwd'] } + describe '#script' do + it 'returns concatenated command' do + expect(entry.script).to eq "ls\npwd" + end + end + describe '#errors' do it 'does not append errors' do expect(entry.errors).to be_empty end end + end - context 'when entry value is not correct' do - let(:value) { 'ls' } + context 'when entry value is not correct' do + let(:value) { 'ls' } + describe '#errors' do it 'saves errors' do expect(entry.errors) .to include /should be an array of strings/ end end + + describe '#script' do + it 'raises error' do + expect { entry.script }.to raise_error + end + end end end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 05e035ada39..7f49b89f6d6 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Global do let(:global) { described_class.new(hash) } + before { global.process! } + describe '#keys' do it 'can contain global config keys' do expect(global.keys).to include :before_script @@ -19,8 +21,6 @@ describe Gitlab::Ci::Config::Node::Global do end describe '#process!' do - before { global.process! } - it 'creates nodes hash' do expect(global.nodes).to be_an Array end @@ -40,6 +40,12 @@ describe Gitlab::Ci::Config::Node::Global do expect(global).not_to be_leaf end end + + describe '#before_script' do + it 'returns correct script' do + expect(global.before_script).to eq "ls\npwd" + end + end end context 'when hash is not valid' do @@ -47,8 +53,6 @@ describe Gitlab::Ci::Config::Node::Global do { before_script: 'ls' } end - before { global.process! } - describe '#valid?' do it 'is not valid' do expect(global).not_to be_valid @@ -66,8 +70,6 @@ describe Gitlab::Ci::Config::Node::Global do context 'when value is not a hash' do let(:hash) { [] } - before { global.process! } - describe '#valid?' do it 'is not valid' do expect(global).not_to be_valid From 50b3b8ce80b3573f53c22ac5ff34391b5bc469d8 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 17:54:29 +0300 Subject: [PATCH 021/318] Added tests for categorised search autocomplete. --- .../javascripts/search_autocomplete.js.coffee | 2 +- spec/features/search_spec.rb | 79 +++++++++++ .../fixtures/search_autocomplete.html.haml | 10 ++ spec/javascripts/notes_spec.js.coffee | 2 +- spec/javascripts/project_title_spec.js.coffee | 2 +- .../search_autocomplete_spec.js.coffee | 129 ++++++++++++++++++ 6 files changed, 221 insertions(+), 3 deletions(-) create mode 100644 spec/javascripts/fixtures/search_autocomplete.html.haml create mode 100644 spec/javascripts/search_autocomplete_spec.js.coffee diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 943dba9bcba..8493d2684d9 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -134,7 +134,7 @@ class @SearchAutocomplete userId = gon.current_user_id projectName = 'Dashboard' projectSlug = gl.utils.getProjectSlug() - projectOptions = gl.projectOptions[projectSlug] + projectOptions = gl.projectOptions?[projectSlug] if projectSlug and projectOptions { issuesPath, mrPath, projectName } = projectOptions diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 029a11ea43c..4f4d4b1e3e9 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -47,4 +47,83 @@ describe "Search", feature: true do expect(page).to have_link(snippet.title) end end + + + describe 'Right header search field', feature: true do + + describe 'Search in project page' do + before do + visit namespace_project_path(project.namespace, project) + end + + it 'top right search form is present' do + expect(page).to have_selector('#search') + end + + it 'top right search form contains location badge' do + expect(page).to have_selector('.has-location-badge') + end + + context 'clicking the search field', js: true do + it 'should show category search dropdown' do + page.find('#search').click + + expect(page).to have_selector('.dropdown-header', text: /go to in #{project.name}/i) + end + end + + context 'click the links in the category search dropdown', js: true do + + before do + page.find('#search').click + end + + it 'should take user to her issues page when issues assigned is clicked' do + find('.dropdown-menu').click_link 'Issues assigned to me' + sleep 2 + + expect(page).to have_selector('.issues-holder') + expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) + end + + it 'should take user to her issues page when issues authored is clicked' do + find('.dropdown-menu').click_link "Issues I've created" + sleep 2 + + expect(page).to have_selector('.issues-holder') + expect(find('.js-author-search .dropdown-toggle-text')).to have_content(user.name) + end + + it 'should take user to her MR page when MR assigned is clicked' do + find('.dropdown-menu').click_link 'Merge requests assigned to me' + sleep 2 + + expect(page).to have_selector('.merge-requests-holder') + expect(find('.js-assignee-search .dropdown-toggle-text')).to have_content(user.name) + end + + it 'should take user to her MR page when MR authored is clicked' do + find('.dropdown-menu').click_link "Merge requests I've created" + sleep 2 + + expect(page).to have_selector('.merge-requests-holder') + expect(find('.js-author-search .dropdown-toggle-text')).to have_content(user.name) + end + end + + context 'entering text into the search field', js: true do + before do + page.within '.search-input-wrap' do + fill_in "search", with: project.name[0..3] + end + end + + it 'should not display the category search dropdown' do + expect(page).not_to have_selector('.dropdown-header', text: /go to in #{project.name}/i) + end + end + end + end + + end diff --git a/spec/javascripts/fixtures/search_autocomplete.html.haml b/spec/javascripts/fixtures/search_autocomplete.html.haml new file mode 100644 index 00000000000..7785120da5b --- /dev/null +++ b/spec/javascripts/fixtures/search_autocomplete.html.haml @@ -0,0 +1,10 @@ +.search.search-form.has-location-badge + %form.navbar-form + .search-input-container + %div.location-badge + This project + .search-input-wrap + .dropdown + %input#search.search-input.dropdown-menu-toggle + .dropdown-menu.dropdown-select + .dropdown-content diff --git a/spec/javascripts/notes_spec.js.coffee b/spec/javascripts/notes_spec.js.coffee index dd160e821b3..3a3c8d63e82 100644 --- a/spec/javascripts/notes_spec.js.coffee +++ b/spec/javascripts/notes_spec.js.coffee @@ -1,7 +1,7 @@ #= require notes #= require gl_form -window.gon = {} +window.gon or= {} window.disableButtonIfEmptyField = -> null describe 'Notes', -> diff --git a/spec/javascripts/project_title_spec.js.coffee b/spec/javascripts/project_title_spec.js.coffee index 1cf34d4d2d3..9be29097f4c 100644 --- a/spec/javascripts/project_title_spec.js.coffee +++ b/spec/javascripts/project_title_spec.js.coffee @@ -6,7 +6,7 @@ #= require project_select #= require project -window.gon = {} +window.gon or= {} window.gon.api_version = 'v3' describe 'Project Title', -> diff --git a/spec/javascripts/search_autocomplete_spec.js.coffee b/spec/javascripts/search_autocomplete_spec.js.coffee new file mode 100644 index 00000000000..5212f5d223a --- /dev/null +++ b/spec/javascripts/search_autocomplete_spec.js.coffee @@ -0,0 +1,129 @@ +#= require gl_dropdown +#= require search_autocomplete +#= require jquery +#= require lib/common_utils +#= require lib/type_utility +#= require fuzzaldrin-plus + + +widget = null +userId = 1 +window.gon or= {} +window.gon.current_user_id = userId + +dashboardIssuesPath = '/dashboard/issues' +dashboardMRsPath = '/dashboard/merge_requests' +projectIssuesPath = "/gitlab-org/gitlab-ce/issues" +projectMRsPath = "/gitlab-org/gitlab-ce/merge_requests" +projectName = 'GitLab Community Edition' + +# Add required attributes to body before starting the test. +addBodyAttributes = (page = 'groups') -> + + $('body').removeAttr 'data-page' + $('body').removeAttr 'data-project' + + $('body').data 'page', "#{page}:show" + $('body').data 'project', 'gitlab-ce' + + +# Mock `gl` object in window for dashboard specific page. App code will need it. +mockDashboardOptions = -> + + window.gl or= {} + window.gl.dashboardOptions = + issuesPath: dashboardIssuesPath + mrPath : dashboardMRsPath + + +# Mock `gl` object in window for project specific page. App code will need it. +mockProjectOptions = -> + + window.gl or= {} + window.gl.projectOptions = + 'gitlab-ce' : + issuesPath : projectIssuesPath + mrPath : projectMRsPath + projectName : projectName + + +assertLinks = (list, a1, a2, a3, a4) -> + + expect(list.find(a1).length).toBe 1 + expect(list.find(a1).text()).toBe ' Issues assigned to me ' + + expect(list.find(a2).length).toBe 1 + expect(list.find(a2).text()).toBe " Issues I've created " + + expect(list.find(a3).length).toBe 1 + expect(list.find(a3).text()).toBe ' Merge requests assigned to me ' + + expect(list.find(a4).length).toBe 1 + expect(list.find(a4).text()).toBe " Merge requests I've created " + + + +describe 'Search autocomplete dropdown', -> + + fixture.preload 'search_autocomplete.html' + + beforeEach -> + + fixture.load 'search_autocomplete.html' + widget = new SearchAutocomplete + + + it 'should show Dashboard specific dropdown menu', -> + + addBodyAttributes() + mockDashboardOptions() + + # Focus input to show dropdown list. + widget.searchInput.focus() + + w = widget.wrap.find '.dropdown-menu' + l = w.find 'ul' + + # # Expect dropdown and dropdown header + expect(w.find('.dropdown-header').text()).toBe 'Go to in Dashboard' + + # Create links then assert link urls and inner texts + issuesAssignedToMeLink = "#{dashboardIssuesPath}/?assignee_id=#{userId}" + issuesIHaveCreatedLink = "#{dashboardIssuesPath}/?author_id=#{userId}" + mrsAssignedToMeLink = "#{dashboardMRsPath}/?assignee_id=#{userId}" + mrsIHaveCreatedLink = "#{dashboardMRsPath}/?author_id=#{userId}" + + a1 = "a[href='#{issuesAssignedToMeLink}']" + a2 = "a[href='#{issuesIHaveCreatedLink}']" + a3 = "a[href='#{mrsAssignedToMeLink}']" + a4 = "a[href='#{mrsIHaveCreatedLink}']" + + assertLinks l, a1, a2, a3, a4 + + + it 'should show Project specific dropdown menu', -> + + addBodyAttributes 'projects' + mockProjectOptions() + + # Focus input to show dropdown list. + widget.searchInput.focus() + + w = widget.wrap.find '.dropdown-menu' + l = w.find 'ul' + + # Expect dropdown and dropdown header + expect(w.find('.dropdown-header').text()).toBe "Go to in #{projectName}" + + # Create links then verify link urls and inner texts + issuesAssignedToMeLink = "#{projectIssuesPath}/?assignee_id=#{userId}" + issuesIHaveCreatedLink = "#{projectIssuesPath}/?author_id=#{userId}" + mrsAssignedToMeLink = "#{projectMRsPath}/?assignee_id=#{userId}" + mrsIHaveCreatedLink = "#{projectMRsPath}/?author_id=#{userId}" + + a1 = "a[href='#{issuesAssignedToMeLink}']" + a2 = "a[href='#{issuesIHaveCreatedLink}']" + a3 = "a[href='#{mrsAssignedToMeLink}']" + a4 = "a[href='#{mrsIHaveCreatedLink}']" + + assertLinks l, a1, a2, a3, a4 From 8827eea8643bba95571edf2ea0f769b18e8369c2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 08:28:18 +0100 Subject: [PATCH 022/318] Updated commits UI Closes #14633 --- app/assets/stylesheets/pages/commits.scss | 124 +++++++++--------- app/helpers/ci_status_helper.rb | 8 +- app/helpers/commits_helper.rb | 26 +++- app/views/projects/commits/_commit.html.haml | 21 ++- app/views/projects/commits/_commits.html.haml | 17 +-- app/views/projects/commits/show.html.haml | 11 +- .../merge_requests/show/_commits.html.haml | 3 +- 7 files changed, 109 insertions(+), 101 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index c8c6bbde084..05949e2cd43 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -7,72 +7,82 @@ margin-right: 9px; } -.lists-separator { - margin: 10px 0; - border-color: #ddd; -} +.commit-header { + padding: 5px 10px; + background-color: $background-color; + border-top: 1px solid $border-color; + border-bottom: 1px solid $border-color; + font-size: 14px; -.commits-row { - ul { - margin: 0; - - li.commit { - padding: 8px 0; - } - } - - .commits-row-date { - font-size: 15px; - line-height: 20px; - margin-bottom: 5px; + &:first-child { + border-top-width: 0; } } -li.commit { - list-style: none; +.commit-row-title { + line-height: 20px; + margin-bottom: 2px; - .commit-row-title { - font-size: $list-font-size; - line-height: 20px; - margin-bottom: 2px; + .notes_count { + float: right; + margin-right: 10px; + } - .btn-clipboard { - margin-top: -1px; + .str-truncated { + max-width: 70%; + } + + .commit-row-message { + color: $gl-dark-link-color; + + &:hover { + text-decoration: underline; } + } - .notes_count { - float: right; - margin-right: 10px; + .text-expander { + background: #eee; + color: #555; + padding: 0 5px; + cursor: pointer; + margin-left: 4px; + &:hover { + background-color: #ddd; } + } +} - .commit_short_id { - min-width: 65px; - color: $gl-dark-link-color; - font-family: $monospace_font; +.commit-actions { + @media (min-width: $screen-md-min) { + float: right; + } +} + +.commit-short-id { + font-family: $monospace_font; + font-weight: 600; +} + +.commit { + padding: 10px 0 10px 55px; + + &:not(:last-child) { + border-bottom: 1px solid #eee; + } + + a { + color: $gl-dark-link-color; + } + + .commit-link { + &:hover { + color: $gl-link-color; + text-decoration: none; } + } - .str-truncated { - max-width: 70%; - } - - .commit-row-message { - color: $gl-dark-link-color; - - &:hover { - text-decoration: underline; - } - } - - .text-expander { - background: #eee; - color: #555; - padding: 0 5px; - cursor: pointer; - margin-left: 4px; - &:hover { - background-color: #ddd; - } - } + .avatar { + margin-left: -55px; } .item-title { @@ -84,7 +94,7 @@ li.commit { font-size: 14px; border-left: 1px solid #eee; padding: 10px 15px; - margin: 5px 0 10px 5px; + margin: 10px 0 10px 0; background: #f9f9f9; display: none; @@ -111,10 +121,6 @@ li.commit { .avatar { margin-right: 8px; } - - .committed_ago { - display: inline-block; - } } &.inline-commit { diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 07e5c146844..8e4ae1e6aec 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -38,10 +38,10 @@ module CiStatusHelper icon(icon_name + ' fw') end - def render_commit_status(commit, tooltip_placement: 'auto left') + def render_commit_status(commit, tooltip_placement: 'auto left', cssclass: '') project = commit.project path = builds_namespace_project_commit_path(project.namespace, project, commit) - render_status_with_link('commit', commit.status, path, tooltip_placement) + render_status_with_link('commit', commit.status, path, tooltip_placement, cssclass: cssclass) end def render_pipeline_status(pipeline, tooltip_placement: 'auto left') @@ -57,10 +57,10 @@ module CiStatusHelper private - def render_status_with_link(type, status, path, tooltip_placement) + def render_status_with_link(type, status, path, tooltip_placement, cssclass: '') link_to ci_icon_for_status(status), path, - class: "ci-status-link ci-status-icon-#{status.dasherize}", + class: "ci-status-link ci-status-icon-#{status.dasherize} #{cssclass}", title: "#{type.titleize}: #{ci_label_for_status(status)}", data: { toggle: 'tooltip', placement: tooltip_placement } end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index d328f56c80c..767b346f2ff 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -16,6 +16,19 @@ module CommitsHelper commit_person_link(commit, options.merge(source: :committer)) end + def commit_author_avatar(commit, options = {}) + options = options.merge(source: :author) + user = commit.send(options[:source]) + + source_name = clean(commit.send "#{options[:source]}_name".to_sym) + source_email = clean(commit.send "#{options[:source]}_email".to_sym) + + person_name = user.try(:name) || source_name + person_email = user.try(:email) || source_email + + image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]}", width: options[:size], alt: "") + end + def image_diff_class(diff) if diff.deleted_file "deleted" @@ -102,24 +115,24 @@ module CommitsHelper if current_controller?(:projects, :commits) if @repo.blob_at(commit.id, @path) return link_to( - "Browse File »", + "Browse File", namespace_project_blob_path(project.namespace, project, tree_join(commit.id, @path)), - class: "pull-right" + class: "btn btn-default" ) elsif @path.present? return link_to( - "Browse Directory »", + "Browse Directory", namespace_project_tree_path(project.namespace, project, tree_join(commit.id, @path)), - class: "pull-right" + class: "btn btn-default" ) end end link_to( "Browse Files", namespace_project_tree_path(project.namespace, project, commit), - class: "pull-right" + class: "btn btn-default" ) end @@ -191,8 +204,7 @@ module CommitsHelper text = if options[:avatar] - avatar = image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]}", width: options[:size], alt: "") - %Q{#{avatar} #{person_name}} + %Q{#{person_name}} else person_name end diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 367027182b6..288b95c3e6e 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -9,26 +9,25 @@ = cache(cache_key) do %li.commit.js-toggle-container{ id: "commit-#{commit.short_id}" } + = commit_author_avatar(commit) .commit-row-title %span.item-title - = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" + = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message commit-link" - if commit.description? %a.text-expander.js-toggle-button ... - .pull-right + .commit-actions - if commit.status - = render_commit_status(commit) + = render_commit_status(commit, cssclass: 'btn btn-transparent') = clipboard_button(clipboard_text: commit.id) - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent commit-link" + = link_to_browse_code(project, commit) - if commit.description? - .commit-row-description.js-toggle-content - %pre - = preserve(markdown(escape_once(commit.description), pipeline: :single_line, author: commit.author)) + %pre.commit-row-description.js-toggle-content + = preserve(markdown(escape_once(commit.description), pipeline: :single_line, author: commit.author)) .commit-row-info - by = commit_author_link(commit, avatar: true, size: 24) - .committed_ago - #{time_ago_with_tooltip(commit.committed_date)}   - = link_to_browse_code(project, commit) + authored + #{time_ago_with_tooltip(commit.committed_date)}   diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index 7283a78a64e..dd12eae8f7e 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -4,18 +4,11 @@ - commits, hidden = limited_commits(@commits) - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits| - .row.commits-row - .col-md-2.hidden-xs.hidden-sm - %h5.commits-row-date - %i.fa.fa-calendar - %span= day.strftime('%d %b, %Y') - .light - = pluralize(commits.count, 'commit') - .col-md-10.col-sm-12 - %ul.content-list - = render commits, project: project - %hr.lists-separator + %li.commit-header= "#{day.strftime('%d %b, %Y')} #{pluralize(commits.count, 'commit')}" + %li.commits-row + %ul.list-unstyled.commit-list + = render commits, project: project - if hidden > 0 - .alert.alert-warning + %li.alert.alert-warning #{number_with_delimiter(hidden)} additional commits have been omitted to prevent performance issues. diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index 76ba0bea36d..51ca4eb903e 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -23,21 +23,18 @@ Create Merge Request .control - = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'pull-left commits-search-form') do - = search_field_tag :search, params[:search], { placeholder: 'Filter by commit message', id: 'commits-search', class: 'form-control search-text-input', spellcheck: false } - + = form_tag(namespace_project_commits_path(@project.namespace, @project, @id), method: :get, class: 'commits-search-form') do + = search_field_tag :search, params[:search], { placeholder: 'Filter by commit message', id: 'commits-search', class: 'form-control search-text-input input-short', spellcheck: false } - if current_user && current_user.private_token .control = link_to namespace_project_commits_path(@project.namespace, @project, @ref, {format: :atom, private_token: current_user.private_token}), title: "Commits Feed", class: 'btn' do = icon("rss") - - %ul.breadcrumb.repo-breadcrumb = commits_breadcrumbs %div{id: dom_id(@project)} - #commits-list.content_list= render "commits", project: @project - .clear + %ol#commits-list.list-unstyled.content_list + = render "commits", project: @project = spinner :javascript diff --git a/app/views/projects/merge_requests/show/_commits.html.haml b/app/views/projects/merge_requests/show/_commits.html.haml index a8f09f855d4..0b05785430b 100644 --- a/app/views/projects/merge_requests/show/_commits.html.haml +++ b/app/views/projects/merge_requests/show/_commits.html.haml @@ -2,4 +2,5 @@ = icon("sort-amount-desc") Most recent commits displayed first -= render "projects/commits/commits", project: @merge_request.project +%ol#commits-list.list-unstyled + = render "projects/commits/commits", project: @merge_request.project From 79b375e17876105cefcbc5c451e785aceedb0002 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 14:00:49 +0100 Subject: [PATCH 023/318] Updated some commit UI colors Fixed issue with tree view styles --- app/assets/stylesheets/pages/commits.scss | 25 ++++++++++---------- app/assets/stylesheets/pages/tree.scss | 2 +- app/helpers/button_helper.rb | 4 ++-- app/views/projects/commits/_commit.html.haml | 8 +++---- 4 files changed, 19 insertions(+), 20 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 05949e2cd43..d360a224848 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -41,13 +41,17 @@ } .text-expander { - background: #eee; - color: #555; + background: $gray-light; + color: $gl-gray-dark; padding: 0 5px; cursor: pointer; - margin-left: 4px; + border: 1px solid $border-gray-dark; + border-radius: $border-radius-default; + margin-left: 5px; + &:hover { - background-color: #ddd; + background-color: darken($gray-light, 10%); + text-decoration: none; } } } @@ -55,6 +59,7 @@ .commit-actions { @media (min-width: $screen-md-min) { float: right; + margin-left: $gl-padding; } } @@ -70,17 +75,11 @@ border-bottom: 1px solid #eee; } - a { + a, + button { color: $gl-dark-link-color; } - .commit-link { - &:hover { - color: $gl-link-color; - text-decoration: none; - } - } - .avatar { margin-left: -55px; } @@ -94,7 +93,7 @@ font-size: 14px; border-left: 1px solid #eee; padding: 10px 15px; - margin: 10px 0 10px 0; + margin: 10px 0; background: #f9f9f9; display: none; diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index f16fc7f388f..cfb6e2e888e 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -101,7 +101,7 @@ margin: 0; .commit { - padding: 0; + padding: 0 0 0 55px; .commit-row-title { .commit-row-message { diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index f742922d926..bf5505125ab 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -14,10 +14,10 @@ module ButtonHelper # # => "" # # See http://clipboardjs.com/#usage - def clipboard_button(data = {}) + def clipboard_button(data = {}, css_class: 'btn-clipboard') content_tag :button, icon('clipboard'), - class: 'btn btn-clipboard', + class: "btn #{css_class}", data: data, type: :button end diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 288b95c3e6e..f79c9448f60 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -12,15 +12,15 @@ = commit_author_avatar(commit) .commit-row-title %span.item-title - = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message commit-link" + = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" - if commit.description? %a.text-expander.js-toggle-button ... .commit-actions - if commit.status = render_commit_status(commit, cssclass: 'btn btn-transparent') - = clipboard_button(clipboard_text: commit.id) - = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent commit-link" + = clipboard_button({ clipboard_text: commit.id }, css_class: 'btn-transparent') + = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" = link_to_browse_code(project, commit) - if commit.description? @@ -30,4 +30,4 @@ .commit-row-info = commit_author_link(commit, avatar: true, size: 24) authored - #{time_ago_with_tooltip(commit.committed_date)}   + #{time_ago_with_tooltip(commit.committed_date)} From 8b40a7745be84659c10db02e3bbb74126bd42414 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 17:06:49 +0100 Subject: [PATCH 024/318] Updated tests --- app/helpers/button_helper.rb | 8 ++++++++ app/helpers/commits_helper.rb | 1 - app/views/projects/commits/_commit.html.haml | 2 +- 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index bf5505125ab..fabd726aae9 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -22,6 +22,14 @@ module ButtonHelper type: :button end + def clipboard_button_with_class(data = {}, css_class: 'btn-clipboard') + content_tag :button, + icon('clipboard'), + class: "btn #{css_class}", + data: data, + type: :button + end + def http_clone_button(project) klass = 'http-selector' klass << ' has-tooltip' if current_user.try(:require_password?) diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 767b346f2ff..97d52b1fb9e 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -200,7 +200,6 @@ module CommitsHelper source_email = clean(commit.send "#{options[:source]}_email".to_sym) person_name = user.try(:name) || source_name - person_email = user.try(:email) || source_email text = if options[:avatar] diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index f79c9448f60..d6661deb5ff 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -19,7 +19,7 @@ .commit-actions - if commit.status = render_commit_status(commit, cssclass: 'btn btn-transparent') - = clipboard_button({ clipboard_text: commit.id }, css_class: 'btn-transparent') + = clipboard_button_with_class({ clipboard_text: commit.id }, css_class: 'btn-transparent') = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit-short-id btn btn-transparent" = link_to_browse_code(project, commit) From 48726e9d307536318c7d87e2ba93f93582e22bfa Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 25 May 2016 09:11:47 +0100 Subject: [PATCH 025/318] Updated failing tests --- app/assets/stylesheets/pages/commits.scss | 4 ++-- app/helpers/button_helper.rb | 4 ++-- app/helpers/commits_helper.rb | 3 --- features/steps/project/source/browse_files.rb | 6 +++--- 4 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index d360a224848..ba8d9cce49b 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -10,8 +10,8 @@ .commit-header { padding: 5px 10px; background-color: $background-color; - border-top: 1px solid $border-color; - border-bottom: 1px solid $border-color; + border-bottom: 1px solid #eee; + border-bottom: 1px solid #eee; font-size: 14px; &:first-child { diff --git a/app/helpers/button_helper.rb b/app/helpers/button_helper.rb index fabd726aae9..07a3f452460 100644 --- a/app/helpers/button_helper.rb +++ b/app/helpers/button_helper.rb @@ -14,10 +14,10 @@ module ButtonHelper # # => "" # # See http://clipboardjs.com/#usage - def clipboard_button(data = {}, css_class: 'btn-clipboard') + def clipboard_button(data = {}) content_tag :button, icon('clipboard'), - class: "btn #{css_class}", + class: "btn", data: data, type: :button end diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 97d52b1fb9e..3dbb6e4a551 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -20,10 +20,7 @@ module CommitsHelper options = options.merge(source: :author) user = commit.send(options[:source]) - source_name = clean(commit.send "#{options[:source]}_name".to_sym) source_email = clean(commit.send "#{options[:source]}_email".to_sym) - - person_name = user.try(:name) || source_name person_email = user.try(:email) || source_email image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]}", width: options[:size], alt: "") diff --git a/features/steps/project/source/browse_files.rb b/features/steps/project/source/browse_files.rb index 2c0498de3b9..79a3ed8197e 100644 --- a/features/steps/project/source/browse_files.rb +++ b/features/steps/project/source/browse_files.rb @@ -202,8 +202,8 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps end step 'I see Browse dir link' do - expect(page).to have_link 'Browse Directory »' - expect(page).not_to have_link 'Browse Code »' + expect(page).to have_link 'Browse Directory' + expect(page).not_to have_link 'Browse Code' end step 'I click on readme file' do @@ -219,7 +219,7 @@ class Spinach::Features::ProjectSourceBrowseFiles < Spinach::FeatureSteps step 'I see Browse code link' do expect(page).to have_link 'Browse Files' - expect(page).not_to have_link 'Browse Directory »' + expect(page).not_to have_link 'Browse Directory' end step 'I click on Permalink' do From 97cee7e231689a7dee2f193411f3cd7962c6ea52 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:04:18 +0100 Subject: [PATCH 026/318] Improved spacing on mobile --- app/assets/stylesheets/pages/commits.scss | 14 +++++++++++--- app/helpers/commits_helper.rb | 2 +- app/views/projects/commits/_commit.html.haml | 6 ++++-- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index ba8d9cce49b..2723fb0b6e0 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -57,7 +57,7 @@ } .commit-actions { - @media (min-width: $screen-md-min) { + @media (min-width: $screen-sm-min) { float: right; margin-left: $gl-padding; } @@ -69,7 +69,11 @@ } .commit { - padding: 10px 0 10px 55px; + padding: 10px 0 10px; + + @media (min-width: $screen-sm-min) { + padding-left: 55px; + } &:not(:last-child) { border-bottom: 1px solid #eee; @@ -78,6 +82,7 @@ a, button { color: $gl-dark-link-color; + vertical-align: baseline; } .avatar { @@ -86,7 +91,10 @@ .item-title { display: inline-block; - max-width: 70%; + + @media (min-width: $screen-sm-min) { + max-width: 70%; + } } .commit-row-description { diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 3dbb6e4a551..55d65698292 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -23,7 +23,7 @@ module CommitsHelper source_email = clean(commit.send "#{options[:source]}_email".to_sym) person_email = user.try(:email) || source_email - image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]}", width: options[:size], alt: "") + image_tag(avatar_icon(person_email, options[:size]), class: "avatar #{"s#{options[:size]}" if options[:size]} hidden-xs", width: options[:size], alt: "") end def image_diff_class(diff) diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index d6661deb5ff..58ccd31442a 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -13,10 +13,12 @@ .commit-row-title %span.item-title = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" + - if commit.status + = render_commit_status(commit, cssclass: 'visible-xs-inline') - if commit.description? - %a.text-expander.js-toggle-button ... + %a.text-expander.hidden-xs.js-toggle-button ... - .commit-actions + .commit-actions.hidden-xs - if commit.status = render_commit_status(commit, cssclass: 'btn btn-transparent') = clipboard_button_with_class({ clipboard_text: commit.id }, css_class: 'btn-transparent') From d5afb1324f2fb9b9c19df3806662e159bbe4ffb3 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:17:13 +0100 Subject: [PATCH 027/318] Sends correct parameter to commit_author_link for avatar --- app/views/projects/commits/_commit.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 58ccd31442a..66df5fe5e20 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -30,6 +30,6 @@ = preserve(markdown(escape_once(commit.description), pipeline: :single_line, author: commit.author)) .commit-row-info - = commit_author_link(commit, avatar: true, size: 24) + = commit_author_link(commit, avatar: false, size: 24) authored #{time_ago_with_tooltip(commit.committed_date)} From 6863444b7e7d71e6b50ed8ab09cda6f3e0117176 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:40:46 +0100 Subject: [PATCH 028/318] SCSS lint fix --- app/assets/stylesheets/pages/commits.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 2723fb0b6e0..93566be88d1 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -69,7 +69,7 @@ } .commit { - padding: 10px 0 10px; + padding: 10px 0; @media (min-width: $screen-sm-min) { padding-left: 55px; From e5a83a9a94f7fbedb2fcce645248e1198dcf474f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Jun 2016 10:17:32 +0100 Subject: [PATCH 029/318] Added short commit ID to mobile --- app/assets/stylesheets/pages/commits.scss | 4 ---- app/views/projects/commits/_commit.html.haml | 3 +++ 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 93566be88d1..a392993b38d 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -34,10 +34,6 @@ .commit-row-message { color: $gl-dark-link-color; - - &:hover { - text-decoration: underline; - } } .text-expander { diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 66df5fe5e20..757f4e7e8e0 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -13,6 +13,9 @@ .commit-row-title %span.item-title = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" + %span.commit-row-message.visible-xs-inline + · + = commit.short_id - if commit.status = render_commit_status(commit, cssclass: 'visible-xs-inline') - if commit.description? From 41c2ea9b7a036da7064b433de43c19e578cc7531 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 16:09:46 +0100 Subject: [PATCH 030/318] Vertical alignment of buttons in commit row --- app/assets/stylesheets/pages/commits.scss | 6 +++--- app/views/projects/commits/_commit.html.haml | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index a392993b38d..335d9e5efd7 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -20,8 +20,8 @@ } .commit-row-title { - line-height: 20px; - margin-bottom: 2px; + line-height: 1; + margin-bottom: 6px; .notes_count { float: right; @@ -115,7 +115,7 @@ .commit-row-info { color: $gl-gray; - line-height: 24px; + line-height: 1; a { color: $gl-gray; diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 757f4e7e8e0..a959b34a539 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -9,7 +9,7 @@ = cache(cache_key) do %li.commit.js-toggle-container{ id: "commit-#{commit.short_id}" } - = commit_author_avatar(commit) + = commit_author_avatar(commit, size: 36) .commit-row-title %span.item-title = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit.id), class: "commit-row-message" From e7ee3f9f4d34f74bb40c0e439a2f7920e55ba3ba Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 16:16:18 +0100 Subject: [PATCH 031/318] Changed margin to better align vertically --- app/assets/stylesheets/pages/commits.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 335d9e5efd7..b954ed50945 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -21,7 +21,7 @@ .commit-row-title { line-height: 1; - margin-bottom: 6px; + margin-bottom: 5px; .notes_count { float: right; From b76ab726b2542d77d59b12457b62016d9205a5b2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Jun 2016 10:32:54 +0100 Subject: [PATCH 032/318] Fixed horizontal and veritcal alignment of commit action buttons --- app/assets/stylesheets/pages/commits.scss | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index b954ed50945..811f0765a27 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -10,7 +10,7 @@ .commit-header { padding: 5px 10px; background-color: $background-color; - border-bottom: 1px solid #eee; + border-top: 1px solid #eee; border-bottom: 1px solid #eee; font-size: 14px; @@ -21,7 +21,7 @@ .commit-row-title { line-height: 1; - margin-bottom: 5px; + margin-bottom: 7px; .notes_count { float: right; @@ -37,6 +37,7 @@ } .text-expander { + display: inline-block; background: $gray-light; color: $gl-gray-dark; padding: 0 5px; @@ -56,6 +57,19 @@ @media (min-width: $screen-sm-min) { float: right; margin-left: $gl-padding; + margin-top: 2px; + font-size: 0; + } + + .btn-transparent { + padding-left: 0; + padding-right: 0; + } + + .btn { + &:not(:first-child) { + margin-left: $gl-padding; + } } } @@ -68,7 +82,7 @@ padding: 10px 0; @media (min-width: $screen-sm-min) { - padding-left: 55px; + padding-left: 46px; } &:not(:last-child) { @@ -82,7 +96,7 @@ } .avatar { - margin-left: -55px; + margin-left: -46px; } .item-title { From cba266aabc60aeee64ac2eb7e76b3e9e7012bad4 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 8 Jun 2016 11:44:07 +0200 Subject: [PATCH 033/318] Remove old before_script from legacy Ci config --- lib/ci/gitlab_ci_yaml_processor.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index b37d231e893..c5a820563f0 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -11,7 +11,7 @@ module Ci :allow_failure, :type, :stage, :when, :artifacts, :cache, :dependencies, :before_script, :after_script, :variables] - attr_reader :before_script, :after_script, :image, :services, :path, :cache + attr_reader :after_script, :image, :services, :path, :cache def initialize(config, path = nil) @ci_config = Gitlab::Ci::Config.new(config) @@ -54,7 +54,6 @@ module Ci private def initial_parsing - @before_script = @config[:before_script] || [] @after_script = @config[:after_script] @image = @config[:image] @services = @config[:services] From 87fe50f2a0facd5bfdf287195a21932ff2340e1b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 8 Jun 2016 12:32:56 +0200 Subject: [PATCH 034/318] Delegate Ci config entry value to single method --- lib/gitlab/ci/config/node/before_script.rb | 4 +-- lib/gitlab/ci/config/node/entry.rb | 34 +++++++++++++------ lib/gitlab/ci/config/node/global.rb | 4 --- lib/gitlab/ci/config/node/null.rb | 7 ++++ .../ci/config/node/before_script_spec.rb | 10 ++---- spec/lib/gitlab/ci/config/node/global_spec.rb | 8 +++++ spec/lib/gitlab/ci/config/node/null_spec.rb | 6 ++++ 7 files changed, 48 insertions(+), 25 deletions(-) diff --git a/lib/gitlab/ci/config/node/before_script.rb b/lib/gitlab/ci/config/node/before_script.rb index 271cb7b5da1..be2ceebf3f9 100644 --- a/lib/gitlab/ci/config/node/before_script.rb +++ b/lib/gitlab/ci/config/node/before_script.rb @@ -9,9 +9,7 @@ module Gitlab 'Script that is executed before the one defined in a job.' end - def script - raise unless valid? - + def value @value.join("\n") end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index f8f2d0be23a..0767fadcb9a 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -3,17 +3,14 @@ module Gitlab class Config module Node class Entry - attr_reader :value, :nodes, :parent + class InvalidError < StandardError; end def initialize(value, root = nil, parent = nil) @value = value @root = root @parent = parent - @nodes, @errors = [], [] - - keys.each_key do |key| - instance_variable_set("@#{key}", Null.new(nil, root, self)) - end + @nodes = {} + @errors = [] unless leaf? || value.is_a?(Hash) @errors << 'should be a configuration entry with hash value' @@ -24,17 +21,23 @@ module Gitlab return if leaf? || !valid? keys.each do |key, entry_class| - next unless @value.has_key?(key) + if @value.has_key?(key) + entry = entry_class.new(@value[key], @root, self) + else + entry = Node::Null.new(nil, @root, self) + end - entry = entry_class.new(@value[key], @root, self) - instance_variable_set("@#{key}", entry) - @nodes.append(entry) + @nodes[key] = entry end nodes.each(&:process!) nodes.each(&:validate!) end + def nodes + @nodes.values + end + def errors @errors + nodes.map(&:errors).flatten end @@ -51,6 +54,17 @@ module Gitlab self.class.nodes || {} end + def method_missing(name, *args) + super unless keys.has_key?(name) + raise InvalidError unless valid? + + @nodes[name].value + end + + def value + raise NotImplementedError + end + def validate! raise NotImplementedError end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index 5912ead21c6..cfa506c28b7 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -4,10 +4,6 @@ module Gitlab module Node class Global < Entry add_node :before_script, BeforeScript - - def before_script - @before_script.script - end end end end diff --git a/lib/gitlab/ci/config/node/null.rb b/lib/gitlab/ci/config/node/null.rb index fc240e16f55..db3fa05c328 100644 --- a/lib/gitlab/ci/config/node/null.rb +++ b/lib/gitlab/ci/config/node/null.rb @@ -3,6 +3,13 @@ module Gitlab class Config module Node class Null < Entry + def value + nil + end + + def validate! + end + def method_missing(*) nil end diff --git a/spec/lib/gitlab/ci/config/node/before_script_spec.rb b/spec/lib/gitlab/ci/config/node/before_script_spec.rb index 8ccefb9b9b9..bc34b9c9b56 100644 --- a/spec/lib/gitlab/ci/config/node/before_script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/before_script_spec.rb @@ -7,9 +7,9 @@ describe Gitlab::Ci::Config::Node::BeforeScript do context 'when entry value is correct' do let(:value) { ['ls', 'pwd'] } - describe '#script' do + describe '#value' do it 'returns concatenated command' do - expect(entry.script).to eq "ls\npwd" + expect(entry.value).to eq "ls\npwd" end end @@ -29,11 +29,5 @@ describe Gitlab::Ci::Config::Node::BeforeScript do .to include /should be an array of strings/ end end - - describe '#script' do - it 'raises error' do - expect { entry.script }.to raise_error - end - end end end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 7f49b89f6d6..66d40be6e6e 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -65,6 +65,14 @@ describe Gitlab::Ci::Config::Node::Global do .to include 'before_script should be an array of strings' end end + + describe '#before_script' do + it 'raises error' do + expect { global.before_script }.to raise_error( + Gitlab::Ci::Config::Node::Entry::InvalidError + ) + end + end end context 'when value is not a hash' do diff --git a/spec/lib/gitlab/ci/config/node/null_spec.rb b/spec/lib/gitlab/ci/config/node/null_spec.rb index 42a67892966..fa75bdcaa6f 100644 --- a/spec/lib/gitlab/ci/config/node/null_spec.rb +++ b/spec/lib/gitlab/ci/config/node/null_spec.rb @@ -14,4 +14,10 @@ describe Gitlab::Ci::Config::Node::Null do expect(entry.any_method).to be nil end end + + describe '#value' do + it 'returns nill' do + expect(entry.value).to be nil + end + end end From 5065612a0a1a5dd68c075e54f5f5f89c5c025a6b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 8 Jun 2016 13:01:44 +0200 Subject: [PATCH 035/318] Add minor improvements in new Ci config design --- lib/gitlab/ci/config/node/entry.rb | 40 +++++++++++++------ lib/gitlab/ci/config/node/null.rb | 1 + .../ci/config/node/before_script_spec.rb | 12 ++++++ spec/lib/gitlab/ci/config/node/global_spec.rb | 12 ++++++ 4 files changed, 52 insertions(+), 13 deletions(-) diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 0767fadcb9a..302cded664f 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -12,22 +12,16 @@ module Gitlab @nodes = {} @errors = [] - unless leaf? || value.is_a?(Hash) + unless leaf? || has_config? @errors << 'should be a configuration entry with hash value' end end def process! - return if leaf? || !valid? + return if leaf? || invalid? keys.each do |key, entry_class| - if @value.has_key?(key) - entry = entry_class.new(@value[key], @root, self) - else - entry = Node::Null.new(nil, @root, self) - end - - @nodes[key] = entry + add_node(key, entry_class) end nodes.each(&:process!) @@ -38,22 +32,30 @@ module Gitlab @nodes.values end - def errors - @errors + nodes.map(&:errors).flatten - end - def valid? errors.none? end + def invalid? + !valid? + end + def leaf? keys.none? end + def has_config? + @value.is_a?(Hash) + end + def keys self.class.nodes || {} end + def errors + @errors + nodes.map(&:errors).flatten + end + def method_missing(name, *args) super unless keys.has_key?(name) raise InvalidError unless valid? @@ -73,6 +75,18 @@ module Gitlab raise NotImplementedError end + private + + def add_node(key, entry_class) + if @value.has_key?(key) + entry = entry_class.new(@value[key], @root, self) + else + entry = Node::Null.new(nil, @root, self) + end + + @nodes[key] = entry + end + class << self attr_reader :nodes diff --git a/lib/gitlab/ci/config/node/null.rb b/lib/gitlab/ci/config/node/null.rb index db3fa05c328..bf8bc62dc91 100644 --- a/lib/gitlab/ci/config/node/null.rb +++ b/lib/gitlab/ci/config/node/null.rb @@ -8,6 +8,7 @@ module Gitlab end def validate! + nil end def method_missing(*) diff --git a/spec/lib/gitlab/ci/config/node/before_script_spec.rb b/spec/lib/gitlab/ci/config/node/before_script_spec.rb index bc34b9c9b56..b506b9743c6 100644 --- a/spec/lib/gitlab/ci/config/node/before_script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/before_script_spec.rb @@ -18,6 +18,12 @@ describe Gitlab::Ci::Config::Node::BeforeScript do expect(entry.errors).to be_empty end end + + describe '#has_config?' do + it 'does not have config' do + expect(entry).not_to have_config + end + end end context 'when entry value is not correct' do @@ -29,5 +35,11 @@ describe Gitlab::Ci::Config::Node::BeforeScript do .to include /should be an array of strings/ end end + + describe '#invalid?' do + it 'is not valid' do + expect(entry).to be_invalid + end + end end end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 66d40be6e6e..74a64c6df98 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -35,6 +35,12 @@ describe Gitlab::Ci::Config::Node::Global do end end + describe '#has_config?' do + it 'has config' do + expect(global).to have_config + end + end + describe '#leaf?' do it 'is not leaf' do expect(global).not_to be_leaf @@ -59,6 +65,12 @@ describe Gitlab::Ci::Config::Node::Global do end end + describe '#invalid?' do + it 'is not valid' do + expect(global).to be_invalid + end + end + describe '#errors' do it 'reports errors from child nodes' do expect(global.errors) From 48a59c1a8baf3921f26c8503a9fdd63bf7398f0f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 8 Jun 2016 13:22:39 +0200 Subject: [PATCH 036/318] Rename BeforeScript to Script in new Ci config --- lib/gitlab/ci/config/node/global.rb | 2 +- lib/gitlab/ci/config/node/{before_script.rb => script.rb} | 2 +- spec/lib/gitlab/ci/config/node/global_spec.rb | 2 +- .../ci/config/node/{before_script_spec.rb => script_spec.rb} | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) rename lib/gitlab/ci/config/node/{before_script.rb => script.rb} (93%) rename spec/lib/gitlab/ci/config/node/{before_script_spec.rb => script_spec.rb} (94%) diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index cfa506c28b7..2e899b0b2a3 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -3,7 +3,7 @@ module Gitlab class Config module Node class Global < Entry - add_node :before_script, BeforeScript + add_node :before_script, Script end end end diff --git a/lib/gitlab/ci/config/node/before_script.rb b/lib/gitlab/ci/config/node/script.rb similarity index 93% rename from lib/gitlab/ci/config/node/before_script.rb rename to lib/gitlab/ci/config/node/script.rb index be2ceebf3f9..db635f6541f 100644 --- a/lib/gitlab/ci/config/node/before_script.rb +++ b/lib/gitlab/ci/config/node/script.rb @@ -2,7 +2,7 @@ module Gitlab module Ci class Config module Node - class BeforeScript < Entry + class Script < Entry include ValidationHelpers def description diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 74a64c6df98..ecfd60b2736 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -31,7 +31,7 @@ describe Gitlab::Ci::Config::Node::Global do it 'creates node object using valid class' do expect(global.nodes.first) - .to be_an_instance_of Gitlab::Ci::Config::Node::BeforeScript + .to be_an_instance_of Gitlab::Ci::Config::Node::Script end end diff --git a/spec/lib/gitlab/ci/config/node/before_script_spec.rb b/spec/lib/gitlab/ci/config/node/script_spec.rb similarity index 94% rename from spec/lib/gitlab/ci/config/node/before_script_spec.rb rename to spec/lib/gitlab/ci/config/node/script_spec.rb index b506b9743c6..0af97bab164 100644 --- a/spec/lib/gitlab/ci/config/node/before_script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/script_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Ci::Config::Node::BeforeScript do +describe Gitlab::Ci::Config::Node::Script do let(:entry) { described_class.new(value, double)} before { entry.validate! } From 57ec290f0c47d04745e49598c490735b3e650edb Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Wed, 8 Jun 2016 16:37:13 +0300 Subject: [PATCH 037/318] Updated CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index bee1a824974..b37c23de40b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -40,6 +40,7 @@ v 8.9.0 (unreleased) - Put project Files and Commits tabs under Code tab - Replace Colorize with Rainbow for coloring console output in Rake tasks. - An indicator is now displayed at the top of the comment field for confidential issues. + - Show categorised search queries in the search autocomplete v 8.8.4 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From 2fd009d23810939a77ddc1716ab4250ee18826fa Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Wed, 8 Jun 2016 16:56:44 +0200 Subject: [PATCH 038/318] Retry spinach tests in case of failure using rerun reporter --- .gitlab-ci.yml | 4 +--- CHANGELOG | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5ef3081395a..965de5e50e9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -89,9 +89,7 @@ update-knapsack: - export KNAPSACK_REPORT_PATH=knapsack/spinach_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json - export KNAPSACK_GENERATE_REPORT=true - cp knapsack/spinach_report.json ${KNAPSACK_REPORT_PATH} - - knapsack spinach "-r rerun" - # retry failed tests 3 times - - retry '[ ! -e tmp/spinach-rerun.txt ] || bin/spinach -r rerun $(cat tmp/spinach-rerun.txt)' + - knapsack spinach "-r rerun" || retry '[ ! -e tmp/spinach-rerun.txt ] || bin/spinach -r rerun $(cat tmp/spinach-rerun.txt)' artifacts: paths: - knapsack/ diff --git a/CHANGELOG b/CHANGELOG index 0506854599f..874b942f46c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -17,6 +17,7 @@ v 8.9.0 (unreleased) - Fix 404 page when viewing TODOs that contain milestones or labels in different projects - Redesign navigation for project pages - Fix groups API to list only user's accessible projects + - Retry spinach tests in case of failure using rerun reporter - Redesign account and email confirmation emails - Bump nokogiri to 1.6.8 - Use gitlab-shell v3.0.0 From 81dfabad393fbd22417e649a87820623d0284bbe Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Wed, 18 May 2016 15:28:46 -0500 Subject: [PATCH 039/318] Added when to artifacts --- CHANGELOG | 1 + lib/ci/gitlab_ci_yaml_processor.rb | 24 ++++++++++++++++++++ spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 23 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 0506854599f..da9f561208e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -27,6 +27,7 @@ v 8.9.0 (unreleased) - Add rake task 'gitlab:db:configure' for conditionally seeding or migrating the database - Changed the Slack build message to use the singular duration if necessary (Aran Koning) - Fix issues filter when ordering by milestone + - Added artifacts:when to .gitlab-ci.yml - this requires GitLab Runner 1.3 - Todos will display target state if issuable target is 'Closed' or 'Merged' - Fix bug when sorting issues by milestone due date and filtering by two or more labels - Add support for using Yubikeys (U2F) for two-factor authentication diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 130f5b0892e..15d57a46eb0 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -8,6 +8,8 @@ module Ci ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, :allow_failure, :type, :stage, :when, :artifacts, :cache, :dependencies, :before_script, :after_script, :variables] + ALLOWED_CACHE_KEYS = [:key, :untracked, :paths] + ALLOWED_ARTIFACTS_KEYS = [:name, :untracked, :paths, :when] attr_reader :before_script, :after_script, :image, :services, :path, :cache @@ -135,6 +137,12 @@ module Ci end def validate_global_cache! + @cache.keys.each do |key| + unless ALLOWED_CACHE_KEYS.include? key + raise ValidationError, "#{name} cache unknown parameter #{key}" + end + end + if @cache[:key] && !validate_string(@cache[:key]) raise ValidationError, "cache:key parameter should be a string" end @@ -233,6 +241,12 @@ module Ci end def validate_job_cache!(name, job) + job[:cache].keys.each do |key| + unless ALLOWED_CACHE_KEYS.include? key + raise ValidationError, "#{name} job: cache unknown parameter #{key}" + end + end + if job[:cache][:key] && !validate_string(job[:cache][:key]) raise ValidationError, "#{name} job: cache:key parameter should be a string" end @@ -247,6 +261,12 @@ module Ci end def validate_job_artifacts!(name, job) + job[:artifacts].keys.each do |key| + unless ALLOWED_ARTIFACTS_KEYS.include? key + raise ValidationError, "#{name} job: artifacts unknown parameter #{key}" + end + end + if job[:artifacts][:name] && !validate_string(job[:artifacts][:name]) raise ValidationError, "#{name} job: artifacts:name parameter should be a string" end @@ -258,6 +278,10 @@ module Ci if job[:artifacts][:paths] && !validate_array_of_strings(job[:artifacts][:paths]) raise ValidationError, "#{name} job: artifacts:paths parameter should be an array of strings" end + + if job[:artifacts][:when] && !job[:artifacts][:when].in?(%w(on_success on_failure always)) + raise ValidationError, "#{name} job: artifacts:when parameter should be on_success, on_failure or always" + end end def validate_job_dependencies!(name, job) diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 7375539cf17..3d3715f0ef0 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -601,6 +601,22 @@ module Ci allow_failure: false }) end + + %w(on_success on_failure always).each do |when_state| + it "returns artifacts for when #{when_state} defined" do + config = YAML.dump({ + rspec: { + script: "rspec", + artifacts: { paths: ["logs/", "binaries/"], when: when_state } + } + }) + + config_processor = GitlabCiYamlProcessor.new(config, path) + builds = config_processor.builds_for_stage_and_ref("test", "master") + expect(builds.size).to eq(1) + expect(builds.first[:options][:artifacts][:when]).to eq(when_state) + end + end end describe "Dependencies" do @@ -967,6 +983,13 @@ EOT end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:name parameter should be a string") end + it "returns errors if job artifacts:when is not an a predefined value" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { when: 1 } } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:when parameter should be on_success, on_failure or always") + end + it "returns errors if job artifacts:untracked is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { untracked: "string" } } }) expect do From c1818eec1d10688c05467a900f60259490b627c1 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:21:35 +0300 Subject: [PATCH 040/318] Inject group options. --- app/views/layouts/_search.html.haml | 12 +++++++++++- app/views/layouts/application.html.haml | 2 +- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index b76e31f7dc8..5c6429d07b4 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -41,9 +41,19 @@ gl.projectOptions["#{@project.path}"] = { issuesPath: "#{namespace_project_issues_path(@project.namespace, @project)}", mrPath: "#{namespace_project_merge_requests_path(@project.namespace, @project)}", - projectName: "#{@project.name}" + name: "#{@project.name}" }; + - if @group + :javascript + gl.groupOptions = gl.groupOptions || {}; + gl.groupOptions["#{@group.path}"] = { + name: "#{@group.name}", + issuesPath: "#{issues_group_path(@group.path)}", + mrPath: "#{merge_requests_group_path(@group.path)}" + }; + + :javascript gl.dashboardOptions = { issuesPath: "#{issues_dashboard_url}", diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 504abd8f3e4..33cedaaf2ee 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,7 +1,7 @@ !!! 5 %html{ lang: "en"} = render "layouts/head" - %body{class: "#{user_application_theme}", 'data-page' => body_data_page, 'data-project' => "#{@project.path if @project}"} + %body{class: "#{user_application_theme}", data: {page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}"}} = Gon::Base.render_data -# Ideally this would be inside the head, but turbolinks only evaluates page-specific JS in the body. From 7df512d5a209cc82b06020d6196a47d79b73f861 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:21:50 +0300 Subject: [PATCH 041/318] Add new utils. --- .../javascripts/lib/common_utils.js.coffee | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/lib/common_utils.js.coffee b/app/assets/javascripts/lib/common_utils.js.coffee index 3ec569f73ea..95c4dd319ab 100644 --- a/app/assets/javascripts/lib/common_utils.js.coffee +++ b/app/assets/javascripts/lib/common_utils.js.coffee @@ -3,11 +3,24 @@ w.gl or= {} w.gl.utils or= {} + w.gl.utils.isInGroupsPage = -> + + return $('body').data('page').split(':')[0] is 'groups' + + + w.gl.utils.isInProjectPage = -> + + return $('body').data('page').split(':')[0] is 'projects' + + w.gl.utils.getProjectSlug = -> - $body = $ 'body' - isInProjectPage = $body.data('page').split(':')[0] is 'projects' + return if @isInProjectPage() then $('body').data 'project' else null + + + w.gl.utils.getGroupSlug = -> + + return if @isInGroupsPage() then $('body').data 'group' else null - return if isInProjectPage then $body.data 'project' else null ) window From 522ef5754d222de1e8687c4b9bbc081478c69041 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:22:13 +0300 Subject: [PATCH 042/318] Refactor search autocomplete to support groups category contents. --- .../javascripts/search_autocomplete.js.coffee | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 8493d2684d9..421328554b8 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -68,8 +68,6 @@ class @SearchAutocomplete _this = @ unless term - return unless @hasLocationBadge() - if contents = @getCategoryContents() @searchInput.data('glDropdown').filter.options.callback contents @enableAutocomplete() @@ -131,18 +129,22 @@ class @SearchAutocomplete getCategoryContents: -> - userId = gon.current_user_id - projectName = 'Dashboard' - projectSlug = gl.utils.getProjectSlug() - projectOptions = gl.projectOptions?[projectSlug] + userId = gon.current_user_id + { utils, projectOptions, groupOptions, dashboardOptions } = gl - if projectSlug and projectOptions - { issuesPath, mrPath, projectName } = projectOptions - else - { issuesPath, mrPath } = gl.dashboardOptions + if utils.isInGroupsPage() and groupOptions + options = groupOptions[utils.getGroupSlug()] - return [ - { header: "Go to in #{projectName}" } + else if utils.isInProjectPage() and projectOptions + options = projectOptions[utils.getProjectSlug()] + + else if dashboardOptions + options = dashboardOptions + + { issuesPath, mrPath, name } = options + + items = [ + { header: "#{name}" } { text: 'Issues assigned to me', url: "#{issuesPath}/?assignee_id=#{userId}" } { text: "Issues I've created", url: "#{issuesPath}/?author_id=#{userId}" } 'separator' @@ -150,6 +152,10 @@ class @SearchAutocomplete { text: "Merge requests I've created", url: "#{mrPath}/?author_id=#{userId}" } ] + items.splice 0, 1 unless name + + return items + serializeState: -> { @@ -238,8 +244,7 @@ class @SearchAutocomplete @isFocused = true @wrap.addClass('search-active') - if @hasLocationBadge() and @getValue() is '' - @getData() + @getData() if @getValue() is '' getValue: -> return @searchInput.val() From 33cd090b93714e147e59195d24918e8b7c6d4614 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 10:08:49 +0200 Subject: [PATCH 043/318] Move new Ci config configurable DSL to concern --- lib/gitlab/ci/config/node/configurable.rb | 39 +++++++++++++++++++++++ lib/gitlab/ci/config/node/entry.rb | 34 +++++--------------- lib/gitlab/ci/config/node/global.rb | 2 ++ 3 files changed, 49 insertions(+), 26 deletions(-) create mode 100644 lib/gitlab/ci/config/node/configurable.rb diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb new file mode 100644 index 00000000000..9c04a1cdc08 --- /dev/null +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -0,0 +1,39 @@ +module Gitlab + module Ci + class Config + module Node + module Configurable + extend ActiveSupport::Concern + + def keys + self.class.nodes || {} + end + + private + + def add_node(key, entry_class) + if @value.has_key?(key) + entry = entry_class.new(@value[key], @root, self) + else + entry = Node::Null.new(nil, @root, self) + end + + @nodes[key] = entry + end + + class_methods do + attr_reader :nodes + + private + + def add_node(symbol, entry_class) + node = { symbol.to_sym => entry_class } + + (@nodes ||= {}).merge!(node) + end + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 302cded664f..c45744efdf5 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -20,8 +20,8 @@ module Gitlab def process! return if leaf? || invalid? - keys.each do |key, entry_class| - add_node(key, entry_class) + keys.each do |key, entry| + add_node(key, entry) end nodes.each(&:process!) @@ -49,7 +49,7 @@ module Gitlab end def keys - self.class.nodes || {} + {} end def errors @@ -60,7 +60,11 @@ module Gitlab super unless keys.has_key?(name) raise InvalidError unless valid? - @nodes[name].value + @nodes[name].try(:value) + end + + def add_node(key, entry) + raise NotImplementedError end def value @@ -74,28 +78,6 @@ module Gitlab def description raise NotImplementedError end - - private - - def add_node(key, entry_class) - if @value.has_key?(key) - entry = entry_class.new(@value[key], @root, self) - else - entry = Node::Null.new(nil, @root, self) - end - - @nodes[key] = entry - end - - class << self - attr_reader :nodes - - private - - def add_node(symbol, entry_class) - (@nodes ||= {}).merge!(symbol.to_sym => entry_class) - end - end end end end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index 2e899b0b2a3..5a176ab5eaf 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -3,6 +3,8 @@ module Gitlab class Config module Node class Global < Entry + include Configurable + add_node :before_script, Script end end From d9d5042fd9edf2abd662566ddc4c65b6a9bdbb08 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 10:28:44 +0200 Subject: [PATCH 044/318] Extract method that composes new Ci config entry --- lib/gitlab/ci/config/node/entry.rb | 10 +++++-- spec/lib/gitlab/ci/config/node/global_spec.rb | 29 ++++++++++++++++--- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index c45744efdf5..bdef2af9ae1 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -20,14 +20,18 @@ module Gitlab def process! return if leaf? || invalid? - keys.each do |key, entry| - add_node(key, entry) - end + compose! nodes.each(&:process!) nodes.each(&:validate!) end + def compose! + keys.each do |key, entry| + add_node(key, entry) + end + end + def nodes @nodes.values end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index ecfd60b2736..606750648d4 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -3,8 +3,6 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Global do let(:global) { described_class.new(hash) } - before { global.process! } - describe '#keys' do it 'can contain global config keys' do expect(global.keys).to include :before_script @@ -20,7 +18,18 @@ describe Gitlab::Ci::Config::Node::Global do { before_script: ['ls', 'pwd'] } end + describe '#compose!' do + before { global.compose! } + + it 'instantiates entry nodes' do + expect(global.nodes.first) + .to be_an_instance_of Gitlab::Ci::Config::Node::Script + end + end + describe '#process!' do + before { global.process! } + it 'creates nodes hash' do expect(global.nodes).to be_an Array end @@ -48,13 +57,25 @@ describe Gitlab::Ci::Config::Node::Global do end describe '#before_script' do - it 'returns correct script' do - expect(global.before_script).to eq "ls\npwd" + context 'when processed' do + before { global.process! } + + it 'returns correct script' do + expect(global.before_script).to eq "ls\npwd" + end + end + + context 'when not processed' do + it 'returns nil' do + expect(global.before_script).to be nil + end end end end context 'when hash is not valid' do + before { global.process! } + let(:hash) do { before_script: 'ls' } end From 6a319fd28790228295de19d8c786d1a807f73376 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 10:53:56 +0200 Subject: [PATCH 045/318] Make it possible configure Ci entry description --- lib/gitlab/ci/config/node/configurable.rb | 23 ++++++++++++------- lib/gitlab/ci/config/node/entry.rb | 6 ++--- lib/gitlab/ci/config/node/global.rb | 3 ++- lib/gitlab/ci/config/node/script.rb | 4 ---- spec/lib/gitlab/ci/config/node/global_spec.rb | 5 ++++ 5 files changed, 24 insertions(+), 17 deletions(-) diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index 9c04a1cdc08..4b33fe025bb 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -11,23 +11,30 @@ module Gitlab private - def add_node(key, entry_class) - if @value.has_key?(key) - entry = entry_class.new(@value[key], @root, self) - else - entry = Node::Null.new(nil, @root, self) - end + def add_node(key, metadata) + entry = create_entry(key, metadata[:class]) + entry.description = metadata[:description] @nodes[key] = entry end + def create_entry(key, entry_class) + if @value.has_key?(key) + entry_class.new(@value[key], @root, self) + else + Node::Null.new(nil, @root, self) + end + end + class_methods do attr_reader :nodes private - def add_node(symbol, entry_class) - node = { symbol.to_sym => entry_class } + def add_node(symbol, entry_class, metadata) + node = { symbol.to_sym => + { class: entry_class, + description: metadata[:description] } } (@nodes ||= {}).merge!(node) end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index bdef2af9ae1..bbe07d68b36 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -5,6 +5,8 @@ module Gitlab class Entry class InvalidError < StandardError; end + attr_accessor :description + def initialize(value, root = nil, parent = nil) @value = value @root = root @@ -78,10 +80,6 @@ module Gitlab def validate! raise NotImplementedError end - - def description - raise NotImplementedError - end end end end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index 5a176ab5eaf..7411f8c863e 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -5,7 +5,8 @@ module Gitlab class Global < Entry include Configurable - add_node :before_script, Script + add_node :before_script, Script, + description: 'Script that will be executed before each job.' end end end diff --git a/lib/gitlab/ci/config/node/script.rb b/lib/gitlab/ci/config/node/script.rb index db635f6541f..34d18ad2781 100644 --- a/lib/gitlab/ci/config/node/script.rb +++ b/lib/gitlab/ci/config/node/script.rb @@ -5,10 +5,6 @@ module Gitlab class Script < Entry include ValidationHelpers - def description - 'Script that is executed before the one defined in a job.' - end - def value @value.join("\n") end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 606750648d4..9cbd62cbf60 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -42,6 +42,11 @@ describe Gitlab::Ci::Config::Node::Global do expect(global.nodes.first) .to be_an_instance_of Gitlab::Ci::Config::Node::Script end + + it 'sets correct description for nodes' do + expect(global.nodes.first.description) + .to eq 'Script that will be executed before each job.' + end end describe '#has_config?' do From 1f192afa2abab5fcab693eaf3e0fa3c874cfb793 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 9 Jun 2016 10:50:03 +0100 Subject: [PATCH 046/318] Updated text expander text color --- app/assets/stylesheets/pages/commits.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 811f0765a27..5a6e55cf63f 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -39,7 +39,7 @@ .text-expander { display: inline-block; background: $gray-light; - color: $gl-gray-dark; + color: $gl-placeholder-color; padding: 0 5px; cursor: pointer; border: 1px solid $border-gray-dark; From 20ccd4465b0fbba45839256af93cf36c7b45d4e9 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 12:35:24 +0200 Subject: [PATCH 047/318] Do not require Ci config node to have a hash value --- lib/gitlab/ci/config/node/configurable.rb | 8 ++++++++ lib/gitlab/ci/config/node/entry.rb | 4 ---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index 4b33fe025bb..e0a0b40fc60 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -5,6 +5,14 @@ module Gitlab module Configurable extend ActiveSupport::Concern + def initialize(*) + super + + unless leaf? || has_config? + @errors << 'should be a configuration entry with hash value' + end + end + def keys self.class.nodes || {} end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index bbe07d68b36..6b59461a585 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -13,10 +13,6 @@ module Gitlab @parent = parent @nodes = {} @errors = [] - - unless leaf? || has_config? - @errors << 'should be a configuration entry with hash value' - end end def process! From 99ee39bf6c21eef8cebc431fb79286d5347d1d21 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 13:01:19 +0200 Subject: [PATCH 048/318] Add comments to new CI config classes and modules --- lib/gitlab/ci/config.rb | 5 +++-- lib/gitlab/ci/config/node/configurable.rb | 11 +++++++++++ lib/gitlab/ci/config/node/entry.rb | 3 +++ lib/gitlab/ci/config/node/global.rb | 4 ++++ lib/gitlab/ci/config/node/null.rb | 6 ++++++ lib/gitlab/ci/config/node/script.rb | 8 ++++++++ 6 files changed, 35 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/ci/config.rb b/lib/gitlab/ci/config.rb index 2d02036af11..b48d3592f16 100644 --- a/lib/gitlab/ci/config.rb +++ b/lib/gitlab/ci/config.rb @@ -1,8 +1,9 @@ module Gitlab module Ci + ## + # Base GitLab CI Configuration facade + # class Config - class LoaderError < StandardError; end - delegate :valid?, :errors, to: :@global ## diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index e0a0b40fc60..d3ed72649bc 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -2,6 +2,17 @@ module Gitlab module Ci class Config module Node + ## + # This mixin is responsible for adding DSL, which purpose is to + # simplifly process of adding child nodes. + # + # This can be used only if parent node is a configuration entry that + # holds a hash as a configuration value, for example: + # + # job: + # script: ... + # artifacts: ... + # module Configurable extend ActiveSupport::Concern diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 6b59461a585..7d7e6f26cbd 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -2,6 +2,9 @@ module Gitlab module Ci class Config module Node + ## + # Base abstract class for each configuration entry node. + # class Entry class InvalidError < StandardError; end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index 7411f8c863e..911dc51da48 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -2,6 +2,10 @@ module Gitlab module Ci class Config module Node + ## + # This class represents a global entry - root node for entire + # GitLab CI Configuration file. + # class Global < Entry include Configurable diff --git a/lib/gitlab/ci/config/node/null.rb b/lib/gitlab/ci/config/node/null.rb index bf8bc62dc91..ab7b0abaf23 100644 --- a/lib/gitlab/ci/config/node/null.rb +++ b/lib/gitlab/ci/config/node/null.rb @@ -1,6 +1,12 @@ module Gitlab module Ci class Config + ## + # This class represents a configuration entry that is not being used + # in configuration file. + # + # This implements Null Object pattern. + # module Node class Null < Entry def value diff --git a/lib/gitlab/ci/config/node/script.rb b/lib/gitlab/ci/config/node/script.rb index 34d18ad2781..84f9ec0eb04 100644 --- a/lib/gitlab/ci/config/node/script.rb +++ b/lib/gitlab/ci/config/node/script.rb @@ -2,6 +2,14 @@ module Gitlab module Ci class Config module Node + ## + # Entry that represents a script. + # + # Each element in the value array is a command that will be executed + # by GitLab Runner. Currently we concatenate this commands with + # new line character as a separator what is compatbile with + # implementation in Runner. + # class Script < Entry include ValidationHelpers From d7e125116124b9c08c27b4a02f4738619db1d2f5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 14:59:59 +0200 Subject: [PATCH 049/318] Rename method that returns allowed nodes in Ci config --- lib/gitlab/ci/config/node/configurable.rb | 2 +- lib/gitlab/ci/config/node/entry.rb | 14 +++++++------- spec/lib/gitlab/ci/config/node/global_spec.rb | 6 +++--- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index d3ed72649bc..cf065c7f6fe 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -24,7 +24,7 @@ module Gitlab end end - def keys + def allowed_nodes self.class.nodes || {} end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 7d7e6f26cbd..19fc997297a 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -28,7 +28,7 @@ module Gitlab end def compose! - keys.each do |key, entry| + allowed_nodes.each do |key, entry| add_node(key, entry) end end @@ -46,23 +46,23 @@ module Gitlab end def leaf? - keys.none? + allowed_nodes.none? end def has_config? @value.is_a?(Hash) end - def keys - {} - end - def errors @errors + nodes.map(&:errors).flatten end + def allowed_nodes + {} + end + def method_missing(name, *args) - super unless keys.has_key?(name) + super unless allowed_nodes.has_key?(name) raise InvalidError unless valid? @nodes[name].try(:value) diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 9cbd62cbf60..1a51528336b 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -3,13 +3,13 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Global do let(:global) { described_class.new(hash) } - describe '#keys' do + describe '#allowed_nodes' do it 'can contain global config keys' do - expect(global.keys).to include :before_script + expect(global.allowed_nodes).to include :before_script end it 'returns a hash' do - expect(global.keys).to be_a Hash + expect(global.allowed_nodes).to be_a Hash end end From e864bdf25b0082be8d0847fed6a2d16fe348ae59 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Fri, 10 Jun 2016 01:05:09 +0300 Subject: [PATCH 050/318] Fix specs and add new tests. --- spec/features/search_spec.rb | 4 +- .../search_autocomplete_spec.js.coffee | 104 +++++++++++------- 2 files changed, 64 insertions(+), 44 deletions(-) diff --git a/spec/features/search_spec.rb b/spec/features/search_spec.rb index 4f4d4b1e3e9..b9e63a7152c 100644 --- a/spec/features/search_spec.rb +++ b/spec/features/search_spec.rb @@ -68,7 +68,7 @@ describe "Search", feature: true do it 'should show category search dropdown' do page.find('#search').click - expect(page).to have_selector('.dropdown-header', text: /go to in #{project.name}/i) + expect(page).to have_selector('.dropdown-header', text: /#{project.name}/i) end end @@ -119,7 +119,7 @@ describe "Search", feature: true do end it 'should not display the category search dropdown' do - expect(page).not_to have_selector('.dropdown-header', text: /go to in #{project.name}/i) + expect(page).not_to have_selector('.dropdown-header', text: /#{project.name}/i) end end end diff --git a/spec/javascripts/search_autocomplete_spec.js.coffee b/spec/javascripts/search_autocomplete_spec.js.coffee index 5212f5d223a..e77177783a7 100644 --- a/spec/javascripts/search_autocomplete_spec.js.coffee +++ b/spec/javascripts/search_autocomplete_spec.js.coffee @@ -13,18 +13,33 @@ window.gon.current_user_id = userId dashboardIssuesPath = '/dashboard/issues' dashboardMRsPath = '/dashboard/merge_requests' -projectIssuesPath = "/gitlab-org/gitlab-ce/issues" -projectMRsPath = "/gitlab-org/gitlab-ce/merge_requests" +projectIssuesPath = '/gitlab-org/gitlab-ce/issues' +projectMRsPath = '/gitlab-org/gitlab-ce/merge_requests' +groupIssuesPath = '/groups/gitlab-org/issues' +groupMRsPath = '/groups/gitlab-org/merge_requests' projectName = 'GitLab Community Edition' +groupName = 'Gitlab Org' + # Add required attributes to body before starting the test. -addBodyAttributes = (page = 'groups') -> +# section would be dashboard|group|project +addBodyAttributes = (section = 'dashboard') -> - $('body').removeAttr 'data-page' - $('body').removeAttr 'data-project' + $body = $ 'body' - $('body').data 'page', "#{page}:show" - $('body').data 'project', 'gitlab-ce' + $body.removeAttr 'data-page' + $body.removeAttr 'data-project' + $body.removeAttr 'data-group' + + switch section + when 'dashboard' + $body.data 'page', 'root:index' + when 'group' + $body.data 'page', 'groups:show' + $body.data 'group', 'gitlab-org' + when 'project' + $body.data 'page', 'projects:show' + $body.data 'project', 'gitlab-ce' # Mock `gl` object in window for dashboard specific page. App code will need it. @@ -47,7 +62,27 @@ mockProjectOptions = -> projectName : projectName -assertLinks = (list, a1, a2, a3, a4) -> +mockGroupOptions = -> + + window.gl or= {} + window.gl.groupOptions = + 'gitlab-org' : + issuesPath : groupIssuesPath + mrPath : groupMRsPath + projectName : groupName + + +assertLinks = (list, issuesPath, mrsPath) -> + + issuesAssignedToMeLink = "#{issuesPath}/?assignee_id=#{userId}" + issuesIHaveCreatedLink = "#{issuesPath}/?author_id=#{userId}" + mrsAssignedToMeLink = "#{mrsPath}/?assignee_id=#{userId}" + mrsIHaveCreatedLink = "#{mrsPath}/?author_id=#{userId}" + + a1 = "a[href='#{issuesAssignedToMeLink}']" + a2 = "a[href='#{issuesIHaveCreatedLink}']" + a3 = "a[href='#{mrsAssignedToMeLink}']" + a4 = "a[href='#{mrsIHaveCreatedLink}']" expect(list.find(a1).length).toBe 1 expect(list.find(a1).text()).toBe ' Issues assigned to me ' @@ -62,7 +97,6 @@ assertLinks = (list, a1, a2, a3, a4) -> expect(list.find(a4).text()).toBe " Merge requests I've created " - describe 'Search autocomplete dropdown', -> fixture.preload 'search_autocomplete.html' @@ -77,53 +111,39 @@ describe 'Search autocomplete dropdown', -> addBodyAttributes() mockDashboardOptions() - - # Focus input to show dropdown list. widget.searchInput.focus() - w = widget.wrap.find '.dropdown-menu' - l = w.find 'ul' + list = widget.wrap.find('.dropdown-menu').find 'ul' + assertLinks list, dashboardIssuesPath, dashboardMRsPath - # # Expect dropdown and dropdown header - expect(w.find('.dropdown-header').text()).toBe 'Go to in Dashboard' - # Create links then assert link urls and inner texts - issuesAssignedToMeLink = "#{dashboardIssuesPath}/?assignee_id=#{userId}" - issuesIHaveCreatedLink = "#{dashboardIssuesPath}/?author_id=#{userId}" - mrsAssignedToMeLink = "#{dashboardMRsPath}/?assignee_id=#{userId}" - mrsIHaveCreatedLink = "#{dashboardMRsPath}/?author_id=#{userId}" + it 'should show Group specific dropdown menu', -> - a1 = "a[href='#{issuesAssignedToMeLink}']" - a2 = "a[href='#{issuesIHaveCreatedLink}']" - a3 = "a[href='#{mrsAssignedToMeLink}']" - a4 = "a[href='#{mrsIHaveCreatedLink}']" + addBodyAttributes 'group' + mockGroupOptions() + widget.searchInput.focus() - assertLinks l, a1, a2, a3, a4 + list = widget.wrap.find('.dropdown-menu').find 'ul' + assertLinks list, groupIssuesPath, groupMRsPath it 'should show Project specific dropdown menu', -> - addBodyAttributes 'projects' + addBodyAttributes 'project' mockProjectOptions() - - # Focus input to show dropdown list. widget.searchInput.focus() - w = widget.wrap.find '.dropdown-menu' - l = w.find 'ul' + list = widget.wrap.find('.dropdown-menu').find 'ul' + assertLinks list, projectIssuesPath, projectMRsPath - # Expect dropdown and dropdown header - expect(w.find('.dropdown-header').text()).toBe "Go to in #{projectName}" - # Create links then verify link urls and inner texts - issuesAssignedToMeLink = "#{projectIssuesPath}/?assignee_id=#{userId}" - issuesIHaveCreatedLink = "#{projectIssuesPath}/?author_id=#{userId}" - mrsAssignedToMeLink = "#{projectMRsPath}/?assignee_id=#{userId}" - mrsIHaveCreatedLink = "#{projectMRsPath}/?author_id=#{userId}" + it 'should not show category related menu if there is text in the input', -> - a1 = "a[href='#{issuesAssignedToMeLink}']" - a2 = "a[href='#{issuesIHaveCreatedLink}']" - a3 = "a[href='#{mrsAssignedToMeLink}']" - a4 = "a[href='#{mrsIHaveCreatedLink}']" + addBodyAttributes 'project' + mockProjectOptions() + widget.searchInput.val 'help' + widget.searchInput.focus() - assertLinks l, a1, a2, a3, a4 + list = widget.wrap.find('.dropdown-menu').find 'ul' + link = "a[href='#{projectIssuesPath}/?assignee_id=#{userId}']" + expect(list.find(link).length).toBe 0 From 828a15bccd5a6fe0471e97ebd5c0c0f6f674b9b7 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 10 Jun 2016 10:49:47 +0200 Subject: [PATCH 051/318] Rename method used to allow node in Ci config --- lib/gitlab/ci/config/node/configurable.rb | 8 ++++---- lib/gitlab/ci/config/node/global.rb | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index cf065c7f6fe..c8c917f229f 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -25,7 +25,7 @@ module Gitlab end def allowed_nodes - self.class.nodes || {} + self.class.allowed_nodes || {} end private @@ -46,16 +46,16 @@ module Gitlab end class_methods do - attr_reader :nodes + attr_reader :allowed_nodes private - def add_node(symbol, entry_class, metadata) + def allow_node(symbol, entry_class, metadata) node = { symbol.to_sym => { class: entry_class, description: metadata[:description] } } - (@nodes ||= {}).merge!(node) + (@allowed_nodes ||= {}).merge!(node) end end end diff --git a/lib/gitlab/ci/config/node/global.rb b/lib/gitlab/ci/config/node/global.rb index 911dc51da48..044603423d5 100644 --- a/lib/gitlab/ci/config/node/global.rb +++ b/lib/gitlab/ci/config/node/global.rb @@ -9,7 +9,7 @@ module Gitlab class Global < Entry include Configurable - add_node :before_script, Script, + allow_node :before_script, Script, description: 'Script that will be executed before each job.' end end From 12080ba150328963987674d282f435fc0e88b9d6 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 10 Jun 2016 11:20:46 +0200 Subject: [PATCH 052/318] Simplify new ci config entry class interface --- lib/gitlab/ci/config/node/configurable.rb | 6 +- lib/gitlab/ci/config/node/entry.rb | 15 +---- lib/gitlab/ci/config/node/null.rb | 12 ++-- lib/gitlab/ci/config/node/script.rb | 4 +- spec/lib/gitlab/ci/config/node/global_spec.rb | 12 ---- spec/lib/gitlab/ci/config/node/null_spec.rb | 2 +- spec/lib/gitlab/ci/config/node/script_spec.rb | 65 ++++++++++--------- 7 files changed, 49 insertions(+), 67 deletions(-) diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index c8c917f229f..120457690d8 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -19,7 +19,7 @@ module Gitlab def initialize(*) super - unless leaf? || has_config? + unless @value.is_a?(Hash) @errors << 'should be a configuration entry with hash value' end end @@ -39,9 +39,9 @@ module Gitlab def create_entry(key, entry_class) if @value.has_key?(key) - entry_class.new(@value[key], @root, self) + entry_class.new(@value[key]) else - Node::Null.new(nil, @root, self) + Node::Null.new(nil) end end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 19fc997297a..ed1cdd6f15d 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -10,16 +10,15 @@ module Gitlab attr_accessor :description - def initialize(value, root = nil, parent = nil) + def initialize(value) @value = value - @root = root - @parent = parent @nodes = {} @errors = [] end def process! - return if leaf? || invalid? + return if leaf? + return unless valid? compose! @@ -41,18 +40,10 @@ module Gitlab errors.none? end - def invalid? - !valid? - end - def leaf? allowed_nodes.none? end - def has_config? - @value.is_a?(Hash) - end - def errors @errors + nodes.map(&:errors).flatten end diff --git a/lib/gitlab/ci/config/node/null.rb b/lib/gitlab/ci/config/node/null.rb index ab7b0abaf23..4f590f6bec8 100644 --- a/lib/gitlab/ci/config/node/null.rb +++ b/lib/gitlab/ci/config/node/null.rb @@ -1,13 +1,13 @@ module Gitlab module Ci class Config - ## - # This class represents a configuration entry that is not being used - # in configuration file. - # - # This implements Null Object pattern. - # module Node + ## + # This class represents a configuration entry that is not being used + # in configuration file. + # + # This implements Null Object pattern. + # class Null < Entry def value nil diff --git a/lib/gitlab/ci/config/node/script.rb b/lib/gitlab/ci/config/node/script.rb index 84f9ec0eb04..5072bf0db7d 100644 --- a/lib/gitlab/ci/config/node/script.rb +++ b/lib/gitlab/ci/config/node/script.rb @@ -6,8 +6,8 @@ module Gitlab # Entry that represents a script. # # Each element in the value array is a command that will be executed - # by GitLab Runner. Currently we concatenate this commands with - # new line character as a separator what is compatbile with + # by GitLab Runner. Currently we concatenate these commands with + # new line character as a separator, what is compatible with # implementation in Runner. # class Script < Entry diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 1a51528336b..2227fcec638 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -49,12 +49,6 @@ describe Gitlab::Ci::Config::Node::Global do end end - describe '#has_config?' do - it 'has config' do - expect(global).to have_config - end - end - describe '#leaf?' do it 'is not leaf' do expect(global).not_to be_leaf @@ -91,12 +85,6 @@ describe Gitlab::Ci::Config::Node::Global do end end - describe '#invalid?' do - it 'is not valid' do - expect(global).to be_invalid - end - end - describe '#errors' do it 'reports errors from child nodes' do expect(global.errors) diff --git a/spec/lib/gitlab/ci/config/node/null_spec.rb b/spec/lib/gitlab/ci/config/node/null_spec.rb index fa75bdcaa6f..fb6c3b5cbc0 100644 --- a/spec/lib/gitlab/ci/config/node/null_spec.rb +++ b/spec/lib/gitlab/ci/config/node/null_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Null do - let(:entry) { described_class.new(double, double) } + let(:entry) { described_class.new(nil) } describe '#leaf?' do it 'is leaf node' do diff --git a/spec/lib/gitlab/ci/config/node/script_spec.rb b/spec/lib/gitlab/ci/config/node/script_spec.rb index 0af97bab164..e4d6481f8a5 100644 --- a/spec/lib/gitlab/ci/config/node/script_spec.rb +++ b/spec/lib/gitlab/ci/config/node/script_spec.rb @@ -1,44 +1,47 @@ require 'spec_helper' describe Gitlab::Ci::Config::Node::Script do - let(:entry) { described_class.new(value, double)} - before { entry.validate! } + let(:entry) { described_class.new(value) } - context 'when entry value is correct' do - let(:value) { ['ls', 'pwd'] } + describe '#validate!' do + before { entry.validate! } - describe '#value' do - it 'returns concatenated command' do - expect(entry.value).to eq "ls\npwd" + context 'when entry value is correct' do + let(:value) { ['ls', 'pwd'] } + + describe '#value' do + it 'returns concatenated command' do + expect(entry.value).to eq "ls\npwd" + end + end + + describe '#errors' do + it 'does not append errors' do + expect(entry.errors).to be_empty + end + end + + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end end end - describe '#errors' do - it 'does not append errors' do - expect(entry.errors).to be_empty + context 'when entry value is not correct' do + let(:value) { 'ls' } + + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include /should be an array of strings/ + end end - end - describe '#has_config?' do - it 'does not have config' do - expect(entry).not_to have_config - end - end - end - - context 'when entry value is not correct' do - let(:value) { 'ls' } - - describe '#errors' do - it 'saves errors' do - expect(entry.errors) - .to include /should be an array of strings/ - end - end - - describe '#invalid?' do - it 'is not valid' do - expect(entry).to be_invalid + describe '#valid?' do + it 'is not valid' do + expect(entry).not_to be_valid + end end end end From 5abfc7fa7157e876299d1675f1cc96b78a3feadc Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 10 Jun 2016 11:32:49 +0200 Subject: [PATCH 053/318] Define ci entry accessor instead of method_missing --- lib/gitlab/ci/config/node/configurable.rb | 6 ++++++ lib/gitlab/ci/config/node/entry.rb | 7 ------- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index 120457690d8..b72bc0d592a 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -55,6 +55,12 @@ module Gitlab { class: entry_class, description: metadata[:description] } } + define_method(symbol) do + raise Entry::InvalidError unless valid? + + @nodes[symbol].try(:value) + end + (@allowed_nodes ||= {}).merge!(node) end end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index ed1cdd6f15d..f7649784c28 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -52,13 +52,6 @@ module Gitlab {} end - def method_missing(name, *args) - super unless allowed_nodes.has_key?(name) - raise InvalidError unless valid? - - @nodes[name].try(:value) - end - def add_node(key, entry) raise NotImplementedError end From ffd07382b08586420628ae7ecda8a512adf091aa Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Jun 2016 11:23:57 +0100 Subject: [PATCH 054/318] Fixed issue with bold in issuable sidebar --- app/assets/javascripts/users_select.js.coffee | 2 +- app/assets/stylesheets/pages/issuable.scss | 1 - app/views/shared/issuable/_sidebar.html.haml | 10 ++++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index de0eae58bff..de38d9fb26e 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -84,7 +84,7 @@ class @UsersSelect <% } else { %> No assignee - - + assign yourself diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 787c387379e..8b6370caa7d 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -141,7 +141,6 @@ .assign-yourself { margin-top: 10px; - font-weight: normal; display: block; } } diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index fb906de829a..a1f6defafc4 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -29,8 +29,10 @@ %span.assign-yourself No assignee - if can_edit_issuable - %a.js-assign-yourself{ href: '#' } - \- assign yourself + %span.light + \- + %a.js-assign-yourself{ href: '#' } + assign yourself .selectbox.hide-collapsed = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' @@ -56,7 +58,7 @@ %span.has-tooltip{title: milestone_remaining_days(issuable.milestone), data: {container: 'body', html: 1}} = issuable.milestone.title - else - .light None + None .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil @@ -112,7 +114,7 @@ - issuable.labels_array.each do |label| = link_to_label(label, type: issuable.to_ability_name) - else - .light None + None .selectbox.hide-collapsed - issuable.labels_array.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil From 492e0062172b5a6ea1e553f97b2ac410badc496f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:03:33 +0100 Subject: [PATCH 055/318] Corrected all sidebar font weights to correctly match the design --- .../javascripts/due_date_select.js.coffee | 5 +-- .../javascripts/labels_select.js.coffee | 2 +- .../javascripts/milestone_select.js.coffee | 8 ++--- app/assets/javascripts/users_select.js.coffee | 6 ++-- app/assets/stylesheets/pages/issuable.scss | 4 +++ app/helpers/projects_helper.rb | 2 +- app/views/shared/issuable/_sidebar.html.haml | 31 +++++++++---------- 7 files changed, 28 insertions(+), 30 deletions(-) diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index 3d009a96d05..d5cb3f620b1 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -32,7 +32,7 @@ class @DueDateSelect date = new Date value.replace(new RegExp('-', 'g'), ',') mediumDate = $.datepicker.formatDate 'M d, yy', date else - mediumDate = 'None' + mediumDate = 'No due date' data = {} data[abilityName] = {} @@ -50,7 +50,8 @@ class @DueDateSelect $selectbox.hide() $value.css('display', '') - $valueContent.html(mediumDate) + cssClass = if mediumDate is "No due date" then 'no-value' else 'bold' + $valueContent.html("#{mediumDate}") $sidebarValue.html(mediumDate) if value isnt '' diff --git a/app/assets/javascripts/labels_select.js.coffee b/app/assets/javascripts/labels_select.js.coffee index ec74dfaae1a..6dff9fc4fd0 100644 --- a/app/assets/javascripts/labels_select.js.coffee +++ b/app/assets/javascripts/labels_select.js.coffee @@ -39,7 +39,7 @@ class @LabelsSelect <% }); %>' ) - labelNoneHTMLTemplate = _.template('
None
') + labelNoneHTMLTemplate = _.template('None') if newLabelField.length diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee index 648e1f3bde0..a312103d82b 100644 --- a/app/assets/javascripts/milestone_select.js.coffee +++ b/app/assets/javascripts/milestone_select.js.coffee @@ -24,14 +24,10 @@ class @MilestoneSelect if issueUpdateURL milestoneLinkTemplate = _.template( - ' - - <%= _.escape(title) %> - - ' + '<%= _.escape(title) %>' ) - milestoneLinkNoneTemplate = '
None
' + milestoneLinkNoneTemplate = 'None' collapsedSidebarLabelTemplate = _.template( ' diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index de38d9fb26e..cb7a700d028 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -72,7 +72,7 @@ class @UsersSelect assigneeTemplate = _.template( '<% if (username) { %> - + <% if( avatar ) { %> <% } %> @@ -82,9 +82,9 @@ class @UsersSelect <% } else { %> - + No assignee - - + assign yourself diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 8b6370caa7d..fb95aa22831 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -153,6 +153,10 @@ font-weight: normal; } + .no-value { + color: $gl-placeholder-color; + } + .sidebar-collapsed-icon { display: none; } diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 5e5d170a9f3..61c9a2254df 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -49,7 +49,7 @@ module ProjectsHelper author_html = author_html.html_safe if opts[:name] - link_to(author_html, user_path(author), class: "author_link #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe + link_to(author_html, user_path(author), class: "author_link #{"#{opts[:extra_class]}" if opts[:extra_class]} #{"#{opts[:mobile_classes]}" if opts[:mobile_classes]}").html_safe else title = opts[:title].sub(":name", sanitize(author.name)) link_to(author_html, user_path(author), class: "author_link has-tooltip", title: title, data: { container: 'body' } ).html_safe diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index a1f6defafc4..1daad44fa1f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -17,22 +17,21 @@ = icon('spinner spin', class: 'block-loading') - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.bold.hide-collapsed + .value.hide-collapsed - if issuable.assignee - = link_to_member(@project, issuable.assignee, size: 32) do + = link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do - if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee) %span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' } = icon('exclamation-triangle') %span.username = issuable.assignee.to_reference - else - %span.assign-yourself + %span.assign-yourself.no-value No assignee - if can_edit_issuable - %span.light - \- - %a.js-assign-yourself{ href: '#' } - assign yourself + \- + %a.js-assign-yourself{ href: '#' } + assign yourself .selectbox.hide-collapsed = f.hidden_field 'assignee_id', value: issuable.assignee_id, id: 'issue_assignee_id' @@ -52,13 +51,11 @@ = icon('spinner spin', class: 'block-loading') - if can_edit_issuable = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.bold.hide-collapsed + .value.hide-collapsed - if issuable.milestone - = link_to namespace_project_milestone_path(@project.namespace, @project, issuable.milestone) do - %span.has-tooltip{title: milestone_remaining_days(issuable.milestone), data: {container: 'body', html: 1}} - = issuable.milestone.title + = link_to issuable.milestone.title, namespace_project_milestone_path(@project.namespace, @project, issuable.milestone), class: "bold has-tooltip", title: milestone_remaining_days(issuable.milestone), data: { container: "body", html: 1 } - else - None + %span.no-value None .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil @@ -75,14 +72,14 @@ = icon('spinner spin', class: 'block-loading') - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) = link_to 'Edit', '#', class: 'edit-link pull-right' - .value.bold.hide-collapsed + .value.hide-collapsed %span.value-content - if issuable.due_date - = issuable.due_date.to_s(:medium) + %span.bold= issuable.due_date.to_s(:medium) - else - None + %span.no-value No due date - if can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - %span.light.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) } + %span.no-value.js-remove-due-date-holder{ class: ("hidden" if issuable.due_date.nil?) } \- %a.js-remove-due-date{ href: "#", role: "button" } remove due date @@ -114,7 +111,7 @@ - issuable.labels_array.each do |label| = link_to_label(label, type: issuable.to_ability_name) - else - None + %span.no-value None .selectbox.hide-collapsed - issuable.labels_array.each do |label| = hidden_field_tag "#{issuable.to_ability_name}[label_names][]", label.id, id: nil From 88f562469f20be70392cf30c34f421ea9b68bcb2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:05:32 +0100 Subject: [PATCH 056/318] Updated link color --- app/assets/stylesheets/pages/issuable.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index fb95aa22831..4145b26ed19 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -154,7 +154,7 @@ } .no-value { - color: $gl-placeholder-color; + color: #8c8c8c; } .sidebar-collapsed-icon { From e15b17f855c03cb993d446985fccf36b13d4bd25 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:06:07 +0100 Subject: [PATCH 057/318] Uses already defined color for text in sidebar --- app/assets/stylesheets/pages/issuable.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 4145b26ed19..ba658d9faca 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -154,7 +154,7 @@ } .no-value { - color: #8c8c8c; + color: $gl-placeholder-color; } .sidebar-collapsed-icon { @@ -321,7 +321,7 @@ margin-left: 5px; a { - color: #8c8c8c; + color: $gl-placeholder-color; } } From b0cf82105bbb3d61e559e8bb36abfa308cbee6ae Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 12:24:31 +0100 Subject: [PATCH 058/318] Fixed tests --- spec/features/issues_spec.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index f6fb6a72d22..32d1e631408 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -515,10 +515,10 @@ describe 'Issues', feature: true do first('.ui-state-default').click end - expect(page).to have_no_content 'None' + expect(page).to have_no_content 'No due date' click_link 'remove due date' - expect(page).to have_content 'None' + expect(page).to have_content 'No due date' end end end From a99d25b174e07bf58ae8d0c5055291065038f81a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 10 Jun 2016 08:42:03 +0100 Subject: [PATCH 059/318] Checks against date parsing instead of string Removes template for html string that isn't needed --- app/assets/javascripts/due_date_select.js.coffee | 2 +- app/assets/javascripts/labels_select.js.coffee | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index d5cb3f620b1..32c143cae16 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -50,7 +50,7 @@ class @DueDateSelect $selectbox.hide() $value.css('display', '') - cssClass = if mediumDate is "No due date" then 'no-value' else 'bold' + cssClass = if Date.parse(mediumDate) then 'bold' else 'no-value' $valueContent.html("#{mediumDate}") $sidebarValue.html(mediumDate) diff --git a/app/assets/javascripts/labels_select.js.coffee b/app/assets/javascripts/labels_select.js.coffee index 6dff9fc4fd0..5df3af6091a 100644 --- a/app/assets/javascripts/labels_select.js.coffee +++ b/app/assets/javascripts/labels_select.js.coffee @@ -39,7 +39,7 @@ class @LabelsSelect <% }); %>' ) - labelNoneHTMLTemplate = _.template('None') + labelNoneHTMLTemplate = 'None' if newLabelField.length @@ -142,7 +142,7 @@ class @LabelsSelect template = labelHTMLTemplate(data) labelCount = data.labels.length else - template = labelNoneHTMLTemplate() + template = labelNoneHTMLTemplate $value .removeAttr('style') .html(template) From 9d09bd08e541c42dc05e336293cc2917b3a60df8 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 08:30:38 +0100 Subject: [PATCH 060/318] Due date can be removed from milestones Closes #15063 --- CHANGELOG | 2 ++ app/assets/javascripts/dispatcher.js.coffee | 1 + app/assets/javascripts/due_date_select.js.coffee | 15 +++++++++++++++ app/views/projects/milestones/_form.html.haml | 8 +------- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b00c149a753..07c7ad19c5f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,6 +73,8 @@ v 8.8.5 (unreleased) v 8.8.4 - Fix LDAP-based login for users with 2FA enabled. !4493 + - Added descriptions to notification settings dropdown + - Due date can be removed from milestones v 8.8.3 - Fix 404 page when viewing TODOs that contain milestones or labels in different projects. !4312 diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 29ac0f70b30..08ab9604361 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -29,6 +29,7 @@ class Dispatcher new Todos() when 'projects:milestones:new', 'projects:milestones:edit' new ZenMode() + new DueDateSelect() new GLForm($('.milestone-form')) when 'groups:milestones:new' new ZenMode() diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index 3d009a96d05..99d59eca9cb 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -1,5 +1,20 @@ class @DueDateSelect constructor: -> + # Milestone edit/new form + $datePicker = $('.datepicker') + $dueDate = $('#milestone_due_date') + $datePicker.datepicker + dateFormat: 'yy-mm-dd' + onSelect: (dateText, inst) -> + $dueDate.val(dateText) + .datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val())) + + $('.js-clear-due-date').on 'click', (e) -> + e.preventDefault() + $dueDate.val('') + $datePicker.datepicker('setDate', '') + + # Issuable sidebar $loading = $('.js-issuable-update .due_date') .find('.block-loading') .hide() diff --git a/app/views/projects/milestones/_form.html.haml b/app/views/projects/milestones/_form.html.haml index f5e2b927da8..cbf1ba04170 100644 --- a/app/views/projects/milestones/_form.html.haml +++ b/app/views/projects/milestones/_form.html.haml @@ -19,6 +19,7 @@ = f.label :due_date, "Due Date", class: "control-label" .col-sm-10 = f.text_field :due_date, class: "datepicker form-control", placeholder: "Select due date" + %a.inline.prepend-top-5.js-clear-due-date{ href: "#" } Clear due date .form-actions - if @milestone.new_record? @@ -27,10 +28,3 @@ -else = f.submit 'Save changes', class: "btn-save btn" = link_to "Cancel", namespace_project_milestone_path(@project.namespace, @project, @milestone), class: "btn btn-cancel" - - -:javascript - $(".datepicker").datepicker({ - dateFormat: "yy-mm-dd", - onSelect: function(dateText, inst) { $("#milestone_due_date").val(dateText) } - }).datepicker("setDate", $.datepicker.parseDate('yy-mm-dd', $('#milestone_due_date').val())); From ab722343ada1f79cc7274721ec707dc7760f3fce Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 12:23:17 +0100 Subject: [PATCH 061/318] Fixed tests --- app/assets/javascripts/due_date_select.js.coffee | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index 99d59eca9cb..401433b0732 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -2,12 +2,14 @@ class @DueDateSelect constructor: -> # Milestone edit/new form $datePicker = $('.datepicker') - $dueDate = $('#milestone_due_date') - $datePicker.datepicker - dateFormat: 'yy-mm-dd' - onSelect: (dateText, inst) -> - $dueDate.val(dateText) - .datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val())) + + if $datePicker.length + $dueDate = $('#milestone_due_date') + $datePicker.datepicker + dateFormat: 'yy-mm-dd' + onSelect: (dateText, inst) -> + $dueDate.val(dateText) + .datepicker('setDate', $.datepicker.parseDate('yy-mm-dd', $dueDate.val())) $('.js-clear-due-date').on 'click', (e) -> e.preventDefault() From ff702bd6e05520a2cb695a03984a35ed981c6561 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 10 Jun 2016 12:04:21 +0100 Subject: [PATCH 062/318] Changed how date gets cleared --- app/assets/javascripts/due_date_select.js.coffee | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/javascripts/due_date_select.js.coffee b/app/assets/javascripts/due_date_select.js.coffee index 401433b0732..2a79a0c3d86 100644 --- a/app/assets/javascripts/due_date_select.js.coffee +++ b/app/assets/javascripts/due_date_select.js.coffee @@ -13,8 +13,7 @@ class @DueDateSelect $('.js-clear-due-date').on 'click', (e) -> e.preventDefault() - $dueDate.val('') - $datePicker.datepicker('setDate', '') + $.datepicker._clearDate($datePicker) # Issuable sidebar $loading = $('.js-issuable-update .due_date') From e6209a407e2e8abee99bb61f089e262c3a5e51e8 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 10 Jun 2016 13:26:36 +0200 Subject: [PATCH 063/318] Added description of artifacts:when --- doc/ci/yaml/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index a3481f58c6c..39fad549a04 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -30,6 +30,7 @@ If you want a quick introduction to GitLab CI, follow our - [when](#when) - [artifacts](#artifacts) - [artifacts:name](#artifacts-name) + - [artifacts:when](#artifacts-when) - [dependencies](#dependencies) - [before_script and after_script](#before_script-and-after_script) - [Hidden jobs](#hidden-jobs) @@ -651,6 +652,32 @@ job: untracked: true ``` +#### artifacts:when + +>**Note:** +Introduced in GitLab 8.9 and GitLab Runner v1.3.0. + +`artifacts:when` is used to upload artifacts on build failure or despite the +failure. + +`artifacts:when` can be set to one of the following values: + +1. `on_success` - upload artifacts only when build succeeds. This is the default +1. `on_failure` - upload artifacts only when build fails +1. `always` - upload artifacts despite the build status + +--- + +**Example configurations** + +To upload artifacts only when build fails + +```yaml +job: + artifacts: + when: on_failure +``` + ### dependencies >**Note:** From cc373a35504bc1f92f1a040c87a712a6480757ec Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 10 Jun 2016 14:01:07 +0200 Subject: [PATCH 064/318] Add factory for fabricating new ci config nodes --- lib/gitlab/ci/config/node/configurable.rb | 24 +++------ lib/gitlab/ci/config/node/entry.rb | 10 ++-- lib/gitlab/ci/config/node/factory.rb | 44 +++++++++++++++++ .../lib/gitlab/ci/config/node/factory_spec.rb | 49 +++++++++++++++++++ 4 files changed, 106 insertions(+), 21 deletions(-) create mode 100644 lib/gitlab/ci/config/node/factory.rb create mode 100644 spec/lib/gitlab/ci/config/node/factory_spec.rb diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index b72bc0d592a..650c6efba63 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -30,19 +30,10 @@ module Gitlab private - def add_node(key, metadata) - entry = create_entry(key, metadata[:class]) - entry.description = metadata[:description] - - @nodes[key] = entry - end - - def create_entry(key, entry_class) - if @value.has_key?(key) - entry_class.new(@value[key]) - else - Node::Null.new(nil) - end + def create_node(key, factory) + factory.with_value(@value[key]) + factory.null_node unless @value.has_key?(key) + factory.create! end class_methods do @@ -51,9 +42,8 @@ module Gitlab private def allow_node(symbol, entry_class, metadata) - node = { symbol.to_sym => - { class: entry_class, - description: metadata[:description] } } + factory = Node::Factory.new(entry_class) + .with_description(metadata[:description]) define_method(symbol) do raise Entry::InvalidError unless valid? @@ -61,7 +51,7 @@ module Gitlab @nodes[symbol].try(:value) end - (@allowed_nodes ||= {}).merge!(node) + (@allowed_nodes ||= {}).merge!(symbol => factory) end end end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index f7649784c28..2f327fa9bf3 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -27,8 +27,8 @@ module Gitlab end def compose! - allowed_nodes.each do |key, entry| - add_node(key, entry) + allowed_nodes.each do |key, factory| + @nodes[key] = create_node(key, factory.dup) end end @@ -52,7 +52,7 @@ module Gitlab {} end - def add_node(key, entry) + def validate! raise NotImplementedError end @@ -60,7 +60,9 @@ module Gitlab raise NotImplementedError end - def validate! + private + + def create_node(key, factory) raise NotImplementedError end end diff --git a/lib/gitlab/ci/config/node/factory.rb b/lib/gitlab/ci/config/node/factory.rb new file mode 100644 index 00000000000..969af45272e --- /dev/null +++ b/lib/gitlab/ci/config/node/factory.rb @@ -0,0 +1,44 @@ +module Gitlab + module Ci + class Config + module Node + ## + # Factory class responsible for fabricating node entry objects. + # + # It uses Fluent Interface pattern to set all necessary attributes. + # + class Factory + class InvalidFactory < StandardError; end + + def initialize(entry_class) + @entry_class = entry_class + @attributes = {} + end + + def with_value(value) + @attributes[:value] = value + self + end + + def with_description(description) + @attributes[:description] = description + self + end + + def null_node + @entry_class = Node::Null + self + end + + def create! + raise InvalidFactory unless @attributes.has_key?(:value) + + @entry_class.new(@attributes[:value]).tap do |entry| + entry.description = @attributes[:description] + end + end + end + end + end + end +end diff --git a/spec/lib/gitlab/ci/config/node/factory_spec.rb b/spec/lib/gitlab/ci/config/node/factory_spec.rb new file mode 100644 index 00000000000..73d760d1b0a --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/factory_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Factory do + describe '#create!' do + let(:factory) { described_class.new(entry_class) } + let(:entry_class) { Gitlab::Ci::Config::Node::Script } + + context 'when value setting value' do + it 'creates entry with valid value' do + entry = factory + .with_value(['ls', 'pwd']) + .create! + + expect(entry.value).to eq "ls\npwd" + end + + context 'when setting description' do + it 'creates entry with description' do + entry = factory + .with_value(['ls', 'pwd']) + .with_description('test description') + .create! + + expect(entry.value).to eq "ls\npwd" + expect(entry.description).to eq 'test description' + end + end + end + + context 'when not setting value' do + it 'raises error' do + expect { factory.create! }.to raise_error( + Gitlab::Ci::Config::Node::Factory::InvalidFactory + ) + end + end + + context 'when creating a null entry' do + it 'creates a null entry' do + entry = factory + .with_value(nil) + .null_node + .create! + + expect(entry).to be_an_instance_of Gitlab::Ci::Config::Node::Null + end + end + end +end From 0e896ffe4eebb8bcf04bc1327d498bb041faed56 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Fri, 10 Jun 2016 14:51:16 +0200 Subject: [PATCH 065/318] Improve Gitlab::Auth method names Auth.find was a very generic name for a very specific method. Auth.find_in_gitlab_or_ldap was inaccurate in GitLab EE where it also looks in Kerberos. --- app/controllers/jwt_controller.rb | 2 +- app/controllers/projects/git_http_controller.rb | 2 +- config/initializers/doorkeeper.rb | 2 +- lib/api/session.rb | 2 +- lib/gitlab/auth.rb | 6 +++--- lib/gitlab/backend/grack_auth.rb | 2 +- spec/lib/gitlab/auth_spec.rb | 16 ++++++++-------- spec/requests/jwt_controller_spec.rb | 2 +- 8 files changed, 17 insertions(+), 17 deletions(-) diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 131a16dad9b..014b9b43ff2 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -42,7 +42,7 @@ class JwtController < ApplicationController end def authenticate_user(login, password) - user = Gitlab::Auth.find_in_gitlab_or_ldap(login, password) + user = Gitlab::Auth.find_with_user_password(login, password) Gitlab::Auth.rate_limit!(request.ip, success: user.present?, login: login) user end diff --git a/app/controllers/projects/git_http_controller.rb b/app/controllers/projects/git_http_controller.rb index 348d6cf4d96..f907d63258b 100644 --- a/app/controllers/projects/git_http_controller.rb +++ b/app/controllers/projects/git_http_controller.rb @@ -43,7 +43,7 @@ class Projects::GitHttpController < Projects::ApplicationController return if project && project.public? && upload_pack? authenticate_or_request_with_http_basic do |login, password| - auth_result = Gitlab::Auth.find(login, password, project: project, ip: request.ip) + auth_result = Gitlab::Auth.find_for_git_client(login, password, project: project, ip: request.ip) if auth_result.type == :ci && upload_pack? @ci = true diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index 8dc8e270afc..618dba74151 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -12,7 +12,7 @@ Doorkeeper.configure do end resource_owner_from_credentials do |routes| - Gitlab::Auth.find_in_gitlab_or_ldap(params[:username], params[:password]) + Gitlab::Auth.find_with_user_password(params[:username], params[:password]) end # If you want to restrict access to the web interface for adding oauth authorized applications, you need to declare the block below. diff --git a/lib/api/session.rb b/lib/api/session.rb index 56e69b2366f..56c202f1294 100644 --- a/lib/api/session.rb +++ b/lib/api/session.rb @@ -11,7 +11,7 @@ module API # Example Request: # POST /session post "/session" do - user = Gitlab::Auth.find_in_gitlab_or_ldap(params[:email] || params[:login], params[:password]) + user = Gitlab::Auth.find_with_user_password(params[:email] || params[:login], params[:password]) return unauthorized! unless user present user, with: Entities::UserLogin diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 076e2af7d38..db1704af75e 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -3,14 +3,14 @@ module Gitlab Result = Struct.new(:user, :type) class << self - def find(login, password, project:, ip:) + def find_for_git_client(login, password, project:, ip:) raise "Must provide an IP for rate limiting" if ip.nil? result = Result.new if valid_ci_request?(login, password, project) result.type = :ci - elsif result.user = find_in_gitlab_or_ldap(login, password) + elsif result.user = find_with_user_password(login, password) result.type = :gitlab_or_ldap elsif result.user = oauth_access_token_check(login, password) result.type = :oauth @@ -20,7 +20,7 @@ module Gitlab result end - def find_in_gitlab_or_ldap(login, password) + def find_with_user_password(login, password) user = User.by_login(login) # If no user is found, or it's an LDAP server, try LDAP. diff --git a/lib/gitlab/backend/grack_auth.rb b/lib/gitlab/backend/grack_auth.rb index 9e09d2e118d..adbf5941a96 100644 --- a/lib/gitlab/backend/grack_auth.rb +++ b/lib/gitlab/backend/grack_auth.rb @@ -95,7 +95,7 @@ module Grack end def authenticate_user(login, password) - user = Gitlab::Auth.find_in_gitlab_or_ldap(login, password) + user = Gitlab::Auth.find_with_user_password(login, password) unless user user = oauth_access_token_check(login, password) diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index a814ad2a4e7..f081d550ec8 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -41,7 +41,7 @@ describe Gitlab::Auth, lib: true do end end - describe 'find_in_gitlab_or_ldap' do + describe 'find_with_user_password' do let!(:user) do create(:user, username: username, @@ -52,25 +52,25 @@ describe Gitlab::Auth, lib: true do let(:password) { 'my-secret' } it "should find user by valid login/password" do - expect( gl_auth.find_in_gitlab_or_ldap(username, password) ).to eql user + expect( gl_auth.find_with_user_password(username, password) ).to eql user end it 'should find user by valid email/password with case-insensitive email' do - expect(gl_auth.find_in_gitlab_or_ldap(user.email.upcase, password)).to eql user + expect(gl_auth.find_with_user_password(user.email.upcase, password)).to eql user end it 'should find user by valid username/password with case-insensitive username' do - expect(gl_auth.find_in_gitlab_or_ldap(username.upcase, password)).to eql user + expect(gl_auth.find_with_user_password(username.upcase, password)).to eql user end it "should not find user with invalid password" do password = 'wrong' - expect( gl_auth.find_in_gitlab_or_ldap(username, password) ).not_to eql user + expect( gl_auth.find_with_user_password(username, password) ).not_to eql user end it "should not find user with invalid login" do user = 'wrong' - expect( gl_auth.find_in_gitlab_or_ldap(username, password) ).not_to eql user + expect( gl_auth.find_with_user_password(username, password) ).not_to eql user end context "with ldap enabled" do @@ -81,13 +81,13 @@ describe Gitlab::Auth, lib: true do it "tries to autheticate with db before ldap" do expect(Gitlab::LDAP::Authentication).not_to receive(:login) - gl_auth.find_in_gitlab_or_ldap(username, password) + gl_auth.find_with_user_password(username, password) end it "uses ldap as fallback to for authentication" do expect(Gitlab::LDAP::Authentication).to receive(:login) - gl_auth.find_in_gitlab_or_ldap('ldap_user', 'password') + gl_auth.find_with_user_password('ldap_user', 'password') end end end diff --git a/spec/requests/jwt_controller_spec.rb b/spec/requests/jwt_controller_spec.rb index c995993a853..d2d4a9eca18 100644 --- a/spec/requests/jwt_controller_spec.rb +++ b/spec/requests/jwt_controller_spec.rb @@ -44,7 +44,7 @@ describe JwtController do let(:user) { create(:user) } let(:headers) { { authorization: credentials('user', 'password') } } - before { expect(Gitlab::Auth).to receive(:find_in_gitlab_or_ldap).with('user', 'password').and_return(user) } + before { expect(Gitlab::Auth).to receive(:find_with_user_password).with('user', 'password').and_return(user) } subject! { get '/jwt/auth', parameters, headers } From 16927e3fadfb5b9b2c3b12ffdf7ab217971b8cca Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 30 May 2016 11:05:17 +0200 Subject: [PATCH 066/318] Enable Style/MultilineBlockChain rubocop style cop See #17478 --- .rubocop.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.rubocop.yml b/.rubocop.yml index 678f7db025b..72db0342543 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -348,7 +348,7 @@ Style/MultilineArrayBraceLayout: # Avoid multi-line chains of blocks. Style/MultilineBlockChain: - Enabled: false + Enabled: true # Ensures newlines after multiline block do statements. Style/MultilineBlockLayout: From 49c06ba39133ea3f3468729d661264f77219d251 Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Fri, 10 Jun 2016 14:17:10 +0100 Subject: [PATCH 067/318] Only show issues closing MR when present --- app/views/projects/issues/_merge_requests.html.haml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml index 75f36579b11..d8075371853 100644 --- a/app/views/projects/issues/_merge_requests.html.haml +++ b/app/views/projects/issues/_merge_requests.html.haml @@ -24,8 +24,6 @@ MERGED - elsif merge_request.closed? CLOSED - %li - = render partial: 'projects/issues/closed_by_box', locals: {merge_request_count: @merge_requests.count} - if @closed_by_merge_requests.present? %li = render partial: 'projects/issues/closed_by_box', locals: {merge_request_count: @merge_requests.count} From c7941acd1e410fedb431efe13dc3b74dfa7f9f8a Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Fri, 10 Jun 2016 14:22:50 +0100 Subject: [PATCH 068/318] Don't try to count a relation with aliases 98f147e84d2bd8f2278452ac0852118452c76d4a fixed this for issues in HTML, but not MRs or Atom feeds. --- app/views/dashboard/issues.atom.builder | 4 ++-- app/views/groups/issues.atom.builder | 4 ++-- app/views/projects/issues/index.atom.builder | 4 ++-- app/views/shared/_merge_requests.html.haml | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/views/dashboard/issues.atom.builder b/app/views/dashboard/issues.atom.builder index 83c0c6da21b..0404d0728ea 100644 --- a/app/views/dashboard/issues.atom.builder +++ b/app/views/dashboard/issues.atom.builder @@ -4,7 +4,7 @@ xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://sear xml.link href: issues_dashboard_url(format: :atom, private_token: current_user.try(:private_token)), rel: "self", type: "application/atom+xml" xml.link href: issues_dashboard_url, rel: "alternate", type: "text/html" xml.id issues_dashboard_url - xml.updated @issues.first.created_at.xmlschema if @issues.any? + xml.updated @issues.first.created_at.xmlschema if @issues.reorder(nil).any? - xml << render(partial: 'issues/issue', collection: @issues) if @issues.any? + xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any? end diff --git a/app/views/groups/issues.atom.builder b/app/views/groups/issues.atom.builder index c19671295af..b1628040325 100644 --- a/app/views/groups/issues.atom.builder +++ b/app/views/groups/issues.atom.builder @@ -4,7 +4,7 @@ xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://sear xml.link href: issues_group_url(format: :atom, private_token: current_user.try(:private_token)), rel: "self", type: "application/atom+xml" xml.link href: issues_group_url, rel: "alternate", type: "text/html" xml.id issues_group_url - xml.updated @issues.first.created_at.xmlschema if @issues.any? + xml.updated @issues.first.created_at.xmlschema if @issues.reorder(nil).any? - xml << render(partial: 'issues/issue', collection: @issues) if @issues.any? + xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any? end diff --git a/app/views/projects/issues/index.atom.builder b/app/views/projects/issues/index.atom.builder index 7ad7c9c87e8..36957560de0 100644 --- a/app/views/projects/issues/index.atom.builder +++ b/app/views/projects/issues/index.atom.builder @@ -4,7 +4,7 @@ xml.feed "xmlns" => "http://www.w3.org/2005/Atom", "xmlns:media" => "http://sear xml.link href: namespace_project_issues_url(@project.namespace, @project, format: :atom, private_token: current_user.try(:private_token)), rel: "self", type: "application/atom+xml" xml.link href: namespace_project_issues_url(@project.namespace, @project), rel: "alternate", type: "text/html" xml.id namespace_project_issues_url(@project.namespace, @project) - xml.updated @issues.first.created_at.xmlschema if @issues.any? + xml.updated @issues.first.created_at.xmlschema if @issues.reorder(nil).any? - xml << render(partial: 'issues/issue', collection: @issues) if @issues.any? + xml << render(partial: 'issues/issue', collection: @issues) if @issues.reorder(nil).any? end diff --git a/app/views/shared/_merge_requests.html.haml b/app/views/shared/_merge_requests.html.haml index e74fc36c797..ca3178395c1 100644 --- a/app/views/shared/_merge_requests.html.haml +++ b/app/views/shared/_merge_requests.html.haml @@ -1,4 +1,4 @@ -- if @merge_requests.any? +- if @merge_requests.reorder(nil).any? - @merge_requests.group_by(&:target_project).each do |group| .panel.panel-default.panel-small - project = group[0] From 8f6d43e0fea3ce62ec2e8e211755e557f19c51fd Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Mon, 6 Jun 2016 12:50:54 -0300 Subject: [PATCH 069/318] Remove notification level from user model --- CHANGELOG | 1 + .../profiles/notifications_controller.rb | 24 +- app/helpers/notifications_helper.rb | 32 ++ app/models/notification_setting.rb | 1 - app/models/user.rb | 18 +- app/services/notification_service.rb | 16 +- .../notifications/_group_settings.html.haml | 2 +- .../notifications/_project_settings.html.haml | 2 +- .../profiles/notifications/show.html.haml | 28 +- ...tification_setting_not_null_constraints.rb | 7 + ...201627_migrate_users_notification_level.rb | 24 ++ spec/models/notification_setting_spec.rb | 1 - spec/services/notification_service_spec.rb | 296 +++++++++++++++++- 13 files changed, 391 insertions(+), 61 deletions(-) create mode 100644 db/migrate/20160606192159_remove_notification_setting_not_null_constraints.rb create mode 100644 db/migrate/20160607201627_migrate_users_notification_level.rb diff --git a/CHANGELOG b/CHANGELOG index 1c64739f701..bf437e267a4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -54,6 +54,7 @@ v 8.9.0 (unreleased) - Improve error handling importing projects - Remove duplicated notification settings - Put project Files and Commits tabs under Code tab + - Decouple global notification level from user model - Replace Colorize with Rainbow for coloring console output in Rake tasks. - An indicator is now displayed at the top of the comment field for confidential issues. - RepositoryCheck::SingleRepositoryWorker public and private methods are now instrumented diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index 18ee55c839a..1e9ceb87857 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -1,12 +1,13 @@ class Profiles::NotificationsController < Profiles::ApplicationController def show - @user = current_user - @group_notifications = current_user.notification_settings.for_groups - @project_notifications = current_user.notification_settings.for_projects + @user = current_user + @group_notifications = current_user.notification_settings.for_groups + @project_notifications = current_user.notification_settings.for_projects + @global_notification_setting = current_user.global_notification_setting end def update - if current_user.update_attributes(user_params) + if current_user.update_attributes(user_params) && update_notification_settings flash[:notice] = "Notification settings saved" else flash[:alert] = "Failed to save new settings" @@ -18,4 +19,19 @@ class Profiles::NotificationsController < Profiles::ApplicationController def user_params params.require(:user).permit(:notification_email, :notification_level) end + + def notification_params + params.require(:notification_level) + end + + private + + def update_notification_settings + return true unless notification_params + + notification_setting = current_user.global_notification_setting + notification_setting.level = notification_params + + notification_setting.save + end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index b8e64b3890a..9769458f79e 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -61,4 +61,36 @@ module NotificationsHelper end end end + + def notification_level_radio_buttons + html = "" + + NotificationSetting.levels.each_key do |level| + level = level.to_sym + next if level == :global + + html << content_tag(:div, class: "radio") do + content_tag(:label, { value: level }) do + radio_button_tag(:notification_level, level, @global_notification_setting.level.to_sym == level) + + content_tag(:div, level.to_s.capitalize, class: "level-title") + + content_tag(:p, notification_level_description(level)) + end + end + end + + html.html_safe + end + + def notification_level_description(level) + case level + when :disabled + "You will not get any notifications via email" + when :mention + "You will receive notifications only for comments in which you were @mentioned" + when :participating + "You will only receive notifications from related resources (e.g. from your commits or assigned issues)" + when :watch + "You will receive notifications for any activity" + end + end end diff --git a/app/models/notification_setting.rb b/app/models/notification_setting.rb index 17fb15b08df..0ce87968e46 100644 --- a/app/models/notification_setting.rb +++ b/app/models/notification_setting.rb @@ -7,7 +7,6 @@ class NotificationSetting < ActiveRecord::Base belongs_to :source, polymorphic: true validates :user, presence: true - validates :source, presence: true validates :level, presence: true validates :user_id, uniqueness: { scope: [:source_type, :source_id], message: "already exists in source", diff --git a/app/models/user.rb b/app/models/user.rb index e0987e07e1f..d2da83ab4b3 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -10,6 +10,8 @@ class User < ActiveRecord::Base include CaseSensitivity include TokenAuthenticatable + DEFAULT_NOTIFICATION_LEVEL = :participating + add_authentication_token_field :authentication_token default_value_for :admin, false @@ -99,7 +101,6 @@ class User < ActiveRecord::Base presence: true, uniqueness: { case_sensitive: false } - validates :notification_level, presence: true validate :namespace_uniq, if: ->(user) { user.username_changed? } validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validate :unique_email, if: ->(user) { user.email_changed? } @@ -133,13 +134,6 @@ class User < ActiveRecord::Base # Note: When adding an option, it MUST go on the end of the array. enum project_view: [:readme, :activity, :files] - # Notification level - # Note: When adding an option, it MUST go on the end of the array. - # - # TODO: Add '_prefix: :notification' to enum when update to Rails 5. https://github.com/rails/rails/pull/19813 - # Because user.notification_disabled? is much better than user.disabled? - enum notification_level: [:disabled, :participating, :watch, :global, :mention] - alias_attribute :private_token, :authentication_token delegate :path, to: :namespace, allow_nil: true, prefix: true @@ -800,6 +794,14 @@ class User < ActiveRecord::Base notification_settings.find_or_initialize_by(source: source) end + # Lazy load global notification setting + # Initializes User setting with Participating level if setting not persisted + def global_notification_setting + setting = notification_settings.find_or_initialize_by(source: nil) + setting.level = NotificationSetting.levels[DEFAULT_NOTIFICATION_LEVEL] unless setting.persisted? + setting + end + def assigned_open_merge_request_count(force: false) Rails.cache.fetch(['users', id, 'assigned_open_merge_request_count'], force: force) do assigned_merge_requests.opened.count diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 91ca82ed3b7..af7bbe37439 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -279,10 +279,11 @@ class NotificationService end def users_with_global_level_watch(ids) - User.where( - id: ids, - notification_level: NotificationSetting.levels[:watch] - ).pluck(:id) + NotificationSetting.where( + user_id: ids, + source_type: nil, + level: NotificationSetting.levels[:watch] + ).pluck(:user_id) end # Build a list of users based on project notifcation settings @@ -352,7 +353,7 @@ class NotificationService users = users.reject(&:blocked?) users.reject do |user| - next user.notification_level == level unless project + next user.global_notification_setting.level == level unless project setting = user.notification_settings_for(project) @@ -361,13 +362,13 @@ class NotificationService end # reject users who globally set mention notification and has no setting per project/group - next user.notification_level == level unless setting + next user.global_notification_setting.level == level unless setting # reject users who set mention notification in project next true if setting.level == level # reject users who have mention level in project and disabled in global settings - setting.global? && user.notification_level == level + setting.global? && user.global_notification_setting.level == level end end @@ -456,7 +457,6 @@ class NotificationService def build_recipients(target, project, current_user, action: nil, previous_assignee: nil) recipients = target.participants(current_user) - recipients = add_project_watchers(recipients, project) recipients = reject_mention_users(recipients, project) diff --git a/app/views/profiles/notifications/_group_settings.html.haml b/app/views/profiles/notifications/_group_settings.html.haml index 89ae7ffda2b..f0cf82afe83 100644 --- a/app/views/profiles/notifications/_group_settings.html.haml +++ b/app/views/profiles/notifications/_group_settings.html.haml @@ -1,7 +1,7 @@ %li.notification-list-item %span.notification.fa.fa-holder.append-right-5 - if setting.global? - = notification_icon(current_user.notification_level) + = notification_icon(current_user.global_notification_setting.level) - else = notification_icon(setting.level) diff --git a/app/views/profiles/notifications/_project_settings.html.haml b/app/views/profiles/notifications/_project_settings.html.haml index 17c097154da..e0fad555c09 100644 --- a/app/views/profiles/notifications/_project_settings.html.haml +++ b/app/views/profiles/notifications/_project_settings.html.haml @@ -1,7 +1,7 @@ %li.notification-list-item %span.notification.fa.fa-holder.append-right-5 - if setting.global? - = notification_icon(current_user.notification_level) + = notification_icon(current_user.global_notification_setting.level) - else = notification_icon(setting.level) diff --git a/app/views/profiles/notifications/show.html.haml b/app/views/profiles/notifications/show.html.haml index 7696f112bb3..f2659ac14b5 100644 --- a/app/views/profiles/notifications/show.html.haml +++ b/app/views/profiles/notifications/show.html.haml @@ -26,33 +26,7 @@ = f.select :notification_email, @user.all_emails, { include_blank: false }, class: "select2" .form-group = f.label :notification_level, class: 'label-light' - .radio - = f.label :notification_level, value: :disabled do - = f.radio_button :notification_level, :disabled - .level-title - Disabled - %p You will not get any notifications via email - - .radio - = f.label :notification_level, value: :mention do - = f.radio_button :notification_level, :mention - .level-title - On Mention - %p You will receive notifications only for comments in which you were @mentioned - - .radio - = f.label :notification_level, value: :participating do - = f.radio_button :notification_level, :participating - .level-title - Participating - %p You will only receive notifications from related resources (e.g. from your commits or assigned issues) - - .radio - = f.label :notification_level, value: :watch do - = f.radio_button :notification_level, :watch - .level-title - Watch - %p You will receive notifications for any activity + = notification_level_radio_buttons .prepend-top-default = f.submit 'Update settings', class: "btn btn-create" diff --git a/db/migrate/20160606192159_remove_notification_setting_not_null_constraints.rb b/db/migrate/20160606192159_remove_notification_setting_not_null_constraints.rb new file mode 100644 index 00000000000..c20ac9acdc2 --- /dev/null +++ b/db/migrate/20160606192159_remove_notification_setting_not_null_constraints.rb @@ -0,0 +1,7 @@ +class RemoveNotificationSettingNotNullConstraints < ActiveRecord::Migration + def up + change_column :notification_settings, :source_type, :string, null: true + change_column :notification_settings, :source_id, :integer, null: true + change_column :users, :notification_level, :integer, null: true + end +end diff --git a/db/migrate/20160607201627_migrate_users_notification_level.rb b/db/migrate/20160607201627_migrate_users_notification_level.rb new file mode 100644 index 00000000000..7417d66fef7 --- /dev/null +++ b/db/migrate/20160607201627_migrate_users_notification_level.rb @@ -0,0 +1,24 @@ +class MigrateUsersNotificationLevel < ActiveRecord::Migration + # Migrates only users which changes theier default notification level :participating + # creating a new record on notification settins table + + def up + changed_users = exec_query(%Q{ + SELECT id, notification_level + FROM users + WHERE notification_level != 1 + }) + + changed_users.each do |row| + uid = row['id'] + u_notification_level = row['notification_level'] + + execute(%Q{ + INSERT INTO notification_settings + (user_id, level, created_at, updated_at) + VALUES + (#{uid}, #{u_notification_level}, now(), now()) + }) + end + end +end diff --git a/spec/models/notification_setting_spec.rb b/spec/models/notification_setting_spec.rb index 295081e9da1..4e24e89b008 100644 --- a/spec/models/notification_setting_spec.rb +++ b/spec/models/notification_setting_spec.rb @@ -10,7 +10,6 @@ RSpec.describe NotificationSetting, type: :model do subject { NotificationSetting.new(source_id: 1, source_type: 'Project') } it { is_expected.to validate_presence_of(:user) } - it { is_expected.to validate_presence_of(:source) } it { is_expected.to validate_presence_of(:level) } it { is_expected.to validate_uniqueness_of(:user_id).scoped_to([:source_id, :source_type]).with_message(/already exists in source/) } end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index cef5e0d8659..5a9a9d62a15 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -72,6 +72,7 @@ describe NotificationService, services: true do should_not_email(@u_disabled) should_not_email(@unsubscriber) should_not_email(@u_outsider_mentioned) + should_not_email(@u_lazy_participant) end it 'filters out "mentioned in" notes' do @@ -80,6 +81,19 @@ describe NotificationService, services: true do expect(Notify).not_to receive(:note_issue_email) notification.new_note(mentioned_note) end + + context 'participating' do + context 'by note' do + before do + note.author = @u_lazy_participant + note.save + notification.new_note(note) + end + + + it { should_email(@u_lazy_participant) } + end + end end describe 'new note on issue in project that belongs to a group' do @@ -106,6 +120,7 @@ describe NotificationService, services: true do should_not_email(note.author) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) end end end @@ -235,6 +250,7 @@ describe NotificationService, services: true do should_not_email(note.author) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) end it do @@ -248,10 +264,11 @@ describe NotificationService, services: true do should_not_email(note.author) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) end it do - @u_committer.update_attributes(notification_level: :mention) + @u_committer = create_global_setting_for(@u_committer, :mention) notification.new_note(note) should_not_email(@u_committer) end @@ -280,10 +297,11 @@ describe NotificationService, services: true do should_not_email(@u_mentioned) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) end it do - issue.assignee.update_attributes(notification_level: :mention) + create_global_setting_for(issue.assignee, :mention) notification.new_issue(issue, @u_disabled) should_not_email(issue.assignee) @@ -341,6 +359,7 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) end it 'emails previous assignee even if he has the "on mention" notif level' do @@ -356,6 +375,7 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) end it 'emails new assignee even if he has the "on mention" notif level' do @@ -371,6 +391,7 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) end it 'emails new assignee' do @@ -386,6 +407,7 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) end it 'does not email new assignee if they are the current user' do @@ -401,6 +423,35 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + context 'participating' do + context 'by assignee' do + before do + issue.update_attribute(:assignee, @u_lazy_participant) + notification.reassigned_issue(issue, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: 'anything', author: @u_lazy_participant) } + + before { notification.reassigned_issue(issue, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + issue.author = @u_lazy_participant + notification.reassigned_issue(issue, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end end end @@ -479,6 +530,35 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + context 'participating' do + context 'by assignee' do + before do + issue.update_attribute(:assignee, @u_lazy_participant) + notification.close_issue(issue, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: 'anything', author: @u_lazy_participant) } + + before { notification.close_issue(issue, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + issue.author = @u_lazy_participant + notification.close_issue(issue, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end end end @@ -495,6 +575,35 @@ describe NotificationService, services: true do should_email(@watcher_and_subscriber) should_not_email(@unsubscriber) should_not_email(@u_participating) + should_not_email(@u_lazy_participant) + end + + context 'participating' do + context 'by assignee' do + before do + issue.update_attribute(:assignee, @u_lazy_participant) + notification.reopen_issue(issue, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: issue, project_id: issue.project_id, note: 'anything', author: @u_lazy_participant) } + + before { notification.reopen_issue(issue, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + issue.author = @u_lazy_participant + notification.reopen_issue(issue, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end end end end @@ -520,6 +629,7 @@ describe NotificationService, services: true do should_email(@u_guest_watcher) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) end it "emails subscribers of the merge request's labels" do @@ -530,6 +640,36 @@ describe NotificationService, services: true do should_email(subscriber) end + + + context 'participating' do + context 'by assignee' do + before do + merge_request.update_attribute(:assignee, @u_lazy_participant) + notification.new_merge_request(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: merge_request, project_id: project.id, note: 'anything', author: @u_lazy_participant) } + + before { notification.new_merge_request(merge_request, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + merge_request.author = @u_lazy_participant + merge_request.save + notification.new_merge_request(merge_request, @u_disabled) + end + + it { should_not_email(@u_lazy_participant) } + end + end end describe '#reassigned_merge_request' do @@ -545,6 +685,36 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + context 'participating' do + context 'by assignee' do + before do + merge_request.update_attribute(:assignee, @u_lazy_participant) + notification.reassigned_merge_request(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: merge_request, project_id: project.id, note: 'anything', author: @u_lazy_participant) } + + before { notification.reassigned_merge_request(merge_request, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + merge_request.author = @u_lazy_participant + merge_request.save + notification.reassigned_merge_request(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end end end @@ -572,6 +742,7 @@ describe NotificationService, services: true do should_not_email(@watcher_and_subscriber) should_not_email(@unsubscriber) should_not_email(@u_participating) + should_not_email(@u_lazy_participant) should_not_email(subscriber_to_label) should_email(subscriber_to_label2) end @@ -590,6 +761,36 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + context 'participating' do + context 'by assignee' do + before do + merge_request.update_attribute(:assignee, @u_lazy_participant) + notification.close_mr(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: merge_request, project_id: project.id, note: 'anything', author: @u_lazy_participant) } + + before { notification.close_mr(merge_request, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + merge_request.author = @u_lazy_participant + merge_request.save + notification.close_mr(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end end end @@ -606,6 +807,36 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + context 'participating' do + context 'by assignee' do + before do + merge_request.update_attribute(:assignee, @u_lazy_participant) + notification.merge_mr(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: merge_request, project_id: project.id, note: 'anything', author: @u_lazy_participant) } + + before { notification.merge_mr(merge_request, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + merge_request.author = @u_lazy_participant + merge_request.save + notification.merge_mr(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end end end @@ -622,6 +853,36 @@ describe NotificationService, services: true do should_not_email(@unsubscriber) should_not_email(@u_participating) should_not_email(@u_disabled) + should_not_email(@u_lazy_participant) + end + + context 'participating' do + context 'by assignee' do + before do + merge_request.update_attribute(:assignee, @u_lazy_participant) + notification.reopen_mr(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end + + context 'by note' do + let!(:note) { create(:note_on_issue, noteable: merge_request, project_id: project.id, note: 'anything', author: @u_lazy_participant) } + + before { notification.reopen_mr(merge_request, @u_disabled) } + + it { should_email(@u_lazy_participant) } + end + + context 'by author' do + before do + merge_request.author = @u_lazy_participant + merge_request.save + notification.reopen_mr(merge_request, @u_disabled) + end + + it { should_email(@u_lazy_participant) } + end end end end @@ -640,6 +901,7 @@ describe NotificationService, services: true do should_email(@u_watcher) should_email(@u_participating) + should_email(@u_lazy_participant) should_not_email(@u_guest_watcher) should_not_email(@u_disabled) end @@ -647,14 +909,19 @@ describe NotificationService, services: true do end def build_team(project) - @u_watcher = create(:user, notification_level: :watch) - @u_participating = create(:user, notification_level: :participating) - @u_participant_mentioned = create(:user, username: 'participant', notification_level: :participating) - @u_disabled = create(:user, notification_level: :disabled) - @u_mentioned = create(:user, username: 'mention', notification_level: :mention) - @u_committer = create(:user, username: 'committer') - @u_not_mentioned = create(:user, username: 'regular', notification_level: :participating) - @u_outsider_mentioned = create(:user, username: 'outsider') + @u_watcher = create_global_setting_for(create(:user), :watch) + @u_participating = create_global_setting_for(create(:user), :participating) + @u_participant_mentioned = create_global_setting_for(create(:user, username: 'participant'), :participating) + @u_disabled = create_global_setting_for(create(:user), :disabled) + @u_mentioned = create_global_setting_for(create(:user, username: 'mention'), :mention) + @u_committer = create(:user, username: 'committer') + @u_not_mentioned = create_global_setting_for(create(:user, username: 'regular'), :participating) + @u_outsider_mentioned = create(:user, username: 'outsider') + + # User to be participant by default + # This user does not contain any record in notification settings table + # It should be treated with a :participating notification_level + @u_lazy_participant = create(:user, username: 'lazy-participant') create_guest_watcher @@ -665,6 +932,15 @@ describe NotificationService, services: true do project.team << [@u_mentioned, :master] project.team << [@u_committer, :master] project.team << [@u_not_mentioned, :master] + project.team << [@u_lazy_participant, :master] + end + + def create_global_setting_for(user, level) + setting = user.global_notification_setting + setting.level = level + setting.save + + user end def create_guest_watcher From 39ead205de72461e86db07525922f2fab5fff2a9 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Thu, 9 Jun 2016 16:33:31 -0300 Subject: [PATCH 070/318] Remove notification level fild from users, improve migrations and specs --- .../profiles/notifications_controller.rb | 13 ++++------ app/helpers/notifications_helper.rb | 17 ++----------- app/models/user.rb | 9 ++++--- app/services/notification_service.rb | 8 ++++--- ...201627_migrate_users_notification_level.rb | 24 ------------------- ...ification_setting_not_null_constraints.rb} | 6 ++++- ...201627_migrate_users_notification_level.rb | 21 ++++++++++++++++ ...27_remove_notification_level_from_users.rb | 7 ++++++ spec/services/notification_service_spec.rb | 7 +++--- 9 files changed, 55 insertions(+), 57 deletions(-) delete mode 100644 db/migrate/20160607201627_migrate_users_notification_level.rb rename db/migrate/{20160606192159_remove_notification_setting_not_null_constraints.rb => 20160610140403_remove_notification_setting_not_null_constraints.rb} (58%) create mode 100644 db/migrate/20160610201627_migrate_users_notification_level.rb create mode 100644 db/migrate/20160610301627_remove_notification_level_from_users.rb diff --git a/app/controllers/profiles/notifications_controller.rb b/app/controllers/profiles/notifications_controller.rb index 1e9ceb87857..40d1906a53f 100644 --- a/app/controllers/profiles/notifications_controller.rb +++ b/app/controllers/profiles/notifications_controller.rb @@ -17,21 +17,18 @@ class Profiles::NotificationsController < Profiles::ApplicationController end def user_params - params.require(:user).permit(:notification_email, :notification_level) + params.require(:user).permit(:notification_email) end - def notification_params - params.require(:notification_level) + def global_notification_setting_params + params.require(:global_notification_setting).permit(:level) end private def update_notification_settings - return true unless notification_params + return true unless global_notification_setting_params - notification_setting = current_user.global_notification_setting - notification_setting.level = notification_params - - notification_setting.save + current_user.global_notification_setting.update_attributes(global_notification_setting_params) end end diff --git a/app/helpers/notifications_helper.rb b/app/helpers/notifications_helper.rb index 9769458f79e..50c21fc0d49 100644 --- a/app/helpers/notifications_helper.rb +++ b/app/helpers/notifications_helper.rb @@ -71,26 +71,13 @@ module NotificationsHelper html << content_tag(:div, class: "radio") do content_tag(:label, { value: level }) do - radio_button_tag(:notification_level, level, @global_notification_setting.level.to_sym == level) + + radio_button_tag(:"global_notification_setting[level]", level, @global_notification_setting.level.to_sym == level) + content_tag(:div, level.to_s.capitalize, class: "level-title") + - content_tag(:p, notification_level_description(level)) + content_tag(:p, notification_description(level)) end end end html.html_safe end - - def notification_level_description(level) - case level - when :disabled - "You will not get any notifications via email" - when :mention - "You will receive notifications only for comments in which you were @mentioned" - when :participating - "You will only receive notifications from related resources (e.g. from your commits or assigned issues)" - when :watch - "You will receive notifications for any activity" - end - end end diff --git a/app/models/user.rb b/app/models/user.rb index d2da83ab4b3..7afbfbf112a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -797,9 +797,12 @@ class User < ActiveRecord::Base # Lazy load global notification setting # Initializes User setting with Participating level if setting not persisted def global_notification_setting - setting = notification_settings.find_or_initialize_by(source: nil) - setting.level = NotificationSetting.levels[DEFAULT_NOTIFICATION_LEVEL] unless setting.persisted? - setting + return @global_notification_setting if defined?(@global_notification_setting) + + @global_notification_setting = notification_settings.find_or_initialize_by(source: nil) + @global_notification_setting.update_attributes(level: NotificationSetting.levels[DEFAULT_NOTIFICATION_LEVEL]) unless @global_notification_setting.persisted? + + @global_notification_setting end def assigned_open_merge_request_count(force: false) diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index af7bbe37439..875a3f4fab6 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -353,7 +353,9 @@ class NotificationService users = users.reject(&:blocked?) users.reject do |user| - next user.global_notification_setting.level == level unless project + global_notification_setting = user.global_notification_setting + + next global_notification_setting.level == level unless project setting = user.notification_settings_for(project) @@ -362,13 +364,13 @@ class NotificationService end # reject users who globally set mention notification and has no setting per project/group - next user.global_notification_setting.level == level unless setting + next global_notification_setting.level == level unless setting # reject users who set mention notification in project next true if setting.level == level # reject users who have mention level in project and disabled in global settings - setting.global? && user.global_notification_setting.level == level + setting.global? && global_notification_setting.level == level end end diff --git a/db/migrate/20160607201627_migrate_users_notification_level.rb b/db/migrate/20160607201627_migrate_users_notification_level.rb deleted file mode 100644 index 7417d66fef7..00000000000 --- a/db/migrate/20160607201627_migrate_users_notification_level.rb +++ /dev/null @@ -1,24 +0,0 @@ -class MigrateUsersNotificationLevel < ActiveRecord::Migration - # Migrates only users which changes theier default notification level :participating - # creating a new record on notification settins table - - def up - changed_users = exec_query(%Q{ - SELECT id, notification_level - FROM users - WHERE notification_level != 1 - }) - - changed_users.each do |row| - uid = row['id'] - u_notification_level = row['notification_level'] - - execute(%Q{ - INSERT INTO notification_settings - (user_id, level, created_at, updated_at) - VALUES - (#{uid}, #{u_notification_level}, now(), now()) - }) - end - end -end diff --git a/db/migrate/20160606192159_remove_notification_setting_not_null_constraints.rb b/db/migrate/20160610140403_remove_notification_setting_not_null_constraints.rb similarity index 58% rename from db/migrate/20160606192159_remove_notification_setting_not_null_constraints.rb rename to db/migrate/20160610140403_remove_notification_setting_not_null_constraints.rb index c20ac9acdc2..259abb08e47 100644 --- a/db/migrate/20160606192159_remove_notification_setting_not_null_constraints.rb +++ b/db/migrate/20160610140403_remove_notification_setting_not_null_constraints.rb @@ -2,6 +2,10 @@ class RemoveNotificationSettingNotNullConstraints < ActiveRecord::Migration def up change_column :notification_settings, :source_type, :string, null: true change_column :notification_settings, :source_id, :integer, null: true - change_column :users, :notification_level, :integer, null: true + end + + def down + change_column :notification_settings, :source_type, :string, null: false + change_column :notification_settings, :source_id, :integer, null: false end end diff --git a/db/migrate/20160610201627_migrate_users_notification_level.rb b/db/migrate/20160610201627_migrate_users_notification_level.rb new file mode 100644 index 00000000000..760b766828e --- /dev/null +++ b/db/migrate/20160610201627_migrate_users_notification_level.rb @@ -0,0 +1,21 @@ +class MigrateUsersNotificationLevel < ActiveRecord::Migration + # Migrates only users who changed their default notification level :participating + # creating a new record on notification settings table + + def up + execute(%Q{ + INSERT INTO notification_settings + (user_id, level, created_at, updated_at) + (SELECT id, notification_level, created_at, updated_at FROM users WHERE notification_level != 1) + }) + end + + # Migrates from notification settings back to user notification_level + # If no value is found the default level of 1 will be used + def down + execute(%Q{ + UPDATE users u SET + notification_level = COALESCE((SELECT level FROM notification_settings WHERE user_id = u.id AND source_type IS NULL), 1) + }) + end +end diff --git a/db/migrate/20160610301627_remove_notification_level_from_users.rb b/db/migrate/20160610301627_remove_notification_level_from_users.rb new file mode 100644 index 00000000000..8afb14df2cf --- /dev/null +++ b/db/migrate/20160610301627_remove_notification_level_from_users.rb @@ -0,0 +1,7 @@ +class RemoveNotificationLevelFromUsers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + def change + remove_column :users, :notification_level, :integer + end +end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 5a9a9d62a15..b99e02ba678 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -85,13 +85,14 @@ describe NotificationService, services: true do context 'participating' do context 'by note' do before do + ActionMailer::Base.deliveries.clear note.author = @u_lazy_participant note.save notification.new_note(note) end - it { should_email(@u_lazy_participant) } + it { should_not_email(@u_lazy_participant) } end end end @@ -953,8 +954,8 @@ describe NotificationService, services: true do def add_users_with_subscription(project, issuable) @subscriber = create :user @unsubscriber = create :user - @subscribed_participant = create(:user, username: 'subscribed_participant', notification_level: :participating) - @watcher_and_subscriber = create(:user, notification_level: :watch) + @subscribed_participant = create_global_setting_for(create(:user, username: 'subscribed_participant'), :participating) + @watcher_and_subscriber = create_global_setting_for(create(:user), :watch) project.team << [@subscribed_participant, :master] project.team << [@subscriber, :master] From 18e16e427d331415db042afd3c8dd5689db32a53 Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Tue, 7 Jun 2016 15:07:00 -0600 Subject: [PATCH 071/318] Replace raphael-rails with raphael.js so it can be split from the rest of the JavaScript. The gem isn't maintained anymore anyway. Added a network folder with an application.js including raphael components, since that's the only page using it currently. --- Gemfile | 1 - Gemfile.lock | 2 - app/assets/javascripts/application.js.coffee | 4 - .../javascripts/network/application.js.coffee | 20 + .../{ => network}/branch-graph.js.coffee | 0 .../{ => network}/network.js.coffee | 0 app/views/projects/network/show.html.haml | 12 +- config/application.rb | 1 + vendor/assets/javascripts/raphael.js | 8239 +++++++++++++++++ 9 files changed, 8262 insertions(+), 17 deletions(-) create mode 100644 app/assets/javascripts/network/application.js.coffee rename app/assets/javascripts/{ => network}/branch-graph.js.coffee (100%) rename app/assets/javascripts/{ => network}/network.js.coffee (100%) create mode 100644 vendor/assets/javascripts/raphael.js diff --git a/Gemfile b/Gemfile index b2660144f2b..5a058e10046 100644 --- a/Gemfile +++ b/Gemfile @@ -224,7 +224,6 @@ gem 'gon', '~> 6.0.1' gem 'jquery-atwho-rails', '~> 1.3.2' gem 'jquery-rails', '~> 4.1.0' gem 'jquery-ui-rails', '~> 5.0.0' -gem 'raphael-rails', '~> 2.1.2' gem 'request_store', '~> 1.3.0' gem 'select2-rails', '~> 3.5.9' gem 'virtus', '~> 1.0.1' diff --git a/Gemfile.lock b/Gemfile.lock index dfc15700494..00276d238c0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -563,7 +563,6 @@ GEM rainbow (2.1.0) raindrops (0.15.0) rake (10.5.0) - raphael-rails (2.1.2) rb-fsevent (0.9.6) rb-inotify (0.9.5) ffi (>= 0.5.0) @@ -952,7 +951,6 @@ DEPENDENCIES rails (= 4.2.6) rails-deprecated_sanitizer (~> 1.0.3) rainbow (~> 2.1.0) - raphael-rails (~> 2.1.2) rblineprof rdoc (~> 3.6) recaptcha (~> 3.0) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index b28327ce12d..228f48ad7c5 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -32,10 +32,6 @@ #= require bootstrap/tooltip #= require bootstrap/popover #= require select2 -#= require raphael -#= require g.raphael -#= require g.bar -#= require branch-graph #= require ace/ace #= require ace/ext-searchbox #= require underscore diff --git a/app/assets/javascripts/network/application.js.coffee b/app/assets/javascripts/network/application.js.coffee new file mode 100644 index 00000000000..cb9eead855b --- /dev/null +++ b/app/assets/javascripts/network/application.js.coffee @@ -0,0 +1,20 @@ +# This is a manifest file that'll be compiled into including all the files listed below. +# Add new JavaScript/Coffee code in separate files in this directory and they'll automatically +# be included in the compiled file accessible from http://example.com/assets/application.js +# It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the +# the compiled file. +# +#= require raphael +#= require g.raphael +#= require g.bar +#= require_tree . + +$ -> + network_graph = new Network({ + url: $(".network-graph").attr('data-url'), + commit_url: $(".network-graph").attr('data-commit-url'), + ref: $(".network-graph").attr('data-ref'), + commit_id: $(".network-graph").attr('data-commit-id') + }) + + new ShortcutsNetwork(network_graph.branch_graph) diff --git a/app/assets/javascripts/branch-graph.js.coffee b/app/assets/javascripts/network/branch-graph.js.coffee similarity index 100% rename from app/assets/javascripts/branch-graph.js.coffee rename to app/assets/javascripts/network/branch-graph.js.coffee diff --git a/app/assets/javascripts/network.js.coffee b/app/assets/javascripts/network/network.js.coffee similarity index 100% rename from app/assets/javascripts/network.js.coffee rename to app/assets/javascripts/network/network.js.coffee diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index bf9baaea889..3c155e97f72 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -1,4 +1,5 @@ - page_title "Network", @ref +- page_specific_javascripts asset_path("network/application.js") = render "projects/commits/head" = render "head" %div{ class: (container_class) } @@ -14,14 +15,5 @@ = check_box_tag :filter_ref, 1, @options[:filter_ref] %span Begin with the selected commit - .network-graph + .network-graph{ data: { url: "#{escape_javascript(@url)}", commit_url: "#{escape_javascript(@commit_url)}", ref: "#{escape_javascript(@ref)}", commit_id: "#{escape_javascript(@commit.id)}" } } = spinner nil, true - -:javascript - network_graph = new Network({ - url: "#{escape_javascript(@url)}", - commit_url: "#{escape_javascript(@commit_url)}", - ref: "#{escape_javascript(@ref)}", - commit_id: '#{@commit.id}' - }) - new ShortcutsNetwork(network_graph.branch_graph) diff --git a/config/application.rb b/config/application.rb index 49d4d3ba555..05fec995ed3 100644 --- a/config/application.rb +++ b/config/application.rb @@ -83,6 +83,7 @@ module Gitlab config.assets.precompile << "mailers/*.css" config.assets.precompile << "graphs/application.js" config.assets.precompile << "users/application.js" + config.assets.precompile << "network/application.js" # Version of your assets, change this if you want to expire all your assets config.assets.version = '1.0' diff --git a/vendor/assets/javascripts/raphael.js b/vendor/assets/javascripts/raphael.js new file mode 100644 index 00000000000..3f3f8a0b7f6 --- /dev/null +++ b/vendor/assets/javascripts/raphael.js @@ -0,0 +1,8239 @@ +// ┌────────────────────────────────────────────────────────────────────┐ \\ +// │ Raphaël 2.1.4 - JavaScript Vector Library │ \\ +// ├────────────────────────────────────────────────────────────────────┤ \\ +// │ Copyright © 2008-2012 Dmitry Baranovskiy (http://raphaeljs.com) │ \\ +// │ Copyright © 2008-2012 Sencha Labs (http://sencha.com) │ \\ +// ├────────────────────────────────────────────────────────────────────┤ \\ +// │ Licensed under the MIT (http://raphaeljs.com/license.html) license.│ \\ +// └────────────────────────────────────────────────────────────────────┘ \\ +// Copyright (c) 2013 Adobe Systems Incorporated. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ┌────────────────────────────────────────────────────────────┐ \\ +// │ Eve 0.4.2 - JavaScript Events Library │ \\ +// ├────────────────────────────────────────────────────────────┤ \\ +// │ Author Dmitry Baranovskiy (http://dmitry.baranovskiy.com/) │ \\ +// └────────────────────────────────────────────────────────────┘ \\ + +(function (glob) { + var version = "0.4.2", + has = "hasOwnProperty", + separator = /[\.\/]/, + wildcard = "*", + fun = function () {}, + numsort = function (a, b) { + return a - b; + }, + current_event, + stop, + events = {n: {}}, + /*\ + * eve + [ method ] + + * Fires event with given `name`, given scope and other parameters. + + > Arguments + + - name (string) name of the *event*, dot (`.`) or slash (`/`) separated + - scope (object) context for the event handlers + - varargs (...) the rest of arguments will be sent to event handlers + + = (object) array of returned values from the listeners + \*/ + eve = function (name, scope) { + name = String(name); + var e = events, + oldstop = stop, + args = Array.prototype.slice.call(arguments, 2), + listeners = eve.listeners(name), + z = 0, + f = false, + l, + indexed = [], + queue = {}, + out = [], + ce = current_event, + errors = []; + current_event = name; + stop = 0; + for (var i = 0, ii = listeners.length; i < ii; i++) if ("zIndex" in listeners[i]) { + indexed.push(listeners[i].zIndex); + if (listeners[i].zIndex < 0) { + queue[listeners[i].zIndex] = listeners[i]; + } + } + indexed.sort(numsort); + while (indexed[z] < 0) { + l = queue[indexed[z++]]; + out.push(l.apply(scope, args)); + if (stop) { + stop = oldstop; + return out; + } + } + for (i = 0; i < ii; i++) { + l = listeners[i]; + if ("zIndex" in l) { + if (l.zIndex == indexed[z]) { + out.push(l.apply(scope, args)); + if (stop) { + break; + } + do { + z++; + l = queue[indexed[z]]; + l && out.push(l.apply(scope, args)); + if (stop) { + break; + } + } while (l) + } else { + queue[l.zIndex] = l; + } + } else { + out.push(l.apply(scope, args)); + if (stop) { + break; + } + } + } + stop = oldstop; + current_event = ce; + return out.length ? out : null; + }; + // Undocumented. Debug only. + eve._events = events; + /*\ + * eve.listeners + [ method ] + + * Internal method which gives you array of all event handlers that will be triggered by the given `name`. + + > Arguments + + - name (string) name of the event, dot (`.`) or slash (`/`) separated + + = (array) array of event handlers + \*/ + eve.listeners = function (name) { + var names = name.split(separator), + e = events, + item, + items, + k, + i, + ii, + j, + jj, + nes, + es = [e], + out = []; + for (i = 0, ii = names.length; i < ii; i++) { + nes = []; + for (j = 0, jj = es.length; j < jj; j++) { + e = es[j].n; + items = [e[names[i]], e[wildcard]]; + k = 2; + while (k--) { + item = items[k]; + if (item) { + nes.push(item); + out = out.concat(item.f || []); + } + } + } + es = nes; + } + return out; + }; + + /*\ + * eve.on + [ method ] + ** + * Binds given event handler with a given name. You can use wildcards “`*`” for the names: + | eve.on("*.under.*", f); + | eve("mouse.under.floor"); // triggers f + * Use @eve to trigger the listener. + ** + > Arguments + ** + - name (string) name of the event, dot (`.`) or slash (`/`) separated, with optional wildcards + - f (function) event handler function + ** + = (function) returned function accepts a single numeric parameter that represents z-index of the handler. It is an optional feature and only used when you need to ensure that some subset of handlers will be invoked in a given order, despite of the order of assignment. + > Example: + | eve.on("mouse", eatIt)(2); + | eve.on("mouse", scream); + | eve.on("mouse", catchIt)(1); + * This will ensure that `catchIt()` function will be called before `eatIt()`. + * + * If you want to put your handler before non-indexed handlers, specify a negative value. + * Note: I assume most of the time you don’t need to worry about z-index, but it’s nice to have this feature “just in case”. + \*/ + eve.on = function (name, f) { + name = String(name); + if (typeof f != "function") { + return function () {}; + } + var names = name.split(separator), + e = events; + for (var i = 0, ii = names.length; i < ii; i++) { + e = e.n; + e = e.hasOwnProperty(names[i]) && e[names[i]] || (e[names[i]] = {n: {}}); + } + e.f = e.f || []; + for (i = 0, ii = e.f.length; i < ii; i++) if (e.f[i] == f) { + return fun; + } + e.f.push(f); + return function (zIndex) { + if (+zIndex == +zIndex) { + f.zIndex = +zIndex; + } + }; + }; + /*\ + * eve.f + [ method ] + ** + * Returns function that will fire given event with optional arguments. + * Arguments that will be passed to the result function will be also + * concated to the list of final arguments. + | el.onclick = eve.f("click", 1, 2); + | eve.on("click", function (a, b, c) { + | console.log(a, b, c); // 1, 2, [event object] + | }); + > Arguments + - event (string) event name + - varargs (…) and any other arguments + = (function) possible event handler function + \*/ + eve.f = function (event) { + var attrs = [].slice.call(arguments, 1); + return function () { + eve.apply(null, [event, null].concat(attrs).concat([].slice.call(arguments, 0))); + }; + }; + /*\ + * eve.stop + [ method ] + ** + * Is used inside an event handler to stop the event, preventing any subsequent listeners from firing. + \*/ + eve.stop = function () { + stop = 1; + }; + /*\ + * eve.nt + [ method ] + ** + * Could be used inside event handler to figure out actual name of the event. + ** + > Arguments + ** + - subname (string) #optional subname of the event + ** + = (string) name of the event, if `subname` is not specified + * or + = (boolean) `true`, if current event’s name contains `subname` + \*/ + eve.nt = function (subname) { + if (subname) { + return new RegExp("(?:\\.|\\/|^)" + subname + "(?:\\.|\\/|$)").test(current_event); + } + return current_event; + }; + /*\ + * eve.nts + [ method ] + ** + * Could be used inside event handler to figure out actual name of the event. + ** + ** + = (array) names of the event + \*/ + eve.nts = function () { + return current_event.split(separator); + }; + /*\ + * eve.off + [ method ] + ** + * Removes given function from the list of event listeners assigned to given name. + * If no arguments specified all the events will be cleared. + ** + > Arguments + ** + - name (string) name of the event, dot (`.`) or slash (`/`) separated, with optional wildcards + - f (function) event handler function + \*/ + /*\ + * eve.unbind + [ method ] + ** + * See @eve.off + \*/ + eve.off = eve.unbind = function (name, f) { + if (!name) { + eve._events = events = {n: {}}; + return; + } + var names = name.split(separator), + e, + key, + splice, + i, ii, j, jj, + cur = [events]; + for (i = 0, ii = names.length; i < ii; i++) { + for (j = 0; j < cur.length; j += splice.length - 2) { + splice = [j, 1]; + e = cur[j].n; + if (names[i] != wildcard) { + if (e[names[i]]) { + splice.push(e[names[i]]); + } + } else { + for (key in e) if (e[has](key)) { + splice.push(e[key]); + } + } + cur.splice.apply(cur, splice); + } + } + for (i = 0, ii = cur.length; i < ii; i++) { + e = cur[i]; + while (e.n) { + if (f) { + if (e.f) { + for (j = 0, jj = e.f.length; j < jj; j++) if (e.f[j] == f) { + e.f.splice(j, 1); + break; + } + !e.f.length && delete e.f; + } + for (key in e.n) if (e.n[has](key) && e.n[key].f) { + var funcs = e.n[key].f; + for (j = 0, jj = funcs.length; j < jj; j++) if (funcs[j] == f) { + funcs.splice(j, 1); + break; + } + !funcs.length && delete e.n[key].f; + } + } else { + delete e.f; + for (key in e.n) if (e.n[has](key) && e.n[key].f) { + delete e.n[key].f; + } + } + e = e.n; + } + } + }; + /*\ + * eve.once + [ method ] + ** + * Binds given event handler with a given name to only run once then unbind itself. + | eve.once("login", f); + | eve("login"); // triggers f + | eve("login"); // no listeners + * Use @eve to trigger the listener. + ** + > Arguments + ** + - name (string) name of the event, dot (`.`) or slash (`/`) separated, with optional wildcards + - f (function) event handler function + ** + = (function) same return function as @eve.on + \*/ + eve.once = function (name, f) { + var f2 = function () { + eve.unbind(name, f2); + return f.apply(this, arguments); + }; + return eve.on(name, f2); + }; + /*\ + * eve.version + [ property (string) ] + ** + * Current version of the library. + \*/ + eve.version = version; + eve.toString = function () { + return "You are running Eve " + version; + }; + (typeof module != "undefined" && module.exports) ? (module.exports = eve) : (typeof define != "undefined" ? (define("eve", [], function() { return eve; })) : (glob.eve = eve)); +})(window || this); +// ┌─────────────────────────────────────────────────────────────────────┐ \\ +// │ "Raphaël 2.1.2" - JavaScript Vector Library │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ Copyright (c) 2008-2011 Dmitry Baranovskiy (http://raphaeljs.com) │ \\ +// │ Copyright (c) 2008-2011 Sencha Labs (http://sencha.com) │ \\ +// │ Licensed under the MIT (http://raphaeljs.com/license.html) license. │ \\ +// └─────────────────────────────────────────────────────────────────────┘ \\ + +(function (glob, factory) { + // AMD support + if (typeof define === "function" && define.amd) { + // Define as an anonymous module + define(["eve"], function( eve ) { + return factory(glob, eve); + }); + } else { + // Browser globals (glob is window) + // Raphael adds itself to window + factory(glob, glob.eve || (typeof require == "function" && require('eve')) ); + } +}(this, function (window, eve) { + /*\ + * Raphael + [ method ] + ** + * Creates a canvas object on which to draw. + * You must do this first, as all future calls to drawing methods + * from this instance will be bound to this canvas. + > Parameters + ** + - container (HTMLElement|string) DOM element or its ID which is going to be a parent for drawing surface + - width (number) + - height (number) + - callback (function) #optional callback function which is going to be executed in the context of newly created paper + * or + - x (number) + - y (number) + - width (number) + - height (number) + - callback (function) #optional callback function which is going to be executed in the context of newly created paper + * or + - all (array) (first 3 or 4 elements in the array are equal to [containerID, width, height] or [x, y, width, height]. The rest are element descriptions in format {type: type, }). See @Paper.add. + - callback (function) #optional callback function which is going to be executed in the context of newly created paper + * or + - onReadyCallback (function) function that is going to be called on DOM ready event. You can also subscribe to this event via Eve’s “DOMLoad” event. In this case method returns `undefined`. + = (object) @Paper + > Usage + | // Each of the following examples create a canvas + | // that is 320px wide by 200px high. + | // Canvas is created at the viewport’s 10,50 coordinate. + | var paper = Raphael(10, 50, 320, 200); + | // Canvas is created at the top left corner of the #notepad element + | // (or its top right corner in dir="rtl" elements) + | var paper = Raphael(document.getElementById("notepad"), 320, 200); + | // Same as above + | var paper = Raphael("notepad", 320, 200); + | // Image dump + | var set = Raphael(["notepad", 320, 200, { + | type: "rect", + | x: 10, + | y: 10, + | width: 25, + | height: 25, + | stroke: "#f00" + | }, { + | type: "text", + | x: 30, + | y: 40, + | text: "Dump" + | }]); + \*/ + function R(first) { + if (R.is(first, "function")) { + return loaded ? first() : eve.on("raphael.DOMload", first); + } else if (R.is(first, array)) { + return R._engine.create[apply](R, first.splice(0, 3 + R.is(first[0], nu))).add(first); + } else { + var args = Array.prototype.slice.call(arguments, 0); + if (R.is(args[args.length - 1], "function")) { + var f = args.pop(); + return loaded ? f.call(R._engine.create[apply](R, args)) : eve.on("raphael.DOMload", function () { + f.call(R._engine.create[apply](R, args)); + }); + } else { + return R._engine.create[apply](R, arguments); + } + } + } + R.version = "2.1.2"; + R.eve = eve; + var loaded, + separator = /[, ]+/, + elements = {circle: 1, rect: 1, path: 1, ellipse: 1, text: 1, image: 1}, + formatrg = /\{(\d+)\}/g, + proto = "prototype", + has = "hasOwnProperty", + g = { + doc: document, + win: window + }, + oldRaphael = { + was: Object.prototype[has].call(g.win, "Raphael"), + is: g.win.Raphael + }, + Paper = function () { + /*\ + * Paper.ca + [ property (object) ] + ** + * Shortcut for @Paper.customAttributes + \*/ + /*\ + * Paper.customAttributes + [ property (object) ] + ** + * If you have a set of attributes that you would like to represent + * as a function of some number you can do it easily with custom attributes: + > Usage + | paper.customAttributes.hue = function (num) { + | num = num % 1; + | return {fill: "hsb(" + num + ", 0.75, 1)"}; + | }; + | // Custom attribute “hue” will change fill + | // to be given hue with fixed saturation and brightness. + | // Now you can use it like this: + | var c = paper.circle(10, 10, 10).attr({hue: .45}); + | // or even like this: + | c.animate({hue: 1}, 1e3); + | + | // You could also create custom attribute + | // with multiple parameters: + | paper.customAttributes.hsb = function (h, s, b) { + | return {fill: "hsb(" + [h, s, b].join(",") + ")"}; + | }; + | c.attr({hsb: "0.5 .8 1"}); + | c.animate({hsb: [1, 0, 0.5]}, 1e3); + \*/ + this.ca = this.customAttributes = {}; + }, + paperproto, + appendChild = "appendChild", + apply = "apply", + concat = "concat", + supportsTouch = ('ontouchstart' in g.win) || g.win.DocumentTouch && g.doc instanceof DocumentTouch, //taken from Modernizr touch test + E = "", + S = " ", + Str = String, + split = "split", + events = "click dblclick mousedown mousemove mouseout mouseover mouseup touchstart touchmove touchend touchcancel"[split](S), + touchMap = { + mousedown: "touchstart", + mousemove: "touchmove", + mouseup: "touchend" + }, + lowerCase = Str.prototype.toLowerCase, + math = Math, + mmax = math.max, + mmin = math.min, + abs = math.abs, + pow = math.pow, + PI = math.PI, + nu = "number", + string = "string", + array = "array", + toString = "toString", + fillString = "fill", + objectToString = Object.prototype.toString, + paper = {}, + push = "push", + ISURL = R._ISURL = /^url\(['"]?(.+?)['"]?\)$/i, + colourRegExp = /^\s*((#[a-f\d]{6})|(#[a-f\d]{3})|rgba?\(\s*([\d\.]+%?\s*,\s*[\d\.]+%?\s*,\s*[\d\.]+%?(?:\s*,\s*[\d\.]+%?)?)\s*\)|hsba?\(\s*([\d\.]+(?:deg|\xb0|%)?\s*,\s*[\d\.]+%?\s*,\s*[\d\.]+(?:%?\s*,\s*[\d\.]+)?)%?\s*\)|hsla?\(\s*([\d\.]+(?:deg|\xb0|%)?\s*,\s*[\d\.]+%?\s*,\s*[\d\.]+(?:%?\s*,\s*[\d\.]+)?)%?\s*\))\s*$/i, + isnan = {"NaN": 1, "Infinity": 1, "-Infinity": 1}, + bezierrg = /^(?:cubic-)?bezier\(([^,]+),([^,]+),([^,]+),([^\)]+)\)/, + round = math.round, + setAttribute = "setAttribute", + toFloat = parseFloat, + toInt = parseInt, + upperCase = Str.prototype.toUpperCase, + availableAttrs = R._availableAttrs = { + "arrow-end": "none", + "arrow-start": "none", + blur: 0, + "clip-rect": "0 0 1e9 1e9", + cursor: "default", + cx: 0, + cy: 0, + fill: "#fff", + "fill-opacity": 1, + font: '10px "Arial"', + "font-family": '"Arial"', + "font-size": "10", + "font-style": "normal", + "font-weight": 400, + gradient: 0, + height: 0, + href: "http://raphaeljs.com/", + "letter-spacing": 0, + opacity: 1, + path: "M0,0", + r: 0, + rx: 0, + ry: 0, + src: "", + stroke: "#000", + "stroke-dasharray": "", + "stroke-linecap": "butt", + "stroke-linejoin": "butt", + "stroke-miterlimit": 0, + "stroke-opacity": 1, + "stroke-width": 1, + target: "_blank", + "text-anchor": "middle", + title: "Raphael", + transform: "", + width: 0, + x: 0, + y: 0 + }, + availableAnimAttrs = R._availableAnimAttrs = { + blur: nu, + "clip-rect": "csv", + cx: nu, + cy: nu, + fill: "colour", + "fill-opacity": nu, + "font-size": nu, + height: nu, + opacity: nu, + path: "path", + r: nu, + rx: nu, + ry: nu, + stroke: "colour", + "stroke-opacity": nu, + "stroke-width": nu, + transform: "transform", + width: nu, + x: nu, + y: nu + }, + whitespace = /[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]/g, + commaSpaces = /[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*/, + hsrg = {hs: 1, rg: 1}, + p2s = /,?([achlmqrstvxz]),?/gi, + pathCommand = /([achlmrqstvz])[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029,]*((-?\d*\.?\d*(?:e[\-+]?\d+)?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*)+)/ig, + tCommand = /([rstm])[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029,]*((-?\d*\.?\d*(?:e[\-+]?\d+)?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*)+)/ig, + pathValues = /(-?\d*\.?\d*(?:e[\-+]?\d+)?)[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,?[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*/ig, + radial_gradient = R._radial_gradient = /^r(?:\(([^,]+?)[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*,[\x09\x0a\x0b\x0c\x0d\x20\xa0\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000\u2028\u2029]*([^\)]+?)\))?/, + eldata = {}, + sortByKey = function (a, b) { + return a.key - b.key; + }, + sortByNumber = function (a, b) { + return toFloat(a) - toFloat(b); + }, + fun = function () {}, + pipe = function (x) { + return x; + }, + rectPath = R._rectPath = function (x, y, w, h, r) { + if (r) { + return [["M", x + r, y], ["l", w - r * 2, 0], ["a", r, r, 0, 0, 1, r, r], ["l", 0, h - r * 2], ["a", r, r, 0, 0, 1, -r, r], ["l", r * 2 - w, 0], ["a", r, r, 0, 0, 1, -r, -r], ["l", 0, r * 2 - h], ["a", r, r, 0, 0, 1, r, -r], ["z"]]; + } + return [["M", x, y], ["l", w, 0], ["l", 0, h], ["l", -w, 0], ["z"]]; + }, + ellipsePath = function (x, y, rx, ry) { + if (ry == null) { + ry = rx; + } + return [["M", x, y], ["m", 0, -ry], ["a", rx, ry, 0, 1, 1, 0, 2 * ry], ["a", rx, ry, 0, 1, 1, 0, -2 * ry], ["z"]]; + }, + getPath = R._getPath = { + path: function (el) { + return el.attr("path"); + }, + circle: function (el) { + var a = el.attrs; + return ellipsePath(a.cx, a.cy, a.r); + }, + ellipse: function (el) { + var a = el.attrs; + return ellipsePath(a.cx, a.cy, a.rx, a.ry); + }, + rect: function (el) { + var a = el.attrs; + return rectPath(a.x, a.y, a.width, a.height, a.r); + }, + image: function (el) { + var a = el.attrs; + return rectPath(a.x, a.y, a.width, a.height); + }, + text: function (el) { + var bbox = el._getBBox(); + return rectPath(bbox.x, bbox.y, bbox.width, bbox.height); + }, + set : function(el) { + var bbox = el._getBBox(); + return rectPath(bbox.x, bbox.y, bbox.width, bbox.height); + } + }, + /*\ + * Raphael.mapPath + [ method ] + ** + * Transform the path string with given matrix. + > Parameters + - path (string) path string + - matrix (object) see @Matrix + = (string) transformed path string + \*/ + mapPath = R.mapPath = function (path, matrix) { + if (!matrix) { + return path; + } + var x, y, i, j, ii, jj, pathi; + path = path2curve(path); + for (i = 0, ii = path.length; i < ii; i++) { + pathi = path[i]; + for (j = 1, jj = pathi.length; j < jj; j += 2) { + x = matrix.x(pathi[j], pathi[j + 1]); + y = matrix.y(pathi[j], pathi[j + 1]); + pathi[j] = x; + pathi[j + 1] = y; + } + } + return path; + }; + + R._g = g; + /*\ + * Raphael.type + [ property (string) ] + ** + * Can be “SVG”, “VML” or empty, depending on browser support. + \*/ + R.type = (g.win.SVGAngle || g.doc.implementation.hasFeature("http://www.w3.org/TR/SVG11/feature#BasicStructure", "1.1") ? "SVG" : "VML"); + if (R.type == "VML") { + var d = g.doc.createElement("div"), + b; + d.innerHTML = ''; + b = d.firstChild; + b.style.behavior = "url(#default#VML)"; + if (!(b && typeof b.adj == "object")) { + return (R.type = E); + } + d = null; + } + /*\ + * Raphael.svg + [ property (boolean) ] + ** + * `true` if browser supports SVG. + \*/ + /*\ + * Raphael.vml + [ property (boolean) ] + ** + * `true` if browser supports VML. + \*/ + R.svg = !(R.vml = R.type == "VML"); + R._Paper = Paper; + /*\ + * Raphael.fn + [ property (object) ] + ** + * You can add your own method to the canvas. For example if you want to draw a pie chart, + * you can create your own pie chart function and ship it as a Raphaël plugin. To do this + * you need to extend the `Raphael.fn` object. You should modify the `fn` object before a + * Raphaël instance is created, otherwise it will take no effect. Please note that the + * ability for namespaced plugins was removed in Raphael 2.0. It is up to the plugin to + * ensure any namespacing ensures proper context. + > Usage + | Raphael.fn.arrow = function (x1, y1, x2, y2, size) { + | return this.path( ... ); + | }; + | // or create namespace + | Raphael.fn.mystuff = { + | arrow: function () {…}, + | star: function () {…}, + | // etc… + | }; + | var paper = Raphael(10, 10, 630, 480); + | // then use it + | paper.arrow(10, 10, 30, 30, 5).attr({fill: "#f00"}); + | paper.mystuff.arrow(); + | paper.mystuff.star(); + \*/ + R.fn = paperproto = Paper.prototype = R.prototype; + R._id = 0; + R._oid = 0; + /*\ + * Raphael.is + [ method ] + ** + * Handful of replacements for `typeof` operator. + > Parameters + - o (…) any object or primitive + - type (string) name of the type, i.e. “string”, “function”, “number”, etc. + = (boolean) is given value is of given type + \*/ + R.is = function (o, type) { + type = lowerCase.call(type); + if (type == "finite") { + return !isnan[has](+o); + } + if (type == "array") { + return o instanceof Array; + } + return (type == "null" && o === null) || + (type == typeof o && o !== null) || + (type == "object" && o === Object(o)) || + (type == "array" && Array.isArray && Array.isArray(o)) || + objectToString.call(o).slice(8, -1).toLowerCase() == type; + }; + + function clone(obj) { + if (typeof obj == "function" || Object(obj) !== obj) { + return obj; + } + var res = new obj.constructor; + for (var key in obj) if (obj[has](key)) { + res[key] = clone(obj[key]); + } + return res; + } + + /*\ + * Raphael.angle + [ method ] + ** + * Returns angle between two or three points + > Parameters + - x1 (number) x coord of first point + - y1 (number) y coord of first point + - x2 (number) x coord of second point + - y2 (number) y coord of second point + - x3 (number) #optional x coord of third point + - y3 (number) #optional y coord of third point + = (number) angle in degrees. + \*/ + R.angle = function (x1, y1, x2, y2, x3, y3) { + if (x3 == null) { + var x = x1 - x2, + y = y1 - y2; + if (!x && !y) { + return 0; + } + return (180 + math.atan2(-y, -x) * 180 / PI + 360) % 360; + } else { + return R.angle(x1, y1, x3, y3) - R.angle(x2, y2, x3, y3); + } + }; + /*\ + * Raphael.rad + [ method ] + ** + * Transform angle to radians + > Parameters + - deg (number) angle in degrees + = (number) angle in radians. + \*/ + R.rad = function (deg) { + return deg % 360 * PI / 180; + }; + /*\ + * Raphael.deg + [ method ] + ** + * Transform angle to degrees + > Parameters + - rad (number) angle in radians + = (number) angle in degrees. + \*/ + R.deg = function (rad) { + return Math.round ((rad * 180 / PI% 360)* 1000) / 1000; + }; + /*\ + * Raphael.snapTo + [ method ] + ** + * Snaps given value to given grid. + > Parameters + - values (array|number) given array of values or step of the grid + - value (number) value to adjust + - tolerance (number) #optional tolerance for snapping. Default is `10`. + = (number) adjusted value. + \*/ + R.snapTo = function (values, value, tolerance) { + tolerance = R.is(tolerance, "finite") ? tolerance : 10; + if (R.is(values, array)) { + var i = values.length; + while (i--) if (abs(values[i] - value) <= tolerance) { + return values[i]; + } + } else { + values = +values; + var rem = value % values; + if (rem < tolerance) { + return value - rem; + } + if (rem > values - tolerance) { + return value - rem + values; + } + } + return value; + }; + + /*\ + * Raphael.createUUID + [ method ] + ** + * Returns RFC4122, version 4 ID + \*/ + var createUUID = R.createUUID = (function (uuidRegEx, uuidReplacer) { + return function () { + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(uuidRegEx, uuidReplacer).toUpperCase(); + }; + })(/[xy]/g, function (c) { + var r = math.random() * 16 | 0, + v = c == "x" ? r : (r & 3 | 8); + return v.toString(16); + }); + + /*\ + * Raphael.setWindow + [ method ] + ** + * Used when you need to draw in `<iframe>`. Switched window to the iframe one. + > Parameters + - newwin (window) new window object + \*/ + R.setWindow = function (newwin) { + eve("raphael.setWindow", R, g.win, newwin); + g.win = newwin; + g.doc = g.win.document; + if (R._engine.initWin) { + R._engine.initWin(g.win); + } + }; + var toHex = function (color) { + if (R.vml) { + // http://dean.edwards.name/weblog/2009/10/convert-any-colour-value-to-hex-in-msie/ + var trim = /^\s+|\s+$/g; + var bod; + try { + var docum = new ActiveXObject("htmlfile"); + docum.write(""); + docum.close(); + bod = docum.body; + } catch(e) { + bod = createPopup().document.body; + } + var range = bod.createTextRange(); + toHex = cacher(function (color) { + try { + bod.style.color = Str(color).replace(trim, E); + var value = range.queryCommandValue("ForeColor"); + value = ((value & 255) << 16) | (value & 65280) | ((value & 16711680) >>> 16); + return "#" + ("000000" + value.toString(16)).slice(-6); + } catch(e) { + return "none"; + } + }); + } else { + var i = g.doc.createElement("i"); + i.title = "Rapha\xebl Colour Picker"; + i.style.display = "none"; + g.doc.body.appendChild(i); + toHex = cacher(function (color) { + i.style.color = color; + return g.doc.defaultView.getComputedStyle(i, E).getPropertyValue("color"); + }); + } + return toHex(color); + }, + hsbtoString = function () { + return "hsb(" + [this.h, this.s, this.b] + ")"; + }, + hsltoString = function () { + return "hsl(" + [this.h, this.s, this.l] + ")"; + }, + rgbtoString = function () { + return this.hex; + }, + prepareRGB = function (r, g, b) { + if (g == null && R.is(r, "object") && "r" in r && "g" in r && "b" in r) { + b = r.b; + g = r.g; + r = r.r; + } + if (g == null && R.is(r, string)) { + var clr = R.getRGB(r); + r = clr.r; + g = clr.g; + b = clr.b; + } + if (r > 1 || g > 1 || b > 1) { + r /= 255; + g /= 255; + b /= 255; + } + + return [r, g, b]; + }, + packageRGB = function (r, g, b, o) { + r *= 255; + g *= 255; + b *= 255; + var rgb = { + r: r, + g: g, + b: b, + hex: R.rgb(r, g, b), + toString: rgbtoString + }; + R.is(o, "finite") && (rgb.opacity = o); + return rgb; + }; + + /*\ + * Raphael.color + [ method ] + ** + * Parses the color string and returns object with all values for the given color. + > Parameters + - clr (string) color string in one of the supported formats (see @Raphael.getRGB) + = (object) Combined RGB & HSB object in format: + o { + o r (number) red, + o g (number) green, + o b (number) blue, + o hex (string) color in HTML/CSS format: #••••••, + o error (boolean) `true` if string can’t be parsed, + o h (number) hue, + o s (number) saturation, + o v (number) value (brightness), + o l (number) lightness + o } + \*/ + R.color = function (clr) { + var rgb; + if (R.is(clr, "object") && "h" in clr && "s" in clr && "b" in clr) { + rgb = R.hsb2rgb(clr); + clr.r = rgb.r; + clr.g = rgb.g; + clr.b = rgb.b; + clr.hex = rgb.hex; + } else if (R.is(clr, "object") && "h" in clr && "s" in clr && "l" in clr) { + rgb = R.hsl2rgb(clr); + clr.r = rgb.r; + clr.g = rgb.g; + clr.b = rgb.b; + clr.hex = rgb.hex; + } else { + if (R.is(clr, "string")) { + clr = R.getRGB(clr); + } + if (R.is(clr, "object") && "r" in clr && "g" in clr && "b" in clr) { + rgb = R.rgb2hsl(clr); + clr.h = rgb.h; + clr.s = rgb.s; + clr.l = rgb.l; + rgb = R.rgb2hsb(clr); + clr.v = rgb.b; + } else { + clr = {hex: "none"}; + clr.r = clr.g = clr.b = clr.h = clr.s = clr.v = clr.l = -1; + } + } + clr.toString = rgbtoString; + return clr; + }; + /*\ + * Raphael.hsb2rgb + [ method ] + ** + * Converts HSB values to RGB object. + > Parameters + - h (number) hue + - s (number) saturation + - v (number) value or brightness + = (object) RGB object in format: + o { + o r (number) red, + o g (number) green, + o b (number) blue, + o hex (string) color in HTML/CSS format: #•••••• + o } + \*/ + R.hsb2rgb = function (h, s, v, o) { + if (this.is(h, "object") && "h" in h && "s" in h && "b" in h) { + v = h.b; + s = h.s; + o = h.o; + h = h.h; + } + h *= 360; + var R, G, B, X, C; + h = (h % 360) / 60; + C = v * s; + X = C * (1 - abs(h % 2 - 1)); + R = G = B = v - C; + + h = ~~h; + R += [C, X, 0, 0, X, C][h]; + G += [X, C, C, X, 0, 0][h]; + B += [0, 0, X, C, C, X][h]; + return packageRGB(R, G, B, o); + }; + /*\ + * Raphael.hsl2rgb + [ method ] + ** + * Converts HSL values to RGB object. + > Parameters + - h (number) hue + - s (number) saturation + - l (number) luminosity + = (object) RGB object in format: + o { + o r (number) red, + o g (number) green, + o b (number) blue, + o hex (string) color in HTML/CSS format: #•••••• + o } + \*/ + R.hsl2rgb = function (h, s, l, o) { + if (this.is(h, "object") && "h" in h && "s" in h && "l" in h) { + l = h.l; + s = h.s; + h = h.h; + } + if (h > 1 || s > 1 || l > 1) { + h /= 360; + s /= 100; + l /= 100; + } + h *= 360; + var R, G, B, X, C; + h = (h % 360) / 60; + C = 2 * s * (l < .5 ? l : 1 - l); + X = C * (1 - abs(h % 2 - 1)); + R = G = B = l - C / 2; + + h = ~~h; + R += [C, X, 0, 0, X, C][h]; + G += [X, C, C, X, 0, 0][h]; + B += [0, 0, X, C, C, X][h]; + return packageRGB(R, G, B, o); + }; + /*\ + * Raphael.rgb2hsb + [ method ] + ** + * Converts RGB values to HSB object. + > Parameters + - r (number) red + - g (number) green + - b (number) blue + = (object) HSB object in format: + o { + o h (number) hue + o s (number) saturation + o b (number) brightness + o } + \*/ + R.rgb2hsb = function (r, g, b) { + b = prepareRGB(r, g, b); + r = b[0]; + g = b[1]; + b = b[2]; + + var H, S, V, C; + V = mmax(r, g, b); + C = V - mmin(r, g, b); + H = (C == 0 ? null : + V == r ? (g - b) / C : + V == g ? (b - r) / C + 2 : + (r - g) / C + 4 + ); + H = ((H + 360) % 6) * 60 / 360; + S = C == 0 ? 0 : C / V; + return {h: H, s: S, b: V, toString: hsbtoString}; + }; + /*\ + * Raphael.rgb2hsl + [ method ] + ** + * Converts RGB values to HSL object. + > Parameters + - r (number) red + - g (number) green + - b (number) blue + = (object) HSL object in format: + o { + o h (number) hue + o s (number) saturation + o l (number) luminosity + o } + \*/ + R.rgb2hsl = function (r, g, b) { + b = prepareRGB(r, g, b); + r = b[0]; + g = b[1]; + b = b[2]; + + var H, S, L, M, m, C; + M = mmax(r, g, b); + m = mmin(r, g, b); + C = M - m; + H = (C == 0 ? null : + M == r ? (g - b) / C : + M == g ? (b - r) / C + 2 : + (r - g) / C + 4); + H = ((H + 360) % 6) * 60 / 360; + L = (M + m) / 2; + S = (C == 0 ? 0 : + L < .5 ? C / (2 * L) : + C / (2 - 2 * L)); + return {h: H, s: S, l: L, toString: hsltoString}; + }; + R._path2string = function () { + return this.join(",").replace(p2s, "$1"); + }; + function repush(array, item) { + for (var i = 0, ii = array.length; i < ii; i++) if (array[i] === item) { + return array.push(array.splice(i, 1)[0]); + } + } + function cacher(f, scope, postprocessor) { + function newf() { + var arg = Array.prototype.slice.call(arguments, 0), + args = arg.join("\u2400"), + cache = newf.cache = newf.cache || {}, + count = newf.count = newf.count || []; + if (cache[has](args)) { + repush(count, args); + return postprocessor ? postprocessor(cache[args]) : cache[args]; + } + count.length >= 1e3 && delete cache[count.shift()]; + count.push(args); + cache[args] = f[apply](scope, arg); + return postprocessor ? postprocessor(cache[args]) : cache[args]; + } + return newf; + } + + var preload = R._preload = function (src, f) { + var img = g.doc.createElement("img"); + img.style.cssText = "position:absolute;left:-9999em;top:-9999em"; + img.onload = function () { + f.call(this); + this.onload = null; + g.doc.body.removeChild(this); + }; + img.onerror = function () { + g.doc.body.removeChild(this); + }; + g.doc.body.appendChild(img); + img.src = src; + }; + + function clrToString() { + return this.hex; + } + + /*\ + * Raphael.getRGB + [ method ] + ** + * Parses colour string as RGB object + > Parameters + - colour (string) colour string in one of formats: + #
    + #
  • Colour name (“red”, “green”, “cornflowerblue”, etc)
  • + #
  • #••• — shortened HTML colour: (“#000”, “#fc0”, etc)
  • + #
  • #•••••• — full length HTML colour: (“#000000”, “#bd2300”)
  • + #
  • rgb(•••, •••, •••) — red, green and blue channels’ values: (“rgb(200, 100, 0)”)
  • + #
  • rgb(•••%, •••%, •••%) — same as above, but in %: (“rgb(100%, 175%, 0%)”)
  • + #
  • hsb(•••, •••, •••) — hue, saturation and brightness values: (“hsb(0.5, 0.25, 1)”)
  • + #
  • hsb(•••%, •••%, •••%) — same as above, but in %
  • + #
  • hsl(•••, •••, •••) — same as hsb
  • + #
  • hsl(•••%, •••%, •••%) — same as hsb
  • + #
+ = (object) RGB object in format: + o { + o r (number) red, + o g (number) green, + o b (number) blue + o hex (string) color in HTML/CSS format: #••••••, + o error (boolean) true if string can’t be parsed + o } + \*/ + R.getRGB = cacher(function (colour) { + if (!colour || !!((colour = Str(colour)).indexOf("-") + 1)) { + return {r: -1, g: -1, b: -1, hex: "none", error: 1, toString: clrToString}; + } + if (colour == "none") { + return {r: -1, g: -1, b: -1, hex: "none", toString: clrToString}; + } + !(hsrg[has](colour.toLowerCase().substring(0, 2)) || colour.charAt() == "#") && (colour = toHex(colour)); + var res, + red, + green, + blue, + opacity, + t, + values, + rgb = colour.match(colourRegExp); + if (rgb) { + if (rgb[2]) { + blue = toInt(rgb[2].substring(5), 16); + green = toInt(rgb[2].substring(3, 5), 16); + red = toInt(rgb[2].substring(1, 3), 16); + } + if (rgb[3]) { + blue = toInt((t = rgb[3].charAt(3)) + t, 16); + green = toInt((t = rgb[3].charAt(2)) + t, 16); + red = toInt((t = rgb[3].charAt(1)) + t, 16); + } + if (rgb[4]) { + values = rgb[4][split](commaSpaces); + red = toFloat(values[0]); + values[0].slice(-1) == "%" && (red *= 2.55); + green = toFloat(values[1]); + values[1].slice(-1) == "%" && (green *= 2.55); + blue = toFloat(values[2]); + values[2].slice(-1) == "%" && (blue *= 2.55); + rgb[1].toLowerCase().slice(0, 4) == "rgba" && (opacity = toFloat(values[3])); + values[3] && values[3].slice(-1) == "%" && (opacity /= 100); + } + if (rgb[5]) { + values = rgb[5][split](commaSpaces); + red = toFloat(values[0]); + values[0].slice(-1) == "%" && (red *= 2.55); + green = toFloat(values[1]); + values[1].slice(-1) == "%" && (green *= 2.55); + blue = toFloat(values[2]); + values[2].slice(-1) == "%" && (blue *= 2.55); + (values[0].slice(-3) == "deg" || values[0].slice(-1) == "\xb0") && (red /= 360); + rgb[1].toLowerCase().slice(0, 4) == "hsba" && (opacity = toFloat(values[3])); + values[3] && values[3].slice(-1) == "%" && (opacity /= 100); + return R.hsb2rgb(red, green, blue, opacity); + } + if (rgb[6]) { + values = rgb[6][split](commaSpaces); + red = toFloat(values[0]); + values[0].slice(-1) == "%" && (red *= 2.55); + green = toFloat(values[1]); + values[1].slice(-1) == "%" && (green *= 2.55); + blue = toFloat(values[2]); + values[2].slice(-1) == "%" && (blue *= 2.55); + (values[0].slice(-3) == "deg" || values[0].slice(-1) == "\xb0") && (red /= 360); + rgb[1].toLowerCase().slice(0, 4) == "hsla" && (opacity = toFloat(values[3])); + values[3] && values[3].slice(-1) == "%" && (opacity /= 100); + return R.hsl2rgb(red, green, blue, opacity); + } + rgb = {r: red, g: green, b: blue, toString: clrToString}; + rgb.hex = "#" + (16777216 | blue | (green << 8) | (red << 16)).toString(16).slice(1); + R.is(opacity, "finite") && (rgb.opacity = opacity); + return rgb; + } + return {r: -1, g: -1, b: -1, hex: "none", error: 1, toString: clrToString}; + }, R); + /*\ + * Raphael.hsb + [ method ] + ** + * Converts HSB values to hex representation of the colour. + > Parameters + - h (number) hue + - s (number) saturation + - b (number) value or brightness + = (string) hex representation of the colour. + \*/ + R.hsb = cacher(function (h, s, b) { + return R.hsb2rgb(h, s, b).hex; + }); + /*\ + * Raphael.hsl + [ method ] + ** + * Converts HSL values to hex representation of the colour. + > Parameters + - h (number) hue + - s (number) saturation + - l (number) luminosity + = (string) hex representation of the colour. + \*/ + R.hsl = cacher(function (h, s, l) { + return R.hsl2rgb(h, s, l).hex; + }); + /*\ + * Raphael.rgb + [ method ] + ** + * Converts RGB values to hex representation of the colour. + > Parameters + - r (number) red + - g (number) green + - b (number) blue + = (string) hex representation of the colour. + \*/ + R.rgb = cacher(function (r, g, b) { + return "#" + (16777216 | b | (g << 8) | (r << 16)).toString(16).slice(1); + }); + /*\ + * Raphael.getColor + [ method ] + ** + * On each call returns next colour in the spectrum. To reset it back to red call @Raphael.getColor.reset + > Parameters + - value (number) #optional brightness, default is `0.75` + = (string) hex representation of the colour. + \*/ + R.getColor = function (value) { + var start = this.getColor.start = this.getColor.start || {h: 0, s: 1, b: value || .75}, + rgb = this.hsb2rgb(start.h, start.s, start.b); + start.h += .075; + if (start.h > 1) { + start.h = 0; + start.s -= .2; + start.s <= 0 && (this.getColor.start = {h: 0, s: 1, b: start.b}); + } + return rgb.hex; + }; + /*\ + * Raphael.getColor.reset + [ method ] + ** + * Resets spectrum position for @Raphael.getColor back to red. + \*/ + R.getColor.reset = function () { + delete this.start; + }; + + // http://schepers.cc/getting-to-the-point + function catmullRom2bezier(crp, z) { + var d = []; + for (var i = 0, iLen = crp.length; iLen - 2 * !z > i; i += 2) { + var p = [ + {x: +crp[i - 2], y: +crp[i - 1]}, + {x: +crp[i], y: +crp[i + 1]}, + {x: +crp[i + 2], y: +crp[i + 3]}, + {x: +crp[i + 4], y: +crp[i + 5]} + ]; + if (z) { + if (!i) { + p[0] = {x: +crp[iLen - 2], y: +crp[iLen - 1]}; + } else if (iLen - 4 == i) { + p[3] = {x: +crp[0], y: +crp[1]}; + } else if (iLen - 2 == i) { + p[2] = {x: +crp[0], y: +crp[1]}; + p[3] = {x: +crp[2], y: +crp[3]}; + } + } else { + if (iLen - 4 == i) { + p[3] = p[2]; + } else if (!i) { + p[0] = {x: +crp[i], y: +crp[i + 1]}; + } + } + d.push(["C", + (-p[0].x + 6 * p[1].x + p[2].x) / 6, + (-p[0].y + 6 * p[1].y + p[2].y) / 6, + (p[1].x + 6 * p[2].x - p[3].x) / 6, + (p[1].y + 6*p[2].y - p[3].y) / 6, + p[2].x, + p[2].y + ]); + } + + return d; + } + /*\ + * Raphael.parsePathString + [ method ] + ** + * Utility method + ** + * Parses given path string into an array of arrays of path segments. + > Parameters + - pathString (string|array) path string or array of segments (in the last case it will be returned straight away) + = (array) array of segments. + \*/ + R.parsePathString = function (pathString) { + if (!pathString) { + return null; + } + var pth = paths(pathString); + if (pth.arr) { + return pathClone(pth.arr); + } + + var paramCounts = {a: 7, c: 6, h: 1, l: 2, m: 2, r: 4, q: 4, s: 4, t: 2, v: 1, z: 0}, + data = []; + if (R.is(pathString, array) && R.is(pathString[0], array)) { // rough assumption + data = pathClone(pathString); + } + if (!data.length) { + Str(pathString).replace(pathCommand, function (a, b, c) { + var params = [], + name = b.toLowerCase(); + c.replace(pathValues, function (a, b) { + b && params.push(+b); + }); + if (name == "m" && params.length > 2) { + data.push([b][concat](params.splice(0, 2))); + name = "l"; + b = b == "m" ? "l" : "L"; + } + if (name == "r") { + data.push([b][concat](params)); + } else while (params.length >= paramCounts[name]) { + data.push([b][concat](params.splice(0, paramCounts[name]))); + if (!paramCounts[name]) { + break; + } + } + }); + } + data.toString = R._path2string; + pth.arr = pathClone(data); + return data; + }; + /*\ + * Raphael.parseTransformString + [ method ] + ** + * Utility method + ** + * Parses given path string into an array of transformations. + > Parameters + - TString (string|array) transform string or array of transformations (in the last case it will be returned straight away) + = (array) array of transformations. + \*/ + R.parseTransformString = cacher(function (TString) { + if (!TString) { + return null; + } + var paramCounts = {r: 3, s: 4, t: 2, m: 6}, + data = []; + if (R.is(TString, array) && R.is(TString[0], array)) { // rough assumption + data = pathClone(TString); + } + if (!data.length) { + Str(TString).replace(tCommand, function (a, b, c) { + var params = [], + name = lowerCase.call(b); + c.replace(pathValues, function (a, b) { + b && params.push(+b); + }); + data.push([b][concat](params)); + }); + } + data.toString = R._path2string; + return data; + }); + // PATHS + var paths = function (ps) { + var p = paths.ps = paths.ps || {}; + if (p[ps]) { + p[ps].sleep = 100; + } else { + p[ps] = { + sleep: 100 + }; + } + setTimeout(function () { + for (var key in p) if (p[has](key) && key != ps) { + p[key].sleep--; + !p[key].sleep && delete p[key]; + } + }); + return p[ps]; + }; + /*\ + * Raphael.findDotsAtSegment + [ method ] + ** + * Utility method + ** + * Find dot coordinates on the given cubic bezier curve at the given t. + > Parameters + - p1x (number) x of the first point of the curve + - p1y (number) y of the first point of the curve + - c1x (number) x of the first anchor of the curve + - c1y (number) y of the first anchor of the curve + - c2x (number) x of the second anchor of the curve + - c2y (number) y of the second anchor of the curve + - p2x (number) x of the second point of the curve + - p2y (number) y of the second point of the curve + - t (number) position on the curve (0..1) + = (object) point information in format: + o { + o x: (number) x coordinate of the point + o y: (number) y coordinate of the point + o m: { + o x: (number) x coordinate of the left anchor + o y: (number) y coordinate of the left anchor + o } + o n: { + o x: (number) x coordinate of the right anchor + o y: (number) y coordinate of the right anchor + o } + o start: { + o x: (number) x coordinate of the start of the curve + o y: (number) y coordinate of the start of the curve + o } + o end: { + o x: (number) x coordinate of the end of the curve + o y: (number) y coordinate of the end of the curve + o } + o alpha: (number) angle of the curve derivative at the point + o } + \*/ + R.findDotsAtSegment = function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t) { + var t1 = 1 - t, + t13 = pow(t1, 3), + t12 = pow(t1, 2), + t2 = t * t, + t3 = t2 * t, + x = t13 * p1x + t12 * 3 * t * c1x + t1 * 3 * t * t * c2x + t3 * p2x, + y = t13 * p1y + t12 * 3 * t * c1y + t1 * 3 * t * t * c2y + t3 * p2y, + mx = p1x + 2 * t * (c1x - p1x) + t2 * (c2x - 2 * c1x + p1x), + my = p1y + 2 * t * (c1y - p1y) + t2 * (c2y - 2 * c1y + p1y), + nx = c1x + 2 * t * (c2x - c1x) + t2 * (p2x - 2 * c2x + c1x), + ny = c1y + 2 * t * (c2y - c1y) + t2 * (p2y - 2 * c2y + c1y), + ax = t1 * p1x + t * c1x, + ay = t1 * p1y + t * c1y, + cx = t1 * c2x + t * p2x, + cy = t1 * c2y + t * p2y, + alpha = (90 - math.atan2(mx - nx, my - ny) * 180 / PI); + (mx > nx || my < ny) && (alpha += 180); + return { + x: x, + y: y, + m: {x: mx, y: my}, + n: {x: nx, y: ny}, + start: {x: ax, y: ay}, + end: {x: cx, y: cy}, + alpha: alpha + }; + }; + /*\ + * Raphael.bezierBBox + [ method ] + ** + * Utility method + ** + * Return bounding box of a given cubic bezier curve + > Parameters + - p1x (number) x of the first point of the curve + - p1y (number) y of the first point of the curve + - c1x (number) x of the first anchor of the curve + - c1y (number) y of the first anchor of the curve + - c2x (number) x of the second anchor of the curve + - c2y (number) y of the second anchor of the curve + - p2x (number) x of the second point of the curve + - p2y (number) y of the second point of the curve + * or + - bez (array) array of six points for bezier curve + = (object) point information in format: + o { + o min: { + o x: (number) x coordinate of the left point + o y: (number) y coordinate of the top point + o } + o max: { + o x: (number) x coordinate of the right point + o y: (number) y coordinate of the bottom point + o } + o } + \*/ + R.bezierBBox = function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y) { + if (!R.is(p1x, "array")) { + p1x = [p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y]; + } + var bbox = curveDim.apply(null, p1x); + return { + x: bbox.min.x, + y: bbox.min.y, + x2: bbox.max.x, + y2: bbox.max.y, + width: bbox.max.x - bbox.min.x, + height: bbox.max.y - bbox.min.y + }; + }; + /*\ + * Raphael.isPointInsideBBox + [ method ] + ** + * Utility method + ** + * Returns `true` if given point is inside bounding boxes. + > Parameters + - bbox (string) bounding box + - x (string) x coordinate of the point + - y (string) y coordinate of the point + = (boolean) `true` if point inside + \*/ + R.isPointInsideBBox = function (bbox, x, y) { + return x >= bbox.x && x <= bbox.x2 && y >= bbox.y && y <= bbox.y2; + }; + /*\ + * Raphael.isBBoxIntersect + [ method ] + ** + * Utility method + ** + * Returns `true` if two bounding boxes intersect + > Parameters + - bbox1 (string) first bounding box + - bbox2 (string) second bounding box + = (boolean) `true` if they intersect + \*/ + R.isBBoxIntersect = function (bbox1, bbox2) { + var i = R.isPointInsideBBox; + return i(bbox2, bbox1.x, bbox1.y) + || i(bbox2, bbox1.x2, bbox1.y) + || i(bbox2, bbox1.x, bbox1.y2) + || i(bbox2, bbox1.x2, bbox1.y2) + || i(bbox1, bbox2.x, bbox2.y) + || i(bbox1, bbox2.x2, bbox2.y) + || i(bbox1, bbox2.x, bbox2.y2) + || i(bbox1, bbox2.x2, bbox2.y2) + || (bbox1.x < bbox2.x2 && bbox1.x > bbox2.x || bbox2.x < bbox1.x2 && bbox2.x > bbox1.x) + && (bbox1.y < bbox2.y2 && bbox1.y > bbox2.y || bbox2.y < bbox1.y2 && bbox2.y > bbox1.y); + }; + function base3(t, p1, p2, p3, p4) { + var t1 = -3 * p1 + 9 * p2 - 9 * p3 + 3 * p4, + t2 = t * t1 + 6 * p1 - 12 * p2 + 6 * p3; + return t * t2 - 3 * p1 + 3 * p2; + } + function bezlen(x1, y1, x2, y2, x3, y3, x4, y4, z) { + if (z == null) { + z = 1; + } + z = z > 1 ? 1 : z < 0 ? 0 : z; + var z2 = z / 2, + n = 12, + Tvalues = [-0.1252,0.1252,-0.3678,0.3678,-0.5873,0.5873,-0.7699,0.7699,-0.9041,0.9041,-0.9816,0.9816], + Cvalues = [0.2491,0.2491,0.2335,0.2335,0.2032,0.2032,0.1601,0.1601,0.1069,0.1069,0.0472,0.0472], + sum = 0; + for (var i = 0; i < n; i++) { + var ct = z2 * Tvalues[i] + z2, + xbase = base3(ct, x1, x2, x3, x4), + ybase = base3(ct, y1, y2, y3, y4), + comb = xbase * xbase + ybase * ybase; + sum += Cvalues[i] * math.sqrt(comb); + } + return z2 * sum; + } + function getTatLen(x1, y1, x2, y2, x3, y3, x4, y4, ll) { + if (ll < 0 || bezlen(x1, y1, x2, y2, x3, y3, x4, y4) < ll) { + return; + } + var t = 1, + step = t / 2, + t2 = t - step, + l, + e = .01; + l = bezlen(x1, y1, x2, y2, x3, y3, x4, y4, t2); + while (abs(l - ll) > e) { + step /= 2; + t2 += (l < ll ? 1 : -1) * step; + l = bezlen(x1, y1, x2, y2, x3, y3, x4, y4, t2); + } + return t2; + } + function intersect(x1, y1, x2, y2, x3, y3, x4, y4) { + if ( + mmax(x1, x2) < mmin(x3, x4) || + mmin(x1, x2) > mmax(x3, x4) || + mmax(y1, y2) < mmin(y3, y4) || + mmin(y1, y2) > mmax(y3, y4) + ) { + return; + } + var nx = (x1 * y2 - y1 * x2) * (x3 - x4) - (x1 - x2) * (x3 * y4 - y3 * x4), + ny = (x1 * y2 - y1 * x2) * (y3 - y4) - (y1 - y2) * (x3 * y4 - y3 * x4), + denominator = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4); + + if (!denominator) { + return; + } + var px = nx / denominator, + py = ny / denominator, + px2 = +px.toFixed(2), + py2 = +py.toFixed(2); + if ( + px2 < +mmin(x1, x2).toFixed(2) || + px2 > +mmax(x1, x2).toFixed(2) || + px2 < +mmin(x3, x4).toFixed(2) || + px2 > +mmax(x3, x4).toFixed(2) || + py2 < +mmin(y1, y2).toFixed(2) || + py2 > +mmax(y1, y2).toFixed(2) || + py2 < +mmin(y3, y4).toFixed(2) || + py2 > +mmax(y3, y4).toFixed(2) + ) { + return; + } + return {x: px, y: py}; + } + function inter(bez1, bez2) { + return interHelper(bez1, bez2); + } + function interCount(bez1, bez2) { + return interHelper(bez1, bez2, 1); + } + function interHelper(bez1, bez2, justCount) { + var bbox1 = R.bezierBBox(bez1), + bbox2 = R.bezierBBox(bez2); + if (!R.isBBoxIntersect(bbox1, bbox2)) { + return justCount ? 0 : []; + } + var l1 = bezlen.apply(0, bez1), + l2 = bezlen.apply(0, bez2), + n1 = mmax(~~(l1 / 5), 1), + n2 = mmax(~~(l2 / 5), 1), + dots1 = [], + dots2 = [], + xy = {}, + res = justCount ? 0 : []; + for (var i = 0; i < n1 + 1; i++) { + var p = R.findDotsAtSegment.apply(R, bez1.concat(i / n1)); + dots1.push({x: p.x, y: p.y, t: i / n1}); + } + for (i = 0; i < n2 + 1; i++) { + p = R.findDotsAtSegment.apply(R, bez2.concat(i / n2)); + dots2.push({x: p.x, y: p.y, t: i / n2}); + } + for (i = 0; i < n1; i++) { + for (var j = 0; j < n2; j++) { + var di = dots1[i], + di1 = dots1[i + 1], + dj = dots2[j], + dj1 = dots2[j + 1], + ci = abs(di1.x - di.x) < .001 ? "y" : "x", + cj = abs(dj1.x - dj.x) < .001 ? "y" : "x", + is = intersect(di.x, di.y, di1.x, di1.y, dj.x, dj.y, dj1.x, dj1.y); + if (is) { + if (xy[is.x.toFixed(4)] == is.y.toFixed(4)) { + continue; + } + xy[is.x.toFixed(4)] = is.y.toFixed(4); + var t1 = di.t + abs((is[ci] - di[ci]) / (di1[ci] - di[ci])) * (di1.t - di.t), + t2 = dj.t + abs((is[cj] - dj[cj]) / (dj1[cj] - dj[cj])) * (dj1.t - dj.t); + if (t1 >= 0 && t1 <= 1.001 && t2 >= 0 && t2 <= 1.001) { + if (justCount) { + res++; + } else { + res.push({ + x: is.x, + y: is.y, + t1: mmin(t1, 1), + t2: mmin(t2, 1) + }); + } + } + } + } + } + return res; + } + /*\ + * Raphael.pathIntersection + [ method ] + ** + * Utility method + ** + * Finds intersections of two paths + > Parameters + - path1 (string) path string + - path2 (string) path string + = (array) dots of intersection + o [ + o { + o x: (number) x coordinate of the point + o y: (number) y coordinate of the point + o t1: (number) t value for segment of path1 + o t2: (number) t value for segment of path2 + o segment1: (number) order number for segment of path1 + o segment2: (number) order number for segment of path2 + o bez1: (array) eight coordinates representing beziér curve for the segment of path1 + o bez2: (array) eight coordinates representing beziér curve for the segment of path2 + o } + o ] + \*/ + R.pathIntersection = function (path1, path2) { + return interPathHelper(path1, path2); + }; + R.pathIntersectionNumber = function (path1, path2) { + return interPathHelper(path1, path2, 1); + }; + function interPathHelper(path1, path2, justCount) { + path1 = R._path2curve(path1); + path2 = R._path2curve(path2); + var x1, y1, x2, y2, x1m, y1m, x2m, y2m, bez1, bez2, + res = justCount ? 0 : []; + for (var i = 0, ii = path1.length; i < ii; i++) { + var pi = path1[i]; + if (pi[0] == "M") { + x1 = x1m = pi[1]; + y1 = y1m = pi[2]; + } else { + if (pi[0] == "C") { + bez1 = [x1, y1].concat(pi.slice(1)); + x1 = bez1[6]; + y1 = bez1[7]; + } else { + bez1 = [x1, y1, x1, y1, x1m, y1m, x1m, y1m]; + x1 = x1m; + y1 = y1m; + } + for (var j = 0, jj = path2.length; j < jj; j++) { + var pj = path2[j]; + if (pj[0] == "M") { + x2 = x2m = pj[1]; + y2 = y2m = pj[2]; + } else { + if (pj[0] == "C") { + bez2 = [x2, y2].concat(pj.slice(1)); + x2 = bez2[6]; + y2 = bez2[7]; + } else { + bez2 = [x2, y2, x2, y2, x2m, y2m, x2m, y2m]; + x2 = x2m; + y2 = y2m; + } + var intr = interHelper(bez1, bez2, justCount); + if (justCount) { + res += intr; + } else { + for (var k = 0, kk = intr.length; k < kk; k++) { + intr[k].segment1 = i; + intr[k].segment2 = j; + intr[k].bez1 = bez1; + intr[k].bez2 = bez2; + } + res = res.concat(intr); + } + } + } + } + } + return res; + } + /*\ + * Raphael.isPointInsidePath + [ method ] + ** + * Utility method + ** + * Returns `true` if given point is inside a given closed path. + > Parameters + - path (string) path string + - x (number) x of the point + - y (number) y of the point + = (boolean) true, if point is inside the path + \*/ + R.isPointInsidePath = function (path, x, y) { + var bbox = R.pathBBox(path); + return R.isPointInsideBBox(bbox, x, y) && + interPathHelper(path, [["M", x, y], ["H", bbox.x2 + 10]], 1) % 2 == 1; + }; + R._removedFactory = function (methodname) { + return function () { + eve("raphael.log", null, "Rapha\xebl: you are calling to method \u201c" + methodname + "\u201d of removed object", methodname); + }; + }; + /*\ + * Raphael.pathBBox + [ method ] + ** + * Utility method + ** + * Return bounding box of a given path + > Parameters + - path (string) path string + = (object) bounding box + o { + o x: (number) x coordinate of the left top point of the box + o y: (number) y coordinate of the left top point of the box + o x2: (number) x coordinate of the right bottom point of the box + o y2: (number) y coordinate of the right bottom point of the box + o width: (number) width of the box + o height: (number) height of the box + o cx: (number) x coordinate of the center of the box + o cy: (number) y coordinate of the center of the box + o } + \*/ + var pathDimensions = R.pathBBox = function (path) { + var pth = paths(path); + if (pth.bbox) { + return clone(pth.bbox); + } + if (!path) { + return {x: 0, y: 0, width: 0, height: 0, x2: 0, y2: 0}; + } + path = path2curve(path); + var x = 0, + y = 0, + X = [], + Y = [], + p; + for (var i = 0, ii = path.length; i < ii; i++) { + p = path[i]; + if (p[0] == "M") { + x = p[1]; + y = p[2]; + X.push(x); + Y.push(y); + } else { + var dim = curveDim(x, y, p[1], p[2], p[3], p[4], p[5], p[6]); + X = X[concat](dim.min.x, dim.max.x); + Y = Y[concat](dim.min.y, dim.max.y); + x = p[5]; + y = p[6]; + } + } + var xmin = mmin[apply](0, X), + ymin = mmin[apply](0, Y), + xmax = mmax[apply](0, X), + ymax = mmax[apply](0, Y), + width = xmax - xmin, + height = ymax - ymin, + bb = { + x: xmin, + y: ymin, + x2: xmax, + y2: ymax, + width: width, + height: height, + cx: xmin + width / 2, + cy: ymin + height / 2 + }; + pth.bbox = clone(bb); + return bb; + }, + pathClone = function (pathArray) { + var res = clone(pathArray); + res.toString = R._path2string; + return res; + }, + pathToRelative = R._pathToRelative = function (pathArray) { + var pth = paths(pathArray); + if (pth.rel) { + return pathClone(pth.rel); + } + if (!R.is(pathArray, array) || !R.is(pathArray && pathArray[0], array)) { // rough assumption + pathArray = R.parsePathString(pathArray); + } + var res = [], + x = 0, + y = 0, + mx = 0, + my = 0, + start = 0; + if (pathArray[0][0] == "M") { + x = pathArray[0][1]; + y = pathArray[0][2]; + mx = x; + my = y; + start++; + res.push(["M", x, y]); + } + for (var i = start, ii = pathArray.length; i < ii; i++) { + var r = res[i] = [], + pa = pathArray[i]; + if (pa[0] != lowerCase.call(pa[0])) { + r[0] = lowerCase.call(pa[0]); + switch (r[0]) { + case "a": + r[1] = pa[1]; + r[2] = pa[2]; + r[3] = pa[3]; + r[4] = pa[4]; + r[5] = pa[5]; + r[6] = +(pa[6] - x).toFixed(3); + r[7] = +(pa[7] - y).toFixed(3); + break; + case "v": + r[1] = +(pa[1] - y).toFixed(3); + break; + case "m": + mx = pa[1]; + my = pa[2]; + default: + for (var j = 1, jj = pa.length; j < jj; j++) { + r[j] = +(pa[j] - ((j % 2) ? x : y)).toFixed(3); + } + } + } else { + r = res[i] = []; + if (pa[0] == "m") { + mx = pa[1] + x; + my = pa[2] + y; + } + for (var k = 0, kk = pa.length; k < kk; k++) { + res[i][k] = pa[k]; + } + } + var len = res[i].length; + switch (res[i][0]) { + case "z": + x = mx; + y = my; + break; + case "h": + x += +res[i][len - 1]; + break; + case "v": + y += +res[i][len - 1]; + break; + default: + x += +res[i][len - 2]; + y += +res[i][len - 1]; + } + } + res.toString = R._path2string; + pth.rel = pathClone(res); + return res; + }, + pathToAbsolute = R._pathToAbsolute = function (pathArray) { + var pth = paths(pathArray); + if (pth.abs) { + return pathClone(pth.abs); + } + if (!R.is(pathArray, array) || !R.is(pathArray && pathArray[0], array)) { // rough assumption + pathArray = R.parsePathString(pathArray); + } + if (!pathArray || !pathArray.length) { + return [["M", 0, 0]]; + } + var res = [], + x = 0, + y = 0, + mx = 0, + my = 0, + start = 0; + if (pathArray[0][0] == "M") { + x = +pathArray[0][1]; + y = +pathArray[0][2]; + mx = x; + my = y; + start++; + res[0] = ["M", x, y]; + } + var crz = pathArray.length == 3 && pathArray[0][0] == "M" && pathArray[1][0].toUpperCase() == "R" && pathArray[2][0].toUpperCase() == "Z"; + for (var r, pa, i = start, ii = pathArray.length; i < ii; i++) { + res.push(r = []); + pa = pathArray[i]; + if (pa[0] != upperCase.call(pa[0])) { + r[0] = upperCase.call(pa[0]); + switch (r[0]) { + case "A": + r[1] = pa[1]; + r[2] = pa[2]; + r[3] = pa[3]; + r[4] = pa[4]; + r[5] = pa[5]; + r[6] = +(pa[6] + x); + r[7] = +(pa[7] + y); + break; + case "V": + r[1] = +pa[1] + y; + break; + case "H": + r[1] = +pa[1] + x; + break; + case "R": + var dots = [x, y][concat](pa.slice(1)); + for (var j = 2, jj = dots.length; j < jj; j++) { + dots[j] = +dots[j] + x; + dots[++j] = +dots[j] + y; + } + res.pop(); + res = res[concat](catmullRom2bezier(dots, crz)); + break; + case "M": + mx = +pa[1] + x; + my = +pa[2] + y; + default: + for (j = 1, jj = pa.length; j < jj; j++) { + r[j] = +pa[j] + ((j % 2) ? x : y); + } + } + } else if (pa[0] == "R") { + dots = [x, y][concat](pa.slice(1)); + res.pop(); + res = res[concat](catmullRom2bezier(dots, crz)); + r = ["R"][concat](pa.slice(-2)); + } else { + for (var k = 0, kk = pa.length; k < kk; k++) { + r[k] = pa[k]; + } + } + switch (r[0]) { + case "Z": + x = mx; + y = my; + break; + case "H": + x = r[1]; + break; + case "V": + y = r[1]; + break; + case "M": + mx = r[r.length - 2]; + my = r[r.length - 1]; + default: + x = r[r.length - 2]; + y = r[r.length - 1]; + } + } + res.toString = R._path2string; + pth.abs = pathClone(res); + return res; + }, + l2c = function (x1, y1, x2, y2) { + return [x1, y1, x2, y2, x2, y2]; + }, + q2c = function (x1, y1, ax, ay, x2, y2) { + var _13 = 1 / 3, + _23 = 2 / 3; + return [ + _13 * x1 + _23 * ax, + _13 * y1 + _23 * ay, + _13 * x2 + _23 * ax, + _13 * y2 + _23 * ay, + x2, + y2 + ]; + }, + a2c = function (x1, y1, rx, ry, angle, large_arc_flag, sweep_flag, x2, y2, recursive) { + // for more information of where this math came from visit: + // http://www.w3.org/TR/SVG11/implnote.html#ArcImplementationNotes + var _120 = PI * 120 / 180, + rad = PI / 180 * (+angle || 0), + res = [], + xy, + rotate = cacher(function (x, y, rad) { + var X = x * math.cos(rad) - y * math.sin(rad), + Y = x * math.sin(rad) + y * math.cos(rad); + return {x: X, y: Y}; + }); + if (!recursive) { + xy = rotate(x1, y1, -rad); + x1 = xy.x; + y1 = xy.y; + xy = rotate(x2, y2, -rad); + x2 = xy.x; + y2 = xy.y; + var cos = math.cos(PI / 180 * angle), + sin = math.sin(PI / 180 * angle), + x = (x1 - x2) / 2, + y = (y1 - y2) / 2; + var h = (x * x) / (rx * rx) + (y * y) / (ry * ry); + if (h > 1) { + h = math.sqrt(h); + rx = h * rx; + ry = h * ry; + } + var rx2 = rx * rx, + ry2 = ry * ry, + k = (large_arc_flag == sweep_flag ? -1 : 1) * + math.sqrt(abs((rx2 * ry2 - rx2 * y * y - ry2 * x * x) / (rx2 * y * y + ry2 * x * x))), + cx = k * rx * y / ry + (x1 + x2) / 2, + cy = k * -ry * x / rx + (y1 + y2) / 2, + f1 = math.asin(((y1 - cy) / ry).toFixed(9)), + f2 = math.asin(((y2 - cy) / ry).toFixed(9)); + + f1 = x1 < cx ? PI - f1 : f1; + f2 = x2 < cx ? PI - f2 : f2; + f1 < 0 && (f1 = PI * 2 + f1); + f2 < 0 && (f2 = PI * 2 + f2); + if (sweep_flag && f1 > f2) { + f1 = f1 - PI * 2; + } + if (!sweep_flag && f2 > f1) { + f2 = f2 - PI * 2; + } + } else { + f1 = recursive[0]; + f2 = recursive[1]; + cx = recursive[2]; + cy = recursive[3]; + } + var df = f2 - f1; + if (abs(df) > _120) { + var f2old = f2, + x2old = x2, + y2old = y2; + f2 = f1 + _120 * (sweep_flag && f2 > f1 ? 1 : -1); + x2 = cx + rx * math.cos(f2); + y2 = cy + ry * math.sin(f2); + res = a2c(x2, y2, rx, ry, angle, 0, sweep_flag, x2old, y2old, [f2, f2old, cx, cy]); + } + df = f2 - f1; + var c1 = math.cos(f1), + s1 = math.sin(f1), + c2 = math.cos(f2), + s2 = math.sin(f2), + t = math.tan(df / 4), + hx = 4 / 3 * rx * t, + hy = 4 / 3 * ry * t, + m1 = [x1, y1], + m2 = [x1 + hx * s1, y1 - hy * c1], + m3 = [x2 + hx * s2, y2 - hy * c2], + m4 = [x2, y2]; + m2[0] = 2 * m1[0] - m2[0]; + m2[1] = 2 * m1[1] - m2[1]; + if (recursive) { + return [m2, m3, m4][concat](res); + } else { + res = [m2, m3, m4][concat](res).join()[split](","); + var newres = []; + for (var i = 0, ii = res.length; i < ii; i++) { + newres[i] = i % 2 ? rotate(res[i - 1], res[i], rad).y : rotate(res[i], res[i + 1], rad).x; + } + return newres; + } + }, + findDotAtSegment = function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t) { + var t1 = 1 - t; + return { + x: pow(t1, 3) * p1x + pow(t1, 2) * 3 * t * c1x + t1 * 3 * t * t * c2x + pow(t, 3) * p2x, + y: pow(t1, 3) * p1y + pow(t1, 2) * 3 * t * c1y + t1 * 3 * t * t * c2y + pow(t, 3) * p2y + }; + }, + curveDim = cacher(function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y) { + var a = (c2x - 2 * c1x + p1x) - (p2x - 2 * c2x + c1x), + b = 2 * (c1x - p1x) - 2 * (c2x - c1x), + c = p1x - c1x, + t1 = (-b + math.sqrt(b * b - 4 * a * c)) / 2 / a, + t2 = (-b - math.sqrt(b * b - 4 * a * c)) / 2 / a, + y = [p1y, p2y], + x = [p1x, p2x], + dot; + abs(t1) > "1e12" && (t1 = .5); + abs(t2) > "1e12" && (t2 = .5); + if (t1 > 0 && t1 < 1) { + dot = findDotAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t1); + x.push(dot.x); + y.push(dot.y); + } + if (t2 > 0 && t2 < 1) { + dot = findDotAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t2); + x.push(dot.x); + y.push(dot.y); + } + a = (c2y - 2 * c1y + p1y) - (p2y - 2 * c2y + c1y); + b = 2 * (c1y - p1y) - 2 * (c2y - c1y); + c = p1y - c1y; + t1 = (-b + math.sqrt(b * b - 4 * a * c)) / 2 / a; + t2 = (-b - math.sqrt(b * b - 4 * a * c)) / 2 / a; + abs(t1) > "1e12" && (t1 = .5); + abs(t2) > "1e12" && (t2 = .5); + if (t1 > 0 && t1 < 1) { + dot = findDotAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t1); + x.push(dot.x); + y.push(dot.y); + } + if (t2 > 0 && t2 < 1) { + dot = findDotAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, t2); + x.push(dot.x); + y.push(dot.y); + } + return { + min: {x: mmin[apply](0, x), y: mmin[apply](0, y)}, + max: {x: mmax[apply](0, x), y: mmax[apply](0, y)} + }; + }), + path2curve = R._path2curve = cacher(function (path, path2) { + var pth = !path2 && paths(path); + if (!path2 && pth.curve) { + return pathClone(pth.curve); + } + var p = pathToAbsolute(path), + p2 = path2 && pathToAbsolute(path2), + attrs = {x: 0, y: 0, bx: 0, by: 0, X: 0, Y: 0, qx: null, qy: null}, + attrs2 = {x: 0, y: 0, bx: 0, by: 0, X: 0, Y: 0, qx: null, qy: null}, + processPath = function (path, d, pcom) { + var nx, ny, tq = {T:1, Q:1}; + if (!path) { + return ["C", d.x, d.y, d.x, d.y, d.x, d.y]; + } + !(path[0] in tq) && (d.qx = d.qy = null); + switch (path[0]) { + case "M": + d.X = path[1]; + d.Y = path[2]; + break; + case "A": + path = ["C"][concat](a2c[apply](0, [d.x, d.y][concat](path.slice(1)))); + break; + case "S": + if (pcom == "C" || pcom == "S") { // In "S" case we have to take into account, if the previous command is C/S. + nx = d.x * 2 - d.bx; // And reflect the previous + ny = d.y * 2 - d.by; // command's control point relative to the current point. + } + else { // or some else or nothing + nx = d.x; + ny = d.y; + } + path = ["C", nx, ny][concat](path.slice(1)); + break; + case "T": + if (pcom == "Q" || pcom == "T") { // In "T" case we have to take into account, if the previous command is Q/T. + d.qx = d.x * 2 - d.qx; // And make a reflection similar + d.qy = d.y * 2 - d.qy; // to case "S". + } + else { // or something else or nothing + d.qx = d.x; + d.qy = d.y; + } + path = ["C"][concat](q2c(d.x, d.y, d.qx, d.qy, path[1], path[2])); + break; + case "Q": + d.qx = path[1]; + d.qy = path[2]; + path = ["C"][concat](q2c(d.x, d.y, path[1], path[2], path[3], path[4])); + break; + case "L": + path = ["C"][concat](l2c(d.x, d.y, path[1], path[2])); + break; + case "H": + path = ["C"][concat](l2c(d.x, d.y, path[1], d.y)); + break; + case "V": + path = ["C"][concat](l2c(d.x, d.y, d.x, path[1])); + break; + case "Z": + path = ["C"][concat](l2c(d.x, d.y, d.X, d.Y)); + break; + } + return path; + }, + fixArc = function (pp, i) { + if (pp[i].length > 7) { + pp[i].shift(); + var pi = pp[i]; + while (pi.length) { + pcoms1[i]="A"; // if created multiple C:s, their original seg is saved + p2 && (pcoms2[i]="A"); // the same as above + pp.splice(i++, 0, ["C"][concat](pi.splice(0, 6))); + } + pp.splice(i, 1); + ii = mmax(p.length, p2 && p2.length || 0); + } + }, + fixM = function (path1, path2, a1, a2, i) { + if (path1 && path2 && path1[i][0] == "M" && path2[i][0] != "M") { + path2.splice(i, 0, ["M", a2.x, a2.y]); + a1.bx = 0; + a1.by = 0; + a1.x = path1[i][1]; + a1.y = path1[i][2]; + ii = mmax(p.length, p2 && p2.length || 0); + } + }, + pcoms1 = [], // path commands of original path p + pcoms2 = [], // path commands of original path p2 + pfirst = "", // temporary holder for original path command + pcom = ""; // holder for previous path command of original path + for (var i = 0, ii = mmax(p.length, p2 && p2.length || 0); i < ii; i++) { + p[i] && (pfirst = p[i][0]); // save current path command + + if (pfirst != "C") // C is not saved yet, because it may be result of conversion + { + pcoms1[i] = pfirst; // Save current path command + i && ( pcom = pcoms1[i-1]); // Get previous path command pcom + } + p[i] = processPath(p[i], attrs, pcom); // Previous path command is inputted to processPath + + if (pcoms1[i] != "A" && pfirst == "C") pcoms1[i] = "C"; // A is the only command + // which may produce multiple C:s + // so we have to make sure that C is also C in original path + + fixArc(p, i); // fixArc adds also the right amount of A:s to pcoms1 + + if (p2) { // the same procedures is done to p2 + p2[i] && (pfirst = p2[i][0]); + if (pfirst != "C") + { + pcoms2[i] = pfirst; + i && (pcom = pcoms2[i-1]); + } + p2[i] = processPath(p2[i], attrs2, pcom); + + if (pcoms2[i]!="A" && pfirst=="C") pcoms2[i]="C"; + + fixArc(p2, i); + } + fixM(p, p2, attrs, attrs2, i); + fixM(p2, p, attrs2, attrs, i); + var seg = p[i], + seg2 = p2 && p2[i], + seglen = seg.length, + seg2len = p2 && seg2.length; + attrs.x = seg[seglen - 2]; + attrs.y = seg[seglen - 1]; + attrs.bx = toFloat(seg[seglen - 4]) || attrs.x; + attrs.by = toFloat(seg[seglen - 3]) || attrs.y; + attrs2.bx = p2 && (toFloat(seg2[seg2len - 4]) || attrs2.x); + attrs2.by = p2 && (toFloat(seg2[seg2len - 3]) || attrs2.y); + attrs2.x = p2 && seg2[seg2len - 2]; + attrs2.y = p2 && seg2[seg2len - 1]; + } + if (!p2) { + pth.curve = pathClone(p); + } + return p2 ? [p, p2] : p; + }, null, pathClone), + parseDots = R._parseDots = cacher(function (gradient) { + var dots = []; + for (var i = 0, ii = gradient.length; i < ii; i++) { + var dot = {}, + par = gradient[i].match(/^([^:]*):?([\d\.]*)/); + dot.color = R.getRGB(par[1]); + if (dot.color.error) { + return null; + } + dot.color = dot.color.hex; + par[2] && (dot.offset = par[2] + "%"); + dots.push(dot); + } + for (i = 1, ii = dots.length - 1; i < ii; i++) { + if (!dots[i].offset) { + var start = toFloat(dots[i - 1].offset || 0), + end = 0; + for (var j = i + 1; j < ii; j++) { + if (dots[j].offset) { + end = dots[j].offset; + break; + } + } + if (!end) { + end = 100; + j = ii; + } + end = toFloat(end); + var d = (end - start) / (j - i + 1); + for (; i < j; i++) { + start += d; + dots[i].offset = start + "%"; + } + } + } + return dots; + }), + tear = R._tear = function (el, paper) { + el == paper.top && (paper.top = el.prev); + el == paper.bottom && (paper.bottom = el.next); + el.next && (el.next.prev = el.prev); + el.prev && (el.prev.next = el.next); + }, + tofront = R._tofront = function (el, paper) { + if (paper.top === el) { + return; + } + tear(el, paper); + el.next = null; + el.prev = paper.top; + paper.top.next = el; + paper.top = el; + }, + toback = R._toback = function (el, paper) { + if (paper.bottom === el) { + return; + } + tear(el, paper); + el.next = paper.bottom; + el.prev = null; + paper.bottom.prev = el; + paper.bottom = el; + }, + insertafter = R._insertafter = function (el, el2, paper) { + tear(el, paper); + el2 == paper.top && (paper.top = el); + el2.next && (el2.next.prev = el); + el.next = el2.next; + el.prev = el2; + el2.next = el; + }, + insertbefore = R._insertbefore = function (el, el2, paper) { + tear(el, paper); + el2 == paper.bottom && (paper.bottom = el); + el2.prev && (el2.prev.next = el); + el.prev = el2.prev; + el2.prev = el; + el.next = el2; + }, + /*\ + * Raphael.toMatrix + [ method ] + ** + * Utility method + ** + * Returns matrix of transformations applied to a given path + > Parameters + - path (string) path string + - transform (string|array) transformation string + = (object) @Matrix + \*/ + toMatrix = R.toMatrix = function (path, transform) { + var bb = pathDimensions(path), + el = { + _: { + transform: E + }, + getBBox: function () { + return bb; + } + }; + extractTransform(el, transform); + return el.matrix; + }, + /*\ + * Raphael.transformPath + [ method ] + ** + * Utility method + ** + * Returns path transformed by a given transformation + > Parameters + - path (string) path string + - transform (string|array) transformation string + = (string) path + \*/ + transformPath = R.transformPath = function (path, transform) { + return mapPath(path, toMatrix(path, transform)); + }, + extractTransform = R._extractTransform = function (el, tstr) { + if (tstr == null) { + return el._.transform; + } + tstr = Str(tstr).replace(/\.{3}|\u2026/g, el._.transform || E); + var tdata = R.parseTransformString(tstr), + deg = 0, + dx = 0, + dy = 0, + sx = 1, + sy = 1, + _ = el._, + m = new Matrix; + _.transform = tdata || []; + if (tdata) { + for (var i = 0, ii = tdata.length; i < ii; i++) { + var t = tdata[i], + tlen = t.length, + command = Str(t[0]).toLowerCase(), + absolute = t[0] != command, + inver = absolute ? m.invert() : 0, + x1, + y1, + x2, + y2, + bb; + if (command == "t" && tlen == 3) { + if (absolute) { + x1 = inver.x(0, 0); + y1 = inver.y(0, 0); + x2 = inver.x(t[1], t[2]); + y2 = inver.y(t[1], t[2]); + m.translate(x2 - x1, y2 - y1); + } else { + m.translate(t[1], t[2]); + } + } else if (command == "r") { + if (tlen == 2) { + bb = bb || el.getBBox(1); + m.rotate(t[1], bb.x + bb.width / 2, bb.y + bb.height / 2); + deg += t[1]; + } else if (tlen == 4) { + if (absolute) { + x2 = inver.x(t[2], t[3]); + y2 = inver.y(t[2], t[3]); + m.rotate(t[1], x2, y2); + } else { + m.rotate(t[1], t[2], t[3]); + } + deg += t[1]; + } + } else if (command == "s") { + if (tlen == 2 || tlen == 3) { + bb = bb || el.getBBox(1); + m.scale(t[1], t[tlen - 1], bb.x + bb.width / 2, bb.y + bb.height / 2); + sx *= t[1]; + sy *= t[tlen - 1]; + } else if (tlen == 5) { + if (absolute) { + x2 = inver.x(t[3], t[4]); + y2 = inver.y(t[3], t[4]); + m.scale(t[1], t[2], x2, y2); + } else { + m.scale(t[1], t[2], t[3], t[4]); + } + sx *= t[1]; + sy *= t[2]; + } + } else if (command == "m" && tlen == 7) { + m.add(t[1], t[2], t[3], t[4], t[5], t[6]); + } + _.dirtyT = 1; + el.matrix = m; + } + } + + /*\ + * Element.matrix + [ property (object) ] + ** + * Keeps @Matrix object, which represents element transformation + \*/ + el.matrix = m; + + _.sx = sx; + _.sy = sy; + _.deg = deg; + _.dx = dx = m.e; + _.dy = dy = m.f; + + if (sx == 1 && sy == 1 && !deg && _.bbox) { + _.bbox.x += +dx; + _.bbox.y += +dy; + } else { + _.dirtyT = 1; + } + }, + getEmpty = function (item) { + var l = item[0]; + switch (l.toLowerCase()) { + case "t": return [l, 0, 0]; + case "m": return [l, 1, 0, 0, 1, 0, 0]; + case "r": if (item.length == 4) { + return [l, 0, item[2], item[3]]; + } else { + return [l, 0]; + } + case "s": if (item.length == 5) { + return [l, 1, 1, item[3], item[4]]; + } else if (item.length == 3) { + return [l, 1, 1]; + } else { + return [l, 1]; + } + } + }, + equaliseTransform = R._equaliseTransform = function (t1, t2) { + t2 = Str(t2).replace(/\.{3}|\u2026/g, t1); + t1 = R.parseTransformString(t1) || []; + t2 = R.parseTransformString(t2) || []; + var maxlength = mmax(t1.length, t2.length), + from = [], + to = [], + i = 0, j, jj, + tt1, tt2; + for (; i < maxlength; i++) { + tt1 = t1[i] || getEmpty(t2[i]); + tt2 = t2[i] || getEmpty(tt1); + if ((tt1[0] != tt2[0]) || + (tt1[0].toLowerCase() == "r" && (tt1[2] != tt2[2] || tt1[3] != tt2[3])) || + (tt1[0].toLowerCase() == "s" && (tt1[3] != tt2[3] || tt1[4] != tt2[4])) + ) { + return; + } + from[i] = []; + to[i] = []; + for (j = 0, jj = mmax(tt1.length, tt2.length); j < jj; j++) { + j in tt1 && (from[i][j] = tt1[j]); + j in tt2 && (to[i][j] = tt2[j]); + } + } + return { + from: from, + to: to + }; + }; + R._getContainer = function (x, y, w, h) { + var container; + container = h == null && !R.is(x, "object") ? g.doc.getElementById(x) : x; + if (container == null) { + return; + } + if (container.tagName) { + if (y == null) { + return { + container: container, + width: container.style.pixelWidth || container.offsetWidth, + height: container.style.pixelHeight || container.offsetHeight + }; + } else { + return { + container: container, + width: y, + height: w + }; + } + } + return { + container: 1, + x: x, + y: y, + width: w, + height: h + }; + }; + /*\ + * Raphael.pathToRelative + [ method ] + ** + * Utility method + ** + * Converts path to relative form + > Parameters + - pathString (string|array) path string or array of segments + = (array) array of segments. + \*/ + R.pathToRelative = pathToRelative; + R._engine = {}; + /*\ + * Raphael.path2curve + [ method ] + ** + * Utility method + ** + * Converts path to a new path where all segments are cubic bezier curves. + > Parameters + - pathString (string|array) path string or array of segments + = (array) array of segments. + \*/ + R.path2curve = path2curve; + /*\ + * Raphael.matrix + [ method ] + ** + * Utility method + ** + * Returns matrix based on given parameters. + > Parameters + - a (number) + - b (number) + - c (number) + - d (number) + - e (number) + - f (number) + = (object) @Matrix + \*/ + R.matrix = function (a, b, c, d, e, f) { + return new Matrix(a, b, c, d, e, f); + }; + function Matrix(a, b, c, d, e, f) { + if (a != null) { + this.a = +a; + this.b = +b; + this.c = +c; + this.d = +d; + this.e = +e; + this.f = +f; + } else { + this.a = 1; + this.b = 0; + this.c = 0; + this.d = 1; + this.e = 0; + this.f = 0; + } + } + (function (matrixproto) { + /*\ + * Matrix.add + [ method ] + ** + * Adds given matrix to existing one. + > Parameters + - a (number) + - b (number) + - c (number) + - d (number) + - e (number) + - f (number) + or + - matrix (object) @Matrix + \*/ + matrixproto.add = function (a, b, c, d, e, f) { + var out = [[], [], []], + m = [[this.a, this.c, this.e], [this.b, this.d, this.f], [0, 0, 1]], + matrix = [[a, c, e], [b, d, f], [0, 0, 1]], + x, y, z, res; + + if (a && a instanceof Matrix) { + matrix = [[a.a, a.c, a.e], [a.b, a.d, a.f], [0, 0, 1]]; + } + + for (x = 0; x < 3; x++) { + for (y = 0; y < 3; y++) { + res = 0; + for (z = 0; z < 3; z++) { + res += m[x][z] * matrix[z][y]; + } + out[x][y] = res; + } + } + this.a = out[0][0]; + this.b = out[1][0]; + this.c = out[0][1]; + this.d = out[1][1]; + this.e = out[0][2]; + this.f = out[1][2]; + }; + /*\ + * Matrix.invert + [ method ] + ** + * Returns inverted version of the matrix + = (object) @Matrix + \*/ + matrixproto.invert = function () { + var me = this, + x = me.a * me.d - me.b * me.c; + return new Matrix(me.d / x, -me.b / x, -me.c / x, me.a / x, (me.c * me.f - me.d * me.e) / x, (me.b * me.e - me.a * me.f) / x); + }; + /*\ + * Matrix.clone + [ method ] + ** + * Returns copy of the matrix + = (object) @Matrix + \*/ + matrixproto.clone = function () { + return new Matrix(this.a, this.b, this.c, this.d, this.e, this.f); + }; + /*\ + * Matrix.translate + [ method ] + ** + * Translate the matrix + > Parameters + - x (number) + - y (number) + \*/ + matrixproto.translate = function (x, y) { + this.add(1, 0, 0, 1, x, y); + }; + /*\ + * Matrix.scale + [ method ] + ** + * Scales the matrix + > Parameters + - x (number) + - y (number) #optional + - cx (number) #optional + - cy (number) #optional + \*/ + matrixproto.scale = function (x, y, cx, cy) { + y == null && (y = x); + (cx || cy) && this.add(1, 0, 0, 1, cx, cy); + this.add(x, 0, 0, y, 0, 0); + (cx || cy) && this.add(1, 0, 0, 1, -cx, -cy); + }; + /*\ + * Matrix.rotate + [ method ] + ** + * Rotates the matrix + > Parameters + - a (number) + - x (number) + - y (number) + \*/ + matrixproto.rotate = function (a, x, y) { + a = R.rad(a); + x = x || 0; + y = y || 0; + var cos = +math.cos(a).toFixed(9), + sin = +math.sin(a).toFixed(9); + this.add(cos, sin, -sin, cos, x, y); + this.add(1, 0, 0, 1, -x, -y); + }; + /*\ + * Matrix.x + [ method ] + ** + * Return x coordinate for given point after transformation described by the matrix. See also @Matrix.y + > Parameters + - x (number) + - y (number) + = (number) x + \*/ + matrixproto.x = function (x, y) { + return x * this.a + y * this.c + this.e; + }; + /*\ + * Matrix.y + [ method ] + ** + * Return y coordinate for given point after transformation described by the matrix. See also @Matrix.x + > Parameters + - x (number) + - y (number) + = (number) y + \*/ + matrixproto.y = function (x, y) { + return x * this.b + y * this.d + this.f; + }; + matrixproto.get = function (i) { + return +this[Str.fromCharCode(97 + i)].toFixed(4); + }; + matrixproto.toString = function () { + return R.svg ? + "matrix(" + [this.get(0), this.get(1), this.get(2), this.get(3), this.get(4), this.get(5)].join() + ")" : + [this.get(0), this.get(2), this.get(1), this.get(3), 0, 0].join(); + }; + matrixproto.toFilter = function () { + return "progid:DXImageTransform.Microsoft.Matrix(M11=" + this.get(0) + + ", M12=" + this.get(2) + ", M21=" + this.get(1) + ", M22=" + this.get(3) + + ", Dx=" + this.get(4) + ", Dy=" + this.get(5) + ", sizingmethod='auto expand')"; + }; + matrixproto.offset = function () { + return [this.e.toFixed(4), this.f.toFixed(4)]; + }; + function norm(a) { + return a[0] * a[0] + a[1] * a[1]; + } + function normalize(a) { + var mag = math.sqrt(norm(a)); + a[0] && (a[0] /= mag); + a[1] && (a[1] /= mag); + } + /*\ + * Matrix.split + [ method ] + ** + * Splits matrix into primitive transformations + = (object) in format: + o dx (number) translation by x + o dy (number) translation by y + o scalex (number) scale by x + o scaley (number) scale by y + o shear (number) shear + o rotate (number) rotation in deg + o isSimple (boolean) could it be represented via simple transformations + \*/ + matrixproto.split = function () { + var out = {}; + // translation + out.dx = this.e; + out.dy = this.f; + + // scale and shear + var row = [[this.a, this.c], [this.b, this.d]]; + out.scalex = math.sqrt(norm(row[0])); + normalize(row[0]); + + out.shear = row[0][0] * row[1][0] + row[0][1] * row[1][1]; + row[1] = [row[1][0] - row[0][0] * out.shear, row[1][1] - row[0][1] * out.shear]; + + out.scaley = math.sqrt(norm(row[1])); + normalize(row[1]); + out.shear /= out.scaley; + + // rotation + var sin = -row[0][1], + cos = row[1][1]; + if (cos < 0) { + out.rotate = R.deg(math.acos(cos)); + if (sin < 0) { + out.rotate = 360 - out.rotate; + } + } else { + out.rotate = R.deg(math.asin(sin)); + } + + out.isSimple = !+out.shear.toFixed(9) && (out.scalex.toFixed(9) == out.scaley.toFixed(9) || !out.rotate); + out.isSuperSimple = !+out.shear.toFixed(9) && out.scalex.toFixed(9) == out.scaley.toFixed(9) && !out.rotate; + out.noRotation = !+out.shear.toFixed(9) && !out.rotate; + return out; + }; + /*\ + * Matrix.toTransformString + [ method ] + ** + * Return transform string that represents given matrix + = (string) transform string + \*/ + matrixproto.toTransformString = function (shorter) { + var s = shorter || this[split](); + if (s.isSimple) { + s.scalex = +s.scalex.toFixed(4); + s.scaley = +s.scaley.toFixed(4); + s.rotate = +s.rotate.toFixed(4); + return (s.dx || s.dy ? "t" + [s.dx, s.dy] : E) + + (s.scalex != 1 || s.scaley != 1 ? "s" + [s.scalex, s.scaley, 0, 0] : E) + + (s.rotate ? "r" + [s.rotate, 0, 0] : E); + } else { + return "m" + [this.get(0), this.get(1), this.get(2), this.get(3), this.get(4), this.get(5)]; + } + }; + })(Matrix.prototype); + + // WebKit rendering bug workaround method + var version = navigator.userAgent.match(/Version\/(.*?)\s/) || navigator.userAgent.match(/Chrome\/(\d+)/); + if ((navigator.vendor == "Apple Computer, Inc.") && (version && version[1] < 4 || navigator.platform.slice(0, 2) == "iP") || + (navigator.vendor == "Google Inc." && version && version[1] < 8)) { + /*\ + * Paper.safari + [ method ] + ** + * There is an inconvenient rendering bug in Safari (WebKit): + * sometimes the rendering should be forced. + * This method should help with dealing with this bug. + \*/ + paperproto.safari = function () { + var rect = this.rect(-99, -99, this.width + 99, this.height + 99).attr({stroke: "none"}); + setTimeout(function () {rect.remove();}); + }; + } else { + paperproto.safari = fun; + } + + var preventDefault = function () { + this.returnValue = false; + }, + preventTouch = function () { + return this.originalEvent.preventDefault(); + }, + stopPropagation = function () { + this.cancelBubble = true; + }, + stopTouch = function () { + return this.originalEvent.stopPropagation(); + }, + getEventPosition = function (e) { + var scrollY = g.doc.documentElement.scrollTop || g.doc.body.scrollTop, + scrollX = g.doc.documentElement.scrollLeft || g.doc.body.scrollLeft; + + return { + x: e.clientX + scrollX, + y: e.clientY + scrollY + }; + }, + addEvent = (function () { + if (g.doc.addEventListener) { + return function (obj, type, fn, element) { + var f = function (e) { + var pos = getEventPosition(e); + return fn.call(element, e, pos.x, pos.y); + }; + obj.addEventListener(type, f, false); + + if (supportsTouch && touchMap[type]) { + var _f = function (e) { + var pos = getEventPosition(e), + olde = e; + + for (var i = 0, ii = e.targetTouches && e.targetTouches.length; i < ii; i++) { + if (e.targetTouches[i].target == obj) { + e = e.targetTouches[i]; + e.originalEvent = olde; + e.preventDefault = preventTouch; + e.stopPropagation = stopTouch; + break; + } + } + + return fn.call(element, e, pos.x, pos.y); + }; + obj.addEventListener(touchMap[type], _f, false); + } + + return function () { + obj.removeEventListener(type, f, false); + + if (supportsTouch && touchMap[type]) + obj.removeEventListener(touchMap[type], _f, false); + + return true; + }; + }; + } else if (g.doc.attachEvent) { + return function (obj, type, fn, element) { + var f = function (e) { + e = e || g.win.event; + var scrollY = g.doc.documentElement.scrollTop || g.doc.body.scrollTop, + scrollX = g.doc.documentElement.scrollLeft || g.doc.body.scrollLeft, + x = e.clientX + scrollX, + y = e.clientY + scrollY; + e.preventDefault = e.preventDefault || preventDefault; + e.stopPropagation = e.stopPropagation || stopPropagation; + return fn.call(element, e, x, y); + }; + obj.attachEvent("on" + type, f); + var detacher = function () { + obj.detachEvent("on" + type, f); + return true; + }; + return detacher; + }; + } + })(), + drag = [], + dragMove = function (e) { + var x = e.clientX, + y = e.clientY, + scrollY = g.doc.documentElement.scrollTop || g.doc.body.scrollTop, + scrollX = g.doc.documentElement.scrollLeft || g.doc.body.scrollLeft, + dragi, + j = drag.length; + while (j--) { + dragi = drag[j]; + if (supportsTouch && e.touches) { + var i = e.touches.length, + touch; + while (i--) { + touch = e.touches[i]; + if (touch.identifier == dragi.el._drag.id) { + x = touch.clientX; + y = touch.clientY; + (e.originalEvent ? e.originalEvent : e).preventDefault(); + break; + } + } + } else { + e.preventDefault(); + } + var node = dragi.el.node, + o, + next = node.nextSibling, + parent = node.parentNode, + display = node.style.display; + g.win.opera && parent.removeChild(node); + node.style.display = "none"; + o = dragi.el.paper.getElementByPoint(x, y); + node.style.display = display; + g.win.opera && (next ? parent.insertBefore(node, next) : parent.appendChild(node)); + o && eve("raphael.drag.over." + dragi.el.id, dragi.el, o); + x += scrollX; + y += scrollY; + eve("raphael.drag.move." + dragi.el.id, dragi.move_scope || dragi.el, x - dragi.el._drag.x, y - dragi.el._drag.y, x, y, e); + } + }, + dragUp = function (e) { + R.unmousemove(dragMove).unmouseup(dragUp); + var i = drag.length, + dragi; + while (i--) { + dragi = drag[i]; + dragi.el._drag = {}; + eve("raphael.drag.end." + dragi.el.id, dragi.end_scope || dragi.start_scope || dragi.move_scope || dragi.el, e); + } + drag = []; + }, + /*\ + * Raphael.el + [ property (object) ] + ** + * You can add your own method to elements. This is usefull when you want to hack default functionality or + * want to wrap some common transformation or attributes in one method. In difference to canvas methods, + * you can redefine element method at any time. Expending element methods wouldn’t affect set. + > Usage + | Raphael.el.red = function () { + | this.attr({fill: "#f00"}); + | }; + | // then use it + | paper.circle(100, 100, 20).red(); + \*/ + elproto = R.el = {}; + /*\ + * Element.click + [ method ] + ** + * Adds event handler for click for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unclick + [ method ] + ** + * Removes event handler for click for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.dblclick + [ method ] + ** + * Adds event handler for double click for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.undblclick + [ method ] + ** + * Removes event handler for double click for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mousedown + [ method ] + ** + * Adds event handler for mousedown for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmousedown + [ method ] + ** + * Removes event handler for mousedown for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mousemove + [ method ] + ** + * Adds event handler for mousemove for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmousemove + [ method ] + ** + * Removes event handler for mousemove for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mouseout + [ method ] + ** + * Adds event handler for mouseout for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmouseout + [ method ] + ** + * Removes event handler for mouseout for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mouseover + [ method ] + ** + * Adds event handler for mouseover for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmouseover + [ method ] + ** + * Removes event handler for mouseover for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.mouseup + [ method ] + ** + * Adds event handler for mouseup for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.unmouseup + [ method ] + ** + * Removes event handler for mouseup for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.touchstart + [ method ] + ** + * Adds event handler for touchstart for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.untouchstart + [ method ] + ** + * Removes event handler for touchstart for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.touchmove + [ method ] + ** + * Adds event handler for touchmove for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.untouchmove + [ method ] + ** + * Removes event handler for touchmove for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.touchend + [ method ] + ** + * Adds event handler for touchend for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.untouchend + [ method ] + ** + * Removes event handler for touchend for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + + /*\ + * Element.touchcancel + [ method ] + ** + * Adds event handler for touchcancel for the element. + > Parameters + - handler (function) handler for the event + = (object) @Element + \*/ + /*\ + * Element.untouchcancel + [ method ] + ** + * Removes event handler for touchcancel for the element. + > Parameters + - handler (function) #optional handler for the event + = (object) @Element + \*/ + for (var i = events.length; i--;) { + (function (eventName) { + R[eventName] = elproto[eventName] = function (fn, scope) { + if (R.is(fn, "function")) { + this.events = this.events || []; + this.events.push({name: eventName, f: fn, unbind: addEvent(this.shape || this.node || g.doc, eventName, fn, scope || this)}); + } + return this; + }; + R["un" + eventName] = elproto["un" + eventName] = function (fn) { + var events = this.events || [], + l = events.length; + while (l--){ + if (events[l].name == eventName && (R.is(fn, "undefined") || events[l].f == fn)) { + events[l].unbind(); + events.splice(l, 1); + !events.length && delete this.events; + } + } + return this; + }; + })(events[i]); + } + + /*\ + * Element.data + [ method ] + ** + * Adds or retrieves given value asociated with given key. + ** + * See also @Element.removeData + > Parameters + - key (string) key to store data + - value (any) #optional value to store + = (object) @Element + * or, if value is not specified: + = (any) value + * or, if key and value are not specified: + = (object) Key/value pairs for all the data associated with the element. + > Usage + | for (var i = 0, i < 5, i++) { + | paper.circle(10 + 15 * i, 10, 10) + | .attr({fill: "#000"}) + | .data("i", i) + | .click(function () { + | alert(this.data("i")); + | }); + | } + \*/ + elproto.data = function (key, value) { + var data = eldata[this.id] = eldata[this.id] || {}; + if (arguments.length == 0) { + return data; + } + if (arguments.length == 1) { + if (R.is(key, "object")) { + for (var i in key) if (key[has](i)) { + this.data(i, key[i]); + } + return this; + } + eve("raphael.data.get." + this.id, this, data[key], key); + return data[key]; + } + data[key] = value; + eve("raphael.data.set." + this.id, this, value, key); + return this; + }; + /*\ + * Element.removeData + [ method ] + ** + * Removes value associated with an element by given key. + * If key is not provided, removes all the data of the element. + > Parameters + - key (string) #optional key + = (object) @Element + \*/ + elproto.removeData = function (key) { + if (key == null) { + eldata[this.id] = {}; + } else { + eldata[this.id] && delete eldata[this.id][key]; + } + return this; + }; + /*\ + * Element.getData + [ method ] + ** + * Retrieves the element data + = (object) data + \*/ + elproto.getData = function () { + return clone(eldata[this.id] || {}); + }; + /*\ + * Element.hover + [ method ] + ** + * Adds event handlers for hover for the element. + > Parameters + - f_in (function) handler for hover in + - f_out (function) handler for hover out + - icontext (object) #optional context for hover in handler + - ocontext (object) #optional context for hover out handler + = (object) @Element + \*/ + elproto.hover = function (f_in, f_out, scope_in, scope_out) { + return this.mouseover(f_in, scope_in).mouseout(f_out, scope_out || scope_in); + }; + /*\ + * Element.unhover + [ method ] + ** + * Removes event handlers for hover for the element. + > Parameters + - f_in (function) handler for hover in + - f_out (function) handler for hover out + = (object) @Element + \*/ + elproto.unhover = function (f_in, f_out) { + return this.unmouseover(f_in).unmouseout(f_out); + }; + var draggable = []; + /*\ + * Element.drag + [ method ] + ** + * Adds event handlers for drag of the element. + > Parameters + - onmove (function) handler for moving + - onstart (function) handler for drag start + - onend (function) handler for drag end + - mcontext (object) #optional context for moving handler + - scontext (object) #optional context for drag start handler + - econtext (object) #optional context for drag end handler + * Additionaly following `drag` events will be triggered: `drag.start.` on start, + * `drag.end.` on end and `drag.move.` on every move. When element will be dragged over another element + * `drag.over.` will be fired as well. + * + * Start event and start handler will be called in specified context or in context of the element with following parameters: + o x (number) x position of the mouse + o y (number) y position of the mouse + o event (object) DOM event object + * Move event and move handler will be called in specified context or in context of the element with following parameters: + o dx (number) shift by x from the start point + o dy (number) shift by y from the start point + o x (number) x position of the mouse + o y (number) y position of the mouse + o event (object) DOM event object + * End event and end handler will be called in specified context or in context of the element with following parameters: + o event (object) DOM event object + = (object) @Element + \*/ + elproto.drag = function (onmove, onstart, onend, move_scope, start_scope, end_scope) { + function start(e) { + (e.originalEvent || e).preventDefault(); + var x = e.clientX, + y = e.clientY, + scrollY = g.doc.documentElement.scrollTop || g.doc.body.scrollTop, + scrollX = g.doc.documentElement.scrollLeft || g.doc.body.scrollLeft; + this._drag.id = e.identifier; + if (supportsTouch && e.touches) { + var i = e.touches.length, touch; + while (i--) { + touch = e.touches[i]; + this._drag.id = touch.identifier; + if (touch.identifier == this._drag.id) { + x = touch.clientX; + y = touch.clientY; + break; + } + } + } + this._drag.x = x + scrollX; + this._drag.y = y + scrollY; + !drag.length && R.mousemove(dragMove).mouseup(dragUp); + drag.push({el: this, move_scope: move_scope, start_scope: start_scope, end_scope: end_scope}); + onstart && eve.on("raphael.drag.start." + this.id, onstart); + onmove && eve.on("raphael.drag.move." + this.id, onmove); + onend && eve.on("raphael.drag.end." + this.id, onend); + eve("raphael.drag.start." + this.id, start_scope || move_scope || this, e.clientX + scrollX, e.clientY + scrollY, e); + } + this._drag = {}; + draggable.push({el: this, start: start}); + this.mousedown(start); + return this; + }; + /*\ + * Element.onDragOver + [ method ] + ** + * Shortcut for assigning event handler for `drag.over.` event, where id is id of the element (see @Element.id). + > Parameters + - f (function) handler for event, first argument would be the element you are dragging over + \*/ + elproto.onDragOver = function (f) { + f ? eve.on("raphael.drag.over." + this.id, f) : eve.unbind("raphael.drag.over." + this.id); + }; + /*\ + * Element.undrag + [ method ] + ** + * Removes all drag event handlers from given element. + \*/ + elproto.undrag = function () { + var i = draggable.length; + while (i--) if (draggable[i].el == this) { + this.unmousedown(draggable[i].start); + draggable.splice(i, 1); + eve.unbind("raphael.drag.*." + this.id); + } + !draggable.length && R.unmousemove(dragMove).unmouseup(dragUp); + drag = []; + }; + /*\ + * Paper.circle + [ method ] + ** + * Draws a circle. + ** + > Parameters + ** + - x (number) x coordinate of the centre + - y (number) y coordinate of the centre + - r (number) radius + = (object) Raphaël element object with type “circle” + ** + > Usage + | var c = paper.circle(50, 50, 40); + \*/ + paperproto.circle = function (x, y, r) { + var out = R._engine.circle(this, x || 0, y || 0, r || 0); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.rect + [ method ] + * + * Draws a rectangle. + ** + > Parameters + ** + - x (number) x coordinate of the top left corner + - y (number) y coordinate of the top left corner + - width (number) width + - height (number) height + - r (number) #optional radius for rounded corners, default is 0 + = (object) Raphaël element object with type “rect” + ** + > Usage + | // regular rectangle + | var c = paper.rect(10, 10, 50, 50); + | // rectangle with rounded corners + | var c = paper.rect(40, 40, 50, 50, 10); + \*/ + paperproto.rect = function (x, y, w, h, r) { + var out = R._engine.rect(this, x || 0, y || 0, w || 0, h || 0, r || 0); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.ellipse + [ method ] + ** + * Draws an ellipse. + ** + > Parameters + ** + - x (number) x coordinate of the centre + - y (number) y coordinate of the centre + - rx (number) horizontal radius + - ry (number) vertical radius + = (object) Raphaël element object with type “ellipse” + ** + > Usage + | var c = paper.ellipse(50, 50, 40, 20); + \*/ + paperproto.ellipse = function (x, y, rx, ry) { + var out = R._engine.ellipse(this, x || 0, y || 0, rx || 0, ry || 0); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.path + [ method ] + ** + * Creates a path element by given path data string. + > Parameters + - pathString (string) #optional path string in SVG format. + * Path string consists of one-letter commands, followed by comma seprarated arguments in numercal form. Example: + | "M10,20L30,40" + * Here we can see two commands: “M”, with arguments `(10, 20)` and “L” with arguments `(30, 40)`. Upper case letter mean command is absolute, lower case—relative. + * + #

Here is short list of commands available, for more details see SVG path string format.

+ # + # + # + # + # + # + # + # + # + # + # + #
CommandNameParameters
Mmoveto(x y)+
Zclosepath(none)
Llineto(x y)+
Hhorizontal linetox+
Vvertical linetoy+
Ccurveto(x1 y1 x2 y2 x y)+
Ssmooth curveto(x2 y2 x y)+
Qquadratic Bézier curveto(x1 y1 x y)+
Tsmooth quadratic Bézier curveto(x y)+
Aelliptical arc(rx ry x-axis-rotation large-arc-flag sweep-flag x y)+
RCatmull-Rom curveto*x1 y1 (x y)+
+ * * “Catmull-Rom curveto” is a not standard SVG command and added in 2.0 to make life easier. + * Note: there is a special case when path consist of just three commands: “M10,10R…z”. In this case path will smoothly connects to its beginning. + > Usage + | var c = paper.path("M10 10L90 90"); + | // draw a diagonal line: + | // move to 10,10, line to 90,90 + * For example of path strings, check out these icons: http://raphaeljs.com/icons/ + \*/ + paperproto.path = function (pathString) { + pathString && !R.is(pathString, string) && !R.is(pathString[0], array) && (pathString += E); + var out = R._engine.path(R.format[apply](R, arguments), this); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.image + [ method ] + ** + * Embeds an image into the surface. + ** + > Parameters + ** + - src (string) URI of the source image + - x (number) x coordinate position + - y (number) y coordinate position + - width (number) width of the image + - height (number) height of the image + = (object) Raphaël element object with type “image” + ** + > Usage + | var c = paper.image("apple.png", 10, 10, 80, 80); + \*/ + paperproto.image = function (src, x, y, w, h) { + var out = R._engine.image(this, src || "about:blank", x || 0, y || 0, w || 0, h || 0); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.text + [ method ] + ** + * Draws a text string. If you need line breaks, put “\n” in the string. + ** + > Parameters + ** + - x (number) x coordinate position + - y (number) y coordinate position + - text (string) The text string to draw + = (object) Raphaël element object with type “text” + ** + > Usage + | var t = paper.text(50, 50, "Raphaël\nkicks\nbutt!"); + \*/ + paperproto.text = function (x, y, text) { + var out = R._engine.text(this, x || 0, y || 0, Str(text)); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Paper.set + [ method ] + ** + * Creates array-like object to keep and operate several elements at once. + * Warning: it doesn’t create any elements for itself in the page, it just groups existing elements. + * Sets act as pseudo elements — all methods available to an element can be used on a set. + = (object) array-like object that represents set of elements + ** + > Usage + | var st = paper.set(); + | st.push( + | paper.circle(10, 10, 5), + | paper.circle(30, 10, 5) + | ); + | st.attr({fill: "red"}); // changes the fill of both circles + \*/ + paperproto.set = function (itemsArray) { + !R.is(itemsArray, "array") && (itemsArray = Array.prototype.splice.call(arguments, 0, arguments.length)); + var out = new Set(itemsArray); + this.__set__ && this.__set__.push(out); + out["paper"] = this; + out["type"] = "set"; + return out; + }; + /*\ + * Paper.setStart + [ method ] + ** + * Creates @Paper.set. All elements that will be created after calling this method and before calling + * @Paper.setFinish will be added to the set. + ** + > Usage + | paper.setStart(); + | paper.circle(10, 10, 5), + | paper.circle(30, 10, 5) + | var st = paper.setFinish(); + | st.attr({fill: "red"}); // changes the fill of both circles + \*/ + paperproto.setStart = function (set) { + this.__set__ = set || this.set(); + }; + /*\ + * Paper.setFinish + [ method ] + ** + * See @Paper.setStart. This method finishes catching and returns resulting set. + ** + = (object) set + \*/ + paperproto.setFinish = function (set) { + var out = this.__set__; + delete this.__set__; + return out; + }; + /*\ + * Paper.getSize + [ method ] + ** + * Obtains current paper actual size. + ** + = (object) + \*/ + paperproto.getSize = function () { + var container = this.canvas.parentNode; + return { + width: container.offsetWidth, + height: container.offsetHeight + }; + }; + /*\ + * Paper.setSize + [ method ] + ** + * If you need to change dimensions of the canvas call this method + ** + > Parameters + ** + - width (number) new width of the canvas + - height (number) new height of the canvas + \*/ + paperproto.setSize = function (width, height) { + return R._engine.setSize.call(this, width, height); + }; + /*\ + * Paper.setViewBox + [ method ] + ** + * Sets the view box of the paper. Practically it gives you ability to zoom and pan whole paper surface by + * specifying new boundaries. + ** + > Parameters + ** + - x (number) new x position, default is `0` + - y (number) new y position, default is `0` + - w (number) new width of the canvas + - h (number) new height of the canvas + - fit (boolean) `true` if you want graphics to fit into new boundary box + \*/ + paperproto.setViewBox = function (x, y, w, h, fit) { + return R._engine.setViewBox.call(this, x, y, w, h, fit); + }; + /*\ + * Paper.top + [ property ] + ** + * Points to the topmost element on the paper + \*/ + /*\ + * Paper.bottom + [ property ] + ** + * Points to the bottom element on the paper + \*/ + paperproto.top = paperproto.bottom = null; + /*\ + * Paper.raphael + [ property ] + ** + * Points to the @Raphael object/function + \*/ + paperproto.raphael = R; + var getOffset = function (elem) { + var box = elem.getBoundingClientRect(), + doc = elem.ownerDocument, + body = doc.body, + docElem = doc.documentElement, + clientTop = docElem.clientTop || body.clientTop || 0, clientLeft = docElem.clientLeft || body.clientLeft || 0, + top = box.top + (g.win.pageYOffset || docElem.scrollTop || body.scrollTop ) - clientTop, + left = box.left + (g.win.pageXOffset || docElem.scrollLeft || body.scrollLeft) - clientLeft; + return { + y: top, + x: left + }; + }; + /*\ + * Paper.getElementByPoint + [ method ] + ** + * Returns you topmost element under given point. + ** + = (object) Raphaël element object + > Parameters + ** + - x (number) x coordinate from the top left corner of the window + - y (number) y coordinate from the top left corner of the window + > Usage + | paper.getElementByPoint(mouseX, mouseY).attr({stroke: "#f00"}); + \*/ + paperproto.getElementByPoint = function (x, y) { + var paper = this, + svg = paper.canvas, + target = g.doc.elementFromPoint(x, y); + if (g.win.opera && target.tagName == "svg") { + var so = getOffset(svg), + sr = svg.createSVGRect(); + sr.x = x - so.x; + sr.y = y - so.y; + sr.width = sr.height = 1; + var hits = svg.getIntersectionList(sr, null); + if (hits.length) { + target = hits[hits.length - 1]; + } + } + if (!target) { + return null; + } + while (target.parentNode && target != svg.parentNode && !target.raphael) { + target = target.parentNode; + } + target == paper.canvas.parentNode && (target = svg); + target = target && target.raphael ? paper.getById(target.raphaelid) : null; + return target; + }; + + /*\ + * Paper.getElementsByBBox + [ method ] + ** + * Returns set of elements that have an intersecting bounding box + ** + > Parameters + ** + - bbox (object) bbox to check with + = (object) @Set + \*/ + paperproto.getElementsByBBox = function (bbox) { + var set = this.set(); + this.forEach(function (el) { + if (R.isBBoxIntersect(el.getBBox(), bbox)) { + set.push(el); + } + }); + return set; + }; + + /*\ + * Paper.getById + [ method ] + ** + * Returns you element by its internal ID. + ** + > Parameters + ** + - id (number) id + = (object) Raphaël element object + \*/ + paperproto.getById = function (id) { + var bot = this.bottom; + while (bot) { + if (bot.id == id) { + return bot; + } + bot = bot.next; + } + return null; + }; + /*\ + * Paper.forEach + [ method ] + ** + * Executes given function for each element on the paper + * + * If callback function returns `false` it will stop loop running. + ** + > Parameters + ** + - callback (function) function to run + - thisArg (object) context object for the callback + = (object) Paper object + > Usage + | paper.forEach(function (el) { + | el.attr({ stroke: "blue" }); + | }); + \*/ + paperproto.forEach = function (callback, thisArg) { + var bot = this.bottom; + while (bot) { + if (callback.call(thisArg, bot) === false) { + return this; + } + bot = bot.next; + } + return this; + }; + /*\ + * Paper.getElementsByPoint + [ method ] + ** + * Returns set of elements that have common point inside + ** + > Parameters + ** + - x (number) x coordinate of the point + - y (number) y coordinate of the point + = (object) @Set + \*/ + paperproto.getElementsByPoint = function (x, y) { + var set = this.set(); + this.forEach(function (el) { + if (el.isPointInside(x, y)) { + set.push(el); + } + }); + return set; + }; + function x_y() { + return this.x + S + this.y; + } + function x_y_w_h() { + return this.x + S + this.y + S + this.width + " \xd7 " + this.height; + } + /*\ + * Element.isPointInside + [ method ] + ** + * Determine if given point is inside this element’s shape + ** + > Parameters + ** + - x (number) x coordinate of the point + - y (number) y coordinate of the point + = (boolean) `true` if point inside the shape + \*/ + elproto.isPointInside = function (x, y) { + var rp = this.realPath = getPath[this.type](this); + if (this.attr('transform') && this.attr('transform').length) { + rp = R.transformPath(rp, this.attr('transform')); + } + return R.isPointInsidePath(rp, x, y); + }; + /*\ + * Element.getBBox + [ method ] + ** + * Return bounding box for a given element + ** + > Parameters + ** + - isWithoutTransform (boolean) flag, `true` if you want to have bounding box before transformations. Default is `false`. + = (object) Bounding box object: + o { + o x: (number) top left corner x + o y: (number) top left corner y + o x2: (number) bottom right corner x + o y2: (number) bottom right corner y + o width: (number) width + o height: (number) height + o } + \*/ + elproto.getBBox = function (isWithoutTransform) { + if (this.removed) { + return {}; + } + var _ = this._; + if (isWithoutTransform) { + if (_.dirty || !_.bboxwt) { + this.realPath = getPath[this.type](this); + _.bboxwt = pathDimensions(this.realPath); + _.bboxwt.toString = x_y_w_h; + _.dirty = 0; + } + return _.bboxwt; + } + if (_.dirty || _.dirtyT || !_.bbox) { + if (_.dirty || !this.realPath) { + _.bboxwt = 0; + this.realPath = getPath[this.type](this); + } + _.bbox = pathDimensions(mapPath(this.realPath, this.matrix)); + _.bbox.toString = x_y_w_h; + _.dirty = _.dirtyT = 0; + } + return _.bbox; + }; + /*\ + * Element.clone + [ method ] + ** + = (object) clone of a given element + ** + \*/ + elproto.clone = function () { + if (this.removed) { + return null; + } + var out = this.paper[this.type]().attr(this.attr()); + this.__set__ && this.__set__.push(out); + return out; + }; + /*\ + * Element.glow + [ method ] + ** + * Return set of elements that create glow-like effect around given element. See @Paper.set. + * + * Note: Glow is not connected to the element. If you change element attributes it won’t adjust itself. + ** + > Parameters + ** + - glow (object) #optional parameters object with all properties optional: + o { + o width (number) size of the glow, default is `10` + o fill (boolean) will it be filled, default is `false` + o opacity (number) opacity, default is `0.5` + o offsetx (number) horizontal offset, default is `0` + o offsety (number) vertical offset, default is `0` + o color (string) glow colour, default is `black` + o } + = (object) @Paper.set of elements that represents glow + \*/ + elproto.glow = function (glow) { + if (this.type == "text") { + return null; + } + glow = glow || {}; + var s = { + width: (glow.width || 10) + (+this.attr("stroke-width") || 1), + fill: glow.fill || false, + opacity: glow.opacity || .5, + offsetx: glow.offsetx || 0, + offsety: glow.offsety || 0, + color: glow.color || "#000" + }, + c = s.width / 2, + r = this.paper, + out = r.set(), + path = this.realPath || getPath[this.type](this); + path = this.matrix ? mapPath(path, this.matrix) : path; + for (var i = 1; i < c + 1; i++) { + out.push(r.path(path).attr({ + stroke: s.color, + fill: s.fill ? s.color : "none", + "stroke-linejoin": "round", + "stroke-linecap": "round", + "stroke-width": +(s.width / c * i).toFixed(3), + opacity: +(s.opacity / c).toFixed(3) + })); + } + return out.insertBefore(this).translate(s.offsetx, s.offsety); + }; + var curveslengths = {}, + getPointAtSegmentLength = function (p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, length) { + if (length == null) { + return bezlen(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y); + } else { + return R.findDotsAtSegment(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, getTatLen(p1x, p1y, c1x, c1y, c2x, c2y, p2x, p2y, length)); + } + }, + getLengthFactory = function (istotal, subpath) { + return function (path, length, onlystart) { + path = path2curve(path); + var x, y, p, l, sp = "", subpaths = {}, point, + len = 0; + for (var i = 0, ii = path.length; i < ii; i++) { + p = path[i]; + if (p[0] == "M") { + x = +p[1]; + y = +p[2]; + } else { + l = getPointAtSegmentLength(x, y, p[1], p[2], p[3], p[4], p[5], p[6]); + if (len + l > length) { + if (subpath && !subpaths.start) { + point = getPointAtSegmentLength(x, y, p[1], p[2], p[3], p[4], p[5], p[6], length - len); + sp += ["C" + point.start.x, point.start.y, point.m.x, point.m.y, point.x, point.y]; + if (onlystart) {return sp;} + subpaths.start = sp; + sp = ["M" + point.x, point.y + "C" + point.n.x, point.n.y, point.end.x, point.end.y, p[5], p[6]].join(); + len += l; + x = +p[5]; + y = +p[6]; + continue; + } + if (!istotal && !subpath) { + point = getPointAtSegmentLength(x, y, p[1], p[2], p[3], p[4], p[5], p[6], length - len); + return {x: point.x, y: point.y, alpha: point.alpha}; + } + } + len += l; + x = +p[5]; + y = +p[6]; + } + sp += p.shift() + p; + } + subpaths.end = sp; + point = istotal ? len : subpath ? subpaths : R.findDotsAtSegment(x, y, p[0], p[1], p[2], p[3], p[4], p[5], 1); + point.alpha && (point = {x: point.x, y: point.y, alpha: point.alpha}); + return point; + }; + }; + var getTotalLength = getLengthFactory(1), + getPointAtLength = getLengthFactory(), + getSubpathsAtLength = getLengthFactory(0, 1); + /*\ + * Raphael.getTotalLength + [ method ] + ** + * Returns length of the given path in pixels. + ** + > Parameters + ** + - path (string) SVG path string. + ** + = (number) length. + \*/ + R.getTotalLength = getTotalLength; + /*\ + * Raphael.getPointAtLength + [ method ] + ** + * Return coordinates of the point located at the given length on the given path. + ** + > Parameters + ** + - path (string) SVG path string + - length (number) + ** + = (object) representation of the point: + o { + o x: (number) x coordinate + o y: (number) y coordinate + o alpha: (number) angle of derivative + o } + \*/ + R.getPointAtLength = getPointAtLength; + /*\ + * Raphael.getSubpath + [ method ] + ** + * Return subpath of a given path from given length to given length. + ** + > Parameters + ** + - path (string) SVG path string + - from (number) position of the start of the segment + - to (number) position of the end of the segment + ** + = (string) pathstring for the segment + \*/ + R.getSubpath = function (path, from, to) { + if (this.getTotalLength(path) - to < 1e-6) { + return getSubpathsAtLength(path, from).end; + } + var a = getSubpathsAtLength(path, to, 1); + return from ? getSubpathsAtLength(a, from).end : a; + }; + /*\ + * Element.getTotalLength + [ method ] + ** + * Returns length of the path in pixels. Only works for element of “path” type. + = (number) length. + \*/ + elproto.getTotalLength = function () { + var path = this.getPath(); + if (!path) { + return; + } + + if (this.node.getTotalLength) { + return this.node.getTotalLength(); + } + + return getTotalLength(path); + }; + /*\ + * Element.getPointAtLength + [ method ] + ** + * Return coordinates of the point located at the given length on the given path. Only works for element of “path” type. + ** + > Parameters + ** + - length (number) + ** + = (object) representation of the point: + o { + o x: (number) x coordinate + o y: (number) y coordinate + o alpha: (number) angle of derivative + o } + \*/ + elproto.getPointAtLength = function (length) { + var path = this.getPath(); + if (!path) { + return; + } + + return getPointAtLength(path, length); + }; + /*\ + * Element.getPath + [ method ] + ** + * Returns path of the element. Only works for elements of “path” type and simple elements like circle. + = (object) path + ** + \*/ + elproto.getPath = function () { + var path, + getPath = R._getPath[this.type]; + + if (this.type == "text" || this.type == "set") { + return; + } + + if (getPath) { + path = getPath(this); + } + + return path; + }; + /*\ + * Element.getSubpath + [ method ] + ** + * Return subpath of a given element from given length to given length. Only works for element of “path” type. + ** + > Parameters + ** + - from (number) position of the start of the segment + - to (number) position of the end of the segment + ** + = (string) pathstring for the segment + \*/ + elproto.getSubpath = function (from, to) { + var path = this.getPath(); + if (!path) { + return; + } + + return R.getSubpath(path, from, to); + }; + /*\ + * Raphael.easing_formulas + [ property ] + ** + * Object that contains easing formulas for animation. You could extend it with your own. By default it has following list of easing: + #
    + #
  • “linear”
  • + #
  • “<” or “easeIn” or “ease-in”
  • + #
  • “>” or “easeOut” or “ease-out”
  • + #
  • “<>” or “easeInOut” or “ease-in-out”
  • + #
  • “backIn” or “back-in”
  • + #
  • “backOut” or “back-out”
  • + #
  • “elastic”
  • + #
  • “bounce”
  • + #
+ #

See also Easing demo.

+ \*/ + var ef = R.easing_formulas = { + linear: function (n) { + return n; + }, + "<": function (n) { + return pow(n, 1.7); + }, + ">": function (n) { + return pow(n, .48); + }, + "<>": function (n) { + var q = .48 - n / 1.04, + Q = math.sqrt(.1734 + q * q), + x = Q - q, + X = pow(abs(x), 1 / 3) * (x < 0 ? -1 : 1), + y = -Q - q, + Y = pow(abs(y), 1 / 3) * (y < 0 ? -1 : 1), + t = X + Y + .5; + return (1 - t) * 3 * t * t + t * t * t; + }, + backIn: function (n) { + var s = 1.70158; + return n * n * ((s + 1) * n - s); + }, + backOut: function (n) { + n = n - 1; + var s = 1.70158; + return n * n * ((s + 1) * n + s) + 1; + }, + elastic: function (n) { + if (n == !!n) { + return n; + } + return pow(2, -10 * n) * math.sin((n - .075) * (2 * PI) / .3) + 1; + }, + bounce: function (n) { + var s = 7.5625, + p = 2.75, + l; + if (n < (1 / p)) { + l = s * n * n; + } else { + if (n < (2 / p)) { + n -= (1.5 / p); + l = s * n * n + .75; + } else { + if (n < (2.5 / p)) { + n -= (2.25 / p); + l = s * n * n + .9375; + } else { + n -= (2.625 / p); + l = s * n * n + .984375; + } + } + } + return l; + } + }; + ef.easeIn = ef["ease-in"] = ef["<"]; + ef.easeOut = ef["ease-out"] = ef[">"]; + ef.easeInOut = ef["ease-in-out"] = ef["<>"]; + ef["back-in"] = ef.backIn; + ef["back-out"] = ef.backOut; + + var animationElements = [], + requestAnimFrame = window.requestAnimationFrame || + window.webkitRequestAnimationFrame || + window.mozRequestAnimationFrame || + window.oRequestAnimationFrame || + window.msRequestAnimationFrame || + function (callback) { + setTimeout(callback, 16); + }, + animation = function () { + var Now = +new Date, + l = 0; + for (; l < animationElements.length; l++) { + var e = animationElements[l]; + if (e.el.removed || e.paused) { + continue; + } + var time = Now - e.start, + ms = e.ms, + easing = e.easing, + from = e.from, + diff = e.diff, + to = e.to, + t = e.t, + that = e.el, + set = {}, + now, + init = {}, + key; + if (e.initstatus) { + time = (e.initstatus * e.anim.top - e.prev) / (e.percent - e.prev) * ms; + e.status = e.initstatus; + delete e.initstatus; + e.stop && animationElements.splice(l--, 1); + } else { + e.status = (e.prev + (e.percent - e.prev) * (time / ms)) / e.anim.top; + } + if (time < 0) { + continue; + } + if (time < ms) { + var pos = easing(time / ms); + for (var attr in from) if (from[has](attr)) { + switch (availableAnimAttrs[attr]) { + case nu: + now = +from[attr] + pos * ms * diff[attr]; + break; + case "colour": + now = "rgb(" + [ + upto255(round(from[attr].r + pos * ms * diff[attr].r)), + upto255(round(from[attr].g + pos * ms * diff[attr].g)), + upto255(round(from[attr].b + pos * ms * diff[attr].b)) + ].join(",") + ")"; + break; + case "path": + now = []; + for (var i = 0, ii = from[attr].length; i < ii; i++) { + now[i] = [from[attr][i][0]]; + for (var j = 1, jj = from[attr][i].length; j < jj; j++) { + now[i][j] = +from[attr][i][j] + pos * ms * diff[attr][i][j]; + } + now[i] = now[i].join(S); + } + now = now.join(S); + break; + case "transform": + if (diff[attr].real) { + now = []; + for (i = 0, ii = from[attr].length; i < ii; i++) { + now[i] = [from[attr][i][0]]; + for (j = 1, jj = from[attr][i].length; j < jj; j++) { + now[i][j] = from[attr][i][j] + pos * ms * diff[attr][i][j]; + } + } + } else { + var get = function (i) { + return +from[attr][i] + pos * ms * diff[attr][i]; + }; + // now = [["r", get(2), 0, 0], ["t", get(3), get(4)], ["s", get(0), get(1), 0, 0]]; + now = [["m", get(0), get(1), get(2), get(3), get(4), get(5)]]; + } + break; + case "csv": + if (attr == "clip-rect") { + now = []; + i = 4; + while (i--) { + now[i] = +from[attr][i] + pos * ms * diff[attr][i]; + } + } + break; + default: + var from2 = [][concat](from[attr]); + now = []; + i = that.paper.customAttributes[attr].length; + while (i--) { + now[i] = +from2[i] + pos * ms * diff[attr][i]; + } + break; + } + set[attr] = now; + } + that.attr(set); + (function (id, that, anim) { + setTimeout(function () { + eve("raphael.anim.frame." + id, that, anim); + }); + })(that.id, that, e.anim); + } else { + (function(f, el, a) { + setTimeout(function() { + eve("raphael.anim.frame." + el.id, el, a); + eve("raphael.anim.finish." + el.id, el, a); + R.is(f, "function") && f.call(el); + }); + })(e.callback, that, e.anim); + that.attr(to); + animationElements.splice(l--, 1); + if (e.repeat > 1 && !e.next) { + for (key in to) if (to[has](key)) { + init[key] = e.totalOrigin[key]; + } + e.el.attr(init); + runAnimation(e.anim, e.el, e.anim.percents[0], null, e.totalOrigin, e.repeat - 1); + } + if (e.next && !e.stop) { + runAnimation(e.anim, e.el, e.next, null, e.totalOrigin, e.repeat); + } + } + } + R.svg && that && that.paper && that.paper.safari(); + animationElements.length && requestAnimFrame(animation); + }, + upto255 = function (color) { + return color > 255 ? 255 : color < 0 ? 0 : color; + }; + /*\ + * Element.animateWith + [ method ] + ** + * Acts similar to @Element.animate, but ensure that given animation runs in sync with another given element. + ** + > Parameters + ** + - el (object) element to sync with + - anim (object) animation to sync with + - params (object) #optional final attributes for the element, see also @Element.attr + - ms (number) #optional number of milliseconds for animation to run + - easing (string) #optional easing type. Accept on of @Raphael.easing_formulas or CSS format: `cubic‐bezier(XX, XX, XX, XX)` + - callback (function) #optional callback function. Will be called at the end of animation. + * or + - element (object) element to sync with + - anim (object) animation to sync with + - animation (object) #optional animation object, see @Raphael.animation + ** + = (object) original element + \*/ + elproto.animateWith = function (el, anim, params, ms, easing, callback) { + var element = this; + if (element.removed) { + callback && callback.call(element); + return element; + } + var a = params instanceof Animation ? params : R.animation(params, ms, easing, callback), + x, y; + runAnimation(a, element, a.percents[0], null, element.attr()); + for (var i = 0, ii = animationElements.length; i < ii; i++) { + if (animationElements[i].anim == anim && animationElements[i].el == el) { + animationElements[ii - 1].start = animationElements[i].start; + break; + } + } + return element; + // + // + // var a = params ? R.animation(params, ms, easing, callback) : anim, + // status = element.status(anim); + // return this.animate(a).status(a, status * anim.ms / a.ms); + }; + function CubicBezierAtTime(t, p1x, p1y, p2x, p2y, duration) { + var cx = 3 * p1x, + bx = 3 * (p2x - p1x) - cx, + ax = 1 - cx - bx, + cy = 3 * p1y, + by = 3 * (p2y - p1y) - cy, + ay = 1 - cy - by; + function sampleCurveX(t) { + return ((ax * t + bx) * t + cx) * t; + } + function solve(x, epsilon) { + var t = solveCurveX(x, epsilon); + return ((ay * t + by) * t + cy) * t; + } + function solveCurveX(x, epsilon) { + var t0, t1, t2, x2, d2, i; + for(t2 = x, i = 0; i < 8; i++) { + x2 = sampleCurveX(t2) - x; + if (abs(x2) < epsilon) { + return t2; + } + d2 = (3 * ax * t2 + 2 * bx) * t2 + cx; + if (abs(d2) < 1e-6) { + break; + } + t2 = t2 - x2 / d2; + } + t0 = 0; + t1 = 1; + t2 = x; + if (t2 < t0) { + return t0; + } + if (t2 > t1) { + return t1; + } + while (t0 < t1) { + x2 = sampleCurveX(t2); + if (abs(x2 - x) < epsilon) { + return t2; + } + if (x > x2) { + t0 = t2; + } else { + t1 = t2; + } + t2 = (t1 - t0) / 2 + t0; + } + return t2; + } + return solve(t, 1 / (200 * duration)); + } + elproto.onAnimation = function (f) { + f ? eve.on("raphael.anim.frame." + this.id, f) : eve.unbind("raphael.anim.frame." + this.id); + return this; + }; + function Animation(anim, ms) { + var percents = [], + newAnim = {}; + this.ms = ms; + this.times = 1; + if (anim) { + for (var attr in anim) if (anim[has](attr)) { + newAnim[toFloat(attr)] = anim[attr]; + percents.push(toFloat(attr)); + } + percents.sort(sortByNumber); + } + this.anim = newAnim; + this.top = percents[percents.length - 1]; + this.percents = percents; + } + /*\ + * Animation.delay + [ method ] + ** + * Creates a copy of existing animation object with given delay. + ** + > Parameters + ** + - delay (number) number of ms to pass between animation start and actual animation + ** + = (object) new altered Animation object + | var anim = Raphael.animation({cx: 10, cy: 20}, 2e3); + | circle1.animate(anim); // run the given animation immediately + | circle2.animate(anim.delay(500)); // run the given animation after 500 ms + \*/ + Animation.prototype.delay = function (delay) { + var a = new Animation(this.anim, this.ms); + a.times = this.times; + a.del = +delay || 0; + return a; + }; + /*\ + * Animation.repeat + [ method ] + ** + * Creates a copy of existing animation object with given repetition. + ** + > Parameters + ** + - repeat (number) number iterations of animation. For infinite animation pass `Infinity` + ** + = (object) new altered Animation object + \*/ + Animation.prototype.repeat = function (times) { + var a = new Animation(this.anim, this.ms); + a.del = this.del; + a.times = math.floor(mmax(times, 0)) || 1; + return a; + }; + function runAnimation(anim, element, percent, status, totalOrigin, times) { + percent = toFloat(percent); + var params, + isInAnim, + isInAnimSet, + percents = [], + next, + prev, + timestamp, + ms = anim.ms, + from = {}, + to = {}, + diff = {}; + if (status) { + for (i = 0, ii = animationElements.length; i < ii; i++) { + var e = animationElements[i]; + if (e.el.id == element.id && e.anim == anim) { + if (e.percent != percent) { + animationElements.splice(i, 1); + isInAnimSet = 1; + } else { + isInAnim = e; + } + element.attr(e.totalOrigin); + break; + } + } + } else { + status = +to; // NaN + } + for (var i = 0, ii = anim.percents.length; i < ii; i++) { + if (anim.percents[i] == percent || anim.percents[i] > status * anim.top) { + percent = anim.percents[i]; + prev = anim.percents[i - 1] || 0; + ms = ms / anim.top * (percent - prev); + next = anim.percents[i + 1]; + params = anim.anim[percent]; + break; + } else if (status) { + element.attr(anim.anim[anim.percents[i]]); + } + } + if (!params) { + return; + } + if (!isInAnim) { + for (var attr in params) if (params[has](attr)) { + if (availableAnimAttrs[has](attr) || element.paper.customAttributes[has](attr)) { + from[attr] = element.attr(attr); + (from[attr] == null) && (from[attr] = availableAttrs[attr]); + to[attr] = params[attr]; + switch (availableAnimAttrs[attr]) { + case nu: + diff[attr] = (to[attr] - from[attr]) / ms; + break; + case "colour": + from[attr] = R.getRGB(from[attr]); + var toColour = R.getRGB(to[attr]); + diff[attr] = { + r: (toColour.r - from[attr].r) / ms, + g: (toColour.g - from[attr].g) / ms, + b: (toColour.b - from[attr].b) / ms + }; + break; + case "path": + var pathes = path2curve(from[attr], to[attr]), + toPath = pathes[1]; + from[attr] = pathes[0]; + diff[attr] = []; + for (i = 0, ii = from[attr].length; i < ii; i++) { + diff[attr][i] = [0]; + for (var j = 1, jj = from[attr][i].length; j < jj; j++) { + diff[attr][i][j] = (toPath[i][j] - from[attr][i][j]) / ms; + } + } + break; + case "transform": + var _ = element._, + eq = equaliseTransform(_[attr], to[attr]); + if (eq) { + from[attr] = eq.from; + to[attr] = eq.to; + diff[attr] = []; + diff[attr].real = true; + for (i = 0, ii = from[attr].length; i < ii; i++) { + diff[attr][i] = [from[attr][i][0]]; + for (j = 1, jj = from[attr][i].length; j < jj; j++) { + diff[attr][i][j] = (to[attr][i][j] - from[attr][i][j]) / ms; + } + } + } else { + var m = (element.matrix || new Matrix), + to2 = { + _: {transform: _.transform}, + getBBox: function () { + return element.getBBox(1); + } + }; + from[attr] = [ + m.a, + m.b, + m.c, + m.d, + m.e, + m.f + ]; + extractTransform(to2, to[attr]); + to[attr] = to2._.transform; + diff[attr] = [ + (to2.matrix.a - m.a) / ms, + (to2.matrix.b - m.b) / ms, + (to2.matrix.c - m.c) / ms, + (to2.matrix.d - m.d) / ms, + (to2.matrix.e - m.e) / ms, + (to2.matrix.f - m.f) / ms + ]; + // from[attr] = [_.sx, _.sy, _.deg, _.dx, _.dy]; + // var to2 = {_:{}, getBBox: function () { return element.getBBox(); }}; + // extractTransform(to2, to[attr]); + // diff[attr] = [ + // (to2._.sx - _.sx) / ms, + // (to2._.sy - _.sy) / ms, + // (to2._.deg - _.deg) / ms, + // (to2._.dx - _.dx) / ms, + // (to2._.dy - _.dy) / ms + // ]; + } + break; + case "csv": + var values = Str(params[attr])[split](separator), + from2 = Str(from[attr])[split](separator); + if (attr == "clip-rect") { + from[attr] = from2; + diff[attr] = []; + i = from2.length; + while (i--) { + diff[attr][i] = (values[i] - from[attr][i]) / ms; + } + } + to[attr] = values; + break; + default: + values = [][concat](params[attr]); + from2 = [][concat](from[attr]); + diff[attr] = []; + i = element.paper.customAttributes[attr].length; + while (i--) { + diff[attr][i] = ((values[i] || 0) - (from2[i] || 0)) / ms; + } + break; + } + } + } + var easing = params.easing, + easyeasy = R.easing_formulas[easing]; + if (!easyeasy) { + easyeasy = Str(easing).match(bezierrg); + if (easyeasy && easyeasy.length == 5) { + var curve = easyeasy; + easyeasy = function (t) { + return CubicBezierAtTime(t, +curve[1], +curve[2], +curve[3], +curve[4], ms); + }; + } else { + easyeasy = pipe; + } + } + timestamp = params.start || anim.start || +new Date; + e = { + anim: anim, + percent: percent, + timestamp: timestamp, + start: timestamp + (anim.del || 0), + status: 0, + initstatus: status || 0, + stop: false, + ms: ms, + easing: easyeasy, + from: from, + diff: diff, + to: to, + el: element, + callback: params.callback, + prev: prev, + next: next, + repeat: times || anim.times, + origin: element.attr(), + totalOrigin: totalOrigin + }; + animationElements.push(e); + if (status && !isInAnim && !isInAnimSet) { + e.stop = true; + e.start = new Date - ms * status; + if (animationElements.length == 1) { + return animation(); + } + } + if (isInAnimSet) { + e.start = new Date - e.ms * status; + } + animationElements.length == 1 && requestAnimFrame(animation); + } else { + isInAnim.initstatus = status; + isInAnim.start = new Date - isInAnim.ms * status; + } + eve("raphael.anim.start." + element.id, element, anim); + } + /*\ + * Raphael.animation + [ method ] + ** + * Creates an animation object that can be passed to the @Element.animate or @Element.animateWith methods. + * See also @Animation.delay and @Animation.repeat methods. + ** + > Parameters + ** + - params (object) final attributes for the element, see also @Element.attr + - ms (number) number of milliseconds for animation to run + - easing (string) #optional easing type. Accept one of @Raphael.easing_formulas or CSS format: `cubic‐bezier(XX, XX, XX, XX)` + - callback (function) #optional callback function. Will be called at the end of animation. + ** + = (object) @Animation + \*/ + R.animation = function (params, ms, easing, callback) { + if (params instanceof Animation) { + return params; + } + if (R.is(easing, "function") || !easing) { + callback = callback || easing || null; + easing = null; + } + params = Object(params); + ms = +ms || 0; + var p = {}, + json, + attr; + for (attr in params) if (params[has](attr) && toFloat(attr) != attr && toFloat(attr) + "%" != attr) { + json = true; + p[attr] = params[attr]; + } + if (!json) { + // if percent-like syntax is used and end-of-all animation callback used + if(callback){ + // find the last one + var lastKey = 0; + for(var i in params){ + var percent = toInt(i); + if(params[has](i) && percent > lastKey){ + lastKey = percent; + } + } + lastKey += '%'; + // if already defined callback in the last keyframe, skip + !params[lastKey].callback && (params[lastKey].callback = callback); + } + return new Animation(params, ms); + } else { + easing && (p.easing = easing); + callback && (p.callback = callback); + return new Animation({100: p}, ms); + } + }; + /*\ + * Element.animate + [ method ] + ** + * Creates and starts animation for given element. + ** + > Parameters + ** + - params (object) final attributes for the element, see also @Element.attr + - ms (number) number of milliseconds for animation to run + - easing (string) #optional easing type. Accept one of @Raphael.easing_formulas or CSS format: `cubic‐bezier(XX, XX, XX, XX)` + - callback (function) #optional callback function. Will be called at the end of animation. + * or + - animation (object) animation object, see @Raphael.animation + ** + = (object) original element + \*/ + elproto.animate = function (params, ms, easing, callback) { + var element = this; + if (element.removed) { + callback && callback.call(element); + return element; + } + var anim = params instanceof Animation ? params : R.animation(params, ms, easing, callback); + runAnimation(anim, element, anim.percents[0], null, element.attr()); + return element; + }; + /*\ + * Element.setTime + [ method ] + ** + * Sets the status of animation of the element in milliseconds. Similar to @Element.status method. + ** + > Parameters + ** + - anim (object) animation object + - value (number) number of milliseconds from the beginning of the animation + ** + = (object) original element if `value` is specified + * Note, that during animation following events are triggered: + * + * On each animation frame event `anim.frame.`, on start `anim.start.` and on end `anim.finish.`. + \*/ + elproto.setTime = function (anim, value) { + if (anim && value != null) { + this.status(anim, mmin(value, anim.ms) / anim.ms); + } + return this; + }; + /*\ + * Element.status + [ method ] + ** + * Gets or sets the status of animation of the element. + ** + > Parameters + ** + - anim (object) #optional animation object + - value (number) #optional 0 – 1. If specified, method works like a setter and sets the status of a given animation to the value. This will cause animation to jump to the given position. + ** + = (number) status + * or + = (array) status if `anim` is not specified. Array of objects in format: + o { + o anim: (object) animation object + o status: (number) status + o } + * or + = (object) original element if `value` is specified + \*/ + elproto.status = function (anim, value) { + var out = [], + i = 0, + len, + e; + if (value != null) { + runAnimation(anim, this, -1, mmin(value, 1)); + return this; + } else { + len = animationElements.length; + for (; i < len; i++) { + e = animationElements[i]; + if (e.el.id == this.id && (!anim || e.anim == anim)) { + if (anim) { + return e.status; + } + out.push({ + anim: e.anim, + status: e.status + }); + } + } + if (anim) { + return 0; + } + return out; + } + }; + /*\ + * Element.pause + [ method ] + ** + * Stops animation of the element with ability to resume it later on. + ** + > Parameters + ** + - anim (object) #optional animation object + ** + = (object) original element + \*/ + elproto.pause = function (anim) { + for (var i = 0; i < animationElements.length; i++) if (animationElements[i].el.id == this.id && (!anim || animationElements[i].anim == anim)) { + if (eve("raphael.anim.pause." + this.id, this, animationElements[i].anim) !== false) { + animationElements[i].paused = true; + } + } + return this; + }; + /*\ + * Element.resume + [ method ] + ** + * Resumes animation if it was paused with @Element.pause method. + ** + > Parameters + ** + - anim (object) #optional animation object + ** + = (object) original element + \*/ + elproto.resume = function (anim) { + for (var i = 0; i < animationElements.length; i++) if (animationElements[i].el.id == this.id && (!anim || animationElements[i].anim == anim)) { + var e = animationElements[i]; + if (eve("raphael.anim.resume." + this.id, this, e.anim) !== false) { + delete e.paused; + this.status(e.anim, e.status); + } + } + return this; + }; + /*\ + * Element.stop + [ method ] + ** + * Stops animation of the element. + ** + > Parameters + ** + - anim (object) #optional animation object + ** + = (object) original element + \*/ + elproto.stop = function (anim) { + for (var i = 0; i < animationElements.length; i++) if (animationElements[i].el.id == this.id && (!anim || animationElements[i].anim == anim)) { + if (eve("raphael.anim.stop." + this.id, this, animationElements[i].anim) !== false) { + animationElements.splice(i--, 1); + } + } + return this; + }; + function stopAnimation(paper) { + for (var i = 0; i < animationElements.length; i++) if (animationElements[i].el.paper == paper) { + animationElements.splice(i--, 1); + } + } + eve.on("raphael.remove", stopAnimation); + eve.on("raphael.clear", stopAnimation); + elproto.toString = function () { + return "Rapha\xebl\u2019s object"; + }; + + // Set + var Set = function (items) { + this.items = []; + this.length = 0; + this.type = "set"; + if (items) { + for (var i = 0, ii = items.length; i < ii; i++) { + if (items[i] && (items[i].constructor == elproto.constructor || items[i].constructor == Set)) { + this[this.items.length] = this.items[this.items.length] = items[i]; + this.length++; + } + } + } + }, + setproto = Set.prototype; + /*\ + * Set.push + [ method ] + ** + * Adds each argument to the current set. + = (object) original element + \*/ + setproto.push = function () { + var item, + len; + for (var i = 0, ii = arguments.length; i < ii; i++) { + item = arguments[i]; + if (item && (item.constructor == elproto.constructor || item.constructor == Set)) { + len = this.items.length; + this[len] = this.items[len] = item; + this.length++; + } + } + return this; + }; + /*\ + * Set.pop + [ method ] + ** + * Removes last element and returns it. + = (object) element + \*/ + setproto.pop = function () { + this.length && delete this[this.length--]; + return this.items.pop(); + }; + /*\ + * Set.forEach + [ method ] + ** + * Executes given function for each element in the set. + * + * If function returns `false` it will stop loop running. + ** + > Parameters + ** + - callback (function) function to run + - thisArg (object) context object for the callback + = (object) Set object + \*/ + setproto.forEach = function (callback, thisArg) { + for (var i = 0, ii = this.items.length; i < ii; i++) { + if (callback.call(thisArg, this.items[i], i) === false) { + return this; + } + } + return this; + }; + for (var method in elproto) if (elproto[has](method)) { + setproto[method] = (function (methodname) { + return function () { + var arg = arguments; + return this.forEach(function (el) { + el[methodname][apply](el, arg); + }); + }; + })(method); + } + setproto.attr = function (name, value) { + if (name && R.is(name, array) && R.is(name[0], "object")) { + for (var j = 0, jj = name.length; j < jj; j++) { + this.items[j].attr(name[j]); + } + } else { + for (var i = 0, ii = this.items.length; i < ii; i++) { + this.items[i].attr(name, value); + } + } + return this; + }; + /*\ + * Set.clear + [ method ] + ** + * Removes all elements from the set + \*/ + setproto.clear = function () { + while (this.length) { + this.pop(); + } + }; + /*\ + * Set.splice + [ method ] + ** + * Removes given element from the set + ** + > Parameters + ** + - index (number) position of the deletion + - count (number) number of element to remove + - insertion… (object) #optional elements to insert + = (object) set elements that were deleted + \*/ + setproto.splice = function (index, count, insertion) { + index = index < 0 ? mmax(this.length + index, 0) : index; + count = mmax(0, mmin(this.length - index, count)); + var tail = [], + todel = [], + args = [], + i; + for (i = 2; i < arguments.length; i++) { + args.push(arguments[i]); + } + for (i = 0; i < count; i++) { + todel.push(this[index + i]); + } + for (; i < this.length - index; i++) { + tail.push(this[index + i]); + } + var arglen = args.length; + for (i = 0; i < arglen + tail.length; i++) { + this.items[index + i] = this[index + i] = i < arglen ? args[i] : tail[i - arglen]; + } + i = this.items.length = this.length -= count - arglen; + while (this[i]) { + delete this[i++]; + } + return new Set(todel); + }; + /*\ + * Set.exclude + [ method ] + ** + * Removes given element from the set + ** + > Parameters + ** + - element (object) element to remove + = (boolean) `true` if object was found & removed from the set + \*/ + setproto.exclude = function (el) { + for (var i = 0, ii = this.length; i < ii; i++) if (this[i] == el) { + this.splice(i, 1); + return true; + } + }; + setproto.animate = function (params, ms, easing, callback) { + (R.is(easing, "function") || !easing) && (callback = easing || null); + var len = this.items.length, + i = len, + item, + set = this, + collector; + if (!len) { + return this; + } + callback && (collector = function () { + !--len && callback.call(set); + }); + easing = R.is(easing, string) ? easing : collector; + var anim = R.animation(params, ms, easing, collector); + item = this.items[--i].animate(anim); + while (i--) { + this.items[i] && !this.items[i].removed && this.items[i].animateWith(item, anim, anim); + (this.items[i] && !this.items[i].removed) || len--; + } + return this; + }; + setproto.insertAfter = function (el) { + var i = this.items.length; + while (i--) { + this.items[i].insertAfter(el); + } + return this; + }; + setproto.getBBox = function () { + var x = [], + y = [], + x2 = [], + y2 = []; + for (var i = this.items.length; i--;) if (!this.items[i].removed) { + var box = this.items[i].getBBox(); + x.push(box.x); + y.push(box.y); + x2.push(box.x + box.width); + y2.push(box.y + box.height); + } + x = mmin[apply](0, x); + y = mmin[apply](0, y); + x2 = mmax[apply](0, x2); + y2 = mmax[apply](0, y2); + return { + x: x, + y: y, + x2: x2, + y2: y2, + width: x2 - x, + height: y2 - y + }; + }; + setproto.clone = function (s) { + s = this.paper.set(); + for (var i = 0, ii = this.items.length; i < ii; i++) { + s.push(this.items[i].clone()); + } + return s; + }; + setproto.toString = function () { + return "Rapha\xebl\u2018s set"; + }; + + setproto.glow = function(glowConfig) { + var ret = this.paper.set(); + this.forEach(function(shape, index){ + var g = shape.glow(glowConfig); + if(g != null){ + g.forEach(function(shape2, index2){ + ret.push(shape2); + }); + } + }); + return ret; + }; + + + /*\ + * Set.isPointInside + [ method ] + ** + * Determine if given point is inside this set’s elements + ** + > Parameters + ** + - x (number) x coordinate of the point + - y (number) y coordinate of the point + = (boolean) `true` if point is inside any of the set's elements + \*/ + setproto.isPointInside = function (x, y) { + var isPointInside = false; + this.forEach(function (el) { + if (el.isPointInside(x, y)) { + isPointInside = true; + return false; // stop loop + } + }); + return isPointInside; + }; + + /*\ + * Raphael.registerFont + [ method ] + ** + * Adds given font to the registered set of fonts for Raphaël. Should be used as an internal call from within Cufón’s font file. + * Returns original parameter, so it could be used with chaining. + # More about Cufón and how to convert your font form TTF, OTF, etc to JavaScript file. + ** + > Parameters + ** + - font (object) the font to register + = (object) the font you passed in + > Usage + | Cufon.registerFont(Raphael.registerFont({…})); + \*/ + R.registerFont = function (font) { + if (!font.face) { + return font; + } + this.fonts = this.fonts || {}; + var fontcopy = { + w: font.w, + face: {}, + glyphs: {} + }, + family = font.face["font-family"]; + for (var prop in font.face) if (font.face[has](prop)) { + fontcopy.face[prop] = font.face[prop]; + } + if (this.fonts[family]) { + this.fonts[family].push(fontcopy); + } else { + this.fonts[family] = [fontcopy]; + } + if (!font.svg) { + fontcopy.face["units-per-em"] = toInt(font.face["units-per-em"], 10); + for (var glyph in font.glyphs) if (font.glyphs[has](glyph)) { + var path = font.glyphs[glyph]; + fontcopy.glyphs[glyph] = { + w: path.w, + k: {}, + d: path.d && "M" + path.d.replace(/[mlcxtrv]/g, function (command) { + return {l: "L", c: "C", x: "z", t: "m", r: "l", v: "c"}[command] || "M"; + }) + "z" + }; + if (path.k) { + for (var k in path.k) if (path[has](k)) { + fontcopy.glyphs[glyph].k[k] = path.k[k]; + } + } + } + } + return font; + }; + /*\ + * Paper.getFont + [ method ] + ** + * Finds font object in the registered fonts by given parameters. You could specify only one word from the font name, like “Myriad” for “Myriad Pro”. + ** + > Parameters + ** + - family (string) font family name or any word from it + - weight (string) #optional font weight + - style (string) #optional font style + - stretch (string) #optional font stretch + = (object) the font object + > Usage + | paper.print(100, 100, "Test string", paper.getFont("Times", 800), 30); + \*/ + paperproto.getFont = function (family, weight, style, stretch) { + stretch = stretch || "normal"; + style = style || "normal"; + weight = +weight || {normal: 400, bold: 700, lighter: 300, bolder: 800}[weight] || 400; + if (!R.fonts) { + return; + } + var font = R.fonts[family]; + if (!font) { + var name = new RegExp("(^|\\s)" + family.replace(/[^\w\d\s+!~.:_-]/g, E) + "(\\s|$)", "i"); + for (var fontName in R.fonts) if (R.fonts[has](fontName)) { + if (name.test(fontName)) { + font = R.fonts[fontName]; + break; + } + } + } + var thefont; + if (font) { + for (var i = 0, ii = font.length; i < ii; i++) { + thefont = font[i]; + if (thefont.face["font-weight"] == weight && (thefont.face["font-style"] == style || !thefont.face["font-style"]) && thefont.face["font-stretch"] == stretch) { + break; + } + } + } + return thefont; + }; + /*\ + * Paper.print + [ method ] + ** + * Creates path that represent given text written using given font at given position with given size. + * Result of the method is path element that contains whole text as a separate path. + ** + > Parameters + ** + - x (number) x position of the text + - y (number) y position of the text + - string (string) text to print + - font (object) font object, see @Paper.getFont + - size (number) #optional size of the font, default is `16` + - origin (string) #optional could be `"baseline"` or `"middle"`, default is `"middle"` + - letter_spacing (number) #optional number in range `-1..1`, default is `0` + - line_spacing (number) #optional number in range `1..3`, default is `1` + = (object) resulting path element, which consist of all letters + > Usage + | var txt = r.print(10, 50, "print", r.getFont("Museo"), 30).attr({fill: "#fff"}); + \*/ + paperproto.print = function (x, y, string, font, size, origin, letter_spacing, line_spacing) { + origin = origin || "middle"; // baseline|middle + letter_spacing = mmax(mmin(letter_spacing || 0, 1), -1); + line_spacing = mmax(mmin(line_spacing || 1, 3), 1); + var letters = Str(string)[split](E), + shift = 0, + notfirst = 0, + path = E, + scale; + R.is(font, "string") && (font = this.getFont(font)); + if (font) { + scale = (size || 16) / font.face["units-per-em"]; + var bb = font.face.bbox[split](separator), + top = +bb[0], + lineHeight = bb[3] - bb[1], + shifty = 0, + height = +bb[1] + (origin == "baseline" ? lineHeight + (+font.face.descent) : lineHeight / 2); + for (var i = 0, ii = letters.length; i < ii; i++) { + if (letters[i] == "\n") { + shift = 0; + curr = 0; + notfirst = 0; + shifty += lineHeight * line_spacing; + } else { + var prev = notfirst && font.glyphs[letters[i - 1]] || {}, + curr = font.glyphs[letters[i]]; + shift += notfirst ? (prev.w || font.w) + (prev.k && prev.k[letters[i]] || 0) + (font.w * letter_spacing) : 0; + notfirst = 1; + } + if (curr && curr.d) { + path += R.transformPath(curr.d, ["t", shift * scale, shifty * scale, "s", scale, scale, top, height, "t", (x - top) / scale, (y - height) / scale]); + } + } + } + return this.path(path).attr({ + fill: "#000", + stroke: "none" + }); + }; + + /*\ + * Paper.add + [ method ] + ** + * Imports elements in JSON array in format `{type: type, }` + ** + > Parameters + ** + - json (array) + = (object) resulting set of imported elements + > Usage + | paper.add([ + | { + | type: "circle", + | cx: 10, + | cy: 10, + | r: 5 + | }, + | { + | type: "rect", + | x: 10, + | y: 10, + | width: 10, + | height: 10, + | fill: "#fc0" + | } + | ]); + \*/ + paperproto.add = function (json) { + if (R.is(json, "array")) { + var res = this.set(), + i = 0, + ii = json.length, + j; + for (; i < ii; i++) { + j = json[i] || {}; + elements[has](j.type) && res.push(this[j.type]().attr(j)); + } + } + return res; + }; + + /*\ + * Raphael.format + [ method ] + ** + * Simple format function. Replaces construction of type “`{}`” to the corresponding argument. + ** + > Parameters + ** + - token (string) string to format + - … (string) rest of arguments will be treated as parameters for replacement + = (string) formated string + > Usage + | var x = 10, + | y = 20, + | width = 40, + | height = 50; + | // this will draw a rectangular shape equivalent to "M10,20h40v50h-40z" + | paper.path(Raphael.format("M{0},{1}h{2}v{3}h{4}z", x, y, width, height, -width)); + \*/ + R.format = function (token, params) { + var args = R.is(params, array) ? [0][concat](params) : arguments; + token && R.is(token, string) && args.length - 1 && (token = token.replace(formatrg, function (str, i) { + return args[++i] == null ? E : args[i]; + })); + return token || E; + }; + /*\ + * Raphael.fullfill + [ method ] + ** + * A little bit more advanced format function than @Raphael.format. Replaces construction of type “`{}`” to the corresponding argument. + ** + > Parameters + ** + - token (string) string to format + - json (object) object which properties will be used as a replacement + = (string) formated string + > Usage + | // this will draw a rectangular shape equivalent to "M10,20h40v50h-40z" + | paper.path(Raphael.fullfill("M{x},{y}h{dim.width}v{dim.height}h{dim['negative width']}z", { + | x: 10, + | y: 20, + | dim: { + | width: 40, + | height: 50, + | "negative width": -40 + | } + | })); + \*/ + R.fullfill = (function () { + var tokenRegex = /\{([^\}]+)\}/g, + objNotationRegex = /(?:(?:^|\.)(.+?)(?=\[|\.|$|\()|\[('|")(.+?)\2\])(\(\))?/g, // matches .xxxxx or ["xxxxx"] to run over object properties + replacer = function (all, key, obj) { + var res = obj; + key.replace(objNotationRegex, function (all, name, quote, quotedName, isFunc) { + name = name || quotedName; + if (res) { + if (name in res) { + res = res[name]; + } + typeof res == "function" && isFunc && (res = res()); + } + }); + res = (res == null || res == obj ? all : res) + ""; + return res; + }; + return function (str, obj) { + return String(str).replace(tokenRegex, function (all, key) { + return replacer(all, key, obj); + }); + }; + })(); + /*\ + * Raphael.ninja + [ method ] + ** + * If you want to leave no trace of Raphaël (Well, Raphaël creates only one global variable `Raphael`, but anyway.) You can use `ninja` method. + * Beware, that in this case plugins could stop working, because they are depending on global variable existance. + ** + = (object) Raphael object + > Usage + | (function (local_raphael) { + | var paper = local_raphael(10, 10, 320, 200); + | … + | })(Raphael.ninja()); + \*/ + R.ninja = function () { + oldRaphael.was ? (g.win.Raphael = oldRaphael.is) : delete Raphael; + return R; + }; + /*\ + * Raphael.st + [ property (object) ] + ** + * You can add your own method to elements and sets. It is wise to add a set method for each element method + * you added, so you will be able to call the same method on sets too. + ** + * See also @Raphael.el. + > Usage + | Raphael.el.red = function () { + | this.attr({fill: "#f00"}); + | }; + | Raphael.st.red = function () { + | this.forEach(function (el) { + | el.red(); + | }); + | }; + | // then use it + | paper.set(paper.circle(100, 100, 20), paper.circle(110, 100, 20)).red(); + \*/ + R.st = setproto; + + eve.on("raphael.DOMload", function () { + loaded = true; + }); + + // Firefox <3.6 fix: http://webreflection.blogspot.com/2009/11/195-chars-to-help-lazy-loading.html + (function (doc, loaded, f) { + if (doc.readyState == null && doc.addEventListener){ + doc.addEventListener(loaded, f = function () { + doc.removeEventListener(loaded, f, false); + doc.readyState = "complete"; + }, false); + doc.readyState = "loading"; + } + function isLoaded() { + (/in/).test(doc.readyState) ? setTimeout(isLoaded, 9) : R.eve("raphael.DOMload"); + } + isLoaded(); + })(document, "DOMContentLoaded"); + +// ┌─────────────────────────────────────────────────────────────────────┐ \\ +// │ Raphaël - JavaScript Vector Library │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ SVG Module │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ Copyright (c) 2008-2011 Dmitry Baranovskiy (http://raphaeljs.com) │ \\ +// │ Copyright (c) 2008-2011 Sencha Labs (http://sencha.com) │ \\ +// │ Licensed under the MIT (http://raphaeljs.com/license.html) license. │ \\ +// └─────────────────────────────────────────────────────────────────────┘ \\ + +(function(){ + if (!R.svg) { + return; + } + var has = "hasOwnProperty", + Str = String, + toFloat = parseFloat, + toInt = parseInt, + math = Math, + mmax = math.max, + abs = math.abs, + pow = math.pow, + separator = /[, ]+/, + eve = R.eve, + E = "", + S = " "; + var xlink = "http://www.w3.org/1999/xlink", + markers = { + block: "M5,0 0,2.5 5,5z", + classic: "M5,0 0,2.5 5,5 3.5,3 3.5,2z", + diamond: "M2.5,0 5,2.5 2.5,5 0,2.5z", + open: "M6,1 1,3.5 6,6", + oval: "M2.5,0A2.5,2.5,0,0,1,2.5,5 2.5,2.5,0,0,1,2.5,0z" + }, + markerCounter = {}; + R.toString = function () { + return "Your browser supports SVG.\nYou are running Rapha\xebl " + this.version; + }; + var $ = function (el, attr) { + if (attr) { + if (typeof el == "string") { + el = $(el); + } + for (var key in attr) if (attr[has](key)) { + if (key.substring(0, 6) == "xlink:") { + el.setAttributeNS(xlink, key.substring(6), Str(attr[key])); + } else { + el.setAttribute(key, Str(attr[key])); + } + } + } else { + el = R._g.doc.createElementNS("http://www.w3.org/2000/svg", el); + el.style && (el.style.webkitTapHighlightColor = "rgba(0,0,0,0)"); + } + return el; + }, + addGradientFill = function (element, gradient) { + var type = "linear", + id = element.id + gradient, + fx = .5, fy = .5, + o = element.node, + SVG = element.paper, + s = o.style, + el = R._g.doc.getElementById(id); + if (!el) { + gradient = Str(gradient).replace(R._radial_gradient, function (all, _fx, _fy) { + type = "radial"; + if (_fx && _fy) { + fx = toFloat(_fx); + fy = toFloat(_fy); + var dir = ((fy > .5) * 2 - 1); + pow(fx - .5, 2) + pow(fy - .5, 2) > .25 && + (fy = math.sqrt(.25 - pow(fx - .5, 2)) * dir + .5) && + fy != .5 && + (fy = fy.toFixed(5) - 1e-5 * dir); + } + return E; + }); + gradient = gradient.split(/\s*\-\s*/); + if (type == "linear") { + var angle = gradient.shift(); + angle = -toFloat(angle); + if (isNaN(angle)) { + return null; + } + var vector = [0, 0, math.cos(R.rad(angle)), math.sin(R.rad(angle))], + max = 1 / (mmax(abs(vector[2]), abs(vector[3])) || 1); + vector[2] *= max; + vector[3] *= max; + if (vector[2] < 0) { + vector[0] = -vector[2]; + vector[2] = 0; + } + if (vector[3] < 0) { + vector[1] = -vector[3]; + vector[3] = 0; + } + } + var dots = R._parseDots(gradient); + if (!dots) { + return null; + } + id = id.replace(/[\(\)\s,\xb0#]/g, "_"); + + if (element.gradient && id != element.gradient.id) { + SVG.defs.removeChild(element.gradient); + delete element.gradient; + } + + if (!element.gradient) { + el = $(type + "Gradient", {id: id}); + element.gradient = el; + $(el, type == "radial" ? { + fx: fx, + fy: fy + } : { + x1: vector[0], + y1: vector[1], + x2: vector[2], + y2: vector[3], + gradientTransform: element.matrix.invert() + }); + SVG.defs.appendChild(el); + for (var i = 0, ii = dots.length; i < ii; i++) { + el.appendChild($("stop", { + offset: dots[i].offset ? dots[i].offset : i ? "100%" : "0%", + "stop-color": dots[i].color || "#fff" + })); + } + } + } + $(o, { + fill: "url('" + document.location + "#" + id + "')", + opacity: 1, + "fill-opacity": 1 + }); + s.fill = E; + s.opacity = 1; + s.fillOpacity = 1; + return 1; + }, + updatePosition = function (o) { + var bbox = o.getBBox(1); + $(o.pattern, {patternTransform: o.matrix.invert() + " translate(" + bbox.x + "," + bbox.y + ")"}); + }, + addArrow = function (o, value, isEnd) { + if (o.type == "path") { + var values = Str(value).toLowerCase().split("-"), + p = o.paper, + se = isEnd ? "end" : "start", + node = o.node, + attrs = o.attrs, + stroke = attrs["stroke-width"], + i = values.length, + type = "classic", + from, + to, + dx, + refX, + attr, + w = 3, + h = 3, + t = 5; + while (i--) { + switch (values[i]) { + case "block": + case "classic": + case "oval": + case "diamond": + case "open": + case "none": + type = values[i]; + break; + case "wide": h = 5; break; + case "narrow": h = 2; break; + case "long": w = 5; break; + case "short": w = 2; break; + } + } + if (type == "open") { + w += 2; + h += 2; + t += 2; + dx = 1; + refX = isEnd ? 4 : 1; + attr = { + fill: "none", + stroke: attrs.stroke + }; + } else { + refX = dx = w / 2; + attr = { + fill: attrs.stroke, + stroke: "none" + }; + } + if (o._.arrows) { + if (isEnd) { + o._.arrows.endPath && markerCounter[o._.arrows.endPath]--; + o._.arrows.endMarker && markerCounter[o._.arrows.endMarker]--; + } else { + o._.arrows.startPath && markerCounter[o._.arrows.startPath]--; + o._.arrows.startMarker && markerCounter[o._.arrows.startMarker]--; + } + } else { + o._.arrows = {}; + } + if (type != "none") { + var pathId = "raphael-marker-" + type, + markerId = "raphael-marker-" + se + type + w + h + "-obj" + o.id; + if (!R._g.doc.getElementById(pathId)) { + p.defs.appendChild($($("path"), { + "stroke-linecap": "round", + d: markers[type], + id: pathId + })); + markerCounter[pathId] = 1; + } else { + markerCounter[pathId]++; + } + var marker = R._g.doc.getElementById(markerId), + use; + if (!marker) { + marker = $($("marker"), { + id: markerId, + markerHeight: h, + markerWidth: w, + orient: "auto", + refX: refX, + refY: h / 2 + }); + use = $($("use"), { + "xlink:href": "#" + pathId, + transform: (isEnd ? "rotate(180 " + w / 2 + " " + h / 2 + ") " : E) + "scale(" + w / t + "," + h / t + ")", + "stroke-width": (1 / ((w / t + h / t) / 2)).toFixed(4) + }); + marker.appendChild(use); + p.defs.appendChild(marker); + markerCounter[markerId] = 1; + } else { + markerCounter[markerId]++; + use = marker.getElementsByTagName("use")[0]; + } + $(use, attr); + var delta = dx * (type != "diamond" && type != "oval"); + if (isEnd) { + from = o._.arrows.startdx * stroke || 0; + to = R.getTotalLength(attrs.path) - delta * stroke; + } else { + from = delta * stroke; + to = R.getTotalLength(attrs.path) - (o._.arrows.enddx * stroke || 0); + } + attr = {}; + attr["marker-" + se] = "url(#" + markerId + ")"; + if (to || from) { + attr.d = R.getSubpath(attrs.path, from, to); + } + $(node, attr); + o._.arrows[se + "Path"] = pathId; + o._.arrows[se + "Marker"] = markerId; + o._.arrows[se + "dx"] = delta; + o._.arrows[se + "Type"] = type; + o._.arrows[se + "String"] = value; + } else { + if (isEnd) { + from = o._.arrows.startdx * stroke || 0; + to = R.getTotalLength(attrs.path) - from; + } else { + from = 0; + to = R.getTotalLength(attrs.path) - (o._.arrows.enddx * stroke || 0); + } + o._.arrows[se + "Path"] && $(node, {d: R.getSubpath(attrs.path, from, to)}); + delete o._.arrows[se + "Path"]; + delete o._.arrows[se + "Marker"]; + delete o._.arrows[se + "dx"]; + delete o._.arrows[se + "Type"]; + delete o._.arrows[se + "String"]; + } + for (attr in markerCounter) if (markerCounter[has](attr) && !markerCounter[attr]) { + var item = R._g.doc.getElementById(attr); + item && item.parentNode.removeChild(item); + } + } + }, + dasharray = { + "": [0], + "none": [0], + "-": [3, 1], + ".": [1, 1], + "-.": [3, 1, 1, 1], + "-..": [3, 1, 1, 1, 1, 1], + ". ": [1, 3], + "- ": [4, 3], + "--": [8, 3], + "- .": [4, 3, 1, 3], + "--.": [8, 3, 1, 3], + "--..": [8, 3, 1, 3, 1, 3] + }, + addDashes = function (o, value, params) { + value = dasharray[Str(value).toLowerCase()]; + if (value) { + var width = o.attrs["stroke-width"] || "1", + butt = {round: width, square: width, butt: 0}[o.attrs["stroke-linecap"] || params["stroke-linecap"]] || 0, + dashes = [], + i = value.length; + while (i--) { + dashes[i] = value[i] * width + ((i % 2) ? 1 : -1) * butt; + } + $(o.node, {"stroke-dasharray": dashes.join(",")}); + } + }, + setFillAndStroke = function (o, params) { + var node = o.node, + attrs = o.attrs, + vis = node.style.visibility; + node.style.visibility = "hidden"; + for (var att in params) { + if (params[has](att)) { + if (!R._availableAttrs[has](att)) { + continue; + } + var value = params[att]; + attrs[att] = value; + switch (att) { + case "blur": + o.blur(value); + break; + case "title": + var title = node.getElementsByTagName("title"); + + // Use the existing . + if (title.length && (title = title[0])) { + title.firstChild.nodeValue = value; + } else { + title = $("title"); + var val = R._g.doc.createTextNode(value); + title.appendChild(val); + node.appendChild(title); + } + break; + case "href": + case "target": + var pn = node.parentNode; + if (pn.tagName.toLowerCase() != "a") { + var hl = $("a"); + pn.insertBefore(hl, node); + hl.appendChild(node); + pn = hl; + } + if (att == "target") { + pn.setAttributeNS(xlink, "show", value == "blank" ? "new" : value); + } else { + pn.setAttributeNS(xlink, att, value); + } + break; + case "cursor": + node.style.cursor = value; + break; + case "transform": + o.transform(value); + break; + case "arrow-start": + addArrow(o, value); + break; + case "arrow-end": + addArrow(o, value, 1); + break; + case "clip-rect": + var rect = Str(value).split(separator); + if (rect.length == 4) { + o.clip && o.clip.parentNode.parentNode.removeChild(o.clip.parentNode); + var el = $("clipPath"), + rc = $("rect"); + el.id = R.createUUID(); + $(rc, { + x: rect[0], + y: rect[1], + width: rect[2], + height: rect[3] + }); + el.appendChild(rc); + o.paper.defs.appendChild(el); + $(node, {"clip-path": "url(#" + el.id + ")"}); + o.clip = rc; + } + if (!value) { + var path = node.getAttribute("clip-path"); + if (path) { + var clip = R._g.doc.getElementById(path.replace(/(^url\(#|\)$)/g, E)); + clip && clip.parentNode.removeChild(clip); + $(node, {"clip-path": E}); + delete o.clip; + } + } + break; + case "path": + if (o.type == "path") { + $(node, {d: value ? attrs.path = R._pathToAbsolute(value) : "M0,0"}); + o._.dirty = 1; + if (o._.arrows) { + "startString" in o._.arrows && addArrow(o, o._.arrows.startString); + "endString" in o._.arrows && addArrow(o, o._.arrows.endString, 1); + } + } + break; + case "width": + node.setAttribute(att, value); + o._.dirty = 1; + if (attrs.fx) { + att = "x"; + value = attrs.x; + } else { + break; + } + case "x": + if (attrs.fx) { + value = -attrs.x - (attrs.width || 0); + } + case "rx": + if (att == "rx" && o.type == "rect") { + break; + } + case "cx": + node.setAttribute(att, value); + o.pattern && updatePosition(o); + o._.dirty = 1; + break; + case "height": + node.setAttribute(att, value); + o._.dirty = 1; + if (attrs.fy) { + att = "y"; + value = attrs.y; + } else { + break; + } + case "y": + if (attrs.fy) { + value = -attrs.y - (attrs.height || 0); + } + case "ry": + if (att == "ry" && o.type == "rect") { + break; + } + case "cy": + node.setAttribute(att, value); + o.pattern && updatePosition(o); + o._.dirty = 1; + break; + case "r": + if (o.type == "rect") { + $(node, {rx: value, ry: value}); + } else { + node.setAttribute(att, value); + } + o._.dirty = 1; + break; + case "src": + if (o.type == "image") { + node.setAttributeNS(xlink, "href", value); + } + break; + case "stroke-width": + if (o._.sx != 1 || o._.sy != 1) { + value /= mmax(abs(o._.sx), abs(o._.sy)) || 1; + } + node.setAttribute(att, value); + if (attrs["stroke-dasharray"]) { + addDashes(o, attrs["stroke-dasharray"], params); + } + if (o._.arrows) { + "startString" in o._.arrows && addArrow(o, o._.arrows.startString); + "endString" in o._.arrows && addArrow(o, o._.arrows.endString, 1); + } + break; + case "stroke-dasharray": + addDashes(o, value, params); + break; + case "fill": + var isURL = Str(value).match(R._ISURL); + if (isURL) { + el = $("pattern"); + var ig = $("image"); + el.id = R.createUUID(); + $(el, {x: 0, y: 0, patternUnits: "userSpaceOnUse", height: 1, width: 1}); + $(ig, {x: 0, y: 0, "xlink:href": isURL[1]}); + el.appendChild(ig); + + (function (el) { + R._preload(isURL[1], function () { + var w = this.offsetWidth, + h = this.offsetHeight; + $(el, {width: w, height: h}); + $(ig, {width: w, height: h}); + o.paper.safari(); + }); + })(el); + o.paper.defs.appendChild(el); + $(node, {fill: "url(#" + el.id + ")"}); + o.pattern = el; + o.pattern && updatePosition(o); + break; + } + var clr = R.getRGB(value); + if (!clr.error) { + delete params.gradient; + delete attrs.gradient; + !R.is(attrs.opacity, "undefined") && + R.is(params.opacity, "undefined") && + $(node, {opacity: attrs.opacity}); + !R.is(attrs["fill-opacity"], "undefined") && + R.is(params["fill-opacity"], "undefined") && + $(node, {"fill-opacity": attrs["fill-opacity"]}); + } else if ((o.type == "circle" || o.type == "ellipse" || Str(value).charAt() != "r") && addGradientFill(o, value)) { + if ("opacity" in attrs || "fill-opacity" in attrs) { + var gradient = R._g.doc.getElementById(node.getAttribute("fill").replace(/^url\(#|\)$/g, E)); + if (gradient) { + var stops = gradient.getElementsByTagName("stop"); + $(stops[stops.length - 1], {"stop-opacity": ("opacity" in attrs ? attrs.opacity : 1) * ("fill-opacity" in attrs ? attrs["fill-opacity"] : 1)}); + } + } + attrs.gradient = value; + attrs.fill = "none"; + break; + } + clr[has]("opacity") && $(node, {"fill-opacity": clr.opacity > 1 ? clr.opacity / 100 : clr.opacity}); + case "stroke": + clr = R.getRGB(value); + node.setAttribute(att, clr.hex); + att == "stroke" && clr[has]("opacity") && $(node, {"stroke-opacity": clr.opacity > 1 ? clr.opacity / 100 : clr.opacity}); + if (att == "stroke" && o._.arrows) { + "startString" in o._.arrows && addArrow(o, o._.arrows.startString); + "endString" in o._.arrows && addArrow(o, o._.arrows.endString, 1); + } + break; + case "gradient": + (o.type == "circle" || o.type == "ellipse" || Str(value).charAt() != "r") && addGradientFill(o, value); + break; + case "opacity": + if (attrs.gradient && !attrs[has]("stroke-opacity")) { + $(node, {"stroke-opacity": value > 1 ? value / 100 : value}); + } + // fall + case "fill-opacity": + if (attrs.gradient) { + gradient = R._g.doc.getElementById(node.getAttribute("fill").replace(/^url\(#|\)$/g, E)); + if (gradient) { + stops = gradient.getElementsByTagName("stop"); + $(stops[stops.length - 1], {"stop-opacity": value}); + } + break; + } + default: + att == "font-size" && (value = toInt(value, 10) + "px"); + var cssrule = att.replace(/(\-.)/g, function (w) { + return w.substring(1).toUpperCase(); + }); + node.style[cssrule] = value; + o._.dirty = 1; + node.setAttribute(att, value); + break; + } + } + } + + tuneText(o, params); + node.style.visibility = vis; + }, + leading = 1.2, + tuneText = function (el, params) { + if (el.type != "text" || !(params[has]("text") || params[has]("font") || params[has]("font-size") || params[has]("x") || params[has]("y"))) { + return; + } + var a = el.attrs, + node = el.node, + fontSize = node.firstChild ? toInt(R._g.doc.defaultView.getComputedStyle(node.firstChild, E).getPropertyValue("font-size"), 10) : 10; + + if (params[has]("text")) { + a.text = params.text; + while (node.firstChild) { + node.removeChild(node.firstChild); + } + var texts = Str(params.text).split("\n"), + tspans = [], + tspan; + for (var i = 0, ii = texts.length; i < ii; i++) { + tspan = $("tspan"); + i && $(tspan, {dy: fontSize * leading, x: a.x}); + tspan.appendChild(R._g.doc.createTextNode(texts[i])); + node.appendChild(tspan); + tspans[i] = tspan; + } + } else { + tspans = node.getElementsByTagName("tspan"); + for (i = 0, ii = tspans.length; i < ii; i++) if (i) { + $(tspans[i], {dy: fontSize * leading, x: a.x}); + } else { + $(tspans[0], {dy: 0}); + } + } + $(node, {x: a.x, y: a.y}); + el._.dirty = 1; + var bb = el._getBBox(), + dif = a.y - (bb.y + bb.height / 2); + dif && R.is(dif, "finite") && $(tspans[0], {dy: dif}); + }, + getRealNode = function (node) { + if (node.parentNode && node.parentNode.tagName.toLowerCase() === "a") { + return node.parentNode; + } else { + return node; + } + }, + Element = function (node, svg) { + var X = 0, + Y = 0; + /*\ + * Element.node + [ property (object) ] + ** + * Gives you a reference to the DOM object, so you can assign event handlers or just mess around. + ** + * Note: Don’t mess with it. + > Usage + | // draw a circle at coordinate 10,10 with radius of 10 + | var c = paper.circle(10, 10, 10); + | c.node.onclick = function () { + | c.attr("fill", "red"); + | }; + \*/ + this[0] = this.node = node; + /*\ + * Element.raphael + [ property (object) ] + ** + * Internal reference to @Raphael object. In case it is not available. + > Usage + | Raphael.el.red = function () { + | var hsb = this.paper.raphael.rgb2hsb(this.attr("fill")); + | hsb.h = 1; + | this.attr({fill: this.paper.raphael.hsb2rgb(hsb).hex}); + | } + \*/ + node.raphael = true; + /*\ + * Element.id + [ property (number) ] + ** + * Unique id of the element. Especially useful when you want to listen to events of the element, + * because all events are fired in format `<module>.<action>.<id>`. Also useful for @Paper.getById method. + \*/ + this.id = R._oid++; + node.raphaelid = this.id; + this.matrix = R.matrix(); + this.realPath = null; + /*\ + * Element.paper + [ property (object) ] + ** + * Internal reference to “paper” where object drawn. Mainly for use in plugins and element extensions. + > Usage + | Raphael.el.cross = function () { + | this.attr({fill: "red"}); + | this.paper.path("M10,10L50,50M50,10L10,50") + | .attr({stroke: "red"}); + | } + \*/ + this.paper = svg; + this.attrs = this.attrs || {}; + this._ = { + transform: [], + sx: 1, + sy: 1, + deg: 0, + dx: 0, + dy: 0, + dirty: 1 + }; + !svg.bottom && (svg.bottom = this); + /*\ + * Element.prev + [ property (object) ] + ** + * Reference to the previous element in the hierarchy. + \*/ + this.prev = svg.top; + svg.top && (svg.top.next = this); + svg.top = this; + /*\ + * Element.next + [ property (object) ] + ** + * Reference to the next element in the hierarchy. + \*/ + this.next = null; + }, + elproto = R.el; + + Element.prototype = elproto; + elproto.constructor = Element; + + R._engine.path = function (pathString, SVG) { + var el = $("path"); + SVG.canvas && SVG.canvas.appendChild(el); + var p = new Element(el, SVG); + p.type = "path"; + setFillAndStroke(p, { + fill: "none", + stroke: "#000", + path: pathString + }); + return p; + }; + /*\ + * Element.rotate + [ method ] + ** + * Deprecated! Use @Element.transform instead. + * Adds rotation by given angle around given point to the list of + * transformations of the element. + > Parameters + - deg (number) angle in degrees + - cx (number) #optional x coordinate of the centre of rotation + - cy (number) #optional y coordinate of the centre of rotation + * If cx & cy aren’t specified centre of the shape is used as a point of rotation. + = (object) @Element + \*/ + elproto.rotate = function (deg, cx, cy) { + if (this.removed) { + return this; + } + deg = Str(deg).split(separator); + if (deg.length - 1) { + cx = toFloat(deg[1]); + cy = toFloat(deg[2]); + } + deg = toFloat(deg[0]); + (cy == null) && (cx = cy); + if (cx == null || cy == null) { + var bbox = this.getBBox(1); + cx = bbox.x + bbox.width / 2; + cy = bbox.y + bbox.height / 2; + } + this.transform(this._.transform.concat([["r", deg, cx, cy]])); + return this; + }; + /*\ + * Element.scale + [ method ] + ** + * Deprecated! Use @Element.transform instead. + * Adds scale by given amount relative to given point to the list of + * transformations of the element. + > Parameters + - sx (number) horisontal scale amount + - sy (number) vertical scale amount + - cx (number) #optional x coordinate of the centre of scale + - cy (number) #optional y coordinate of the centre of scale + * If cx & cy aren’t specified centre of the shape is used instead. + = (object) @Element + \*/ + elproto.scale = function (sx, sy, cx, cy) { + if (this.removed) { + return this; + } + sx = Str(sx).split(separator); + if (sx.length - 1) { + sy = toFloat(sx[1]); + cx = toFloat(sx[2]); + cy = toFloat(sx[3]); + } + sx = toFloat(sx[0]); + (sy == null) && (sy = sx); + (cy == null) && (cx = cy); + if (cx == null || cy == null) { + var bbox = this.getBBox(1); + } + cx = cx == null ? bbox.x + bbox.width / 2 : cx; + cy = cy == null ? bbox.y + bbox.height / 2 : cy; + this.transform(this._.transform.concat([["s", sx, sy, cx, cy]])); + return this; + }; + /*\ + * Element.translate + [ method ] + ** + * Deprecated! Use @Element.transform instead. + * Adds translation by given amount to the list of transformations of the element. + > Parameters + - dx (number) horisontal shift + - dy (number) vertical shift + = (object) @Element + \*/ + elproto.translate = function (dx, dy) { + if (this.removed) { + return this; + } + dx = Str(dx).split(separator); + if (dx.length - 1) { + dy = toFloat(dx[1]); + } + dx = toFloat(dx[0]) || 0; + dy = +dy || 0; + this.transform(this._.transform.concat([["t", dx, dy]])); + return this; + }; + /*\ + * Element.transform + [ method ] + ** + * Adds transformation to the element which is separate to other attributes, + * i.e. translation doesn’t change `x` or `y` of the rectange. The format + * of transformation string is similar to the path string syntax: + | "t100,100r30,100,100s2,2,100,100r45s1.5" + * Each letter is a command. There are four commands: `t` is for translate, `r` is for rotate, `s` is for + * scale and `m` is for matrix. + * + * There are also alternative “absolute” translation, rotation and scale: `T`, `R` and `S`. They will not take previous transformation into account. For example, `...T100,0` will always move element 100 px horisontally, while `...t100,0` could move it vertically if there is `r90` before. Just compare results of `r90t100,0` and `r90T100,0`. + * + * So, the example line above could be read like “translate by 100, 100; rotate 30° around 100, 100; scale twice around 100, 100; + * rotate 45° around centre; scale 1.5 times relative to centre”. As you can see rotate and scale commands have origin + * coordinates as optional parameters, the default is the centre point of the element. + * Matrix accepts six parameters. + > Usage + | var el = paper.rect(10, 20, 300, 200); + | // translate 100, 100, rotate 45°, translate -100, 0 + | el.transform("t100,100r45t-100,0"); + | // if you want you can append or prepend transformations + | el.transform("...t50,50"); + | el.transform("s2..."); + | // or even wrap + | el.transform("t50,50...t-50-50"); + | // to reset transformation call method with empty string + | el.transform(""); + | // to get current value call it without parameters + | console.log(el.transform()); + > Parameters + - tstr (string) #optional transformation string + * If tstr isn’t specified + = (string) current transformation string + * else + = (object) @Element + \*/ + elproto.transform = function (tstr) { + var _ = this._; + if (tstr == null) { + return _.transform; + } + R._extractTransform(this, tstr); + + this.clip && $(this.clip, {transform: this.matrix.invert()}); + this.pattern && updatePosition(this); + this.node && $(this.node, {transform: this.matrix}); + + if (_.sx != 1 || _.sy != 1) { + var sw = this.attrs[has]("stroke-width") ? this.attrs["stroke-width"] : 1; + this.attr({"stroke-width": sw}); + } + + return this; + }; + /*\ + * Element.hide + [ method ] + ** + * Makes element invisible. See @Element.show. + = (object) @Element + \*/ + elproto.hide = function () { + !this.removed && this.paper.safari(this.node.style.display = "none"); + return this; + }; + /*\ + * Element.show + [ method ] + ** + * Makes element visible. See @Element.hide. + = (object) @Element + \*/ + elproto.show = function () { + !this.removed && this.paper.safari(this.node.style.display = ""); + return this; + }; + /*\ + * Element.remove + [ method ] + ** + * Removes element from the paper. + \*/ + elproto.remove = function () { + var node = getRealNode(this.node); + if (this.removed || !node.parentNode) { + return; + } + var paper = this.paper; + paper.__set__ && paper.__set__.exclude(this); + eve.unbind("raphael.*.*." + this.id); + if (this.gradient) { + paper.defs.removeChild(this.gradient); + } + R._tear(this, paper); + + node.parentNode.removeChild(node); + + // Remove custom data for element + this.removeData(); + + for (var i in this) { + this[i] = typeof this[i] == "function" ? R._removedFactory(i) : null; + } + this.removed = true; + }; + elproto._getBBox = function () { + if (this.node.style.display == "none") { + this.show(); + var hide = true; + } + var canvasHidden = false, + containerStyle; + if (this.paper.canvas.parentElement) { + containerStyle = this.paper.canvas.parentElement.style; + } //IE10+ can't find parentElement + else if (this.paper.canvas.parentNode) { + containerStyle = this.paper.canvas.parentNode.style; + } + + if(containerStyle && containerStyle.display == "none") { + canvasHidden = true; + containerStyle.display = ""; + } + var bbox = {}; + try { + bbox = this.node.getBBox(); + } catch(e) { + // Firefox 3.0.x, 25.0.1 (probably more versions affected) play badly here - possible fix + bbox = { + x: this.node.clientLeft, + y: this.node.clientTop, + width: this.node.clientWidth, + height: this.node.clientHeight + } + } finally { + bbox = bbox || {}; + if(canvasHidden){ + containerStyle.display = "none"; + } + } + hide && this.hide(); + return bbox; + }; + /*\ + * Element.attr + [ method ] + ** + * Sets the attributes of the element. + > Parameters + - attrName (string) attribute’s name + - value (string) value + * or + - params (object) object of name/value pairs + * or + - attrName (string) attribute’s name + * or + - attrNames (array) in this case method returns array of current values for given attribute names + = (object) @Element if attrsName & value or params are passed in. + = (...) value of the attribute if only attrsName is passed in. + = (array) array of values of the attribute if attrsNames is passed in. + = (object) object of attributes if nothing is passed in. + > Possible parameters + # <p>Please refer to the <a href="http://www.w3.org/TR/SVG/" title="The W3C Recommendation for the SVG language describes these properties in detail.">SVG specification</a> for an explanation of these parameters.</p> + o arrow-end (string) arrowhead on the end of the path. The format for string is `<type>[-<width>[-<length>]]`. Possible types: `classic`, `block`, `open`, `oval`, `diamond`, `none`, width: `wide`, `narrow`, `medium`, length: `long`, `short`, `midium`. + o clip-rect (string) comma or space separated values: x, y, width and height + o cursor (string) CSS type of the cursor + o cx (number) the x-axis coordinate of the center of the circle, or ellipse + o cy (number) the y-axis coordinate of the center of the circle, or ellipse + o fill (string) colour, gradient or image + o fill-opacity (number) + o font (string) + o font-family (string) + o font-size (number) font size in pixels + o font-weight (string) + o height (number) + o href (string) URL, if specified element behaves as hyperlink + o opacity (number) + o path (string) SVG path string format + o r (number) radius of the circle, ellipse or rounded corner on the rect + o rx (number) horisontal radius of the ellipse + o ry (number) vertical radius of the ellipse + o src (string) image URL, only works for @Element.image element + o stroke (string) stroke colour + o stroke-dasharray (string) [“”, “`-`”, “`.`”, “`-.`”, “`-..`”, “`. `”, “`- `”, “`--`”, “`- .`”, “`--.`”, “`--..`”] + o stroke-linecap (string) [“`butt`”, “`square`”, “`round`”] + o stroke-linejoin (string) [“`bevel`”, “`round`”, “`miter`”] + o stroke-miterlimit (number) + o stroke-opacity (number) + o stroke-width (number) stroke width in pixels, default is '1' + o target (string) used with href + o text (string) contents of the text element. Use `\n` for multiline text + o text-anchor (string) [“`start`”, “`middle`”, “`end`”], default is “`middle`” + o title (string) will create tooltip with a given text + o transform (string) see @Element.transform + o width (number) + o x (number) + o y (number) + > Gradients + * Linear gradient format: “`‹angle›-‹colour›[-‹colour›[:‹offset›]]*-‹colour›`”, example: “`90-#fff-#000`” – 90° + * gradient from white to black or “`0-#fff-#f00:20-#000`” – 0° gradient from white via red (at 20%) to black. + * + * radial gradient: “`r[(‹fx›, ‹fy›)]‹colour›[-‹colour›[:‹offset›]]*-‹colour›`”, example: “`r#fff-#000`” – + * gradient from white to black or “`r(0.25, 0.75)#fff-#000`” – gradient from white to black with focus point + * at 0.25, 0.75. Focus point coordinates are in 0..1 range. Radial gradients can only be applied to circles and ellipses. + > Path String + # <p>Please refer to <a href="http://www.w3.org/TR/SVG/paths.html#PathData" title="Details of a path’s data attribute’s format are described in the SVG specification.">SVG documentation regarding path string</a>. Raphaël fully supports it.</p> + > Colour Parsing + # <ul> + # <li>Colour name (“<code>red</code>”, “<code>green</code>”, “<code>cornflowerblue</code>”, etc)</li> + # <li>#••• — shortened HTML colour: (“<code>#000</code>”, “<code>#fc0</code>”, etc)</li> + # <li>#•••••• — full length HTML colour: (“<code>#000000</code>”, “<code>#bd2300</code>”)</li> + # <li>rgb(•••, •••, •••) — red, green and blue channels’ values: (“<code>rgb(200, 100, 0)</code>”)</li> + # <li>rgb(•••%, •••%, •••%) — same as above, but in %: (“<code>rgb(100%, 175%, 0%)</code>”)</li> + # <li>rgba(•••, •••, •••, •••) — red, green and blue channels’ values: (“<code>rgba(200, 100, 0, .5)</code>”)</li> + # <li>rgba(•••%, •••%, •••%, •••%) — same as above, but in %: (“<code>rgba(100%, 175%, 0%, 50%)</code>”)</li> + # <li>hsb(•••, •••, •••) — hue, saturation and brightness values: (“<code>hsb(0.5, 0.25, 1)</code>”)</li> + # <li>hsb(•••%, •••%, •••%) — same as above, but in %</li> + # <li>hsba(•••, •••, •••, •••) — same as above, but with opacity</li> + # <li>hsl(•••, •••, •••) — almost the same as hsb, see <a href="http://en.wikipedia.org/wiki/HSL_and_HSV" title="HSL and HSV - Wikipedia, the free encyclopedia">Wikipedia page</a></li> + # <li>hsl(•••%, •••%, •••%) — same as above, but in %</li> + # <li>hsla(•••, •••, •••, •••) — same as above, but with opacity</li> + # <li>Optionally for hsb and hsl you could specify hue as a degree: “<code>hsl(240deg, 1, .5)</code>” or, if you want to go fancy, “<code>hsl(240°, 1, .5)</code>”</li> + # </ul> + \*/ + elproto.attr = function (name, value) { + if (this.removed) { + return this; + } + if (name == null) { + var res = {}; + for (var a in this.attrs) if (this.attrs[has](a)) { + res[a] = this.attrs[a]; + } + res.gradient && res.fill == "none" && (res.fill = res.gradient) && delete res.gradient; + res.transform = this._.transform; + return res; + } + if (value == null && R.is(name, "string")) { + if (name == "fill" && this.attrs.fill == "none" && this.attrs.gradient) { + return this.attrs.gradient; + } + if (name == "transform") { + return this._.transform; + } + var names = name.split(separator), + out = {}; + for (var i = 0, ii = names.length; i < ii; i++) { + name = names[i]; + if (name in this.attrs) { + out[name] = this.attrs[name]; + } else if (R.is(this.paper.customAttributes[name], "function")) { + out[name] = this.paper.customAttributes[name].def; + } else { + out[name] = R._availableAttrs[name]; + } + } + return ii - 1 ? out : out[names[0]]; + } + if (value == null && R.is(name, "array")) { + out = {}; + for (i = 0, ii = name.length; i < ii; i++) { + out[name[i]] = this.attr(name[i]); + } + return out; + } + if (value != null) { + var params = {}; + params[name] = value; + } else if (name != null && R.is(name, "object")) { + params = name; + } + for (var key in params) { + eve("raphael.attr." + key + "." + this.id, this, params[key]); + } + for (key in this.paper.customAttributes) if (this.paper.customAttributes[has](key) && params[has](key) && R.is(this.paper.customAttributes[key], "function")) { + var par = this.paper.customAttributes[key].apply(this, [].concat(params[key])); + this.attrs[key] = params[key]; + for (var subkey in par) if (par[has](subkey)) { + params[subkey] = par[subkey]; + } + } + setFillAndStroke(this, params); + return this; + }; + /*\ + * Element.toFront + [ method ] + ** + * Moves the element so it is the closest to the viewer’s eyes, on top of other elements. + = (object) @Element + \*/ + elproto.toFront = function () { + if (this.removed) { + return this; + } + var node = getRealNode(this.node); + node.parentNode.appendChild(node); + var svg = this.paper; + svg.top != this && R._tofront(this, svg); + return this; + }; + /*\ + * Element.toBack + [ method ] + ** + * Moves the element so it is the furthest from the viewer’s eyes, behind other elements. + = (object) @Element + \*/ + elproto.toBack = function () { + if (this.removed) { + return this; + } + var node = getRealNode(this.node); + var parentNode = node.parentNode; + parentNode.insertBefore(node, parentNode.firstChild); + R._toback(this, this.paper); + var svg = this.paper; + return this; + }; + /*\ + * Element.insertAfter + [ method ] + ** + * Inserts current object after the given one. + = (object) @Element + \*/ + elproto.insertAfter = function (element) { + if (this.removed || !element) { + return this; + } + + var node = getRealNode(this.node); + var afterNode = getRealNode(element.node || element[element.length - 1].node); + if (afterNode.nextSibling) { + afterNode.parentNode.insertBefore(node, afterNode.nextSibling); + } else { + afterNode.parentNode.appendChild(node); + } + R._insertafter(this, element, this.paper); + return this; + }; + /*\ + * Element.insertBefore + [ method ] + ** + * Inserts current object before the given one. + = (object) @Element + \*/ + elproto.insertBefore = function (element) { + if (this.removed || !element) { + return this; + } + + var node = getRealNode(this.node); + var beforeNode = getRealNode(element.node || element[0].node); + beforeNode.parentNode.insertBefore(node, beforeNode); + R._insertbefore(this, element, this.paper); + return this; + }; + elproto.blur = function (size) { + // Experimental. No Safari support. Use it on your own risk. + var t = this; + if (+size !== 0) { + var fltr = $("filter"), + blur = $("feGaussianBlur"); + t.attrs.blur = size; + fltr.id = R.createUUID(); + $(blur, {stdDeviation: +size || 1.5}); + fltr.appendChild(blur); + t.paper.defs.appendChild(fltr); + t._blur = fltr; + $(t.node, {filter: "url(#" + fltr.id + ")"}); + } else { + if (t._blur) { + t._blur.parentNode.removeChild(t._blur); + delete t._blur; + delete t.attrs.blur; + } + t.node.removeAttribute("filter"); + } + return t; + }; + R._engine.circle = function (svg, x, y, r) { + var el = $("circle"); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = {cx: x, cy: y, r: r, fill: "none", stroke: "#000"}; + res.type = "circle"; + $(el, res.attrs); + return res; + }; + R._engine.rect = function (svg, x, y, w, h, r) { + var el = $("rect"); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = {x: x, y: y, width: w, height: h, rx: r || 0, ry: r || 0, fill: "none", stroke: "#000"}; + res.type = "rect"; + $(el, res.attrs); + return res; + }; + R._engine.ellipse = function (svg, x, y, rx, ry) { + var el = $("ellipse"); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = {cx: x, cy: y, rx: rx, ry: ry, fill: "none", stroke: "#000"}; + res.type = "ellipse"; + $(el, res.attrs); + return res; + }; + R._engine.image = function (svg, src, x, y, w, h) { + var el = $("image"); + $(el, {x: x, y: y, width: w, height: h, preserveAspectRatio: "none"}); + el.setAttributeNS(xlink, "href", src); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = {x: x, y: y, width: w, height: h, src: src}; + res.type = "image"; + return res; + }; + R._engine.text = function (svg, x, y, text) { + var el = $("text"); + svg.canvas && svg.canvas.appendChild(el); + var res = new Element(el, svg); + res.attrs = { + x: x, + y: y, + "text-anchor": "middle", + text: text, + "font-family": R._availableAttrs["font-family"], + "font-size": R._availableAttrs["font-size"], + stroke: "none", + fill: "#000" + }; + res.type = "text"; + setFillAndStroke(res, res.attrs); + return res; + }; + R._engine.setSize = function (width, height) { + this.width = width || this.width; + this.height = height || this.height; + this.canvas.setAttribute("width", this.width); + this.canvas.setAttribute("height", this.height); + if (this._viewBox) { + this.setViewBox.apply(this, this._viewBox); + } + return this; + }; + R._engine.create = function () { + var con = R._getContainer.apply(0, arguments), + container = con && con.container, + x = con.x, + y = con.y, + width = con.width, + height = con.height; + if (!container) { + throw new Error("SVG container not found."); + } + var cnvs = $("svg"), + css = "overflow:hidden;", + isFloating; + x = x || 0; + y = y || 0; + width = width || 512; + height = height || 342; + $(cnvs, { + height: height, + version: 1.1, + width: width, + xmlns: "http://www.w3.org/2000/svg", + "xmlns:xlink": "http://www.w3.org/1999/xlink" + }); + if (container == 1) { + cnvs.style.cssText = css + "position:absolute;left:" + x + "px;top:" + y + "px"; + R._g.doc.body.appendChild(cnvs); + isFloating = 1; + } else { + cnvs.style.cssText = css + "position:relative"; + if (container.firstChild) { + container.insertBefore(cnvs, container.firstChild); + } else { + container.appendChild(cnvs); + } + } + container = new R._Paper; + container.width = width; + container.height = height; + container.canvas = cnvs; + container.clear(); + container._left = container._top = 0; + isFloating && (container.renderfix = function () {}); + container.renderfix(); + return container; + }; + R._engine.setViewBox = function (x, y, w, h, fit) { + eve("raphael.setViewBox", this, this._viewBox, [x, y, w, h, fit]); + var paperSize = this.getSize(), + size = mmax(w / paperSize.width, h / paperSize.height), + top = this.top, + aspectRatio = fit ? "xMidYMid meet" : "xMinYMin", + vb, + sw; + if (x == null) { + if (this._vbSize) { + size = 1; + } + delete this._vbSize; + vb = "0 0 " + this.width + S + this.height; + } else { + this._vbSize = size; + vb = x + S + y + S + w + S + h; + } + $(this.canvas, { + viewBox: vb, + preserveAspectRatio: aspectRatio + }); + while (size && top) { + sw = "stroke-width" in top.attrs ? top.attrs["stroke-width"] : 1; + top.attr({"stroke-width": sw}); + top._.dirty = 1; + top._.dirtyT = 1; + top = top.prev; + } + this._viewBox = [x, y, w, h, !!fit]; + return this; + }; + /*\ + * Paper.renderfix + [ method ] + ** + * Fixes the issue of Firefox and IE9 regarding subpixel rendering. If paper is dependant + * on other elements after reflow it could shift half pixel which cause for lines to lost their crispness. + * This method fixes the issue. + ** + Special thanks to Mariusz Nowak (http://www.medikoo.com/) for this method. + \*/ + R.prototype.renderfix = function () { + var cnvs = this.canvas, + s = cnvs.style, + pos; + try { + pos = cnvs.getScreenCTM() || cnvs.createSVGMatrix(); + } catch (e) { + pos = cnvs.createSVGMatrix(); + } + var left = -pos.e % 1, + top = -pos.f % 1; + if (left || top) { + if (left) { + this._left = (this._left + left) % 1; + s.left = this._left + "px"; + } + if (top) { + this._top = (this._top + top) % 1; + s.top = this._top + "px"; + } + } + }; + /*\ + * Paper.clear + [ method ] + ** + * Clears the paper, i.e. removes all the elements. + \*/ + R.prototype.clear = function () { + R.eve("raphael.clear", this); + var c = this.canvas; + while (c.firstChild) { + c.removeChild(c.firstChild); + } + this.bottom = this.top = null; + (this.desc = $("desc")).appendChild(R._g.doc.createTextNode("Created with Rapha\xebl " + R.version)); + c.appendChild(this.desc); + c.appendChild(this.defs = $("defs")); + }; + /*\ + * Paper.remove + [ method ] + ** + * Removes the paper from the DOM. + \*/ + R.prototype.remove = function () { + eve("raphael.remove", this); + this.canvas.parentNode && this.canvas.parentNode.removeChild(this.canvas); + for (var i in this) { + this[i] = typeof this[i] == "function" ? R._removedFactory(i) : null; + } + }; + var setproto = R.st; + for (var method in elproto) if (elproto[has](method) && !setproto[has](method)) { + setproto[method] = (function (methodname) { + return function () { + var arg = arguments; + return this.forEach(function (el) { + el[methodname].apply(el, arg); + }); + }; + })(method); + } +})(); + +// ┌─────────────────────────────────────────────────────────────────────┐ \\ +// │ Raphaël - JavaScript Vector Library │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ VML Module │ \\ +// ├─────────────────────────────────────────────────────────────────────┤ \\ +// │ Copyright (c) 2008-2011 Dmitry Baranovskiy (http://raphaeljs.com) │ \\ +// │ Copyright (c) 2008-2011 Sencha Labs (http://sencha.com) │ \\ +// │ Licensed under the MIT (http://raphaeljs.com/license.html) license. │ \\ +// └─────────────────────────────────────────────────────────────────────┘ \\ + +(function(){ + if (!R.vml) { + return; + } + var has = "hasOwnProperty", + Str = String, + toFloat = parseFloat, + math = Math, + round = math.round, + mmax = math.max, + mmin = math.min, + abs = math.abs, + fillString = "fill", + separator = /[, ]+/, + eve = R.eve, + ms = " progid:DXImageTransform.Microsoft", + S = " ", + E = "", + map = {M: "m", L: "l", C: "c", Z: "x", m: "t", l: "r", c: "v", z: "x"}, + bites = /([clmz]),?([^clmz]*)/gi, + blurregexp = / progid:\S+Blur\([^\)]+\)/g, + val = /-?[^,\s-]+/g, + cssDot = "position:absolute;left:0;top:0;width:1px;height:1px;behavior:url(#default#VML)", + zoom = 21600, + pathTypes = {path: 1, rect: 1, image: 1}, + ovalTypes = {circle: 1, ellipse: 1}, + path2vml = function (path) { + var total = /[ahqstv]/ig, + command = R._pathToAbsolute; + Str(path).match(total) && (command = R._path2curve); + total = /[clmz]/g; + if (command == R._pathToAbsolute && !Str(path).match(total)) { + var res = Str(path).replace(bites, function (all, command, args) { + var vals = [], + isMove = command.toLowerCase() == "m", + res = map[command]; + args.replace(val, function (value) { + if (isMove && vals.length == 2) { + res += vals + map[command == "m" ? "l" : "L"]; + vals = []; + } + vals.push(round(value * zoom)); + }); + return res + vals; + }); + return res; + } + var pa = command(path), p, r; + res = []; + for (var i = 0, ii = pa.length; i < ii; i++) { + p = pa[i]; + r = pa[i][0].toLowerCase(); + r == "z" && (r = "x"); + for (var j = 1, jj = p.length; j < jj; j++) { + r += round(p[j] * zoom) + (j != jj - 1 ? "," : E); + } + res.push(r); + } + return res.join(S); + }, + compensation = function (deg, dx, dy) { + var m = R.matrix(); + m.rotate(-deg, .5, .5); + return { + dx: m.x(dx, dy), + dy: m.y(dx, dy) + }; + }, + setCoords = function (p, sx, sy, dx, dy, deg) { + var _ = p._, + m = p.matrix, + fillpos = _.fillpos, + o = p.node, + s = o.style, + y = 1, + flip = "", + dxdy, + kx = zoom / sx, + ky = zoom / sy; + s.visibility = "hidden"; + if (!sx || !sy) { + return; + } + o.coordsize = abs(kx) + S + abs(ky); + s.rotation = deg * (sx * sy < 0 ? -1 : 1); + if (deg) { + var c = compensation(deg, dx, dy); + dx = c.dx; + dy = c.dy; + } + sx < 0 && (flip += "x"); + sy < 0 && (flip += " y") && (y = -1); + s.flip = flip; + o.coordorigin = (dx * -kx) + S + (dy * -ky); + if (fillpos || _.fillsize) { + var fill = o.getElementsByTagName(fillString); + fill = fill && fill[0]; + o.removeChild(fill); + if (fillpos) { + c = compensation(deg, m.x(fillpos[0], fillpos[1]), m.y(fillpos[0], fillpos[1])); + fill.position = c.dx * y + S + c.dy * y; + } + if (_.fillsize) { + fill.size = _.fillsize[0] * abs(sx) + S + _.fillsize[1] * abs(sy); + } + o.appendChild(fill); + } + s.visibility = "visible"; + }; + R.toString = function () { + return "Your browser doesn\u2019t support SVG. Falling down to VML.\nYou are running Rapha\xebl " + this.version; + }; + var addArrow = function (o, value, isEnd) { + var values = Str(value).toLowerCase().split("-"), + se = isEnd ? "end" : "start", + i = values.length, + type = "classic", + w = "medium", + h = "medium"; + while (i--) { + switch (values[i]) { + case "block": + case "classic": + case "oval": + case "diamond": + case "open": + case "none": + type = values[i]; + break; + case "wide": + case "narrow": h = values[i]; break; + case "long": + case "short": w = values[i]; break; + } + } + var stroke = o.node.getElementsByTagName("stroke")[0]; + stroke[se + "arrow"] = type; + stroke[se + "arrowlength"] = w; + stroke[se + "arrowwidth"] = h; + }, + setFillAndStroke = function (o, params) { + // o.paper.canvas.style.display = "none"; + o.attrs = o.attrs || {}; + var node = o.node, + a = o.attrs, + s = node.style, + xy, + newpath = pathTypes[o.type] && (params.x != a.x || params.y != a.y || params.width != a.width || params.height != a.height || params.cx != a.cx || params.cy != a.cy || params.rx != a.rx || params.ry != a.ry || params.r != a.r), + isOval = ovalTypes[o.type] && (a.cx != params.cx || a.cy != params.cy || a.r != params.r || a.rx != params.rx || a.ry != params.ry), + res = o; + + + for (var par in params) if (params[has](par)) { + a[par] = params[par]; + } + if (newpath) { + a.path = R._getPath[o.type](o); + o._.dirty = 1; + } + params.href && (node.href = params.href); + params.title && (node.title = params.title); + params.target && (node.target = params.target); + params.cursor && (s.cursor = params.cursor); + "blur" in params && o.blur(params.blur); + if (params.path && o.type == "path" || newpath) { + node.path = path2vml(~Str(a.path).toLowerCase().indexOf("r") ? R._pathToAbsolute(a.path) : a.path); + o._.dirty = 1; + if (o.type == "image") { + o._.fillpos = [a.x, a.y]; + o._.fillsize = [a.width, a.height]; + setCoords(o, 1, 1, 0, 0, 0); + } + } + "transform" in params && o.transform(params.transform); + if (isOval) { + var cx = +a.cx, + cy = +a.cy, + rx = +a.rx || +a.r || 0, + ry = +a.ry || +a.r || 0; + node.path = R.format("ar{0},{1},{2},{3},{4},{1},{4},{1}x", round((cx - rx) * zoom), round((cy - ry) * zoom), round((cx + rx) * zoom), round((cy + ry) * zoom), round(cx * zoom)); + o._.dirty = 1; + } + if ("clip-rect" in params) { + var rect = Str(params["clip-rect"]).split(separator); + if (rect.length == 4) { + rect[2] = +rect[2] + (+rect[0]); + rect[3] = +rect[3] + (+rect[1]); + var div = node.clipRect || R._g.doc.createElement("div"), + dstyle = div.style; + dstyle.clip = R.format("rect({1}px {2}px {3}px {0}px)", rect); + if (!node.clipRect) { + dstyle.position = "absolute"; + dstyle.top = 0; + dstyle.left = 0; + dstyle.width = o.paper.width + "px"; + dstyle.height = o.paper.height + "px"; + node.parentNode.insertBefore(div, node); + div.appendChild(node); + node.clipRect = div; + } + } + if (!params["clip-rect"]) { + node.clipRect && (node.clipRect.style.clip = "auto"); + } + } + if (o.textpath) { + var textpathStyle = o.textpath.style; + params.font && (textpathStyle.font = params.font); + params["font-family"] && (textpathStyle.fontFamily = '"' + params["font-family"].split(",")[0].replace(/^['"]+|['"]+$/g, E) + '"'); + params["font-size"] && (textpathStyle.fontSize = params["font-size"]); + params["font-weight"] && (textpathStyle.fontWeight = params["font-weight"]); + params["font-style"] && (textpathStyle.fontStyle = params["font-style"]); + } + if ("arrow-start" in params) { + addArrow(res, params["arrow-start"]); + } + if ("arrow-end" in params) { + addArrow(res, params["arrow-end"], 1); + } + if (params.opacity != null || + params["stroke-width"] != null || + params.fill != null || + params.src != null || + params.stroke != null || + params["stroke-width"] != null || + params["stroke-opacity"] != null || + params["fill-opacity"] != null || + params["stroke-dasharray"] != null || + params["stroke-miterlimit"] != null || + params["stroke-linejoin"] != null || + params["stroke-linecap"] != null) { + var fill = node.getElementsByTagName(fillString), + newfill = false; + fill = fill && fill[0]; + !fill && (newfill = fill = createNode(fillString)); + if (o.type == "image" && params.src) { + fill.src = params.src; + } + params.fill && (fill.on = true); + if (fill.on == null || params.fill == "none" || params.fill === null) { + fill.on = false; + } + if (fill.on && params.fill) { + var isURL = Str(params.fill).match(R._ISURL); + if (isURL) { + fill.parentNode == node && node.removeChild(fill); + fill.rotate = true; + fill.src = isURL[1]; + fill.type = "tile"; + var bbox = o.getBBox(1); + fill.position = bbox.x + S + bbox.y; + o._.fillpos = [bbox.x, bbox.y]; + + R._preload(isURL[1], function () { + o._.fillsize = [this.offsetWidth, this.offsetHeight]; + }); + } else { + fill.color = R.getRGB(params.fill).hex; + fill.src = E; + fill.type = "solid"; + if (R.getRGB(params.fill).error && (res.type in {circle: 1, ellipse: 1} || Str(params.fill).charAt() != "r") && addGradientFill(res, params.fill, fill)) { + a.fill = "none"; + a.gradient = params.fill; + fill.rotate = false; + } + } + } + if ("fill-opacity" in params || "opacity" in params) { + var opacity = ((+a["fill-opacity"] + 1 || 2) - 1) * ((+a.opacity + 1 || 2) - 1) * ((+R.getRGB(params.fill).o + 1 || 2) - 1); + opacity = mmin(mmax(opacity, 0), 1); + fill.opacity = opacity; + if (fill.src) { + fill.color = "none"; + } + } + node.appendChild(fill); + var stroke = (node.getElementsByTagName("stroke") && node.getElementsByTagName("stroke")[0]), + newstroke = false; + !stroke && (newstroke = stroke = createNode("stroke")); + if ((params.stroke && params.stroke != "none") || + params["stroke-width"] || + params["stroke-opacity"] != null || + params["stroke-dasharray"] || + params["stroke-miterlimit"] || + params["stroke-linejoin"] || + params["stroke-linecap"]) { + stroke.on = true; + } + (params.stroke == "none" || params.stroke === null || stroke.on == null || params.stroke == 0 || params["stroke-width"] == 0) && (stroke.on = false); + var strokeColor = R.getRGB(params.stroke); + stroke.on && params.stroke && (stroke.color = strokeColor.hex); + opacity = ((+a["stroke-opacity"] + 1 || 2) - 1) * ((+a.opacity + 1 || 2) - 1) * ((+strokeColor.o + 1 || 2) - 1); + var width = (toFloat(params["stroke-width"]) || 1) * .75; + opacity = mmin(mmax(opacity, 0), 1); + params["stroke-width"] == null && (width = a["stroke-width"]); + params["stroke-width"] && (stroke.weight = width); + width && width < 1 && (opacity *= width) && (stroke.weight = 1); + stroke.opacity = opacity; + + params["stroke-linejoin"] && (stroke.joinstyle = params["stroke-linejoin"] || "miter"); + stroke.miterlimit = params["stroke-miterlimit"] || 8; + params["stroke-linecap"] && (stroke.endcap = params["stroke-linecap"] == "butt" ? "flat" : params["stroke-linecap"] == "square" ? "square" : "round"); + if ("stroke-dasharray" in params) { + var dasharray = { + "-": "shortdash", + ".": "shortdot", + "-.": "shortdashdot", + "-..": "shortdashdotdot", + ". ": "dot", + "- ": "dash", + "--": "longdash", + "- .": "dashdot", + "--.": "longdashdot", + "--..": "longdashdotdot" + }; + stroke.dashstyle = dasharray[has](params["stroke-dasharray"]) ? dasharray[params["stroke-dasharray"]] : E; + } + newstroke && node.appendChild(stroke); + } + if (res.type == "text") { + res.paper.canvas.style.display = E; + var span = res.paper.span, + m = 100, + fontSize = a.font && a.font.match(/\d+(?:\.\d*)?(?=px)/); + s = span.style; + a.font && (s.font = a.font); + a["font-family"] && (s.fontFamily = a["font-family"]); + a["font-weight"] && (s.fontWeight = a["font-weight"]); + a["font-style"] && (s.fontStyle = a["font-style"]); + fontSize = toFloat(a["font-size"] || fontSize && fontSize[0]) || 10; + s.fontSize = fontSize * m + "px"; + res.textpath.string && (span.innerHTML = Str(res.textpath.string).replace(/</g, "<").replace(/&/g, "&").replace(/\n/g, "<br>")); + var brect = span.getBoundingClientRect(); + res.W = a.w = (brect.right - brect.left) / m; + res.H = a.h = (brect.bottom - brect.top) / m; + // res.paper.canvas.style.display = "none"; + res.X = a.x; + res.Y = a.y + res.H / 2; + + ("x" in params || "y" in params) && (res.path.v = R.format("m{0},{1}l{2},{1}", round(a.x * zoom), round(a.y * zoom), round(a.x * zoom) + 1)); + var dirtyattrs = ["x", "y", "text", "font", "font-family", "font-weight", "font-style", "font-size"]; + for (var d = 0, dd = dirtyattrs.length; d < dd; d++) if (dirtyattrs[d] in params) { + res._.dirty = 1; + break; + } + + // text-anchor emulation + switch (a["text-anchor"]) { + case "start": + res.textpath.style["v-text-align"] = "left"; + res.bbx = res.W / 2; + break; + case "end": + res.textpath.style["v-text-align"] = "right"; + res.bbx = -res.W / 2; + break; + default: + res.textpath.style["v-text-align"] = "center"; + res.bbx = 0; + break; + } + res.textpath.style["v-text-kern"] = true; + } + // res.paper.canvas.style.display = E; + }, + addGradientFill = function (o, gradient, fill) { + o.attrs = o.attrs || {}; + var attrs = o.attrs, + pow = Math.pow, + opacity, + oindex, + type = "linear", + fxfy = ".5 .5"; + o.attrs.gradient = gradient; + gradient = Str(gradient).replace(R._radial_gradient, function (all, fx, fy) { + type = "radial"; + if (fx && fy) { + fx = toFloat(fx); + fy = toFloat(fy); + pow(fx - .5, 2) + pow(fy - .5, 2) > .25 && (fy = math.sqrt(.25 - pow(fx - .5, 2)) * ((fy > .5) * 2 - 1) + .5); + fxfy = fx + S + fy; + } + return E; + }); + gradient = gradient.split(/\s*\-\s*/); + if (type == "linear") { + var angle = gradient.shift(); + angle = -toFloat(angle); + if (isNaN(angle)) { + return null; + } + } + var dots = R._parseDots(gradient); + if (!dots) { + return null; + } + o = o.shape || o.node; + if (dots.length) { + o.removeChild(fill); + fill.on = true; + fill.method = "none"; + fill.color = dots[0].color; + fill.color2 = dots[dots.length - 1].color; + var clrs = []; + for (var i = 0, ii = dots.length; i < ii; i++) { + dots[i].offset && clrs.push(dots[i].offset + S + dots[i].color); + } + fill.colors = clrs.length ? clrs.join() : "0% " + fill.color; + if (type == "radial") { + fill.type = "gradientTitle"; + fill.focus = "100%"; + fill.focussize = "0 0"; + fill.focusposition = fxfy; + fill.angle = 0; + } else { + // fill.rotate= true; + fill.type = "gradient"; + fill.angle = (270 - angle) % 360; + } + o.appendChild(fill); + } + return 1; + }, + Element = function (node, vml) { + this[0] = this.node = node; + node.raphael = true; + this.id = R._oid++; + node.raphaelid = this.id; + this.X = 0; + this.Y = 0; + this.attrs = {}; + this.paper = vml; + this.matrix = R.matrix(); + this._ = { + transform: [], + sx: 1, + sy: 1, + dx: 0, + dy: 0, + deg: 0, + dirty: 1, + dirtyT: 1 + }; + !vml.bottom && (vml.bottom = this); + this.prev = vml.top; + vml.top && (vml.top.next = this); + vml.top = this; + this.next = null; + }; + var elproto = R.el; + + Element.prototype = elproto; + elproto.constructor = Element; + elproto.transform = function (tstr) { + if (tstr == null) { + return this._.transform; + } + var vbs = this.paper._viewBoxShift, + vbt = vbs ? "s" + [vbs.scale, vbs.scale] + "-1-1t" + [vbs.dx, vbs.dy] : E, + oldt; + if (vbs) { + oldt = tstr = Str(tstr).replace(/\.{3}|\u2026/g, this._.transform || E); + } + R._extractTransform(this, vbt + tstr); + var matrix = this.matrix.clone(), + skew = this.skew, + o = this.node, + split, + isGrad = ~Str(this.attrs.fill).indexOf("-"), + isPatt = !Str(this.attrs.fill).indexOf("url("); + matrix.translate(1, 1); + if (isPatt || isGrad || this.type == "image") { + skew.matrix = "1 0 0 1"; + skew.offset = "0 0"; + split = matrix.split(); + if ((isGrad && split.noRotation) || !split.isSimple) { + o.style.filter = matrix.toFilter(); + var bb = this.getBBox(), + bbt = this.getBBox(1), + dx = bb.x - bbt.x, + dy = bb.y - bbt.y; + o.coordorigin = (dx * -zoom) + S + (dy * -zoom); + setCoords(this, 1, 1, dx, dy, 0); + } else { + o.style.filter = E; + setCoords(this, split.scalex, split.scaley, split.dx, split.dy, split.rotate); + } + } else { + o.style.filter = E; + skew.matrix = Str(matrix); + skew.offset = matrix.offset(); + } + if (oldt !== null) { // empty string value is true as well + this._.transform = oldt; + R._extractTransform(this, oldt); + } + return this; + }; + elproto.rotate = function (deg, cx, cy) { + if (this.removed) { + return this; + } + if (deg == null) { + return; + } + deg = Str(deg).split(separator); + if (deg.length - 1) { + cx = toFloat(deg[1]); + cy = toFloat(deg[2]); + } + deg = toFloat(deg[0]); + (cy == null) && (cx = cy); + if (cx == null || cy == null) { + var bbox = this.getBBox(1); + cx = bbox.x + bbox.width / 2; + cy = bbox.y + bbox.height / 2; + } + this._.dirtyT = 1; + this.transform(this._.transform.concat([["r", deg, cx, cy]])); + return this; + }; + elproto.translate = function (dx, dy) { + if (this.removed) { + return this; + } + dx = Str(dx).split(separator); + if (dx.length - 1) { + dy = toFloat(dx[1]); + } + dx = toFloat(dx[0]) || 0; + dy = +dy || 0; + if (this._.bbox) { + this._.bbox.x += dx; + this._.bbox.y += dy; + } + this.transform(this._.transform.concat([["t", dx, dy]])); + return this; + }; + elproto.scale = function (sx, sy, cx, cy) { + if (this.removed) { + return this; + } + sx = Str(sx).split(separator); + if (sx.length - 1) { + sy = toFloat(sx[1]); + cx = toFloat(sx[2]); + cy = toFloat(sx[3]); + isNaN(cx) && (cx = null); + isNaN(cy) && (cy = null); + } + sx = toFloat(sx[0]); + (sy == null) && (sy = sx); + (cy == null) && (cx = cy); + if (cx == null || cy == null) { + var bbox = this.getBBox(1); + } + cx = cx == null ? bbox.x + bbox.width / 2 : cx; + cy = cy == null ? bbox.y + bbox.height / 2 : cy; + + this.transform(this._.transform.concat([["s", sx, sy, cx, cy]])); + this._.dirtyT = 1; + return this; + }; + elproto.hide = function () { + !this.removed && (this.node.style.display = "none"); + return this; + }; + elproto.show = function () { + !this.removed && (this.node.style.display = E); + return this; + }; + // Needed to fix the vml setViewBox issues + elproto.auxGetBBox = R.el.getBBox; + elproto.getBBox = function(){ + var b = this.auxGetBBox(); + if (this.paper && this.paper._viewBoxShift) + { + var c = {}; + var z = 1/this.paper._viewBoxShift.scale; + c.x = b.x - this.paper._viewBoxShift.dx; + c.x *= z; + c.y = b.y - this.paper._viewBoxShift.dy; + c.y *= z; + c.width = b.width * z; + c.height = b.height * z; + c.x2 = c.x + c.width; + c.y2 = c.y + c.height; + return c; + } + return b; + }; + elproto._getBBox = function () { + if (this.removed) { + return {}; + } + return { + x: this.X + (this.bbx || 0) - this.W / 2, + y: this.Y - this.H, + width: this.W, + height: this.H + }; + }; + elproto.remove = function () { + if (this.removed || !this.node.parentNode) { + return; + } + this.paper.__set__ && this.paper.__set__.exclude(this); + R.eve.unbind("raphael.*.*." + this.id); + R._tear(this, this.paper); + this.node.parentNode.removeChild(this.node); + this.shape && this.shape.parentNode.removeChild(this.shape); + for (var i in this) { + this[i] = typeof this[i] == "function" ? R._removedFactory(i) : null; + } + this.removed = true; + }; + elproto.attr = function (name, value) { + if (this.removed) { + return this; + } + if (name == null) { + var res = {}; + for (var a in this.attrs) if (this.attrs[has](a)) { + res[a] = this.attrs[a]; + } + res.gradient && res.fill == "none" && (res.fill = res.gradient) && delete res.gradient; + res.transform = this._.transform; + return res; + } + if (value == null && R.is(name, "string")) { + if (name == fillString && this.attrs.fill == "none" && this.attrs.gradient) { + return this.attrs.gradient; + } + var names = name.split(separator), + out = {}; + for (var i = 0, ii = names.length; i < ii; i++) { + name = names[i]; + if (name in this.attrs) { + out[name] = this.attrs[name]; + } else if (R.is(this.paper.customAttributes[name], "function")) { + out[name] = this.paper.customAttributes[name].def; + } else { + out[name] = R._availableAttrs[name]; + } + } + return ii - 1 ? out : out[names[0]]; + } + if (this.attrs && value == null && R.is(name, "array")) { + out = {}; + for (i = 0, ii = name.length; i < ii; i++) { + out[name[i]] = this.attr(name[i]); + } + return out; + } + var params; + if (value != null) { + params = {}; + params[name] = value; + } + value == null && R.is(name, "object") && (params = name); + for (var key in params) { + eve("raphael.attr." + key + "." + this.id, this, params[key]); + } + if (params) { + for (key in this.paper.customAttributes) if (this.paper.customAttributes[has](key) && params[has](key) && R.is(this.paper.customAttributes[key], "function")) { + var par = this.paper.customAttributes[key].apply(this, [].concat(params[key])); + this.attrs[key] = params[key]; + for (var subkey in par) if (par[has](subkey)) { + params[subkey] = par[subkey]; + } + } + // this.paper.canvas.style.display = "none"; + if (params.text && this.type == "text") { + this.textpath.string = params.text; + } + setFillAndStroke(this, params); + // this.paper.canvas.style.display = E; + } + return this; + }; + elproto.toFront = function () { + !this.removed && this.node.parentNode.appendChild(this.node); + this.paper && this.paper.top != this && R._tofront(this, this.paper); + return this; + }; + elproto.toBack = function () { + if (this.removed) { + return this; + } + if (this.node.parentNode.firstChild != this.node) { + this.node.parentNode.insertBefore(this.node, this.node.parentNode.firstChild); + R._toback(this, this.paper); + } + return this; + }; + elproto.insertAfter = function (element) { + if (this.removed) { + return this; + } + if (element.constructor == R.st.constructor) { + element = element[element.length - 1]; + } + if (element.node.nextSibling) { + element.node.parentNode.insertBefore(this.node, element.node.nextSibling); + } else { + element.node.parentNode.appendChild(this.node); + } + R._insertafter(this, element, this.paper); + return this; + }; + elproto.insertBefore = function (element) { + if (this.removed) { + return this; + } + if (element.constructor == R.st.constructor) { + element = element[0]; + } + element.node.parentNode.insertBefore(this.node, element.node); + R._insertbefore(this, element, this.paper); + return this; + }; + elproto.blur = function (size) { + var s = this.node.runtimeStyle, + f = s.filter; + f = f.replace(blurregexp, E); + if (+size !== 0) { + this.attrs.blur = size; + s.filter = f + S + ms + ".Blur(pixelradius=" + (+size || 1.5) + ")"; + s.margin = R.format("-{0}px 0 0 -{0}px", round(+size || 1.5)); + } else { + s.filter = f; + s.margin = 0; + delete this.attrs.blur; + } + return this; + }; + + R._engine.path = function (pathString, vml) { + var el = createNode("shape"); + el.style.cssText = cssDot; + el.coordsize = zoom + S + zoom; + el.coordorigin = vml.coordorigin; + var p = new Element(el, vml), + attr = {fill: "none", stroke: "#000"}; + pathString && (attr.path = pathString); + p.type = "path"; + p.path = []; + p.Path = E; + setFillAndStroke(p, attr); + vml.canvas.appendChild(el); + var skew = createNode("skew"); + skew.on = true; + el.appendChild(skew); + p.skew = skew; + p.transform(E); + return p; + }; + R._engine.rect = function (vml, x, y, w, h, r) { + var path = R._rectPath(x, y, w, h, r), + res = vml.path(path), + a = res.attrs; + res.X = a.x = x; + res.Y = a.y = y; + res.W = a.width = w; + res.H = a.height = h; + a.r = r; + a.path = path; + res.type = "rect"; + return res; + }; + R._engine.ellipse = function (vml, x, y, rx, ry) { + var res = vml.path(), + a = res.attrs; + res.X = x - rx; + res.Y = y - ry; + res.W = rx * 2; + res.H = ry * 2; + res.type = "ellipse"; + setFillAndStroke(res, { + cx: x, + cy: y, + rx: rx, + ry: ry + }); + return res; + }; + R._engine.circle = function (vml, x, y, r) { + var res = vml.path(), + a = res.attrs; + res.X = x - r; + res.Y = y - r; + res.W = res.H = r * 2; + res.type = "circle"; + setFillAndStroke(res, { + cx: x, + cy: y, + r: r + }); + return res; + }; + R._engine.image = function (vml, src, x, y, w, h) { + var path = R._rectPath(x, y, w, h), + res = vml.path(path).attr({stroke: "none"}), + a = res.attrs, + node = res.node, + fill = node.getElementsByTagName(fillString)[0]; + a.src = src; + res.X = a.x = x; + res.Y = a.y = y; + res.W = a.width = w; + res.H = a.height = h; + a.path = path; + res.type = "image"; + fill.parentNode == node && node.removeChild(fill); + fill.rotate = true; + fill.src = src; + fill.type = "tile"; + res._.fillpos = [x, y]; + res._.fillsize = [w, h]; + node.appendChild(fill); + setCoords(res, 1, 1, 0, 0, 0); + return res; + }; + R._engine.text = function (vml, x, y, text) { + var el = createNode("shape"), + path = createNode("path"), + o = createNode("textpath"); + x = x || 0; + y = y || 0; + text = text || ""; + path.v = R.format("m{0},{1}l{2},{1}", round(x * zoom), round(y * zoom), round(x * zoom) + 1); + path.textpathok = true; + o.string = Str(text); + o.on = true; + el.style.cssText = cssDot; + el.coordsize = zoom + S + zoom; + el.coordorigin = "0 0"; + var p = new Element(el, vml), + attr = { + fill: "#000", + stroke: "none", + font: R._availableAttrs.font, + text: text + }; + p.shape = el; + p.path = path; + p.textpath = o; + p.type = "text"; + p.attrs.text = Str(text); + p.attrs.x = x; + p.attrs.y = y; + p.attrs.w = 1; + p.attrs.h = 1; + setFillAndStroke(p, attr); + el.appendChild(o); + el.appendChild(path); + vml.canvas.appendChild(el); + var skew = createNode("skew"); + skew.on = true; + el.appendChild(skew); + p.skew = skew; + p.transform(E); + return p; + }; + R._engine.setSize = function (width, height) { + var cs = this.canvas.style; + this.width = width; + this.height = height; + width == +width && (width += "px"); + height == +height && (height += "px"); + cs.width = width; + cs.height = height; + cs.clip = "rect(0 " + width + " " + height + " 0)"; + if (this._viewBox) { + R._engine.setViewBox.apply(this, this._viewBox); + } + return this; + }; + R._engine.setViewBox = function (x, y, w, h, fit) { + R.eve("raphael.setViewBox", this, this._viewBox, [x, y, w, h, fit]); + var paperSize = this.getSize(), + width = paperSize.width, + height = paperSize.height, + H, W; + if (fit) { + H = height / h; + W = width / w; + if (w * H < width) { + x -= (width - w * H) / 2 / H; + } + if (h * W < height) { + y -= (height - h * W) / 2 / W; + } + } + this._viewBox = [x, y, w, h, !!fit]; + this._viewBoxShift = { + dx: -x, + dy: -y, + scale: paperSize + }; + this.forEach(function (el) { + el.transform("..."); + }); + return this; + }; + var createNode; + R._engine.initWin = function (win) { + var doc = win.document; + if (doc.styleSheets.length < 31) { + doc.createStyleSheet().addRule(".rvml", "behavior:url(#default#VML)"); + } else { + // no more room, add to the existing one + // http://msdn.microsoft.com/en-us/library/ms531194%28VS.85%29.aspx + doc.styleSheets[0].addRule(".rvml", "behavior:url(#default#VML)"); + } + try { + !doc.namespaces.rvml && doc.namespaces.add("rvml", "urn:schemas-microsoft-com:vml"); + createNode = function (tagName) { + return doc.createElement('<rvml:' + tagName + ' class="rvml">'); + }; + } catch (e) { + createNode = function (tagName) { + return doc.createElement('<' + tagName + ' xmlns="urn:schemas-microsoft.com:vml" class="rvml">'); + }; + } + }; + R._engine.initWin(R._g.win); + R._engine.create = function () { + var con = R._getContainer.apply(0, arguments), + container = con.container, + height = con.height, + s, + width = con.width, + x = con.x, + y = con.y; + if (!container) { + throw new Error("VML container not found."); + } + var res = new R._Paper, + c = res.canvas = R._g.doc.createElement("div"), + cs = c.style; + x = x || 0; + y = y || 0; + width = width || 512; + height = height || 342; + res.width = width; + res.height = height; + width == +width && (width += "px"); + height == +height && (height += "px"); + res.coordsize = zoom * 1e3 + S + zoom * 1e3; + res.coordorigin = "0 0"; + res.span = R._g.doc.createElement("span"); + res.span.style.cssText = "position:absolute;left:-9999em;top:-9999em;padding:0;margin:0;line-height:1;"; + c.appendChild(res.span); + cs.cssText = R.format("top:0;left:0;width:{0};height:{1};display:inline-block;position:relative;clip:rect(0 {0} {1} 0);overflow:hidden", width, height); + if (container == 1) { + R._g.doc.body.appendChild(c); + cs.left = x + "px"; + cs.top = y + "px"; + cs.position = "absolute"; + } else { + if (container.firstChild) { + container.insertBefore(c, container.firstChild); + } else { + container.appendChild(c); + } + } + res.renderfix = function () {}; + return res; + }; + R.prototype.clear = function () { + R.eve("raphael.clear", this); + this.canvas.innerHTML = E; + this.span = R._g.doc.createElement("span"); + this.span.style.cssText = "position:absolute;left:-9999em;top:-9999em;padding:0;margin:0;line-height:1;display:inline;"; + this.canvas.appendChild(this.span); + this.bottom = this.top = null; + }; + R.prototype.remove = function () { + R.eve("raphael.remove", this); + this.canvas.parentNode.removeChild(this.canvas); + for (var i in this) { + this[i] = typeof this[i] == "function" ? R._removedFactory(i) : null; + } + return true; + }; + + var setproto = R.st; + for (var method in elproto) if (elproto[has](method) && !setproto[has](method)) { + setproto[method] = (function (methodname) { + return function () { + var arg = arguments; + return this.forEach(function (el) { + el[methodname].apply(el, arg); + }); + }; + })(method); + } +})(); + + // EXPOSE + // SVG and VML are appended just before the EXPOSE line + // Even with AMD, Raphael should be defined globally + oldRaphael.was ? (g.win.Raphael = R) : (Raphael = R); + + if(typeof exports == "object"){ + module.exports = R; + } + return R; +})); From 7163229738c4fa534d7909ea168d0612ef89fbef Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 8 Jun 2016 11:28:35 -0600 Subject: [PATCH 072/318] Fix failing test. --- app/views/projects/network/show.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/network/show.html.haml b/app/views/projects/network/show.html.haml index 3c155e97f72..e4ab064eda8 100644 --- a/app/views/projects/network/show.html.haml +++ b/app/views/projects/network/show.html.haml @@ -15,5 +15,5 @@ = check_box_tag :filter_ref, 1, @options[:filter_ref] %span Begin with the selected commit - .network-graph{ data: { url: "#{escape_javascript(@url)}", commit_url: "#{escape_javascript(@commit_url)}", ref: "#{escape_javascript(@ref)}", commit_id: "#{escape_javascript(@commit.id)}" } } + .network-graph{ data: { url: '#{escape_javascript(@url)}', commit_url: '#{escape_javascript(@commit_url)}', ref: '#{escape_javascript(@ref)}', commit_id: '#{escape_javascript(@commit.id)}' } } = spinner nil, true From 853435d10b43a9d6e13351493197368bb803b01d Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 9 Jun 2016 15:07:35 +0100 Subject: [PATCH 073/318] .tree-controls stacking context now above .tree-holdr Updated CHANGELOG Removed CHANGELOG entry --- app/assets/stylesheets/pages/tree.scss | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index f16fc7f388f..770bbdfc265 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -129,4 +129,6 @@ .tree-controls { float: right; margin-top: 11px; + position: relative; + z-index: 2; } From a9d14ddcedb7c126b8ee4942b0ca6e794ff996f8 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Thu, 9 Jun 2016 18:26:14 +0100 Subject: [PATCH 074/318] added whitespace toggle to diffs page and set it to return the project compare path with the selected whitespace params Updated CHANGELOG Moved CHANGELOG entry --- CHANGELOG | 1 + app/helpers/diff_helper.rb | 5 +++++ app/views/projects/diffs/_diffs.html.haml | 2 ++ 3 files changed, 8 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 0c712b445a4..58bd01741db 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -38,6 +38,7 @@ v 8.9.0 (unreleased) - Add support for using Yubikeys (U2F) for two-factor authentication - Link to blank group icon doesn't throw a 404 anymore - Remove 'main language' feature + - Toggle whitespace button now available for compare branches diffs #17881 - Pipelines can be canceled only when there are running builds - Use downcased path to container repository as this is expected path by Docker - Projects pending deletion will render a 404 page diff --git a/app/helpers/diff_helper.rb b/app/helpers/diff_helper.rb index cbe47176831..e22dce59d0f 100644 --- a/app/helpers/diff_helper.rb +++ b/app/helpers/diff_helper.rb @@ -135,6 +135,11 @@ module DiffHelper toggle_whitespace_link(url, options) end + def diff_compare_whitespace_link(project, from, to, options) + url = namespace_project_compare_path(project.namespace, project, from, to, params_with_whitespace) + toggle_whitespace_link(url, options) + end + private def hide_whitespace? diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index d9c4b410d32..1e8d99f06eb 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -11,6 +11,8 @@ = commit_diff_whitespace_link(@project, @commit, class: 'hidden-xs') - elsif current_controller?(:merge_requests) = diff_merge_request_whitespace_link(@project, @merge_request, class: 'hidden-xs') + - elsif current_controller?(:compare) + = diff_compare_whitespace_link(@project, params[:from], params[:to], class: 'hidden-xs') .btn-group = inline_diff_btn = parallel_diff_btn From 52e1e03092c67392438d8fae24192e4acfb09535 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Wed, 8 Jun 2016 20:00:27 +0100 Subject: [PATCH 075/318] Updated '.event-item a' color Updated CHANGELOG Removed CHANGELOG entry --- app/assets/stylesheets/pages/events.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 6fe57c737b3..dde189a21d5 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -21,7 +21,7 @@ } a { - color: $gl-dark-link-color; + color: $gl-link-color; } .avatar { From 1ede0afacc1b9089ffebaa294586b4c81bddd8c8 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Tue, 7 Jun 2016 23:04:14 +0100 Subject: [PATCH 076/318] added hover state to top nav links Updated CHANGELOG Removed CHANGELOG entry --- app/assets/stylesheets/framework/nav.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index a036799e15a..4c3fea4df84 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -345,6 +345,12 @@ .badge { color: $gl-icon-color; } + + &:hover { + a, i { + color: $black; + } + } } } From 20a6111d2b989e9cba9ee1106975eeb6054e01d5 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Mon, 6 Jun 2016 22:44:20 +0100 Subject: [PATCH 077/318] Added ability to skip the Mousetrap binding reset Added 'y' shortcut for copying a files immutable content hash link Updated CHANGELOG changed ! to not Moved CHANGELOG entry --- CHANGELOG | 1 + app/assets/javascripts/dispatcher.js.coffee | 1 + app/assets/javascripts/shortcuts.js.coffee | 4 ++-- app/assets/javascripts/shortcuts_blob.coffee | 10 ++++++++++ 4 files changed, 14 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/shortcuts_blob.coffee diff --git a/CHANGELOG b/CHANGELOG index 0c712b445a4..5e8e192538f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -18,6 +18,7 @@ v 8.9.0 (unreleased) - Remove project notification settings associated with deleted projects - Fix 404 page when viewing TODOs that contain milestones or labels in different projects - Redesign navigation for project pages + - Added shortcut 'y' for copying a files content hash URL #14470 - Fix groups API to list only user's accessible projects - Redesign account and email confirmation emails - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 29ac0f70b30..b5892dacf2c 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -96,6 +96,7 @@ class Dispatcher when 'projects:blob:show', 'projects:blame:show' new LineHighlighter() shortcut_handler = new ShortcutsNavigation() + new ShortcutsBlob true when 'projects:labels:new', 'projects:labels:edit' new Labels() when 'projects:labels:index' diff --git a/app/assets/javascripts/shortcuts.js.coffee b/app/assets/javascripts/shortcuts.js.coffee index f3d66004138..c03877e9b06 100644 --- a/app/assets/javascripts/shortcuts.js.coffee +++ b/app/assets/javascripts/shortcuts.js.coffee @@ -1,7 +1,7 @@ class @Shortcuts - constructor: -> + constructor: (skipResetBindings) -> @enabledHelp = [] - Mousetrap.reset() + Mousetrap.reset() if not skipResetBindings Mousetrap.bind('?', @onToggleHelp) Mousetrap.bind('s', Shortcuts.focusSearch) Mousetrap.bind(['ctrl+shift+p', 'command+shift+p'], @toggleMarkdownPreview) diff --git a/app/assets/javascripts/shortcuts_blob.coffee b/app/assets/javascripts/shortcuts_blob.coffee new file mode 100644 index 00000000000..6d21e5d1150 --- /dev/null +++ b/app/assets/javascripts/shortcuts_blob.coffee @@ -0,0 +1,10 @@ +#= require shortcuts + +class @ShortcutsBlob extends Shortcuts + constructor: (skipResetBindings) -> + super skipResetBindings + Mousetrap.bind('y', ShortcutsBlob.copyToClipboard) + + @copyToClipboard: -> + clipboardButton = $('.btn-clipboard') + clipboardButton.click() if clipboardButton From 1381b4f42b3caa3ff39264cce8042339c93c4d47 Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Fri, 10 Jun 2016 10:43:11 -0600 Subject: [PATCH 078/318] Fix displaying of project settings links the user cannot access. --- app/views/layouts/nav/_project.html.haml | 25 +++--- .../layouts/nav/_project_settings.html.haml | 79 ++++++++++--------- 2 files changed, 53 insertions(+), 51 deletions(-) diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index ca99ba8def3..cc2825932d9 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -1,23 +1,24 @@ - if current_user + - access = user_max_access_in_project(current_user.id, @project) + - can_edit = can?(current_user, :admin_project, @project) .controls - - access = user_max_access_in_project(current_user.id, @project) - - can_edit = can?(current_user, :admin_project, @project) .dropdown.project-settings-dropdown %a.dropdown-new.btn.btn-default#project-settings-button{href: '#', 'data-toggle' => 'dropdown'} = icon('cog') = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right = render 'layouts/nav/project_settings' - %li.divider - - if can_edit - %li - = link_to edit_project_path(@project) do - Edit Project - - if access - %li - = link_to leave_namespace_project_project_members_path(@project.namespace, @project), - data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do - Leave Project + - if can_edit || access + %li.divider + - if can_edit + %li + = link_to edit_project_path(@project) do + Edit Project + - if access + %li + = link_to leave_namespace_project_project_members_path(@project.namespace, @project), + data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do + Leave Project %div{ class: nav_control_class } %ul.nav-links.scrolling-tabs diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 885e78d38c6..459502d7140 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -1,45 +1,46 @@ +- access = user_max_access_in_project(current_user.id, @project) - if project_nav_tab? :team = nav_link(controller: [:project_members, :teams]) do = link_to namespace_project_project_members_path(@project.namespace, @project), title: 'Members', class: 'team-tab tab' do %span Members +- if access + - if @project.allowed_to_share_with_group? + = nav_link(controller: :group_links) do + = link_to namespace_project_group_links_path(@project.namespace, @project), title: "Groups" do + %span + Groups + = nav_link(controller: :deploy_keys) do + = link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do + %span + Deploy Keys + = nav_link(controller: :hooks) do + = link_to namespace_project_hooks_path(@project.namespace, @project), title: 'Webhooks' do + %span + Webhooks + = nav_link(controller: :services) do + = link_to namespace_project_services_path(@project.namespace, @project), title: 'Services' do + %span + Services + = nav_link(controller: :protected_branches) do + = link_to namespace_project_protected_branches_path(@project.namespace, @project), title: 'Protected Branches' do + %span + Protected Branches -- if @project.allowed_to_share_with_group? - = nav_link(controller: :group_links) do - = link_to namespace_project_group_links_path(@project.namespace, @project), title: "Groups" do - %span - Groups -= nav_link(controller: :deploy_keys) do - = link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do - %span - Deploy Keys -= nav_link(controller: :hooks) do - = link_to namespace_project_hooks_path(@project.namespace, @project), title: 'Webhooks' do - %span - Webhooks -= nav_link(controller: :services) do - = link_to namespace_project_services_path(@project.namespace, @project), title: 'Services' do - %span - Services -= nav_link(controller: :protected_branches) do - = link_to namespace_project_protected_branches_path(@project.namespace, @project), title: 'Protected Branches' do - %span - Protected Branches - -- if @project.builds_enabled? - = nav_link(controller: :runners) do - = link_to namespace_project_runners_path(@project.namespace, @project), title: 'Runners' do - %span - Runners - = nav_link(controller: :variables) do - = link_to namespace_project_variables_path(@project.namespace, @project), title: 'Variables' do - %span - Variables - = nav_link(controller: :triggers) do - = link_to namespace_project_triggers_path(@project.namespace, @project), title: 'Triggers' do - %span - Triggers - = nav_link(controller: :badges) do - = link_to namespace_project_badges_path(@project.namespace, @project), title: 'Badges' do - %span - Badges + - if @project.builds_enabled? + = nav_link(controller: :runners) do + = link_to namespace_project_runners_path(@project.namespace, @project), title: 'Runners' do + %span + Runners + = nav_link(controller: :variables) do + = link_to namespace_project_variables_path(@project.namespace, @project), title: 'Variables' do + %span + Variables + = nav_link(controller: :triggers) do + = link_to namespace_project_triggers_path(@project.namespace, @project), title: 'Triggers' do + %span + Triggers + = nav_link(controller: :badges) do + = link_to namespace_project_badges_path(@project.namespace, @project), title: 'Badges' do + %span + Badges From 358404687fd4981a57d434f0edaa36336d2befd4 Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Fri, 10 Jun 2016 10:49:12 -0600 Subject: [PATCH 079/318] Fix a bug that allowed Guests to still see Settings links they couldn't access. --- app/views/layouts/nav/_project_settings.html.haml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 459502d7140..d26f89bdf17 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -1,10 +1,11 @@ - access = user_max_access_in_project(current_user.id, @project) +- can_edit = can?(current_user, :admin_project, @project) - if project_nav_tab? :team = nav_link(controller: [:project_members, :teams]) do = link_to namespace_project_project_members_path(@project.namespace, @project), title: 'Members', class: 'team-tab tab' do %span Members -- if access +- if access && can_edit - if @project.allowed_to_share_with_group? = nav_link(controller: :group_links) do = link_to namespace_project_group_links_path(@project.namespace, @project), title: "Groups" do From 479ecbab9b859b829a9ff15d5eba4fa641d0bfaa Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lukeeeebennettplus@gmail.com> Date: Wed, 1 Jun 2016 00:57:44 +0100 Subject: [PATCH 080/318] Tidied dispatcher switch and added shortcuts to project pipelines, milestones and forks pages Updated CHANGELOG Moved CHANGELOG entry --- CHANGELOG | 1 + app/assets/javascripts/dispatcher.js.coffee | 16 +++++----------- 2 files changed, 6 insertions(+), 11 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 0c712b445a4..5969e18701c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -32,6 +32,7 @@ v 8.9.0 (unreleased) - Changed the Slack build message to use the singular duration if necessary (Aran Koning) - Links from a wiki page to other wiki pages should be rewritten as expected - Add option to project to only allow merge requests to be merged if the build succeeds (Rui Santos) + - Added navigation shortcuts to the project pipelines, milestones, builds and forks page. !4393 - Fix issues filter when ordering by milestone - Todos will display target state if issuable target is 'Closed' or 'Merged' - Fix bug when sorting issues by milestone due date and filtering by two or more labels diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 29ac0f70b30..4c50e540980 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -68,9 +68,7 @@ class Dispatcher new Diff() new ZenMode() shortcut_handler = new ShortcutsNavigation() - when 'projects:commits:show' - shortcut_handler = new ShortcutsNavigation() - when 'projects:activity' + when 'projects:commits:show', 'projects:activity' shortcut_handler = new ShortcutsNavigation() when 'projects:show' shortcut_handler = new ShortcutsNavigation() @@ -129,15 +127,11 @@ class Dispatcher new Project() new ProjectAvatar() switch path[1] - when 'compare' - shortcut_handler = new ShortcutsNavigation() when 'edit' shortcut_handler = new ShortcutsNavigation() new ProjectNew() - when 'new' + when 'new', 'show' new ProjectNew() - when 'show' - new ProjectShow() when 'wikis' new Wikis() shortcut_handler = new ShortcutsNavigation() @@ -146,9 +140,9 @@ class Dispatcher when 'snippets' shortcut_handler = new ShortcutsNavigation() new ZenMode() if path[2] == 'show' - when 'labels', 'graphs' - shortcut_handler = new ShortcutsNavigation() - when 'project_members', 'deploy_keys', 'hooks', 'services', 'protected_branches' + when 'labels', 'graphs', 'compare', 'pipelines', 'forks', \ + 'milestones', 'project_members', 'deploy_keys', 'builds', \ + 'hooks', 'services', 'protected_branches' shortcut_handler = new ShortcutsNavigation() # If we haven't installed a custom shortcut handler, install the default one From 30bf8dcc144784a3f8bc37b3a98bf8e393d05953 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lukeeeebennettplus@gmail.com> Date: Wed, 1 Jun 2016 00:07:05 +0100 Subject: [PATCH 081/318] Pipeline artifacts download button wording improved Updated CHANGELOG Removed CHANGELOG entry --- app/views/projects/ci/pipelines/_pipeline.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/ci/pipelines/_pipeline.html.haml b/app/views/projects/ci/pipelines/_pipeline.html.haml index a0ffa065067..b8d8758fd2b 100644 --- a/app/views/projects/ci/pipelines/_pipeline.html.haml +++ b/app/views/projects/ci/pipelines/_pipeline.html.haml @@ -60,7 +60,7 @@ %li = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, build), rel: 'nofollow' do = icon("download") - %span #{build.name} + %span Download '#{build.name}' artifacts - if can?(current_user, :update_pipeline, @project) - if pipeline.retryable? From 3714e1914b6b891274ab7d8b8db8f21dedd29e37 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 21:25:48 +0200 Subject: [PATCH 082/318] Improve after review --- doc/ci/yaml/README.md | 2 +- lib/ci/gitlab_ci_yaml_processor.rb | 4 ++-- spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 39fad549a04..0707555e393 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -670,7 +670,7 @@ failure. **Example configurations** -To upload artifacts only when build fails +To upload artifacts only when build fails. ```yaml job: diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 15d57a46eb0..40a5d180fd0 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -208,7 +208,7 @@ module Ci raise ValidationError, "#{name} job: allow_failure parameter should be an boolean" end - if job[:when] && !job[:when].in?(%w(on_success on_failure always)) + if job[:when] && !job[:when].in?(%w[on_success on_failure always]) raise ValidationError, "#{name} job: when parameter should be on_success, on_failure or always" end end @@ -279,7 +279,7 @@ module Ci raise ValidationError, "#{name} job: artifacts:paths parameter should be an array of strings" end - if job[:artifacts][:when] && !job[:artifacts][:when].in?(%w(on_success on_failure always)) + if job[:artifacts][:when] && !job[:artifacts][:when].in?(%w[on_success on_failure always]) raise ValidationError, "#{name} job: artifacts:when parameter should be on_success, on_failure or always" end end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 3d3715f0ef0..fe1e8b2262e 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -602,7 +602,7 @@ module Ci }) end - %w(on_success on_failure always).each do |when_state| + %w[on_success on_failure always].each do |when_state| it "returns artifacts for when #{when_state} defined" do config = YAML.dump({ rspec: { @@ -612,6 +612,7 @@ module Ci }) config_processor = GitlabCiYamlProcessor.new(config, path) + builds = config_processor.builds_for_stage_and_ref("test", "master") expect(builds.size).to eq(1) expect(builds.first[:options][:artifacts][:when]).to eq(when_state) From dc7734f4bf919e67323eafdaa1c875a84433fdf9 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 22:08:06 +0200 Subject: [PATCH 083/318] Use bundle exec to run spinach --- .gitlab-ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index aa84a9e4da2..b5585158479 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -91,7 +91,7 @@ update-knapsack: - export KNAPSACK_REPORT_PATH=knapsack/spinach_node_${CI_NODE_INDEX}_${CI_NODE_TOTAL}_report.json - export KNAPSACK_GENERATE_REPORT=true - cp knapsack/spinach_report.json ${KNAPSACK_REPORT_PATH} - - knapsack spinach "-r rerun" || retry '[ ! -e tmp/spinach-rerun.txt ] || bin/spinach -r rerun $(cat tmp/spinach-rerun.txt)' + - knapsack spinach "-r rerun" || retry '[ ! -e tmp/spinach-rerun.txt ] || bundle exec spinach -r rerun $(cat tmp/spinach-rerun.txt)' artifacts: paths: - knapsack/ From cf7da039bedcad5163ce9deedccc94206d4c485a Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 29 Apr 2016 15:14:38 +0200 Subject: [PATCH 084/318] commit status --- .../projects/environments_controller.rb | 17 ++++++ app/helpers/gitlab_routing_helper.rb | 4 ++ app/models/ci/pipeline.rb | 4 ++ .../environments/_environment.html.haml | 58 +++++++++++++++++++ .../environments/_header_title.html.haml | 1 + .../projects/environments/index.html.haml | 22 +++++++ .../projects/environments/show.html.haml | 30 ++++++++++ config/routes.rb | 2 + 8 files changed, 138 insertions(+) create mode 100644 app/controllers/projects/environments_controller.rb create mode 100644 app/views/projects/environments/_environment.html.haml create mode 100644 app/views/projects/environments/_header_title.html.haml create mode 100644 app/views/projects/environments/index.html.haml create mode 100644 app/views/projects/environments/show.html.haml diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb new file mode 100644 index 00000000000..f5af24ed217 --- /dev/null +++ b/app/controllers/projects/environments_controller.rb @@ -0,0 +1,17 @@ +class Projects::EnvironmentsController < Projects::ApplicationController + layout 'project' + + def index + @environments = project.builds.where.not(environment: nil).pluck(:environment).uniq + @environments = @environments.map { |env| build_for_env(env) }.compact + end + + def show + @environment = params[:id].to_s + @builds = project.builds.where.not(status: ["manual"]).where(environment: params[:id].to_s).order(id: :desc).page(params[:page]).per(30) + end + + def build_for_env(environment) + project.builds.success.order(id: :desc).find_by(environment: environment) + end +end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 2ce2d4e694f..aae6b5d0d38 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -29,6 +29,10 @@ module GitlabRoutingHelper namespace_project_pipelines_path(project.namespace, project, *args) end + def project_environments_path(project, *args) + namespace_project_environments_path(project.namespace, project, *args) + end + def project_builds_path(project, *args) namespace_project_builds_path(project.namespace, project, *args) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 9b5b46f4928..85d9e0856d1 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -161,6 +161,10 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end + def environments + builds.where.not(environment: nil).success.pluck(:environment).uniq + end + private def update_state diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml new file mode 100644 index 00000000000..e3216aea6cd --- /dev/null +++ b/app/views/projects/environments/_environment.html.haml @@ -0,0 +1,58 @@ +%tr.commit + - commit = build.commit + - status = build.status + + %td + %strong + = link_to build.environment, namespace_project_environment_path(@project.namespace, @project, build.environment), class: "monospace" + + %td.commit-link + = link_to namespace_project_pipeline_path(@project.namespace, @project, commit.id), class: "ci-status ci-#{commit.status}" do + = ci_icon_for_status(commit.status) + %strong ##{commit.id} + + %td.commit-link + = link_to namespace_project_build_path(@project.namespace, @project, build.id), class: "ci-status ci-#{build.status}" do + = ci_icon_for_status(build.status) + %strong ##{build.id} + + %td + %div.branch-commit + - if commit.ref + = link_to commit.ref, namespace_project_commits_path(@project.namespace, @project, commit.ref), class: "monospace" + · + = link_to commit.short_sha, namespace_project_commit_path(@project.namespace, @project, commit.sha), class: "commit-id monospace" + + %p + %span + - if commit_data = commit.commit_data + = link_to_gfm commit_data.title, namespace_project_commit_path(@project.namespace, @project, commit_data.id), class: "commit-row-message" + - else + Cant find HEAD commit for this branch + + %td + - if build.started_at && build.finished_at + %p + %i.fa.fa-clock-o +   + #{duration_in_words(build.finished_at, build.started_at)} + - if build.finished_at + %p + %i.fa.fa-calendar +   + #{time_ago_with_tooltip(build.finished_at)} + + %td + .controls.hidden-xs.pull-right + - manual = commit.builds.latest.manual_actions.to_a + - if manual.any? + .dropdown.inline + %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} + = icon('play') + %b.caret + %ul.dropdown-menu.dropdown-menu-align-right + - manual.each do |manual_build| + %li + = link_to '#', rel: 'nofollow' do + %i.fa.fa-play + %span #{manual_build.name} diff --git a/app/views/projects/environments/_header_title.html.haml b/app/views/projects/environments/_header_title.html.haml new file mode 100644 index 00000000000..e056fccad5d --- /dev/null +++ b/app/views/projects/environments/_header_title.html.haml @@ -0,0 +1 @@ +- header_title project_title(@project, "Environments", project_environments_path(@project)) diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml new file mode 100644 index 00000000000..e94bc97be9d --- /dev/null +++ b/app/views/projects/environments/index.html.haml @@ -0,0 +1,22 @@ +- page_title "Environments" += render "header_title" + +.gray-content-block + Environments for this project + +%ul.content-list + - if @environments.blank? + %li + .nothing-here-block No environments to show + - else + .table-holder + %table.table.builds + %tbody + %th Environment + %th Pipeline ID + %th Build ID + %th Changes + %th + %th + - @environments.each do |build| + = render "environment", build: build diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml new file mode 100644 index 00000000000..ce2d9cf7d71 --- /dev/null +++ b/app/views/projects/environments/show.html.haml @@ -0,0 +1,30 @@ +- page_title "Environments" + += render "header_title" + +.gray-content-block + Latest deployments for + %strong + = @environment + +%ul.content-list + - if @builds.blank? + %li + .nothing-here-block No builds to show for specific environment + - else + .table-holder + %table.table.builds + %thead + %tr + %th Status + %th Build ID + %th Commit + %th Ref + %th Name + %th Duration + %th Finished at + %th + + = render @builds, commit_sha: true, ref: true, allow_retry: true + + = paginate @builds, theme: 'gitlab' diff --git a/config/routes.rb b/config/routes.rb index 95fbe7dd9df..6b8402c40dd 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -704,6 +704,8 @@ Rails.application.routes.draw do end end + resources :environments, only: [:index, :show] + resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do collection do post :cancel_all From bcc3f8f237f5cf2b64088564637f8bb22d3522c8 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Sat, 11 Jun 2016 00:24:24 +0300 Subject: [PATCH 085/318] Fix emoji block selector. --- app/assets/javascripts/awards_handler.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/awards_handler.coffee b/app/assets/javascripts/awards_handler.coffee index 136db8ee14d..030f1564862 100644 --- a/app/assets/javascripts/awards_handler.coffee +++ b/app/assets/javascripts/awards_handler.coffee @@ -40,7 +40,7 @@ class @AwardsHandler $menu = $ '.emoji-menu' if $addBtn.hasClass 'js-note-emoji' - $addBtn.parents('.note').find('.js-awards-block').addClass 'current' + $addBtn.closest('.note').find('.js-awards-block').addClass 'current' else $addBtn.closest('.js-awards-block').addClass 'current' From 907c0e6796b69f9577c147dd489cf55748c749ac Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 23:36:54 +0200 Subject: [PATCH 086/318] Added initial version of deployments --- app/controllers/projects/builds_controller.rb | 2 +- app/controllers/projects/commit_controller.rb | 2 +- .../projects/environments_controller.rb | 14 ++-- app/helpers/projects_helper.rb | 4 ++ app/models/ability.rb | 12 +++- app/models/ci/build.rb | 13 ++-- app/models/deployment.rb | 25 +++++++ app/models/environment.rb | 11 +++ app/models/project.rb | 2 + app/services/ci/create_builds_service.rb | 3 +- app/services/create_deployment_service.rb | 45 ++++++++++++ .../deployments/_deployment.html.haml | 32 +++++++++ .../environments/_environment.html.haml | 68 ++++++------------- .../projects/environments/index.html.haml | 38 +++++------ .../projects/environments/show.html.haml | 46 ++++++------- app/views/projects/pipelines/_head.html.haml | 6 ++ db/migrate/20160610204157_add_deployments.rb | 27 ++++++++ db/migrate/20160610204158_add_environments.rb | 17 +++++ ...0160610211845_add_environment_to_builds.rb | 10 +++ db/schema.rb | 35 +++++++++- lib/api/builds.rb | 2 +- lib/ci/gitlab_ci_yaml_processor.rb | 8 ++- 22 files changed, 311 insertions(+), 111 deletions(-) create mode 100644 app/models/deployment.rb create mode 100644 app/models/environment.rb create mode 100644 app/services/create_deployment_service.rb create mode 100644 app/views/projects/deployments/_deployment.html.haml create mode 100644 db/migrate/20160610204157_add_deployments.rb create mode 100644 db/migrate/20160610204158_add_environments.rb create mode 100644 db/migrate/20160610211845_add_environment_to_builds.rb diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 14c82826342..ef3051d7519 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -51,7 +51,7 @@ class Projects::BuildsController < Projects::ApplicationController return render_404 end - build = Ci::Build.retry(@build) + build = Ci::Build.retry(@build, current_user) redirect_to build_path(build) end diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 20637fa46fe..6751737d15e 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -46,7 +46,7 @@ class Projects::CommitController < Projects::ApplicationController def retry_builds ci_builds.latest.failed.each do |build| if build.retryable? - Ci::Build.retry(build) + Ci::Build.retry(build, current_user) end end diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index f5af24ed217..722954a6b78 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -1,17 +1,19 @@ class Projects::EnvironmentsController < Projects::ApplicationController layout 'project' + before_action :authorize_read_environment! + before_action :environment, only: [:show] def index - @environments = project.builds.where.not(environment: nil).pluck(:environment).uniq - @environments = @environments.map { |env| build_for_env(env) }.compact + @environments = project.environments end def show - @environment = params[:id].to_s - @builds = project.builds.where.not(status: ["manual"]).where(environment: params[:id].to_s).order(id: :desc).page(params[:page]).per(30) end - def build_for_env(environment) - project.builds.success.order(id: :desc).find_by(environment: environment) + private + + def environment + @environment ||= project.environments.find(params[:id].to_s) + @environment || render_404 end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 5e5d170a9f3..2ad7520b63a 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -156,6 +156,10 @@ module ProjectsHelper nav_tabs << :container_registry end + if can?(current_user, :read_environment, project) + nav_tabs << :environments + end + if can?(current_user, :admin_project, project) nav_tabs << :settings end diff --git a/app/models/ability.rb b/app/models/ability.rb index 44515550d9e..747f250ff4f 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -228,6 +228,8 @@ class Ability :read_build, :read_container_image, :read_pipeline, + :read_environment, + :read_deployment ] end @@ -246,6 +248,10 @@ class Ability :push_code, :create_container_image, :update_container_image, + :create_environment, + :update_environment, + :create_deployment, + :update_deployment, ] end @@ -273,7 +279,9 @@ class Ability :admin_commit_status, :admin_build, :admin_container_image, - :admin_pipeline + :admin_pipeline, + :admin_environment, + :admin_deployment ] end @@ -317,6 +325,8 @@ class Ability unless project.builds_enabled rules += named_abilities('build') rules += named_abilities('pipeline') + rules += named_abilities('environment') + rules += named_abilities('deployment') end unless project.container_registry_enabled diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 6a64ca451f7..60202525727 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -38,7 +38,7 @@ module Ci new_build.save end - def retry(build) + def retry(build, user = nil) new_build = Ci::Build.new(status: 'pending') new_build.ref = build.ref new_build.tag = build.tag @@ -52,6 +52,7 @@ module Ci new_build.stage = build.stage new_build.stage_idx = build.stage_idx new_build.trigger_request = build.trigger_request + new_build.user = user new_build.save MergeRequests::AddTodoWhenBuildFailsService.new(build.project, nil).close(new_build) new_build @@ -73,6 +74,12 @@ module Ci build.update_coverage build.execute_hooks end + + after_transition any: :success do |build| + if build.environment.present? + CreateDeploymentService.new(build.project, build.user, environment: build.environment).execute(build) + end + end end def retryable? @@ -83,10 +90,6 @@ module Ci !self.pipeline.statuses.latest.include?(self) end - def retry - Ci::Build.retry(self) - end - def depends_on_builds # Get builds of the same type latest_builds = self.pipeline.builds.latest diff --git a/app/models/deployment.rb b/app/models/deployment.rb new file mode 100644 index 00000000000..7cdfc740441 --- /dev/null +++ b/app/models/deployment.rb @@ -0,0 +1,25 @@ +class Deployment < ActiveRecord::Base + include InternalId + + belongs_to :project + belongs_to :environment + belongs_to :user + belongs_to :deployable, polymorphic: true + + validates_presence_of :sha + validates_presence_of :ref + + delegate :name, to: :environment, prefix: true + + def commit + project.commit(sha) + end + + def commit_title + commit.try(:title) + end + + def short_sha + Commit::truncate_sha(sha) + end +end diff --git a/app/models/environment.rb b/app/models/environment.rb new file mode 100644 index 00000000000..623404ba634 --- /dev/null +++ b/app/models/environment.rb @@ -0,0 +1,11 @@ +class Environment < ActiveRecord::Base + belongs_to :project + + has_many :deployments + + validates_presence_of :name + + def last_deployment + deployments.last + end +end diff --git a/app/models/project.rb b/app/models/project.rb index e2f7ffe493c..be714ea41fd 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -125,6 +125,8 @@ class Project < ActiveRecord::Base has_many :runners, through: :runner_projects, source: :runner, class_name: 'Ci::Runner' has_many :variables, dependent: :destroy, class_name: 'Ci::Variable', foreign_key: :gl_project_id has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :gl_project_id + has_many :environments, dependent: :destroy + has_many :deployments, dependent: :destroy accepts_nested_attributes_for :variables, allow_destroy: true diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index 64bcdac5c65..3a74ae094e8 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -29,7 +29,8 @@ module Ci :options, :allow_failure, :stage, - :stage_idx) + :stage_idx, + :environment) build_attrs.merge!(ref: @pipeline.ref, tag: @pipeline.tag, diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb new file mode 100644 index 00000000000..f745471913f --- /dev/null +++ b/app/services/create_deployment_service.rb @@ -0,0 +1,45 @@ +require_relative 'base_service' + +class CreateDeploymentService < BaseService + def execute(deployable) + environment = find_or_create_environment(params[:environment]) + + deployment = create_deployment(environment, deployable) + if deployment.persisted? + success(deployment) + else + error(deployment.errors) + end + end + + private + + def find_or_create_environment(environment) + find_environment(environment) || create_environment(environment) + end + + def create_environment(environment) + project.environments.create(name: environment) + end + + def find_environment(environment) + project.environments.find_by(name: environment) + end + + def create_deployment(environment, deployable) + environment.deployments.create( + project: project, + ref: build.ref, + tag: build.tag, + sha: build.sha, + user: current_user, + deployable: deployable, + ) + end + + def success(deployment) + out = super() + out[:deployment] = deployment + out + end +end diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml new file mode 100644 index 00000000000..363c394d6d3 --- /dev/null +++ b/app/views/projects/deployments/_deployment.html.haml @@ -0,0 +1,32 @@ +%tr.deployment + %td + %strong= "##{environment.id}" + + %td + %div.branch-commit + - if deployment.ref + = link_to last.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace" + · + = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" + + %p + %span + - if commit_title = deployment.commit_title + = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message" + - else + Cant find HEAD commit for this branch + + %td + - if deployment.deployable + = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: "monospace" do + = "#{deployment.deployable.name} (##{deployment.deployable.id})" + + %td + %p + %i.fa.fa-calendar +   + #{time_ago_with_tooltip(deployment.created_at)} + + %td + - if can?(current_user, :update_deployment, @project) && deployment.deployable + = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable, :retry], method: :post, title: 'Retry', class: 'btn btn-build' diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml index e3216aea6cd..a4c88fface2 100644 --- a/app/views/projects/environments/_environment.html.haml +++ b/app/views/projects/environments/_environment.html.haml @@ -1,58 +1,32 @@ -%tr.commit - - commit = build.commit - - status = build.status +- last_deployment = environment.last_deployment +%tr.environment %td %strong - = link_to build.environment, namespace_project_environment_path(@project.namespace, @project, build.environment), class: "monospace" - - %td.commit-link - = link_to namespace_project_pipeline_path(@project.namespace, @project, commit.id), class: "ci-status ci-#{commit.status}" do - = ci_icon_for_status(commit.status) - %strong ##{commit.id} - - %td.commit-link - = link_to namespace_project_build_path(@project.namespace, @project, build.id), class: "ci-status ci-#{build.status}" do - = ci_icon_for_status(build.status) - %strong ##{build.id} + = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment), class: "monospace" %td - %div.branch-commit - - if commit.ref - = link_to commit.ref, namespace_project_commits_path(@project.namespace, @project, commit.ref), class: "monospace" - · - = link_to commit.short_sha, namespace_project_commit_path(@project.namespace, @project, commit.sha), class: "commit-id monospace" + - if last_deployment + %div.branch-commit + - if last_deployment.ref + = link_to last.ref, namespace_project_commits_path(@project.namespace, @project, last_deployment.ref), class: "monospace" + · + = link_to last_deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, last_deployment.sha), class: "commit-id monospace" + %p + %span + - if commit_title = last_deployment.commit_title + = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, last_deployment.sha), class: "commit-row-message" + - else + Cant find HEAD commit for this branch + - else %p - %span - - if commit_data = commit.commit_data - = link_to_gfm commit_data.title, namespace_project_commit_path(@project.namespace, @project, commit_data.id), class: "commit-row-message" - - else - Cant find HEAD commit for this branch + No deployments yet %td - - if build.started_at && build.finished_at - %p - %i.fa.fa-clock-o -   - #{duration_in_words(build.finished_at, build.started_at)} - - if build.finished_at - %p - %i.fa.fa-calendar -   - #{time_ago_with_tooltip(build.finished_at)} + %p + %i.fa.fa-calendar +   + #{time_ago_with_tooltip(last_deployment.created_at)} %td - .controls.hidden-xs.pull-right - - manual = commit.builds.latest.manual_actions.to_a - - if manual.any? - .dropdown.inline - %button.dropdown-toggle.btn{type: 'button', 'data-toggle' => 'dropdown'} - = icon('play') - %b.caret - %ul.dropdown-menu.dropdown-menu-align-right - - manual.each do |manual_build| - %li - = link_to '#', rel: 'nofollow' do - %i.fa.fa-play - %span #{manual_build.name} diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index e94bc97be9d..40d35ef3881 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -1,22 +1,22 @@ +- @no_container = true - page_title "Environments" -= render "header_title" += render "projects/pipelines/head" -.gray-content-block - Environments for this project +%div{ class: (container_class) } + .gray-content-block + Environments for this project -%ul.content-list - - if @environments.blank? - %li - .nothing-here-block No environments to show - - else - .table-holder - %table.table.builds - %tbody - %th Environment - %th Pipeline ID - %th Build ID - %th Changes - %th - %th - - @environments.each do |build| - = render "environment", build: build + %ul.content-list + - if @environments.blank? + %li + .nothing-here-block No environments to show + - else + .table-holder + %table.table.builds + %tbody + %th Environment + %th Last deployment + %th Date + %th + - @environments.each do |environment| + = render 'environment', environment: environment diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index ce2d9cf7d71..de5e686044f 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -1,30 +1,26 @@ +- @no_container = true - page_title "Environments" += render "projects/pipelines/head" -= render "header_title" +%div{ class: (container_class) } + .gray-content-block + Latest deployments for + %strong= @environment.name -.gray-content-block - Latest deployments for - %strong - = @environment + %ul.content-list + - if @deployments.blank? + %li + .nothing-here-block No deployment for specific environment + - else + .table-holder + %table.table.builds + %thead + %tr + %th Commit + %th Context + %th Date + %th -%ul.content-list - - if @builds.blank? - %li - .nothing-here-block No builds to show for specific environment - - else - .table-holder - %table.table.builds - %thead - %tr - %th Status - %th Build ID - %th Commit - %th Ref - %th Name - %th Duration - %th Finished at - %th + = render @deployments - = render @builds, commit_sha: true, ref: true, allow_retry: true - - = paginate @builds, theme: 'gitlab' + = paginate @deployments, theme: 'gitlab' diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index f278d4e0538..3562d91dfbd 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -13,3 +13,9 @@ %span Builds %span.badge.count.builds_counter= number_with_delimiter(@project.running_or_pending_build_count) + + - if project_nav_tab? :environments + = nav_link(controller: %w(environments)) do + = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do + %span + Environments diff --git a/db/migrate/20160610204157_add_deployments.rb b/db/migrate/20160610204157_add_deployments.rb new file mode 100644 index 00000000000..c93d3bf64d3 --- /dev/null +++ b/db/migrate/20160610204157_add_deployments.rb @@ -0,0 +1,27 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddDeployments < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + def change + create_table :deployments, force: true do |t| + t.integer :iid + t.integer :project_id + t.integer :environment_id + t.string :ref + t.boolean :tag + t.string :sha + t.integer :user_id + t.integer :deployable_id, null: false + t.string :deployable_type, null: false + t.datetime :created_at + t.datetime :updated_at + end + + add_index :deployments, :project_id + add_index :deployments, [:project_id, :iid] + add_index :deployments, [:project_id, :environment_id] + add_index :deployments, [:project_id, :environment_id, :iid] + end +end diff --git a/db/migrate/20160610204158_add_environments.rb b/db/migrate/20160610204158_add_environments.rb new file mode 100644 index 00000000000..8311fd39b01 --- /dev/null +++ b/db/migrate/20160610204158_add_environments.rb @@ -0,0 +1,17 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddEnvironments < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + def change + create_table :environments, force: true do |t| + t.integer :project_id + t.string :name, null: false + t.datetime :created_at + t.datetime :updated_at + end + + add_index :environments, [:project_id, :name] + end +end diff --git a/db/migrate/20160610211845_add_environment_to_builds.rb b/db/migrate/20160610211845_add_environment_to_builds.rb new file mode 100644 index 00000000000..990e445ac55 --- /dev/null +++ b/db/migrate/20160610211845_add_environment_to_builds.rb @@ -0,0 +1,10 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddEnvironmentToBuilds < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + def change + add_column :ci_builds, :environment, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index aac327797e7..63df5efb879 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: 20160608155312) do +ActiveRecord::Schema.define(version: 20160610211845) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -144,9 +144,9 @@ ActiveRecord::Schema.define(version: 20160608155312) do t.text "commands" t.integer "job_id" t.string "name" - t.boolean "deploy", default: false + t.boolean "deploy", default: false t.text "options" - t.boolean "allow_failure", default: false, null: false + t.boolean "allow_failure", default: false, null: false t.string "stage" t.integer "trigger_request_id" t.integer "stage_idx" @@ -161,6 +161,7 @@ ActiveRecord::Schema.define(version: 20160608155312) do t.text "artifacts_metadata" t.integer "erased_by_id" t.datetime "erased_at" + t.string "environment" end add_index "ci_builds", ["commit_id", "stage_idx", "created_at"], name: "index_ci_builds_on_commit_id_and_stage_idx_and_created_at", using: :btree @@ -381,6 +382,25 @@ ActiveRecord::Schema.define(version: 20160608155312) do add_index "deploy_keys_projects", ["project_id"], name: "index_deploy_keys_projects_on_project_id", using: :btree + create_table "deployments", force: :cascade do |t| + t.integer "iid" + t.integer "project_id" + t.integer "environment_id" + t.string "ref" + t.boolean "tag" + t.string "sha" + t.integer "user_id" + t.integer "deployable_id", null: false + t.string "deployable_type", null: false + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "deployments", ["project_id", "environment_id", "iid"], name: "index_deployments_on_project_id_and_environment_id_and_iid", using: :btree + add_index "deployments", ["project_id", "environment_id"], name: "index_deployments_on_project_id_and_environment_id", using: :btree + add_index "deployments", ["project_id", "iid"], name: "index_deployments_on_project_id_and_iid", using: :btree + add_index "deployments", ["project_id"], name: "index_deployments_on_project_id", using: :btree + create_table "emails", force: :cascade do |t| t.integer "user_id", null: false t.string "email", null: false @@ -391,6 +411,15 @@ ActiveRecord::Schema.define(version: 20160608155312) do add_index "emails", ["email"], name: "index_emails_on_email", unique: true, using: :btree add_index "emails", ["user_id"], name: "index_emails_on_user_id", using: :btree + create_table "environments", force: :cascade do |t| + t.integer "project_id" + t.string "name", null: false + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "environments", ["project_id", "name"], name: "index_environments_on_project_id_and_name", using: :btree + create_table "events", force: :cascade do |t| t.string "target_type" t.integer "target_id" diff --git a/lib/api/builds.rb b/lib/api/builds.rb index 0ff8fa74a84..6bf59afab53 100644 --- a/lib/api/builds.rb +++ b/lib/api/builds.rb @@ -142,7 +142,7 @@ module API return not_found!(build) unless build return forbidden!('Build is not retryable') unless build.retryable? - build = Ci::Build.retry(build) + build = Ci::Build.retry(build, current_user) present build, with: Entities::Build, user_can_download_artifacts: can?(current_user, :read_build, user_project) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 130f5b0892e..5aacb59dc5c 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -7,7 +7,8 @@ module Ci ALLOWED_YAML_KEYS = [:before_script, :after_script, :image, :services, :types, :stages, :variables, :cache] ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, :allow_failure, :type, :stage, :when, :artifacts, :cache, - :dependencies, :before_script, :after_script, :variables] + :dependencies, :before_script, :after_script, :variables, + :environment] attr_reader :before_script, :after_script, :image, :services, :path, :cache @@ -85,6 +86,7 @@ module Ci except: job[:except], allow_failure: job[:allow_failure] || false, when: job[:when] || 'on_success', + environment: job[:environment], options: { image: job[:image] || @image, services: job[:services] || @services, @@ -203,6 +205,10 @@ module Ci if job[:when] && !job[:when].in?(%w(on_success on_failure always)) raise ValidationError, "#{name} job: when parameter should be on_success, on_failure or always" end + + if job[:environment] && !validate_string(job[:environment]) + raise ValidationError, "#{name} job: environment should be a string" + end end def validate_job_script!(name, job) From 4f00b93ddd07d8a31a04b37dbe150340e84ccfd8 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Sat, 11 Jun 2016 00:15:53 +0200 Subject: [PATCH 087/318] Add deployment views --- .../projects/environments_controller.rb | 29 ++++++++++++++++++- app/services/create_deployment_service.rb | 11 ++----- .../deployments/_deployment.html.haml | 14 ++++----- .../environments/_environment.html.haml | 9 +++--- .../projects/environments/index.html.haml | 11 ++++--- app/views/projects/environments/new.html.haml | 15 ++++++++++ .../projects/environments/show.html.haml | 15 ++++++---- app/views/projects/pipelines/_head.html.haml | 1 + config/routes.rb | 2 +- 9 files changed, 74 insertions(+), 33 deletions(-) create mode 100644 app/views/projects/environments/new.html.haml diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 722954a6b78..c6a9a0a403a 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -1,17 +1,44 @@ class Projects::EnvironmentsController < Projects::ApplicationController layout 'project' before_action :authorize_read_environment! - before_action :environment, only: [:show] + before_action :environment, only: [:show, :destroy] def index @environments = project.environments end def show + @deployments = environment.deployments.order(id: :desc).page(params[:page]).per(30) + end + + def new + @environment = project.environments.new + end + + def create + @environment = project.environments.create(create_params) + unless @environment.persisted? + render 'new' + return + end + + redirect_to namespace_project_environment_path(project.namespace, project, @environment) + end + + def destroy + if @environment.destroy + redirect_to namespace_project_environments_path(project.namespace, project), notice: 'Environment was successfully removed.' + else + redirect_to namespace_project_environments_path(project.namespace, project), alert: 'Failed to remove environment.' + end end private + def create_params + params.require(:environment).permit(:name) + end + def environment @environment ||= project.environments.find(params[:id].to_s) @environment || render_404 diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb index f745471913f..7408ec367f6 100644 --- a/app/services/create_deployment_service.rb +++ b/app/services/create_deployment_service.rb @@ -2,7 +2,8 @@ require_relative 'base_service' class CreateDeploymentService < BaseService def execute(deployable) - environment = find_or_create_environment(params[:environment]) + environment = find_environment(params[:environment]) + return error('no environment') unless environmnet deployment = create_deployment(environment, deployable) if deployment.persisted? @@ -14,14 +15,6 @@ class CreateDeploymentService < BaseService private - def find_or_create_environment(environment) - find_environment(environment) || create_environment(environment) - end - - def create_environment(environment) - project.environments.create(name: environment) - end - def find_environment(environment) project.environments.find_by(name: environment) end diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index 363c394d6d3..539c297cad3 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -1,11 +1,11 @@ %tr.deployment %td - %strong= "##{environment.id}" + %strong= "##{deployment.iid}" %td %div.branch-commit - if deployment.ref - = link_to last.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace" + = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace" · = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" @@ -18,15 +18,13 @@ %td - if deployment.deployable - = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable], class: "monospace" do + = link_to namespace_project_build_path(@project.namespace, @project, deployment.deployable), class: "monospace" do = "#{deployment.deployable.name} (##{deployment.deployable.id})" %td - %p - %i.fa.fa-calendar -   - #{time_ago_with_tooltip(deployment.created_at)} + #{time_ago_with_tooltip(deployment.created_at)} %td - if can?(current_user, :update_deployment, @project) && deployment.deployable - = link_to [@project.namespace.becomes(Namespace), @project, deployment.deployable, :retry], method: :post, title: 'Retry', class: 'btn btn-build' + .pull-right + = link_to 'Retry', retry_namespace_project_build_path(@project.namespace, @project, deployment.deployable), method: :post, class: 'btn btn-build' diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml index a4c88fface2..16d04832e1a 100644 --- a/app/views/projects/environments/_environment.html.haml +++ b/app/views/projects/environments/_environment.html.haml @@ -9,7 +9,7 @@ - if last_deployment %div.branch-commit - if last_deployment.ref - = link_to last.ref, namespace_project_commits_path(@project.namespace, @project, last_deployment.ref), class: "monospace" + = link_to last_deployment.ref, namespace_project_commits_path(@project.namespace, @project, last_deployment.ref), class: "monospace" · = link_to last_deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, last_deployment.sha), class: "commit-id monospace" @@ -24,9 +24,8 @@ No deployments yet %td - %p - %i.fa.fa-calendar -   - #{time_ago_with_tooltip(last_deployment.created_at)} + - if last_deployment + %p + #{time_ago_with_tooltip(last_deployment.created_at)} %td diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 40d35ef3881..2da8d068e9f 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -3,16 +3,19 @@ = render "projects/pipelines/head" %div{ class: (container_class) } - .gray-content-block - Environments for this project + - if can?(current_user, :create_environment, @project) + .top-area + .nav-controls + = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do + New environment - %ul.content-list + %ul.content-list.environments - if @environments.blank? %li .nothing-here-block No environments to show - else .table-holder - %table.table.builds + %table.table %tbody %th Environment %th Last deployment diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml new file mode 100644 index 00000000000..5e8bc596f1e --- /dev/null +++ b/app/views/projects/environments/new.html.haml @@ -0,0 +1,15 @@ +- page_title "New Environment" + +%h3.page-title + New Environment +%hr + += form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { id: "new-environment-form", class: "form-horizontal js-new-environment-form js-requires-input" } do |f| + = form_errors(@environment) + .form-group + = f.label :ref, 'Name', class: 'control-label' + .col-sm-10 + = f.text_field :name, required: true, tabindex: 2, class: 'form-control' + .form-actions + = f.submit 'Create', class: 'btn btn-create', tabindex: 3 + = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel' diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index de5e686044f..dc07ad1a769 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -3,21 +3,26 @@ = render "projects/pipelines/head" %div{ class: (container_class) } - .gray-content-block - Latest deployments for - %strong= @environment.name + .top-area + .col-md-9 + %h3= @environment.name.titleize + + .col-md-3 + .nav-controls + = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post %ul.content-list - if @deployments.blank? %li - .nothing-here-block No deployment for specific environment + .nothing-here-block No deployments for #{@environment.name} - else .table-holder %table.table.builds %thead %tr + %th ID %th Commit - %th Context + %th Build %th Date %th diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index 3562d91dfbd..8374cb4223d 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -19,3 +19,4 @@ = link_to project_environments_path(@project), title: 'Environments', class: 'shortcuts-environments' do %span Environments + %span.badge.count.environments_counter= number_with_delimiter(@project.environments.count) diff --git a/config/routes.rb b/config/routes.rb index 6b8402c40dd..d50e2535e75 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -704,7 +704,7 @@ Rails.application.routes.draw do end end - resources :environments, only: [:index, :show] + resources :environments, only: [:index, :show, :new, :create, :destroy] resources :builds, only: [:index, :show], constraints: { id: /\d+/ } do collection do From 952660d2cf9b2e25b2014d117cce3bf4c93d6818 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran <alfredo@gitlab.com> Date: Fri, 10 Jun 2016 12:40:54 -0500 Subject: [PATCH 088/318] Fixes a bug when assigning a label to multiple issues Fixes the case when we want to assign a label to multiple issues and one of the issues has already the label we want to apply. --- .../issues-bulk-assignment.js.coffee | 17 +++++++++++++---- .../issues/bulk_assigment_labels_spec.rb | 17 +++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/issues-bulk-assignment.js.coffee b/app/assets/javascripts/issues-bulk-assignment.js.coffee index 16d023dd391..9dc3529a17f 100644 --- a/app/assets/javascripts/issues-bulk-assignment.js.coffee +++ b/app/assets/javascripts/issues-bulk-assignment.js.coffee @@ -97,13 +97,22 @@ class @IssuableBulkActions $labels = @form.find('.labels-filter input[name="update[label_ids][]"]') $labels.each (k, label) -> - labelIds.push $(label).val() if label + labelIds.push parseInt($(label).val()) if label labelIds ###* - * Just an alias of @getUnmarkedIndeterminedLabels - * @return {Array} Array of labels + * Returns Label IDs that will be removed from issue selection + * @return {Array} Array of labels IDs ### getLabelsToRemove: -> - @getUnmarkedIndeterminedLabels() + result = [] + indeterminatedLabels = @getUnmarkedIndeterminedLabels() + labelsToApply = @getLabelsToApply() + + indeterminatedLabels.map (id) -> + # We need to exclude label IDs that will be applied + # By not doing this will cause issues from selection to not add labels at all + result.push(id) if labelsToApply.indexOf(id) is -1 + + result diff --git a/spec/features/issues/bulk_assigment_labels_spec.rb b/spec/features/issues/bulk_assigment_labels_spec.rb index c58b87281a3..0fbc2062e39 100644 --- a/spec/features/issues/bulk_assigment_labels_spec.rb +++ b/spec/features/issues/bulk_assigment_labels_spec.rb @@ -83,6 +83,23 @@ feature 'Issues > Labels bulk assignment', feature: true do end end + context 'can assign a label to all issues when label is present' do + before do + issue2.labels << bug + issue2.labels << feature + visit namespace_project_issues_path(project.namespace, project) + + check 'check_all_issues' + open_labels_dropdown ['bug'] + update_issues + end + + it do + expect(find("#issue_#{issue1.id}")).to have_content 'bug' + expect(find("#issue_#{issue2.id}")).to have_content 'bug' + end + end + context 'can bulk un-assign' do context 'all labels to all issues' do before do From e6d66c4d3b8bdaa4abc85f3f35e0b06b785008da Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Sun, 12 Jun 2016 15:15:58 +0200 Subject: [PATCH 089/318] Don't fail builds for projects that are deleted when they are stuck --- CHANGELOG | 1 + app/workers/stuck_ci_builds_worker.rb | 2 +- spec/workers/stuck_ci_builds_worker_spec.rb | 19 ++++++++++++++++--- 3 files changed, 18 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8f897b4a34c..59a335e9d8c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -21,6 +21,7 @@ v 8.9.0 (unreleased) - Redesign navigation for project pages - Fix groups API to list only user's accessible projects - Redesign account and email confirmation emails + - Don't fail builds for projects that are deleted - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 - Use gitlab-shell v3.0.0 diff --git a/app/workers/stuck_ci_builds_worker.rb b/app/workers/stuck_ci_builds_worker.rb index ca594e77e7c..6828013b377 100644 --- a/app/workers/stuck_ci_builds_worker.rb +++ b/app/workers/stuck_ci_builds_worker.rb @@ -6,7 +6,7 @@ class StuckCiBuildsWorker def perform Rails.logger.info 'Cleaning stuck builds' - builds = Ci::Build.running_or_pending.where('updated_at < ?', BUILD_STUCK_TIMEOUT.ago) + builds = Ci::Build.joins(:project).running_or_pending.where('ci_builds.updated_at < ?', BUILD_STUCK_TIMEOUT.ago) builds.find_each(batch_size: 50).each do |build| Rails.logger.debug "Dropping stuck #{build.status} build #{build.id} for runner #{build.runner_id}" build.drop diff --git a/spec/workers/stuck_ci_builds_worker_spec.rb b/spec/workers/stuck_ci_builds_worker_spec.rb index 665ec20f224..801fa31b45d 100644 --- a/spec/workers/stuck_ci_builds_worker_spec.rb +++ b/spec/workers/stuck_ci_builds_worker_spec.rb @@ -2,6 +2,7 @@ require "spec_helper" describe StuckCiBuildsWorker do let!(:build) { create :ci_build } + let(:worker) { described_class.new } subject do build.reload @@ -16,13 +17,13 @@ describe StuckCiBuildsWorker do it 'gets dropped if it was updated over 2 days ago' do build.update!(updated_at: 2.days.ago) - StuckCiBuildsWorker.new.perform + worker.perform is_expected.to eq('failed') end it "is still #{status}" do build.update!(updated_at: 1.minute.ago) - StuckCiBuildsWorker.new.perform + worker.perform is_expected.to eq(status) end end @@ -36,9 +37,21 @@ describe StuckCiBuildsWorker do it "is still #{status}" do build.update!(updated_at: 2.days.ago) - StuckCiBuildsWorker.new.perform + worker.perform is_expected.to eq(status) end end end + + context "for deleted project" do + before do + build.update!(status: :running, updated_at: 2.days.ago) + build.project.update(pending_delete: true) + end + + it "does not drop build" do + expect_any_instance_of(Ci::Build).not_to receive(:drop) + worker.perform + end + end end From 0fdfd2dd6e01648f4daf6853f11a3ffc9a678a55 Mon Sep 17 00:00:00 2001 From: Stan Hu <stanhu@gmail.com> Date: Mon, 23 May 2016 22:59:35 -0700 Subject: [PATCH 090/318] Fix Error 500 when viewing a blob with binary characters after the 1024-byte mark Here was the problem: 1. When determining whether a given blob is viewable text, gitlab_git reads the first 1024 bytes and checks with Linguist whether it is a text or binary file. 2. If the blob is text, GitLab will attempt to display it. 3. However, if the text has binary characters after the first 1024 bytes, then GitLab will attempt to load the entire contents, but the encoding will be ASCII-8BIT since there are binary characters. 4. The Error 500 results when GitLab attempts to display a mix UTF-8 and ASCII-8BIT. To fix this, we load as much data as we are willing to display so that the detection will work properly. Requires an update to gitlab_git: gitlab-org/gitlab_git!86 Closes #13826 --- CHANGELOG | 1 + Gemfile.lock | 4 ++-- app/helpers/blob_helper.rb | 2 +- app/models/blob.rb | 2 +- app/models/repository.rb | 2 +- app/views/projects/diffs/_diffs.html.haml | 1 + app/views/projects/diffs/_file.html.haml | 2 ++ spec/controllers/blob_controller_spec.rb | 5 +++++ spec/controllers/projects/commit_controller_spec.rb | 12 ++++++++++++ spec/support/test_env.rb | 1 + 10 files changed, 27 insertions(+), 5 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 8f897b4a34c..acb349594aa 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -18,6 +18,7 @@ v 8.9.0 (unreleased) - Reduce number of fog gem dependencies - Remove project notification settings associated with deleted projects - Fix 404 page when viewing TODOs that contain milestones or labels in different projects + - Fix Error 500 when viewing a blob with binary characters after the 1024-byte mark - Redesign navigation for project pages - Fix groups API to list only user's accessible projects - Redesign account and email confirmation emails diff --git a/Gemfile.lock b/Gemfile.lock index dfc15700494..15b3158c63e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -284,7 +284,7 @@ GEM posix-spawn (~> 0.3) gitlab_emoji (0.3.1) gemojione (~> 2.2, >= 2.2.1) - gitlab_git (10.1.0) + gitlab_git (10.1.3) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) @@ -408,7 +408,7 @@ GEM mime-types (>= 1.16, < 4) mail_room (0.7.0) method_source (0.8.2) - mime-types (2.99.1) + mime-types (2.99.2) mimemagic (0.3.0) mini_portile2 (2.1.0) minitest (5.7.0) diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index cec2dc753fe..85559fbc5f5 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -116,7 +116,7 @@ module BlobHelper end def blob_text_viewable?(blob) - blob && blob.text? && !blob.lfs_pointer? + blob && blob.text? && !blob.lfs_pointer? && !blob.only_display_raw? end def blob_size(blob) diff --git a/app/models/blob.rb b/app/models/blob.rb index 0fea6b7f576..4279ea2ce57 100644 --- a/app/models/blob.rb +++ b/app/models/blob.rb @@ -24,7 +24,7 @@ class Blob < SimpleDelegator end def only_display_raw? - size && size > 5.megabytes + size && truncated? end def svg? diff --git a/app/models/repository.rb b/app/models/repository.rb index 1ab163510bf..e5b277cb198 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -446,7 +446,7 @@ class Repository def blob_at(sha, path) unless Gitlab::Git.blank_ref?(sha) - Gitlab::Git::Blob.find(self, sha, path) + Blob.decorate(Gitlab::Git::Blob.find(self, sha, path)) end end diff --git a/app/views/projects/diffs/_diffs.html.haml b/app/views/projects/diffs/_diffs.html.haml index d9c4b410d32..6c11afbe420 100644 --- a/app/views/projects/diffs/_diffs.html.haml +++ b/app/views/projects/diffs/_diffs.html.haml @@ -24,6 +24,7 @@ - diff_commit = commit_for_diff(diff_file) - blob = project.repository.blob_for_diff(diff_commit, diff_file) - next unless blob + - blob.load_all_data!(project.repository) unless blob.only_display_raw? = render 'projects/diffs/file', i: index, project: project, diff_file: diff_file, diff_commit: diff_commit, blob: blob, diff_refs: diff_refs diff --git a/app/views/projects/diffs/_file.html.haml b/app/views/projects/diffs/_file.html.haml index e5983c58039..2395ea3c275 100644 --- a/app/views/projects/diffs/_file.html.haml +++ b/app/views/projects/diffs/_file.html.haml @@ -49,6 +49,8 @@ = render "projects/diffs/parallel_view", diff_file: diff_file, project: project, blob: blob, index: i - else = render "projects/diffs/text_file", diff_file: diff_file, index: i + - elsif blob.only_display_raw? + .nothing-here-block This file is too large to display. - elsif blob.image? - old_file = project.repository.prev_blob_for_diff(diff_commit, diff_file) = render "projects/diffs/image", diff_file: diff_file, old_file: old_file, file: blob, index: i, diff_refs: diff_refs diff --git a/spec/controllers/blob_controller_spec.rb b/spec/controllers/blob_controller_spec.rb index eb91e577b87..465013231f9 100644 --- a/spec/controllers/blob_controller_spec.rb +++ b/spec/controllers/blob_controller_spec.rb @@ -38,6 +38,11 @@ describe Projects::BlobController do let(:id) { 'invalid-branch/README.md' } it { is_expected.to respond_with(:not_found) } end + + context "binary file" do + let(:id) { 'binary-encoding/encoding/binary-1.bin' } + it { is_expected.to respond_with(:success) } + end end describe 'GET show with tree path' do diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index 438e776ec4b..6e3db10e451 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -2,6 +2,8 @@ require 'rails_helper' describe Projects::CommitController do describe 'GET show' do + render_views + let(:project) { create(:project) } before do @@ -27,6 +29,16 @@ describe Projects::CommitController do end end + it 'handles binary files' do + get(:show, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: TestEnv::BRANCH_SHA['binary-encoding'], + format: "html") + + expect(response).to be_success + end + def go(id:) get :show, namespace_id: project.namespace.to_param, diff --git a/spec/support/test_env.rb b/spec/support/test_env.rb index 71664bb192e..498bd4bf800 100644 --- a/spec/support/test_env.rb +++ b/spec/support/test_env.rb @@ -16,6 +16,7 @@ module TestEnv 'master' => '5937ac0', "'test'" => 'e56497b', 'orphaned-branch' => '45127a9', + 'binary-encoding' => '7b1cf43', } # gitlab-test-fork is a fork of gitlab-fork, but we don't necessarily From b43c6c43b4098f0633f24fbdc0ff249e3e6d4edc Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Sun, 12 Jun 2016 23:05:19 +0200 Subject: [PATCH 091/318] Cache only apt and ruby from vendor Since introduction of gitignore the vendor folder contains also gitignores which affects detection when to update a cache. We explicitly cache only apt and ruby folders. --- .gitlab-ci.yml | 3 ++- scripts/prepare_build.sh | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3dc48a89463..f1dcf990629 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -7,7 +7,8 @@ services: cache: key: "ruby21" paths: - - vendor + - vendor/apt + - vendor/ruby variables: MYSQL_ALLOW_EMPTY_PASSWORD: "1" diff --git a/scripts/prepare_build.sh b/scripts/prepare_build.sh index d6fb1a34e8c..7e71a030901 100755 --- a/scripts/prepare_build.sh +++ b/scripts/prepare_build.sh @@ -16,10 +16,10 @@ retry() { } if [ -f /.dockerenv ] || [ -f ./dockerinit ]; then - mkdir -p vendor + mkdir -p vendor/apt # Install phantomjs package - pushd vendor + pushd vendor/apt if [ ! -e phantomjs_1.9.8-0jessie_amd64.deb ]; then wget -q https://gitlab.com/axil/phantomjs-debian/raw/master/phantomjs_1.9.8-0jessie_amd64.deb fi From f395ed242add9c21536a6e4d9ea7f0154351e503 Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Sun, 12 Jun 2016 18:25:21 -0600 Subject: [PATCH 092/318] Update brakeman from 3.2.1 to 3.3.2 Removes a few dependencies. Changelog: https://github.com/presidentbeef/brakeman/blob/master/CHANGES --- Gemfile | 2 +- Gemfile.lock | 28 +++++----------------------- 2 files changed, 6 insertions(+), 24 deletions(-) diff --git a/Gemfile b/Gemfile index b2660144f2b..6d8a33c2eef 100644 --- a/Gemfile +++ b/Gemfile @@ -245,7 +245,7 @@ end group :development do gem "foreman" - gem 'brakeman', '~> 3.2.0', require: false + gem 'brakeman', '~> 3.3.0', require: false gem 'letter_opener_web', '~> 1.3.0' gem 'quiet_assets', '~> 1.0.2' diff --git a/Gemfile.lock b/Gemfile.lock index dfc15700494..2ba2676efa1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -97,16 +97,7 @@ GEM bootstrap-sass (3.3.6) autoprefixer-rails (>= 5.2.1) sass (>= 3.3.4) - brakeman (3.2.1) - erubis (~> 2.6) - haml (>= 3.0, < 5.0) - highline (>= 1.6.20, < 2.0) - ruby2ruby (~> 2.3.0) - ruby_parser (~> 3.8.1) - safe_yaml (>= 1.0) - sass (~> 3.0) - slim (>= 1.3.6, < 4.0) - terminal-table (~> 1.4) + brakeman (3.3.2) browser (2.0.3) builder (3.2.2) bullet (5.0.0) @@ -338,7 +329,6 @@ GEM hashie (3.4.3) health_check (1.5.1) rails (>= 2.3.0) - highline (1.7.8) hipchat (1.5.2) httparty mimemagic @@ -642,10 +632,7 @@ GEM ruby-saml (1.1.2) nokogiri (>= 1.5.10) uuid (~> 2.3) - ruby2ruby (2.3.0) - ruby_parser (~> 3.1) - sexp_processor (~> 4.0) - ruby_parser (3.8.1) + ruby_parser (3.8.2) sexp_processor (~> 4.1) rubyntlm (0.5.2) rubypants (0.2.0) @@ -655,7 +642,7 @@ GEM safe_yaml (1.0.4) sanitize (2.1.0) nokogiri (>= 1.4.4) - sass (3.4.21) + sass (3.4.22) sass-rails (5.0.4) railties (>= 4.0.0, < 5.0) sass (~> 3.1) @@ -704,9 +691,6 @@ GEM tilt (>= 1.3, < 3) six (0.2.0) slack-notifier (1.2.1) - slim (3.0.6) - temple (~> 0.7.3) - tilt (>= 1.3.3, < 2.1) slop (3.6.0) spinach (0.8.10) colorize @@ -747,10 +731,8 @@ GEM railties (>= 3.2.5, < 6) teaspoon-jasmine (2.2.0) teaspoon (>= 1.0.0) - temple (0.7.6) term-ansicolor (1.3.2) tins (~> 1.0) - terminal-table (1.5.2) test_after_commit (0.4.2) activerecord (>= 3.2) thin (1.6.4) @@ -759,7 +741,7 @@ GEM rack (~> 1.0) thor (0.19.1) thread_safe (0.3.5) - tilt (2.0.2) + tilt (2.0.5) timecop (0.8.1) timfel-krb5-auth (0.8.3) tinder (1.10.1) @@ -848,7 +830,7 @@ DEPENDENCIES better_errors (~> 1.0.1) binding_of_caller (~> 0.7.2) bootstrap-sass (~> 3.3.0) - brakeman (~> 3.2.0) + brakeman (~> 3.3.0) browser (~> 2.0.3) bullet bundler-audit From c1f37964ed04e34fd8606d9b2a63f9d6798766f5 Mon Sep 17 00:00:00 2001 From: Stan Hu <stanhu@gmail.com> Date: Sun, 12 Jun 2016 19:13:14 -0700 Subject: [PATCH 093/318] Fix typo causing related branches to Error 500 --- app/views/projects/issues/_related_branches.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index b9bb6fe559d..c6fc499a7b8 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -6,7 +6,7 @@ %li - sha = @project.repository.find_branch(branch).target - pipeline = @project.pipeline(sha, branch) if sha - - if ci_copipelinemmit + - if pipeline %span.related-branch-ci-status = render_pipeline_status(pipeline) %span.related-branch-info From ec7cdc18c875a06686ff575d0d3b1dcb0a0e6d35 Mon Sep 17 00:00:00 2001 From: Stan Hu <stanhu@gmail.com> Date: Sun, 12 Jun 2016 19:13:14 -0700 Subject: [PATCH 094/318] Fix typo in obtaining a backtrace from all threads in gdb Also add command to turn off pagination [ci skip] --- doc/administration/troubleshooting/sidekiq.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/administration/troubleshooting/sidekiq.md b/doc/administration/troubleshooting/sidekiq.md index a776cd3f05e..b71f8fabbc8 100644 --- a/doc/administration/troubleshooting/sidekiq.md +++ b/doc/administration/troubleshooting/sidekiq.md @@ -147,7 +147,8 @@ bt To output a backtrace from all threads at once: ``` -apply all thread bt +set pagination off +thread apply all bt ``` Once you're done debugging with `gdb`, be sure to detach from the process and From 7c8f3b0cfc38838755a21641e402b3ef7a1f9d0b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Mon, 13 Jun 2016 08:50:12 +0200 Subject: [PATCH 095/318] Duplicate CI config node factory on class level --- lib/gitlab/ci/config/node/configurable.rb | 4 ++- lib/gitlab/ci/config/node/entry.rb | 6 ++-- .../ci/config/node/configurable_spec.rb | 35 +++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 spec/lib/gitlab/ci/config/node/configurable_spec.rb diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index 650c6efba63..f2383e07aa7 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -37,7 +37,9 @@ module Gitlab end class_methods do - attr_reader :allowed_nodes + def allowed_nodes + Hash[@allowed_nodes.map { |key, factory| [key, factory.dup] } ] + end private diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 2f327fa9bf3..e5692e72947 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -27,8 +27,8 @@ module Gitlab end def compose! - allowed_nodes.each do |key, factory| - @nodes[key] = create_node(key, factory.dup) + allowed_nodes.each do |key, essence| + @nodes[key] = create_node(key, essence) end end @@ -62,7 +62,7 @@ module Gitlab private - def create_node(key, factory) + def create_node(key, essence) raise NotImplementedError end end diff --git a/spec/lib/gitlab/ci/config/node/configurable_spec.rb b/spec/lib/gitlab/ci/config/node/configurable_spec.rb new file mode 100644 index 00000000000..47c68f96dc8 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/configurable_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Configurable do + let(:node) { Class.new } + + before do + node.include(described_class) + end + + describe 'allowed nodes' do + before do + node.class_eval do + allow_node :object, Object, description: 'test object' + end + end + + describe '#allowed_nodes' do + it 'has valid allowed nodes' do + expect(node.allowed_nodes).to include :object + end + + it 'creates a node factory' do + expect(node.allowed_nodes[:object]) + .to be_an_instance_of Gitlab::Ci::Config::Node::Factory + end + + it 'returns a duplicated factory object' do + first_factory = node.allowed_nodes[:object] + second_factory = node.allowed_nodes[:object] + + expect(first_factory).not_to be_equal(second_factory) + end + end + end +end From 11c0d022835cafc1d52e18580d0e1523a83bbdd2 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Mon, 13 Jun 2016 09:14:23 +0200 Subject: [PATCH 096/318] Simplify ci config node factory --- lib/gitlab/ci/config/node/configurable.rb | 6 +++--- lib/gitlab/ci/config/node/factory.rb | 11 +++-------- spec/lib/gitlab/ci/config/node/factory_spec.rb | 10 +++++----- 3 files changed, 11 insertions(+), 16 deletions(-) diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index f2383e07aa7..86cc33e11be 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -31,8 +31,8 @@ module Gitlab private def create_node(key, factory) - factory.with_value(@value[key]) - factory.null_node unless @value.has_key?(key) + factory.with(value: @value[key]) + factory.nullify! unless @value.has_key?(key) factory.create! end @@ -45,7 +45,7 @@ module Gitlab def allow_node(symbol, entry_class, metadata) factory = Node::Factory.new(entry_class) - .with_description(metadata[:description]) + .with(description: metadata[:description]) define_method(symbol) do raise Entry::InvalidError unless valid? diff --git a/lib/gitlab/ci/config/node/factory.rb b/lib/gitlab/ci/config/node/factory.rb index 969af45272e..787ca006f5a 100644 --- a/lib/gitlab/ci/config/node/factory.rb +++ b/lib/gitlab/ci/config/node/factory.rb @@ -15,17 +15,12 @@ module Gitlab @attributes = {} end - def with_value(value) - @attributes[:value] = value + def with(attributes) + @attributes.merge!(attributes) self end - def with_description(description) - @attributes[:description] = description - self - end - - def null_node + def nullify! @entry_class = Node::Null self end diff --git a/spec/lib/gitlab/ci/config/node/factory_spec.rb b/spec/lib/gitlab/ci/config/node/factory_spec.rb index 73d760d1b0a..d681aa32456 100644 --- a/spec/lib/gitlab/ci/config/node/factory_spec.rb +++ b/spec/lib/gitlab/ci/config/node/factory_spec.rb @@ -8,7 +8,7 @@ describe Gitlab::Ci::Config::Node::Factory do context 'when value setting value' do it 'creates entry with valid value' do entry = factory - .with_value(['ls', 'pwd']) + .with(value: ['ls', 'pwd']) .create! expect(entry.value).to eq "ls\npwd" @@ -17,8 +17,8 @@ describe Gitlab::Ci::Config::Node::Factory do context 'when setting description' do it 'creates entry with description' do entry = factory - .with_value(['ls', 'pwd']) - .with_description('test description') + .with(value: ['ls', 'pwd']) + .with(description: 'test description') .create! expect(entry.value).to eq "ls\npwd" @@ -38,8 +38,8 @@ describe Gitlab::Ci::Config::Node::Factory do context 'when creating a null entry' do it 'creates a null entry' do entry = factory - .with_value(nil) - .null_node + .with(value: nil) + .nullify! .create! expect(entry).to be_an_instance_of Gitlab::Ci::Config::Node::Null From 5e955d2cba4b3f0508874ef49fa549a638d4944c Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Mon, 13 Jun 2016 10:20:59 +0100 Subject: [PATCH 097/318] Aligned the two navs horizontally Closes #18513 --- app/assets/stylesheets/framework/nav.scss | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 4de89daeb36..71fd75b61fa 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -280,11 +280,9 @@ } .dropdown { - margin-left: 7px; - - @media (max-width: $screen-xs-min) { - margin-left: 0; - } + position: absolute; + top: 7px; + right: 15px; li.active { font-weight: bold; From b33b7be53e113e4f07154b6aafb7858d76d99516 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Mon, 13 Jun 2016 11:22:58 +0200 Subject: [PATCH 098/318] Handle NULL migration errors in migration helpers This ensures that whenever changing the NULL constraint of a column fails we still drop the column. --- lib/gitlab/database/migration_helpers.rb | 4 ++-- spec/lib/gitlab/database/migration_helpers_spec.rb | 13 +++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 978c3f7896d..0f488e968f6 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -126,6 +126,8 @@ module Gitlab begin transaction do update_column_in_batches(table, column, default) + + change_column_null(table, column, false) unless allow_null end # We want to rescue _all_ exceptions here, even those that don't inherit # from StandardError. @@ -134,8 +136,6 @@ module Gitlab raise error end - - change_column_null(table, column, false) unless allow_null end end end diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb index 83ddabe6b0b..1ec539066a7 100644 --- a/spec/lib/gitlab/database/migration_helpers_spec.rb +++ b/spec/lib/gitlab/database/migration_helpers_spec.rb @@ -120,6 +120,19 @@ describe Gitlab::Database::MigrationHelpers, lib: true do model.add_column_with_default(:projects, :foo, :integer, default: 10) end.to raise_error(RuntimeError) end + + it 'removes the added column whenever changing a column NULL constraint fails' do + expect(model).to receive(:change_column_null). + with(:projects, :foo, false). + and_raise(RuntimeError) + + expect(model).to receive(:remove_column). + with(:projects, :foo) + + expect do + model.add_column_with_default(:projects, :foo, :integer, default: 10) + end.to raise_error(RuntimeError) + end end context 'inside a transaction' do From 0e50fa24a5b4ff7665a68da2a1221b5cae9e5633 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> Date: Mon, 13 Jun 2016 12:32:53 +0300 Subject: [PATCH 099/318] Remove counters from Pipeline navigation Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> --- app/views/projects/pipelines/_head.html.haml | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index f278d4e0538..d0ba0d27d7c 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -5,11 +5,9 @@ = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines - %span.badge.count.ci_counter= number_with_delimiter(@project.pipelines.running_or_pending.count) - if project_nav_tab? :builds = nav_link(controller: %w(builds)) do = link_to project_builds_path(@project), title: 'Builds', class: 'shortcuts-builds' do %span Builds - %span.badge.count.builds_counter= number_with_delimiter(@project.running_or_pending_build_count) From 9c238dc970afc4cc9e4e4f9e3327e7a34b8d7c9a Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Mon, 13 Jun 2016 11:38:57 +0200 Subject: [PATCH 100/318] Update columns in batches until no rows are left Instead of updating a fixed number of rows (based on the amount of rows available at the start of the update) the method "update_column_in_batches" will now continue updating rows until it runs out of rows to process. For a table with a high rate of inserts this may result in the migration taking quite some time. However, the alternative is not all rows being updated or the "change_column_null" method raising an error due to there being NULL values. --- lib/gitlab/database/migration_helpers.rb | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index 0f488e968f6..ddf428e9cb4 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -55,10 +55,10 @@ module Gitlab first['count']. to_i - # Update in batches of 5% + # Update in batches of 5% until we run out of any rows to update. batch_size = ((total / 100.0) * 5.0).ceil - while processed < total + loop do start_row = exec_query(%Q{ SELECT id FROM #{quoted_table} @@ -66,6 +66,9 @@ module Gitlab LIMIT 1 OFFSET #{processed} }).to_hash.first + # There are no more rows to process + break unless start_row + stop_row = exec_query(%Q{ SELECT id FROM #{quoted_table} From 7a1b2e4f94e3e651d3264aa566a9056fe0f554e9 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 18 May 2016 15:28:46 -0500 Subject: [PATCH 101/318] Added when to artifacts --- CHANGELOG | 1 + lib/ci/gitlab_ci_yaml_processor.rb | 24 ++++++++++++++++++++ spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 23 +++++++++++++++++++ 3 files changed, 48 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 364690286e1..1f6c1d40e63 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -36,6 +36,7 @@ v 8.9.0 (unreleased) - Links from a wiki page to other wiki pages should be rewritten as expected - Add option to project to only allow merge requests to be merged if the build succeeds (Rui Santos) - Fix issues filter when ordering by milestone + - Added artifacts:when to .gitlab-ci.yml - this requires GitLab Runner 1.3 - Todos will display target state if issuable target is 'Closed' or 'Merged' - Fix bug when sorting issues by milestone due date and filtering by two or more labels - Add support for using Yubikeys (U2F) for two-factor authentication diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 130f5b0892e..15d57a46eb0 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -8,6 +8,8 @@ module Ci ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, :allow_failure, :type, :stage, :when, :artifacts, :cache, :dependencies, :before_script, :after_script, :variables] + ALLOWED_CACHE_KEYS = [:key, :untracked, :paths] + ALLOWED_ARTIFACTS_KEYS = [:name, :untracked, :paths, :when] attr_reader :before_script, :after_script, :image, :services, :path, :cache @@ -135,6 +137,12 @@ module Ci end def validate_global_cache! + @cache.keys.each do |key| + unless ALLOWED_CACHE_KEYS.include? key + raise ValidationError, "#{name} cache unknown parameter #{key}" + end + end + if @cache[:key] && !validate_string(@cache[:key]) raise ValidationError, "cache:key parameter should be a string" end @@ -233,6 +241,12 @@ module Ci end def validate_job_cache!(name, job) + job[:cache].keys.each do |key| + unless ALLOWED_CACHE_KEYS.include? key + raise ValidationError, "#{name} job: cache unknown parameter #{key}" + end + end + if job[:cache][:key] && !validate_string(job[:cache][:key]) raise ValidationError, "#{name} job: cache:key parameter should be a string" end @@ -247,6 +261,12 @@ module Ci end def validate_job_artifacts!(name, job) + job[:artifacts].keys.each do |key| + unless ALLOWED_ARTIFACTS_KEYS.include? key + raise ValidationError, "#{name} job: artifacts unknown parameter #{key}" + end + end + if job[:artifacts][:name] && !validate_string(job[:artifacts][:name]) raise ValidationError, "#{name} job: artifacts:name parameter should be a string" end @@ -258,6 +278,10 @@ module Ci if job[:artifacts][:paths] && !validate_array_of_strings(job[:artifacts][:paths]) raise ValidationError, "#{name} job: artifacts:paths parameter should be an array of strings" end + + if job[:artifacts][:when] && !job[:artifacts][:when].in?(%w(on_success on_failure always)) + raise ValidationError, "#{name} job: artifacts:when parameter should be on_success, on_failure or always" + end end def validate_job_dependencies!(name, job) diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 7375539cf17..3d3715f0ef0 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -601,6 +601,22 @@ module Ci allow_failure: false }) end + + %w(on_success on_failure always).each do |when_state| + it "returns artifacts for when #{when_state} defined" do + config = YAML.dump({ + rspec: { + script: "rspec", + artifacts: { paths: ["logs/", "binaries/"], when: when_state } + } + }) + + config_processor = GitlabCiYamlProcessor.new(config, path) + builds = config_processor.builds_for_stage_and_ref("test", "master") + expect(builds.size).to eq(1) + expect(builds.first[:options][:artifacts][:when]).to eq(when_state) + end + end end describe "Dependencies" do @@ -967,6 +983,13 @@ EOT end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:name parameter should be a string") end + it "returns errors if job artifacts:when is not an a predefined value" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { when: 1 } } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:when parameter should be on_success, on_failure or always") + end + it "returns errors if job artifacts:untracked is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { untracked: "string" } } }) expect do From ea7ff1341032c04ff9abad0e286888a3ab8a9a15 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Mon, 13 Jun 2016 11:50:27 +0200 Subject: [PATCH 102/318] Removed old comment from update_column_in_batches --- lib/gitlab/database/migration_helpers.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb index ddf428e9cb4..dd3ff0ab18b 100644 --- a/lib/gitlab/database/migration_helpers.rb +++ b/lib/gitlab/database/migration_helpers.rb @@ -31,8 +31,6 @@ module Gitlab # Any data inserted while running this method (or after it has finished # running) is _not_ updated automatically. # - # This method _only_ updates rows where the column's value is set to NULL. - # # table - The name of the table. # column - The name of the column to update. # value - The value for the column. From aea4041ce96f18afea70da15af3cbe1be4fa1f94 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 18 May 2016 15:21:51 -0500 Subject: [PATCH 103/318] Allow to expire build artifacts --- Gemfile | 3 +++ Gemfile.lock | 4 ++++ app/controllers/projects/builds_controller.rb | 6 ++++++ app/models/ci/build.rb | 18 ++++++++++++++++-- app/views/projects/builds/_sidebar.html.haml | 9 +++++++++ app/workers/expire_build_artifacts.rb | 12 ++++++++++++ config/gitlab.yml.example | 3 +++ config/initializers/1_settings.rb | 3 +++ config/routes.rb | 1 + ...1_add_artifacts_expire_date_to_ci_builds.rb | 5 +++++ lib/ci/api/builds.rb | 2 ++ lib/ci/gitlab_ci_yaml_processor.rb | 2 +- 12 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 app/workers/expire_build_artifacts.rb create mode 100644 db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb diff --git a/Gemfile b/Gemfile index b2660144f2b..f56daa099a2 100644 --- a/Gemfile +++ b/Gemfile @@ -210,6 +210,9 @@ gem 'mousetrap-rails', '~> 1.4.6' # Detect and convert string character encoding gem 'charlock_holmes', '~> 0.7.3' +# Parse duration +gem 'chronic_duration', '~> 0.10.6' + gem "sass-rails", '~> 5.0.0' gem "coffee-rails", '~> 4.1.0' gem "uglifier", '~> 2.7.2' diff --git a/Gemfile.lock b/Gemfile.lock index dfc15700494..2b2e2d2bb07 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -133,6 +133,8 @@ GEM mime-types (>= 1.16) cause (0.1) charlock_holmes (0.7.3) + chronic_duration (0.10.6) + numerizer (~> 0.1.1) chunky_png (1.3.5) cliver (0.3.2) coderay (1.1.0) @@ -424,6 +426,7 @@ GEM nokogiri (1.6.8) mini_portile2 (~> 2.1.0) pkg-config (~> 1.1.7) + numerizer (0.1.1) oauth (0.4.7) oauth2 (1.0.0) faraday (>= 0.8, < 0.10) @@ -857,6 +860,7 @@ DEPENDENCIES capybara-screenshot (~> 1.0.0) carrierwave (~> 0.10.0) charlock_holmes (~> 0.7.3) + chronic_duration (~> 0.10.6) coffee-rails (~> 4.1.0) connection_pool (~> 2.0) coveralls (~> 0.8.2) diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 14c82826342..514f1b507fe 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -78,6 +78,12 @@ class Projects::BuildsController < Projects::ApplicationController end end + def keep_artifacts + @build.keep_artifacts + redirect_to namespace_project_build_path(project.namespace, project, @build), + notice: "Artifacts will not be removed!" + end + private def build diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 6a64ca451f7..74084b650cf 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -11,6 +11,8 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } + scope :with_artifacts, ->() { where.not(artifacts_file: nil) } + scope :with_artifacts_expired, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } mount_uploader :artifacts_file, ArtifactUploader mount_uploader :artifacts_metadata, ArtifactUploader @@ -328,11 +330,15 @@ module Ci Gitlab::Ci::Build::Artifacts::Metadata.new(artifacts_metadata.path, path, **options).to_entry end + def erase_artifacts! + remove_artifacts_file! + remove_artifacts_metadata! + end + def erase(opts = {}) return false unless erasable? - remove_artifacts_file! - remove_artifacts_metadata! + erase_artifacts! erase_trace! update_erased!(opts[:erased_by]) end @@ -345,6 +351,14 @@ module Ci !self.erased_at.nil? end + def artifacts_expired? + self.artifacts_expire_at < Time.now && !artifacts? + end + + def keep_artifacts + self.update(artifacts_expire_at: nil) + end + private def erase_trace! diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index 5d931389dfb..d1a0da29ef7 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -44,6 +44,15 @@ %p.build-detail-row %span.build-light-text Erased: #{time_ago_with_tooltip(@build.erased_at)} + - elsif @build.artifacts_expired? + %p.build-detail-row.artifacts-expired.alert.alert-warning + The artifacts were removed #{time_ago_with_tooltip(@build.artifacts_expire_at)} + - elsif @build.artifacts_expire_at + %p.build-detail-row.artifacts-expired.alert.alert-info + The artifacts will be removed at #{time_ago_with_tooltip(@build.artifacts_expire_at)} + .pull-right + = link_to keep_artifacts_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do + Keep %p.build-detail-row %span.build-light-text Runner: - if @build.runner && current_user && current_user.admin diff --git a/app/workers/expire_build_artifacts.rb b/app/workers/expire_build_artifacts.rb new file mode 100644 index 00000000000..3d809d8ab6b --- /dev/null +++ b/app/workers/expire_build_artifacts.rb @@ -0,0 +1,12 @@ +class ExpireBuildArtifacts + include Sidekiq::Worker + + def perform + Rails.logger.info 'Cleaning old build artifacts' + + builds = Ci::Build.with_artifacts_expired + builds.find_each(batch_size: 50).each do |build| + build.erase_artifacts! + end + end +end diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 1048ef6e243..7b37e92ed46 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -164,6 +164,9 @@ production: &base # Flag stuck CI builds as failed stuck_ci_builds_worker: cron: "0 0 * * *" + # Remove old artifacts + expire_build_artifacts: + cron: "50 * * * *" # Periodically run 'git fsck' on all repositories. If started more than # once per hour you will have concurrent 'git fsck' jobs. repository_check_worker: diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 436751b9d16..b412d1e0981 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -279,6 +279,9 @@ Settings['cron_jobs'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_builds_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_builds_worker']['cron'] ||= '0 0 * * *' Settings.cron_jobs['stuck_ci_builds_worker']['job_class'] = 'StuckCiBuildsWorker' +Settings.cron_jobs['expire_build_artifacts'] ||= Settingslogic.new({}) +Settings.cron_jobs['expire_build_artifacts']['cron'] ||= '0 0 * * *' +Settings.cron_jobs['expire_build_artifacts']['job_class'] = 'ExpireBuildArtifacts' Settings.cron_jobs['repository_check_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['repository_check_worker']['cron'] ||= '20 * * * *' Settings.cron_jobs['repository_check_worker']['job_class'] = 'RepositoryCheck::BatchWorker' diff --git a/config/routes.rb b/config/routes.rb index 95fbe7dd9df..3d092d98c8e 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -714,6 +714,7 @@ Rails.application.routes.draw do post :cancel post :retry post :erase + post :keep_artifacts get :trace get :raw end diff --git a/db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb b/db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb new file mode 100644 index 00000000000..915167b038d --- /dev/null +++ b/db/migrate/20160518200441_add_artifacts_expire_date_to_ci_builds.rb @@ -0,0 +1,5 @@ +class AddArtifactsExpireDateToCiBuilds < ActiveRecord::Migration + def change + add_column :ci_builds, :artifacts_expire_at, :timestamp + end +end diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index 607359769d1..54f5626c7d7 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -114,6 +114,7 @@ module Ci # id (required) - The ID of a build # token (required) - The build authorization token # file (required) - Artifacts file + # expire_in (optional) - Specify when artifacts should expire (ex. 7d) # Parameters (accelerated by GitLab Workhorse): # file.path - path to locally stored body (generated by Workhorse) # file.name - real filename as send in Content-Disposition @@ -145,6 +146,7 @@ module Ci build.artifacts_file = artifacts build.artifacts_metadata = metadata + build.artifacts_expire_at = Time.now + ChronicDuration.parse(params['expire_in']) if build.save present(build, with: Entities::BuildDetails) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 15d57a46eb0..b1297565ebe 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -9,7 +9,7 @@ module Ci :allow_failure, :type, :stage, :when, :artifacts, :cache, :dependencies, :before_script, :after_script, :variables] ALLOWED_CACHE_KEYS = [:key, :untracked, :paths] - ALLOWED_ARTIFACTS_KEYS = [:name, :untracked, :paths, :when] + ALLOWED_ARTIFACTS_KEYS = [:name, :untracked, :paths, :when, :expire_in] attr_reader :before_script, :after_script, :image, :services, :path, :cache From ffd316483ce01591e84996dfaedd539480226e5a Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Mon, 13 Jun 2016 11:59:42 +0200 Subject: [PATCH 104/318] Instrument all Banzai::ReferenceParser classes Now that this code is no longer part of Banzai::Filter it needs to be instrumented explicitly. --- CHANGELOG | 1 + config/initializers/metrics.rb | 17 +++++++++++------ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 364690286e1..c39c99674b7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -68,6 +68,7 @@ v 8.9.0 (unreleased) - Improved UX of date pickers on issue & milestone forms - Cache on the database if a project has an active external issue tracker. - Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav + - All classes in the Banzai::ReferenceParser namespace are now instrumented v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/config/initializers/metrics.rb b/config/initializers/metrics.rb index 2673093b96a..f6509ee43f1 100644 --- a/config/initializers/metrics.rb +++ b/config/initializers/metrics.rb @@ -96,13 +96,18 @@ if Gitlab::Metrics.enabled? config.instrument_instance_methods(const) end - # Instruments all Banzai filters - Dir[Rails.root.join('lib', 'banzai', 'filter', '*.rb')].each do |file| - klass = File.basename(file, File.extname(file)).camelize - const = Banzai::Filter.const_get(klass) + # Instruments all Banzai filters and reference parsers + { + Filter: Rails.root.join('lib', 'banzai', 'filter', '*.rb'), + ReferenceParser: Rails.root.join('lib', 'banzai', 'reference_parser', '*.rb') + }.each do |const_name, path| + Dir[path].each do |file| + klass = File.basename(file, File.extname(file)).camelize + const = Banzai.const_get(const_name).const_get(klass) - config.instrument_methods(const) - config.instrument_instance_methods(const) + config.instrument_methods(const) + config.instrument_instance_methods(const) + end end config.instrument_methods(Banzai::Renderer) From ffe8dbde9b2aec2425e7859aeed5ad1642c53938 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 8 Jun 2016 17:18:54 +0200 Subject: [PATCH 105/318] Move keep to ArtifactsController --- .../projects/artifacts_controller.rb | 5 +++++ app/models/ci/build.rb | 8 ++++---- app/views/projects/builds/_sidebar.html.haml | 19 ++++++++++--------- ...ts.rb => expire_build_artifacts_worker.rb} | 3 ++- config/gitlab.yml.example | 4 ++-- config/initializers/1_settings.rb | 6 +++--- config/routes.rb | 2 +- 7 files changed, 27 insertions(+), 20 deletions(-) rename app/workers/{expire_build_artifacts.rb => expire_build_artifacts_worker.rb} (66%) diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 832d7deb57d..028e1f77119 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -34,6 +34,11 @@ class Projects::ArtifactsController < Projects::ApplicationController end end + def keep + build.keep_artifacts! + redirect_to namespace_project_build_path(project.namespace, project, build) + end + private def build diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 74084b650cf..5eb9fe5f1f5 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -12,7 +12,7 @@ module Ci scope :unstarted, ->() { where(runner_id: nil) } scope :ignore_failures, ->() { where(allow_failure: false) } scope :with_artifacts, ->() { where.not(artifacts_file: nil) } - scope :with_artifacts_expired, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } + scope :with_expired_artifacts, ->() { with_artifacts.where('artifacts_expire_at < ?', Time.now) } mount_uploader :artifacts_file, ArtifactUploader mount_uploader :artifacts_metadata, ArtifactUploader @@ -352,10 +352,10 @@ module Ci end def artifacts_expired? - self.artifacts_expire_at < Time.now && !artifacts? + !artifacts? && artifacts_expire_at && artifacts_expire_at < Time.now end - def keep_artifacts + def keep_artifacts! self.update(artifacts_expire_at: nil) end @@ -366,7 +366,7 @@ module Ci end def update_erased!(user = nil) - self.update(erased_by: user, erased_at: Time.now) + self.update(erased_by: user, erased_at: Time.now, artifacts_expire_at: nil) end def yaml_variables diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index d1a0da29ef7..e1fdd7019ff 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -44,15 +44,16 @@ %p.build-detail-row %span.build-light-text Erased: #{time_ago_with_tooltip(@build.erased_at)} - - elsif @build.artifacts_expired? - %p.build-detail-row.artifacts-expired.alert.alert-warning - The artifacts were removed #{time_ago_with_tooltip(@build.artifacts_expire_at)} - - elsif @build.artifacts_expire_at - %p.build-detail-row.artifacts-expired.alert.alert-info - The artifacts will be removed at #{time_ago_with_tooltip(@build.artifacts_expire_at)} - .pull-right - = link_to keep_artifacts_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do - Keep + - else + - if @build.artifacts_expired? + .artifacts-expired.alert.alert-warning + The artifacts were removed #{time_ago_with_tooltip(@build.artifacts_expire_at)} + - elsif @build.artifacts_expire_at + .artifacts-expired.alert.alert-warning + The artifacts will be removed in #{duration_in_words(@build.artifacts_expire_at, Time.now)} + .pull-right + = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-xs btn-primary', method: :post do + Keep %p.build-detail-row %span.build-light-text Runner: - if @build.runner && current_user && current_user.admin diff --git a/app/workers/expire_build_artifacts.rb b/app/workers/expire_build_artifacts_worker.rb similarity index 66% rename from app/workers/expire_build_artifacts.rb rename to app/workers/expire_build_artifacts_worker.rb index 3d809d8ab6b..17b3b5f227f 100644 --- a/app/workers/expire_build_artifacts.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -4,8 +4,9 @@ class ExpireBuildArtifacts def perform Rails.logger.info 'Cleaning old build artifacts' - builds = Ci::Build.with_artifacts_expired + builds = Ci::Build.with_expired_artifacts builds.find_each(batch_size: 50).each do |build| + Rails.logger.debug "Removing artifacts build #{build.id}..." build.erase_artifacts! end end diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 7b37e92ed46..75e1a3c1093 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -164,8 +164,8 @@ production: &base # Flag stuck CI builds as failed stuck_ci_builds_worker: cron: "0 0 * * *" - # Remove old artifacts - expire_build_artifacts: + # Remove expired build artifacts + expire_build_artifacts_worker: cron: "50 * * * *" # Periodically run 'git fsck' on all repositories. If started more than # once per hour you will have concurrent 'git fsck' jobs. diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index b412d1e0981..a7320c3a0a7 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -279,9 +279,9 @@ Settings['cron_jobs'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_builds_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_builds_worker']['cron'] ||= '0 0 * * *' Settings.cron_jobs['stuck_ci_builds_worker']['job_class'] = 'StuckCiBuildsWorker' -Settings.cron_jobs['expire_build_artifacts'] ||= Settingslogic.new({}) -Settings.cron_jobs['expire_build_artifacts']['cron'] ||= '0 0 * * *' -Settings.cron_jobs['expire_build_artifacts']['job_class'] = 'ExpireBuildArtifacts' +Settings.cron_jobs['expire_build_artifacts_worker'] ||= Settingslogic.new({}) +Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '0 0 * * *' +Settings.cron_jobs['expire_build_artifacts_worker']['job_class'] = 'ExpireBuildArtifactsWorker' Settings.cron_jobs['repository_check_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['repository_check_worker']['cron'] ||= '20 * * * *' Settings.cron_jobs['repository_check_worker']['job_class'] = 'RepositoryCheck::BatchWorker' diff --git a/config/routes.rb b/config/routes.rb index 3d092d98c8e..59724b737f6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -714,7 +714,6 @@ Rails.application.routes.draw do post :cancel post :retry post :erase - post :keep_artifacts get :trace get :raw end @@ -723,6 +722,7 @@ Rails.application.routes.draw do get :download get :browse, path: 'browse(/*path)', format: false get :file, path: 'file/*path', format: false + post :keep end end From 897bc59761ad410728136308a20a184cbd9340c9 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 13:26:36 +0200 Subject: [PATCH 106/318] Added description of artifacts:when --- doc/ci/yaml/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index a3481f58c6c..39fad549a04 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -30,6 +30,7 @@ If you want a quick introduction to GitLab CI, follow our - [when](#when) - [artifacts](#artifacts) - [artifacts:name](#artifacts-name) + - [artifacts:when](#artifacts-when) - [dependencies](#dependencies) - [before_script and after_script](#before_script-and-after_script) - [Hidden jobs](#hidden-jobs) @@ -651,6 +652,32 @@ job: untracked: true ``` +#### artifacts:when + +>**Note:** +Introduced in GitLab 8.9 and GitLab Runner v1.3.0. + +`artifacts:when` is used to upload artifacts on build failure or despite the +failure. + +`artifacts:when` can be set to one of the following values: + +1. `on_success` - upload artifacts only when build succeeds. This is the default +1. `on_failure` - upload artifacts only when build fails +1. `always` - upload artifacts despite the build status + +--- + +**Example configurations** + +To upload artifacts only when build fails + +```yaml +job: + artifacts: + when: on_failure +``` + ### dependencies >**Note:** From 4e9e4e22af38750d8948c0b3ccb532866975a023 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 14:25:38 +0200 Subject: [PATCH 107/318] Enable exceptions on ChronicDuration --- config/initializers/chronic_duration.rb | 1 + 1 file changed, 1 insertion(+) create mode 100644 config/initializers/chronic_duration.rb diff --git a/config/initializers/chronic_duration.rb b/config/initializers/chronic_duration.rb new file mode 100644 index 00000000000..b65b06c813a --- /dev/null +++ b/config/initializers/chronic_duration.rb @@ -0,0 +1 @@ +ChronicDuration.raise_exceptions = true From ee7c5539f38c5e66d06610d457efe983196372e2 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 14:25:54 +0200 Subject: [PATCH 108/318] Add artifacts_expire_in method for Ci::Build --- app/models/ci/build.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 5eb9fe5f1f5..9f66ae63a55 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -355,6 +355,18 @@ module Ci !artifacts? && artifacts_expire_at && artifacts_expire_at < Time.now end + def artifacts_expire_in + artifacts_expire_at - Time.now if artifacts_expire_at + end + + def artifacts_expire_in=(value) + if value + self.artifacts_expire_at = Time.now + ChronicDuration.parse(value) + else + self.artifacts_expire_at = nil + end + end + def keep_artifacts! self.update(artifacts_expire_at: nil) end From 1501940ee0452f01acc5a228df17928e2f91cf39 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 14:26:12 +0200 Subject: [PATCH 109/318] Validate artifacts:expire_in in yaml processor --- lib/ci/gitlab_ci_yaml_processor.rb | 10 ++++++++ spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 24 ++++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index b1297565ebe..88fa079f30d 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -282,6 +282,10 @@ module Ci if job[:artifacts][:when] && !job[:artifacts][:when].in?(%w(on_success on_failure always)) raise ValidationError, "#{name} job: artifacts:when parameter should be on_success, on_failure or always" end + + if job[:artifacts][:expire_in] && !validate_duration(job[:artifacts][:expire_in]) + raise ValidationError, "#{name} job: artifacts:expire_in parameter should be a duration" + end end def validate_job_dependencies!(name, job) @@ -300,6 +304,12 @@ module Ci end end + def validate_duration(value) + value.is_a?(String) && ChronicDuration.parse(value) + rescue ChronicDuration::DurationParseError + false + end + def validate_array_of_strings(values) values.is_a?(Array) && values.all? { |value| validate_string(value) } end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 3d3715f0ef0..00a04683e50 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -572,7 +572,12 @@ module Ci services: ["mysql"], before_script: ["pwd"], rspec: { - artifacts: { paths: ["logs/", "binaries/"], untracked: true, name: "custom_name" }, + artifacts: { + paths: ["logs/", "binaries/"], + untracked: true, + name: "custom_name", + expire_in: "7d" + }, script: "rspec" } }) @@ -594,7 +599,8 @@ module Ci artifacts: { name: "custom_name", paths: ["logs/", "binaries/"], - untracked: true + untracked: true, + expire_in: "7d" } }, when: "on_success", @@ -990,6 +996,20 @@ EOT end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:when parameter should be on_success, on_failure or always") end + it "returns errors if job artifacts:expire_in is not an a string" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { expire_in: 1 } } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:expire_in parameter should be a duration") + end + + it "returns errors if job artifacts:expire_in is not an a valid duration" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { expire_in: "7 elephants" } } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:expire_in parameter should be a duration") + end + it "returns errors if job artifacts:untracked is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { untracked: "string" } } }) expect do From 86800bf51aec25eef970eac82838bcba087703f8 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 14:26:31 +0200 Subject: [PATCH 110/318] Support expiration date in CI API when uploading artifacts --- lib/ci/api/builds.rb | 2 +- lib/ci/api/entities.rb | 1 + spec/requests/ci/api/builds_spec.rb | 36 +++++++++++++++++++++++++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/lib/ci/api/builds.rb b/lib/ci/api/builds.rb index 54f5626c7d7..9f270f7b387 100644 --- a/lib/ci/api/builds.rb +++ b/lib/ci/api/builds.rb @@ -146,7 +146,7 @@ module Ci build.artifacts_file = artifacts build.artifacts_metadata = metadata - build.artifacts_expire_at = Time.now + ChronicDuration.parse(params['expire_in']) + build.artifacts_expire_in = params['expire_in'] if build.save present(build, with: Entities::BuildDetails) diff --git a/lib/ci/api/entities.rb b/lib/ci/api/entities.rb index a902ced35d7..352d92e7cc0 100644 --- a/lib/ci/api/entities.rb +++ b/lib/ci/api/entities.rb @@ -29,6 +29,7 @@ module Ci expose :before_sha expose :allow_git_fetch expose :token + expose :artifacts_expire_at, if: lambda { |build, opts| build.artifacts? } expose :options do |model| model.options diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index e8508f8f950..dd2ade368f1 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -364,6 +364,42 @@ describe Ci::API::API do end end + context 'expire date' do + let!(:artifacts) { file_upload } + + let(:post_data) do + { 'file.path' => artifacts.path, + 'file.name' => artifacts.original_filename, + 'expire_in' => expire_in } + end + + before do + post(post_url, post_data, headers_with_token) + end + + context 'updates when specified' do + let(:expire_in) { '7 days' } + + it do + build.reload + expect(response.status).to eq(201) + expect(json_response['artifacts_expire_at']).not_to be_empty + expect(build.artifacts_expire_at).to be_within(5.minutes).of(Time.now + 7.days) + end + end + + context 'ignores if not specified' do + let(:expire_in) { nil } + + it do + build.reload + expect(response.status).to eq(201) + expect(json_response['artifacts_expire_at']).to be_nil + expect(build.artifacts_expire_at).to be_nil + end + end + end + context "artifacts file is too large" do it "should fail to post too large artifact" do stub_application_setting(max_artifacts_size: 0) From c59947112f352a12e74563453a1bec3082baab41 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 14:39:36 +0200 Subject: [PATCH 111/318] Validate existence of artifacts in ArtifactsController, render 404 if not found --- app/controllers/projects/artifacts_controller.rb | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 028e1f77119..0ab95cd9518 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,22 +1,17 @@ class Projects::ArtifactsController < Projects::ApplicationController layout 'project' before_action :authorize_read_build! + before_action :validate_artifacts! def download unless artifacts_file.file_storage? return redirect_to artifacts_file.url end - unless artifacts_file.exists? - return render_404 - end - send_file artifacts_file.path, disposition: 'attachment' end def browse - return render_404 unless build.artifacts? - directory = params[:path] ? "#{params[:path]}/" : '' @entry = build.artifacts_metadata_entry(directory) @@ -41,6 +36,10 @@ class Projects::ArtifactsController < Projects::ApplicationController private + def validate_artifacts! + render_404 unless build.artifacts? + end + def build @build ||= project.builds.find_by!(id: params[:build_id]) end From 950d78f6d9ae43bf5c807d95326cf18afcfceedb Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 14:39:58 +0200 Subject: [PATCH 112/318] Remove keep_artifacts from BuildsController --- app/controllers/projects/builds_controller.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/controllers/projects/builds_controller.rb b/app/controllers/projects/builds_controller.rb index 514f1b507fe..14c82826342 100644 --- a/app/controllers/projects/builds_controller.rb +++ b/app/controllers/projects/builds_controller.rb @@ -78,12 +78,6 @@ class Projects::BuildsController < Projects::ApplicationController end end - def keep_artifacts - @build.keep_artifacts - redirect_to namespace_project_build_path(project.namespace, project, @build), - notice: "Artifacts will not be removed!" - end - private def build From b9977525394ac714e31c1751690c7b993eb8d830 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Fri, 10 Jun 2016 16:43:25 +0100 Subject: [PATCH 113/318] Only show branches for revert / cherry-pick Tags are immutable, so we can't add a commit to either revert or cherry-pick another commit to them. --- CHANGELOG | 1 + app/helpers/branches_helper.rb | 4 ++++ app/views/projects/commit/_change.html.haml | 2 +- spec/features/projects/commits/cherry_pick_spec.rb | 1 + 4 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 0c712b445a4..509f3ec6a26 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -8,6 +8,7 @@ v 8.9.0 (unreleased) - Bump rouge to 1.11.0 - Make EmailsOnPushWorker use Sidekiq mailers queue - Fix wiki page events' webhook to point to the wiki repository + - Don't show tags for revert and cherry-pick operations - Fix issue todo not remove when leave project !4150 (Long Nguyen) - Allow customisable text on the 'nearly there' page after a user signs up - Bump recaptcha gem to 3.0.0 to remove deprecated stoken support diff --git a/app/helpers/branches_helper.rb b/app/helpers/branches_helper.rb index e39548e17e1..3ee3fc74f0c 100644 --- a/app/helpers/branches_helper.rb +++ b/app/helpers/branches_helper.rb @@ -14,4 +14,8 @@ module BranchesHelper ::Gitlab::GitAccess.new(current_user, project).can_push_to_branch?(branch_name) end + + def project_branches + options_for_select(@project.repository.branch_names, @project.default_branch) + end end diff --git a/app/views/projects/commit/_change.html.haml b/app/views/projects/commit/_change.html.haml index 44ef1fdbbe3..d9b800a4ded 100644 --- a/app/views/projects/commit/_change.html.haml +++ b/app/views/projects/commit/_change.html.haml @@ -17,7 +17,7 @@ .form-group.branch = label_tag 'target_branch', target_label, class: 'control-label' .col-sm-10 - = select_tag "target_branch", grouped_options_refs, class: "select2 select2-sm js-target-branch" + = select_tag "target_branch", project_branches, class: "select2 select2-sm js-target-branch" - if can?(current_user, :push_code, @project) .js-create-merge-request-container .checkbox diff --git a/spec/features/projects/commits/cherry_pick_spec.rb b/spec/features/projects/commits/cherry_pick_spec.rb index 0559b02f321..f88c0616b52 100644 --- a/spec/features/projects/commits/cherry_pick_spec.rb +++ b/spec/features/projects/commits/cherry_pick_spec.rb @@ -16,6 +16,7 @@ describe 'Cherry-pick Commits' do it do visit namespace_project_commit_path(project.namespace, project, master_pickable_commit.id) find("a[href='#modal-cherry-pick-commit']").click + expect(page).not_to have_content('v1.0.0') # Only branches, not tags page.within('#modal-cherry-pick-commit') do uncheck 'create_merge_request' click_button 'Cherry-pick' From fab1c4a81b7eef247abe6bdd3775cf0ce42badc1 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 14:40:25 +0200 Subject: [PATCH 114/318] Show the artifacts expiration prompt in Build Artifacts widget --- app/models/ci/build.rb | 4 +-- app/views/projects/builds/_sidebar.html.haml | 35 ++++++++++---------- 2 files changed, 20 insertions(+), 19 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 9f66ae63a55..80702b274dd 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -319,7 +319,7 @@ module Ci end def artifacts? - artifacts_file.exists? + !artifacts_expired? && artifacts_file.exists? end def artifacts_metadata? @@ -352,7 +352,7 @@ module Ci end def artifacts_expired? - !artifacts? && artifacts_expire_at && artifacts_expire_at < Time.now + artifacts_expire_at && artifacts_expire_at < Time.now end def artifacts_expire_in diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index e1fdd7019ff..0741426b5af 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -11,17 +11,28 @@ %p.build-detail-row #{@build.coverage}% - - if can?(current_user, :read_build, @project) && @build.artifacts? + - if can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?) .block{ class: ("block-first" if !@build.coverage) } .title Build artifacts - .btn-group.btn-group-justified{ role: :group } - = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Download + - if @build.artifacts_expired? + .artifacts-expired.alert.alert-warning + The artifacts were removed #{time_ago_with_tooltip(@build.artifacts_expire_at)} + - elsif @build.artifacts_expire_at + .artifacts-expired.alert.alert-warning + The artifacts will be removed in #{time_interval_in_words(@build.artifacts_expire_in)} + - if @build.artifacts? + .btn-group.btn-group-justified{ role: :group } + - if @build.artifacts_expire_at + = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Keep - - if @build.artifacts_metadata? - = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do - Browse + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Download + + - if @build.artifacts_metadata? + = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + Browse .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && @build.artifacts?)) } .title @@ -44,16 +55,6 @@ %p.build-detail-row %span.build-light-text Erased: #{time_ago_with_tooltip(@build.erased_at)} - - else - - if @build.artifacts_expired? - .artifacts-expired.alert.alert-warning - The artifacts were removed #{time_ago_with_tooltip(@build.artifacts_expire_at)} - - elsif @build.artifacts_expire_at - .artifacts-expired.alert.alert-warning - The artifacts will be removed in #{duration_in_words(@build.artifacts_expire_at, Time.now)} - .pull-right - = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-xs btn-primary', method: :post do - Keep %p.build-detail-row %span.build-light-text Runner: - if @build.runner && current_user && current_user.admin From 304979f89777f4aca52b382fdbe3a593dc7e50f3 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 15:11:53 +0200 Subject: [PATCH 115/318] Allow to show the time in the future --- app/assets/javascripts/application.js.coffee | 2 ++ app/helpers/time_helper.rb | 1 - app/views/projects/builds/_sidebar.html.haml | 11 +++++++---- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 69d4c4f5dd3..33e593f4376 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -254,6 +254,8 @@ $ -> .on "resize.app", (e) -> fitSidebarForSize() + jQuery.timeago.settings.allowFuture = true; + gl.awardsHandler = new AwardsHandler() checkInitialSidebarSize() new Aside() diff --git a/app/helpers/time_helper.rb b/app/helpers/time_helper.rb index 8142f733e76..b04b0a5114c 100644 --- a/app/helpers/time_helper.rb +++ b/app/helpers/time_helper.rb @@ -20,7 +20,6 @@ module TimeHelper end end - def date_from_to(from, to) "#{from.to_s(:short)} - #{to.to_s(:short)}" end diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index 0741426b5af..14571145313 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -16,11 +16,14 @@ .title Build artifacts - if @build.artifacts_expired? - .artifacts-expired.alert.alert-warning - The artifacts were removed #{time_ago_with_tooltip(@build.artifacts_expire_at)} + %p + The artifacts were removed + #{time_ago_with_tooltip(@build.artifacts_expire_at)} - elsif @build.artifacts_expire_at - .artifacts-expired.alert.alert-warning - The artifacts will be removed in #{time_interval_in_words(@build.artifacts_expire_in)} + %p + The artifacts will be removed in + #{time_ago_with_tooltip(@build.artifacts_expire_at)} + - if @build.artifacts? .btn-group.btn-group-justified{ role: :group } - if @build.artifacts_expire_at From bb3fc8c72c2a3af7873cabf9ae3e951376e7ac9e Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Mon, 13 Jun 2016 12:07:03 +0200 Subject: [PATCH 116/318] Make "four phase test" --- spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index fe1e8b2262e..304290d6608 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -501,6 +501,7 @@ module Ci }) config_processor = GitlabCiYamlProcessor.new(config, path) + builds = config_processor.builds_for_stage_and_ref("test", "master") expect(builds.size).to eq(1) expect(builds.first[:when]).to eq(when_state) From 7e9273dd946f46b2b2bcc0a751316dc704089a16 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 16:20:11 +0200 Subject: [PATCH 117/318] Test controllers if they allow to keep artifacts --- .../projects/artifacts_controller.rb | 1 + spec/features/builds_spec.rb | 44 +++++++++++++ spec/models/build_spec.rb | 65 ++++++++++++++++++- 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 0ab95cd9518..f11c8321464 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -1,6 +1,7 @@ class Projects::ArtifactsController < Projects::ApplicationController layout 'project' before_action :authorize_read_build! + before_action :authorize_update_build!, only: [:keep] before_action :validate_artifacts! def download diff --git a/spec/features/builds_spec.rb b/spec/features/builds_spec.rb index b8ecc356b4d..a5c3f7cc0b0 100644 --- a/spec/features/builds_spec.rb +++ b/spec/features/builds_spec.rb @@ -97,6 +97,48 @@ describe "Builds" do end end + context 'Artifacts expire date' do + before do + @build.update_attributes(artifacts_file: artifacts_file, artifacts_expire_at: expire_at) + visit namespace_project_build_path(@project.namespace, @project, @build) + end + + context 'no expire date defined' do + let(:expire_at) { nil } + + it 'should not have the Keep button' do + page.within('.artifacts') do + expect(page).not_to have_content 'Keep' + end + end + end + + context 'when expire date is defined' do + let(:expire_at) { Time.now + 7.days } + + it 'should keep artifacts when Keep button is clicked' do + page.within('.artifacts') do + expect(page).to have_content 'The artifacts will be removed' + click_link 'Keep' + end + + expect(page).not_to have_link 'Keep' + expect(page).not_to have_content 'The artifacts will be removed' + end + end + + context 'when artifacts expired' do + let(:expire_at) { Time.now - 7.days } + + it 'should not have the Keep button' do + page.within('.artifacts') do + expect(page).to have_content 'The artifacts were removed' + expect(page).not_to have_link 'Keep' + end + end + end + end + context 'Build raw trace' do before do @build.run! @@ -108,6 +150,8 @@ describe "Builds" do expect(page).to have_link 'Raw' end end + + context '' end describe "POST /:project/builds/:id/cancel" do diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 2beb6cc598d..a2e4639dbf7 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -397,9 +397,32 @@ describe Ci::Build, models: true do context 'artifacts archive exists' do let(:build) { create(:ci_build, :artifacts) } it { is_expected.to be_truthy } + + context 'is expired' do + before { build.update(artifacts_expire_at: Time.now - 7.days) } + it { is_expected.to be_falsy } + end + + context 'is not expired' do + before { build.update(artifacts_expire_at: Time.now + 7.days) } + it { is_expected.to be_truthy } + end end end + describe '#artifacts_expired?' do + subject { build.artifacts_expired? } + + context 'is expired' do + before { build.update(artifacts_expire_at: Time.now - 7.days) } + it { is_expected.to be_falsy } + end + + context 'is not expired' do + before { build.update(artifacts_expire_at: Time.now + 7.days) } + it { is_expected.to be_truthy } + end + end describe '#artifacts_metadata?' do subject { build.artifacts_metadata? } @@ -412,7 +435,6 @@ describe Ci::Build, models: true do it { is_expected.to be_truthy } end end - describe '#repo_url' do let(:build) { create(:ci_build) } let(:project) { build.project } @@ -427,6 +449,47 @@ describe Ci::Build, models: true do it { is_expected.to include(project.web_url[7..-1]) } end + describe '#artifacts_expire_in' do + subject { build.artifacts_expire_in } + it { is_expected.to be_nil } + + context 'when artifacts_expire_at is specified' do + let(:expire_at) { Time.now + 7.days } + + before { build.artifacts_expire_at = expire_at } + + it { is_expected.to be_within(5).of(expire_at - Time.now) } + end + end + + describe '#artifacts_expire_in=' do + subject { build.artifacts_expire_in } + + it 'when assigning valid duration' do + build.artifacts_expire_in = '7 days' + is_expected.to be_within(10).of(7.days.to_i) + end + + it 'when assigning invalid duration' do + expect{ build.artifacts_expire_in = '7 elephants' }.not_to raise_error + is_expected.to be_nil + end + + it 'when resseting value' do + build.artifacts_expire_in = nil + is_expected.to be_nil + end + end + + describe '#keep_artifacts!' do + let(:build) { create(:ci_build, artifacts_expire_at: Time.now + 7.days) } + + it 'to reset expire_at' do + build.keep_artifacts! + expect(build.artifacts_expire_at).to be_nil + end + end + describe '#depends_on_builds' do let!(:build) { create(:ci_build, pipeline: pipeline, name: 'build', stage_idx: 0, stage: 'build') } let!(:rspec_test) { create(:ci_build, pipeline: pipeline, name: 'rspec', stage_idx: 1, stage: 'test') } From 6013768fec33e3bf084019d97dbfb7cca78f8e82 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 17:11:27 +0200 Subject: [PATCH 118/318] Added keep artifacts API endpoint --- doc/api/builds.md | 50 ++++++++++++++++++++++++++++++++ lib/api/builds.rb | 19 ++++++++++++ spec/requests/api/builds_spec.rb | 27 +++++++++++++++++ 3 files changed, 96 insertions(+) diff --git a/doc/api/builds.md b/doc/api/builds.md index 5669bd0cdda..0f9f4e99ea2 100644 --- a/doc/api/builds.md +++ b/doc/api/builds.md @@ -443,3 +443,53 @@ Example of response "user": null } ``` + +## Keep artifacts + +Prevents artifacts from being deleted when expiration is set + +``` +POST /projects/:id/builds/:build_id/artifacts/keep +``` + +Parameters + +| Attribute | Type | required | Description | +|-------------|---------|----------|---------------------| +| `id` | integer | yes | The ID of a project | +| `build_id` | integer | yes | The ID of a build | + +Example of request + +``` +curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/artifacts/keep" +``` + +Example of response + +```json +{ + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "download_url": null, + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "created_at": "2016-01-11T10:13:33.506Z", + "started_at": "2016-01-11T10:13:33.506Z", + "finished_at": "2016-01-11T10:15:10.506Z", + "status": "failed", + "tag": false, + "user": null +} +``` diff --git a/lib/api/builds.rb b/lib/api/builds.rb index 0ff8fa74a84..704654e9e8c 100644 --- a/lib/api/builds.rb +++ b/lib/api/builds.rb @@ -166,6 +166,25 @@ module API present build, with: Entities::Build, user_can_download_artifacts: can?(current_user, :download_build_artifacts, user_project) end + + # Keep the artifacts to prevent them to be deleted + # + # Parameters: + # id (required) - The ID of a build + # Example Request: + # POST /projects/:id/builds/:build_id/artifacts/keep + post ':id/builds/:build_id/artifacts/keep' do + authorize_update_builds! + + build = get_build(params[:build_id]) + return not_found!(build) unless build && build.artifacts? + + build.keep_artifacts! + + status 200 + present build, with: Entities::Build, + user_can_download_artifacts: can?(current_user, :read_build, user_project) + end end helpers do diff --git a/spec/requests/api/builds_spec.rb b/spec/requests/api/builds_spec.rb index 6cb7be188ef..b92d991b998 100644 --- a/spec/requests/api/builds_spec.rb +++ b/spec/requests/api/builds_spec.rb @@ -241,4 +241,31 @@ describe API::API, api: true do end end end + + describe 'POST /projects/:id/builds/:build_id/artifacts/keep' do + before do + post api("/projects/#{project.id}/builds/#{build.id}/artifacts/keep", user) + end + + context 'artifacts did not expire' do + let(:build) do + create(:ci_build, :trace, :artifacts, :success, + project: project, pipeline: pipeline, artifacts_expire_at: Time.now + 7.days) + end + + it 'should keep artifacts' do + expect(response.status).to eq 200 + build.reload + expect(build.artifacts_expire_at).to be_nil + end + end + + context 'no artifacts' do + let(:build) { create(:ci_build, project: project, pipeline: pipeline) } + + it 'should respond with not found' do + expect(response.status).to eq 404 + end + end + end end From 1c60ff0b7ae190a5c6c1cc8c72358af6ef66c05e Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 17:27:49 +0200 Subject: [PATCH 119/318] Test ExpireBuildArtifactsWorker --- app/workers/expire_build_artifacts_worker.rb | 2 +- .../expire_build_artifacts_worker_spec.rb | 55 +++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) create mode 100644 spec/workers/expire_build_artifacts_worker_spec.rb diff --git a/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb index 17b3b5f227f..c64ea108d52 100644 --- a/app/workers/expire_build_artifacts_worker.rb +++ b/app/workers/expire_build_artifacts_worker.rb @@ -1,4 +1,4 @@ -class ExpireBuildArtifacts +class ExpireBuildArtifactsWorker include Sidekiq::Worker def perform diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb new file mode 100644 index 00000000000..c9ccddc2a09 --- /dev/null +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe ExpireBuildArtifactsWorker do + include RepoHelpers + + let(:worker) { ExpireBuildArtifactsWorker.new } + + describe '#perform' do + context 'with expired artifacts' do + let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } + + it do + expect_any_instance_of(Ci::Build).to receive(:erase_artifacts!) + worker.perform + build.reload + expect(build.artifacts_expired?).to be_truthy + end + end + + context 'with not yet expired artifacts' do + let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now + 7.days) } + + it do + expect_any_instance_of(Ci::Build).not_to receive(:erase_artifacts!) + worker.perform + build.reload + expect(build.artifacts_expired?).to be_falsey + end + end + + context 'without expire date' do + let!(:build) { create(:ci_build, :artifacts) } + + it do + expect_any_instance_of(Ci::Build).not_to receive(:erase_artifacts!) + worker.perform + end + end + + context 'for expired artifacts' do + let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } + + before do + build.erase_artifacts! + end + + it do + expect_any_instance_of(Ci::Build).not_to receive(:erase_artifacts!) + worker.perform + build.reload + expect(build.artifacts_expired?).to be_truthy + end + end + end +end From e0673f82c9ee222cf807438520a5abbd75a70456 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 17:31:22 +0200 Subject: [PATCH 120/318] Save database after erasing artifacts --- spec/workers/expire_build_artifacts_worker_spec.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb index c9ccddc2a09..64a55e8c587 100644 --- a/spec/workers/expire_build_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -42,6 +42,7 @@ describe ExpireBuildArtifactsWorker do before do build.erase_artifacts! + build.save end it do From 93080b65cd42ca90ccab8c9ecb2b68c79aafa193 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 10 Jun 2016 17:19:17 +0100 Subject: [PATCH 121/318] Displays time remaining relative to now --- app/assets/javascripts/application.js.coffee | 2 -- app/assets/javascripts/ci/build.coffee | 9 +++++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 33e593f4376..69d4c4f5dd3 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -254,8 +254,6 @@ $ -> .on "resize.app", (e) -> fitSidebarForSize() - jQuery.timeago.settings.allowFuture = true; - gl.awardsHandler = new AwardsHandler() checkInitialSidebarSize() new Aside() diff --git a/app/assets/javascripts/ci/build.coffee b/app/assets/javascripts/ci/build.coffee index f763ba96e33..2d515d7efa2 100644 --- a/app/assets/javascripts/ci/build.coffee +++ b/app/assets/javascripts/ci/build.coffee @@ -17,6 +17,8 @@ class @CiBuild .off 'resize.build' .on 'resize.build', @hideSidebar + @updateArtifactRemoveDate() + if $('#build-trace').length @getInitialBuildTrace() @initScrollButtonAffix() @@ -103,3 +105,10 @@ class @CiBuild $('.js-build-sidebar') .removeClass 'right-sidebar-collapsed' .addClass 'right-sidebar-expanded' + + updateArtifactRemoveDate: -> + $date = $('.js-artifacts-remove') + + if $date.length + date = $date.text() + $date.text $.timefor(new Date(date), ' ') From d23b91b0d9b8db16801872c49a1fb1d3be3a7144 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 21:25:48 +0200 Subject: [PATCH 122/318] Improve after review --- doc/ci/yaml/README.md | 2 +- lib/ci/gitlab_ci_yaml_processor.rb | 4 ++-- spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 39fad549a04..0707555e393 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -670,7 +670,7 @@ failure. **Example configurations** -To upload artifacts only when build fails +To upload artifacts only when build fails. ```yaml job: diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 88fa079f30d..76d84433cbe 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -208,7 +208,7 @@ module Ci raise ValidationError, "#{name} job: allow_failure parameter should be an boolean" end - if job[:when] && !job[:when].in?(%w(on_success on_failure always)) + if job[:when] && !job[:when].in?(%w[on_success on_failure always]) raise ValidationError, "#{name} job: when parameter should be on_success, on_failure or always" end end @@ -279,7 +279,7 @@ module Ci raise ValidationError, "#{name} job: artifacts:paths parameter should be an array of strings" end - if job[:artifacts][:when] && !job[:artifacts][:when].in?(%w(on_success on_failure always)) + if job[:artifacts][:when] && !job[:artifacts][:when].in?(%w[on_success on_failure always]) raise ValidationError, "#{name} job: artifacts:when parameter should be on_success, on_failure or always" end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 00a04683e50..ad693dd05f5 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -608,7 +608,7 @@ module Ci }) end - %w(on_success on_failure always).each do |when_state| + %w[on_success on_failure always].each do |when_state| it "returns artifacts for when #{when_state} defined" do config = YAML.dump({ rspec: { @@ -618,6 +618,7 @@ module Ci }) config_processor = GitlabCiYamlProcessor.new(config, path) + builds = config_processor.builds_for_stage_and_ref("test", "master") expect(builds.size).to eq(1) expect(builds.first[:options][:artifacts][:when]).to eq(when_state) From 9281709b41ce5be5637194cda191a6dd76ddd495 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 10 Jun 2016 20:40:25 +0100 Subject: [PATCH 123/318] Added missing span element around time --- app/views/projects/builds/_sidebar.html.haml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index 14571145313..af69490019d 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -16,13 +16,13 @@ .title Build artifacts - if @build.artifacts_expired? - %p + %p.build-detail-row The artifacts were removed #{time_ago_with_tooltip(@build.artifacts_expire_at)} - elsif @build.artifacts_expire_at - %p + %p.build-detail-row The artifacts will be removed in - #{time_ago_with_tooltip(@build.artifacts_expire_at)} + %span.js-artifacts-remove= @build.artifacts_expire_at - if @build.artifacts? .btn-group.btn-group-justified{ role: :group } From 421be01dabb13cd1f45d0118b4e1be9d33baef61 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 21:45:06 +0200 Subject: [PATCH 124/318] Improve design based on review --- app/models/ci/build.rb | 9 +- doc/api/builds.md | 528 +++++++++--------- lib/api/builds.rb | 5 +- lib/ci/api/entities.rb | 4 +- spec/features/builds_spec.rb | 8 +- spec/models/build_spec.rb | 3 + spec/requests/api/builds_spec.rb | 7 +- spec/requests/ci/api/builds_spec.rb | 6 +- .../expire_build_artifacts_worker_spec.rb | 18 +- 9 files changed, 296 insertions(+), 292 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 80702b274dd..89a1f8b3f57 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -360,11 +360,10 @@ module Ci end def artifacts_expire_in=(value) - if value - self.artifacts_expire_at = Time.now + ChronicDuration.parse(value) - else - self.artifacts_expire_at = nil - end + self.artifacts_expire_at = + if value + Time.now + ChronicDuration.parse(value) + end end def keep_artifacts! diff --git a/doc/api/builds.md b/doc/api/builds.md index 0f9f4e99ea2..de998944352 100644 --- a/doc/api/builds.md +++ b/doc/api/builds.md @@ -21,85 +21,85 @@ Example of response ```json [ - { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2015-12-24T15:51:21.802Z", - "artifacts_file": { - "filename": "artifacts.zip", - "size": 1000 - }, - "finished_at": "2015-12-24T17:54:27.895Z", - "id": 7, - "name": "teaspoon", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": "2015-12-24T17:54:27.722Z", - "status": "failed", - "tag": false, - "user": { - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "bio": null, - "created_at": "2015-12-21T13:14:24.077Z", - "id": 1, - "is_admin": true, - "linkedin": "", - "name": "Administrator", - "skype": "", - "state": "active", - "twitter": "", - "username": "root", - "web_url": "http://gitlab.dev/u/root", - "website_url": "" - } + { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." }, - { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2015-12-24T15:51:21.727Z", - "artifacts_file": null, - "finished_at": "2015-12-24T17:54:24.921Z", - "id": 6, - "name": "spinach:other", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": "2015-12-24T17:54:24.729Z", - "status": "failed", - "tag": false, - "user": { - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "bio": null, - "created_at": "2015-12-21T13:14:24.077Z", - "id": 1, - "is_admin": true, - "linkedin": "", - "name": "Administrator", - "skype": "", - "state": "active", - "twitter": "", - "username": "root", - "web_url": "http://gitlab.dev/u/root", - "website_url": "" - } + "coverage": null, + "created_at": "2015-12-24T15:51:21.802Z", + "artifacts_file": { + "filename": "artifacts.zip", + "size": 1000 + }, + "finished_at": "2015-12-24T17:54:27.895Z", + "id": 7, + "name": "teaspoon", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": "2015-12-24T17:54:27.722Z", + "status": "failed", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2015-12-21T13:14:24.077Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://gitlab.dev/u/root", + "website_url": "" } + }, + { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2015-12-24T15:51:21.727Z", + "artifacts_file": null, + "finished_at": "2015-12-24T17:54:24.921Z", + "id": 6, + "name": "spinach:other", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": "2015-12-24T17:54:24.729Z", + "status": "failed", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2015-12-21T13:14:24.077Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://gitlab.dev/u/root", + "website_url": "" + } + } ] ``` @@ -125,68 +125,68 @@ Example of response ```json [ - { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2016-01-11T10:13:33.506Z", - "artifacts_file": null, - "finished_at": "2016-01-11T10:14:09.526Z", - "id": 69, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": null, - "status": "canceled", - "tag": false, - "user": null + { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." }, - { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2015-12-24T15:51:21.957Z", - "artifacts_file": null, - "finished_at": "2015-12-24T17:54:33.913Z", - "id": 9, - "name": "brakeman", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": "2015-12-24T17:54:33.727Z", - "status": "failed", - "tag": false, - "user": { - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "bio": null, - "created_at": "2015-12-21T13:14:24.077Z", - "id": 1, - "is_admin": true, - "linkedin": "", - "name": "Administrator", - "skype": "", - "state": "active", - "twitter": "", - "username": "root", - "web_url": "http://gitlab.dev/u/root", - "website_url": "" - } + "coverage": null, + "created_at": "2016-01-11T10:13:33.506Z", + "artifacts_file": null, + "finished_at": "2016-01-11T10:14:09.526Z", + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": null, + "status": "canceled", + "tag": false, + "user": null + }, + { + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2015-12-24T15:51:21.957Z", + "artifacts_file": null, + "finished_at": "2015-12-24T17:54:33.913Z", + "id": 9, + "name": "brakeman", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": "2015-12-24T17:54:33.727Z", + "status": "failed", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2015-12-21T13:14:24.077Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://gitlab.dev/u/root", + "website_url": "" } + } ] ``` @@ -211,42 +211,42 @@ Example of response ```json { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2015-12-24T15:51:21.880Z", - "artifacts_file": null, - "finished_at": "2015-12-24T17:54:31.198Z", - "id": 8, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": "2015-12-24T17:54:30.733Z", - "status": "failed", - "tag": false, - "user": { - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", - "bio": null, - "created_at": "2015-12-21T13:14:24.077Z", - "id": 1, - "is_admin": true, - "linkedin": "", - "name": "Administrator", - "skype": "", - "state": "active", - "twitter": "", - "username": "root", - "web_url": "http://gitlab.dev/u/root", - "website_url": "" - } + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2015-12-24T15:51:21.880Z", + "artifacts_file": null, + "finished_at": "2015-12-24T17:54:31.198Z", + "id": 8, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": "2015-12-24T17:54:30.733Z", + "status": "failed", + "tag": false, + "user": { + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "bio": null, + "created_at": "2015-12-21T13:14:24.077Z", + "id": 1, + "is_admin": true, + "linkedin": "", + "name": "Administrator", + "skype": "", + "state": "active", + "twitter": "", + "username": "root", + "web_url": "http://gitlab.dev/u/root", + "website_url": "" + } } ``` @@ -323,28 +323,28 @@ Example of response ```json { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2016-01-11T10:13:33.506Z", - "artifacts_file": null, - "finished_at": "2016-01-11T10:14:09.526Z", - "id": 69, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": null, - "status": "canceled", - "tag": false, - "user": null + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2016-01-11T10:13:33.506Z", + "artifacts_file": null, + "finished_at": "2016-01-11T10:14:09.526Z", + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": null, + "status": "canceled", + "tag": false, + "user": null } ``` @@ -369,28 +369,28 @@ Example of response ```json { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "created_at": "2016-01-11T10:13:33.506Z", - "artifacts_file": null, - "finished_at": null, - "id": 69, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "started_at": null, - "status": "pending", - "tag": false, - "user": null + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "created_at": "2016-01-11T10:13:33.506Z", + "artifacts_file": null, + "finished_at": null, + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "started_at": null, + "status": "pending", + "tag": false, + "user": null } ``` @@ -419,34 +419,34 @@ Example of response ```json { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "download_url": null, - "id": 69, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "created_at": "2016-01-11T10:13:33.506Z", - "started_at": "2016-01-11T10:13:33.506Z", - "finished_at": "2016-01-11T10:15:10.506Z", - "status": "failed", - "tag": false, - "user": null + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "download_url": null, + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "created_at": "2016-01-11T10:13:33.506Z", + "started_at": "2016-01-11T10:13:33.506Z", + "finished_at": "2016-01-11T10:15:10.506Z", + "status": "failed", + "tag": false, + "user": null } ``` ## Keep artifacts -Prevents artifacts from being deleted when expiration is set +Prevents artifacts from being deleted when expiration is set. ``` POST /projects/:id/builds/:build_id/artifacts/keep @@ -459,37 +459,37 @@ Parameters | `id` | integer | yes | The ID of a project | | `build_id` | integer | yes | The ID of a build | -Example of request +Example request: ``` curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab.example.com/api/v3/projects/1/builds/1/artifacts/keep" ``` -Example of response +Example response: ```json { - "commit": { - "author_email": "admin@example.com", - "author_name": "Administrator", - "created_at": "2015-12-24T16:51:14.000+01:00", - "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", - "message": "Test the CI integration.", - "short_id": "0ff3ae19", - "title": "Test the CI integration." - }, - "coverage": null, - "download_url": null, - "id": 69, - "name": "rubocop", - "ref": "master", - "runner": null, - "stage": "test", - "created_at": "2016-01-11T10:13:33.506Z", - "started_at": "2016-01-11T10:13:33.506Z", - "finished_at": "2016-01-11T10:15:10.506Z", - "status": "failed", - "tag": false, - "user": null + "commit": { + "author_email": "admin@example.com", + "author_name": "Administrator", + "created_at": "2015-12-24T16:51:14.000+01:00", + "id": "0ff3ae198f8601a285adcf5c0fff204ee6fba5fd", + "message": "Test the CI integration.", + "short_id": "0ff3ae19", + "title": "Test the CI integration." + }, + "coverage": null, + "download_url": null, + "id": 69, + "name": "rubocop", + "ref": "master", + "runner": null, + "stage": "test", + "created_at": "2016-01-11T10:13:33.506Z", + "started_at": "2016-01-11T10:13:33.506Z", + "finished_at": "2016-01-11T10:15:10.506Z", + "status": "failed", + "tag": false, + "user": null } ``` diff --git a/lib/api/builds.rb b/lib/api/builds.rb index 704654e9e8c..644e5a2a99d 100644 --- a/lib/api/builds.rb +++ b/lib/api/builds.rb @@ -167,10 +167,11 @@ module API user_can_download_artifacts: can?(current_user, :download_build_artifacts, user_project) end - # Keep the artifacts to prevent them to be deleted + # Keep the artifacts to prevent them from being deleted # # Parameters: - # id (required) - The ID of a build + # id (required) - the id of a project + # build_id (required) - The ID of a build # Example Request: # POST /projects/:id/builds/:build_id/artifacts/keep post ':id/builds/:build_id/artifacts/keep' do diff --git a/lib/ci/api/entities.rb b/lib/ci/api/entities.rb index 352d92e7cc0..3f5bdaba3f5 100644 --- a/lib/ci/api/entities.rb +++ b/lib/ci/api/entities.rb @@ -20,7 +20,7 @@ module Ci expose :name, :token, :stage expose :project_id expose :project_name - expose :artifacts_file, using: ArtifactFile, if: lambda { |build, opts| build.artifacts? } + expose :artifacts_file, using: ArtifactFile, if: ->(build, _) { build.artifacts? } end class BuildDetails < Build @@ -29,7 +29,7 @@ module Ci expose :before_sha expose :allow_git_fetch expose :token - expose :artifacts_expire_at, if: lambda { |build, opts| build.artifacts? } + expose :artifacts_expire_at, if: ->(build, _) { build.artifacts? } expose :options do |model| model.options diff --git a/spec/features/builds_spec.rb b/spec/features/builds_spec.rb index a5c3f7cc0b0..0fd95295388 100644 --- a/spec/features/builds_spec.rb +++ b/spec/features/builds_spec.rb @@ -106,7 +106,7 @@ describe "Builds" do context 'no expire date defined' do let(:expire_at) { nil } - it 'should not have the Keep button' do + it 'does not have the Keep button' do page.within('.artifacts') do expect(page).not_to have_content 'Keep' end @@ -116,7 +116,7 @@ describe "Builds" do context 'when expire date is defined' do let(:expire_at) { Time.now + 7.days } - it 'should keep artifacts when Keep button is clicked' do + it 'keeps artifacts when Keep button is clicked' do page.within('.artifacts') do expect(page).to have_content 'The artifacts will be removed' click_link 'Keep' @@ -130,7 +130,7 @@ describe "Builds" do context 'when artifacts expired' do let(:expire_at) { Time.now - 7.days } - it 'should not have the Keep button' do + it 'does not have the Keep button' do page.within('.artifacts') do expect(page).to have_content 'The artifacts were removed' expect(page).not_to have_link 'Keep' @@ -150,8 +150,6 @@ describe "Builds" do expect(page).to have_link 'Raw' end end - - context '' end describe "POST /:project/builds/:id/cancel" do diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index a2e4639dbf7..f25b676651e 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -467,6 +467,7 @@ describe Ci::Build, models: true do it 'when assigning valid duration' do build.artifacts_expire_in = '7 days' + is_expected.to be_within(10).of(7.days.to_i) end @@ -477,6 +478,7 @@ describe Ci::Build, models: true do it 'when resseting value' do build.artifacts_expire_in = nil + is_expected.to be_nil end end @@ -486,6 +488,7 @@ describe Ci::Build, models: true do it 'to reset expire_at' do build.keep_artifacts! + expect(build.artifacts_expire_at).to be_nil end end diff --git a/spec/requests/api/builds_spec.rb b/spec/requests/api/builds_spec.rb index b92d991b998..ac85f340922 100644 --- a/spec/requests/api/builds_spec.rb +++ b/spec/requests/api/builds_spec.rb @@ -253,17 +253,16 @@ describe API::API, api: true do project: project, pipeline: pipeline, artifacts_expire_at: Time.now + 7.days) end - it 'should keep artifacts' do + it 'keeps artifacts' do expect(response.status).to eq 200 - build.reload - expect(build.artifacts_expire_at).to be_nil + expect(build.reload.artifacts_expire_at).to be_nil end end context 'no artifacts' do let(:build) { create(:ci_build, project: project, pipeline: pipeline) } - it 'should respond with not found' do + it 'responds with not found' do expect(response.status).to eq 404 end end diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index dd2ade368f1..616b41eabe0 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -364,7 +364,7 @@ describe Ci::API::API do end end - context 'expire date' do + context 'with an expire date' do let!(:artifacts) { file_upload } let(:post_data) do @@ -377,7 +377,7 @@ describe Ci::API::API do post(post_url, post_data, headers_with_token) end - context 'updates when specified' do + context 'with an expire_in given' do let(:expire_in) { '7 days' } it do @@ -388,7 +388,7 @@ describe Ci::API::API do end end - context 'ignores if not specified' do + context 'with no expire_in given' do let(:expire_in) { nil } it do diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb index 64a55e8c587..501ca630e55 100644 --- a/spec/workers/expire_build_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe ExpireBuildArtifactsWorker do include RepoHelpers - let(:worker) { ExpireBuildArtifactsWorker.new } + let(:worker) { described_class.new } describe '#perform' do context 'with expired artifacts' do @@ -11,9 +11,10 @@ describe ExpireBuildArtifactsWorker do it do expect_any_instance_of(Ci::Build).to receive(:erase_artifacts!) + worker.perform - build.reload - expect(build.artifacts_expired?).to be_truthy + + expect(build.reload.artifacts_expired?).to be_truthy end end @@ -22,9 +23,10 @@ describe ExpireBuildArtifactsWorker do it do expect_any_instance_of(Ci::Build).not_to receive(:erase_artifacts!) + worker.perform - build.reload - expect(build.artifacts_expired?).to be_falsey + + expect(build.reload.artifacts_expired?).to be_falsey end end @@ -33,6 +35,7 @@ describe ExpireBuildArtifactsWorker do it do expect_any_instance_of(Ci::Build).not_to receive(:erase_artifacts!) + worker.perform end end @@ -47,9 +50,10 @@ describe ExpireBuildArtifactsWorker do it do expect_any_instance_of(Ci::Build).not_to receive(:erase_artifacts!) + worker.perform - build.reload - expect(build.artifacts_expired?).to be_truthy + + expect(build.reload.artifacts_expired?).to be_truthy end end end From cf9c5b54c68218281ac066cac5d3c002fb72153a Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Fri, 10 Jun 2016 21:53:57 +0200 Subject: [PATCH 125/318] Added documentation to artifacts expire --- config/initializers/1_settings.rb | 2 +- doc/ci/yaml/README.md | 35 +++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index a7320c3a0a7..916fd33e767 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -280,7 +280,7 @@ Settings.cron_jobs['stuck_ci_builds_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['stuck_ci_builds_worker']['cron'] ||= '0 0 * * *' Settings.cron_jobs['stuck_ci_builds_worker']['job_class'] = 'StuckCiBuildsWorker' Settings.cron_jobs['expire_build_artifacts_worker'] ||= Settingslogic.new({}) -Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '0 0 * * *' +Settings.cron_jobs['expire_build_artifacts_worker']['cron'] ||= '50 * * * *' Settings.cron_jobs['expire_build_artifacts_worker']['job_class'] = 'ExpireBuildArtifactsWorker' Settings.cron_jobs['repository_check_worker'] ||= Settingslogic.new({}) Settings.cron_jobs['repository_check_worker']['cron'] ||= '20 * * * *' diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 0707555e393..d71ce6d6b13 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -31,6 +31,7 @@ If you want a quick introduction to GitLab CI, follow our - [artifacts](#artifacts) - [artifacts:name](#artifacts-name) - [artifacts:when](#artifacts-when) + - [artifacts:expire_in](#artifacts-expire_in) - [dependencies](#dependencies) - [before_script and after_script](#before_script-and-after_script) - [Hidden jobs](#hidden-jobs) @@ -678,6 +679,40 @@ job: when: on_failure ``` +#### artifacts:expire_in + +>**Note:** +Introduced in GitLab 8.9 and GitLab Runner v1.3.0. + +`artifacts:expire_in` is used to remove uploaded artifacts after specified time. +By default artifacts are stored on GitLab forver. +`expire_in` allows to specify after what time the artifacts should be removed. +The artifacts will expire counting from the moment when they are uploaded and stored on GitLab. + +After artifacts uploading you can use the **Keep** button on build page to keep the artifacts forever. + +Artifacts are removed every hour, but they are not accessible after expire date. + +The value of `expire_in` is a elapsed time. The example of parsable values: +- '3 mins 4 sec' +- '2 hrs 20 min' +- '2h20min' +- '6 mos 1 day' +- '47 yrs 6 mos and 4d' +- '3 weeks and 2 days' + +--- + +**Example configurations** + +To expire artifacts after 1 week from the moment that they are uploaded: + +```yaml +job: + artifacts: + expire_in: 1 week +``` + ### dependencies >**Note:** From b0b1b85d7197b211c472779c07410de70b39e548 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Mon, 13 Jun 2016 11:12:38 +0100 Subject: [PATCH 126/318] Fixed spacing with row below in build sidebar --- app/views/projects/builds/_sidebar.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index af69490019d..7127acf388b 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -37,7 +37,7 @@ = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do Browse - .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && @build.artifacts?)) } + .block{ class: ("block-first" if !@build.coverage && !(can?(current_user, :read_build, @project) && (@build.artifacts? || @build.artifacts_expired?))) } .title Build details - if @build.retryable? From 22e97dd702182aa51586972bb54861ee8b19846b Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 8 Jun 2016 17:04:41 +0100 Subject: [PATCH 127/318] Fixed issue with MR buttons being in a group Also removed some inline code --- app/assets/javascripts/dispatcher.js.coffee | 4 ++ .../javascripts/merged_buttons.js.coffee | 30 +++++++++ .../stylesheets/pages/merge_requests.scss | 10 +++ app/helpers/commits_helper.rb | 6 +- .../merge_requests/widget/_merged.html.haml | 61 +++++++------------ .../widget/_merged_buttons.haml | 4 +- 6 files changed, 71 insertions(+), 44 deletions(-) create mode 100644 app/assets/javascripts/merged_buttons.js.coffee diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 29ac0f70b30..8b39e6b090c 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -53,9 +53,13 @@ class Dispatcher new Diff() shortcut_handler = new ShortcutsIssuable(true) new ZenMode() + new MergedButtons() + when 'projects:merge_requests:commits', 'projects:merge_requests:builds' + new MergedButtons() when "projects:merge_requests:diffs" new Diff() new ZenMode() + new MergedButtons() when 'projects:merge_requests:index' shortcut_handler = new ShortcutsNavigation() Issuable.init() diff --git a/app/assets/javascripts/merged_buttons.js.coffee b/app/assets/javascripts/merged_buttons.js.coffee new file mode 100644 index 00000000000..4929295c10b --- /dev/null +++ b/app/assets/javascripts/merged_buttons.js.coffee @@ -0,0 +1,30 @@ +class @MergedButtons + constructor: -> + @$removeBranchWidget = $('.remove_source_branch_widget') + @$removeBranchProgress = $('.remove_source_branch_in_progress') + @$removeBranchFailed = $('.remove_source_branch_widget.failed') + + @cleanEventListeners() + @initEventListeners() + + cleanEventListeners: -> + $(document).off 'click', '.remove_source_branch' + $(document).off 'ajax:success', '.remove_source_branch' + $(document).off 'ajax:error', '.remove_source_branch' + + initEventListeners: -> + $(document).on 'click', '.remove_source_branch', @removeSourceBranch + $(document).on 'ajax:success', '.remove_source_branch', @removeBranchSuccess + $(document).on 'ajax:error', '.remove_source_branch', @removeBranchError + + removeSourceBranch: => + @$removeBranchWidget.hide() + @$removeBranchProgress.show() + + removeBranchSuccess: -> + location.reload() + + removeBranchError: -> + @$removeBranchWidget.hide() + @$removeBranchProgress.hide() + @$removeBranchFailed.show() diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index a47f2580aa3..53bff508c72 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -313,3 +313,13 @@ } } } + +.merged-buttons { + .btn { + float: left; + + &:not(:last-child) { + margin-right: 10px; + } + } +} diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index d328f56c80c..493505e0c95 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -129,7 +129,7 @@ module CommitsHelper tooltip = "Revert this #{commit.change_type_title} in a new merge request" if has_tooltip if can_collaborate_with_project? - btn_class = "btn btn-grouped btn-close btn-#{btn_class}" unless btn_class.nil? + btn_class = "btn btn-warning btn-#{btn_class}" unless btn_class.nil? link_to 'Revert', '#modal-revert-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}" elsif can?(current_user, :fork_project, @project) continue_params = { @@ -141,7 +141,7 @@ module CommitsHelper namespace_key: current_user.namespace.id, continue: continue_params) - btn_class = "btn btn-grouped btn-close" unless btn_class.nil? + btn_class = "btn btn-grouped btn-warning" unless btn_class.nil? link_to 'Revert', fork_path, class: btn_class, method: :post, 'data-toggle' => 'tooltip', 'data-container' => 'body', title: (tooltip if has_tooltip) end @@ -153,7 +153,7 @@ module CommitsHelper tooltip = "Cherry-pick this #{commit.change_type_title} in a new merge request" if can_collaborate_with_project? - btn_class = "btn btn-default btn-grouped btn-#{btn_class}" unless btn_class.nil? + btn_class = "btn btn-default btn-#{btn_class}" unless btn_class.nil? link_to 'Cherry-pick', '#modal-cherry-pick-commit', 'data-toggle' => 'modal', 'data-container' => 'body', title: (tooltip if has_tooltip), class: "#{btn_class} #{'has-tooltip' if has_tooltip}" elsif can?(current_user, :fork_project, @project) continue_params = { diff --git a/app/views/projects/merge_requests/widget/_merged.html.haml b/app/views/projects/merge_requests/widget/_merged.html.haml index ec4beae9727..19b5d0ff066 100644 --- a/app/views/projects/merge_requests/widget/_merged.html.haml +++ b/app/views/projects/merge_requests/widget/_merged.html.haml @@ -6,46 +6,29 @@ - if @merge_request.merge_event by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)} #{time_ago_with_tooltip(@merge_request.merge_event.created_at)} - %div - - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true') + - if !@merge_request.source_branch_exists? || (params[:delete_source] == 'true') + %p + The changes were merged into + #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. + The source branch has been removed. + = render 'projects/merge_requests/widget/merged_buttons' + - elsif @merge_request.can_remove_source_branch?(current_user) + .remove_source_branch_widget %p The changes were merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - The source branch has been removed. - = render 'projects/merge_requests/widget/merged_buttons' - - elsif @merge_request.can_remove_source_branch?(current_user) - .remove_source_branch_widget - %p - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - You can remove the source branch now. - = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true - .remove_source_branch_widget.failed.hide - %p - Failed to remove source branch '#{@merge_request.source_branch}'. - - .remove_source_branch_in_progress.hide - %p - = icon('spinner spin') - Removing source branch '#{@merge_request.source_branch}'. Please wait, this page will be automatically reloaded. - - :javascript - $('.remove_source_branch').on('click', function() { - $('.remove_source_branch_widget').hide(); - $('.remove_source_branch_in_progress').show(); - }); - - $(".remove_source_branch").on("ajax:success", function (e, data, status, xhr) { - location.reload(); - }); - - $(".remove_source_branch").on("ajax:error", function (e, data, status, xhr) { - $('.remove_source_branch_widget').hide(); - $('.remove_source_branch_in_progress').hide(); - $('.remove_source_branch_widget.failed').show(); - }); - - else + You can remove the source branch now. + = render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true + .remove_source_branch_widget.failed.hide %p - The changes were merged into - #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. - = render 'projects/merge_requests/widget/merged_buttons' + Failed to remove source branch '#{@merge_request.source_branch}'. + + .remove_source_branch_in_progress.hide + %p + = icon('spinner spin') + Removing source branch '#{@merge_request.source_branch}'. Please wait, this page will be automatically reloaded. + - else + %p + The changes were merged into + #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}. + = render 'projects/merge_requests/widget/merged_buttons' diff --git a/app/views/projects/merge_requests/widget/_merged_buttons.haml b/app/views/projects/merge_requests/widget/_merged_buttons.haml index 56167509af9..d836a253507 100644 --- a/app/views/projects/merge_requests/widget/_merged_buttons.haml +++ b/app/views/projects/merge_requests/widget/_merged_buttons.haml @@ -3,9 +3,9 @@ - mr_can_be_cherry_picked = @merge_request.can_be_cherry_picked? - if can_remove_source_branch || mr_can_be_reverted || mr_can_be_cherry_picked - .btn-group + .clearfix.merged-buttons - if can_remove_source_branch - = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default btn-grouped btn-sm remove_source_branch" do + = link_to namespace_project_branch_path(@merge_request.source_project.namespace, @merge_request.source_project, @merge_request.source_branch), remote: true, method: :delete, class: "btn btn-default btn-sm remove_source_branch" do = icon('trash-o') Remove Source Branch - if mr_can_be_reverted From 35e9fc98655e72ea67f4e04015d255fe7f242717 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis <axilleas@axilleas.me> Date: Mon, 13 Jun 2016 13:52:20 +0200 Subject: [PATCH 128/318] Move logs/logs.md to administration/logs.md [ci skip] --- doc/administration/logs.md | 137 +++++++++++++++++++++++++++++++++++++ doc/logs/logs.md | 93 +------------------------ 2 files changed, 138 insertions(+), 92 deletions(-) create mode 100644 doc/administration/logs.md diff --git a/doc/administration/logs.md b/doc/administration/logs.md new file mode 100644 index 00000000000..737b39db16c --- /dev/null +++ b/doc/administration/logs.md @@ -0,0 +1,137 @@ +## Log system + +GitLab has an advanced log system where everything is logged so that you +can analyze your instance using various system log files. In addition to +system log files, GitLab Enterprise Edition comes with Audit Events. +Find more about them [in Audit Events +documentation](http://docs.gitlab.com/ee/administration/audit_events.html) + +System log files are typically plain text in a standard log file format. +This guide talks about how to read and use these system log files. + +### production.log + +This file lives in `/var/log/gitlab/gitlab-rails/production.log` for +omnibus package or in `/home/git/gitlab/log/production.log` for +installations from source. + +It contains information about all performed requests. You can see the +URL and type of request, IP address and what exactly parts of code were +involved to service this particular request. Also you can see all SQL +request that have been performed and how much time it took. This task is +more useful for GitLab contributors and developers. Use part of this log +file when you are going to report bug. For example: + +``` +Started GET "/gitlabhq/yaml_db/tree/master" for 168.111.56.1 at 2015-02-12 19:34:53 +0200 +Processing by Projects::TreeController#show as HTML + Parameters: {"project_id"=>"gitlabhq/yaml_db", "id"=>"master"} + + ... [CUT OUT] + + Namespaces"."created_at" DESC, "namespaces"."id" DESC LIMIT 1 [["id", 26]] + CACHE (0.0ms) SELECT "members".* FROM "members" WHERE "members"."source_type" = 'Project' AND "members"."type" IN ('ProjectMember') AND "members"."source_id" = $1 AND "members"."source_type" = $2 AND "members"."user_id" = 1 ORDER BY "members"."created_at" DESC, "members"."id" DESC LIMIT 1 [["source_id", 18], ["source_type", "Project"]] + CACHE (0.0ms) SELECT "members".* FROM "members" WHERE "members"."source_type" = 'Project' AND "members". + (1.4ms) SELECT COUNT(*) FROM "merge_requests" WHERE "merge_requests"."target_project_id" = $1 AND ("merge_requests"."state" IN ('opened','reopened')) [["target_project_id", 18]] + Rendered layouts/nav/_project.html.haml (28.0ms) + Rendered layouts/_collapse_button.html.haml (0.2ms) + Rendered layouts/_flash.html.haml (0.1ms) + Rendered layouts/_page.html.haml (32.9ms) +Completed 200 OK in 166ms (Views: 117.4ms | ActiveRecord: 27.2ms) +``` + +In this example we can see that server processed an HTTP request with URL +`/gitlabhq/yaml_db/tree/master` from IP 168.111.56.1 at 2015-02-12 +19:34:53 +0200. Also we can see that request was processed by +`Projects::TreeController`. + +### application.log + +This file lives in `/var/log/gitlab/gitlab-rails/application.log` for +omnibus package or in `/home/git/gitlab/log/application.log` for +installations from source. + +It helps you discover events happening in your instance such as user creation, +project removing and so on. For example: + +``` +October 06, 2014 11:56: User "Administrator" (admin@example.com) was created +October 06, 2014 11:56: Documentcloud created a new project "Documentcloud / Underscore" +October 06, 2014 11:56: Gitlab Org created a new project "Gitlab Org / Gitlab Ce" +October 07, 2014 11:25: User "Claudie Hodkiewicz" (nasir_stehr@olson.co.uk) was removed +October 07, 2014 11:25: Project "project133" was removed +``` + +### githost.log + +This file lives in `/var/log/gitlab/gitlab-rails/githost.log` for +omnibus package or in `/home/git/gitlab/log/githost.log` for +installations from source. + +GitLab has to interact with Git repositories but in some rare cases +something can go wrong and in this case you will know what exactly +happened. This log file contains all failed requests from GitLab to Git +repositories. In the majority of cases this file will be useful for developers +only. For example: + +``` +December 03, 2014 13:20 -> ERROR -> Command failed [1]: /usr/bin/git --git-dir=/Users/vsizov/gitlab-development-kit/gitlab/tmp/tests/gitlab-satellites/group184/gitlabhq/.git --work-tree=/Users/vsizov/gitlab-development-kit/gitlab/tmp/tests/gitlab-satellites/group184/gitlabhq merge --no-ff -mMerge branch 'feature_conflict' into 'feature' source/feature_conflict + +error: failed to push some refs to '/Users/vsizov/gitlab-development-kit/repositories/gitlabhq/gitlab_git.git' +``` + +### sidekiq.log + +This file lives in `/var/log/gitlab/gitlab-rails/sidekiq.log` for +omnibus package or in `/home/git/gitlab/log/sidekiq.log` for +installations from source. + +GitLab uses background jobs for processing tasks which can take a long +time. All information about processing these jobs are written down to +this file. For example: + +``` +2014-06-10T07:55:20Z 2037 TID-tm504 ERROR: /opt/bitnami/apps/discourse/htdocs/vendor/bundle/ruby/1.9.1/gems/redis-3.0.7/lib/redis/client.rb:228:in `read' +2014-06-10T18:18:26Z 14299 TID-55uqo INFO: Booting Sidekiq 3.0.0 with redis options {:url=>"redis://localhost:6379/0", :namespace=>"sidekiq"} +``` + +### gitlab-shell.log + +This file lives in `/var/log/gitlab/gitlab-shell/gitlab-shell.log` for +omnibus package or in `/home/git/gitlab-shell/gitlab-shell.log` for +installations from source. + +GitLab shell is used by Gitlab for executing Git commands and provide +SSH access to Git repositories. For example: + +``` +I, [2015-02-13T06:17:00.671315 #9291] INFO -- : Adding project root/example.git at </var/opt/gitlab/git-data/repositories/root/dcdcdcdcd.git>. +I, [2015-02-13T06:17:00.679433 #9291] INFO -- : Moving existing hooks directory and symlinking global hooks directory for /var/opt/gitlab/git-data/repositories/root/example.git. +``` + +### unicorn\_stderr.log + +This file lives in `/var/log/gitlab/unicorn/unicorn_stderr.log` for +omnibus package or in `/home/git/gitlab/log/unicorn_stderr.log` for +installations from source. + +Unicorn is a high-performance forking Web server which is used for +serving the GitLab application. You can look at this log if, for +example, your application does not respond. This log contains all +information about the state of unicorn processes at any given time. + +``` +I, [2015-02-13T06:14:46.680381 #9047] INFO -- : Refreshing Gem list +I, [2015-02-13T06:14:56.931002 #9047] INFO -- : listening on addr=127.0.0.1:8080 fd=12 +I, [2015-02-13T06:14:56.931381 #9047] INFO -- : listening on addr=/var/opt/gitlab/gitlab-rails/sockets/gitlab.socket fd=13 +I, [2015-02-13T06:14:56.936638 #9047] INFO -- : master process ready +I, [2015-02-13T06:14:56.946504 #9092] INFO -- : worker=0 spawned pid=9092 +I, [2015-02-13T06:14:56.946943 #9092] INFO -- : worker=0 ready +I, [2015-02-13T06:14:56.947892 #9094] INFO -- : worker=1 spawned pid=9094 +I, [2015-02-13T06:14:56.948181 #9094] INFO -- : worker=1 ready +W, [2015-02-13T07:16:01.312916 #9094] WARN -- : #<Unicorn::HttpServer:0x0000000208f618>: worker (pid: 9094) exceeds memory limit (320626688 bytes > 247066940 bytes) +W, [2015-02-13T07:16:01.313000 #9094] WARN -- : Unicorn::WorkerKiller send SIGQUIT (pid: 9094) alive: 3621 sec (trial 1) +I, [2015-02-13T07:16:01.530733 #9047] INFO -- : reaped #<Process::Status: pid 9094 exit 0> worker=1 +I, [2015-02-13T07:16:01.534501 #13379] INFO -- : worker=1 spawned pid=13379 +I, [2015-02-13T07:16:01.534848 #13379] INFO -- : worker=1 ready +``` diff --git a/doc/logs/logs.md b/doc/logs/logs.md index f84060b8d07..a2eca62d691 100644 --- a/doc/logs/logs.md +++ b/doc/logs/logs.md @@ -1,92 +1 @@ -## Log system -GitLab has an advanced log system where everything is logged so that you can analyze your instance using various system log files. -In addition to system log files, GitLab Enterprise Edition comes with Audit Events. Find more about them [in Audit Events documentation](http://docs.gitlab.com/ee/administration/audit_events.html) - -System log files are typically plain text in a standard log file format. This guide talks about how to read and use these system log files. - -#### production.log -This file lives in `/var/log/gitlab/gitlab-rails/production.log` for omnibus package or in `/home/git/gitlab/log/production.log` for installations from the source. - -This file contains information about all performed requests. You can see url and type of request, IP address and what exactly parts of code were involved to service this particular request. Also you can see all SQL request that have been performed and how much time it took. -This task is more useful for GitLab contributors and developers. Use part of this log file when you are going to report bug. - -``` -Started GET "/gitlabhq/yaml_db/tree/master" for 168.111.56.1 at 2015-02-12 19:34:53 +0200 -Processing by Projects::TreeController#show as HTML - Parameters: {"project_id"=>"gitlabhq/yaml_db", "id"=>"master"} - - ... [CUT OUT] - - amespaces"."created_at" DESC, "namespaces"."id" DESC LIMIT 1 [["id", 26]] - CACHE (0.0ms) SELECT "members".* FROM "members" WHERE "members"."source_type" = 'Project' AND "members"."type" IN ('ProjectMember') AND "members"."source_id" = $1 AND "members"."source_type" = $2 AND "members"."user_id" = 1 ORDER BY "members"."created_at" DESC, "members"."id" DESC LIMIT 1 [["source_id", 18], ["source_type", "Project"]] - CACHE (0.0ms) SELECT "members".* FROM "members" WHERE "members"."source_type" = 'Project' AND "members". -  (1.4ms) SELECT COUNT(*) FROM "merge_requests" WHERE "merge_requests"."target_project_id" = $1 AND ("merge_requests"."state" IN ('opened','reopened')) [["target_project_id", 18]] - Rendered layouts/nav/_project.html.haml (28.0ms) - Rendered layouts/_collapse_button.html.haml (0.2ms) - Rendered layouts/_flash.html.haml (0.1ms) - Rendered layouts/_page.html.haml (32.9ms) -Completed 200 OK in 166ms (Views: 117.4ms | ActiveRecord: 27.2ms) -``` -In this example we can see that server processed HTTP request with url `/gitlabhq/yaml_db/tree/master` from IP 168.111.56.1 at 2015-02-12 19:34:53 +0200. Also we can see that request was processed by Projects::TreeController. - -#### application.log -This file lives in `/var/log/gitlab/gitlab-rails/application.log` for omnibus package or in `/home/git/gitlab/log/application.log` for installations from the source. - -This log file helps you discover events happening in your instance such as user creation, project removing and so on. - -``` -October 06, 2014 11:56: User "Administrator" (admin@example.com) was created -October 06, 2014 11:56: Documentcloud created a new project "Documentcloud / Underscore" -October 06, 2014 11:56: Gitlab Org created a new project "Gitlab Org / Gitlab Ce" -October 07, 2014 11:25: User "Claudie Hodkiewicz" (nasir_stehr@olson.co.uk) was removed -October 07, 2014 11:25: Project "project133" was removed -``` -#### githost.log -This file lives in `/var/log/gitlab/gitlab-rails/githost.log` for omnibus package or in `/home/git/gitlab/log/githost.log` for installations from the source. - -The GitLab has to interact with git repositories but in some rare cases something can go wrong and in this case you will know what exactly happened. This log file contains all failed requests from GitLab to git repository. In majority of cases this file will be useful for developers only. -``` -December 03, 2014 13:20 -> ERROR -> Command failed [1]: /usr/bin/git --git-dir=/Users/vsizov/gitlab-development-kit/gitlab/tmp/tests/gitlab-satellites/group184/gitlabhq/.git --work-tree=/Users/vsizov/gitlab-development-kit/gitlab/tmp/tests/gitlab-satellites/group184/gitlabhq merge --no-ff -mMerge branch 'feature_conflict' into 'feature' source/feature_conflict - -error: failed to push some refs to '/Users/vsizov/gitlab-development-kit/repositories/gitlabhq/gitlab_git.git' -``` - -#### sidekiq.log -This file lives in `/var/log/gitlab/gitlab-rails/sidekiq.log` for omnibus package or in `/home/git/gitlab/log/sidekiq.log` for installations from the source. - -GitLab uses background jobs for processing tasks which can take a long time. All information about processing these jobs are writing down to this file. -``` -2014-06-10T07:55:20Z 2037 TID-tm504 ERROR: /opt/bitnami/apps/discourse/htdocs/vendor/bundle/ruby/1.9.1/gems/redis-3.0.7/lib/redis/client.rb:228:in `read' -2014-06-10T18:18:26Z 14299 TID-55uqo INFO: Booting Sidekiq 3.0.0 with redis options {:url=>"redis://localhost:6379/0", :namespace=>"sidekiq"} -``` - -#### gitlab-shell.log -This file lives in `/var/log/gitlab/gitlab-shell/gitlab-shell.log` for omnibus package or in `/home/git/gitlab-shell/gitlab-shell.log` for installations from the source. - -gitlab-shell is using by Gitlab for executing git commands and provide ssh access to git repositories. - -``` -I, [2015-02-13T06:17:00.671315 #9291] INFO -- : Adding project root/example.git at </var/opt/gitlab/git-data/repositories/root/dcdcdcdcd.git>. -I, [2015-02-13T06:17:00.679433 #9291] INFO -- : Moving existing hooks directory and symlinking global hooks directory for /var/opt/gitlab/git-data/repositories/root/example.git. -``` - -#### unicorn_stderr.log -This file lives in `/var/log/gitlab/unicorn/unicorn_stderr.log` for omnibus package or in `/home/git/gitlab/log/unicorn_stderr.log` for installations from the source. - -Unicorn is a high-performance forking Web server which is used for serving the GitLab application. You can look at this log if, for example, your application does not respond. This log contains all information about the state of unicorn processes at any given time. - -``` -I, [2015-02-13T06:14:46.680381 #9047] INFO -- : Refreshing Gem list -I, [2015-02-13T06:14:56.931002 #9047] INFO -- : listening on addr=127.0.0.1:8080 fd=12 -I, [2015-02-13T06:14:56.931381 #9047] INFO -- : listening on addr=/var/opt/gitlab/gitlab-rails/sockets/gitlab.socket fd=13 -I, [2015-02-13T06:14:56.936638 #9047] INFO -- : master process ready -I, [2015-02-13T06:14:56.946504 #9092] INFO -- : worker=0 spawned pid=9092 -I, [2015-02-13T06:14:56.946943 #9092] INFO -- : worker=0 ready -I, [2015-02-13T06:14:56.947892 #9094] INFO -- : worker=1 spawned pid=9094 -I, [2015-02-13T06:14:56.948181 #9094] INFO -- : worker=1 ready -W, [2015-02-13T07:16:01.312916 #9094] WARN -- : #<Unicorn::HttpServer:0x0000000208f618>: worker (pid: 9094) exceeds memory limit (320626688 bytes > 247066940 bytes) -W, [2015-02-13T07:16:01.313000 #9094] WARN -- : Unicorn::WorkerKiller send SIGQUIT (pid: 9094) alive: 3621 sec (trial 1) -I, [2015-02-13T07:16:01.530733 #9047] INFO -- : reaped #<Process::Status: pid 9094 exit 0> worker=1 -I, [2015-02-13T07:16:01.534501 #13379] INFO -- : worker=1 spawned pid=13379 -I, [2015-02-13T07:16:01.534848 #13379] INFO -- : worker=1 ready -``` +This document was moved to [administration/logs.md](../administration/logs.md). From 03d2bf141cde7bb12f88f25bcb08a612e65044c4 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Mon, 13 Jun 2016 13:06:40 +0100 Subject: [PATCH 129/318] Fix description and GFM pipelines conflicting Consider this command: bundle exec rails r "include GitlabMarkdownHelper puts markdown('<span>this is a span</span>', pipeline: :description) puts markdown('<span>this is a span</span>')" And the same in the opposite order: bundle exec rails r "include GitlabMarkdownHelper puts markdown('<span>this is a span</span>') puts markdown('<span>this is a span</span>', pipeline: :description)" Before this change, they would both output: <p><span>this is a span</span></p> <p>this is a span</p> That's because `span` is added to the list of whitelisted elements in the `SanitizationFilter`, but this method tries not to make the same changes multiple times. Unfortunately, `HTML::Pipeline::SanitizationFilter::LIMITED`, which is used by the `DescriptionPipeline`, uses the same Ruby objects for all of its hash values _except_ `:elements`. That means that whichever of `DescriptionPipeline` and `GfmPipeline` is called first would have `span` in its whitelisted elements, and the second wouldn't. Fix this by creating an entirely separate hash, before either pipeline is invoked. --- lib/banzai/pipeline/description_pipeline.rb | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/lib/banzai/pipeline/description_pipeline.rb b/lib/banzai/pipeline/description_pipeline.rb index f2395867658..042fb2e6e14 100644 --- a/lib/banzai/pipeline/description_pipeline.rb +++ b/lib/banzai/pipeline/description_pipeline.rb @@ -1,23 +1,16 @@ module Banzai module Pipeline class DescriptionPipeline < FullPipeline + WHITELIST = Banzai::Filter::SanitizationFilter::LIMITED.deep_dup.merge( + elements: Banzai::Filter::SanitizationFilter::LIMITED[:elements] - %w(pre code img ol ul li) + ) + def self.transform_context(context) super(context).merge( # SanitizationFilter - whitelist: whitelist + whitelist: WHITELIST ) end - - private - - def self.whitelist - # Descriptions are more heavily sanitized, allowing only a few elements. - # See http://git.io/vkuAN - whitelist = Banzai::Filter::SanitizationFilter::LIMITED - whitelist[:elements] -= %w(pre code img ol ul li) - - whitelist - end end end end From e489e9a58534b7f71085048747e12d6223d1cb1e Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis <axilleas@axilleas.me> Date: Mon, 13 Jun 2016 14:19:29 +0200 Subject: [PATCH 130/318] Change logs.md location in README [ci skip] --- doc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index d1345ab2493..5d89d0c9821 100644 --- a/doc/README.md +++ b/doc/README.md @@ -28,7 +28,7 @@ - [Integration](integration/README.md) How to integrate with systems such as JIRA, Redmine, Twitter. - [Issue closing](customization/issue_closing.md) Customize how to close an issue from commit messages. - [Libravatar](customization/libravatar.md) Use Libravatar for user avatars. -- [Log system](logs/logs.md) Log system. +- [Log system](administration/logs.md) Log system. - [Environment Variables](administration/environment_variables.md) to configure GitLab. - [Operations](operations/README.md) Keeping GitLab up and running - [Raketasks](raketasks/README.md) Backups, maintenance, automatic webhook setup and the importing of projects. From d1abbb3b662bd41a3b58eeb60c5c0740adff986c Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis <axilleas@axilleas.me> Date: Mon, 13 Jun 2016 15:00:28 +0200 Subject: [PATCH 131/318] Add guide on changing a document's location [ci skip] --- doc/development/doc_styleguide.md | 42 +++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 8292b393757..c59012bef46 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -141,6 +141,48 @@ Inside the document: [ruby-dl]: https://www.ruby-lang.org/en/downloads/ "Ruby download website" +## Changing document location + +Changing a document's location is not to be taken lightly. Remember that the +documentation is available to all installations under `help/` and not only to +GitLab.com or http://docs.gitlab.com. Make sure this is discussed with the +Documentation team beforehand. + +If you indeed need to change a document's location, do NOT remove the old +document, but rather put a text in it that points to the new location, like: + +``` +This document was moved to [path/to/new_doc.md](path/to/new_doc.md). +``` + +where `path/to/new_doc.md` is the relative path to the root directory `doc/`. + +--- + +For example, if you were to move `doc/workflow/lfs/lfs_administration.md` to +`doc/administration/lfs.md`, then the steps would be: + +1. Copy `doc/workflow/lfs/lfs_administration.md` to `doc/administration/lfs.md` +1. Replace the contents of `doc/workflow/lfs/lfs_administration.md` with: + + ``` + This document was moved to [administration/lfs.md](../../administration/lfs.md). + ``` + +1. Find and replace any occurrences of the old location with the new one. + A quick way to find them is to use `grep`: + + ``` + grep -nR "lfs_administration.md" doc/ + ``` + + The above command will search in the `doc/` directory for + `lfs_administration.md` recursively and will print the file and the line + where this file is mentioned. Note that we used just the filename + (`lfs_administration.md`) and not the whole the relative path + (`workflow/lfs/lfs_administration.md`). + + ## API Here is a list of must-have items. Use them in the exact order that appears From cb7fa10cb593a7288d51e91466ff5be2767c98f0 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis <axilleas@axilleas.me> Date: Mon, 13 Jun 2016 15:04:22 +0200 Subject: [PATCH 132/318] Change to new Notes styleguide [ci skip] --- doc/development/doc_styleguide.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/development/doc_styleguide.md b/doc/development/doc_styleguide.md index 8292b393757..dbaa7eff410 100644 --- a/doc/development/doc_styleguide.md +++ b/doc/development/doc_styleguide.md @@ -103,14 +103,14 @@ Inside the document: - Every piece of documentation that comes with a new feature should declare the GitLab version that feature got introduced. Right below the heading add a - note: `_**Note:** This feature was introduced in GitLab 8.3_` + note: `>**Note:** This feature was introduced in GitLab 8.3` - If possible every feature should have a link to the MR that introduced it. The above note would be then transformed to: - `_**Note:** This feature was [introduced][ce-1242] in GitLab 8.3_`, where + `>**Note:** This feature was [introduced][ce-1242] in GitLab 8.3`, where the [link identifier](#links) is named after the repository (CE) and the MR number - If the feature is only in GitLab EE, don't forget to mention it, like: - `_**Note:** This feature was introduced in GitLab EE 8.3_`. Otherwise, leave + `>**Note:** This feature was introduced in GitLab EE 8.3`. Otherwise, leave this mention out ## References @@ -222,8 +222,8 @@ curl --data "name=foo" -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" "https://gitlab. #### Post data using JSON content -_**Note:** In this example we create a new group. Watch carefully the single -and double quotes._ +> **Note:** In this example we create a new group. Watch carefully the single +and double quotes. ```bash curl -X POST -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" -H "Content-Type: application/json" --data '{"path": "my-group", "name": "My group"}' https://gitlab.example.com/api/v3/groups From f73cf3e937b92d29753e468dac8a17470253c791 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer <jacob@gitlab.com> Date: Mon, 13 Jun 2016 15:38:25 +0200 Subject: [PATCH 133/318] Also rename "find" in the specs --- spec/lib/gitlab/auth_spec.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index f081d550ec8..7bec1367156 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Gitlab::Auth, lib: true do let(:gl_auth) { described_class } - describe 'find' do + describe 'find_for_git_client' do it 'recognizes CI' do token = '123' project = create(:empty_project) @@ -11,7 +11,7 @@ describe Gitlab::Auth, lib: true do ip = 'ip' expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'gitlab-ci-token') - expect(gl_auth.find('gitlab-ci-token', token, project: project, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, :ci)) + expect(gl_auth.find_for_git_client('gitlab-ci-token', token, project: project, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, :ci)) end it 'recognizes master passwords' do @@ -19,7 +19,7 @@ describe Gitlab::Auth, lib: true do ip = 'ip' expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: user.username) - expect(gl_auth.find(user.username, 'password', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, :gitlab_or_ldap)) + expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, :gitlab_or_ldap)) end it 'recognizes OAuth tokens' do @@ -29,7 +29,7 @@ describe Gitlab::Auth, lib: true do ip = 'ip' expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'oauth2') - expect(gl_auth.find("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, :oauth)) + expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, :oauth)) end it 'returns double nil for invalid credentials' do @@ -37,7 +37,7 @@ describe Gitlab::Auth, lib: true do ip = 'ip' expect(gl_auth).to receive(:rate_limit!).with(ip, success: false, login: login) - expect(gl_auth.find(login, 'bar', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new) + expect(gl_auth.find_for_git_client(login, 'bar', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new) end end From 672aec4a2da36d9ee755156be4a907f4b8d96347 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Mon, 13 Jun 2016 15:00:51 +0100 Subject: [PATCH 134/318] Improved views --- .../environments/_environment.html.haml | 4 +-- .../projects/environments/index.html.haml | 27 +++++++------- app/views/projects/environments/new.html.haml | 24 ++++++------- .../projects/environments/show.html.haml | 35 ++++++++++--------- 4 files changed, 44 insertions(+), 46 deletions(-) diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml index 16d04832e1a..5ca57bd153d 100644 --- a/app/views/projects/environments/_environment.html.haml +++ b/app/views/projects/environments/_environment.html.haml @@ -3,7 +3,7 @@ %tr.environment %td %strong - = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment), class: "monospace" + = link_to environment.name, namespace_project_environment_path(@project.namespace, @project, environment) %td - if last_deployment @@ -27,5 +27,3 @@ - if last_deployment %p #{time_ago_with_tooltip(last_deployment.created_at)} - - %td diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 2da8d068e9f..4a445a157ec 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -9,17 +9,16 @@ = link_to new_namespace_project_environment_path(@project.namespace, @project), class: 'btn btn-create' do New environment - %ul.content-list.environments - - if @environments.blank? - %li - .nothing-here-block No environments to show - - else - .table-holder - %table.table - %tbody - %th Environment - %th Last deployment - %th Date - %th - - @environments.each do |environment| - = render 'environment', environment: environment + - if @environments.blank? + %ul.content-list.environments + %li.nothing-here-block + No environments to show + - else + .table-holder + %table.table + %tbody + %th Environment + %th Last deployment + %th Date + - @environments.each do |environment| + = render 'environment', environment: environment diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml index 5e8bc596f1e..c7abac6e49f 100644 --- a/app/views/projects/environments/new.html.haml +++ b/app/views/projects/environments/new.html.haml @@ -1,15 +1,15 @@ - page_title "New Environment" += render "projects/pipelines/head" -%h3.page-title - New Environment -%hr +.row.prepend-top-default.append-bottom-default + .col-lg-3 + %h4.prepend-top-0 + New Environment -= form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { id: "new-environment-form", class: "form-horizontal js-new-environment-form js-requires-input" } do |f| - = form_errors(@environment) - .form-group - = f.label :ref, 'Name', class: 'control-label' - .col-sm-10 - = f.text_field :name, required: true, tabindex: 2, class: 'form-control' - .form-actions - = f.submit 'Create', class: 'btn btn-create', tabindex: 3 - = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel' + = form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { id: "new-environment-form", class: "col-lg-9 js-new-environment-form js-requires-input" } do |f| + = form_errors(@environment) + .form-group + = f.label :ref, 'Environment name', class: 'label-light' + = f.text_field :name, required: true, class: 'form-control' + = f.submit 'Create environment', class: 'btn btn-create' + = link_to "Cancel", namespace_project_environments_path(@project.namespace, @project), class: "btn btn-cancel" diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index dc07ad1a769..f5e30d75b42 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -9,23 +9,24 @@ .col-md-3 .nav-controls - = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post + = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :delete - %ul.content-list - - if @deployments.blank? - %li - .nothing-here-block No deployments for #{@environment.name} - - else - .table-holder - %table.table.builds - %thead - %tr - %th ID - %th Commit - %th Build - %th Date - %th + - if @deployments.blank? + %ul.content-list + %li.nothing-here-block + No deployments for + %strong= @environment.name + - else + .table-holder + %table.table.builds + %thead + %tr + %th ID + %th Commit + %th Build + %th Date + %th - = render @deployments + = render @deployments - = paginate @deployments, theme: 'gitlab' + = paginate @deployments, theme: 'gitlab' From 63900c1dcfa477ab573e7fd57d8b3e5cc2ecf6cf Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Mon, 13 Jun 2016 08:01:46 -0600 Subject: [PATCH 135/318] Pass can_edit and access to partial. --- app/views/layouts/nav/_project.html.haml | 2 +- app/views/layouts/nav/_project_settings.html.haml | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index cc2825932d9..155fd1792ce 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -7,7 +7,7 @@ = icon('cog') = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right - = render 'layouts/nav/project_settings' + = render 'layouts/nav/project_settings', access: access, can_edit: can_edit - if can_edit || access %li.divider - if can_edit diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index d26f89bdf17..13d32bd1354 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -1,5 +1,3 @@ -- access = user_max_access_in_project(current_user.id, @project) -- can_edit = can?(current_user, :admin_project, @project) - if project_nav_tab? :team = nav_link(controller: [:project_members, :teams]) do = link_to namespace_project_project_members_path(@project.namespace, @project), title: 'Members', class: 'team-tab tab' do From c534d2e89ed00ff98c83a197674b5ac66a8aca93 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Mon, 13 Jun 2016 16:05:23 +0200 Subject: [PATCH 136/318] Improve tests --- spec/models/build_spec.rb | 2 +- spec/requests/ci/api/builds_spec.rb | 4 ++-- .../expire_build_artifacts_worker_spec.rb | 24 +++++++------------ 3 files changed, 12 insertions(+), 18 deletions(-) diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index f25b676651e..c07832a4b5f 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -472,7 +472,7 @@ describe Ci::Build, models: true do end it 'when assigning invalid duration' do - expect{ build.artifacts_expire_in = '7 elephants' }.not_to raise_error + expect { build.artifacts_expire_in = '7 elephants' }.not_to raise_error is_expected.to be_nil end diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index 616b41eabe0..7e50bea90d1 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -380,7 +380,7 @@ describe Ci::API::API do context 'with an expire_in given' do let(:expire_in) { '7 days' } - it do + it 'updates when specified' do build.reload expect(response.status).to eq(201) expect(json_response['artifacts_expire_at']).not_to be_empty @@ -391,7 +391,7 @@ describe Ci::API::API do context 'with no expire_in given' do let(:expire_in) { nil } - it do + it 'ignores if not specified' do build.reload expect(response.status).to eq(201) expect(json_response['artifacts_expire_at']).to be_nil diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb index 501ca630e55..8168ad98062 100644 --- a/spec/workers/expire_build_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -6,14 +6,14 @@ describe ExpireBuildArtifactsWorker do let(:worker) { described_class.new } describe '#perform' do + before { build } + + subject! { worker.perform } + context 'with expired artifacts' do let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } - it do - expect_any_instance_of(Ci::Build).to receive(:erase_artifacts!) - - worker.perform - + it 'does expire' do expect(build.reload.artifacts_expired?).to be_truthy end end @@ -21,22 +21,16 @@ describe ExpireBuildArtifactsWorker do context 'with not yet expired artifacts' do let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now + 7.days) } - it do - expect_any_instance_of(Ci::Build).not_to receive(:erase_artifacts!) - - worker.perform - - expect(build.reload.artifacts_expired?).to be_falsey + it 'does not expire' do + expect(build.reload.artifacts_expired?).to be_truthy end end context 'without expire date' do let!(:build) { create(:ci_build, :artifacts) } - it do - expect_any_instance_of(Ci::Build).not_to receive(:erase_artifacts!) - - worker.perform + it 'does not expire' do + expect(build.reload.artifacts_expired?).to be_falsey end end From 29130f37ad15bf5aa4ac2cf62d0ea8249218dcd6 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Mon, 13 Jun 2016 15:58:09 +0100 Subject: [PATCH 137/318] Hides the fade right unless required --- app/assets/javascripts/layout_nav.js.coffee | 42 ++++++++++++++------- app/assets/stylesheets/framework/nav.scss | 1 + 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/layout_nav.js.coffee b/app/assets/javascripts/layout_nav.js.coffee index 6adac6dac97..f02292dd4f3 100644 --- a/app/assets/javascripts/layout_nav.js.coffee +++ b/app/assets/javascripts/layout_nav.js.coffee @@ -1,14 +1,30 @@ -class @LayoutNav - $ -> - $('.fade-left').addClass('end-scroll') - $('.scrolling-tabs').on 'scroll', (event) -> - $this = $(this) - $el = $(event.target) - currentPosition = $this.scrollLeft() - size = bp.getBreakpointSize() - controlBtnWidth = $('.controls').width() - maxPosition = $this.get(0).scrollWidth - $this.parent().width() - maxPosition += controlBtnWidth if size isnt 'xs' and $('.nav-control').length +hideEndFade = ($scrollingTabs) -> + $scrollingTabs.each -> + $this = $(@) - $el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) - $el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) + $this + .find('.fade-right') + .toggleClass('end-scroll', $this.width() is $this.prop('scrollWidth')) + +$ -> + $('.fade-left').addClass('end-scroll') + + hideEndFade($('.scrolling-tabs')) + + $(window) + .off 'resize.nav' + .on 'resize.nav', -> + hideEndFade($('.scrolling-tabs')) + + $('.scrolling-tabs').on 'scroll', (event) -> + $this = $(this) + $el = $(event.target) + currentPosition = $this.scrollLeft() + size = bp.getBreakpointSize() + controlBtnWidth = $('.controls').width() + maxPosition = ($this.get(0).scrollWidth - $this.parent().width()) - 1 + # maxPosition += controlBtnWidth if size isnt 'xs' and $('.nav-control').length + console.log maxPosition, currentPosition + + $el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) + $el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 71fd75b61fa..1222dc9047a 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -283,6 +283,7 @@ position: absolute; top: 7px; right: 15px; + z-index: 2; li.active { font-weight: bold; From b63dc993534a567b7aba737db1565e3b56033ba2 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Mon, 13 Jun 2016 19:18:15 +0300 Subject: [PATCH 138/318] Defensive check for the group options. --- app/views/layouts/_search.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 5c6429d07b4..4587cf50653 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -44,7 +44,7 @@ name: "#{@project.name}" }; - - if @group + - if @group and @group.path :javascript gl.groupOptions = gl.groupOptions || {}; gl.groupOptions["#{@group.path}"] = { From 33db51f9154f8421dfdc2e07d04684b1c1f404d9 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Mon, 13 Jun 2016 18:18:24 +0200 Subject: [PATCH 139/318] Improve ExpireBuildArtifactsWorker spec --- spec/workers/expire_build_artifacts_worker_spec.rb | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb index 8168ad98062..eb8afb20275 100644 --- a/spec/workers/expire_build_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -35,18 +35,13 @@ describe ExpireBuildArtifactsWorker do end context 'for expired artifacts' do - let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } + let!(:build) { create(:ci_build, artifacts_expire_at: Time.now - 7.days) } - before do - build.erase_artifacts! - build.save + it 'does not erase artifacts' do + expect_any_instance_of(Ci::Build).not_to have_received(:erase_artifacts!) end - it do - expect_any_instance_of(Ci::Build).not_to receive(:erase_artifacts!) - - worker.perform - + it 'does expire' do expect(build.reload.artifacts_expired?).to be_truthy end end From f9bbc4d485d32a8233894a75eae4945a2807188b Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Mon, 13 Jun 2016 18:23:55 +0200 Subject: [PATCH 140/318] Revert CHANGELOG --- CHANGELOG | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index f0b0bf4a2f7..8f897b4a34c 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -20,7 +20,6 @@ v 8.9.0 (unreleased) - Fix 404 page when viewing TODOs that contain milestones or labels in different projects - Redesign navigation for project pages - Fix groups API to list only user's accessible projects - - Retry spinach tests in case of failure using rerun reporter - Redesign account and email confirmation emails - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 From af33338bbf752ab5f4dba5b981644b399ae3563b Mon Sep 17 00:00:00 2001 From: Alexander Matyushentsev <amatyushentsev@gmail.com> Date: Sun, 15 May 2016 00:22:01 -0700 Subject: [PATCH 141/318] Add more information into RSS fead for issues --- CHANGELOG | 2 ++ app/views/issues/_issue.atom.builder | 20 +++++++++++- spec/features/atom/dashboard_issues_spec.rb | 35 +++++++++++++++------ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7a6a14919da..80b19bbd04e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -123,6 +123,8 @@ v 8.8.1 v 8.8.0 - Implement GFM references for milestones (Alejandro Rodríguez) +v 8.8.0 (unreleased) + - Add more information into RSS fead for issues. - Snippets tab under user profile. !4001 (Long Nguyen) - Fix error when using link to uploads in global snippets - Fix Error 500 when attempting to retrieve project license when HEAD points to non-existent ref diff --git a/app/views/issues/_issue.atom.builder b/app/views/issues/_issue.atom.builder index 68a2d19e58d..96831874144 100644 --- a/app/views/issues/_issue.atom.builder +++ b/app/views/issues/_issue.atom.builder @@ -5,10 +5,28 @@ xml.entry do xml.updated issue.created_at.xmlschema xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(issue.author_email)) - xml.author do |author| + xml.author do xml.name issue.author_name xml.email issue.author_email end xml.summary issue.title + xml.description issue.description if issue.description + xml.milestone issue.milestone.title if issue.milestone + xml.due_date issue.due_date if issue.due_date + + unless issue.labels.empty? + xml.labels do + issue.labels.each do |label| + xml.label label.name + end + end + end + + if issue.assignee + xml.assignee do + xml.name issue.assignee.name + xml.email issue.assignee.email + end + end end diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index b710cb3c72f..87b478adb8f 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -2,15 +2,18 @@ require 'spec_helper' describe "Dashboard Issues Feed", feature: true do describe "GET /issues" do - let!(:user) { create(:user) } - let!(:project1) { create(:project) } - let!(:project2) { create(:project) } - let!(:issue1) { create(:issue, author: user, assignee: user, project: project1) } - let!(:issue2) { create(:issue, author: user, assignee: user, project: project2) } + let!(:user) { create(:user) } + let!(:project1) { create(:project) } + let!(:project2) { create(:project) } + let!(:milestone1) { create(:milestone, project: project1, title: 'v1') } + let!(:label1) { create(:label, project: project1, title: 'label1') } + let!(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone1) } + let!(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'test desc') } before do project1.team << [user, :master] project2.team << [user, :master] + issue1.labels << label1 end describe "atom feed" do @@ -20,10 +23,24 @@ describe "Dashboard Issues Feed", feature: true do expect(response_headers['Content-Type']). to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{user.name} issues") - expect(body).to have_selector('author email', text: issue1.author_email) - expect(body).to have_selector('entry summary', text: issue1.title) - expect(body).to have_selector('author email', text: issue2.author_email) - expect(body).to have_selector('entry summary', text: issue2.title) + + entry_1 = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue1.title}')]") + expect(entry_1).to be_present + + entry_2 = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]") + expect(entry_2).to be_present + + expect(entry_1).to have_selector('author email', text: issue1.author_email) + expect(entry_1).to have_selector('assignee email', text: issue1.author_email) + expect(entry_1).to have_selector('labels label', text: label1.title) + expect(entry_1).to have_selector('milestone', text: milestone1.title) + expect(entry_1).not_to have_selector('description') + + expect(entry_2).to have_selector('author email', text: issue2.author_email) + expect(entry_2).to have_selector('assignee email', text: issue2.author_email) + expect(entry_2).not_to have_selector('labels') + expect(entry_2).not_to have_selector('milestone') + expect(entry_2).to have_selector('description', text: issue1.description) end end end From 5328930e3f7d013791417ba38805cfab9d029dbd Mon Sep 17 00:00:00 2001 From: Alexander Matyushentsev <amatyushentsev@gmail.com> Date: Sat, 11 Jun 2016 20:01:16 -0700 Subject: [PATCH 142/318] Move change description to proper release and fix typo --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 80b19bbd04e..1e611ef481e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,6 +2,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.9.0 (unreleased) - Fix Error 500 when using closes_issues API with an external issue tracker + - Add more information into RSS feed for issues. - Bulk assign/unassign labels to issues. - Ability to prioritize labels !4009 / !3205 (Thijs Wouters) - Fix endless redirections when accessing user OAuth applications when they are disabled @@ -124,7 +125,6 @@ v 8.8.1 v 8.8.0 - Implement GFM references for milestones (Alejandro Rodríguez) v 8.8.0 (unreleased) - - Add more information into RSS fead for issues. - Snippets tab under user profile. !4001 (Long Nguyen) - Fix error when using link to uploads in global snippets - Fix Error 500 when attempting to retrieve project license when HEAD points to non-existent ref From fcbb14f6b79ea4d97078ebae9df9a0fc4bba021e Mon Sep 17 00:00:00 2001 From: Alexander Matyushentsev <amatyushentsev@gmail.com> Date: Sat, 11 Jun 2016 20:03:39 -0700 Subject: [PATCH 143/318] Move issue rendering tests into separate contexts --- spec/features/atom/dashboard_issues_spec.rb | 58 +++++++++++++-------- 1 file changed, 37 insertions(+), 21 deletions(-) diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 87b478adb8f..9a4eb8f9504 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -5,42 +5,58 @@ describe "Dashboard Issues Feed", feature: true do let!(:user) { create(:user) } let!(:project1) { create(:project) } let!(:project2) { create(:project) } - let!(:milestone1) { create(:milestone, project: project1, title: 'v1') } - let!(:label1) { create(:label, project: project1, title: 'label1') } - let!(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone1) } - let!(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'test desc') } before do project1.team << [user, :master] project2.team << [user, :master] - issue1.labels << label1 end describe "atom feed" do it "should render atom feed via private token" do visit issues_dashboard_path(:atom, private_token: user.private_token) - expect(response_headers['Content-Type']). - to have_content('application/atom+xml') + expect(response_headers['Content-Type']).to have_content('application/atom+xml') expect(body).to have_selector('title', text: "#{user.name} issues") + end - entry_1 = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue1.title}')]") - expect(entry_1).to be_present + context "issue with basic fields" do + let!(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'test desc') } - entry_2 = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]") - expect(entry_2).to be_present + it "should render issue fields" do + visit issues_dashboard_path(:atom, private_token: user.private_token) - expect(entry_1).to have_selector('author email', text: issue1.author_email) - expect(entry_1).to have_selector('assignee email', text: issue1.author_email) - expect(entry_1).to have_selector('labels label', text: label1.title) - expect(entry_1).to have_selector('milestone', text: milestone1.title) - expect(entry_1).not_to have_selector('description') + entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]") - expect(entry_2).to have_selector('author email', text: issue2.author_email) - expect(entry_2).to have_selector('assignee email', text: issue2.author_email) - expect(entry_2).not_to have_selector('labels') - expect(entry_2).not_to have_selector('milestone') - expect(entry_2).to have_selector('description', text: issue1.description) + expect(entry).to be_present + expect(entry).to have_selector('author email', text: issue2.author_email) + expect(entry).to have_selector('assignee email', text: issue2.author_email) + expect(entry).not_to have_selector('labels') + expect(entry).not_to have_selector('milestone') + expect(entry).to have_selector('description', text: issue2.description) + end + end + + context "issue with label and milestone" do + let!(:milestone1) { create(:milestone, project: project1, title: 'v1') } + let!(:label1) { create(:label, project: project1, title: 'label1') } + let!(:issue1) { create(:issue, author: user, assignee: user, project: project1, milestone: milestone1) } + + before do + issue1.labels << label1 + end + + it "should render issue label and milestone info" do + visit issues_dashboard_path(:atom, private_token: user.private_token) + + entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue1.title}')]") + + expect(entry).to be_present + expect(entry).to have_selector('author email', text: issue1.author_email) + expect(entry).to have_selector('assignee email', text: issue1.author_email) + expect(entry).to have_selector('labels label', text: label1.title) + expect(entry).to have_selector('milestone', text: milestone1.title) + expect(entry).not_to have_selector('description') + end end end end From e8bf8ec40725a0af21677211f2d73d9b516c184a Mon Sep 17 00:00:00 2001 From: Alexander Matyushentsev <amatyushentsev@gmail.com> Date: Mon, 13 Jun 2016 09:51:55 -0700 Subject: [PATCH 144/318] Apply reviewer notes: update CHANGELOG, adjust code formatting --- CHANGELOG | 3 +-- spec/features/atom/dashboard_issues_spec.rb | 12 ++++++------ 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 1e611ef481e..01c7f3c2ef3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -2,7 +2,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.9.0 (unreleased) - Fix Error 500 when using closes_issues API with an external issue tracker - - Add more information into RSS feed for issues. + - Add more information into RSS feed for issues (Alexander Matyushentsev) - Bulk assign/unassign labels to issues. - Ability to prioritize labels !4009 / !3205 (Thijs Wouters) - Fix endless redirections when accessing user OAuth applications when they are disabled @@ -124,7 +124,6 @@ v 8.8.1 v 8.8.0 - Implement GFM references for milestones (Alejandro Rodríguez) -v 8.8.0 (unreleased) - Snippets tab under user profile. !4001 (Long Nguyen) - Fix error when using link to uploads in global snippets - Fix Error 500 when attempting to retrieve project license when HEAD points to non-existent ref diff --git a/spec/features/atom/dashboard_issues_spec.rb b/spec/features/atom/dashboard_issues_spec.rb index 9a4eb8f9504..4dd9548cfc5 100644 --- a/spec/features/atom/dashboard_issues_spec.rb +++ b/spec/features/atom/dashboard_issues_spec.rb @@ -2,9 +2,9 @@ require 'spec_helper' describe "Dashboard Issues Feed", feature: true do describe "GET /issues" do - let!(:user) { create(:user) } - let!(:project1) { create(:project) } - let!(:project2) { create(:project) } + let!(:user) { create(:user) } + let!(:project1) { create(:project) } + let!(:project2) { create(:project) } before do project1.team << [user, :master] @@ -12,7 +12,7 @@ describe "Dashboard Issues Feed", feature: true do end describe "atom feed" do - it "should render atom feed via private token" do + it "renders atom feed via private token" do visit issues_dashboard_path(:atom, private_token: user.private_token) expect(response_headers['Content-Type']).to have_content('application/atom+xml') @@ -22,7 +22,7 @@ describe "Dashboard Issues Feed", feature: true do context "issue with basic fields" do let!(:issue2) { create(:issue, author: user, assignee: user, project: project2, description: 'test desc') } - it "should render issue fields" do + it "renders issue fields" do visit issues_dashboard_path(:atom, private_token: user.private_token) entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]") @@ -45,7 +45,7 @@ describe "Dashboard Issues Feed", feature: true do issue1.labels << label1 end - it "should render issue label and milestone info" do + it "renders issue label and milestone info" do visit issues_dashboard_path(:atom, private_token: user.private_token) entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue1.title}')]") From f866af766e5ffe461b835e0071f47e9668d4f93d Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" <lbennett@gitlab.com> Date: Mon, 13 Jun 2016 17:52:34 +0100 Subject: [PATCH 145/318] Fixed notes action buttons --- app/assets/stylesheets/pages/notes.scss | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 0c084118753..35d728aec83 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -139,6 +139,12 @@ ul.notes { @media (min-width: $screen-sm-min) { padding-right: 0; } + + @media (max-width: $screen-xs-min) { + .inline { + display: block; + } + } } .note-emoji-button { @@ -258,7 +264,11 @@ ul.notes { position: absolute; right: 0; top: 0; - + + .note-action-button { + margin-left: 10px; + } + @media (min-width: $screen-sm-min) { position: relative; } From 58b4e5531bfaaa385e31aa71dfb2236372733f48 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Mon, 13 Jun 2016 19:00:09 +0200 Subject: [PATCH 146/318] Update db/schema.rb --- db/schema.rb | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index aac327797e7..91b9cb0a98a 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: 20160608155312) do +ActiveRecord::Schema.define(version: 20160610301627) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -144,9 +144,9 @@ ActiveRecord::Schema.define(version: 20160608155312) do t.text "commands" t.integer "job_id" t.string "name" - t.boolean "deploy", default: false + t.boolean "deploy", default: false t.text "options" - t.boolean "allow_failure", default: false, null: false + t.boolean "allow_failure", default: false, null: false t.string "stage" t.integer "trigger_request_id" t.integer "stage_idx" @@ -161,6 +161,7 @@ ActiveRecord::Schema.define(version: 20160608155312) do t.text "artifacts_metadata" t.integer "erased_by_id" t.datetime "erased_at" + t.datetime "artifacts_expire_at" end add_index "ci_builds", ["commit_id", "stage_idx", "created_at"], name: "index_ci_builds_on_commit_id_and_stage_idx_and_created_at", using: :btree @@ -670,8 +671,8 @@ ActiveRecord::Schema.define(version: 20160608155312) do create_table "notification_settings", force: :cascade do |t| t.integer "user_id", null: false - t.integer "source_id", null: false - t.string "source_type", null: false + t.integer "source_id" + t.string "source_type" t.integer "level", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -988,7 +989,6 @@ ActiveRecord::Schema.define(version: 20160608155312) do t.boolean "can_create_team", default: true, null: false t.string "state" t.integer "color_scheme_id", default: 1, null: false - t.integer "notification_level", default: 1, null: false t.datetime "password_expires_at" t.integer "created_by_id" t.datetime "last_credential_check_at" From 278a0e1a0fb094dc2eceec0d82d9a56be81c8046 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Mon, 13 Jun 2016 19:19:11 +0200 Subject: [PATCH 147/318] Fix keep action of build artifacts widget --- app/views/projects/builds/_sidebar.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/builds/_sidebar.html.haml b/app/views/projects/builds/_sidebar.html.haml index 7127acf388b..cab21f0cf19 100644 --- a/app/views/projects/builds/_sidebar.html.haml +++ b/app/views/projects/builds/_sidebar.html.haml @@ -27,7 +27,7 @@ - if @build.artifacts? .btn-group.btn-group-justified{ role: :group } - if @build.artifacts_expire_at - = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do + = link_to keep_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default', method: :post do Keep = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-default' do From a9dd1beea414e4160fe7b25539c1a3fbd6606d10 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Mon, 13 Jun 2016 14:30:40 -0500 Subject: [PATCH 148/318] Remove div between ul and li --- app/assets/stylesheets/framework/nav.scss | 1 + app/views/projects/commits/_head.html.haml | 4 ++-- app/views/projects/issues/_head.html.haml | 4 ++-- app/views/projects/pipelines/_head.html.haml | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 4de89daeb36..43fdfc0e357 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -74,6 +74,7 @@ .container-fluid { background-color: $background-color; + margin-bottom: 0; } li { diff --git a/app/views/projects/commits/_head.html.haml b/app/views/projects/commits/_head.html.haml index a72e8ba73ad..c8aa849c217 100644 --- a/app/views/projects/commits/_head.html.haml +++ b/app/views/projects/commits/_head.html.haml @@ -1,6 +1,6 @@ .scrolling-tabs-container - %ul.nav-links.sub-nav.scrolling-tabs - %div{ class: (container_class) } + .nav-links.sub-nav.scrolling-tabs + %ul{ class: (container_class) } .fade-left = nav_link(controller: %w(tree blob blame edit_tree new_tree find_file)) do = link_to project_files_path(@project) do diff --git a/app/views/projects/issues/_head.html.haml b/app/views/projects/issues/_head.html.haml index 166dae248b6..403adb7426b 100644 --- a/app/views/projects/issues/_head.html.haml +++ b/app/views/projects/issues/_head.html.haml @@ -1,5 +1,5 @@ -%ul.nav-links.sub-nav - %div{ class: (container_class) } +.nav-links.sub-nav + %ul{ class: (container_class) } - if project_nav_tab?(:issues) && !current_controller?(:merge_requests) = nav_link(controller: :issues) do = link_to url_for_project_issues(@project, only_path: true), title: 'Issues' do diff --git a/app/views/projects/pipelines/_head.html.haml b/app/views/projects/pipelines/_head.html.haml index d0ba0d27d7c..fcaf8c0b013 100644 --- a/app/views/projects/pipelines/_head.html.haml +++ b/app/views/projects/pipelines/_head.html.haml @@ -1,5 +1,5 @@ -%ul.nav-links.sub-nav - %div{ class: (container_class) } +.nav-links.sub-nav + %ul{ class: (container_class) } - if project_nav_tab? :pipelines = nav_link(controller: :pipelines) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do From ee2e583500360385c9b3f8d9231233223ab72b42 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Mon, 13 Jun 2016 21:52:41 +0200 Subject: [PATCH 149/318] Fair usage of Shared Runners --- CHANGELOG | 1 + app/services/ci/register_build_service.rb | 25 +++++++++--- .../ci/register_build_service_spec.rb | 40 +++++++++++++++++++ 3 files changed, 60 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7a6a14919da..3c1a55d7771 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -19,6 +19,7 @@ v 8.9.0 (unreleased) - Added descriptions to notification settings dropdown - Improve note validation to prevent errors when creating invalid note via API - Reduce number of fog gem dependencies + - Implement a fair usage of shared runners - Remove project notification settings associated with deleted projects - Fix 404 page when viewing TODOs that contain milestones or labels in different projects - Redesign navigation for project pages diff --git a/app/services/ci/register_build_service.rb b/app/services/ci/register_build_service.rb index 4ff268a6f06..54aceba1c87 100644 --- a/app/services/ci/register_build_service.rb +++ b/app/services/ci/register_build_service.rb @@ -7,15 +7,15 @@ module Ci builds = if current_runner.shared? - # don't run projects which have not enables shared runners - builds.joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }) + # this returns builds that are ordered by number of running builds + # we prefer projects that don't use shared runners at all + builds.joins("JOIN (#{projects_with_builds_for_shared_runners.to_sql}) AS projects ON ci_builds.gl_project_id=projects.gl_project_id"). + order('projects.running_builds ASC', 'ci_builds.id ASC') else - # do run projects which are only assigned to this runner - builds.where(project: current_runner.projects.where(builds_enabled: true)) + # do run projects which are only assigned to this runner (FIFO) + builds.where(project: current_runner.projects.where(builds_enabled: true)).order('created_at ASC') end - builds = builds.order('created_at ASC') - build = builds.find do |build| build.can_be_served?(current_runner) end @@ -35,5 +35,18 @@ module Ci rescue StateMachines::InvalidTransition nil end + + private + + def projects_with_builds_for_shared_runners + Ci::Build.running_or_pending. + joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }). + group(:gl_project_id). + select(:gl_project_id, "count(case when status = 'running' AND runner_id = (#{shared_runners.to_sql}) then 1 end) as running_builds") + end + + def shared_runners + Ci::Runner.shared.select(:id) + end end end diff --git a/spec/services/ci/register_build_service_spec.rb b/spec/services/ci/register_build_service_spec.rb index d91fc574299..fa4c2fddeb8 100644 --- a/spec/services/ci/register_build_service_spec.rb +++ b/spec/services/ci/register_build_service_spec.rb @@ -50,6 +50,46 @@ module Ci project.update(shared_runners_enabled: true) end + context 'for multiple builds' do + let!(:project2) { create :empty_project, shared_runners_enabled: true } + let!(:pipeline2) { create :ci_pipeline, project: project2 } + let!(:project3) { create :empty_project, shared_runners_enabled: true } + let!(:pipeline3) { create :ci_pipeline, project: project3 } + let!(:build1_project1) { pending_build } + let!(:build2_project1) { FactoryGirl.create :ci_build, pipeline: pipeline } + let!(:build3_project1) { FactoryGirl.create :ci_build, pipeline: pipeline } + let!(:build1_project2) { FactoryGirl.create :ci_build, pipeline: pipeline2 } + let!(:build2_project2) { FactoryGirl.create :ci_build, pipeline: pipeline2 } + let!(:build1_project3) { FactoryGirl.create :ci_build, pipeline: pipeline3 } + + it 'prefers projects without builds first' do + # it gets for one build from each of the projects + expect(service.execute(shared_runner)).to eq(build1_project1) + expect(service.execute(shared_runner)).to eq(build1_project2) + expect(service.execute(shared_runner)).to eq(build1_project3) + + # then it gets a second build from each of the projects + expect(service.execute(shared_runner)).to eq(build2_project1) + expect(service.execute(shared_runner)).to eq(build2_project2) + + # in the end the third build + expect(service.execute(shared_runner)).to eq(build3_project1) + end + + it 'equalises number of running builds' do + # after finishing the first build for project 1, get a second build from the same project + expect(service.execute(shared_runner)).to eq(build1_project1) + build1_project1.success + expect(service.execute(shared_runner)).to eq(build2_project1) + + expect(service.execute(shared_runner)).to eq(build1_project2) + build1_project2.success + expect(service.execute(shared_runner)).to eq(build2_project2) + expect(service.execute(shared_runner)).to eq(build1_project3) + expect(service.execute(shared_runner)).to eq(build3_project1) + end + end + context 'shared runner' do let(:build) { service.execute(shared_runner) } From e20aa4581b0d17eae36d9722ee8789af47f57727 Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Mon, 13 Jun 2016 16:23:38 -0400 Subject: [PATCH 150/318] Fix note polling when a window has been hidden `refresh` was called, `refreshing` was set to true, but then because `document.hidden` was true, `getContent` was never called, and `refreshing` never got reset to `false`, which stopped polling entirely until refresh. --- CHANGELOG | 2 ++ app/assets/javascripts/notes.js.coffee | 6 ++++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7a6a14919da..a5e5c5c5c8f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -8,6 +8,8 @@ v 8.9.0 (unreleased) - Allow enabling wiki page events from Webhook management UI - Bump rouge to 1.11.0 - Fix issue with arrow keys not working in search autocomplete dropdown + - Fix an issue where note polling stopped working if a window was in the + background during a refresh. - Make EmailsOnPushWorker use Sidekiq mailers queue - Fix wiki page events' webhook to point to the wiki repository - Don't show tags for revert and cherry-pick operations diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee index ad216910c8d..e2d3241437b 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -115,12 +115,14 @@ class @Notes , @pollingInterval refresh: => - return if @refreshing is true - @refreshing = true if not document.hidden and document.URL.indexOf(@noteable_url) is 0 @getContent() getContent: -> + return if @refreshing + + @refreshing = true + $.ajax url: @notes_url data: "last_fetched_at=" + @last_fetched_at From 747989c115c86b090612f805170beb175fe1672a Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Mon, 13 Jun 2016 17:40:36 -0300 Subject: [PATCH 151/318] =?UTF-8?q?Schema=20doesn=E2=80=99t=20reflect=20th?= =?UTF-8?q?e=20changes=20of=20the=20last=203=20migrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The schema doesn’t reflect the changes of the last 3 migrations: * 20160610140403_remove_notification_setting_not_null_constraints.rb * 20160610201627_migrate_users_notification_level.rb * 20160610301627_remove_notification_level_from_users.rb --- db/schema.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/db/schema.rb b/db/schema.rb index aac327797e7..3c947d62e82 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: 20160608155312) do +ActiveRecord::Schema.define(version: 20160610301627) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -670,8 +670,8 @@ ActiveRecord::Schema.define(version: 20160608155312) do create_table "notification_settings", force: :cascade do |t| t.integer "user_id", null: false - t.integer "source_id", null: false - t.string "source_type", null: false + t.integer "source_id" + t.string "source_type" t.integer "level", default: 0, null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -988,7 +988,6 @@ ActiveRecord::Schema.define(version: 20160608155312) do t.boolean "can_create_team", default: true, null: false t.string "state" t.integer "color_scheme_id", default: 1, null: false - t.integer "notification_level", default: 1, null: false t.datetime "password_expires_at" t.integer "created_by_id" t.datetime "last_credential_check_at" From 6f8626de0609da6c789457153b2b19dc79db2c95 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Tue, 14 Jun 2016 00:07:18 +0300 Subject: [PATCH 152/318] Escape JavaScript in haml template. --- app/views/layouts/_search.html.haml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 4587cf50653..245b9c3b4d4 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -38,19 +38,19 @@ :javascript gl.projectOptions = gl.projectOptions || {}; - gl.projectOptions["#{@project.path}"] = { + gl.projectOptions["#{j(@project.path)}"] = { issuesPath: "#{namespace_project_issues_path(@project.namespace, @project)}", mrPath: "#{namespace_project_merge_requests_path(@project.namespace, @project)}", - name: "#{@project.name}" + name: "#{j(@project.name)}" }; - if @group and @group.path :javascript gl.groupOptions = gl.groupOptions || {}; - gl.groupOptions["#{@group.path}"] = { - name: "#{@group.name}", - issuesPath: "#{issues_group_path(@group.path)}", - mrPath: "#{merge_requests_group_path(@group.path)}" + gl.groupOptions["#{j(@group.path)}"] = { + name: "#{j(@group.name)}", + issuesPath: "#{issues_group_path(j(@group.path))}", + mrPath: "#{merge_requests_group_path(j(@group.path))}" }; From 0568b90c97dcbad3ab100e060fef91e0786aafe8 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Fri, 10 Jun 2016 16:53:20 -0300 Subject: [PATCH 153/318] Remove deprecated issues_tracker and issues_tracker_id from project --- app/models/project.rb | 5 ----- .../project_services/issue_tracker_service.rb | 18 +++------------ ...ed_issues_tracker_columns_from_projects.rb | 6 +++++ db/schema.rb | 2 -- spec/factories/projects.rb | 6 ----- spec/helpers/issues_helper_spec.rb | 16 +++----------- spec/models/project_spec.rb | 22 ------------------- 7 files changed, 12 insertions(+), 63 deletions(-) create mode 100644 db/migrate/20160610194713_remove_deprecated_issues_tracker_columns_from_projects.rb diff --git a/app/models/project.rb b/app/models/project.rb index e2f7ffe493c..dfa99fe0df2 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -146,7 +146,6 @@ class Project < ActiveRecord::Base message: Gitlab::Regex.project_path_regex_message } validates :issues_enabled, :merge_requests_enabled, :wiki_enabled, inclusion: { in: [true, false] } - validates :issues_tracker_id, length: { maximum: 255 }, allow_blank: true validates :namespace, presence: true validates_uniqueness_of :name, scope: :namespace_id validates_uniqueness_of :path, scope: :namespace_id @@ -589,10 +588,6 @@ class Project < ActiveRecord::Base update_column(:has_external_issue_tracker, services.external_issue_trackers.any?) end - def can_have_issues_tracker_id? - self.issues_enabled && !self.default_issues_tracker? - end - def build_missing_services services_templates = Service.where(template: true) diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 6ae9b16d3ce..87ecb3b8b86 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -38,9 +38,9 @@ class IssueTrackerService < Service if enabled_in_gitlab_config self.properties = { title: issues_tracker['title'], - project_url: add_issues_tracker_id(issues_tracker['project_url']), - issues_url: add_issues_tracker_id(issues_tracker['issues_url']), - new_issue_url: add_issues_tracker_id(issues_tracker['new_issue_url']) + project_url: issues_tracker['project_url'], + issues_url: issues_tracker['issues_url'], + new_issue_url: issues_tracker['new_issue_url'] } else self.properties = {} @@ -83,16 +83,4 @@ class IssueTrackerService < Service def issues_tracker Gitlab.config.issues_tracker[to_param] end - - def add_issues_tracker_id(url) - if self.project - id = self.project.issues_tracker_id - - if id - url = url.gsub(":issues_tracker_id", id) - end - end - - url - end end diff --git a/db/migrate/20160610194713_remove_deprecated_issues_tracker_columns_from_projects.rb b/db/migrate/20160610194713_remove_deprecated_issues_tracker_columns_from_projects.rb new file mode 100644 index 00000000000..477b2106dea --- /dev/null +++ b/db/migrate/20160610194713_remove_deprecated_issues_tracker_columns_from_projects.rb @@ -0,0 +1,6 @@ +class RemoveDeprecatedIssuesTrackerColumnsFromProjects < ActiveRecord::Migration + def change + remove_column :projects, :issues_tracker, :string, default: 'gitlab', null: false + remove_column :projects, :issues_tracker_id, :string + end +end diff --git a/db/schema.rb b/db/schema.rb index 3c947d62e82..3dccbbd50ba 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -751,8 +751,6 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.boolean "merge_requests_enabled", default: true, null: false t.boolean "wiki_enabled", default: true, null: false t.integer "namespace_id" - t.string "issues_tracker", default: "gitlab", null: false - t.string "issues_tracker_id" t.boolean "snippets_enabled", default: true, null: false t.datetime "last_activity_at" t.string "import_url" diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index da8d97c9f82..5c8ddbebf0d 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -67,9 +67,6 @@ FactoryGirl.define do 'new_issue_url' => 'http://redmine/projects/project_name_in_redmine/issues/new' } ) - - project.issues_tracker = 'redmine' - project.issues_tracker_id = 'project_name_in_redmine' end end @@ -84,9 +81,6 @@ FactoryGirl.define do 'new_issue_url' => 'http://jira.example/secure/CreateIssue.jspa' } ) - - project.issues_tracker = 'jira' - project.issues_tracker_id = 'project_name_in_jira' end end end diff --git a/spec/helpers/issues_helper_spec.rb b/spec/helpers/issues_helper_spec.rb index eae61a54dfc..831ae7fb69c 100644 --- a/spec/helpers/issues_helper_spec.rb +++ b/spec/helpers/issues_helper_spec.rb @@ -7,10 +7,7 @@ describe IssuesHelper do describe "url_for_project_issues" do let(:project_url) { ext_project.external_issue_tracker.project_url } - let(:ext_expected) do - project_url.gsub(':project_id', ext_project.id.to_s) - .gsub(':issues_tracker_id', ext_project.issues_tracker_id.to_s) - end + let(:ext_expected) { project_url.gsub(':project_id', ext_project.id.to_s) } let(:int_expected) { polymorphic_path([@project.namespace, project]) } it "should return internal path if used internal tracker" do @@ -56,11 +53,7 @@ describe IssuesHelper do describe "url_for_issue" do let(:issues_url) { ext_project.external_issue_tracker.issues_url} - let(:ext_expected) do - issues_url.gsub(':id', issue.iid.to_s) - .gsub(':project_id', ext_project.id.to_s) - .gsub(':issues_tracker_id', ext_project.issues_tracker_id.to_s) - end + let(:ext_expected) { issues_url.gsub(':id', issue.iid.to_s).gsub(':project_id', ext_project.id.to_s) } let(:int_expected) { polymorphic_path([@project.namespace, project, issue]) } it "should return internal path if used internal tracker" do @@ -106,10 +99,7 @@ describe IssuesHelper do describe 'url_for_new_issue' do let(:issues_url) { ext_project.external_issue_tracker.new_issue_url } - let(:ext_expected) do - issues_url.gsub(':project_id', ext_project.id.to_s) - .gsub(':issues_tracker_id', ext_project.issues_tracker_id.to_s) - end + let(:ext_expected) { issues_url.gsub(':project_id', ext_project.id.to_s) } let(:int_expected) { new_namespace_project_issue_path(project.namespace, project) } it "should return internal path if used internal tracker" do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index f3590f72cfe..de8815f5a38 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -53,7 +53,6 @@ describe Project, models: true do it { is_expected.to validate_length_of(:path).is_within(0..255) } it { is_expected.to validate_length_of(:description).is_within(0..2000) } it { is_expected.to validate_presence_of(:creator) } - it { is_expected.to validate_length_of(:issues_tracker_id).is_within(0..255) } it { is_expected.to validate_presence_of(:namespace) } it 'should not allow new projects beyond user limits' do @@ -321,27 +320,6 @@ describe Project, models: true do end end - describe :can_have_issues_tracker_id? do - let(:project) { create(:project) } - let(:ext_project) { create(:redmine_project) } - - it 'should be true for projects with external issues tracker if issues enabled' do - expect(ext_project.can_have_issues_tracker_id?).to be_truthy - end - - it 'should be false for projects with internal issue tracker if issues enabled' do - expect(project.can_have_issues_tracker_id?).to be_falsey - end - - it 'should be always false if issues disabled' do - project.issues_enabled = false - ext_project.issues_enabled = false - - expect(project.can_have_issues_tracker_id?).to be_falsey - expect(ext_project.can_have_issues_tracker_id?).to be_falsey - end - end - describe :open_branches do let(:project) { create(:project) } From 4c716de450ecc2959022c67f99f188fff540c6c4 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Fri, 10 Jun 2016 16:53:48 -0300 Subject: [PATCH 154/318] Update CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 7a6a14919da..3387394de5b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -72,6 +72,7 @@ v 8.9.0 (unreleased) - Cache on the database if a project has an active external issue tracker. - Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav - All classes in the Banzai::ReferenceParser namespace are now instrumented + - Remove deprecated issues_tracker and issues_tracker_id from project model v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From 1685b9dc2eccdabeea2dbe61d4f9fb28d06f9c3c Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 00:14:30 +0200 Subject: [PATCH 155/318] Optimise SQL query --- app/services/ci/register_build_service.rb | 24 +++++++++++------------ 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/app/services/ci/register_build_service.rb b/app/services/ci/register_build_service.rb index 54aceba1c87..9583f6c7c49 100644 --- a/app/services/ci/register_build_service.rb +++ b/app/services/ci/register_build_service.rb @@ -7,10 +7,14 @@ module Ci builds = if current_runner.shared? - # this returns builds that are ordered by number of running builds - # we prefer projects that don't use shared runners at all - builds.joins("JOIN (#{projects_with_builds_for_shared_runners.to_sql}) AS projects ON ci_builds.gl_project_id=projects.gl_project_id"). - order('projects.running_builds ASC', 'ci_builds.id ASC') + builds. + # don't run projects which have not enables shared runners + joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }). + + # this returns builds that are ordered by number of running builds + # we prefer projects that don't use shared runners at all + joins("LEFT JOIN (#{running_builds_for_shared_runners.to_sql}) AS project_builds ON ci_builds.gl_project_id=project_builds.gl_project_id"). + order('COALESCE(project_builds.running_builds, 0) ASC', 'ci_builds.id ASC') else # do run projects which are only assigned to this runner (FIFO) builds.where(project: current_runner.projects.where(builds_enabled: true)).order('created_at ASC') @@ -38,15 +42,9 @@ module Ci private - def projects_with_builds_for_shared_runners - Ci::Build.running_or_pending. - joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }). - group(:gl_project_id). - select(:gl_project_id, "count(case when status = 'running' AND runner_id = (#{shared_runners.to_sql}) then 1 end) as running_builds") - end - - def shared_runners - Ci::Runner.shared.select(:id) + def running_builds_for_shared_runners + Ci::Build.running.where(runner: Ci::Runner.shared). + group(:gl_project_id).select(:gl_project_id, 'count(*) AS running_builds') end end end From af8500f43010f42176b2ec1814f0fe7248258b05 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Mon, 6 Jun 2016 15:56:04 -0300 Subject: [PATCH 156/318] Allow users to create confidential issues in private projects --- app/views/shared/issuable/_form.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 17e2a7e9290..d503026f913 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -35,7 +35,7 @@ .clearfix .error-alert -- if issuable.is_a?(Issue) && !issuable.project.private? +- if issuable.is_a?(Issue) .form-group .col-sm-offset-2.col-sm-10 .checkbox From b56c45675019baaaf47615d51c08d5caa0734ad3 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Mon, 6 Jun 2016 16:13:31 -0300 Subject: [PATCH 157/318] Project members with guest role can't access confidential issues --- app/finders/snippets_finder.rb | 2 +- app/models/ability.rb | 2 +- app/models/issue.rb | 10 +++++++- app/models/note.rb | 2 +- app/models/project_team.rb | 10 ++++++-- app/models/user.rb | 25 +++++++++++++------ app/views/shared/issuable/_form.html.haml | 2 +- .../projects/issues_controller_spec.rb | 19 +++++++++++++- .../lib/banzai/filter/redactor_filter_spec.rb | 12 +++++++++ .../lib/gitlab/project_search_results_spec.rb | 12 +++++++++ spec/lib/gitlab/search_results_spec.rb | 16 ++++++++++++ spec/models/concerns/milestoneish_spec.rb | 14 +++++++++++ spec/models/event_spec.rb | 6 +++++ spec/models/note_spec.rb | 15 ++++++++--- spec/models/project_team_spec.rb | 6 +++++ spec/requests/api/issues_spec.rb | 25 ++++++++++++++++++- spec/requests/api/milestones_spec.rb | 13 ++++++++++ spec/services/notification_service_spec.rb | 11 ++++++++ .../projects/autocomplete_service_spec.rb | 12 +++++++++ spec/services/todo_service_spec.rb | 18 ++++++++++--- 20 files changed, 208 insertions(+), 24 deletions(-) diff --git a/app/finders/snippets_finder.rb b/app/finders/snippets_finder.rb index 01cbf91c658..00ff1611039 100644 --- a/app/finders/snippets_finder.rb +++ b/app/finders/snippets_finder.rb @@ -51,7 +51,7 @@ class SnippetsFinder snippets = project.snippets.fresh if current_user - if project.team.member?(current_user.id) || current_user.admin? + if project.team.member?(current_user) || current_user.admin? snippets else snippets.public_and_internal diff --git a/app/models/ability.rb b/app/models/ability.rb index 44515550d9e..aea946f9224 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -533,7 +533,7 @@ class Ability def filter_confidential_issues_abilities(user, issue, rules) return rules if user.admin? || !issue.confidential? - unless issue.author == user || issue.assignee == user || issue.project.team.member?(user.id) + unless issue.author == user || issue.assignee == user || issue.project.team.member?(user, Gitlab::Access::REPORTER) rules.delete(:admin_issue) rules.delete(:read_issue) rules.delete(:update_issue) diff --git a/app/models/issue.rb b/app/models/issue.rb index 235922710ad..6ecb3535359 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -54,7 +54,15 @@ class Issue < ActiveRecord::Base return where(confidential: false) if user.blank? return all if user.admin? - where('issues.confidential = false OR (issues.confidential = true AND (issues.author_id = :user_id OR issues.assignee_id = :user_id OR issues.project_id IN(:project_ids)))', user_id: user.id, project_ids: user.authorized_projects.select(:id)) + where(' + issues.confidential IS NULL + OR issues.confidential IS FALSE + OR (issues.confidential = TRUE + AND (issues.author_id = :user_id + OR issues.assignee_id = :user_id + OR issues.project_id IN(:project_ids)))', + user_id: user.id, + project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) end def self.reference_prefix diff --git a/app/models/note.rb b/app/models/note.rb index 585d8c4ad84..8ce2b6fa538 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -100,7 +100,7 @@ class Note < ActiveRecord::Base OR issues.assignee_id = :user_id OR issues.project_id IN(:project_ids)))', user_id: as_user.id, - project_ids: as_user.authorized_projects.select(:id)) + project_ids: as_user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) else found_notes.where('issues.confidential IS NULL OR issues.confidential IS FALSE') end diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 70a8bbaba65..e29e854860a 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -131,8 +131,14 @@ class ProjectTeam max_member_access(user.id) == Gitlab::Access::MASTER end - def member?(user_id) - !!find_member(user_id) + def member?(user, min_member_access = nil) + member = !!find_member(user.id) + + if min_member_access + member && max_member_access(user.id) >= min_member_access + else + member + end end def human_max_access(user_id) diff --git a/app/models/user.rb b/app/models/user.rb index 7afbfbf112a..69c1bf4bc3d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -405,8 +405,8 @@ class User < ActiveRecord::Base end # Returns projects user is authorized to access. - def authorized_projects - Project.where("projects.id IN (#{projects_union.to_sql})") + def authorized_projects(min_access_level = nil) + Project.where("projects.id IN (#{projects_union(min_access_level).to_sql})") end def viewable_starred_projects @@ -824,11 +824,22 @@ class User < ActiveRecord::Base private - def projects_union - Gitlab::SQL::Union.new([personal_projects.select(:id), - groups_projects.select(:id), - projects.select(:id), - groups.joins(:shared_projects).select(:project_id)]) + def projects_union(min_access_level = nil) + relations = if min_access_level + scope = { access_level: Gitlab::Access.values.select { |access| access >= min_access_level } } + + [personal_projects.select(:id), + groups_projects.where(members: scope).select(:id), + projects.where(members: scope).select(:id), + groups.joins(:shared_projects).where(members: scope).select(:project_id)] + else + [personal_projects.select(:id), + groups_projects.select(:id), + projects.select(:id), + groups.joins(:shared_projects).select(:project_id)] + end + + Gitlab::SQL::Union.new(relations) end def ci_projects_union diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index d503026f913..c30bdb0ae91 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -41,7 +41,7 @@ .checkbox = f.label :confidential do = f.check_box :confidential - This issue is confidential and should only be visible to team members + This issue is confidential and should only be visible to team members with at least Reporter access. - if can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) - has_due_date = issuable.has_attribute?(:due_date) diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 78be7e3dc35..cbaa3e0b7b2 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -105,6 +105,15 @@ describe Projects::IssuesController do expect(assigns(:issues)).to eq [issue] end + it 'should not list confidential issues for project members with guest role' do + sign_in(member) + project.team << [member, :guest] + + get_issues + + expect(assigns(:issues)).to eq [issue] + end + it 'should list confidential issues for author' do sign_in(author) get_issues @@ -148,7 +157,7 @@ describe Projects::IssuesController do shared_examples_for 'restricted action' do |http_status| it 'returns 404 for guests' do - sign_out :user + sign_out(:user) go(id: unescaped_parameter_value.to_param) expect(response).to have_http_status :not_found @@ -161,6 +170,14 @@ describe Projects::IssuesController do expect(response).to have_http_status :not_found end + it 'returns 404 for project members with guest role' do + sign_in(member) + project.team << [member, :guest] + go(id: unescaped_parameter_value.to_param) + + expect(response).to have_http_status :not_found + end + it "returns #{http_status[:success]} for author" do sign_in(author) go(id: unescaped_parameter_value.to_param) diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb index 697d10bbf70..f181125156b 100644 --- a/spec/lib/banzai/filter/redactor_filter_spec.rb +++ b/spec/lib/banzai/filter/redactor_filter_spec.rb @@ -69,6 +69,18 @@ describe Banzai::Filter::RedactorFilter, lib: true do expect(doc.css('a').length).to eq 0 end + it 'removes references for project members with guest role' do + member = create(:user) + project = create(:empty_project, :public) + project.team << [member, :guest] + issue = create(:issue, :confidential, project: project) + + link = reference_link(project: project.id, issue: issue.id, reference_type: 'issue') + doc = filter(link, current_user: member) + + expect(doc.css('a').length).to eq 0 + end + it 'allows references for author' do author = create(:user) project = create(:empty_project, :public) diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index db0ff95b4f5..270b89972d7 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -43,6 +43,18 @@ describe Gitlab::ProjectSearchResults, lib: true do expect(results.issues_count).to eq 1 end + it 'should not list project confidential issues for project members with guest role' do + project.team << [member, :guest] + + results = described_class.new(member, project, query) + issues = results.objects('issues') + + 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 + end + it 'should list project confidential issues for author' do results = described_class.new(author, project, query) issues = results.objects('issues') diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index f4afe597e8d..1bb444bf34f 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -86,6 +86,22 @@ describe Gitlab::SearchResults do expect(results.issues_count).to eq 1 end + it 'should not list confidential issues for project members with guest role' do + project_1.team << [member, :guest] + project_2.team << [member, :guest] + + results = described_class.new(member, limit_projects, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).not_to include security_issue_1 + expect(issues).not_to include security_issue_2 + 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 + end + it 'should list confidential issues for author' do results = described_class.new(author, limit_projects, query) issues = results.objects('issues') diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb index 47c3be673c5..7e9ab8940cf 100644 --- a/spec/models/concerns/milestoneish_spec.rb +++ b/spec/models/concerns/milestoneish_spec.rb @@ -5,6 +5,7 @@ describe Milestone, 'Milestoneish' do let(:assignee) { create(:user) } let(:non_member) { create(:user) } let(:member) { create(:user) } + let(:guest) { create(:user) } let(:admin) { create(:admin) } let(:project) { create(:project, :public) } let(:milestone) { create(:milestone, project: project) } @@ -21,6 +22,7 @@ describe Milestone, 'Milestoneish' do before do project.team << [member, :developer] + project.team << [guest, :guest] end describe '#closed_items_count' do @@ -28,6 +30,10 @@ describe Milestone, 'Milestoneish' do expect(milestone.closed_items_count(non_member)).to eq 2 end + it 'should not count confidential issues for project members with guest role' do + expect(milestone.closed_items_count(guest)).to eq 2 + end + it 'should count confidential issues for author' do expect(milestone.closed_items_count(author)).to eq 4 end @@ -50,6 +56,10 @@ describe Milestone, 'Milestoneish' do expect(milestone.total_items_count(non_member)).to eq 4 end + it 'should not count confidential issues for project members with guest role' do + expect(milestone.total_items_count(guest)).to eq 4 + end + it 'should count confidential issues for author' do expect(milestone.total_items_count(author)).to eq 7 end @@ -85,6 +95,10 @@ describe Milestone, 'Milestoneish' do expect(milestone.percent_complete(non_member)).to eq 50 end + it 'should not count confidential issues for project members with guest role' do + expect(milestone.percent_complete(guest)).to eq 50 + end + it 'should count confidential issues for author' do expect(milestone.percent_complete(author)).to eq 57 end diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index b0e76fec693..166a1dc4ddb 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -50,6 +50,7 @@ describe Event, models: true do let(:project) { create(:empty_project, :public) } let(:non_member) { create(:user) } let(:member) { create(:user) } + let(:guest) { create(:user) } let(:author) { create(:author) } let(:assignee) { create(:user) } let(:admin) { create(:admin) } @@ -61,6 +62,7 @@ describe Event, models: true do before do project.team << [member, :developer] + project.team << [guest, :guest] end context 'issue event' do @@ -71,6 +73,7 @@ describe Event, models: true do it { expect(event.visible_to_user?(author)).to eq true } it { expect(event.visible_to_user?(assignee)).to eq true } it { expect(event.visible_to_user?(member)).to eq true } + it { expect(event.visible_to_user?(guest)).to eq true } it { expect(event.visible_to_user?(admin)).to eq true } end @@ -81,6 +84,7 @@ describe Event, models: true do it { expect(event.visible_to_user?(author)).to eq true } it { expect(event.visible_to_user?(assignee)).to eq true } it { expect(event.visible_to_user?(member)).to eq true } + it { expect(event.visible_to_user?(guest)).to eq false } it { expect(event.visible_to_user?(admin)).to eq true } end end @@ -93,6 +97,7 @@ describe Event, models: true do it { expect(event.visible_to_user?(author)).to eq true } it { expect(event.visible_to_user?(assignee)).to eq true } it { expect(event.visible_to_user?(member)).to eq true } + it { expect(event.visible_to_user?(guest)).to eq true } it { expect(event.visible_to_user?(admin)).to eq true } end @@ -103,6 +108,7 @@ describe Event, models: true do it { expect(event.visible_to_user?(author)).to eq true } it { expect(event.visible_to_user?(assignee)).to eq true } it { expect(event.visible_to_user?(member)).to eq true } + it { expect(event.visible_to_user?(guest)).to eq false } it { expect(event.visible_to_user?(admin)).to eq true } end end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index f15e96714b2..285ab19cfaf 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -162,16 +162,23 @@ describe Note, models: true do end context "confidential issues" do - let(:user) { create :user } - let(:confidential_issue) { create(:issue, :confidential, author: user) } - let(:confidential_note) { create :note, note: "Random", noteable: confidential_issue, project: confidential_issue.project } + let(:user) { create(:user) } + let(:project) { create(:project) } + let(:confidential_issue) { create(:issue, :confidential, project: project, author: user) } + let(:confidential_note) { create(:note, note: "Random", noteable: confidential_issue, project: confidential_issue.project) } it "returns notes with matching content if user can see the issue" do expect(described_class.search(confidential_note.note, as_user: user)).to eq([confidential_note]) end it "does not return notes with matching content if user can not see the issue" do - user = create :user + user = create(:user) + expect(described_class.search(confidential_note.note, as_user: user)).to be_empty + end + + it "does not return notes with matching content for project members with guest role" do + user = create(:user) + project.team << [user, :guest] expect(described_class.search(confidential_note.note, as_user: user)).to be_empty end diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index bacb17a8883..8bebd6a9447 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -29,6 +29,9 @@ describe ProjectTeam, models: true do it { expect(project.team.master?(nonmember)).to be_falsey } it { expect(project.team.member?(nonmember)).to be_falsey } it { expect(project.team.member?(guest)).to be_truthy } + it { expect(project.team.member?(reporter, Gitlab::Access::REPORTER)).to be_truthy } + it { expect(project.team.member?(guest, Gitlab::Access::REPORTER)).to be_falsey } + it { expect(project.team.member?(nonmember, Gitlab::Access::GUEST)).to be_falsey } end end @@ -64,6 +67,9 @@ describe ProjectTeam, models: true do it { expect(project.team.master?(nonmember)).to be_falsey } it { expect(project.team.member?(nonmember)).to be_falsey } it { expect(project.team.member?(guest)).to be_truthy } + it { expect(project.team.member?(guest, Gitlab::Access::MASTER)).to be_truthy } + it { expect(project.team.member?(reporter, Gitlab::Access::MASTER)).to be_falsey } + it { expect(project.team.member?(nonmember, Gitlab::Access::GUEST)).to be_falsey } end end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index bb926172593..59e557c5b2a 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -5,6 +5,7 @@ describe API::API, api: true do let(:user) { create(:user) } let(:user2) { create(:user) } let(:non_member) { create(:user) } + let(:guest) { create(:user) } let(:author) { create(:author) } let(:assignee) { create(:assignee) } let(:admin) { create(:user, :admin) } @@ -41,7 +42,10 @@ describe API::API, api: true do end let!(:note) { create(:note_on_issue, author: user, project: project, noteable: issue) } - before { project.team << [user, :reporter] } + before do + project.team << [user, :reporter] + project.team << [guest, :guest] + end describe "GET /issues" do context "when unauthenticated" do @@ -144,6 +148,14 @@ describe API::API, api: true do expect(json_response.first['title']).to eq(issue.title) end + it 'should return project issues without confidential issues for project members with guest role' do + get api("#{base_url}/issues", guest) + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(2) + expect(json_response.first['title']).to eq(issue.title) + end + it 'should return project confidential issues for author' do get api("#{base_url}/issues", author) expect(response.status).to eq(200) @@ -278,6 +290,11 @@ describe API::API, api: true do expect(response.status).to eq(404) end + it "should return 404 for project members with guest role" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest) + expect(response.status).to eq(404) + end + it "should return confidential issue for project members" do get api("/projects/#{project.id}/issues/#{confidential_issue.id}", user) expect(response.status).to eq(200) @@ -413,6 +430,12 @@ describe API::API, api: true do expect(response.status).to eq(403) end + it "should return 403 for project members with guest role" do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", guest), + title: 'updated title' + expect(response.status).to eq(403) + end + it "should update a confidential issue for project members" do put api("/projects/#{project.id}/issues/#{confidential_issue.id}", user), title: 'updated title' diff --git a/spec/requests/api/milestones_spec.rb b/spec/requests/api/milestones_spec.rb index 241995041bb..0154d1c62cc 100644 --- a/spec/requests/api/milestones_spec.rb +++ b/spec/requests/api/milestones_spec.rb @@ -146,6 +146,7 @@ describe API::API, api: true do let(:milestone) { create(:milestone, project: public_project) } let(:issue) { create(:issue, project: public_project) } let(:confidential_issue) { create(:issue, confidential: true, project: public_project) } + before do public_project.team << [user, :developer] milestone.issues << issue << confidential_issue @@ -160,6 +161,18 @@ describe API::API, api: true do expect(json_response.map { |issue| issue['id'] }).to include(issue.id, confidential_issue.id) end + it 'does not return confidential issues to team members with guest role' do + member = create(:user) + project.team << [member, :guest] + + get api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", member) + + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.size).to eq(1) + expect(json_response.map { |issue| issue['id'] }).to include(issue.id) + end + it 'does not return confidential issues to regular users' do get api("/projects/#{public_project.id}/milestones/#{milestone.id}/issues", create(:user)) diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index b99e02ba678..e871a103d42 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -132,12 +132,14 @@ describe NotificationService, services: true do let(:assignee) { create(:user) } let(:non_member) { create(:user) } let(:member) { create(:user) } + let(:guest) { create(:user) } let(:admin) { create(:admin) } let(:confidential_issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } let(:note) { create(:note_on_issue, noteable: confidential_issue, project: project, note: "#{author.to_reference} #{assignee.to_reference} #{non_member.to_reference} #{member.to_reference} #{admin.to_reference}") } it 'filters out users that can not read the issue' do project.team << [member, :developer] + project.team << [guest, :guest] expect(SentNotification).to receive(:record).with(confidential_issue, any_args).exactly(4).times @@ -146,6 +148,7 @@ describe NotificationService, services: true do notification.new_note(note) should_not_email(non_member) + should_not_email(guest) should_email(author) should_email(assignee) should_email(member) @@ -322,17 +325,20 @@ describe NotificationService, services: true do let(:assignee) { create(:user) } let(:non_member) { create(:user) } let(:member) { create(:user) } + let(:guest) { create(:user) } let(:admin) { create(:admin) } let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } it "emails subscribers of the issue's labels that can read the issue" do project.team << [member, :developer] + project.team << [guest, :guest] label = create(:label, issues: [confidential_issue]) label.toggle_subscription(non_member) label.toggle_subscription(author) label.toggle_subscription(assignee) label.toggle_subscription(member) + label.toggle_subscription(guest) label.toggle_subscription(admin) ActionMailer::Base.deliveries.clear @@ -341,6 +347,7 @@ describe NotificationService, services: true do should_not_email(non_member) should_not_email(author) + should_not_email(guest) should_email(assignee) should_email(member) should_email(admin) @@ -490,6 +497,7 @@ describe NotificationService, services: true do let(:assignee) { create(:user) } let(:non_member) { create(:user) } let(:member) { create(:user) } + let(:guest) { create(:user) } let(:admin) { create(:admin) } let(:confidential_issue) { create(:issue, :confidential, project: project, title: 'Confidential issue', author: author, assignee: assignee) } let!(:label_1) { create(:label, issues: [confidential_issue]) } @@ -497,11 +505,13 @@ describe NotificationService, services: true do it "emails subscribers of the issue's labels that can read the issue" do project.team << [member, :developer] + project.team << [guest, :guest] label_2.toggle_subscription(non_member) label_2.toggle_subscription(author) label_2.toggle_subscription(assignee) label_2.toggle_subscription(member) + label_2.toggle_subscription(guest) label_2.toggle_subscription(admin) ActionMailer::Base.deliveries.clear @@ -509,6 +519,7 @@ describe NotificationService, services: true do notification.relabeled_issue(confidential_issue, [label_2], @u_disabled) should_not_email(non_member) + should_not_email(guest) should_email(author) should_email(assignee) should_email(member) diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb index 6108c26a78b..0971fec2e9f 100644 --- a/spec/services/projects/autocomplete_service_spec.rb +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -33,6 +33,18 @@ describe Projects::AutocompleteService, services: true do expect(issues.count).to eq 1 end + it 'should not list project confidential issues for project members with guest role' do + project.team << [member, :guest] + + autocomplete = described_class.new(project, non_member) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).not_to include security_issue_1.iid + expect(issues).not_to include security_issue_2.iid + expect(issues.count).to eq 1 + end + it 'should list project confidential issues for author' do autocomplete = described_class.new(project, author) issues = autocomplete.issues.map(&:iid) diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 489c920f19f..549a936b060 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -5,13 +5,15 @@ describe TodoService, services: true do let(:assignee) { create(:user) } let(:non_member) { create(:user) } let(:member) { create(:user) } + let(:guest) { create(:user) } let(:admin) { create(:admin) } let(:john_doe) { create(:user) } let(:project) { create(:project) } - let(:mentions) { [author, assignee, john_doe, member, non_member, admin].map(&:to_reference).join(' ') } + let(:mentions) { [author, assignee, john_doe, member, guest, non_member, admin].map(&:to_reference).join(' ') } let(:service) { described_class.new } before do + project.team << [guest, :guest] project.team << [author, :developer] project.team << [member, :developer] project.team << [john_doe, :developer] @@ -41,18 +43,20 @@ describe TodoService, services: true do service.new_issue(issue, author) should_create_todo(user: member, target: issue, action: Todo::MENTIONED) + should_create_todo(user: guest, target: issue, action: Todo::MENTIONED) should_not_create_todo(user: author, target: issue, action: Todo::MENTIONED) should_not_create_todo(user: john_doe, target: issue, action: Todo::MENTIONED) should_not_create_todo(user: non_member, target: issue, action: Todo::MENTIONED) end - it 'does not create todo for non project members when issue is confidential' do + it 'does not create todo if user can not see the issue when issue is confidential' do service.new_issue(confidential_issue, john_doe) should_create_todo(user: assignee, target: confidential_issue, author: john_doe, action: Todo::ASSIGNED) should_create_todo(user: author, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) should_create_todo(user: member, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) should_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) + should_not_create_todo(user: guest, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) should_not_create_todo(user: john_doe, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) end @@ -81,6 +85,7 @@ describe TodoService, services: true do service.update_issue(issue, author) should_create_todo(user: member, target: issue, action: Todo::MENTIONED) + should_create_todo(user: guest, target: issue, action: Todo::MENTIONED) should_create_todo(user: john_doe, target: issue, action: Todo::MENTIONED) should_not_create_todo(user: author, target: issue, action: Todo::MENTIONED) should_not_create_todo(user: non_member, target: issue, action: Todo::MENTIONED) @@ -92,13 +97,14 @@ describe TodoService, services: true do expect { service.update_issue(issue, author) }.not_to change(member.todos, :count) end - it 'does not create todo for non project members when issue is confidential' do + it 'does not create todo if user can not see the issue when issue is confidential' do service.update_issue(confidential_issue, john_doe) should_create_todo(user: author, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) should_create_todo(user: assignee, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) should_create_todo(user: member, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) should_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) + should_not_create_todo(user: guest, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) should_not_create_todo(user: john_doe, target: confidential_issue, author: john_doe, action: Todo::MENTIONED) end @@ -192,18 +198,20 @@ describe TodoService, services: true do service.new_note(note, john_doe) should_create_todo(user: member, target: issue, author: john_doe, action: Todo::MENTIONED, note: note) + should_create_todo(user: guest, target: issue, author: john_doe, action: Todo::MENTIONED, note: note) should_create_todo(user: author, target: issue, author: john_doe, action: Todo::MENTIONED, note: note) should_not_create_todo(user: john_doe, target: issue, author: john_doe, action: Todo::MENTIONED, note: note) should_not_create_todo(user: non_member, target: issue, author: john_doe, action: Todo::MENTIONED, note: note) end - it 'does not create todo for non project members when leaving a note on a confidential issue' do + it 'does not create todo if user can not see the issue when leaving a note on a confidential issue' do service.new_note(note_on_confidential_issue, john_doe) should_create_todo(user: author, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue) should_create_todo(user: assignee, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue) should_create_todo(user: member, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue) should_create_todo(user: admin, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue) + should_not_create_todo(user: guest, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue) should_not_create_todo(user: john_doe, target: confidential_issue, author: john_doe, action: Todo::MENTIONED, note: note_on_confidential_issue) end @@ -245,6 +253,7 @@ describe TodoService, services: true do service.new_merge_request(mr_assigned, author) should_create_todo(user: member, target: mr_assigned, action: Todo::MENTIONED) + should_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: author, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: john_doe, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: non_member, target: mr_assigned, action: Todo::MENTIONED) @@ -256,6 +265,7 @@ describe TodoService, services: true do service.update_merge_request(mr_assigned, author) should_create_todo(user: member, target: mr_assigned, action: Todo::MENTIONED) + should_create_todo(user: guest, target: mr_assigned, action: Todo::MENTIONED) should_create_todo(user: john_doe, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: author, target: mr_assigned, action: Todo::MENTIONED) should_not_create_todo(user: non_member, target: mr_assigned, action: Todo::MENTIONED) From 149176758393c8d89d996c62c11511b3e86b3f8d Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Fri, 10 Jun 2016 11:26:47 -0300 Subject: [PATCH 158/318] Use Issue.visible_to_user in Notes.search to avoid query duplication --- app/models/issue.rb | 2 +- app/models/note.rb | 19 +++---------------- app/models/user.rb | 21 +++++++++------------ 3 files changed, 13 insertions(+), 29 deletions(-) diff --git a/app/models/issue.rb b/app/models/issue.rb index 6ecb3535359..1bdf9c011b2 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -51,7 +51,7 @@ class Issue < ActiveRecord::Base end def self.visible_to_user(user) - return where(confidential: false) if user.blank? + return where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank? return all if user.admin? where(' diff --git a/app/models/note.rb b/app/models/note.rb index 8ce2b6fa538..58133f1581f 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -88,22 +88,9 @@ class Note < ActiveRecord::Base table = arel_table pattern = "%#{query}%" - found_notes = joins('LEFT JOIN issues ON issues.id = noteable_id'). - where(table[:note].matches(pattern)) - - if as_user - found_notes.where(' - issues.confidential IS NULL - OR issues.confidential IS FALSE - OR (issues.confidential IS TRUE - AND (issues.author_id = :user_id - OR issues.assignee_id = :user_id - OR issues.project_id IN(:project_ids)))', - user_id: as_user.id, - project_ids: as_user.authorized_projects(Gitlab::Access::REPORTER).select(:id)) - else - found_notes.where('issues.confidential IS NULL OR issues.confidential IS FALSE') - end + Note.joins('LEFT JOIN issues ON issues.id = noteable_id'). + where(table[:note].matches(pattern)). + merge(Issue.visible_to_user(as_user)) end end diff --git a/app/models/user.rb b/app/models/user.rb index 69c1bf4bc3d..a5b3c8afe51 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -825,19 +825,16 @@ class User < ActiveRecord::Base private def projects_union(min_access_level = nil) - relations = if min_access_level - scope = { access_level: Gitlab::Access.values.select { |access| access >= min_access_level } } + relations = [personal_projects.select(:id), + groups_projects.select(:id), + projects.select(:id), + groups.joins(:shared_projects).select(:project_id)] - [personal_projects.select(:id), - groups_projects.where(members: scope).select(:id), - projects.where(members: scope).select(:id), - groups.joins(:shared_projects).where(members: scope).select(:project_id)] - else - [personal_projects.select(:id), - groups_projects.select(:id), - projects.select(:id), - groups.joins(:shared_projects).select(:project_id)] - end + + if min_access_level + scope = { access_level: Gitlab::Access.values.select { |access| access >= min_access_level } } + relations = [relations.shift] + relations.map { |relation| relation.where(members: scope) } + end Gitlab::SQL::Union.new(relations) end From 6789d2ebf37d5f0537bea72ba99d3b7711e70728 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 8 Jun 2016 16:09:03 -0300 Subject: [PATCH 159/318] Update CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 3387394de5b..92666e63259 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,6 +73,7 @@ v 8.9.0 (unreleased) - Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav - All classes in the Banzai::ReferenceParser namespace are now instrumented - Remove deprecated issues_tracker and issues_tracker_id from project model + - Allow users to create confidential issues in private projects v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From be04becfb39e853f17c52a25dc2e0c2f97eb9284 Mon Sep 17 00:00:00 2001 From: Arinde Eniola <eniolaarinde1@gmail.com> Date: Mon, 2 May 2016 18:15:00 +0100 Subject: [PATCH 160/318] show number of processed mrs in milestone page --- app/views/shared/milestones/_merge_requests_tab.haml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/shared/milestones/_merge_requests_tab.haml b/app/views/shared/milestones/_merge_requests_tab.haml index c29d8ee6737..9c193f901e2 100644 --- a/app/views/shared/milestones/_merge_requests_tab.haml +++ b/app/views/shared/milestones/_merge_requests_tab.haml @@ -3,10 +3,10 @@ .row.prepend-top-default .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Work in progress (open and unassigned)', issuables: merge_requests.opened.unassigned, id: 'unassigned') + = render 'shared/milestones/issuables', args.merge(title: 'Work in progress (open and unassigned)', issuables: merge_requests.opened.unassigned, id: 'unassigned', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Waiting for merge (open and assigned)', issuables: merge_requests.opened.assigned, id: 'ongoing') + = render 'shared/milestones/issuables', args.merge(title: 'Waiting for merge (open and assigned)', issuables: merge_requests.opened.assigned, id: 'ongoing', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Rejected (closed)', issuables: merge_requests.closed, id: 'closed') + = render 'shared/milestones/issuables', args.merge(title: 'Rejected (closed)', issuables: merge_requests.closed, id: 'closed', show_counter: true) .col-md-3 - = render 'shared/milestones/issuables', args.merge(title: 'Merged', issuables: merge_requests.merged, id: 'merged', primary: true) + = render 'shared/milestones/issuables', args.merge(title: 'Merged', issuables: merge_requests.merged, id: 'merged', primary: true, show_counter: true) From f4f30908a77738b7966ea50b89c1232540fd0ee3 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Tue, 14 Jun 2016 04:09:26 +0300 Subject: [PATCH 161/318] Fix long commit message scroll issue. Fixes #18481. --- app/assets/stylesheets/pages/commits.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index c8c6bbde084..f8edf7d601b 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -93,6 +93,7 @@ li.commit { background: inherit; padding: 0; margin: 0; + white-space: pre-wrap; } a { From 17eb51594d220658686ac25b183b661db0936e6c Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Tue, 7 Jun 2016 21:33:11 -0700 Subject: [PATCH 162/318] Fix some grammar --- doc/container_registry/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/container_registry/README.md b/doc/container_registry/README.md index 4df24ef13cc..a7dac54a1ad 100644 --- a/doc/container_registry/README.md +++ b/doc/container_registry/README.md @@ -82,7 +82,7 @@ Make sure that your GitLab Runner is configured to allow building docker images. You have to check the [Using Docker Build documentation](../../ci/docker/using_docker_build.md). You can use [docker:dind](https://hub.docker.com/_/docker/) to build your images, -and this is how `.gitlab-ci.yml` should look like: +and this is how your `.gitlab-ci.yml` should look: ``` build_image: @@ -98,7 +98,7 @@ and this is how `.gitlab-ci.yml` should look like: You have to use the credentials of the special `gitlab-ci-token` user with its password stored in `$CI_BUILD_TOKEN` in order to push to the Registry connected -to your project. This allows you to automated building and deployment of your +to your project. This allows you to automate building and deployment of your Docker images. ## Limitations From 4209212bb80c8d92955bcc71fa8e6973b44cf59a Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Tue, 7 Jun 2016 21:33:54 -0700 Subject: [PATCH 163/318] Add docker bind-mount as an option --- doc/ci/docker/using_docker_build.md | 78 +++++++++++++++++++++++++---- 1 file changed, 68 insertions(+), 10 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index ca52a483a59..f98b2860e21 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -4,14 +4,14 @@ GitLab CI allows you to use Docker Engine to build and test docker-based project **This also allows to you to use `docker-compose` and other docker-enabled tools.** -This is one of new trends in Continuous Integration/Deployment to: +This is one of the new trends in Continuous Integration/Deployment to: -1. create application image, -1. run test against created image, -1. push image to remote registry, -1. deploy server from pushed image +1. create an application image, +1. run tests against the created image, +1. push image to a remote registry, +1. deploy server from the pushed image -It's also useful in case when your application already has the `Dockerfile` that can be used to create and test image: +It's also useful when your application already has the `Dockerfile` that can be used to create and test an image: ```bash $ docker build -t my-image dockerfiles/ $ docker run my-docker-image /script/to/run/tests @@ -19,10 +19,7 @@ $ docker tag my-image my-registry:5000/my-image $ docker push my-registry:5000/my-image ``` -However, this requires special configuration of GitLab Runner to enable `docker` support during build. -**This requires running GitLab Runner in privileged mode which can be harmful when untrusted code is run.** - -There are two methods to enable the use of `docker build` and `docker run` during build. +However, this requires special configuration of GitLab Runner to enable `docker` support during builds. There are three methods to enable the use of `docker build` and `docker run` during builds. ## 1. Use shell executor @@ -150,5 +147,66 @@ In order to do that follow the steps: An example project using this approach can be found here: https://gitlab.com/gitlab-examples/docker. +## 3. Bind Docker socket + +The third approach is to bind-mount `/var/run/docker.sock` into the container so that docker is available in the context of that image. + +In order to do that follow the steps: + +1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/#installation). + +1. Register GitLab Runner from the command line to use `docker` and `privileged` + mode: + + ```bash + sudo gitlab-runner register -n \ + --url https://gitlab.com/ci \ + --token RUNNER_TOKEN \ + --executor docker \ + --description "My Docker Runner" \ + --docker-image "docker:latest" \ + --docker-volumes /var/run/docker.sock:/var/run/docker.sock + ``` + + The above command will register a new Runner to use the special + `docker:latest` image which is provided by Docker. **Notice that it's using + the Docker daemon of the runner itself, and any containers spawned by docker commands will be siblings of the runner rather than children of the runner.** This may have complications and limitations that are unsuitable for your workflow. + + The above command will create a `config.toml` entry similar to this: + + ``` + [[runners]] + url = "https://gitlab.com/ci" + token = TOKEN + executor = "docker" + [runners.docker] + tls_verify = false + image = "docker:latest" + privileged = false + disable_cache = false + volumes = ["/usr/local/bin/docker:/usr/bin/docker", "/cache"] + [runners.cache] + Insecure = false + ``` + +1. You can now use `docker` from build script (note that you don't need to include the `docker:dind` service as in the option above): + + ```yaml + image: docker:latest + + before_script: + - docker info + + build: + stage: build + script: + - docker build -t my-docker-image . + - docker run my-docker-image /script/to/run/tests + ``` + +1. However, by sharing the docker daemon, you are effectively disabling all + the security mechanisms of containers and exposing your host to privilege + escalation which can lead to container breakout. + [docker-in-docker]: https://blog.docker.com/2013/09/docker-can-now-run-within-docker/ [docker-cap]: https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities From d7664c7223cbd9e91e21beaf6ceb9ea7c2f294d8 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Tue, 7 Jun 2016 21:34:30 -0700 Subject: [PATCH 164/318] Add example using GitLab Container Registry --- doc/ci/docker/using_docker_build.md | 72 +++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index f98b2860e21..fe2c5207cd1 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -208,5 +208,77 @@ In order to do that follow the steps: the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. +## Using the GitLab Container Registry + +Once you've built a Docker image, you can push it up to the built-in [GitLab Container Registry](../../container_registry/README.md). + +``` + build: + stage: build + script: + - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.example.com + - docker build -t registry.example.com/group/project:latest . + - docker push registry.example.com/group/project:latest +``` + +Here's a more elaborate example that splits up the tasks into 4 stages, +including two tests that run in parallel. The build is stored in the container +registry and used by subsequent stages, downloading the image +when needed. Changes to `master` also get tagged as `latest` and deployed using +an application-specific deploy script: + +```yaml +image: docker:git +services: +- docker:dind + +stages: +- build +- test +- release +- deploy + +variables: + CONTAINER_TEST_IMAGE: registry.example.com/my-group/my-project:$CI_BUILD_REF_NAME + CONTAINER_RELEASE_IMAGE: registry.example.com/my-group/my-project:latest + +before_script: + - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.example.com + +build: + stage: build + script: + - docker build --pull -t $CONTAINER_TEST_IMAGE . + - docker push $CONTAINER_TEST_IMAGE + +test1: + stage: test + script: + - docker pull $CONTAINER_TEST_IMAGE + - docker run $CONTAINER_TEST_IMAGE /script/to/run/tests + +test2: + stage: test + script: + - docker pull $CONTAINER_TEST_IMAGE + - docker run $CONTAINER_TEST_IMAGE /script/to/run/another/test + +release-image: + stage: release + script: + - docker pull $CONTAINER_TEST_IMAGE + - docker tag $CONTAINER_TEST_IMAGE $CONTAINER_RELEASE_IMAGE + - docker push $CONTAINER_RELEASE_IMAGE + only: + - master + +deploy: + stage: deploy + script: + - ./deploy.sh + only: + - master +``` + [docker-in-docker]: https://blog.docker.com/2013/09/docker-can-now-run-within-docker/ [docker-cap]: https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities From 6841e76b45b44da9f749538dbae2bb1fc63d8ee4 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Tue, 7 Jun 2016 22:09:15 -0700 Subject: [PATCH 165/318] Add notes --- doc/ci/docker/using_docker_build.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index fe2c5207cd1..a5f37366265 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -280,5 +280,11 @@ deploy: - master ``` +Notes: +1. You must log in to the container registry before running commands. Putting this in `before_script` will run it before each build job. +1. Using `docker build --pull` makes sure that Docker fetches any changes to base images before building just in case your cache is stale. It takes slightly longer, but means you don’t get stuck without security patches to base images. +1. Doing an explicit `docker pull` before each `docker run` makes sure to fetch the latest image that was just built. This is especially important if you are using multiple runners that cache images locally. Using the git SHA in your image tag makes this less necessary since each build will be unique and you shouldn't ever have a stale image, but it's still possible if you re-build a given commit after a dependency has changed. +1. You don't want to build directly to `latest` in case there are multiple builds happening simultaneously. + [docker-in-docker]: https://blog.docker.com/2013/09/docker-can-now-run-within-docker/ [docker-cap]: https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities From d9cbe019866843132225d440754a20da0e937d00 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Tue, 7 Jun 2016 22:25:33 -0700 Subject: [PATCH 166/318] Moar commas --- doc/ci/docker/using_docker_build.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index a5f37366265..4620eeac2b6 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -76,7 +76,7 @@ The second approach is to use the special Docker image with all tools installed (`docker` and `docker-compose`) and run the build script in context of that image in privileged mode. -In order to do that follow the steps: +In order to do that, follow the steps: 1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/#installation). @@ -151,7 +151,7 @@ An example project using this approach can be found here: https://gitlab.com/git The third approach is to bind-mount `/var/run/docker.sock` into the container so that docker is available in the context of that image. -In order to do that follow the steps: +In order to do that, follow the steps: 1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/#installation). From 84128441081869ff2bd260a92a7c0b43d68ca415 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Tue, 7 Jun 2016 22:26:59 -0700 Subject: [PATCH 167/318] Fix instructions --- doc/ci/docker/using_docker_build.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 4620eeac2b6..62e48a6d8d6 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -155,8 +155,7 @@ In order to do that, follow the steps: 1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/#installation). -1. Register GitLab Runner from the command line to use `docker` and `privileged` - mode: +1. Register GitLab Runner from the command line to use `docker` and share `/var/run/docker.sock`: ```bash sudo gitlab-runner register -n \ From 9b30f26b988f69995d1f548f79020c23dfe1a9ea Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Tue, 7 Jun 2016 23:39:38 -0700 Subject: [PATCH 168/318] Fix runner CLI instructions --- doc/ci/docker/using_docker_build.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 62e48a6d8d6..3af4afbbefe 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -84,9 +84,9 @@ In order to do that, follow the steps: mode: ```bash - sudo gitlab-runner register -n \ + sudo gitlab-ci-multi-runner register -n \ --url https://gitlab.com/ci \ - --token RUNNER_TOKEN \ + --registration-token REGISTRATION_TOKEN \ --executor docker \ --description "My Docker Runner" \ --docker-image "docker:latest" \ @@ -158,9 +158,9 @@ In order to do that, follow the steps: 1. Register GitLab Runner from the command line to use `docker` and share `/var/run/docker.sock`: ```bash - sudo gitlab-runner register -n \ + sudo gitlab-ci-multi-runner register -n \ --url https://gitlab.com/ci \ - --token RUNNER_TOKEN \ + --registration-token REGISTRATION_TOKEN \ --executor docker \ --description "My Docker Runner" \ --docker-image "docker:latest" \ From 6ca1370c92dcf074af73562fb0fd613c8af45ce1 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Tue, 7 Jun 2016 23:50:26 -0700 Subject: [PATCH 169/318] Fix more instructions --- doc/ci/docker/using_docker_build.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 3af4afbbefe..aae37010508 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -24,16 +24,16 @@ However, this requires special configuration of GitLab Runner to enable `docker` ## 1. Use shell executor The simplest approach is to install GitLab Runner in `shell` execution mode. -GitLab Runner then executes build scripts as `gitlab-runner` user. +GitLab Runner then executes build scripts as the `gitlab-runner` user. 1. Install [GitLab Runner](https://gitlab.com/gitlab-org/gitlab-ci-multi-runner/#installation). 1. During GitLab Runner installation select `shell` as method of executing build scripts or use command: ```bash - $ sudo gitlab-runner register -n \ + $ sudo gitlab-ci-multi-runner register -n \ --url https://gitlab.com/ci \ - --token RUNNER_TOKEN \ + --registration-token REGISTRATION_TOKEN \ --executor shell --description "My Runner" ``` From db656a3987131816d47897b2424821b19ca147b0 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Wed, 8 Jun 2016 00:28:23 -0700 Subject: [PATCH 170/318] Fix more references to old gitlab-runner --- doc/ci/docker/using_docker_images.md | 2 +- doc/ci/examples/php.md | 4 ++-- doc/ci/runners/README.md | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/doc/ci/docker/using_docker_images.md b/doc/ci/docker/using_docker_images.md index 56ac2195c49..a849905ac6b 100644 --- a/doc/ci/docker/using_docker_images.md +++ b/doc/ci/docker/using_docker_images.md @@ -23,7 +23,7 @@ To use GitLab Runner with docker you need to register a new runner to use the `docker` executor: ```bash -gitlab-runner register \ +gitlab-ci-multi-runner register \ --url "https://gitlab.com/" \ --registration-token "PROJECT_REGISTRATION_TOKEN" \ --description "docker-ruby-2.1" \ diff --git a/doc/ci/examples/php.md b/doc/ci/examples/php.md index 26953014502..17e1c64bb8a 100644 --- a/doc/ci/examples/php.md +++ b/doc/ci/examples/php.md @@ -263,10 +263,10 @@ terminal execute: ```bash # Check using docker executor -gitlab-runner exec docker test:app +gitlab-ci-multi-runner exec docker test:app # Check using shell executor -gitlab-runner exec shell test:app +gitlab-ci-multi-runner exec shell test:app ``` ## Example project diff --git a/doc/ci/runners/README.md b/doc/ci/runners/README.md index b42d7a62ebc..400784da617 100644 --- a/doc/ci/runners/README.md +++ b/doc/ci/runners/README.md @@ -63,10 +63,10 @@ instance. Now simply register the runner as any runner: ``` -sudo gitlab-runner register +sudo gitlab-ci-multi-runner register ``` -Shared runners are enabled by default as of GitLab 8.2, but can be disabled with the +Shared runners are enabled by default as of GitLab 8.2, but can be disabled with the `DISABLE SHARED RUNNERS` button. Previous versions of GitLab defaulted shared runners to disabled. @@ -93,7 +93,7 @@ setup a specific runner for this project. To register the runner, run the command below and follow instructions: ``` -sudo gitlab-runner register +sudo gitlab-ci-multi-runner register ``` ### Making an existing Shared Runner Specific From e97af053eb24391df926cb7f7ca20d67a4ff03d0 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Wed, 8 Jun 2016 11:10:06 -0700 Subject: [PATCH 171/318] Fix docker volume --- doc/ci/docker/using_docker_build.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index aae37010508..5df1fdd84c7 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -183,7 +183,7 @@ In order to do that, follow the steps: image = "docker:latest" privileged = false disable_cache = false - volumes = ["/usr/local/bin/docker:/usr/bin/docker", "/cache"] + volumes = ["/var/run/docker.sock", "/cache"] [runners.cache] Insecure = false ``` From 46114eddf0a2fc07f932fe45948a48896abbeb78 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Wed, 8 Jun 2016 11:39:31 -0700 Subject: [PATCH 172/318] Add more pros and cons for each docker approach --- doc/ci/docker/using_docker_build.md | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 5df1fdd84c7..17ba953ca73 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -19,7 +19,7 @@ $ docker tag my-image my-registry:5000/my-image $ docker push my-registry:5000/my-image ``` -However, this requires special configuration of GitLab Runner to enable `docker` support during builds. There are three methods to enable the use of `docker build` and `docker run` during builds. +However, this requires special configuration of GitLab Runner to enable `docker` support during builds. There are three methods to enable the use of `docker build` and `docker run` during builds; each with their own tradeoffs. ## 1. Use shell executor @@ -67,7 +67,7 @@ GitLab Runner then executes build scripts as the `gitlab-runner` user. 5. You can now use `docker` command and install `docker-compose` if needed. -6. However, by adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. +However, by adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. For more information please checkout [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). ## 2. Use docker-in-docker executor @@ -138,12 +138,16 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -1. However, by enabling `--docker-privileged` you are effectively disabling all - the security mechanisms of containers and exposing your host to privilege - escalation which can lead to container breakout. +However, by enabling `--docker-privileged` you are effectively disabling all +the security mechanisms of containers and exposing your host to privilege +escalation which can lead to container breakout. For more information, check out the official Docker documentation on +[Runtime privilege and Linux capabilities][docker-cap]. - For more information, check out the official Docker documentation on - [Runtime privilege and Linux capabilities][docker-cap]. +Using docker-in-docker, each build is in a clean environment without the past +history. Concurrent builds work fine because every build get it's own instance of docker engine so they won't conflict with each other. But this also means builds can be slower because there's no caching of layers. + +By default `docker:dind` uses ``--storage-driver vfs` which is the slowest form +offered. An example project using this approach can be found here: https://gitlab.com/gitlab-examples/docker. @@ -203,9 +207,14 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -1. However, by sharing the docker daemon, you are effectively disabling all - the security mechanisms of containers and exposing your host to privilege - escalation which can lead to container breakout. +However, by sharing the docker daemon, you are effectively disabling all +the security mechanisms of containers and exposing your host to privilege +escalation which can lead to container breakout. For example, if a project +ran `docker rm -f $(docker ps -a -q)` it would remove the GitLab Runner +containers. + +Also, concurrent builds may not work; if your tests +create containers with specific names, they may conflict with each other. ## Using the GitLab Container Registry From 1c02ef9c144f3a8d40e31a21d82b5628e72d48e6 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Wed, 8 Jun 2016 12:00:17 -0700 Subject: [PATCH 173/318] Drop some 'however's --- doc/ci/docker/using_docker_build.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 17ba953ca73..cc820d81144 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -19,7 +19,7 @@ $ docker tag my-image my-registry:5000/my-image $ docker push my-registry:5000/my-image ``` -However, this requires special configuration of GitLab Runner to enable `docker` support during builds. There are three methods to enable the use of `docker build` and `docker run` during builds; each with their own tradeoffs. +This requires special configuration of GitLab Runner to enable `docker` support during builds. There are three methods to enable the use of `docker build` and `docker run` during builds; each with their own tradeoffs. ## 1. Use shell executor @@ -67,7 +67,7 @@ GitLab Runner then executes build scripts as the `gitlab-runner` user. 5. You can now use `docker` command and install `docker-compose` if needed. -However, by adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. +By adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. For more information please checkout [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). ## 2. Use docker-in-docker executor @@ -138,7 +138,7 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -However, by enabling `--docker-privileged` you are effectively disabling all +By enabling `--docker-privileged` you are effectively disabling all the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For more information, check out the official Docker documentation on [Runtime privilege and Linux capabilities][docker-cap]. @@ -207,7 +207,7 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -However, by sharing the docker daemon, you are effectively disabling all +By sharing the docker daemon, you are effectively disabling all the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For example, if a project ran `docker rm -f $(docker ps -a -q)` it would remove the GitLab Runner From b393478f63ad2f4381996dc08111fc3393bf762e Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Wed, 8 Jun 2016 12:11:44 -0700 Subject: [PATCH 174/318] Refactor notes --- doc/ci/docker/using_docker_build.md | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index cc820d81144..5af6d36e83e 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -67,7 +67,8 @@ GitLab Runner then executes build scripts as the `gitlab-runner` user. 5. You can now use `docker` command and install `docker-compose` if needed. -By adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. +Notes: +* By adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. For more information please checkout [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). ## 2. Use docker-in-docker executor @@ -138,15 +139,16 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -By enabling `--docker-privileged` you are effectively disabling all +Notes: +* By enabling `--docker-privileged` you are effectively disabling all the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For more information, check out the official Docker documentation on [Runtime privilege and Linux capabilities][docker-cap]. -Using docker-in-docker, each build is in a clean environment without the past +* Using docker-in-docker, each build is in a clean environment without the past history. Concurrent builds work fine because every build get it's own instance of docker engine so they won't conflict with each other. But this also means builds can be slower because there's no caching of layers. -By default `docker:dind` uses ``--storage-driver vfs` which is the slowest form +* By default, `docker:dind` uses ``--storage-driver vfs` which is the slowest form offered. An example project using this approach can be found here: https://gitlab.com/gitlab-examples/docker. @@ -207,15 +209,21 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -By sharing the docker daemon, you are effectively disabling all +Notes: +* By sharing the docker daemon, you are effectively disabling all the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For example, if a project ran `docker rm -f $(docker ps -a -q)` it would remove the GitLab Runner containers. -Also, concurrent builds may not work; if your tests +* Concurrent builds may not work; if your tests create containers with specific names, they may conflict with each other. +* Sharing files and directories from the source repo into containers may not +work as expected since volume mounting is done in the context of the host +machine, not the build container. +e.g. `docker run --rm -t -i -v $(pwd)/src:/home/app/src test-image:latest run_app_tests` + ## Using the GitLab Container Registry Once you've built a Docker image, you can push it up to the built-in [GitLab Container Registry](../../container_registry/README.md). From b0cbeb18d1864ab36fb17c69d963321d745924fa Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Wed, 8 Jun 2016 14:11:15 -0700 Subject: [PATCH 175/318] Remove unnecessary message --- doc/ci/docker/using_docker_build.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 5af6d36e83e..c44b1d7a0cc 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -117,10 +117,6 @@ In order to do that, follow the steps: Insecure = false ``` - If you want to use the Shared Runners available on your GitLab CE/EE - installation in order to build Docker images, then make sure that your - Shared Runners configuration has the `privileged` mode set to `true`. - 1. You can now use `docker` from build script: ```yaml From 6f834ecaa94a1da230c933c981b33634d937d8dd Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Wed, 8 Jun 2016 14:17:03 -0700 Subject: [PATCH 176/318] Reformat notes --- doc/ci/docker/using_docker_build.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index c44b1d7a0cc..697b9f10163 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -67,7 +67,7 @@ GitLab Runner then executes build scripts as the `gitlab-runner` user. 5. You can now use `docker` command and install `docker-compose` if needed. -Notes: +### Notes * By adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. For more information please checkout [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). @@ -135,7 +135,7 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -Notes: +### Notes * By enabling `--docker-privileged` you are effectively disabling all the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For more information, check out the official Docker documentation on @@ -205,7 +205,7 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -Notes: +### Notes * By sharing the docker daemon, you are effectively disabling all the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For example, if a project @@ -292,7 +292,7 @@ deploy: - master ``` -Notes: +### Notes 1. You must log in to the container registry before running commands. Putting this in `before_script` will run it before each build job. 1. Using `docker build --pull` makes sure that Docker fetches any changes to base images before building just in case your cache is stale. It takes slightly longer, but means you don’t get stuck without security patches to base images. 1. Doing an explicit `docker pull` before each `docker run` makes sure to fetch the latest image that was just built. This is especially important if you are using multiple runners that cache images locally. Using the git SHA in your image tag makes this less necessary since each build will be unique and you shouldn't ever have a stale image, but it's still possible if you re-build a given commit after a dependency has changed. From 35ce04ef2e02e5b176c57567f2ddf82871af7639 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Wed, 8 Jun 2016 14:40:56 -0700 Subject: [PATCH 177/318] Move registry CI example to CI docs --- doc/ci/docker/using_docker_build.md | 22 ++++++++++++++++++---- doc/container_registry/README.md | 23 ++--------------------- 2 files changed, 20 insertions(+), 25 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 697b9f10163..33b1624d00b 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -73,7 +73,8 @@ For more information please checkout [On Docker security: `docker` group conside ## 2. Use docker-in-docker executor -The second approach is to use the special Docker image with all tools installed +The second approach is to use the special docker-in-docker (dind) +[Docker image](https://hub.docker.com/_/docker/) with all tools installed (`docker` and `docker-compose`) and run the build script in context of that image in privileged mode. @@ -222,10 +223,18 @@ e.g. `docker run --rm -t -i -v $(pwd)/src:/home/app/src test-image:latest run_ap ## Using the GitLab Container Registry -Once you've built a Docker image, you can push it up to the built-in [GitLab Container Registry](../../container_registry/README.md). +> **Note:** +This feature requires GitLab 8.8 and GitLab Runner 1.2. -``` +Once you've built a Docker image, you can push it up to the built-in [GitLab Container Registry](../../container_registry/README.md). For example, if you're using +docker-in-docker on your runners, this is how your `.gitlab-ci.yml` could look: + + +```yaml build: + image: docker:git + services: + - docker:dind stage: build script: - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.example.com @@ -233,7 +242,12 @@ Once you've built a Docker image, you can push it up to the built-in [GitLab Con - docker push registry.example.com/group/project:latest ``` -Here's a more elaborate example that splits up the tasks into 4 stages, +You have to use the credentials of the special `gitlab-ci-token` user with its +password stored in `$CI_BUILD_TOKEN` in order to push to the Registry connected +to your project. This allows you to automate building and deployment of your +Docker images. + +Here's a more elaborate example that splits up the tasks into 4 pipeline stages, including two tests that run in parallel. The build is stored in the container registry and used by subsequent stages, downloading the image when needed. Changes to `master` also get tagged as `latest` and deployed using diff --git a/doc/container_registry/README.md b/doc/container_registry/README.md index a7dac54a1ad..1b465434498 100644 --- a/doc/container_registry/README.md +++ b/doc/container_registry/README.md @@ -79,27 +79,8 @@ delete them. This feature requires GitLab 8.8 and GitLab Runner 1.2. Make sure that your GitLab Runner is configured to allow building docker images. -You have to check the [Using Docker Build documentation](../../ci/docker/using_docker_build.md). - -You can use [docker:dind](https://hub.docker.com/_/docker/) to build your images, -and this is how your `.gitlab-ci.yml` should look: - -``` - build_image: - image: docker:git - services: - - docker:dind - stage: build - script: - - docker login -u gitlab-ci-token -p $CI_BUILD_TOKEN registry.example.com - - docker build -t registry.example.com/group/project:latest . - - docker push registry.example.com/group/project:latest -``` - -You have to use the credentials of the special `gitlab-ci-token` user with its -password stored in `$CI_BUILD_TOKEN` in order to push to the Registry connected -to your project. This allows you to automate building and deployment of your -Docker images. +You have to check the [Using Docker Build documentation](../ci/docker/using_docker_build.md). +Then see the CI documentation on [Using the GitLab Container Registry](../ci/docker/using_docker_build.md#using-the-gitlab-container-registry). ## Limitations From a7caea9e3e6b624ada8d3dbabf13c2f9ad79b463 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Wed, 8 Jun 2016 15:41:27 -0700 Subject: [PATCH 178/318] Use docker:latest --- doc/ci/docker/using_docker_build.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 33b1624d00b..d5bc1d7406e 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -232,7 +232,7 @@ docker-in-docker on your runners, this is how your `.gitlab-ci.yml` could look: ```yaml build: - image: docker:git + image: docker:latest services: - docker:dind stage: build @@ -254,7 +254,7 @@ when needed. Changes to `master` also get tagged as `latest` and deployed using an application-specific deploy script: ```yaml -image: docker:git +image: docker:latest services: - docker:dind From f95791d4118e7a1ad85ab0f287784c5639182560 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Mon, 13 Jun 2016 22:32:01 -0700 Subject: [PATCH 179/318] Make Achilleas' suggested changes --- doc/ci/docker/using_docker_build.md | 56 ++++++++++++++--------------- 1 file changed, 28 insertions(+), 28 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index d5bc1d7406e..77291597659 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -4,12 +4,12 @@ GitLab CI allows you to use Docker Engine to build and test docker-based project **This also allows to you to use `docker-compose` and other docker-enabled tools.** -This is one of the new trends in Continuous Integration/Deployment to: +One of the new trends in Continuous Integration/Deployment is to: 1. create an application image, 1. run tests against the created image, -1. push image to a remote registry, -1. deploy server from the pushed image +1. push image to a remote registry, and +1. deploy to a server from the pushed image. It's also useful when your application already has the `Dockerfile` that can be used to create and test an image: ```bash @@ -19,9 +19,13 @@ $ docker tag my-image my-registry:5000/my-image $ docker push my-registry:5000/my-image ``` -This requires special configuration of GitLab Runner to enable `docker` support during builds. There are three methods to enable the use of `docker build` and `docker run` during builds; each with their own tradeoffs. +This requires special configuration of GitLab Runner to enable `docker` support during builds. -## 1. Use shell executor +## Runner Configuration + +There are three methods to enable the use of `docker build` and `docker run` during builds; each with their own tradeoffs. + +### Use shell executor The simplest approach is to install GitLab Runner in `shell` execution mode. GitLab Runner then executes build scripts as the `gitlab-runner` user. @@ -67,11 +71,11 @@ GitLab Runner then executes build scripts as the `gitlab-runner` user. 5. You can now use `docker` command and install `docker-compose` if needed. -### Notes +> **Note:** * By adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. -For more information please checkout [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). +For more information please check out [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). -## 2. Use docker-in-docker executor +### Use docker-in-docker executor The second approach is to use the special docker-in-docker (dind) [Docker image](https://hub.docker.com/_/docker/) with all tools installed @@ -118,7 +122,7 @@ In order to do that, follow the steps: Insecure = false ``` -1. You can now use `docker` from build script: +1. You can now use `docker` in the build script: ```yaml image: docker:latest @@ -136,21 +140,19 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -### Notes -* By enabling `--docker-privileged` you are effectively disabling all +> **Notes:** +> * By enabling `--docker-privileged`, you are effectively disabling all the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For more information, check out the official Docker documentation on [Runtime privilege and Linux capabilities][docker-cap]. - -* Using docker-in-docker, each build is in a clean environment without the past -history. Concurrent builds work fine because every build get it's own instance of docker engine so they won't conflict with each other. But this also means builds can be slower because there's no caching of layers. - -* By default, `docker:dind` uses ``--storage-driver vfs` which is the slowest form +> * Using docker-in-docker, each build is in a clean environment without the past +history. Concurrent builds work fine because every build gets it's own instance of docker engine so they won't conflict with each other. But this also means builds can be slower because there's no caching of layers. +> * By default, `docker:dind` uses `--storage-driver vfs` which is the slowest form offered. An example project using this approach can be found here: https://gitlab.com/gitlab-examples/docker. -## 3. Bind Docker socket +### Use Docker socket binding The third approach is to bind-mount `/var/run/docker.sock` into the container so that docker is available in the context of that image. @@ -172,14 +174,14 @@ In order to do that, follow the steps: The above command will register a new Runner to use the special `docker:latest` image which is provided by Docker. **Notice that it's using - the Docker daemon of the runner itself, and any containers spawned by docker commands will be siblings of the runner rather than children of the runner.** This may have complications and limitations that are unsuitable for your workflow. + the Docker daemon of the Runner itself, and any containers spawned by docker commands will be siblings of the Runner rather than children of the runner.** This may have complications and limitations that are unsuitable for your workflow. The above command will create a `config.toml` entry similar to this: ``` [[runners]] url = "https://gitlab.com/ci" - token = TOKEN + token = REGISTRATION_TOKEN executor = "docker" [runners.docker] tls_verify = false @@ -191,7 +193,7 @@ In order to do that, follow the steps: Insecure = false ``` -1. You can now use `docker` from build script (note that you don't need to include the `docker:dind` service as in the option above): +1. You can now use `docker` in the build script (note that you don't need to include the `docker:dind` service as when using the Docker in Docker executor): ```yaml image: docker:latest @@ -206,16 +208,14 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -### Notes +While the above method avoids using Docker in privileged mode, you should be aware of the following implications: * By sharing the docker daemon, you are effectively disabling all the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For example, if a project ran `docker rm -f $(docker ps -a -q)` it would remove the GitLab Runner containers. - * Concurrent builds may not work; if your tests create containers with specific names, they may conflict with each other. - * Sharing files and directories from the source repo into containers may not work as expected since volume mounting is done in the context of the host machine, not the build container. @@ -306,11 +306,11 @@ deploy: - master ``` -### Notes -1. You must log in to the container registry before running commands. Putting this in `before_script` will run it before each build job. -1. Using `docker build --pull` makes sure that Docker fetches any changes to base images before building just in case your cache is stale. It takes slightly longer, but means you don’t get stuck without security patches to base images. -1. Doing an explicit `docker pull` before each `docker run` makes sure to fetch the latest image that was just built. This is especially important if you are using multiple runners that cache images locally. Using the git SHA in your image tag makes this less necessary since each build will be unique and you shouldn't ever have a stale image, but it's still possible if you re-build a given commit after a dependency has changed. -1. You don't want to build directly to `latest` in case there are multiple builds happening simultaneously. +Some things you should be aware of when using the Container Registry: +* You must log in to the container registry before running commands. Putting this in `before_script` will run it before each build job. +* Using `docker build --pull` makes sure that Docker fetches any changes to base images before building just in case your cache is stale. It takes slightly longer, but means you don’t get stuck without security patches to base images. +* Doing an explicit `docker pull` before each `docker run` makes sure to fetch the latest image that was just built. This is especially important if you are using multiple runners that cache images locally. Using the git SHA in your image tag makes this less necessary since each build will be unique and you shouldn't ever have a stale image, but it's still possible if you re-build a given commit after a dependency has changed. +* You don't want to build directly to `latest` in case there are multiple builds happening simultaneously. [docker-in-docker]: https://blog.docker.com/2013/09/docker-can-now-run-within-docker/ [docker-cap]: https://docs.docker.com/engine/reference/run/#runtime-privilege-and-linux-capabilities From 4c571041de6989d71f09fc326f8d6bee731f0b19 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Mon, 13 Jun 2016 22:36:28 -0700 Subject: [PATCH 180/318] Make minor grammar change --- doc/ci/docker/using_docker_build.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 77291597659..09a2d8b5966 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -72,8 +72,8 @@ GitLab Runner then executes build scripts as the `gitlab-runner` user. 5. You can now use `docker` command and install `docker-compose` if needed. > **Note:** -* By adding `gitlab-runner` to `docker` group you are effectively granting `gitlab-runner` full root permissions. -For more information please check out [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). +* By adding `gitlab-runner` to the `docker` group you are effectively granting `gitlab-runner` full root permissions. +For more information please read [On Docker security: `docker` group considered harmful](https://www.andreas-jung.com/contents/on-docker-security-docker-group-considered-harmful). ### Use docker-in-docker executor From aefb08cb6a8bd15415b641c385e790f941b72ced Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Mon, 13 Jun 2016 22:42:46 -0700 Subject: [PATCH 181/318] Clarify dind example --- doc/ci/docker/using_docker_build.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 09a2d8b5966..36ff4dcf05a 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -122,7 +122,7 @@ In order to do that, follow the steps: Insecure = false ``` -1. You can now use `docker` in the build script: +1. You can now use `docker` in the build script (note the inclusion of the `docker:dind` service): ```yaml image: docker:latest @@ -141,7 +141,7 @@ In order to do that, follow the steps: ``` > **Notes:** -> * By enabling `--docker-privileged`, you are effectively disabling all +> * By enabling `--docker-privileged`, you are effectively disabling all of the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For more information, check out the official Docker documentation on [Runtime privilege and Linux capabilities][docker-cap]. From 8df7d90d5a92b7d8aa26ac07b7391b4e86d63499 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Mon, 13 Jun 2016 22:45:43 -0700 Subject: [PATCH 182/318] De-note-ify --- doc/ci/docker/using_docker_build.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 36ff4dcf05a..39eea740d18 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -140,14 +140,14 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -> **Notes:** -> * By enabling `--docker-privileged`, you are effectively disabling all of +Docker-in-Docker works well, and is our recommended configuration, but it is not without its own challenges: +* By enabling `--docker-privileged`, you are effectively disabling all of the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For more information, check out the official Docker documentation on [Runtime privilege and Linux capabilities][docker-cap]. -> * Using docker-in-docker, each build is in a clean environment without the past +* Using docker-in-docker, each build is in a clean environment without the past history. Concurrent builds work fine because every build gets it's own instance of docker engine so they won't conflict with each other. But this also means builds can be slower because there's no caching of layers. -> * By default, `docker:dind` uses `--storage-driver vfs` which is the slowest form +* By default, `docker:dind` uses `--storage-driver vfs` which is the slowest form offered. An example project using this approach can be found here: https://gitlab.com/gitlab-examples/docker. From 6ed7fcad29d0b96b4513c2961c342d0309eda07e Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Mon, 13 Jun 2016 22:47:54 -0700 Subject: [PATCH 183/318] Remove our --- doc/ci/docker/using_docker_build.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/docker/using_docker_build.md b/doc/ci/docker/using_docker_build.md index 39eea740d18..7f83f846454 100644 --- a/doc/ci/docker/using_docker_build.md +++ b/doc/ci/docker/using_docker_build.md @@ -140,7 +140,7 @@ In order to do that, follow the steps: - docker run my-docker-image /script/to/run/tests ``` -Docker-in-Docker works well, and is our recommended configuration, but it is not without its own challenges: +Docker-in-Docker works well, and is the recommended configuration, but it is not without its own challenges: * By enabling `--docker-privileged`, you are effectively disabling all of the security mechanisms of containers and exposing your host to privilege escalation which can lead to container breakout. For more information, check out the official Docker documentation on From f67b06ada016915211e84a7d12a063aa25e422f3 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 7 Jun 2016 09:44:01 +0100 Subject: [PATCH 184/318] Manually create todo for issuable Added a button into the sidebar for issues & merge requests to allow users to manually create todo items Closes #15045 --- .../javascripts/right_sidebar.js.coffee | 41 ++++++++++++++++++- app/assets/stylesheets/pages/issuable.scss | 12 +++--- app/controllers/projects/issues_controller.rb | 14 +++++++ .../projects/merge_requests_controller.rb | 14 +++++++ app/finders/todos_finder.rb | 2 +- app/helpers/issuables_helper.rb | 14 +++++++ app/models/todo.rb | 1 + app/services/todo_service.rb | 6 +++ app/views/layouts/header/_default.html.haml | 5 +-- app/views/shared/issuable/_sidebar.html.haml | 13 +++++- config/routes.rb | 2 + 11 files changed, 111 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index c9cb0f4bb32..3ee943fe78c 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -43,6 +43,45 @@ class @Sidebar $('.right-sidebar') .hasClass('right-sidebar-collapsed'), { path: '/' }) + $(document) + .off 'click', '.js-issuable-todo' + .on 'click', '.js-issuable-todo', @toggleTodo + + toggleTodo: (e) -> + $this = $(@) + $btnText = $this.find('span') + data = { + todo_id: $this.attr('data-id') + } + + $.ajax( + url: $this.data('url') + type: 'POST' + dataType: 'json' + data: data + beforeSend: -> + $this.disable() + $('.js-issuable-todo-loading').removeClass 'hidden' + ).done (data) -> + $todoPendingCount = $('.todos-pending-count') + $todoPendingCount.text data.count + + $this.enable() + $('.js-issuable-todo-loading').addClass 'hidden' + + if data.count is 0 + $this.removeAttr 'data-id' + $btnText.text $this.data('todo-text') + + $todoPendingCount + .addClass 'hidden' + else + $btnText.text $this.data('mark-text') + $todoPendingCount + .removeClass 'hidden' + + if data.todo? + $this.attr 'data-id', data.todo.id sidebarDropdownLoading: (e) -> $sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon') @@ -117,5 +156,3 @@ class @Sidebar getBlock: (name) -> @sidebar.find(".block.#{name}") - - diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index ea453ce356a..acbb7e7f713 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -34,6 +34,10 @@ color: inherit; } + .issuable-header-text { + margin-top: 7px; + } + .block { @include clearfix; padding: $gl-padding 0; @@ -60,10 +64,6 @@ margin-top: 0; } - .issuable-count { - margin-top: 7px; - } - .gutter-toggle { margin-left: 20px; padding-left: 10px; @@ -250,7 +250,7 @@ } } - .issuable-pager { + .issuable-header-btn { background: $gray-normal; border: 1px solid $border-gray-normal; &:hover { @@ -263,7 +263,7 @@ } } - a:not(.issuable-pager) { + a:not(.issuable-header-btn) { &:hover { color: $md-link-color; text-decoration: none; diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 4e2d3bebb2e..5678d584d4a 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -164,6 +164,20 @@ class Projects::IssuesController < Projects::ApplicationController end end + def todo + json_data = Hash.new + + if params[:todo_id].nil? + TodoService.new.mark_todo(issue, current_user) + + json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issue.id) + else + current_user.todos.find_by_id(params[:todo_id]).update(state: :done) + end + + render json: json_data.merge({ count: current_user.todos.pending.count }) + end + protected def issue diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 67e7187c10d..f0eba453caa 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -260,6 +260,20 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: response end + def todo + json_data = Hash.new + + if params[:todo_id].nil? + TodoService.new.mark_todo(merge_request, current_user) + + json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: merge_request.id) + else + current_user.todos.find_by_id(params[:todo_id]).update(state: :done) + end + + render json: json_data.merge({ count: current_user.todos.pending.count }) + end + protected def selected_target_project diff --git a/app/finders/todos_finder.rb b/app/finders/todos_finder.rb index 1d88116d7d2..aa47c6c157e 100644 --- a/app/finders/todos_finder.rb +++ b/app/finders/todos_finder.rb @@ -36,7 +36,7 @@ class TodosFinder private def action_id? - action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED].include?(action_id.to_i) + action_id.present? && [Todo::ASSIGNED, Todo::MENTIONED, Todo::BUILD_FAILED, Todo::MARKED].include?(action_id.to_i) end def action_id diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 40d8ce8a1d3..88ef1a6468c 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -67,6 +67,20 @@ module IssuablesHelper end end + def issuable_todo_path(issuable) + project = issuable.project + + if issuable.kind_of?(MergeRequest) + todo_namespace_project_merge_request_path(project.namespace, project, issuable.iid, :json) + else + todo_namespace_project_issue_path(project.namespace, project, issuable.iid, :json) + end + end + + def has_todo(issuable) + current_user.todos.find_by(target_id: issuable.id, state: :pending) + end + private def sidebar_gutter_collapsed? diff --git a/app/models/todo.rb b/app/models/todo.rb index 3a091373329..2792fa9b9a8 100644 --- a/app/models/todo.rb +++ b/app/models/todo.rb @@ -2,6 +2,7 @@ class Todo < ActiveRecord::Base ASSIGNED = 1 MENTIONED = 2 BUILD_FAILED = 3 + MARKED = 4 belongs_to :author, class_name: "User" belongs_to :note diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 8e03ff8ddde..5a192e54f25 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -139,6 +139,12 @@ class TodoService pending_todos(user, attributes).update_all(state: :done) end + # When user marks an issue as todo + def mark_todo(issuable, current_user) + attributes = attributes_for_todo(issuable.project, issuable, current_user, Todo::MARKED) + create_todos(current_user, attributes) + end + private def create_todos(users, attributes) diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index ad30a367fc5..ebc9f01675a 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -27,9 +27,8 @@ %li = link_to dashboard_todos_path, title: 'Todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('bell fw') - - unless todos_pending_count == 0 - %span.badge.todos-pending-count - = todos_pending_count + %span.badge.todos-pending-count{ class: ("hidden" if todos_pending_count == 0)} + = todos_pending_count - if current_user.can_create_project? %li = link_to new_project_path, title: 'New project', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index fb906de829a..25d830b6e49 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,9 +1,20 @@ +- todo = has_todo(issuable) %aside.right-sidebar{ class: sidebar_gutter_collapsed_class } .issuable-sidebar - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - %a.gutter-toggle.pull-right.js-sidebar-toggle{href: '#'} + %span.issuable-header-text.hide-collapsed.pull-left + Todo + %button.gutter-toggle.pull-right.js-sidebar-toggle{ type: "button", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } + - if todo.nil? + %span + Add Todo + - else + %span + Mark Done + = icon('spin spinner', class: 'hidden js-issuable-todo-loading') = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: {class: 'issuable-context-form inline-update js-issuable-update'} do |f| .block.assignee diff --git a/config/routes.rb b/config/routes.rb index 95fbe7dd9df..d018fa742cc 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -679,6 +679,7 @@ Rails.application.routes.draw do post :toggle_subscription post :toggle_award_emoji post :remove_wip + post :todo end collection do @@ -759,6 +760,7 @@ Rails.application.routes.draw do get :referenced_merge_requests get :related_branches get :can_create_branch + post :todo end collection do post :bulk_update From 1e762c0609d31942c05101ca7d38fa1572ec35a2 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 7 Jun 2016 09:51:07 +0100 Subject: [PATCH 185/318] todo title text update for manual todos --- app/helpers/todos_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index b4923fbb138..6cfc86dfb9f 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -12,6 +12,7 @@ module TodosHelper when Todo::ASSIGNED then 'assigned you' when Todo::MENTIONED then 'mentioned you on' when Todo::BUILD_FAILED then 'The build failed for your' + when Todo::MARKED then 'todo' end end From 82be673bec39f626cc97bdaa24007684404fc25e Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 7 Jun 2016 09:52:03 +0100 Subject: [PATCH 186/318] Fixed issue with sidebar button styling --- app/views/shared/issuable/_sidebar.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 25d830b6e49..baeee7f57ec 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -5,7 +5,7 @@ .block.issuable-sidebar-header %span.issuable-header-text.hide-collapsed.pull-left Todo - %button.gutter-toggle.pull-right.js-sidebar-toggle{ type: "button", aria: { label: "Toggle sidebar" } } + %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } - if todo.nil? From 05525b5531f570e144341faad7428a6099a82710 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 7 Jun 2016 09:55:53 +0100 Subject: [PATCH 187/318] Fixed issue with todo button not updating state This would happen when a todo already exists, the state of the button wouldn't update after the ajax call --- app/assets/javascripts/right_sidebar.js.coffee | 8 ++++---- app/assets/stylesheets/pages/issuable.scss | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index 3ee943fe78c..def735d3b4a 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -70,18 +70,18 @@ class @Sidebar $('.js-issuable-todo-loading').addClass 'hidden' if data.count is 0 - $this.removeAttr 'data-id' - $btnText.text $this.data('todo-text') - $todoPendingCount .addClass 'hidden' else - $btnText.text $this.data('mark-text') $todoPendingCount .removeClass 'hidden' if data.todo? + $btnText.text $this.data('mark-text') $this.attr 'data-id', data.todo.id + else + $this.removeAttr 'data-id' + $btnText.text $this.data('todo-text') sidebarDropdownLoading: (e) -> $sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon') diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index acbb7e7f713..f57845ad9c9 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -263,7 +263,7 @@ } } - a:not(.issuable-header-btn) { + a { &:hover { color: $md-link-color; text-decoration: none; From a1be3241ec1f91182435a10615beac15fcfe235a Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 7 Jun 2016 10:18:57 +0100 Subject: [PATCH 188/318] Todo tests and CHANGELOG --- CHANGELOG | 1 + spec/features/issues/todo_spec.rb | 33 +++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 spec/features/issues/todo_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 3387394de5b..ae62b6b4c45 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -73,6 +73,7 @@ v 8.9.0 (unreleased) - Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav - All classes in the Banzai::ReferenceParser namespace are now instrumented - Remove deprecated issues_tracker and issues_tracker_id from project model + - Manually mark a issue or merge request as a todo v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/spec/features/issues/todo_spec.rb b/spec/features/issues/todo_spec.rb new file mode 100644 index 00000000000..b69cce3e7d7 --- /dev/null +++ b/spec/features/issues/todo_spec.rb @@ -0,0 +1,33 @@ +require 'rails_helper' + +feature 'Manually create a todo item from issue', feature: true, js: true do + let!(:project) { create(:project) } + let!(:issue) { create(:issue, project: project) } + let!(:user) { create(:user)} + + before do + project.team << [user, :master] + login_as(user) + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it 'should create todo when clicking button' do + page.within '.issuable-sidebar' do + click_button 'Add Todo' + expect(page).to have_content 'Mark Done' + end + + page.within '.header-content .todos-pending-count' do + expect(page).to have_content '1' + end + end + + it 'should mark a todo as done' do + page.within '.issuable-sidebar' do + click_button 'Add Todo' + click_button 'Mark Done' + end + + expect(page).to have_selector('.todos-pending-count', visible: false) + end +end From 04c199a0ab2db012e8c5a190ce2836f22e846305 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 7 Jun 2016 10:54:02 +0100 Subject: [PATCH 189/318] Fixed bug with sidebar when user is not logged in --- app/helpers/issuables_helper.rb | 4 +++- app/views/shared/issuable/_sidebar.html.haml | 22 +++++++++++--------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 88ef1a6468c..2ae7f5c5f32 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -78,7 +78,9 @@ module IssuablesHelper end def has_todo(issuable) - current_user.todos.find_by(target_id: issuable.id, state: :pending) + unless current_user.nil? + current_user.todos.find_by(target_id: issuable.id, state: :pending) + end end private diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index baeee7f57ec..e3aacb50c97 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -3,18 +3,20 @@ .issuable-sidebar - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header - %span.issuable-header-text.hide-collapsed.pull-left - Todo + - if current_user + %span.issuable-header-text.hide-collapsed.pull-left + Todo %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon - %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } - - if todo.nil? - %span - Add Todo - - else - %span - Mark Done - = icon('spin spinner', class: 'hidden js-issuable-todo-loading') + - if current_user + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } + - if todo.nil? + %span + Add Todo + - else + %span + Mark Done + = icon('spin spinner', class: 'hidden js-issuable-todo-loading') = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: {class: 'issuable-context-form inline-update js-issuable-update'} do |f| .block.assignee From f8a8999a2069dedd9ca21bde2b726a077c057576 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 7 Jun 2016 12:49:56 +0100 Subject: [PATCH 190/318] Cached jQuery selectors --- .../javascripts/right_sidebar.js.coffee | 29 ++++++++++--------- app/views/shared/issuable/_sidebar.html.haml | 9 +++--- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index def735d3b4a..ce8de7515dd 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -47,40 +47,41 @@ class @Sidebar .off 'click', '.js-issuable-todo' .on 'click', '.js-issuable-todo', @toggleTodo - toggleTodo: (e) -> + toggleTodo: -> $this = $(@) - $btnText = $this.find('span') - data = { - todo_id: $this.attr('data-id') - } + $todoLoading = $('.js-issuable-todo-loading') + $btnText = $('.js-issuable-todo-text', $this) $.ajax( url: $this.data('url') type: 'POST' dataType: 'json' - data: data + data: + todo_id: $this.attr('data-id') beforeSend: -> $this.disable() - $('.js-issuable-todo-loading').removeClass 'hidden' + $todoLoading.removeClass 'hidden' ).done (data) -> $todoPendingCount = $('.todos-pending-count') $todoPendingCount.text data.count $this.enable() - $('.js-issuable-todo-loading').addClass 'hidden' + $todoLoading.addClass 'hidden' if data.count is 0 - $todoPendingCount - .addClass 'hidden' + $todoPendingCount.addClass 'hidden' else - $todoPendingCount - .removeClass 'hidden' + $todoPendingCount.removeClass 'hidden' if data.todo? + $this + .attr 'aria-label', $this.data('mark-text') + .attr 'data-id', data.todo.id $btnText.text $this.data('mark-text') - $this.attr 'data-id', data.todo.id else - $this.removeAttr 'data-id' + $this + .attr 'aria-label', $this.data('todo-text') + .removeAttr 'data-id' $btnText.text $this.data('todo-text') sidebarDropdownLoading: (e) -> diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index e3aacb50c97..26052c47b0f 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -9,12 +9,11 @@ %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon - if current_user - %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } - - if todo.nil? - %span + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } + %span.js-issuable-todo-text + - if todo.nil? Add Todo - - else - %span + - else Mark Done = icon('spin spinner', class: 'hidden js-issuable-todo-loading') From 20d382a891d92197620eb4e72526577a916292d7 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 8 Jun 2016 09:29:19 +0100 Subject: [PATCH 191/318] Moved todo creation over to project todos controller --- .../javascripts/right_sidebar.js.coffee | 2 ++ app/controllers/projects/issues_controller.rb | 14 ---------- .../projects/merge_requests_controller.rb | 14 ---------- app/controllers/projects/todos_controller.rb | 28 +++++++++++++++++++ app/helpers/issuables_helper.rb | 10 ------- app/views/shared/issuable/_sidebar.html.haml | 2 +- config/routes.rb | 4 +-- 7 files changed, 33 insertions(+), 41 deletions(-) create mode 100644 app/controllers/projects/todos_controller.rb diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index ce8de7515dd..18abec4f51e 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -58,6 +58,8 @@ class @Sidebar dataType: 'json' data: todo_id: $this.attr('data-id') + issuable_id: $this.data('issuable') + issuable_type: $this.data('issuable-type') beforeSend: -> $this.disable() $todoLoading.removeClass 'hidden' diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 5678d584d4a..4e2d3bebb2e 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -164,20 +164,6 @@ class Projects::IssuesController < Projects::ApplicationController end end - def todo - json_data = Hash.new - - if params[:todo_id].nil? - TodoService.new.mark_todo(issue, current_user) - - json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issue.id) - else - current_user.todos.find_by_id(params[:todo_id]).update(state: :done) - end - - render json: json_data.merge({ count: current_user.todos.pending.count }) - end - protected def issue diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index f0eba453caa..67e7187c10d 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -260,20 +260,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: response end - def todo - json_data = Hash.new - - if params[:todo_id].nil? - TodoService.new.mark_todo(merge_request, current_user) - - json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: merge_request.id) - else - current_user.todos.find_by_id(params[:todo_id]).update(state: :done) - end - - render json: json_data.merge({ count: current_user.todos.pending.count }) - end - protected def selected_target_project diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb new file mode 100644 index 00000000000..21745977860 --- /dev/null +++ b/app/controllers/projects/todos_controller.rb @@ -0,0 +1,28 @@ +class Projects::TodosController < Projects::ApplicationController + def create + json_data = Hash.new + + if params[:todo_id].nil? + TodoService.new.mark_todo(issuable, current_user) + + json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issuable.id) + else + current_user.todos.find_by_id(params[:todo_id]).update(state: :done) + end + + render json: json_data.merge({ count: current_user.todos.pending.count }) + end + + private + + def issuable + @issuable ||= begin + case params[:issuable_type] + when "issue" + @project.issues.find(params[:issuable_id]) + when "merge_request" + @project.merge_requests.find(params[:issuable_id]) + end + end + end +end diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 2ae7f5c5f32..8dbc51a689f 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -67,16 +67,6 @@ module IssuablesHelper end end - def issuable_todo_path(issuable) - project = issuable.project - - if issuable.kind_of?(MergeRequest) - todo_namespace_project_merge_request_path(project.namespace, project, issuable.iid, :json) - else - todo_namespace_project_issue_path(project.namespace, project, issuable.iid, :json) - end - end - def has_todo(issuable) unless current_user.nil? current_user.todos.find_by(target_id: issuable.id, state: :pending) diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 26052c47b0f..17f623b3461 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -9,7 +9,7 @@ %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon - if current_user - %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), url: issuable_todo_path(issuable) } } + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), issuable: issuable.id, issuable_type: issuable.class.name.underscore, url: namespace_project_todos_path(@project.namespace, @project, :json) } } %span.js-issuable-todo-text - if todo.nil? Add Todo diff --git a/config/routes.rb b/config/routes.rb index d018fa742cc..ef198a5e87a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -679,7 +679,6 @@ Rails.application.routes.draw do post :toggle_subscription post :toggle_award_emoji post :remove_wip - post :todo end collection do @@ -760,7 +759,6 @@ Rails.application.routes.draw do get :referenced_merge_requests get :related_branches get :can_create_branch - post :todo end collection do post :bulk_update @@ -791,6 +789,8 @@ Rails.application.routes.draw do end end + resources :todos, only: [:create], constraints: { id: /\d+/ } + resources :uploads, only: [:create] do collection do get ":secret/:filename", action: :show, as: :show, constraints: { filename: /[^\/]+/ } From 330e91368195e182cbfa9b41a1d5304f67d07334 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 9 Jun 2016 08:50:18 +0100 Subject: [PATCH 192/318] Uses update URL to update the status of a todo --- .../javascripts/right_sidebar.js.coffee | 61 +++++++++++-------- app/controllers/projects/todos_controller.rb | 21 ++++--- app/views/shared/issuable/_sidebar.html.haml | 2 +- config/routes.rb | 2 +- 4 files changed, 48 insertions(+), 38 deletions(-) diff --git a/app/assets/javascripts/right_sidebar.js.coffee b/app/assets/javascripts/right_sidebar.js.coffee index 18abec4f51e..8eb005b0a22 100644 --- a/app/assets/javascripts/right_sidebar.js.coffee +++ b/app/assets/javascripts/right_sidebar.js.coffee @@ -47,44 +47,51 @@ class @Sidebar .off 'click', '.js-issuable-todo' .on 'click', '.js-issuable-todo', @toggleTodo - toggleTodo: -> - $this = $(@) + toggleTodo: (e) => + $this = $(e.currentTarget) $todoLoading = $('.js-issuable-todo-loading') $btnText = $('.js-issuable-todo-text', $this) + ajaxType = if $this.attr('data-id') then 'PATCH' else 'POST' + ajaxUrlExtra = if $this.attr('data-id') then "/#{$this.attr('data-id')}" else '' $.ajax( - url: $this.data('url') - type: 'POST' + url: "#{$this.data('url')}#{ajaxUrlExtra}" + type: ajaxType dataType: 'json' data: - todo_id: $this.attr('data-id') issuable_id: $this.data('issuable') issuable_type: $this.data('issuable-type') - beforeSend: -> - $this.disable() - $todoLoading.removeClass 'hidden' - ).done (data) -> - $todoPendingCount = $('.todos-pending-count') - $todoPendingCount.text data.count + beforeSend: => + @beforeTodoSend($this, $todoLoading) + ).done (data) => + @todoUpdateDone(data, $this, $btnText, $todoLoading) - $this.enable() - $todoLoading.addClass 'hidden' + beforeTodoSend: ($btn, $todoLoading) -> + $btn.disable() + $todoLoading.removeClass 'hidden' - if data.count is 0 - $todoPendingCount.addClass 'hidden' - else - $todoPendingCount.removeClass 'hidden' + todoUpdateDone: (data, $btn, $btnText, $todoLoading) -> + $todoPendingCount = $('.todos-pending-count') + $todoPendingCount.text data.count - if data.todo? - $this - .attr 'aria-label', $this.data('mark-text') - .attr 'data-id', data.todo.id - $btnText.text $this.data('mark-text') - else - $this - .attr 'aria-label', $this.data('todo-text') - .removeAttr 'data-id' - $btnText.text $this.data('todo-text') + $btn.enable() + $todoLoading.addClass 'hidden' + + if data.count is 0 + $todoPendingCount.addClass 'hidden' + else + $todoPendingCount.removeClass 'hidden' + + if data.todo? + $btn + .attr 'aria-label', $btn.data('mark-text') + .attr 'data-id', data.todo.id + $btnText.text $btn.data('mark-text') + else + $btn + .attr 'aria-label', $btn.data('todo-text') + .removeAttr 'data-id' + $btnText.text $btn.data('todo-text') sidebarDropdownLoading: (e) -> $sidebarCollapsedIcon = $(@).closest('.block').find('.sidebar-collapsed-icon') diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb index 21745977860..64e70a5bcc6 100644 --- a/app/controllers/projects/todos_controller.rb +++ b/app/controllers/projects/todos_controller.rb @@ -1,20 +1,23 @@ class Projects::TodosController < Projects::ApplicationController def create - json_data = Hash.new + TodoService.new.mark_todo(issuable, current_user) - if params[:todo_id].nil? - TodoService.new.mark_todo(issuable, current_user) + render json: { + todo: current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issuable.id), + count: current_user.todos.pending.count, + } + end - json_data[:todo] = current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issuable.id) - else - current_user.todos.find_by_id(params[:todo_id]).update(state: :done) - end + def update + current_user.todos.find_by_id(params[:id]).update(state: :done) - render json: json_data.merge({ count: current_user.todos.pending.count }) + render json: { + count: current_user.todos.pending.count, + } end private - + def issuable @issuable ||= begin case params[:issuable_type] diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 17f623b3461..539c4f3630a 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -9,7 +9,7 @@ %a.gutter-toggle.pull-right.js-sidebar-toggle{ role: "button", href: "#", aria: { label: "Toggle sidebar" } } = sidebar_gutter_toggle_icon - if current_user - %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), issuable: issuable.id, issuable_type: issuable.class.name.underscore, url: namespace_project_todos_path(@project.namespace, @project, :json) } } + %button.btn.btn-default.issuable-header-btn.pull-right.js-issuable-todo{ type: "button", aria: { label: (todo.nil? ? "Add Todo" : "Mark Done") }, data: { todo_text: "Add Todo", mark_text: "Mark Done", id: (todo.id unless todo.nil?), issuable: issuable.id, issuable_type: issuable.class.name.underscore, url: namespace_project_todos_path(@project.namespace, @project) } } %span.js-issuable-todo-text - if todo.nil? Add Todo diff --git a/config/routes.rb b/config/routes.rb index ef198a5e87a..93dd3c938d0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -789,7 +789,7 @@ Rails.application.routes.draw do end end - resources :todos, only: [:create], constraints: { id: /\d+/ } + resources :todos, only: [:create, :update], constraints: { id: /\d+/ } resources :uploads, only: [:create] do collection do From 8abd7b35ff20214c072658a4e92e0418ae9e936a Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 9 Jun 2016 08:51:40 +0100 Subject: [PATCH 193/318] Updated TODO description --- app/helpers/todos_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 6cfc86dfb9f..9adf5ef29f7 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -12,7 +12,7 @@ module TodosHelper when Todo::ASSIGNED then 'assigned you' when Todo::MENTIONED then 'mentioned you on' when Todo::BUILD_FAILED then 'The build failed for your' - when Todo::MARKED then 'todo' + when Todo::MARKED then 'marked this as a Todo for' end end From 16970d07e84f5967eccd928c9f9d9d7b027e91ac Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 9 Jun 2016 16:12:59 +0100 Subject: [PATCH 194/318] Returns created todos to control rather than re-query --- app/controllers/projects/todos_controller.rb | 4 ++-- app/services/todo_service.rb | 2 +- app/views/layouts/header/_default.html.haml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/controllers/projects/todos_controller.rb b/app/controllers/projects/todos_controller.rb index 64e70a5bcc6..a51bd5e2b49 100644 --- a/app/controllers/projects/todos_controller.rb +++ b/app/controllers/projects/todos_controller.rb @@ -1,9 +1,9 @@ class Projects::TodosController < Projects::ApplicationController def create - TodoService.new.mark_todo(issuable, current_user) + todos = TodoService.new.mark_todo(issuable, current_user) render json: { - todo: current_user.todos.find_by(state: :pending, action: Todo::MARKED, target_id: issuable.id), + todo: todos, count: current_user.todos.pending.count, } end diff --git a/app/services/todo_service.rb b/app/services/todo_service.rb index 5a192e54f25..e1f9ea64dc4 100644 --- a/app/services/todo_service.rb +++ b/app/services/todo_service.rb @@ -148,7 +148,7 @@ class TodoService private def create_todos(users, attributes) - Array(users).each do |user| + Array(users).map do |user| next if pending_todos(user, attributes).exists? Todo.create(attributes.merge(user_id: user.id)) end diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index ebc9f01675a..a0f560a13ec 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -27,7 +27,7 @@ %li = link_to dashboard_todos_path, title: 'Todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('bell fw') - %span.badge.todos-pending-count{ class: ("hidden" if todos_pending_count == 0)} + %span.badge.todos-pending-count{ class: ("hidden" if todos_pending_count == 0) } = todos_pending_count - if current_user.can_create_project? %li From e737ffc48a4794d4dc8f58f20c973154eadff11b Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Mon, 13 Jun 2016 14:25:58 +0100 Subject: [PATCH 195/318] Todo service tests --- spec/services/todo_service_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/services/todo_service_spec.rb b/spec/services/todo_service_spec.rb index 489c920f19f..5e46bfeebd9 100644 --- a/spec/services/todo_service_spec.rb +++ b/spec/services/todo_service_spec.rb @@ -220,6 +220,14 @@ describe TodoService, services: true do should_not_create_any_todo { service.new_note(note_on_project_snippet, john_doe) } end end + + describe '#mark_todo' do + it 'creates a todo from a issue' do + service.mark_todo(unassigned_issue, author) + + should_create_todo(user: author, target: unassigned_issue, action: Todo::MARKED) + end + end end describe 'Merge Requests' do @@ -351,6 +359,14 @@ describe TodoService, services: true do expect(second_todo.reload).not_to be_done end end + + describe '#mark_todo' do + it 'creates a todo from a merge request' do + service.mark_todo(mr_unassigned, author) + + should_create_todo(user: author, target: mr_unassigned, action: Todo::MARKED) + end + end end def should_create_todo(attributes = {}) From b22ba26caa233bc6cb56bc0b82f493713f657909 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 14 Jun 2016 08:36:34 +0100 Subject: [PATCH 196/318] CHANGELOG --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index ae62b6b4c45..96b27d97488 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -54,6 +54,7 @@ v 8.9.0 (unreleased) - Use Knapsack only in CI environment - Cache project build count in sidebar nav - Add milestone expire date to the right sidebar + - Manually mark a issue or merge request as a todo - Fix markdown_spec to use before instead of before(:all) to properly cleanup database after testing - Reduce number of queries needed to render issue labels in the sidebar - Improve error handling importing projects @@ -73,7 +74,6 @@ v 8.9.0 (unreleased) - Put project Labels and Milestones pages links under Issues and Merge Requests tabs as subnav - All classes in the Banzai::ReferenceParser namespace are now instrumented - Remove deprecated issues_tracker and issues_tracker_id from project model - - Manually mark a issue or merge request as a todo v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From 84b07f7054bca5820bc54a99014538506718201e Mon Sep 17 00:00:00 2001 From: Benjamin Schmid <benjamin.schmid@exxcellent.de> Date: Thu, 19 May 2016 17:08:10 +0200 Subject: [PATCH 197/318] Honor credentials on calling Bamboo CI trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This improves the Bamboo Service and provides a fix for situations, where the build trigger won't work, because Bamboo is requiring authentication also for the trigger GET. The change now does provide additional HTTP Basic Auth parameters if user credentials were provided and appends an request parameter indicating the HTTP Basic Authentication should be used. This aligns interaction with Bamboo with the other calls this service executes. Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/models/project_services/bamboo_service.rb | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index 1d1780dcfbf..8c9c52ac7d1 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -1,6 +1,4 @@ class BambooService < CiService - include HTTParty - prop_accessor :bamboo_url, :build_key, :username, :password validates :bamboo_url, presence: true, url: true, if: :activated? @@ -112,8 +110,19 @@ class BambooService < CiService def execute(data) return unless supported_events.include?(data[:object_kind]) - # Bamboo requires a GET and does not take any data. + # Bamboo requires a GET and does take authentification url = URI.join(bamboo_url, "/updateAndBuild.action?buildKey=#{build_key}").to_s - self.class.get(url, verify: false) + + if username.blank? && password.blank? + HTTParty.get(url, verify: false) + else + url << '&os_authType=basic' + auth = { + username: username, + password: password + } + HTTParty.get(url, verify: false, basic_auth: auth) + end + end end From 46f3cd7c65b871d4efa6c33fbfccbc01fdf36649 Mon Sep 17 00:00:00 2001 From: Benjamin Schmid <benjamin.schmid@exxcellent.de> Date: Mon, 30 May 2016 12:30:35 +0200 Subject: [PATCH 198/318] Fix broken URI joining for `bamboo_url` with suffixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If one had configured a `bamboo_url` like http://foo.bar/bamboo in the previous implementation the plugin directed it's request i.e. to http://foo.bar/rest/... instead of http://foo.bar/bamboo/rest/... `URI.join` only works correctly, if the prefix URL has - at least one or more trailing '/' - the appended parts are _not_ prefixed with '/' The current implementation should work with all sorts of Bamboo base URLs. Signed-off-by: Rémy Coutable <remy@rymai.me> --- CHANGELOG | 1 + app/models/project_services/bamboo_service.rb | 8 ++++---- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 3387394de5b..bba6991f184 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -38,6 +38,7 @@ v 8.9.0 (unreleased) - Add option to project to only allow merge requests to be merged if the build succeeds (Rui Santos) - Fix issues filter when ordering by milestone - Added artifacts:when to .gitlab-ci.yml - this requires GitLab Runner 1.3 + - Bamboo Service: Fix missing credentials & URL handling when base URL contains a path (Benjamin Schmid) - Todos will display target state if issuable target is 'Closed' or 'Merged' - Fix bug when sorting issues by milestone due date and filtering by two or more labels - Add support for using Yubikeys (U2F) for two-factor authentication diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index 8c9c52ac7d1..cb215b595f5 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -59,7 +59,7 @@ class BambooService < CiService end def build_info(sha) - url = URI.join(bamboo_url, "/rest/api/latest/result?label=#{sha}").to_s + url = URI.join("#{bamboo_url}/", "rest/api/latest/result?label=#{sha}").to_s if username.blank? && password.blank? @response = HTTParty.get(url, verify: false) @@ -78,11 +78,11 @@ class BambooService < CiService if @response.code != 200 || @response['results']['results']['size'] == '0' # If actual build link can't be determined, send user to build summary page. - URI.join(bamboo_url, "/browse/#{build_key}").to_s + URI.join("#{bamboo_url}/", "browse/#{build_key}").to_s else # If actual build link is available, go to build result page. result_key = @response['results']['results']['result']['planResultKey']['key'] - URI.join(bamboo_url, "/browse/#{result_key}").to_s + URI.join("#{bamboo_url}/", "browse/#{result_key}").to_s end end @@ -111,7 +111,7 @@ class BambooService < CiService return unless supported_events.include?(data[:object_kind]) # Bamboo requires a GET and does take authentification - url = URI.join(bamboo_url, "/updateAndBuild.action?buildKey=#{build_key}").to_s + url = URI.join("#{bamboo_url}/", "updateAndBuild.action?buildKey=#{build_key}").to_s if username.blank? && password.blank? HTTParty.get(url, verify: false) From 17c32ee8d0b2dafa61b3f509d48f7ee8a8dbea14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 1 Jun 2016 16:43:40 +0200 Subject: [PATCH 199/318] Factorize duplicated code into a method in BambooService and update specs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/models/project_services/bamboo_service.rb | 37 +++++++++---------- .../project_services/bamboo_service_spec.rb | 12 +++--- 2 files changed, 23 insertions(+), 26 deletions(-) diff --git a/app/models/project_services/bamboo_service.rb b/app/models/project_services/bamboo_service.rb index cb215b595f5..b5c76e4d4fe 100644 --- a/app/models/project_services/bamboo_service.rb +++ b/app/models/project_services/bamboo_service.rb @@ -59,18 +59,7 @@ class BambooService < CiService end def build_info(sha) - url = URI.join("#{bamboo_url}/", "rest/api/latest/result?label=#{sha}").to_s - - if username.blank? && password.blank? - @response = HTTParty.get(url, verify: false) - else - url << '&os_authType=basic' - auth = { - username: username, - password: password - } - @response = HTTParty.get(url, verify: false, basic_auth: auth) - end + @response = get_path("rest/api/latest/result?label=#{sha}") end def build_page(sha, ref) @@ -110,19 +99,27 @@ class BambooService < CiService def execute(data) return unless supported_events.include?(data[:object_kind]) - # Bamboo requires a GET and does take authentification - url = URI.join("#{bamboo_url}/", "updateAndBuild.action?buildKey=#{build_key}").to_s + get_path("updateAndBuild.action?buildKey=#{build_key}") + end + + private + + def build_url(path) + URI.join("#{bamboo_url}/", path).to_s + end + + def get_path(path) + url = build_url(path) if username.blank? && password.blank? HTTParty.get(url, verify: false) else url << '&os_authType=basic' - auth = { - username: username, - password: password - } - HTTParty.get(url, verify: false, basic_auth: auth) + HTTParty.get(url, verify: false, + basic_auth: { + username: username, + password: password + }) end - end end diff --git a/spec/models/project_services/bamboo_service_spec.rb b/spec/models/project_services/bamboo_service_spec.rb index ec81f05fc7a..9ae461f8c2d 100644 --- a/spec/models/project_services/bamboo_service_spec.rb +++ b/spec/models/project_services/bamboo_service_spec.rb @@ -126,25 +126,25 @@ describe BambooService, models: true do it 'returns a specific URL when status is 500' do stub_request(status: 500) - expect(service.build_page('123', 'unused')).to eq('http://gitlab.com/browse/foo') + expect(service.build_page('123', 'unused')).to eq('http://gitlab.com/bamboo/browse/foo') end it 'returns a specific URL when response has no results' do stub_request(body: %Q({"results":{"results":{"size":"0"}}})) - expect(service.build_page('123', 'unused')).to eq('http://gitlab.com/browse/foo') + expect(service.build_page('123', 'unused')).to eq('http://gitlab.com/bamboo/browse/foo') end it 'returns a build URL when bamboo_url has no trailing slash' do stub_request(body: %Q({"results":{"results":{"result":{"planResultKey":{"key":"42"}}}}})) - expect(service(bamboo_url: 'http://gitlab.com').build_page('123', 'unused')).to eq('http://gitlab.com/browse/42') + expect(service(bamboo_url: 'http://gitlab.com/bamboo').build_page('123', 'unused')).to eq('http://gitlab.com/bamboo/browse/42') end it 'returns a build URL when bamboo_url has a trailing slash' do stub_request(body: %Q({"results":{"results":{"result":{"planResultKey":{"key":"42"}}}}})) - expect(service(bamboo_url: 'http://gitlab.com/').build_page('123', 'unused')).to eq('http://gitlab.com/browse/42') + expect(service(bamboo_url: 'http://gitlab.com/bamboo/').build_page('123', 'unused')).to eq('http://gitlab.com/bamboo/browse/42') end end @@ -192,7 +192,7 @@ describe BambooService, models: true do end end - def service(bamboo_url: 'http://gitlab.com') + def service(bamboo_url: 'http://gitlab.com/bamboo') described_class.create( project: create(:empty_project), properties: { @@ -205,7 +205,7 @@ describe BambooService, models: true do end def stub_request(status: 200, body: nil, build_state: 'success') - bamboo_full_url = 'http://mic:password@gitlab.com/rest/api/latest/result?label=123&os_authType=basic' + bamboo_full_url = 'http://mic:password@gitlab.com/bamboo/rest/api/latest/result?label=123&os_authType=basic' body ||= %Q({"results":{"results":{"result":{"buildState":"#{build_state}"}}}}) WebMock.stub_request(:get, bamboo_full_url).to_return( From 2f7b2057f25d4390d063e4c4fce3f4f12ea58463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 1 Jun 2016 16:44:39 +0200 Subject: [PATCH 200/318] Fix broken URI joining for `teamcity_url` with suffixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If one had configured a `teamcity_url` like http://foo.bar/teamcity in the previous implementation the plugin directed it's request i.e. to http://foo.bar/httpAuth/... instead of http://foo.bar/teamcity/httpAuth/... `URI.join` only works correctly, if the prefix URL has - at least one or more trailing '/' - the appended parts are _not_ prefixed with '/' The current implementation should work with all sorts of TeamCity base URLs. Signed-off-by: Rémy Coutable <remy@rymai.me> --- CHANGELOG | 1 + .../project_services/teamcity_service.rb | 37 ++++++++++--------- .../project_services/teamcity_service_spec.rb | 10 ++--- 3 files changed, 25 insertions(+), 23 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index bba6991f184..3d46901ba69 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -39,6 +39,7 @@ v 8.9.0 (unreleased) - Fix issues filter when ordering by milestone - Added artifacts:when to .gitlab-ci.yml - this requires GitLab Runner 1.3 - Bamboo Service: Fix missing credentials & URL handling when base URL contains a path (Benjamin Schmid) + - TeamCity Service: Fix URL handling when base URL contains a path - Todos will display target state if issuable target is 'Closed' or 'Merged' - Fix bug when sorting issues by milestone due date and filtering by two or more labels - Add support for using Yubikeys (U2F) for two-factor authentication diff --git a/app/models/project_services/teamcity_service.rb b/app/models/project_services/teamcity_service.rb index b0dcb52eba1..a4a967c9bc9 100644 --- a/app/models/project_services/teamcity_service.rb +++ b/app/models/project_services/teamcity_service.rb @@ -1,6 +1,4 @@ class TeamcityService < CiService - include HTTParty - prop_accessor :teamcity_url, :build_type, :username, :password validates :teamcity_url, presence: true, url: true, if: :activated? @@ -64,15 +62,7 @@ class TeamcityService < CiService end def build_info(sha) - url = URI.join( - teamcity_url, - "/httpAuth/app/rest/builds/branch:unspecified:any,number:#{sha}" - ).to_s - auth = { - username: username, - password: password - } - @response = HTTParty.get(url, verify: false, basic_auth: auth) + @response = get_path("httpAuth/app/rest/builds/branch:unspecified:any,number:#{sha}") end def build_page(sha, ref) @@ -81,14 +71,11 @@ class TeamcityService < CiService if @response.code != 200 # If actual build link can't be determined, # send user to build summary page. - URI.join(teamcity_url, "/viewLog.html?buildTypeId=#{build_type}").to_s + build_url("viewLog.html?buildTypeId=#{build_type}") else # If actual build link is available, go to build result page. built_id = @response['build']['id'] - URI.join( - teamcity_url, - "/viewLog.html?buildId=#{built_id}&buildTypeId=#{build_type}" - ).to_s + build_url("viewLog.html?buildId=#{built_id}&buildTypeId=#{build_type}") end end @@ -123,8 +110,8 @@ class TeamcityService < CiService branch = Gitlab::Git.ref_name(data[:ref]) - self.class.post( - URI.join(teamcity_url, '/httpAuth/app/rest/buildQueue').to_s, + HTTParty.post( + build_url('httpAuth/app/rest/buildQueue'), body: "<build branchName=\"#{branch}\">"\ "<buildType id=\"#{build_type}\"/>"\ '</build>', @@ -132,4 +119,18 @@ class TeamcityService < CiService basic_auth: auth ) end + + private + + def build_url(path) + URI.join("#{teamcity_url}/", path).to_s + end + + def get_path(path) + HTTParty.get(build_url(path), verify: false, + basic_auth: { + username: username, + password: password + }) + end end diff --git a/spec/models/project_services/teamcity_service_spec.rb b/spec/models/project_services/teamcity_service_spec.rb index 24a708ca849..474715d24c3 100644 --- a/spec/models/project_services/teamcity_service_spec.rb +++ b/spec/models/project_services/teamcity_service_spec.rb @@ -126,19 +126,19 @@ describe TeamcityService, models: true do it 'returns a specific URL when status is 500' do stub_request(status: 500) - expect(service.build_page('123', 'unused')).to eq('http://gitlab.com/viewLog.html?buildTypeId=foo') + expect(service.build_page('123', 'unused')).to eq('http://gitlab.com/teamcity/viewLog.html?buildTypeId=foo') end it 'returns a build URL when teamcity_url has no trailing slash' do stub_request(body: %Q({"build":{"id":"666"}})) - expect(service(teamcity_url: 'http://gitlab.com').build_page('123', 'unused')).to eq('http://gitlab.com/viewLog.html?buildId=666&buildTypeId=foo') + expect(service(teamcity_url: 'http://gitlab.com/teamcity').build_page('123', 'unused')).to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') end it 'returns a build URL when teamcity_url has a trailing slash' do stub_request(body: %Q({"build":{"id":"666"}})) - expect(service(teamcity_url: 'http://gitlab.com/').build_page('123', 'unused')).to eq('http://gitlab.com/viewLog.html?buildId=666&buildTypeId=foo') + expect(service(teamcity_url: 'http://gitlab.com/teamcity/').build_page('123', 'unused')).to eq('http://gitlab.com/teamcity/viewLog.html?buildId=666&buildTypeId=foo') end end @@ -180,7 +180,7 @@ describe TeamcityService, models: true do end end - def service(teamcity_url: 'http://gitlab.com') + def service(teamcity_url: 'http://gitlab.com/teamcity') described_class.create( project: create(:empty_project), properties: { @@ -193,7 +193,7 @@ describe TeamcityService, models: true do end def stub_request(status: 200, body: nil, build_status: 'success') - teamcity_full_url = 'http://mic:password@gitlab.com/httpAuth/app/rest/builds/branch:unspecified:any,number:123' + teamcity_full_url = 'http://mic:password@gitlab.com/teamcity/httpAuth/app/rest/builds/branch:unspecified:any,number:123' body ||= %Q({"build":{"status":"#{build_status}","id":"666"}}) WebMock.stub_request(:get, teamcity_full_url).to_return( From 59eeec3ff87ce175e34ac96e86c9690c5290502b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Mon, 13 Jun 2016 14:03:11 +0200 Subject: [PATCH 201/318] Make method that composes ci config entry private --- lib/gitlab/ci/config/node/configurable.rb | 2 +- lib/gitlab/ci/config/node/entry.rb | 12 ++++++------ spec/lib/gitlab/ci/config/node/global_spec.rb | 9 --------- spec/lib/gitlab/ci/config/node/null_spec.rb | 2 +- 4 files changed, 8 insertions(+), 17 deletions(-) diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index 86cc33e11be..7587c8c34c9 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -38,7 +38,7 @@ module Gitlab class_methods do def allowed_nodes - Hash[@allowed_nodes.map { |key, factory| [key, factory.dup] } ] + Hash[@allowed_nodes.map { |key, factory| [key, factory.dup] }] end private diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index e5692e72947..507312e0c09 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -26,12 +26,6 @@ module Gitlab nodes.each(&:validate!) end - def compose! - allowed_nodes.each do |key, essence| - @nodes[key] = create_node(key, essence) - end - end - def nodes @nodes.values end @@ -62,6 +56,12 @@ module Gitlab private + def compose! + allowed_nodes.each do |key, essence| + @nodes[key] = create_node(key, essence) + end + end + def create_node(key, essence) raise NotImplementedError end diff --git a/spec/lib/gitlab/ci/config/node/global_spec.rb b/spec/lib/gitlab/ci/config/node/global_spec.rb index 2227fcec638..b1972172435 100644 --- a/spec/lib/gitlab/ci/config/node/global_spec.rb +++ b/spec/lib/gitlab/ci/config/node/global_spec.rb @@ -18,15 +18,6 @@ describe Gitlab::Ci::Config::Node::Global do { before_script: ['ls', 'pwd'] } end - describe '#compose!' do - before { global.compose! } - - it 'instantiates entry nodes' do - expect(global.nodes.first) - .to be_an_instance_of Gitlab::Ci::Config::Node::Script - end - end - describe '#process!' do before { global.process! } diff --git a/spec/lib/gitlab/ci/config/node/null_spec.rb b/spec/lib/gitlab/ci/config/node/null_spec.rb index fb6c3b5cbc0..36101c62462 100644 --- a/spec/lib/gitlab/ci/config/node/null_spec.rb +++ b/spec/lib/gitlab/ci/config/node/null_spec.rb @@ -16,7 +16,7 @@ describe Gitlab::Ci::Config::Node::Null do end describe '#value' do - it 'returns nill' do + it 'returns nil' do expect(entry.value).to be nil end end From 30e946ce8a9272b3de1a64498965933804b7bb6d Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 14 Jun 2016 11:28:20 +0200 Subject: [PATCH 202/318] Validate ci config entry value before processing nodes --- lib/gitlab/ci/config/node/configurable.rb | 14 ++++++-------- lib/gitlab/ci/config/node/entry.rb | 5 +++++ 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/lib/gitlab/ci/config/node/configurable.rb b/lib/gitlab/ci/config/node/configurable.rb index 7587c8c34c9..d60f87f3f94 100644 --- a/lib/gitlab/ci/config/node/configurable.rb +++ b/lib/gitlab/ci/config/node/configurable.rb @@ -16,20 +16,18 @@ module Gitlab module Configurable extend ActiveSupport::Concern - def initialize(*) - super - - unless @value.is_a?(Hash) - @errors << 'should be a configuration entry with hash value' - end - end - def allowed_nodes self.class.allowed_nodes || {} end private + def prevalidate! + unless @value.is_a?(Hash) + @errors << 'should be a configuration entry with hash value' + end + end + def create_node(key, factory) factory.with(value: @value[key]) factory.nullify! unless @value.has_key?(key) diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb index 507312e0c09..52758a962f3 100644 --- a/lib/gitlab/ci/config/node/entry.rb +++ b/lib/gitlab/ci/config/node/entry.rb @@ -14,6 +14,8 @@ module Gitlab @value = value @nodes = {} @errors = [] + + prevalidate! end def process! @@ -56,6 +58,9 @@ module Gitlab private + def prevalidate! + end + def compose! allowed_nodes.each do |key, essence| @nodes[key] = create_node(key, essence) From 60e0137c864e26fee0120dc4447bb95acc46ce51 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 11:38:34 +0200 Subject: [PATCH 203/318] Fix specs --- lib/api/builds.rb | 2 +- spec/features/builds_spec.rb | 16 +++----- spec/models/build_spec.rb | 6 ++- .../expire_build_artifacts_worker_spec.rb | 40 +++++++++++-------- 4 files changed, 34 insertions(+), 30 deletions(-) diff --git a/lib/api/builds.rb b/lib/api/builds.rb index 644e5a2a99d..645e2dda0b7 100644 --- a/lib/api/builds.rb +++ b/lib/api/builds.rb @@ -184,7 +184,7 @@ module API status 200 present build, with: Entities::Build, - user_can_download_artifacts: can?(current_user, :read_build, user_project) + user_can_download_artifacts: can?(current_user, :read_build, user_project) end end diff --git a/spec/features/builds_spec.rb b/spec/features/builds_spec.rb index 0fd95295388..16832c297ac 100644 --- a/spec/features/builds_spec.rb +++ b/spec/features/builds_spec.rb @@ -107,9 +107,7 @@ describe "Builds" do let(:expire_at) { nil } it 'does not have the Keep button' do - page.within('.artifacts') do - expect(page).not_to have_content 'Keep' - end + expect(page).not_to have_content 'Keep' end end @@ -117,10 +115,8 @@ describe "Builds" do let(:expire_at) { Time.now + 7.days } it 'keeps artifacts when Keep button is clicked' do - page.within('.artifacts') do - expect(page).to have_content 'The artifacts will be removed' - click_link 'Keep' - end + expect(page).to have_content 'The artifacts will be removed' + click_link 'Keep' expect(page).not_to have_link 'Keep' expect(page).not_to have_content 'The artifacts will be removed' @@ -131,10 +127,8 @@ describe "Builds" do let(:expire_at) { Time.now - 7.days } it 'does not have the Keep button' do - page.within('.artifacts') do - expect(page).to have_content 'The artifacts were removed' - expect(page).not_to have_link 'Keep' - end + expect(page).to have_content 'The artifacts were removed' + expect(page).not_to have_link 'Keep' end end end diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index c07832a4b5f..35554e1e0c0 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -415,12 +415,14 @@ describe Ci::Build, models: true do context 'is expired' do before { build.update(artifacts_expire_at: Time.now - 7.days) } - it { is_expected.to be_falsy } + + it { is_expected.to be_truthy } end context 'is not expired' do before { build.update(artifacts_expire_at: Time.now + 7.days) } - it { is_expected.to be_truthy } + + it { is_expected.to be_falsey } end end diff --git a/spec/workers/expire_build_artifacts_worker_spec.rb b/spec/workers/expire_build_artifacts_worker_spec.rb index eb8afb20275..e3827cae9a6 100644 --- a/spec/workers/expire_build_artifacts_worker_spec.rb +++ b/spec/workers/expire_build_artifacts_worker_spec.rb @@ -11,37 +11,45 @@ describe ExpireBuildArtifactsWorker do subject! { worker.perform } context 'with expired artifacts' do - let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } + let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } it 'does expire' do expect(build.reload.artifacts_expired?).to be_truthy end + + it 'does remove files' do + expect(build.reload.artifacts_file.exists?).to be_falsey + end end context 'with not yet expired artifacts' do - let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now + 7.days) } - - it 'does not expire' do - expect(build.reload.artifacts_expired?).to be_truthy - end - end - - context 'without expire date' do - let!(:build) { create(:ci_build, :artifacts) } + let(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now + 7.days) } it 'does not expire' do expect(build.reload.artifacts_expired?).to be_falsey end + + it 'does not remove files' do + expect(build.reload.artifacts_file.exists?).to be_truthy + end + end + + context 'without expire date' do + let(:build) { create(:ci_build, :artifacts) } + + it 'does not expire' do + expect(build.reload.artifacts_expired?).to be_falsey + end + + it 'does not remove files' do + expect(build.reload.artifacts_file.exists?).to be_truthy + end end context 'for expired artifacts' do - let!(:build) { create(:ci_build, artifacts_expire_at: Time.now - 7.days) } + let(:build) { create(:ci_build, artifacts_expire_at: Time.now - 7.days) } - it 'does not erase artifacts' do - expect_any_instance_of(Ci::Build).not_to have_received(:erase_artifacts!) - end - - it 'does expire' do + it 'is still expired' do expect(build.reload.artifacts_expired?).to be_truthy end end From 17c22156c5fa5663aae65178ed38cbeef9a80b7e Mon Sep 17 00:00:00 2001 From: David Alexander <davidpaulalexander@gmail.com> Date: Mon, 14 Mar 2016 09:13:35 -0400 Subject: [PATCH 204/318] Initial implementation of user access request to projects --- .../projects/project_members_controller.rb | 31 +++++++++- app/helpers/projects_helper.rb | 12 +++- app/mailers/emails/projects.rb | 42 +++++++++++++ app/models/ability.rb | 2 +- app/models/member.rb | 60 +++++++++++++++++-- app/models/members/project_member.rb | 18 ++++++ app/models/project_team.rb | 6 ++ app/services/notification_service.rb | 12 ++++ app/views/layouts/nav/_project.html.haml | 18 ++++++ ...ct_request_access_accepted_email.html.haml | 4 ++ ...ect_request_access_accepted_email.text.erb | 3 + ...ject_request_access_denied_email.html.haml | 4 ++ ...oject_request_access_denied_email.text.erb | 3 + .../project_members/_pending.html.haml | 21 +++++++ .../project_members/_project_member.html.haml | 15 ++++- .../projects/project_members/index.html.haml | 3 +- config/routes.rb | 2 + .../20160314114439_add_membership_request.rb | 5 ++ db/schema.rb | 1 + 19 files changed, 248 insertions(+), 14 deletions(-) create mode 100644 app/views/notify/project_request_access_accepted_email.html.haml create mode 100644 app/views/notify/project_request_access_accepted_email.text.erb create mode 100644 app/views/notify/project_request_access_denied_email.html.haml create mode 100644 app/views/notify/project_request_access_denied_email.text.erb create mode 100644 app/views/projects/project_members/_pending.html.haml create mode 100644 db/migrate/20160314114439_add_membership_request.rb diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index cdea5f0b776..ba5ef30be38 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -1,10 +1,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController # Authorize - before_action :authorize_admin_project_member!, except: [:leave, :index] + before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] def index @project_members = @project.project_members - @project_members = @project_members.non_invite unless can?(current_user, :admin_project, @project) + @project_members = @project_members.non_pending unless can?(current_user, :admin_project, @project) if params[:search].present? users = @project.users.search(params[:search]).to_a @@ -93,6 +93,33 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end + def request_access + redirect_path = namespace_project_path(@project.namespace, @project) + # current_user + # @project + @project_member = ProjectMember.new(source: @project, access_level: ProjectMember::DEVELOPER, user_id: current_user.id, created_by_id: current_user.id, requested: true) + @project_member.save! + + + redirect_to redirect_path, notice: 'Your request for access has been queued for review.' + end + + def approval + @project_member = @project.project_members.find(params[:id]) + + return render_403 unless can?(current_user, :update_project_member, @project_member) + + @project_member.requested = nil + @project_member.save! + + respond_to do |format| + format.html do + redirect_to namespace_project_project_members_path(@project.namespace, @project) + end + format.js { render nothing: true } + end + end + def apply_import source_project = Project.find(params[:source_project_id]) diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 5e5d170a9f3..a015b5e6a02 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,12 +1,18 @@ module ProjectsHelper def remove_from_project_team_message(project, member) - if member.user - "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" - else + if !member.user "You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" + elsif member.request? + "You are going to deny #{member.user.name}'s request to join #{project.name} project team. Are you sure?" + else + "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" end end + def approve_for_project_team_message(project, member) + "You are going to approve #{member.user.name}'s request for #{member.human_access} access to the #{project.name} project team. Are you sure?" + end + def link_to_project(project) link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index fdf1e9f5afc..6662c407c2c 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -11,6 +11,48 @@ module Emails subject: subject("Access to project was granted")) end + def project_member_requested_access(project_member_id) + @project_member = ProjectMember.find project_member_id + @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) + + project_admins = ProjectMember.in_project(@project) + .where(access_level: [Gitlab::Access::OWNER, Gitlab::Access::MASTER]) + .pluck(:notification_email) + + project_admins.each do |address| + mail(to: address, + subject: subject("Request to join project: #{@project.name_with_namespace}")) + end + end + + def project_request_access_accepted_email(project_member_id) + @project_member = ProjectMember.find project_member_id + return if @project_member.created_by.nil? + + @project = @project_member.project + + @target_url = namespace_project_url(@project.namespace, @project) + @current_user = @project_member.created_by + + mail(to: @project_member.created_by.notification_email, + subject: subject('Request for access granted')) + end + + def project_request_access_declined_email(project_member_id) + @project_member = ProjectMember.find project_member_id + return if @project_member.created_by.nil? + + @project = @project_member.project + + @target_url = namespace_project_url(@project.namespace, @project) + @current_user = @project_member.created_by + + mail(to: @project_member.created_by.notification_email, + subject: subject('Request for access declined')) + end + + def project_member_invited_email(project_member_id, token) @project_member = ProjectMember.find project_member_id @project = @project_member.project diff --git a/app/models/ability.rb b/app/models/ability.rb index aea946f9224..b3db26f989e 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -153,7 +153,7 @@ class Ability RequestStore.store[key] ||= begin # Push abilities on the users team role - rules.push(*project_team_rules(project.team, user)) + rules.push(*project_team_rules(project.team, user)) unless project.team.pending?(user) if project.owner == user || (project.group && project.group.has_owner?(user)) || diff --git a/app/models/member.rb b/app/models/member.rb index d3060f07fc0..2210e7dd66a 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -27,7 +27,12 @@ class Member < ActiveRecord::Base } scope :invite, -> { where(user_id: nil) } - scope :non_invite, -> { where("user_id IS NOT NULL") } + scope :non_invite, -> { where('user_id IS NOT NULL') } + scope :request, -> { where(requested: true) } + scope :non_request, -> { where(requested: nil) } + scope :pending, -> { where("user_id IS NULL OR requested") } + scope :non_pending, -> { self.non_invite.non_request } + scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } @@ -35,11 +40,16 @@ class Member < ActiveRecord::Base scope :owners, -> { where(access_level: OWNER) } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } + after_create :send_invite, if: :invite? - after_create :create_notification_setting, unless: :invite? - after_create :post_create_hook, unless: :invite? - after_update :post_update_hook, unless: :invite? - after_destroy :post_destroy_hook, unless: :invite? + after_create :send_request_access, if: :request? + + after_create :create_notification_setting, unless: :pending? + after_create :post_create_hook, unless: :pending? + + after_update :post_update_hook, unless: :pending? + + after_destroy :post_destroy_hook, unless: :pending? delegate :name, :username, :email, to: :user, prefix: true @@ -96,10 +106,38 @@ class Member < ActiveRecord::Base end end + def pending? + request? || invite? + end + + def request? + self.requested + end + def invite? self.invite_token.present? end + def accept_request_access! + return false unless request? + + self.request = false + saved = self.save + + after_accept_request_access if saved + + saved + end + + def decline_request_access! + return false unless request? + + destroyed = self.destroy + after_decline_request_access if destroyed + + destroyed + end + def accept_invite!(new_user) return false unless invite? @@ -153,6 +191,10 @@ class Member < ActiveRecord::Base private + def send_request_access + # override in subclass + end + def send_invite # override in subclass end @@ -169,6 +211,14 @@ class Member < ActiveRecord::Base system_hook_service.execute_hooks_for(self, :destroy) end + def after_accept_request_access + post_create_hook + end + + def after_decline_request_access + # override in subclass + end + def after_accept_invite post_create_hook end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 46955b430f3..9db8db8450d 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -107,6 +107,12 @@ class ProjectMember < Member user.todos.where(project_id: source_id).destroy_all if user end + def send_request_access + notification_service.request_access_project_member(self) + + super + end + def send_invite notification_service.invite_project_member(self, @raw_invite_token) @@ -136,6 +142,18 @@ class ProjectMember < Member super end + def after_accept_request_access + notification_service.accept_project_request_access(self) + + super + end + + def after_decline_request_access + notification_service.decline_project_request_access(self) + + super + end + def after_accept_invite notification_service.accept_project_invite(self) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index e29e854860a..769b73666ce 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -115,6 +115,12 @@ class ProjectTeam false end + def pending?(user) + project.project_members.each do |member| + return member.pending? if member.user_id == user.id + end + end + def guest?(user) max_member_access(user.id) == Gitlab::Access::GUEST end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 875a3f4fab6..e7676861e9b 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -173,6 +173,18 @@ class NotificationService end end + def request_access_project_member(project_member) + mailer.project_member_requested_access(project_member.id).deliver_later + end + + def accept_project_request_access(project_member) + mailer.project_request_access_accepted_email(project_member.id).deliver_later + end + + def decline_project_request_access(project_member) + mailer.project_request_access_declined_email(project_member.id).deliver_later + end + def invite_project_member(project_member, token) mailer.project_member_invited_email(project_member.id, token).deliver_later end diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 53d1fcc30a6..1336191bc5e 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -8,6 +8,19 @@ = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right = render 'layouts/nav/project_settings' + + - if access + %li + = link_to leave_namespace_project_project_members_path(@project.namespace, @project), + data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do + Leave Project + - else + = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), + class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do + Request Access + + + %li.divider - if can_edit %li @@ -18,6 +31,11 @@ = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do Leave Project + - else + %li + = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), + class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do + Request Access %div{ class: nav_control_class } %ul.nav-links.scrolling-tabs diff --git a/app/views/notify/project_request_access_accepted_email.html.haml b/app/views/notify/project_request_access_accepted_email.html.haml new file mode 100644 index 00000000000..dfdf82e70a5 --- /dev/null +++ b/app/views/notify/project_request_access_accepted_email.html.haml @@ -0,0 +1,4 @@ +%p + Your request to join project + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} + has been granted with #{@project_member.human_access} access. diff --git a/app/views/notify/project_request_access_accepted_email.text.erb b/app/views/notify/project_request_access_accepted_email.text.erb new file mode 100644 index 00000000000..9fb68874494 --- /dev/null +++ b/app/views/notify/project_request_access_accepted_email.text.erb @@ -0,0 +1,3 @@ +Your request to join project <%= @project.name_with_namespace %> has been granted with <%= @project_member.human_access %> access. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_request_access_denied_email.html.haml b/app/views/notify/project_request_access_denied_email.html.haml new file mode 100644 index 00000000000..8ad75b96cf9 --- /dev/null +++ b/app/views/notify/project_request_access_denied_email.html.haml @@ -0,0 +1,4 @@ +%p + Your request to join project + #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} + has been denied. diff --git a/app/views/notify/project_request_access_denied_email.text.erb b/app/views/notify/project_request_access_denied_email.text.erb new file mode 100644 index 00000000000..a9c57e4cab4 --- /dev/null +++ b/app/views/notify/project_request_access_denied_email.text.erb @@ -0,0 +1,3 @@ +Your request to join project <%= @project.name_with_namespace %> has been denied. + +<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/projects/project_members/_pending.html.haml b/app/views/projects/project_members/_pending.html.haml new file mode 100644 index 00000000000..88ac36937ac --- /dev/null +++ b/app/views/projects/project_members/_pending.html.haml @@ -0,0 +1,21 @@ +.panel.panel-default + .panel-heading + %strong #{@project.name} + candidates + %small + (#{members.count}) + .controls + = form_tag namespace_project_project_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do + .form-group + = search_field_tag :search, params[:search], { placeholder: 'Find existing member by name', class: 'form-control', spellcheck: false } + = button_tag class: 'btn', title: 'Search' do + = icon("search") + %ul.content-list + - members.each do |project_member| + = render 'project_member', member: project_member + +:javascript + $('form.member-search-form').on('submit', function (event) { + event.preventDefault(); + Turbolinks.visit(this.action + '?' + $(this).serialize()); + }); diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml index 268f140d7db..3faf5dba8a2 100644 --- a/app/views/projects/project_members/_project_member.html.haml +++ b/app/views/projects/project_members/_project_member.html.haml @@ -13,6 +13,9 @@ - if user.blocked? %label.label.label-danger %strong Blocked + - if member.request? + %span.label.label-info + Pending Approval - else = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' %strong @@ -27,7 +30,6 @@ - if can?(current_user, :admin_project_member, @project) = link_to resend_invite_namespace_project_project_member_path(@project.namespace, @project, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do Resend invite - - if can?(current_user, :admin_project_member, @project) .pull-right %strong= member.human_access @@ -35,10 +37,19 @@ = button_tag class: "btn-xs btn-grouped inline btn js-toggle-button", title: 'Edit access level', type: 'button' do = icon('pencil') + - if member.request? +   + = link_to approval_namespace_project_project_member_path(@project.namespace, @project, member), + class: "btn-xs btn btn-success", + title: 'Grant access', type: 'button' do + %i.fa.fa-check.fa-inverse - if can?(current_user, :destroy_project_member, member)   - - if current_user == user + - if member.request? + = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Deny access' do + %i.fa.fa-times.fa-inverse + - elsif current_user == user = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do = icon("sign-out") Leave diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 15dc064e7ea..d5a19799c49 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -12,8 +12,9 @@ %p.light Users with access to this project are listed below. = render "new_project_member" + = render "pending", members: @project_members.request - = render "team", members: @project_members + = render "team", members: @project_members.non_request - if @group = render "group_members", members: @group_members diff --git a/config/routes.rb b/config/routes.rb index 95fbe7dd9df..fb35bf9dcf0 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -768,6 +768,7 @@ Rails.application.routes.draw do resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ } do collection do delete :leave + post :request_access # Used for import team # from another project @@ -777,6 +778,7 @@ Rails.application.routes.draw do member do post :resend_invite + post :approval end end diff --git a/db/migrate/20160314114439_add_membership_request.rb b/db/migrate/20160314114439_add_membership_request.rb new file mode 100644 index 00000000000..319b750e6c6 --- /dev/null +++ b/db/migrate/20160314114439_add_membership_request.rb @@ -0,0 +1,5 @@ +class AddMembershipRequest < ActiveRecord::Migration + def change + add_column :members, :requested, :boolean + end +end diff --git a/db/schema.rb b/db/schema.rb index 3dccbbd50ba..b59552fbbe7 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -536,6 +536,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.string "invite_email" t.string "invite_token" t.datetime "invite_accepted_at" + t.boolean "requested" end add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree From f8290c2862c04f9f4cd4973824ea732ef7f6871b Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <zegerjan@gitlab.com> Date: Fri, 10 Jun 2016 10:00:32 +0200 Subject: [PATCH 205/318] Fix timing issues on convertion migration award emoji This commit does two things: 1. It adds logic which prevents timing issues when running the migration. During the migration, notes can be created which _should_ be award emoji and thus migrated. To prevent these timing issues, a lock is obtained on the table (MySQL) or on Transaction level (PG). 2. There was no down migration before as you'd probably lose some data. Data effected is all awards on notes. These could be migrated back, as the noteable type would just be Note, though this would litter the DB with data which should not be there. This down migration does not yet delete the table. --- ...82152_convert_award_note_to_emoji_award.rb | 37 +++++++++++++++++-- .../20160416190505_remove_note_is_award.rb | 6 --- 2 files changed, 33 insertions(+), 10 deletions(-) delete mode 100644 db/migrate/20160416190505_remove_note_is_award.rb diff --git a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb index c226bc11f6c..b523f17417a 100644 --- a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb +++ b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb @@ -1,10 +1,39 @@ # rubocop:disable all class ConvertAwardNoteToEmojiAward < ActiveRecord::Migration - def change - def up - execute "INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true)" + disable_ddl_transaction! - execute "DELETE FROM notes WHERE is_award = true" + def up + if Gitlab::Database.postgresql? + migrate_postgresql + else + migrate_mysql end end + + def down + add_column :notes, :is_award, :boolean + + # This migration does NOT move the awards on notes, if the table is dropped in another migration, these notes will be lost. + execute "INSERT INTO notes (noteable_type, noteable_id, author_id, note, created_at, updated_at, is_award) (SELECT awardable_type, awardable_id, user_id, name, created_at, updated_at, TRUE FROM award_emoji)" + end + + def migrate_postgresql + connection.transaction do + execute 'LOCK notes IN EXCLUSIVE' + migrate_notes + end + end + + def migrate_mysql + execute 'LOCK TABLES notes WRITE' + migrate_notes + ensure + execute 'UNLOCK TABLES' + end + + def migrate_notes + execute "INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true)" + execute "DELETE FROM notes WHERE is_award = true" + remove_column :notes, :is_award, :boolean + end end diff --git a/db/migrate/20160416190505_remove_note_is_award.rb b/db/migrate/20160416190505_remove_note_is_award.rb deleted file mode 100644 index dd24917feb9..00000000000 --- a/db/migrate/20160416190505_remove_note_is_award.rb +++ /dev/null @@ -1,6 +0,0 @@ -# rubocop:disable all -class RemoveNoteIsAward < ActiveRecord::Migration - def change - remove_column :notes, :is_award, :boolean - end -end From fc5b3a8fa51a4cbc116cc7e702602dd03cb726e1 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <zegerjan@gitlab.com> Date: Fri, 10 Jun 2016 17:33:23 +0200 Subject: [PATCH 206/318] Fix MySQL migration, obtain lock the right way As suggested by @yorrickpeterse in https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/4581#note_12373882 the locking of the MySQL database wasn't correct. --- ...82152_convert_award_note_to_emoji_award.rb | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb index b523f17417a..3906ab79398 100644 --- a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb +++ b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb @@ -20,20 +20,21 @@ class ConvertAwardNoteToEmojiAward < ActiveRecord::Migration def migrate_postgresql connection.transaction do execute 'LOCK notes IN EXCLUSIVE' - migrate_notes + execute "INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true)" + execute "DELETE FROM notes WHERE is_award = true" + remove_column :notes, :is_award, :boolean end end def migrate_mysql - execute 'LOCK TABLES notes WRITE' - migrate_notes + execute <<-EOF + lock tables notes WRITE, award_emoji WRITE; + INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true); + EOF + + execute "DELETE FROM notes WHERE is_award = true" + remove_column :notes, :is_award, :boolean ensure execute 'UNLOCK TABLES' end - - def migrate_notes - execute "INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true)" - execute "DELETE FROM notes WHERE is_award = true" - remove_column :notes, :is_award, :boolean - end end From d032c6b0ff77a9a7568e1be8c728e252ddc1b11a Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Mon, 13 Jun 2016 14:15:11 +0200 Subject: [PATCH 207/318] Move LOCK TABLES to a separate execute MySQL apparently doesn't support executing multiple queries in the same `execute` call so we have to use a separate one for the "LOCK TABLES" statement. --- .../20160416182152_convert_award_note_to_emoji_award.rb | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb index 3906ab79398..6d57b796151 100644 --- a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb +++ b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb @@ -27,11 +27,8 @@ class ConvertAwardNoteToEmojiAward < ActiveRecord::Migration end def migrate_mysql - execute <<-EOF - lock tables notes WRITE, award_emoji WRITE; - INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true); - EOF - + execute 'LOCK TABLES notes WRITE, award_emoji WRITE;' + execute 'INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true);' execute "DELETE FROM notes WHERE is_award = true" remove_column :notes, :is_award, :boolean ensure From c6744b49497fca340c8ee5b9913f805ec8ea9be8 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Tue, 14 Jun 2016 12:17:41 +0200 Subject: [PATCH 208/318] Fixed locking syntax for PostgreSQL --- db/migrate/20160416182152_convert_award_note_to_emoji_award.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb index 6d57b796151..95ee03611d9 100644 --- a/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb +++ b/db/migrate/20160416182152_convert_award_note_to_emoji_award.rb @@ -19,7 +19,7 @@ class ConvertAwardNoteToEmojiAward < ActiveRecord::Migration def migrate_postgresql connection.transaction do - execute 'LOCK notes IN EXCLUSIVE' + execute 'LOCK notes IN EXCLUSIVE MODE' execute "INSERT INTO award_emoji (awardable_type, awardable_id, user_id, name, created_at, updated_at) (SELECT noteable_type, noteable_id, author_id, note, created_at, updated_at FROM notes WHERE is_award = true)" execute "DELETE FROM notes WHERE is_award = true" remove_column :notes, :is_award, :boolean From 1cda245cc4f04dedfb826053c95166a141d2ca4a Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Wed, 8 Jun 2016 15:09:12 +0100 Subject: [PATCH 209/318] Forbid scripting for wiki files Wiki files (not pages - files in the repo) are just sent to the browser with whatever content-type the mime_types gem assigns to them based on their extension. As this is from the same domain as the GitLab application, this is an XSS vulnerability. Set a CSP forbidding all sources for scripting, CSS, XHR, etc. on these files. --- app/controllers/projects/wikis_controller.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/controllers/projects/wikis_controller.rb b/app/controllers/projects/wikis_controller.rb index 2aa6bed0724..7ec1e73b3be 100644 --- a/app/controllers/projects/wikis_controller.rb +++ b/app/controllers/projects/wikis_controller.rb @@ -16,6 +16,9 @@ class Projects::WikisController < Projects::ApplicationController if @page render 'show' elsif file = @project_wiki.find_file(params[:id], params[:version_id]) + response.headers['Content-Security-Policy'] = "default-src 'none'" + response.headers['X-Content-Security-Policy'] = "default-src 'none'" + if file.on_disk? send_file file.on_disk_path, disposition: 'inline' else From 120fbbd4875f340b5c863b7e0e3eabcb2796e15d Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Mon, 13 Jun 2016 18:41:37 +0200 Subject: [PATCH 210/318] Measure CPU time for instrumented methods --- CHANGELOG | 1 + doc/development/instrumentation.md | 11 ++++++----- lib/gitlab/metrics/instrumentation.rb | 11 +++++++---- spec/lib/gitlab/metrics/instrumentation_spec.rb | 4 ++-- 4 files changed, 16 insertions(+), 11 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 2aed8eb322b..e71a154d1d5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -77,6 +77,7 @@ v 8.9.0 (unreleased) - All classes in the Banzai::ReferenceParser namespace are now instrumented - Remove deprecated issues_tracker and issues_tracker_id from project model - Allow users to create confidential issues in private projects + - Measure CPU time for instrumented methods v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/doc/development/instrumentation.md b/doc/development/instrumentation.md index 9168c70945a..50d2866ca46 100644 --- a/doc/development/instrumentation.md +++ b/doc/development/instrumentation.md @@ -97,15 +97,16 @@ def #{name}(#{args_signature}) trans = Gitlab::Metrics::Instrumentation.transaction if trans - start = Time.now - retval = super - duration = (Time.now - start) * 1000.0 + start = Time.now + cpu_start = Gitlab::Metrics::System.cpu_time + retval = super + duration = (Time.now - start) * 1000.0 if duration >= Gitlab::Metrics.method_call_threshold - trans.increment(:method_duration, duration) + cpu_duration = Gitlab::Metrics::System.cpu_time - cpu_start trans.add_metric(Gitlab::Metrics::Instrumentation::SERIES, - { duration: duration }, + { duration: duration, cpu_duration: cpu_duration }, method: #{label.inspect}) end diff --git a/lib/gitlab/metrics/instrumentation.rb b/lib/gitlab/metrics/instrumentation.rb index 0f115893a15..ad9ce3fa442 100644 --- a/lib/gitlab/metrics/instrumentation.rb +++ b/lib/gitlab/metrics/instrumentation.rb @@ -149,13 +149,16 @@ module Gitlab trans = Gitlab::Metrics::Instrumentation.transaction if trans - start = Time.now - retval = super - duration = (Time.now - start) * 1000.0 + start = Time.now + cpu_start = Gitlab::Metrics::System.cpu_time + retval = super + duration = (Time.now - start) * 1000.0 if duration >= Gitlab::Metrics.method_call_threshold + cpu_duration = Gitlab::Metrics::System.cpu_time - cpu_start + trans.add_metric(Gitlab::Metrics::Instrumentation::SERIES, - { duration: duration }, + { duration: duration, cpu_duration: cpu_duration }, method: #{label.inspect}) end diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb index 220e86924a2..c6e979b69a4 100644 --- a/spec/lib/gitlab/metrics/instrumentation_spec.rb +++ b/spec/lib/gitlab/metrics/instrumentation_spec.rb @@ -57,7 +57,7 @@ describe Gitlab::Metrics::Instrumentation do and_return(transaction) expect(transaction).to receive(:add_metric). - with(described_class::SERIES, an_instance_of(Hash), + with(described_class::SERIES, hash_including(:duration, :cpu_duration), method: 'Dummy.foo') @dummy.foo @@ -137,7 +137,7 @@ describe Gitlab::Metrics::Instrumentation do and_return(transaction) expect(transaction).to receive(:add_metric). - with(described_class::SERIES, an_instance_of(Hash), + with(described_class::SERIES, hash_including(:duration, :cpu_duration), method: 'Dummy#bar') @dummy.new.bar From 3656a6edf37f9e24e6c080223cbfddff464e7962 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 13:04:10 +0200 Subject: [PATCH 211/318] Make retry action on pipeline to save a user --- app/controllers/projects/pipelines_controller.rb | 2 +- app/models/ci/pipeline.rb | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index cac440ae53e..127bd1a4318 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -32,7 +32,7 @@ class Projects::PipelinesController < Projects::ApplicationController end def retry - pipeline.retry_failed + pipeline.retry_failed(current_user) redirect_back_or_default default: namespace_project_pipelines_path(project.namespace, project) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 85d9e0856d1..4bbfb4cc806 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -76,8 +76,10 @@ module Ci builds.running_or_pending.each(&:cancel) end - def retry_failed - builds.latest.failed.select(&:retryable?).each(&:retry) + def retry_failed(user) + builds.latest.failed.select(&:retryable?).each do |build| + Ci::Build.retry(build, user) + end end def latest? From e8f09f02bf8b0053f276a8e5ce0bdd18c621a1a3 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 13:04:21 +0200 Subject: [PATCH 212/318] Validate environment name with regex --- app/models/environment.rb | 6 +- lib/ci/gitlab_ci_yaml_processor.rb | 8 ++- lib/gitlab/regex.rb | 8 +++ spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 66 +++++++++++++++++--- 4 files changed, 78 insertions(+), 10 deletions(-) diff --git a/app/models/environment.rb b/app/models/environment.rb index 623404ba634..b29cca8fbe2 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -3,7 +3,11 @@ class Environment < ActiveRecord::Base has_many :deployments - validates_presence_of :name + validates :name, + presence: true, + length: { within: 0..255 }, + format: { with: Gitlab::Regex.environment_name_regex, + message: Gitlab::Regex.environment_name_regex_message } def last_deployment deployments.last diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 66f1bcea4ff..b19ce4aaff9 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -214,8 +214,8 @@ module Ci raise ValidationError, "#{name} job: when parameter should be on_success, on_failure or always" end - if job[:environment] && !validate_string(job[:environment]) - raise ValidationError, "#{name} job: environment should be a string" + if job[:environment] && !validate_environment(job[:environment]) + raise ValidationError, "#{name} job: environment parameter #{Gitlab::Regex.environment_name_regex_message}" end end @@ -322,6 +322,10 @@ module Ci value.in?([true, false]) end + def validate_environment(value) + value.is_a?(String) && value =~ Gitlab::Regex.environment_name_regex + end + def process?(only_params, except_params, ref, tag, trigger_request) if only_params.present? return false unless matching?(only_params, ref, tag, trigger_request) diff --git a/lib/gitlab/regex.rb b/lib/gitlab/regex.rb index 1cbd6d945a0..c84c68f96f6 100644 --- a/lib/gitlab/regex.rb +++ b/lib/gitlab/regex.rb @@ -100,5 +100,13 @@ module Gitlab def container_registry_reference_regex git_reference_regex end + + def environment_name_regex + @environment_name_regex ||= /\A[a-zA-Z0-9_-]+\z/.freeze + end + + def environment_name_regex_message + "can contain only letters, digits, '-' and '_'." + end end end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 304290d6608..530aa79955a 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -26,7 +26,8 @@ module Ci tag_list: [], options: {}, allow_failure: false, - when: "on_success" + when: "on_success", + environment: nil, }) end @@ -387,7 +388,8 @@ module Ci services: ["mysql"] }, allow_failure: false, - when: "on_success" + when: "on_success", + environment: nil, }) end @@ -415,7 +417,8 @@ module Ci services: ["postgresql"] }, allow_failure: false, - when: "on_success" + when: "on_success", + environment: nil, }) end end @@ -599,7 +602,8 @@ module Ci } }, when: "on_success", - allow_failure: false + allow_failure: false, + environment: nil, }) end @@ -621,6 +625,51 @@ module Ci end end + describe '#environment' do + let(:config) do + { + deploy_to_production: { stage: 'deploy', script: 'test', environment: environment } + } + end + + let(:processor) { GitlabCiYamlProcessor.new(YAML.dump(config)) } + let(:builds) { processor.builds_for_stage_and_ref('deploy', 'master') } + + context 'when a production environment is specified' do + let(:environment) { 'production' } + + it 'does return production' do + expect(builds.size).to eq(1) + expect(builds.first[:environment]).to eq(environment) + end + end + + context 'when no environment is specified' do + let(:environment) { nil } + + it 'does return nil environment' do + expect(builds.size).to eq(1) + expect(builds.first[:environment]).to be_nil + end + end + + context 'is not a string' do + let(:environment) { 1 } + + it 'raises error' do + expect { builds }.to raise_error("deploy_to_production job: environment parameter #{Gitlab::Regex.environment_name_regex_message}") + end + end + + context 'is not a valid string' do + let(:environment) { 'production staging' } + + it 'raises error' do + expect { builds }.to raise_error("deploy_to_production job: environment parameter #{Gitlab::Regex.environment_name_regex_message}") + end + end + end + describe "Dependencies" do let(:config) do { @@ -682,7 +731,8 @@ module Ci tag_list: [], options: {}, when: "on_success", - allow_failure: false + allow_failure: false, + environment: nil, }) end end @@ -727,7 +777,8 @@ module Ci tag_list: [], options: {}, when: "on_success", - allow_failure: false + allow_failure: false, + environment: nil, }) expect(subject.second).to eq({ except: nil, @@ -739,7 +790,8 @@ module Ci tag_list: [], options: {}, when: "on_success", - allow_failure: false + allow_failure: false, + environment: nil, }) end end From 509082bafb01e39f4dac6f45b4ea98129ed5109c Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Mon, 13 Jun 2016 16:23:17 +0200 Subject: [PATCH 213/318] Instrument Grape Endpoint with Metrics::RackMiddleware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generating the following tags Grape#GET /projects/:id/archive from Grape::Route objects like { :path => /:version/projects/:id/archive(.:format) :version => “v3”, :method => “GET” } Use an instance variable to cache raw_path transformations. This variable is only going to growth to the number of endpoints of the API, not with exact different requests We can store this cache as an instance variable because middleware are initialised only once --- lib/gitlab/metrics/rack_middleware.rb | 25 +++++++++++++++- .../gitlab/metrics/rack_middleware_spec.rb | 29 +++++++++++++++++++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/lib/gitlab/metrics/rack_middleware.rb b/lib/gitlab/metrics/rack_middleware.rb index 6f179789d3e..3fe27779d03 100644 --- a/lib/gitlab/metrics/rack_middleware.rb +++ b/lib/gitlab/metrics/rack_middleware.rb @@ -1,8 +1,9 @@ module Gitlab module Metrics - # Rack middleware for tracking Rails requests. + # Rack middleware for tracking Rails and Grape requests. class RackMiddleware CONTROLLER_KEY = 'action_controller.instance' + ENDPOINT_KEY = 'api.endpoint' def initialize(app) @app = app @@ -21,6 +22,8 @@ module Gitlab ensure if env[CONTROLLER_KEY] tag_controller(trans, env) + elsif env[ENDPOINT_KEY] + tag_endpoint(trans, env) end trans.finish @@ -42,6 +45,26 @@ module Gitlab controller = env[CONTROLLER_KEY] trans.action = "#{controller.class.name}##{controller.action_name}" end + + def tag_endpoint(trans, env) + endpoint = env[ENDPOINT_KEY] + path = endpoint_paths_cache[endpoint.route.route_method][endpoint.route.route_path] + trans.action = "Grape##{endpoint.route.route_method} #{path}" + end + + private + + def endpoint_paths_cache + @endpoint_paths_cache ||= Hash.new do |hash, http_method| + hash[http_method] = Hash.new do |inner_hash, raw_path| + inner_hash[raw_path] = endpoint_instrumentable_path(raw_path) + end + end + end + + def endpoint_instrumentable_path(raw_path) + raw_path.sub('(.:format)', '').sub('/:version', '') + end end end end diff --git a/spec/lib/gitlab/metrics/rack_middleware_spec.rb b/spec/lib/gitlab/metrics/rack_middleware_spec.rb index b99be4e1060..40289f8b972 100644 --- a/spec/lib/gitlab/metrics/rack_middleware_spec.rb +++ b/spec/lib/gitlab/metrics/rack_middleware_spec.rb @@ -31,6 +31,20 @@ describe Gitlab::Metrics::RackMiddleware do middleware.call(env) end + + it 'tags a transaction with the method andpath of the route in the grape endpoint' do + route = double(:route, route_method: "GET", route_path: "/:version/projects/:id/archive(.:format)") + endpoint = double(:endpoint, route: route) + + env['api.endpoint'] = endpoint + + allow(app).to receive(:call).with(env) + + expect(middleware).to receive(:tag_endpoint). + with(an_instance_of(Gitlab::Metrics::Transaction), env) + + middleware.call(env) + end end describe '#transaction_from_env' do @@ -60,4 +74,19 @@ describe Gitlab::Metrics::RackMiddleware do expect(transaction.action).to eq('TestController#show') end end + + describe '#tag_endpoint' do + let(:transaction) { middleware.transaction_from_env(env) } + + it 'tags a transaction with the method and path of the route in the grape endpount' do + route = double(:route, route_method: "GET", route_path: "/:version/projects/:id/archive(.:format)") + endpoint = double(:endpoint, route: route) + + env['api.endpoint'] = endpoint + + middleware.tag_endpoint(transaction, env) + + expect(transaction.action).to eq('Grape#GET /projects/:id/archive') + end + end end From d26f81239a33b80694783ee35f0da0e2ed082c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Mon, 18 Apr 2016 18:53:32 +0200 Subject: [PATCH 214/318] Add request access for groups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/assets/stylesheets/pages/projects.scss | 4 - .../groups/group_members_controller.rb | 34 ++- .../projects/project_members_controller.rb | 44 ++-- app/helpers/groups_helper.rb | 20 -- app/helpers/members_helper.rb | 117 ++++++++ app/helpers/projects_helper.rb | 26 +- app/mailers/emails/groups.rb | 53 ++-- app/mailers/emails/projects.rb | 83 +++--- app/models/ability.rb | 8 +- app/models/concerns/access_requestable.rb | 27 ++ app/models/group.rb | 1 + app/models/member.rb | 58 ++-- app/models/members/group_member.rb | 15 +- app/models/members/project_member.rb | 30 +-- app/models/project.rb | 13 +- app/models/project_team.rb | 28 +- app/models/user.rb | 5 +- app/services/notification_service.rb | 22 +- app/views/admin/groups/show.html.haml | 2 +- app/views/admin/projects/show.html.haml | 4 +- app/views/admin/users/groups.html.haml | 2 +- app/views/admin/users/projects.html.haml | 3 +- .../group_members/_group_member.html.haml | 57 ---- .../groups/group_members/index.html.haml | 12 +- app/views/groups/group_members/update.js.haml | 2 +- .../layouts/nav/_group_settings.html.haml | 21 +- app/views/layouts/nav/_project.html.haml | 26 +- .../group_access_denied_email.html.haml | 2 + .../notify/group_access_denied_email.text.erb | 3 + .../group_access_granted_email.html.haml | 5 +- .../group_access_granted_email.text.erb | 5 +- .../group_access_requested_email.html.haml | 3 + .../group_access_requested_email.text.erb | 3 + .../project_access_denied_email.html.haml | 3 + ...b => project_access_denied_email.text.erb} | 2 +- .../project_access_granted_email.html.haml | 6 +- .../project_access_granted_email.text.erb | 5 +- .../project_access_requested_email.html.haml | 3 + .../project_access_requested_email.text.erb | 3 + ...ct_request_access_accepted_email.html.haml | 4 - ...ect_request_access_accepted_email.text.erb | 3 - ...ject_request_access_denied_email.html.haml | 4 - app/views/projects/notes/_note.html.haml | 2 +- .../project_members/_group_members.html.haml | 13 +- .../_new_project_member.html.haml | 2 +- .../project_members/_pending.html.haml | 21 -- .../project_members/_project_member.html.haml | 66 ----- .../_shared_group_members.html.haml | 6 +- .../projects/project_members/_team.html.haml | 3 +- .../projects/project_members/index.html.haml | 3 +- .../_group_or_project_home_dropdown.html.haml | 30 +++ app/views/shared/groups/_group.html.haml | 2 +- app/views/shared/members/_member.html.haml | 77 ++++++ app/views/shared/members/_requests.html.haml | 10 + config/routes.rb | 13 +- .../20160314114439_add_membership_request.rb | 5 - ...60314114439_add_requested_at_to_members.rb | 5 + db/schema.rb | 2 +- features/steps/group/members.rb | 4 +- lib/api/project_members.rb | 2 +- .../groups/group_members_controller_spec.rb | 198 +++++++++++++- .../project_members_controller_spec.rb | 249 ++++++++++++++++-- .../owner_manages_access_requests_spec.rb | 52 ++++ .../members/user_requests_access_spec.rb | 54 ++++ .../master_manages_access_requests_spec.rb | 51 ++++ .../members/user_requests_access_spec.rb | 54 ++++ spec/helpers/members_helper_spec.rb | 139 ++++++++++ spec/helpers/projects_helper_spec.rb | 29 +- spec/mailers/notify_spec.rb | 100 ++++++- .../concerns/access_requestable_spec.rb | 41 +++ spec/models/group_spec.rb | 59 ++++- spec/models/member_spec.rb | 89 +++++++ spec/models/members/group_member_spec.rb | 22 +- spec/models/members/project_member_spec.rb | 22 ++ spec/models/project_spec.rb | 11 + spec/models/project_team_spec.rb | 134 ++++++---- 76 files changed, 1768 insertions(+), 573 deletions(-) create mode 100644 app/helpers/members_helper.rb create mode 100644 app/models/concerns/access_requestable.rb delete mode 100644 app/views/groups/group_members/_group_member.html.haml create mode 100644 app/views/notify/group_access_denied_email.html.haml create mode 100644 app/views/notify/group_access_denied_email.text.erb create mode 100644 app/views/notify/group_access_requested_email.html.haml create mode 100644 app/views/notify/group_access_requested_email.text.erb create mode 100644 app/views/notify/project_access_denied_email.html.haml rename app/views/notify/{project_request_access_denied_email.text.erb => project_access_denied_email.text.erb} (58%) create mode 100644 app/views/notify/project_access_requested_email.html.haml create mode 100644 app/views/notify/project_access_requested_email.text.erb delete mode 100644 app/views/notify/project_request_access_accepted_email.html.haml delete mode 100644 app/views/notify/project_request_access_accepted_email.text.erb delete mode 100644 app/views/notify/project_request_access_denied_email.html.haml delete mode 100644 app/views/projects/project_members/_pending.html.haml delete mode 100644 app/views/projects/project_members/_project_member.html.haml create mode 100644 app/views/shared/_group_or_project_home_dropdown.html.haml create mode 100644 app/views/shared/members/_member.html.haml create mode 100644 app/views/shared/members/_requests.html.haml delete mode 100644 db/migrate/20160314114439_add_membership_request.rb create mode 100644 db/migrate/20160314114439_add_requested_at_to_members.rb create mode 100644 spec/features/groups/members/owner_manages_access_requests_spec.rb create mode 100644 spec/features/groups/members/user_requests_access_spec.rb create mode 100644 spec/features/projects/members/master_manages_access_requests_spec.rb create mode 100644 spec/features/projects/members/user_requests_access_spec.rb create mode 100644 spec/helpers/members_helper_spec.rb create mode 100644 spec/models/concerns/access_requestable_spec.rb diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index bb250904255..2505deaf757 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -286,10 +286,6 @@ color: #555; } -.project_member_row form { - margin: 0; -} - .transfer-project .select2-container { min-width: 200px; } diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 48dbf656e84..2ebc506040f 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -1,11 +1,11 @@ class Groups::GroupMembersController < Groups::ApplicationController # Authorize - before_action :authorize_admin_group_member!, except: [:index, :leave] + before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] def index @project = @group.projects.find(params[:project_id]) if params[:project_id] @members = @group.group_members - @members = @members.non_invite unless can?(current_user, :admin_group, @group) + @members = @members.non_pending unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a @@ -36,7 +36,7 @@ class Groups::GroupMembersController < Groups::ApplicationController return render_403 unless can?(current_user, :destroy_group_member, @group_member) - @group_member.destroy + @group_member.request? ? @group_member.decline_request : @group_member.destroy respond_to do |format| format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } @@ -59,12 +59,20 @@ class Groups::GroupMembersController < Groups::ApplicationController end def leave - @group_member = @group.group_members.find_by(user_id: current_user) + @group_member = + @group.group_members.find_by(user_id: current_user.id) || + @group.group_members.find_by(created_by_id: current_user.id) if can?(current_user, :destroy_group_member, @group_member) + notice = + if @group_member.request? + 'You withdrawn your access request to the group.' + else + "You left #{@group.name} group." + end @group_member.destroy - redirect_to(dashboard_groups_path, notice: "You left #{group.name} group.") + redirect_to dashboard_groups_path, notice: notice else if @group.last_owner?(current_user) redirect_to(dashboard_groups_path, alert: "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group.") @@ -74,6 +82,22 @@ class Groups::GroupMembersController < Groups::ApplicationController end end + def request_access + @group.request_access(current_user) + + redirect_to group_path(@group), notice: 'Your request for access has been queued for review.' + end + + def approve + @group_member = @group.group_members.request.find(params[:id]) + + return render_403 unless can?(current_user, :update_group_member, @group_member) + + @group_member.accept_request + + redirect_to group_group_members_path(@group) + end + protected def member_params diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index ba5ef30be38..c979c5e9fa9 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -14,9 +14,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController @project_members = @project_members.order('access_level DESC') @group = @project.group + if @group @group_members = @group.group_members - @group_members = @group_members.non_invite unless can?(current_user, :admin_group, @group) + @group_members = @group_members.non_pending unless can?(current_user, :admin_group, @group) if params[:search].present? users = @group.users.search(params[:search]).to_a @@ -49,7 +50,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController return render_403 unless can?(current_user, :destroy_project_member, @project_member) - @project_member.destroy + @project_member.request? ? @project_member.decline_request : @project_member.destroy respond_to do |format| format.html do @@ -74,15 +75,20 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def leave - @project_member = @project.project_members.find_by(user_id: current_user) + @project_member = + @project.project_members.find_by(user_id: current_user.id) || + @project.project_members.find_by(created_by_id: current_user.id) if can?(current_user, :destroy_project_member, @project_member) + notice = + if @project_member.request? + 'You withdrawn your access request to the project.' + else + 'You left the project.' + end @project_member.destroy - respond_to do |format| - format.html { redirect_to dashboard_projects_path, notice: "You left the project." } - format.js { head :ok } - end + redirect_to dashboard_projects_path, notice: notice else if current_user == @project.owner message = 'You can not leave your own project. Transfer or delete the project.' @@ -94,30 +100,20 @@ class Projects::ProjectMembersController < Projects::ApplicationController end def request_access - redirect_path = namespace_project_path(@project.namespace, @project) - # current_user - # @project - @project_member = ProjectMember.new(source: @project, access_level: ProjectMember::DEVELOPER, user_id: current_user.id, created_by_id: current_user.id, requested: true) - @project_member.save! + @project.request_access(current_user) - - redirect_to redirect_path, notice: 'Your request for access has been queued for review.' + redirect_to namespace_project_path(@project.namespace, @project), + notice: 'Your request for access has been queued for review.' end - def approval - @project_member = @project.project_members.find(params[:id]) + def approve + @project_member = @project.project_members.request.find(params[:id]) return render_403 unless can?(current_user, :update_project_member, @project_member) - @project_member.requested = nil - @project_member.save! + @project_member.accept_request - respond_to do |format| - format.html do - redirect_to namespace_project_project_members_path(@project.namespace, @project) - end - format.js { render nothing: true } - end + redirect_to namespace_project_project_members_path(@project.namespace, @project) end def apply_import diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 4cac69c6795..b9211e88473 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -1,24 +1,4 @@ module GroupsHelper - def remove_user_from_group_message(group, member) - if member.user - "Are you sure you want to remove \"#{member.user.name}\" from \"#{group.name}\"?" - else - "Are you sure you want to revoke the invitation for \"#{member.invite_email}\" to join \"#{group.name}\"?" - end - end - - def leave_group_message(group) - "Are you sure you want to leave \"#{group}\" group?" - end - - def should_user_see_group_roles?(user, group) - if user - user.is_admin? || group.members.exists?(user_id: user.id) - else - false - end - end - def can_change_group_visibility_level?(group) can?(current_user, :change_visibility_level, group) end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb new file mode 100644 index 00000000000..6599c59d1c9 --- /dev/null +++ b/app/helpers/members_helper.rb @@ -0,0 +1,117 @@ +module MembersHelper + def member_class(member) + "#{member.source.class.to_s}Member".constantize + end + + def members_association(entity) + "#{entity.class.to_s.underscore}_members".to_sym + end + + def action_member_permission(action, member) + "#{action}_#{member.source.class.to_s.underscore}_member".to_sym + end + + def can_see_entity_roles?(user, entity) + return false unless user + + user.is_admin? || entity.send(members_association(entity)).exists?(user_id: user.id) + end + + def member_path(member) + case member.source + when Project + namespace_project_project_member_path(member.source.namespace, member.source, member) + when Group + group_group_member_path(member.source, member) + else + raise ArgumentError.new('Unknown object class') + end + end + + def resend_invite_member_path(member) + case member.source + when Project + resend_invite_namespace_project_project_member_path(member.source.namespace, member.source, member) + when Group + resend_invite_group_group_member_path(member.source, member) + else + raise ArgumentError.new('Unknown object class') + end + end + + def request_access_path(entity) + case entity + when Project + request_access_namespace_project_project_members_path(entity.namespace, entity) + when Group + request_access_group_group_members_path(entity) + else + raise ArgumentError.new('Unknown object class') + end + end + + def approve_request_member_path(member) + case member.source + when Project + approve_namespace_project_project_member_path(member.source.namespace, member.source, member) + when Group + approve_group_group_member_path(member.source, member) + else + raise ArgumentError.new('Unknown object class') + end + end + + def leave_path(entity) + case entity + when Project + leave_namespace_project_project_members_path(entity.namespace, entity) + when Group + leave_group_group_members_path(entity) + else + raise ArgumentError.new('Unknown object class') + end + end + + def withdraw_request_message(entity) + "Are you sure you want to withdraw your access request for the \"#{entity_name(entity)}\" #{entity_type(entity)}?" + end + + def remove_member_message(member) + entity = member.source + entity_type = entity_type(entity) + entity_name = entity_name(entity) + + if member.request? + "You are going to deny #{member.created_by.name}'s request to join the #{entity_name} #{entity_type}. Are you sure?" + elsif member.invite? + "You are going to revoke the invitation for #{member.invite_email} to join the #{entity_name} #{entity_type}. Are you sure?" + else + "You are going to remove #{member.user.name} from the #{entity_name} #{entity_type}. Are you sure?" + end + end + + def remove_member_title(member) + member.request? ? 'Deny access request' : 'Remove user' + end + + def leave_confirmation_message(entity) + "Are you sure you want to leave \"#{entity_name(entity)}\" #{entity_type(entity)}?" + end + + private + + def entity_type(entity) + entity.class.to_s.underscore + end + + def entity_name(entity) + case entity + when Project + entity.name_with_namespace + when Group + entity.name + else + raise ArgumentError.new('Unknown object class') + end + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index a015b5e6a02..03941f87b13 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,16 +1,6 @@ module ProjectsHelper - def remove_from_project_team_message(project, member) - if !member.user - "You are going to revoke the invitation for #{member.invite_email} to join #{project.name} project team. Are you sure?" - elsif member.request? - "You are going to deny #{member.user.name}'s request to join #{project.name} project team. Are you sure?" - else - "You are going to remove #{member.user.name} from #{project.name} project team. Are you sure?" - end - end - - def approve_for_project_team_message(project, member) - "You are going to approve #{member.user.name}'s request for #{member.human_access} access to the #{project.name} project team. Are you sure?" + def max_access_level(project, user) + Gitlab::Access.options_with_owner.key(project.team.max_member_access(user.id)) end def link_to_project(project) @@ -121,14 +111,6 @@ module ProjectsHelper end end - def user_max_access_in_project(user_id, project) - level = project.team.max_member_access(user_id) - - if level - Gitlab::Access.options_with_owner.key(level) - end - end - def license_short_name(project) return 'LICENSE' if project.repository.license_key.nil? @@ -292,10 +274,6 @@ module ProjectsHelper end end - def leave_project_message(project) - "Are you sure you want to leave \"#{project.name}\" project?" - end - def new_readme_path ref = @repository.root_ref if @repository ref ||= 'master' diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb index 1c43f95dc8c..fe218bfbe05 100644 --- a/app/mailers/emails/groups.rb +++ b/app/mailers/emails/groups.rb @@ -1,22 +1,38 @@ module Emails module Groups - def group_access_granted_email(group_member_id) - @group_member = GroupMember.find(group_member_id) - @group = @group_member.group + def group_access_requested_email(group_member_id) + setup_group_member_mail(group_member_id) + + @requester = @group_member.created_by + + group_admins = User.where(id: @group.group_members.admins.pluck(:user_id)).pluck(:notification_email) + + mail(to: group_admins, + subject: subject("Request to join #{@group.name} group")) + end + + def group_access_granted_email(group_member_id) + setup_group_member_mail(group_member_id) - @target_url = group_url(@group) @current_user = @group_member.user - mail(to: @group_member.user.notification_email, - subject: subject("Access to group was granted")) + mail(to: @current_user.notification_email, + subject: subject("Access to #{@group.name} group was granted")) + end + + def group_access_denied_email(group_id, user_id) + @group = Group.find(group_id) + @current_user = User.find(user_id) + @target_url = group_url(@group) + + mail(to: @current_user.notification_email, + subject: subject("Access to #{@group.name} group was denied")) end def group_member_invited_email(group_member_id, token) - @group_member = GroupMember.find group_member_id - @group = @group_member.group - @token = token + setup_group_member_mail(group_member_id) - @target_url = group_url(@group) + @token = token @current_user = @group_member.user mail(to: @group_member.invite_email, @@ -24,15 +40,12 @@ module Emails end def group_invite_accepted_email(group_member_id) - @group_member = GroupMember.find group_member_id + setup_group_member_mail(group_member_id) return if @group_member.created_by.nil? - @group = @group_member.group - - @target_url = group_url(@group) @current_user = @group_member.created_by - mail(to: @group_member.created_by.notification_email, + mail(to: @current_user.notification_email, subject: subject("Invitation accepted")) end @@ -43,10 +56,18 @@ module Emails @current_user = @created_by = User.find(created_by_id) @access_level = access_level @invite_email = invite_email - + @target_url = group_url(@group) mail(to: @created_by.notification_email, subject: subject("Invitation declined")) end + + private + + def setup_group_member_mail(group_member_id) + @group_member = GroupMember.find(group_member_id) + @group = @group_member.group + @target_url = group_url(@group) + end end end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 6662c407c2c..43a2a7e80a8 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -1,64 +1,38 @@ module Emails module Projects - def project_access_granted_email(project_member_id) - @project_member = ProjectMember.find project_member_id - @project = @project_member.project + def project_access_requested_email(project_member_id) + setup_project_member_mail(project_member_id) + + @requester = @project_member.created_by + + project_admins = User.where(id: @project.project_members.admins.pluck(:user_id)).pluck(:notification_email) + + mail(to: project_admins, + subject: subject("Request to join #{@project.name_with_namespace} project")) + end + + def project_access_granted_email(project_member_id) + setup_project_member_mail(project_member_id) - @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.user - mail(to: @project_member.user.notification_email, - subject: subject("Access to project was granted")) + mail(to: @current_user.notification_email, + subject: subject("Access to #{@project.name_with_namespace} project was granted")) end - def project_member_requested_access(project_member_id) - @project_member = ProjectMember.find project_member_id - @project = @project_member.project + def project_access_denied_email(project_id, user_id) + @project = Project.find(project_id) + @current_user = User.find(user_id) @target_url = namespace_project_url(@project.namespace, @project) - project_admins = ProjectMember.in_project(@project) - .where(access_level: [Gitlab::Access::OWNER, Gitlab::Access::MASTER]) - .pluck(:notification_email) - - project_admins.each do |address| - mail(to: address, - subject: subject("Request to join project: #{@project.name_with_namespace}")) - end + mail(to: @current_user.notification_email, + subject: subject("Access to #{@project.name_with_namespace} project was denied")) end - def project_request_access_accepted_email(project_member_id) - @project_member = ProjectMember.find project_member_id - return if @project_member.created_by.nil? - - @project = @project_member.project - - @target_url = namespace_project_url(@project.namespace, @project) - @current_user = @project_member.created_by - - mail(to: @project_member.created_by.notification_email, - subject: subject('Request for access granted')) - end - - def project_request_access_declined_email(project_member_id) - @project_member = ProjectMember.find project_member_id - return if @project_member.created_by.nil? - - @project = @project_member.project - - @target_url = namespace_project_url(@project.namespace, @project) - @current_user = @project_member.created_by - - mail(to: @project_member.created_by.notification_email, - subject: subject('Request for access declined')) - end - - def project_member_invited_email(project_member_id, token) - @project_member = ProjectMember.find project_member_id - @project = @project_member.project - @token = token + setup_project_member_mail(project_member_id) - @target_url = namespace_project_url(@project.namespace, @project) + @token = token @current_user = @project_member.user mail(to: @project_member.invite_email, @@ -66,12 +40,9 @@ module Emails end def project_invite_accepted_email(project_member_id) - @project_member = ProjectMember.find project_member_id + setup_project_member_mail(project_member_id) return if @project_member.created_by.nil? - @project = @project_member.project - - @target_url = namespace_project_url(@project.namespace, @project) @current_user = @project_member.created_by mail(to: @project_member.created_by.notification_email, @@ -117,5 +88,13 @@ module Emails reply_to: @message.reply_to, subject: @message.subject) end + + private + + def setup_project_member_mail(project_member_id) + @project_member = ProjectMember.find(project_member_id) + @project = @project_member.project + @target_url = namespace_project_url(@project.namespace, @project) + end end end diff --git a/app/models/ability.rb b/app/models/ability.rb index b3db26f989e..90156bf130c 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -153,7 +153,7 @@ class Ability RequestStore.store[key] ||= begin # Push abilities on the users team role - rules.push(*project_team_rules(project.team, user)) unless project.team.pending?(user) + rules.push(*project_team_rules(project.team, user)) if project.owner == user || (project.group && project.group.has_owner?(user)) || @@ -187,6 +187,8 @@ class Ability project_report_rules elsif team.guest?(user) project_guest_rules + else + [] end end @@ -458,6 +460,8 @@ class Ability rules << :destroy_group_member elsif user == target_user rules << :destroy_group_member + elsif subject.request? && user == subject.created_by + rules << :destroy_group_member end end @@ -477,6 +481,8 @@ class Ability rules << :destroy_project_member elsif user == target_user rules << :destroy_project_member + elsif subject.request? && user == subject.created_by + rules << :destroy_project_member end end diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb new file mode 100644 index 00000000000..cf37284e31a --- /dev/null +++ b/app/models/concerns/access_requestable.rb @@ -0,0 +1,27 @@ +# == AccessRequestable concern +# +# Contains functionality related to objects that can receive request for access. +# +# Used by Project, and Group. +# +module AccessRequestable + extend ActiveSupport::Concern + + def request_access(user) + members.create( + access_level: Gitlab::Access::DEVELOPER, + created_by: user, + requested_at: Time.now.utc) + end + + def access_requested?(user) + members.where(created_by_id: user.id).where.not(requested_at: nil).any? + end + + private + + # Returns a `<entities>_members` association, e.g.: project_members, group_members + def members + @members ||= send("#{self.class.to_s.underscore}_members".to_sym) + end +end diff --git a/app/models/group.rb b/app/models/group.rb index aec92e335e6..b6929112cba 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -3,6 +3,7 @@ require 'carrierwave/orm/activerecord' class Group < Namespace include Gitlab::ConfigHelper include Gitlab::VisibilityLevel + include AccessRequestable include Referable has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' diff --git a/app/models/member.rb b/app/models/member.rb index 2210e7dd66a..5c3a5eab406 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -8,7 +8,7 @@ class Member < ActiveRecord::Base belongs_to :user belongs_to :source, polymorphic: true - validates :user, presence: true, unless: :invite? + validates :user, presence: true, unless: :pending? validates :source, presence: true validates :user_id, uniqueness: { scope: [:source_type, :source_id], message: "already exists in source", @@ -26,29 +26,25 @@ class Member < ActiveRecord::Base allow_nil: true } - scope :invite, -> { where(user_id: nil) } - scope :non_invite, -> { where('user_id IS NOT NULL') } - scope :request, -> { where(requested: true) } - scope :non_request, -> { where(requested: nil) } - scope :pending, -> { where("user_id IS NULL OR requested") } - scope :non_pending, -> { self.non_invite.non_request } + scope :invite, -> { where.not(invite_token: nil) } + scope :request, -> { where.not(requested_at: nil) } + scope :non_request, -> { where(requested_at: nil) } + scope :non_pending, -> { where.not(user_id: nil) } scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } scope :masters, -> { where(access_level: MASTER) } scope :owners, -> { where(access_level: OWNER) } + scope :admins, -> { where(access_level: [OWNER, MASTER]) } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } after_create :send_invite, if: :invite? - after_create :send_request_access, if: :request? - + after_create :send_request, if: :request? after_create :create_notification_setting, unless: :pending? after_create :post_create_hook, unless: :pending? - after_update :post_update_hook, unless: :pending? - after_destroy :post_destroy_hook, unless: :pending? delegate :name, :username, :email, to: :user, prefix: true @@ -111,31 +107,29 @@ class Member < ActiveRecord::Base end def request? - self.requested + user.nil? && created_by.present? && requested_at.present? end def invite? self.invite_token.present? end - def accept_request_access! + def accept_request return false unless request? - self.request = false - saved = self.save + updated = self.update(user: created_by, requested_at: nil) + after_accept_request if updated - after_accept_request_access if saved - - saved + updated end - def decline_request_access! + def decline_request return false unless request? - destroyed = self.destroy - after_decline_request_access if destroyed + self.destroy + after_decline_request if destroyed? - destroyed + destroyed? end def accept_invite!(new_user) @@ -191,11 +185,11 @@ class Member < ActiveRecord::Base private - def send_request_access + def send_invite # override in subclass end - def send_invite + def send_request # override in subclass end @@ -211,14 +205,6 @@ class Member < ActiveRecord::Base system_hook_service.execute_hooks_for(self, :destroy) end - def after_accept_request_access - post_create_hook - end - - def after_decline_request_access - # override in subclass - end - def after_accept_invite post_create_hook end @@ -227,6 +213,14 @@ class Member < ActiveRecord::Base # override in subclass end + def after_accept_request + post_create_hook + end + + def after_decline_request + # override in subclass + end + def system_hook_service SystemHooksService.new end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index f63a0debf1a..476b4816b90 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -8,9 +8,6 @@ class GroupMember < Member validates_format_of :source_type, with: /\ANamespace\z/ default_scope { where(source_type: SOURCE_TYPE) } - scope :with_group, ->(group) { where(source_id: group.id) } - scope :with_user, ->(user) { where(user_id: user.id) } - def self.access_level_roles Gitlab::Access.options_with_owner end @@ -31,6 +28,12 @@ class GroupMember < Member super end + def send_request + notification_service.new_group_access_request(self) + + super + end + def post_create_hook notification_service.new_group_member(self) @@ -56,4 +59,10 @@ class GroupMember < Member super end + + def after_decline_request + notification_service.decline_group_access_request(group, created_by) + + super + end end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index 9db8db8450d..c6fd1a5c3d1 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -11,8 +11,6 @@ class ProjectMember < Member default_scope { where(source_type: SOURCE_TYPE) } scope :in_project, ->(project) { where(source_id: project.id) } - scope :in_projects, ->(projects) { where(source_id: projects.pluck(:id)) } - scope :with_user, ->(user) { where(user_id: user.id) } before_destroy :delete_member_todos @@ -84,7 +82,7 @@ class ProjectMember < Member Gitlab::Access.sym_options end - def access_roles + def access_level_roles Gitlab::Access.options end end @@ -107,14 +105,14 @@ class ProjectMember < Member user.todos.where(project_id: source_id).destroy_all if user end - def send_request_access - notification_service.request_access_project_member(self) + def send_invite + notification_service.invite_project_member(self, @raw_invite_token) super end - def send_invite - notification_service.invite_project_member(self, @raw_invite_token) + def send_request + notification_service.new_project_access_request(self) super end @@ -142,18 +140,6 @@ class ProjectMember < Member super end - def after_accept_request_access - notification_service.accept_project_request_access(self) - - super - end - - def after_decline_request_access - notification_service.decline_project_request_access(self) - - super - end - def after_accept_invite notification_service.accept_project_invite(self) @@ -166,6 +152,12 @@ class ProjectMember < Member super end + def after_decline_request + notification_service.decline_project_access_request(project, created_by) + + super + end + def event_service EventCreateService.new end diff --git a/app/models/project.rb b/app/models/project.rb index dfa99fe0df2..ef665373495 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -5,6 +5,7 @@ class Project < ActiveRecord::Base include Gitlab::ShellAdapter include Gitlab::VisibilityLevel include Gitlab::CurrentSettings + include AccessRequestable include Referable include Sortable include AfterCommitQueue @@ -102,7 +103,7 @@ class Project < ActiveRecord::Base has_many :snippets, dependent: :destroy, class_name: 'ProjectSnippet' has_many :hooks, dependent: :destroy, class_name: 'ProjectHook' has_many :protected_branches, dependent: :destroy - has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember' + has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember' has_many :users, through: :project_members has_many :deploy_keys_projects, dependent: :destroy has_many :deploy_keys, through: :deploy_keys_projects @@ -680,16 +681,6 @@ class Project < ActiveRecord::Base end end - def project_member_by_name_or_email(name = nil, email = nil) - user = users.find_by('name like ? or email like ?', name, email) - project_members.where(user: user) if user - end - - # Get Team Member record by user id - def project_member_by_id(user_id) - project_members.find_by(user_id: user_id) - end - def name_with_namespace @name_with_namespace ||= begin if namespace diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 769b73666ce..7fb17df0e96 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -21,16 +21,6 @@ class ProjectTeam end end - def find(user_id) - user = project.users.find_by(id: user_id) - - if group - user ||= group.users.find_by(id: user_id) - end - - user - end - def find_member(user_id) member = project.project_members.find_by(user_id: user_id) @@ -61,13 +51,10 @@ class ProjectTeam ProjectMember.truncate_team(project) end - def users - members - end - def members @members ||= fetch_members end + alias_method :users, :members def guests @guests ||= fetch_members(:guests) @@ -115,12 +102,6 @@ class ProjectTeam false end - def pending?(user) - project.project_members.each do |member| - return member.pending? if member.user_id == user.id - end - end - def guest?(user) max_member_access(user.id) == Gitlab::Access::GUEST end @@ -147,10 +128,6 @@ class ProjectTeam end end - def human_max_access(user_id) - Gitlab::Access.options_with_owner.key(max_member_access(user_id)) - end - # This method assumes project and group members are eager loaded for optimal # performance. def max_member_access(user_id) @@ -179,6 +156,7 @@ class ProjectTeam access.compact.max end + private def max_invited_level(user_id) project.project_group_links.map do |group_link| @@ -195,8 +173,6 @@ class ProjectTeam end.compact.max end - private - def fetch_members(level = nil) project_members = project.project_members group_members = group ? group.group_members : [] diff --git a/app/models/user.rb b/app/models/user.rb index a5b3c8afe51..8d0427da5ab 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -56,8 +56,7 @@ class User < ActiveRecord::Base # Groups has_many :members, dependent: :destroy - has_many :project_members, source: 'ProjectMember' - has_many :group_members, source: 'GroupMember' + has_many :group_members, dependent: :destroy, source: 'GroupMember' has_many :groups, through: :group_members has_many :owned_groups, -> { where members: { access_level: Gitlab::Access::OWNER } }, through: :group_members, source: :group has_many :masters_groups, -> { where members: { access_level: Gitlab::Access::MASTER } }, through: :group_members, source: :group @@ -65,13 +64,13 @@ class User < ActiveRecord::Base # Projects has_many :groups_projects, through: :groups, source: :projects has_many :personal_projects, through: :namespace, source: :projects + has_many :project_members, dependent: :destroy, class_name: 'ProjectMember' has_many :projects, through: :project_members has_many :created_projects, foreign_key: :creator_id, class_name: 'Project' has_many :users_star_projects, dependent: :destroy has_many :starred_projects, through: :users_star_projects, source: :project has_many :snippets, dependent: :destroy, foreign_key: :author_id, class_name: "Snippet" - has_many :project_members, dependent: :destroy, class_name: 'ProjectMember' has_many :issues, dependent: :destroy, foreign_key: :author_id has_many :notes, dependent: :destroy, foreign_key: :author_id has_many :merge_requests, dependent: :destroy, foreign_key: :author_id diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index e7676861e9b..cd11feb9d7a 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -173,16 +173,13 @@ class NotificationService end end - def request_access_project_member(project_member) - mailer.project_member_requested_access(project_member.id).deliver_later + # Project access request + def new_project_access_request(project_member) + mailer.project_access_requested_email(project_member.id).deliver_later end - def accept_project_request_access(project_member) - mailer.project_request_access_accepted_email(project_member.id).deliver_later - end - - def decline_project_request_access(project_member) - mailer.project_request_access_declined_email(project_member.id).deliver_later + def decline_project_access_request(project, user) + mailer.project_access_denied_email(project.id, user.id).deliver_later end def invite_project_member(project_member, token) @@ -210,6 +207,15 @@ class NotificationService mailer.project_access_granted_email(project_member.id).deliver_later end + # Group access request + def new_group_access_request(group_member) + mailer.group_access_requested_email(group_member.id).deliver_later + end + + def decline_group_access_request(group, user) + mailer.group_access_denied_email(group.id, user.id).deliver_later + end + def invite_group_member(group_member, token) mailer.group_member_invited_email(group_member.id, token).deliver_later end diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index f309e80a39a..5b8a0262ea0 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -109,7 +109,7 @@ %span.pull-right.light = member.human_access - if can?(current_user, :destroy_group_member, member) - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(@group, member), data: { confirm: remove_member_message(member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-minus.fa-inverse .panel-footer = paginate @members, param_name: 'members_page', theme: 'gitlab' diff --git a/app/views/admin/projects/show.html.haml b/app/views/admin/projects/show.html.haml index 73986d21bcf..9e55a562e18 100644 --- a/app/views/admin/projects/show.html.haml +++ b/app/views/admin/projects/show.html.haml @@ -142,7 +142,7 @@ %i.fa.fa-pencil-square-o %ul.well-list - @group_members.each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: false + = render 'shared/members/member', member: member, show_controls: false .panel-footer = paginate @group_members, param_name: 'group_members_page', theme: 'gitlab' @@ -172,7 +172,7 @@ %span.light Owner - else %span.light= project_member.human_access - = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_from_project_team_message(@project, project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do + = link_to namespace_project_project_member_path(@project.namespace, @project, project_member), data: { confirm: remove_member_message(project_member)}, method: :delete, remote: true, class: "btn btn-sm btn-remove" do %i.fa.fa-times .panel-footer = paginate @project_members, param_name: 'project_members_page', theme: 'gitlab' diff --git a/app/views/admin/users/groups.html.haml b/app/views/admin/users/groups.html.haml index dbecb7bbfd6..b0a709a568a 100644 --- a/app/views/admin/users/groups.html.haml +++ b/app/views/admin/users/groups.html.haml @@ -13,7 +13,7 @@ .pull-right %span.light= group_member.human_access - unless group_member.owner? - = link_to group_group_member_path(group, group_member), data: { confirm: remove_user_from_group_message(group, group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = link_to group_group_member_path(group, group_member), data: { confirm: remove_member_message(group_member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do %i.fa.fa-times.fa-inverse - else .nothing-here-block This user has no groups. diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index b655b2a15f5..84b9ceb23b3 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -38,6 +38,5 @@ %span.light= member.human_access - if member.respond_to? :project - = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_from_project_team_message(project, member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do + = link_to namespace_project_project_member_path(project.namespace, project, member), data: { confirm: remove_member_message(member) }, remote: true, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from project' do %i.fa.fa-times - diff --git a/app/views/groups/group_members/_group_member.html.haml b/app/views/groups/group_members/_group_member.html.haml deleted file mode 100644 index 6bb542e658d..00000000000 --- a/app/views/groups/group_members/_group_member.html.haml +++ /dev/null @@ -1,57 +0,0 @@ -- user = member.user -- return unless user || member.invite? -- show_roles = local_assigns.fetch(:show_roles, true) - -%li{class: "#{dom_class(member)} js-toggle-container", id: dom_id(member)} - %span{class: ("list-item-name" if show_controls)} - - if member.user - = image_tag avatar_icon(user, 24), class: "avatar s24", alt: '' - %strong - = link_to user.name, user_path(user) - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked - - else - = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' - %strong - = member.invite_email - %span.cgray - invited - - if member.created_by - by - = link_to member.created_by.name, user_path(member.created_by) - = time_ago_with_tooltip(member.created_at) - - - if show_controls && can?(current_user, :admin_group_member, @group) - = link_to resend_invite_group_group_member_path(@group, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do - Resend invite - - - if show_roles && should_user_see_group_roles?(current_user, @group) - %span.pull-right - %strong.member-access-level= member.human_access - - if show_controls - - if can?(current_user, :update_group_member, member) - = button_tag class: "btn-xs btn btn-grouped inline js-toggle-button", - title: 'Edit access level', type: 'button' do - = icon('pencil') - - - if can?(current_user, :destroy_group_member, member) -   - - if current_user == user - = link_to leave_group_group_members_path(@group), data: { confirm: leave_group_message(@group.name)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do - = icon("sign-out") - Leave - - else - = link_to group_group_member_path(@group, member), data: { confirm: remove_user_from_group_message(@group, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from group' do - = icon('trash') - - .edit-member.hide.js-toggle-content - %br - = form_for [@group, member], remote: true do |f| - .prepend-top-10 - = f.select :access_level, options_for_select(GroupMember.access_level_roles, member.access_level), {}, class: 'form-control' - .prepend-top-10 - = f.submit 'Save', class: 'btn btn-save btn-sm' diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index 0eb6bbd4420..a39d5d3d0f0 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -6,12 +6,13 @@ .panel-heading Add new user to group .panel-body - - if should_user_see_group_roles?(current_user, @group) - %p.light - Members of group have access to all group projects. + %p.light + Members of group have access to all group projects. .new-group-member-holder = render "new_group_member" + = render "shared/members/requests", entity: @group, members: @members + .panel.panel-default .panel-heading %strong #{@group.name} @@ -25,9 +26,8 @@ = button_tag class: 'btn', title: 'Search' do = icon("search") %ul.content-list - - @members.each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: true - = paginate @members, theme: 'gitlab' + = render partial: 'shared/members/member', collection: @members.non_request, as: :member + = paginate @members.non_request, theme: 'gitlab' :javascript $('form.member-search-form').on('submit', function(event) { diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml index df726e2b2b9..b0b3a51ce58 100644 --- a/app/views/groups/group_members/update.js.haml +++ b/app/views/groups/group_members/update.js.haml @@ -1,2 +1,2 @@ :plain - $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member, show_controls: true))}'); + $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member))}'); diff --git a/app/views/layouts/nav/_group_settings.html.haml b/app/views/layouts/nav/_group_settings.html.haml index 0b2673f1a82..b461772b87e 100644 --- a/app/views/layouts/nav/_group_settings.html.haml +++ b/app/views/layouts/nav/_group_settings.html.haml @@ -1,20 +1,3 @@ - if current_user - - if access = @group.users.find_by(id: current_user.id) - .controls - .dropdown.group-settings-dropdown - %a.dropdown-new.btn.btn-default#group-settings-button{href: '#', 'data-toggle' => 'dropdown'} - = icon('cog') - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - - if can?(current_user, :admin_group, @group) - = nav_link(path: 'groups#projects') do - = link_to projects_group_path(@group), title: 'Projects' do - Projects - %li.divider - %li - = link_to edit_group_path(@group) do - Edit Group - %li - = link_to leave_group_group_members_path(@group), - data: { confirm: leave_group_message(@group.name) }, method: :delete, title: 'Leave group' do - Leave Group + .controls + = render 'shared/group_or_project_home_dropdown', entity: @group diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 1336191bc5e..3398794302f 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -8,19 +8,6 @@ = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right = render 'layouts/nav/project_settings' - - - if access - %li - = link_to leave_namespace_project_project_members_path(@project.namespace, @project), - data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do - Leave Project - - else - = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), - class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do - Request Access - - - %li.divider - if can_edit %li @@ -28,13 +15,18 @@ Edit Project - if access %li - = link_to leave_namespace_project_project_members_path(@project.namespace, @project), - data: { confirm: leave_project_message(@project) }, method: :delete, title: 'Leave project' do + = link_to leave_path(@project), + data: { confirm: leave_confirmation_message(@project) }, method: :delete do Leave Project + - elsif @project.access_requested?(current_user) + %li + = link_to leave_path(@project), + data: { confirm: withdraw_request_message(@project) }, method: :delete do + Withdraw Request - else %li - = link_to request_access_namespace_project_project_members_path(@project.namespace, @project), - class: 'btn btn-gray', style: 'margin-left: 10px', method: :post, title: 'Request access' do + = link_to request_access_path(@project), + class: 'btn btn-gray', style: 'margin-left: 10px', method: :post do Request Access %div{ class: nav_control_class } diff --git a/app/views/notify/group_access_denied_email.html.haml b/app/views/notify/group_access_denied_email.html.haml new file mode 100644 index 00000000000..4edfd4e4793 --- /dev/null +++ b/app/views/notify/group_access_denied_email.html.haml @@ -0,0 +1,2 @@ +%p + Your request to join group #{link_to @group.name, @target_url} has been denied. diff --git a/app/views/notify/group_access_denied_email.text.erb b/app/views/notify/group_access_denied_email.text.erb new file mode 100644 index 00000000000..cb32177e826 --- /dev/null +++ b/app/views/notify/group_access_denied_email.text.erb @@ -0,0 +1,3 @@ +Your request to join group <%= @group.name %> has been denied. + +<%= @target_url %> diff --git a/app/views/notify/group_access_granted_email.html.haml b/app/views/notify/group_access_granted_email.html.haml index f1916d624b6..1283758c576 100644 --- a/app/views/notify/group_access_granted_email.html.haml +++ b/app/views/notify/group_access_granted_email.html.haml @@ -1,4 +1,3 @@ %p - = "You have been granted #{@group_member.human_access} access to group" - = link_to group_url(@group) do - = @group.name + You have been granted #{@group_member.human_access} access to group + #{link_to @group.name, @target_url}. diff --git a/app/views/notify/group_access_granted_email.text.erb b/app/views/notify/group_access_granted_email.text.erb index ef9617bfc16..c7568350075 100644 --- a/app/views/notify/group_access_granted_email.text.erb +++ b/app/views/notify/group_access_granted_email.text.erb @@ -1,4 +1,3 @@ +You have been granted <%= @group_member.human_access %> access to group <%= @group.name %>. -You have been granted <%= @group_member.human_access %> access to group <%= @group.name %> - -<%= url_for(group_url(@group)) %> +<%= @target_url %> diff --git a/app/views/notify/group_access_requested_email.html.haml b/app/views/notify/group_access_requested_email.html.haml new file mode 100644 index 00000000000..4fbcedabae0 --- /dev/null +++ b/app/views/notify/group_access_requested_email.html.haml @@ -0,0 +1,3 @@ +%p + #{link_to @requester.name, @requester} requested #{@group_member.human_access} + access to group #{link_to @group.name, @target_url}. diff --git a/app/views/notify/group_access_requested_email.text.erb b/app/views/notify/group_access_requested_email.text.erb new file mode 100644 index 00000000000..2f9d293a79e --- /dev/null +++ b/app/views/notify/group_access_requested_email.text.erb @@ -0,0 +1,3 @@ +<%= @requester.name %> (<%= user_url(@requester) %>) requested <%= @group_member.human_access %> access to group <%= @group.name %> + +<%= @target_url %> diff --git a/app/views/notify/project_access_denied_email.html.haml b/app/views/notify/project_access_denied_email.html.haml new file mode 100644 index 00000000000..cecdaf24f39 --- /dev/null +++ b/app/views/notify/project_access_denied_email.html.haml @@ -0,0 +1,3 @@ +%p + Your request to join project #{link_to @project.name_with_namespace, @target_url} + has been denied. diff --git a/app/views/notify/project_request_access_denied_email.text.erb b/app/views/notify/project_access_denied_email.text.erb similarity index 58% rename from app/views/notify/project_request_access_denied_email.text.erb rename to app/views/notify/project_access_denied_email.text.erb index a9c57e4cab4..24357e059d2 100644 --- a/app/views/notify/project_request_access_denied_email.text.erb +++ b/app/views/notify/project_access_denied_email.text.erb @@ -1,3 +1,3 @@ Your request to join project <%= @project.name_with_namespace %> has been denied. -<%= namespace_project_url(@project.namespace, @project) %> +<%= @target_url %> diff --git a/app/views/notify/project_access_granted_email.html.haml b/app/views/notify/project_access_granted_email.html.haml index dfc30a2d360..88873e7fe52 100644 --- a/app/views/notify/project_access_granted_email.html.haml +++ b/app/views/notify/project_access_granted_email.html.haml @@ -1,5 +1,3 @@ %p - = "You have been granted #{@project_member.human_access} access to project" -%p - = link_to namespace_project_url(@project.namespace, @project) do - = @project.name_with_namespace + You have been granted #{@project_member.human_access} access to project + #{link_to @project.name_with_namespace, @target_url}. diff --git a/app/views/notify/project_access_granted_email.text.erb b/app/views/notify/project_access_granted_email.text.erb index 68eb1611ba7..f5e4b313858 100644 --- a/app/views/notify/project_access_granted_email.text.erb +++ b/app/views/notify/project_access_granted_email.text.erb @@ -1,4 +1,3 @@ +You have been granted <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %>. -You have been granted <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %> - -<%= url_for(namespace_project_url(@project.namespace, @project)) %> +<%= @target_url %> diff --git a/app/views/notify/project_access_requested_email.html.haml b/app/views/notify/project_access_requested_email.html.haml new file mode 100644 index 00000000000..2a705ad3b0a --- /dev/null +++ b/app/views/notify/project_access_requested_email.html.haml @@ -0,0 +1,3 @@ +%p + #{link_to @requester.name, @requester} requested #{@project_member.human_access} + access to project #{link_to @project.name_with_namespace, @target_url}. diff --git a/app/views/notify/project_access_requested_email.text.erb b/app/views/notify/project_access_requested_email.text.erb new file mode 100644 index 00000000000..2437fa4ee86 --- /dev/null +++ b/app/views/notify/project_access_requested_email.text.erb @@ -0,0 +1,3 @@ +<%= @requester.name %> (<%= user_url(@requester) %>) requested <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %>. + +<%= @target_url %> diff --git a/app/views/notify/project_request_access_accepted_email.html.haml b/app/views/notify/project_request_access_accepted_email.html.haml deleted file mode 100644 index dfdf82e70a5..00000000000 --- a/app/views/notify/project_request_access_accepted_email.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%p - Your request to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} - has been granted with #{@project_member.human_access} access. diff --git a/app/views/notify/project_request_access_accepted_email.text.erb b/app/views/notify/project_request_access_accepted_email.text.erb deleted file mode 100644 index 9fb68874494..00000000000 --- a/app/views/notify/project_request_access_accepted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -Your request to join project <%= @project.name_with_namespace %> has been granted with <%= @project_member.human_access %> access. - -<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_request_access_denied_email.html.haml b/app/views/notify/project_request_access_denied_email.html.haml deleted file mode 100644 index 8ad75b96cf9..00000000000 --- a/app/views/notify/project_request_access_denied_email.html.haml +++ /dev/null @@ -1,4 +0,0 @@ -%p - Your request to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)} - has been denied. diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index bcdbff08011..112a532f9d3 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -17,7 +17,7 @@ %a{ href: "##{dom_id(note)}" } = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') .note-actions - - access = note.project.team.human_max_access(note.author.id) + - access = max_access_level(note.project, note.author) - if access %span.note-role.hidden-xs= access - if current_user diff --git a/app/views/projects/project_members/_group_members.html.haml b/app/views/projects/project_members/_group_members.html.haml index 6671ee2c6d6..78c12d52a78 100644 --- a/app/views/projects/project_members/_group_members.html.haml +++ b/app/views/projects/project_members/_group_members.html.haml @@ -9,8 +9,13 @@ = link_to group_group_members_path(@group), class: 'btn' do Manage group members %ul.content-list - - members.limit(20).each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: false - - if members.count > 20 + = render partial: 'shared/members/member', + collection: members.limit(20), + as: :member, + locals: { show_controls: false } + - if members.size > 20 %li - and #{members.count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(@group)} + and + = members.size - 20 + more. For full list visit + = link_to 'group members page', group_group_members_path(@group) diff --git a/app/views/projects/project_members/_new_project_member.html.haml b/app/views/projects/project_members/_new_project_member.html.haml index f0f3bb3c177..82892a33358 100644 --- a/app/views/projects/project_members/_new_project_member.html.haml +++ b/app/views/projects/project_members/_new_project_member.html.haml @@ -9,7 +9,7 @@ .form-group = f.label :access_level, "Project Access", class: 'control-label' .col-sm-10 - = select_tag :access_level, options_for_select(ProjectMember.access_roles, @project_member.access_level), class: "project-access-select select2" + = select_tag :access_level, options_for_select(ProjectMember.access_level_roles, @project_member.access_level), class: "project-access-select select2" .help-block Read more about role permissions %strong= link_to "here", help_page_path("permissions", "permissions"), class: "vlink" diff --git a/app/views/projects/project_members/_pending.html.haml b/app/views/projects/project_members/_pending.html.haml deleted file mode 100644 index 88ac36937ac..00000000000 --- a/app/views/projects/project_members/_pending.html.haml +++ /dev/null @@ -1,21 +0,0 @@ -.panel.panel-default - .panel-heading - %strong #{@project.name} - candidates - %small - (#{members.count}) - .controls - = form_tag namespace_project_project_members_path(@project.namespace, @project), method: :get, class: 'form-inline member-search-form' do - .form-group - = search_field_tag :search, params[:search], { placeholder: 'Find existing member by name', class: 'form-control', spellcheck: false } - = button_tag class: 'btn', title: 'Search' do - = icon("search") - %ul.content-list - - members.each do |project_member| - = render 'project_member', member: project_member - -:javascript - $('form.member-search-form').on('submit', function (event) { - event.preventDefault(); - Turbolinks.visit(this.action + '?' + $(this).serialize()); - }); diff --git a/app/views/projects/project_members/_project_member.html.haml b/app/views/projects/project_members/_project_member.html.haml deleted file mode 100644 index 3faf5dba8a2..00000000000 --- a/app/views/projects/project_members/_project_member.html.haml +++ /dev/null @@ -1,66 +0,0 @@ -- user = member.user -- return unless user || member.invite? - -%li{class: "#{dom_class(member)} js-toggle-container project_member_row access-#{member.human_access.downcase}", id: dom_id(member)} - %span.list-item-name - - if member.user - = image_tag avatar_icon(user, 24), class: "avatar s24", alt: '' - %strong - = link_to user.name, user_path(user) - %span.cgray= user.username - - if user == current_user - %span.label.label-success It's you - - if user.blocked? - %label.label.label-danger - %strong Blocked - - if member.request? - %span.label.label-info - Pending Approval - - else - = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' - %strong - = member.invite_email - %span.cgray - invited - - if member.created_by - by - = link_to member.created_by.name, user_path(member.created_by) - = time_ago_with_tooltip(member.created_at) - - - if can?(current_user, :admin_project_member, @project) - = link_to resend_invite_namespace_project_project_member_path(@project.namespace, @project, member), method: :post, class: "btn-xs btn", title: 'Resend invite' do - Resend invite - - if can?(current_user, :admin_project_member, @project) - .pull-right - %strong= member.human_access - - if can?(current_user, :update_project_member, member) - = button_tag class: "btn-xs btn-grouped inline btn js-toggle-button", - title: 'Edit access level', type: 'button' do - = icon('pencil') - - if member.request? -   - = link_to approval_namespace_project_project_member_path(@project.namespace, @project, member), - class: "btn-xs btn btn-success", - title: 'Grant access', type: 'button' do - %i.fa.fa-check.fa-inverse - - - if can?(current_user, :destroy_project_member, member) -   - - if member.request? - = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Deny access' do - %i.fa.fa-times.fa-inverse - - elsif current_user == user - = link_to leave_namespace_project_project_members_path(@project.namespace, @project), data: { confirm: leave_project_message(@project) }, method: :delete, class: "btn-xs btn btn-remove", title: 'Leave project' do - = icon("sign-out") - Leave - - else - = link_to namespace_project_project_member_path(@project.namespace, @project, member), data: { confirm: remove_from_project_team_message(@project, member) }, method: :delete, remote: true, class: "btn-xs btn btn-remove", title: 'Remove user from team' do - = icon('trash') - - .edit-member.hide.js-toggle-content - %br - = form_for member, as: :project_member, url: namespace_project_project_member_path(@project.namespace, @project, member), remote: true do |f| - .prepend-top-10 - = f.select :access_level, options_for_select(ProjectMember.access_roles, member.access_level), {}, class: 'form-control' - .prepend-top-10 - = f.submit 'Save', class: 'btn btn-save' diff --git a/app/views/projects/project_members/_shared_group_members.html.haml b/app/views/projects/project_members/_shared_group_members.html.haml index ae13f8428f0..952844acefc 100644 --- a/app/views/projects/project_members/_shared_group_members.html.haml +++ b/app/views/projects/project_members/_shared_group_members.html.haml @@ -14,8 +14,10 @@ %i.fa.fa-pencil-square-o Edit group members %ul.content-list - - shared_group.group_members.order('access_level DESC').limit(20).each do |member| - = render 'groups/group_members/group_member', member: member, show_controls: false, show_roles: false + = render partial: 'shared/members/member', + collection: shared_group.group_members.order(access_level: :desc).limit(20), + as: :member, + locals: { show_controls: false, show_roles: false } - if shared_group_users_count > 20 %li and #{shared_group_users_count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(shared_group)} diff --git a/app/views/projects/project_members/_team.html.haml b/app/views/projects/project_members/_team.html.haml index e8dce30425f..03207614258 100644 --- a/app/views/projects/project_members/_team.html.haml +++ b/app/views/projects/project_members/_team.html.haml @@ -11,8 +11,7 @@ = button_tag class: 'btn', title: 'Search' do = icon("search") %ul.content-list - - members.each do |project_member| - = render 'project_member', member: project_member + = render partial: 'shared/members/member', collection: members, as: :member :javascript $('form.member-search-form').on('submit', function (event) { diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index d5a19799c49..61a82724d69 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -12,7 +12,8 @@ %p.light Users with access to this project are listed below. = render "new_project_member" - = render "pending", members: @project_members.request + + = render "shared/members/requests", entity: @project, members: @project_members = render "team", members: @project_members.non_request diff --git a/app/views/shared/_group_or_project_home_dropdown.html.haml b/app/views/shared/_group_or_project_home_dropdown.html.haml new file mode 100644 index 00000000000..fb9e63f2bd4 --- /dev/null +++ b/app/views/shared/_group_or_project_home_dropdown.html.haml @@ -0,0 +1,30 @@ +- member = entity.send(members_association(entity)).find_by(user_id: current_user.id) +- can_edit = can?(current_user, "admin_#{entity.class.to_s.underscore}".to_sym, entity) + +- if member || can_edit + .dropdown.project-settings-dropdown + %a.dropdown-new.btn.btn-gray{ href: '#', id: "#{entity.class.to_s.underscore}-settings-button", data: { toggle: 'dropdown' } } + = icon('cog') + = icon('caret-down') + %ul.dropdown-menu.dropdown-menu-align-right + - if can_edit + %li + = link_to "Edit #{entity.class.to_s}", [:edit, entity] + + - if member + %li + = link_to "Leave #{entity.class.to_s}", + leave_path(entity), + method: :delete, + data: { confirm: leave_confirmation_message(entity) } +- elsif entity.access_requested?(current_user) + = link_to 'Withdraw Request', + leave_path(entity), + data: { confirm: withdraw_request_message(entity) }, + method: :delete, + class: 'btn btn-grouped btn-gray' +- else + = link_to 'Request Access', + request_access_path(entity), + method: :post, + class: 'btn btn-grouped btn-gray' diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index a25365a94f2..1ad95351005 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -9,7 +9,7 @@ = link_to edit_group_path(group), class: "btn" do = icon('cogs') - = link_to leave_group_group_members_path(group), data: { confirm: leave_group_message(group.name) }, method: :delete, class: "btn", title: 'Leave this group' do + = link_to leave_group_group_members_path(group), data: { confirm: leave_confirmation_message(group) }, method: :delete, class: "btn", title: 'Leave this group' do = icon('sign-out') .stats diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml new file mode 100644 index 00000000000..7e119155a6c --- /dev/null +++ b/app/views/shared/members/_member.html.haml @@ -0,0 +1,77 @@ +- show_roles = local_assigns.fetch(:show_roles, true) +- show_controls = local_assigns.fetch(:show_controls, true) +- user = member.request? ? member.created_by : member.user + +%li.js-toggle-container{ class: dom_class(member), id: dom_id(member) } + %span{ class: ("list-item-name" if show_controls) } + - if user + = image_tag avatar_icon(user, 24), class: "avatar s24", alt: '' + %strong + = link_to user.name, user_path(user) + %span.cgray= user.username + + - if user == current_user + %span.label.label-success It's you + + - if user.blocked? + %label.label.label-danger + %strong Blocked + + - if member.request? + %small + – Requested + = time_ago_with_tooltip(member.requested_at) + - else + = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' + %strong= member.invite_email + %span.cgray + invited + - if member.created_by + by + = link_to member.created_by.name, user_path(member.created_by) + = time_ago_with_tooltip(member.created_at) + + - if show_controls && can?(current_user, action_member_permission(:admin, member), member.source) + = link_to 'Resend invite', resend_invite_member_path(member), + method: :post, + class: 'btn-xs btn' + + - if show_roles && can_see_entity_roles?(current_user, member.source) + %span.pull-right + %strong= member.human_access + - if show_controls + - if can?(current_user, action_member_permission(:update, member), member) + = button_tag icon('pencil'), + type: 'button', + class: 'btn-xs btn btn-grouped inline js-toggle-button', + title: 'Edit access level' + + - if member.request? +   + = link_to icon('check inverse'), approve_request_member_path(member), + method: :post, + type: 'button', + class: 'btn-xs btn btn-success', + title: 'Grant access' + + - if can?(current_user, action_member_permission(:destroy, member), member) +   + - if current_user == user + = link_to leave_path(member.source), data: { confirm: leave_confirmation_message(member.source)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do + = icon("sign-out") + Leave + - else + = link_to icon('trash'), member_path(member), + method: :delete, + remote: true, + data: { confirm: remove_member_message(member) }, + class: 'btn-xs btn btn-remove', + title: remove_member_title(member) + + .edit-member.hide.js-toggle-content + %br + = form_for member_path(member), as: "#{member.source.class.to_s.underscore}_member".to_sym, remote: true do |f| + .prepend-top-10 + = f.select :access_level, options_for_select(member_class(member).access_level_roles, member.access_level), {}, class: 'form-control' + .prepend-top-10 + = f.submit 'Save', class: 'btn btn-save btn-sm' diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml new file mode 100644 index 00000000000..ffbb380f794 --- /dev/null +++ b/app/views/shared/members/_requests.html.haml @@ -0,0 +1,10 @@ +- requesters = members.request + +- if requesters.any? + .panel.panel-default + .panel-heading + %strong= entity.name + access requests + %small= "(#{requesters.size})" + %ul.content-list + = render partial: 'shared/members/member', collection: requesters, as: :member diff --git a/config/routes.rb b/config/routes.rb index fb35bf9dcf0..62c892ee9f4 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -410,8 +410,15 @@ Rails.application.routes.draw do scope module: :groups do resources :group_members, only: [:index, :create, :update, :destroy] do - post :resend_invite, on: :member - delete :leave, on: :collection + collection do + delete :leave + post :request_access + end + + member do + post :resend_invite + post :approve + end end resource :avatar, only: [:destroy] @@ -778,7 +785,7 @@ Rails.application.routes.draw do member do post :resend_invite - post :approval + post :approve end end diff --git a/db/migrate/20160314114439_add_membership_request.rb b/db/migrate/20160314114439_add_membership_request.rb deleted file mode 100644 index 319b750e6c6..00000000000 --- a/db/migrate/20160314114439_add_membership_request.rb +++ /dev/null @@ -1,5 +0,0 @@ -class AddMembershipRequest < ActiveRecord::Migration - def change - add_column :members, :requested, :boolean - end -end diff --git a/db/migrate/20160314114439_add_requested_at_to_members.rb b/db/migrate/20160314114439_add_requested_at_to_members.rb new file mode 100644 index 00000000000..273819d4cd8 --- /dev/null +++ b/db/migrate/20160314114439_add_requested_at_to_members.rb @@ -0,0 +1,5 @@ +class AddRequestedAtToMembers < ActiveRecord::Migration + def change + add_column :members, :requested_at, :datetime + end +end diff --git a/db/schema.rb b/db/schema.rb index b59552fbbe7..f425479da19 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -536,7 +536,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.string "invite_email" t.string "invite_token" t.datetime "invite_accepted_at" - t.boolean "requested" + t.datetime "requested_at" end add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb index 0706df3aec5..9de82765df1 100644 --- a/features/steps/group/members.rb +++ b/features/steps/group/members.rb @@ -128,9 +128,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps member = mary_jane_member page.within "#group_member_#{member.id}" do - page.within '.member-access-level' do - expect(page).to have_content "Developer" - end + expect(page).to have_content "Developer" end end diff --git a/lib/api/project_members.rb b/lib/api/project_members.rb index 4aefdf319c6..b703da0557a 100644 --- a/lib/api/project_members.rb +++ b/lib/api/project_members.rb @@ -46,7 +46,7 @@ module API required_attributes! [:user_id, :access_level] # either the user is already a team member or a new one - project_member = user_project.project_member_by_id(params[:user_id]) + project_member = user_project.project_member(params[:user_id]) if project_member.nil? project_member = user_project.project_members.new( user_id: params[:user_id], diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index a5986598715..aea809f890b 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -4,17 +4,211 @@ describe Groups::GroupMembersController do let(:user) { create(:user) } let(:group) { create(:group) } - context "index" do + describe '#index' do before do group.add_owner(user) stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) end it 'renders index with group members' do - get :index, group_id: group.path + get :index, group_id: group expect(response.status).to eq(200) expect(response).to render_template(:index) end end + + describe '#destroy' do + let(:group) { create(:group, :public) } + + context 'when member is not found' do + it 'returns 403' do + delete :destroy, group_id: group, + id: 42 + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:group_user) { create(:user) } + let(:member) do + group.add_developer(group_user) + group.group_members.find_by(user_id: group_user.id) + end + + context 'when user does not have enough rights' do + before do + group.add_developer(user) + sign_in(user) + end + + it 'returns 403' do + delete :destroy, group_id: group, + id: member + + expect(response.status).to eq(403) + expect(group.users).to include group_user + end + end + + context 'when user has enough rights' do + before do + group.add_owner(user) + sign_in(user) + end + + it '[HTML] removes user from members' do + delete :destroy, group_id: group, + id: member + + expect(response).to set_flash.to 'User was successfully removed from group.' + expect(response).to redirect_to(group_group_members_path(group)) + expect(group.users).not_to include group_user + end + + it '[JS] removes user from members' do + xhr :delete, :destroy, group_id: group, + id: member + + expect(response).to be_success + expect(group.users).not_to include group_user + end + end + end + end + + describe '#leave' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + context 'when member is not found' do + before { sign_in(user) } + + it 'returns 403' do + delete :leave, group_id: group + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + context 'and is not an owner' do + before do + group.add_developer(user) + sign_in(user) + end + + it 'removes user from members' do + delete :leave, group_id: group + + expect(response).to set_flash.to "You left #{group.name} group." + expect(response).to redirect_to(dashboard_groups_path) + expect(group.users).not_to include user + end + end + + context 'and is an owner' do + before do + group.add_owner(user) + sign_in(user) + end + + it 'cannot removes himself from the group' do + delete :leave, group_id: group + + expect(response).to redirect_to(dashboard_groups_path) + expect(response).to set_flash[:alert].to "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group." + expect(group.users).to include user + end + end + + context 'and is a requester' do + before do + group.request_access(user) + sign_in(user) + end + + it 'removes user from members' do + delete :leave, group_id: group + + expect(response).to set_flash.to 'You withdrawn your access request to the group.' + expect(response).to redirect_to(dashboard_groups_path) + expect(group.group_members.request).to be_empty + expect(group.users).not_to include user + end + end + end + end + + describe '#request_access' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'creates a new GroupMember that is not a team member' do + post :request_access, group_id: group + + expect(response).to set_flash.to 'Your request for access has been queued for review.' + expect(response).to redirect_to(group_path(group)) + expect(group.group_members.request.find_by(created_by_id: user.id).created_by).to eq user + expect(group.users).not_to include user + end + end + + describe '#approve' do + let(:group) { create(:group, :public) } + + context 'when member is not found' do + it 'returns 403' do + post :approve, group_id: group, + id: 42 + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:group_requester) { create(:user) } + let(:member) do + group.request_access(group_requester) + group.group_members.request.find_by(created_by_id: group_requester.id) + end + + context 'when user does not have enough rights' do + before do + group.add_developer(user) + sign_in(user) + end + + it 'returns 403' do + post :approve, group_id: group, + id: member + + expect(response.status).to eq(403) + expect(group.users).not_to include group_requester + end + end + + context 'when user has enough rights' do + before do + group.add_owner(user) + sign_in(user) + end + + it 'adds user to members' do + post :approve, group_id: group, + id: member + + expect(response).to redirect_to(group_group_members_path(group)) + expect(group.users).to include group_requester + end + end + end + end end diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 750fbecdd07..2ea09f43f26 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -1,22 +1,22 @@ require('spec_helper') describe Projects::ProjectMembersController do - let(:project) { create(:project) } - let(:another_project) { create(:project, :private) } - let(:user) { create(:user) } - let(:member) { create(:user) } - - before do - project.team << [user, :master] - another_project.team << [member, :guest] - sign_in(user) - end - describe '#apply_import' do + let(:project) { create(:project) } + let(:another_project) { create(:project, :private) } + let(:user) { create(:user) } + let(:member) { create(:user) } + + before do + project.team << [user, :master] + another_project.team << [member, :guest] + sign_in(user) + end + shared_context 'import applied' do before do - post(:apply_import, namespace_id: project.namespace.to_param, - project_id: project.to_param, + post(:apply_import, namespace_id: project.namespace, + project_id: project, source_project_id: another_project.id) end end @@ -48,18 +48,231 @@ describe Projects::ProjectMembersController do end describe '#index' do - let(:project) { create(:project, :private) } - context 'when user is member' do - let(:member) { create(:user) } - before do + project = create(:project, :private) + member = create(:user) project.team << [member, :guest] sign_in(member) - get :index, namespace_id: project.namespace.to_param, project_id: project.to_param + + get :index, namespace_id: project.namespace, project_id: project end it { expect(response.status).to eq(200) } end end + + describe '#destroy' do + let(:project) { create(:project, :public) } + + context 'when member is not found' do + it 'returns 404' do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: 42 + + expect(response.status).to eq(404) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:team_user) { create(:user) } + let(:member) do + project.team << [team_user, :developer] + project.project_members.find_by(user_id: team_user.id) + end + + context 'when user does not have enough rights' do + before do + project.team << [user, :developer] + sign_in(user) + end + + it 'returns 404' do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response.status).to eq(404) + expect(project.users).to include team_user + end + end + + context 'when user has enough rights' do + before do + project.team << [user, :master] + sign_in(user) + end + + it '[HTML] removes user from members' do + delete :destroy, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response).to redirect_to( + namespace_project_project_members_path(project.namespace, project) + ) + expect(project.users).not_to include team_user + end + + it '[JS] removes user from members' do + xhr :delete, :destroy, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response).to be_success + expect(project.users).not_to include team_user + end + end + end + end + + describe '#leave' do + let(:project) { create(:project, :public) } + let(:user) { create(:user) } + + context 'when member is not found' do + before { sign_in(user) } + + it 'returns 403' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response.status).to eq(403) + end + end + + context 'when member is found' do + context 'and is not an owner' do + before do + project.team << [user, :developer] + sign_in(user) + end + + it 'removes user from members' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response).to set_flash.to 'You left the project.' + expect(response).to redirect_to(dashboard_projects_path) + expect(project.users).not_to include user + end + end + + context 'and is an owner' do + before do + project.update(namespace_id: user.namespace_id) + project.team << [user, :master, user] + sign_in(user) + end + + it 'cannot removes himself from the project' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response).to redirect_to( + namespace_project_project_members_path(project.namespace, project) + ) + expect(response).to set_flash[:alert].to 'You can not leave your own project. Transfer or delete the project.' + expect(project.users).to include user + end + end + + context 'and is a requester' do + before do + project.request_access(user) + sign_in(user) + end + + it 'removes user from members' do + delete :leave, namespace_id: project.namespace, + project_id: project + + expect(response).to set_flash.to 'You withdrawn your access request to the project.' + expect(response).to redirect_to(dashboard_projects_path) + expect(project.project_members.request).to be_empty + expect(project.users).not_to include user + end + end + end + end + + describe '#request_access' do + let(:project) { create(:project, :public) } + let(:user) { create(:user) } + + before do + sign_in(user) + end + + it 'creates a new ProjectMember that is not a team member' do + post :request_access, namespace_id: project.namespace, + project_id: project + + expect(response).to set_flash.to 'Your request for access has been queued for review.' + expect(response).to redirect_to( + namespace_project_path(project.namespace, project) + ) + expect(project.project_members.request.find_by(created_by_id: user.id).created_by).to eq user + expect(project.users).not_to include user + end + end + + describe '#approve' do + let(:project) { create(:project, :public) } + + context 'when member is not found' do + it 'returns 404' do + post :approve, namespace_id: project.namespace, + project_id: project, + id: 42 + + expect(response.status).to eq(404) + end + end + + context 'when member is found' do + let(:user) { create(:user) } + let(:team_requester) { create(:user) } + let(:member) do + project.request_access(team_requester) + project.project_members.request.find_by(created_by_id: team_requester.id) + end + + context 'when user does not have enough rights' do + before do + project.team << [user, :developer] + sign_in(user) + end + + it 'returns 404' do + post :approve, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response.status).to eq(404) + expect(project.users).not_to include team_requester + end + end + + context 'when user has enough rights' do + before do + project.team << [user, :master] + sign_in(user) + end + + it 'adds user to members' do + post :approve, namespace_id: project.namespace, + project_id: project, + id: member + + expect(response).to redirect_to( + namespace_project_project_members_path(project.namespace, project) + ) + expect(project.users).to include team_requester + end + end + end + end end diff --git a/spec/features/groups/members/owner_manages_access_requests_spec.rb b/spec/features/groups/members/owner_manages_access_requests_spec.rb new file mode 100644 index 00000000000..d5b5e0e35ea --- /dev/null +++ b/spec/features/groups/members/owner_manages_access_requests_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +feature 'Groups > Members > Owner manages access requests', feature: true do + let(:user) { create(:user) } + let(:owner) { create(:user) } + let(:group) { create(:group, :public) } + + background do + group.request_access(user) + group.add_owner(owner) + login_as(owner) + end + + scenario 'owner can see access requests' do + visit group_group_members_path(group) + + expect_visible_access_request(group, user) + end + + scenario 'master can grant access' do + visit group_group_members_path(group) + + expect_visible_access_request(group, user) + + perform_enqueued_jobs do + click_on 'Grant access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{group.name} group was granted/ + end + + scenario 'master can deny access' do + visit group_group_members_path(group) + + expect_visible_access_request(group, user) + + perform_enqueued_jobs do + click_on 'Deny access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{group.name} group was denied/ + end + + + def expect_visible_access_request(group, user) + expect(group.access_requested?(user)).to be_truthy + expect(page).to have_content "#{group.name} access requests (1)" + expect(page).to have_content user.name + end +end diff --git a/spec/features/groups/members/user_requests_access_spec.rb b/spec/features/groups/members/user_requests_access_spec.rb new file mode 100644 index 00000000000..9b8492807fa --- /dev/null +++ b/spec/features/groups/members/user_requests_access_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +feature 'Groups > Members > User requests access', feature: true do + let(:user) { create(:user) } + let(:owner) { create(:user) } + let(:group) { create(:group, :public) } + + background do + group.add_owner(owner) + login_as(user) + end + + scenario 'user can request access to a group' do + visit group_path(group) + + perform_enqueued_jobs do + click_link 'Request Access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Request to join #{group.name} group/ + + expect(group.access_requested?(user)).to be_truthy + expect(page).to have_content 'Your request for access has been queued for review.' + expect(page).to have_content 'Withdraw Request' + end + + scenario 'user is not listed in the group members page' do + visit group_path(group) + + click_link 'Request Access' + + expect(group.access_requested?(user)).to be_truthy + + click_link 'Members' + + visit group_group_members_path(group) + page.within('.content') do + expect(page).not_to have_content(user.name) + end + end + + scenario 'user can withdraw its request for access' do + visit group_path(group) + click_link 'Request Access' + + expect(group.access_requested?(user)).to be_truthy + + click_link 'Withdraw Request' + + expect(group.access_requested?(user)).to be_falsey + expect(page).to have_content 'You withdrawn your access request to the group.' + end +end diff --git a/spec/features/projects/members/master_manages_access_requests_spec.rb b/spec/features/projects/members/master_manages_access_requests_spec.rb new file mode 100644 index 00000000000..1b5490ba97f --- /dev/null +++ b/spec/features/projects/members/master_manages_access_requests_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +feature 'Projects > Members > Master manages access requests', feature: true do + let(:user) { create(:user) } + let(:master) { create(:user) } + let(:project) { create(:project, :public) } + + background do + project.request_access(user) + project.team << [master, :master] + login_as(master) + end + + scenario 'master can see access requests' do + visit namespace_project_project_members_path(project.namespace, project) + + expect_visible_access_request(project, user) + end + + scenario 'master can grant access' do + visit namespace_project_project_members_path(project.namespace, project) + + expect_visible_access_request(project, user) + + perform_enqueued_jobs do + click_on 'Grant access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{project.name_with_namespace} project was granted/ + end + + scenario 'master can deny access' do + visit namespace_project_project_members_path(project.namespace, project) + + expect_visible_access_request(project, user) + + perform_enqueued_jobs do + click_on 'Deny access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{project.name_with_namespace} project was denied/ + end + + def expect_visible_access_request(project, user) + expect(project.access_requested?(user)).to be_truthy + expect(page).to have_content "#{project.name} access requests (1)" + expect(page).to have_content user.name + end +end diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb new file mode 100644 index 00000000000..58a7ec1880d --- /dev/null +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +feature 'Projects > Members > User requests access', feature: true do + let(:user) { create(:user) } + let(:master) { create(:user) } + let(:project) { create(:project, :public) } + + background do + project.team << [master, :master] + login_as(user) + end + + scenario 'user can request access to a project' do + visit namespace_project_path(project.namespace, project) + + perform_enqueued_jobs do + click_link 'Request Access' + end + + expect(ActionMailer::Base.deliveries.last.to).to eq [master.notification_email] + expect(ActionMailer::Base.deliveries.last.subject).to match /Request to join #{project.name_with_namespace} project/ + + expect(project.access_requested?(user)).to be_truthy + expect(page).to have_content 'Your request for access has been queued for review.' + expect(page).to have_content 'Withdraw Request' + end + + scenario 'user is not listed in the project members page' do + visit namespace_project_path(project.namespace, project) + + click_link 'Request Access' + + expect(project.access_requested?(user)).to be_truthy + + click_link 'Members' + + visit namespace_project_project_members_path(project.namespace, project) + page.within('.content') do + expect(page).not_to have_content(user.name) + end + end + + scenario 'user can withdraw its request for access' do + visit namespace_project_path(project.namespace, project) + click_link 'Request Access' + + expect(project.access_requested?(user)).to be_truthy + + click_link 'Withdraw Request' + + expect(project.access_requested?(user)).to be_falsey + expect(page).to have_content 'You withdrawn your access request to the project.' + end +end diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb new file mode 100644 index 00000000000..f1782146241 --- /dev/null +++ b/spec/helpers/members_helper_spec.rb @@ -0,0 +1,139 @@ +require 'spec_helper' + +describe MembersHelper do + describe '#member_class' do + let(:project_member) { build(:project_member) } + let(:group_member) { build(:group_member) } + + it { expect(member_class(project_member)).to eq ProjectMember } + it { expect(member_class(group_member)).to eq GroupMember } + end + + describe '#members_association' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + + it { expect(members_association(project)).to eq :project_members } + it { expect(members_association(group)).to eq :group_members } + end + + describe '#action_member_permission' do + let(:project_member) { build(:project_member) } + let(:group_member) { build(:group_member) } + + it { expect(action_member_permission(:admin, project_member)).to eq :admin_project_member } + it { expect(action_member_permission(:admin, group_member)).to eq :admin_group_member } + end + + describe '#can_see_entity_roles?' do + let(:project) { create(:project) } + let(:group) { create(:group) } + let(:user) { build(:user) } + let(:admin) { build(:user, :admin) } + let(:project_member) { create(:project_member, project: project) } + let(:group_member) { create(:group_member, group: group) } + + it { expect(can_see_entity_roles?(nil, project)).to be_falsy } + it { expect(can_see_entity_roles?(nil, group)).to be_falsy } + it { expect(can_see_entity_roles?(admin, project)).to be_truthy } + it { expect(can_see_entity_roles?(admin, group)).to be_truthy } + it { expect(can_see_entity_roles?(project_member.user, project)).to be_truthy } + it { expect(can_see_entity_roles?(group_member.user, group)).to be_truthy } + end + + describe '#member_path' do + let(:project_member) { create(:project_member) } + let(:group_member) { create(:group_member) } + + it { expect(member_path(project_member)).to eq namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + it { expect(member_path(group_member)).to eq group_group_member_path(group_member.source, group_member) } + it { expect { member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#resend_invite_member_path' do + let(:project_member) { create(:project_member) } + let(:group_member) { create(:group_member) } + + it { expect(resend_invite_member_path(project_member)).to eq resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + it { expect(resend_invite_member_path(group_member)).to eq resend_invite_group_group_member_path(group_member.source, group_member) } + it { expect { resend_invite_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#request_access_path' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + + it { expect(request_access_path(project)).to eq request_access_namespace_project_project_members_path(project.namespace, project) } + it { expect(request_access_path(group)).to eq request_access_group_group_members_path(group) } + it { expect { request_access_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#approve_request_member_path' do + let(:project_member) { create(:project_member) } + let(:group_member) { create(:group_member) } + + it { expect(approve_request_member_path(project_member)).to eq approve_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + it { expect(approve_request_member_path(group_member)).to eq approve_group_group_member_path(group_member.source, group_member) } + it { expect { approve_request_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#leave_path' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + + it { expect(leave_path(project)).to eq leave_namespace_project_project_members_path(project.namespace, project) } + it { expect(leave_path(group)).to eq leave_group_group_members_path(group) } + it { expect { leave_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } + end + + describe '#withdraw_request_message' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + + it { expect(withdraw_request_message(project)).to eq "Are you sure you want to withdraw your access request for the \"#{project.name_with_namespace}\" project?" } + it { expect(withdraw_request_message(group)).to eq "Are you sure you want to withdraw your access request for the \"#{group.name}\" group?" } + end + + describe '#remove_member_message' do + let(:requester) { build(:user) } + let(:project) { create(:project) } + let(:project_member) { build(:project_member, project: project) } + let(:project_member_invite) { build(:project_member, project: project).tap { |m| m.generate_invite_token! } } + let(:project_member_request) { project.request_access(requester) } + let(:group) { create(:group) } + let(:group_member) { build(:group_member, group: group) } + let(:group_member_invite) { build(:group_member, group: group).tap { |m| m.generate_invite_token! } } + let(:group_member_request) { group.request_access(requester) } + + it { expect(remove_member_message(project_member)).to eq "You are going to remove #{project_member.user.name} from the #{project.name_with_namespace} project. Are you sure?" } + it { expect(remove_member_message(project_member_invite)).to eq "You are going to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.name_with_namespace} project. Are you sure?" } + it { expect(remove_member_message(project_member_request)).to eq "You are going to deny #{requester.name}'s request to join the #{project.name_with_namespace} project. Are you sure?" } + it { expect(remove_member_message(group_member)).to eq "You are going to remove #{group_member.user.name} from the #{group.name} group. Are you sure?" } + it { expect(remove_member_message(group_member_invite)).to eq "You are going to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group. Are you sure?" } + it { expect(remove_member_message(group_member_request)).to eq "You are going to deny #{requester.name}'s request to join the #{group.name} group. Are you sure?" } + end + + describe '#remove_member_title' do + let(:requester) { build(:user) } + let(:project) { create(:project) } + let(:project_member) { build(:project_member, project: project) } + let(:project_member_request) { project.request_access(requester) } + let(:group) { create(:group) } + let(:group_member) { build(:group_member, group: group) } + let(:group_member_request) { group.request_access(requester) } + + it { expect(remove_member_title(project_member)).to eq 'Remove user' } + it { expect(remove_member_title(project_member_request)).to eq 'Deny access request' } + it { expect(remove_member_title(group_member)).to eq 'Remove user' } + it { expect(remove_member_title(group_member_request)).to eq 'Deny access request' } + end + + describe '#leave_confirmation_message' do + let(:project) { build_stubbed(:project) } + let(:group) { build_stubbed(:group) } + let(:user) { build_stubbed(:user) } + + it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave \"#{project.name_with_namespace}\" project?" } + it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave \"#{group.name}\" group?" } + end +end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index ac5af8740dc..fa81c28849e 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -1,6 +1,25 @@ require 'spec_helper' describe ProjectsHelper do + describe '#max_access_level' do + let(:master) { create(:user) } + let(:owner) { create(:user) } + let(:reporter) { create(:user) } + let(:group) { create(:group) } + let(:project) { build_stubbed(:empty_project, namespace: group) } + + before do + group.add_master(master) + group.add_owner(owner) + group.add_reporter(reporter) + end + + it { expect(max_access_level(project, master)).to eq 'Master' } + it { expect(max_access_level(project, owner)).to eq 'Owner' } + it { expect(max_access_level(project, reporter)).to eq 'Reporter' } + it { expect(max_access_level(project, build_stubbed(:user))).to be_nil } + end + describe "#project_status_css_class" do it "returns appropriate class" do expect(project_status_css_class("started")).to eq("active") @@ -45,16 +64,6 @@ describe ProjectsHelper do end end - describe 'user_max_access_in_project' do - let(:project) { create(:project) } - let(:user) { create(:user) } - before do - project.team.add_user(user, Gitlab::Access::MASTER) - end - - it { expect(helper.user_max_access_in_project(user.id, project)).to eq('Master') } - end - describe "readme_cache_key" do let(:project) { create(:project) } diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 818825b1477..2d86038030e 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -400,6 +400,54 @@ describe Notify do end end + describe 'project access requested' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:project_member) do + project.request_access(user) + project.project_members.find_by(created_by_id: user.id) + end + subject { Notify.project_access_requested_email(project_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'has the correct subject' do + is_expected.to have_subject /Request to join #{project.name_with_namespace} project/ + end + + it 'contains name of project' do + is_expected.to have_body_text /#{project.name}/ + end + + it 'contains new user role' do + is_expected.to have_body_text /#{project_member.human_access}/ + end + end + + describe 'project access denied' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:project_member) do + project.request_access(user) + project.project_members.find_by(created_by_id: user.id) + end + subject { Notify.project_access_denied_email(project.id, user.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'has the correct subject' do + is_expected.to have_subject /Access to #{project.name_with_namespace} project was denied/ + end + + it 'contains name of project' do + is_expected.to have_body_text /#{project.name}/ + end + end + describe 'project access changed' do let(:project) { create(:project) } let(:user) { create(:user) } @@ -411,7 +459,7 @@ describe Notify do it_behaves_like "a user cannot unsubscribe through footer link" it 'has the correct subject' do - is_expected.to have_subject /Access to project was granted/ + is_expected.to have_subject /Access to #{project.name_with_namespace} project was granted/ end it 'contains name of project' do @@ -535,6 +583,54 @@ describe Notify do end end + describe 'group access requested' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.group_members.find_by(created_by_id: user.id) + end + subject { Notify.group_access_requested_email(group_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'has the correct subject' do + is_expected.to have_subject /Request to join #{group.name} group/ + end + + it 'contains name of group' do + is_expected.to have_body_text /#{group.name}/ + end + + it 'contains new user role' do + is_expected.to have_body_text /#{group_member.human_access}/ + end + end + + describe 'group access denied' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.group_members.find_by(created_by_id: user.id) + end + subject { Notify.group_access_denied_email(group.id, user.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it 'has the correct subject' do + is_expected.to have_subject /Access to #{group.name} group was denied/ + end + + it 'contains name of group' do + is_expected.to have_body_text /#{group.name}/ + end + end + describe 'group access changed' do let(:group) { create(:group) } let(:user) { create(:user) } @@ -547,7 +643,7 @@ describe Notify do it_behaves_like "a user cannot unsubscribe through footer link" it 'has the correct subject' do - is_expected.to have_subject /Access to group was granted/ + is_expected.to have_subject /Access to #{group.name} group was granted/ end it 'contains name of project' do diff --git a/spec/models/concerns/access_requestable_spec.rb b/spec/models/concerns/access_requestable_spec.rb new file mode 100644 index 00000000000..2dfed1eb4c4 --- /dev/null +++ b/spec/models/concerns/access_requestable_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe AccessRequestable do + describe 'Group' do + describe '#request_access' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + it { expect(group.request_access(user)).to be_a(GroupMember) } + it { expect(group.request_access(user).user).to be_nil } + it { expect(group.request_access(user).created_by).to eq(user) } + end + + describe '#access_requested?' do + let(:group) { create(:group, :public) } + let(:user) { create(:user) } + + before { group.request_access(user) } + + it { expect(group.access_requested?(user)).to be_truthy } + end + end + + describe 'Project' do + describe '#request_access' do + let(:project) { create(:empty_project, :public) } + let(:user) { create(:user) } + + it { expect(project.request_access(user)).to be_a(ProjectMember) } + end + + describe '#access_requested?' do + let(:project) { create(:empty_project, :public) } + let(:user) { create(:user) } + + before { project.request_access(user) } + + it { expect(project.access_requested?(user)).to be_truthy } + end + end +end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 6fa16be7f04..52f9d57bc0a 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -5,7 +5,22 @@ describe Group, models: true do describe 'associations' do it { is_expected.to have_many :projects } - it { is_expected.to have_many :group_members } + it { is_expected.to have_many(:group_members).dependent(:destroy) } + it { is_expected.to have_many(:users).through(:group_members) } + it { is_expected.to have_many(:project_group_links).dependent(:destroy) } + it { is_expected.to have_many(:shared_projects).through(:project_group_links) } + it { is_expected.to have_many(:notification_settings).dependent(:destroy) } + + describe '#group_members' do + let(:user) { create(:user) } + let(:group) { create(:group) } + + before { group.request_access(user) } + + it 'does not includes membership requests' do + expect(user.group_members).to be_empty + end + end end describe 'modules' do @@ -131,4 +146,46 @@ describe Group, models: true do expect(described_class.search(group.path.upcase)).to eq([group]) end end + + describe '#has_owner?' do + before { @members = setup_group_members(group) } + + it { expect(group.has_owner?(@members[:owner])).to be_truthy } + it { expect(group.has_owner?(@members[:master])).to be_falsey } + it { expect(group.has_owner?(@members[:developer])).to be_falsey } + it { expect(group.has_owner?(@members[:reporter])).to be_falsey } + it { expect(group.has_owner?(@members[:guest])).to be_falsey } + it { expect(group.has_owner?(@members[:requester])).to be_falsey } + end + + describe '#has_master?' do + before { @members = setup_group_members(group) } + + it { expect(group.has_master?(@members[:owner])).to be_falsey } + it { expect(group.has_master?(@members[:master])).to be_truthy } + it { expect(group.has_master?(@members[:developer])).to be_falsey } + it { expect(group.has_master?(@members[:reporter])).to be_falsey } + it { expect(group.has_master?(@members[:guest])).to be_falsey } + it { expect(group.has_master?(@members[:requester])).to be_falsey } + end + + def setup_group_members(group) + members = { + owner: create(:user), + master: create(:user), + developer: create(:user), + reporter: create(:user), + guest: create(:user), + requester: create(:user) + } + + group.add_user(members[:owner], GroupMember::OWNER) + group.add_user(members[:master], GroupMember::MASTER) + group.add_user(members[:developer], GroupMember::DEVELOPER) + group.add_user(members[:reporter], GroupMember::REPORTER) + group.add_user(members[:guest], GroupMember::GUEST) + group.request_access(members[:requester]) + + members + end end diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index 6e51730eecd..a3d525d8d56 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -55,6 +55,47 @@ describe Member, models: true do end end + describe 'Scopes' do + before do + project = create(:project) + @invited_member = build(:project_member, user: nil).tap { |m| m.generate_invite_token! } + @accepted_invite_member = build(:project_member, user: nil).tap { |m| m.generate_invite_token! && m.accept_invite!(build(:user)) } + + requested_user = create(:user).tap { |u| project.request_access(u) } + @requested_member = project.project_members.find_by(created_by_id: requested_user.id) + accepted_request_user = create(:user).tap { |u| project.request_access(u) } + @accepted_request_member = project.project_members.find_by(created_by_id: accepted_request_user.id).tap { |m| m.accept_request } + end + + describe '#invite' do + it { expect(described_class.invite).to include @invited_member } + it { expect(described_class.invite).not_to include @accepted_invite_member } + it { expect(described_class.invite).not_to include @requested_member } + it { expect(described_class.invite).not_to include @accepted_request_member } + end + + describe '#request' do + it { expect(described_class.request).not_to include @invited_member } + it { expect(described_class.request).not_to include @accepted_invite_member } + it { expect(described_class.request).to include @requested_member } + it { expect(described_class.request).not_to include @accepted_request_member } + end + + describe '#non_request' do + it { expect(described_class.non_request).to include @invited_member } + it { expect(described_class.non_request).to include @accepted_invite_member } + it { expect(described_class.non_request).not_to include @requested_member } + it { expect(described_class.non_request).to include @accepted_request_member } + end + + describe '#non_pending' do + it { expect(described_class.non_pending).not_to include @invited_member } + it { expect(described_class.non_pending).to include @accepted_invite_member } + it { expect(described_class.non_pending).not_to include @requested_member } + it { expect(described_class.non_pending).to include @accepted_request_member } + end + end + describe "Delegate methods" do it { is_expected.to respond_to(:user_name) } it { is_expected.to respond_to(:user_email) } @@ -97,6 +138,54 @@ describe Member, models: true do end end + describe '#accept_request' do + let(:user) { create(:user) } + let(:member) { create(:project_member, requested_at: Time.now.utc, user: nil, created_by: user) } + + it 'returns true' do + expect(member.accept_request).to be_truthy + end + + it 'sets the user' do + member.accept_request + + expect(member.user).to eq(user) + end + + it 'clears requested_at' do + member.accept_request + + expect(member.requested_at).to be_nil + end + + it 'calls #after_accept_request' do + expect(member).to receive(:after_accept_request) + + member.accept_request + end + end + + describe '#decline_request' do + let(:user) { create(:user) } + let(:member) { create(:project_member, requested_at: Time.now.utc, user: nil, created_by: user) } + + it 'returns true' do + expect(member.decline_request).to be_truthy + end + + it 'destroys the member' do + member.decline_request + + expect(member).to be_destroyed + end + + it 'calls #after_decline_request' do + expect(member).to receive(:after_decline_request) + + member.decline_request + end + end + describe "#accept_invite!" do let!(:member) { create(:project_member, invite_email: "user@example.com", user: nil) } let(:user) { create(:user) } diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb index 5424c9b9cba..c3070d4cb78 100644 --- a/spec/models/members/group_member_spec.rb +++ b/spec/models/members/group_member_spec.rb @@ -20,7 +20,7 @@ require 'spec_helper' describe GroupMember, models: true do - context 'notification' do + describe 'notifications' do describe "#after_create" do it "should send email to user" do membership = build(:group_member) @@ -50,5 +50,25 @@ describe GroupMember, models: true do @group_member.update_attribute(:access_level, GroupMember::OWNER) end end + + describe 'after accept_request' do + let(:member) { create(:group_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + + it "calls #accept_group_access_request" do + expect_any_instance_of(NotificationService).to receive(:new_group_member) + + member.accept_request + end + end + + describe 'after decline_request' do + let(:member) { create(:group_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + + it "calls #decline_group_access_request" do + expect_any_instance_of(NotificationService).to receive(:decline_group_access_request) + + member.decline_request + end + end end end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 9f13874b532..99b3c77c6cd 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -135,4 +135,26 @@ describe ProjectMember, models: true do it { expect(@project_1.users).to be_empty } it { expect(@project_2.users).to be_empty } end + + describe 'notifications' do + describe 'after accept_request' do + let(:member) { create(:project_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + + it 'calls #accept_project_access_request' do + expect_any_instance_of(NotificationService).to receive(:new_project_member) + + member.accept_request + end + end + + describe 'after decline_request' do + let(:member) { create(:project_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + + it 'calls #decline_project_access_request' do + expect_any_instance_of(NotificationService).to receive(:decline_project_access_request) + + member.decline_request + end + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index de8815f5a38..d5a4b73affd 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -29,6 +29,17 @@ describe Project, models: true do it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:triggers) } it { is_expected.to have_many(:todos).dependent(:destroy) } + + describe '#project_members' do + let(:user) { create(:user) } + let(:project) { create(:project) } + + before { project.request_access(user) } + + it 'does not includes membership requests' do + expect(user.project_members).to be_empty + end + end end describe 'modules' do diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 8bebd6a9447..36b1f439955 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -73,69 +73,107 @@ describe ProjectTeam, models: true do end end - describe :max_invited_level do - let(:group) { create(:group) } - let(:project) { create(:empty_project) } + describe '#find_member' do + context 'personal project' do + let(:project) { create(:empty_project) } + let(:requester) { create(:user) } - before do - project.project_group_links.create( - group: group, - group_access: Gitlab::Access::DEVELOPER - ) + before do + project.team << [master, :master] + project.team << [reporter, :reporter] + project.team << [guest, :guest] + project.request_access(requester) + end - group.add_user(master, Gitlab::Access::MASTER) - group.add_user(reporter, Gitlab::Access::REPORTER) + it { expect(project.team.find_member(master.id)).to be_a(ProjectMember) } + it { expect(project.team.find_member(reporter.id)).to be_a(ProjectMember) } + it { expect(project.team.find_member(guest.id)).to be_a(ProjectMember) } + it { expect(project.team.find_member(nonmember.id)).to be_nil } + it { expect(project.team.find_member(requester.id)).to be_nil } end - it { expect(project.team.max_invited_level(master.id)).to eq(Gitlab::Access::DEVELOPER) } - it { expect(project.team.max_invited_level(reporter.id)).to eq(Gitlab::Access::REPORTER) } - it { expect(project.team.max_invited_level(nonmember.id)).to be_nil } - end + context 'group project' do + let(:group) { create(:group) } + let(:project) { create(:empty_project, group: group) } + let(:requester) { create(:user) } - describe :max_member_access do - let(:group) { create(:group) } - let(:project) { create(:empty_project) } + before do + group.add_master(master) + group.add_reporter(reporter) + group.add_guest(guest) + group.request_access(requester) + end - before do - project.project_group_links.create( - group: group, - group_access: Gitlab::Access::DEVELOPER - ) - - group.add_user(master, Gitlab::Access::MASTER) - group.add_user(reporter, Gitlab::Access::REPORTER) - end - - it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::DEVELOPER) } - it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } - it { expect(project.team.max_member_access(nonmember.id)).to be_nil } - - it "does not have an access" do - project.namespace.update(share_with_group_lock: true) - expect(project.team.max_member_access(master.id)).to be_nil - expect(project.team.max_member_access(reporter.id)).to be_nil + it { expect(project.team.find_member(master.id)).to be_a(GroupMember) } + it { expect(project.team.find_member(reporter.id)).to be_a(GroupMember) } + it { expect(project.team.find_member(guest.id)).to be_a(GroupMember) } + it { expect(project.team.find_member(nonmember.id)).to be_nil } + it { expect(project.team.find_member(requester.id)).to be_nil } end end - describe "#human_max_access" do - it 'returns Master role' do - user = create(:user) - group = create(:group) - group.add_master(user) + describe '#max_member_access' do + let(:requester) { create(:user) } - project = build_stubbed(:empty_project, namespace: group) + context 'personal project' do + let(:project) { create(:empty_project) } - expect(project.team.human_max_access(user.id)).to eq 'Master' + context 'when project is not shared with group' do + before do + project.team << [master, :master] + project.team << [reporter, :reporter] + project.team << [guest, :guest] + project.request_access(requester) + end + + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::MASTER) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_member_access(guest.id)).to eq(Gitlab::Access::GUEST) } + it { expect(project.team.max_member_access(nonmember.id)).to be_nil } + it { expect(project.team.max_member_access(requester.id)).to be_nil } + end + + context 'when project is shared with group' do + before do + group = create(:group) + project.project_group_links.create( + group: group, + group_access: Gitlab::Access::DEVELOPER) + + group.add_master(master) + group.add_reporter(reporter) + end + + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::DEVELOPER) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_member_access(nonmember.id)).to be_nil } + it { expect(project.team.max_member_access(requester.id)).to be_nil } + + context 'but share_with_group_lock is true' do + before { project.namespace.update(share_with_group_lock: true) } + + it { expect(project.team.max_member_access(master.id)).to be_nil } + it { expect(project.team.max_member_access(reporter.id)).to be_nil } + end + end end - it 'returns Owner role' do - user = create(:user) - group = create(:group) - group.add_owner(user) + context 'group project' do + let(:group) { create(:group) } + let(:project) { create(:empty_project, group: group) } - project = build_stubbed(:empty_project, namespace: group) + before do + group.add_master(master) + group.add_reporter(reporter) + group.add_guest(guest) + group.request_access(requester) + end - expect(project.team.human_max_access(user.id)).to eq 'Owner' + it { expect(project.team.max_member_access(master.id)).to eq(Gitlab::Access::MASTER) } + it { expect(project.team.max_member_access(reporter.id)).to eq(Gitlab::Access::REPORTER) } + it { expect(project.team.max_member_access(guest.id)).to eq(Gitlab::Access::GUEST) } + it { expect(project.team.max_member_access(nonmember.id)).to be_nil } + it { expect(project.team.max_member_access(requester.id)).to be_nil } end end end From d71fbe0dbdb3b7aba6f71e6d3d50daaa890769e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 1 Jun 2016 18:07:23 +0200 Subject: [PATCH 215/318] Factorize #request_access and #approve_access_request into a new AccessRequestActions controller concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- .../concerns/access_request_actions.rb | 38 +++++++++++++++++++ .../groups/group_members_controller.rb | 31 ++++++++------- .../projects/project_members_controller.rb | 32 ++++++++-------- config/routes.rb | 5 +++ .../groups/group_members_controller_spec.rb | 6 +-- .../project_members_controller_spec.rb | 6 +-- 6 files changed, 79 insertions(+), 39 deletions(-) create mode 100644 app/controllers/concerns/access_request_actions.rb diff --git a/app/controllers/concerns/access_request_actions.rb b/app/controllers/concerns/access_request_actions.rb new file mode 100644 index 00000000000..1b0a1fe3081 --- /dev/null +++ b/app/controllers/concerns/access_request_actions.rb @@ -0,0 +1,38 @@ +module AccessRequestActions + extend ActiveSupport::Concern + + def request_access + access_requestable_resource.request_access(current_user) + + redirect_to access_requestable_resource_path, + notice: 'Your request for access has been queued for review.' + end + + def approve + @member = access_requestable_resource.public_send(member_entity_name.pluralize).request.find(params[:id]) + + return render_403 unless can?(current_user, :"update_#{member_entity_name}", @member) + + @member.accept_request + + redirect_to access_requestable_resource_members_path + end + + protected + + def access_requestable_resource + raise NotImplementedError + end + + def access_requestable_resource_path + access_requestable_resource + end + + def access_requestable_resource_members_path + [access_requestable_resource, 'members'] + end + + def member_entity_name + "#{access_requestable_resource.class.to_s.underscore}_member" + end +end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 2ebc506040f..a37129062f9 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -1,4 +1,6 @@ class Groups::GroupMembersController < Groups::ApplicationController + include AccessRequestActions + # Authorize before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] @@ -82,25 +84,22 @@ class Groups::GroupMembersController < Groups::ApplicationController end end - def request_access - @group.request_access(current_user) - - redirect_to group_path(@group), notice: 'Your request for access has been queued for review.' - end - - def approve - @group_member = @group.group_members.request.find(params[:id]) - - return render_403 unless can?(current_user, :update_group_member, @group_member) - - @group_member.accept_request - - redirect_to group_group_members_path(@group) - end - protected def member_params params.require(:group_member).permit(:access_level, :user_id) end + + # AccessRequestActions concern + def access_requestable_resource + @group + end + + def access_requestable_resource_path + group_path(@group) + end + + def access_requestable_resource_members_path + group_group_members_path(@group) + end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index c979c5e9fa9..c61eda95bc7 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -1,4 +1,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController + include AccessRequestActions + # Authorize before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] @@ -99,23 +101,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end - def request_access - @project.request_access(current_user) - - redirect_to namespace_project_path(@project.namespace, @project), - notice: 'Your request for access has been queued for review.' - end - - def approve - @project_member = @project.project_members.request.find(params[:id]) - - return render_403 unless can?(current_user, :update_project_member, @project_member) - - @project_member.accept_request - - redirect_to namespace_project_project_members_path(@project.namespace, @project) - end - def apply_import source_project = Project.find(params[:source_project_id]) @@ -135,4 +120,17 @@ class Projects::ProjectMembersController < Projects::ApplicationController def member_params params.require(:project_member).permit(:user_id, :access_level) end + + # AccessRequestActions concern + def access_requestable_resource + @project + end + + def access_requestable_resource_path + namespace_project_path(@project.namespace, @project) + end + + def access_requestable_resource_members_path + namespace_project_project_members_path(@project.namespace, @project) + end end diff --git a/config/routes.rb b/config/routes.rb index 62c892ee9f4..2eccb19deff 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -30,6 +30,11 @@ Rails.application.routes.draw do mount LetterOpenerWeb::Engine, at: '/rails/letter_opener' end + concern :access_requestable do + post :request_access, on: :collection + post :approve_access_request_access_request, on: :member + end + namespace :ci do # CI API Ci::API::API.logger Rails.logger diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index aea809f890b..0ca8a656f63 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -165,7 +165,7 @@ describe Groups::GroupMembersController do context 'when member is not found' do it 'returns 403' do - post :approve, group_id: group, + post :approve_access_request, group_id: group, id: 42 expect(response.status).to eq(403) @@ -187,7 +187,7 @@ describe Groups::GroupMembersController do end it 'returns 403' do - post :approve, group_id: group, + post :approve_access_request, group_id: group, id: member expect(response.status).to eq(403) @@ -202,7 +202,7 @@ describe Groups::GroupMembersController do end it 'adds user to members' do - post :approve, group_id: group, + post :approve_access_request, group_id: group, id: member expect(response).to redirect_to(group_group_members_path(group)) diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index 2ea09f43f26..d3bd2d0bbba 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -224,7 +224,7 @@ describe Projects::ProjectMembersController do context 'when member is not found' do it 'returns 404' do - post :approve, namespace_id: project.namespace, + post :approve_access_request, namespace_id: project.namespace, project_id: project, id: 42 @@ -247,7 +247,7 @@ describe Projects::ProjectMembersController do end it 'returns 404' do - post :approve, namespace_id: project.namespace, + post :approve_access_request, namespace_id: project.namespace, project_id: project, id: member @@ -263,7 +263,7 @@ describe Projects::ProjectMembersController do end it 'adds user to members' do - post :approve, namespace_id: project.namespace, + post :approve_access_request, namespace_id: project.namespace, project_id: project, id: member From d75edf1a9854b2ab609c7d3acf5eee1ca89e8db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 1 Jun 2016 18:17:03 +0200 Subject: [PATCH 216/318] Factorize access request routes into a new :access_requestable route concern MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- .../concerns/access_request_actions.rb | 2 +- app/helpers/members_helper.rb | 4 ++-- config/routes.rb | 19 +++++-------------- spec/helpers/members_helper_spec.rb | 4 ++-- 4 files changed, 10 insertions(+), 19 deletions(-) diff --git a/app/controllers/concerns/access_request_actions.rb b/app/controllers/concerns/access_request_actions.rb index 1b0a1fe3081..c4d22749d6a 100644 --- a/app/controllers/concerns/access_request_actions.rb +++ b/app/controllers/concerns/access_request_actions.rb @@ -8,7 +8,7 @@ module AccessRequestActions notice: 'Your request for access has been queued for review.' end - def approve + def approve_access_request @member = access_requestable_resource.public_send(member_entity_name.pluralize).request.find(params[:id]) return render_403 unless can?(current_user, :"update_#{member_entity_name}", @member) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 6599c59d1c9..bd84b8b239f 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -53,9 +53,9 @@ module MembersHelper def approve_request_member_path(member) case member.source when Project - approve_namespace_project_project_member_path(member.source.namespace, member.source, member) + approve_access_request_namespace_project_project_member_path(member.source.namespace, member.source, member) when Group - approve_group_group_member_path(member.source, member) + approve_access_request_group_group_member_path(member.source, member) else raise ArgumentError.new('Unknown object class') end diff --git a/config/routes.rb b/config/routes.rb index 2eccb19deff..f5574fb99a5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -32,7 +32,7 @@ Rails.application.routes.draw do concern :access_requestable do post :request_access, on: :collection - post :approve_access_request_access_request, on: :member + post :approve_access_request, on: :member end namespace :ci do @@ -414,16 +414,9 @@ Rails.application.routes.draw do end scope module: :groups do - resources :group_members, only: [:index, :create, :update, :destroy] do - collection do - delete :leave - post :request_access - end - - member do - post :resend_invite - post :approve - end + resources :group_members, only: [:index, :create, :update, :destroy], concerns: :access_requestable do + post :resend_invite, on: :member + delete :leave, on: :collection end resource :avatar, only: [:destroy] @@ -777,10 +770,9 @@ Rails.application.routes.draw do end end - resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ } do + resources :project_members, except: [:new, :edit], constraints: { id: /[a-zA-Z.\/0-9_\-#%+]+/ }, concerns: :access_requestable do collection do delete :leave - post :request_access # Used for import team # from another project @@ -790,7 +782,6 @@ Rails.application.routes.draw do member do post :resend_invite - post :approve end end diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb index f1782146241..c2f10e1db75 100644 --- a/spec/helpers/members_helper_spec.rb +++ b/spec/helpers/members_helper_spec.rb @@ -72,8 +72,8 @@ describe MembersHelper do let(:project_member) { create(:project_member) } let(:group_member) { create(:group_member) } - it { expect(approve_request_member_path(project_member)).to eq approve_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } - it { expect(approve_request_member_path(group_member)).to eq approve_group_group_member_path(group_member.source, group_member) } + it { expect(approve_request_member_path(project_member)).to eq approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + it { expect(approve_request_member_path(group_member)).to eq approve_access_request_group_group_member_path(group_member.source, group_member) } it { expect { approve_request_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } end From 6d103a2f4764441b1650ba6d790732056c9a8516 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Thu, 2 Jun 2016 16:14:02 +0200 Subject: [PATCH 217/318] Factorize members mails into a new Emails::Members module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/mailers/emails/groups.rb | 73 ----- app/mailers/emails/members.rb | 104 +++++++ app/mailers/emails/projects.rb | 71 ----- app/mailers/notify.rb | 2 +- app/models/group.rb | 4 + app/services/notification_service.rb | 30 +- .../group_access_denied_email.html.haml | 2 - .../notify/group_access_denied_email.text.erb | 3 - .../group_access_requested_email.html.haml | 3 - .../group_access_requested_email.text.erb | 3 - .../member_access_denied_email.html.haml | 4 + .../member_access_denied_email.text.erb | 3 + .../member_access_granted_email.html.haml | 3 + .../member_access_granted_email.text.erb | 3 + .../member_access_requested_email.html.haml | 3 + .../member_access_requested_email.text.erb | 3 + .../member_invite_accepted_email.html.haml | 5 + .../member_invite_accepted_email.text.erb | 3 + .../member_invite_declined_email.html.haml | 4 + .../member_invite_declined_email.text.erb | 3 + .../notify/member_invited_email.html.haml | 13 + .../notify/member_invited_email.text.erb | 4 + .../project_access_denied_email.html.haml | 3 - .../project_access_denied_email.text.erb | 3 - .../project_access_granted_email.html.haml | 3 - .../project_access_granted_email.text.erb | 3 - .../project_access_requested_email.html.haml | 3 - .../project_access_requested_email.text.erb | 3 - .../project_invite_accepted_email.html.haml | 6 - .../project_invite_accepted_email.text.erb | 3 - .../project_invite_declined_email.html.haml | 5 - .../project_invite_declined_email.text.erb | 3 - .../project_member_invited_email.html.haml | 13 - .../project_member_invited_email.text.erb | 4 - spec/mailers/notify_spec.rb | 264 ++++++++++++------ 35 files changed, 359 insertions(+), 303 deletions(-) delete mode 100644 app/mailers/emails/groups.rb create mode 100644 app/mailers/emails/members.rb delete mode 100644 app/views/notify/group_access_denied_email.html.haml delete mode 100644 app/views/notify/group_access_denied_email.text.erb delete mode 100644 app/views/notify/group_access_requested_email.html.haml delete mode 100644 app/views/notify/group_access_requested_email.text.erb create mode 100644 app/views/notify/member_access_denied_email.html.haml create mode 100644 app/views/notify/member_access_denied_email.text.erb create mode 100644 app/views/notify/member_access_granted_email.html.haml create mode 100644 app/views/notify/member_access_granted_email.text.erb create mode 100644 app/views/notify/member_access_requested_email.html.haml create mode 100644 app/views/notify/member_access_requested_email.text.erb create mode 100644 app/views/notify/member_invite_accepted_email.html.haml create mode 100644 app/views/notify/member_invite_accepted_email.text.erb create mode 100644 app/views/notify/member_invite_declined_email.html.haml create mode 100644 app/views/notify/member_invite_declined_email.text.erb create mode 100644 app/views/notify/member_invited_email.html.haml create mode 100644 app/views/notify/member_invited_email.text.erb delete mode 100644 app/views/notify/project_access_denied_email.html.haml delete mode 100644 app/views/notify/project_access_denied_email.text.erb delete mode 100644 app/views/notify/project_access_granted_email.html.haml delete mode 100644 app/views/notify/project_access_granted_email.text.erb delete mode 100644 app/views/notify/project_access_requested_email.html.haml delete mode 100644 app/views/notify/project_access_requested_email.text.erb delete mode 100644 app/views/notify/project_invite_accepted_email.html.haml delete mode 100644 app/views/notify/project_invite_accepted_email.text.erb delete mode 100644 app/views/notify/project_invite_declined_email.html.haml delete mode 100644 app/views/notify/project_invite_declined_email.text.erb delete mode 100644 app/views/notify/project_member_invited_email.html.haml delete mode 100644 app/views/notify/project_member_invited_email.text.erb diff --git a/app/mailers/emails/groups.rb b/app/mailers/emails/groups.rb deleted file mode 100644 index fe218bfbe05..00000000000 --- a/app/mailers/emails/groups.rb +++ /dev/null @@ -1,73 +0,0 @@ -module Emails - module Groups - def group_access_requested_email(group_member_id) - setup_group_member_mail(group_member_id) - - @requester = @group_member.created_by - - group_admins = User.where(id: @group.group_members.admins.pluck(:user_id)).pluck(:notification_email) - - mail(to: group_admins, - subject: subject("Request to join #{@group.name} group")) - end - - def group_access_granted_email(group_member_id) - setup_group_member_mail(group_member_id) - - @current_user = @group_member.user - - mail(to: @current_user.notification_email, - subject: subject("Access to #{@group.name} group was granted")) - end - - def group_access_denied_email(group_id, user_id) - @group = Group.find(group_id) - @current_user = User.find(user_id) - @target_url = group_url(@group) - - mail(to: @current_user.notification_email, - subject: subject("Access to #{@group.name} group was denied")) - end - - def group_member_invited_email(group_member_id, token) - setup_group_member_mail(group_member_id) - - @token = token - @current_user = @group_member.user - - mail(to: @group_member.invite_email, - subject: "Invitation to join group #{@group.name}") - end - - def group_invite_accepted_email(group_member_id) - setup_group_member_mail(group_member_id) - return if @group_member.created_by.nil? - - @current_user = @group_member.created_by - - mail(to: @current_user.notification_email, - subject: subject("Invitation accepted")) - end - - def group_invite_declined_email(group_id, invite_email, access_level, created_by_id) - return if created_by_id.nil? - - @group = Group.find(group_id) - @current_user = @created_by = User.find(created_by_id) - @access_level = access_level - @invite_email = invite_email - - @target_url = group_url(@group) - mail(to: @created_by.notification_email, - subject: subject("Invitation declined")) - end - - private - - def setup_group_member_mail(group_member_id) - @group_member = GroupMember.find(group_member_id) - @group = @group_member.group - @target_url = group_url(@group) - end - end -end diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb new file mode 100644 index 00000000000..5fd55c149df --- /dev/null +++ b/app/mailers/emails/members.rb @@ -0,0 +1,104 @@ +module Emails + module Members + extend ActiveSupport::Concern + + included do + attr_reader :member_target_type + helper_method :member, :access_requester, :member_target_type, :member_target_name, :member_target_url + end + + def member_access_requested_email(member_target_type, member_id) + @member_target_type = member_target_type + @member_id = member_id + + admins = User.where(id: target.public_send(members_association).admins.pluck(:user_id)).pluck(:notification_email) + + mail(to: admins, + subject: subject("Request to join the #{member_target_name} #{member_target_type}")) + end + + def member_access_granted_email(member_target_type, member_id) + @member_target_type = member_target_type + @member_id = member_id + + mail(to: member.user.notification_email, + subject: subject("Access to the #{member_target_name} #{member_target_type} was granted")) + end + + def member_access_denied_email(member_target_type, target_id, user_id) + @member_target_type = member_target_type + @target = target_class.find(target_id) + + mail(to: User.find(user_id).notification_email, + subject: subject("Access to the #{member_target_name} #{member_target_type} was denied")) + end + + def member_invited_email(member_target_type, member_id, token) + @member_target_type = member_target_type + @member_id = member_id + @token = token + + mail(to: member.invite_email, + subject: "Invitation to join the #{member_target_name} #{member_target_type}") + end + + def member_invite_accepted_email(member_target_type, member_id) + @member_target_type = member_target_type + @member_id = member_id + return if access_requester.nil? + + mail(to: access_requester.notification_email, + subject: subject('Invitation accepted')) + end + + def member_invite_declined_email(member_target_type, target_id, invite_email, created_by_id) + return if created_by_id.nil? + + @member_target_type = member_target_type + @target = target_class.find(target_id) + @invite_email = invite_email + + mail(to: User.find(created_by_id).notification_email, + subject: subject('Invitation declined')) + end + + def member + @member ||= member_class.find(@member_id) + end + + def access_requester + @access_requester ||= member.created_by + end + + def member_target_name + case member_target_type + when 'project' + target.name_with_namespace + else + target.name + end + end + + def member_target_url + @member_target_url ||= target.web_url + end + + private + + def target + @target ||= member.public_send(member_target_type) + end + + def target_class + @target_class ||= member_target_type.classify.constantize + end + + def member_class + @member_class ||= "#{member_target_type.classify}Member".constantize + end + + def members_association + @members_association ||= member_class.to_s.tableize + end + end +end diff --git a/app/mailers/emails/projects.rb b/app/mailers/emails/projects.rb index 43a2a7e80a8..689fb3e0ffb 100644 --- a/app/mailers/emails/projects.rb +++ b/app/mailers/emails/projects.rb @@ -1,68 +1,5 @@ module Emails module Projects - def project_access_requested_email(project_member_id) - setup_project_member_mail(project_member_id) - - @requester = @project_member.created_by - - project_admins = User.where(id: @project.project_members.admins.pluck(:user_id)).pluck(:notification_email) - - mail(to: project_admins, - subject: subject("Request to join #{@project.name_with_namespace} project")) - end - - def project_access_granted_email(project_member_id) - setup_project_member_mail(project_member_id) - - @current_user = @project_member.user - - mail(to: @current_user.notification_email, - subject: subject("Access to #{@project.name_with_namespace} project was granted")) - end - - def project_access_denied_email(project_id, user_id) - @project = Project.find(project_id) - @current_user = User.find(user_id) - @target_url = namespace_project_url(@project.namespace, @project) - - mail(to: @current_user.notification_email, - subject: subject("Access to #{@project.name_with_namespace} project was denied")) - end - - def project_member_invited_email(project_member_id, token) - setup_project_member_mail(project_member_id) - - @token = token - @current_user = @project_member.user - - mail(to: @project_member.invite_email, - subject: "Invitation to join project #{@project.name_with_namespace}") - end - - def project_invite_accepted_email(project_member_id) - setup_project_member_mail(project_member_id) - return if @project_member.created_by.nil? - - @current_user = @project_member.created_by - - mail(to: @project_member.created_by.notification_email, - subject: subject("Invitation accepted")) - end - - def project_invite_declined_email(project_id, invite_email, access_level, created_by_id) - return if created_by_id.nil? - - @project = Project.find(project_id) - @current_user = @created_by = User.find(created_by_id) - @access_level = access_level - @invite_email = invite_email - - @target_url = namespace_project_url(@project.namespace, @project) - - mail(to: @created_by.notification_email, - subject: subject("Invitation declined")) - end - def project_was_moved_email(project_id, user_id, old_path_with_namespace) @current_user = @user = User.find user_id @project = Project.find project_id @@ -88,13 +25,5 @@ module Emails reply_to: @message.reply_to, subject: @message.subject) end - - private - - def setup_project_member_mail(project_member_id) - @project_member = ProjectMember.find(project_member_id) - @project = @project_member.project - @target_url = namespace_project_url(@project.namespace, @project) - end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index 1c663bdd521..bd5c6788cce 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -6,8 +6,8 @@ class Notify < BaseMailer include Emails::Notes include Emails::Projects include Emails::Profile - include Emails::Groups include Emails::Builds + include Emails::Members add_template_helper MergeRequestsHelper add_template_helper DiffHelper diff --git a/app/models/group.rb b/app/models/group.rb index b6929112cba..520cbd0283c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -59,6 +59,10 @@ class Group < Namespace "#{self.class.reference_prefix}#{name}" end + def web_url + Gitlab::Routing.url_helpers.group_url(self) + end + def human_name name end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index cd11feb9d7a..259199f6e2b 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -175,23 +175,24 @@ class NotificationService # Project access request def new_project_access_request(project_member) - mailer.project_access_requested_email(project_member.id).deliver_later + mailer.member_access_requested_email('project', project_member.id).deliver_later end def decline_project_access_request(project, user) - mailer.project_access_denied_email(project.id, user.id).deliver_later + mailer.member_access_denied_email('project', project.id, user.id).deliver_later end def invite_project_member(project_member, token) - mailer.project_member_invited_email(project_member.id, token).deliver_later + mailer.member_invited_email('project', project_member.id, token).deliver_later end def accept_project_invite(project_member) - mailer.project_invite_accepted_email(project_member.id).deliver_later + mailer.member_invite_accepted_email('project', project_member.id).deliver_later end def decline_project_invite(project_member) - mailer.project_invite_declined_email( + mailer.member_invite_declined_email( + 'project', project_member.project.id, project_member.invite_email, project_member.access_level, @@ -200,32 +201,33 @@ class NotificationService end def new_project_member(project_member) - mailer.project_access_granted_email(project_member.id).deliver_later + mailer.member_access_granted_email('project', project_member.id).deliver_later end def update_project_member(project_member) - mailer.project_access_granted_email(project_member.id).deliver_later + mailer.member_access_granted_email('project', project_member.id).deliver_later end # Group access request def new_group_access_request(group_member) - mailer.group_access_requested_email(group_member.id).deliver_later + mailer.member_access_requested_email('group', group_member.id).deliver_later end def decline_group_access_request(group, user) - mailer.group_access_denied_email(group.id, user.id).deliver_later + mailer.member_access_denied_email('group', group.id, user.id).deliver_later end def invite_group_member(group_member, token) - mailer.group_member_invited_email(group_member.id, token).deliver_later + mailer.member_invited_email('group', group_member.id, token).deliver_later end def accept_group_invite(group_member) - mailer.group_invite_accepted_email(group_member.id).deliver_later + mailer.member_invite_accepted_email(group_member.id).deliver_later end def decline_group_invite(group_member) - mailer.group_invite_declined_email( + mailer.member_invite_declined_email( + 'group', group_member.group.id, group_member.invite_email, group_member.access_level, @@ -234,11 +236,11 @@ class NotificationService end def new_group_member(group_member) - mailer.group_access_granted_email(group_member.id).deliver_later + mailer.member_access_granted_email('group', group_member.id).deliver_later end def update_group_member(group_member) - mailer.group_access_granted_email(group_member.id).deliver_later + mailer.member_access_granted_email('group', group_member.id).deliver_later end def project_was_moved(project, old_path_with_namespace) diff --git a/app/views/notify/group_access_denied_email.html.haml b/app/views/notify/group_access_denied_email.html.haml deleted file mode 100644 index 4edfd4e4793..00000000000 --- a/app/views/notify/group_access_denied_email.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -%p - Your request to join group #{link_to @group.name, @target_url} has been denied. diff --git a/app/views/notify/group_access_denied_email.text.erb b/app/views/notify/group_access_denied_email.text.erb deleted file mode 100644 index cb32177e826..00000000000 --- a/app/views/notify/group_access_denied_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -Your request to join group <%= @group.name %> has been denied. - -<%= @target_url %> diff --git a/app/views/notify/group_access_requested_email.html.haml b/app/views/notify/group_access_requested_email.html.haml deleted file mode 100644 index 4fbcedabae0..00000000000 --- a/app/views/notify/group_access_requested_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - #{link_to @requester.name, @requester} requested #{@group_member.human_access} - access to group #{link_to @group.name, @target_url}. diff --git a/app/views/notify/group_access_requested_email.text.erb b/app/views/notify/group_access_requested_email.text.erb deleted file mode 100644 index 2f9d293a79e..00000000000 --- a/app/views/notify/group_access_requested_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @requester.name %> (<%= user_url(@requester) %>) requested <%= @group_member.human_access %> access to group <%= @group.name %> - -<%= @target_url %> diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml new file mode 100644 index 00000000000..a25af24d783 --- /dev/null +++ b/app/views/notify/member_access_denied_email.html.haml @@ -0,0 +1,4 @@ +%p + Your request to join the + #{link_to member_target_name, member_target_url} #{member_target_type} + has been denied. diff --git a/app/views/notify/member_access_denied_email.text.erb b/app/views/notify/member_access_denied_email.text.erb new file mode 100644 index 00000000000..eb204458d9d --- /dev/null +++ b/app/views/notify/member_access_denied_email.text.erb @@ -0,0 +1,3 @@ +Your request to join the <%= member_target_name %> <%= member_target_type %> has been denied. + +<%= member_target_url %> diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml new file mode 100644 index 00000000000..62837d74555 --- /dev/null +++ b/app/views/notify/member_access_granted_email.html.haml @@ -0,0 +1,3 @@ +%p + You have been granted #{member.human_access} access to the + #{link_to member_target_name, member_target_url} #{member_target_type}. diff --git a/app/views/notify/member_access_granted_email.text.erb b/app/views/notify/member_access_granted_email.text.erb new file mode 100644 index 00000000000..be9bb5ee948 --- /dev/null +++ b/app/views/notify/member_access_granted_email.text.erb @@ -0,0 +1,3 @@ +You have been granted <%= member.human_access %> access to the <%= member_target_name %> <%= member_target_type %>. + +<%= member_target_url %> diff --git a/app/views/notify/member_access_requested_email.html.haml b/app/views/notify/member_access_requested_email.html.haml new file mode 100644 index 00000000000..96e92a069f2 --- /dev/null +++ b/app/views/notify/member_access_requested_email.html.haml @@ -0,0 +1,3 @@ +%p + #{link_to access_requester.name, access_requester} requested #{member.human_access} + access to the #{link_to member_target_name, member_target_url} #{member_target_type}. diff --git a/app/views/notify/member_access_requested_email.text.erb b/app/views/notify/member_access_requested_email.text.erb new file mode 100644 index 00000000000..3b5de8c2abe --- /dev/null +++ b/app/views/notify/member_access_requested_email.text.erb @@ -0,0 +1,3 @@ +<%= access_requester.name %> (<%= user_url(access_requester) %>) requested <%= member.human_access %> access to the <%= member_target_name %> <%= member_target_type %>. + +<%= member_target_url %> diff --git a/app/views/notify/member_invite_accepted_email.html.haml b/app/views/notify/member_invite_accepted_email.html.haml new file mode 100644 index 00000000000..c420a8a7b3c --- /dev/null +++ b/app/views/notify/member_invite_accepted_email.html.haml @@ -0,0 +1,5 @@ +%p + #{member.invite_email}, now known as + #{link_to member.user.name, user_url(member.user)}, + has accepted your invitation to join the + #{link_to member_target_name, member_target_url} #{member_target_type}. diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb new file mode 100644 index 00000000000..a1616163ceb --- /dev/null +++ b/app/views/notify/member_invite_accepted_email.text.erb @@ -0,0 +1,3 @@ +<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_target_name %> <%= member_target_type %>. + +<%= member_target_url %> diff --git a/app/views/notify/member_invite_declined_email.html.haml b/app/views/notify/member_invite_declined_email.html.haml new file mode 100644 index 00000000000..5a30ac31b3c --- /dev/null +++ b/app/views/notify/member_invite_declined_email.html.haml @@ -0,0 +1,4 @@ +%p + #{@invite_email} + has declined your invitation to join the + #{link_to member_target_name, member_target_url} #{member_target_type}. diff --git a/app/views/notify/member_invite_declined_email.text.erb b/app/views/notify/member_invite_declined_email.text.erb new file mode 100644 index 00000000000..301287946d4 --- /dev/null +++ b/app/views/notify/member_invite_declined_email.text.erb @@ -0,0 +1,3 @@ +<%= @invite_email %> has declined your invitation to join the <%= member_target_name %> <%= member_target_type %>. + +<%= member_target_url %> diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml new file mode 100644 index 00000000000..a8e58df9ac8 --- /dev/null +++ b/app/views/notify/member_invited_email.html.haml @@ -0,0 +1,13 @@ +%p + You have been invited + - if access_requester + by + = link_to access_requester.name, user_url(access_requester) + to join the + = link_to member_target_name, member_target_url + #{member_target_type} as #{member.human_access}. + +%p + = link_to 'Accept invitation', invite_url(@token) + or + = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb new file mode 100644 index 00000000000..1b6897ee2ec --- /dev/null +++ b/app/views/notify/member_invited_email.text.erb @@ -0,0 +1,4 @@ +You have been invited <%= "by #{access_requester.name} " if access_requester %>to join the <%= member_target_name %> <%= member_target_type %> as <%= member.human_access %>. + +Accept invitation: <%= invite_url(@token) %> +Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/project_access_denied_email.html.haml b/app/views/notify/project_access_denied_email.html.haml deleted file mode 100644 index cecdaf24f39..00000000000 --- a/app/views/notify/project_access_denied_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - Your request to join project #{link_to @project.name_with_namespace, @target_url} - has been denied. diff --git a/app/views/notify/project_access_denied_email.text.erb b/app/views/notify/project_access_denied_email.text.erb deleted file mode 100644 index 24357e059d2..00000000000 --- a/app/views/notify/project_access_denied_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -Your request to join project <%= @project.name_with_namespace %> has been denied. - -<%= @target_url %> diff --git a/app/views/notify/project_access_granted_email.html.haml b/app/views/notify/project_access_granted_email.html.haml deleted file mode 100644 index 88873e7fe52..00000000000 --- a/app/views/notify/project_access_granted_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - You have been granted #{@project_member.human_access} access to project - #{link_to @project.name_with_namespace, @target_url}. diff --git a/app/views/notify/project_access_granted_email.text.erb b/app/views/notify/project_access_granted_email.text.erb deleted file mode 100644 index f5e4b313858..00000000000 --- a/app/views/notify/project_access_granted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -You have been granted <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %>. - -<%= @target_url %> diff --git a/app/views/notify/project_access_requested_email.html.haml b/app/views/notify/project_access_requested_email.html.haml deleted file mode 100644 index 2a705ad3b0a..00000000000 --- a/app/views/notify/project_access_requested_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - #{link_to @requester.name, @requester} requested #{@project_member.human_access} - access to project #{link_to @project.name_with_namespace, @target_url}. diff --git a/app/views/notify/project_access_requested_email.text.erb b/app/views/notify/project_access_requested_email.text.erb deleted file mode 100644 index 2437fa4ee86..00000000000 --- a/app/views/notify/project_access_requested_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @requester.name %> (<%= user_url(@requester) %>) requested <%= @project_member.human_access %> access to project <%= @project.name_with_namespace %>. - -<%= @target_url %> diff --git a/app/views/notify/project_invite_accepted_email.html.haml b/app/views/notify/project_invite_accepted_email.html.haml deleted file mode 100644 index 7e58d30b10a..00000000000 --- a/app/views/notify/project_invite_accepted_email.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%p - #{@project_member.invite_email}, now known as - #{link_to @project_member.user.name, user_url(@project_member.user)}, - has accepted your invitation to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. - diff --git a/app/views/notify/project_invite_accepted_email.text.erb b/app/views/notify/project_invite_accepted_email.text.erb deleted file mode 100644 index fcbe752114d..00000000000 --- a/app/views/notify/project_invite_accepted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @project_member.invite_email %>, now known as <%= @project_member.user.name %>, has accepted your invitation to join project <%= @project.name_with_namespace %>. - -<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_invite_declined_email.html.haml b/app/views/notify/project_invite_declined_email.html.haml deleted file mode 100644 index c2d7e6f6e3a..00000000000 --- a/app/views/notify/project_invite_declined_email.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -%p - #{@invite_email} - has declined your invitation to join project - #{link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project)}. - diff --git a/app/views/notify/project_invite_declined_email.text.erb b/app/views/notify/project_invite_declined_email.text.erb deleted file mode 100644 index 484687fa51c..00000000000 --- a/app/views/notify/project_invite_declined_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @invite_email %> has declined your invitation to join project <%= @project.name_with_namespace %>. - -<%= namespace_project_url(@project.namespace, @project) %> diff --git a/app/views/notify/project_member_invited_email.html.haml b/app/views/notify/project_member_invited_email.html.haml deleted file mode 100644 index 79eb89616de..00000000000 --- a/app/views/notify/project_member_invited_email.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -%p - You have been invited - - if inviter = @project_member.created_by - by - = link_to inviter.name, user_url(inviter) - to join project - = link_to @project.name_with_namespace, namespace_project_url(@project.namespace, @project) - as #{@project_member.human_access}. - -%p - = link_to 'Accept invitation', invite_url(@token) - or - = link_to 'decline', decline_invite_url(@token) diff --git a/app/views/notify/project_member_invited_email.text.erb b/app/views/notify/project_member_invited_email.text.erb deleted file mode 100644 index e0706272115..00000000000 --- a/app/views/notify/project_member_invited_email.text.erb +++ /dev/null @@ -1,4 +0,0 @@ -You have been invited <%= "by #{@project_member.created_by.name} " if @project_member.created_by %>to join project <%= @project.name_with_namespace %> as <%= @project_member.human_access %>. - -Accept invitation: <%= invite_url(@token) %> -Decline invitation: <%= decline_invite_url(@token) %> diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 2d86038030e..a86ec865b5d 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -407,23 +407,16 @@ describe Notify do project.request_access(user) project.project_members.find_by(created_by_id: user.id) end - subject { Notify.project_access_requested_email(project_member.id) } + subject { Notify.member_access_requested_email('project', project_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Request to join #{project.name_with_namespace} project/ - end - - it 'contains name of project' do - is_expected.to have_body_text /#{project.name}/ - end - - it 'contains new user role' do - is_expected.to have_body_text /#{project_member.human_access}/ - end + it { is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.human_access}/ } end describe 'project access denied' do @@ -433,42 +426,99 @@ describe Notify do project.request_access(user) project.project_members.find_by(created_by_id: user.id) end - subject { Notify.project_access_denied_email(project.id, user.id) } + subject { Notify.member_access_denied_email('project', project.id, user.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Access to #{project.name_with_namespace} project was denied/ - end - - it 'contains name of project' do - is_expected.to have_body_text /#{project.name}/ - end + it { is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied" } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } end describe 'project access changed' do let(:project) { create(:project) } let(:user) { create(:user) } let(:project_member) { create(:project_member, project: project, user: user) } - subject { Notify.project_access_granted_email(project_member.id) } + subject { Notify.member_access_granted_email('project', project_member.id) } it_behaves_like 'an email sent from GitLab' it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Access to #{project.name_with_namespace} project was granted/ + it { is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted" } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.human_access}/ } + end + + def invite_to_project(project:, email:, inviter:) + ProjectMember.add_user(project.project_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter) + + project.project_members.invite.last + end + + describe 'project invitation' do + let(:project) { create(:project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:project_member) { invite_to_project(project: project, email: 'toto@example.com', inviter: master) } + + subject { Notify.member_invited_email('project', project_member.id, project_member.invite_token) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project" } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.human_access}/ } + it { is_expected.to have_body_text /#{project_member.invite_token}/ } + end + + describe 'project invitation accepted' do + let(:project) { create(:project) } + let(:invited_user) { create(:user) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:project_member) do + invitee = invite_to_project(project: project, email: 'toto@example.com', inviter: master) + invitee.accept_invite!(invited_user) + invitee end - it 'contains name of project' do - is_expected.to have_body_text /#{project.name}/ + subject { Notify.member_invite_accepted_email('project', project_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject 'Invitation accepted' } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.invite_email}/ } + it { is_expected.to have_body_text /#{invited_user.name}/ } + end + + describe 'project invitation declined' do + let(:project) { create(:project) } + let(:master) { create(:user).tap { |u| project.team << [u, :master] } } + let(:project_member) do + invitee = invite_to_project(project: project, email: 'toto@example.com', inviter: master) + invitee.decline_invite! + invitee end - it 'contains new user role' do - is_expected.to have_body_text /#{project_member.human_access}/ - end + subject { Notify.member_invite_declined_email('project', project.id, project_member.invite_email, master.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject 'Invitation declined' } + it { is_expected.to have_body_text /#{project.name_with_namespace}/ } + it { is_expected.to have_body_text /#{project.web_url}/ } + it { is_expected.to have_body_text /#{project_member.invite_email}/ } end context 'items that are noteable, the email for a note' do @@ -583,75 +633,127 @@ describe Notify do end end - describe 'group access requested' do - let(:group) { create(:group) } - let(:user) { create(:user) } - let(:group_member) do - group.request_access(user) - group.group_members.find_by(created_by_id: user.id) - end - subject { Notify.group_access_requested_email(group_member.id) } + context 'for a group' do + describe 'group access requested' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.group_members.find_by(created_by_id: user.id) + end + subject { Notify.member_access_requested_email('group', group_member.id) } - it_behaves_like 'an email sent from GitLab' - it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Request to join #{group.name} group/ + it { is_expected.to have_subject "Request to join the #{group.name} group" } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.human_access}/ } end - it 'contains name of group' do - is_expected.to have_body_text /#{group.name}/ + describe 'group access denied' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) do + group.request_access(user) + group.group_members.find_by(created_by_id: user.id) + end + subject { Notify.member_access_denied_email('group', group.id, user.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject "Access to the #{group.name} group was denied" } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } end - it 'contains new user role' do - is_expected.to have_body_text /#{group_member.human_access}/ - end - end + describe 'group access changed' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) { create(:group_member, group: group, user: user) } - describe 'group access denied' do - let(:group) { create(:group) } - let(:user) { create(:user) } - let(:group_member) do - group.request_access(user) - group.group_members.find_by(created_by_id: user.id) - end - subject { Notify.group_access_denied_email(group.id, user.id) } + subject { Notify.member_access_granted_email('group', group_member.id) } - it_behaves_like 'an email sent from GitLab' - it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" - it 'has the correct subject' do - is_expected.to have_subject /Access to #{group.name} group was denied/ + it { is_expected.to have_subject "Access to the #{group.name} group was granted" } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.human_access}/ } end - it 'contains name of group' do - is_expected.to have_body_text /#{group.name}/ - end - end + def invite_to_group(group:, email:, inviter:) + GroupMember.add_user(group.group_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter) - describe 'group access changed' do - let(:group) { create(:group) } - let(:user) { create(:user) } - let(:membership) { create(:group_member, group: group, user: user) } - - subject { Notify.group_access_granted_email(membership.id) } - - it_behaves_like 'an email sent from GitLab' - it_behaves_like 'it should not have Gmail Actions links' - it_behaves_like "a user cannot unsubscribe through footer link" - - it 'has the correct subject' do - is_expected.to have_subject /Access to #{group.name} group was granted/ + group.group_members.invite.last end - it 'contains name of project' do - is_expected.to have_body_text /#{group.name}/ + describe 'group invitation' do + let(:group) { create(:group) } + let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } + let(:group_member) { invite_to_group(group: group, email: 'toto@example.com', inviter: owner) } + + subject { Notify.member_invited_email('group', group_member.id, group_member.invite_token) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject "Invitation to join the #{group.name} group" } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.human_access}/ } + it { is_expected.to have_body_text /#{group_member.invite_token}/ } end - it 'contains new user role' do - is_expected.to have_body_text /#{membership.human_access}/ + describe 'group invitation accepted' do + let(:group) { create(:group) } + let(:invited_user) { create(:user) } + let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } + let(:group_member) do + invitee = invite_to_group(group: group, email: 'toto@example.com', inviter: owner) + invitee.accept_invite!(invited_user) + invitee + end + + subject { Notify.member_invite_accepted_email('group', group_member.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject 'Invitation accepted' } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.invite_email}/ } + it { is_expected.to have_body_text /#{invited_user.name}/ } + end + + describe 'group invitation declined' do + let(:group) { create(:group) } + let(:owner) { create(:user).tap { |u| group.add_user(u, Gitlab::Access::OWNER) } } + let(:group_member) do + invitee = invite_to_group(group: group, email: 'toto@example.com', inviter: owner) + invitee.decline_invite! + invitee + end + + subject { Notify.member_invite_declined_email('group', group.id, group_member.invite_email, owner.id) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like "a user cannot unsubscribe through footer link" + + it { is_expected.to have_subject 'Invitation declined' } + it { is_expected.to have_body_text /#{group.name}/ } + it { is_expected.to have_body_text /#{group.web_url}/ } + it { is_expected.to have_body_text /#{group_member.invite_email}/ } end end From 2a82684b4780559367a2afba6bb95d28a622ee59 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Tue, 14 Jun 2016 14:08:02 +0300 Subject: [PATCH 218/318] Update CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 3387394de5b..093806d2c17 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -23,6 +23,7 @@ v 8.9.0 (unreleased) - Fix 404 page when viewing TODOs that contain milestones or labels in different projects - Redesign navigation for project pages - Fix groups API to list only user's accessible projects + - Fix horizontal scrollbar for long commit message. - Redesign account and email confirmation emails - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 From 515205d3c1c6655302ed0ae44cc5954dead7ae79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Thu, 2 Jun 2016 18:05:06 +0200 Subject: [PATCH 219/318] UI and copywriting improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit + Move 'Edit Project/Group' out of membership-related partial + Show the access request buttons only to logged-in users + Put the request access buttons out of in a more visible button + Improve the copy in the #remove_member_message helper Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/assets/stylesheets/pages/groups.scss | 17 +++ app/assets/stylesheets/pages/projects.scss | 17 ++- .../concerns/access_request_actions.rb | 38 ----- .../concerns/membership_actions.rb | 58 ++++++++ .../groups/group_members_controller.rb | 42 +----- .../projects/project_members_controller.rb | 43 +----- app/helpers/gitlab_routing_helper.rb | 77 ++++++++-- app/helpers/members_helper.rb | 128 ++++------------- app/helpers/projects_helper.rb | 4 - app/mailers/emails/members.rb | 89 +++++------- app/mailers/notify.rb | 2 + app/models/ability.rb | 4 - app/models/concerns/access_requestable.rb | 13 +- app/models/group.rb | 2 +- app/models/member.rb | 37 +++-- app/models/members/group_member.rb | 9 +- app/models/members/project_member.rb | 4 +- app/models/project.rb | 4 +- app/models/project_team.rb | 18 ++- app/services/notification_service.rb | 30 ++-- .../groups/group_members/index.html.haml | 2 +- app/views/groups/show.html.haml | 3 + .../layouts/nav/_group_settings.html.haml | 17 ++- app/views/layouts/nav/_project.html.haml | 36 ++--- .../group_access_granted_email.html.haml | 3 - .../group_access_granted_email.text.erb | 3 - .../group_invite_accepted_email.html.haml | 6 - .../group_invite_accepted_email.text.erb | 3 - .../group_invite_declined_email.html.haml | 5 - .../group_invite_declined_email.text.erb | 3 - .../group_member_invited_email.html.haml | 14 -- .../group_member_invited_email.text.erb | 4 - .../member_access_denied_email.html.haml | 2 +- .../member_access_denied_email.text.erb | 4 +- .../member_access_granted_email.html.haml | 2 +- .../member_access_granted_email.text.erb | 4 +- .../member_access_requested_email.html.haml | 4 +- .../member_access_requested_email.text.erb | 4 +- .../member_invite_accepted_email.html.haml | 2 +- .../member_invite_accepted_email.text.erb | 4 +- .../member_invite_declined_email.html.haml | 2 +- .../member_invite_declined_email.text.erb | 4 +- .../notify/member_invited_email.html.haml | 8 +- .../notify/member_invited_email.text.erb | 2 +- app/views/projects/_home_panel.html.haml | 11 +- .../projects/buttons/_notifications.html.haml | 2 +- app/views/projects/notes/_note.html.haml | 2 +- .../project_members/_group_members.html.haml | 10 +- .../projects/project_members/index.html.haml | 4 +- .../_group_or_project_home_dropdown.html.haml | 30 ---- .../members/_access_request_buttons.html.haml | 12 ++ app/views/shared/members/_member.html.haml | 28 ++-- app/views/shared/members/_requests.html.haml | 10 +- features/steps/dashboard/group.rb | 2 +- features/steps/group/members.rb | 10 +- features/steps/project/team_management.rb | 26 ++-- lib/api/entities.rb | 5 +- .../groups/group_members_controller_spec.rb | 24 ++-- .../project_members_controller_spec.rb | 30 ++-- .../owner_manages_access_requests_spec.rb | 14 +- .../members/user_requests_access_spec.rb | 28 ++-- .../master_manages_access_requests_spec.rb | 14 +- .../members/user_requests_access_spec.rb | 32 ++--- spec/helpers/gitlab_routing_helper_spec.rb | 79 +++++++++++ spec/helpers/members_helper_spec.rb | 111 +++------------ spec/helpers/projects_helper_spec.rb | 19 --- spec/mailers/notify_spec.rb | 132 +++++++++++------- .../concerns/access_requestable_spec.rb | 7 +- spec/models/group_spec.rb | 11 -- spec/models/member_spec.rb | 103 +++++++++----- spec/models/members/group_member_spec.rb | 22 +-- spec/models/members/project_member_spec.rb | 22 +-- spec/models/project_spec.rb | 19 +-- spec/models/project_team_spec.rb | 22 +++ 74 files changed, 777 insertions(+), 841 deletions(-) delete mode 100644 app/controllers/concerns/access_request_actions.rb create mode 100644 app/controllers/concerns/membership_actions.rb delete mode 100644 app/views/notify/group_access_granted_email.html.haml delete mode 100644 app/views/notify/group_access_granted_email.text.erb delete mode 100644 app/views/notify/group_invite_accepted_email.html.haml delete mode 100644 app/views/notify/group_invite_accepted_email.text.erb delete mode 100644 app/views/notify/group_invite_declined_email.html.haml delete mode 100644 app/views/notify/group_invite_declined_email.text.erb delete mode 100644 app/views/notify/group_member_invited_email.html.haml delete mode 100644 app/views/notify/group_member_invited_email.text.erb delete mode 100644 app/views/shared/_group_or_project_home_dropdown.html.haml create mode 100644 app/views/shared/members/_access_request_buttons.html.haml create mode 100644 spec/helpers/gitlab_routing_helper_spec.rb diff --git a/app/assets/stylesheets/pages/groups.scss b/app/assets/stylesheets/pages/groups.scss index ec6c099df5b..ac7721cbe15 100644 --- a/app/assets/stylesheets/pages/groups.scss +++ b/app/assets/stylesheets/pages/groups.scss @@ -39,3 +39,20 @@ } } } + +.groups-cover-block { + + .container-fluid { + position: relative; + } + + .access-request-button { + @include btn-gray; + position: absolute; + right: 16px; + bottom: 32px; + padding: 3px 10px; + text-transform: none; + background-color: $background-color; + } +} diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 2505deaf757..0e4cefc55c2 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -229,13 +229,20 @@ right: 16px; bottom: 0; - .btn { - padding: 3px 10px; - background-color: $background-color; + @media (max-width: $screen-lg-min) { + top: 0; } - @media (max-width: 1304px) { - top: 0; + .access-request-button { + position: absolute; + right: 0; + bottom: 61px; + + @media (max-width: $screen-lg-min) { + position: relative; + bottom: 0; + margin-right: 10px; + } } } diff --git a/app/controllers/concerns/access_request_actions.rb b/app/controllers/concerns/access_request_actions.rb deleted file mode 100644 index c4d22749d6a..00000000000 --- a/app/controllers/concerns/access_request_actions.rb +++ /dev/null @@ -1,38 +0,0 @@ -module AccessRequestActions - extend ActiveSupport::Concern - - def request_access - access_requestable_resource.request_access(current_user) - - redirect_to access_requestable_resource_path, - notice: 'Your request for access has been queued for review.' - end - - def approve_access_request - @member = access_requestable_resource.public_send(member_entity_name.pluralize).request.find(params[:id]) - - return render_403 unless can?(current_user, :"update_#{member_entity_name}", @member) - - @member.accept_request - - redirect_to access_requestable_resource_members_path - end - - protected - - def access_requestable_resource - raise NotImplementedError - end - - def access_requestable_resource_path - access_requestable_resource - end - - def access_requestable_resource_members_path - [access_requestable_resource, 'members'] - end - - def member_entity_name - "#{access_requestable_resource.class.to_s.underscore}_member" - end -end diff --git a/app/controllers/concerns/membership_actions.rb b/app/controllers/concerns/membership_actions.rb new file mode 100644 index 00000000000..a24273fad0b --- /dev/null +++ b/app/controllers/concerns/membership_actions.rb @@ -0,0 +1,58 @@ +module MembershipActions + extend ActiveSupport::Concern + include MembersHelper + + def request_access + membershipable.request_access(current_user) + + redirect_to polymorphic_path(membershipable), + notice: 'Your request for access has been queued for review.' + end + + def approve_access_request + @member = membershipable.members.request.find(params[:id]) + + return render_403 unless can?(current_user, action_member_permission(:update, @member), @member) + + @member.accept_request + + redirect_to polymorphic_url([membershipable, :members]) + end + + def leave + @member = membershipable.members.find_by(user_id: current_user) + return render_403 unless @member + + source_type = @member.real_source_type.humanize(capitalize: false) + + if can?(current_user, action_member_permission(:destroy, @member), @member) + notice = + if @member.request? + "Your access request to the #{source_type} has been withdrawn." + else + "You left the \"#{@member.source.human_name}\" #{source_type}." + end + @member.destroy + + redirect_to [:dashboard, @member.real_source_type.tableize], notice: notice + else + if cannot_leave? + alert = "You can not leave the \"#{@member.source.human_name}\" #{source_type}." + alert << " Transfer or delete the #{source_type}." + redirect_to polymorphic_url(membershipable), alert: alert + else + render_403 + end + end + end + + protected + + def membershipable + raise NotImplementedError + end + + def cannot_leave? + raise NotImplementedError + end +end diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index a37129062f9..d0f2e2949f0 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -1,5 +1,5 @@ class Groups::GroupMembersController < Groups::ApplicationController - include AccessRequestActions + include MembershipActions # Authorize before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] @@ -38,7 +38,7 @@ class Groups::GroupMembersController < Groups::ApplicationController return render_403 unless can?(current_user, :destroy_group_member, @group_member) - @group_member.request? ? @group_member.decline_request : @group_member.destroy + @group_member.destroy respond_to do |format| format.html { redirect_to group_group_members_path(@group), notice: 'User was successfully removed from group.' } @@ -60,46 +60,16 @@ class Groups::GroupMembersController < Groups::ApplicationController end end - def leave - @group_member = - @group.group_members.find_by(user_id: current_user.id) || - @group.group_members.find_by(created_by_id: current_user.id) - - if can?(current_user, :destroy_group_member, @group_member) - notice = - if @group_member.request? - 'You withdrawn your access request to the group.' - else - "You left #{@group.name} group." - end - @group_member.destroy - - redirect_to dashboard_groups_path, notice: notice - else - if @group.last_owner?(current_user) - redirect_to(dashboard_groups_path, alert: "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group.") - else - return render_403 - end - end - end - protected def member_params params.require(:group_member).permit(:access_level, :user_id) end - # AccessRequestActions concern - def access_requestable_resource - @group - end + # MembershipActions concern + alias_method :membershipable, :group - def access_requestable_resource_path - group_path(@group) - end - - def access_requestable_resource_members_path - group_group_members_path(@group) + def cannot_leave? + @group.last_owner?(current_user) end end diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index c61eda95bc7..35d067cd029 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -1,5 +1,5 @@ class Projects::ProjectMembersController < Projects::ApplicationController - include AccessRequestActions + include MembershipActions # Authorize before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] @@ -52,7 +52,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController return render_403 unless can?(current_user, :destroy_project_member, @project_member) - @project_member.request? ? @project_member.decline_request : @project_member.destroy + @project_member.destroy respond_to do |format| format.html do @@ -76,31 +76,6 @@ class Projects::ProjectMembersController < Projects::ApplicationController end end - def leave - @project_member = - @project.project_members.find_by(user_id: current_user.id) || - @project.project_members.find_by(created_by_id: current_user.id) - - if can?(current_user, :destroy_project_member, @project_member) - notice = - if @project_member.request? - 'You withdrawn your access request to the project.' - else - 'You left the project.' - end - @project_member.destroy - - redirect_to dashboard_projects_path, notice: notice - else - if current_user == @project.owner - message = 'You can not leave your own project. Transfer or delete the project.' - redirect_back_or_default(default: { action: 'index' }, options: { alert: message }) - else - render_403 - end - end - end - def apply_import source_project = Project.find(params[:source_project_id]) @@ -121,16 +96,10 @@ class Projects::ProjectMembersController < Projects::ApplicationController params.require(:project_member).permit(:user_id, :access_level) end - # AccessRequestActions concern - def access_requestable_resource - @project - end + # MembershipActions concern + alias_method :membershipable, :project - def access_requestable_resource_path - namespace_project_path(@project.namespace, @project) - end - - def access_requestable_resource_members_path - namespace_project_project_members_path(@project.namespace, @project) + def cannot_leave? + current_user == @project.owner end end diff --git a/app/helpers/gitlab_routing_helper.rb b/app/helpers/gitlab_routing_helper.rb index 2ce2d4e694f..3a43e936aee 100644 --- a/app/helpers/gitlab_routing_helper.rb +++ b/app/helpers/gitlab_routing_helper.rb @@ -13,10 +13,23 @@ # merge_request_path(merge_request) # module GitlabRoutingHelper + # Project def project_path(project, *args) namespace_project_path(project.namespace, project, *args) end + def project_url(project, *args) + namespace_project_url(project.namespace, project, *args) + end + + def edit_project_path(project, *args) + edit_namespace_project_path(project.namespace, project, *args) + end + + def edit_project_url(project, *args) + edit_namespace_project_url(project.namespace, project, *args) + end + def project_files_path(project, *args) namespace_project_tree_path(project.namespace, project, @ref || project.repository.root_ref) end @@ -41,10 +54,6 @@ module GitlabRoutingHelper activity_namespace_project_path(project.namespace, project, *args) end - def edit_project_path(project, *args) - edit_namespace_project_path(project.namespace, project, *args) - end - def runners_path(project, *args) namespace_project_runners_path(project.namespace, project, *args) end @@ -65,14 +74,6 @@ module GitlabRoutingHelper namespace_project_milestone_path(entity.project.namespace, entity.project, entity, *args) end - def project_url(project, *args) - namespace_project_url(project.namespace, project, *args) - end - - def edit_project_url(project, *args) - edit_namespace_project_url(project.namespace, project, *args) - end - def issue_url(entity, *args) namespace_project_issue_url(entity.project.namespace, entity.project, entity, *args) end @@ -92,4 +93,56 @@ module GitlabRoutingHelper toggle_subscription_namespace_project_merge_request_path(entity.project.namespace, entity.project, entity) end end + + ## Members + def project_members_url(project, *args) + namespace_project_project_members_url(project.namespace, project) + end + + def project_member_path(project_member, *args) + namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) + end + + def request_access_project_members_path(project, *args) + request_access_namespace_project_project_members_path(project.namespace, project) + end + + def leave_project_members_path(project, *args) + leave_namespace_project_project_members_path(project.namespace, project) + end + + def approve_access_request_project_member_path(project_member, *args) + approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) + end + + def resend_invite_project_member_path(project_member, *args) + resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) + end + + # Groups + + ## Members + def group_members_url(group, *args) + group_group_members_url(group, *args) + end + + def group_member_path(group_member, *args) + group_group_member_path(group_member.source, group_member) + end + + def request_access_group_members_path(group, *args) + request_access_group_group_members_path(group) + end + + def leave_group_members_path(group, *args) + leave_group_group_members_path(group) + end + + def approve_access_request_group_member_path(group_member, *args) + approve_access_request_group_group_member_path(group_member.source, group_member) + end + + def resend_invite_group_member_path(group_member, *args) + resend_invite_group_group_member_path(group_member.source, group_member) + end end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index bd84b8b239f..a53828ef4e7 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -1,117 +1,45 @@ module MembersHelper - def member_class(member) - "#{member.source.class.to_s}Member".constantize - end - - def members_association(entity) - "#{entity.class.to_s.underscore}_members".to_sym - end - + # Returns a `<action>_<source>_member` association, e.g.: + # - admin_project_member, update_project_member, destroy_project_member + # - admin_group_member, update_group_member, destroy_group_member def action_member_permission(action, member) - "#{action}_#{member.source.class.to_s.underscore}_member".to_sym + "#{action}_#{member.type.underscore}".to_sym end - def can_see_entity_roles?(user, entity) + def can_see_member_roles?(source:, user: nil) return false unless user - user.is_admin? || entity.send(members_association(entity)).exists?(user_id: user.id) + user.is_admin? || source.members.exists?(user_id: user.id) end - def member_path(member) - case member.source - when Project - namespace_project_project_member_path(member.source.namespace, member.source, member) - when Group - group_group_member_path(member.source, member) - else - raise ArgumentError.new('Unknown object class') - end - end + def remove_member_message(member, user: nil) + user = current_user if defined?(current_user) - def resend_invite_member_path(member) - case member.source - when Project - resend_invite_namespace_project_project_member_path(member.source.namespace, member.source, member) - when Group - resend_invite_group_group_member_path(member.source, member) - else - raise ArgumentError.new('Unknown object class') - end - end + text = 'Are you sure you want to ' + action = + if member.request? + if member.user == user + 'withdraw your access request for' + else + "deny #{member.user.name}'s request to join" + end + elsif member.invite? + "revoke the invitation for #{member.invite_email} to join" + else + "remove #{member.user.name} from" + end - def request_access_path(entity) - case entity - when Project - request_access_namespace_project_project_members_path(entity.namespace, entity) - when Group - request_access_group_group_members_path(entity) - else - raise ArgumentError.new('Unknown object class') - end - end - - def approve_request_member_path(member) - case member.source - when Project - approve_access_request_namespace_project_project_member_path(member.source.namespace, member.source, member) - when Group - approve_access_request_group_group_member_path(member.source, member) - else - raise ArgumentError.new('Unknown object class') - end - end - - def leave_path(entity) - case entity - when Project - leave_namespace_project_project_members_path(entity.namespace, entity) - when Group - leave_group_group_members_path(entity) - else - raise ArgumentError.new('Unknown object class') - end - end - - def withdraw_request_message(entity) - "Are you sure you want to withdraw your access request for the \"#{entity_name(entity)}\" #{entity_type(entity)}?" - end - - def remove_member_message(member) - entity = member.source - entity_type = entity_type(entity) - entity_name = entity_name(entity) - - if member.request? - "You are going to deny #{member.created_by.name}'s request to join the #{entity_name} #{entity_type}. Are you sure?" - elsif member.invite? - "You are going to revoke the invitation for #{member.invite_email} to join the #{entity_name} #{entity_type}. Are you sure?" - else - "You are going to remove #{member.user.name} from the #{entity_name} #{entity_type}. Are you sure?" - end + text << action << " the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?" end def remove_member_title(member) - member.request? ? 'Deny access request' : 'Remove user' + text = " from #{member.real_source_type.humanize(capitalize: false)}" + + text.prepend(member.request? ? 'Deny access request' : 'Remove user') end - def leave_confirmation_message(entity) - "Are you sure you want to leave \"#{entity_name(entity)}\" #{entity_type(entity)}?" - end - - private - - def entity_type(entity) - entity.class.to_s.underscore - end - - def entity_name(entity) - case entity - when Project - entity.name_with_namespace - when Group - entity.name - else - raise ArgumentError.new('Unknown object class') - end + def leave_confirmation_message(member_source) + "Are you sure you want to leave the " \ + "\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?" end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 03941f87b13..d30dd66202b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -1,8 +1,4 @@ module ProjectsHelper - def max_access_level(project, user) - Gitlab::Access.options_with_owner.key(project.team.max_member_access(user.id)) - end - def link_to_project(project) link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') diff --git a/app/mailers/emails/members.rb b/app/mailers/emails/members.rb index 5fd55c149df..6dde2e9847d 100644 --- a/app/mailers/emails/members.rb +++ b/app/mailers/emails/members.rb @@ -1,104 +1,81 @@ module Emails module Members extend ActiveSupport::Concern + include MembersHelper included do - attr_reader :member_target_type - helper_method :member, :access_requester, :member_target_type, :member_target_name, :member_target_url + helper_method :member_source, :member end - def member_access_requested_email(member_target_type, member_id) - @member_target_type = member_target_type + def member_access_requested_email(member_source_type, member_id) + @member_source_type = member_source_type @member_id = member_id - admins = User.where(id: target.public_send(members_association).admins.pluck(:user_id)).pluck(:notification_email) + admins = member_source.members.owners_and_masters.includes(:user).pluck(:notification_email) mail(to: admins, - subject: subject("Request to join the #{member_target_name} #{member_target_type}")) + subject: subject("Request to join the #{member_source.human_name} #{member_source.model_name.singular}")) end - def member_access_granted_email(member_target_type, member_id) - @member_target_type = member_target_type + def member_access_granted_email(member_source_type, member_id) + @member_source_type = member_source_type @member_id = member_id mail(to: member.user.notification_email, - subject: subject("Access to the #{member_target_name} #{member_target_type} was granted")) + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was granted")) end - def member_access_denied_email(member_target_type, target_id, user_id) - @member_target_type = member_target_type - @target = target_class.find(target_id) + def member_access_denied_email(member_source_type, source_id, user_id) + @member_source_type = member_source_type + @member_source = member_source_class.find(source_id) + requester = User.find(user_id) - mail(to: User.find(user_id).notification_email, - subject: subject("Access to the #{member_target_name} #{member_target_type} was denied")) + mail(to: requester.notification_email, + subject: subject("Access to the #{member_source.human_name} #{member_source.model_name.singular} was denied")) end - def member_invited_email(member_target_type, member_id, token) - @member_target_type = member_target_type + def member_invited_email(member_source_type, member_id, token) + @member_source_type = member_source_type @member_id = member_id @token = token mail(to: member.invite_email, - subject: "Invitation to join the #{member_target_name} #{member_target_type}") + subject: "Invitation to join the #{member_source.human_name} #{member_source.model_name.singular}") end - def member_invite_accepted_email(member_target_type, member_id) - @member_target_type = member_target_type + def member_invite_accepted_email(member_source_type, member_id) + @member_source_type = member_source_type @member_id = member_id - return if access_requester.nil? + return unless member.created_by - mail(to: access_requester.notification_email, + mail(to: member.created_by.notification_email, subject: subject('Invitation accepted')) end - def member_invite_declined_email(member_target_type, target_id, invite_email, created_by_id) - return if created_by_id.nil? + def member_invite_declined_email(member_source_type, source_id, invite_email, created_by_id) + return unless created_by_id - @member_target_type = member_target_type - @target = target_class.find(target_id) + @member_source_type = member_source_type + @member_source = member_source_class.find(source_id) @invite_email = invite_email + inviter = User.find(created_by_id) - mail(to: User.find(created_by_id).notification_email, + mail(to: inviter.notification_email, subject: subject('Invitation declined')) end def member - @member ||= member_class.find(@member_id) + @member ||= Member.find(@member_id) end - def access_requester - @access_requester ||= member.created_by - end - - def member_target_name - case member_target_type - when 'project' - target.name_with_namespace - else - target.name - end - end - - def member_target_url - @member_target_url ||= target.web_url + def member_source + @member_source ||= member.source end private - def target - @target ||= member.public_send(member_target_type) - end - - def target_class - @target_class ||= member_target_type.classify.constantize - end - - def member_class - @member_class ||= "#{member_target_type.classify}Member".constantize - end - - def members_association - @members_association ||= member_class.to_s.tableize + def member_source_class + @member_source_type.classify.constantize end end end diff --git a/app/mailers/notify.rb b/app/mailers/notify.rb index bd5c6788cce..0cc709f68e4 100644 --- a/app/mailers/notify.rb +++ b/app/mailers/notify.rb @@ -13,6 +13,8 @@ class Notify < BaseMailer add_template_helper DiffHelper add_template_helper BlobHelper add_template_helper EmailsHelper + add_template_helper MembersHelper + add_template_helper GitlabRoutingHelper def test_email(recipient_email, subject, body) mail(to: recipient_email, diff --git a/app/models/ability.rb b/app/models/ability.rb index 90156bf130c..647a73aa1ce 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -460,8 +460,6 @@ class Ability rules << :destroy_group_member elsif user == target_user rules << :destroy_group_member - elsif subject.request? && user == subject.created_by - rules << :destroy_group_member end end @@ -481,8 +479,6 @@ class Ability rules << :destroy_project_member elsif user == target_user rules << :destroy_project_member - elsif subject.request? && user == subject.created_by - rules << :destroy_project_member end end diff --git a/app/models/concerns/access_requestable.rb b/app/models/concerns/access_requestable.rb index cf37284e31a..eedd32a729f 100644 --- a/app/models/concerns/access_requestable.rb +++ b/app/models/concerns/access_requestable.rb @@ -10,18 +10,7 @@ module AccessRequestable def request_access(user) members.create( access_level: Gitlab::Access::DEVELOPER, - created_by: user, + user: user, requested_at: Time.now.utc) end - - def access_requested?(user) - members.where(created_by_id: user.id).where.not(requested_at: nil).any? - end - - private - - # Returns a `<entities>_members` association, e.g.: project_members, group_members - def members - @members ||= send("#{self.class.to_s.underscore}_members".to_sym) - end end diff --git a/app/models/group.rb b/app/models/group.rb index 520cbd0283c..b8dffe9f5b9 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -8,7 +8,7 @@ class Group < Namespace has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members - has_many :users, through: :group_members + has_many :users, -> { where(members: { requested_at: nil }) }, through: :group_members has_many :project_group_links, dependent: :destroy has_many :shared_projects, through: :project_group_links, source: :project has_many :notification_settings, dependent: :destroy, as: :source diff --git a/app/models/member.rb b/app/models/member.rb index 5c3a5eab406..cea6d259760 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -8,7 +8,7 @@ class Member < ActiveRecord::Base belongs_to :user belongs_to :source, polymorphic: true - validates :user, presence: true, unless: :pending? + validates :user, presence: true, unless: :invite? validates :source, presence: true validates :user_id, uniqueness: { scope: [:source_type, :source_id], message: "already exists in source", @@ -27,16 +27,17 @@ class Member < ActiveRecord::Base } scope :invite, -> { where.not(invite_token: nil) } + scope :non_invite, -> { where(invite_token: nil) } scope :request, -> { where.not(requested_at: nil) } scope :non_request, -> { where(requested_at: nil) } - scope :non_pending, -> { where.not(user_id: nil) } + scope :non_pending, -> { non_request.non_invite } scope :guests, -> { where(access_level: GUEST) } scope :reporters, -> { where(access_level: REPORTER) } scope :developers, -> { where(access_level: DEVELOPER) } scope :masters, -> { where(access_level: MASTER) } scope :owners, -> { where(access_level: OWNER) } - scope :admins, -> { where(access_level: [OWNER, MASTER]) } + scope :owners_and_masters, -> { where(access_level: [OWNER, MASTER]) } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } @@ -46,6 +47,7 @@ class Member < ActiveRecord::Base after_create :post_create_hook, unless: :pending? after_update :post_update_hook, unless: :pending? after_destroy :post_destroy_hook, unless: :pending? + after_destroy :post_decline_request, if: :request? delegate :name, :username, :email, to: :user, prefix: true @@ -102,36 +104,31 @@ class Member < ActiveRecord::Base end end - def pending? - request? || invite? - end - - def request? - user.nil? && created_by.present? && requested_at.present? + def real_source_type + source_type end def invite? self.invite_token.present? end + def request? + requested_at.present? + end + + def pending? + invite? || request? + end + def accept_request return false unless request? - updated = self.update(user: created_by, requested_at: nil) + updated = self.update(requested_at: nil) after_accept_request if updated updated end - def decline_request - return false unless request? - - self.destroy - after_decline_request if destroyed? - - destroyed? - end - def accept_invite!(new_user) return false unless invite? @@ -217,7 +214,7 @@ class Member < ActiveRecord::Base post_create_hook end - def after_decline_request + def post_decline_request # override in subclass end diff --git a/app/models/members/group_member.rb b/app/models/members/group_member.rb index 476b4816b90..363db877968 100644 --- a/app/models/members/group_member.rb +++ b/app/models/members/group_member.rb @@ -20,6 +20,11 @@ class GroupMember < Member access_level end + # Because source_type is `Namespace`... + def real_source_type + 'Group' + end + private def send_invite @@ -60,8 +65,8 @@ class GroupMember < Member super end - def after_decline_request - notification_service.decline_group_access_request(group, created_by) + def post_decline_request + notification_service.decline_group_access_request(self) super end diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index c6fd1a5c3d1..250ee04fd1d 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -152,8 +152,8 @@ class ProjectMember < Member super end - def after_decline_request - notification_service.decline_project_access_request(project, created_by) + def post_decline_request + notification_service.decline_project_access_request(self) super end diff --git a/app/models/project.rb b/app/models/project.rb index ef665373495..0d2e612436a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -104,7 +104,8 @@ class Project < ActiveRecord::Base has_many :hooks, dependent: :destroy, class_name: 'ProjectHook' has_many :protected_branches, dependent: :destroy has_many :project_members, dependent: :destroy, as: :source, class_name: 'ProjectMember' - has_many :users, through: :project_members + alias_method :members, :project_members + has_many :users, -> { where(members: { requested_at: nil }) }, through: :project_members has_many :deploy_keys_projects, dependent: :destroy has_many :deploy_keys, through: :deploy_keys_projects has_many :users_star_projects, dependent: :destroy @@ -690,6 +691,7 @@ class Project < ActiveRecord::Base end end end + alias_method :human_name, :name_with_namespace def path_with_namespace if namespace diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 7fb17df0e96..73e736820af 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -22,12 +22,12 @@ class ProjectTeam end def find_member(user_id) - member = project.project_members.find_by(user_id: user_id) + member = project.members.non_request.find_by(user_id: user_id) # If user is not in project members # we should check for group membership if group && !member - member = group.group_members.find_by(user_id: user_id) + member = group.members.non_request.find_by(user_id: user_id) end member @@ -128,12 +128,16 @@ class ProjectTeam end end + def human_max_access(user_id) + Gitlab::Access.options_with_owner.key(max_member_access(user_id)) + end + # This method assumes project and group members are eager loaded for optimal # performance. def max_member_access(user_id) access = [] - project.project_members.each do |member| + project.members.non_request.each do |member| if member.user_id == user_id access << member.access_field if member.access_field break @@ -141,7 +145,7 @@ class ProjectTeam end if group - group.group_members.each do |member| + group.members.non_request.each do |member| if member.user_id == user_id access << member.access_field if member.access_field break @@ -174,14 +178,14 @@ class ProjectTeam end def fetch_members(level = nil) - project_members = project.project_members - group_members = group ? group.group_members : [] + project_members = project.members.non_request + group_members = group ? group.members.non_request : [] invited_members = [] if project.invited_groups.any? && project.allowed_to_share_with_group? project.project_group_links.each do |group_link| invited_group = group_link.group - im = invited_group.group_members + im = invited_group.members.non_request if level int_level = GroupMember.access_level_roles[level.to_s.singularize.titleize] diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 259199f6e2b..f804ac171c4 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -175,24 +175,24 @@ class NotificationService # Project access request def new_project_access_request(project_member) - mailer.member_access_requested_email('project', project_member.id).deliver_later + mailer.member_access_requested_email(project_member.real_source_type, project_member.id).deliver_later end - def decline_project_access_request(project, user) - mailer.member_access_denied_email('project', project.id, user.id).deliver_later + def decline_project_access_request(project_member) + mailer.member_access_denied_email(project_member.real_source_type, project_member.project.id, project_member.user.id).deliver_later end def invite_project_member(project_member, token) - mailer.member_invited_email('project', project_member.id, token).deliver_later + mailer.member_invited_email(project_member.real_source_type, project_member.id, token).deliver_later end def accept_project_invite(project_member) - mailer.member_invite_accepted_email('project', project_member.id).deliver_later + mailer.member_invite_accepted_email(project_member.real_source_type, project_member.id).deliver_later end def decline_project_invite(project_member) mailer.member_invite_declined_email( - 'project', + project_member.real_source_type, project_member.project.id, project_member.invite_email, project_member.access_level, @@ -201,24 +201,24 @@ class NotificationService end def new_project_member(project_member) - mailer.member_access_granted_email('project', project_member.id).deliver_later + mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later end def update_project_member(project_member) - mailer.member_access_granted_email('project', project_member.id).deliver_later + mailer.member_access_granted_email(project_member.real_source_type, project_member.id).deliver_later end # Group access request def new_group_access_request(group_member) - mailer.member_access_requested_email('group', group_member.id).deliver_later + mailer.member_access_requested_email(group_member.real_source_type, group_member.id).deliver_later end - def decline_group_access_request(group, user) - mailer.member_access_denied_email('group', group.id, user.id).deliver_later + def decline_group_access_request(group_member) + mailer.member_access_denied_email(group_member.real_source_type, group_member.group.id, group_member.user.id).deliver_later end def invite_group_member(group_member, token) - mailer.member_invited_email('group', group_member.id, token).deliver_later + mailer.member_invited_email(group_member.real_source_type, group_member.id, token).deliver_later end def accept_group_invite(group_member) @@ -227,7 +227,7 @@ class NotificationService def decline_group_invite(group_member) mailer.member_invite_declined_email( - 'group', + group_member.real_source_type, group_member.group.id, group_member.invite_email, group_member.access_level, @@ -236,11 +236,11 @@ class NotificationService end def new_group_member(group_member) - mailer.member_access_granted_email('group', group_member.id).deliver_later + mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later end def update_group_member(group_member) - mailer.member_access_granted_email('group', group_member.id).deliver_later + mailer.member_access_granted_email(group_member.real_source_type, group_member.id).deliver_later end def project_was_moved(project, old_path_with_namespace) diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index a39d5d3d0f0..a36531e095a 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -11,7 +11,7 @@ .new-group-member-holder = render "new_group_member" - = render "shared/members/requests", entity: @group, members: @members + = render 'shared/members/requests', membership_source: @group, members: @members.request .panel.panel-default .panel-heading diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 85635bc4616..62ebd69485c 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -19,6 +19,9 @@ .cover-desc.description = markdown(@group.description, pipeline: :description) + - if current_user + = render 'shared/members/access_request_buttons', source: @group + %div{ class: container_class } .top-area %ul.nav-links diff --git a/app/views/layouts/nav/_group_settings.html.haml b/app/views/layouts/nav/_group_settings.html.haml index b461772b87e..dac46648b9f 100644 --- a/app/views/layouts/nav/_group_settings.html.haml +++ b/app/views/layouts/nav/_group_settings.html.haml @@ -1,3 +1,16 @@ - if current_user - .controls - = render 'shared/group_or_project_home_dropdown', entity: @group + - if access = @group.users.find_by(id: current_user.id) + .controls + .dropdown.group-settings-dropdown + %a.dropdown-new.btn.btn-default#group-settings-button{href: '#', 'data-toggle' => 'dropdown'} + = icon('cog') + = icon('caret-down') + %ul.dropdown-menu.dropdown-menu-align-right + - if can?(current_user, :admin_group, @group) + = nav_link(path: 'groups#projects') do + = link_to projects_group_path(@group), title: 'Projects' do + Projects + %li.divider + %li + = link_to edit_group_path(@group) do + Edit Group diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 3398794302f..ad019710830 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -1,33 +1,25 @@ - if current_user .controls - - access = user_max_access_in_project(current_user.id, @project) - - can_edit = can?(current_user, :admin_project, @project) .dropdown.project-settings-dropdown %a.dropdown-new.btn.btn-default#project-settings-button{href: '#', 'data-toggle' => 'dropdown'} = icon('cog') = icon('caret-down') %ul.dropdown-menu.dropdown-menu-align-right = render 'layouts/nav/project_settings' - %li.divider - - if can_edit - %li - = link_to edit_project_path(@project) do - Edit Project - - if access - %li - = link_to leave_path(@project), - data: { confirm: leave_confirmation_message(@project) }, method: :delete do - Leave Project - - elsif @project.access_requested?(current_user) - %li - = link_to leave_path(@project), - data: { confirm: withdraw_request_message(@project) }, method: :delete do - Withdraw Request - - else - %li - = link_to request_access_path(@project), - class: 'btn btn-gray', style: 'margin-left: 10px', method: :post do - Request Access + + - access = @project.team.max_member_access(current_user.id) + - can_edit = can?(current_user, :admin_project, @project) + - if can_edit || access + %li.divider + - if can_edit + %li + = link_to edit_project_path(@project) do + Edit Project + - if access + %li + = link_to polymorphic_path([:leave, @project, :members]), + data: { confirm: leave_confirmation_message(@project) }, method: :delete do + Leave Project %div{ class: nav_control_class } %ul.nav-links.scrolling-tabs diff --git a/app/views/notify/group_access_granted_email.html.haml b/app/views/notify/group_access_granted_email.html.haml deleted file mode 100644 index 1283758c576..00000000000 --- a/app/views/notify/group_access_granted_email.html.haml +++ /dev/null @@ -1,3 +0,0 @@ -%p - You have been granted #{@group_member.human_access} access to group - #{link_to @group.name, @target_url}. diff --git a/app/views/notify/group_access_granted_email.text.erb b/app/views/notify/group_access_granted_email.text.erb deleted file mode 100644 index c7568350075..00000000000 --- a/app/views/notify/group_access_granted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -You have been granted <%= @group_member.human_access %> access to group <%= @group.name %>. - -<%= @target_url %> diff --git a/app/views/notify/group_invite_accepted_email.html.haml b/app/views/notify/group_invite_accepted_email.html.haml deleted file mode 100644 index 55efad384a7..00000000000 --- a/app/views/notify/group_invite_accepted_email.html.haml +++ /dev/null @@ -1,6 +0,0 @@ -%p - #{@group_member.invite_email}, now known as - #{link_to @group_member.user.name, user_url(@group_member.user)}, - has accepted your invitation to join group - #{link_to @group.name, group_url(@group)}. - diff --git a/app/views/notify/group_invite_accepted_email.text.erb b/app/views/notify/group_invite_accepted_email.text.erb deleted file mode 100644 index f8b70f7a5a6..00000000000 --- a/app/views/notify/group_invite_accepted_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @group_member.invite_email %>, now known as <%= @group_member.user.name %>, has accepted your invitation to join group <%= @group.name %>. - -<%= group_url(@group) %> diff --git a/app/views/notify/group_invite_declined_email.html.haml b/app/views/notify/group_invite_declined_email.html.haml deleted file mode 100644 index f9525d84fac..00000000000 --- a/app/views/notify/group_invite_declined_email.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -%p - #{@invite_email} - has declined your invitation to join group - #{link_to @group.name, group_url(@group)}. - diff --git a/app/views/notify/group_invite_declined_email.text.erb b/app/views/notify/group_invite_declined_email.text.erb deleted file mode 100644 index 6c19a288d15..00000000000 --- a/app/views/notify/group_invite_declined_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= @invite_email %> has declined your invitation to join group <%= @group.name %>. - -<%= group_url(@group) %> diff --git a/app/views/notify/group_member_invited_email.html.haml b/app/views/notify/group_member_invited_email.html.haml deleted file mode 100644 index 163e88bfea3..00000000000 --- a/app/views/notify/group_member_invited_email.html.haml +++ /dev/null @@ -1,14 +0,0 @@ -%p - You have been invited - - if inviter = @group_member.created_by - by - = link_to inviter.name, user_url(inviter) - to join group - = link_to @group.name, group_url(@group) - as #{@group_member.human_access}. - -%p - = link_to 'Accept invitation', invite_url(@token) - or - = link_to 'decline', decline_invite_url(@token) - diff --git a/app/views/notify/group_member_invited_email.text.erb b/app/views/notify/group_member_invited_email.text.erb deleted file mode 100644 index 28ce4819b14..00000000000 --- a/app/views/notify/group_member_invited_email.text.erb +++ /dev/null @@ -1,4 +0,0 @@ -You have been invited <%= "by #{@group_member.created_by.name} " if @group_member.created_by %>to join group <%= @group.name %> as <%= @group_member.human_access %>. - -Accept invitation: <%= invite_url(@token) %> -Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/notify/member_access_denied_email.html.haml b/app/views/notify/member_access_denied_email.html.haml index a25af24d783..71c9c50071a 100644 --- a/app/views/notify/member_access_denied_email.html.haml +++ b/app/views/notify/member_access_denied_email.html.haml @@ -1,4 +1,4 @@ %p Your request to join the - #{link_to member_target_name, member_target_url} #{member_target_type} + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular} has been denied. diff --git a/app/views/notify/member_access_denied_email.text.erb b/app/views/notify/member_access_denied_email.text.erb index eb204458d9d..87f2ef817ee 100644 --- a/app/views/notify/member_access_denied_email.text.erb +++ b/app/views/notify/member_access_denied_email.text.erb @@ -1,3 +1,3 @@ -Your request to join the <%= member_target_name %> <%= member_target_type %> has been denied. +Your request to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> has been denied. -<%= member_target_url %> +<%= member_source.web_url %> diff --git a/app/views/notify/member_access_granted_email.html.haml b/app/views/notify/member_access_granted_email.html.haml index 62837d74555..18dec806539 100644 --- a/app/views/notify/member_access_granted_email.html.haml +++ b/app/views/notify/member_access_granted_email.html.haml @@ -1,3 +1,3 @@ %p You have been granted #{member.human_access} access to the - #{link_to member_target_name, member_target_url} #{member_target_type}. + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_access_granted_email.text.erb b/app/views/notify/member_access_granted_email.text.erb index be9bb5ee948..a9fb3a589a5 100644 --- a/app/views/notify/member_access_granted_email.text.erb +++ b/app/views/notify/member_access_granted_email.text.erb @@ -1,3 +1,3 @@ -You have been granted <%= member.human_access %> access to the <%= member_target_name %> <%= member_target_type %>. +You have been granted <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. -<%= member_target_url %> +<%= member_source.web_url %> diff --git a/app/views/notify/member_access_requested_email.html.haml b/app/views/notify/member_access_requested_email.html.haml index 96e92a069f2..76f1f08a0cb 100644 --- a/app/views/notify/member_access_requested_email.html.haml +++ b/app/views/notify/member_access_requested_email.html.haml @@ -1,3 +1,3 @@ %p - #{link_to access_requester.name, access_requester} requested #{member.human_access} - access to the #{link_to member_target_name, member_target_url} #{member_target_type}. + #{link_to member.user.name, member.user} requested #{member.human_access} + access to the #{link_to member_source.human_name, polymorphic_url([member_source, :members])} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_access_requested_email.text.erb b/app/views/notify/member_access_requested_email.text.erb index 3b5de8c2abe..9c5ee0eaf26 100644 --- a/app/views/notify/member_access_requested_email.text.erb +++ b/app/views/notify/member_access_requested_email.text.erb @@ -1,3 +1,3 @@ -<%= access_requester.name %> (<%= user_url(access_requester) %>) requested <%= member.human_access %> access to the <%= member_target_name %> <%= member_target_type %>. +<%= member.user.name %> (<%= user_url(member.user) %>) requested <%= member.human_access %> access to the <%= member_source.human_name %> <%= member_source.model_name.singular %>. -<%= member_target_url %> +<%= polymorphic_url([member_source, :members]) %> diff --git a/app/views/notify/member_invite_accepted_email.html.haml b/app/views/notify/member_invite_accepted_email.html.haml index c420a8a7b3c..2d1d40881eb 100644 --- a/app/views/notify/member_invite_accepted_email.html.haml +++ b/app/views/notify/member_invite_accepted_email.html.haml @@ -2,4 +2,4 @@ #{member.invite_email}, now known as #{link_to member.user.name, user_url(member.user)}, has accepted your invitation to join the - #{link_to member_target_name, member_target_url} #{member_target_type}. + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_invite_accepted_email.text.erb b/app/views/notify/member_invite_accepted_email.text.erb index a1616163ceb..cef87101427 100644 --- a/app/views/notify/member_invite_accepted_email.text.erb +++ b/app/views/notify/member_invite_accepted_email.text.erb @@ -1,3 +1,3 @@ -<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_target_name %> <%= member_target_type %>. +<%= member.invite_email %>, now known as <%= member.user.name %>, has accepted your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. -<%= member_target_url %> +<%= member_source.web_url %> diff --git a/app/views/notify/member_invite_declined_email.html.haml b/app/views/notify/member_invite_declined_email.html.haml index 5a30ac31b3c..aa1b373d1a6 100644 --- a/app/views/notify/member_invite_declined_email.html.haml +++ b/app/views/notify/member_invite_declined_email.html.haml @@ -1,4 +1,4 @@ %p #{@invite_email} has declined your invitation to join the - #{link_to member_target_name, member_target_url} #{member_target_type}. + #{link_to member_source.human_name, member_source.web_url} #{member_source.model_name.singular}. diff --git a/app/views/notify/member_invite_declined_email.text.erb b/app/views/notify/member_invite_declined_email.text.erb index 301287946d4..8bc305910c4 100644 --- a/app/views/notify/member_invite_declined_email.text.erb +++ b/app/views/notify/member_invite_declined_email.text.erb @@ -1,3 +1,3 @@ -<%= @invite_email %> has declined your invitation to join the <%= member_target_name %> <%= member_target_type %>. +<%= @invite_email %> has declined your invitation to join the <%= member_source.human_name %> <%= member_source.model_name.singular %>. -<%= member_target_url %> +<%= member_source.web_url %> diff --git a/app/views/notify/member_invited_email.html.haml b/app/views/notify/member_invited_email.html.haml index a8e58df9ac8..b8b75da3f2f 100644 --- a/app/views/notify/member_invited_email.html.haml +++ b/app/views/notify/member_invited_email.html.haml @@ -1,11 +1,11 @@ %p You have been invited - - if access_requester + - if member.created_by by - = link_to access_requester.name, user_url(access_requester) + = link_to member.created_by.name, user_url(member.created_by) to join the - = link_to member_target_name, member_target_url - #{member_target_type} as #{member.human_access}. + = link_to member_source.human_name, member_source.web_url + #{member_source.model_name.singular} as #{member.human_access}. %p = link_to 'Accept invitation', invite_url(@token) diff --git a/app/views/notify/member_invited_email.text.erb b/app/views/notify/member_invited_email.text.erb index 1b6897ee2ec..0a6393355be 100644 --- a/app/views/notify/member_invited_email.text.erb +++ b/app/views/notify/member_invited_email.text.erb @@ -1,4 +1,4 @@ -You have been invited <%= "by #{access_requester.name} " if access_requester %>to join the <%= member_target_name %> <%= member_target_type %> as <%= member.human_access %>. +You have been invited <%= "by #{member.created_by.name} " if member.created_by %>to join the <%= member_source.human_name %> <%= member_source.model_name.singular %> as <%= member.human_access %>. Accept invitation: <%= invite_url(@token) %> Decline invitation: <%= decline_invite_url(@token) %> diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index f5bc1b4e409..2b19ee93eea 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -29,10 +29,13 @@ .project-clone-holder = render "shared/clone_panel" - .project-repo-buttons.btn-group.project-right-buttons - = render "projects/buttons/download" - = render 'projects/buttons/dropdown' - = render 'projects/buttons/notifications' + .project-repo-buttons.project-right-buttons + - if current_user + = render 'shared/members/access_request_buttons', source: @project + .btn-group + = render "projects/buttons/download" + = render 'projects/buttons/dropdown' + = render 'projects/buttons/notifications' :javascript new Star(); diff --git a/app/views/projects/buttons/_notifications.html.haml b/app/views/projects/buttons/_notifications.html.haml index 3b97dc9328f..a7a97181096 100644 --- a/app/views/projects/buttons/_notifications.html.haml +++ b/app/views/projects/buttons/_notifications.html.haml @@ -1,7 +1,7 @@ - if @notification_setting = form_for @notification_setting, url: namespace_project_notification_setting_path(@project.namespace.becomes(Namespace), @project), method: :patch, remote: true, html: { class: 'inline', id: 'notification-form' } do |f| = f.hidden_field :level - .dropdown + .dropdown.hidden-sm %button.btn.btn-default.notifications-btn#notifications-button{ data: { toggle: "dropdown" }, aria: { haspopup: "true", expanded: "false" } } = icon('bell') = notification_title(@notification_setting.level) diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 112a532f9d3..bcdbff08011 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -17,7 +17,7 @@ %a{ href: "##{dom_id(note)}" } = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note-created-ago') .note-actions - - access = max_access_level(note.project, note.author) + - access = note.project.team.human_max_access(note.author.id) - if access %span.note-role.hidden-xs= access - if current_user diff --git a/app/views/projects/project_members/_group_members.html.haml b/app/views/projects/project_members/_group_members.html.haml index 78c12d52a78..cb6136c215a 100644 --- a/app/views/projects/project_members/_group_members.html.haml +++ b/app/views/projects/project_members/_group_members.html.haml @@ -6,8 +6,9 @@ (#{members.count}) - if can?(current_user, :admin_group_member, @group) .controls - = link_to group_group_members_path(@group), class: 'btn' do - Manage group members + = link_to 'Manage group members', + group_group_members_path(@group), + class: 'btn' %ul.content-list = render partial: 'shared/members/member', collection: members.limit(20), @@ -15,7 +16,4 @@ locals: { show_controls: false } - if members.size > 20 %li - and - = members.size - 20 - more. For full list visit - = link_to 'group members page', group_group_members_path(@group) + and #{members.count - 20} more. For full list visit #{link_to 'group members page', group_group_members_path(@group)} diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index 61a82724d69..357ccccaf1d 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -13,9 +13,9 @@ Users with access to this project are listed below. = render "new_project_member" - = render "shared/members/requests", entity: @project, members: @project_members + = render 'shared/members/requests', membership_source: @project, members: @project_members.request - = render "team", members: @project_members.non_request + = render 'team', members: @project_members.non_request - if @group = render "group_members", members: @group_members diff --git a/app/views/shared/_group_or_project_home_dropdown.html.haml b/app/views/shared/_group_or_project_home_dropdown.html.haml deleted file mode 100644 index fb9e63f2bd4..00000000000 --- a/app/views/shared/_group_or_project_home_dropdown.html.haml +++ /dev/null @@ -1,30 +0,0 @@ -- member = entity.send(members_association(entity)).find_by(user_id: current_user.id) -- can_edit = can?(current_user, "admin_#{entity.class.to_s.underscore}".to_sym, entity) - -- if member || can_edit - .dropdown.project-settings-dropdown - %a.dropdown-new.btn.btn-gray{ href: '#', id: "#{entity.class.to_s.underscore}-settings-button", data: { toggle: 'dropdown' } } - = icon('cog') - = icon('caret-down') - %ul.dropdown-menu.dropdown-menu-align-right - - if can_edit - %li - = link_to "Edit #{entity.class.to_s}", [:edit, entity] - - - if member - %li - = link_to "Leave #{entity.class.to_s}", - leave_path(entity), - method: :delete, - data: { confirm: leave_confirmation_message(entity) } -- elsif entity.access_requested?(current_user) - = link_to 'Withdraw Request', - leave_path(entity), - data: { confirm: withdraw_request_message(entity) }, - method: :delete, - class: 'btn btn-grouped btn-gray' -- else - = link_to 'Request Access', - request_access_path(entity), - method: :post, - class: 'btn btn-grouped btn-gray' diff --git a/app/views/shared/members/_access_request_buttons.html.haml b/app/views/shared/members/_access_request_buttons.html.haml new file mode 100644 index 00000000000..ed0a6ebcf84 --- /dev/null +++ b/app/views/shared/members/_access_request_buttons.html.haml @@ -0,0 +1,12 @@ +- member = source.members.find_by(user_id: current_user.id) + +- if member + - if member.request? + = link_to 'Withdraw Access Request', polymorphic_path([:leave, source, :members]), + method: :delete, + data: { confirm: remove_member_message(member) }, + class: 'btn access-request-button hidden-xs' +- else + = link_to 'Request Access', polymorphic_path([:request_access, source, :members]), + method: :post, + class: 'btn access-request-button hidden-xs' diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index 7e119155a6c..c69d4cbfbe3 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -1,6 +1,6 @@ - show_roles = local_assigns.fetch(:show_roles, true) - show_controls = local_assigns.fetch(:show_controls, true) -- user = member.request? ? member.created_by : member.user +- user = member.user %li.js-toggle-container{ class: dom_class(member), id: dom_id(member) } %span{ class: ("list-item-name" if show_controls) } @@ -18,25 +18,25 @@ %strong Blocked - if member.request? - %small + %span.cgray – Requested = time_ago_with_tooltip(member.requested_at) - else = image_tag avatar_icon(member.invite_email, 24), class: "avatar s24", alt: '' %strong= member.invite_email %span.cgray - invited + – Invited - if member.created_by by = link_to member.created_by.name, user_path(member.created_by) = time_ago_with_tooltip(member.created_at) - if show_controls && can?(current_user, action_member_permission(:admin, member), member.source) - = link_to 'Resend invite', resend_invite_member_path(member), + = link_to 'Resend invite', polymorphic_path([:resend_invite, member]), method: :post, class: 'btn-xs btn' - - if show_roles && can_see_entity_roles?(current_user, member.source) + - if show_roles && can_see_member_roles?(source: member.source, user: current_user) %span.pull-right %strong= member.human_access - if show_controls @@ -48,30 +48,30 @@ - if member.request?   - = link_to icon('check inverse'), approve_request_member_path(member), + = link_to icon('check inverse'), polymorphic_path([:approve_access_request, member]), method: :post, - type: 'button', class: 'btn-xs btn btn-success', title: 'Grant access' - if can?(current_user, action_member_permission(:destroy, member), member)   - if current_user == user - = link_to leave_path(member.source), data: { confirm: leave_confirmation_message(member.source)}, method: :delete, class: "btn-xs btn btn-remove", title: 'Remove user from group' do - = icon("sign-out") - Leave - - else - = link_to icon('trash'), member_path(member), + = link_to icon('sign-out', text: 'Leave'), polymorphic_path([:leave, member.source, :members]), method: :delete, + data: { confirm: leave_confirmation_message(member.source) }, + class: 'btn-xs btn btn-remove' + - else + = link_to icon('trash'), member, remote: true, + method: :delete, data: { confirm: remove_member_message(member) }, class: 'btn-xs btn btn-remove', title: remove_member_title(member) .edit-member.hide.js-toggle-content %br - = form_for member_path(member), as: "#{member.source.class.to_s.underscore}_member".to_sym, remote: true do |f| + = form_for member, remote: true do |f| .prepend-top-10 - = f.select :access_level, options_for_select(member_class(member).access_level_roles, member.access_level), {}, class: 'form-control' + = f.select :access_level, options_for_select(member.class.access_level_roles, member.access_level), {}, class: 'form-control' .prepend-top-10 = f.submit 'Save', class: 'btn btn-save btn-sm' diff --git a/app/views/shared/members/_requests.html.haml b/app/views/shared/members/_requests.html.haml index ffbb380f794..b5963876034 100644 --- a/app/views/shared/members/_requests.html.haml +++ b/app/views/shared/members/_requests.html.haml @@ -1,10 +1,8 @@ -- requesters = members.request - -- if requesters.any? +- if members.any? .panel.panel-default .panel-heading - %strong= entity.name + %strong= membership_source.name access requests - %small= "(#{requesters.size})" + %small= "(#{members.size})" %ul.content-list - = render partial: 'shared/members/member', collection: requesters, as: :member + = render partial: 'shared/members/member', collection: members, as: :member diff --git a/features/steps/dashboard/group.rb b/features/steps/dashboard/group.rb index 0c6a0ae3725..9b79a3be49b 100644 --- a/features/steps/dashboard/group.rb +++ b/features/steps/dashboard/group.rb @@ -62,6 +62,6 @@ class Spinach::Features::DashboardGroup < Spinach::FeatureSteps end step 'I should see the "Can not leave message"' do - expect(page).to have_content "You can not leave Owned group because you're the last owner" + expect(page).to have_content "You can not leave the \"Owned\" group." end end diff --git a/features/steps/group/members.rb b/features/steps/group/members.rb index 9de82765df1..dfa2fa75def 100644 --- a/features/steps/group/members.rb +++ b/features/steps/group/members.rb @@ -53,7 +53,7 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do page.within '.content-list' do expect(page).to have_content('sjobs@apple.com') - expect(page).to have_content('invited') + expect(page).to have_content('Invited') expect(page).to have_content('Reporter') end end @@ -116,11 +116,9 @@ class Spinach::Features::GroupMembers < Spinach::FeatureSteps member = mary_jane_member page.within "#group_member_#{member.id}" do - find(".js-toggle-button").click - page.within "#edit_group_member_#{member.id}" do - select 'Developer', from: 'group_member_access_level' - click_on 'Save' - end + click_button "Edit access level" + select 'Developer', from: 'group_member_access_level' + click_on 'Save' end end diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index c6ced747370..f32576d2cb1 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -26,8 +26,11 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end step 'I should see "Mike" in team list as "Reporter"' do - page.within ".access-reporter" do + user = User.find_by(name: 'Mike') + project_member = project.project_members.find_by(user_id: user.id) + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('Mike') + expect(page).to have_content('Reporter') end end @@ -40,16 +43,20 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end step 'I should see "sjobs@apple.com" in team list as invited "Reporter"' do - page.within ".access-reporter" do + project_member = project.project_members.find_by(invite_email: 'sjobs@apple.com') + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('sjobs@apple.com') - expect(page).to have_content('invited') + expect(page).to have_content('Invited') expect(page).to have_content('Reporter') end end step 'I should see "Dmitriy" in team list as "Developer"' do - page.within ".access-developer" do + user = User.find_by(name: 'Dmitriy') + project_member = project.project_members.find_by(user_id: user.id) + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('Dmitriy') + expect(page).to have_content('Developer') end end @@ -65,15 +72,14 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps end step 'I should see "Dmitriy" in team list as "Reporter"' do - page.within ".access-reporter" do + user = User.find_by(name: 'Dmitriy') + project_member = project.project_members.find_by(user_id: user.id) + page.within "#project_member_#{project_member.id}" do expect(page).to have_content('Dmitriy') + expect(page).to have_content('Reporter') end end - step 'I click link "Remove from team"' do - click_link "Remove from team" - end - step 'I should not see "Dmitriy" in team list' do user = User.find_by(name: "Dmitriy") expect(page).not_to have_content(user.name) @@ -120,7 +126,7 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps user = User.find_by(name: 'Dmitriy') project_member = project.project_members.find_by(user_id: user.id) page.within "#project_member_#{project_member.id}" do - click_link('Remove user from team') + click_link('Remove user from project') end end diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 14370ac218d..cc29c7ef428 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -88,10 +88,7 @@ module API class Group < Grape::Entity expose :id, :name, :path, :description, :visibility_level expose :avatar_url - - expose :web_url do |group, options| - Gitlab::Routing.url_helpers.group_url(group) - end + expose :web_url end class GroupDetail < Group diff --git a/spec/controllers/groups/group_members_controller_spec.rb b/spec/controllers/groups/group_members_controller_spec.rb index 0ca8a656f63..89c2c26a367 100644 --- a/spec/controllers/groups/group_members_controller_spec.rb +++ b/spec/controllers/groups/group_members_controller_spec.rb @@ -35,7 +35,7 @@ describe Groups::GroupMembersController do let(:group_user) { create(:user) } let(:member) do group.add_developer(group_user) - group.group_members.find_by(user_id: group_user.id) + group.members.find_by(user_id: group_user) end context 'when user does not have enough rights' do @@ -103,7 +103,7 @@ describe Groups::GroupMembersController do it 'removes user from members' do delete :leave, group_id: group - expect(response).to set_flash.to "You left #{group.name} group." + expect(response).to set_flash.to "You left the \"#{group.name}\" group." expect(response).to redirect_to(dashboard_groups_path) expect(group.users).not_to include user end @@ -118,8 +118,8 @@ describe Groups::GroupMembersController do it 'cannot removes himself from the group' do delete :leave, group_id: group - expect(response).to redirect_to(dashboard_groups_path) - expect(response).to set_flash[:alert].to "You can not leave #{group.name} group because you're the last owner. Transfer or delete the group." + expect(response).to redirect_to(group_path(group)) + expect(response).to set_flash[:alert].to "You can not leave the \"#{group.name}\" group. Transfer or delete the group." expect(group.users).to include user end end @@ -133,9 +133,9 @@ describe Groups::GroupMembersController do it 'removes user from members' do delete :leave, group_id: group - expect(response).to set_flash.to 'You withdrawn your access request to the group.' + expect(response).to set_flash.to 'Your access request to the group has been withdrawn.' expect(response).to redirect_to(dashboard_groups_path) - expect(group.group_members.request).to be_empty + expect(group.members.request).to be_empty expect(group.users).not_to include user end end @@ -155,18 +155,18 @@ describe Groups::GroupMembersController do expect(response).to set_flash.to 'Your request for access has been queued for review.' expect(response).to redirect_to(group_path(group)) - expect(group.group_members.request.find_by(created_by_id: user.id).created_by).to eq user + expect(group.members.request.exists?(user_id: user)).to be_truthy expect(group.users).not_to include user end end - describe '#approve' do + describe '#approve_access_request' do let(:group) { create(:group, :public) } context 'when member is not found' do it 'returns 403' do post :approve_access_request, group_id: group, - id: 42 + id: 42 expect(response.status).to eq(403) end @@ -177,7 +177,7 @@ describe Groups::GroupMembersController do let(:group_requester) { create(:user) } let(:member) do group.request_access(group_requester) - group.group_members.request.find_by(created_by_id: group_requester.id) + group.members.request.find_by(user_id: group_requester) end context 'when user does not have enough rights' do @@ -188,7 +188,7 @@ describe Groups::GroupMembersController do it 'returns 403' do post :approve_access_request, group_id: group, - id: member + id: member expect(response.status).to eq(403) expect(group.users).not_to include group_requester @@ -203,7 +203,7 @@ describe Groups::GroupMembersController do it 'adds user to members' do post :approve_access_request, group_id: group, - id: member + id: member expect(response).to redirect_to(group_group_members_path(group)) expect(group.users).to include group_requester diff --git a/spec/controllers/projects/project_members_controller_spec.rb b/spec/controllers/projects/project_members_controller_spec.rb index d3bd2d0bbba..fc5f458e795 100644 --- a/spec/controllers/projects/project_members_controller_spec.rb +++ b/spec/controllers/projects/project_members_controller_spec.rb @@ -80,7 +80,7 @@ describe Projects::ProjectMembersController do let(:team_user) { create(:user) } let(:member) do project.team << [team_user, :developer] - project.project_members.find_by(user_id: team_user.id) + project.members.find_by(user_id: team_user.id) end context 'when user does not have enough rights' do @@ -154,7 +154,7 @@ describe Projects::ProjectMembersController do delete :leave, namespace_id: project.namespace, project_id: project - expect(response).to set_flash.to 'You left the project.' + expect(response).to set_flash.to "You left the \"#{project.human_name}\" project." expect(response).to redirect_to(dashboard_projects_path) expect(project.users).not_to include user end @@ -167,14 +167,14 @@ describe Projects::ProjectMembersController do sign_in(user) end - it 'cannot removes himself from the project' do + it 'cannot remove himself from the project' do delete :leave, namespace_id: project.namespace, project_id: project expect(response).to redirect_to( - namespace_project_project_members_path(project.namespace, project) + namespace_project_path(project.namespace, project) ) - expect(response).to set_flash[:alert].to 'You can not leave your own project. Transfer or delete the project.' + expect(response).to set_flash[:alert].to "You can not leave the \"#{project.human_name}\" project. Transfer or delete the project." expect(project.users).to include user end end @@ -189,9 +189,9 @@ describe Projects::ProjectMembersController do delete :leave, namespace_id: project.namespace, project_id: project - expect(response).to set_flash.to 'You withdrawn your access request to the project.' + expect(response).to set_flash.to 'Your access request to the project has been withdrawn.' expect(response).to redirect_to(dashboard_projects_path) - expect(project.project_members.request).to be_empty + expect(project.members.request).to be_empty expect(project.users).not_to include user end end @@ -214,7 +214,7 @@ describe Projects::ProjectMembersController do expect(response).to redirect_to( namespace_project_path(project.namespace, project) ) - expect(project.project_members.request.find_by(created_by_id: user.id).created_by).to eq user + expect(project.members.request.exists?(user_id: user)).to be_truthy expect(project.users).not_to include user end end @@ -225,8 +225,8 @@ describe Projects::ProjectMembersController do context 'when member is not found' do it 'returns 404' do post :approve_access_request, namespace_id: project.namespace, - project_id: project, - id: 42 + project_id: project, + id: 42 expect(response.status).to eq(404) end @@ -237,7 +237,7 @@ describe Projects::ProjectMembersController do let(:team_requester) { create(:user) } let(:member) do project.request_access(team_requester) - project.project_members.request.find_by(created_by_id: team_requester.id) + project.members.request.find_by(user_id: team_requester.id) end context 'when user does not have enough rights' do @@ -248,8 +248,8 @@ describe Projects::ProjectMembersController do it 'returns 404' do post :approve_access_request, namespace_id: project.namespace, - project_id: project, - id: member + project_id: project, + id: member expect(response.status).to eq(404) expect(project.users).not_to include team_requester @@ -264,8 +264,8 @@ describe Projects::ProjectMembersController do it 'adds user to members' do post :approve_access_request, namespace_id: project.namespace, - project_id: project, - id: member + project_id: project, + id: member expect(response).to redirect_to( namespace_project_project_members_path(project.namespace, project) diff --git a/spec/features/groups/members/owner_manages_access_requests_spec.rb b/spec/features/groups/members/owner_manages_access_requests_spec.rb index d5b5e0e35ea..22525ce530b 100644 --- a/spec/features/groups/members/owner_manages_access_requests_spec.rb +++ b/spec/features/groups/members/owner_manages_access_requests_spec.rb @@ -22,12 +22,10 @@ feature 'Groups > Members > Owner manages access requests', feature: true do expect_visible_access_request(group, user) - perform_enqueued_jobs do - click_on 'Grant access' - end + perform_enqueued_jobs { click_on 'Grant access' } expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{group.name} group was granted/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was granted" end scenario 'master can deny access' do @@ -35,17 +33,15 @@ feature 'Groups > Members > Owner manages access requests', feature: true do expect_visible_access_request(group, user) - perform_enqueued_jobs do - click_on 'Deny access' - end + perform_enqueued_jobs { click_on 'Deny access' } expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{group.name} group was denied/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{group.name} group was denied" end def expect_visible_access_request(group, user) - expect(group.access_requested?(user)).to be_truthy + expect(group.members.request.exists?(user_id: user)).to be_truthy expect(page).to have_content "#{group.name} access requests (1)" expect(page).to have_content user.name end diff --git a/spec/features/groups/members/user_requests_access_spec.rb b/spec/features/groups/members/user_requests_access_spec.rb index 9b8492807fa..a878a96b6ee 100644 --- a/spec/features/groups/members/user_requests_access_spec.rb +++ b/spec/features/groups/members/user_requests_access_spec.rb @@ -8,47 +8,41 @@ feature 'Groups > Members > User requests access', feature: true do background do group.add_owner(owner) login_as(user) + visit group_path(group) end scenario 'user can request access to a group' do - visit group_path(group) - - perform_enqueued_jobs do - click_link 'Request Access' - end + perform_enqueued_jobs { click_link 'Request Access' } expect(ActionMailer::Base.deliveries.last.to).to eq [owner.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Request to join #{group.name} group/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Request to join the #{group.name} group" - expect(group.access_requested?(user)).to be_truthy + expect(group.members.request.exists?(user_id: user)).to be_truthy expect(page).to have_content 'Your request for access has been queued for review.' - expect(page).to have_content 'Withdraw Request' + + expect(page).to have_content 'Withdraw Access Request' end scenario 'user is not listed in the group members page' do - visit group_path(group) - click_link 'Request Access' - expect(group.access_requested?(user)).to be_truthy + expect(group.members.request.exists?(user_id: user)).to be_truthy click_link 'Members' - visit group_group_members_path(group) page.within('.content') do expect(page).not_to have_content(user.name) end end scenario 'user can withdraw its request for access' do - visit group_path(group) click_link 'Request Access' - expect(group.access_requested?(user)).to be_truthy + expect(group.members.request.exists?(user_id: user)).to be_truthy - click_link 'Withdraw Request' + click_link 'Withdraw Access Request' - expect(group.access_requested?(user)).to be_falsey - expect(page).to have_content 'You withdrawn your access request to the group.' + expect(group.members.request.exists?(user_id: user)).to be_falsey + expect(page).to have_content 'Your access request to the group has been withdrawn.' end end diff --git a/spec/features/projects/members/master_manages_access_requests_spec.rb b/spec/features/projects/members/master_manages_access_requests_spec.rb index 1b5490ba97f..5fe4caa12f0 100644 --- a/spec/features/projects/members/master_manages_access_requests_spec.rb +++ b/spec/features/projects/members/master_manages_access_requests_spec.rb @@ -22,12 +22,10 @@ feature 'Projects > Members > Master manages access requests', feature: true do expect_visible_access_request(project, user) - perform_enqueued_jobs do - click_on 'Grant access' - end + perform_enqueued_jobs { click_on 'Grant access' } expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{project.name_with_namespace} project was granted/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{project.name_with_namespace} project was granted" end scenario 'master can deny access' do @@ -35,16 +33,14 @@ feature 'Projects > Members > Master manages access requests', feature: true do expect_visible_access_request(project, user) - perform_enqueued_jobs do - click_on 'Deny access' - end + perform_enqueued_jobs { click_on 'Deny access' } expect(ActionMailer::Base.deliveries.last.to).to eq [user.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Access to #{project.name_with_namespace} project was denied/ + expect(ActionMailer::Base.deliveries.last.subject).to match "Access to the #{project.name_with_namespace} project was denied" end def expect_visible_access_request(project, user) - expect(project.access_requested?(user)).to be_truthy + expect(project.members.request.exists?(user_id: user)).to be_truthy expect(page).to have_content "#{project.name} access requests (1)" expect(page).to have_content user.name end diff --git a/spec/features/projects/members/user_requests_access_spec.rb b/spec/features/projects/members/user_requests_access_spec.rb index 58a7ec1880d..fd92a3a2f0c 100644 --- a/spec/features/projects/members/user_requests_access_spec.rb +++ b/spec/features/projects/members/user_requests_access_spec.rb @@ -8,30 +8,27 @@ feature 'Projects > Members > User requests access', feature: true do background do project.team << [master, :master] login_as(user) + visit namespace_project_path(project.namespace, project) end scenario 'user can request access to a project' do - visit namespace_project_path(project.namespace, project) - - perform_enqueued_jobs do - click_link 'Request Access' - end + perform_enqueued_jobs { click_link 'Request Access' } expect(ActionMailer::Base.deliveries.last.to).to eq [master.notification_email] - expect(ActionMailer::Base.deliveries.last.subject).to match /Request to join #{project.name_with_namespace} project/ + expect(ActionMailer::Base.deliveries.last.subject).to eq "Request to join the #{project.name_with_namespace} project" - expect(project.access_requested?(user)).to be_truthy + expect(project.members.request.exists?(user_id: user)).to be_truthy expect(page).to have_content 'Your request for access has been queued for review.' - expect(page).to have_content 'Withdraw Request' + + expect(page).to have_content 'Withdraw Access Request' end scenario 'user is not listed in the project members page' do - visit namespace_project_path(project.namespace, project) - click_link 'Request Access' - expect(project.access_requested?(user)).to be_truthy + expect(project.members.request.exists?(user_id: user)).to be_truthy + open_project_settings_menu click_link 'Members' visit namespace_project_project_members_path(project.namespace, project) @@ -41,14 +38,17 @@ feature 'Projects > Members > User requests access', feature: true do end scenario 'user can withdraw its request for access' do - visit namespace_project_path(project.namespace, project) click_link 'Request Access' - expect(project.access_requested?(user)).to be_truthy + expect(project.members.request.exists?(user_id: user)).to be_truthy - click_link 'Withdraw Request' + click_link 'Withdraw Access Request' - expect(project.access_requested?(user)).to be_falsey - expect(page).to have_content 'You withdrawn your access request to the project.' + expect(project.members.request.exists?(user_id: user)).to be_falsey + expect(page).to have_content 'Your access request to the project has been withdrawn.' + end + + def open_project_settings_menu + find('#project-settings-button').click end end diff --git a/spec/helpers/gitlab_routing_helper_spec.rb b/spec/helpers/gitlab_routing_helper_spec.rb new file mode 100644 index 00000000000..14847d0a49e --- /dev/null +++ b/spec/helpers/gitlab_routing_helper_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe GitlabRoutingHelper do + describe 'Project URL helpers' do + describe '#project_members_url' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(project_members_url(project)).to eq namespace_project_project_members_url(project.namespace, project) } + end + + describe '#project_member_path' do + let(:project_member) { create(:project_member) } + + it { expect(project_member_path(project_member)).to eq namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + end + + describe '#request_access_project_members_path' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(request_access_project_members_path(project)).to eq request_access_namespace_project_project_members_path(project.namespace, project) } + end + + describe '#leave_project_members_path' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(leave_project_members_path(project)).to eq leave_namespace_project_project_members_path(project.namespace, project) } + end + + describe '#approve_access_request_project_member_path' do + let(:project_member) { create(:project_member) } + + it { expect(approve_access_request_project_member_path(project_member)).to eq approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + end + + describe '#resend_invite_project_member_path' do + let(:project_member) { create(:project_member) } + + it { expect(resend_invite_project_member_path(project_member)).to eq resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } + end + end + + describe 'Group URL helpers' do + describe '#group_members_url' do + let(:group) { build_stubbed(:group) } + + it { expect(group_members_url(group)).to eq group_group_members_url(group) } + end + + describe '#group_member_path' do + let(:group_member) { create(:group_member) } + + it { expect(group_member_path(group_member)).to eq group_group_member_path(group_member.source, group_member) } + end + + describe '#request_access_group_members_path' do + let(:group) { build_stubbed(:group) } + + it { expect(request_access_group_members_path(group)).to eq request_access_group_group_members_path(group) } + end + + describe '#leave_group_members_path' do + let(:group) { build_stubbed(:group) } + + it { expect(leave_group_members_path(group)).to eq leave_group_group_members_path(group) } + end + + describe '#approve_access_request_group_member_path' do + let(:group_member) { create(:group_member) } + + it { expect(approve_access_request_group_member_path(group_member)).to eq approve_access_request_group_group_member_path(group_member.source, group_member) } + end + + describe '#resend_invite_group_member_path' do + let(:group_member) { create(:group_member) } + + it { expect(resend_invite_group_member_path(group_member)).to eq resend_invite_group_group_member_path(group_member.source, group_member) } + end + end +end diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb index c2f10e1db75..0b1a76156e0 100644 --- a/spec/helpers/members_helper_spec.rb +++ b/spec/helpers/members_helper_spec.rb @@ -1,22 +1,6 @@ require 'spec_helper' describe MembersHelper do - describe '#member_class' do - let(:project_member) { build(:project_member) } - let(:group_member) { build(:group_member) } - - it { expect(member_class(project_member)).to eq ProjectMember } - it { expect(member_class(group_member)).to eq GroupMember } - end - - describe '#members_association' do - let(:project) { build_stubbed(:project) } - let(:group) { build_stubbed(:group) } - - it { expect(members_association(project)).to eq :project_members } - it { expect(members_association(group)).to eq :group_members } - end - describe '#action_member_permission' do let(:project_member) { build(:project_member) } let(:group_member) { build(:group_member) } @@ -25,73 +9,20 @@ describe MembersHelper do it { expect(action_member_permission(:admin, group_member)).to eq :admin_group_member } end - describe '#can_see_entity_roles?' do - let(:project) { create(:project) } + describe '#can_see_member_roles?' do + let(:project) { create(:empty_project) } let(:group) { create(:group) } let(:user) { build(:user) } let(:admin) { build(:user, :admin) } let(:project_member) { create(:project_member, project: project) } let(:group_member) { create(:group_member, group: group) } - it { expect(can_see_entity_roles?(nil, project)).to be_falsy } - it { expect(can_see_entity_roles?(nil, group)).to be_falsy } - it { expect(can_see_entity_roles?(admin, project)).to be_truthy } - it { expect(can_see_entity_roles?(admin, group)).to be_truthy } - it { expect(can_see_entity_roles?(project_member.user, project)).to be_truthy } - it { expect(can_see_entity_roles?(group_member.user, group)).to be_truthy } - end - - describe '#member_path' do - let(:project_member) { create(:project_member) } - let(:group_member) { create(:group_member) } - - it { expect(member_path(project_member)).to eq namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } - it { expect(member_path(group_member)).to eq group_group_member_path(group_member.source, group_member) } - it { expect { member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#resend_invite_member_path' do - let(:project_member) { create(:project_member) } - let(:group_member) { create(:group_member) } - - it { expect(resend_invite_member_path(project_member)).to eq resend_invite_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } - it { expect(resend_invite_member_path(group_member)).to eq resend_invite_group_group_member_path(group_member.source, group_member) } - it { expect { resend_invite_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#request_access_path' do - let(:project) { build_stubbed(:project) } - let(:group) { build_stubbed(:group) } - - it { expect(request_access_path(project)).to eq request_access_namespace_project_project_members_path(project.namespace, project) } - it { expect(request_access_path(group)).to eq request_access_group_group_members_path(group) } - it { expect { request_access_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#approve_request_member_path' do - let(:project_member) { create(:project_member) } - let(:group_member) { create(:group_member) } - - it { expect(approve_request_member_path(project_member)).to eq approve_access_request_namespace_project_project_member_path(project_member.source.namespace, project_member.source, project_member) } - it { expect(approve_request_member_path(group_member)).to eq approve_access_request_group_group_member_path(group_member.source, group_member) } - it { expect { approve_request_member_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#leave_path' do - let(:project) { build_stubbed(:project) } - let(:group) { build_stubbed(:group) } - - it { expect(leave_path(project)).to eq leave_namespace_project_project_members_path(project.namespace, project) } - it { expect(leave_path(group)).to eq leave_group_group_members_path(group) } - it { expect { leave_path(double(:member, source: 'foo')) }.to raise_error ArgumentError, 'Unknown object class' } - end - - describe '#withdraw_request_message' do - let(:project) { build_stubbed(:project) } - let(:group) { build_stubbed(:group) } - - it { expect(withdraw_request_message(project)).to eq "Are you sure you want to withdraw your access request for the \"#{project.name_with_namespace}\" project?" } - it { expect(withdraw_request_message(group)).to eq "Are you sure you want to withdraw your access request for the \"#{group.name}\" group?" } + it { expect(can_see_member_roles?(source: project, user: nil)).to be_falsy } + it { expect(can_see_member_roles?(source: group, user: nil)).to be_falsy } + it { expect(can_see_member_roles?(source: project, user: admin)).to be_truthy } + it { expect(can_see_member_roles?(source: group, user: admin)).to be_truthy } + it { expect(can_see_member_roles?(source: project, user: project_member.user)).to be_truthy } + it { expect(can_see_member_roles?(source: group, user: group_member.user)).to be_truthy } end describe '#remove_member_message' do @@ -105,12 +36,14 @@ describe MembersHelper do let(:group_member_invite) { build(:group_member, group: group).tap { |m| m.generate_invite_token! } } let(:group_member_request) { group.request_access(requester) } - it { expect(remove_member_message(project_member)).to eq "You are going to remove #{project_member.user.name} from the #{project.name_with_namespace} project. Are you sure?" } - it { expect(remove_member_message(project_member_invite)).to eq "You are going to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.name_with_namespace} project. Are you sure?" } - it { expect(remove_member_message(project_member_request)).to eq "You are going to deny #{requester.name}'s request to join the #{project.name_with_namespace} project. Are you sure?" } - it { expect(remove_member_message(group_member)).to eq "You are going to remove #{group_member.user.name} from the #{group.name} group. Are you sure?" } - it { expect(remove_member_message(group_member_invite)).to eq "You are going to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group. Are you sure?" } - it { expect(remove_member_message(group_member_request)).to eq "You are going to deny #{requester.name}'s request to join the #{group.name} group. Are you sure?" } + it { expect(remove_member_message(project_member)).to eq "Are you sure you want to remove #{project_member.user.name} from the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(project_member_invite)).to eq "Are you sure you want to revoke the invitation for #{project_member_invite.invite_email} to join the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(project_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(project_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{project.name_with_namespace} project?" } + it { expect(remove_member_message(group_member)).to eq "Are you sure you want to remove #{group_member.user.name} from the #{group.name} group?" } + it { expect(remove_member_message(group_member_invite)).to eq "Are you sure you want to revoke the invitation for #{group_member_invite.invite_email} to join the #{group.name} group?" } + it { expect(remove_member_message(group_member_request)).to eq "Are you sure you want to deny #{requester.name}'s request to join the #{group.name} group?" } + it { expect(remove_member_message(group_member_request, user: requester)).to eq "Are you sure you want to withdraw your access request for the #{group.name} group?" } end describe '#remove_member_title' do @@ -122,10 +55,10 @@ describe MembersHelper do let(:group_member) { build(:group_member, group: group) } let(:group_member_request) { group.request_access(requester) } - it { expect(remove_member_title(project_member)).to eq 'Remove user' } - it { expect(remove_member_title(project_member_request)).to eq 'Deny access request' } - it { expect(remove_member_title(group_member)).to eq 'Remove user' } - it { expect(remove_member_title(group_member_request)).to eq 'Deny access request' } + it { expect(remove_member_title(project_member)).to eq 'Remove user from project' } + it { expect(remove_member_title(project_member_request)).to eq 'Deny access request from project' } + it { expect(remove_member_title(group_member)).to eq 'Remove user from group' } + it { expect(remove_member_title(group_member_request)).to eq 'Deny access request from group' } end describe '#leave_confirmation_message' do @@ -133,7 +66,7 @@ describe MembersHelper do let(:group) { build_stubbed(:group) } let(:user) { build_stubbed(:user) } - it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave \"#{project.name_with_namespace}\" project?" } - it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave \"#{group.name}\" group?" } + it { expect(leave_confirmation_message(project)).to eq "Are you sure you want to leave the \"#{project.name_with_namespace}\" project?" } + it { expect(leave_confirmation_message(group)).to eq "Are you sure you want to leave the \"#{group.name}\" group?" } end end diff --git a/spec/helpers/projects_helper_spec.rb b/spec/helpers/projects_helper_spec.rb index fa81c28849e..09e0bbfd00b 100644 --- a/spec/helpers/projects_helper_spec.rb +++ b/spec/helpers/projects_helper_spec.rb @@ -1,25 +1,6 @@ require 'spec_helper' describe ProjectsHelper do - describe '#max_access_level' do - let(:master) { create(:user) } - let(:owner) { create(:user) } - let(:reporter) { create(:user) } - let(:group) { create(:group) } - let(:project) { build_stubbed(:empty_project, namespace: group) } - - before do - group.add_master(master) - group.add_owner(owner) - group.add_reporter(reporter) - end - - it { expect(max_access_level(project, master)).to eq 'Master' } - it { expect(max_access_level(project, owner)).to eq 'Owner' } - it { expect(max_access_level(project, reporter)).to eq 'Reporter' } - it { expect(max_access_level(project, build_stubbed(:user))).to be_nil } - end - describe "#project_status_css_class" do it "returns appropriate class" do expect(project_status_css_class("started")).to eq("active") diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index a86ec865b5d..1e6eb20ab39 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -405,7 +405,7 @@ describe Notify do let(:user) { create(:user) } let(:project_member) do project.request_access(user) - project.project_members.find_by(created_by_id: user.id) + project.members.request.find_by(user_id: user.id) end subject { Notify.member_access_requested_email('project', project_member.id) } @@ -413,10 +413,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.human_access}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Request to join the #{project.name_with_namespace} project" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{namespace_project_project_members_url(project.namespace, project)}/ + is_expected.to have_body_text /#{project_member.human_access}/ + end end describe 'project access denied' do @@ -424,7 +426,7 @@ describe Notify do let(:user) { create(:user) } let(:project_member) do project.request_access(user) - project.project_members.find_by(created_by_id: user.id) + project.members.request.find_by(user_id: user.id) end subject { Notify.member_access_denied_email('project', project.id, user.id) } @@ -432,9 +434,11 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied" } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{project.name_with_namespace} project was denied" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + end end describe 'project access changed' do @@ -447,10 +451,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted" } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.human_access}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{project.name_with_namespace} project was granted" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.human_access}/ + end end def invite_to_project(project:, email:, inviter:) @@ -470,11 +476,13 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project" } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.human_access}/ } - it { is_expected.to have_body_text /#{project_member.invite_token}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Invitation to join the #{project.name_with_namespace} project" + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.human_access}/ + is_expected.to have_body_text /#{project_member.invite_token}/ + end end describe 'project invitation accepted' do @@ -493,11 +501,13 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject 'Invitation accepted' } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.invite_email}/ } - it { is_expected.to have_body_text /#{invited_user.name}/ } + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation accepted' + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.invite_email}/ + is_expected.to have_body_text /#{invited_user.name}/ + end end describe 'project invitation declined' do @@ -515,10 +525,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject 'Invitation declined' } - it { is_expected.to have_body_text /#{project.name_with_namespace}/ } - it { is_expected.to have_body_text /#{project.web_url}/ } - it { is_expected.to have_body_text /#{project_member.invite_email}/ } + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation declined' + is_expected.to have_body_text /#{project.name_with_namespace}/ + is_expected.to have_body_text /#{project.web_url}/ + is_expected.to have_body_text /#{project_member.invite_email}/ + end end context 'items that are noteable, the email for a note' do @@ -639,7 +651,7 @@ describe Notify do let(:user) { create(:user) } let(:group_member) do group.request_access(user) - group.group_members.find_by(created_by_id: user.id) + group.members.request.find_by(user_id: user.id) end subject { Notify.member_access_requested_email('group', group_member.id) } @@ -647,10 +659,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Request to join the #{group.name} group" } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.human_access}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Request to join the #{group.name} group" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group_group_members_url(group)}/ + is_expected.to have_body_text /#{group_member.human_access}/ + end end describe 'group access denied' do @@ -658,7 +672,7 @@ describe Notify do let(:user) { create(:user) } let(:group_member) do group.request_access(user) - group.group_members.find_by(created_by_id: user.id) + group.members.request.find_by(user_id: user.id) end subject { Notify.member_access_denied_email('group', group.id, user.id) } @@ -666,9 +680,11 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Access to the #{group.name} group was denied" } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{group.name} group was denied" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + end end describe 'group access changed' do @@ -682,10 +698,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Access to the #{group.name} group was granted" } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.human_access}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Access to the #{group.name} group was granted" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.human_access}/ + end end def invite_to_group(group:, email:, inviter:) @@ -705,11 +723,13 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject "Invitation to join the #{group.name} group" } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.human_access}/ } - it { is_expected.to have_body_text /#{group_member.invite_token}/ } + it 'contains all the useful information' do + is_expected.to have_subject "Invitation to join the #{group.name} group" + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.human_access}/ + is_expected.to have_body_text /#{group_member.invite_token}/ + end end describe 'group invitation accepted' do @@ -728,11 +748,13 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject 'Invitation accepted' } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.invite_email}/ } - it { is_expected.to have_body_text /#{invited_user.name}/ } + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation accepted' + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.invite_email}/ + is_expected.to have_body_text /#{invited_user.name}/ + end end describe 'group invitation declined' do @@ -750,10 +772,12 @@ describe Notify do it_behaves_like 'it should not have Gmail Actions links' it_behaves_like "a user cannot unsubscribe through footer link" - it { is_expected.to have_subject 'Invitation declined' } - it { is_expected.to have_body_text /#{group.name}/ } - it { is_expected.to have_body_text /#{group.web_url}/ } - it { is_expected.to have_body_text /#{group_member.invite_email}/ } + it 'contains all the useful information' do + is_expected.to have_subject 'Invitation declined' + is_expected.to have_body_text /#{group.name}/ + is_expected.to have_body_text /#{group.web_url}/ + is_expected.to have_body_text /#{group_member.invite_email}/ + end end end diff --git a/spec/models/concerns/access_requestable_spec.rb b/spec/models/concerns/access_requestable_spec.rb index 2dfed1eb4c4..98307876962 100644 --- a/spec/models/concerns/access_requestable_spec.rb +++ b/spec/models/concerns/access_requestable_spec.rb @@ -7,8 +7,7 @@ describe AccessRequestable do let(:user) { create(:user) } it { expect(group.request_access(user)).to be_a(GroupMember) } - it { expect(group.request_access(user).user).to be_nil } - it { expect(group.request_access(user).created_by).to eq(user) } + it { expect(group.request_access(user).user).to eq(user) } end describe '#access_requested?' do @@ -17,7 +16,7 @@ describe AccessRequestable do before { group.request_access(user) } - it { expect(group.access_requested?(user)).to be_truthy } + it { expect(group.members.request.exists?(user_id: user)).to be_truthy } end end @@ -35,7 +34,7 @@ describe AccessRequestable do before { project.request_access(user) } - it { expect(project.access_requested?(user)).to be_truthy } + it { expect(project.members.request.exists?(user_id: user)).to be_truthy } end end end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 52f9d57bc0a..ccdcb29f773 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -10,17 +10,6 @@ describe Group, models: true do it { is_expected.to have_many(:project_group_links).dependent(:destroy) } it { is_expected.to have_many(:shared_projects).through(:project_group_links) } it { is_expected.to have_many(:notification_settings).dependent(:destroy) } - - describe '#group_members' do - let(:user) { create(:user) } - let(:group) { create(:group) } - - before { group.request_access(user) } - - it 'does not includes membership requests' do - expect(user.group_members).to be_empty - end - end end describe 'modules' do diff --git a/spec/models/member_spec.rb b/spec/models/member_spec.rb index a3d525d8d56..3ed3202ac6c 100644 --- a/spec/models/member_spec.rb +++ b/spec/models/member_spec.rb @@ -55,45 +55,78 @@ describe Member, models: true do end end - describe 'Scopes' do + describe 'Scopes & finders' do before do project = create(:project) - @invited_member = build(:project_member, user: nil).tap { |m| m.generate_invite_token! } - @accepted_invite_member = build(:project_member, user: nil).tap { |m| m.generate_invite_token! && m.accept_invite!(build(:user)) } + group = create(:group) + @owner_user = create(:user).tap { |u| group.add_owner(u) } + @owner = group.members.find_by(user_id: @owner_user.id) + + @master_user = create(:user).tap { |u| project.team << [u, :master] } + @master = project.members.find_by(user_id: @master_user.id) + + ProjectMember.add_user(project.members, 'toto1@example.com', Gitlab::Access::DEVELOPER, @master_user) + @invited_member = project.members.invite.find_by_invite_email('toto1@example.com') + + accepted_invite_user = build(:user) + ProjectMember.add_user(project.members, 'toto2@example.com', Gitlab::Access::DEVELOPER, @master_user) + @accepted_invite_member = project.members.invite.find_by_invite_email('toto2@example.com').tap { |u| u.accept_invite!(accepted_invite_user) } requested_user = create(:user).tap { |u| project.request_access(u) } - @requested_member = project.project_members.find_by(created_by_id: requested_user.id) + @requested_member = project.members.request.find_by(user_id: requested_user.id) + accepted_request_user = create(:user).tap { |u| project.request_access(u) } - @accepted_request_member = project.project_members.find_by(created_by_id: accepted_request_user.id).tap { |m| m.accept_request } + @accepted_request_member = project.members.request.find_by(user_id: accepted_request_user.id).tap { |m| m.accept_request } end - describe '#invite' do + describe '.invite' do + it { expect(described_class.invite).not_to include @master } it { expect(described_class.invite).to include @invited_member } it { expect(described_class.invite).not_to include @accepted_invite_member } it { expect(described_class.invite).not_to include @requested_member } it { expect(described_class.invite).not_to include @accepted_request_member } end - describe '#request' do + describe '.non_invite' do + it { expect(described_class.non_invite).to include @master } + it { expect(described_class.non_invite).not_to include @invited_member } + it { expect(described_class.non_invite).to include @accepted_invite_member } + it { expect(described_class.non_invite).to include @requested_member } + it { expect(described_class.non_invite).to include @accepted_request_member } + end + + describe '.request' do + it { expect(described_class.request).not_to include @master } it { expect(described_class.request).not_to include @invited_member } it { expect(described_class.request).not_to include @accepted_invite_member } it { expect(described_class.request).to include @requested_member } it { expect(described_class.request).not_to include @accepted_request_member } end - describe '#non_request' do + describe '.non_request' do + it { expect(described_class.non_request).to include @master } it { expect(described_class.non_request).to include @invited_member } it { expect(described_class.non_request).to include @accepted_invite_member } it { expect(described_class.non_request).not_to include @requested_member } it { expect(described_class.non_request).to include @accepted_request_member } end - describe '#non_pending' do + describe '.non_pending' do + it { expect(described_class.non_pending).to include @master } it { expect(described_class.non_pending).not_to include @invited_member } it { expect(described_class.non_pending).to include @accepted_invite_member } it { expect(described_class.non_pending).not_to include @requested_member } it { expect(described_class.non_pending).to include @accepted_request_member } end + + describe '.owners_and_masters' do + it { expect(described_class.owners_and_masters).to include @owner } + it { expect(described_class.owners_and_masters).to include @master } + it { expect(described_class.owners_and_masters).not_to include @invited_member } + it { expect(described_class.owners_and_masters).not_to include @accepted_invite_member } + it { expect(described_class.owners_and_masters).not_to include @requested_member } + it { expect(described_class.owners_and_masters).not_to include @accepted_request_member } + end end describe "Delegate methods" do @@ -101,6 +134,18 @@ describe Member, models: true do it { is_expected.to respond_to(:user_email) } end + describe 'Callbacks' do + describe 'after_destroy :post_decline_request, if: :request?' do + let(:member) { create(:project_member, requested_at: Time.now.utc) } + + it 'calls #post_decline_request' do + expect(member).to receive(:post_decline_request) + + member.destroy + end + end + end + describe ".add_user" do let!(:user) { create(:user) } let(:project) { create(:project) } @@ -139,18 +184,9 @@ describe Member, models: true do end describe '#accept_request' do - let(:user) { create(:user) } - let(:member) { create(:project_member, requested_at: Time.now.utc, user: nil, created_by: user) } + let(:member) { create(:project_member, requested_at: Time.now.utc) } - it 'returns true' do - expect(member.accept_request).to be_truthy - end - - it 'sets the user' do - member.accept_request - - expect(member.user).to eq(user) - end + it { expect(member.accept_request).to be_truthy } it 'clears requested_at' do member.accept_request @@ -165,25 +201,24 @@ describe Member, models: true do end end - describe '#decline_request' do - let(:user) { create(:user) } - let(:member) { create(:project_member, requested_at: Time.now.utc, user: nil, created_by: user) } + describe '#invite?' do + subject { create(:project_member, invite_email: "user@example.com", user: nil) } - it 'returns true' do - expect(member.decline_request).to be_truthy - end + it { is_expected.to be_invite } + end - it 'destroys the member' do - member.decline_request + describe '#request?' do + subject { create(:project_member, requested_at: Time.now.utc) } - expect(member).to be_destroyed - end + it { is_expected.to be_request } + end - it 'calls #after_decline_request' do - expect(member).to receive(:after_decline_request) + describe '#pending?' do + let(:invited_member) { create(:project_member, invite_email: "user@example.com", user: nil) } + let(:requester) { create(:project_member, requested_at: Time.now.utc) } - member.decline_request - end + it { expect(invited_member).to be_invite } + it { expect(requester).to be_pending } end describe "#accept_invite!" do diff --git a/spec/models/members/group_member_spec.rb b/spec/models/members/group_member_spec.rb index c3070d4cb78..eeb74a462ac 100644 --- a/spec/models/members/group_member_spec.rb +++ b/spec/models/members/group_member_spec.rb @@ -51,24 +51,30 @@ describe GroupMember, models: true do end end - describe 'after accept_request' do - let(:member) { create(:group_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + describe '#after_accept_request' do + it 'calls NotificationService.accept_group_access_request' do + member = create(:group_member, user: build_stubbed(:user), requested_at: Time.now) - it "calls #accept_group_access_request" do expect_any_instance_of(NotificationService).to receive(:new_group_member) - member.accept_request + member.__send__(:after_accept_request) end end - describe 'after decline_request' do - let(:member) { create(:group_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + describe '#post_decline_request' do + it 'calls NotificationService.decline_group_access_request' do + member = create(:group_member, user: build_stubbed(:user), requested_at: Time.now) - it "calls #decline_group_access_request" do expect_any_instance_of(NotificationService).to receive(:decline_group_access_request) - member.decline_request + member.__send__(:post_decline_request) end end + + describe '#real_source_type' do + subject { create(:group_member).real_source_type } + + it { is_expected.to eq 'Group' } + end end end diff --git a/spec/models/members/project_member_spec.rb b/spec/models/members/project_member_spec.rb index 99b3c77c6cd..1e466f9c620 100644 --- a/spec/models/members/project_member_spec.rb +++ b/spec/models/members/project_member_spec.rb @@ -33,6 +33,12 @@ describe ProjectMember, models: true do it { is_expected.to include_module(Gitlab::ShellAdapter) } end + describe '#real_source_type' do + subject { create(:project_member).real_source_type } + + it { is_expected.to eq 'Project' } + end + describe "#destroy" do let(:owner) { create(:project_member, access_level: ProjectMember::OWNER) } let(:project) { owner.project } @@ -137,23 +143,23 @@ describe ProjectMember, models: true do end describe 'notifications' do - describe 'after accept_request' do - let(:member) { create(:project_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + describe '#after_accept_request' do + it 'calls NotificationService.new_project_member' do + member = create(:project_member, user: build_stubbed(:user), requested_at: Time.now) - it 'calls #accept_project_access_request' do expect_any_instance_of(NotificationService).to receive(:new_project_member) - member.accept_request + member.__send__(:after_accept_request) end end - describe 'after decline_request' do - let(:member) { create(:project_member, user: nil, created_by: build_stubbed(:user), requested_at: Time.now) } + describe '#post_decline_request' do + it 'calls NotificationService.decline_project_access_request' do + member = create(:project_member, user: build_stubbed(:user), requested_at: Time.now) - it 'calls #decline_project_access_request' do expect_any_instance_of(NotificationService).to receive(:decline_project_access_request) - member.decline_request + member.__send__(:post_decline_request) end end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index d5a4b73affd..30aa2b70c8d 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -29,17 +29,6 @@ describe Project, models: true do it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:triggers) } it { is_expected.to have_many(:todos).dependent(:destroy) } - - describe '#project_members' do - let(:user) { create(:user) } - let(:project) { create(:project) } - - before { project.request_access(user) } - - it 'does not includes membership requests' do - expect(user.project_members).to be_empty - end - end end describe 'modules' do @@ -100,11 +89,17 @@ describe Project, models: true do it { is_expected.to respond_to(:repo_exists?) } it { is_expected.to respond_to(:update_merge_requests) } it { is_expected.to respond_to(:execute_hooks) } - it { is_expected.to respond_to(:name_with_namespace) } it { is_expected.to respond_to(:owner) } it { is_expected.to respond_to(:path_with_namespace) } end + describe '#name_with_namespace' do + let(:project) { build_stubbed(:empty_project) } + + it { expect(project.name_with_namespace).to eq "#{project.namespace.human_name} / #{project.name}" } + it { expect(project.human_name).to eq project.name_with_namespace } + end + describe '#to_reference' do let(:project) { create(:empty_project) } diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 36b1f439955..9262aeb6ed8 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -112,6 +112,28 @@ describe ProjectTeam, models: true do end end + describe "#human_max_access" do + it 'returns Master role' do + user = create(:user) + group = create(:group) + group.add_master(user) + + project = build_stubbed(:empty_project, namespace: group) + + expect(project.team.human_max_access(user.id)).to eq 'Master' + end + + it 'returns Owner role' do + user = create(:user) + group = create(:group) + group.add_owner(user) + + project = build_stubbed(:empty_project, namespace: group) + + expect(project.team.human_max_access(user.id)).to eq 'Owner' + end + end + describe '#max_member_access' do let(:requester) { create(:user) } From 3ade826065f38e3734090cf34fbfc28b68ba79d0 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 13:51:12 +0200 Subject: [PATCH 220/318] Add specs for models and services --- app/models/deployment.rb | 2 + app/models/environment.rb | 4 ++ app/services/create_deployment_service.rb | 46 ++++++--------- db/migrate/20160610204157_add_deployments.rb | 4 +- db/schema.rb | 4 +- doc/permissions/permissions.md | 2 + spec/factories/deployments.rb | 12 ++++ spec/factories/environments.rb | 7 +++ spec/models/deployment_spec.rb | 17 ++++++ spec/models/environment_spec.rb | 14 +++++ spec/models/project_spec.rb | 2 + .../create_deployment_service_spec.rb | 59 +++++++++++++++++++ 12 files changed, 142 insertions(+), 31 deletions(-) create mode 100644 spec/factories/deployments.rb create mode 100644 spec/factories/environments.rb create mode 100644 spec/models/deployment_spec.rb create mode 100644 spec/models/environment_spec.rb create mode 100644 spec/services/create_deployment_service_spec.rb diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 7cdfc740441..44a0a7fdd10 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -8,6 +8,8 @@ class Deployment < ActiveRecord::Base validates_presence_of :sha validates_presence_of :ref + validates_associated :project + validates_associated :environment delegate :name, to: :environment, prefix: true diff --git a/app/models/environment.rb b/app/models/environment.rb index b29cca8fbe2..3eab137718e 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -9,6 +9,10 @@ class Environment < ActiveRecord::Base format: { with: Gitlab::Regex.environment_name_regex, message: Gitlab::Regex.environment_name_regex_message } + validates_uniqueness_of :name, scope: :project_id + + validates_associated :project + def last_deployment deployments.last end diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb index 7408ec367f6..eec1773073e 100644 --- a/app/services/create_deployment_service.rb +++ b/app/services/create_deployment_service.rb @@ -1,38 +1,30 @@ require_relative 'base_service' class CreateDeploymentService < BaseService - def execute(deployable) - environment = find_environment(params[:environment]) - return error('no environment') unless environmnet + def execute(deployable = nil) + environment = create_or_find_environment(params[:environment]) - deployment = create_deployment(environment, deployable) - if deployment.persisted? - success(deployment) - else - error(deployment.errors) - end - end - - private - - def find_environment(environment) - project.environments.find_by(name: environment) - end - - def create_deployment(environment, deployable) - environment.deployments.create( - project: project, - ref: build.ref, - tag: build.tag, - sha: build.sha, + project.deployments.create( + environment: environment, + ref: params[:ref], + tag: params[:tag], + sha: params[:sha], user: current_user, deployable: deployable, ) end - def success(deployment) - out = super() - out[:deployment] = deployment - out + private + + def create_or_find_environment(environment) + find_environment(environment) || create_environment(environment) + end + + def create_environment(environment) + project.environments.create(name: environment) + end + + def find_environment(environment) + project.environments.find_by(name: environment) end end diff --git a/db/migrate/20160610204157_add_deployments.rb b/db/migrate/20160610204157_add_deployments.rb index c93d3bf64d3..557b78f91e1 100644 --- a/db/migrate/20160610204157_add_deployments.rb +++ b/db/migrate/20160610204157_add_deployments.rb @@ -13,8 +13,8 @@ class AddDeployments < ActiveRecord::Migration t.boolean :tag t.string :sha t.integer :user_id - t.integer :deployable_id, null: false - t.string :deployable_type, null: false + t.integer :deployable_id + t.string :deployable_type t.datetime :created_at t.datetime :updated_at end diff --git a/db/schema.rb b/db/schema.rb index cd6c087c847..51a6044f99c 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -390,8 +390,8 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.boolean "tag" t.string "sha" t.integer "user_id" - t.integer "deployable_id", null: false - t.string "deployable_type", null: false + t.integer "deployable_id" + t.string "deployable_type" t.datetime "created_at" t.datetime "updated_at" end diff --git a/doc/permissions/permissions.md b/doc/permissions/permissions.md index b76ce31cbad..666dcfafd03 100644 --- a/doc/permissions/permissions.md +++ b/doc/permissions/permissions.md @@ -28,6 +28,7 @@ documentation](../workflow/add-user/add-user.md). | Manage labels | | ✓ | ✓ | ✓ | ✓ | | See a commit status | | ✓ | ✓ | ✓ | ✓ | | See a container registry | | ✓ | ✓ | ✓ | ✓ | +| See a environments | | ✓ | ✓ | ✓ | ✓ | | Manage merge requests | | | ✓ | ✓ | ✓ | | Create new merge request | | | ✓ | ✓ | ✓ | | Create new branches | | | ✓ | ✓ | ✓ | @@ -40,6 +41,7 @@ documentation](../workflow/add-user/add-user.md). | Create or update commit status | | | ✓ | ✓ | ✓ | | Update a container registry | | | ✓ | ✓ | ✓ | | Remove a container registry image | | | ✓ | ✓ | ✓ | +| Manage environments | | | ✓ | ✓ | ✓ | | Create new milestones | | | | ✓ | ✓ | | Add new team members | | | | ✓ | ✓ | | Push to protected branches | | | | ✓ | ✓ | diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb new file mode 100644 index 00000000000..f335a111a7d --- /dev/null +++ b/spec/factories/deployments.rb @@ -0,0 +1,12 @@ +FactoryGirl.define do + factory :deployment, class: Deployment do + sha '97de212e80737a608d939f648d959671fb0a0142' + ref 'master' + + environment factory: :environment + + after(:build) do |deployment, evaluator| + deployment.project = deployment.environment.project + end + end +end diff --git a/spec/factories/environments.rb b/spec/factories/environments.rb new file mode 100644 index 00000000000..07265c26ca3 --- /dev/null +++ b/spec/factories/environments.rb @@ -0,0 +1,7 @@ +FactoryGirl.define do + factory :environment, class: Environment do + sequence(:name) { |n| "environment#{n}" } + + project factory: :empty_project + end +end diff --git a/spec/models/deployment_spec.rb b/spec/models/deployment_spec.rb new file mode 100644 index 00000000000..b273018707f --- /dev/null +++ b/spec/models/deployment_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe Deployment, models: true do + subject { build(:deployment) } + + it { is_expected.to belong_to(:project) } + it { is_expected.to belong_to(:environment) } + it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:deployable) } + + it { is_expected.to delegate_method(:name).to(:environment).with_prefix } + it { is_expected.to delegate_method(:commit).to(:project) } + it { is_expected.to delegate_method(:commit_title).to(:commit).as(:try) } + + it { is_expected.to validate_presence_of(:ref) } + it { is_expected.to validate_presence_of(:sha) } +end diff --git a/spec/models/environment_spec.rb b/spec/models/environment_spec.rb new file mode 100644 index 00000000000..7629af6a570 --- /dev/null +++ b/spec/models/environment_spec.rb @@ -0,0 +1,14 @@ +require 'spec_helper' + +describe Environment, models: true do + let(:environment) { create(:environment) } + + it { is_expected.to belong_to(:project) } + it { is_expected.to have_many(:deployments) } + + it { is_expected.to delegate_method(:last_deployment).to(:deployments).as(:last) } + + it { is_expected.to validate_presence_of(:name) } + it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } + it { is_expected.to validate_length_of(:name).is_within(0..255) } +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index de8815f5a38..1f626ff2647 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -28,6 +28,8 @@ describe Project, models: true do it { is_expected.to have_many(:runners) } it { is_expected.to have_many(:variables) } it { is_expected.to have_many(:triggers) } + it { is_expected.to have_many(:environments).dependent(:destroy) } + it { is_expected.to have_many(:deployments).dependent(:destroy) } it { is_expected.to have_many(:todos).dependent(:destroy) } end diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb new file mode 100644 index 00000000000..76f3e0ac9ff --- /dev/null +++ b/spec/services/create_deployment_service_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe CreateDeploymentService, services: true do + let(:build) { create(:ci_build) } + let(:project) { build.project } + let(:user) { create(:user) } + + let(:service) { described_class.new(project, user, params) } + + describe '#execute' do + let(:params) do + { environment: 'production', + ref: 'master', + sha: build.sha, + } + end + + subject { service.execute } + + context 'when no environments exist' do + it 'does create a new environment' do + expect { subject }.to change { Environment.count }.by(1) + end + + it 'does create a deployment' do + expect(subject).to be_persisted + end + end + + context 'when environment exist' do + before { create(:environment, project: project, name: 'production') } + + it 'does not create a new environment' do + expect { subject }.not_to change { Environment.count } + end + + it 'does create a deployment' do + expect(subject).to be_persisted + end + end + + context 'for environment with invalid name' do + let(:params) do + { environment: 'name with spaces', + ref: 'master', + sha: build.sha, + } + end + + it 'does not create a new environment' do + expect { subject }.not_to change { Environment.count } + end + + it 'does not create a deployment' do + expect(subject).not_to be_persisted + end + end + end +end From e129f66d9e597f43f7a85243ddedf3de0fc4946a Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 14:43:16 +0200 Subject: [PATCH 221/318] Add gitlab-ci.yml documentation for environments --- doc/ci/yaml/README.md | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 0707555e393..0546fa50f1c 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -28,6 +28,7 @@ If you want a quick introduction to GitLab CI, follow our - [only and except](#only-and-except) - [tags](#tags) - [when](#when) + - [environment](#environment) - [artifacts](#artifacts) - [artifacts:name](#artifacts-name) - [artifacts:when](#artifacts-when) @@ -353,6 +354,7 @@ job_name: | cache | no | Define list of files that should be cached between subsequent runs | | before_script | no | Override a set of commands that are executed before build | | after_script | no | Override a set of commands that are executed after build | +| environment | no | Defines a name of environment to which deployment is done by this build | ### script @@ -524,6 +526,31 @@ The above script will: 1. Execute `cleanup_build_job` only when `build_job` fails 2. Always execute `cleanup_job` as the last step in pipeline. +### environment + +>**Note:** +Introduced in GitLab v8.9.0. + +`environment` is used to define that job does deployment to specific environment. +This allows to easily track all deployments to your environments straight from GitLab. + +If `environment` is specified and no environment under that name does exist a new one will be created automatically. + +The `environment` name must contain only letters, digits, '-' and '_'. + +--- + +**Example configurations** + +``` +deploy to production: + stage: deploy + script: git push production HEAD:master + environment: production +``` + +The `deploy to production` job will be marked as doing deployment to `production` environment. + ### artifacts >**Notes:** From bb6f246790fb3a6b85ab2fd9341566557da64a23 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 14:43:45 +0200 Subject: [PATCH 222/318] Authorize environments controller actions --- .../projects/environments_controller.rb | 2 + app/views/projects/environments/new.html.haml | 2 +- .../projects/environments/show.html.haml | 3 +- .../security/project/public_access_spec.rb | 43 +++++++++++++++++++ 4 files changed, 48 insertions(+), 2 deletions(-) diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index c6a9a0a403a..4f8dadd6adf 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -1,6 +1,8 @@ class Projects::EnvironmentsController < Projects::ApplicationController layout 'project' before_action :authorize_read_environment! + before_action :authorize_create_environment!, only: [:new, :create] + before_action :authorize_update_environment!, only: [:destroy] before_action :environment, only: [:show, :destroy] def index diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml index c7abac6e49f..ade41d9de2d 100644 --- a/app/views/projects/environments/new.html.haml +++ b/app/views/projects/environments/new.html.haml @@ -9,7 +9,7 @@ = form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { id: "new-environment-form", class: "col-lg-9 js-new-environment-form js-requires-input" } do |f| = form_errors(@environment) .form-group - = f.label :ref, 'Environment name', class: 'label-light' + = f.label :name, 'Environment name', class: 'label-light' = f.text_field :name, required: true, class: 'form-control' = f.submit 'Create environment', class: 'btn btn-create' = link_to "Cancel", namespace_project_environments_path(@project.namespace, @project), class: "btn btn-cancel" diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index f5e30d75b42..1d39bef9427 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -9,7 +9,8 @@ .col-md-3 .nav-controls - = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :delete + - if can?(current_user, :update_environment, @project) + = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :delete - if @deployments.blank? %ul.content-list diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index c5f741709ad..f6c6687e162 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -175,6 +175,49 @@ describe "Public Project Access", feature: true do end end + describe "GET /:project_path/environments" do + subject { namespace_project_environments_path(project.namespace, project) } + + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for owner } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for developer } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_denied_for guest } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_denied_for :visitor } + end + + describe "GET /:project_path/environments/:id" do + let(:environment) { create(:environment, project: project) } + subject { namespace_project_environments_path(project.namespace, project, environment) } + + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for owner } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for developer } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_denied_for guest } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_denied_for :visitor } + end + + describe "GET /:project_path/environments/new" do + subject { new_namespace_project_environment_path(project.namespace, project) } + + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for owner } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for developer } + it { is_expected.to be_denied_for reporter } + it { is_expected.to be_denied_for guest } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_denied_for :visitor } + end + describe "GET /:project_path/blob" do let(:commit) { project.repository.commit } From 6209b60c96d8b380ac184d83647c3c8b0b026cac Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 14:44:09 +0200 Subject: [PATCH 223/318] Properly create a new deployment after build success --- app/models/ci/build.rb | 8 ++- .../create_deployment_service_spec.rb | 66 +++++++++++++++++-- 2 files changed, 68 insertions(+), 6 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 60202525727..9215ad36547 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -75,9 +75,13 @@ module Ci build.execute_hooks end - after_transition any: :success do |build| + after_transition any => [:success] do |build| if build.environment.present? - CreateDeploymentService.new(build.project, build.user, environment: build.environment).execute(build) + service = CreateDeploymentService.new(build.project, build.user, + environment: build.environment, + sha: build.sha, ref: build.ref, + tag: build.tag) + service.execute(build) end end end diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb index 76f3e0ac9ff..b6ae3505379 100644 --- a/spec/services/create_deployment_service_spec.rb +++ b/spec/services/create_deployment_service_spec.rb @@ -1,8 +1,7 @@ require 'spec_helper' describe CreateDeploymentService, services: true do - let(:build) { create(:ci_build) } - let(:project) { build.project } + let(:project) { create(:empty_project) } let(:user) { create(:user) } let(:service) { described_class.new(project, user, params) } @@ -11,7 +10,7 @@ describe CreateDeploymentService, services: true do let(:params) do { environment: 'production', ref: 'master', - sha: build.sha, + sha: '97de212e80737a608d939f648d959671fb0a0142', } end @@ -43,7 +42,7 @@ describe CreateDeploymentService, services: true do let(:params) do { environment: 'name with spaces', ref: 'master', - sha: build.sha, + sha: '97de212e80737a608d939f648d959671fb0a0142', } end @@ -56,4 +55,63 @@ describe CreateDeploymentService, services: true do end end end + + describe 'processing of builds' do + let(:environment) { nil } + + shared_examples 'does not create environment and deployment' do + it 'does not create a new environment' do + expect { subject }.not_to change { Environment.count } + end + + it 'does not create a new deployment' do + expect { subject }.not_to change { Deployment.count } + end + + it 'does not call a service' do + expect_any_instance_of(described_class).not_to receive(:execute) + subject + end + end + + shared_examples 'does create environment and deployment' do + it 'does create a new environment' do + expect { subject }.to change { Environment.count }.by(1) + end + + it 'does create a new deployment' do + expect { subject }.to change { Deployment.count }.by(1) + end + + it 'does call a service' do + expect_any_instance_of(described_class).to receive(:execute) + subject + end + end + + context 'without environment specified' do + let(:build) { create(:ci_build, project: project) } + + it_behaves_like 'does not create environment and deployment' do + subject { build.success } + end + end + + context 'when environment is specified' do + let(:pipeline) { create(:ci_pipeline, project: project) } + let(:build) { create(:ci_build, pipeline: pipeline, environment: 'production') } + + context 'when build succeeds' do + it_behaves_like 'does create environment and deployment' do + subject { build.success } + end + end + + context 'when build fails' do + it_behaves_like 'does not create environment and deployment' do + subject { build.drop } + end + end + end + end end From 30877effb15d8a3eccc13925549a4c97de93c58e Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 14:47:00 +0200 Subject: [PATCH 224/318] Test environment controller specs --- app/models/deployment.rb | 4 + .../deployments/_deployment.html.haml | 6 +- spec/features/environments_spec.rb | 159 ++++++++++++++++++ 3 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 spec/features/environments_spec.rb diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 44a0a7fdd10..32799ee27e6 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -24,4 +24,8 @@ class Deployment < ActiveRecord::Base def short_sha Commit::truncate_sha(sha) end + + def last? + self == environment.last_deployment + end end diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index 539c297cad3..1ac17af8b58 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -27,4 +27,8 @@ %td - if can?(current_user, :update_deployment, @project) && deployment.deployable .pull-right - = link_to 'Retry', retry_namespace_project_build_path(@project.namespace, @project, deployment.deployable), method: :post, class: 'btn btn-build' + = link_to retry_namespace_project_build_path(@project.namespace, @project, deployment.deployable), method: :post, class: 'btn btn-build' do + - if deployment.last? + Retry + - else + Rollback diff --git a/spec/features/environments_spec.rb b/spec/features/environments_spec.rb new file mode 100644 index 00000000000..b73bb30e216 --- /dev/null +++ b/spec/features/environments_spec.rb @@ -0,0 +1,159 @@ +require 'spec_helper' + +describe 'Environments' do + include GitlabRoutingHelper + + let(:project) { create(:empty_project) } + let(:user) { create(:user) } + let(:role) { :developer } + + before do + login_as(user) + project.team << [user, role] + end + + describe 'GET /:project/environments' do + subject { visit namespace_project_environments_path(project.namespace, project) } + + context 'without environments' do + it 'does show no environments' do + subject + + expect(page).to have_content('No environments to show') + end + end + + context 'with environments' do + let!(:environment) { create(:environment, project: project) } + + it 'does show environment name' do + subject + + expect(page).to have_link(environment.name) + end + + context 'without deployments' do + it 'does show no deployments' do + subject + + expect(page).to have_content('No deployments yet') + end + end + + context 'with deployments' do + let!(:deployment) { create(:deployment, environment: environment) } + + it 'does show deployment SHA' do + subject + + expect(page).to have_link(deployment.short_sha) + end + end + end + + it 'does have a New environment button' do + subject + + expect(page).to have_link('New environment') + end + end + + describe 'GET /:project/environments/:id' do + let(:environment) { create(:environment, project: project) } + + subject { visit namespace_project_environment_path(project.namespace, project, environment) } + + context 'without deployments' do + it 'does show no deployments' do + subject + + expect(page).to have_content('No deployments for') + end + end + + context 'with deployments' do + let!(:deployment) { create(:deployment, environment: environment) } + + before { subject } + + it 'does show deployment SHA' do + expect(page).to have_link(deployment.short_sha) + end + + it 'does not show a retry button for deployment without build' do + expect(page).not_to have_link('Retry') + end + + context 'with build' do + let(:build) { create(:ci_build, project: project) } + let(:deployment) { create(:deployment, environment: environment, deployable: build) } + + it 'does show build name' do + expect(page).to have_link("#{build.name} (##{build.id})") + end + + it 'does show retry button' do + expect(page).to have_link('Retry') + end + end + end + end + + describe 'POST /:project/environments' do + before { visit namespace_project_environments_path(project.namespace, project) } + + context 'when logged as developer' do + before { click_link 'New environment' } + + context 'for valid name' do + before do + fill_in('Environment name', with: 'production') + click_on 'Create environment' + end + + it 'does create a new pipeline' do + expect(page).to have_content('production') + end + end + + context 'for invalid name' do + before do + fill_in('Environment name', with: 'name with spaces') + click_on 'Create environment' + end + + it { expect(page).to have_content('Name can contain only letters') } + end + end + + context 'when logged as reporter' do + let(:role) { :reporter } + + it 'does not have a New environment link' do + expect(page).not_to have_link('New environment') + end + end + end + + describe 'DELETE /:project/environments/:id' do + let(:environment) { create(:environment, project: project) } + + before { visit namespace_project_environment_path(project.namespace, project, environment) } + + context 'when logged as developer' do + before { click_link 'Destroy' } + + it 'does not have environment' do + expect(page).not_to have_link(environment.name) + end + end + + context 'when logged as reporter' do + let(:role) { :reporter } + + it 'does not have a Destroy link' do + expect(page).not_to have_link('Destroy') + end + end + end +end From 47c9b7d34ce0e4e842dba72cedd66671efc03be5 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis <axilleas@axilleas.me> Date: Thu, 9 Jun 2016 18:32:17 +0200 Subject: [PATCH 225/318] Update CI API docs - Move ci/api under api/ci - Clean up builds.md and runners.md - Replace old links with new ones - Add CI API links in ci/README.md --- doc/api/README.md | 49 ++++++++------- doc/api/ci/README.md | 22 +++++++ doc/api/ci/builds.md | 138 ++++++++++++++++++++++++++++++++++++++++++ doc/api/ci/runners.md | 57 +++++++++++++++++ doc/ci/README.md | 2 +- doc/ci/api/README.md | 21 +------ doc/ci/api/builds.md | 138 +----------------------------------------- doc/ci/api/runners.md | 45 +------------- 8 files changed, 249 insertions(+), 223 deletions(-) create mode 100644 doc/api/ci/README.md create mode 100644 doc/api/ci/builds.md create mode 100644 doc/api/ci/runners.md diff --git a/doc/api/README.md b/doc/api/README.md index 27c5962decf..e3fc5a09f21 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -8,32 +8,39 @@ under [`/lib/api`](https://gitlab.com/gitlab-org/gitlab-ce/tree/master/lib/api). Documentation for various API resources can be found separately in the following locations: -- [Users](users.md) -- [Session](session.md) -- [Projects](projects.md) including setting Webhooks -- [Project Snippets](project_snippets.md) -- [Services](services.md) -- [Repositories](repositories.md) -- [Repository Files](repository_files.md) -- [Commits](commits.md) -- [Tags](tags.md) - [Branches](branches.md) -- [Merge Requests](merge_requests.md) -- [Issues](issues.md) -- [Labels](labels.md) -- [Milestones](milestones.md) -- [Notes](notes.md) (comments) -- [Deploy Keys](deploy_keys.md) -- [System Hooks](system_hooks.md) -- [Groups](groups.md) -- [Namespaces](namespaces.md) -- [Settings](settings.md) -- [Keys](keys.md) - [Builds](builds.md) - [Build triggers](build_triggers.md) - [Build Variables](build_variables.md) -- [Runners](runners.md) +- [Commits](commits.md) +- [Deploy Keys](deploy_keys.md) +- [Groups](groups.md) +- [Issues](issues.md) +- [Keys](keys.md) +- [Labels](labels.md) +- [Merge Requests](merge_requests.md) +- [Milestones](milestones.md) - [Open source license templates](licenses.md) +- [Namespaces](namespaces.md) +- [Notes](notes.md) (comments) +- [Projects](projects.md) including setting Webhooks +- [Project Snippets](project_snippets.md) +- [Repositories](repositories.md) +- [Repository Files](repository_files.md) +- [Runners](runners.md) +- [Services](services.md) +- [Session](session.md) +- [Settings](settings.md) +- [System Hooks](system_hooks.md) +- [Tags](tags.md) +- [Users](users.md) + +### Internal CI API + +The following documentation is for the [internal CI API](ci/README.md): + +- [Builds](ci/builds.md) +- [Runners](ci/runners.md) ## Authentication diff --git a/doc/api/ci/README.md b/doc/api/ci/README.md new file mode 100644 index 00000000000..aea808007fc --- /dev/null +++ b/doc/api/ci/README.md @@ -0,0 +1,22 @@ +# GitLab CI API + +## Purpose + +Main purpose of GitLab CI API is to provide necessary data and context for +GitLab CI Runners. + +For consumer API take a look at this [documentation](../../api/README.md) where +you will find all relevant information. + +## API Prefix + +Current CI API prefix is `/ci/api/v1`. + +You need to prepend this prefix to all examples in this documentation, like: + + GET /ci/api/v1/builds/:id/artifacts + +## Resources + +- [Builds](builds.md) +- [Runners](runners.md) diff --git a/doc/api/ci/builds.md b/doc/api/ci/builds.md new file mode 100644 index 00000000000..d779463fd8c --- /dev/null +++ b/doc/api/ci/builds.md @@ -0,0 +1,138 @@ +# Builds API + +API used by runners to receive and update builds. + +>**Note:** +This API is intended to be used only by Runners as their own +communication channel. For the consumer API see the +[Builds API](../builds.md). + +## Authentication + +This API uses two types of authentication: + +1. Unique Runner's token which is the token assigned to the Runner after it + has been registered. + +2. Using the build authorization token. + This is project's CI token that can be found under the **Builds** section of + a project's settings. The build authorization token can be passed as a + parameter or a value of `BUILD-TOKEN` header. + +These two methods of authentication are interchangeable. + +## Builds + +### Runs oldest pending build by runner + +``` +POST /ci/api/v1/builds/register +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|---------------------| +| `token` | string | yes | Unique runner token | + + +``` +curl -X POST "https://gitlab.example.com/ci/api/v1/builds/register" -F "token=t0k3n" +``` + +### Update details of an existing build + +``` +PUT /ci/api/v1/builds/:id +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|----------------------| +| `id` | integer | yes | The ID of a project | +| `token` | string | yes | Unique runner token | +| `state` | string | no | The state of a build | +| `trace` | string | no | The trace of a build | + +``` +curl -X PUT "https://gitlab.example.com/ci/api/v1/builds/1234" -F "token=t0k3n" -F "state=running" -F "trace=Running git clone...\n" +``` + +### Incremental build trace update + +Using this method you need to send trace content as a request body. You also need to provide the `Content-Range` header +with a range of sent trace part. Note that you need to send parts in the proper order, so the begining of the part +must start just after the end of the previous part. If you provide the wrong part, then GitLab CI API will return `416 +Range Not Satisfiable` response with a header `Range: 0-X`, where `X` is the current trace length. + +For example, if you receive `Range: 0-11` in the response, then your next part must contain a `Content-Range: 11-...` +header and a trace part covered by this range. + +For a valid update API will return `202` response with: +* `Build-Status: {status}` header containing current status of the build, +* `Range: 0-{length}` header with the current trace length. + +``` +PATCH /ci/api/v1/builds/:id/trace.txt +``` + +Parameters: + +| Attribute | Type | Required | Description | +|-----------|---------|----------|----------------------| +| `id` | integer | yes | The ID of a build | + +Headers: + +| Attribute | Type | Required | Description | +|-----------------|---------|----------|-----------------------------------| +| `BUILD-TOKEN` | string | yes | The build authorization token | +| `Content-Range` | string | yes | Bytes range of trace that is sent | + +``` +curl -X PATCH "https://gitlab.example.com/ci/api/v1/builds/1234/trace.txt" -H "BUILD-TOKEN=build_t0k3n" -H "Content-Range=0-21" -d "Running git clone...\n" +``` + + +### Upload artifacts to build + +``` +POST /ci/api/v1/builds/:id/artifacts +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|-------------------------------| +| `id` | integer | yes | The ID of a build | +| `token` | string | yes | The build authorization token | +| `file` | mixed | yes | Artifacts file | + +``` +curl -X POST "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" -F "file=@/path/to/file" +``` + +### Download the artifacts file from build + +``` +GET /ci/api/v1/builds/:id/artifacts +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|-------------------------------| +| `id` | integer | yes | The ID of a build | +| `token` | string | yes | The build authorization token | + +``` +curl "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" +``` + +### Remove the artifacts file from build + +``` +DELETE /ci/api/v1/builds/:id/artifacts +``` + +| Attribute | Type | Required | Description | +|-----------|---------|----------|-------------------------------| +| ` id` | integer | yes | The ID of a build | +| `token` | string | yes | The build authorization token | + +``` +curl -X DELETE "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" +``` diff --git a/doc/api/ci/runners.md b/doc/api/ci/runners.md new file mode 100644 index 00000000000..96b3c42f773 --- /dev/null +++ b/doc/api/ci/runners.md @@ -0,0 +1,57 @@ +# Runners API + +API used by Runners to register and delete themselves. + +>**Note:** +This API is intended to be used only by Runners as their own +communication channel. For the consumer API see the +[new Runners API](../runners.md). + +## Authentication + +This API uses two types of authentication: + +1. Unique Runner's token, which is the token assigned to the Runner after it + has been registered. + +2. Using Runners' registration token. + This is a token that can be found in project's settings. + It can also be found in the **Admin > Runners** settings area. + There are two types of tokens you can pass: shared Runner registration + token or project specific registration token. + +## Register a new runner + +Used to make GitLab CI aware of available runners. + +```sh +POST /ci/api/v1/runners/register +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | --------- | ----------- | +| `token` | string | yes | Runner's registration token | + +Example request: + +```sh +curl -X POST "https://gitlab.example.com/ci/api/v1/runners/register" -F "token=t0k3n" +``` + +## Delete a Runner + +Used to remove a Runner. + +```sh +DELETE /ci/api/v1/runners/delete +``` + +| Attribute | Type | Required | Description | +| --------- | ------- | --------- | ----------- | +| `token` | string | yes | Runner's registration token | + +Example request: + +```sh +curl -X DELETE "https://gitlab.example.com/ci/api/v1/runners/delete" -F "token=t0k3n" +``` diff --git a/doc/ci/README.md b/doc/ci/README.md index 4abc45bf9bb..ef72df97ce6 100644 --- a/doc/ci/README.md +++ b/doc/ci/README.md @@ -14,5 +14,5 @@ - [Trigger builds through the API](triggers/README.md) - [Build artifacts](build_artifacts/README.md) - [User permissions](permissions/README.md) -- [API](api/README.md) +- [API](../../api/ci/README.md) - [CI services (linked docker containers)](services/README.md) diff --git a/doc/ci/api/README.md b/doc/ci/api/README.md index aea808007fc..4ca8d92d7cc 100644 --- a/doc/ci/api/README.md +++ b/doc/ci/api/README.md @@ -1,22 +1,3 @@ # GitLab CI API -## Purpose - -Main purpose of GitLab CI API is to provide necessary data and context for -GitLab CI Runners. - -For consumer API take a look at this [documentation](../../api/README.md) where -you will find all relevant information. - -## API Prefix - -Current CI API prefix is `/ci/api/v1`. - -You need to prepend this prefix to all examples in this documentation, like: - - GET /ci/api/v1/builds/:id/artifacts - -## Resources - -- [Builds](builds.md) -- [Runners](runners.md) +This document was moved to a [new location](../../api/ci/README.md). diff --git a/doc/ci/api/builds.md b/doc/ci/api/builds.md index 79761a893da..f5bd3181c02 100644 --- a/doc/ci/api/builds.md +++ b/doc/ci/api/builds.md @@ -1,139 +1,3 @@ # Builds API -API used by runners to receive and update builds. - -_**Note:** This API is intended to be used only by Runners as their own -communication channel. For the consumer API see the -[Builds API](../../api/builds.md)._ - -## Authentication - -This API uses two types of authentication: - -1. Unique runner's token - - Token assigned to runner after it has been registered. - -2. Using build authorization token - - This is project's CI token that can be found in Continuous Integration - project settings. - - Build authorization token can be passed as a parameter or a value of - `BUILD-TOKEN` header. This method are interchangeable. - -## Builds - -### Runs oldest pending build by runner - -``` -POST /ci/api/v1/builds/register -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|---------------------| -| `token` | string | yes | Unique runner token | - - -``` -curl -X POST "https://gitlab.example.com/ci/api/v1/builds/register" -F "token=t0k3n" -``` - -### Update details of an existing build - -``` -PUT /ci/api/v1/builds/:id -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|----------------------| -| `id` | integer | yes | The ID of a project | -| `token` | string | yes | Unique runner token | -| `state` | string | no | The state of a build | -| `trace` | string | no | The trace of a build | - -``` -curl -X PUT "https://gitlab.example.com/ci/api/v1/builds/1234" -F "token=t0k3n" -F "state=running" -F "trace=Running git clone...\n" -``` - -### Incremental build trace update - -Using this method you need to send trace content as a request body. You also need to provide the `Content-Range` header -with a range of sent trace part. Note that you need to send parts in the proper order, so the begining of the part -must start just after the end of the previous part. If you provide the wrong part, then GitLab CI API will return `416 -Range Not Satisfiable` response with a header `Range: 0-X`, where `X` is the current trace length. - -For example, if you receive `Range: 0-11` in the response, then your next part must contain a `Content-Range: 11-...` -header and a trace part covered by this range. - -For a valid update API will return `202` response with: -* `Build-Status: {status}` header containing current status of the build, -* `Range: 0-{length}` header with the current trace length. - -``` -PATCH /ci/api/v1/builds/:id/trace.txt -``` - -Parameters: - -| Attribute | Type | Required | Description | -|-----------|---------|----------|----------------------| -| `id` | integer | yes | The ID of a build | - -Headers: - -| Attribute | Type | Required | Description | -|-----------------|---------|----------|-----------------------------------| -| `BUILD-TOKEN` | string | yes | The build authorization token | -| `Content-Range` | string | yes | Bytes range of trace that is sent | - -``` -curl -X PATCH "https://gitlab.example.com/ci/api/v1/builds/1234/trace.txt" -H "BUILD-TOKEN=build_t0k3n" -H "Content-Range=0-21" -d "Running git clone...\n" -``` - - -### Upload artifacts to build - -``` -POST /ci/api/v1/builds/:id/artifacts -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|-------------------------------| -| `id` | integer | yes | The ID of a build | -| `token` | string | yes | The build authorization token | -| `file` | mixed | yes | Artifacts file | - -``` -curl -X POST "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" -F "file=@/path/to/file" -``` - -### Download the artifacts file from build - -``` -GET /ci/api/v1/builds/:id/artifacts -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|-------------------------------| -| `id` | integer | yes | The ID of a build | -| `token` | string | yes | The build authorization token | - -``` -curl "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" -``` - -### Remove the artifacts file from build - -``` -DELETE /ci/api/v1/builds/:id/artifacts -``` - -| Attribute | Type | Required | Description | -|-----------|---------|----------|-------------------------------| -| ` id` | integer | yes | The ID of a build | -| `token` | string | yes | The build authorization token | - -``` -curl -X DELETE "https://gitlab.example.com/ci/api/v1/builds/1234/artifacts" -F "token=build_t0k3n" -``` +This document was moved to a [new location](../../api/ci/builds.md). diff --git a/doc/ci/api/runners.md b/doc/ci/api/runners.md index 2f01da4bd76..b14ea99db76 100644 --- a/doc/ci/api/runners.md +++ b/doc/ci/api/runners.md @@ -1,46 +1,3 @@ # Runners API -API used by runners to register and delete themselves. - -_**Note:** This API is intended to be used only by Runners as their own -communication channel. For the consumer API see the -[new Runners API](../../api/runners.md)._ - -## Authentication - -This API uses two types of authentication: - -1. Unique runner's token - - Token assigned to runner after it has been registered. - -2. Using runners' registration token - - This is a token that can be found in project's settings. - It can be also found in Admin area » Runners settings. - - There are two types of tokens you can pass - shared runner registration - token or project specific registration token. - -## Runners - -### Register a new runner - -Used to make GitLab CI aware of available runners. - - POST /ci/api/v1/runners/register - -Parameters: - - * `token` (required) - Registration token - - -### Delete a runner - -Used to remove runner. - - DELETE /ci/api/v1/runners/delete - -Parameters: - - * `token` (required) - Unique runner token +This document was moved to a [new location](../../api/ci/runners.md). From f2f5a115c6d9dbb9f016693df979e67dd20833a4 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis <axilleas@axilleas.me> Date: Tue, 14 Jun 2016 14:50:25 +0200 Subject: [PATCH 226/318] Fix grammar and syntax --- doc/api/ci/README.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/doc/api/ci/README.md b/doc/api/ci/README.md index aea808007fc..96a281e27c8 100644 --- a/doc/api/ci/README.md +++ b/doc/api/ci/README.md @@ -2,19 +2,21 @@ ## Purpose -Main purpose of GitLab CI API is to provide necessary data and context for -GitLab CI Runners. +The main purpose of GitLab CI API is to provide the necessary data and context +for GitLab CI Runners. -For consumer API take a look at this [documentation](../../api/README.md) where -you will find all relevant information. +All relevant information about the consumer API can be found in a +[separate document](../../api/README.md). ## API Prefix -Current CI API prefix is `/ci/api/v1`. +The current CI API prefix is `/ci/api/v1`. You need to prepend this prefix to all examples in this documentation, like: - GET /ci/api/v1/builds/:id/artifacts +```bash +GET /ci/api/v1/builds/:id/artifacts +``` ## Resources From 2b5449b96d1c08eafc3e874f28dd9f85a6b09535 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 14:51:09 +0200 Subject: [PATCH 227/318] Fix Ci::Build#artifacts_expire_in= when assigning invalid duration --- spec/models/build_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index 35554e1e0c0..5d1fa8226e5 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -474,7 +474,7 @@ describe Ci::Build, models: true do end it 'when assigning invalid duration' do - expect { build.artifacts_expire_in = '7 elephants' }.not_to raise_error + expect { build.artifacts_expire_in = '7 elephants' }.to raise_error(ChronicDuration::DurationParseError) is_expected.to be_nil end From dadc531353bdf0e384d05d173d19756b0d9fba13 Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Mon, 13 Jun 2016 18:49:21 +0200 Subject: [PATCH 228/318] Instrument private/protected methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit By default instrumentation will instrument public, protected and private methods, because usually heavy work is done on private method or at least that’s what facts is showing --- CHANGELOG | 1 + config/initializers/metrics.rb | 5 -- doc/development/instrumentation.md | 4 +- lib/gitlab/metrics/instrumentation.rb | 10 ++-- .../gitlab/metrics/instrumentation_spec.rb | 56 ++++++++++++++++++- 5 files changed, 63 insertions(+), 13 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e71a154d1d5..74fb52d3aeb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -78,6 +78,7 @@ v 8.9.0 (unreleased) - Remove deprecated issues_tracker and issues_tracker_id from project model - Allow users to create confidential issues in private projects - Measure CPU time for instrumented methods + - Instrument private methods and private instance methods by default instead just public methods v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/config/initializers/metrics.rb b/config/initializers/metrics.rb index f6509ee43f1..989404c6a61 100644 --- a/config/initializers/metrics.rb +++ b/config/initializers/metrics.rb @@ -128,11 +128,6 @@ if Gitlab::Metrics.enabled? config.instrument_instance_methods(API::Helpers) config.instrument_instance_methods(RepositoryCheck::SingleRepositoryWorker) - # Iterate over each non-super private instance method to keep up to date if - # internals change - RepositoryCheck::SingleRepositoryWorker.private_instance_methods(false).each do |method| - config.instrument_instance_method(RepositoryCheck::SingleRepositoryWorker, method) - end end GC::Profiler.enable diff --git a/doc/development/instrumentation.md b/doc/development/instrumentation.md index 50d2866ca46..6cd9b274d11 100644 --- a/doc/development/instrumentation.md +++ b/doc/development/instrumentation.md @@ -15,8 +15,8 @@ instrument code: * `instrument_instance_method`: instruments a single instance method. * `instrument_class_hierarchy`: given a Class this method will recursively instrument all sub-classes (both class and instance methods). -* `instrument_methods`: instruments all public class methods of a Module. -* `instrument_instance_methods`: instruments all public instance methods of a +* `instrument_methods`: instruments all public and private class methods of a Module. +* `instrument_instance_methods`: instruments all public and private instance methods of a Module. To remove the need for typing the full `Gitlab::Metrics::Instrumentation` diff --git a/lib/gitlab/metrics/instrumentation.rb b/lib/gitlab/metrics/instrumentation.rb index ad9ce3fa442..d81d26754fe 100644 --- a/lib/gitlab/metrics/instrumentation.rb +++ b/lib/gitlab/metrics/instrumentation.rb @@ -56,7 +56,7 @@ module Gitlab end end - # Instruments all public methods of a module. + # Instruments all public and private methods of a module. # # This method optionally takes a block that can be used to determine if a # method should be instrumented or not. The block is passed the receiving @@ -65,7 +65,8 @@ module Gitlab # # mod - The module to instrument. def self.instrument_methods(mod) - mod.public_methods(false).each do |name| + methods = mod.methods(false) + mod.private_methods(false) + methods.each do |name| method = mod.method(name) if method.owner == mod.singleton_class @@ -76,13 +77,14 @@ module Gitlab end end - # Instruments all public instance methods of a module. + # Instruments all public and private instance methods of a module. # # See `instrument_methods` for more information. # # mod - The module to instrument. def self.instrument_instance_methods(mod) - mod.public_instance_methods(false).each do |name| + methods = mod.instance_methods(false) + mod.private_instance_methods(false) + methods.each do |name| method = mod.instance_method(name) if method.owner == mod diff --git a/spec/lib/gitlab/metrics/instrumentation_spec.rb b/spec/lib/gitlab/metrics/instrumentation_spec.rb index c6e979b69a4..cdf641341cb 100644 --- a/spec/lib/gitlab/metrics/instrumentation_spec.rb +++ b/spec/lib/gitlab/metrics/instrumentation_spec.rb @@ -9,9 +9,31 @@ describe Gitlab::Metrics::Instrumentation do text end + class << self + def buzz(text = 'buzz') + text + end + private :buzz + + def flaky(text = 'flaky') + text + end + protected :flaky + end + def bar(text = 'bar') text end + + def wadus(text = 'wadus') + text + end + private :wadus + + def chaf(text = 'chaf') + text + end + protected :chaf end allow(@dummy).to receive(:name).and_return('Dummy') @@ -208,6 +230,21 @@ describe Gitlab::Metrics::Instrumentation do described_class.instrument_methods(@dummy) expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true) + expect(@dummy.method(:foo).source_location.first).to match(/instrumentation\.rb/) + end + + it 'instruments all protected class methods' do + described_class.instrument_methods(@dummy) + + expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true) + expect(@dummy.method(:flaky).source_location.first).to match(/instrumentation\.rb/) + end + + it 'instruments all private instance methods' do + described_class.instrument_methods(@dummy) + + expect(described_class.instrumented?(@dummy.singleton_class)).to eq(true) + expect(@dummy.method(:buzz).source_location.first).to match(/instrumentation\.rb/) end it 'only instruments methods directly defined in the module' do @@ -241,6 +278,21 @@ describe Gitlab::Metrics::Instrumentation do described_class.instrument_instance_methods(@dummy) expect(described_class.instrumented?(@dummy)).to eq(true) + expect(@dummy.new.method(:bar).source_location.first).to match(/instrumentation\.rb/) + end + + it 'instruments all protected instance methods' do + described_class.instrument_instance_methods(@dummy) + + expect(described_class.instrumented?(@dummy)).to eq(true) + expect(@dummy.new.method(:chaf).source_location.first).to match(/instrumentation\.rb/) + end + + it 'instruments all private instance methods' do + described_class.instrument_instance_methods(@dummy) + + expect(described_class.instrumented?(@dummy)).to eq(true) + expect(@dummy.new.method(:wadus).source_location.first).to match(/instrumentation\.rb/) end it 'only instruments methods directly defined in the module' do @@ -253,7 +305,7 @@ describe Gitlab::Metrics::Instrumentation do described_class.instrument_instance_methods(@dummy) - expect(@dummy.method_defined?(:_original_kittens)).to eq(false) + expect(@dummy.new.method(:kittens).source_location.first).not_to match(/instrumentation\.rb/) end it 'can take a block to determine if a method should be instrumented' do @@ -261,7 +313,7 @@ describe Gitlab::Metrics::Instrumentation do false end - expect(@dummy.method_defined?(:_original_bar)).to eq(false) + expect(@dummy.new.method(:bar).source_location.first).not_to match(/instrumentation\.rb/) end end end From dc41a933f4f9a79e7160e38f248d33d7beb99bb6 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 16:11:28 +0200 Subject: [PATCH 229/318] Update scss to make the views look nicer --- app/assets/stylesheets/pages/environments.scss | 5 +++++ app/views/projects/deployments/_deployment.html.haml | 2 +- app/views/projects/environments/_environment.html.haml | 7 +++---- app/views/projects/environments/index.html.haml | 2 +- app/views/projects/environments/show.html.haml | 6 +++--- 5 files changed, 13 insertions(+), 9 deletions(-) create mode 100644 app/assets/stylesheets/pages/environments.scss diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss new file mode 100644 index 00000000000..e160d676e35 --- /dev/null +++ b/app/assets/stylesheets/pages/environments.scss @@ -0,0 +1,5 @@ +.environments { + .commit-title { + margin: 0; + } +} diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index 1ac17af8b58..28c003d22a8 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -9,7 +9,7 @@ · = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" - %p + %p.commit-title %span - if commit_title = deployment.commit_title = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message" diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml index 5ca57bd153d..c2e6d11f941 100644 --- a/app/views/projects/environments/_environment.html.haml +++ b/app/views/projects/environments/_environment.html.haml @@ -13,17 +13,16 @@ · = link_to last_deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, last_deployment.sha), class: "commit-id monospace" - %p + %p.commit-title %span - if commit_title = last_deployment.commit_title = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, last_deployment.sha), class: "commit-row-message" - else Cant find HEAD commit for this branch - else - %p + %p.commit-title No deployments yet %td - if last_deployment - %p - #{time_ago_with_tooltip(last_deployment.created_at)} + #{time_ago_with_tooltip(last_deployment.created_at)} diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 4a445a157ec..fa1046bbe1a 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -15,7 +15,7 @@ No environments to show - else .table-holder - %table.table + %table.table.environments %tbody %th Environment %th Last deployment diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 1d39bef9427..6454101004a 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -5,7 +5,7 @@ %div{ class: (container_class) } .top-area .col-md-9 - %h3= @environment.name.titleize + %h3.page-title= @environment.name.titleize .col-md-3 .nav-controls @@ -13,13 +13,13 @@ = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :delete - if @deployments.blank? - %ul.content-list + %ul.content-list.environments %li.nothing-here-block No deployments for %strong= @environment.name - else .table-holder - %table.table.builds + %table.table.environments %thead %tr %th ID From d183e27e5c75bce80fc3b9e8297b69f1007e6819 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 09:58:54 -0500 Subject: [PATCH 230/318] Put all sidebar icons in fixed width container --- app/assets/stylesheets/framework/sidebar.scss | 6 ++++ app/views/layouts/nav/_dashboard.html.haml | 30 ++++++++++++------- 2 files changed, 26 insertions(+), 10 deletions(-) diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index b7ec3f70bfb..4668e7e911b 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -83,6 +83,12 @@ margin-top: 10px; } + .icon-container { + width: 34px; + display: inline-block; + text-align: center; + } + a { width: $sidebar_width; padding: 7px 15px 7px 23px; diff --git a/app/views/layouts/nav/_dashboard.html.haml b/app/views/layouts/nav/_dashboard.html.haml index 18cae5bf87f..52e41b1a857 100644 --- a/app/views/layouts/nav/_dashboard.html.haml +++ b/app/views/layouts/nav/_dashboard.html.haml @@ -1,54 +1,64 @@ %ul.nav.nav-sidebar = nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "#{project_tab_class} home"}) do = link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do - = navbar_icon('project') + .icon-container + = navbar_icon('project') %span Projects = nav_link(controller: :todos) do = link_to dashboard_todos_path, title: 'Todos' do - = icon('bell fw') + .icon-container + = icon('bell fw') %span Todos %span.count= number_with_delimiter(todos_pending_count) = nav_link(path: 'dashboard#activity') do = link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do - = navbar_icon('activity') + .icon-container + = navbar_icon('activity') %span Activity = nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do = link_to dashboard_groups_path, title: 'Groups' do - = navbar_icon('group') + .icon-container + = navbar_icon('group') %span Groups = nav_link(controller: 'dashboard/milestones') do = link_to dashboard_milestones_path, title: 'Milestones' do - = navbar_icon('milestones') + .icon-container + = navbar_icon('milestones') %span Milestones = nav_link(path: 'dashboard#issues') do = link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues' do - = navbar_icon('issues') + .icon-container + = navbar_icon('issues') %span Issues %span.count= number_with_delimiter(current_user.assigned_issues.opened.count) = nav_link(path: 'dashboard#merge_requests') do = link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do - = navbar_icon('mr') + .icon-container + = navbar_icon('mr') %span Merge Requests %span.count= number_with_delimiter(current_user.assigned_merge_requests.opened.count) = nav_link(controller: :snippets) do = link_to dashboard_snippets_path, title: 'Snippets' do - = icon('clipboard fw') + .icon-container + = icon('clipboard fw') %span Snippets = nav_link(controller: :help) do = link_to help_path, title: 'Help' do - = icon('question-circle fw') + .icon-container + = icon('question-circle fw') %span Help = nav_link(html_options: {class: profile_tab_class}) do = link_to profile_path, title: 'Profile Settings', data: {placement: 'bottom'} do - = icon('user fw') + .icon-container + = icon('user fw') %span Profile Settings From 3582c6aedcee14162217cd103092e6340a1c4741 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Tue, 14 Jun 2016 15:49:26 +0100 Subject: [PATCH 231/318] Track new Redis connections Increment the counter `new_redis_connections` on each call to `Redis::Client#connect`, if we're in a transaction. --- CHANGELOG | 1 + config/initializers/metrics.rb | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 2aed8eb322b..c00d478e43f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -22,6 +22,7 @@ v 8.9.0 (unreleased) - Reduce number of fog gem dependencies - Remove project notification settings associated with deleted projects - Fix 404 page when viewing TODOs that contain milestones or labels in different projects + - Add a metric for the number of new Redis connections created by a transaction - Redesign navigation for project pages - Fix groups API to list only user's accessible projects - Redesign account and email confirmation emails diff --git a/config/initializers/metrics.rb b/config/initializers/metrics.rb index f6509ee43f1..4bc6acdedb9 100644 --- a/config/initializers/metrics.rb +++ b/config/initializers/metrics.rb @@ -138,4 +138,20 @@ if Gitlab::Metrics.enabled? GC::Profiler.enable Gitlab::Metrics::Sampler.new.start + + module TrackNewRedisConnections + def connect(*args) + val = super + + if current_transaction = Gitlab::Metrics::Transaction.current + current_transaction.increment(:new_redis_connections, 1) + end + + val + end + end + + class ::Redis::Client + prepend TrackNewRedisConnections + end end From d07426ac198af72538663e7acc36f8c8bfe8de28 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Tue, 14 Jun 2016 16:07:12 +0100 Subject: [PATCH 232/318] Fix spec description typo --- spec/requests/git_http_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index c44a4a7a1fc..fd26ca97818 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -340,7 +340,7 @@ describe 'Git HTTP requests', lib: true do end end - context "when the file exists" do + context "when the file does not exist" do before { get "/#{project.path_with_namespace}/blob/master/info/refs" } it "returns not found" do From 82090d291fa56e11e5be24102fa651273ac28d4b Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Tue, 14 Jun 2016 14:35:10 +0200 Subject: [PATCH 233/318] Update the allocations Gem to 1.0.5 This allows it to be used on Ruby 2.3 without it crashing all the time. --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index 209f29de1e0..d517fcb8ed3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -50,7 +50,7 @@ GEM after_commit_queue (1.3.0) activerecord (>= 3.0) akismet (2.0.0) - allocations (1.0.4) + allocations (1.0.5) arel (6.0.3) asana (0.4.0) faraday (~> 0.9) From ab91f1226f9dc99725e10323c0ea319f335204b3 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Tue, 14 Jun 2016 14:35:25 +0200 Subject: [PATCH 234/318] Filter out classes without names in the sampler We can't do a lot with classes without names as we can't filter by them, have no idea where they come from, etc. As such it's best to just ignore these. --- lib/gitlab/metrics/sampler.rb | 6 +++++- spec/lib/gitlab/metrics/sampler_spec.rb | 25 ++++++++++++++++++------- 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/lib/gitlab/metrics/sampler.rb b/lib/gitlab/metrics/sampler.rb index fc709222a9b..0000450d9bb 100644 --- a/lib/gitlab/metrics/sampler.rb +++ b/lib/gitlab/metrics/sampler.rb @@ -66,7 +66,11 @@ module Gitlab def sample_objects sample = Allocations.to_hash counts = sample.each_with_object({}) do |(klass, count), hash| - hash[klass.name] = count + name = klass.name + + next unless name + + hash[name] = count end # Symbols aren't allocated so we'll need to add those manually. diff --git a/spec/lib/gitlab/metrics/sampler_spec.rb b/spec/lib/gitlab/metrics/sampler_spec.rb index 59db127674a..1ab923b58cf 100644 --- a/spec/lib/gitlab/metrics/sampler_spec.rb +++ b/spec/lib/gitlab/metrics/sampler_spec.rb @@ -72,14 +72,25 @@ describe Gitlab::Metrics::Sampler do end end - describe '#sample_objects' do - it 'adds a metric containing the amount of allocated objects' do - expect(sampler).to receive(:add_metric). - with(/object_counts/, an_instance_of(Hash), an_instance_of(Hash)). - at_least(:once). - and_call_original + if Gitlab::Metrics.mri? + describe '#sample_objects' do + it 'adds a metric containing the amount of allocated objects' do + expect(sampler).to receive(:add_metric). + with(/object_counts/, an_instance_of(Hash), an_instance_of(Hash)). + at_least(:once). + and_call_original - sampler.sample_objects + sampler.sample_objects + end + + it 'ignores classes without a name' do + expect(Allocations).to receive(:to_hash).and_return({ Class.new => 4 }) + + expect(sampler).not_to receive(:add_metric). + with('object_counts', an_instance_of(Hash), type: nil) + + sampler.sample_objects + end end end From 7eabc67efeda871fdff345c4d9723db577f8b58e Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Tue, 14 Jun 2016 14:41:06 +0200 Subject: [PATCH 235/318] Added CHANGELOG entry for allocations Gem/name fix --- CHANGELOG | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 74fb52d3aeb..162c6723dd2 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -79,6 +79,8 @@ v 8.9.0 (unreleased) - Allow users to create confidential issues in private projects - Measure CPU time for instrumented methods - Instrument private methods and private instance methods by default instead just public methods + - Updated the allocations Gem to version 1.0.5 + - The background sampler now ignores classes without names v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From 14a02a6a95353948d00f8f973b35b80ac06f4599 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Tue, 14 Jun 2016 18:34:48 +0200 Subject: [PATCH 236/318] Improve design after review --- .../projects/environments_controller.rb | 19 +++++++++--------- app/models/ability.rb | 16 ++++++++++++--- app/models/ci/build.rb | 3 ++- app/models/deployment.rb | 10 +++++----- app/models/environment.rb | 5 ++--- app/services/create_deployment_service.rb | 20 ++++--------------- app/views/layouts/nav/_project.html.haml | 2 +- .../projects/deployments/_commit.html.haml | 12 +++++++++++ .../deployments/_deployment.html.haml | 17 +++------------- .../environments/_environment.html.haml | 13 +----------- .../projects/environments/index.html.haml | 3 +-- app/views/projects/environments/new.html.haml | 3 ++- .../projects/environments/show.html.haml | 2 +- doc/permissions/permissions.md | 5 +++-- 14 files changed, 60 insertions(+), 70 deletions(-) create mode 100644 app/views/projects/deployments/_commit.html.haml diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 4f8dadd6adf..1f9f676c63b 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -19,20 +19,22 @@ class Projects::EnvironmentsController < Projects::ApplicationController def create @environment = project.environments.create(create_params) - unless @environment.persisted? - render 'new' - return - end - redirect_to namespace_project_environment_path(project.namespace, project, @environment) + if @environment.persisted? + redirect_to namespace_project_environment_path(project.namespace, project, @environment) + else + render 'new' + end end def destroy if @environment.destroy - redirect_to namespace_project_environments_path(project.namespace, project), notice: 'Environment was successfully removed.' + flash[:notice] = 'Environment was successfully removed.' else - redirect_to namespace_project_environments_path(project.namespace, project), alert: 'Failed to remove environment.' + flash[:alert] = 'Failed to remove environment.' end + + redirect_to namespace_project_environments_path(project.namespace, project) end private @@ -42,7 +44,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def environment - @environment ||= project.environments.find(params[:id].to_s) - @environment || render_404 + @environment ||= project.environments.find_by!(id: params[:id]) end end diff --git a/app/models/ability.rb b/app/models/ability.rb index 93905abbee8..32e45674682 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -18,6 +18,8 @@ class Ability when Namespace then namespace_abilities(user, subject) when GroupMember then group_member_abilities(user, subject) when ProjectMember then project_member_abilities(user, subject) + when Deployment then deployment_abilities(user, subject) + when Environment then environment_abilities(user, subject) when User then user_abilities else [] end.concat(global_abilities(user)) @@ -249,9 +251,7 @@ class Ability :create_container_image, :update_container_image, :create_environment, - :update_environment, - :create_deployment, - :update_deployment, + :create_deployment ] end @@ -269,6 +269,8 @@ class Ability @project_master_rules ||= project_dev_rules + [ :push_code_to_protected_branches, :update_project_snippet, + :update_environment, + :update_deployment, :admin_milestone, :admin_project_snippet, :admin_project_member, @@ -525,6 +527,14 @@ class Ability project_abilities(user, subject.project) end + def deployment_abilities(user, subject) + project_abilities(user, subject.project) + end + + def environment_abilities(user, subject) + project_abilities(user, subject.project) + end + private def restricted_public_level? diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index ac039a3b148..764d8e4e136 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -81,7 +81,8 @@ module Ci if build.environment.present? service = CreateDeploymentService.new(build.project, build.user, environment: build.environment, - sha: build.sha, ref: build.ref, + sha: build.sha, + ref: build.ref, tag: build.tag) service.execute(build) end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 32799ee27e6..d9006b70e30 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -6,10 +6,10 @@ class Deployment < ActiveRecord::Base belongs_to :user belongs_to :deployable, polymorphic: true - validates_presence_of :sha - validates_presence_of :ref - validates_associated :project - validates_associated :environment + validates :sha, presence: true + validates :ref, presence: true + validates :project, associated: true + validates :environment, associated: true delegate :name, to: :environment, prefix: true @@ -22,7 +22,7 @@ class Deployment < ActiveRecord::Base end def short_sha - Commit::truncate_sha(sha) + Commit.truncate_sha(sha) end def last? diff --git a/app/models/environment.rb b/app/models/environment.rb index 3eab137718e..ac6f8c81e01 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -5,13 +5,12 @@ class Environment < ActiveRecord::Base validates :name, presence: true, + uniqueness: { scope: :project_id }, length: { within: 0..255 }, format: { with: Gitlab::Regex.environment_name_regex, message: Gitlab::Regex.environment_name_regex_message } - validates_uniqueness_of :name, scope: :project_id - - validates_associated :project + validates :project, associated: true def last_deployment deployments.last diff --git a/app/services/create_deployment_service.rb b/app/services/create_deployment_service.rb index eec1773073e..efeb9df9527 100644 --- a/app/services/create_deployment_service.rb +++ b/app/services/create_deployment_service.rb @@ -2,7 +2,9 @@ require_relative 'base_service' class CreateDeploymentService < BaseService def execute(deployable = nil) - environment = create_or_find_environment(params[:environment]) + environment = project.environments.find_or_create_by( + name: params[:environment] + ) project.deployments.create( environment: environment, @@ -10,21 +12,7 @@ class CreateDeploymentService < BaseService tag: params[:tag], sha: params[:sha], user: current_user, - deployable: deployable, + deployable: deployable ) end - - private - - def create_or_find_environment(environment) - find_environment(environment) || create_environment(environment) - end - - def create_environment(environment) - project.environments.create(name: environment) - end - - def find_environment(environment) - project.environments.find_by(name: environment) - end end diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 0ac44b084a9..32a91afab8d 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -40,7 +40,7 @@ Code - if project_nav_tab? :pipelines - = nav_link(controller: :pipelines) do + = nav_link(controller: [:pipelines, :builds, :environments]) do = link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do %span Pipelines diff --git a/app/views/projects/deployments/_commit.html.haml b/app/views/projects/deployments/_commit.html.haml new file mode 100644 index 00000000000..0f9d9512d88 --- /dev/null +++ b/app/views/projects/deployments/_commit.html.haml @@ -0,0 +1,12 @@ +%div.branch-commit + - if deployment.ref + = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace" + · + = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" + + %p.commit-title + %span + - if commit_title = deployment.commit_title + = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message" + - else + Cant find HEAD commit for this branch diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index 28c003d22a8..f065f28c6ee 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -3,29 +3,18 @@ %strong= "##{deployment.iid}" %td - %div.branch-commit - - if deployment.ref - = link_to deployment.ref, namespace_project_commits_path(@project.namespace, @project, deployment.ref), class: "monospace" - · - = link_to deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-id monospace" - - %p.commit-title - %span - - if commit_title = deployment.commit_title - = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, deployment.sha), class: "commit-row-message" - - else - Cant find HEAD commit for this branch + = render 'projects/deployments/commit', deployment: deployment %td - if deployment.deployable - = link_to namespace_project_build_path(@project.namespace, @project, deployment.deployable), class: "monospace" do + = link_to namespace_project_build_path(@project.namespace, @project, deployment.deployable) do = "#{deployment.deployable.name} (##{deployment.deployable.id})" %td #{time_ago_with_tooltip(deployment.created_at)} %td - - if can?(current_user, :update_deployment, @project) && deployment.deployable + - if can?(current_user, :update_deployment, deployment) && deployment.deployable .pull-right = link_to retry_namespace_project_build_path(@project.namespace, @project, deployment.deployable), method: :post, class: 'btn btn-build' do - if deployment.last? diff --git a/app/views/projects/environments/_environment.html.haml b/app/views/projects/environments/_environment.html.haml index c2e6d11f941..eafa246d05f 100644 --- a/app/views/projects/environments/_environment.html.haml +++ b/app/views/projects/environments/_environment.html.haml @@ -7,18 +7,7 @@ %td - if last_deployment - %div.branch-commit - - if last_deployment.ref - = link_to last_deployment.ref, namespace_project_commits_path(@project.namespace, @project, last_deployment.ref), class: "monospace" - · - = link_to last_deployment.short_sha, namespace_project_commit_path(@project.namespace, @project, last_deployment.sha), class: "commit-id monospace" - - %p.commit-title - %span - - if commit_title = last_deployment.commit_title - = link_to_gfm commit_title, namespace_project_commit_path(@project.namespace, @project, last_deployment.sha), class: "commit-row-message" - - else - Cant find HEAD commit for this branch + = render 'projects/deployments/commit', deployment: last_deployment - else %p.commit-title No deployments yet diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index fa1046bbe1a..ae9e77e7d89 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -20,5 +20,4 @@ %th Environment %th Last deployment %th Date - - @environments.each do |environment| - = render 'environment', environment: environment + = render @environments diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml index ade41d9de2d..533f624c4e2 100644 --- a/app/views/projects/environments/new.html.haml +++ b/app/views/projects/environments/new.html.haml @@ -1,3 +1,4 @@ +- @no_container = true - page_title "New Environment" = render "projects/pipelines/head" @@ -6,7 +7,7 @@ %h4.prepend-top-0 New Environment - = form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { id: "new-environment-form", class: "col-lg-9 js-new-environment-form js-requires-input" } do |f| + = form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { class: "col-lg-9" } do |f| = form_errors(@environment) .form-group = f.label :name, 'Environment name', class: 'label-light' diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index 6454101004a..b41b1651a81 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -9,7 +9,7 @@ .col-md-3 .nav-controls - - if can?(current_user, :update_environment, @project) + - if can?(current_user, :update_environment, @environment) = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :delete - if @deployments.blank? diff --git a/doc/permissions/permissions.md b/doc/permissions/permissions.md index 666dcfafd03..963b35de3a0 100644 --- a/doc/permissions/permissions.md +++ b/doc/permissions/permissions.md @@ -28,7 +28,7 @@ documentation](../workflow/add-user/add-user.md). | Manage labels | | ✓ | ✓ | ✓ | ✓ | | See a commit status | | ✓ | ✓ | ✓ | ✓ | | See a container registry | | ✓ | ✓ | ✓ | ✓ | -| See a environments | | ✓ | ✓ | ✓ | ✓ | +| See environments | | ✓ | ✓ | ✓ | ✓ | | Manage merge requests | | | ✓ | ✓ | ✓ | | Create new merge request | | | ✓ | ✓ | ✓ | | Create new branches | | | ✓ | ✓ | ✓ | @@ -41,7 +41,7 @@ documentation](../workflow/add-user/add-user.md). | Create or update commit status | | | ✓ | ✓ | ✓ | | Update a container registry | | | ✓ | ✓ | ✓ | | Remove a container registry image | | | ✓ | ✓ | ✓ | -| Manage environments | | | ✓ | ✓ | ✓ | +| Create new environments | | | ✓ | ✓ | ✓ | | Create new milestones | | | | ✓ | ✓ | | Add new team members | | | | ✓ | ✓ | | Push to protected branches | | | | ✓ | ✓ | @@ -54,6 +54,7 @@ documentation](../workflow/add-user/add-user.md). | Manage runners | | | | ✓ | ✓ | | Manage build triggers | | | | ✓ | ✓ | | Manage variables | | | | ✓ | ✓ | +| Delete environments | | | | ✓ | ✓ | | Switch visibility level | | | | | ✓ | | Transfer project to another namespace | | | | | ✓ | | Remove project | | | | | ✓ | From 06e0ef07bcb92925e6819cbe1e33cdcf645b736b Mon Sep 17 00:00:00 2001 From: Patricio Cano <suprnova32@gmail.com> Date: Tue, 14 Jun 2016 11:45:45 -0500 Subject: [PATCH 237/318] Added API endpoint for Sidekiq Metrics --- doc/api/README.md | 1 + doc/api/sidekiq_metrics.md | 152 ++++++++++++++++++++++ lib/api/api.rb | 1 + lib/api/sidekiq_metrics.rb | 90 +++++++++++++ spec/requests/api/sidekiq_metrics_spec.rb | 40 ++++++ 5 files changed, 284 insertions(+) create mode 100644 doc/api/sidekiq_metrics.md create mode 100644 lib/api/sidekiq_metrics.rb create mode 100644 spec/requests/api/sidekiq_metrics_spec.rb diff --git a/doc/api/README.md b/doc/api/README.md index e3fc5a09f21..6042ef7637c 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -31,6 +31,7 @@ following locations: - [Services](services.md) - [Session](session.md) - [Settings](settings.md) +- [Sidekiq Metrics](sidekiq_metrics.md) - [System Hooks](system_hooks.md) - [Tags](tags.md) - [Users](users.md) diff --git a/doc/api/sidekiq_metrics.md b/doc/api/sidekiq_metrics.md new file mode 100644 index 00000000000..de627c1a969 --- /dev/null +++ b/doc/api/sidekiq_metrics.md @@ -0,0 +1,152 @@ +# Sidekiq Metrics + +>**Note:** This endpoint is only available on GitLab 8.9 and above. + +This API endpoint allows you to retrieve some information about the current state +of Sidekiq, it's jobs, queues, and processes. + +## Get the current Queue Metrics + +List information about all the registered queues, their backlog and their +latency. + +``` +GET /sidekiq/queue_metrics +``` + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/queue_metrics +``` + +Example response: + +```json +{ + "queues": { + "default": { + "backlog": 0, + "latency": 0 + } + } +} +``` + +## Get the current Process Metrics + +List information about all the Sidekiq workers registered to process your queues. + +``` +GET /sidekiq/process_metrics +``` + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/process_metrics +``` + +Example response: + +```json +{ + "processes": [ + { + "hostname": "local.host", + "pid": 5649, + "tag": "gitlab", + "started_at": "2016-06-14T10:45:07.159-05:00", + "queues": [ + "post_receive", + "mailers", + "archive_repo", + "system_hook", + "project_web_hook", + "gitlab_shell", + "incoming_email", + "runner", + "common", + "default" + ], + "labels": [], + "concurrency": 25, + "busy": 0 + } + ] +} +``` + +## Get the current Job Statistics + +List information about the jobs that Sidekiq has performed. + +``` +GET /sidekiq/job_stats +``` + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/job_stats +``` + +Example response: + +```json +{ + "jobs": { + "processed": 2, + "failed": 0, + "enqueued": 0 + } +} +``` + +## Get a compound response of all the previously mentioned metrics + +List all the currently available information about Sidekiq. + +``` +GET /sidekiq/compound_metrics +``` + +```bash +curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/compound_metrics +``` + +Example response: + +```json +{ + "queues": { + "default": { + "backlog": 0, + "latency": 0 + } + }, + "processes": [ + { + "hostname": "local.host", + "pid": 5649, + "tag": "gitlab", + "started_at": "2016-06-14T10:45:07.159-05:00", + "queues": [ + "post_receive", + "mailers", + "archive_repo", + "system_hook", + "project_web_hook", + "gitlab_shell", + "incoming_email", + "runner", + "common", + "default" + ], + "labels": [], + "concurrency": 25, + "busy": 0 + } + ], + "jobs": { + "processed": 2, + "failed": 0, + "enqueued": 0 + } +} +``` + diff --git a/lib/api/api.rb b/lib/api/api.rb index 6cd909f6115..51ddd0dbfc4 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -59,5 +59,6 @@ module API mount ::API::Licenses mount ::API::Subscriptions mount ::API::Gitignores + mount ::API::SidekiqMetrics end end diff --git a/lib/api/sidekiq_metrics.rb b/lib/api/sidekiq_metrics.rb new file mode 100644 index 00000000000..f99cdb7a948 --- /dev/null +++ b/lib/api/sidekiq_metrics.rb @@ -0,0 +1,90 @@ +require 'sidekiq/api' + +module API + class SidekiqMetrics < Grape::API + before { authenticated_as_admin! } + + helpers do + def queue_metrics + Sidekiq::Queue.all.each_with_object({}) do |queue, hash| + hash[queue.name] = { + backlog: queue.size, + latency: queue.latency.to_i + } + end + end + + def process_metrics + Sidekiq::ProcessSet.new.map do |process| + { + hostname: process['hostname'], + pid: process['pid'], + tag: process['tag'], + started_at: Time.at(process['started_at']), + queues: process['queues'], + labels: process['labels'], + concurrency: process['concurrency'], + busy: process['busy'] + } + end + end + + def job_stats + stats = Sidekiq::Stats.new + { + processed: stats.processed, + failed: stats.failed, + enqueued: stats.enqueued + } + end + end + + # Get Sidekiq Queue metrics + # + # Parameters: + # None + # + # Example: + # GET /sidekiq/queue_metrics + # + get 'sidekiq/queue_metrics' do + { queues: queue_metrics } + end + + # Get Sidekiq Process metrics + # + # Parameters: + # None + # + # Example: + # GET /sidekiq/process_metrics + # + get 'sidekiq/process_metrics' do + { processes: process_metrics } + end + + # Get Sidekiq Job statistics + # + # Parameters: + # None + # + # Example: + # GET /sidekiq/job_stats + # + get 'sidekiq/job_stats' do + { jobs: job_stats } + end + + # Get Sidekiq Compound metrics. Includes all previous metrics + # + # Parameters: + # None + # + # Example: + # GET /sidekiq/compound_metrics + # + get 'sidekiq/compound_metrics' do + { queues: queue_metrics, processes: process_metrics, jobs: job_stats } + end + end +end \ No newline at end of file diff --git a/spec/requests/api/sidekiq_metrics_spec.rb b/spec/requests/api/sidekiq_metrics_spec.rb new file mode 100644 index 00000000000..e65890167bb --- /dev/null +++ b/spec/requests/api/sidekiq_metrics_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe API::SidekiqMetrics, api: true do + include ApiHelpers + + let(:admin) { create(:user, :admin) } + + describe 'GET sidekiq/*' do + it 'defines the `queue_metrics` endpoint' do + get api('/sidekiq/queue_metrics', admin) + + expect(response.status).to eq(200) + expect(json_response).to be_a Hash + end + + it 'defines the `process_metrics` endpoint' do + get api('/sidekiq/process_metrics', admin) + + expect(response.status).to eq(200) + expect(json_response['processes']).to be_an Array + end + + it 'defines the `job_stats` endpoint' do + get api('/sidekiq/job_stats', admin) + + expect(response.status).to eq(200) + expect(json_response).to be_a Hash + end + + it 'defines the `compound_metrics` endpoint' do + get api('/sidekiq/compound_metrics', admin) + + expect(response.status).to eq(200) + expect(json_response).to be_a Hash + expect(json_response['queues']).to be_a Hash + expect(json_response['processes']).to be_an Array + expect(json_response['jobs']).to be_a Hash + end + end +end \ No newline at end of file From 6023dc356a421462a1d00333b5893116e64cfd33 Mon Sep 17 00:00:00 2001 From: Patricio Cano <patricio@gitlab.com> Date: Tue, 14 Jun 2016 16:46:53 +0000 Subject: [PATCH 238/318] Revert "Added API endpoint for Sidekiq Metrics" This reverts commit 06e0ef07bcb92925e6819cbe1e33cdcf645b736b --- doc/api/README.md | 1 - doc/api/sidekiq_metrics.md | 152 ---------------------- lib/api/api.rb | 1 - lib/api/sidekiq_metrics.rb | 90 ------------- spec/requests/api/sidekiq_metrics_spec.rb | 40 ------ 5 files changed, 284 deletions(-) delete mode 100644 doc/api/sidekiq_metrics.md delete mode 100644 lib/api/sidekiq_metrics.rb delete mode 100644 spec/requests/api/sidekiq_metrics_spec.rb diff --git a/doc/api/README.md b/doc/api/README.md index 6042ef7637c..e3fc5a09f21 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -31,7 +31,6 @@ following locations: - [Services](services.md) - [Session](session.md) - [Settings](settings.md) -- [Sidekiq Metrics](sidekiq_metrics.md) - [System Hooks](system_hooks.md) - [Tags](tags.md) - [Users](users.md) diff --git a/doc/api/sidekiq_metrics.md b/doc/api/sidekiq_metrics.md deleted file mode 100644 index de627c1a969..00000000000 --- a/doc/api/sidekiq_metrics.md +++ /dev/null @@ -1,152 +0,0 @@ -# Sidekiq Metrics - ->**Note:** This endpoint is only available on GitLab 8.9 and above. - -This API endpoint allows you to retrieve some information about the current state -of Sidekiq, it's jobs, queues, and processes. - -## Get the current Queue Metrics - -List information about all the registered queues, their backlog and their -latency. - -``` -GET /sidekiq/queue_metrics -``` - -```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/queue_metrics -``` - -Example response: - -```json -{ - "queues": { - "default": { - "backlog": 0, - "latency": 0 - } - } -} -``` - -## Get the current Process Metrics - -List information about all the Sidekiq workers registered to process your queues. - -``` -GET /sidekiq/process_metrics -``` - -```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/process_metrics -``` - -Example response: - -```json -{ - "processes": [ - { - "hostname": "local.host", - "pid": 5649, - "tag": "gitlab", - "started_at": "2016-06-14T10:45:07.159-05:00", - "queues": [ - "post_receive", - "mailers", - "archive_repo", - "system_hook", - "project_web_hook", - "gitlab_shell", - "incoming_email", - "runner", - "common", - "default" - ], - "labels": [], - "concurrency": 25, - "busy": 0 - } - ] -} -``` - -## Get the current Job Statistics - -List information about the jobs that Sidekiq has performed. - -``` -GET /sidekiq/job_stats -``` - -```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/job_stats -``` - -Example response: - -```json -{ - "jobs": { - "processed": 2, - "failed": 0, - "enqueued": 0 - } -} -``` - -## Get a compound response of all the previously mentioned metrics - -List all the currently available information about Sidekiq. - -``` -GET /sidekiq/compound_metrics -``` - -```bash -curl -H "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v3/sidekiq/compound_metrics -``` - -Example response: - -```json -{ - "queues": { - "default": { - "backlog": 0, - "latency": 0 - } - }, - "processes": [ - { - "hostname": "local.host", - "pid": 5649, - "tag": "gitlab", - "started_at": "2016-06-14T10:45:07.159-05:00", - "queues": [ - "post_receive", - "mailers", - "archive_repo", - "system_hook", - "project_web_hook", - "gitlab_shell", - "incoming_email", - "runner", - "common", - "default" - ], - "labels": [], - "concurrency": 25, - "busy": 0 - } - ], - "jobs": { - "processed": 2, - "failed": 0, - "enqueued": 0 - } -} -``` - diff --git a/lib/api/api.rb b/lib/api/api.rb index 51ddd0dbfc4..6cd909f6115 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -59,6 +59,5 @@ module API mount ::API::Licenses mount ::API::Subscriptions mount ::API::Gitignores - mount ::API::SidekiqMetrics end end diff --git a/lib/api/sidekiq_metrics.rb b/lib/api/sidekiq_metrics.rb deleted file mode 100644 index f99cdb7a948..00000000000 --- a/lib/api/sidekiq_metrics.rb +++ /dev/null @@ -1,90 +0,0 @@ -require 'sidekiq/api' - -module API - class SidekiqMetrics < Grape::API - before { authenticated_as_admin! } - - helpers do - def queue_metrics - Sidekiq::Queue.all.each_with_object({}) do |queue, hash| - hash[queue.name] = { - backlog: queue.size, - latency: queue.latency.to_i - } - end - end - - def process_metrics - Sidekiq::ProcessSet.new.map do |process| - { - hostname: process['hostname'], - pid: process['pid'], - tag: process['tag'], - started_at: Time.at(process['started_at']), - queues: process['queues'], - labels: process['labels'], - concurrency: process['concurrency'], - busy: process['busy'] - } - end - end - - def job_stats - stats = Sidekiq::Stats.new - { - processed: stats.processed, - failed: stats.failed, - enqueued: stats.enqueued - } - end - end - - # Get Sidekiq Queue metrics - # - # Parameters: - # None - # - # Example: - # GET /sidekiq/queue_metrics - # - get 'sidekiq/queue_metrics' do - { queues: queue_metrics } - end - - # Get Sidekiq Process metrics - # - # Parameters: - # None - # - # Example: - # GET /sidekiq/process_metrics - # - get 'sidekiq/process_metrics' do - { processes: process_metrics } - end - - # Get Sidekiq Job statistics - # - # Parameters: - # None - # - # Example: - # GET /sidekiq/job_stats - # - get 'sidekiq/job_stats' do - { jobs: job_stats } - end - - # Get Sidekiq Compound metrics. Includes all previous metrics - # - # Parameters: - # None - # - # Example: - # GET /sidekiq/compound_metrics - # - get 'sidekiq/compound_metrics' do - { queues: queue_metrics, processes: process_metrics, jobs: job_stats } - end - end -end \ No newline at end of file diff --git a/spec/requests/api/sidekiq_metrics_spec.rb b/spec/requests/api/sidekiq_metrics_spec.rb deleted file mode 100644 index e65890167bb..00000000000 --- a/spec/requests/api/sidekiq_metrics_spec.rb +++ /dev/null @@ -1,40 +0,0 @@ -require 'spec_helper' - -describe API::SidekiqMetrics, api: true do - include ApiHelpers - - let(:admin) { create(:user, :admin) } - - describe 'GET sidekiq/*' do - it 'defines the `queue_metrics` endpoint' do - get api('/sidekiq/queue_metrics', admin) - - expect(response.status).to eq(200) - expect(json_response).to be_a Hash - end - - it 'defines the `process_metrics` endpoint' do - get api('/sidekiq/process_metrics', admin) - - expect(response.status).to eq(200) - expect(json_response['processes']).to be_an Array - end - - it 'defines the `job_stats` endpoint' do - get api('/sidekiq/job_stats', admin) - - expect(response.status).to eq(200) - expect(json_response).to be_a Hash - end - - it 'defines the `compound_metrics` endpoint' do - get api('/sidekiq/compound_metrics', admin) - - expect(response.status).to eq(200) - expect(json_response).to be_a Hash - expect(json_response['queues']).to be_a Hash - expect(json_response['processes']).to be_an Array - expect(json_response['jobs']).to be_a Hash - end - end -end \ No newline at end of file From 0b1eea8f970e170cd4314ec75aba9707ffa98127 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 14 Jun 2016 17:53:26 +0100 Subject: [PATCH 239/318] Removed console.log Uses outerWidth instead of width --- app/assets/javascripts/layout_nav.js.coffee | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/layout_nav.js.coffee b/app/assets/javascripts/layout_nav.js.coffee index f02292dd4f3..f8f0aea427e 100644 --- a/app/assets/javascripts/layout_nav.js.coffee +++ b/app/assets/javascripts/layout_nav.js.coffee @@ -18,13 +18,8 @@ $ -> $('.scrolling-tabs').on 'scroll', (event) -> $this = $(this) - $el = $(event.target) currentPosition = $this.scrollLeft() - size = bp.getBreakpointSize() - controlBtnWidth = $('.controls').width() - maxPosition = ($this.get(0).scrollWidth - $this.parent().width()) - 1 - # maxPosition += controlBtnWidth if size isnt 'xs' and $('.nav-control').length - console.log maxPosition, currentPosition + maxPosition = $this.prop('scrollWidth') - $this.outerWidth() - $el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) - $el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) + $this.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) + $this.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) From 72d87d6c16fc2bea4bef7a8ec246db598f4d19cf Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran <alfredo@gitlab.com> Date: Mon, 13 Jun 2016 13:09:50 -0500 Subject: [PATCH 240/318] Turn off handlers before binding events --- app/assets/javascripts/issuable.js.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/issuable.js.coffee b/app/assets/javascripts/issuable.js.coffee index c2447120033..edeb43eae1a 100644 --- a/app/assets/javascripts/issuable.js.coffee +++ b/app/assets/javascripts/issuable.js.coffee @@ -99,11 +99,11 @@ issuable_created = false $('#filter_issue_search').val($('#issue_search').val()) initChecks: -> - $('.check_all_issues').on 'click', -> + $('.check_all_issues').off('click').on 'click', -> $('.selected_issue').prop('checked', @checked) Issuable.checkChanged() - $('.selected_issue').on 'change', Issuable.checkChanged + $('.selected_issue').off('change').on 'change', Issuable.checkChanged updateStateFilters: -> stateFilters = $('.issues-state-filters, .dropdown-menu-sort') From a0d58a83e0f1980defe560991648c6f5f1ba0d0b Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran <alfredo@gitlab.com> Date: Mon, 13 Jun 2016 13:30:14 -0500 Subject: [PATCH 241/318] Reinitialize checkboxes to toggle event bindings --- app/assets/javascripts/issuable.js.coffee | 5 +++-- app/assets/javascripts/issues-bulk-assignment.js.coffee | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/issuable.js.coffee b/app/assets/javascripts/issuable.js.coffee index edeb43eae1a..31a6bb15d52 100644 --- a/app/assets/javascripts/issuable.js.coffee +++ b/app/assets/javascripts/issuable.js.coffee @@ -99,11 +99,12 @@ issuable_created = false $('#filter_issue_search').val($('#issue_search').val()) initChecks: -> - $('.check_all_issues').off('click').on 'click', -> + $('.check_all_issues').off('click').on('click', -> $('.selected_issue').prop('checked', @checked) Issuable.checkChanged() + ) - $('.selected_issue').off('change').on 'change', Issuable.checkChanged + $('.selected_issue').off('change').on('change', Issuable.checkChanged) updateStateFilters: -> stateFilters = $('.issues-state-filters, .dropdown-menu-sort') diff --git a/app/assets/javascripts/issues-bulk-assignment.js.coffee b/app/assets/javascripts/issues-bulk-assignment.js.coffee index 9dc3529a17f..b454f9389dd 100644 --- a/app/assets/javascripts/issues-bulk-assignment.js.coffee +++ b/app/assets/javascripts/issues-bulk-assignment.js.coffee @@ -9,6 +9,9 @@ class @IssuableBulkActions @bindEvents() + # Fixes bulk-assign not working when navigating through pages + Issuable.initChecks(); + getElement: (selector) -> @container.find selector From fef47d234a283d400c73e0581a00a59a0c770e2c Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran <alfredo@gitlab.com> Date: Tue, 14 Jun 2016 00:46:56 -0500 Subject: [PATCH 242/318] Use Turbolink instead of ajax --- app/assets/javascripts/issuable.js.coffee | 52 +---------------------- 1 file changed, 1 insertion(+), 51 deletions(-) diff --git a/app/assets/javascripts/issuable.js.coffee b/app/assets/javascripts/issuable.js.coffee index 31a6bb15d52..d0901be1509 100644 --- a/app/assets/javascripts/issuable.js.coffee +++ b/app/assets/javascripts/issuable.js.coffee @@ -56,13 +56,6 @@ issuable_created = false Issuable.filterResults $('.filter-form') $('.js-label-select').trigger('update.label') - toggleLabelFilters: -> - $filteredLabels = $('.filtered-labels') - if $filteredLabels.find('.label-row').length > 0 - $filteredLabels.removeClass('hidden') - else - $filteredLabels.addClass('hidden') - filterResults: (form) => formData = form.serialize() @@ -71,32 +64,8 @@ issuable_created = false issuesUrl = formAction issuesUrl += ("#{if formAction.indexOf('?') < 0 then '?' else '&'}") issuesUrl += formData - $.ajax - type: 'GET' - url: formAction - data: formData - complete: -> - $('.issues-holder, .merge-requests-holder').css('opacity', '1.0') - success: (data) -> - $('.issues-holder, .merge-requests-holder').html(data.html) - # Change url so if user reload a page - search results are saved - history.replaceState {page: issuesUrl}, document.title, issuesUrl - Issuable.reload() - Issuable.updateStateFilters() - $filteredLabels = $('.filtered-labels') - if typeof Issuable.labelRow is 'function' - $filteredLabels.html(Issuable.labelRow(data)) - - Issuable.toggleLabelFilters() - - dataType: "json" - - reload: -> - if Issuable.created - Issuable.initChecks() - - $('#filter_issue_search').val($('#issue_search').val()) + Turbolinks.visit(issuesUrl); initChecks: -> $('.check_all_issues').off('click').on('click', -> @@ -106,25 +75,6 @@ issuable_created = false $('.selected_issue').off('change').on('change', Issuable.checkChanged) - updateStateFilters: -> - stateFilters = $('.issues-state-filters, .dropdown-menu-sort') - newParams = {} - paramKeys = ['author_id', 'milestone_title', 'assignee_id', 'issue_search', 'issue_search'] - - for paramKey in paramKeys - newParams[paramKey] = gl.utils.getParameterValues(paramKey)[0] or '' - - if stateFilters.length - stateFilters.find('a').each -> - initialUrl = gl.utils.removeParamQueryString($(this).attr('href'), 'label_name[]') - labelNameValues = gl.utils.getParameterValues('label_name[]') - if labelNameValues - labelNameQueryString = ("label_name[]=#{value}" for value in labelNameValues).join('&') - newUrl = "#{gl.utils.mergeUrlParams(newParams, initialUrl)}&#{labelNameQueryString}" - else - newUrl = gl.utils.mergeUrlParams(newParams, initialUrl) - $(this).attr 'href', newUrl - checkChanged: -> checked_issues = $('.selected_issue:checked') if checked_issues.length > 0 From 363fa59712ae58bc032f24496b49398ea9a65e87 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran <alfredo@gitlab.com> Date: Tue, 14 Jun 2016 06:21:24 -0500 Subject: [PATCH 243/318] Update tests to make it work with Turbolinks approach --- spec/features/issues/filter_by_labels_spec.rb | 20 ++++++++-------- spec/features/issues/filter_issues_spec.rb | 23 +++++++++++-------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/spec/features/issues/filter_by_labels_spec.rb b/spec/features/issues/filter_by_labels_spec.rb index 16c619c9288..5ea02b8d39c 100644 --- a/spec/features/issues/filter_by_labels_spec.rb +++ b/spec/features/issues/filter_by_labels_spec.rb @@ -56,8 +56,9 @@ feature 'Issue filtering by Labels', feature: true do end it 'should remove label "bug"' do - first('.js-label-filter-remove').click - expect(find('.filtered-labels')).to have_no_content "bug" + find('.js-label-filter-remove').click + wait_for_ajax + expect(find('.filtered-labels', visible: false)).to have_no_content "bug" end end @@ -142,7 +143,8 @@ feature 'Issue filtering by Labels', feature: true do end it 'should remove label "enhancement"' do - first('.js-label-filter-remove').click + find('.js-label-filter-remove', match: :first).click + wait_for_ajax expect(find('.filtered-labels')).to have_no_content "enhancement" end end @@ -179,6 +181,7 @@ feature 'Issue filtering by Labels', feature: true do before do page.within '.labels-filter' do click_button 'Label' + wait_for_ajax click_link 'bug' find('.dropdown-menu-close').click end @@ -189,14 +192,11 @@ feature 'Issue filtering by Labels', feature: true do end it 'should allow user to remove filtered labels' do - page.within '.filtered-labels' do - first('.js-label-filter-remove').click - expect(page).not_to have_content 'bug' - end + first('.js-label-filter-remove').click + wait_for_ajax - page.within '.labels-filter' do - expect(page).not_to have_content 'bug' - end + expect(find('.filtered-labels', visible: false)).not_to have_content 'bug' + expect(find('.labels-filter')).not_to have_content 'bug' end end diff --git a/spec/features/issues/filter_issues_spec.rb b/spec/features/issues/filter_issues_spec.rb index 1f0594e6b02..4bcb105b17d 100644 --- a/spec/features/issues/filter_issues_spec.rb +++ b/spec/features/issues/filter_issues_spec.rb @@ -1,6 +1,7 @@ require 'rails_helper' describe 'Filter issues', feature: true do + include WaitForAjax let!(:project) { create(:project) } let!(:user) { create(:user)} @@ -21,7 +22,7 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-user-link', text: user.username).click - sleep 2 + wait_for_ajax end context 'assignee', js: true do @@ -53,7 +54,7 @@ describe 'Filter issues', feature: true do find('.milestone-filter .dropdown-content a', text: milestone.title).click - sleep 2 + wait_for_ajax end context 'milestone', js: true do @@ -80,23 +81,21 @@ describe 'Filter issues', feature: true do before do visit namespace_project_issues_path(project.namespace, project) find('.js-label-select').click + wait_for_ajax end it 'should filter by any label' do find('.dropdown-menu-labels a', text: 'Any Label').click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - sleep 2 + wait_for_ajax - page.within '.labels-filter' do - expect(page).to have_content 'Any Label' - end - expect(find('.js-label-select .dropdown-toggle-text')).to have_content('Any Label') + expect(find('.labels-filter')).to have_content 'Label' end it 'should filter by no label' do find('.dropdown-menu-labels a', text: 'No Label').click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - sleep 2 + wait_for_ajax page.within '.labels-filter' do expect(page).to have_content 'No Label' @@ -122,14 +121,14 @@ describe 'Filter issues', feature: true do find('.dropdown-menu-user-link', text: user.username).click - sleep 2 + wait_for_ajax find('.js-label-select').click find('.dropdown-menu-labels .dropdown-content a', text: label.title).click page.first('.labels-filter .dropdown-title .dropdown-menu-close-icon').click - sleep 2 + wait_for_ajax end context 'assignee and label', js: true do @@ -276,9 +275,12 @@ describe 'Filter issues', feature: true do it 'should be able to filter and sort issues' do click_button 'Label' + wait_for_ajax page.within '.labels-filter' do click_link 'bug' end + find('.dropdown-menu-close-icon').click + wait_for_ajax page.within '.issues-list' do expect(page).to have_selector('.issue', count: 2) @@ -288,6 +290,7 @@ describe 'Filter issues', feature: true do page.within '.dropdown-menu-sort' do click_link 'Oldest created' end + wait_for_ajax page.within '.issues-list' do expect(first('.issue')).to have_content('Frontend') From de3a9d7ef1c1c9bfdfe1cf7e984ed20c3dcce3ba Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 8 Jun 2016 18:46:17 -0500 Subject: [PATCH 244/318] Update CHANGELOG --- CHANGELOG | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 162c6723dd2..780cd8e91e3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -81,6 +81,11 @@ v 8.9.0 (unreleased) - Instrument private methods and private instance methods by default instead just public methods - Updated the allocations Gem to version 1.0.5 - The background sampler now ignores classes without names + - Update design for `Close` buttons + - New custom icons for navigation + - Horizontally scrolling navigation on project, group, and profile settings pages + - Hide global side navigation by default + - Remove tanuki logo from side navigation; center on top nav v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From 2d29ca85e86e6865f08540d351902641a0d0b4d5 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Tue, 14 Jun 2016 14:37:41 -0300 Subject: [PATCH 245/318] Fix notes on confidential issues through JSON to users without access --- app/finders/notes_finder.rb | 2 +- spec/finders/notes_finder_spec.rb | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/finders/notes_finder.rb b/app/finders/notes_finder.rb index ee14ac60fb4..0b7832e6583 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -12,7 +12,7 @@ class NotesFinder when "commit" project.notes.for_commit_id(target_id).non_diff_notes when "issue" - project.issues.find(target_id).notes.inc_author + project.issues.visible_to_user(current_user).find(target_id).notes.inc_author when "merge_request" project.merge_requests.find(target_id).mr_and_commit_notes.inc_author when "snippet", "project_snippet" diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index c83824b900d..639b28d49ee 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -34,5 +34,21 @@ describe NotesFinder do notes = NotesFinder.new.execute(project, user, params) expect(notes).to eq([note1]) end + + context 'confidential issue notes' do + let(:confidential_issue) { create(:issue, :confidential, project: project, author: user) } + let!(:confidential_note) { create(:note, noteable: confidential_issue, project: confidential_issue.project) } + + let(:params) { { target_id: confidential_issue.id, target_type: 'issue', last_fetched_at: 1.hour.ago.to_i } } + + it 'returns notes if user can see the issue' do + expect(NotesFinder.new.execute(project, user, params)).to eq([confidential_note]) + end + + it 'raises an error if user can not see the issue' do + user = create(:user) + expect { NotesFinder.new.execute(project, user, params) }.to raise_error(ActiveRecord::RecordNotFound) + end + end end end From 7ae0df8faeeabbcfb07d9f834c132ad5c56c7f74 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Tue, 14 Jun 2016 14:39:41 -0300 Subject: [PATCH 246/318] Update CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 74fb52d3aeb..a3d5a36bf57 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -79,6 +79,7 @@ v 8.9.0 (unreleased) - Allow users to create confidential issues in private projects - Measure CPU time for instrumented methods - Instrument private methods and private instance methods by default instead just public methods + - Only show notes through JSON on confidential issues that the user has access to v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds From b4f0dcc7df786fd01f6fc357d102bc834433a3c7 Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Tue, 14 Jun 2016 15:57:46 -0600 Subject: [PATCH 247/318] Fix #18604, logo in header wasn't an anchor link. --- app/assets/javascripts/logo.js.coffee | 6 ------ app/views/layouts/header/_default.html.haml | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/assets/javascripts/logo.js.coffee b/app/assets/javascripts/logo.js.coffee index 9fdc27a9787..dc2590a0355 100644 --- a/app/assets/javascripts/logo.js.coffee +++ b/app/assets/javascripts/logo.js.coffee @@ -42,9 +42,3 @@ work = -> $(document).on('page:fetch', start) $(document).on('page:change', stop) - -$ -> - # Make logo clickable as part of a workaround for Safari visited - # link behaviour (See !2690). - $('#logo').on 'click', -> - Turbolinks.visit('/') diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index ad30a367fc5..4170b937dd6 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -51,7 +51,7 @@ %h1.title= title .header-logo - #logo + = link_to root_path, class: 'home', title: 'Dashboard', id: 'logo' do = brand_header_logo = yield :header_content From fbb06125938a6c4dd1a046b6d08ad37040f62672 Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Fri, 10 Jun 2016 16:07:05 +0530 Subject: [PATCH 248/318] Don't allow clicking on "Setup New U2F Device" unless an authenticator app has been set up. - Also change the help message to indicate that an authenticator app is now a prerequisite for U2F. --- .../profiles/two_factor_auths/show.html.haml | 6 +++--- app/views/u2f/_register.html.haml | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index ce76cb73c9c..593be2617c1 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -51,9 +51,9 @@ %p Use a hardware device to add the second factor of authentication. %p - As U2F devices are only supported by a few browsers, it's recommended that you set up a - two-factor authentication app as well as a U2F device so you'll always be able to log in - using an unsupported browser. + As U2F devices are only supported by a few browsers, we require that you set up a + two-factor authentication app before a U2F device. That way you'll always be able to + log in - even when you're using an unsupported browser. .col-lg-9 %p - if @registration_key_handles.present? diff --git a/app/views/u2f/_register.html.haml b/app/views/u2f/_register.html.haml index 46af591fc43..cbb8dfb7829 100644 --- a/app/views/u2f/_register.html.haml +++ b/app/views/u2f/_register.html.haml @@ -4,11 +4,18 @@ %p Your browser doesn't support U2F. Please use Google Chrome desktop (version 41 or newer). %script#js-register-u2f-setup{ type: "text/template" } - .row.append-bottom-10 - .col-md-3 - %a#js-setup-u2f-device.btn.btn-info{ href: 'javascript:void(0)' } Setup New U2F Device - .col-md-9 - %p Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left. + - if current_user.two_factor_otp_enabled? + .row.append-bottom-10 + .col-md-3 + %button#js-setup-u2f-device.btn.btn-info Setup New U2F Device + .col-md-9 + %p Your U2F device needs to be set up. Plug it in (if not already) and click the button on the left. + - else + .row.append-bottom-10 + .col-md-3 + %button#js-setup-u2f-device.btn.btn-info{ disabled: true } Setup New U2F Device + .col-md-9 + %p.text-warning You need to register a two-factor authentication app before you can set up a U2F device. %script#js-register-u2f-in-progress{ type: "text/template" } %p Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now. From 298eb449f3365a8f753dc6c08b51e2a8cb6e972c Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Tue, 14 Jun 2016 09:48:52 +0530 Subject: [PATCH 249/318] Update `u2f_spec` to cover U2F being disabled until authenticator is set up. --- spec/features/u2f_spec.rb | 59 ++++++++++++++++----------------------- 1 file changed, 24 insertions(+), 35 deletions(-) diff --git a/spec/features/u2f_spec.rb b/spec/features/u2f_spec.rb index 366a90228b1..14613754f74 100644 --- a/spec/features/u2f_spec.rb +++ b/spec/features/u2f_spec.rb @@ -12,39 +12,24 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: describe "registration" do let(:user) { create(:user) } - before { login_as(user) } + + before do + login_as(user) + user.update_attribute(:otp_required_for_login, true) + end describe 'when 2FA via OTP is disabled' do - it 'allows registering a new device' do + before { user.update_attribute(:otp_required_for_login, false) } + + it 'does not allow registering a new device' do visit profile_account_path click_on 'Enable Two-Factor Authentication' - register_u2f_device - - expect(page.body).to match('Your U2F device was registered') - end - - it 'allows registering more than one device' do - visit profile_account_path - - # First device - click_on 'Enable Two-Factor Authentication' - register_u2f_device - expect(page.body).to match('Your U2F device was registered') - - # Second device - click_on 'Manage Two-Factor Authentication' - register_u2f_device - expect(page.body).to match('Your U2F device was registered') - click_on 'Manage Two-Factor Authentication' - - expect(page.body).to match('You have 2 U2F devices registered') + expect(page).to have_button('Setup New U2F Device', disabled: true) end end describe 'when 2FA via OTP is enabled' do - before { user.update_attributes(otp_required_for_login: true) } - it 'allows registering a new device' do visit profile_account_path click_on 'Manage Two-Factor Authentication' @@ -67,7 +52,6 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: click_on 'Manage Two-Factor Authentication' register_u2f_device expect(page.body).to match('Your U2F device was registered') - click_on 'Manage Two-Factor Authentication' expect(page.body).to match('You have 2 U2F devices registered') end @@ -76,15 +60,16 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it 'allows the same device to be registered for multiple users' do # First user visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' u2f_device = register_u2f_device expect(page.body).to match('Your U2F device was registered') logout # Second user - login_as(:user) + user = login_as(:user) + user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' register_u2f_device(u2f_device) expect(page.body).to match('Your U2F device was registered') @@ -94,7 +79,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: context "when there are form errors" do it "doesn't register the device if there are errors" do visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' # Have the "u2f device" respond with bad data page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") @@ -109,7 +94,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it "allows retrying registration" do visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' # Failed registration page.execute_script("u2f.register = function(_,_,_,callback) { callback('bad response'); };") @@ -133,8 +118,9 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: before do # Register and logout login_as(user) + user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' @u2f_device = register_u2f_device logout end @@ -154,7 +140,7 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: describe "when 2FA via OTP is enabled" do it "allows logging in with the U2F device" do - user.update_attributes(otp_required_for_login: true) + user.update_attribute(:otp_required_for_login, true) login_with(user) @u2f_device.respond_to_u2f_authentication @@ -171,8 +157,9 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it "does not allow logging in with that particular device" do # Register current user with the different U2F device current_user = login_as(:user) + current_user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' register_u2f_device logout @@ -191,8 +178,9 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: it "allows logging in with that particular device" do # Register current user with the same U2F device current_user = login_as(:user) + current_user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' register_u2f_device(@u2f_device) logout @@ -227,8 +215,9 @@ feature 'Using U2F (Universal 2nd Factor) Devices for Authentication', feature: before do login_as(user) + user.update_attribute(:otp_required_for_login, true) visit profile_account_path - click_on 'Enable Two-Factor Authentication' + click_on 'Manage Two-Factor Authentication' register_u2f_device end From d8a531687c8aaef67d6b7586916273cf59d4b5a3 Mon Sep 17 00:00:00 2001 From: Timothy Andrew <mail@timothyandrew.net> Date: Tue, 14 Jun 2016 10:47:00 +0530 Subject: [PATCH 250/318] Fix teaspoon spec. - We added a `current_user.two_factor_via_otp?` check to the view. When rendering the view via the teaspoon fixture, `current_user` is `nil`. --- spec/javascripts/fixtures/u2f/register.html.haml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spec/javascripts/fixtures/u2f/register.html.haml b/spec/javascripts/fixtures/u2f/register.html.haml index 393c0613fd3..5ed51be689c 100644 --- a/spec/javascripts/fixtures/u2f/register.html.haml +++ b/spec/javascripts/fixtures/u2f/register.html.haml @@ -1 +1,2 @@ -= render partial: "u2f/register", locals: { create_u2f_profile_two_factor_auth_path: '/profile/two_factor_auth/create_u2f' } +- user = FactoryGirl.build(:user, :two_factor_via_otp) += render partial: "u2f/register", locals: { create_u2f_profile_two_factor_auth_path: '/profile/two_factor_auth/create_u2f', current_user: user } From 6d9ed76419641c48ab18f07e08e640008c72b906 Mon Sep 17 00:00:00 2001 From: Mark Pundsack <markpundsack@users.noreply.github.com> Date: Tue, 14 Jun 2016 21:26:38 -0700 Subject: [PATCH 251/318] Document CI_BUILD_TOKEN --- doc/ci/variables/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index 70fb81492d6..137b080a8f7 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -34,6 +34,7 @@ The `API_TOKEN` will take the Secure Variable value: `SECURE`. | **CI_BUILD_ID** | all | The unique id of the current build that GitLab CI uses internally | | **CI_BUILD_REPO** | all | The URL to clone the Git repository | | **CI_BUILD_TRIGGERED** | 0.5 | The flag to indicate that build was [triggered] | +| **CI_BUILD_TOKEN** | 1.2 | Token used for authenticating with the GitLab Container Registry | | **CI_PROJECT_ID** | all | The unique id of the current project that GitLab CI uses internally | | **CI_PROJECT_DIR** | all | The full path where the repository is cloned and where the build is ran | @@ -50,6 +51,7 @@ export CI_BUILD_TAG="1.0.0" export CI_BUILD_NAME="spec:other" export CI_BUILD_STAGE="test" export CI_BUILD_TRIGGERED="true" +export CI_BUILD_TOKEN="abcde-1234ABCD5678ef" export CI_PROJECT_DIR="/builds/gitlab-org/gitlab-ce" export CI_PROJECT_ID="34" export CI_SERVER="yes" From 52a2b8a41001b35338bcc6d92fa4c9679c4196a8 Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Tue, 14 Jun 2016 16:48:39 +0200 Subject: [PATCH 252/318] Include user relationship when retrieving award_emoji Avoiding N+1 when showing grouped awards and when calculating participants for awardable entities --- CHANGELOG | 1 + app/models/concerns/awardable.rb | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 884b9f6e9fd..d458db93ff7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -90,6 +90,7 @@ v 8.9.0 (unreleased) - Horizontally scrolling navigation on project, group, and profile settings pages - Hide global side navigation by default - Remove tanuki logo from side navigation; center on top nav + - Include user relationships when retrieving award_emoji v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/app/models/concerns/awardable.rb b/app/models/concerns/awardable.rb index aa4b4201250..539c7c31e30 100644 --- a/app/models/concerns/awardable.rb +++ b/app/models/concerns/awardable.rb @@ -5,7 +5,7 @@ module Awardable has_many :award_emoji, as: :awardable, dependent: :destroy if self < Participable - participant :award_emoji + participant :award_emoji_with_associations end end @@ -34,8 +34,12 @@ module Awardable end end + def award_emoji_with_associations + award_emoji.includes(:user) + end + def grouped_awards(with_thumbs: true) - awards = award_emoji.group_by(&:name) + awards = award_emoji_with_associations.group_by(&:name) if with_thumbs awards[AwardEmoji::UPVOTE_NAME] ||= [] From e412c1f25e9abfebaa7b5669e1acf7f26a66d722 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 08:53:11 +0100 Subject: [PATCH 253/318] Fixed issue with move dropdown not being searchable Closes #18634 --- app/assets/javascripts/issuable_form.js.coffee | 4 ++++ app/controllers/autocomplete_controller.rb | 1 + 2 files changed, 5 insertions(+) diff --git a/app/assets/javascripts/issuable_form.js.coffee b/app/assets/javascripts/issuable_form.js.coffee index 898506fde32..5b7a4831dfc 100644 --- a/app/assets/javascripts/issuable_form.js.coffee +++ b/app/assets/javascripts/issuable_form.js.coffee @@ -102,6 +102,10 @@ class @IssuableForm return { results: data } + data: (query) -> + { + search: query + } formatResult: (project) -> project.name_with_namespace formatSelection: (project) -> diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index 3865b2d61fd..c89678cf2d8 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -35,6 +35,7 @@ class AutocompleteController < ApplicationController project = Project.find_by_id(params[:project_id]) projects = current_user.authorized_projects + projects = projects.search(params[:search]) if params[:search].present? projects = projects.select do |project| current_user.can?(:admin_issue, project) end From 0daa6b4321839513ec443547236be614c4696177 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Fri, 6 May 2016 12:52:04 +0200 Subject: [PATCH 254/318] Add docs for assigning labels/milestone when moving issue [ci skip] --- doc/api/issues.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/doc/api/issues.md b/doc/api/issues.md index 3e78149f442..58e080a7791 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -360,6 +360,9 @@ target project is not found, error `404` is returned. If the target project equals the source project or the user has insufficient permissions to move an issue, error `400` together with an explaining error message is returned. +If a given label and/or milestone with the same name also exists in the target +project, it will then be assigned to the issue that is being moved. + ``` POST /projects/:id/issues/:issue_id/move ``` From 080cbcabd96b27e300b3a85e11399d3f4449d335 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <zegerjan@gitlab.com> Date: Thu, 9 Jun 2016 10:54:43 +0200 Subject: [PATCH 255/318] Seed Award Emoji while seeding the database --- db/fixtures/development/15_award_emoji.rb | 27 +++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 db/fixtures/development/15_award_emoji.rb diff --git a/db/fixtures/development/15_award_emoji.rb b/db/fixtures/development/15_award_emoji.rb new file mode 100644 index 00000000000..3ac3fea43a5 --- /dev/null +++ b/db/fixtures/development/15_award_emoji.rb @@ -0,0 +1,27 @@ +Gitlab::Seeder.quiet do + emoji = Gitlab::AwardEmoji.emojis.keys + + Issue.all.each do |issue| + project = issue.project + + project.team.users.sample(2) do |user| + issue.create_award_emoji(emoji.sample, user) + + note = issue.notes.sample + note.create_award_emoji(emoji.sample, user) + print '.' + end + end + + MergeRequest.all.each do |mr| + project = mr.project + + project.team.users.sample(2).each do |user| + mr.create_award_emoji(emoji.sample, user) + + note = mr.notes.sample + note.create_award_emoji(emoji.sample, user) + print '.' + end + end +end From bd3324cca2fefb00e421c4dc1fb276db540d08bd Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <zegerjan@gitlab.com> Date: Fri, 10 Jun 2016 12:43:11 +0200 Subject: [PATCH 256/318] Skip system notes from receiving award emoji --- db/fixtures/development/15_award_emoji.rb | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/db/fixtures/development/15_award_emoji.rb b/db/fixtures/development/15_award_emoji.rb index 3ac3fea43a5..e195da931ed 100644 --- a/db/fixtures/development/15_award_emoji.rb +++ b/db/fixtures/development/15_award_emoji.rb @@ -7,8 +7,11 @@ Gitlab::Seeder.quiet do project.team.users.sample(2) do |user| issue.create_award_emoji(emoji.sample, user) - note = issue.notes.sample - note.create_award_emoji(emoji.sample, user) + issue.notes.sample(2).each do |note| + next if note.system? + note.create_award_emoji(emoji.sample, user) + end + print '.' end end @@ -19,8 +22,11 @@ Gitlab::Seeder.quiet do project.team.users.sample(2).each do |user| mr.create_award_emoji(emoji.sample, user) - note = mr.notes.sample - note.create_award_emoji(emoji.sample, user) + mr.notes.sample(2).each do |note| + next if note.system? + note.create_award_emoji(emoji.sample, user) + end + print '.' end end From 7dc08033b9cbd2876ed444878525d9f8bbfcef64 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <zegerjan@gitlab.com> Date: Fri, 10 Jun 2016 13:31:42 +0200 Subject: [PATCH 257/318] Near half of the Issues get Award Emoji when seeding --- db/fixtures/development/15_award_emoji.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/db/fixtures/development/15_award_emoji.rb b/db/fixtures/development/15_award_emoji.rb index e195da931ed..1db10f5f0b6 100644 --- a/db/fixtures/development/15_award_emoji.rb +++ b/db/fixtures/development/15_award_emoji.rb @@ -1,10 +1,10 @@ Gitlab::Seeder.quiet do emoji = Gitlab::AwardEmoji.emojis.keys - - Issue.all.each do |issue| + issue_count = Issue.count / 2 + Issue.order("RANDOM()").limit(Issue.count / 2).each do |issue| project = issue.project - project.team.users.sample(2) do |user| + project.team.users.sample(2).each do |user| issue.create_award_emoji(emoji.sample, user) issue.notes.sample(2).each do |note| @@ -16,7 +16,7 @@ Gitlab::Seeder.quiet do end end - MergeRequest.all.each do |mr| + MergeRequest.order("RANDOM()").limit(MergeRequest.count / 2).each do |mr| project = mr.project project.team.users.sample(2).each do |user| From 2541e50d7ce64bb402d06dc9d75567b78282c7b7 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 12:03:49 +0200 Subject: [PATCH 258/318] Improve validations --- app/models/deployment.rb | 6 +-- app/models/environment.rb | 4 +- db/migrate/20160610204157_add_deployments.rb | 12 +++--- db/migrate/20160610204158_add_environments.rb | 2 +- db/schema.rb | 40 +++++++++---------- spec/factories/deployments.rb | 1 + 6 files changed, 31 insertions(+), 34 deletions(-) diff --git a/app/models/deployment.rb b/app/models/deployment.rb index d9006b70e30..cda922080cb 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -1,15 +1,13 @@ class Deployment < ActiveRecord::Base include InternalId - belongs_to :project - belongs_to :environment + belongs_to :project, required: true + belongs_to :environment, required: true belongs_to :user belongs_to :deployable, polymorphic: true validates :sha, presence: true validates :ref, presence: true - validates :project, associated: true - validates :environment, associated: true delegate :name, to: :environment, prefix: true diff --git a/app/models/environment.rb b/app/models/environment.rb index ac6f8c81e01..7986a2529df 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -1,5 +1,5 @@ class Environment < ActiveRecord::Base - belongs_to :project + belongs_to :project, required: true has_many :deployments @@ -10,8 +10,6 @@ class Environment < ActiveRecord::Base format: { with: Gitlab::Regex.environment_name_regex, message: Gitlab::Regex.environment_name_regex_message } - validates :project, associated: true - def last_deployment deployments.last end diff --git a/db/migrate/20160610204157_add_deployments.rb b/db/migrate/20160610204157_add_deployments.rb index 557b78f91e1..cfa842daa6d 100644 --- a/db/migrate/20160610204157_add_deployments.rb +++ b/db/migrate/20160610204157_add_deployments.rb @@ -6,12 +6,12 @@ class AddDeployments < ActiveRecord::Migration def change create_table :deployments, force: true do |t| - t.integer :iid - t.integer :project_id - t.integer :environment_id - t.string :ref - t.boolean :tag - t.string :sha + t.integer :iid, null: false + t.integer :project_id, null: false + t.integer :environment_id, null: false + t.string :ref, null: false + t.boolean :tag, null: false + t.string :sha, null: false t.integer :user_id t.integer :deployable_id t.string :deployable_type diff --git a/db/migrate/20160610204158_add_environments.rb b/db/migrate/20160610204158_add_environments.rb index 8311fd39b01..e1c71d173c4 100644 --- a/db/migrate/20160610204158_add_environments.rb +++ b/db/migrate/20160610204158_add_environments.rb @@ -6,7 +6,7 @@ class AddEnvironments < ActiveRecord::Migration def change create_table :environments, force: true do |t| - t.integer :project_id + t.integer :project_id, null: false t.string :name, null: false t.datetime :created_at t.datetime :updated_at diff --git a/db/schema.rb b/db/schema.rb index 388b259277a..3ac64e888ee 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -384,12 +384,12 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "deploy_keys_projects", ["project_id"], name: "index_deploy_keys_projects_on_project_id", using: :btree create_table "deployments", force: :cascade do |t| - t.integer "iid" - t.integer "project_id" - t.integer "environment_id" - t.string "ref" - t.boolean "tag" - t.string "sha" + t.integer "iid", null: false + t.integer "project_id", null: false + t.integer "environment_id", null: false + t.string "ref", null: false + t.boolean "tag", null: false + t.string "sha", null: false t.integer "user_id" t.integer "deployable_id" t.string "deployable_type" @@ -413,7 +413,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "emails", ["user_id"], name: "index_emails_on_user_id", using: :btree create_table "environments", force: :cascade do |t| - t.integer "project_id" + t.integer "project_id", null: false t.string "name", null: false t.datetime "created_at" t.datetime "updated_at" @@ -777,37 +777,37 @@ ActiveRecord::Schema.define(version: 20160610301627) do t.datetime "created_at" t.datetime "updated_at" t.integer "creator_id" - t.boolean "issues_enabled", default: true, null: false - t.boolean "merge_requests_enabled", default: true, null: false - t.boolean "wiki_enabled", default: true, null: false + t.boolean "issues_enabled", default: true, null: false + t.boolean "merge_requests_enabled", default: true, null: false + t.boolean "wiki_enabled", default: true, null: false t.integer "namespace_id" - t.boolean "snippets_enabled", default: true, null: false + t.boolean "snippets_enabled", default: true, null: false t.datetime "last_activity_at" t.string "import_url" - t.integer "visibility_level", default: 0, null: false - t.boolean "archived", default: false, null: false + t.integer "visibility_level", default: 0, null: false + t.boolean "archived", default: false, null: false t.string "avatar" t.string "import_status" t.float "repository_size", default: 0.0 - t.integer "star_count", default: 0, null: false + t.integer "star_count", default: 0, null: false t.string "import_type" t.string "import_source" t.integer "commit_count", default: 0 t.text "import_error" t.integer "ci_id" - t.boolean "builds_enabled", default: true, null: false - t.boolean "shared_runners_enabled", default: true, null: false + t.boolean "builds_enabled", default: true, null: false + t.boolean "shared_runners_enabled", default: true, null: false t.string "runners_token" t.string "build_coverage_regex" - t.boolean "build_allow_git_fetch", default: true, null: false - t.integer "build_timeout", default: 3600, null: false + t.boolean "build_allow_git_fetch", default: true, null: false + t.integer "build_timeout", default: 3600, null: false t.boolean "pending_delete", default: false - t.boolean "public_builds", default: true, null: false + t.boolean "public_builds", default: true, null: false t.integer "pushes_since_gc", default: 0 t.boolean "last_repository_check_failed" t.datetime "last_repository_check_at" t.boolean "container_registry_enabled" - t.boolean "only_allow_merge_if_build_succeeds", default: false, null: false + t.boolean "only_allow_merge_if_build_succeeds", default: false, null: false t.boolean "has_external_issue_tracker" end diff --git a/spec/factories/deployments.rb b/spec/factories/deployments.rb index f335a111a7d..82591604fcb 100644 --- a/spec/factories/deployments.rb +++ b/spec/factories/deployments.rb @@ -2,6 +2,7 @@ FactoryGirl.define do factory :deployment, class: Deployment do sha '97de212e80737a608d939f648d959671fb0a0142' ref 'master' + tag false environment factory: :environment From 00526440092bb82beb86b87376dd1ea6178bf05f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 12:07:06 +0200 Subject: [PATCH 259/318] Improve forms and specs --- app/controllers/projects/environments_controller.rb | 4 ++-- app/models/ability.rb | 4 ++-- app/views/projects/environments/_form.html.haml | 7 +++++++ app/views/projects/environments/new.html.haml | 13 +++---------- app/views/projects/environments/show.html.haml | 2 +- spec/features/environments_spec.rb | 12 +++++++----- 6 files changed, 22 insertions(+), 20 deletions(-) create mode 100644 app/views/projects/environments/_form.html.haml diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index 1f9f676c63b..4b433796161 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -10,7 +10,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def show - @deployments = environment.deployments.order(id: :desc).page(params[:page]).per(30) + @deployments = environment.deployments.order(id: :desc).page(params[:page]) end def new @@ -44,6 +44,6 @@ class Projects::EnvironmentsController < Projects::ApplicationController end def environment - @environment ||= project.environments.find_by!(id: params[:id]) + @environment ||= project.environments.find(params[:id]) end end diff --git a/app/models/ability.rb b/app/models/ability.rb index 32e45674682..734b152605b 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -251,7 +251,8 @@ class Ability :create_container_image, :update_container_image, :create_environment, - :create_deployment + :create_deployment, + :update_deployment ] end @@ -270,7 +271,6 @@ class Ability :push_code_to_protected_branches, :update_project_snippet, :update_environment, - :update_deployment, :admin_milestone, :admin_project_snippet, :admin_project_member, diff --git a/app/views/projects/environments/_form.html.haml b/app/views/projects/environments/_form.html.haml new file mode 100644 index 00000000000..c07f4bd510c --- /dev/null +++ b/app/views/projects/environments/_form.html.haml @@ -0,0 +1,7 @@ += form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { class: 'col-lg-9' } do |f| + = form_errors(@environment) + .form-group + = f.label :name, 'Name', class: 'label-light' + = f.text_field :name, required: true, class: 'form-control' + = f.submit 'Create environment', class: 'btn btn-create' + = link_to 'Cancel', namespace_project_environments_path(@project.namespace, @project), class: 'btn btn-cancel' diff --git a/app/views/projects/environments/new.html.haml b/app/views/projects/environments/new.html.haml index 533f624c4e2..54465828ba9 100644 --- a/app/views/projects/environments/new.html.haml +++ b/app/views/projects/environments/new.html.haml @@ -1,16 +1,9 @@ -- @no_container = true -- page_title "New Environment" -= render "projects/pipelines/head" +- page_title 'New Environment' .row.prepend-top-default.append-bottom-default .col-lg-3 %h4.prepend-top-0 New Environment + %p Environments allow you to track deployments of your application - = form_for @environment, url: namespace_project_environments_path(@project.namespace, @project), html: { class: "col-lg-9" } do |f| - = form_errors(@environment) - .form-group - = f.label :name, 'Environment name', class: 'label-light' - = f.text_field :name, required: true, class: 'form-control' - = f.submit 'Create environment', class: 'btn btn-create' - = link_to "Cancel", namespace_project_environments_path(@project.namespace, @project), class: "btn btn-cancel" + = render 'form' diff --git a/app/views/projects/environments/show.html.haml b/app/views/projects/environments/show.html.haml index b41b1651a81..069b77b5adf 100644 --- a/app/views/projects/environments/show.html.haml +++ b/app/views/projects/environments/show.html.haml @@ -10,7 +10,7 @@ .col-md-3 .nav-controls - if can?(current_user, :update_environment, @environment) - = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :delete + = link_to 'Destroy', namespace_project_environment_path(@project.namespace, @project, @environment), data: { confirm: 'Are you sure you want to delete this environment?' }, class: 'btn btn-danger', method: :delete - if @deployments.blank? %ul.content-list.environments diff --git a/spec/features/environments_spec.rb b/spec/features/environments_spec.rb index b73bb30e216..8002b793986 100644 --- a/spec/features/environments_spec.rb +++ b/spec/features/environments_spec.rb @@ -107,7 +107,7 @@ describe 'Environments' do context 'for valid name' do before do - fill_in('Environment name', with: 'production') + fill_in('Name', with: 'production') click_on 'Create environment' end @@ -118,7 +118,7 @@ describe 'Environments' do context 'for invalid name' do before do - fill_in('Environment name', with: 'name with spaces') + fill_in('Name', with: 'name with spaces') click_on 'Create environment' end @@ -140,7 +140,9 @@ describe 'Environments' do before { visit namespace_project_environment_path(project.namespace, project, environment) } - context 'when logged as developer' do + context 'when logged as master' do + let(:role) { :master } + before { click_link 'Destroy' } it 'does not have environment' do @@ -148,8 +150,8 @@ describe 'Environments' do end end - context 'when logged as reporter' do - let(:role) { :reporter } + context 'when logged as developer' do + let(:role) { :developer } it 'does not have a Destroy link' do expect(page).not_to have_link('Destroy') From b2df11856144e91c84f51e8934e10e21f4f3fa70 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <zegerjan@gitlab.com> Date: Wed, 15 Jun 2016 12:10:41 +0200 Subject: [PATCH 260/318] Random selection now also works for MySQL --- db/fixtures/development/15_award_emoji.rb | 6 +++--- lib/gitlab/database.rb | 4 ++++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/db/fixtures/development/15_award_emoji.rb b/db/fixtures/development/15_award_emoji.rb index 1db10f5f0b6..baac32f2d10 100644 --- a/db/fixtures/development/15_award_emoji.rb +++ b/db/fixtures/development/15_award_emoji.rb @@ -1,7 +1,7 @@ Gitlab::Seeder.quiet do emoji = Gitlab::AwardEmoji.emojis.keys - issue_count = Issue.count / 2 - Issue.order("RANDOM()").limit(Issue.count / 2).each do |issue| + + Issue.order(Gitlab::Database.random).limit(Issue.count / 2).each do |issue| project = issue.project project.team.users.sample(2).each do |user| @@ -16,7 +16,7 @@ Gitlab::Seeder.quiet do end end - MergeRequest.order("RANDOM()").limit(MergeRequest.count / 2).each do |mr| + MergeRequest.order(Gitlab::Database.random).limit(MergeRequest.count / 2).each do |mr| project = mr.project project.team.users.sample(2).each do |user| diff --git a/lib/gitlab/database.rb b/lib/gitlab/database.rb index 04fa6a3a5de..d76ecb54017 100644 --- a/lib/gitlab/database.rb +++ b/lib/gitlab/database.rb @@ -30,6 +30,10 @@ module Gitlab order end + def self.random + Gitlab::Database.postgresql? ? "RANDOM()" : "RAND()" + end + def true_value if Gitlab::Database.postgresql? "'t'" From 18fd2ccb8b9b60e2acd6782a4160f85d3ee6c95f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 12:12:26 +0200 Subject: [PATCH 261/318] Improve cyclomatic of ability::allowed --- app/models/ability.rb | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/app/models/ability.rb b/app/models/ability.rb index 734b152605b..8d76e8efa13 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -9,7 +9,6 @@ class Ability when CommitStatus then commit_status_abilities(user, subject) when Project then project_abilities(user, subject) when Issue then issue_abilities(user, subject) - when ExternalIssue then external_issue_abilities(user, subject) when Note then note_abilities(user, subject) when ProjectSnippet then project_snippet_abilities(user, subject) when PersonalSnippet then personal_snippet_abilities(user, subject) @@ -18,9 +17,8 @@ class Ability when Namespace then namespace_abilities(user, subject) when GroupMember then group_member_abilities(user, subject) when ProjectMember then project_member_abilities(user, subject) - when Deployment then deployment_abilities(user, subject) - when Environment then environment_abilities(user, subject) when User then user_abilities + when ExternalIssue, Deployment, Environment then project_abilities(user, subject.project) else [] end.concat(global_abilities(user)) end @@ -523,18 +521,6 @@ class Ability end end - def external_issue_abilities(user, subject) - project_abilities(user, subject.project) - end - - def deployment_abilities(user, subject) - project_abilities(user, subject.project) - end - - def environment_abilities(user, subject) - project_abilities(user, subject.project) - end - private def restricted_public_level? From 32a400aa14a0f2b2245251cb831fdc688917b4c1 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 12:24:47 +0200 Subject: [PATCH 262/318] Make environments_spec more feature-spec --- spec/features/environments_spec.rb | 113 ++++++++++++++--------------- 1 file changed, 56 insertions(+), 57 deletions(-) diff --git a/spec/features/environments_spec.rb b/spec/features/environments_spec.rb index 8002b793986..40fea5211e9 100644 --- a/spec/features/environments_spec.rb +++ b/spec/features/environments_spec.rb @@ -1,109 +1,105 @@ require 'spec_helper' -describe 'Environments' do - include GitlabRoutingHelper +feature 'Environments', feature: true do + given(:project) { create(:empty_project) } + given(:user) { create(:user) } + given(:role) { :developer } - let(:project) { create(:empty_project) } - let(:user) { create(:user) } - let(:role) { :developer } - - before do + background do login_as(user) project.team << [user, role] end - describe 'GET /:project/environments' do - subject { visit namespace_project_environments_path(project.namespace, project) } + describe 'when showing environments' do + given!(:environment) { } + given!(:deployment) { } + + before do + visit namespace_project_environments_path(project.namespace, project) + end context 'without environments' do - it 'does show no environments' do - subject - + scenario 'does show no environments' do expect(page).to have_content('No environments to show') end end context 'with environments' do - let!(:environment) { create(:environment, project: project) } - - it 'does show environment name' do - subject + given(:environment) { create(:environment, project: project) } + scenario 'does show environment name' do expect(page).to have_link(environment.name) end context 'without deployments' do - it 'does show no deployments' do - subject - + scenario 'does show no deployments' do expect(page).to have_content('No deployments yet') end end context 'with deployments' do - let!(:deployment) { create(:deployment, environment: environment) } - - it 'does show deployment SHA' do - subject + given(:deployment) { create(:deployment, environment: environment) } + scenario 'does show deployment SHA' do expect(page).to have_link(deployment.short_sha) end end end - it 'does have a New environment button' do - subject - + scenario 'does have a New environment button' do expect(page).to have_link('New environment') end end - describe 'GET /:project/environments/:id' do - let(:environment) { create(:environment, project: project) } + describe 'when showing the environment' do + given(:environment) { create(:environment, project: project) } + given!(:deployment) { } - subject { visit namespace_project_environment_path(project.namespace, project, environment) } + before do + visit namespace_project_environment_path(project.namespace, project, environment) + end context 'without deployments' do - it 'does show no deployments' do - subject - + scenario 'does show no deployments' do expect(page).to have_content('No deployments for') end end context 'with deployments' do - let!(:deployment) { create(:deployment, environment: environment) } + given(:deployment) { create(:deployment, environment: environment) } - before { subject } - - it 'does show deployment SHA' do + scenario 'does show deployment SHA' do expect(page).to have_link(deployment.short_sha) end - it 'does not show a retry button for deployment without build' do + scenario 'does not show a retry button for deployment without build' do expect(page).not_to have_link('Retry') end context 'with build' do - let(:build) { create(:ci_build, project: project) } - let(:deployment) { create(:deployment, environment: environment, deployable: build) } + given(:build) { create(:ci_build, project: project) } + given(:deployment) { create(:deployment, environment: environment, deployable: build) } - it 'does show build name' do + scenario 'does show build name' do expect(page).to have_link("#{build.name} (##{build.id})") end - it 'does show retry button' do + scenario 'does show retry button' do expect(page).to have_link('Retry') end end end end - describe 'POST /:project/environments' do - before { visit namespace_project_environments_path(project.namespace, project) } + describe 'when creating a new environment' do + before do + visit namespace_project_environments_path(project.namespace, project) + end context 'when logged as developer' do - before { click_link 'New environment' } + before do + click_link 'New environment' + end context 'for valid name' do before do @@ -111,7 +107,7 @@ describe 'Environments' do click_on 'Create environment' end - it 'does create a new pipeline' do + scenario 'does create a new pipeline' do expect(page).to have_content('production') end end @@ -122,38 +118,41 @@ describe 'Environments' do click_on 'Create environment' end - it { expect(page).to have_content('Name can contain only letters') } + scenario 'does show errors' do + expect(page).to have_content('Name can contain only letters') + end end end context 'when logged as reporter' do - let(:role) { :reporter } + given(:role) { :reporter } - it 'does not have a New environment link' do + scenario 'does not have a New environment link' do expect(page).not_to have_link('New environment') end end end - describe 'DELETE /:project/environments/:id' do - let(:environment) { create(:environment, project: project) } + describe 'when deleting existing environment' do + given(:environment) { create(:environment, project: project) } - before { visit namespace_project_environment_path(project.namespace, project, environment) } + before do + visit namespace_project_environment_path(project.namespace, project, environment) + end context 'when logged as master' do - let(:role) { :master } + given(:role) { :master } - before { click_link 'Destroy' } - - it 'does not have environment' do + scenario 'does delete environment' do + click_link 'Destroy' expect(page).not_to have_link(environment.name) end end context 'when logged as developer' do - let(:role) { :developer } + given(:role) { :developer } - it 'does not have a Destroy link' do + scenario 'does not have a Destroy link' do expect(page).not_to have_link('Destroy') end end From 2bed8db99567292bd619ddd9ec8158f1ed7b54e6 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 12:24:53 +0200 Subject: [PATCH 263/318] Add CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index e71a154d1d5..77fee01f66f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -24,6 +24,7 @@ v 8.9.0 (unreleased) - Fix 404 page when viewing TODOs that contain milestones or labels in different projects - Redesign navigation for project pages - Fix groups API to list only user's accessible projects + - Add Environments and Deployments - Redesign account and email confirmation emails - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 From aa35abf9bf7ad528369bd8b54796f38cf68dfd96 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 11:34:11 +0100 Subject: [PATCH 264/318] Added test to dropdown search --- spec/features/issues/move_spec.rb | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index c7019c5aea1..7773c486b4e 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -26,6 +26,7 @@ feature 'issue move to another project' do context 'user has permission to move issue' do let!(:mr) { create(:merge_request, source_project: old_project) } let(:new_project) { create(:project) } + let(:new_project_search) { create(:project) } let(:text) { 'Text with !1' } let(:cross_reference) { old_project.to_reference } @@ -47,6 +48,21 @@ feature 'issue move to another project' do expect(page).to have_content(issue.title) end + scenario 'searching project dropdown', js: true do + new_project_search.team << [user, :reporter] + + page.within '.js-move-dropdown' do + first('.select2-choice').click + end + + fill_in('s2id_autogen2_search', with: new_project_search.name) + + page.within '.select2-drop' do + expect(page).to have_content(new_project_search.name) + expect(page).not_to have_content(new_project.name) + end + end + context 'user does not have permission to move the issue to a project', js: true do let!(:private_project) { create(:project, :private) } let(:another_project) { create(:project) } From f30d1fdf94a373649b2b570bbd6d77cbe817ebe0 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 12:53:10 +0200 Subject: [PATCH 265/318] Add support for Docker Registry manifest v1 --- CHANGELOG | 1 + .../container_registry/_tag.html.haml | 14 ++- lib/container_registry/blob.rb | 2 +- lib/container_registry/client.rb | 4 +- lib/container_registry/tag.rb | 14 ++- .../container_registry/tag_manifest_1.json | 32 ++++++ spec/lib/container_registry/tag_spec.rb | 101 ++++++++++++------ 7 files changed, 130 insertions(+), 38 deletions(-) create mode 100644 spec/fixtures/container_registry/tag_manifest_1.json diff --git a/CHANGELOG b/CHANGELOG index 6f29b578a95..8abefd618d0 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -29,6 +29,7 @@ v 8.9.0 (unreleased) - Fix groups API to list only user's accessible projects - Redesign account and email confirmation emails - Don't fail builds for projects that are deleted + - Support Docker Registry manifest v1 - `git clone https://host/namespace/project` now works, in addition to using the `.git` suffix - Bump nokogiri to 1.6.8 - Use gitlab-shell v3.0.0 diff --git a/app/views/projects/container_registry/_tag.html.haml b/app/views/projects/container_registry/_tag.html.haml index 4e9f936539b..d5fa07fd180 100644 --- a/app/views/projects/container_registry/_tag.html.haml +++ b/app/views/projects/container_registry/_tag.html.haml @@ -9,11 +9,19 @@ - else \- %td - = number_to_human_size(tag.total_size) - · - = pluralize(tag.layers.size, "layer") + - if tag.total_size + = number_to_human_size(tag.total_size) + · + = pluralize(tag.layers.size, "layer") + - else + .light + \- %td + - if tag.created_at = time_ago_in_words(tag.created_at) + - else + .light + \- - if can?(current_user, :update_container_image, @project) %td.content .controls.hidden-xs.pull-right diff --git a/lib/container_registry/blob.rb b/lib/container_registry/blob.rb index 4e20dc4f875..eb5a2596177 100644 --- a/lib/container_registry/blob.rb +++ b/lib/container_registry/blob.rb @@ -18,7 +18,7 @@ module ContainerRegistry end def digest - config['digest'] + config['digest'] || config['blobSum'] end def type diff --git a/lib/container_registry/client.rb b/lib/container_registry/client.rb index 4d726692f45..e0b3f14d384 100644 --- a/lib/container_registry/client.rb +++ b/lib/container_registry/client.rb @@ -47,7 +47,9 @@ module ContainerRegistry conn.request :json conn.headers['Accept'] = MANIFEST_VERSION - conn.response :json, content_type: /\bjson$/ + conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+prettyjws' + conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v1+json' + conn.response :json, content_type: 'application/vnd.docker.distribution.manifest.v2+json' if options[:user] && options[:password] conn.request(:basic_auth, options[:user].to_s, options[:password].to_s) diff --git a/lib/container_registry/tag.rb b/lib/container_registry/tag.rb index 43f8d6dc8c2..7a0929d774e 100644 --- a/lib/container_registry/tag.rb +++ b/lib/container_registry/tag.rb @@ -12,6 +12,14 @@ module ContainerRegistry manifest.present? end + def v1? + manifest && manifest['schemaVersion'] == 1 + end + + def v2? + manifest && manifest['schemaVersion'] == 2 + end + def manifest return @manifest if defined?(@manifest) @@ -57,7 +65,9 @@ module ContainerRegistry return @layers if defined?(@layers) return unless manifest - @layers = manifest['layers'].map do |layer| + layers = manifest['layers'] || manifest['fsLayers'] + + @layers = layers.map do |layer| repository.blob(layer) end end @@ -65,7 +75,7 @@ module ContainerRegistry def total_size return unless layers - layers.map(&:size).sum + layers.map(&:size).sum if v2? end def delete diff --git a/spec/fixtures/container_registry/tag_manifest_1.json b/spec/fixtures/container_registry/tag_manifest_1.json new file mode 100644 index 00000000000..d09ede5bea7 --- /dev/null +++ b/spec/fixtures/container_registry/tag_manifest_1.json @@ -0,0 +1,32 @@ +{ + "schemaVersion": 1, + "name": "library/alpine", + "tag": "2.6", + "architecture": "amd64", + "fsLayers": [ + { + "blobSum": "sha256:2a3ebcb7fbcc29bf40c4f62863008bb573acdea963454834d9483b3e5300c45d" + } + ], + "history": [ + { + "v1Compatibility": "{\"id\":\"dd807873c9a21bcc82e30317c283e6601d7e19f5cf7867eec34cdd1aeb3f099e\",\"created\":\"2016-01-18T18:32:39.162138276Z\",\"container\":\"556a728876db7b0e621adc029c87c649d32520804f8f15defd67bb070dc1a88d\",\"container_config\":{\"Hostname\":\"556a728876db\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":[\"/bin/sh\",\"-c\",\"#(nop) ADD file:7dee8a455bcc39013aa168d27ece9227aad155adbaacbd153d94ca60113f59fc in /\"],\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"docker_version\":\"1.8.3\",\"config\":{\"Hostname\":\"556a728876db\",\"Domainname\":\"\",\"User\":\"\",\"AttachStdin\":false,\"AttachStdout\":false,\"AttachStderr\":false,\"Tty\":false,\"OpenStdin\":false,\"StdinOnce\":false,\"Env\":null,\"Cmd\":null,\"Image\":\"\",\"Volumes\":null,\"WorkingDir\":\"\",\"Entrypoint\":null,\"OnBuild\":null,\"Labels\":null},\"architecture\":\"amd64\",\"os\":\"linux\",\"Size\":4501436}" + } + ], + "signatures": [ + { + "header": { + "jwk": { + "crv": "P-256", + "kid": "4MZL:Z5ZP:2RPA:Q3TD:QOHA:743L:EM2G:QY6Q:ZJCX:BSD7:CRYC:LQ6T", + "kty": "EC", + "x": "qmWOaxPUk7QsE5iTPdeG1e9yNE-wranvQEnWzz9FhWM", + "y": "WeeBpjTOYnTNrfCIxtFY5qMrJNNk9C1vc5ryxbbMD_M" + }, + "alg": "ES256" + }, + "signature": "0zmjTJ4m21yVwAeteLc3SsQ0miScViCDktFPR67W-ozGjjI3iBjlDjwOl6o2sds5ZI9U6bSIKOeLDinGOhHoOQ", + "protected": "eyJmb3JtYXRMZW5ndGgiOjEzNzIsImZvcm1hdFRhaWwiOiJDbjAiLCJ0aW1lIjoiMjAxNi0wNi0xNVQxMDo0NDoxNFoifQ" + } + ] +} diff --git a/spec/lib/container_registry/tag_spec.rb b/spec/lib/container_registry/tag_spec.rb index 858cb0bb134..c7324c2bf77 100644 --- a/spec/lib/container_registry/tag_spec.rb +++ b/spec/lib/container_registry/tag_spec.rb @@ -17,46 +17,85 @@ describe ContainerRegistry::Tag do end context 'manifest processing' do - before do - stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). - with(headers: headers). - to_return( - status: 200, - body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json'), - headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' }) - end - - context '#layers' do - subject { tag.layers } - - it { expect(subject.length).to eq(1) } - end - - context '#total_size' do - subject { tag.total_size } - - it { is_expected.to eq(2319870) } - end - - context 'config processing' do + context 'schema v1' do before do - stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). - with(headers: { 'Accept' => 'application/octet-stream' }). + stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). + with(headers: headers). to_return( status: 200, - body: File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json')) + body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest_1.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v1+prettyjws' }) end - context '#config' do - subject { tag.config } + context '#layers' do + subject { tag.layers } - it { is_expected.not_to be_nil } + it { expect(subject.length).to eq(1) } end - context '#created_at' do - subject { tag.created_at } + context '#total_size' do + subject { tag.total_size } - it { is_expected.not_to be_nil } + it { is_expected.to be_nil } + end + + context 'config processing' do + context '#config' do + subject { tag.config } + + it { is_expected.to be_nil } + end + + context '#created_at' do + subject { tag.created_at } + + it { is_expected.to be_nil } + end + end + end + + context 'schema v2' do + before do + stub_request(:get, 'http://example.com/v2/group/test/manifests/tag'). + with(headers: headers). + to_return( + status: 200, + body: File.read(Rails.root + 'spec/fixtures/container_registry/tag_manifest.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' }) + end + + context '#layers' do + subject { tag.layers } + + it { expect(subject.length).to eq(1) } + end + + context '#total_size' do + subject { tag.total_size } + + it { is_expected.to eq(2319870) } + end + + context 'config processing' do + before do + stub_request(:get, 'http://example.com/v2/group/test/blobs/sha256:d7a513a663c1a6dcdba9ed832ca53c02ac2af0c333322cd6ca92936d1d9917ac'). + with(headers: { 'Accept' => 'application/octet-stream' }). + to_return( + status: 200, + body: File.read(Rails.root + 'spec/fixtures/container_registry/config_blob.json')) + end + + context '#config' do + subject { tag.config } + + it { is_expected.not_to be_nil } + end + + context '#created_at' do + subject { tag.created_at } + + it { is_expected.not_to be_nil } + end end end end From eb26755d63dbe3b4c32230a2ec8730a0d889f292 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 12:56:02 +0200 Subject: [PATCH 266/318] Create_deployment ability is need to create retry or rollback deployment --- app/models/ability.rb | 4 ++-- app/views/projects/deployments/_deployment.html.haml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/ability.rb b/app/models/ability.rb index 8d76e8efa13..ecf02a0ff6f 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -249,8 +249,7 @@ class Ability :create_container_image, :update_container_image, :create_environment, - :create_deployment, - :update_deployment + :create_deployment ] end @@ -269,6 +268,7 @@ class Ability :push_code_to_protected_branches, :update_project_snippet, :update_environment, + :update_deployment, :admin_milestone, :admin_project_snippet, :admin_project_member, diff --git a/app/views/projects/deployments/_deployment.html.haml b/app/views/projects/deployments/_deployment.html.haml index f065f28c6ee..d08dd92f1f6 100644 --- a/app/views/projects/deployments/_deployment.html.haml +++ b/app/views/projects/deployments/_deployment.html.haml @@ -14,7 +14,7 @@ #{time_ago_with_tooltip(deployment.created_at)} %td - - if can?(current_user, :update_deployment, deployment) && deployment.deployable + - if can?(current_user, :create_deployment, deployment) && deployment.deployable .pull-right = link_to retry_namespace_project_build_path(@project.namespace, @project, deployment.deployable), method: :post, class: 'btn btn-build' do - if deployment.last? From 14433b341d5e8f0e55d984b478267f5df98f42ae Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 13:00:13 +0200 Subject: [PATCH 267/318] Make `project_id` and `environment_id` nullable This is done to make belongs_to with required to properly validate association. Otherwise `ActiveRecord::StatementInvalid` is raised. --- db/migrate/20160610204157_add_deployments.rb | 4 ++-- db/schema.rb | 6 +++--- spec/services/create_deployment_service_spec.rb | 2 ++ 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/db/migrate/20160610204157_add_deployments.rb b/db/migrate/20160610204157_add_deployments.rb index cfa842daa6d..a15f6c0ea6b 100644 --- a/db/migrate/20160610204157_add_deployments.rb +++ b/db/migrate/20160610204157_add_deployments.rb @@ -7,8 +7,8 @@ class AddDeployments < ActiveRecord::Migration def change create_table :deployments, force: true do |t| t.integer :iid, null: false - t.integer :project_id, null: false - t.integer :environment_id, null: false + t.integer :project_id + t.integer :environment_id t.string :ref, null: false t.boolean :tag, null: false t.string :sha, null: false diff --git a/db/schema.rb b/db/schema.rb index 3ac64e888ee..1e8d86d0aae 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -385,8 +385,8 @@ ActiveRecord::Schema.define(version: 20160610301627) do create_table "deployments", force: :cascade do |t| t.integer "iid", null: false - t.integer "project_id", null: false - t.integer "environment_id", null: false + t.integer "project_id" + t.integer "environment_id" t.string "ref", null: false t.boolean "tag", null: false t.string "sha", null: false @@ -413,7 +413,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "emails", ["user_id"], name: "index_emails_on_user_id", using: :btree create_table "environments", force: :cascade do |t| - t.integer "project_id", null: false + t.integer "project_id" t.string "name", null: false t.datetime "created_at" t.datetime "updated_at" diff --git a/spec/services/create_deployment_service_spec.rb b/spec/services/create_deployment_service_spec.rb index b6ae3505379..654e441f3cd 100644 --- a/spec/services/create_deployment_service_spec.rb +++ b/spec/services/create_deployment_service_spec.rb @@ -10,6 +10,7 @@ describe CreateDeploymentService, services: true do let(:params) do { environment: 'production', ref: 'master', + tag: false, sha: '97de212e80737a608d939f648d959671fb0a0142', } end @@ -42,6 +43,7 @@ describe CreateDeploymentService, services: true do let(:params) do { environment: 'name with spaces', ref: 'master', + tag: false, sha: '97de212e80737a608d939f648d959671fb0a0142', } end From 78d5828fb2142c612ceba687debfb97bac2f671e Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 13:06:01 +0200 Subject: [PATCH 268/318] Fix typo --- app/services/ci/register_build_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/ci/register_build_service.rb b/app/services/ci/register_build_service.rb index 9583f6c7c49..f0ed09a629a 100644 --- a/app/services/ci/register_build_service.rb +++ b/app/services/ci/register_build_service.rb @@ -8,7 +8,7 @@ module Ci builds = if current_runner.shared? builds. - # don't run projects which have not enables shared runners + # don't run projects which have not enabled shared runners joins(:project).where(projects: { builds_enabled: true, shared_runners_enabled: true }). # this returns builds that are ordered by number of running builds From 342434c886a680bea5a4e37dbfbd8d96882ae780 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 12:40:22 +0100 Subject: [PATCH 269/318] Fixed issue with de-selecting dropdown option in issue sidebar Closes #18641 --- .../javascripts/milestone_select.js.coffee | 2 +- app/assets/javascripts/users_select.js.coffee | 2 +- spec/features/issues_spec.rb | 41 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/milestone_select.js.coffee b/app/assets/javascripts/milestone_select.js.coffee index 648e1f3bde0..b108f747bd6 100644 --- a/app/assets/javascripts/milestone_select.js.coffee +++ b/app/assets/javascripts/milestone_select.js.coffee @@ -116,7 +116,7 @@ class @MilestoneSelect .val() data = {} data[abilityName] = {} - data[abilityName].milestone_id = selected + data[abilityName].milestone_id = if selected? then selected else null $loading .fadeIn() $dropdown.trigger('loading.gl.dropdown') diff --git a/app/assets/javascripts/users_select.js.coffee b/app/assets/javascripts/users_select.js.coffee index 88246b0feb8..3dbc1d7f14f 100644 --- a/app/assets/javascripts/users_select.js.coffee +++ b/app/assets/javascripts/users_select.js.coffee @@ -31,7 +31,7 @@ class @UsersSelect assignTo = (selected) -> data = {} data[abilityName] = {} - data[abilityName].assignee_id = selected + data[abilityName].assignee_id = if selected? then selected else null $loading .fadeIn() $dropdown.trigger('loading.gl.dropdown') diff --git a/spec/features/issues_spec.rb b/spec/features/issues_spec.rb index f6fb6a72d22..65fe918e2e8 100644 --- a/spec/features/issues_spec.rb +++ b/spec/features/issues_spec.rb @@ -396,6 +396,27 @@ describe 'Issues', feature: true do expect(page).to have_content @user.name end end + + it 'allows user to unselect themselves', js: true do + issue2 = create(:issue, project: project, author: @user) + visit namespace_project_issue_path(project.namespace, project, issue2) + + page.within '.assignee' do + click_link 'Edit' + click_link @user.name + + page.within '.value' do + expect(page).to have_content @user.name + end + + click_link 'Edit' + click_link @user.name + + page.within '.value' do + expect(page).to have_content "No assignee" + end + end + end end context 'by unauthorized user' do @@ -440,6 +461,26 @@ describe 'Issues', feature: true do expect(issue.reload.milestone).to be_nil end + + it 'allows user to de-select milestone', js: true do + visit namespace_project_issue_path(project.namespace, project, issue) + + page.within('.milestone') do + click_link 'Edit' + click_link milestone.title + + page.within '.value' do + expect(page).to have_content milestone.title + end + + click_link 'Edit' + click_link milestone.title + + page.within '.value' do + expect(page).to have_content 'None' + end + end + end end context 'by unauthorized user' do From 9e487100b5e7ee7e226121fa353060d4e3dda8d4 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 14:05:10 +0200 Subject: [PATCH 270/318] Validate project and environment instead of only requiring --- app/models/deployment.rb | 4 +- db/migrate/20160610204157_add_deployments.rb | 4 +- db/schema.rb | 471 +------------------ 3 files changed, 6 insertions(+), 473 deletions(-) diff --git a/app/models/deployment.rb b/app/models/deployment.rb index cda922080cb..030648470ee 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -1,8 +1,8 @@ class Deployment < ActiveRecord::Base include InternalId - belongs_to :project, required: true - belongs_to :environment, required: true + belongs_to :project, validate: true + belongs_to :environment, validate: true belongs_to :user belongs_to :deployable, polymorphic: true diff --git a/db/migrate/20160610204157_add_deployments.rb b/db/migrate/20160610204157_add_deployments.rb index a15f6c0ea6b..cfa842daa6d 100644 --- a/db/migrate/20160610204157_add_deployments.rb +++ b/db/migrate/20160610204157_add_deployments.rb @@ -7,8 +7,8 @@ class AddDeployments < ActiveRecord::Migration def change create_table :deployments, force: true do |t| t.integer :iid, null: false - t.integer :project_id - t.integer :environment_id + t.integer :project_id, null: false + t.integer :environment_id, null: false t.string :ref, null: false t.boolean :tag, null: false t.string :sha, null: false diff --git a/db/schema.rb b/db/schema.rb index 1e8d86d0aae..603ad8a29e9 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -385,8 +385,8 @@ ActiveRecord::Schema.define(version: 20160610301627) do create_table "deployments", force: :cascade do |t| t.integer "iid", null: false - t.integer "project_id" - t.integer "environment_id" + t.integer "project_id", null: false + t.integer "environment_id", null: false t.string "ref", null: false t.boolean "tag", null: false t.string "sha", null: false @@ -628,470 +628,3 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "merge_requests", ["title"], name: "index_merge_requests_on_title", using: :btree add_index "merge_requests", ["title"], name: "index_merge_requests_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} - create_table "milestones", force: :cascade do |t| - t.string "title", null: false - t.integer "project_id", null: false - t.text "description" - t.date "due_date" - t.datetime "created_at" - t.datetime "updated_at" - t.string "state" - t.integer "iid" - end - - add_index "milestones", ["created_at", "id"], name: "index_milestones_on_created_at_and_id", using: :btree - add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} - add_index "milestones", ["due_date"], name: "index_milestones_on_due_date", using: :btree - add_index "milestones", ["project_id", "iid"], name: "index_milestones_on_project_id_and_iid", unique: true, using: :btree - add_index "milestones", ["project_id"], name: "index_milestones_on_project_id", using: :btree - add_index "milestones", ["title"], name: "index_milestones_on_title", using: :btree - add_index "milestones", ["title"], name: "index_milestones_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} - - create_table "namespaces", force: :cascade do |t| - t.string "name", null: false - t.string "path", null: false - t.integer "owner_id" - t.datetime "created_at" - t.datetime "updated_at" - t.string "type" - t.string "description", default: "", null: false - t.string "avatar" - t.boolean "share_with_group_lock", default: false - t.integer "visibility_level", default: 20, null: false - end - - add_index "namespaces", ["created_at", "id"], name: "index_namespaces_on_created_at_and_id", using: :btree - add_index "namespaces", ["name"], name: "index_namespaces_on_name", unique: true, using: :btree - add_index "namespaces", ["name"], name: "index_namespaces_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} - add_index "namespaces", ["owner_id"], name: "index_namespaces_on_owner_id", using: :btree - add_index "namespaces", ["path"], name: "index_namespaces_on_path", unique: true, using: :btree - add_index "namespaces", ["path"], name: "index_namespaces_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"} - add_index "namespaces", ["type"], name: "index_namespaces_on_type", using: :btree - add_index "namespaces", ["visibility_level"], name: "index_namespaces_on_visibility_level", using: :btree - - create_table "notes", force: :cascade do |t| - t.text "note" - t.string "noteable_type" - t.integer "author_id" - t.datetime "created_at" - t.datetime "updated_at" - t.integer "project_id" - t.string "attachment" - t.string "line_code" - t.string "commit_id" - t.integer "noteable_id" - t.boolean "system", default: false, null: false - t.text "st_diff" - t.integer "updated_by_id" - t.string "type" - end - - add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree - add_index "notes", ["commit_id"], name: "index_notes_on_commit_id", using: :btree - add_index "notes", ["created_at", "id"], name: "index_notes_on_created_at_and_id", using: :btree - add_index "notes", ["created_at"], name: "index_notes_on_created_at", using: :btree - add_index "notes", ["line_code"], name: "index_notes_on_line_code", using: :btree - add_index "notes", ["note"], name: "index_notes_on_note_trigram", using: :gin, opclasses: {"note"=>"gin_trgm_ops"} - add_index "notes", ["noteable_id", "noteable_type"], name: "index_notes_on_noteable_id_and_noteable_type", using: :btree - add_index "notes", ["noteable_type"], name: "index_notes_on_noteable_type", using: :btree - add_index "notes", ["project_id", "noteable_type"], name: "index_notes_on_project_id_and_noteable_type", using: :btree - add_index "notes", ["project_id"], name: "index_notes_on_project_id", using: :btree - add_index "notes", ["updated_at"], name: "index_notes_on_updated_at", using: :btree - - create_table "notification_settings", force: :cascade do |t| - t.integer "user_id", null: false - t.integer "source_id" - t.string "source_type" - t.integer "level", default: 0, null: false - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - add_index "notification_settings", ["source_id", "source_type"], name: "index_notification_settings_on_source_id_and_source_type", using: :btree - add_index "notification_settings", ["user_id", "source_id", "source_type"], name: "index_notifications_on_user_id_and_source_id_and_source_type", unique: true, using: :btree - add_index "notification_settings", ["user_id"], name: "index_notification_settings_on_user_id", using: :btree - - create_table "oauth_access_grants", force: :cascade do |t| - t.integer "resource_owner_id", null: false - t.integer "application_id", null: false - t.string "token", null: false - t.integer "expires_in", null: false - t.text "redirect_uri", null: false - t.datetime "created_at", null: false - t.datetime "revoked_at" - t.string "scopes" - end - - add_index "oauth_access_grants", ["token"], name: "index_oauth_access_grants_on_token", unique: true, using: :btree - - create_table "oauth_access_tokens", force: :cascade do |t| - t.integer "resource_owner_id" - t.integer "application_id" - t.string "token", null: false - t.string "refresh_token" - t.integer "expires_in" - t.datetime "revoked_at" - t.datetime "created_at", null: false - t.string "scopes" - end - - add_index "oauth_access_tokens", ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true, using: :btree - add_index "oauth_access_tokens", ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id", using: :btree - add_index "oauth_access_tokens", ["token"], name: "index_oauth_access_tokens_on_token", unique: true, using: :btree - - create_table "oauth_applications", force: :cascade do |t| - t.string "name", null: false - t.string "uid", null: false - t.string "secret", null: false - t.text "redirect_uri", null: false - t.string "scopes", default: "", null: false - t.datetime "created_at" - t.datetime "updated_at" - t.integer "owner_id" - t.string "owner_type" - end - - add_index "oauth_applications", ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type", using: :btree - add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree - - create_table "project_group_links", force: :cascade do |t| - t.integer "project_id", null: false - t.integer "group_id", null: false - t.datetime "created_at" - t.datetime "updated_at" - t.integer "group_access", default: 30, null: false - end - - create_table "project_import_data", force: :cascade do |t| - t.integer "project_id" - t.text "data" - t.text "encrypted_credentials" - t.string "encrypted_credentials_iv" - t.string "encrypted_credentials_salt" - end - - create_table "projects", force: :cascade do |t| - t.string "name" - t.string "path" - t.text "description" - t.datetime "created_at" - t.datetime "updated_at" - t.integer "creator_id" - t.boolean "issues_enabled", default: true, null: false - t.boolean "merge_requests_enabled", default: true, null: false - t.boolean "wiki_enabled", default: true, null: false - t.integer "namespace_id" - t.boolean "snippets_enabled", default: true, null: false - t.datetime "last_activity_at" - t.string "import_url" - t.integer "visibility_level", default: 0, null: false - t.boolean "archived", default: false, null: false - t.string "avatar" - t.string "import_status" - t.float "repository_size", default: 0.0 - t.integer "star_count", default: 0, null: false - t.string "import_type" - t.string "import_source" - t.integer "commit_count", default: 0 - t.text "import_error" - t.integer "ci_id" - t.boolean "builds_enabled", default: true, null: false - t.boolean "shared_runners_enabled", default: true, null: false - t.string "runners_token" - t.string "build_coverage_regex" - t.boolean "build_allow_git_fetch", default: true, null: false - t.integer "build_timeout", default: 3600, null: false - t.boolean "pending_delete", default: false - t.boolean "public_builds", default: true, null: false - t.integer "pushes_since_gc", default: 0 - t.boolean "last_repository_check_failed" - t.datetime "last_repository_check_at" - t.boolean "container_registry_enabled" - t.boolean "only_allow_merge_if_build_succeeds", default: false, null: false - t.boolean "has_external_issue_tracker" - end - - add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree - add_index "projects", ["builds_enabled"], name: "index_projects_on_builds_enabled", using: :btree - add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree - add_index "projects", ["created_at", "id"], name: "index_projects_on_created_at_and_id", using: :btree - add_index "projects", ["creator_id"], name: "index_projects_on_creator_id", using: :btree - add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} - add_index "projects", ["last_activity_at"], name: "index_projects_on_last_activity_at", using: :btree - add_index "projects", ["last_repository_check_failed"], name: "index_projects_on_last_repository_check_failed", using: :btree - add_index "projects", ["name"], name: "index_projects_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} - add_index "projects", ["namespace_id"], name: "index_projects_on_namespace_id", using: :btree - add_index "projects", ["path"], name: "index_projects_on_path", using: :btree - add_index "projects", ["path"], name: "index_projects_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"} - add_index "projects", ["pending_delete"], name: "index_projects_on_pending_delete", using: :btree - add_index "projects", ["runners_token"], name: "index_projects_on_runners_token", using: :btree - add_index "projects", ["star_count"], name: "index_projects_on_star_count", using: :btree - add_index "projects", ["visibility_level"], name: "index_projects_on_visibility_level", using: :btree - - create_table "protected_branches", force: :cascade do |t| - t.integer "project_id", null: false - t.string "name", null: false - t.datetime "created_at" - t.datetime "updated_at" - t.boolean "developers_can_push", default: false, null: false - end - - add_index "protected_branches", ["project_id"], name: "index_protected_branches_on_project_id", using: :btree - - create_table "releases", force: :cascade do |t| - t.string "tag" - t.text "description" - t.integer "project_id" - t.datetime "created_at" - t.datetime "updated_at" - end - - add_index "releases", ["project_id", "tag"], name: "index_releases_on_project_id_and_tag", using: :btree - add_index "releases", ["project_id"], name: "index_releases_on_project_id", using: :btree - - create_table "sent_notifications", force: :cascade do |t| - t.integer "project_id" - t.integer "noteable_id" - t.string "noteable_type" - t.integer "recipient_id" - t.string "commit_id" - t.string "reply_key", null: false - t.string "line_code" - end - - add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree - - create_table "services", force: :cascade do |t| - t.string "type" - t.string "title" - t.integer "project_id" - t.datetime "created_at" - t.datetime "updated_at" - t.boolean "active", default: false, null: false - t.text "properties" - t.boolean "template", default: false - t.boolean "push_events", default: true - t.boolean "issues_events", default: true - t.boolean "merge_requests_events", default: true - t.boolean "tag_push_events", default: true - t.boolean "note_events", default: true, null: false - t.boolean "build_events", default: false, null: false - t.string "category", default: "common", null: false - t.boolean "default", default: false - t.boolean "wiki_page_events", default: true - end - - add_index "services", ["category"], name: "index_services_on_category", using: :btree - add_index "services", ["created_at", "id"], name: "index_services_on_created_at_and_id", using: :btree - add_index "services", ["default"], name: "index_services_on_default", using: :btree - add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree - add_index "services", ["template"], name: "index_services_on_template", using: :btree - - create_table "snippets", force: :cascade do |t| - t.string "title" - t.text "content" - t.integer "author_id", null: false - t.integer "project_id" - t.datetime "created_at" - t.datetime "updated_at" - t.string "file_name" - t.string "type" - t.integer "visibility_level", default: 0, null: false - end - - add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree - add_index "snippets", ["created_at", "id"], name: "index_snippets_on_created_at_and_id", using: :btree - add_index "snippets", ["created_at"], name: "index_snippets_on_created_at", using: :btree - add_index "snippets", ["file_name"], name: "index_snippets_on_file_name_trigram", using: :gin, opclasses: {"file_name"=>"gin_trgm_ops"} - add_index "snippets", ["project_id"], name: "index_snippets_on_project_id", using: :btree - add_index "snippets", ["title"], name: "index_snippets_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} - add_index "snippets", ["updated_at"], name: "index_snippets_on_updated_at", using: :btree - add_index "snippets", ["visibility_level"], name: "index_snippets_on_visibility_level", using: :btree - - create_table "spam_logs", force: :cascade do |t| - t.integer "user_id" - t.string "source_ip" - t.string "user_agent" - t.boolean "via_api" - t.integer "project_id" - t.string "noteable_type" - t.string "title" - t.text "description" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - create_table "subscriptions", force: :cascade do |t| - t.integer "user_id" - t.integer "subscribable_id" - t.string "subscribable_type" - t.boolean "subscribed" - t.datetime "created_at" - t.datetime "updated_at" - end - - add_index "subscriptions", ["subscribable_id", "subscribable_type", "user_id"], name: "subscriptions_user_id_and_ref_fields", unique: true, using: :btree - - create_table "taggings", force: :cascade do |t| - t.integer "tag_id" - t.integer "taggable_id" - t.string "taggable_type" - t.integer "tagger_id" - t.string "tagger_type" - t.string "context" - t.datetime "created_at" - end - - add_index "taggings", ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true, using: :btree - add_index "taggings", ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context", using: :btree - - create_table "tags", force: :cascade do |t| - t.string "name" - t.integer "taggings_count", default: 0 - end - - add_index "tags", ["name"], name: "index_tags_on_name", unique: true, using: :btree - - create_table "todos", force: :cascade do |t| - t.integer "user_id", null: false - t.integer "project_id", null: false - t.integer "target_id" - t.string "target_type", null: false - t.integer "author_id" - t.integer "action", null: false - t.string "state", null: false - t.datetime "created_at" - t.datetime "updated_at" - t.integer "note_id" - t.string "commit_id" - end - - add_index "todos", ["author_id"], name: "index_todos_on_author_id", using: :btree - add_index "todos", ["commit_id"], name: "index_todos_on_commit_id", using: :btree - add_index "todos", ["note_id"], name: "index_todos_on_note_id", using: :btree - add_index "todos", ["project_id"], name: "index_todos_on_project_id", using: :btree - add_index "todos", ["state"], name: "index_todos_on_state", using: :btree - add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree - add_index "todos", ["user_id"], name: "index_todos_on_user_id", using: :btree - - create_table "u2f_registrations", force: :cascade do |t| - t.text "certificate" - t.string "key_handle" - t.string "public_key" - t.integer "counter" - t.integer "user_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - end - - add_index "u2f_registrations", ["key_handle"], name: "index_u2f_registrations_on_key_handle", using: :btree - add_index "u2f_registrations", ["user_id"], name: "index_u2f_registrations_on_user_id", using: :btree - - create_table "users", force: :cascade do |t| - t.string "email", default: "", null: false - t.string "encrypted_password", default: "", null: false - t.string "reset_password_token" - t.datetime "reset_password_sent_at" - t.datetime "remember_created_at" - t.integer "sign_in_count", default: 0 - t.datetime "current_sign_in_at" - t.datetime "last_sign_in_at" - t.string "current_sign_in_ip" - t.string "last_sign_in_ip" - t.datetime "created_at" - t.datetime "updated_at" - t.string "name" - t.boolean "admin", default: false, null: false - t.integer "projects_limit", default: 10 - t.string "skype", default: "", null: false - t.string "linkedin", default: "", null: false - t.string "twitter", default: "", null: false - t.string "authentication_token" - t.integer "theme_id", default: 1, null: false - t.string "bio" - t.integer "failed_attempts", default: 0 - t.datetime "locked_at" - t.string "username" - t.boolean "can_create_group", default: true, null: false - t.boolean "can_create_team", default: true, null: false - t.string "state" - t.integer "color_scheme_id", default: 1, null: false - t.datetime "password_expires_at" - t.integer "created_by_id" - t.datetime "last_credential_check_at" - t.string "avatar" - t.string "confirmation_token" - t.datetime "confirmed_at" - t.datetime "confirmation_sent_at" - t.string "unconfirmed_email" - t.boolean "hide_no_ssh_key", default: false - t.string "website_url", default: "", null: false - t.string "notification_email" - t.boolean "hide_no_password", default: false - t.boolean "password_automatically_set", default: false - t.string "location" - t.string "encrypted_otp_secret" - t.string "encrypted_otp_secret_iv" - t.string "encrypted_otp_secret_salt" - t.boolean "otp_required_for_login", default: false, null: false - t.text "otp_backup_codes" - t.string "public_email", default: "", null: false - t.integer "dashboard", default: 0 - t.integer "project_view", default: 0 - t.integer "consumed_timestep" - t.integer "layout", default: 0 - t.boolean "hide_project_limit", default: false - t.string "unlock_token" - t.datetime "otp_grace_period_started_at" - t.boolean "ldap_email", default: false, null: false - t.boolean "external", default: false - end - - add_index "users", ["admin"], name: "index_users_on_admin", using: :btree - add_index "users", ["authentication_token"], name: "index_users_on_authentication_token", unique: true, using: :btree - add_index "users", ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree - add_index "users", ["created_at", "id"], name: "index_users_on_created_at_and_id", using: :btree - add_index "users", ["current_sign_in_at"], name: "index_users_on_current_sign_in_at", using: :btree - add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree - add_index "users", ["email"], name: "index_users_on_email_trigram", using: :gin, opclasses: {"email"=>"gin_trgm_ops"} - add_index "users", ["name"], name: "index_users_on_name", using: :btree - add_index "users", ["name"], name: "index_users_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} - add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree - add_index "users", ["state"], name: "index_users_on_state", using: :btree - add_index "users", ["username"], name: "index_users_on_username", using: :btree - add_index "users", ["username"], name: "index_users_on_username_trigram", using: :gin, opclasses: {"username"=>"gin_trgm_ops"} - - create_table "users_star_projects", force: :cascade do |t| - t.integer "project_id", null: false - t.integer "user_id", null: false - t.datetime "created_at" - t.datetime "updated_at" - end - - add_index "users_star_projects", ["project_id"], name: "index_users_star_projects_on_project_id", using: :btree - add_index "users_star_projects", ["user_id", "project_id"], name: "index_users_star_projects_on_user_id_and_project_id", unique: true, using: :btree - add_index "users_star_projects", ["user_id"], name: "index_users_star_projects_on_user_id", using: :btree - - create_table "web_hooks", force: :cascade do |t| - t.string "url", limit: 2000 - t.integer "project_id" - t.datetime "created_at" - t.datetime "updated_at" - t.string "type", default: "ProjectHook" - t.integer "service_id" - t.boolean "push_events", default: true, null: false - t.boolean "issues_events", default: false, null: false - t.boolean "merge_requests_events", default: false, null: false - t.boolean "tag_push_events", default: false - t.boolean "note_events", default: false, null: false - t.boolean "enable_ssl_verification", default: true - t.boolean "build_events", default: false, null: false - t.boolean "wiki_page_events", default: false, null: false - t.string "token" - end - - add_index "web_hooks", ["created_at", "id"], name: "index_web_hooks_on_created_at_and_id", using: :btree - add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree - - add_foreign_key "u2f_registrations", "users" -end From a4dc5f79bf281127fe5e4ace36ac4f7664701e0b Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 14:05:26 +0200 Subject: [PATCH 271/318] Make project_id, iid unique for deployments --- db/migrate/20160610204157_add_deployments.rb | 2 +- db/schema.rb | 469 ++++++++++++++++++- 2 files changed, 469 insertions(+), 2 deletions(-) diff --git a/db/migrate/20160610204157_add_deployments.rb b/db/migrate/20160610204157_add_deployments.rb index cfa842daa6d..cb144ea8a6d 100644 --- a/db/migrate/20160610204157_add_deployments.rb +++ b/db/migrate/20160610204157_add_deployments.rb @@ -20,7 +20,7 @@ class AddDeployments < ActiveRecord::Migration end add_index :deployments, :project_id - add_index :deployments, [:project_id, :iid] + add_index :deployments, [:project_id, :iid], unique: true add_index :deployments, [:project_id, :environment_id] add_index :deployments, [:project_id, :environment_id, :iid] end diff --git a/db/schema.rb b/db/schema.rb index 603ad8a29e9..c5259b00efc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -399,7 +399,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "deployments", ["project_id", "environment_id", "iid"], name: "index_deployments_on_project_id_and_environment_id_and_iid", using: :btree add_index "deployments", ["project_id", "environment_id"], name: "index_deployments_on_project_id_and_environment_id", using: :btree - add_index "deployments", ["project_id", "iid"], name: "index_deployments_on_project_id_and_iid", using: :btree + add_index "deployments", ["project_id", "iid"], name: "index_deployments_on_project_id_and_iid", unique: true, using: :btree add_index "deployments", ["project_id"], name: "index_deployments_on_project_id", using: :btree create_table "emails", force: :cascade do |t| @@ -628,3 +628,470 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "merge_requests", ["title"], name: "index_merge_requests_on_title", using: :btree add_index "merge_requests", ["title"], name: "index_merge_requests_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} + create_table "milestones", force: :cascade do |t| + t.string "title", null: false + t.integer "project_id", null: false + t.text "description" + t.date "due_date" + t.datetime "created_at" + t.datetime "updated_at" + t.string "state" + t.integer "iid" + end + + add_index "milestones", ["created_at", "id"], name: "index_milestones_on_created_at_and_id", using: :btree + add_index "milestones", ["description"], name: "index_milestones_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} + add_index "milestones", ["due_date"], name: "index_milestones_on_due_date", using: :btree + add_index "milestones", ["project_id", "iid"], name: "index_milestones_on_project_id_and_iid", unique: true, using: :btree + add_index "milestones", ["project_id"], name: "index_milestones_on_project_id", using: :btree + add_index "milestones", ["title"], name: "index_milestones_on_title", using: :btree + add_index "milestones", ["title"], name: "index_milestones_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} + + create_table "namespaces", force: :cascade do |t| + t.string "name", null: false + t.string "path", null: false + t.integer "owner_id" + t.datetime "created_at" + t.datetime "updated_at" + t.string "type" + t.string "description", default: "", null: false + t.string "avatar" + t.boolean "share_with_group_lock", default: false + t.integer "visibility_level", default: 20, null: false + end + + add_index "namespaces", ["created_at", "id"], name: "index_namespaces_on_created_at_and_id", using: :btree + add_index "namespaces", ["name"], name: "index_namespaces_on_name", unique: true, using: :btree + add_index "namespaces", ["name"], name: "index_namespaces_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} + add_index "namespaces", ["owner_id"], name: "index_namespaces_on_owner_id", using: :btree + add_index "namespaces", ["path"], name: "index_namespaces_on_path", unique: true, using: :btree + add_index "namespaces", ["path"], name: "index_namespaces_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"} + add_index "namespaces", ["type"], name: "index_namespaces_on_type", using: :btree + add_index "namespaces", ["visibility_level"], name: "index_namespaces_on_visibility_level", using: :btree + + create_table "notes", force: :cascade do |t| + t.text "note" + t.string "noteable_type" + t.integer "author_id" + t.datetime "created_at" + t.datetime "updated_at" + t.integer "project_id" + t.string "attachment" + t.string "line_code" + t.string "commit_id" + t.integer "noteable_id" + t.boolean "system", default: false, null: false + t.text "st_diff" + t.integer "updated_by_id" + t.string "type" + end + + add_index "notes", ["author_id"], name: "index_notes_on_author_id", using: :btree + add_index "notes", ["commit_id"], name: "index_notes_on_commit_id", using: :btree + add_index "notes", ["created_at", "id"], name: "index_notes_on_created_at_and_id", using: :btree + add_index "notes", ["created_at"], name: "index_notes_on_created_at", using: :btree + add_index "notes", ["line_code"], name: "index_notes_on_line_code", using: :btree + add_index "notes", ["note"], name: "index_notes_on_note_trigram", using: :gin, opclasses: {"note"=>"gin_trgm_ops"} + add_index "notes", ["noteable_id", "noteable_type"], name: "index_notes_on_noteable_id_and_noteable_type", using: :btree + add_index "notes", ["noteable_type"], name: "index_notes_on_noteable_type", using: :btree + add_index "notes", ["project_id", "noteable_type"], name: "index_notes_on_project_id_and_noteable_type", using: :btree + add_index "notes", ["project_id"], name: "index_notes_on_project_id", using: :btree + add_index "notes", ["updated_at"], name: "index_notes_on_updated_at", using: :btree + + create_table "notification_settings", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "source_id" + t.string "source_type" + t.integer "level", default: 0, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "notification_settings", ["source_id", "source_type"], name: "index_notification_settings_on_source_id_and_source_type", using: :btree + add_index "notification_settings", ["user_id", "source_id", "source_type"], name: "index_notifications_on_user_id_and_source_id_and_source_type", unique: true, using: :btree + add_index "notification_settings", ["user_id"], name: "index_notification_settings_on_user_id", using: :btree + + create_table "oauth_access_grants", force: :cascade do |t| + t.integer "resource_owner_id", null: false + t.integer "application_id", null: false + t.string "token", null: false + t.integer "expires_in", null: false + t.text "redirect_uri", null: false + t.datetime "created_at", null: false + t.datetime "revoked_at" + t.string "scopes" + end + + add_index "oauth_access_grants", ["token"], name: "index_oauth_access_grants_on_token", unique: true, using: :btree + + create_table "oauth_access_tokens", force: :cascade do |t| + t.integer "resource_owner_id" + t.integer "application_id" + t.string "token", null: false + t.string "refresh_token" + t.integer "expires_in" + t.datetime "revoked_at" + t.datetime "created_at", null: false + t.string "scopes" + end + + add_index "oauth_access_tokens", ["refresh_token"], name: "index_oauth_access_tokens_on_refresh_token", unique: true, using: :btree + add_index "oauth_access_tokens", ["resource_owner_id"], name: "index_oauth_access_tokens_on_resource_owner_id", using: :btree + add_index "oauth_access_tokens", ["token"], name: "index_oauth_access_tokens_on_token", unique: true, using: :btree + + create_table "oauth_applications", force: :cascade do |t| + t.string "name", null: false + t.string "uid", null: false + t.string "secret", null: false + t.text "redirect_uri", null: false + t.string "scopes", default: "", null: false + t.datetime "created_at" + t.datetime "updated_at" + t.integer "owner_id" + t.string "owner_type" + end + + add_index "oauth_applications", ["owner_id", "owner_type"], name: "index_oauth_applications_on_owner_id_and_owner_type", using: :btree + add_index "oauth_applications", ["uid"], name: "index_oauth_applications_on_uid", unique: true, using: :btree + + create_table "project_group_links", force: :cascade do |t| + t.integer "project_id", null: false + t.integer "group_id", null: false + t.datetime "created_at" + t.datetime "updated_at" + t.integer "group_access", default: 30, null: false + end + + create_table "project_import_data", force: :cascade do |t| + t.integer "project_id" + t.text "data" + t.text "encrypted_credentials" + t.string "encrypted_credentials_iv" + t.string "encrypted_credentials_salt" + end + + create_table "projects", force: :cascade do |t| + t.string "name" + t.string "path" + t.text "description" + t.datetime "created_at" + t.datetime "updated_at" + t.integer "creator_id" + t.boolean "issues_enabled", default: true, null: false + t.boolean "merge_requests_enabled", default: true, null: false + t.boolean "wiki_enabled", default: true, null: false + t.integer "namespace_id" + t.boolean "snippets_enabled", default: true, null: false + t.datetime "last_activity_at" + t.string "import_url" + t.integer "visibility_level", default: 0, null: false + t.boolean "archived", default: false, null: false + t.string "avatar" + t.string "import_status" + t.float "repository_size", default: 0.0 + t.integer "star_count", default: 0, null: false + t.string "import_type" + t.string "import_source" + t.integer "commit_count", default: 0 + t.text "import_error" + t.integer "ci_id" + t.boolean "builds_enabled", default: true, null: false + t.boolean "shared_runners_enabled", default: true, null: false + t.string "runners_token" + t.string "build_coverage_regex" + t.boolean "build_allow_git_fetch", default: true, null: false + t.integer "build_timeout", default: 3600, null: false + t.boolean "pending_delete", default: false + t.boolean "public_builds", default: true, null: false + t.integer "pushes_since_gc", default: 0 + t.boolean "last_repository_check_failed" + t.datetime "last_repository_check_at" + t.boolean "container_registry_enabled" + t.boolean "only_allow_merge_if_build_succeeds", default: false, null: false + t.boolean "has_external_issue_tracker" + end + + add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree + add_index "projects", ["builds_enabled"], name: "index_projects_on_builds_enabled", using: :btree + add_index "projects", ["ci_id"], name: "index_projects_on_ci_id", using: :btree + add_index "projects", ["created_at", "id"], name: "index_projects_on_created_at_and_id", using: :btree + add_index "projects", ["creator_id"], name: "index_projects_on_creator_id", using: :btree + add_index "projects", ["description"], name: "index_projects_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} + add_index "projects", ["last_activity_at"], name: "index_projects_on_last_activity_at", using: :btree + add_index "projects", ["last_repository_check_failed"], name: "index_projects_on_last_repository_check_failed", using: :btree + add_index "projects", ["name"], name: "index_projects_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} + add_index "projects", ["namespace_id"], name: "index_projects_on_namespace_id", using: :btree + add_index "projects", ["path"], name: "index_projects_on_path", using: :btree + add_index "projects", ["path"], name: "index_projects_on_path_trigram", using: :gin, opclasses: {"path"=>"gin_trgm_ops"} + add_index "projects", ["pending_delete"], name: "index_projects_on_pending_delete", using: :btree + add_index "projects", ["runners_token"], name: "index_projects_on_runners_token", using: :btree + add_index "projects", ["star_count"], name: "index_projects_on_star_count", using: :btree + add_index "projects", ["visibility_level"], name: "index_projects_on_visibility_level", using: :btree + + create_table "protected_branches", force: :cascade do |t| + t.integer "project_id", null: false + t.string "name", null: false + t.datetime "created_at" + t.datetime "updated_at" + t.boolean "developers_can_push", default: false, null: false + end + + add_index "protected_branches", ["project_id"], name: "index_protected_branches_on_project_id", using: :btree + + create_table "releases", force: :cascade do |t| + t.string "tag" + t.text "description" + t.integer "project_id" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "releases", ["project_id", "tag"], name: "index_releases_on_project_id_and_tag", using: :btree + add_index "releases", ["project_id"], name: "index_releases_on_project_id", using: :btree + + create_table "sent_notifications", force: :cascade do |t| + t.integer "project_id" + t.integer "noteable_id" + t.string "noteable_type" + t.integer "recipient_id" + t.string "commit_id" + t.string "reply_key", null: false + t.string "line_code" + end + + add_index "sent_notifications", ["reply_key"], name: "index_sent_notifications_on_reply_key", unique: true, using: :btree + + create_table "services", force: :cascade do |t| + t.string "type" + t.string "title" + t.integer "project_id" + t.datetime "created_at" + t.datetime "updated_at" + t.boolean "active", default: false, null: false + t.text "properties" + t.boolean "template", default: false + t.boolean "push_events", default: true + t.boolean "issues_events", default: true + t.boolean "merge_requests_events", default: true + t.boolean "tag_push_events", default: true + t.boolean "note_events", default: true, null: false + t.boolean "build_events", default: false, null: false + t.string "category", default: "common", null: false + t.boolean "default", default: false + t.boolean "wiki_page_events", default: true + end + + add_index "services", ["category"], name: "index_services_on_category", using: :btree + add_index "services", ["created_at", "id"], name: "index_services_on_created_at_and_id", using: :btree + add_index "services", ["default"], name: "index_services_on_default", using: :btree + add_index "services", ["project_id"], name: "index_services_on_project_id", using: :btree + add_index "services", ["template"], name: "index_services_on_template", using: :btree + + create_table "snippets", force: :cascade do |t| + t.string "title" + t.text "content" + t.integer "author_id", null: false + t.integer "project_id" + t.datetime "created_at" + t.datetime "updated_at" + t.string "file_name" + t.string "type" + t.integer "visibility_level", default: 0, null: false + end + + add_index "snippets", ["author_id"], name: "index_snippets_on_author_id", using: :btree + add_index "snippets", ["created_at", "id"], name: "index_snippets_on_created_at_and_id", using: :btree + add_index "snippets", ["created_at"], name: "index_snippets_on_created_at", using: :btree + add_index "snippets", ["file_name"], name: "index_snippets_on_file_name_trigram", using: :gin, opclasses: {"file_name"=>"gin_trgm_ops"} + add_index "snippets", ["project_id"], name: "index_snippets_on_project_id", using: :btree + add_index "snippets", ["title"], name: "index_snippets_on_title_trigram", using: :gin, opclasses: {"title"=>"gin_trgm_ops"} + add_index "snippets", ["updated_at"], name: "index_snippets_on_updated_at", using: :btree + add_index "snippets", ["visibility_level"], name: "index_snippets_on_visibility_level", using: :btree + + create_table "spam_logs", force: :cascade do |t| + t.integer "user_id" + t.string "source_ip" + t.string "user_agent" + t.boolean "via_api" + t.integer "project_id" + t.string "noteable_type" + t.string "title" + t.text "description" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + create_table "subscriptions", force: :cascade do |t| + t.integer "user_id" + t.integer "subscribable_id" + t.string "subscribable_type" + t.boolean "subscribed" + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "subscriptions", ["subscribable_id", "subscribable_type", "user_id"], name: "subscriptions_user_id_and_ref_fields", unique: true, using: :btree + + create_table "taggings", force: :cascade do |t| + t.integer "tag_id" + t.integer "taggable_id" + t.string "taggable_type" + t.integer "tagger_id" + t.string "tagger_type" + t.string "context" + t.datetime "created_at" + end + + add_index "taggings", ["tag_id", "taggable_id", "taggable_type", "context", "tagger_id", "tagger_type"], name: "taggings_idx", unique: true, using: :btree + add_index "taggings", ["taggable_id", "taggable_type", "context"], name: "index_taggings_on_taggable_id_and_taggable_type_and_context", using: :btree + + create_table "tags", force: :cascade do |t| + t.string "name" + t.integer "taggings_count", default: 0 + end + + add_index "tags", ["name"], name: "index_tags_on_name", unique: true, using: :btree + + create_table "todos", force: :cascade do |t| + t.integer "user_id", null: false + t.integer "project_id", null: false + t.integer "target_id" + t.string "target_type", null: false + t.integer "author_id" + t.integer "action", null: false + t.string "state", null: false + t.datetime "created_at" + t.datetime "updated_at" + t.integer "note_id" + t.string "commit_id" + end + + add_index "todos", ["author_id"], name: "index_todos_on_author_id", using: :btree + add_index "todos", ["commit_id"], name: "index_todos_on_commit_id", using: :btree + add_index "todos", ["note_id"], name: "index_todos_on_note_id", using: :btree + add_index "todos", ["project_id"], name: "index_todos_on_project_id", using: :btree + add_index "todos", ["state"], name: "index_todos_on_state", using: :btree + add_index "todos", ["target_type", "target_id"], name: "index_todos_on_target_type_and_target_id", using: :btree + add_index "todos", ["user_id"], name: "index_todos_on_user_id", using: :btree + + create_table "u2f_registrations", force: :cascade do |t| + t.text "certificate" + t.string "key_handle" + t.string "public_key" + t.integer "counter" + t.integer "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + + add_index "u2f_registrations", ["key_handle"], name: "index_u2f_registrations_on_key_handle", using: :btree + add_index "u2f_registrations", ["user_id"], name: "index_u2f_registrations_on_user_id", using: :btree + + create_table "users", force: :cascade do |t| + t.string "email", default: "", null: false + t.string "encrypted_password", default: "", null: false + t.string "reset_password_token" + t.datetime "reset_password_sent_at" + t.datetime "remember_created_at" + t.integer "sign_in_count", default: 0 + t.datetime "current_sign_in_at" + t.datetime "last_sign_in_at" + t.string "current_sign_in_ip" + t.string "last_sign_in_ip" + t.datetime "created_at" + t.datetime "updated_at" + t.string "name" + t.boolean "admin", default: false, null: false + t.integer "projects_limit", default: 10 + t.string "skype", default: "", null: false + t.string "linkedin", default: "", null: false + t.string "twitter", default: "", null: false + t.string "authentication_token" + t.integer "theme_id", default: 1, null: false + t.string "bio" + t.integer "failed_attempts", default: 0 + t.datetime "locked_at" + t.string "username" + t.boolean "can_create_group", default: true, null: false + t.boolean "can_create_team", default: true, null: false + t.string "state" + t.integer "color_scheme_id", default: 1, null: false + t.datetime "password_expires_at" + t.integer "created_by_id" + t.datetime "last_credential_check_at" + t.string "avatar" + t.string "confirmation_token" + t.datetime "confirmed_at" + t.datetime "confirmation_sent_at" + t.string "unconfirmed_email" + t.boolean "hide_no_ssh_key", default: false + t.string "website_url", default: "", null: false + t.string "notification_email" + t.boolean "hide_no_password", default: false + t.boolean "password_automatically_set", default: false + t.string "location" + t.string "encrypted_otp_secret" + t.string "encrypted_otp_secret_iv" + t.string "encrypted_otp_secret_salt" + t.boolean "otp_required_for_login", default: false, null: false + t.text "otp_backup_codes" + t.string "public_email", default: "", null: false + t.integer "dashboard", default: 0 + t.integer "project_view", default: 0 + t.integer "consumed_timestep" + t.integer "layout", default: 0 + t.boolean "hide_project_limit", default: false + t.string "unlock_token" + t.datetime "otp_grace_period_started_at" + t.boolean "ldap_email", default: false, null: false + t.boolean "external", default: false + end + + add_index "users", ["admin"], name: "index_users_on_admin", using: :btree + add_index "users", ["authentication_token"], name: "index_users_on_authentication_token", unique: true, using: :btree + add_index "users", ["confirmation_token"], name: "index_users_on_confirmation_token", unique: true, using: :btree + add_index "users", ["created_at", "id"], name: "index_users_on_created_at_and_id", using: :btree + add_index "users", ["current_sign_in_at"], name: "index_users_on_current_sign_in_at", using: :btree + add_index "users", ["email"], name: "index_users_on_email", unique: true, using: :btree + add_index "users", ["email"], name: "index_users_on_email_trigram", using: :gin, opclasses: {"email"=>"gin_trgm_ops"} + add_index "users", ["name"], name: "index_users_on_name", using: :btree + add_index "users", ["name"], name: "index_users_on_name_trigram", using: :gin, opclasses: {"name"=>"gin_trgm_ops"} + add_index "users", ["reset_password_token"], name: "index_users_on_reset_password_token", unique: true, using: :btree + add_index "users", ["state"], name: "index_users_on_state", using: :btree + add_index "users", ["username"], name: "index_users_on_username", using: :btree + add_index "users", ["username"], name: "index_users_on_username_trigram", using: :gin, opclasses: {"username"=>"gin_trgm_ops"} + + create_table "users_star_projects", force: :cascade do |t| + t.integer "project_id", null: false + t.integer "user_id", null: false + t.datetime "created_at" + t.datetime "updated_at" + end + + add_index "users_star_projects", ["project_id"], name: "index_users_star_projects_on_project_id", using: :btree + add_index "users_star_projects", ["user_id", "project_id"], name: "index_users_star_projects_on_user_id_and_project_id", unique: true, using: :btree + add_index "users_star_projects", ["user_id"], name: "index_users_star_projects_on_user_id", using: :btree + + create_table "web_hooks", force: :cascade do |t| + t.string "url", limit: 2000 + t.integer "project_id" + t.datetime "created_at" + t.datetime "updated_at" + t.string "type", default: "ProjectHook" + t.integer "service_id" + t.boolean "push_events", default: true, null: false + t.boolean "issues_events", default: false, null: false + t.boolean "merge_requests_events", default: false, null: false + t.boolean "tag_push_events", default: false + t.boolean "note_events", default: false, null: false + t.boolean "enable_ssl_verification", default: true + t.boolean "build_events", default: false, null: false + t.boolean "wiki_page_events", default: false, null: false + t.string "token" + end + + add_index "web_hooks", ["created_at", "id"], name: "index_web_hooks_on_created_at_and_id", using: :btree + add_index "web_hooks", ["project_id"], name: "index_web_hooks_on_project_id", using: :btree + + add_foreign_key "u2f_registrations", "users" +end From d8b399a8c6051a3bdef56e8d7c63ac1d40ddc071 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Wed, 15 Jun 2016 15:14:23 +0300 Subject: [PATCH 272/318] Fix project star tooltip in to show actual message. --- app/views/projects/buttons/_star.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/buttons/_star.html.haml b/app/views/projects/buttons/_star.html.haml index 02dbb2985a4..71cf5582a4c 100644 --- a/app/views/projects/buttons/_star.html.haml +++ b/app/views/projects/buttons/_star.html.haml @@ -1,5 +1,5 @@ - if current_user - = link_to toggle_star_namespace_project_path(@project.namespace, @project), class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: "Star project" do + = link_to toggle_star_namespace_project_path(@project.namespace, @project), { class: 'btn star-btn toggle-star has-tooltip', method: :post, remote: true, title: current_user.starred?(@project) ? 'Unstar project' : 'Star project' } do - if current_user.starred?(@project) = icon('star fw') %span.starred Unstar From 13b32e74bcd379eae0422dd971a196d07fa2c5fe Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Wed, 15 Jun 2016 15:14:53 +0300 Subject: [PATCH 273/318] Fix project star tooltip on the fly. Introduced new util called updateTooltipTitle. --- app/assets/javascripts/lib/common_utils.js.coffee | 12 ++++++++++++ app/assets/javascripts/star.js.coffee | 2 ++ 2 files changed, 14 insertions(+) diff --git a/app/assets/javascripts/lib/common_utils.js.coffee b/app/assets/javascripts/lib/common_utils.js.coffee index 0000e99a650..5e3a802f45f 100644 --- a/app/assets/javascripts/lib/common_utils.js.coffee +++ b/app/assets/javascripts/lib/common_utils.js.coffee @@ -1,5 +1,8 @@ ((w) -> + window.gl or= {} + window.gl.utils or= {} + jQuery.timefor = (time, suffix, expiredLabel) -> return '' unless time @@ -21,4 +24,13 @@ return timefor + + gl.utils.updateTooltipTitle = ($tooltipEl, newTitle) -> + + $tooltipEl + .tooltip 'destroy' + .attr 'title', newTitle + .tooltip 'fixTitle' + + ) window diff --git a/app/assets/javascripts/star.js.coffee b/app/assets/javascripts/star.js.coffee index f27780dda93..01b28171f72 100644 --- a/app/assets/javascripts/star.js.coffee +++ b/app/assets/javascripts/star.js.coffee @@ -9,9 +9,11 @@ class @Star $this.parent().find('.star-count').text data.star_count if isStarred $starSpan.removeClass('starred').text 'Star' + gl.utils.updateTooltipTitle $this, 'Star project' $starIcon.removeClass('fa-star').addClass 'fa-star-o' else $starSpan.addClass('starred').text 'Unstar' + gl.utils.updateTooltipTitle $this, 'Unstar project' $starIcon.removeClass('fa-star-o').addClass 'fa-star' return From 138ff057a1812ddfbc5ffc4f9406336ca7a3153e Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Wed, 15 Jun 2016 15:15:51 +0300 Subject: [PATCH 274/318] Update CHANGELOG. --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 6f29b578a95..be9b5315c5a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -91,6 +91,7 @@ v 8.9.0 (unreleased) - New custom icons for navigation - Horizontally scrolling navigation on project, group, and profile settings pages - Hide global side navigation by default + - Fix project Star/Unstar project button tooltip - Remove tanuki logo from side navigation; center on top nav - Include user relationships when retrieving award_emoji From c32e61251e5afa9131f4c5d08f762a6e9f7de110 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer <jacob@gitlab.com> Date: Wed, 15 Jun 2016 14:59:37 +0200 Subject: [PATCH 275/318] Get rid of Gitlab::ShellEnv --- app/services/git_hooks_service.rb | 2 +- lib/gitlab/backend/grack_auth.rb | 7 ------- lib/gitlab/backend/shell_env.rb | 28 ---------------------------- lib/gitlab/gl_id.rb | 11 +++++++++++ lib/gitlab/workhorse.rb | 2 +- 5 files changed, 13 insertions(+), 37 deletions(-) delete mode 100644 lib/gitlab/backend/shell_env.rb create mode 100644 lib/gitlab/gl_id.rb diff --git a/app/services/git_hooks_service.rb b/app/services/git_hooks_service.rb index 8f5c3393dfc..d7a0c25a044 100644 --- a/app/services/git_hooks_service.rb +++ b/app/services/git_hooks_service.rb @@ -3,7 +3,7 @@ class GitHooksService def execute(user, repo_path, oldrev, newrev, ref) @repo_path = repo_path - @user = Gitlab::ShellEnv.gl_id(user) + @user = Gitlab::GlId.gl_id(user) @oldrev = oldrev @newrev = newrev @ref = ref diff --git a/lib/gitlab/backend/grack_auth.rb b/lib/gitlab/backend/grack_auth.rb index adbf5941a96..7e3f5abba62 100644 --- a/lib/gitlab/backend/grack_auth.rb +++ b/lib/gitlab/backend/grack_auth.rb @@ -1,5 +1,3 @@ -require_relative 'shell_env' - module Grack class AuthSpawner def self.call(env) @@ -61,11 +59,6 @@ module Grack end @user = authenticate_user(login, password) - - if @user - Gitlab::ShellEnv.set_env(@user) - @env['REMOTE_USER'] = @auth.username - end end def ci_request?(login, password) diff --git a/lib/gitlab/backend/shell_env.rb b/lib/gitlab/backend/shell_env.rb deleted file mode 100644 index 9f5adee594a..00000000000 --- a/lib/gitlab/backend/shell_env.rb +++ /dev/null @@ -1,28 +0,0 @@ -module Gitlab - # This module provide 2 methods - # to set specific ENV variables for GitLab Shell - module ShellEnv - extend self - - def set_env(user) - # Set GL_ID env variable - if user - ENV['GL_ID'] = gl_id(user) - end - end - - def reset_env - # Reset GL_ID env variable - ENV['GL_ID'] = nil - end - - def gl_id(user) - if user.present? - "user-#{user.id}" - else - # This empty string is used in the render_grack_auth_ok method - "" - end - end - end -end diff --git a/lib/gitlab/gl_id.rb b/lib/gitlab/gl_id.rb new file mode 100644 index 00000000000..624fd00367e --- /dev/null +++ b/lib/gitlab/gl_id.rb @@ -0,0 +1,11 @@ +module Gitlab + module GlId + def self.gl_id(user) + if user.present? + "user-#{user.id}" + else + "" + end + end + end +end diff --git a/lib/gitlab/workhorse.rb b/lib/gitlab/workhorse.rb index 388f84dbe0e..40e8299c36b 100644 --- a/lib/gitlab/workhorse.rb +++ b/lib/gitlab/workhorse.rb @@ -8,7 +8,7 @@ module Gitlab class << self def git_http_ok(repository, user) { - 'GL_ID' => Gitlab::ShellEnv.gl_id(user), + 'GL_ID' => Gitlab::GlId.gl_id(user), 'RepoPath' => repository.path_to_repo, } end From 31944179aac2c0c0dcb932b73e69421da4fa2ff8 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <zegerjan@gitlab.com> Date: Wed, 15 Jun 2016 12:29:57 +0200 Subject: [PATCH 276/318] Award Emoji can't be awarded on system notes backend --- app/models/note.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/models/note.rb b/app/models/note.rb index 58133f1581f..4b6748053ff 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -187,6 +187,10 @@ class Note < ActiveRecord::Base award_emoji_supported? && contains_emoji_only? end + def emoji_awardable? + !system? + end + def clear_blank_line_code! self.line_code = nil if self.line_code.blank? end From 6ace6d940a90e70f89392c3be7d9e538b6cec04c Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 15:09:50 +0200 Subject: [PATCH 277/318] Use validate and required for environment and project --- app/models/deployment.rb | 4 ++-- app/models/environment.rb | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 030648470ee..e498ca96e3c 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -1,8 +1,8 @@ class Deployment < ActiveRecord::Base include InternalId - belongs_to :project, validate: true - belongs_to :environment, validate: true + belongs_to :project, required: true, validate: true + belongs_to :environment, required: true, validate: true belongs_to :user belongs_to :deployable, polymorphic: true diff --git a/app/models/environment.rb b/app/models/environment.rb index 7986a2529df..ac3a571a1f3 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -1,5 +1,5 @@ class Environment < ActiveRecord::Base - belongs_to :project, required: true + belongs_to :project, required: true, validate: true has_many :deployments From fce675d7fc7e408b3ec01a017a719c8cd036fa0d Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Wed, 8 Jun 2016 18:13:52 +0200 Subject: [PATCH 278/318] Eager load project relations in IssueParser By eager loading these associations we can greatly cut down the number of SQL queries executed when processing documents with lots of references, especially in cases where there are references belonging to the same project. Since these associations are so specific to the reference parsing process and the permissions checking process that follows it I opted to include them directly in IssueParser instead of using something like a scope. Once we have a need for it we can move this code to a scope or method. --- CHANGELOG | 1 + lib/banzai/reference_parser/issue_parser.rb | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 6f29b578a95..910954dfcd6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -93,6 +93,7 @@ v 8.9.0 (unreleased) - Hide global side navigation by default - Remove tanuki logo from side navigation; center on top nav - Include user relationships when retrieving award_emoji + - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/lib/banzai/reference_parser/issue_parser.rb b/lib/banzai/reference_parser/issue_parser.rb index 24076e3d9ec..f306079d833 100644 --- a/lib/banzai/reference_parser/issue_parser.rb +++ b/lib/banzai/reference_parser/issue_parser.rb @@ -25,7 +25,21 @@ module Banzai def issues_for_nodes(nodes) @issues_for_nodes ||= grouped_objects_for_nodes( nodes, - Issue.all.includes(:author, :assignee, :project), + Issue.all.includes( + :author, + :assignee, + { + # These associations are primarily used for checking permissions. + # Eager loading these ensures we don't end up running dozens of + # queries in this process. + project: [ + { namespace: :owner }, + { group: [:owners, :group_members] }, + :invited_groups, + :project_members + ] + } + ), self.class.data_attribute ) end From 10ae4a8e71e14053beb9f90196c9450838d8a44e Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 10:20:35 -0500 Subject: [PATCH 279/318] Move admin nav to horizontal layout nav --- app/views/layouts/admin.html.haml | 2 +- app/views/layouts/nav/_admin.html.haml | 23 +++-------------------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/app/views/layouts/admin.html.haml b/app/views/layouts/admin.html.haml index 6591c52bdbd..87064cc9b3f 100644 --- a/app/views/layouts/admin.html.haml +++ b/app/views/layouts/admin.html.haml @@ -1,5 +1,5 @@ - page_title "Admin Area" - header_title "Admin Area", admin_root_path -- sidebar "admin" +- nav "admin" = render template: "layouts/application" diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index f292730fe45..b2539a1beac 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -1,93 +1,77 @@ -%ul.nav.nav-sidebar +%ul.nav-links.scrolling-tabs + .fade-left = nav_link(controller: :dashboard, html_options: {class: 'home'}) do = link_to admin_root_path, title: 'Overview' do - = icon('dashboard fw') %span Overview = nav_link(controller: [:admin, :projects]) do = link_to admin_namespaces_projects_path, title: 'Projects' do - = icon('cube fw') %span Projects = nav_link(controller: :users) do = link_to admin_users_path, title: 'Users' do - = icon('user fw') %span Users = nav_link(controller: :groups) do = link_to admin_groups_path, title: 'Groups' do - = icon('group fw') %span Groups = nav_link(controller: :deploy_keys) do = link_to admin_deploy_keys_path, title: 'Deploy Keys' do - = icon('key fw') %span Deploy Keys = nav_link path: ['runners#index', 'runners#show'] do = link_to admin_runners_path, title: 'Runners' do - = icon('cog fw') %span Runners %span.count= number_with_delimiter(Ci::Runner.count(:all)) = nav_link path: 'builds#index' do = link_to admin_builds_path, title: 'Builds' do - = icon('link fw') %span Builds %span.count= number_with_delimiter(Ci::Build.count(:all)) = nav_link(controller: :logs) do = link_to admin_logs_path, title: 'Logs' do - = icon('file-text fw') %span Logs = nav_link(controller: :health_check) do = link_to admin_health_check_path, title: 'Health Check' do - = icon('medkit fw') %span Health Check = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path, title: 'Messages' do - = icon('bullhorn fw') %span Messages = nav_link(controller: :hooks) do = link_to admin_hooks_path, title: 'Hooks' do - = icon('external-link fw') %span Hooks = nav_link(controller: :background_jobs) do = link_to admin_background_jobs_path, title: 'Background Jobs' do - = icon('cog fw') %span Background Jobs = nav_link(controller: :appearances) do = link_to admin_appearances_path, title: 'Appearances' do - = icon('image') %span Appearance = nav_link(controller: :applications) do = link_to admin_applications_path, title: 'Applications' do - = icon('cloud fw') %span Applications = nav_link(controller: :services) do = link_to admin_application_settings_services_path, title: 'Service Templates' do - = icon('copy fw') %span Service Templates = nav_link(controller: :labels) do = link_to admin_labels_path, title: 'Labels' do - = icon('tags fw') %span Labels = nav_link(controller: :abuse_reports) do = link_to admin_abuse_reports_path, title: "Abuse Reports" do - = icon('exclamation-circle fw') %span Abuse Reports %span.count= number_with_delimiter(AbuseReport.count(:all)) @@ -95,13 +79,12 @@ - if askimet_enabled? = nav_link(controller: :spam_logs) do = link_to admin_spam_logs_path, title: "Spam Logs" do - = icon('exclamation-triangle fw') %span Spam Logs %span.count= number_with_delimiter(SpamLog.count(:all)) = nav_link(controller: :application_settings, html_options: { class: 'separate-item'}) do = link_to admin_application_settings_path, title: 'Settings' do - = icon('cogs fw') %span Settings + .fade-right From 58c8661cd161e10d6dc51300c59850481e61cfd7 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 10:21:09 -0500 Subject: [PATCH 280/318] Remove admin layout-nav counters --- app/views/layouts/nav/_admin.html.haml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index b2539a1beac..6258e6fd54b 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -24,12 +24,10 @@ = link_to admin_runners_path, title: 'Runners' do %span Runners - %span.count= number_with_delimiter(Ci::Runner.count(:all)) = nav_link path: 'builds#index' do = link_to admin_builds_path, title: 'Builds' do %span Builds - %span.count= number_with_delimiter(Ci::Build.count(:all)) = nav_link(controller: :logs) do = link_to admin_logs_path, title: 'Logs' do %span @@ -74,14 +72,12 @@ = link_to admin_abuse_reports_path, title: "Abuse Reports" do %span Abuse Reports - %span.count= number_with_delimiter(AbuseReport.count(:all)) - if askimet_enabled? = nav_link(controller: :spam_logs) do = link_to admin_spam_logs_path, title: "Spam Logs" do %span Spam Logs - %span.count= number_with_delimiter(SpamLog.count(:all)) = nav_link(controller: :application_settings, html_options: { class: 'separate-item'}) do = link_to admin_application_settings_path, title: 'Settings' do From 736ba42b249d8ccd22b455f6cd09ae946bd2d855 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 10:26:00 -0500 Subject: [PATCH 281/318] Add counter for abuse reports --- app/views/layouts/nav/_admin.html.haml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index 6258e6fd54b..1d53f715e86 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -72,6 +72,7 @@ = link_to admin_abuse_reports_path, title: "Abuse Reports" do %span Abuse Reports + %span.badge.count= number_with_delimiter(AbuseReport.count(:all)) - if askimet_enabled? = nav_link(controller: :spam_logs) do From 922a164d60725246ee038d2603d2beed0a82277a Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 15:37:02 -0500 Subject: [PATCH 282/318] Add sub links to overview --- app/views/admin/dashboard/_head.html.haml | 18 ++ app/views/admin/dashboard/index.html.haml | 300 +++++++++++----------- app/views/admin/groups/index.html.haml | 72 +++--- app/views/admin/projects/index.html.haml | 173 +++++++------ app/views/admin/users/index.html.haml | 199 +++++++------- app/views/layouts/nav/_admin.html.haml | 16 +- 6 files changed, 399 insertions(+), 379 deletions(-) create mode 100644 app/views/admin/dashboard/_head.html.haml diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml new file mode 100644 index 00000000000..b1adc316b50 --- /dev/null +++ b/app/views/admin/dashboard/_head.html.haml @@ -0,0 +1,18 @@ +%ul.nav-links.sub-nav + %div{ class: (container_class) } + = nav_link(controller: :dashboard, html_options: {class: 'home'}) do + = link_to admin_root_path, title: 'Overview' do + %span + Overview + = nav_link(controller: [:admin, :projects]) do + = link_to admin_namespaces_projects_path, title: 'Projects' do + %span + Projects + = nav_link(controller: :users) do + = link_to admin_users_path, title: 'Users' do + %span + Users + = nav_link(controller: :groups) do + = link_to admin_groups_path, title: 'Groups' do + %span + Groups diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 6dd2fef395d..4682016a886 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -1,155 +1,159 @@ -.admin-dashboard.prepend-top-default - .row - .col-md-4 - %h4 Statistics - %hr - %p - Forks - %span.light.pull-right - = number_with_delimiter(ForkedProjectLink.count) - %p - Issues - %span.light.pull-right - = number_with_delimiter(Issue.count) - %p - Merge Requests - %span.light.pull-right - = number_with_delimiter(MergeRequest.count) - %p - Notes - %span.light.pull-right - = number_with_delimiter(Note.count) - %p - Snippets - %span.light.pull-right - = number_with_delimiter(Snippet.count) - %p - SSH Keys - %span.light.pull-right - = number_with_delimiter(Key.count) - %p - Milestones - %span.light.pull-right - = number_with_delimiter(Milestone.count) - %p - Active Users - %span.light.pull-right - = number_with_delimiter(User.active.count) - .col-md-4 - %h4 - Features - %hr - %p - Sign up - %span.light.pull-right - = boolean_to_icon signup_enabled? - %p - LDAP - %span.light.pull-right - = boolean_to_icon Gitlab.config.ldap.enabled - %p - Gravatar - %span.light.pull-right - = boolean_to_icon gravatar_enabled? - %p - OmniAuth - %span.light.pull-right - = boolean_to_icon Gitlab.config.omniauth.enabled - %p - Reply by email - %span.light.pull-right - = boolean_to_icon Gitlab::IncomingEmail.enabled? - .col-md-4 - %h4 - Components - - if current_application_settings.version_check_enabled - .pull-right - = version_status_badge +- @no_container = true += render "admin/dashboard/head" - %hr - %p - GitLab - %span.pull-right - = Gitlab::VERSION - %p - GitLab Shell - %span.pull-right - = Gitlab::Shell.new.version - %p - GitLab API - %span.pull-right - = API::API::version - %p - Git - %span.pull-right - = Gitlab::Git.version - %p - Ruby - %span.pull-right - #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} - - %p - Rails - %span.pull-right - #{Rails::VERSION::STRING} - - %p - = Gitlab::Database.adapter_name - %span.pull-right - = Gitlab::Database.version - %hr - .row - .col-sm-4 - .light-well - %h4 Projects - .data - = link_to admin_namespaces_projects_path do - %h1= number_with_delimiter(Project.count) - %hr - = link_to('New Project', new_project_path, class: "btn btn-new") - .col-sm-4 - .light-well - %h4 Users - .data - = link_to admin_users_path do - %h1= number_with_delimiter(User.count) - %hr - = link_to 'New User', new_admin_user_path, class: "btn btn-new" - .col-sm-4 - .light-well - %h4 Groups - .data - = link_to admin_groups_path do - %h1= number_with_delimiter(Group.count) - %hr - = link_to 'New Group', new_admin_group_path, class: "btn btn-new" - - .row.prepend-top-10 - .col-md-4 - %h4 Latest projects - %hr - - @projects.each do |project| +%div{ class: (container_class) } + .admin-dashboard.prepend-top-default + .row + .col-md-4 + %h4 Statistics + %hr %p - = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated' + Forks %span.light.pull-right - #{time_ago_with_tooltip(project.created_at)} - - .col-md-4 - %h4 Latest users - %hr - - @users.each do |user| + = number_with_delimiter(ForkedProjectLink.count) %p - = link_to [:admin, user], class: 'str-truncated' do - = user.name + Issues %span.light.pull-right - #{time_ago_with_tooltip(user.created_at)} - - .col-md-4 - %h4 Latest groups - %hr - - @groups.each do |group| + = number_with_delimiter(Issue.count) %p - = link_to [:admin, group], class: 'str-truncated' do - = group.name + Merge Requests %span.light.pull-right - #{time_ago_with_tooltip(group.created_at)} + = number_with_delimiter(MergeRequest.count) + %p + Notes + %span.light.pull-right + = number_with_delimiter(Note.count) + %p + Snippets + %span.light.pull-right + = number_with_delimiter(Snippet.count) + %p + SSH Keys + %span.light.pull-right + = number_with_delimiter(Key.count) + %p + Milestones + %span.light.pull-right + = number_with_delimiter(Milestone.count) + %p + Active Users + %span.light.pull-right + = number_with_delimiter(User.active.count) + .col-md-4 + %h4 + Features + %hr + %p + Sign up + %span.light.pull-right + = boolean_to_icon signup_enabled? + %p + LDAP + %span.light.pull-right + = boolean_to_icon Gitlab.config.ldap.enabled + %p + Gravatar + %span.light.pull-right + = boolean_to_icon gravatar_enabled? + %p + OmniAuth + %span.light.pull-right + = boolean_to_icon Gitlab.config.omniauth.enabled + %p + Reply by email + %span.light.pull-right + = boolean_to_icon Gitlab::IncomingEmail.enabled? + .col-md-4 + %h4 + Components + - if current_application_settings.version_check_enabled + .pull-right + = version_status_badge + + %hr + %p + GitLab + %span.pull-right + = Gitlab::VERSION + %p + GitLab Shell + %span.pull-right + = Gitlab::Shell.new.version + %p + GitLab API + %span.pull-right + = API::API::version + %p + Git + %span.pull-right + = Gitlab::Git.version + %p + Ruby + %span.pull-right + #{RUBY_VERSION}p#{RUBY_PATCHLEVEL} + + %p + Rails + %span.pull-right + #{Rails::VERSION::STRING} + + %p + = Gitlab::Database.adapter_name + %span.pull-right + = Gitlab::Database.version + %hr + .row + .col-sm-4 + .light-well + %h4 Projects + .data + = link_to admin_namespaces_projects_path do + %h1= number_with_delimiter(Project.count) + %hr + = link_to('New Project', new_project_path, class: "btn btn-new") + .col-sm-4 + .light-well + %h4 Users + .data + = link_to admin_users_path do + %h1= number_with_delimiter(User.count) + %hr + = link_to 'New User', new_admin_user_path, class: "btn btn-new" + .col-sm-4 + .light-well + %h4 Groups + .data + = link_to admin_groups_path do + %h1= number_with_delimiter(Group.count) + %hr + = link_to 'New Group', new_admin_group_path, class: "btn btn-new" + + .row.prepend-top-10 + .col-md-4 + %h4 Latest projects + %hr + - @projects.each do |project| + %p + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project], class: 'str-truncated' + %span.light.pull-right + #{time_ago_with_tooltip(project.created_at)} + + .col-md-4 + %h4 Latest users + %hr + - @users.each do |user| + %p + = link_to [:admin, user], class: 'str-truncated' do + = user.name + %span.light.pull-right + #{time_ago_with_tooltip(user.created_at)} + + .col-md-4 + %h4 Latest groups + %hr + - @groups.each do |group| + %p + = link_to [:admin, group], class: 'str-truncated' do + = group.name + %span.light.pull-right + #{time_ago_with_tooltip(group.created_at)} diff --git a/app/views/admin/groups/index.html.haml b/app/views/admin/groups/index.html.haml index 775072a7441..4f1996ef7ab 100644 --- a/app/views/admin/groups/index.html.haml +++ b/app/views/admin/groups/index.html.haml @@ -1,41 +1,45 @@ +- @no_container = true - page_title "Groups" -%h3.page-title - Groups (#{number_with_delimiter(@groups.total_count)}) += render "admin/dashboard/head" -%p.light - Group allows you to keep projects organized. - Use groups for uniting related projects. +%div{ class: (container_class) } + %h3.page-title + Groups (#{number_with_delimiter(@groups.total_count)}) -.top-area - .nav-search - = form_tag admin_groups_path, method: :get, class: 'form-inline' do - = hidden_field_tag :sort, @sort - = text_field_tag :name, params[:name], class: "form-control" - = button_tag "Search", class: "btn submit btn-primary" + %p.light + Group allows you to keep projects organized. + Use groups for uniting related projects. - .nav-controls - .dropdown.inline - %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_recently_created - %b.caret - %ul.dropdown-menu - %li - = link_to admin_groups_path(sort: sort_value_recently_created) do + .top-area + .nav-search + = form_tag admin_groups_path, method: :get, class: 'form-inline' do + = hidden_field_tag :sort, @sort + = text_field_tag :name, params[:name], class: "form-control" + = button_tag "Search", class: "btn submit btn-primary" + + .nav-controls + .dropdown.inline + %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else = sort_title_recently_created - = link_to admin_groups_path(sort: sort_value_oldest_created) do - = sort_title_oldest_created - = link_to admin_groups_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to admin_groups_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated - = link_to 'New Group', new_admin_group_path, class: "btn btn-new" + %b.caret + %ul.dropdown-menu + %li + = link_to admin_groups_path(sort: sort_value_recently_created) do + = sort_title_recently_created + = link_to admin_groups_path(sort: sort_value_oldest_created) do + = sort_title_oldest_created + = link_to admin_groups_path(sort: sort_value_recently_updated) do + = sort_title_recently_updated + = link_to admin_groups_path(sort: sort_value_oldest_updated) do + = sort_title_oldest_updated + = link_to 'New Group', new_admin_group_path, class: "btn btn-new" -%ul.content-list - - @groups.each do |group| - = render 'group', group: group + %ul.content-list + - @groups.each do |group| + = render 'group', group: group -= paginate @groups, theme: "gitlab" + = paginate @groups, theme: "gitlab" diff --git a/app/views/admin/projects/index.html.haml b/app/views/admin/projects/index.html.haml index aa07afa0d62..4822cb693c2 100644 --- a/app/views/admin/projects/index.html.haml +++ b/app/views/admin/projects/index.html.haml @@ -1,94 +1,97 @@ +- @no_container = true - page_title "Projects" = render 'shared/show_aside' += render "admin/dashboard/head" -.row.prepend-top-default - %aside.col-md-3 - .panel.admin-filter - = form_tag admin_namespaces_projects_path, method: :get, class: '' do - .form-group - = label_tag :name, 'Name:' - = text_field_tag :name, params[:name], class: "form-control" +%div{ class: (container_class) } + .row.prepend-top-default + %aside.col-md-3 + .panel.admin-filter + = form_tag admin_namespaces_projects_path, method: :get, class: '' do + .form-group + = label_tag :name, 'Name:' + = text_field_tag :name, params[:name], class: "form-control" - .form-group - = label_tag :namespace_id, "Namespace" - = namespace_select_tag :namespace_id, selected: params[:namespace_id], class: 'input-large' + .form-group + = label_tag :namespace_id, "Namespace" + = namespace_select_tag :namespace_id, selected: params[:namespace_id], class: 'input-large' - .form-group - %strong Activity - .checkbox - = label_tag :with_push do - = check_box_tag :with_push, 1, params[:with_push] - %span Projects with push events - .checkbox - = label_tag :abandoned do - = check_box_tag :abandoned, 1, params[:abandoned] - %span No activity over 6 month - .checkbox - = label_tag :with_archived do - = check_box_tag :with_archived, 1, params[:with_archived] - %span Show archived projects + .form-group + %strong Activity + .checkbox + = label_tag :with_push do + = check_box_tag :with_push, 1, params[:with_push] + %span Projects with push events + .checkbox + = label_tag :abandoned do + = check_box_tag :abandoned, 1, params[:abandoned] + %span No activity over 6 month + .checkbox + = label_tag :with_archived do + = check_box_tag :with_archived, 1, params[:with_archived] + %span Show archived projects - %fieldset - %strong Visibility level: - .visibility-levels - - Project.visibility_levels.each do |label, level| - .checkbox - %label - = check_box_tag 'visibility_levels[]', level, params[:visibility_levels].present? && params[:visibility_levels].include?(level.to_s) - %span.descr - = visibility_level_icon(level) - = label - %fieldset - %strong Problems - .checkbox - = label_tag :last_repository_check_failed do - = check_box_tag :last_repository_check_failed, 1, params[:last_repository_check_failed] - %span Last repository check failed + %fieldset + %strong Visibility level: + .visibility-levels + - Project.visibility_levels.each do |label, level| + .checkbox + %label + = check_box_tag 'visibility_levels[]', level, params[:visibility_levels].present? && params[:visibility_levels].include?(level.to_s) + %span.descr + = visibility_level_icon(level) + = label + %fieldset + %strong Problems + .checkbox + = label_tag :last_repository_check_failed do + = check_box_tag :last_repository_check_failed, 1, params[:last_repository_check_failed] + %span Last repository check failed - = hidden_field_tag :sort, params[:sort] - = button_tag "Search", class: "btn submit btn-primary" - = link_to "Reset", admin_namespaces_projects_path, class: "btn btn-cancel" + = hidden_field_tag :sort, params[:sort] + = button_tag "Search", class: "btn submit btn-primary" + = link_to "Reset", admin_namespaces_projects_path, class: "btn btn-cancel" - %section.col-md-9 - .panel.panel-default - .panel-heading - Projects (#{@projects.total_count}) - .controls - .dropdown.inline - %button.dropdown-toggle.btn.btn-sm{type: 'button', 'data-toggle' => 'dropdown'} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_recently_created - %b.caret - %ul.dropdown-menu - %li - = link_to admin_namespaces_projects_path(sort: sort_value_recently_created) do + %section.col-md-9 + .panel.panel-default + .panel-heading + Projects (#{@projects.total_count}) + .controls + .dropdown.inline + %button.dropdown-toggle.btn.btn-sm{type: 'button', 'data-toggle' => 'dropdown'} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else = sort_title_recently_created - = link_to admin_namespaces_projects_path(sort: sort_value_oldest_created) do - = sort_title_oldest_created - = link_to admin_namespaces_projects_path(sort: sort_value_recently_updated) do - = sort_title_recently_updated - = link_to admin_namespaces_projects_path(sort: sort_value_oldest_updated) do - = sort_title_oldest_updated - = link_to admin_namespaces_projects_path(sort: sort_value_largest_repo) do - = sort_title_largest_repo - = link_to 'New Project', new_project_path, class: "btn btn-sm btn-success" - %ul.well-list - - @projects.each do |project| - %li - .list-item-name - %span{ class: visibility_level_color(project.visibility_level) } - = visibility_level_icon(project.visibility_level) - = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] - .pull-right - - if project.archived - %span.label.label-warning archived - %span.label.label-gray - = repository_size(project) - = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm" - = link_to 'Destroy', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-sm btn-remove" - - if @projects.blank? - .nothing-here-block 0 projects matches - = paginate @projects, theme: "gitlab" + %b.caret + %ul.dropdown-menu + %li + = link_to admin_namespaces_projects_path(sort: sort_value_recently_created) do + = sort_title_recently_created + = link_to admin_namespaces_projects_path(sort: sort_value_oldest_created) do + = sort_title_oldest_created + = link_to admin_namespaces_projects_path(sort: sort_value_recently_updated) do + = sort_title_recently_updated + = link_to admin_namespaces_projects_path(sort: sort_value_oldest_updated) do + = sort_title_oldest_updated + = link_to admin_namespaces_projects_path(sort: sort_value_largest_repo) do + = sort_title_largest_repo + = link_to 'New Project', new_project_path, class: "btn btn-sm btn-success" + %ul.well-list + - @projects.each do |project| + %li + .list-item-name + %span{ class: visibility_level_color(project.visibility_level) } + = visibility_level_icon(project.visibility_level) + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] + .pull-right + - if project.archived + %span.label.label-warning archived + %span.label.label-gray + = repository_size(project) + = link_to 'Edit', edit_namespace_project_path(project.namespace, project), id: "edit_#{dom_id(project)}", class: "btn btn-sm" + = link_to 'Destroy', [project.namespace.becomes(Namespace), project], data: { confirm: remove_project_message(project) }, method: :delete, class: "btn btn-sm btn-remove" + - if @projects.blank? + .nothing-here-block 0 projects matches + = paginate @projects, theme: "gitlab" diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index d6743081c8e..d0a696da64b 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -1,107 +1,110 @@ +- @no_container = true - page_title "Users" = render 'shared/show_aside' += render "admin/dashboard/head" -.admin-filter - %ul.nav-links - %li{class: "#{'active' unless params[:filter]}"} - = link_to admin_users_path do - Active - %small.badge= number_with_delimiter(User.active.count) - %li{class: "#{'active' if params[:filter] == "admins"}"} - = link_to admin_users_path(filter: "admins") do - Admins - %small.badge= number_with_delimiter(User.admins.count) - %li.filter-two-factor-enabled{class: "#{'active' if params[:filter] == 'two_factor_enabled'}"} - = link_to admin_users_path(filter: 'two_factor_enabled') do - 2FA Enabled - %small.badge= number_with_delimiter(User.with_two_factor.count) - %li.filter-two-factor-disabled{class: "#{'active' if params[:filter] == 'two_factor_disabled'}"} - = link_to admin_users_path(filter: 'two_factor_disabled') do - 2FA Disabled - %small.badge= number_with_delimiter(User.without_two_factor.count) - %li.filter-external{class: "#{'active' if params[:filter] == 'external'}"} - = link_to admin_users_path(filter: 'external') do - External - %small.badge= number_with_delimiter(User.external.count) - %li{class: "#{'active' if params[:filter] == "blocked"}"} - = link_to admin_users_path(filter: "blocked") do - Blocked - %small.badge= number_with_delimiter(User.blocked.count) - %li{class: "#{'active' if params[:filter] == "wop"}"} - = link_to admin_users_path(filter: "wop") do - Without projects - %small.badge= number_with_delimiter(User.without_projects.count) +%div{ class: (container_class) } + .admin-filter + %ul.nav-links + %li{class: "#{'active' unless params[:filter]}"} + = link_to admin_users_path do + Active + %small.badge= number_with_delimiter(User.active.count) + %li{class: "#{'active' if params[:filter] == "admins"}"} + = link_to admin_users_path(filter: "admins") do + Admins + %small.badge= number_with_delimiter(User.admins.count) + %li.filter-two-factor-enabled{class: "#{'active' if params[:filter] == 'two_factor_enabled'}"} + = link_to admin_users_path(filter: 'two_factor_enabled') do + 2FA Enabled + %small.badge= number_with_delimiter(User.with_two_factor.count) + %li.filter-two-factor-disabled{class: "#{'active' if params[:filter] == 'two_factor_disabled'}"} + = link_to admin_users_path(filter: 'two_factor_disabled') do + 2FA Disabled + %small.badge= number_with_delimiter(User.without_two_factor.count) + %li.filter-external{class: "#{'active' if params[:filter] == 'external'}"} + = link_to admin_users_path(filter: 'external') do + External + %small.badge= number_with_delimiter(User.external.count) + %li{class: "#{'active' if params[:filter] == "blocked"}"} + = link_to admin_users_path(filter: "blocked") do + Blocked + %small.badge= number_with_delimiter(User.blocked.count) + %li{class: "#{'active' if params[:filter] == "wop"}"} + = link_to admin_users_path(filter: "wop") do + Without projects + %small.badge= number_with_delimiter(User.without_projects.count) - .row-content-block.second-block - .pull-right - .dropdown.inline - %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} - %span.light - - if @sort.present? - = sort_options_hash[@sort] - - else - = sort_title_name - %b.caret - %ul.dropdown-menu - %li - = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do + .row-content-block.second-block + .pull-right + .dropdown.inline + %a.dropdown-toggle.btn{href: '#', "data-toggle" => "dropdown"} + %span.light + - if @sort.present? + = sort_options_hash[@sort] + - else = sort_title_name - = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do - = sort_title_recently_signin - = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do - = sort_title_oldest_signin - = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do - = sort_title_recently_created - = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do - = sort_title_oldest_created - = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do - = sort_title_recently_updated - = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do - = sort_title_oldest_updated + %b.caret + %ul.dropdown-menu + %li + = link_to admin_users_path(sort: sort_value_name, filter: params[:filter]) do + = sort_title_name + = link_to admin_users_path(sort: sort_value_recently_signin, filter: params[:filter]) do + = sort_title_recently_signin + = link_to admin_users_path(sort: sort_value_oldest_signin, filter: params[:filter]) do + = sort_title_oldest_signin + = link_to admin_users_path(sort: sort_value_recently_created, filter: params[:filter]) do + = sort_title_recently_created + = link_to admin_users_path(sort: sort_value_oldest_created, filter: params[:filter]) do + = sort_title_oldest_created + = link_to admin_users_path(sort: sort_value_recently_updated, filter: params[:filter]) do + = sort_title_recently_updated + = link_to admin_users_path(sort: sort_value_oldest_updated, filter: params[:filter]) do + = sort_title_oldest_updated - = link_to 'New User', new_admin_user_path, class: "btn btn-new" - = form_tag admin_users_path, method: :get, class: 'form-inline' do - .form-group - = search_field_tag :name, params[:name], placeholder: 'Name, email or username', class: 'form-control', spellcheck: false - = hidden_field_tag "filter", params[:filter] - = button_tag class: 'btn btn-primary' do - %i.fa.fa-search + = link_to 'New User', new_admin_user_path, class: "btn btn-new" + = form_tag admin_users_path, method: :get, class: 'form-inline' do + .form-group + = search_field_tag :name, params[:name], placeholder: 'Name, email or username', class: 'form-control', spellcheck: false + = hidden_field_tag "filter", params[:filter] + = button_tag class: 'btn btn-primary' do + %i.fa.fa-search -.panel.panel-default - %ul.well-list - - @users.each do |user| - %li - .list-item-name - - if user.blocked? - = icon("lock", class: "cred") - - else - = icon("user", class: "cgreen") - = link_to user.name, [:admin, user] - - if user.admin? - %strong.cred (Admin) - - if user.external? - %strong.cred (External) - - if user == current_user - %span.cred It's you! - .pull-right - %span.light - %i.fa.fa-envelope - = mail_to user.email, user.email, class: 'light' -   + .panel.panel-default + %ul.well-list + - @users.each do |user| + %li + .list-item-name + - if user.blocked? + = icon("lock", class: "cred") + - else + = icon("user", class: "cgreen") + = link_to user.name, [:admin, user] + - if user.admin? + %strong.cred (Admin) + - if user.external? + %strong.cred (External) + - if user == current_user + %span.cred It's you! .pull-right - = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn-grouped btn btn-xs' - - unless user == current_user - - if user.ldap_blocked? - = link_to '#', title: 'Cannot unblock LDAP blocked users', data: {toggle: 'tooltip'}, class: 'btn-grouped btn btn-xs btn-success disabled' do - %i.fa.fa-lock - Unblock - - elsif user.blocked? - = link_to 'Unblock', unblock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success' - - else - = link_to 'Block', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: 'btn-grouped btn btn-xs btn-warning' - - if user.access_locked? - = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } - - if user.can_be_removed? - = link_to 'Destroy', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Maybe block the user instead? Are you sure?" }, method: :delete, class: 'btn-grouped btn btn-xs btn-remove' -= paginate @users, theme: "gitlab" + %span.light + %i.fa.fa-envelope + = mail_to user.email, user.email, class: 'light' +   + .pull-right + = link_to 'Edit', edit_admin_user_path(user), id: "edit_#{dom_id(user)}", class: 'btn-grouped btn btn-xs' + - unless user == current_user + - if user.ldap_blocked? + = link_to '#', title: 'Cannot unblock LDAP blocked users', data: {toggle: 'tooltip'}, class: 'btn-grouped btn btn-xs btn-success disabled' do + %i.fa.fa-lock + Unblock + - elsif user.blocked? + = link_to 'Unblock', unblock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success' + - else + = link_to 'Block', block_admin_user_path(user), data: {confirm: 'USER WILL BE BLOCKED! Are you sure?'}, method: :put, class: 'btn-grouped btn btn-xs btn-warning' + - if user.access_locked? + = link_to 'Unlock', unlock_admin_user_path(user), method: :put, class: 'btn-grouped btn btn-xs btn-success', data: { confirm: 'Are you sure?' } + - if user.can_be_removed? + = link_to 'Destroy', [:admin, user], data: { confirm: "USER #{user.name} WILL BE REMOVED! All issues, merge requests and groups linked to this user will also be removed! Maybe block the user instead? Are you sure?" }, method: :delete, class: 'btn-grouped btn btn-xs btn-remove' + = paginate @users, theme: "gitlab" diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index 1d53f715e86..9d85ec1d6d1 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -1,21 +1,9 @@ %ul.nav-links.scrolling-tabs .fade-left - = nav_link(controller: :dashboard, html_options: {class: 'home'}) do - = link_to admin_root_path, title: 'Overview' do + = nav_link(controller: %w(dashboard admin projects users groups), html_options: {class: 'home'}) do + = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview - = nav_link(controller: [:admin, :projects]) do - = link_to admin_namespaces_projects_path, title: 'Projects' do - %span - Projects - = nav_link(controller: :users) do - = link_to admin_users_path, title: 'Users' do - %span - Users - = nav_link(controller: :groups) do - = link_to admin_groups_path, title: 'Groups' do - %span - Groups = nav_link(controller: :deploy_keys) do = link_to admin_deploy_keys_path, title: 'Deploy Keys' do %span From d1c3f3d87258336b6ad50639d4f63647e95958df Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Tue, 14 Jun 2016 20:44:51 -0500 Subject: [PATCH 283/318] Add monitoring link with subtabs --- .../admin/background_jobs/_head.html.haml | 14 +++ .../admin/background_jobs/show.html.haml | 82 ++++++++-------- app/views/admin/health_check/show.html.haml | 93 ++++++++++--------- app/views/admin/logs/show.html.haml | 52 ++++++----- app/views/layouts/nav/_admin.html.haml | 15 +-- 5 files changed, 137 insertions(+), 119 deletions(-) create mode 100644 app/views/admin/background_jobs/_head.html.haml diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/background_jobs/_head.html.haml new file mode 100644 index 00000000000..ce7693bcad8 --- /dev/null +++ b/app/views/admin/background_jobs/_head.html.haml @@ -0,0 +1,14 @@ +%ul.nav-links.sub-nav + %div{ class: (container_class) } + = nav_link(controller: :background_jobs) do + = link_to admin_background_jobs_path, title: 'Background Jobs' do + %span + Background Jobs + = nav_link(controller: :logs) do + = link_to admin_logs_path, title: 'Logs' do + %span + Logs + = nav_link(controller: :health_check) do + = link_to admin_health_check_path, title: 'Health Check' do + %span + Health Check diff --git a/app/views/admin/background_jobs/show.html.haml b/app/views/admin/background_jobs/show.html.haml index de5bc050cf0..654d261aa99 100644 --- a/app/views/admin/background_jobs/show.html.haml +++ b/app/views/admin/background_jobs/show.html.haml @@ -1,46 +1,50 @@ +- @no_container = true - page_title "Background Jobs" -%h3.page-title Background Jobs -%p.light GitLab uses #{link_to "sidekiq", "http://sidekiq.org/"} library for async job processing += render 'admin/background_jobs/head' -%hr +%div{ class: (container_class) } + %h3.page-title Background Jobs + %p.light GitLab uses #{link_to "sidekiq", "http://sidekiq.org/"} library for async job processing -.panel.panel-default - .panel-heading Sidekiq running processes - .panel-body - - if @sidekiq_processes.empty? - %h4.cred - %i.fa.fa-exclamation-triangle - There are no running sidekiq processes. Please restart GitLab - - else - .table-holder - %table.table - %thead - %th USER - %th PID - %th CPU - %th MEM - %th STATE - %th START - %th COMMAND - %tbody - - @sidekiq_processes.each do |process| - - next unless process.match(/(sidekiq \d+\.\d+\.\d+.+$)/) - - data = process.strip.split(' ') - %tr - %td= gitlab_config.user - - 5.times do - %td= data.shift - %td= data.join(' ') + %hr - .clearfix - %p - %i.fa.fa-exclamation-circle - If '[25 of 25 busy]' is shown, restart GitLab with 'sudo service gitlab reload'. - %p - %i.fa.fa-exclamation-circle - If more than one sidekiq process is listed, stop GitLab, kill the remaining sidekiq processes (sudo pkill -u #{gitlab_config.user} -f sidekiq) and restart GitLab. + .panel.panel-default + .panel-heading Sidekiq running processes + .panel-body + - if @sidekiq_processes.empty? + %h4.cred + %i.fa.fa-exclamation-triangle + There are no running sidekiq processes. Please restart GitLab + - else + .table-holder + %table.table + %thead + %th USER + %th PID + %th CPU + %th MEM + %th STATE + %th START + %th COMMAND + %tbody + - @sidekiq_processes.each do |process| + - next unless process.match(/(sidekiq \d+\.\d+\.\d+.+$)/) + - data = process.strip.split(' ') + %tr + %td= gitlab_config.user + - 5.times do + %td= data.shift + %td= data.join(' ') + + .clearfix + %p + %i.fa.fa-exclamation-circle + If '[25 of 25 busy]' is shown, restart GitLab with 'sudo service gitlab reload'. + %p + %i.fa.fa-exclamation-circle + If more than one sidekiq process is listed, stop GitLab, kill the remaining sidekiq processes (sudo pkill -u #{gitlab_config.user} -f sidekiq) and restart GitLab. -.panel.panel-default - %iframe{src: sidekiq_path, width: '100%', height: 970, style: "border: none"} + .panel.panel-default + %iframe{src: sidekiq_path, width: '100%', height: 970, style: "border: none"} diff --git a/app/views/admin/health_check/show.html.haml b/app/views/admin/health_check/show.html.haml index c2313986a7f..7b8407f9152 100644 --- a/app/views/admin/health_check/show.html.haml +++ b/app/views/admin/health_check/show.html.haml @@ -1,49 +1,52 @@ +- @no_container = true - page_title "Health Check" += render 'admin/background_jobs/head' -%h3.page-title - Health Check -.bs-callout.clearfix - .pull-left - %p - Access token is - %code#health-check-token= current_application_settings.health_check_access_token - = button_to reset_health_check_token_admin_application_settings_path, - method: :put, class: 'btn btn-default', - data: { confirm: 'Are you sure you want to reset the health check token?' } do - = icon('refresh') - Reset health check access token -%p.light - Health information can be retrieved as plain text, JSON, or XML using: - %ul - %li - %code= health_check_url(token: current_application_settings.health_check_access_token) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml) +%div{ class: (container_class) } + %h3.page-title + Health Check + .bs-callout.clearfix + .pull-left + %p + Access token is + %code#health-check-token= current_application_settings.health_check_access_token + = button_to reset_health_check_token_admin_application_settings_path, + method: :put, class: 'btn btn-default', + data: { confirm: 'Are you sure you want to reset the health check token?' } do + = icon('refresh') + Reset health check access token + %p.light + Health information can be retrieved as plain text, JSON, or XML using: + %ul + %li + %code= health_check_url(token: current_application_settings.health_check_access_token) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, format: :json) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, format: :xml) -%p.light - You can also ask for the status of specific services: - %ul - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database) - %li - %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations) + %p.light + You can also ask for the status of specific services: + %ul + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :cache) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :database) + %li + %code= health_check_url(token: current_application_settings.health_check_access_token, checks: :migrations) -%hr -.panel.panel-default - .panel-heading - Current Status: - - if @errors.blank? - = icon('circle', class: 'cgreen') - Healthy - - else - = icon('warning', class: 'cred') - Unhealthy - .panel-body - - if @errors.blank? - No Health Problems Detected - - else - = @errors + %hr + .panel.panel-default + .panel-heading + Current Status: + - if @errors.blank? + = icon('circle', class: 'cgreen') + Healthy + - else + = icon('warning', class: 'cred') + Unhealthy + .panel-body + - if @errors.blank? + No Health Problems Detected + - else + = @errors diff --git a/app/views/admin/logs/show.html.haml b/app/views/admin/logs/show.html.haml index 698feb571ac..5ddc3b9ea85 100644 --- a/app/views/admin/logs/show.html.haml +++ b/app/views/admin/logs/show.html.haml @@ -1,28 +1,32 @@ +- @no_container = true - page_title "Logs" - loggers = [Gitlab::GitLogger, Gitlab::AppLogger, Gitlab::ProductionLogger, Gitlab::SidekiqLogger, Gitlab::RepositoryCheckLogger] -%ul.nav-links.log-tabs - - loggers.each do |klass| - %li{ class: (klass == Gitlab::GitLogger ? 'active' : '') } - = link_to klass::file_name, "##{klass::file_name_noext}", - 'data-toggle' => 'tab' -.row-content-block - To prevent performance issues admin logs output the last 2000 lines -.tab-content - - loggers.each do |klass| - .tab-pane{ class: (klass == Gitlab::GitLogger ? 'active' : ''), - id: klass::file_name_noext } - .file-holder#README - .file-title - %i.fa.fa-file - = klass::file_name - .pull-right - = link_to '#', class: 'log-bottom' do - %i.fa.fa-arrow-down - Scroll down - .file-content.logs - %ol - - klass.read_latest.each do |line| - %li - %p= line += render 'admin/background_jobs/head' + +%div{ class: (container_class) } + %ul.nav-links.log-tabs + - loggers.each do |klass| + %li{ class: (klass == Gitlab::GitLogger ? 'active' : '') } + = link_to klass::file_name, "##{klass::file_name_noext}", + 'data-toggle' => 'tab' + .row-content-block + To prevent performance issues admin logs output the last 2000 lines + .tab-content + - loggers.each do |klass| + .tab-pane{ class: (klass == Gitlab::GitLogger ? 'active' : ''), + id: klass::file_name_noext } + .file-holder#README + .file-title + %i.fa.fa-file + = klass::file_name + .pull-right + = link_to '#', class: 'log-bottom' do + %i.fa.fa-arrow-down + Scroll down + .file-content.logs + %ol + - klass.read_latest.each do |line| + %li + %p= line diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index 9d85ec1d6d1..ad25d4908ff 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -16,14 +16,10 @@ = link_to admin_builds_path, title: 'Builds' do %span Builds - = nav_link(controller: :logs) do - = link_to admin_logs_path, title: 'Logs' do + = nav_link(controller: %w(background_jobs logs health_check)) do + = link_to admin_background_jobs_path, title: 'Monitoring' do %span - Logs - = nav_link(controller: :health_check) do - = link_to admin_health_check_path, title: 'Health Check' do - %span - Health Check + Monitoring = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path, title: 'Messages' do %span @@ -32,10 +28,7 @@ = link_to admin_hooks_path, title: 'Hooks' do %span Hooks - = nav_link(controller: :background_jobs) do - = link_to admin_background_jobs_path, title: 'Background Jobs' do - %span - Background Jobs + = nav_link(controller: :appearances) do = link_to admin_appearances_path, title: 'Appearances' do %span From d3b6c18526e72d43cd20db5bb2c69c60320197ce Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 08:07:06 -0500 Subject: [PATCH 284/318] Move builds tab to admin overview --- app/views/admin/builds/index.html.haml | 89 ++++++++++++----------- app/views/admin/dashboard/_head.html.haml | 4 + app/views/layouts/nav/_admin.html.haml | 6 +- 3 files changed, 52 insertions(+), 47 deletions(-) diff --git a/app/views/admin/builds/index.html.haml b/app/views/admin/builds/index.html.haml index d74cf8598e8..efd5b12cfeb 100644 --- a/app/views/admin/builds/index.html.haml +++ b/app/views/admin/builds/index.html.haml @@ -1,49 +1,54 @@ -.top-area - %ul.nav-links - %li{class: ('active' if @scope.nil?)} - = link_to admin_builds_path do - All - %span.badge.js-totalbuilds-count= @all_builds.count(:id) +- @no_container = true += render "admin/dashboard/head" - %li{class: ('active' if @scope == 'running')} - = link_to admin_builds_path(scope: :running) do - Running - %span.badge.js-running-count= number_with_delimiter(@all_builds.running_or_pending.count(:id)) +%div{ class: (container_class) } - %li{class: ('active' if @scope == 'finished')} - = link_to admin_builds_path(scope: :finished) do - Finished - %span.badge.js-running-count= number_with_delimiter(@all_builds.finished.count(:id)) + .top-area + %ul.nav-links + %li{class: ('active' if @scope.nil?)} + = link_to admin_builds_path do + All + %span.badge.js-totalbuilds-count= @all_builds.count(:id) - .nav-controls - - if @all_builds.running_or_pending.any? - = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post + %li{class: ('active' if @scope == 'running')} + = link_to admin_builds_path(scope: :running) do + Running + %span.badge.js-running-count= number_with_delimiter(@all_builds.running_or_pending.count(:id)) -.row-content-block.second-block - #{(@scope || 'all').capitalize} builds + %li{class: ('active' if @scope == 'finished')} + = link_to admin_builds_path(scope: :finished) do + Finished + %span.badge.js-running-count= number_with_delimiter(@all_builds.finished.count(:id)) -%ul.content-list - - if @builds.blank? - %li - .nothing-here-block No builds to show - - else - .table-holder - %table.table.builds - %thead - %tr - %th Status - %th Build ID - %th Project - %th Commit - %th Ref - %th Runner - %th Name - %th Tags - %th Duration - %th Finished at - %th + .nav-controls + - if @all_builds.running_or_pending.any? + = link_to 'Cancel all', cancel_all_admin_builds_path, data: { confirm: 'Are you sure?' }, class: 'btn btn-danger', method: :post - - @builds.each do |build| - = render "admin/builds/build", build: build + .row-content-block.second-block + #{(@scope || 'all').capitalize} builds - = paginate @builds, theme: 'gitlab' + %ul.content-list + - if @builds.blank? + %li + .nothing-here-block No builds to show + - else + .table-holder + %table.table.builds + %thead + %tr + %th Status + %th Build ID + %th Project + %th Commit + %th Ref + %th Runner + %th Name + %th Tags + %th Duration + %th Finished at + %th + + - @builds.each do |build| + = render "admin/builds/build", build: build + + = paginate @builds, theme: 'gitlab' diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index b1adc316b50..ef9d246b2a2 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -4,6 +4,10 @@ = link_to admin_root_path, title: 'Overview' do %span Overview + = nav_link path: 'builds#index' do + = link_to admin_builds_path, title: 'Builds' do + %span + Builds = nav_link(controller: [:admin, :projects]) do = link_to admin_namespaces_projects_path, title: 'Projects' do %span diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index ad25d4908ff..a72f1017132 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -1,6 +1,6 @@ %ul.nav-links.scrolling-tabs .fade-left - = nav_link(controller: %w(dashboard admin projects users groups), html_options: {class: 'home'}) do + = nav_link(controller: %w(dashboard admin projects users groups builds), html_options: {class: 'home'}) do = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview @@ -12,10 +12,6 @@ = link_to admin_runners_path, title: 'Runners' do %span Runners - = nav_link path: 'builds#index' do - = link_to admin_builds_path, title: 'Builds' do - %span - Builds = nav_link(controller: %w(background_jobs logs health_check)) do = link_to admin_background_jobs_path, title: 'Monitoring' do %span From f1245bde894a04fcc83771281d3051a87c5cdab2 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 09:11:17 -0500 Subject: [PATCH 285/318] Nest li elements directly under ul --- app/views/admin/background_jobs/_head.html.haml | 4 ++-- app/views/admin/dashboard/_head.html.haml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/admin/background_jobs/_head.html.haml b/app/views/admin/background_jobs/_head.html.haml index ce7693bcad8..d78682532ed 100644 --- a/app/views/admin/background_jobs/_head.html.haml +++ b/app/views/admin/background_jobs/_head.html.haml @@ -1,5 +1,5 @@ -%ul.nav-links.sub-nav - %div{ class: (container_class) } +.nav-links.sub-nav + %ul{ class: (container_class) } = nav_link(controller: :background_jobs) do = link_to admin_background_jobs_path, title: 'Background Jobs' do %span diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index ef9d246b2a2..617db25a7a6 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -1,5 +1,5 @@ -%ul.nav-links.sub-nav - %div{ class: (container_class) } +.nav-links.sub-nav + %ul{ class: (container_class) } = nav_link(controller: :dashboard, html_options: {class: 'home'}) do = link_to admin_root_path, title: 'Overview' do %span From 3213023dd657ba6c5c6d690fae2ca44a409b16fd Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 16:16:21 +0200 Subject: [PATCH 286/318] Show created_at in table column --- app/views/projects/container_registry/_tag.html.haml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/projects/container_registry/_tag.html.haml b/app/views/projects/container_registry/_tag.html.haml index d5fa07fd180..f35faa6afb5 100644 --- a/app/views/projects/container_registry/_tag.html.haml +++ b/app/views/projects/container_registry/_tag.html.haml @@ -17,11 +17,11 @@ .light \- %td - - if tag.created_at - = time_ago_in_words(tag.created_at) - - else - .light - \- + - if tag.created_at + = time_ago_in_words(tag.created_at) + - else + .light + \- - if can?(current_user, :update_container_image, @project) %td.content .controls.hidden-xs.pull-right From d5efd17d8ae540f2e85b81ae787779c3871d42c4 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> Date: Wed, 15 Jun 2016 17:36:14 +0300 Subject: [PATCH 287/318] Fix admin active tab tests Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> --- features/admin/active_tab.feature | 22 +++++++++++----- features/steps/admin/active_tab.rb | 40 ++++++++++++++---------------- 2 files changed, 34 insertions(+), 28 deletions(-) diff --git a/features/admin/active_tab.feature b/features/admin/active_tab.feature index 5de07e90e28..f5bb06dea7d 100644 --- a/features/admin/active_tab.feature +++ b/features/admin/active_tab.feature @@ -5,28 +5,36 @@ Feature: Admin Active Tab Scenario: On Admin Home Given I visit admin page - Then the active main tab should be Home + Then the active main tab should be Overview And no other main tabs should be active Scenario: On Admin Projects Given I visit admin projects page - Then the active main tab should be Projects + Then the active main tab should be Overview + And the active sub tab should be Projects And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Groups Given I visit admin groups page - Then the active main tab should be Groups + Then the active main tab should be Overview + And the active sub tab should be Groups And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Users Given I visit admin users page - Then the active main tab should be Users + Then the active main tab should be Overview + And the active sub tab should be Users And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Logs Given I visit admin logs page - Then the active main tab should be Logs + Then the active main tab should be Monitoring + And the active sub tab should be Logs And no other main tabs should be active + And no other sub tabs should be active Scenario: On Admin Messages Given I visit admin messages page @@ -40,5 +48,7 @@ Feature: Admin Active Tab Scenario: On Admin Resque Given I visit admin Resque page - Then the active main tab should be Resque + Then the active main tab should be Monitoring + And the active sub tab should be Resque And no other main tabs should be active + And no other sub tabs should be active diff --git a/features/steps/admin/active_tab.rb b/features/steps/admin/active_tab.rb index f2db1801389..9b1689a8198 100644 --- a/features/steps/admin/active_tab.rb +++ b/features/steps/admin/active_tab.rb @@ -1,45 +1,41 @@ class Spinach::Features::AdminActiveTab < Spinach::FeatureSteps include SharedAuthentication include SharedPaths - include SharedSidebarActiveTab + include SharedActiveTab - step 'the active main tab should be Home' do + step 'the active main tab should be Overview' do ensure_active_main_tab('Overview') end - step 'the active main tab should be Projects' do - ensure_active_main_tab('Projects') + step 'the active sub tab should be Projects' do + ensure_active_sub_tab('Projects') end - step 'the active main tab should be Groups' do - ensure_active_main_tab('Groups') + step 'the active sub tab should be Groups' do + ensure_active_sub_tab('Groups') end - step 'the active main tab should be Users' do - ensure_active_main_tab('Users') - end - - step 'the active main tab should be Logs' do - ensure_active_main_tab('Logs') + step 'the active sub tab should be Users' do + ensure_active_sub_tab('Users') end step 'the active main tab should be Hooks' do ensure_active_main_tab('Hooks') end - step 'the active main tab should be Resque' do - ensure_active_main_tab('Background Jobs') + step 'the active main tab should be Monitoring' do + ensure_active_main_tab('Monitoring') + end + + step 'the active sub tab should be Resque' do + ensure_active_sub_tab('Background Jobs') + end + + step 'the active sub tab should be Logs' do + ensure_active_sub_tab('Logs') end step 'the active main tab should be Messages' do ensure_active_main_tab('Messages') end - - step 'no other main tabs should be active' do - expect(page).to have_selector('.nav-sidebar > li.active', count: 1) - end - - def ensure_active_main_tab(content) - expect(find('.nav-sidebar > li.active')).to have_content(content) - end end From fefc3e9e4f476078e0402dd2585c664beda4b98f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Wed, 15 Jun 2016 16:48:42 +0200 Subject: [PATCH 288/318] Make sure that we test RegisterBuildService behavior for deleted projects --- .../ci/register_build_service_spec.rb | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/spec/services/ci/register_build_service_spec.rb b/spec/services/ci/register_build_service_spec.rb index fa4c2fddeb8..f28f2f1438d 100644 --- a/spec/services/ci/register_build_service_spec.rb +++ b/spec/services/ci/register_build_service_spec.rb @@ -45,6 +45,28 @@ module Ci end end + context 'deleted projects' do + before do + project.update(pending_delete: true) + end + + context 'for shared runners' do + before do + project.update(shared_runners_enabled: true) + end + + it 'does not pick a build' do + expect(service.execute(shared_runner)).to be_nil + end + end + + context 'for specific runner' do + it 'does not pick a build' do + expect(service.execute(specific_runner)).to be_nil + end + end + end + context 'allow shared runners' do before do project.update(shared_runners_enabled: true) From 6d10d8251c1acfe59a6fa92e4ff9c780dfbb2a0d Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 09:51:18 -0500 Subject: [PATCH 289/318] Rearrange order of tabs --- app/views/admin/dashboard/_head.html.haml | 8 ++++---- app/views/layouts/nav/_admin.html.haml | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/views/admin/dashboard/_head.html.haml b/app/views/admin/dashboard/_head.html.haml index 617db25a7a6..7b3f88c24df 100644 --- a/app/views/admin/dashboard/_head.html.haml +++ b/app/views/admin/dashboard/_head.html.haml @@ -4,10 +4,6 @@ = link_to admin_root_path, title: 'Overview' do %span Overview - = nav_link path: 'builds#index' do - = link_to admin_builds_path, title: 'Builds' do - %span - Builds = nav_link(controller: [:admin, :projects]) do = link_to admin_namespaces_projects_path, title: 'Projects' do %span @@ -20,3 +16,7 @@ = link_to admin_groups_path, title: 'Groups' do %span Groups + = nav_link path: 'builds#index' do + = link_to admin_builds_path, title: 'Builds' do + %span + Builds diff --git a/app/views/layouts/nav/_admin.html.haml b/app/views/layouts/nav/_admin.html.haml index a72f1017132..54aa34bee0b 100644 --- a/app/views/layouts/nav/_admin.html.haml +++ b/app/views/layouts/nav/_admin.html.haml @@ -4,6 +4,10 @@ = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview + = nav_link(controller: %w(background_jobs logs health_check)) do + = link_to admin_background_jobs_path, title: 'Monitoring' do + %span + Monitoring = nav_link(controller: :deploy_keys) do = link_to admin_deploy_keys_path, title: 'Deploy Keys' do %span @@ -12,10 +16,6 @@ = link_to admin_runners_path, title: 'Runners' do %span Runners - = nav_link(controller: %w(background_jobs logs health_check)) do - = link_to admin_background_jobs_path, title: 'Monitoring' do - %span - Monitoring = nav_link(controller: :broadcast_messages) do = link_to admin_broadcast_messages_path, title: 'Messages' do %span From b4ed272da9a466ffc003d2918bc24c173a4d43ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 15 Jun 2016 16:51:11 +0200 Subject: [PATCH 290/318] Add index on `requested_at` to the `members` table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- ...0160615142710_add_index_on_requested_at_to_members.rb | 9 +++++++++ db/schema.rb | 3 ++- 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20160615142710_add_index_on_requested_at_to_members.rb diff --git a/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb b/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb new file mode 100644 index 00000000000..63f7392e54f --- /dev/null +++ b/db/migrate/20160615142710_add_index_on_requested_at_to_members.rb @@ -0,0 +1,9 @@ +class AddIndexOnRequestedAtToMembers < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + disable_ddl_transaction! + + def change + add_concurrent_index :members, :requested_at + end +end diff --git a/db/schema.rb b/db/schema.rb index e148a3c975d..6a3be7297e3 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: 20160610301627) do +ActiveRecord::Schema.define(version: 20160615142710) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -572,6 +572,7 @@ ActiveRecord::Schema.define(version: 20160610301627) do add_index "members", ["access_level"], name: "index_members_on_access_level", using: :btree add_index "members", ["created_at", "id"], name: "index_members_on_created_at_and_id", using: :btree add_index "members", ["invite_token"], name: "index_members_on_invite_token", unique: true, using: :btree + add_index "members", ["requested_at"], name: "index_members_on_requested_at", using: :btree add_index "members", ["source_id", "source_type"], name: "index_members_on_source_id_and_source_type", using: :btree add_index "members", ["type"], name: "index_members_on_type", using: :btree add_index "members", ["user_id"], name: "index_members_on_user_id", using: :btree From 56ca4859552cc23d5fee88f056952535034e99c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 15 Jun 2016 13:42:46 +0200 Subject: [PATCH 291/318] Fix wrong partial path in JS view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/views/groups/group_members/update.js.haml | 2 +- app/views/projects/project_members/update.js.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/groups/group_members/update.js.haml b/app/views/groups/group_members/update.js.haml index b0b3a51ce58..da71de4cd1e 100644 --- a/app/views/groups/group_members/update.js.haml +++ b/app/views/groups/group_members/update.js.haml @@ -1,2 +1,2 @@ :plain - $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render(@group_member, member: @group_member))}'); + $("##{dom_id(@group_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @group_member))}'); diff --git a/app/views/projects/project_members/update.js.haml b/app/views/projects/project_members/update.js.haml index 2fb3a41d541..45f8ef89060 100644 --- a/app/views/projects/project_members/update.js.haml +++ b/app/views/projects/project_members/update.js.haml @@ -1,2 +1,2 @@ :plain - $("##{dom_id(@project_member)}").replaceWith('#{escape_javascript(render("project_member", member: @project_member))}'); + $("##{dom_id(@project_member)}").replaceWith('#{escape_javascript(render('shared/members/member', member: @project_member))}'); From 0cb7d834f7c428bce4341aef55ac35285cb0071c Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran <alfredo@gitlab.com> Date: Tue, 14 Jun 2016 11:48:42 -0500 Subject: [PATCH 292/318] Add handler icon to prioritized labels --- app/assets/javascripts/LabelManager.js.coffee | 7 +++++-- app/assets/stylesheets/framework/lists.scss | 2 +- app/assets/stylesheets/framework/nav.scss | 6 ++++++ app/assets/stylesheets/pages/labels.scss | 14 ++++++++++++++ app/views/projects/labels/index.html.haml | 8 ++++---- app/views/shared/_label_row.html.haml | 2 ++ 6 files changed, 32 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/LabelManager.js.coffee b/app/assets/javascripts/LabelManager.js.coffee index 365a062bb81..b06bcf0fcbf 100644 --- a/app/assets/javascripts/LabelManager.js.coffee +++ b/app/assets/javascripts/LabelManager.js.coffee @@ -42,10 +42,10 @@ class @LabelManager $from = @prioritizedLabels if $from.find('li').length is 1 - $from.find('.empty-message').show() + $from.find('.empty-message').removeClass('hidden') if not $target.find('li').length - $target.find('.empty-message').hide() + $target.find('.empty-message').addClass('hidden') $label.detach().appendTo($target) @@ -54,6 +54,9 @@ class @LabelManager if action is 'remove' xhr = $.ajax url: url, type: 'DELETE' + + # Restore empty message + $from.find('.empty-message').removeClass('hidden') unless $from.find('li').length else xhr = @savePrioritySort($label, action) diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index b34ec16cdba..a12c0bba44a 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -159,7 +159,7 @@ ul.content-list { background-color: $gray-light; border: dotted 1px $gray-dark; margin: 1px 0; - min-height: 30px; + min-height: 52px; } } } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 829222509f0..7b856db236f 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -242,6 +242,12 @@ } } } + + &.adjust { + .nav-text, .nav-controls { + width: auto; + } + } } .layout-nav { diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index bc65404a741..046c38aba44 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -115,6 +115,13 @@ } } +.draggable-handler { + display: inline-block; + opacity: 0; + transition: opacity .3s; + color: $gray-darkest; +} + .prioritized-labels { margin-bottom: 30px; @@ -122,6 +129,13 @@ display: none; color: $gray-light; } + + li:hover { + .draggable-handler { + display: inline-block; + opacity: 1; + } + } } .other-labels { diff --git a/app/views/projects/labels/index.html.haml b/app/views/projects/labels/index.html.haml index 6e1baa46b05..aa4d69550ec 100644 --- a/app/views/projects/labels/index.html.haml +++ b/app/views/projects/labels/index.html.haml @@ -4,9 +4,10 @@ = render "projects/issues/head" %div{ class: (container_class) } - .top-area + .top-area.adjust .nav-text - Labels can be applied to issues and merge requests. + Labels can be applied to issues and merge requests. Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging. + .nav-controls - if can?(current_user, :admin_label, @project) = link_to new_namespace_project_label_path(@project.namespace, @project), class: "btn btn-new" do @@ -19,10 +20,9 @@ .prioritized-labels{ class: ('hide' if hide) } %h5 Prioritized Labels %ul.content-list.manage-labels-list.js-prioritized-labels{ "data-url" => set_priorities_namespace_project_labels_path(@project.namespace, @project) } + %p.empty-message{ class: ('hidden' unless @prioritized_labels.empty?) } No prioritized labels yet - if @prioritized_labels.present? = render @prioritized_labels - - else - %p.empty-message No prioritized labels yet .other-labels - if can?(current_user, :admin_label, @project) %h5{ class: ('hide' if hide) } Other Labels diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index 478c04318c6..77676454b57 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -1,5 +1,7 @@ %span.label-row - if can?(current_user, :admin_label, @project) + .draggable-handler + = icon('bars') .js-toggle-priority.toggle-priority{ data: { url: remove_priority_namespace_project_label_path(@project.namespace, @project, label), dom_id: dom_id(label) } } %button.add-priority.btn.has-tooltip{ title: 'Prioritize', :'data-placement' => 'top' } From 415b032ba1d003bb407581ce3069c95ac178bfd4 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Wed, 15 Jun 2016 00:15:46 +0300 Subject: [PATCH 293/318] Prevent default disabled buttons and links. --- CHANGELOG | 1 + app/assets/javascripts/application.js.coffee | 1 + .../javascripts/lib/common_utils.js.coffee | 7 +++++ spec/javascripts/application_spec.js.coffee | 30 +++++++++++++++++++ .../fixtures/application.html.haml | 2 ++ 5 files changed, 41 insertions(+) create mode 100644 spec/javascripts/application_spec.js.coffee create mode 100644 spec/javascripts/fixtures/application.html.haml diff --git a/CHANGELOG b/CHANGELOG index a215d794670..bb5bde9b08b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -77,6 +77,7 @@ v 8.9.0 (unreleased) - RepositoryCheck::SingleRepositoryWorker public and private methods are now instrumented - Improve issuables APIs performance when accessing notes !4471 - External links now open in a new tab + - Prevent default actions of disabled buttons and links - Markdown editor now correctly resets the input value on edit cancellation !4175 - Toggling a task list item in a issue/mr description does not creates a Todo for mentions - Improved UX of date pickers on issue & milestone forms diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 69d4c4f5dd3..6c16f89cef6 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -125,6 +125,7 @@ window.onload = -> setTimeout shiftWindow, 100 $ -> + gl.utils.preventDisabledButtons() bootstrapBreakpoint = bp.getBreakpointSize() $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") diff --git a/app/assets/javascripts/lib/common_utils.js.coffee b/app/assets/javascripts/lib/common_utils.js.coffee index 5e3a802f45f..4f1779b8483 100644 --- a/app/assets/javascripts/lib/common_utils.js.coffee +++ b/app/assets/javascripts/lib/common_utils.js.coffee @@ -32,5 +32,12 @@ .attr 'title', newTitle .tooltip 'fixTitle' + gl.utils.preventDisabledButtons = -> + + $('.btn').click (e) -> + if $(this).hasClass 'disabled' + e.preventDefault() + e.stopImmediatePropagation() + return false ) window diff --git a/spec/javascripts/application_spec.js.coffee b/spec/javascripts/application_spec.js.coffee new file mode 100644 index 00000000000..8af39c41f2f --- /dev/null +++ b/spec/javascripts/application_spec.js.coffee @@ -0,0 +1,30 @@ +#= require lib/common_utils + +describe 'Application', -> + describe 'disable buttons', -> + fixture.preload('application.html') + + beforeEach -> + fixture.load('application.html') + + it 'should prevent default action for disabled buttons', -> + + gl.utils.preventDisabledButtons() + + isClicked = false + $button = $ '#test-button' + + $button.click -> isClicked = true + $button.trigger 'click' + + expect(isClicked).toBe false + + + it 'should be on the same page if a disabled link clicked', -> + + locationBeforeLinkClick = window.location.href + gl.utils.preventDisabledButtons() + + $('#test-link').click() + + expect(window.location.href).toBe locationBeforeLinkClick diff --git a/spec/javascripts/fixtures/application.html.haml b/spec/javascripts/fixtures/application.html.haml new file mode 100644 index 00000000000..3fc6114407d --- /dev/null +++ b/spec/javascripts/fixtures/application.html.haml @@ -0,0 +1,2 @@ +%a#test-link.btn.disabled{:href => "/foo"} Test link +%button#test-button.btn.disabled Test Button From bcbe9b4de8776dbbaee6e374200de395cae3c61a Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> Date: Wed, 15 Jun 2016 18:47:43 +0300 Subject: [PATCH 294/318] Fix admin hooks spec Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> --- spec/features/admin/admin_hooks_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/features/admin/admin_hooks_spec.rb b/spec/features/admin/admin_hooks_spec.rb index 7265cdac7a7..31633817d53 100644 --- a/spec/features/admin/admin_hooks_spec.rb +++ b/spec/features/admin/admin_hooks_spec.rb @@ -12,9 +12,11 @@ describe "Admin::Hooks", feature: true do describe "GET /admin/hooks" do it "should be ok" do visit admin_root_path - page.within ".sidebar-wrapper" do + + page.within ".layout-nav" do click_on "Hooks" end + expect(current_path).to eq(admin_hooks_path) end From b21980bff48de425a3994cb3914650d06d48e486 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 15 Jun 2016 17:25:48 +0200 Subject: [PATCH 295/318] Fix permission checks in member row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/helpers/members_helper.rb | 6 ------ app/views/shared/members/_member.html.haml | 5 +++-- spec/helpers/members_helper_spec.rb | 16 ---------------- 3 files changed, 3 insertions(+), 24 deletions(-) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index a53828ef4e7..877c77050be 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -6,12 +6,6 @@ module MembersHelper "#{action}_#{member.type.underscore}".to_sym end - def can_see_member_roles?(source:, user: nil) - return false unless user - - user.is_admin? || source.members.exists?(user_id: user.id) - end - def remove_member_message(member, user: nil) user = current_user if defined?(current_user) diff --git a/app/views/shared/members/_member.html.haml b/app/views/shared/members/_member.html.haml index c69d4cbfbe3..0191814849a 100644 --- a/app/views/shared/members/_member.html.haml +++ b/app/views/shared/members/_member.html.haml @@ -1,4 +1,5 @@ -- show_roles = local_assigns.fetch(:show_roles, true) +- default_show_roles = can?(current_user, action_member_permission(:update, member), member) || can?(current_user, action_member_permission(:destroy, member), member) +- show_roles = local_assigns.fetch(:show_roles, default_show_roles) - show_controls = local_assigns.fetch(:show_controls, true) - user = member.user @@ -36,7 +37,7 @@ method: :post, class: 'btn-xs btn' - - if show_roles && can_see_member_roles?(source: member.source, user: current_user) + - if show_roles %span.pull-right %strong= member.human_access - if show_controls diff --git a/spec/helpers/members_helper_spec.rb b/spec/helpers/members_helper_spec.rb index 0b1a76156e0..7998209b7b0 100644 --- a/spec/helpers/members_helper_spec.rb +++ b/spec/helpers/members_helper_spec.rb @@ -9,22 +9,6 @@ describe MembersHelper do it { expect(action_member_permission(:admin, group_member)).to eq :admin_group_member } end - describe '#can_see_member_roles?' do - let(:project) { create(:empty_project) } - let(:group) { create(:group) } - let(:user) { build(:user) } - let(:admin) { build(:user, :admin) } - let(:project_member) { create(:project_member, project: project) } - let(:group_member) { create(:group_member, group: group) } - - it { expect(can_see_member_roles?(source: project, user: nil)).to be_falsy } - it { expect(can_see_member_roles?(source: group, user: nil)).to be_falsy } - it { expect(can_see_member_roles?(source: project, user: admin)).to be_truthy } - it { expect(can_see_member_roles?(source: group, user: admin)).to be_truthy } - it { expect(can_see_member_roles?(source: project, user: project_member.user)).to be_truthy } - it { expect(can_see_member_roles?(source: group, user: group_member.user)).to be_truthy } - end - describe '#remove_member_message' do let(:requester) { build(:user) } let(:project) { create(:project) } From e3529d543225dac3867ba7273cb9b3275c7a097f Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 17:23:49 +0100 Subject: [PATCH 296/318] Pinned sidebar navigation option Closes #18542 --- app/assets/javascripts/application.js.coffee | 26 +++- app/assets/javascripts/sidebar.js.coffee | 8 +- .../stylesheets/framework/gitlab-theme.scss | 7 +- app/assets/stylesheets/framework/header.scss | 5 - app/assets/stylesheets/framework/sidebar.scss | 121 ++++++++++-------- app/helpers/nav_helper.rb | 17 ++- app/views/layouts/_collapse_button.html.haml | 7 +- app/views/layouts/_page.html.haml | 7 +- app/views/layouts/header/_default.html.haml | 2 +- 9 files changed, 124 insertions(+), 76 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 69d4c4f5dd3..030ef3a60b7 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -127,7 +127,7 @@ window.onload = -> $ -> bootstrapBreakpoint = bp.getBreakpointSize() - $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") + $(".nav-sidebar").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") # Click a .js-select-on-focus field, select the contents $(".js-select-on-focus").on "focusin", -> @@ -257,3 +257,27 @@ $ -> gl.awardsHandler = new AwardsHandler() checkInitialSidebarSize() new Aside() + + # Sidenav pinning + if bootstrapBreakpoint isnt 'lg' and $.cookie('pin_nav') is 'true' + $.cookie('pin_nav', 'false') + $('.page-with-sidebar') + .toggleClass('page-sidebar-collapsed page-sidebar-expanded') + .removeClass('page-sidebar-pinned') + $('.navbar-fixed-top').removeClass('header-pinned-nav') + + $(document) + .off 'click', '.js-nav-pin' + .on 'click', '.js-nav-pin', (e) -> + e.preventDefault() + + $(this).toggleClass 'is-active' + + if $.cookie('pin_nav') is 'true' + $.cookie 'pin_nav', 'false' + $('.page-with-sidebar').removeClass('page-sidebar-pinned') + $('.navbar-fixed-top').removeClass('header-pinned-nav') + else + $.cookie 'pin_nav', 'true' + $('.page-with-sidebar').addClass('page-sidebar-pinned') + $('.navbar-fixed-top').addClass('header-pinned-nav') diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index 2ce63c16428..e7471893d2e 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -3,10 +3,14 @@ expanded = 'page-sidebar-expanded' toggleSidebar = -> $('.page-with-sidebar').toggleClass("#{collapsed} #{expanded}") - $('header').toggleClass("header-collapsed header-expanded") + $('.navbar-fixed-top').toggleClass("header-collapsed header-expanded") + + if $.cookie('pin_nav') is 'true' + $('.navbar-fixed-top').toggleClass('header-pinned-nav') + $('.page-with-sidebar').toggleClass('page-sidebar-pinned') setTimeout ( -> - niceScrollBars = $('.nicescroll').niceScroll(); + niceScrollBars = $('.nav-sidebar').niceScroll(); niceScrollBars.updateScrollBar(); ), 300 diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index 408d4a68e1e..bb09de4121f 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -8,14 +8,9 @@ */ @mixin gitlab-theme($color-light, $color, $color-darker, $color-dark) { .page-with-sidebar { - - .collapse-nav a { + .collapse-nav { color: $color-light; background: $color; - - &:hover { - color: $white-light; - } } .sidebar-wrapper { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 63996ea44f6..595b541379a 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -79,14 +79,9 @@ header { &.header-collapsed { padding: 0 16px; - - .side-nav-toggle { - display: block; - } } .side-nav-toggle { - display: none; position: absolute; left: -10px; margin: 6px 0; diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 4668e7e911b..64b2725abfa 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -6,8 +6,6 @@ position: fixed; top: 0; bottom: 0; - overflow-y: auto; - overflow-x: hidden; left: 0; height: 100%; transition-duration: .3s; @@ -17,6 +15,11 @@ .sidebar-wrapper { z-index: 1000; background: $background-color; + + .nicescroll-rails-hr { + // TODO: Figure out why nicescroll doesn't hide horizontal bar + display: none!important; + } } .content-wrapper { @@ -34,22 +37,19 @@ } } -.sidebar-wrapper { +.sidebar-user { + padding: 15px; + position: absolute; + left: 0; + bottom: 0; + width: $sidebar_width; + overflow: hidden; + transition-duration: .3s; - .sidebar-user { - padding: 15px 22px; - position: fixed; - bottom: 0; - width: $sidebar_width; - overflow: hidden; - transition-duration: .3s; - - .username { - margin-left: 10px; - width: $sidebar_width - 2 * 10px; - font-size: 16px; - line-height: 34px; - } + .username { + margin-left: 10px; + font-size: 16px; + line-height: 36px; } } @@ -65,19 +65,19 @@ .nav-sidebar { - margin-top: 22 + $header-height; - margin-bottom: 116px; + position: absolute; + top: 50px; + bottom: 65px; + width: 100%; transition-duration: .3s; - list-style: none; - overflow: hidden; + overflow-y: auto; + overflow-x: hidden; &.navbar-collapse { padding: 0 !important; } li { - width: $sidebar_width; - &.separate-item { padding-top: 10px; margin-top: 10px; @@ -90,14 +90,14 @@ } a { - width: $sidebar_width; - padding: 7px 15px 7px 23px; + padding: 7px 15px 7px 12px; font-size: $gl-font-size; line-height: 24px; display: block; text-decoration: none; font-weight: normal; outline: none; + white-space: nowrap; &:hover { text-decoration: none; @@ -138,28 +138,47 @@ } } -.collapse-nav a { - width: $sidebar_width; - position: fixed; +.collapse-nav { + width: 100%; + position: absolute;; top: 0; left: 0; padding: 5px 0; font-size: 18px; background: transparent; - height: 50px; - text-align: center; - line-height: 40px; - transition-duration: .3s; - outline: none; +} - &:hover { +.nav-header-btn { + padding: 10px 5px; + color: inherit; + transition-duration: .3s; + + &:hover, + &:focus { + color: $white-light; text-decoration: none; } } -.sidebar-wrapper { - &.hidden-nav { - width: 0; +.toggle-nav-collapse { + position: relative; + left: 10px; + line-height: 40px; +} + +.pin-nav-btn { + position: absolute; + right: 10px; + top: 2px; + + .fa { + transition: transform .15s; + } + + &.is-active { + .fa { + transform: rotate(90deg); + } } } @@ -204,27 +223,23 @@ } .page-sidebar-expanded { - - @media (max-width: $screen-sm-max) { - padding-left: 0; - } - .sidebar-wrapper { width: $sidebar_width; + } +} - .nav-sidebar { - width: $sidebar_width; +.page-sidebar-pinned { + .content-wrapper, + .layout-nav { + @media (min-width: $screen-lg-min) { + padding-left: $sidebar_width; } + } +} - .nav-sidebar li a { - width: $sidebar_width; - - &.back-link { - i { - opacity: 0; - } - } - } +header.header-pinned-nav { + @media (min-width: $screen-lg-min) { + padding-left: ($sidebar_width + $gl-padding); } } diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index 469accf3142..d53ee3c45df 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -12,10 +12,10 @@ module NavHelper end def page_sidebar_class - if nav_menu_collapsed? - "page-sidebar-collapsed" + if pinned_nav? + "page-sidebar-expanded page-sidebar-pinned" else - "page-sidebar-expanded" + "page-sidebar-collapsed" end end @@ -37,6 +37,13 @@ module NavHelper def nav_header_class class_name = " with-horizontal-nav" if defined?(nav) && nav + + if pinned_nav? + class_name << " header-expanded header-pinned-nav" + else + class_name << " header-collapsed" + end + class_name end @@ -47,4 +54,8 @@ module NavHelper def nav_control_class "nav-control" if current_user end + + def pinned_nav? + cookies[:pin_nav] == 'true' + end end diff --git a/app/views/layouts/_collapse_button.html.haml b/app/views/layouts/_collapse_button.html.haml index e4fab897377..5442ee4efe3 100644 --- a/app/views/layouts/_collapse_button.html.haml +++ b/app/views/layouts/_collapse_button.html.haml @@ -1 +1,6 @@ -= link_to icon('bars'), '#', class: 'toggle-nav-collapse', title: "Open/Close" += link_to '#', class: 'nav-header-btn toggle-nav-collapse', title: "Open/Close" do + %span.sr-only Toggle navigation + = icon('bars') += link_to '#', class: "nav-header-btn pin-nav-btn #{'is-active' if pinned_nav?} visible-lg-block js-nav-pin", title: 'Pin/Unpin navigation' do + %span.sr-only Toggle navigation pinning + = icon('thumb-tack') diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index f89e8582792..90e872c461d 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,6 +1,7 @@ -.page-with-sidebar.page-sidebar-collapsed{ class: "#{page_gutter_class}" } +.page-with-sidebar{ class: "#{page_sidebar_class} #{page_gutter_class}" } .sidebar-wrapper.nicescroll{ class: nav_sidebar_class } - + %header.collapse-nav + = render partial: 'layouts/collapse_button' - if defined?(sidebar) && sidebar = render "layouts/nav/#{sidebar}" - elsif current_user @@ -8,8 +9,6 @@ - else = render 'layouts/nav/explore' - .collapse-nav - = render partial: 'layouts/collapse_button' - if current_user = link_to current_user, class: 'sidebar-user', title: "Profile", data: {user: current_user.username} do = image_tag avatar_icon(current_user, 60), alt: 'Profile', class: 'avatar avatar s36' diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index ef31520f5cb..40a2c81eebd 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -1,4 +1,4 @@ -%header.navbar.navbar-fixed-top.navbar-gitlab.header-collapsed{ class: nav_header_class } +%header.navbar.navbar-fixed-top.navbar-gitlab{ class: nav_header_class } %div{ class: fluid_layout ? "container-fluid" : "container-fluid" } .header-content %button.side-nav-toggle{type: 'button'} From 8a9164bf04fe20bfee9ea7923c655f4600e88c7f Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Wed, 8 Jun 2016 18:10:46 +0200 Subject: [PATCH 297/318] Set inverse_of for Project/Services relation This ensures that code such as this don't run needless SQL queries: project.gitlab_issue_tracker_service.project This also means that if the root `project` eager loads any associations the Service object will be able to re-use those. --- CHANGELOG | 1 + app/models/project.rb | 2 +- app/models/service.rb | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index bb5bde9b08b..b767996bc82 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -99,6 +99,7 @@ v 8.9.0 (unreleased) - Remove tanuki logo from side navigation; center on top nav - Include user relationships when retrieving award_emoji - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed + - Set inverse_of for Project/Service association to reduce the number of queries v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/app/models/project.rb b/app/models/project.rb index fdbc84474ed..0bb815e64e7 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -81,7 +81,7 @@ class Project < ActiveRecord::Base has_one :jira_service, dependent: :destroy has_one :redmine_service, dependent: :destroy has_one :custom_issue_tracker_service, dependent: :destroy - has_one :gitlab_issue_tracker_service, dependent: :destroy + has_one :gitlab_issue_tracker_service, dependent: :destroy, inverse_of: :project has_one :external_wiki_service, dependent: :destroy has_one :forked_project_link, dependent: :destroy, foreign_key: "forked_to_project_id" diff --git a/app/models/service.rb b/app/models/service.rb index bf352397509..40d39933ad8 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -18,7 +18,7 @@ class Service < ActiveRecord::Base after_commit :reset_updated_properties after_commit :cache_project_has_external_issue_tracker - belongs_to :project + belongs_to :project, inverse_of: :services has_one :service_hook validates :project_id, presence: true, unless: Proc.new { |service| service.template? } From 9d74eb462298dc553bdaae81cd6476d6c5a1952c Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 18:14:20 +0100 Subject: [PATCH 298/318] Increased speed of sidebar transition --- app/assets/javascripts/application.js.coffee | 8 +- app/assets/stylesheets/framework/header.scss | 16 +--- app/assets/stylesheets/framework/nav.scss | 2 +- app/assets/stylesheets/framework/sidebar.scss | 83 +++++-------------- .../stylesheets/framework/variables.scss | 1 + 5 files changed, 30 insertions(+), 80 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 030ef3a60b7..704911aa13d 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -275,8 +275,12 @@ $ -> if $.cookie('pin_nav') is 'true' $.cookie 'pin_nav', 'false' - $('.page-with-sidebar').removeClass('page-sidebar-pinned') - $('.navbar-fixed-top').removeClass('header-pinned-nav') + $('.page-with-sidebar') + .removeClass('page-sidebar-pinned') + .toggleClass('page-sidebar-collapsed page-sidebar-expanded') + $('.navbar-fixed-top') + .removeClass('header-pinned-nav') + .toggleClass('header-collapsed header-expanded') else $.cookie 'pin_nav', 'true' $('.page-with-sidebar').addClass('page-sidebar-pinned') diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 595b541379a..dca4dbb9f7d 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -3,7 +3,7 @@ * */ header { - transition-duration: .3s; + transition: padding $sidebar-transition-duration; &.navbar-empty { height: $header-height; @@ -103,9 +103,7 @@ header { .header-content { position: relative; height: $header-height; - padding-right: 40px; padding-left: 30px; - transition-duration: .3s; @media (min-width: $screen-sm-min) { padding-right: 0; @@ -193,18 +191,6 @@ header { } } -.header-collapsed { - margin-left: 0; - - .header-content { - - @media (min-width: $screen-sm-max) { - padding-left: 30px; - transition-duration: .3s; - } - } -} - .tanuki-shape { transition: all 0.8s; diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index 829222509f0..c1a860b0d74 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -251,7 +251,7 @@ z-index: 11; background: $background-color; border-bottom: 1px solid $border-color; - transition-duration: .3s; + transition: padding $sidebar-transition-duration; text-align: center; .container-fluid { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 64b2725abfa..1ac11989d7f 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -1,6 +1,6 @@ .page-with-sidebar { padding-top: $header-height; - transition-duration: .3s; + transition: padding $sidebar-transition-duration; .sidebar-wrapper { position: fixed; @@ -8,7 +8,8 @@ bottom: 0; left: 0; height: 100%; - transition-duration: .3s; + overflow: hidden; + transition: width $sidebar-transition-duration; } } @@ -24,6 +25,7 @@ .content-wrapper { width: 100%; + transition: padding $sidebar-transition-duration; .container-fluid { background: #fff; @@ -44,13 +46,9 @@ bottom: 0; width: $sidebar_width; overflow: hidden; - transition-duration: .3s; - - .username { - margin-left: 10px; - font-size: 16px; - line-height: 36px; - } + font-size: 16px; + line-height: 36px; + transition: width $sidebar-transition-duration, padding $sidebar-transition-duration; } @@ -68,8 +66,7 @@ position: absolute; top: 50px; bottom: 65px; - width: 100%; - transition-duration: .3s; + width: $sidebar_width; overflow-y: auto; overflow-x: hidden; @@ -99,11 +96,9 @@ outline: none; white-space: nowrap; - &:hover { - text-decoration: none; - } - - &:active, &:focus { + &:hover, + &:active, + &:focus { text-decoration: none; } @@ -115,10 +110,6 @@ svg { margin-right: 13px; } - - &.back-link i { - transition-duration: .3s; - } } } @@ -129,20 +120,12 @@ } } -.sidebar-subnav { - margin-left: 0; - padding-left: 0; - - li { - list-style: none; - } -} - .collapse-nav { width: 100%; - position: absolute;; + position: absolute; top: 0; left: 0; + min-height: 50px; padding: 5px 0; font-size: 18px; background: transparent; @@ -187,38 +170,6 @@ .sidebar-wrapper { width: 0; - - .nav-sidebar { - width: 0; - - li { - width: auto; - - a { - span { - display: none; - } - } - } - } - - .collapse-nav a { - width: 0; - - i { - display: none; - } - } - - .sidebar-user { - width: 0; - padding-left: 0; - padding-right: 0; - - .username { - display: none; - } - } } } @@ -240,6 +191,14 @@ header.header-pinned-nav { @media (min-width: $screen-lg-min) { padding-left: ($sidebar_width + $gl-padding); + + .side-nav-toggle { + display: none; + } + + .header-content { + padding-left: 0; + } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 752d8ec8788..670edb9300d 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -6,6 +6,7 @@ $sidebar_width: 220px; $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 258px; +$sidebar-transition-duration: .15s; /* * UI elements From 3fe4a2f525375a353755e0620988c33c85cd9f9e Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg <zegerjan@gitlab.com> Date: Thu, 2 Jun 2016 19:36:10 +0000 Subject: [PATCH 299/318] Fix race condition on auto merge --- CHANGELOG | 1 + .../projects/merge_requests_controller.rb | 15 ++++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index bb5bde9b08b..7d34937a066 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -99,6 +99,7 @@ v 8.9.0 (unreleased) - Remove tanuki logo from side navigation; center on top nav - Include user relationships when retrieving award_emoji - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed + - Fix race condition on merge when build succeeds v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 67e7187c10d..49b1f3cec32 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -205,9 +205,18 @@ class Projects::MergeRequestsController < Projects::ApplicationController @merge_request.update(merge_error: nil) if params[:merge_when_build_succeeds].present? && @merge_request.pipeline && @merge_request.pipeline.active? - MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params) - .execute(@merge_request) - @status = :merge_when_build_succeeds + if @merge_request.ci_commit.active? + MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params) + .execute(@merge_request) + @status = :merge_when_build_succeeds + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time + elsif @merge_request.ci_commit.success? + MergeWorker.perform_async(@merge_request.id, current_user.id, params) + @status = :success + else + @status = :failed + end else MergeWorker.perform_async(@merge_request.id, current_user.id, params) @status = :success From 17ad286e5db45c2d0d39fdceb8f201fe2e780a25 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" <zegerjan@gitlab.com> Date: Thu, 9 Jun 2016 11:54:48 +0200 Subject: [PATCH 300/318] Rename ci_commit to pipeline --- CHANGELOG | 2 +- app/controllers/projects/merge_requests_controller.rb | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 7d34937a066..15adf758477 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -42,6 +42,7 @@ v 8.9.0 (unreleased) - Add DB index on users.state - Add rake task 'gitlab:db:configure' for conditionally seeding or migrating the database - Changed the Slack build message to use the singular duration if necessary (Aran Koning) + - Fix race condition on merge when build succeeds - Links from a wiki page to other wiki pages should be rewritten as expected - Add option to project to only allow merge requests to be merged if the build succeeds (Rui Santos) - Fix issues filter when ordering by milestone @@ -99,7 +100,6 @@ v 8.9.0 (unreleased) - Remove tanuki logo from side navigation; center on top nav - Include user relationships when retrieving award_emoji - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed - - Fix race condition on merge when build succeeds v 8.8.5 (unreleased) - Ensure branch cleanup regardless of whether the GitHub import process succeeds diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 49b1f3cec32..851822d805a 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -204,14 +204,14 @@ class Projects::MergeRequestsController < Projects::ApplicationController @merge_request.update(merge_error: nil) - if params[:merge_when_build_succeeds].present? && @merge_request.pipeline && @merge_request.pipeline.active? - if @merge_request.ci_commit.active? + if params[:merge_when_build_succeeds].present? + if @merge_request.pipeline && @merge_request.pipeline.active? MergeRequests::MergeWhenBuildSucceedsService.new(@project, current_user, merge_params) .execute(@merge_request) @status = :merge_when_build_succeeds - # This can be triggered when a user clicks the auto merge button while - # the tests finish at about the same time - elsif @merge_request.ci_commit.success? + elsif @merge_request.pipeline.success? + # This can be triggered when a user clicks the auto merge button while + # the tests finish at about the same time MergeWorker.perform_async(@merge_request.id, current_user.id, params) @status = :success else From 7d9157ff47c1380492a64aa3c7a1e1a7fa6b8e37 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 18:33:18 +0100 Subject: [PATCH 301/318] Clicking body closes nav Fixed issue when nav wasn't present --- app/assets/javascripts/sidebar.js.coffee | 16 ++++++++++++++++ app/helpers/nav_helper.rb | 3 ++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index e7471893d2e..68009e58645 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -14,6 +14,22 @@ toggleSidebar = -> niceScrollBars.updateScrollBar(); ), 300 +$(document) + .off 'click', 'body' + .on 'click', 'body', (e) -> + unless $.cookie('pin_nav') is 'true' + $target = $(e.target) + $nav = $target.closest('.sidebar-wrapper') + pageExpanded = $('.page-with-sidebar').hasClass('page-sidebar-expanded') + $toggle = $target.closest('.toggle-nav-collapse, .side-nav-toggle') + + if $nav.length is 0 and pageExpanded and $toggle.length is 0 + $('.page-with-sidebar') + .toggleClass('page-sidebar-collapsed page-sidebar-expanded') + + $('.navbar-fixed-top') + .toggleClass('header-collapsed header-expanded') + $(document).on("click", '.toggle-nav-collapse, .side-nav-toggle', (e) -> e.preventDefault() diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index d53ee3c45df..3ff8be5e284 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -36,7 +36,8 @@ module NavHelper end def nav_header_class - class_name = " with-horizontal-nav" if defined?(nav) && nav + class_name = '' + class_name << " with-horizontal-nav" if defined?(nav) && nav if pinned_nav? class_name << " header-expanded header-pinned-nav" From 6064bccaed7a1d5f54daf221982453f4140047df Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 15 Jun 2016 11:38:47 -0600 Subject: [PATCH 302/318] Hide the Todo button in the collapsed issuable sidebar. --- app/assets/stylesheets/pages/issuable.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index f57845ad9c9..2a1f0d1d87e 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -248,11 +248,16 @@ padding-bottom: 0; margin-bottom: 10px; } + + .issuable-header-btn { + display: none; + } } .issuable-header-btn { background: $gray-normal; border: 1px solid $border-gray-normal; + &:hover { background: $gray-dark; border: 1px solid $border-gray-dark; From 7886692147ac76e749729505ab368782e76f174e Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Wed, 15 Jun 2016 19:35:37 +0100 Subject: [PATCH 303/318] Moved pinned button to the bottom Changed breakpoint width to 1440px --- app/assets/javascripts/application.js.coffee | 2 +- .../stylesheets/framework/gitlab-theme.scss | 7 +++- app/assets/stylesheets/framework/sidebar.scss | 36 ++++++++++++------- .../stylesheets/framework/variables.scss | 1 + app/views/layouts/_collapse_button.html.haml | 5 +-- app/views/layouts/_page.html.haml | 6 ++-- 6 files changed, 36 insertions(+), 21 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 704911aa13d..bd835436a03 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -259,7 +259,7 @@ $ -> new Aside() # Sidenav pinning - if bootstrapBreakpoint isnt 'lg' and $.cookie('pin_nav') is 'true' + if $(window).width() < 1440 and $.cookie('pin_nav') is 'true' $.cookie('pin_nav', 'false') $('.page-with-sidebar') .toggleClass('page-sidebar-collapsed page-sidebar-expanded') diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index bb09de4121f..0a8603b6702 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -8,9 +8,14 @@ */ @mixin gitlab-theme($color-light, $color, $color-darker, $color-dark) { .page-with-sidebar { - .collapse-nav { + .toggle-nav-collapse, + .pin-nav-btn { color: $color-light; background: $color; + + &:hover { + color: $white-light; + } } .sidebar-wrapper { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 1ac11989d7f..281c0a0e1e9 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -49,6 +49,10 @@ font-size: 16px; line-height: 36px; transition: width $sidebar-transition-duration, padding $sidebar-transition-duration; + + @media (min-width: $sidebar-breakpoint) { + bottom: 50px; + } } @@ -70,6 +74,10 @@ overflow-y: auto; overflow-x: hidden; + @media (min-width: $sidebar-breakpoint) { + bottom: 115px; + } + &.navbar-collapse { padding: 0 !important; } @@ -120,15 +128,15 @@ } } -.collapse-nav { - width: 100%; +.toggle-nav-collapse { + width: $sidebar_width; position: absolute; top: 0; left: 0; min-height: 50px; padding: 5px 0; font-size: 18px; - background: transparent; + line-height: 30px; } .nav-header-btn { @@ -143,16 +151,18 @@ } } -.toggle-nav-collapse { - position: relative; - left: 10px; - line-height: 40px; -} - .pin-nav-btn { + display: none; position: absolute; - right: 10px; - top: 2px; + left: 0; + bottom: 0; + height: 50px; + width: $sidebar_width; + line-height: 30px; + + @media (min-width: $sidebar-breakpoint) { + display: block; + } .fa { transition: transform .15s; @@ -182,14 +192,14 @@ .page-sidebar-pinned { .content-wrapper, .layout-nav { - @media (min-width: $screen-lg-min) { + @media (min-width: $sidebar-breakpoint) { padding-left: $sidebar_width; } } } header.header-pinned-nav { - @media (min-width: $screen-lg-min) { + @media (min-width: $sidebar-breakpoint) { padding-left: ($sidebar_width + $gl-padding); .side-nav-toggle { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 670edb9300d..acada1f16a0 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -7,6 +7,7 @@ $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 258px; $sidebar-transition-duration: .15s; +$sidebar-breakpoint: 1440px; /* * UI elements diff --git a/app/views/layouts/_collapse_button.html.haml b/app/views/layouts/_collapse_button.html.haml index 5442ee4efe3..8c140a5943e 100644 --- a/app/views/layouts/_collapse_button.html.haml +++ b/app/views/layouts/_collapse_button.html.haml @@ -1,6 +1,3 @@ -= link_to '#', class: 'nav-header-btn toggle-nav-collapse', title: "Open/Close" do += link_to '#', class: 'nav-header-btn text-center toggle-nav-collapse', title: "Open/Close" do %span.sr-only Toggle navigation = icon('bars') -= link_to '#', class: "nav-header-btn pin-nav-btn #{'is-active' if pinned_nav?} visible-lg-block js-nav-pin", title: 'Pin/Unpin navigation' do - %span.sr-only Toggle navigation pinning - = icon('thumb-tack') diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 90e872c461d..199ab3c38c3 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -1,7 +1,6 @@ .page-with-sidebar{ class: "#{page_sidebar_class} #{page_gutter_class}" } .sidebar-wrapper.nicescroll{ class: nav_sidebar_class } - %header.collapse-nav - = render partial: 'layouts/collapse_button' + = render partial: 'layouts/collapse_button' - if defined?(sidebar) && sidebar = render "layouts/nav/#{sidebar}" - elsif current_user @@ -14,6 +13,9 @@ = image_tag avatar_icon(current_user, 60), alt: 'Profile', class: 'avatar avatar s36' .username = current_user.username + = link_to '#', class: "nav-header-btn text-center pin-nav-btn #{'is-active' if pinned_nav?} js-nav-pin", title: 'Pin/Unpin navigation' do + %span.sr-only Toggle navigation pinning + = icon('thumb-tack') - if defined?(nav) && nav .layout-nav .container-fluid From 01d9bffdd82c098a8c2e368e39a590e5c753dbc7 Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 15 Jun 2016 12:43:32 -0600 Subject: [PATCH 304/318] Improve New Project page for mobile. Separate the New Project page's "Project path" grouped fields into separate fields. Fixes #18599. --- app/assets/stylesheets/pages/projects.scss | 10 +++------- app/views/projects/new.html.haml | 22 +++++++++------------- 2 files changed, 12 insertions(+), 20 deletions(-) diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 0e4cefc55c2..c85d23a31f0 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -5,10 +5,12 @@ font-weight: normal; } } + .no-ssh-key-message, .project-limit-message { background-color: #f28d35; margin-bottom: 0; } + .new_project, .edit-project { fieldset.features { @@ -18,13 +20,6 @@ } } -.project-name-holder { - .help-inline { - vertical-align: top; - padding: 7px; - } -} - .project-home-panel { background: $white-light; text-align: left; @@ -376,6 +371,7 @@ a.deploy-project-label { .project-import .btn { float: left; + margin-bottom: 10px; margin-right: 10px; } diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index f9ac16b32f3..47a2f2889d8 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -11,21 +11,17 @@ .project-edit-content = form_for @project, html: { class: 'new_project form-horizontal js-requires-input' } do |f| - .form-group.project-name-holder + .form-group = f.label :path, class: 'control-label' do - Project path + Project owner .col-sm-10 - .input-group - - if current_user.can_select_namespace? - .input-group-addon - = root_url - = f.select :namespace_id, namespaces_options(params[:namespace_id] || :current_user, display_path: true), {}, {class: 'select2 js-select-namespace', tabindex: 1} - .input-group-addon - \/ - - else - .input-group-addon - #{root_url}#{current_user.username}/ - = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true + = f.select :namespace_id, namespaces_options(:current_user), {}, {class: 'select2 js-select-namespace', tabindex: 1} + + .form-group + = f.label :path, class: 'control-label' do + Project name + .col-sm-10 + = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true - if current_user.can_create_group? .help-block From fbc91599a8fcd8bcd189d81a136b1bcc2c989fae Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 15 Jun 2016 12:45:27 -0600 Subject: [PATCH 305/318] Fix test. --- features/steps/dashboard/new_project.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/features/steps/dashboard/new_project.rb b/features/steps/dashboard/new_project.rb index a0aad66184d..5308e77fb19 100644 --- a/features/steps/dashboard/new_project.rb +++ b/features/steps/dashboard/new_project.rb @@ -10,7 +10,8 @@ class Spinach::Features::NewProject < Spinach::FeatureSteps end step 'I see "New Project" page' do - expect(page).to have_content('Project path') + expect(page).to have_content('Project owner') + expect(page).to have_content('Project name') end step 'I see all possible import optios' do From c69715bafa28964a71f65dfeba892b85c8cf73c9 Mon Sep 17 00:00:00 2001 From: Stan Hu <stanhu@gmail.com> Date: Wed, 15 Jun 2016 11:50:17 -0700 Subject: [PATCH 306/318] Update CHANGELOG for 8.8.5 release [ci skip] --- CHANGELOG | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b916f880eeb..fa9cba510a7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -102,14 +102,16 @@ v 8.9.0 (unreleased) - Various associations are now eager loaded when parsing issue references to reduce the number of queries executed - Set inverse_of for Project/Service association to reduce the number of queries -v 8.8.5 (unreleased) - - Ensure branch cleanup regardless of whether the GitHub import process succeeds - - Fix todos page throwing errors when you have a project pending deletion - - Reduce number of SQL queries when rendering user references - - Import GitHub repositories respecting the API rate limit - - Fix importer for GitHub comments on diff - - Disable Webhooks before proceeding with the GitHub import - - Fix incremental trace upload API when using multi-byte UTF-8 chars in trace +v 8.8.5 + - Import GitHub repositories respecting the API rate limit !4166 + - Fix todos page throwing errors when you have a project pending deletion !4300 + - Disable Webhooks before proceeding with the GitHub import !4470 + - Fix importer for GitHub comments on diff !4488 + - Adjust the SAML control flow to allow LDAP identities to be added to an existing SAML user !4498 + - Fix incremental trace upload API when using multi-byte UTF-8 chars in trace !4541 + - Prevent unauthorized access for projects build traces + - Forbid scripting for wiki files + - Only show notes through JSON on confidential issues that the user has access to v 8.8.4 - Fix LDAP-based login for users with 2FA enabled. !4493 From 190685741c47aff3ec53fb55308a36b46d1ef8d2 Mon Sep 17 00:00:00 2001 From: Connor Shea <connor.james.shea@gmail.com> Date: Wed, 15 Jun 2016 12:55:05 -0600 Subject: [PATCH 307/318] Move group creation text to below the 'Project owner' field. --- app/views/projects/new.html.haml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 47a2f2889d8..7e8b8f83467 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -17,17 +17,17 @@ .col-sm-10 = f.select :namespace_id, namespaces_options(:current_user), {}, {class: 'select2 js-select-namespace', tabindex: 1} + - if current_user.can_create_group? + .help-block + Want to house several dependent projects under the same namespace? + = link_to "Create a group", new_group_path + .form-group = f.label :path, class: 'control-label' do Project name .col-sm-10 = f.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true - - if current_user.can_create_group? - .help-block - Want to house several dependent projects under the same namespace? - = link_to "Create a group", new_group_path - - if import_sources_enabled? .project-import.js-toggle-container .form-group From 44df30c4c0dc5960bf6f9f0175fc5c6b3b57328f Mon Sep 17 00:00:00 2001 From: Jacob Schatz <jschatz@gitlab.com> Date: Wed, 15 Jun 2016 21:18:04 +0000 Subject: [PATCH 308/318] Revert "Merge branch '18047-event-item-links-dont-look-like-links' into 'master'" This reverts merge request !4544 --- app/assets/stylesheets/pages/events.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index dde189a21d5..6fe57c737b3 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -21,7 +21,7 @@ } a { - color: $gl-link-color; + color: $gl-dark-link-color; } .avatar { From 8033afd817826a1d9e00ae189aec64870224c2a6 Mon Sep 17 00:00:00 2001 From: Drew Blessing <drew@blessing.io> Date: Wed, 15 Jun 2016 21:38:12 +0000 Subject: [PATCH 309/318] Update migration_style_guide.md with new details --- doc/development/migration_style_guide.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/doc/development/migration_style_guide.md b/doc/development/migration_style_guide.md index 02e024ca15a..8a7547e5322 100644 --- a/doc/development/migration_style_guide.md +++ b/doc/development/migration_style_guide.md @@ -34,6 +34,15 @@ First, you need to provide information on whether the migration can be applied: 3. online with errors on new instances while migrating 4. offline (needs to happen without app servers to prevent db corruption) +For example: + +``` +# rubocop:disable all +# Migration type: online without errors (works on previous version and new one) +class MyMigration < ActiveRecord::Migration +... +``` + It is always preferable to have a migration run online. If you expect the migration to take particularly long (for instance, if it loops through all notes), this is valuable information to add. @@ -48,7 +57,6 @@ be possible to downgrade in case of a vulnerability or bugs. In your migration, add a comment describing how the reversibility of the migration was tested. - ## Removing indices If you need to remove index, please add a condition like in following example: @@ -70,6 +78,7 @@ so: ``` class MyMigration < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers disable_ddl_transaction! def change @@ -90,8 +99,11 @@ value of `10` you'd write the following: ``` class MyMigration < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + def up - add_column_with_default(:projects, :foo, :integer, 10) + add_column_with_default(:projects, :foo, :integer, default: 10) end def down From 8d6cfd79221a689415cbe7a86fd6308d19ab56d2 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 18:39:45 -0300 Subject: [PATCH 310/318] Update CHANGELOG for 8.2.6 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index faf2e28eeb3..18fb2e6e1c1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -966,6 +966,10 @@ v 8.3.0 - Expose Git's version in the admin area - Show "New Merge Request" buttons on canonical repos when you have a fork (Josh Frye) +v 8.2.6 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + v 8.2.5 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From 2c26ba42c02fc866a3892c379853153005272e6f Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:08:47 -0300 Subject: [PATCH 311/318] Update CHANGELOG for 8.3.10 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 18fb2e6e1c1..8c4deceef97 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -848,6 +848,10 @@ v 8.4.0 - Add IP check against DNSBLs at account sign-up - Added cache:key to .gitlab-ci.yml allowing to fine tune the caching +v 8.3.10 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + v 8.3.9 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From 510b2522f6fdd932ae1f7409748c76da1025579a Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:10:44 -0300 Subject: [PATCH 312/318] Update CHANGELOG for 8.4.11 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 8c4deceef97..bbb4fdd135f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -712,6 +712,10 @@ v 8.5.0 - Show label row when filtering issues or merge requests by label (Nuttanart Pornprasitsakul) - Add Todos +v 8.4.11 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + v 8.4.10 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From 77554eee57b848cf5c5a0fe3ac47a7fc9afde127 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:11:34 -0300 Subject: [PATCH 313/318] Update CHANGELOG for 8.5.13 release [ci skip] --- CHANGELOG | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index bbb4fdd135f..b6758c7dfb3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -551,6 +551,10 @@ v 8.6.0 - Trigger a todo for mentions on commits page - Let project owners and admins soft delete issues and merge requests +v 8.5.13 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + v 8.5.12 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From cb9bab945b1338d593dada812714abe4e7f33fd7 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:13:33 -0300 Subject: [PATCH 314/318] Update CHANGELOG for 8.6.9 release [skip] --- CHANGELOG | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index b6758c7dfb3..2a414c3ab41 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -397,6 +397,11 @@ v 8.7.0 - Add RAW build trace output and button on build page - Add incremental build trace update into CI API +v 8.6.9 + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + - Only show notes through JSON on confidential issues that the user has access to + v 8.6.8 - Prevent privilege escalation via "impersonate" feature - Prevent privilege escalation via notes API From 1f81137bb47843a518dd8dc3c2bc4b5f6ef180e5 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Wed, 15 Jun 2016 19:16:02 -0300 Subject: [PATCH 315/318] Update CHANGELOG for 8.7.7 release [ci skip] --- CHANGELOG | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 2a414c3ab41..03b9178da3b 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -235,6 +235,9 @@ v 8.8.0 v 8.7.7 - Fix import by `Any Git URL` broken if the URL contains a space + - Prevent unauthorized access to other projects build traces + - Forbid scripting for wiki files + - Only show notes through JSON on confidential issues that the user has access to v 8.7.6 - Fix links on wiki pages for relative url setups. !4131 (Artem Sidorenko) From dda94e04062623702f03d427b17931a7c93f64c5 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski <ayufan@ayufan.eu> Date: Thu, 16 Jun 2016 10:43:47 +0200 Subject: [PATCH 316/318] Make project_id nullable --- ...616084004_change_project_of_environment.rb | 21 +++++++++++++++++++ db/schema.rb | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20160616084004_change_project_of_environment.rb diff --git a/db/migrate/20160616084004_change_project_of_environment.rb b/db/migrate/20160616084004_change_project_of_environment.rb new file mode 100644 index 00000000000..cc1daf9b621 --- /dev/null +++ b/db/migrate/20160616084004_change_project_of_environment.rb @@ -0,0 +1,21 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class ChangeProjectOfEnvironment < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def change + change_column_null :environments, :project_id, true + end +end diff --git a/db/schema.rb b/db/schema.rb index 6a3be7297e3..d6a542a89fd 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: 20160615142710) do +ActiveRecord::Schema.define(version: 20160616084004) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" From 84e2be5a5f3f020f1c57b013e82143ff90e48e58 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Wed, 15 Jun 2016 15:22:05 +0200 Subject: [PATCH 317/318] Turn Group#owners into a has_many association This allows the owners to be eager loaded where needed. --- app/models/group.rb | 10 ++++++---- spec/models/group_spec.rb | 12 ++++++++++++ 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/models/group.rb b/app/models/group.rb index b8dffe9f5b9..e66e04371b2 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -9,6 +9,12 @@ class Group < Namespace has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members has_many :users, -> { where(members: { requested_at: nil }) }, through: :group_members + + has_many :owners, + -> { where(members: { access_level: Gitlab::Access::OWNER }) }, + through: :group_members, + source: :user + has_many :project_group_links, dependent: :destroy has_many :shared_projects, through: :project_group_links, source: :project has_many :notification_settings, dependent: :destroy, as: :source @@ -88,10 +94,6 @@ class Group < Namespace end end - def owners - @owners ||= group_members.owners.includes(:user).map(&:user) - end - def add_users(user_ids, access_level, current_user = nil) user_ids.each do |user_id| Member.add_user(self.group_members, user_id, access_level, current_user) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index ccdcb29f773..2c19aa3f67f 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -158,6 +158,18 @@ describe Group, models: true do it { expect(group.has_master?(@members[:requester])).to be_falsey } end + describe '#owners' do + let(:owner) { create(:user) } + let(:developer) { create(:user) } + + it 'returns the owners of a Group' do + group.add_owner(owner) + group.add_developer(developer) + + expect(group.owners).to eq([owner]) + end + end + def setup_group_members(group) members = { owner: create(:user), From 46696bde83736a83ec6f54f05795b003793b5865 Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Wed, 15 Jun 2016 19:00:50 +0200 Subject: [PATCH 318/318] Banzai::Filter::UploadLinkFilter use XPath --- CHANGELOG | 1 + lib/banzai/filter/upload_link_filter.rb | 11 +++------- .../banzai/filter/upload_link_filter_spec.rb | 20 +++++++++++++++++-- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fa0960b2847..39532e88138 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -118,6 +118,7 @@ v 8.8.5 - Prevent unauthorized access for projects build traces - Forbid scripting for wiki files - Only show notes through JSON on confidential issues that the user has access to + - Banzai::Filter::UploadLinkFilter use XPath instead CSS expressions v 8.8.4 - Fix LDAP-based login for users with 2FA enabled. !4493 diff --git a/lib/banzai/filter/upload_link_filter.rb b/lib/banzai/filter/upload_link_filter.rb index c0f503c9af3..45bb66dc99f 100644 --- a/lib/banzai/filter/upload_link_filter.rb +++ b/lib/banzai/filter/upload_link_filter.rb @@ -10,11 +10,11 @@ module Banzai def call return doc unless project - doc.search('a').each do |el| + doc.xpath('descendant-or-self::a[starts-with(@href, "/uploads/")]').each do |el| process_link_attr el.attribute('href') end - doc.search('img').each do |el| + doc.xpath('descendant-or-self::img[starts-with(@src, "/uploads/")]').each do |el| process_link_attr el.attribute('src') end @@ -24,12 +24,7 @@ module Banzai protected def process_link_attr(html_attr) - return if html_attr.blank? - - uri = html_attr.value - if uri.starts_with?("/uploads/") - html_attr.value = build_url(uri).to_s - end + html_attr.value = build_url(html_attr.value).to_s end def build_url(uri) diff --git a/spec/lib/banzai/filter/upload_link_filter_spec.rb b/spec/lib/banzai/filter/upload_link_filter_spec.rb index b83be54746c..273d2ed709a 100644 --- a/spec/lib/banzai/filter/upload_link_filter_spec.rb +++ b/spec/lib/banzai/filter/upload_link_filter_spec.rb @@ -23,6 +23,14 @@ describe Banzai::Filter::UploadLinkFilter, lib: true do %(<a href="#{path}">#{path}</a>) end + def nested_image(path) + %(<div><img src="#{path}" /></div>) + end + + def nested_link(path) + %(<div><a href="#{path}">#{path}</a></div>) + end + let(:project) { create(:project) } shared_examples :preserve_unchanged do @@ -47,11 +55,19 @@ describe Banzai::Filter::UploadLinkFilter, lib: true do doc = filter(link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) expect(doc.at_css('a')['href']). to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" + + doc = filter(nested_link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) + expect(doc.at_css('a')['href']). + to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" end it 'rebuilds relative URL for an image' do - doc = filter(link('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) - expect(doc.at_css('a')['href']). + doc = filter(image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) + expect(doc.at_css('img')['src']). + to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" + + doc = filter(nested_image('/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg')) + expect(doc.at_css('img')['src']). to eq "#{Gitlab.config.gitlab.url}/#{project.path_with_namespace}/uploads/e90decf88d8f96fe9e1389afc2e4a91f/test.jpg" end