From 14964308dc4ca0d62dd12231529abbcfe940f603 Mon Sep 17 00:00:00 2001 From: Arinde Eniola Date: Tue, 3 May 2016 12:07:06 +0100 Subject: add gfm autocomplete for labels --- app/assets/javascripts/gfm_auto_complete.js.coffee | 25 ++++++++++++++++++++++ app/controllers/projects_controller.rb | 1 + app/services/projects/autocomplete_service.rb | 4 ++++ 3 files changed, 30 insertions(+) diff --git a/app/assets/javascripts/gfm_auto_complete.js.coffee b/app/assets/javascripts/gfm_auto_complete.js.coffee index 61e3f811e73..6d3f4ec9753 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.coffee +++ b/app/assets/javascripts/gfm_auto_complete.js.coffee @@ -14,6 +14,10 @@ GitLab.GfmAutoComplete = Members: template: '
  • ${username} ${title}
  • ' + Labels: + template: '
  • ${title}
    +
  • ' + # Issues and MergeRequests Issues: template: '
  • ${id} ${title}
  • ' @@ -94,6 +98,25 @@ GitLab.GfmAutoComplete = title: sanitize(m.title) search: "#{m.iid} #{m.title}" + @input.atwho + at: '~' + alias: 'labels' + searchKey: 'search' + displayTpl: @Labels.template + insertTpl: '${atwho-at}${title}' + callbacks: + beforeSave: (merges) -> + sanitizeLabelTitle = (title)-> + if /\w+\s+\w+/g.test(title) + "\"#{sanitize(title)}\"" + else + sanitize(title) + + $.map merges, (m) -> + title: sanitizeLabelTitle(m.title) + color: m.color + search: "#{m.title}" + destroyAtWho: -> @input.atwho('destroy') @@ -109,3 +132,5 @@ GitLab.GfmAutoComplete = @input.atwho 'load', 'mergerequests', data.mergerequests # load emojis @input.atwho 'load', ':', data.emojis + # load labels + @input.atwho 'load', '~', data.labels diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 3768efe142a..4d7a3bfe642 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -148,6 +148,7 @@ class ProjectsController < Projects::ApplicationController emojis: AwardEmoji.urls, issues: autocomplete.issues, mergerequests: autocomplete.merge_requests, + labels: autocomplete.labels, members: participants } diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index ba50305dbd5..826b10899d0 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -7,5 +7,9 @@ module Projects def merge_requests @project.merge_requests.opened.select([:iid, :title]) end + + def labels + @project.labels.select([:title, :color]) + end end end -- cgit v1.2.1 From 68da2ac9a0fa6f33b46f0bfbc849043605c2d878 Mon Sep 17 00:00:00 2001 From: Arinde Eniola Date: Tue, 3 May 2016 12:09:13 +0100 Subject: add entry to CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index b6527780bbf..670f7fa3abd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -12,6 +12,7 @@ v 8.8.0 (unreleased) - Display informative message when new milestone is created - Replace Devise Async with Devise ActiveJob integration. !3902 (Connor Shea) - Allow "NEWS" and "CHANGES" as alternative names for CHANGELOG. !3768 (Connor Shea) + - Added Gfm autocomplete for labels - Added button to toggle whitespaces changes on diff view - Backport GitLab Enterprise support from EE - Files over 5MB can only be viewed in their raw form, files over 1MB without highlighting !3718 -- cgit v1.2.1 From af2f56f8f742e00ddb298fadea763fd0fe7054f0 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 12:39:15 +0200 Subject: Refactor ci commit pipeline to prevent implicit saves --- app/models/ci/commit.rb | 16 ++++----------- app/services/ci/create_pipeline_service.rb | 24 ++++++++++------------ app/services/create_commit_builds_service.rb | 2 +- spec/services/create_commit_builds_service_spec.rb | 2 +- 4 files changed, 17 insertions(+), 27 deletions(-) diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index f22b573a94c..c682f3e570e 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -13,7 +13,7 @@ module Ci validate :valid_commit_sha # Invalidate object and save if when touched - after_touch :update_state + after_touch :update_state! def self.truncate_sha(sha) sha[0...8] @@ -135,10 +135,10 @@ module Ci @config_processor ||= begin Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace) rescue Ci::GitlabCiYamlProcessor::ValidationError, Psych::SyntaxError => e - save_yaml_error(e.message) + self.yaml_errors = (e.message) nil rescue - save_yaml_error("Undefined error") + self.yaml_errors = 'Undefined error' nil end end @@ -159,9 +159,7 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end - private - - def update_state + def update_state! statuses.reload self.status = if yaml_errors.blank? statuses.latest.status || 'skipped' @@ -173,11 +171,5 @@ module Ci self.duration = statuses.latest.duration save end - - def save_yaml_error(error) - return if self.yaml_errors? - self.yaml_errors = error - update_state - end end end diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 5bc0c31cb42..0336b767de5 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -8,7 +8,9 @@ module Ci return pipeline end - unless commit + if commit + pipeline.sha = commit.id + else pipeline.errors.add(:base, 'Commit not found') return pipeline end @@ -18,22 +20,18 @@ module Ci return pipeline end - begin - Ci::Commit.transaction do - pipeline.sha = commit.id + unless pipeline.config_processor + pipeline.errors.add(:base, pipeline.yaml_errors || 'Missing .gitlab-ci.yml file') + return pipeline + end - unless pipeline.config_processor - pipeline.errors.add(:base, pipeline.yaml_errors || 'Missing .gitlab-ci.yml file') - raise ActiveRecord::Rollback - end + pipeline.save! - pipeline.save! - pipeline.create_builds(current_user) - end - rescue - pipeline.errors.add(:base, 'The pipeline could not be created. Please try again.') + unless pipeline.create_builds(current_user) + pipeline.errors.add(:base, 'No builds for this pipeline.') end + pipeline.update_state! pipeline end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 5b6fefe669e..ee84023e514 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -34,7 +34,7 @@ class CreateCommitBuildsService commit.create_builds(user) end - commit.touch + commit.update_state! commit end end diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index 9ae8f31b372..e643991e0b9 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -81,7 +81,7 @@ describe CreateCommitBuildsService, services: true do expect(commit.yaml_errors).not_to be_nil end - describe :ci_skip? do + context 'when commit contains a [ci skip] directive' do let(:message) { "some message[ci skip]" } before do -- cgit v1.2.1 From 1bcb61dd2076995b5fed786133f94def1fd637a5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 13:38:30 +0200 Subject: Add specs covering case when there are no builds --- spec/services/create_commit_builds_service_spec.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index e643991e0b9..8c6b602ac83 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -171,5 +171,23 @@ describe CreateCommitBuildsService, services: true do expect(commit.status).to eq("failed") expect(commit.builds.any?).to be false end + + context 'when there are no jobs for this pipeline' do + before do + config = YAML.dump({ test: { deploy: 'ls', only: ['feature'] } }) + stub_ci_commit_yaml_file(config) + end + + it 'does not create a new pipeline' do + result = service.execute(project, user, + ref: 'refs/heads/master', + before: '00000000', + after: '31das312', + commits: [{ message: 'some msg'}]) + + expect(result).to be false + expect(Ci::Build.all).to be_empty + end + end end end -- cgit v1.2.1 From 0d19abf450d26fa76a23aaae38d392ecdef4e1e0 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 13:52:19 +0200 Subject: Add minor improvements in create builds service --- app/models/ci/commit.rb | 2 +- app/services/ci/create_builds_service.rb | 9 ++------- lib/ci/gitlab_ci_yaml_processor.rb | 5 ++++- spec/services/create_commit_builds_service_spec.rb | 2 +- 4 files changed, 8 insertions(+), 10 deletions(-) diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index c682f3e570e..ccd26959ad1 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -135,7 +135,7 @@ module Ci @config_processor ||= begin Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace) rescue Ci::GitlabCiYamlProcessor::ValidationError, Psych::SyntaxError => e - self.yaml_errors = (e.message) + self.yaml_errors = e.message nil rescue self.yaml_errors = 'Undefined error' diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index 18274ce24e2..7fb2ad7e061 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -2,10 +2,11 @@ module Ci class CreateBuildsService def initialize(commit) @commit = commit + @config = commit.config_processor end def execute(stage, user, status, trigger_request = nil) - builds_attrs = config_processor.builds_for_stage_and_ref(stage, @commit.ref, @commit.tag, trigger_request) + builds_attrs = @config.builds_for_stage_and_ref(stage, @commit.ref, @commit.tag, trigger_request) # check when to create next build builds_attrs = builds_attrs.select do |build_attrs| @@ -41,11 +42,5 @@ module Ci end end end - - private - - def config_processor - @config_processor ||= @commit.config_processor - end end end diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 026a5ac97ca..fcc8af16488 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -27,7 +27,10 @@ module Ci end def builds_for_stage_and_ref(stage, ref, tag = false, trigger_request = nil) - builds.select{|build| build[:stage] == stage && process?(build[:only], build[:except], ref, tag, trigger_request)} + builds.select do |build| + build[:stage] == stage && + process?(build[:only], build[:except], ref, tag, trigger_request) + end end def builds diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index 8c6b602ac83..dc915e9dd77 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -183,7 +183,7 @@ describe CreateCommitBuildsService, services: true do ref: 'refs/heads/master', before: '00000000', after: '31das312', - commits: [{ message: 'some msg'}]) + commits: [{ message: 'some msg'} ]) expect(result).to be false expect(Ci::Build.all).to be_empty -- cgit v1.2.1 From 53fe06efde46acd2df62f818c421ecf3a0b971c9 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 14:41:48 +0200 Subject: Update ci commit pipeline specs according to changes --- spec/models/ci/commit_spec.rb | 16 ++++++++-------- spec/services/create_commit_builds_service_spec.rb | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index 22f8639e5ab..0939eb946ac 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -346,9 +346,9 @@ describe Ci::Commit, models: true do end end - describe '#update_state' do - it 'execute update_state after touching object' do - expect(commit).to receive(:update_state).and_return(true) + describe '#update_state!' do + it 'execute update_state! after touching object' do + expect(commit).to receive(:update_state!).and_return(true) commit.touch end @@ -356,17 +356,17 @@ describe Ci::Commit, models: true do let(:commit_status) { build :commit_status, commit: commit } it 'execute update_state after saving dependent object' do - expect(commit).to receive(:update_state).and_return(true) + expect(commit).to receive(:update_state!).and_return(true) commit_status.save end end context 'update state' do let(:current) { Time.now.change(usec: 0) } - let(:build) { FactoryGirl.create :ci_build, :success, commit: commit, started_at: current - 120, finished_at: current - 60 } - - before do - build + let!(:build) do + create :ci_build, :success, commit: commit, + started_at: current - 120, + finished_at: current - 60 end [:status, :started_at, :finished_at, :duration].each do |param| diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index dc915e9dd77..b116e3e8fb4 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -183,7 +183,7 @@ describe CreateCommitBuildsService, services: true do ref: 'refs/heads/master', before: '00000000', after: '31das312', - commits: [{ message: 'some msg'} ]) + commits: [{ message: 'some msg' }]) expect(result).to be false expect(Ci::Build.all).to be_empty -- cgit v1.2.1 From 07af37a243ea0d6b5741754ea116044ee46614b3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 2 Jun 2016 15:55:38 +0200 Subject: Do not create pipeline objects when no builds --- app/services/ci/create_builds_service.rb | 41 +++++++++++++++----------- app/services/create_commit_builds_service.rb | 10 +++---- spec/services/ci/create_builds_service_spec.rb | 6 +++- 3 files changed, 34 insertions(+), 23 deletions(-) diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index 7fb2ad7e061..a02b0b8f9b3 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -20,26 +20,33 @@ module Ci end end + # don't create the same build twice + builds_attrs.reject! do |build_attrs| + @commit.builds.find_by(ref: @commit.ref, tag: @commit.tag, + trigger_request: trigger_request, + name: build_attrs[:name]) + end + builds_attrs.map do |build_attrs| - # don't create the same build twice - unless @commit.builds.find_by(ref: @commit.ref, tag: @commit.tag, - trigger_request: trigger_request, name: build_attrs[:name]) - build_attrs.slice!(:name, - :commands, - :tag_list, - :options, - :allow_failure, - :stage, - :stage_idx) + build_attrs.slice!(:name, + :commands, + :tag_list, + :options, + :allow_failure, + :stage, + :stage_idx) - build_attrs.merge!(ref: @commit.ref, - tag: @commit.tag, - trigger_request: trigger_request, - user: user, - project: @commit.project) + build_attrs.merge!(ref: @commit.ref, + tag: @commit.tag, + trigger_request: trigger_request, + user: user, + project: @commit.project) - @commit.builds.create!(build_attrs) - end + ## + # We do not persist new builds here. + # Those will be persisted when @commit is saved. + # + @commit.builds.new(build_attrs) end end end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index ee84023e514..3f048157b3f 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -25,16 +25,16 @@ class CreateCommitBuildsService return false end - # Create a new ci_commit - commit.save! # Skip creating builds for commits that have [ci skip] unless commit.skip_ci? - # Create builds for commit - commit.create_builds(user) + # Create builds for commit and + # skip saving pipeline when there are no builds + return false unless commit.create_builds(user) end - commit.update_state! + # Create a new ci_commit + commit.save! commit end end diff --git a/spec/services/ci/create_builds_service_spec.rb b/spec/services/ci/create_builds_service_spec.rb index ecc3a88a262..8e737fd44f9 100644 --- a/spec/services/ci/create_builds_service_spec.rb +++ b/spec/services/ci/create_builds_service_spec.rb @@ -9,7 +9,7 @@ describe Ci::CreateBuildsService, services: true do # subject do - described_class.new(commit).execute(commit, nil, user, status) + described_class.new(commit).execute('test', user, status, nil) end context 'next builds available' do @@ -17,6 +17,10 @@ describe Ci::CreateBuildsService, services: true do it { is_expected.to be_an_instance_of Array } it { is_expected.to all(be_an_instance_of Ci::Build) } + + it 'does not persist created builds' do + expect(subject.first).not_to be_persisted + end end context 'builds skipped' do -- cgit v1.2.1 From c6bce7e63c305d07dbc91d032df9c783e0cf0c9f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 2 Jun 2016 17:17:23 +0200 Subject: Save Ci::Commit object to persist all created builds --- app/models/ci/build.rb | 5 ++++- app/models/ci/commit.rb | 6 ++++-- app/services/ci/create_pipeline_service.rb | 2 +- app/services/ci/create_trigger_request_service.rb | 1 + spec/models/ci/commit_spec.rb | 8 ++++++-- spec/requests/ci/api/builds_spec.rb | 5 +++++ 6 files changed, 21 insertions(+), 6 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 64723ab6b4b..fd6fba42a34 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -66,7 +66,10 @@ module Ci # We use around_transition to create builds for next stage as soon as possible, before the `after_*` is executed around_transition any => [:success, :failed, :canceled] do |build, block| block.call - build.commit.create_next_builds(build) if build.commit + if build.commit + build.commit.create_next_builds(build) + build.commit.save + end end after_transition any => [:success, :failed, :canceled] do |build| diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index ccd26959ad1..7e6bb4f8c1b 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -13,7 +13,7 @@ module Ci validate :valid_commit_sha # Invalidate object and save if when touched - after_touch :update_state! + after_touch :update_state def self.truncate_sha(sha) sha[0...8] @@ -159,7 +159,9 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end - def update_state! + private + + def update_state statuses.reload self.status = if yaml_errors.blank? statuses.latest.status || 'skipped' diff --git a/app/services/ci/create_pipeline_service.rb b/app/services/ci/create_pipeline_service.rb index 0336b767de5..864415ef747 100644 --- a/app/services/ci/create_pipeline_service.rb +++ b/app/services/ci/create_pipeline_service.rb @@ -31,7 +31,7 @@ module Ci pipeline.errors.add(:base, 'No builds for this pipeline.') end - pipeline.update_state! + pipeline.save pipeline end diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index 993acf11db9..c611a963112 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -15,6 +15,7 @@ module Ci ) if ci_commit.create_builds(nil, trigger_request) + ci_commit.save trigger_request end end diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index 0939eb946ac..07b875e4f88 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -55,11 +55,15 @@ describe Ci::Commit, models: true do let!(:commit) { FactoryGirl.create :ci_commit, project: project, ref: 'master', tag: false } def create_builds(trigger_request = nil) - commit.create_builds(nil, trigger_request) + if commit.create_builds(nil, trigger_request) + commit.save + end end def create_next_builds - commit.create_next_builds(commit.builds.order(:id).last) + if commit.create_next_builds(commit.builds.order(:id).last) + commit.save + end end it 'creates builds' do diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index e5124ea5ea7..7eff8048667 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -22,6 +22,7 @@ describe Ci::API::API do it "should start a build" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) + commit.save build = commit.builds.first post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -58,6 +59,7 @@ describe Ci::API::API do it "returns options" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) + commit.save post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -68,6 +70,7 @@ describe Ci::API::API do it "returns variables" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) + commit.save project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -87,6 +90,7 @@ describe Ci::API::API do trigger_request = FactoryGirl.create(:ci_trigger_request_with_variables, commit: commit, trigger: trigger) commit.create_builds(nil, trigger_request) + commit.save project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -105,6 +109,7 @@ describe Ci::API::API do it "returns dependent builds" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil, nil) + commit.save commit.builds.where(stage: 'test').each(&:success) post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } -- cgit v1.2.1 From 8e811f2c6c4c74a30789ff5213de5ebc28897753 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 2 Jun 2016 21:02:52 +0200 Subject: Update CreateCommitBuildsService to pass tests --- app/services/ci/create_builds_service.rb | 3 +- app/services/create_commit_builds_service.rb | 35 +++++++++++----------- spec/models/ci/commit_spec.rb | 6 ++-- spec/services/create_commit_builds_service_spec.rb | 8 ++--- 4 files changed, 27 insertions(+), 25 deletions(-) diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index a02b0b8f9b3..f458dee49a6 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -36,7 +36,8 @@ module Ci :stage, :stage_idx) - build_attrs.merge!(ref: @commit.ref, + build_attrs.merge!(commit: @commit, + ref: @commit.ref, tag: @commit.tag, trigger_request: trigger_request, user: user, diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 3f048157b3f..aa0ec45be8c 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -1,40 +1,41 @@ class CreateCommitBuildsService def execute(project, user, params) - return false unless project.builds_enabled? + return unless project.builds_enabled? before_sha = params[:checkout_sha] || params[:before] sha = params[:checkout_sha] || params[:after] origin_ref = params[:ref] - unless origin_ref && sha.present? - return false - end - ref = Gitlab::Git.ref_name(origin_ref) tag = Gitlab::Git.tag_ref?(origin_ref) - # Skip branch removal - if sha == Gitlab::Git::BLANK_SHA - return false - end - commit = Ci::Commit.new(project: project, sha: sha, ref: ref, before_sha: before_sha, tag: tag) - # Skip creating ci_commit when no gitlab-ci.yml is found unless commit.ci_yaml_file - return false + commit.errors.add(:base, 'No .gitlab-ci.yml file found') + return commit end + # Make object as skipped + if commit.skip_ci? + commit.status = 'skipped' + commit.save + return commit + end - # Skip creating builds for commits that have [ci skip] - unless commit.skip_ci? - # Create builds for commit and - # skip saving pipeline when there are no builds - return false unless commit.create_builds(user) + # Create builds for commit and + # skip saving pipeline when there are no builds + unless commit.create_builds(user) + # Save object when there are yaml errors + unless commit.yaml_errors.present? + commit.errors.add(:base, 'No builds created') + return commit + end end # Create a new ci_commit commit.save! + commit.touch commit end end diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index 07b875e4f88..d36aca113a1 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -351,8 +351,8 @@ describe Ci::Commit, models: true do end describe '#update_state!' do - it 'execute update_state! after touching object' do - expect(commit).to receive(:update_state!).and_return(true) + it 'execute update_state after touching object' do + expect(commit).to receive(:update_state).and_return(true) commit.touch end @@ -360,7 +360,7 @@ describe Ci::Commit, models: true do let(:commit_status) { build :commit_status, commit: commit } it 'execute update_state after saving dependent object' do - expect(commit).to receive(:update_state!).and_return(true) + expect(commit).to receive(:update_state).and_return(true) commit_status.save end end diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index b116e3e8fb4..e3202c959d9 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -60,7 +60,7 @@ describe CreateCommitBuildsService, services: true do after: '31das312', commits: [{ message: 'Message' }] ) - expect(result).to be_falsey + expect(result).not_to be_persisted expect(Ci::Commit.count).to eq(0) end @@ -174,7 +174,7 @@ describe CreateCommitBuildsService, services: true do context 'when there are no jobs for this pipeline' do before do - config = YAML.dump({ test: { deploy: 'ls', only: ['feature'] } }) + config = YAML.dump({ test: { script: 'ls', only: ['feature'] } }) stub_ci_commit_yaml_file(config) end @@ -184,9 +184,9 @@ describe CreateCommitBuildsService, services: true do before: '00000000', after: '31das312', commits: [{ message: 'some msg' }]) - - expect(result).to be false + expect(result).not_to be_persisted expect(Ci::Build.all).to be_empty + expect(Ci::Commit.count).to eq(0) end end end -- cgit v1.2.1 From 681b472c36d5079b620b93957d62dbacc473bf6f Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 2 Jun 2016 21:04:45 +0200 Subject: Update specs describe --- spec/models/ci/commit_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index d36aca113a1..a4549d40461 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -350,7 +350,7 @@ describe Ci::Commit, models: true do end end - describe '#update_state!' do + describe '#update_state' do it 'execute update_state after touching object' do expect(commit).to receive(:update_state).and_return(true) commit.touch -- cgit v1.2.1 From a21d084ded6cdf5b83163d4d72bb5c636218d091 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 3 Jun 2016 10:01:00 +0200 Subject: Fix specs for pipeline create for merge requests --- spec/features/merge_requests/created_from_fork_spec.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb index edc0bdec3db..16c572b3197 100644 --- a/spec/features/merge_requests/created_from_fork_spec.rb +++ b/spec/features/merge_requests/created_from_fork_spec.rb @@ -34,7 +34,10 @@ feature 'Merge request created from fork' do ref: merge_request.source_branch) end - background { pipeline.create_builds(user) } + background do + pipeline.create_builds(user) + pipeline.save + end scenario 'user visits a pipelines page', js: true do visit_merge_request(merge_request) -- cgit v1.2.1 From d8c4556d3c8623bf48e689f3734c9c35cda34c2f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 3 Jun 2016 11:58:08 +0200 Subject: Refactor code reponsible for creating builds This removes duplications and extracts method that builds build-jobs without persisting those objects, to a separate method. --- app/models/ci/build.rb | 5 +---- app/models/ci/commit.rb | 19 +++++++++++++++---- app/services/ci/create_trigger_request_service.rb | 1 - app/services/create_commit_builds_service.rb | 2 +- .../features/merge_requests/created_from_fork_spec.rb | 5 +---- spec/models/ci/commit_spec.rb | 8 ++------ spec/requests/ci/api/builds_spec.rb | 5 ----- 7 files changed, 20 insertions(+), 25 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index fd6fba42a34..64723ab6b4b 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -66,10 +66,7 @@ module Ci # We use around_transition to create builds for next stage as soon as possible, before the `after_*` is executed around_transition any => [:success, :failed, :canceled] do |build, block| block.call - if build.commit - build.commit.create_next_builds(build) - build.commit.save - end + build.commit.create_next_builds(build) if build.commit end after_transition any => [:success, :failed, :canceled] do |build| diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index 7e6bb4f8c1b..cdab6d5f316 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -89,13 +89,22 @@ module Ci trigger_requests.any? end - def create_builds(user, trigger_request = nil) + def build_builds_for_stage(stage, user, status, trigger_request) + CreateBuildsService.new(self).execute(stage, user, status, trigger_request) + end + + def build_builds(user, status = 'success', trigger_request = nil) return unless config_processor config_processor.stages.any? do |stage| - CreateBuildsService.new(self).execute(stage, user, 'success', trigger_request).present? + build_builds_for_stage(stage, user, status, trigger_request).present? end end + def create_builds(user, trigger_request = nil) + build_builds(user, 'success', trigger_request) + save! + end + def create_next_builds(build) return unless config_processor @@ -112,9 +121,11 @@ module Ci prior_status = prior_builds.status # create builds for next stages based - next_stages.any? do |stage| - CreateBuildsService.new(self).execute(stage, build.user, prior_status, build.trigger_request).present? + have_builds = next_stages.any? do |stage| + build_builds_for_stage(stage, build.user, prior_status, build.trigger_request).present? end + + save! if have_builds end def retried diff --git a/app/services/ci/create_trigger_request_service.rb b/app/services/ci/create_trigger_request_service.rb index c611a963112..993acf11db9 100644 --- a/app/services/ci/create_trigger_request_service.rb +++ b/app/services/ci/create_trigger_request_service.rb @@ -15,7 +15,6 @@ module Ci ) if ci_commit.create_builds(nil, trigger_request) - ci_commit.save trigger_request end end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index aa0ec45be8c..dbfc93ff5bc 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -25,7 +25,7 @@ class CreateCommitBuildsService # Create builds for commit and # skip saving pipeline when there are no builds - unless commit.create_builds(user) + unless commit.build_builds(user) # Save object when there are yaml errors unless commit.yaml_errors.present? commit.errors.add(:base, 'No builds created') diff --git a/spec/features/merge_requests/created_from_fork_spec.rb b/spec/features/merge_requests/created_from_fork_spec.rb index 16c572b3197..edc0bdec3db 100644 --- a/spec/features/merge_requests/created_from_fork_spec.rb +++ b/spec/features/merge_requests/created_from_fork_spec.rb @@ -34,10 +34,7 @@ feature 'Merge request created from fork' do ref: merge_request.source_branch) end - background do - pipeline.create_builds(user) - pipeline.save - end + background { pipeline.create_builds(user) } scenario 'user visits a pipelines page', js: true do visit_merge_request(merge_request) diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index a4549d40461..01d931b087e 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -55,15 +55,11 @@ describe Ci::Commit, models: true do let!(:commit) { FactoryGirl.create :ci_commit, project: project, ref: 'master', tag: false } def create_builds(trigger_request = nil) - if commit.create_builds(nil, trigger_request) - commit.save - end + commit.create_builds(nil, trigger_request) end def create_next_builds - if commit.create_next_builds(commit.builds.order(:id).last) - commit.save - end + commit.create_next_builds(commit.builds.order(:id).last) end it 'creates builds' do diff --git a/spec/requests/ci/api/builds_spec.rb b/spec/requests/ci/api/builds_spec.rb index 7eff8048667..e5124ea5ea7 100644 --- a/spec/requests/ci/api/builds_spec.rb +++ b/spec/requests/ci/api/builds_spec.rb @@ -22,7 +22,6 @@ describe Ci::API::API do it "should start a build" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) - commit.save build = commit.builds.first post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -59,7 +58,6 @@ describe Ci::API::API do it "returns options" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) - commit.save post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -70,7 +68,6 @@ describe Ci::API::API do it "returns variables" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil) - commit.save project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -90,7 +87,6 @@ describe Ci::API::API do trigger_request = FactoryGirl.create(:ci_trigger_request_with_variables, commit: commit, trigger: trigger) commit.create_builds(nil, trigger_request) - commit.save project.variables << Ci::Variable.new(key: "SECRET_KEY", value: "secret_value") post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } @@ -109,7 +105,6 @@ describe Ci::API::API do it "returns dependent builds" do commit = FactoryGirl.create(:ci_commit, project: project, ref: 'master') commit.create_builds(nil, nil) - commit.save commit.builds.where(stage: 'test').each(&:success) post ci_api("/builds/register"), token: runner.token, info: { platform: :darwin } -- cgit v1.2.1 From 6609589b935147886fbaba187231af7ada846d43 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 09:05:00 +0200 Subject: 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 +++++++++++++ spec/lib/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 -- cgit v1.2.1 From 7f2f683eeb2c3b443f519e2e83dbb3d789a00cf8 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 09:24:16 +0200 Subject: Rename ci config module that holds nodes to Node --- 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 ------------- lib/gitlab/ci/config/node/before_script.rb | 13 +++++++++++++ lib/gitlab/ci/config/node/entry.rb | 19 +++++++++++++++++++ lib/gitlab/ci/config/node/global.rb | 10 ++++++++++ spec/lib/gitlab/ci/config/entry/before_script_spec.rb | 11 ----------- spec/lib/gitlab/ci/config/entry/global_spec.rb | 5 ----- spec/lib/gitlab/ci/config/node/before_script_spec.rb | 11 +++++++++++ spec/lib/gitlab/ci/config/node/global_spec.rb | 5 +++++ 10 files changed, 58 insertions(+), 57 deletions(-) delete mode 100644 lib/gitlab/ci/config/entry/base_entry.rb delete mode 100644 lib/gitlab/ci/config/entry/before_script.rb delete mode 100644 lib/gitlab/ci/config/entry/global.rb create mode 100644 lib/gitlab/ci/config/node/before_script.rb create mode 100644 lib/gitlab/ci/config/node/entry.rb create mode 100644 lib/gitlab/ci/config/node/global.rb delete mode 100644 spec/lib/gitlab/ci/config/entry/before_script_spec.rb delete mode 100644 spec/lib/gitlab/ci/config/entry/global_spec.rb create mode 100644 spec/lib/gitlab/ci/config/node/before_script_spec.rb create mode 100644 spec/lib/gitlab/ci/config/node/global_spec.rb diff --git a/lib/gitlab/ci/config/entry/base_entry.rb b/lib/gitlab/ci/config/entry/base_entry.rb deleted file mode 100644 index 3a41487d897..00000000000 --- a/lib/gitlab/ci/config/entry/base_entry.rb +++ /dev/null @@ -1,15 +0,0 @@ -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 deleted file mode 100644 index b7f15355a51..00000000000 --- a/lib/gitlab/ci/config/entry/before_script.rb +++ /dev/null @@ -1,13 +0,0 @@ -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 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/node/before_script.rb b/lib/gitlab/ci/config/node/before_script.rb new file mode 100644 index 00000000000..bf73c01efbc --- /dev/null +++ b/lib/gitlab/ci/config/node/before_script.rb @@ -0,0 +1,13 @@ +module Gitlab + module Ci + class Config + module Node + class BeforeScript < Entry + def leaf? + true + end + end + end + end + end +end diff --git a/lib/gitlab/ci/config/node/entry.rb b/lib/gitlab/ci/config/node/entry.rb new file mode 100644 index 00000000000..eb1b52a3e5d --- /dev/null +++ b/lib/gitlab/ci/config/node/entry.rb @@ -0,0 +1,19 @@ +module Gitlab + module Ci + class Config + module Node + class Entry + def initialize(hash, config, parent = nil) + @hash = hash + @config = config + @parent = parent + end + + def allowed_keys + [] + end + end + 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/before_script_spec.rb b/spec/lib/gitlab/ci/config/entry/before_script_spec.rb deleted file mode 100644 index 69573af5548..00000000000 --- a/spec/lib/gitlab/ci/config/entry/before_script_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -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 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/node/before_script_spec.rb b/spec/lib/gitlab/ci/config/node/before_script_spec.rb new file mode 100644 index 00000000000..eb86931c586 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/before_script_spec.rb @@ -0,0 +1,11 @@ +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 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 -- cgit v1.2.1 From 8048dcc8e693d713a94a7b9361672692f4e5932f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 10:43:11 +0200 Subject: 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 +++ .../gitlab/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 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 allowed_keys - [] + 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 -- cgit v1.2.1 From 251dd571dfc3e6261ed075ecf725dd98ee176b69 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 11:05:15 +0200 Subject: 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 -- cgit v1.2.1 From 6dbd1c86a82156dd5ad39b0e2ad119a493dadeae Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 11:20:47 +0200 Subject: Validate new before script CI configuration entry --- lib/gitlab/ci/config/node/before_script.rb | 3 +++ lib/gitlab/ci/config/node/entry.rb | 12 ++++++----- .../gitlab/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 -- cgit v1.2.1 From a3c0745514ad98df1fbb8a6142f6cc50df76edae Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 11:54:54 +0200 Subject: 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 -- cgit v1.2.1 From 3b21174d32695d10124bd4d582db14947bf4162d Mon Sep 17 00:00:00 2001 From: Paco Guzman Date: Mon, 6 Jun 2016 15:50:58 +0200 Subject: 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 -- cgit v1.2.1 From 940763e0e72a7f71c6e60f2a1a848f8fe4afaf33 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 6 Jun 2016 12:23:27 +0200 Subject: 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 | 42 ++++++++++++++++++++++++++++++-------- 4 files changed, 46 insertions(+), 14 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' } - - describe '.new' do - it 'raises error' do - expect { config }.to raise_error( - Gitlab::Ci::Config::LoaderError, - /Invalid configuration format/ - ) + 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/ + ) + 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 -- cgit v1.2.1 From 8aad78838374c761a69d7f0e9727706a611ebcaf Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:10:28 +0300 Subject: 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. -- cgit v1.2.1 From b13f6fa99dace85844db2591c922fe7158e7baac Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:10:47 +0300 Subject: 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 -- cgit v1.2.1 From 5b64e486cceda778161ee99da6f60a06c3ba4d08 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:11:23 +0300 Subject: 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 -- cgit v1.2.1 From 495d27be382266513f159a5babc334a93b540a95 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 04:13:02 +0300 Subject: 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 + @locationBadgeEl.hide() @resetSearchState() - @wrap.removeClass('has-location-badge') + @disableAutocomplete() + disableAutocomplete: -> @searchInput.addClass('disabled') -- cgit v1.2.1 From b95c60a0715b5639e70b64e04fd4923e8bdd1923 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 11:26:39 +0200 Subject: 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 -- cgit v1.2.1 From 69a3755c5a93395fd2fdfd5bee00e6064d1670f8 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 11:58:02 +0200 Subject: 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 -- cgit v1.2.1 From e8f995ef2631983ffe960464d0fd13a4c5ed8e09 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 12:13:22 +0200 Subject: 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 -- cgit v1.2.1 From 6bd67f5212de739b3016b0941853ce42f523a0f1 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 12:48:26 +0200 Subject: 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 -- cgit v1.2.1 From df25c19699ba35682fd92da2b9c451bb4ba1c775 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 12:58:32 +0200 Subject: 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 -- lib/gitlab/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 -- cgit v1.2.1 From c2d6d61dac2bf04b649c84ab0f4fe98da906c2c4 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 13:19:22 +0200 Subject: 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 -- cgit v1.2.1 From 36f67b305f37cdf4eb9f75f12cfde3b0dfc01183 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 14:49:25 +0300 Subject: 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}" } -- cgit v1.2.1 From 70bda3e89bc3828fc8771496ec6d61e41ac3d3ed Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 7 Jun 2016 14:23:47 +0200 Subject: 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 ++++ .../gitlab/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 -- cgit v1.2.1 From 50b3b8ce80b3573f53c22ac5ff34391b5bc469d8 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Tue, 7 Jun 2016 17:54:29 +0300 Subject: 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 +- .../javascripts/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 -- cgit v1.2.1 From 8827eea8643bba95571edf2ea0f769b18e8369c2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 08:28:18 +0100 Subject: Updated commits UI Closes #14633 --- app/assets/stylesheets/pages/commits.scss | 114 +++++++++++---------- 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, 104 insertions(+), 96 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,74 +7,84 @@ 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; + + &:first-child { + border-top-width: 0; + } } -.commits-row { - ul { - margin: 0; +.commit-row-title { + line-height: 20px; + margin-bottom: 2px; - li.commit { - padding: 8px 0; - } + .notes_count { + float: right; + margin-right: 10px; } - .commits-row-date { - font-size: 15px; - line-height: 20px; - margin-bottom: 5px; + .str-truncated { + max-width: 70%; } -} -li.commit { - list-style: none; + .commit-row-message { + color: $gl-dark-link-color; - .commit-row-title { - font-size: $list-font-size; - line-height: 20px; - margin-bottom: 2px; - - .btn-clipboard { - margin-top: -1px; + &: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; + } +} - .str-truncated { - max-width: 70%; - } +.commit-short-id { + font-family: $monospace_font; + font-weight: 600; +} - .commit-row-message { - color: $gl-dark-link-color; +.commit { + padding: 10px 0 10px 55px; - &:hover { - text-decoration: underline; - } - } + &:not(:last-child) { + border-bottom: 1px solid #eee; + } + + a { + color: $gl-dark-link-color; + } - .text-expander { - background: #eee; - color: #555; - padding: 0 5px; - cursor: pointer; - margin-left: 4px; - &:hover { - background-color: #ddd; - } + .commit-link { + &:hover { + color: $gl-link-color; + text-decoration: none; } } + .avatar { + margin-left: -55px; + } + .item-title { display: inline-block; max-width: 70%; @@ -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 -- cgit v1.2.1 From 79b375e17876105cefcbc5c451e785aceedb0002 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 14:00:49 +0100 Subject: 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)} -- cgit v1.2.1 From 8b40a7745be84659c10db02e3bbb74126bd42414 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 24 May 2016 17:06:49 +0100 Subject: 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) -- cgit v1.2.1 From 48726e9d307536318c7d87e2ba93f93582e22bfa Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 25 May 2016 09:11:47 +0100 Subject: 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 -- cgit v1.2.1 From 97cee7e231689a7dee2f193411f3cd7962c6ea52 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:04:18 +0100 Subject: 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') -- cgit v1.2.1 From d5afb1324f2fb9b9c19df3806662e159bbe4ffb3 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:17:13 +0100 Subject: 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)} -- cgit v1.2.1 From 6863444b7e7d71e6b50ed8ab09cda6f3e0117176 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 31 May 2016 11:40:46 +0100 Subject: 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; -- cgit v1.2.1 From e5a83a9a94f7fbedb2fcce645248e1198dcf474f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Jun 2016 10:17:32 +0100 Subject: 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? -- cgit v1.2.1 From 41c2ea9b7a036da7064b433de43c19e578cc7531 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 16:09:46 +0100 Subject: 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" -- cgit v1.2.1 From e7ee3f9f4d34f74bb40c0e439a2f7920e55ba3ba Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 7 Jun 2016 16:16:18 +0100 Subject: 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; -- cgit v1.2.1 From b76ab726b2542d77d59b12457b62016d9205a5b2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 8 Jun 2016 10:32:54 +0100 Subject: 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 { -- cgit v1.2.1 From cba266aabc60aeee64ac2eb7e76b3e9e7012bad4 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 8 Jun 2016 11:44:07 +0200 Subject: 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] -- cgit v1.2.1 From 87fe50f2a0facd5bfdf287195a21932ff2340e1b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 8 Jun 2016 12:32:56 +0200 Subject: 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 +++++ .../gitlab/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 -- cgit v1.2.1 From 5065612a0a1a5dd68c075e54f5f5f89c5c025a6b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 8 Jun 2016 13:01:44 +0200 Subject: Add minor improvements in new Ci config design --- lib/gitlab/ci/config/node/entry.rb | 40 +++++++++++++++------- lib/gitlab/ci/config/node/null.rb | 1 + .../gitlab/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) -- cgit v1.2.1 From 48a59c1a8baf3921f26c8503a9fdd63bf7398f0f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 8 Jun 2016 13:22:39 +0200 Subject: Rename BeforeScript to Script in new Ci config --- lib/gitlab/ci/config/node/before_script.rb | 25 ------------ lib/gitlab/ci/config/node/global.rb | 2 +- lib/gitlab/ci/config/node/script.rb | 25 ++++++++++++ .../gitlab/ci/config/node/before_script_spec.rb | 45 ---------------------- spec/lib/gitlab/ci/config/node/global_spec.rb | 2 +- spec/lib/gitlab/ci/config/node/script_spec.rb | 45 ++++++++++++++++++++++ 6 files changed, 72 insertions(+), 72 deletions(-) delete mode 100644 lib/gitlab/ci/config/node/before_script.rb create mode 100644 lib/gitlab/ci/config/node/script.rb delete mode 100644 spec/lib/gitlab/ci/config/node/before_script_spec.rb create mode 100644 spec/lib/gitlab/ci/config/node/script_spec.rb diff --git a/lib/gitlab/ci/config/node/before_script.rb b/lib/gitlab/ci/config/node/before_script.rb deleted file mode 100644 index be2ceebf3f9..00000000000 --- a/lib/gitlab/ci/config/node/before_script.rb +++ /dev/null @@ -1,25 +0,0 @@ -module Gitlab - module Ci - class Config - module Node - class BeforeScript < Entry - include ValidationHelpers - - def description - 'Script that is executed before the one defined in a job.' - end - - def value - @value.join("\n") - end - - def validate! - unless validate_array_of_strings(@value) - @errors << 'before_script should be an array of strings' - end - end - end - end - end - end -end 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/script.rb b/lib/gitlab/ci/config/node/script.rb new file mode 100644 index 00000000000..db635f6541f --- /dev/null +++ b/lib/gitlab/ci/config/node/script.rb @@ -0,0 +1,25 @@ +module Gitlab + module Ci + class Config + module Node + 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 + + def validate! + unless validate_array_of_strings(@value) + @errors << 'before_script should be an array of strings' + end + end + end + 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 deleted file mode 100644 index b506b9743c6..00000000000 --- a/spec/lib/gitlab/ci/config/node/before_script_spec.rb +++ /dev/null @@ -1,45 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Ci::Config::Node::BeforeScript do - let(:entry) { described_class.new(value, double)} - before { entry.validate! } - - 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 '#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 - 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 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/script_spec.rb b/spec/lib/gitlab/ci/config/node/script_spec.rb new file mode 100644 index 00000000000..0af97bab164 --- /dev/null +++ b/spec/lib/gitlab/ci/config/node/script_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe Gitlab::Ci::Config::Node::Script do + let(:entry) { described_class.new(value, double)} + before { entry.validate! } + + 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 '#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 + end + end + end +end -- cgit v1.2.1 From 57ec290f0c47d04745e49598c490735b3e650edb Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Wed, 8 Jun 2016 16:37:13 +0300 Subject: 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 -- cgit v1.2.1 From c1818eec1d10688c05467a900f60259490b627c1 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:21:35 +0300 Subject: 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. -- cgit v1.2.1 From 7df512d5a209cc82b06020d6196a47d79b73f861 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:21:50 +0300 Subject: Add new utils. --- app/assets/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 -- cgit v1.2.1 From 522ef5754d222de1e8687c4b9bbc081478c69041 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Thu, 9 Jun 2016 04:22:13 +0300 Subject: 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()] + + else if utils.isInProjectPage() and projectOptions + options = projectOptions[utils.getProjectSlug()] + + else if dashboardOptions + options = dashboardOptions - return [ - { header: "Go to in #{projectName}" } + { 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() -- cgit v1.2.1 From 33cd090b93714e147e59195d24918e8b7c6d4614 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 10:08:49 +0200 Subject: 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 -- cgit v1.2.1 From d9d5042fd9edf2abd662566ddc4c65b6a9bdbb08 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 10:28:44 +0200 Subject: 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 -- cgit v1.2.1 From 6a319fd28790228295de19d8c786d1a807f73376 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 10:53:56 +0200 Subject: Make it possible configure Ci entry description --- lib/gitlab/ci/config/node/configurable.rb | 21 ++++++++++++++------- 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, 23 insertions(+), 16 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,14 +11,19 @@ module Gitlab private - def add_node(key, entry_class) + 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 = entry_class.new(@value[key], @root, self) + entry_class.new(@value[key], @root, self) else - entry = Node::Null.new(nil, @root, self) + Node::Null.new(nil, @root, self) end - - @nodes[key] = entry end class_methods do @@ -26,8 +31,10 @@ module Gitlab 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 -- cgit v1.2.1 From 1f192afa2abab5fcab693eaf3e0fa3c874cfb793 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 9 Jun 2016 10:50:03 +0100 Subject: 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; -- cgit v1.2.1 From 20ccd4465b0fbba45839256af93cf36c7b45d4e9 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 12:35:24 +0200 Subject: 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! -- cgit v1.2.1 From 99ee39bf6c21eef8cebc431fb79286d5347d1d21 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 13:01:19 +0200 Subject: 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 -- cgit v1.2.1 From d7e125116124b9c08c27b4a02f4738619db1d2f5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 9 Jun 2016 14:59:59 +0200 Subject: 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 -- cgit v1.2.1 From e864bdf25b0082be8d0847fed6a2d16fe348ae59 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Fri, 10 Jun 2016 01:05:09 +0300 Subject: Fix specs and add new tests. --- spec/features/search_spec.rb | 4 +- .../javascripts/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 = $ 'body' - $('body').removeAttr 'data-page' - $('body').removeAttr 'data-project' + $body.removeAttr 'data-page' + $body.removeAttr 'data-project' + $body.removeAttr 'data-group' - $('body').data 'page', "#{page}:show" - $('body').data 'project', 'gitlab-ce' + 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 -- cgit v1.2.1 From 828a15bccd5a6fe0471e97ebd5c0c0f6f674b9b7 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 10 Jun 2016 10:49:47 +0200 Subject: 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 -- cgit v1.2.1 From 12080ba150328963987674d282f435fc0e88b9d6 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 10 Jun 2016 11:20:46 +0200 Subject: 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 | 55 ++++++++++++++------------- 7 files changed, 44 insertions(+), 62 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 - end - describe '#errors' do - it 'does not append errors' do - expect(entry.errors).to be_empty + describe '#errors' do + it 'does not append errors' do + expect(entry.errors).to be_empty + end end - end - describe '#has_config?' do - it 'does not have config' do - expect(entry).not_to have_config + describe '#valid?' do + it 'is valid' do + expect(entry).to be_valid + end 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/ + describe '#errors' do + it 'saves errors' do + expect(entry.errors) + .to include /should be an array of strings/ + end 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 -- cgit v1.2.1 From 5abfc7fa7157e876299d1675f1cc96b78a3feadc Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 10 Jun 2016 11:32:49 +0200 Subject: 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 -- cgit v1.2.1 From ffd07382b08586420628ae7ecda8a512adf091aa Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 1 Jun 2016 11:23:57 +0100 Subject: 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 -- cgit v1.2.1 From 492e0062172b5a6ea1e553f97b2ac410badc496f Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:03:33 +0100 Subject: Corrected all sidebar font weights to correctly match the design --- app/assets/javascripts/due_date_select.js.coffee | 5 ++-- app/assets/javascripts/labels_select.js.coffee | 2 +- app/assets/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 -- cgit v1.2.1 From 88f562469f20be70392cf30c34f421ea9b68bcb2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:05:32 +0100 Subject: 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 { -- cgit v1.2.1 From e15b17f855c03cb993d446985fccf36b13d4bd25 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 11:06:07 +0100 Subject: 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; } } -- cgit v1.2.1 From b0cf82105bbb3d61e559e8bb36abfa308cbee6ae Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 12:24:31 +0100 Subject: 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 -- cgit v1.2.1 From a99d25b174e07bf58ae8d0c5055291065038f81a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 10 Jun 2016 08:42:03 +0100 Subject: 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) -- cgit v1.2.1 From 9d09bd08e541c42dc05e336293cc2917b3a60df8 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 08:30:38 +0100 Subject: 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())); -- cgit v1.2.1 From ab722343ada1f79cc7274721ec707dc7760f3fce Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 2 Jun 2016 12:23:17 +0100 Subject: 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() -- cgit v1.2.1 From ff702bd6e05520a2cb695a03984a35ed981c6561 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 10 Jun 2016 12:04:21 +0100 Subject: 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') -- cgit v1.2.1 From cc373a35504bc1f92f1a040c87a712a6480757ec Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 10 Jun 2016 14:01:07 +0200 Subject: 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 +++++++++++++++++++++++ spec/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 -- cgit v1.2.1 From 18e16e427d331415db042afd3c8dd5689db32a53 Mon Sep 17 00:00:00 2001 From: Connor Shea Date: Tue, 7 Jun 2016 15:07:00 -0600 Subject: 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 - app/assets/javascripts/branch-graph.js.coffee | 340 - app/assets/javascripts/network.js.coffee | 9 - .../javascripts/network/application.js.coffee | 20 + .../javascripts/network/branch-graph.js.coffee | 340 + app/assets/javascripts/network/network.js.coffee | 9 + app/views/projects/network/show.html.haml | 12 +- config/application.rb | 1 + vendor/assets/javascripts/raphael.js | 8239 ++++++++++++++++++++ 11 files changed, 8611 insertions(+), 366 deletions(-) delete mode 100644 app/assets/javascripts/branch-graph.js.coffee delete mode 100644 app/assets/javascripts/network.js.coffee create mode 100644 app/assets/javascripts/network/application.js.coffee create mode 100644 app/assets/javascripts/network/branch-graph.js.coffee create mode 100644 app/assets/javascripts/network/network.js.coffee 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/branch-graph.js.coffee b/app/assets/javascripts/branch-graph.js.coffee deleted file mode 100644 index f2fd2a775a4..00000000000 --- a/app/assets/javascripts/branch-graph.js.coffee +++ /dev/null @@ -1,340 +0,0 @@ -class @BranchGraph - constructor: (@element, @options) -> - @preparedCommits = {} - @mtime = 0 - @mspace = 0 - @parents = {} - @colors = ["#000"] - @offsetX = 150 - @offsetY = 20 - @unitTime = 30 - @unitSpace = 10 - @prev_start = -1 - @load() - - load: -> - $.ajax - url: @options.url - method: "get" - dataType: "json" - success: $.proxy((data) -> - $(".loading", @element).hide() - @prepareData data.days, data.commits - @buildGraph() - , this) - - prepareData: (@days, @commits) -> - @collectParents() - @graphHeight = $(@element).height() - @graphWidth = $(@element).width() - ch = Math.max(@graphHeight, @offsetY + @unitTime * @mtime + 150) - cw = Math.max(@graphWidth, @offsetX + @unitSpace * @mspace + 300) - @r = Raphael(@element.get(0), cw, ch) - @top = @r.set() - @barHeight = Math.max(@graphHeight, @unitTime * @days.length + 320) - - for c in @commits - c.isParent = true if c.id of @parents - @preparedCommits[c.id] = c - @markCommit(c) - - @collectColors() - - collectParents: -> - for c in @commits - @mtime = Math.max(@mtime, c.time) - @mspace = Math.max(@mspace, c.space) - for p in c.parents - @parents[p[0]] = true - @mspace = Math.max(@mspace, p[1]) - - collectColors: -> - k = 0 - while k < @mspace - @colors.push Raphael.getColor(.8) - # Skipping a few colors in the spectrum to get more contrast between colors - Raphael.getColor() - Raphael.getColor() - k++ - - buildGraph: -> - r = @r - cuday = 0 - cumonth = "" - - r.rect(0, 0, 40, @barHeight).attr fill: "#222" - r.rect(40, 0, 30, @barHeight).attr fill: "#444" - - for day, mm in @days - if cuday isnt day[0] || cumonth isnt day[1] - # Dates - r.text(55, @offsetY + @unitTime * mm, day[0]) - .attr( - font: "12px Monaco, monospace" - fill: "#BBB" - ) - cuday = day[0] - - if cumonth isnt day[1] - # Months - r.text(20, @offsetY + @unitTime * mm, day[1]) - .attr( - font: "12px Monaco, monospace" - fill: "#EEE" - ) - cumonth = day[1] - - @renderPartialGraph() - - @bindEvents() - - renderPartialGraph: -> - start = Math.floor((@element.scrollTop() - @offsetY) / @unitTime) - 10 - if start < 0 - isGraphEdge = true - start = 0 - end = start + 40 - if @commits.length < end - isGraphEdge = true - end = @commits.length - - if @prev_start == -1 or Math.abs(@prev_start - start) > 10 or isGraphEdge - i = start - - @prev_start = start - - while i < end - commit = @commits[i] - i += 1 - - if commit.hasDrawn isnt true - x = @offsetX + @unitSpace * (@mspace - commit.space) - y = @offsetY + @unitTime * commit.time - - @drawDot(x, y, commit) - - @drawLines(x, y, commit) - - @appendLabel(x, y, commit) - - @appendAnchor(x, y, commit) - - commit.hasDrawn = true - - @top.toFront() - - bindEvents: -> - element = @element - - $(element).scroll (event) => - @renderPartialGraph() - - scrollDown: => - @element.scrollTop @element.scrollTop() + 50 - @renderPartialGraph() - - scrollUp: => - @element.scrollTop @element.scrollTop() - 50 - @renderPartialGraph() - - scrollLeft: => - @element.scrollLeft @element.scrollLeft() - 50 - @renderPartialGraph() - - scrollRight: => - @element.scrollLeft @element.scrollLeft() + 50 - @renderPartialGraph() - - scrollBottom: => - @element.scrollTop @element.find('svg').height() - - scrollTop: => - @element.scrollTop 0 - - appendLabel: (x, y, commit) -> - return unless commit.refs - - r = @r - shortrefs = commit.refs - # Truncate if longer than 15 chars - shortrefs = shortrefs.substr(0, 15) + "…" if shortrefs.length > 17 - text = r.text(x + 4, y, shortrefs).attr( - "text-anchor": "start" - font: "10px Monaco, monospace" - fill: "#FFF" - title: commit.refs - ) - textbox = text.getBBox() - # Create rectangle based on the size of the textbox - rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr( - fill: "#000" - "fill-opacity": .5 - stroke: "none" - ) - triangle = r.path(["M", x - 5, y, "L", x - 15, y - 4, "L", x - 15, y + 4, "Z"]).attr( - fill: "#000" - "fill-opacity": .5 - stroke: "none" - ) - - label = r.set(rect, text) - label.transform(["t", -rect.getBBox().width - 15, 0]) - - # Set text to front - text.toFront() - - appendAnchor: (x, y, commit) -> - r = @r - top = @top - options = @options - anchor = r.circle(x, y, 10).attr( - fill: "#000" - opacity: 0 - cursor: "pointer" - ).click(-> - window.open options.commit_url.replace("%s", commit.id), "_blank" - ).hover(-> - @tooltip = r.commitTooltip(x + 5, y, commit) - top.push @tooltip.insertBefore(this) - , -> - @tooltip and @tooltip.remove() and delete @tooltip - ) - top.push anchor - - drawDot: (x, y, commit) -> - r = @r - r.circle(x, y, 3).attr( - fill: @colors[commit.space] - stroke: "none" - ) - - avatar_box_x = @offsetX + @unitSpace * @mspace + 10 - avatar_box_y = y - 10 - r.rect(avatar_box_x, avatar_box_y, 20, 20).attr( - stroke: @colors[commit.space] - "stroke-width": 2 - ) - r.image(commit.author.icon, avatar_box_x, avatar_box_y, 20, 20) - r.text(@offsetX + @unitSpace * @mspace + 35, y, commit.message.split("\n")[0]).attr( - "text-anchor": "start" - font: "14px Monaco, monospace" - ) - - drawLines: (x, y, commit) -> - r = @r - for parent, i in commit.parents - parentCommit = @preparedCommits[parent[0]] - parentY = @offsetY + @unitTime * parentCommit.time - parentX1 = @offsetX + @unitSpace * (@mspace - parentCommit.space) - parentX2 = @offsetX + @unitSpace * (@mspace - parent[1]) - - # Set line color - if parentCommit.space <= commit.space - color = @colors[commit.space] - - else - color = @colors[parentCommit.space] - - # Build line shape - if parent[1] is commit.space - offset = [0, 5] - arrow = "l-2,5,4,0,-2,-5,0,5" - - else if parent[1] < commit.space - offset = [3, 3] - arrow = "l5,0,-2,4,-3,-4,4,2" - - else - offset = [-3, 3] - arrow = "l-5,0,2,4,3,-4,-4,2" - - # Start point - route = ["M", x + offset[0], y + offset[1]] - - # Add arrow if not first parent - if i > 0 - route.push(arrow) - - # Circumvent if overlap - if commit.space isnt parentCommit.space or commit.space isnt parent[1] - route.push( - "L", parentX2, y + 10, - "L", parentX2, parentY - 5, - ) - - # End point - route.push("L", parentX1, parentY) - - r - .path(route) - .attr( - stroke: color - "stroke-width": 2) - - markCommit: (commit) -> - if commit.id is @options.commit_id - r = @r - x = @offsetX + @unitSpace * (@mspace - commit.space) - y = @offsetY + @unitTime * commit.time - r.path(["M", x + 5, y, "L", x + 15, y + 4, "L", x + 15, y - 4, "Z"]).attr( - fill: "#000" - "fill-opacity": .5 - stroke: "none" - ) - # Displayed in the center - @element.scrollTop(y - @graphHeight / 2) - -Raphael::commitTooltip = (x, y, commit) -> - boxWidth = 300 - boxHeight = 200 - icon = @image(gon.relative_url_root + commit.author.icon, x, y, 20, 20) - nameText = @text(x + 25, y + 10, commit.author.name) - idText = @text(x, y + 35, commit.id) - messageText = @text(x, y + 50, commit.message) - textSet = @set(icon, nameText, idText, messageText).attr( - "text-anchor": "start" - font: "12px Monaco, monospace" - ) - nameText.attr( - font: "14px Arial" - "font-weight": "bold" - ) - - idText.attr fill: "#AAA" - @textWrap messageText, boxWidth - 50 - rect = @rect(x - 10, y - 10, boxWidth, 100, 4).attr( - fill: "#FFF" - stroke: "#000" - "stroke-linecap": "round" - "stroke-width": 2 - ) - tooltip = @set(rect, textSet) - rect.attr( - height: tooltip.getBBox().height + 10 - width: tooltip.getBBox().width + 10 - ) - - tooltip.transform ["t", 20, 20] - tooltip - -Raphael::textWrap = (t, width) -> - content = t.attr("text") - abc = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" - t.attr text: abc - letterWidth = t.getBBox().width / abc.length - t.attr text: content - words = content.split(" ") - x = 0 - s = [] - - for word in words - if x + (word.length * letterWidth) > width - s.push "\n" - x = 0 - x += word.length * letterWidth - s.push word + " " - - t.attr text: s.join("") - b = t.getBBox() - h = Math.abs(b.y2) - Math.abs(b.y) + 1 - t.attr y: b.y + h diff --git a/app/assets/javascripts/network.js.coffee b/app/assets/javascripts/network.js.coffee deleted file mode 100644 index f4ef07a50a7..00000000000 --- a/app/assets/javascripts/network.js.coffee +++ /dev/null @@ -1,9 +0,0 @@ -class @Network - constructor: (opts) -> - $("#filter_ref").click -> - $(this).closest('form').submit() - - @branch_graph = new BranchGraph($(".network-graph"), opts) - - vph = $(window).height() - 250 - $('.network-graph').css 'height': (vph + 'px') 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/network/branch-graph.js.coffee b/app/assets/javascripts/network/branch-graph.js.coffee new file mode 100644 index 00000000000..f2fd2a775a4 --- /dev/null +++ b/app/assets/javascripts/network/branch-graph.js.coffee @@ -0,0 +1,340 @@ +class @BranchGraph + constructor: (@element, @options) -> + @preparedCommits = {} + @mtime = 0 + @mspace = 0 + @parents = {} + @colors = ["#000"] + @offsetX = 150 + @offsetY = 20 + @unitTime = 30 + @unitSpace = 10 + @prev_start = -1 + @load() + + load: -> + $.ajax + url: @options.url + method: "get" + dataType: "json" + success: $.proxy((data) -> + $(".loading", @element).hide() + @prepareData data.days, data.commits + @buildGraph() + , this) + + prepareData: (@days, @commits) -> + @collectParents() + @graphHeight = $(@element).height() + @graphWidth = $(@element).width() + ch = Math.max(@graphHeight, @offsetY + @unitTime * @mtime + 150) + cw = Math.max(@graphWidth, @offsetX + @unitSpace * @mspace + 300) + @r = Raphael(@element.get(0), cw, ch) + @top = @r.set() + @barHeight = Math.max(@graphHeight, @unitTime * @days.length + 320) + + for c in @commits + c.isParent = true if c.id of @parents + @preparedCommits[c.id] = c + @markCommit(c) + + @collectColors() + + collectParents: -> + for c in @commits + @mtime = Math.max(@mtime, c.time) + @mspace = Math.max(@mspace, c.space) + for p in c.parents + @parents[p[0]] = true + @mspace = Math.max(@mspace, p[1]) + + collectColors: -> + k = 0 + while k < @mspace + @colors.push Raphael.getColor(.8) + # Skipping a few colors in the spectrum to get more contrast between colors + Raphael.getColor() + Raphael.getColor() + k++ + + buildGraph: -> + r = @r + cuday = 0 + cumonth = "" + + r.rect(0, 0, 40, @barHeight).attr fill: "#222" + r.rect(40, 0, 30, @barHeight).attr fill: "#444" + + for day, mm in @days + if cuday isnt day[0] || cumonth isnt day[1] + # Dates + r.text(55, @offsetY + @unitTime * mm, day[0]) + .attr( + font: "12px Monaco, monospace" + fill: "#BBB" + ) + cuday = day[0] + + if cumonth isnt day[1] + # Months + r.text(20, @offsetY + @unitTime * mm, day[1]) + .attr( + font: "12px Monaco, monospace" + fill: "#EEE" + ) + cumonth = day[1] + + @renderPartialGraph() + + @bindEvents() + + renderPartialGraph: -> + start = Math.floor((@element.scrollTop() - @offsetY) / @unitTime) - 10 + if start < 0 + isGraphEdge = true + start = 0 + end = start + 40 + if @commits.length < end + isGraphEdge = true + end = @commits.length + + if @prev_start == -1 or Math.abs(@prev_start - start) > 10 or isGraphEdge + i = start + + @prev_start = start + + while i < end + commit = @commits[i] + i += 1 + + if commit.hasDrawn isnt true + x = @offsetX + @unitSpace * (@mspace - commit.space) + y = @offsetY + @unitTime * commit.time + + @drawDot(x, y, commit) + + @drawLines(x, y, commit) + + @appendLabel(x, y, commit) + + @appendAnchor(x, y, commit) + + commit.hasDrawn = true + + @top.toFront() + + bindEvents: -> + element = @element + + $(element).scroll (event) => + @renderPartialGraph() + + scrollDown: => + @element.scrollTop @element.scrollTop() + 50 + @renderPartialGraph() + + scrollUp: => + @element.scrollTop @element.scrollTop() - 50 + @renderPartialGraph() + + scrollLeft: => + @element.scrollLeft @element.scrollLeft() - 50 + @renderPartialGraph() + + scrollRight: => + @element.scrollLeft @element.scrollLeft() + 50 + @renderPartialGraph() + + scrollBottom: => + @element.scrollTop @element.find('svg').height() + + scrollTop: => + @element.scrollTop 0 + + appendLabel: (x, y, commit) -> + return unless commit.refs + + r = @r + shortrefs = commit.refs + # Truncate if longer than 15 chars + shortrefs = shortrefs.substr(0, 15) + "…" if shortrefs.length > 17 + text = r.text(x + 4, y, shortrefs).attr( + "text-anchor": "start" + font: "10px Monaco, monospace" + fill: "#FFF" + title: commit.refs + ) + textbox = text.getBBox() + # Create rectangle based on the size of the textbox + rect = r.rect(x, y - 7, textbox.width + 5, textbox.height + 5, 4).attr( + fill: "#000" + "fill-opacity": .5 + stroke: "none" + ) + triangle = r.path(["M", x - 5, y, "L", x - 15, y - 4, "L", x - 15, y + 4, "Z"]).attr( + fill: "#000" + "fill-opacity": .5 + stroke: "none" + ) + + label = r.set(rect, text) + label.transform(["t", -rect.getBBox().width - 15, 0]) + + # Set text to front + text.toFront() + + appendAnchor: (x, y, commit) -> + r = @r + top = @top + options = @options + anchor = r.circle(x, y, 10).attr( + fill: "#000" + opacity: 0 + cursor: "pointer" + ).click(-> + window.open options.commit_url.replace("%s", commit.id), "_blank" + ).hover(-> + @tooltip = r.commitTooltip(x + 5, y, commit) + top.push @tooltip.insertBefore(this) + , -> + @tooltip and @tooltip.remove() and delete @tooltip + ) + top.push anchor + + drawDot: (x, y, commit) -> + r = @r + r.circle(x, y, 3).attr( + fill: @colors[commit.space] + stroke: "none" + ) + + avatar_box_x = @offsetX + @unitSpace * @mspace + 10 + avatar_box_y = y - 10 + r.rect(avatar_box_x, avatar_box_y, 20, 20).attr( + stroke: @colors[commit.space] + "stroke-width": 2 + ) + r.image(commit.author.icon, avatar_box_x, avatar_box_y, 20, 20) + r.text(@offsetX + @unitSpace * @mspace + 35, y, commit.message.split("\n")[0]).attr( + "text-anchor": "start" + font: "14px Monaco, monospace" + ) + + drawLines: (x, y, commit) -> + r = @r + for parent, i in commit.parents + parentCommit = @preparedCommits[parent[0]] + parentY = @offsetY + @unitTime * parentCommit.time + parentX1 = @offsetX + @unitSpace * (@mspace - parentCommit.space) + parentX2 = @offsetX + @unitSpace * (@mspace - parent[1]) + + # Set line color + if parentCommit.space <= commit.space + color = @colors[commit.space] + + else + color = @colors[parentCommit.space] + + # Build line shape + if parent[1] is commit.space + offset = [0, 5] + arrow = "l-2,5,4,0,-2,-5,0,5" + + else if parent[1] < commit.space + offset = [3, 3] + arrow = "l5,0,-2,4,-3,-4,4,2" + + else + offset = [-3, 3] + arrow = "l-5,0,2,4,3,-4,-4,2" + + # Start point + route = ["M", x + offset[0], y + offset[1]] + + # Add arrow if not first parent + if i > 0 + route.push(arrow) + + # Circumvent if overlap + if commit.space isnt parentCommit.space or commit.space isnt parent[1] + route.push( + "L", parentX2, y + 10, + "L", parentX2, parentY - 5, + ) + + # End point + route.push("L", parentX1, parentY) + + r + .path(route) + .attr( + stroke: color + "stroke-width": 2) + + markCommit: (commit) -> + if commit.id is @options.commit_id + r = @r + x = @offsetX + @unitSpace * (@mspace - commit.space) + y = @offsetY + @unitTime * commit.time + r.path(["M", x + 5, y, "L", x + 15, y + 4, "L", x + 15, y - 4, "Z"]).attr( + fill: "#000" + "fill-opacity": .5 + stroke: "none" + ) + # Displayed in the center + @element.scrollTop(y - @graphHeight / 2) + +Raphael::commitTooltip = (x, y, commit) -> + boxWidth = 300 + boxHeight = 200 + icon = @image(gon.relative_url_root + commit.author.icon, x, y, 20, 20) + nameText = @text(x + 25, y + 10, commit.author.name) + idText = @text(x, y + 35, commit.id) + messageText = @text(x, y + 50, commit.message) + textSet = @set(icon, nameText, idText, messageText).attr( + "text-anchor": "start" + font: "12px Monaco, monospace" + ) + nameText.attr( + font: "14px Arial" + "font-weight": "bold" + ) + + idText.attr fill: "#AAA" + @textWrap messageText, boxWidth - 50 + rect = @rect(x - 10, y - 10, boxWidth, 100, 4).attr( + fill: "#FFF" + stroke: "#000" + "stroke-linecap": "round" + "stroke-width": 2 + ) + tooltip = @set(rect, textSet) + rect.attr( + height: tooltip.getBBox().height + 10 + width: tooltip.getBBox().width + 10 + ) + + tooltip.transform ["t", 20, 20] + tooltip + +Raphael::textWrap = (t, width) -> + content = t.attr("text") + abc = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + t.attr text: abc + letterWidth = t.getBBox().width / abc.length + t.attr text: content + words = content.split(" ") + x = 0 + s = [] + + for word in words + if x + (word.length * letterWidth) > width + s.push "\n" + x = 0 + x += word.length * letterWidth + s.push word + " " + + t.attr text: s.join("") + b = t.getBBox() + h = Math.abs(b.y2) - Math.abs(b.y) + 1 + t.attr y: b.y + h diff --git a/app/assets/javascripts/network/network.js.coffee b/app/assets/javascripts/network/network.js.coffee new file mode 100644 index 00000000000..f4ef07a50a7 --- /dev/null +++ b/app/assets/javascripts/network/network.js.coffee @@ -0,0 +1,9 @@ +class @Network + constructor: (opts) -> + $("#filter_ref").click -> + $(this).closest('form').submit() + + @branch_graph = new BranchGraph($(".network-graph"), opts) + + vph = $(window).height() - 250 + $('.network-graph').css 'height': (vph + 'px') 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; +})); -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: .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; } -- cgit v1.2.1 From 638e318ee54b8d01773d545ff0de00dc7209d970 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Fri, 10 Jun 2016 17:04:54 +0100 Subject: Fixed issue when opening a highlighted line diff With the new project nav being fixed, the diff page is scrolling the highlighted under the nav meaning you cant see what is highlighted --- app/assets/javascripts/merge_request_tabs.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/merge_request_tabs.js.coffee b/app/assets/javascripts/merge_request_tabs.js.coffee index 49a4727205a..894f80586f1 100644 --- a/app/assets/javascripts/merge_request_tabs.js.coffee +++ b/app/assets/javascripts/merge_request_tabs.js.coffee @@ -88,7 +88,7 @@ class @MergeRequestTabs scrollToElement: (container) -> if window.location.hash - navBarHeight = $('.navbar-gitlab').outerHeight() + navBarHeight = $('.navbar-gitlab').outerHeight() + $('.layout-nav').outerHeight() $el = $("#{container} #{window.location.hash}:not(.match)") $.scrollTo("#{container} #{window.location.hash}:not(.match)", offset: -navBarHeight) if $el.length -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 { -- cgit v1.2.1 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: 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; + } + } } } -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Fix displaying of project settings links the user cannot access. --- app/views/layouts/nav/_project.html.haml | 25 ++++---- app/views/layouts/nav/_project_settings.html.haml | 73 ++++++++++++----------- 2 files changed, 50 insertions(+), 48 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 @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 +- 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 - Runners - = nav_link(controller: :variables) do - = link_to namespace_project_variables_path(@project.namespace, @project), title: 'Variables' do + Deploy Keys + = nav_link(controller: :hooks) do + = link_to namespace_project_hooks_path(@project.namespace, @project), title: 'Webhooks' do %span - Variables - = nav_link(controller: :triggers) do - = link_to namespace_project_triggers_path(@project.namespace, @project), title: 'Triggers' do + Webhooks + = nav_link(controller: :services) do + = link_to namespace_project_services_path(@project.namespace, @project), title: 'Services' do %span - Triggers - = nav_link(controller: :badges) do - = link_to namespace_project_badges_path(@project.namespace, @project), title: 'Badges' do + Services + = nav_link(controller: :protected_branches) do + = link_to namespace_project_protected_branches_path(@project.namespace, @project), title: 'Protected Branches' do %span - Badges + 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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? -- cgit v1.2.1 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: commit status --- .../projects/environments_controller.rb | 17 +++++++ app/helpers/gitlab_routing_helper.rb | 4 ++ app/models/ci/pipeline.rb | 4 ++ .../projects/environments/_environment.html.haml | 58 ++++++++++++++++++++++ .../projects/environments/_header_title.html.haml | 1 + app/views/projects/environments/index.html.haml | 22 ++++++++ app/views/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 -- cgit v1.2.1 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: 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' -- cgit v1.2.1 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: 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 ++++++++++++++ .../projects/deployments/_deployment.html.haml | 32 ++++++++++ .../projects/environments/_environment.html.haml | 68 +++++++--------------- app/views/projects/environments/index.html.haml | 38 ++++++------ app/views/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 ++++++ .../20160610211845_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) -- cgit v1.2.1 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: Add deployment views --- .../projects/environments_controller.rb | 29 +++++++++++++++++++++- app/services/create_deployment_service.rb | 11 ++------ .../projects/deployments/_deployment.html.haml | 14 +++++------ .../projects/environments/_environment.html.haml | 9 +++---- app/views/projects/environments/index.html.haml | 11 +++++--- app/views/projects/environments/new.html.haml | 15 +++++++++++ app/views/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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Duplicate CI config node factory on class level --- lib/gitlab/ci/config/node/configurable.rb | 4 ++- lib/gitlab/ci/config/node/entry.rb | 6 ++-- .../lib/gitlab/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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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; -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 + ...518200441_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 -- cgit v1.2.1 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: Move keep to ArtifactsController --- app/controllers/projects/artifacts_controller.rb | 5 +++++ app/models/ci/build.rb | 8 ++++---- app/views/projects/builds/_sidebar.html.haml | 19 ++++++++++--------- app/workers/expire_build_artifacts.rb | 12 ------------ app/workers/expire_build_artifacts_worker.rb | 13 +++++++++++++ config/gitlab.yml.example | 4 ++-- config/initializers/1_settings.rb | 6 +++--- config/routes.rb | 2 +- 8 files changed, 38 insertions(+), 31 deletions(-) delete mode 100644 app/workers/expire_build_artifacts.rb create mode 100644 app/workers/expire_build_artifacts_worker.rb 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.rb deleted file mode 100644 index 3d809d8ab6b..00000000000 --- a/app/workers/expire_build_artifacts.rb +++ /dev/null @@ -1,12 +0,0 @@ -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/app/workers/expire_build_artifacts_worker.rb b/app/workers/expire_build_artifacts_worker.rb new file mode 100644 index 00000000000..17b3b5f227f --- /dev/null +++ b/app/workers/expire_build_artifacts_worker.rb @@ -0,0 +1,13 @@ +class ExpireBuildArtifacts + include Sidekiq::Worker + + def perform + Rails.logger.info 'Cleaning old build artifacts' + + 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 +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 -- cgit v1.2.1 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: 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:** -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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) -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 + + = 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 + - 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Test controllers if they allow to keep artifacts --- app/controllers/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') } -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Test ExpireBuildArtifactsWorker --- app/workers/expire_build_artifacts_worker.rb | 2 +- spec/workers/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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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), ' ') -- cgit v1.2.1 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: 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) -- cgit v1.2.1 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: 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 } -- cgit v1.2.1 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: 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 +- spec/workers/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." + }, + "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.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 -- cgit v1.2.1 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: 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:** -- cgit v1.2.1 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: 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? -- cgit v1.2.1 From 8c367c918d3c439f8dad46ed1c3f3d661da1a88c Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Mon, 13 Jun 2016 11:41:58 +0100 Subject: Shows build scroll buttons after build is complete Closes #18515 --- app/views/projects/builds/show.html.haml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index a26f8aeb315..4e2702c2e44 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -48,16 +48,16 @@ - if @build.active? .autoscroll-container %button.btn.btn-success.btn-sm#autoscroll-button{:type => "button", :data => {:state => 'disabled'}} enable autoscroll - #js-build-scroll.scroll-controls - = link_to '#build-trace', class: 'btn' do - %i.fa.fa-angle-up - = link_to '#down-build-trace', class: 'btn' do - %i.fa.fa-angle-down - if @build.erased? .erased.alert.alert-warning - erased_by = "by #{link_to @build.erased_by.name, user_path(@build.erased_by)}" if @build.erased_by Build has been erased #{erased_by.html_safe} #{time_ago_with_tooltip(@build.erased_at)} - else + #js-build-scroll.scroll-controls + = link_to '#build-trace', class: 'btn' do + %i.fa.fa-angle-up + = link_to '#down-build-trace', class: 'btn' do + %i.fa.fa-angle-down %pre.build-trace#build-trace %code.bash.js-build-output = icon("refresh spin", class: "js-build-refresh") -- cgit v1.2.1 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: Fixed issue with MR buttons being in a group Also removed some inline code --- app/assets/javascripts/dispatcher.js.coffee | 4 ++ app/assets/javascripts/merged_buttons.js.coffee | 30 +++++++++++ app/assets/stylesheets/pages/merge_requests.scss | 10 ++++ app/helpers/commits_helper.rb | 6 +-- .../merge_requests/widget/_merged.html.haml | 59 ++++++++-------------- .../merge_requests/widget/_merged_buttons.haml | 4 +- 6 files changed, 70 insertions(+), 43 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(); - }); + 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").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 + .remove_source_branch_in_progress.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' + = 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Improved views --- .../projects/environments/_environment.html.haml | 4 +-- app/views/projects/environments/index.html.haml | 27 ++++++++--------- app/views/projects/environments/new.html.haml | 24 +++++++-------- app/views/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' -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Improve tests --- spec/models/build_spec.rb | 2 +- spec/requests/ci/api/builds_spec.rb | 4 ++-- spec/workers/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 - context 'with expired artifacts' do - let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: Time.now - 7.days) } + before { build } - it do - expect_any_instance_of(Ci::Build).to receive(:erase_artifacts!) + subject! { worker.perform } - worker.perform + context 'with expired artifacts' do + 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 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 -- cgit v1.2.1 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: Hides the fade right unless required --- app/assets/javascripts/layout_nav.js.coffee | 44 ++++++++++++++++++++--------- app/assets/stylesheets/framework/nav.scss | 1 + 2 files changed, 31 insertions(+), 14 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 - - $el.find('.fade-left').toggleClass('end-scroll', currentPosition is 0) - $el.find('.fade-right').toggleClass('end-scroll', currentPosition is maxPosition) +hideEndFade = ($scrollingTabs) -> + $scrollingTabs.each -> + $this = $(@) + + $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; -- cgit v1.2.1 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: 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}"] = { -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 + + 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 + visit issues_dashboard_path(:atom, private_token: user.private_token) + + entry = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]") + + 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) } - entry_1 = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue1.title}')]") - expect(entry_1).to be_present + before do + issue1.labels << label1 + end - entry_2 = find(:xpath, "//feed/entry[contains(summary/text(),'#{issue2.title}')]") - expect(entry_2).to be_present + it "should render issue label and milestone info" 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(),'#{issue1.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: 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 -- cgit v1.2.1 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: 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}')]") -- cgit v1.2.1 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: 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; } -- cgit v1.2.1 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: 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" -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Fair usage of Shared Runners --- CHANGELOG | 1 + app/services/ci/register_build_service.rb | 25 ++++++++++++---- spec/services/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) } -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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))}" }; -- cgit v1.2.1 From 385eff573eb300a267af96df153a489ebdf12886 Mon Sep 17 00:00:00 2001 From: Fatih Acet <acetfatih@gmail.com> Date: Tue, 14 Jun 2016 01:12:22 +0300 Subject: Updated CHANGELOG and template. --- CHANGELOG | 2 +- app/assets/javascripts/gfm_auto_complete.js.coffee | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index f2e97335092..1b6bfd22294 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -47,6 +47,7 @@ v 8.9.0 (unreleased) - Use downcased path to container repository as this is expected path by Docker - Projects pending deletion will render a 404 page - Measure queue duration between gitlab-workhorse and Rails + - Added Gfm autocomplete for labels - Make Omniauth providers specs to not modify global configuration - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) @@ -164,7 +165,6 @@ v 8.8.0 - Support multi-line tag messages. !3833 (Calin Seciu) - Force users to reset their password after an admin changes it - Allow "NEWS" and "CHANGES" as alternative names for CHANGELOG. !3768 (Connor Shea) - - Added Gfm autocomplete for labels - Added button to toggle whitespaces changes on diff view - Backport GitHub Enterprise import support from EE - Create tags using Rugged for performance reasons. !3745 diff --git a/app/assets/javascripts/gfm_auto_complete.js.coffee b/app/assets/javascripts/gfm_auto_complete.js.coffee index da58be5185b..190bb38504c 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.coffee +++ b/app/assets/javascripts/gfm_auto_complete.js.coffee @@ -16,8 +16,7 @@ GitLab.GfmAutoComplete = template: '<li>${username} <small>${title}</small></li>' Labels: - template: '<li>${title} <div style="background-color:${color};height:15px;width:15px;display:inline-block;float:right"> - </div></li>' + template: '<li><span class="dropdown-label-box" style="background: ${color}"></span> ${title}</li>' # Issues and MergeRequests Issues: -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 +- .../controllers/projects/issues_controller_spec.rb | 19 +++++++++++++++- spec/lib/banzai/filter/redactor_filter_spec.rb | 12 +++++++++++ spec/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 ++++++++++ .../services/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) -- cgit v1.2.1 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: 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 | 23 ++++++++++------------- 3 files changed, 14 insertions(+), 30 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 } } - - [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 + relations = [personal_projects.select(:id), + groups_projects.select(:id), + projects.select(:id), + groups.joins(:shared_projects).select(:project_id)] + + + 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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) -- cgit v1.2.1 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: 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 { -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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). -- cgit v1.2.1 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: 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 \ -- cgit v1.2.1 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: 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" \ -- cgit v1.2.1 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: 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" ``` -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 ``` -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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). -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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. -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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]. -- cgit v1.2.1 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: 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. -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 --- app/assets/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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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? -- cgit v1.2.1 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: 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; -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Cached jQuery selectors --- app/assets/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') -- cgit v1.2.1 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: Moved todo creation over to project todos controller --- app/assets/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: /[^\/]+/ } -- cgit v1.2.1 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: Uses update URL to update the status of a todo --- app/assets/javascripts/right_sidebar.js.coffee | 67 ++++++++++++++------------ app/controllers/projects/todos_controller.rb | 21 ++++---- app/views/shared/issuable/_sidebar.html.haml | 2 +- config/routes.rb | 2 +- 4 files changed, 51 insertions(+), 41 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 - - $this.enable() - $todoLoading.addClass 'hidden' - - if data.count is 0 - $todoPendingCount.addClass 'hidden' - else - $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') - else - $this - .attr 'aria-label', $this.data('todo-text') - .removeAttr 'data-id' - $btnText.text $this.data('todo-text') + beforeSend: => + @beforeTodoSend($this, $todoLoading) + ).done (data) => + @todoUpdateDone(data, $this, $btnText, $todoLoading) + + beforeTodoSend: ($btn, $todoLoading) -> + $btn.disable() + $todoLoading.removeClass 'hidden' + + todoUpdateDone: (data, $btn, $btnText, $todoLoading) -> + $todoPendingCount = $('.todos-pending-count') + $todoPendingCount.text data.count + + $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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 = {}) -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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) -- cgit v1.2.1 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: 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 ++++++++++------------ .../models/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( -- cgit v1.2.1 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: 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 + app/models/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( -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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) -- cgit v1.2.1 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: Fix specs --- lib/api/builds.rb | 2 +- spec/features/builds_spec.rb | 16 ++++--------- spec/models/build_spec.rb | 6 +++-- spec/workers/expire_build_artifacts_worker_spec.rb | 28 ++++++++++++++-------- 4 files changed, 28 insertions(+), 24 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) } + 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 + 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) } + 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 -- cgit v1.2.1 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: 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 +++++++ ...project_request_access_accepted_email.html.haml | 4 ++ .../project_request_access_accepted_email.text.erb | 3 ++ .../project_request_access_denied_email.html.haml | 4 ++ .../project_request_access_denied_email.text.erb | 3 ++ .../projects/project_members/_pending.html.haml | 21 ++++++++ .../project_members/_project_member.html.haml | 15 +++++- app/views/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 -- cgit v1.2.1 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: 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. --- ...0416182152_convert_award_note_to_emoji_award.rb | 37 +++++++++++++++++++--- db/migrate/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 -- cgit v1.2.1 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: 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. --- .../20160416182152_convert_award_note_to_emoji_award.rb | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 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 - ensure - execute 'UNLOCK TABLES' - end + 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 - 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 + ensure + execute 'UNLOCK TABLES' end end -- cgit v1.2.1 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: 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. --- db/migrate/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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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? -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 ++++++++++++++++++++- spec/lib/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 -- cgit v1.2.1 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: 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 - app/controllers/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 | 51 +++-- app/mailers/emails/projects.rb | 75 +++---- app/models/ability.rb | 8 +- app/models/concerns/access_requestable.rb | 27 +++ app/models/group.rb | 1 + app/models/member.rb | 50 ++--- 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 +- .../groups/group_members/_group_member.html.haml | 57 ----- app/views/groups/group_members/index.html.haml | 12 +- app/views/groups/group_members/update.js.haml | 2 +- app/views/layouts/nav/_group_settings.html.haml | 21 +- app/views/layouts/nav/_project.html.haml | 26 +-- .../notify/group_access_denied_email.html.haml | 2 + .../notify/group_access_denied_email.text.erb | 3 + .../notify/group_access_granted_email.html.haml | 5 +- .../notify/group_access_granted_email.text.erb | 5 +- .../notify/group_access_requested_email.html.haml | 3 + .../notify/group_access_requested_email.text.erb | 3 + .../notify/project_access_denied_email.html.haml | 3 + .../notify/project_access_denied_email.text.erb | 3 + .../notify/project_access_granted_email.html.haml | 6 +- .../notify/project_access_granted_email.text.erb | 5 +- .../project_access_requested_email.html.haml | 3 + .../notify/project_access_requested_email.text.erb | 3 + ...project_request_access_accepted_email.html.haml | 4 - .../project_request_access_accepted_email.text.erb | 3 - .../project_request_access_denied_email.html.haml | 4 - .../project_request_access_denied_email.text.erb | 3 - app/views/projects/notes/_note.html.haml | 2 +- .../project_members/_group_members.html.haml | 13 +- .../project_members/_new_project_member.html.haml | 2 +- .../projects/project_members/_pending.html.haml | 21 -- .../project_members/_project_member.html.haml | 66 ------ .../_shared_group_members.html.haml | 6 +- app/views/projects/project_members/_team.html.haml | 3 +- app/views/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 - .../20160314114439_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 +++++++++++++++- .../projects/project_members_controller_spec.rb | 249 +++++++++++++++++++-- .../members/owner_manages_access_requests_spec.rb | 52 +++++ .../groups/members/user_requests_access_spec.rb | 54 +++++ .../members/master_manages_access_requests_spec.rb | 51 +++++ .../projects/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 ++++++++- spec/models/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 | 150 ++++++++----- 77 files changed, 1769 insertions(+), 574 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 create mode 100644 app/views/notify/project_access_denied_email.text.erb 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/notify/project_request_access_denied_email.text.erb 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_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) - @group_member = GroupMember.find(group_member_id) - @group = @group_member.group + 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 - - @target_url = namespace_project_url(@project.namespace, @project) - @current_user = @project_member.user + def project_access_requested_email(project_member_id) + setup_project_member_mail(project_member_id) - mail(to: @project_member.user.notification_email, - subject: subject("Access to project was granted")) - end + @requester = @project_member.created_by - 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 = User.where(id: @project.project_members.admins.pluck(:user_id)).pluck(:notification_email) - 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: project_admins, + subject: subject("Request to join #{@project.name_with_namespace} project")) 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 + 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.created_by + @current_user = @project_member.user - mail(to: @project_member.created_by.notification_email, - subject: subject('Request for access granted')) + mail(to: @current_user.notification_email, + subject: subject("Access to #{@project.name_with_namespace} project was 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 - + 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) - @current_user = @project_member.created_by - mail(to: @project_member.created_by.notification_email, - subject: subject('Request for access declined')) + 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) - @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,19 +205,19 @@ class Member < ActiveRecord::Base system_hook_service.execute_hooks_for(self, :destroy) end - def after_accept_request_access + def after_accept_invite post_create_hook end - def after_decline_request_access + def after_decline_invite # override in subclass end - def after_accept_invite + def after_accept_request post_create_hook end - def after_decline_invite + def after_decline_request # override in subclass 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_access_denied_email.text.erb b/app/views/notify/project_access_denied_email.text.erb new file mode 100644 index 00000000000..24357e059d2 --- /dev/null +++ b/app/views/notify/project_access_denied_email.text.erb @@ -0,0 +1,3 @@ +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 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/notify/project_request_access_denied_email.text.erb b/app/views/notify/project_request_access_denied_email.text.erb deleted file mode 100644 index a9c57e4cab4..00000000000 --- a/app/views/notify/project_request_access_denied_email.text.erb +++ /dev/null @@ -1,3 +0,0 @@ -Your request to join project <%= @project.name_with_namespace %> has been denied. - -<%= namespace_project_url(@project.namespace, @project) %> 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) } - - 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) + describe '#find_member' do + context 'personal project' do + let(:project) { create(:empty_project) } + let(:requester) { create(:user) } + + before do + project.team << [master, :master] + project.team << [reporter, :reporter] + project.team << [guest, :guest] + project.request_access(requester) + end + + 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 - - describe :max_member_access do - let(:group) { create(:group) } - let(:project) { create(:empty_project) } - - 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 + context 'group project' do + let(:group) { create(:group) } + let(:project) { create(:empty_project, group: group) } + let(:requester) { create(:user) } + + before do + group.add_master(master) + group.add_reporter(reporter) + group.add_guest(guest) + group.request_access(requester) + end + + 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) - - project = build_stubbed(:empty_project, namespace: group) - - expect(project.team.human_max_access(user.id)).to eq 'Master' + describe '#max_member_access' do + let(:requester) { create(:user) } + + context 'personal project' do + let(:project) { create(:empty_project) } + + 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) - - project = build_stubbed(:empty_project, namespace: group) - - expect(project.team.human_max_access(user.id)).to eq 'Owner' + context 'group project' do + let(:group) { create(:group) } + let(:project) { create(:empty_project, group: group) } + + before do + group.add_master(master) + group.add_reporter(reporter) + group.add_guest(guest) + group.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 end end -- cgit v1.2.1 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: 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> --- app/controllers/concerns/access_request_actions.rb | 38 ++++++++++++++++++++++ app/controllers/groups/group_members_controller.rb | 27 ++++++++------- .../projects/project_members_controller.rb | 32 +++++++++--------- config/routes.rb | 5 +++ .../groups/group_members_controller_spec.rb | 6 ++-- .../projects/project_members_controller_spec.rb | 6 ++-- 6 files changed, 77 insertions(+), 37 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) + protected - redirect_to group_path(@group), notice: 'Your request for access has been queued for review.' + def member_params + params.require(:group_member).permit(:access_level, :user_id) 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) + # AccessRequestActions concern + def access_requestable_resource + @group end - protected + def access_requestable_resource_path + group_path(@group) + end - def member_params - params.require(:group_member).permit(:access_level, :user_id) + 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 -- cgit v1.2.1 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: 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> --- app/controllers/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 -- cgit v1.2.1 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: 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 +-- .../notify/group_access_denied_email.html.haml | 2 - .../notify/group_access_denied_email.text.erb | 3 - .../notify/group_access_requested_email.html.haml | 3 - .../notify/group_access_requested_email.text.erb | 3 - .../notify/member_access_denied_email.html.haml | 4 + .../notify/member_access_denied_email.text.erb | 3 + .../notify/member_access_granted_email.html.haml | 3 + .../notify/member_access_granted_email.text.erb | 3 + .../notify/member_access_requested_email.html.haml | 3 + .../notify/member_access_requested_email.text.erb | 3 + .../notify/member_invite_accepted_email.html.haml | 5 + .../notify/member_invite_accepted_email.text.erb | 3 + .../notify/member_invite_declined_email.html.haml | 4 + .../notify/member_invite_declined_email.text.erb | 3 + app/views/notify/member_invited_email.html.haml | 13 ++ app/views/notify/member_invited_email.text.erb | 4 + .../notify/project_access_denied_email.html.haml | 3 - .../notify/project_access_denied_email.text.erb | 3 - .../notify/project_access_granted_email.html.haml | 3 - .../notify/project_access_granted_email.text.erb | 3 - .../project_access_requested_email.html.haml | 3 - .../notify/project_access_requested_email.text.erb | 3 - .../notify/project_invite_accepted_email.html.haml | 6 - .../notify/project_invite_accepted_email.text.erb | 3 - .../notify/project_invite_declined_email.html.haml | 5 - .../notify/project_invite_declined_email.text.erb | 3 - .../notify/project_member_invited_email.html.haml | 13 -- .../notify/project_member_invited_email.text.erb | 4 - spec/mailers/notify_spec.rb | 256 ++++++++++++++------- 35 files changed, 355 insertions(+), 299 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/ - end + 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 - it 'contains name of project' do - is_expected.to have_body_text /#{project.name}/ + 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 new user role' do - is_expected.to have_body_text /#{project_member.human_access}/ + 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 + + 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}/ - 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.member_access_denied_email('group', group.id, user.id) } - it 'contains new user role' do - is_expected.to have_body_text /#{group_member.human_access}/ - end - end + 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" - 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) + 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 - 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" + describe 'group access changed' do + let(:group) { create(:group) } + let(:user) { create(:user) } + let(:group_member) { create(:group_member, group: group, user: user) } - it 'has the correct subject' do - is_expected.to have_subject /Access to #{group.name} group was denied/ + 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 { 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}/ + def invite_to_group(group:, email:, inviter:) + GroupMember.add_user(group.group_members, 'toto@example.com', Gitlab::Access::DEVELOPER, inviter) + + group.group_members.invite.last end - end - describe 'group access changed' do - let(:group) { create(:group) } - let(:user) { create(:user) } - let(:membership) { create(:group_member, group: group, user: user) } + 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.group_access_granted_email(membership.id) } + 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_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/ + 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 name of project' do - is_expected.to have_body_text /#{group.name}/ + 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 - it 'contains new user role' do - is_expected.to have_body_text /#{membership.human_access}/ + 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 ++- app/controllers/concerns/access_request_actions.rb | 38 ------ app/controllers/concerns/membership_actions.rb | 58 +++++++++ app/controllers/groups/group_members_controller.rb | 42 +------ .../projects/project_members_controller.rb | 43 +------ app/helpers/gitlab_routing_helper.rb | 77 ++++++++++-- app/helpers/members_helper.rb | 126 +++++--------------- 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 | 35 +++--- 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 ++--- app/views/groups/group_members/index.html.haml | 2 +- app/views/groups/show.html.haml | 3 + app/views/layouts/nav/_group_settings.html.haml | 17 ++- app/views/layouts/nav/_project.html.haml | 36 +++--- .../notify/group_access_granted_email.html.haml | 3 - .../notify/group_access_granted_email.text.erb | 3 - .../notify/group_invite_accepted_email.html.haml | 6 - .../notify/group_invite_accepted_email.text.erb | 3 - .../notify/group_invite_declined_email.html.haml | 5 - .../notify/group_invite_declined_email.text.erb | 3 - .../notify/group_member_invited_email.html.haml | 14 --- .../notify/group_member_invited_email.text.erb | 4 - .../notify/member_access_denied_email.html.haml | 2 +- .../notify/member_access_denied_email.text.erb | 4 +- .../notify/member_access_granted_email.html.haml | 2 +- .../notify/member_access_granted_email.text.erb | 4 +- .../notify/member_access_requested_email.html.haml | 4 +- .../notify/member_access_requested_email.text.erb | 4 +- .../notify/member_invite_accepted_email.html.haml | 2 +- .../notify/member_invite_accepted_email.text.erb | 4 +- .../notify/member_invite_declined_email.html.haml | 2 +- .../notify/member_invite_declined_email.text.erb | 4 +- app/views/notify/member_invited_email.html.haml | 8 +- app/views/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 +- app/views/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 ++-- .../projects/project_members_controller_spec.rb | 30 ++--- .../members/owner_manages_access_requests_spec.rb | 14 +-- .../groups/members/user_requests_access_spec.rb | 28 ++--- .../members/master_manages_access_requests_spec.rb | 14 +-- .../projects/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 ++++++++++++--------- spec/models/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, 775 insertions(+), 839 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 - - def access_requestable_resource_path - group_path(@group) - end + # MembershipActions concern + alias_method :membershipable, :group - 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 - - def access_requestable_resource_path - namespace_project_path(@project.namespace, @project) - end + # MembershipActions concern + alias_method :membershipable, :project - 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) - 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 + user.is_admin? || source.members.exists?(user_id: user.id) 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 remove_member_message(member, user: nil) + user = current_user if defined?(current_user) - 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 + 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 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' - end - - def leave_confirmation_message(entity) - "Are you sure you want to leave \"#{entity_name(entity)}\" #{entity_type(entity)}?" - end - - private + text = " from #{member.real_source_type.humanize(capitalize: false)}" - def entity_type(entity) - entity.class.to_s.underscore + text.prepend(member.request? ? 'Deny access request' : 'Remove user') 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? + def real_source_type + source_type + end + + def invite? + self.invite_token.present? end def request? - user.nil? && created_by.present? && requested_at.present? + requested_at.present? end - def invite? - self.invite_token.present? + 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.members.request.exists?(user_id: user)).to be_falsey + expect(page).to have_content 'Your access request to the project has been withdrawn.' + end - expect(project.access_requested?(user)).to be_falsey - expect(page).to have_content 'You withdrawn your access request to the project.' + 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) } - - it 'returns true' do - expect(member.accept_request).to be_truthy - end + let(:member) { create(:project_member, requested_at: Time.now.utc) } - 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) } -- cgit v1.2.1 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: Add specs for models and services --- app/models/deployment.rb | 2 + app/models/environment.rb | 4 ++ app/services/create_deployment_service.rb | 40 +++++++---------- 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 + spec/services/create_deployment_service_spec.rb | 59 +++++++++++++++++++++++++ 12 files changed, 139 insertions(+), 28 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 + project.deployments.create( + environment: environment, + ref: params[:ref], + tag: params[:tag], + sha: params[:sha], + user: current_user, + deployable: deployable, + ) end private - def find_environment(environment) - project.environments.find_by(name: environment) + def create_or_find_environment(environment) + find_environment(environment) || create_environment(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, - ) + def create_environment(environment) + project.environments.create(name: environment) end - def success(deployment) - out = super() - out[:deployment] = deployment - out + 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 -- cgit v1.2.1 From b8beb0b8aceb003b940a3a4695b3e7cb67216e5f Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 14 Jun 2016 13:18:19 +0100 Subject: Fixed last push event banner not being in container Closes #18567 --- app/assets/stylesheets/framework/blocks.scss | 4 ++++ app/views/projects/_last_push.html.haml | 22 +++++++++++----------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index fab96404a6c..d5fe5bc2ef1 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -91,6 +91,10 @@ background-color: $white-light; border-top: none; } + + &.top-block .container-fluid { + background-color: inherit; + } } .cover-block { diff --git a/app/views/projects/_last_push.html.haml b/app/views/projects/_last_push.html.haml index 7c2b8d01508..e0ca2a3109c 100644 --- a/app/views/projects/_last_push.html.haml +++ b/app/views/projects/_last_push.html.haml @@ -1,15 +1,15 @@ - if event = last_push_event - if show_last_push_widget?(event) - .row-content-block.top-block.clear-block.hidden-xs - .event-last-push - .event-last-push-text - %span You pushed to - = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do - %strong= event.ref_name - branch - #{time_ago_with_tooltip(event.created_at)} + %div{ class: (container_class) } + .event-last-push + .event-last-push-text + %span You pushed to + = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) do + %strong= event.ref_name + branch + #{time_ago_with_tooltip(event.created_at)} - .pull-right - = link_to new_mr_path_from_push_event(event), title: "New Merge Request", class: "btn btn-info btn-sm" do - Create Merge Request + .pull-right + = link_to new_mr_path_from_push_event(event), title: "New Merge Request", class: "btn btn-info btn-sm" do + Create Merge Request -- cgit v1.2.1 From e45fd5a1e4efb805d3f7f5ed9cb708105c4e9d60 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 14 Jun 2016 14:21:24 +0200 Subject: Remove ci commit specs that remain after bad merge --- spec/models/ci/commit_spec.rb | 403 ------------------------------------------ 1 file changed, 403 deletions(-) delete mode 100644 spec/models/ci/commit_spec.rb diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb deleted file mode 100644 index 01d931b087e..00000000000 --- a/spec/models/ci/commit_spec.rb +++ /dev/null @@ -1,403 +0,0 @@ -require 'spec_helper' - -describe Ci::Commit, models: true do - let(:project) { FactoryGirl.create :empty_project } - let(:commit) { FactoryGirl.create :ci_commit, project: project } - - it { is_expected.to belong_to(:project) } - it { is_expected.to have_many(:statuses) } - it { is_expected.to have_many(:trigger_requests) } - it { is_expected.to have_many(:builds) } - it { is_expected.to validate_presence_of :sha } - it { is_expected.to validate_presence_of :status } - - it { is_expected.to respond_to :git_author_name } - it { is_expected.to respond_to :git_author_email } - it { is_expected.to respond_to :short_sha } - - describe :valid_commit_sha do - context 'commit.sha can not start with 00000000' do - before do - commit.sha = '0' * 40 - commit.valid_commit_sha - end - - it('commit errors should not be empty') { expect(commit.errors).not_to be_empty } - end - end - - describe :short_sha do - subject { commit.short_sha } - - it 'has 8 items' do - expect(subject.size).to eq(8) - end - it { expect(commit.sha).to start_with(subject) } - end - - describe :create_next_builds do - end - - describe :retried do - subject { commit.retried } - - before do - @commit1 = FactoryGirl.create :ci_build, commit: commit, name: 'deploy' - @commit2 = FactoryGirl.create :ci_build, commit: commit, name: 'deploy' - end - - it 'returns old builds' do - is_expected.to contain_exactly(@commit1) - end - end - - describe :create_builds do - let!(:commit) { FactoryGirl.create :ci_commit, project: project, ref: 'master', tag: false } - - def create_builds(trigger_request = nil) - commit.create_builds(nil, trigger_request) - end - - def create_next_builds - commit.create_next_builds(commit.builds.order(:id).last) - end - - it 'creates builds' do - expect(create_builds).to be_truthy - commit.builds.update_all(status: "success") - expect(commit.builds.count(:all)).to eq(2) - - expect(create_next_builds).to be_truthy - commit.builds.update_all(status: "success") - expect(commit.builds.count(:all)).to eq(4) - - expect(create_next_builds).to be_truthy - commit.builds.update_all(status: "success") - expect(commit.builds.count(:all)).to eq(5) - - expect(create_next_builds).to be_falsey - end - - context 'custom stage with first job allowed to fail' do - let(:yaml) do - { - stages: ['clean', 'test'], - clean_job: { - stage: 'clean', - allow_failure: true, - script: 'BUILD', - }, - test_job: { - stage: 'test', - script: 'TEST', - }, - } - end - - before do - stub_ci_commit_yaml_file(YAML.dump(yaml)) - create_builds - end - - it 'properly schedules builds' do - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:drop) - expect(commit.builds.pluck(:status)).to contain_exactly('pending', 'failed') - end - end - - context 'properly creates builds when "when" is defined' do - let(:yaml) do - { - stages: ["build", "test", "test_failure", "deploy", "cleanup"], - build: { - stage: "build", - script: "BUILD", - }, - test: { - stage: "test", - script: "TEST", - }, - test_failure: { - stage: "test_failure", - script: "ON test failure", - when: "on_failure", - }, - deploy: { - stage: "deploy", - script: "PUBLISH", - }, - cleanup: { - stage: "cleanup", - script: "TIDY UP", - when: "always", - } - } - end - - before do - stub_ci_commit_yaml_file(YAML.dump(yaml)) - end - - context 'when builds are successful' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'success', 'success') - commit.reload - expect(commit.status).to eq('success') - end - end - - context 'when test job fails' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:drop) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'success', 'success') - commit.reload - expect(commit.status).to eq('failed') - end - end - - context 'when test and test_failure jobs fail' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:drop) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'pending') - commit.builds.running_or_pending.each(&:drop) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'test_failure', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'failed', 'failed', 'success') - commit.reload - expect(commit.status).to eq('failed') - end - end - - context 'when deploy job fails' do - it 'properly creates builds' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'pending') - commit.builds.running_or_pending.each(&:drop) - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test', 'deploy', 'cleanup') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'success', 'failed', 'success') - commit.reload - expect(commit.status).to eq('failed') - end - end - - context 'when build is canceled in the second stage' do - it 'does not schedule builds after build has been canceled' do - expect(create_builds).to be_truthy - expect(commit.builds.pluck(:name)).to contain_exactly('build') - expect(commit.builds.pluck(:status)).to contain_exactly('pending') - commit.builds.running_or_pending.each(&:success) - - expect(commit.builds.running_or_pending).not_to be_empty - - expect(commit.builds.pluck(:name)).to contain_exactly('build', 'test') - expect(commit.builds.pluck(:status)).to contain_exactly('success', 'pending') - commit.builds.running_or_pending.each(&:cancel) - - expect(commit.builds.running_or_pending).to be_empty - expect(commit.reload.status).to eq('canceled') - end - end - end - end - - describe "#finished_at" do - let(:commit) { FactoryGirl.create :ci_commit } - - it "returns finished_at of latest build" do - build = FactoryGirl.create :ci_build, commit: commit, finished_at: Time.now - 60 - FactoryGirl.create :ci_build, commit: commit, finished_at: Time.now - 120 - - expect(commit.finished_at.to_i).to eq(build.finished_at.to_i) - end - - it "returns nil if there is no finished build" do - FactoryGirl.create :ci_not_started_build, commit: commit - - expect(commit.finished_at).to be_nil - end - end - - describe "coverage" do - let(:project) { FactoryGirl.create :empty_project, build_coverage_regex: "/.*/" } - let(:commit) { FactoryGirl.create :ci_commit, project: project } - - it "calculates average when there are two builds with coverage" do - FactoryGirl.create :ci_build, name: "rspec", coverage: 30, commit: commit - FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, commit: commit - expect(commit.coverage).to eq("35.00") - end - - it "calculates average when there are two builds with coverage and one with nil" do - FactoryGirl.create :ci_build, name: "rspec", coverage: 30, commit: commit - FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, commit: commit - FactoryGirl.create :ci_build, commit: commit - expect(commit.coverage).to eq("35.00") - end - - it "calculates average when there are two builds with coverage and one is retried" do - FactoryGirl.create :ci_build, name: "rspec", coverage: 30, commit: commit - FactoryGirl.create :ci_build, name: "rubocop", coverage: 30, commit: commit - FactoryGirl.create :ci_build, name: "rubocop", coverage: 40, commit: commit - expect(commit.coverage).to eq("35.00") - end - - it "calculates average when there is one build without coverage" do - FactoryGirl.create :ci_build, commit: commit - expect(commit.coverage).to be_nil - end - end - - describe '#retryable?' do - subject { commit.retryable? } - - context 'no failed builds' do - before do - FactoryGirl.create :ci_build, name: "rspec", commit: commit, status: 'success' - end - - it 'be not retryable' do - is_expected.to be_falsey - end - end - - context 'with failed builds' do - before do - FactoryGirl.create :ci_build, name: "rspec", commit: commit, status: 'running' - FactoryGirl.create :ci_build, name: "rubocop", commit: commit, status: 'failed' - end - - it 'be retryable' do - is_expected.to be_truthy - end - end - end - - describe '#stages' do - let(:commit2) { FactoryGirl.create :ci_commit, project: project } - subject { CommitStatus.where(commit: [commit, commit2]).stages } - - before do - FactoryGirl.create :ci_build, commit: commit2, stage: 'test', stage_idx: 1 - FactoryGirl.create :ci_build, commit: commit, stage: 'build', stage_idx: 0 - end - - it 'return all stages' do - is_expected.to eq(%w(build test)) - end - end - - describe '#update_state' do - it 'execute update_state after touching object' do - expect(commit).to receive(:update_state).and_return(true) - commit.touch - end - - context 'dependent objects' do - let(:commit_status) { build :commit_status, commit: commit } - - it 'execute update_state after saving dependent object' do - expect(commit).to receive(:update_state).and_return(true) - commit_status.save - end - end - - context 'update state' do - let(:current) { Time.now.change(usec: 0) } - let!(:build) do - create :ci_build, :success, commit: commit, - started_at: current - 120, - finished_at: current - 60 - end - - [:status, :started_at, :finished_at, :duration].each do |param| - it "update #{param}" do - expect(commit.send(param)).to eq(build.send(param)) - end - end - end - end - - describe '#branch?' do - subject { commit.branch? } - - context 'is not a tag' do - before do - commit.tag = false - end - - it 'return true when tag is set to false' do - is_expected.to be_truthy - end - end - - context 'is not a tag' do - before do - commit.tag = true - end - - it 'return false when tag is set to true' do - is_expected.to be_falsey - end - end - end -end -- cgit v1.2.1 From cf292a3f1d3cc17d55131a15d91a53ff31017f5d Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Tue, 14 Jun 2016 14:09:07 +0200 Subject: Improve code clarity in pipeline create service --- app/models/ci/pipeline.rb | 2 +- app/services/create_commit_builds_service.rb | 52 ++++++++++++++++------ spec/services/create_commit_builds_service_spec.rb | 4 +- 3 files changed, 41 insertions(+), 17 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 83d683b63e4..63639ff2c1f 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -100,7 +100,7 @@ module Ci def create_builds(user, trigger_request = nil) build_builds(user, 'success', trigger_request) - save! + save end def create_next_builds(build) diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index f2a537c595e..668d0a86549 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -14,26 +14,50 @@ class CreateCommitBuildsService return false end - pipeline = Ci::Pipeline.new(project: project, sha: sha, ref: ref, before_sha: before_sha, tag: tag) + @pipeline = Ci::Pipeline.new(project: project, sha: sha, ref: ref, before_sha: before_sha, tag: tag) - # Skip creating pipeline when no gitlab-ci.yml is found - unless pipeline.ci_yaml_file - return pipeline + ## + # Skip creating pipeline if no gitlab-ci.yml is found + # + unless @pipeline.ci_yaml_file + return false end + ## # Skip creating builds for commits that have [ci skip] - if !pipeline.skip_ci? && pipeline.config_processor - # Create builds for commit - unless pipeline.build_builds(user) - pipeline.errors.add(:base, 'No builds created') - return pipeline - end + # but save pipeline object + # + if @pipeline.skip_ci? + return save_pipeline! + end + + ## + # Skip creating builds when CI config is invalid + # but save pipeline object + # + unless @pipeline.config_processor + return save_pipeline! + end + + ## + # Skip creating pipeline object if there are no builds for it. + # + unless @pipeline.build_builds(user) + @pipeline.errors.add(:base, 'No builds created') + return false end - # Create a new pipeline - pipeline.save! + save_pipeline! + end + + private - pipeline.touch - pipeline + ## + # Create a new pipeline and touch object to calculate status + # + def save_pipeline! + @pipeline.save! + @pipeline.touch + @pipeline end end diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index 08cbc9beb5c..50ce9659c10 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -60,7 +60,7 @@ describe CreateCommitBuildsService, services: true do after: '31das312', commits: [{ message: 'Message' }] ) - expect(result).not_to be_persisted + expect(result).to be_falsey expect(Ci::Pipeline.count).to eq(0) end @@ -184,7 +184,7 @@ describe CreateCommitBuildsService, services: true do before: '00000000', after: '31das312', commits: [{ message: 'some msg' }]) - expect(result).not_to be_persisted + expect(result).to be_falsey expect(Ci::Build.all).to be_empty expect(Ci::Pipeline.count).to eq(0) end -- cgit v1.2.1 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: 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:** -- cgit v1.2.1 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: Authorize environments controller actions --- .../projects/environments_controller.rb | 2 + app/views/projects/environments/new.html.haml | 2 +- app/views/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 } -- cgit v1.2.1 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: Properly create a new deployment after build success --- app/models/ci/build.rb | 8 ++- spec/services/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 -- cgit v1.2.1 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: Test environment controller specs --- app/models/deployment.rb | 4 + .../projects/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 -- cgit v1.2.1 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: 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 | 47 +++++++++-------- 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, 248 insertions(+), 222 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) +- [Builds](builds.md) +- [Build triggers](build_triggers.md) +- [Build Variables](build_variables.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) -- [Notes](notes.md) (comments) -- [Deploy Keys](deploy_keys.md) -- [System Hooks](system_hooks.md) -- [Groups](groups.md) +- [Open source license templates](licenses.md) - [Namespaces](namespaces.md) -- [Settings](settings.md) -- [Keys](keys.md) -- [Builds](builds.md) -- [Build triggers](build_triggers.md) -- [Build Variables](build_variables.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) -- [Open source license templates](licenses.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). -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 +++-- spec/lib/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 -- cgit v1.2.1 From 42eab8348f225894f94f1edef1c3e5c3322c1b10 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 14 Jun 2016 14:45:01 +0100 Subject: Fixed alignment of download dropdown --- CHANGELOG | 1 + app/views/projects/merge_requests/_show.html.haml | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e71a154d1d5..2bc9def1ad5 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -28,6 +28,7 @@ v 8.9.0 (unreleased) - `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 + - Fixed alignment of download dropdown in merge requests - Upgrade to jQuery 2 - Use Knapsack to evenly distribute tests across multiple nodes - Add `sha` parameter to MR merge API, to ensure only reviewed changes are merged diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index c4df8bd504f..b60bbb7532e 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -17,11 +17,11 @@ = link_to "#modal_merge_info", class: "btn inline btn-grouped btn-sm", "data-toggle" => "modal" do Check out branch - %span.dropdown + %span.dropdown.inline.prepend-left-5 %a.btn.btn-sm.dropdown-toggle{ data: {toggle: :dropdown} } Download as %span.caret - %ul.dropdown-menu + %ul.dropdown-menu.dropdown-menu-align-right %li= link_to "Email Patches", merge_request_path(@merge_request, format: :patch) %li= link_to "Plain Diff", merge_request_path(@merge_request, format: :diff) .normal -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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) -- cgit v1.2.1 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: 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 + end - sampler.sample_objects + 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Improve design after review --- app/controllers/projects/environments_controller.rb | 17 +++++++++-------- 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 +- app/views/projects/deployments/_commit.html.haml | 12 ++++++++++++ app/views/projects/deployments/_deployment.html.haml | 17 +++-------------- .../projects/environments/_environment.html.haml | 13 +------------ app/views/projects/environments/index.html.haml | 3 +-- app/views/projects/environments/new.html.haml | 3 ++- app/views/projects/environments/show.html.haml | 2 +- doc/permissions/permissions.md | 5 +++-- 14 files changed, 59 insertions(+), 69 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? + + if @environment.persisted? + redirect_to namespace_project_environment_path(project.namespace, project, @environment) + else 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.' + 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 | | | | | ✓ | -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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) -- cgit v1.2.1 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: 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') -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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') -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 From 7b4e0739e6834cfe192012059163af523dcae798 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre <dbalexandre@gmail.com> Date: Tue, 14 Jun 2016 22:13:58 -0300 Subject: Project members with guest role can't access notes on confidential issues --- spec/finders/notes_finder_spec.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/spec/finders/notes_finder_spec.rb b/spec/finders/notes_finder_spec.rb index 639b28d49ee..1bd354815e4 100644 --- a/spec/finders/notes_finder_spec.rb +++ b/spec/finders/notes_finder_spec.rb @@ -49,6 +49,13 @@ describe NotesFinder do user = create(:user) expect { NotesFinder.new.execute(project, user, params) }.to raise_error(ActiveRecord::RecordNotFound) end + + it 'raises an error for project members with guest role' do + user = create(:user) + project.team << [user, :guest] + + expect { NotesFinder.new.execute(project, user, params) }.to raise_error(ActiveRecord::RecordNotFound) + end end end end -- cgit v1.2.1 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: 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. --- app/views/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. -- cgit v1.2.1 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: Update `u2f_spec` to cover U2F being disabled until authenticator is set up. --- spec/features/u2f_spec.rb | 57 +++++++++++++++++++---------------------------- 1 file changed, 23 insertions(+), 34 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) } - describe 'when 2FA via OTP is disabled' do - it 'allows registering a new device' do - visit profile_account_path - click_on 'Enable Two-Factor Authentication' - - register_u2f_device + before do + login_as(user) + user.update_attribute(:otp_required_for_login, true) + end - expect(page.body).to match('Your U2F device was registered') - end + describe 'when 2FA via OTP is disabled' do + before { user.update_attribute(:otp_required_for_login, false) } - it 'allows registering more than one device' do + it 'does not allow registering a new 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 -- cgit v1.2.1 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: 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 } -- cgit v1.2.1 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: 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" -- cgit v1.2.1 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: 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] ||= [] -- cgit v1.2.1 From bf990fcda4c5b728272d3775cdefadce6f80cf01 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 09:35:11 +0200 Subject: Return false in create_builds if not builds created This fixes compatibility with trigger request create service. --- app/models/ci/pipeline.rb | 5 ++--- spec/models/ci/pipeline_spec.rb | 10 ++++++++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 63639ff2c1f..e90924af312 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -91,7 +91,7 @@ module Ci trigger_requests.any? end - def build_builds(user, status = 'success', trigger_request = nil) + def build_builds(user, trigger_request = nil, status = 'success') return unless config_processor config_processor.stages.any? do |stage| build_builds_for_stage(stage, user, status, trigger_request).present? @@ -99,8 +99,7 @@ module Ci end def create_builds(user, trigger_request = nil) - build_builds(user, 'success', trigger_request) - save + build_builds(user, trigger_request) && save end def create_next_builds(build) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 0d769ed7324..458013ad9f2 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -258,6 +258,16 @@ describe Ci::Pipeline, models: true do end end end + + context 'when no builds created' do + before do + stub_ci_pipeline_yaml_file(YAML.dump(before_script: ['ls'])) + end + + it 'returns false' do + expect(pipeline.create_builds(nil)).to be_falsey + end + end end describe "#finished_at" do -- cgit v1.2.1 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: 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 -- cgit v1.2.1 From a76cbe5292d20cd6fdac4e519b65df1ee3544371 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 10:05:36 +0200 Subject: Add note for short circuit eval when building builds --- app/models/ci/pipeline.rb | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index e90924af312..fde03f21f9b 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -93,6 +93,11 @@ module Ci def build_builds(user, trigger_request = nil, status = 'success') return unless config_processor + + ## + # Note that `Array#any?` implements a short circuit evaluation, so we + # build builds only for the first stage that has builds available. + # config_processor.stages.any? do |stage| build_builds_for_stage(stage, user, status, trigger_request).present? end @@ -117,9 +122,14 @@ module Ci prior_builds = latest_builds.where.not(stage: next_stages) prior_status = prior_builds.status - # create builds for next stages based + ## + # Create builds for next stages based. + # + # Note that there is a short circult evaluation here. + # have_builds = next_stages.any? do |stage| - build_builds_for_stage(stage, build.user, prior_status, build.trigger_request).present? + build_builds_for_stage(stage, build.user, prior_status, + build.trigger_request).present? end save! if have_builds -- cgit v1.2.1 From 6ff146340fea6d0df1b711933b0399fbf324e861 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 10:22:20 +0200 Subject: Improve creating builds by combining two loops --- app/models/ci/pipeline.rb | 36 ++++++++++++++++-------------------- 1 file changed, 16 insertions(+), 20 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index fde03f21f9b..58c69251824 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -91,16 +91,11 @@ module Ci trigger_requests.any? end - def build_builds(user, trigger_request = nil, status = 'success') + def build_builds(user, trigger_request = nil) return unless config_processor - ## - # Note that `Array#any?` implements a short circuit evaluation, so we - # build builds only for the first stage that has builds available. - # - config_processor.stages.any? do |stage| - build_builds_for_stage(stage, user, status, trigger_request).present? - end + build_builds_for_stages(config_processor.stages, user, + 'success', trigger_request) end def create_builds(user, trigger_request = nil) @@ -122,17 +117,11 @@ module Ci prior_builds = latest_builds.where.not(stage: next_stages) prior_status = prior_builds.status - ## - # Create builds for next stages based. - # - # Note that there is a short circult evaluation here. - # - have_builds = next_stages.any? do |stage| - build_builds_for_stage(stage, build.user, prior_status, - build.trigger_request).present? - end + # build builds for next stage that has builds available + # and save pipeline if we have builds + build_builds_for_stages(next_stages, build.user, prior_status, + build.trigger_request) && save - save! if have_builds end def retried @@ -179,8 +168,15 @@ module Ci private - def build_builds_for_stage(stage, user, status, trigger_request) - CreateBuildsService.new(self).execute(stage, user, status, trigger_request) + def build_builds_for_stages(stages, user, status, trigger_request) + ## + # Note that `Array#any?` implements a short circuit evaluation, so we + # build builds only for the first stage that has builds available. + # + stages.any? do |stage| + CreateBuildsService.new(self) + .execute(stage, user, status, trigger_request).present? + end end def update_state -- cgit v1.2.1 From 8aed815b6e646df52043867edfdfcf4f618c6a87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= <remy@rymai.me> Date: Wed, 15 Jun 2016 10:32:57 +0200 Subject: Avoid a TypeError when initializing MergeRequest JS class with no arg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this sane default you would get the following error when you tried to instantiate a new MergeRequest object with no argument (i.e. `new MergeRequest();`): TypeError: undefined is not an object (evaluating 'this.opts.action') Signed-off-by: Rémy Coutable <remy@rymai.me> --- app/assets/javascripts/merge_request.js.coffee | 2 +- spec/javascripts/merge_request_spec.js.coffee | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/merge_request.js.coffee b/app/assets/javascripts/merge_request.js.coffee index 1f46e331427..dabfd91cf14 100644 --- a/app/assets/javascripts/merge_request.js.coffee +++ b/app/assets/javascripts/merge_request.js.coffee @@ -9,7 +9,7 @@ class @MergeRequest # Options: # action - String, current controller action # - constructor: (@opts) -> + constructor: (@opts = {}) -> this.$el = $('.merge-request') this.$('.show-all-commits').on 'click', => diff --git a/spec/javascripts/merge_request_spec.js.coffee b/spec/javascripts/merge_request_spec.js.coffee index 22ebc7039d1..3cb67d51c85 100644 --- a/spec/javascripts/merge_request_spec.js.coffee +++ b/spec/javascripts/merge_request_spec.js.coffee @@ -6,7 +6,7 @@ describe 'MergeRequest', -> beforeEach -> fixture.load('merge_requests_show.html') - @merge = new MergeRequest({}) + @merge = new MergeRequest() it 'modifies the Markdown field', -> spyOn(jQuery, 'ajax').and.stub() -- cgit v1.2.1 From 69112072ca915e8d051f39bb8642f1c4fee4b692 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 10:45:08 +0200 Subject: Add Changelog entry for pipeline status fix --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 2aed8eb322b..4defd85ef10 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.9.0 (unreleased) + - Fix pipeline status when there are no builds in pipeline - Fix Error 500 when using closes_issues API with an external issue tracker - Add more information into RSS feed for issues (Alexander Matyushentsev) - Bulk assign/unassign labels to issues. -- cgit v1.2.1 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: 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 ``` -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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| -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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') -- cgit v1.2.1 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: 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'" -- cgit v1.2.1 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: 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? -- cgit v1.2.1 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: 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) { } - context 'without environments' do - it 'does show no environments' do - subject + before do + visit namespace_project_environments_path(project.namespace, project) + end + context 'without environments' do + 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 } - - before { click_link 'Destroy' } + given(:role) { :master } - 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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) } -- cgit v1.2.1 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: Add support for Docker Registry manifest v1 --- CHANGELOG | 1 + .../projects/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 | 89 ++++++++++++++++------ 7 files changed, 124 insertions(+), 32 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 'schema v1' 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_1.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v1+prettyjws' }) + end - context '#layers' do - subject { tag.layers } + context '#layers' do + subject { tag.layers } - it { expect(subject.length).to eq(1) } - end + it { expect(subject.length).to eq(1) } + end + + context '#total_size' do + subject { tag.total_size } - context '#total_size' do - subject { tag.total_size } + it { is_expected.to be_nil } + end - it { is_expected.to eq(2319870) } + 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 'config processing' do + context 'schema v2' 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.json'), + headers: { 'Content-Type' => 'application/vnd.docker.distribution.manifest.v2+json' }) 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.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 } + it { is_expected.not_to be_nil } + end end end end -- cgit v1.2.1 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: 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? -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: Fixed issue with de-selecting dropdown option in issue sidebar Closes #18641 --- app/assets/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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 From 2d495fce529cc3ac15f7096ddf9962db0fbd1e23 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon <grzesiek.bizon@gmail.com> Date: Wed, 15 Jun 2016 14:03:43 +0200 Subject: Remove reduntant method for building pipeline builds --- app/models/ci/pipeline.rb | 12 +++++------- app/services/ci/create_builds_service.rb | 3 ++- app/services/create_commit_builds_service.rb | 2 +- spec/models/ci/pipeline_spec.rb | 3 +++ spec/services/create_commit_builds_service_spec.rb | 1 + 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 58c69251824..a26cb7dd7ee 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -91,15 +91,14 @@ module Ci trigger_requests.any? end - def build_builds(user, trigger_request = nil) + def create_builds(user, trigger_request = nil) + ## + # We persist pipeline only if there are builds available + # return unless config_processor build_builds_for_stages(config_processor.stages, user, - 'success', trigger_request) - end - - def create_builds(user, trigger_request = nil) - build_builds(user, trigger_request) && save + 'success', trigger_request) && save end def create_next_builds(build) @@ -121,7 +120,6 @@ module Ci # and save pipeline if we have builds build_builds_for_stages(next_stages, build.user, prior_status, build.trigger_request) && save - end def retried diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index f7f73aff989..b2882b23d31 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -22,7 +22,8 @@ module Ci # don't create the same build twice builds_attrs.reject! do |build_attrs| - @pipeline.builds.find_by(ref: @pipeline.ref, tag: @pipeline.tag, + @pipeline.builds.find_by(ref: @pipeline.ref, + tag: @pipeline.tag, trigger_request: trigger_request, name: build_attrs[:name]) end diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 668d0a86549..f947e8f452e 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -42,7 +42,7 @@ class CreateCommitBuildsService ## # Skip creating pipeline object if there are no builds for it. # - unless @pipeline.build_builds(user) + unless @pipeline.create_builds(user) @pipeline.errors.add(:base, 'No builds created') return false end diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index 458013ad9f2..34507cf5083 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -260,12 +260,15 @@ describe Ci::Pipeline, models: true do end context 'when no builds created' do + let(:pipeline) { build(:ci_pipeline) } + before do stub_ci_pipeline_yaml_file(YAML.dump(before_script: ['ls'])) end it 'returns false' do expect(pipeline.create_builds(nil)).to be_falsey + expect(pipeline).not_to be_persisted end end end diff --git a/spec/services/create_commit_builds_service_spec.rb b/spec/services/create_commit_builds_service_spec.rb index 50ce9659c10..deab242f45a 100644 --- a/spec/services/create_commit_builds_service_spec.rb +++ b/spec/services/create_commit_builds_service_spec.rb @@ -184,6 +184,7 @@ describe CreateCommitBuildsService, services: true do before: '00000000', after: '31das312', commits: [{ message: 'some msg' }]) + expect(result).to be_falsey expect(Ci::Build.all).to be_empty expect(Ci::Pipeline.count).to eq(0) -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 + 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 - = link_to [:admin, group], class: 'str-truncated' do - = group.name + LDAP %span.light.pull-right - #{time_ago_with_tooltip(group.created_at)} + = 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 -- cgit v1.2.1 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: Add monitoring link with subtabs --- app/views/admin/background_jobs/_head.html.haml | 14 ++++ app/views/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 -- cgit v1.2.1 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: Move builds tab to admin overview --- app/views/admin/builds/index.html.haml | 103 ++++++++++++++++-------------- app/views/admin/dashboard/_head.html.haml | 4 ++ app/views/layouts/nav/_admin.html.haml | 6 +- 3 files changed, 59 insertions(+), 54 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) - - %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)) - - %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)) - - .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 - -.row-content-block.second-block - #{(@scope || 'all').capitalize} builds - -%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' +- @no_container = true += render "admin/dashboard/head" + +%div{ class: (container_class) } + + .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) + + %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)) + + %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)) + + .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 + + .row-content-block.second-block + #{(@scope || 'all').capitalize} builds + + %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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 | 36 ++++++++++++++++-------------------- 2 files changed, 32 insertions(+), 26 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 main tab should be Messages' do - ensure_active_main_tab('Messages') + step 'the active sub tab should be Resque' do + ensure_active_sub_tab('Background Jobs') end - step 'no other main tabs should be active' do - expect(page).to have_selector('.nav-sidebar > li.active', count: 1) + step 'the active sub tab should be Logs' do + ensure_active_sub_tab('Logs') end - def ensure_active_main_tab(content) - expect(find('.nav-sidebar > li.active')).to have_content(content) + step 'the active main tab should be Messages' do + ensure_active_main_tab('Messages') end end -- cgit v1.2.1 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: Make sure that we test RegisterBuildService behavior for deleted projects --- spec/services/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) -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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> --- .../20160615142710_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 -- cgit v1.2.1 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: 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))}'); -- cgit v1.2.1 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: 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' } -- cgit v1.2.1 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: Prevent default disabled buttons and links. --- CHANGELOG | 1 + app/assets/javascripts/application.js.coffee | 1 + app/assets/javascripts/lib/common_utils.js.coffee | 7 ++++++ spec/javascripts/application_spec.js.coffee | 30 +++++++++++++++++++++++ spec/javascripts/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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 From c279f40bc4347f487c4ae3f401e3f8287a0161ca Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> Date: Wed, 15 Jun 2016 18:51:15 +0300 Subject: Add bottom padding for merge request command line text Signed-off-by: Dmitriy Zaporozhets <dmitriy.zaporozhets@gmail.com> --- app/views/projects/merge_requests/_show.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index c4df8bd504f..01887f1002d 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -37,7 +37,7 @@ = render "projects/merge_requests/widget/show.html.haml" - if @merge_request.source_branch_exists? && @merge_request.mergeable? && @merge_request.can_be_merged_by?(current_user) - .light.prepend-top-default + .light.prepend-top-default.append-bottom-default You can also accept this merge request manually using the = succeed '.' do = link_to "command line", "#modal_merge_info", class: "how_to_merge_link vlink", title: "How To Merge", "data-toggle" => "modal" -- cgit v1.2.1 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: 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) } -- cgit v1.2.1 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: Pinned sidebar navigation option Closes #18542 --- app/assets/javascripts/application.js.coffee | 26 ++++- app/assets/javascripts/sidebar.js.coffee | 8 +- app/assets/stylesheets/framework/gitlab-theme.scss | 7 +- app/assets/stylesheets/framework/header.scss | 5 - app/assets/stylesheets/framework/sidebar.scss | 119 ++++++++++++--------- 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, 123 insertions(+), 75 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 22px; - position: fixed; - bottom: 0; - width: $sidebar_width; - overflow: hidden; - transition-duration: .3s; +.sidebar-user { + padding: 15px; + position: absolute; + left: 0; + 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; +} + +.nav-header-btn { + padding: 10px 5px; + color: inherit; transition-duration: .3s; - outline: none; - &:hover { + &: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'} -- cgit v1.2.1 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: 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? } -- cgit v1.2.1 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: 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 +++++++------------------ app/assets/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 -- cgit v1.2.1 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: Fix race condition on auto merge --- CHANGELOG | 1 + app/controllers/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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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" -- cgit v1.2.1 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: 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; -- cgit v1.2.1 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: Moved pinned button to the bottom Changed breakpoint width to 1440px --- app/assets/javascripts/application.js.coffee | 2 +- app/assets/stylesheets/framework/gitlab-theme.scss | 7 ++++- app/assets/stylesheets/framework/sidebar.scss | 36 ++++++++++++++-------- app/assets/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 -- cgit v1.2.1 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: 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 owner + .col-sm-10 + = 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 path + Project name .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.text_field :path, placeholder: "my-awesome-project", class: "form-control", tabindex: 2, autofocus: true, required: true - if current_user.can_create_group? .help-block -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 From 10ae756a8623170ef5ca924364231a4ce341ce23 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 15:35:27 -0500 Subject: Fix project header alignment media query bug --- app/assets/stylesheets/pages/projects.scss | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index c85d23a31f0..855d86cb238 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -28,7 +28,7 @@ .container-fluid { position: relative; - @media (min-width: $screen-md-max) { + @media (min-width: $screen-lg-min) { .row { display: flex; -ms-flex-align: center; @@ -224,7 +224,7 @@ right: 16px; bottom: 0; - @media (max-width: $screen-lg-min) { + @media (max-width: $screen-md-max) { top: 0; } @@ -233,7 +233,7 @@ right: 0; bottom: 61px; - @media (max-width: $screen-lg-min) { + @media (max-width: $screen-md-max) { position: relative; bottom: 0; margin-right: 10px; -- cgit v1.2.1 From 433a65db776ac6be39b60729c8e0dde12ac6659b Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 16:09:38 -0500 Subject: Lighten each logo path color instead of white --- app/assets/stylesheets/framework/header.scss | 32 +++++++++++++++++++++---- app/assets/stylesheets/framework/sidebar.scss | 11 --------- app/assets/stylesheets/framework/variables.scss | 5 ++++ 3 files changed, 32 insertions(+), 16 deletions(-) diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index dca4dbb9f7d..78e6f5914a5 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -2,6 +2,17 @@ * Application Header * */ +@mixin tanuki-logo-colors($path-color) { + fill: $path-color; + transition: all 0.8s; + + &:hover, + &.highlight { + fill: lighten($path-color, 25%); + transition: all 0.1s; + } +} + header { transition: padding $sidebar-transition-duration; @@ -191,13 +202,24 @@ header { } } -.tanuki-shape { - transition: all 0.8s; +#tanuki-logo { - &:hover, &.highlight { - fill: rgb(255, 255, 255); - transition: all 0.1s; + #tanuki-left-ear, + #tanuki-right-ear, + #tanuki-nose { + @include tanuki-logo-colors($tanuki-red); } + + #tanuki-left-eye, + #tanuki-right-eye { + @include tanuki-logo-colors($tanuki-orange); + } + + #tanuki-left-cheek, + #tanuki-right-cheek { + @include tanuki-logo-colors($tanuki-yellow); + } + } @media (max-width: $screen-xs-max) { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 281c0a0e1e9..a0bb3427af0 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -55,17 +55,6 @@ } } - -.tanuki-shape { - transition: all 0.8s; - - &:hover, &.highlight { - fill: rgb(255, 255, 255); - transition: all 0.1s; - } -} - - .nav-sidebar { position: absolute; top: 50px; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index acada1f16a0..148b00ac853 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -156,6 +156,11 @@ $warning-message-border: #f0e2bb; /* header */ $light-grey-header: #faf9f9; +/* tanuki logo colors */ +$tanuki-red: #e24329; +$tanuki-orange: #fc6d26; +$tanuki-yellow: #fca326; + /* * State colors: */ -- cgit v1.2.1 From e4b32e49411c33aaa754a7adb42339752ebb3eb2 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Wed, 15 Jun 2016 16:13:21 -0500 Subject: Update CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 6533f2ea499..8556c368c44 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -103,6 +103,7 @@ v 8.9.0 (unreleased) - 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 + - Update tanuki logo highlight/loading colors v 8.8.5 - Import GitHub repositories respecting the API rate limit !4166 -- cgit v1.2.1 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: 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 { -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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 -- cgit v1.2.1 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: 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) -- cgit v1.2.1 From 94135e6275a0c538ab0a5782c3f71152894efc2d Mon Sep 17 00:00:00 2001 From: Ilan Shamir <ilanshamir1@gmail.com> Date: Tue, 14 Jun 2016 23:43:31 +0300 Subject: Remove JiraIssue model and replace references with ExternalIssue --- CHANGELOG | 1 + app/models/jira_issue.rb | 2 -- spec/helpers/merge_requests_helper_spec.rb | 6 ++--- spec/lib/gitlab/reference_extractor_spec.rb | 3 ++- spec/models/jira_issue_spec.rb | 30 ----------------------- spec/models/project_services/jira_service_spec.rb | 6 +++-- spec/services/system_note_service_spec.rb | 2 +- 7 files changed, 11 insertions(+), 39 deletions(-) delete mode 100644 app/models/jira_issue.rb delete mode 100644 spec/models/jira_issue_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 162c6723dd2..49b95dee484 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -51,6 +51,7 @@ v 8.9.0 (unreleased) - Projects pending deletion will render a 404 page - Measure queue duration between gitlab-workhorse and Rails - Make Omniauth providers specs to not modify global configuration + - Remove unused JiraIssue class and replace references with ExternalIssue - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) - Cache assigned issue and merge request counts in sidebar nav diff --git a/app/models/jira_issue.rb b/app/models/jira_issue.rb deleted file mode 100644 index 5b21aac5e43..00000000000 --- a/app/models/jira_issue.rb +++ /dev/null @@ -1,2 +0,0 @@ -class JiraIssue < ExternalIssue -end diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index a3336c87173..903224589dd 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -33,9 +33,9 @@ describe MergeRequestsHelper do let(:project) { create(:project) } let(:issues) do [ - JiraIssue.new('JIRA-123', project), - JiraIssue.new('JIRA-456', project), - JiraIssue.new('FOOBAR-7890', project) + ExternalIssue.new('JIRA-123', project), + ExternalIssue.new('JIRA-456', project), + ExternalIssue.new('FOOBAR-7890', project) ] end diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index 7c617723e6d..7b4ccc83915 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -105,7 +105,8 @@ describe Gitlab::ReferenceExtractor, lib: true do it 'returns JIRA issues for a JIRA-integrated project' do subject.analyze('JIRA-123 and FOOBAR-4567') - expect(subject.issues).to eq [JiraIssue.new('JIRA-123', project), JiraIssue.new('FOOBAR-4567', project)] + expect(subject.issues).to eq [ExternalIssue.new('JIRA-123', project), + ExternalIssue.new('FOOBAR-4567', project)] end end diff --git a/spec/models/jira_issue_spec.rb b/spec/models/jira_issue_spec.rb deleted file mode 100644 index 1634265b439..00000000000 --- a/spec/models/jira_issue_spec.rb +++ /dev/null @@ -1,30 +0,0 @@ -require 'spec_helper' - -describe JiraIssue do - let(:project) { create(:project) } - subject { JiraIssue.new('JIRA-123', project) } - - describe 'id' do - subject { super().id } - it { is_expected.to eq('JIRA-123') } - end - - describe 'iid' do - subject { super().iid } - it { is_expected.to eq('JIRA-123') } - end - - describe 'to_s' do - subject { super().to_s } - it { is_expected.to eq('JIRA-123') } - end - - describe :== do - specify { expect(subject).to eq(JiraIssue.new('JIRA-123', project)) } - specify { expect(subject).not_to eq(JiraIssue.new('JIRA-124', project)) } - - it 'only compares with JiraIssues' do - expect(subject).not_to eq('JIRA-123') - end - end -end diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 5309cfb99ff..c9517324541 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -76,7 +76,8 @@ describe JiraService, models: true do end it "should call JIRA API" do - @jira_service.execute(merge_request, JiraIssue.new("JIRA-123", project)) + @jira_service.execute(merge_request, + ExternalIssue.new("JIRA-123", project)) expect(WebMock).to have_requested(:post, @comment_url).with( body: /Issue solved with/ ).once @@ -84,7 +85,8 @@ describe JiraService, models: true do it "calls the api with jira_issue_transition_id" do @jira_service.jira_issue_transition_id = 'this-is-a-custom-id' - @jira_service.execute(merge_request, JiraIssue.new("JIRA-123", project)) + @jira_service.execute(merge_request, + ExternalIssue.new("JIRA-123", project)) expect(WebMock).to have_requested(:post, @api_url).with( body: /this-is-a-custom-id/ ).once diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 09f0ee3871d..85dd30bf48c 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -529,7 +529,7 @@ describe SystemNoteService, services: true do let(:author) { create(:user) } let(:issue) { create(:issue, project: project) } let(:mergereq) { create(:merge_request, :simple, target_project: project, source_project: project) } - let(:jira_issue) { JiraIssue.new("JIRA-1", project)} + let(:jira_issue) { ExternalIssue.new("JIRA-1", project)} let(:jira_tracker) { project.create_jira_service if project.jira_service.nil? } let(:commit) { project.commit } -- cgit v1.2.1 From e8a467e0943cfc5aea1c2c42680bfa61e1733cc7 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran <alfredo@gitlab.com> Date: Wed, 15 Jun 2016 02:12:42 -0500 Subject: Implements TemplateDropdown class to create custom template dropdowns Also License dropdown has been ported to use our GL dropdown instead of Select2. Fixes tests to make it work with current implementation --- .../blob/blob_gitignore_selector.js.coffee | 61 ++-------------------- .../blob/blob_gitignore_selectors.js.coffee | 17 ++++++ .../blob/blob_license_selector.js.coffee | 35 +++---------- .../blob/blob_license_selectors.js.coffee | 17 ++++++ app/assets/javascripts/blob/edit_blob.js.coffee | 5 +- .../javascripts/blob/template_selector.js.coffee | 56 ++++++++++++++++++++ app/assets/stylesheets/pages/editor.scss | 3 +- app/helpers/blob_helper.rb | 4 +- app/views/projects/blob/_editor.html.haml | 10 ++-- .../project_owner_creates_license_file_spec.rb | 14 +++-- ...to_create_license_file_in_empty_project_spec.rb | 12 ++++- .../projects/labels/update_prioritization_spec.rb | 1 + 12 files changed, 133 insertions(+), 102 deletions(-) create mode 100644 app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee create mode 100644 app/assets/javascripts/blob/blob_license_selectors.js.coffee create mode 100644 app/assets/javascripts/blob/template_selector.js.coffee diff --git a/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee b/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee index cc8a497d081..8d0e3f363d1 100644 --- a/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee +++ b/app/assets/javascripts/blob/blob_gitignore_selector.js.coffee @@ -1,58 +1,5 @@ -class @BlobGitignoreSelector - constructor: (opts) -> - { - @dropdown - @editor - @$wrapper = @dropdown.closest('.gitignore-selector') - @$filenameInput = $('#file_name') - @data = @dropdown.data('filenames') - } = opts +#= require blob/template_selector - @dropdown.glDropdown( - data: @data, - filterable: true, - selectable: true, - search: - fields: ['name'] - clicked: @onClick - text: (gitignore) -> - gitignore.name - ) - - @toggleGitignoreSelector() - @bindEvents() - - bindEvents: -> - @$filenameInput - .on 'keyup blur', (e) => - @toggleGitignoreSelector() - - toggleGitignoreSelector: -> - filename = @$filenameInput.val() or $('.editor-file-name').text().trim() - @$wrapper.toggleClass 'hidden', filename isnt '.gitignore' - - onClick: (item, el, e) => - e.preventDefault() - @requestIgnoreFile(item.name) - - requestIgnoreFile: (name) -> - Api.gitignoreText name, @requestIgnoreFileSuccess.bind(@) - - requestIgnoreFileSuccess: (gitignore) -> - @editor.setValue(gitignore.content, 1) - @editor.focus() - -class @BlobGitignoreSelectors - constructor: (opts) -> - { - @$dropdowns = $('.js-gitignore-selector') - @editor - } = opts - - @$dropdowns.each (i, dropdown) => - $dropdown = $(dropdown) - - new BlobGitignoreSelector( - dropdown: $dropdown, - editor: @editor - ) +class @BlobGitignoreSelector extends TemplateSelector + requestFile: (query) -> + Api.gitignoreText query.name, @requestFileSuccess.bind(@) diff --git a/app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee b/app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee new file mode 100644 index 00000000000..a719ba25122 --- /dev/null +++ b/app/assets/javascripts/blob/blob_gitignore_selectors.js.coffee @@ -0,0 +1,17 @@ +class @BlobGitignoreSelectors + constructor: (opts) -> + { + @$dropdowns = $('.js-gitignore-selector') + @editor + } = opts + + @$dropdowns.each (i, dropdown) => + $dropdown = $(dropdown) + + new BlobGitignoreSelector( + pattern: /(.gitignore)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-gitignore-selector-wrap'), + dropdown: $dropdown, + editor: @editor + ) diff --git a/app/assets/javascripts/blob/blob_license_selector.js.coffee b/app/assets/javascripts/blob/blob_license_selector.js.coffee index e17eaa75dc1..a3cc8dd844c 100644 --- a/app/assets/javascripts/blob/blob_license_selector.js.coffee +++ b/app/assets/javascripts/blob/blob_license_selector.js.coffee @@ -1,30 +1,9 @@ -class @BlobLicenseSelector - licenseRegex: /^(.+\/)?(licen[sc]e|copying)($|\.)/i +#= require blob/template_selector - constructor: (editor) -> - @$licenseSelector = $('.js-license-selector') - $fileNameInput = $('#file_name') +class @BlobLicenseSelector extends TemplateSelector + requestFile: (query) -> + data = + project: @dropdown.data('project') + fullname: @dropdown.data('fullname') - initialFileNameValue = if $fileNameInput.length - $fileNameInput.val() - else if $('.editor-file-name').length - $('.editor-file-name').text().trim() - - @toggleLicenseSelector(initialFileNameValue) - - if $fileNameInput - $fileNameInput.on 'keyup blur', (e) => - @toggleLicenseSelector($(e.target).val()) - - $('select.license-select').on 'change', (e) -> - data = - project: $(this).data('project') - fullname: $(this).data('fullname') - Api.licenseText $(this).val(), data, (license) -> - editor.setValue(license.content, -1) - - toggleLicenseSelector: (fileName) => - if @licenseRegex.test(fileName) - @$licenseSelector.show() - else - @$licenseSelector.hide() + Api.licenseText query.id, data, @requestFileSuccess.bind(@) diff --git a/app/assets/javascripts/blob/blob_license_selectors.js.coffee b/app/assets/javascripts/blob/blob_license_selectors.js.coffee new file mode 100644 index 00000000000..68438733108 --- /dev/null +++ b/app/assets/javascripts/blob/blob_license_selectors.js.coffee @@ -0,0 +1,17 @@ +class @BlobLicenseSelectors + constructor: (opts) -> + { + @$dropdowns = $('.js-license-selector') + @editor + } = opts + + @$dropdowns.each (i, dropdown) => + $dropdown = $(dropdown) + + new BlobLicenseSelector( + pattern: /^(.+\/)?(licen[sc]e|copying)($|\.)/i, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-license-selector-wrap'), + dropdown: $dropdown, + editor: @editor + ) diff --git a/app/assets/javascripts/blob/edit_blob.js.coffee b/app/assets/javascripts/blob/edit_blob.js.coffee index 79141e768b8..636f909dbd0 100644 --- a/app/assets/javascripts/blob/edit_blob.js.coffee +++ b/app/assets/javascripts/blob/edit_blob.js.coffee @@ -12,8 +12,9 @@ class @EditBlob $("#file-content").val(@editor.getValue()) @initModePanesAndLinks() - new BlobLicenseSelector(@editor) - new BlobGitignoreSelectors(editor: @editor) + + new BlobLicenseSelectors { @editor } + new BlobGitignoreSelectors { @editor } initModePanesAndLinks: -> @$editModePanes = $(".js-edit-mode-pane") diff --git a/app/assets/javascripts/blob/template_selector.js.coffee b/app/assets/javascripts/blob/template_selector.js.coffee new file mode 100644 index 00000000000..e76e303189d --- /dev/null +++ b/app/assets/javascripts/blob/template_selector.js.coffee @@ -0,0 +1,56 @@ +class @TemplateSelector + constructor: (opts = {}) -> + { + @dropdown, + @data, + @pattern, + @wrapper, + @editor, + @fileEndpoint, + @$input = $('#file_name') + } = opts + + @buildDropdown() + @bindEvents() + @onFilenameUpdate() + + buildDropdown: -> + @dropdown.glDropdown( + data: @data, + filterable: true, + selectable: true, + search: + fields: ['name'] + clicked: @onClick + text: (item) -> + item.name + ) + + bindEvents: -> + @$input.on('keyup blur', (e) => + @onFilenameUpdate() + ) + + onFilenameUpdate: -> + return unless @$input.length + + filenameMatches = @pattern.test(@$input.val().trim()) + + if not filenameMatches + @wrapper.addClass('hidden') + return + + @wrapper.removeClass('hidden') + + onClick: (item, el, e) => + e.preventDefault() + @requestFile(item) + + requestFile: (item) -> + # To be implemented on the extending class + # e.g. + # Api.gitignoreText item.name, @requestFileSuccess.bind(@) + + requestFileSuccess: (file) -> + @editor.setValue(file.content, 1) + @editor.focus() diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 22679c764dc..a34b06f1054 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -66,8 +66,7 @@ font-family: $regular_font; } - .gitignore-selector { - + .gitignore-selector, .license-selector { .dropdown { line-height: 21px; } diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 85559fbc5f5..5b54b34070c 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -180,8 +180,8 @@ module BlobHelper licenses = Licensee::License.all @licenses_for_select = { - Popular: licenses.select(&:featured).map { |license| [license.name, license.key] }, - Other: licenses.reject(&:featured).map { |license| [license.name, license.key] } + Popular: licenses.select(&:featured).map { |license| { name: license.name, id: license.key } }, + Other: licenses.reject(&:featured).map { |license| { name: license.name, id: license.key } } } end diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 4071b59c003..ae89637df60 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -13,12 +13,10 @@ required: true, class: 'form-control new-file-name' .pull-right - .license-selector.js-license-selector.hide - = select_tag :license_type, grouped_options_for_select(licenses_for_select, @project.repository.license_key), include_blank: true, class: 'select2 license-select', data: {placeholder: 'Choose a license template', project: @project.name, fullname: @project.namespace.human_name} - - .gitignore-selector.hidden - = dropdown_tag("Choose a .gitignore template", options: { toggle_class: 'js-gitignore-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { filenames: gitignore_names } } ) - + .license-selector.js-license-selector-wrap.hidden + = dropdown_tag("Choose a License template", options: { toggle_class: 'js-license-selector', title: "Choose a license", filter: true, placeholder: "Filter", data: { data: licenses_for_select, project: @project.name, fullname: @project.namespace.human_name } } ) + .gitignore-selector.js-gitignore-selector-wrap.hidden + = dropdown_tag("Choose a .gitignore template", options: { toggle_class: 'js-gitignore-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } ) .encoding-selector = select_tag :encoding, options_for_select([ "base64", "text" ], "text"), class: 'select2' diff --git a/spec/features/projects/files/project_owner_creates_license_file_spec.rb b/spec/features/projects/files/project_owner_creates_license_file_spec.rb index ecc818eb1e1..e1e105e6bbe 100644 --- a/spec/features/projects/files/project_owner_creates_license_file_spec.rb +++ b/spec/features/projects/files/project_owner_creates_license_file_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' feature 'project owner creates a license file', feature: true, js: true do - include Select2Helper + include WaitForAjax let(:project_master) { create(:user) } let(:project) { create(:project) } @@ -21,7 +21,7 @@ feature 'project owner creates a license file', feature: true, js: true do expect(page).to have_selector('.license-selector') - select2('mit', from: '#license_type') + select_template('MIT License') file_content = find('.file-content') expect(file_content).to have_content('The MIT License (MIT)') @@ -44,7 +44,7 @@ feature 'project owner creates a license file', feature: true, js: true do expect(find('#file_name').value).to eq('LICENSE') expect(page).to have_selector('.license-selector') - select2('mit', from: '#license_type') + select_template('MIT License') file_content = find('.file-content') expect(file_content).to have_content('The MIT License (MIT)') @@ -58,4 +58,12 @@ feature 'project owner creates a license file', feature: true, js: true do expect(page).to have_content('The MIT License (MIT)') expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") end + + def select_template(template) + page.within('.js-license-selector-wrap') do + click_button 'Choose a License template' + click_link template + wait_for_ajax + end + end end diff --git a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb index 34eda29c285..67aac25e427 100644 --- a/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb +++ b/spec/features/projects/files/project_owner_sees_link_to_create_license_file_in_empty_project_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' feature 'project owner sees a link to create a license file in empty project', feature: true, js: true do - include Select2Helper + include WaitForAjax let(:project_master) { create(:user) } let(:project) { create(:empty_project) } @@ -20,7 +20,7 @@ feature 'project owner sees a link to create a license file in empty project', f expect(find('#file_name').value).to eq('LICENSE') expect(page).to have_selector('.license-selector') - select2('mit', from: '#license_type') + select_template('MIT License') file_content = find('.file-content') expect(file_content).to have_content('The MIT License (MIT)') @@ -36,4 +36,12 @@ feature 'project owner sees a link to create a license file in empty project', f expect(page).to have_content('The MIT License (MIT)') expect(page).to have_content("Copyright (c) #{Time.now.year} #{project.namespace.human_name}") end + + def select_template(template) + page.within('.js-license-selector-wrap') do + click_button 'Choose a License template' + click_link template + wait_for_ajax + end + end end diff --git a/spec/features/projects/labels/update_prioritization_spec.rb b/spec/features/projects/labels/update_prioritization_spec.rb index 8550d279d09..6a39c302f55 100644 --- a/spec/features/projects/labels/update_prioritization_spec.rb +++ b/spec/features/projects/labels/update_prioritization_spec.rb @@ -77,6 +77,7 @@ feature 'Prioritize labels', feature: true do end visit current_url + wait_for_ajax page.within('.prioritized-labels') do expect(first('li')).to have_content('wontfix') -- cgit v1.2.1 From da592a737c8dad6772a3340dbc5194eb5b3b4687 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Tue, 7 Jun 2016 12:21:30 +0100 Subject: Added source branch text to dropdown toggle Previously, the dropdown toggle would default to "Select source branch", this changes that so that it defaults to the branch name and if that doesn't exist, it defaults to "Select source branch" --- CHANGELOG | 12 ++++++++++++ app/views/projects/merge_requests/_new_compare.html.haml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index fa0960b2847..354ee450ddc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -119,6 +119,18 @@ v 8.8.5 - Forbid scripting for wiki files - Only show notes through JSON on confidential issues that the user has access to +v 8.8.5 (unreleased) + - Adds selected branch name to the dropdown toggle + +v 8.8.4 (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.4 - Fix LDAP-based login for users with 2FA enabled. !4493 - Added descriptions to notification settings dropdown diff --git a/app/views/projects/merge_requests/_new_compare.html.haml b/app/views/projects/merge_requests/_new_compare.html.haml index b08524574e4..de39964fca8 100644 --- a/app/views/projects/merge_requests/_new_compare.html.haml +++ b/app/views/projects/merge_requests/_new_compare.html.haml @@ -21,7 +21,7 @@ selected: f.object.source_project_id .merge-request-select.dropdown = f.hidden_field :source_branch - = dropdown_toggle "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" } + = dropdown_toggle f.object.source_branch || "Select source branch", { toggle: "dropdown", field_name: "#{f.object_name}[source_branch]" }, { toggle_class: "js-compare-dropdown js-source-branch" } .dropdown-menu.dropdown-menu-selectable.dropdown-source-branch = dropdown_title("Select source branch") = dropdown_filter("Search branches") -- cgit v1.2.1 From 7708d5a93d199d534a76abd16d6995940374b9c9 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Mon, 13 Jun 2016 09:41:50 +0100 Subject: CHANGELOG [ci skip] --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 354ee450ddc..e7c68a37af6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -66,6 +66,7 @@ v 8.9.0 (unreleased) - Make Omniauth providers specs to not modify global configuration - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) + - Adds selected branch name to the dropdown toggle - Cache assigned issue and merge request counts in sidebar nav - Use Knapsack only in CI environment - Cache project build count in sidebar nav -- cgit v1.2.1 From cd37c927e1415bc2a181283dd1ff484868337906 Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 16 Jun 2016 08:43:10 +0100 Subject: CHANGELOG [ci skip] --- CHANGELOG | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index e7c68a37af6..c459e876900 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -39,6 +39,7 @@ v 8.9.0 (unreleased) - Bump nokogiri to 1.6.8 - Use gitlab-shell v3.0.0 - Upgrade to jQuery 2 + - Adds selected branch name to the dropdown toggle - Use Knapsack to evenly distribute tests across multiple nodes - Add `sha` parameter to MR merge API, to ensure only reviewed changes are merged - Don't allow MRs to be merged when commits were added since the last review / page load @@ -66,7 +67,6 @@ v 8.9.0 (unreleased) - Make Omniauth providers specs to not modify global configuration - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) - - Adds selected branch name to the dropdown toggle - Cache assigned issue and merge request counts in sidebar nav - Use Knapsack only in CI environment - Cache project build count in sidebar nav @@ -120,18 +120,6 @@ v 8.8.5 - Forbid scripting for wiki files - Only show notes through JSON on confidential issues that the user has access to -v 8.8.5 (unreleased) - - Adds selected branch name to the dropdown toggle - -v 8.8.4 (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.4 - Fix LDAP-based login for users with 2FA enabled. !4493 - Added descriptions to notification settings dropdown -- cgit v1.2.1 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: Make project_id nullable --- .../20160616084004_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" -- cgit v1.2.1 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: 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), -- cgit v1.2.1 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: Banzai::Filter::UploadLinkFilter use XPath --- CHANGELOG | 1 + lib/banzai/filter/upload_link_filter.rb | 11 +++-------- spec/lib/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 -- cgit v1.2.1 From 5eb9cb68f797f8a3e05e4a00c5657c2dd3250c68 Mon Sep 17 00:00:00 2001 From: Sean McGivern <sean@gitlab.com> Date: Thu, 16 Jun 2016 11:47:01 +0100 Subject: Fix images in emails --- CHANGELOG | 1 + config/initializers/default_url_options.rb | 1 + 2 files changed, 2 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index fa0960b2847..c9f745f6ddb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -28,6 +28,7 @@ v 8.9.0 (unreleased) - Add a metric for the number of new Redis connections created by a transaction - Fix Error 500 when viewing a blob with binary characters after the 1024-byte mark - Redesign navigation for project pages + - Fix images in sign-up confirmation email - Added shortcut 'y' for copying a files content hash URL #14470 - Fix groups API to list only user's accessible projects - Fix horizontal scrollbar for long commit message. diff --git a/config/initializers/default_url_options.rb b/config/initializers/default_url_options.rb index 8fd27b1d88e..de2cdc6ecae 100644 --- a/config/initializers/default_url_options.rb +++ b/config/initializers/default_url_options.rb @@ -9,3 +9,4 @@ unless Gitlab.config.gitlab_on_standard_port? end Rails.application.routes.default_url_options = default_url_options +ActionMailer::Base.asset_host = Settings.gitlab['base_url'] -- cgit v1.2.1 From 425987861530c9c0fb7fe618d7f4bab017a80253 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Wed, 15 Jun 2016 18:07:04 +0200 Subject: Fixed ordering in Project.find_with_namespace This ensures that Project.find_with_namespace returns a row matching literally as the first value, instead of returning a random value. The ordering here is _only_ applied to Project.find_with_namespace and _not_ Project.where_paths_in as currently there's no code that requires Project.where_paths_in to return rows in a certain order. Since this method also returns all rows that match there's no real harm in not setting a specific order either. Another reason is that generating all the "WHEN" arms for multiple values in Project.where_paths_in becomes really messy. On MySQL we have to use the "BINARY" operator to turn a "WHERE" into a case-sensitive WHERE as otherwise MySQL may still end up returning rows in an unpredictable order. Fixes gitlab-org/gitlab-ce#18603 --- app/models/project.rb | 18 +++++++++++++++++- spec/models/project_spec.rb | 18 +++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index fdbc84474ed..9228ccab718 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -262,7 +262,23 @@ class Project < ActiveRecord::Base # # Returns a Project, or nil if no project could be found. def find_with_namespace(path) - where_paths_in([path]).reorder(nil).take + namespace_path, project_path = path.split('/', 2) + + return unless namespace_path && project_path + + namespace_path = connection.quote(namespace_path) + project_path = connection.quote(project_path) + + # On MySQL we want to ensure the ORDER BY uses a case-sensitive match so + # any literal matches come first, for this we have to use "BINARY". + # Without this there's still no guarantee in what order MySQL will return + # rows. + binary = Gitlab::Database.mysql? ? 'BINARY' : '' + + order_sql = "(CASE WHEN #{binary} namespaces.path = #{namespace_path} " \ + "AND #{binary} projects.path = #{project_path} THEN 0 ELSE 1 END)" + + where_paths_in([path]).reorder(order_sql).take end # Builds a relation to find multiple projects by their full paths. diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index fedab1f913b..53c8408633c 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -220,7 +220,7 @@ describe Project, models: true do end end - describe :find_with_namespace do + describe '.find_with_namespace' do context 'with namespace' do before do @group = create :group, name: 'gitlab' @@ -231,6 +231,22 @@ describe Project, models: true do it { expect(Project.find_with_namespace('GitLab/GitlabHQ')).to eq(@project) } it { expect(Project.find_with_namespace('gitlab-ci')).to be_nil } end + + context 'when multiple projects using a similar name exist' do + let(:group) { create(:group, name: 'gitlab') } + + let!(:project1) do + create(:empty_project, name: 'gitlab1', path: 'gitlab', namespace: group) + end + + let!(:project2) do + create(:empty_project, name: 'gitlab2', path: 'GITLAB', namespace: group) + end + + it 'returns the row where the path matches literally' do + expect(Project.find_with_namespace('gitlab/GITLAB')).to eq(project2) + end + end end describe :to_param do -- cgit v1.2.1 From 18d4bd9564c514018f955241f774dce40c83ce52 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer <jacob@gitlab.com> Date: Thu, 16 Jun 2016 13:20:12 +0200 Subject: Use gitlab_git 10.1.4 --- Gemfile.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Gemfile.lock b/Gemfile.lock index a22bd1b1f52..2b28cb0d916 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -277,7 +277,7 @@ GEM posix-spawn (~> 0.3) gitlab_emoji (0.3.1) gemojione (~> 2.2, >= 2.2.1) - gitlab_git (10.1.3) + gitlab_git (10.1.4) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) -- cgit v1.2.1 From 19a290e7bfcb5e74a0e9975fd3f7396ca0e2e990 Mon Sep 17 00:00:00 2001 From: Yorick Peterse <yorickpeterse@gmail.com> Date: Wed, 1 Jun 2016 17:39:12 +0200 Subject: Reduce queries in IssueReferenceFilter This reduces the number of queries executed in IssueReferenceFilter by retrieving the various projects/issues that may be referenced in batches _before_ iterating over all the HTML nodes. A chunk of the logic resides in AbstractReferenceFilter so it can be re-used by other filters in the future. --- lib/banzai/filter/abstract_reference_filter.rb | 51 ++++++++++++++++++++- lib/banzai/filter/issue_reference_filter.rb | 31 ++++++++++++- .../lib/banzai/filter/abstract_link_filter_spec.rb | 52 ++++++++++++++++++++++ .../banzai/filter/issue_reference_filter_spec.rb | 9 ++-- spec/services/git_push_service_spec.rb | 3 +- 5 files changed, 139 insertions(+), 7 deletions(-) create mode 100644 spec/lib/banzai/filter/abstract_link_filter_spec.rb diff --git a/lib/banzai/filter/abstract_reference_filter.rb b/lib/banzai/filter/abstract_reference_filter.rb index db95d7c908b..4815bafe238 100644 --- a/lib/banzai/filter/abstract_reference_filter.rb +++ b/lib/banzai/filter/abstract_reference_filter.rb @@ -103,7 +103,7 @@ module Banzai ref_pattern = object_class.reference_pattern link_pattern = object_class.link_reference_pattern - each_node do |node| + nodes.each do |node| if text_node?(node) && ref_pattern replace_text_when_pattern_matches(node, ref_pattern) do |content| object_link_filter(content, ref_pattern) @@ -206,6 +206,55 @@ module Banzai text end + # Returns a Hash containing all object references (e.g. issue IDs) per the + # project they belong to. + def references_per_project + @references_per_project ||= begin + refs = Hash.new { |hash, key| hash[key] = Set.new } + + regex = Regexp.union(object_class.reference_pattern, + object_class.link_reference_pattern) + + nodes.each do |node| + node.to_html.scan(regex) do + project = $~[:project] || current_project_path + + refs[project] << $~[object_sym] + end + end + + refs + end + end + + # Returns a Hash containing referenced projects grouped per their full + # path. + def projects_per_reference + @projects_per_reference ||= begin + hash = {} + refs = Set.new + + references_per_project.each do |project_ref, _| + refs << project_ref + end + + find_projects_for_paths(refs.to_a).each do |project| + hash[project.path_with_namespace] = project + end + + hash + end + end + + # Returns the projects for the given paths. + def find_projects_for_paths(paths) + Project.where_paths_in(paths).includes(:namespace) + end + + def current_project_path + @current_project_path ||= project.path_with_namespace + end + private def project_refs_cache diff --git a/lib/banzai/filter/issue_reference_filter.rb b/lib/banzai/filter/issue_reference_filter.rb index 2496e704002..2614261f9eb 100644 --- a/lib/banzai/filter/issue_reference_filter.rb +++ b/lib/banzai/filter/issue_reference_filter.rb @@ -11,13 +11,40 @@ module Banzai Issue end - def find_object(project, id) - project.get_issue(id) + def find_object(project, iid) + issues_per_project[project][iid] end def url_for_object(issue, project) IssuesHelper.url_for_issue(issue.iid, project, only_path: context[:only_path]) end + + def project_from_ref(ref) + projects_per_reference[ref || current_project_path] + end + + # Returns a Hash containing the issues per Project instance. + def issues_per_project + @issues_per_project ||= begin + hash = Hash.new { |h, k| h[k] = {} } + + projects_per_reference.each do |path, project| + issue_ids = references_per_project[path] + + next unless project.default_issues_tracker? + + project.issues.where(iid: issue_ids.to_a).each do |issue| + hash[project][issue.iid] = issue + end + end + + hash + end + end + + def find_projects_for_paths(paths) + super(paths).includes(:gitlab_issue_tracker_service) + end end end end diff --git a/spec/lib/banzai/filter/abstract_link_filter_spec.rb b/spec/lib/banzai/filter/abstract_link_filter_spec.rb new file mode 100644 index 00000000000..0c55d8e19da --- /dev/null +++ b/spec/lib/banzai/filter/abstract_link_filter_spec.rb @@ -0,0 +1,52 @@ +require 'spec_helper' + +describe Banzai::Filter::AbstractReferenceFilter do + let(:project) { create(:empty_project) } + + describe '#references_per_project' do + it 'returns a Hash containing references grouped per project paths' do + doc = Nokogiri::HTML.fragment("#1 #{project.to_reference}#2") + filter = described_class.new(doc, project: project) + + expect(filter).to receive(:object_class).twice.and_return(Issue) + expect(filter).to receive(:object_sym).twice.and_return(:issue) + + refs = filter.references_per_project + + expect(refs).to be_an_instance_of(Hash) + expect(refs[project.to_reference]).to eq(Set.new(%w[1 2])) + end + end + + describe '#projects_per_reference' do + it 'returns a Hash containing projects grouped per project paths' do + doc = Nokogiri::HTML.fragment('') + filter = described_class.new(doc, project: project) + + expect(filter).to receive(:references_per_project). + and_return({ project.path_with_namespace => Set.new(%w[1]) }) + + expect(filter.projects_per_reference). + to eq({ project.path_with_namespace => project }) + end + end + + describe '#find_projects_for_paths' do + it 'returns a list of Projects for a list of paths' do + doc = Nokogiri::HTML.fragment('') + filter = described_class.new(doc, project: project) + + expect(filter.find_projects_for_paths([project.path_with_namespace])). + to eq([project]) + end + end + + describe '#current_project_path' do + it 'returns the path of the current project' do + doc = Nokogiri::HTML.fragment('') + filter = described_class.new(doc, project: project) + + expect(filter.current_project_path).to eq(project.path_with_namespace) + end + end +end diff --git a/spec/lib/banzai/filter/issue_reference_filter_spec.rb b/spec/lib/banzai/filter/issue_reference_filter_spec.rb index 8e6a264970d..25f0bc2092f 100644 --- a/spec/lib/banzai/filter/issue_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/issue_reference_filter_spec.rb @@ -25,7 +25,9 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do let(:reference) { issue.to_reference } it 'ignores valid references when using non-default tracker' do - expect(project).to receive(:get_issue).with(issue.iid).and_return(nil) + expect_any_instance_of(described_class).to receive(:find_object). + with(project, issue.iid). + and_return(nil) exp = act = "Issue #{reference}" expect(reference_filter(act).to_html).to eq exp @@ -107,8 +109,9 @@ describe Banzai::Filter::IssueReferenceFilter, lib: true do let(:reference) { issue.to_reference(project) } it 'ignores valid references when cross-reference project uses external tracker' do - expect_any_instance_of(Project).to receive(:get_issue). - with(issue.iid).and_return(nil) + expect_any_instance_of(described_class).to receive(:find_object). + with(project2, issue.iid). + and_return(nil) exp = act = "Issue #{reference}" expect(reference_filter(act).to_html).to eq exp diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 18692f1279a..f99ad046f0d 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -312,7 +312,8 @@ describe GitPushService, services: true do end it "doesn't close issues when external issue tracker is in use" do - allow(project).to receive(:default_issues_tracker?).and_return(false) + allow_any_instance_of(Project).to receive(:default_issues_tracker?). + and_return(false) # The push still shouldn't create cross-reference notes. expect do -- cgit v1.2.1 From ae6a54f73caaa0d9023d09f0820f3bee1e0cd0d4 Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Thu, 16 Jun 2016 09:56:58 +0200 Subject: Banzai::Filter::ExternalLinkFilter use XPath --- CHANGELOG | 1 + lib/banzai/filter/external_link_filter.rb | 13 ++------- .../lib/banzai/filter/external_link_filter_spec.rb | 34 +++++++++++++++------- 3 files changed, 26 insertions(+), 22 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 39532e88138..b886668d89d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -119,6 +119,7 @@ v 8.8.5 - 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 + - Banzai::Filter::ExternalLinkFilter 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/external_link_filter.rb b/lib/banzai/filter/external_link_filter.rb index f73ecfc9418..0a29c547a4d 100644 --- a/lib/banzai/filter/external_link_filter.rb +++ b/lib/banzai/filter/external_link_filter.rb @@ -3,17 +3,8 @@ module Banzai # HTML Filter to modify the attributes of external links class ExternalLinkFilter < HTML::Pipeline::Filter def call - doc.search('a').each do |node| - link = node.attr('href') - - next unless link - - # Skip non-HTTP(S) links - next unless link.start_with?('http') - - # Skip internal links - next if link.start_with?(internal_url) - + # Skip non-HTTP(S) links and internal links + doc.xpath("descendant-or-self::a[starts-with(@href, 'http') and not(starts-with(@href, '#{internal_url}'))]").each do |node| node.set_attribute('rel', 'nofollow noreferrer') node.set_attribute('target', '_blank') end diff --git a/spec/lib/banzai/filter/external_link_filter_spec.rb b/spec/lib/banzai/filter/external_link_filter_spec.rb index f4c5c621bd0..695a5bc6fd4 100644 --- a/spec/lib/banzai/filter/external_link_filter_spec.rb +++ b/spec/lib/banzai/filter/external_link_filter_spec.rb @@ -19,19 +19,31 @@ describe Banzai::Filter::ExternalLinkFilter, lib: true do expect(filter(act).to_html).to eq exp end - it 'adds rel="nofollow" to external links' do - act = %q(<a href="https://google.com/">Google</a>) - doc = filter(act) - - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'nofollow' + context 'for root links on document' do + let(:doc) { filter %q(<a href="https://google.com/">Google</a>) } + + it 'adds rel="nofollow" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'nofollow' + end + + it 'adds rel="noreferrer" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'noreferrer' + end end - it 'adds rel="noreferrer" to external links' do - act = %q(<a href="https://google.com/">Google</a>) - doc = filter(act) + context 'for nested links on document' do + let(:doc) { filter %q(<p><a href="https://google.com/">Google</a></p>) } + + it 'adds rel="nofollow" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'nofollow' + end - expect(doc.at_css('a')).to have_attribute('rel') - expect(doc.at_css('a')['rel']).to include 'noreferrer' + it 'adds rel="noreferrer" to external links' do + expect(doc.at_css('a')).to have_attribute('rel') + expect(doc.at_css('a')['rel']).to include 'noreferrer' + end end end -- cgit v1.2.1 From 537e8f2105413623c72f3580bc29cac7bb0ec45d Mon Sep 17 00:00:00 2001 From: Phil Hughes <me@iamphill.com> Date: Thu, 16 Jun 2016 13:58:00 +0100 Subject: Added title attribute to enties in tree view Closes #18353 --- app/views/projects/tree/_blob_item.html.haml | 5 +++-- app/views/projects/tree/_tree_item.html.haml | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/views/projects/tree/_blob_item.html.haml b/app/views/projects/tree/_blob_item.html.haml index 2ddc5d504fa..a3a4dba3fa4 100644 --- a/app/views/projects/tree/_blob_item.html.haml +++ b/app/views/projects/tree/_blob_item.html.haml @@ -1,8 +1,9 @@ %tr{ class: "tree-item #{tree_hex_class(blob_item)}" } %td.tree-item-file-name = tree_icon(type, blob_item.mode, blob_item.name) - %span.str-truncated - = link_to blob_item.name, namespace_project_blob_path(@project.namespace, @project, tree_join(@id || @commit.id, blob_item.name)) + - file_name = blob_item.name + = link_to namespace_project_blob_path(@project.namespace, @project, tree_join(@id || @commit.id, blob_item.name)), title: file_name do + %span.str-truncated= file_name %td.tree_time_ago.cgray = render 'projects/tree/spinner' %td.hidden-xs.tree_commit diff --git a/app/views/projects/tree/_tree_item.html.haml b/app/views/projects/tree/_tree_item.html.haml index cf65057e704..9577696fc0d 100644 --- a/app/views/projects/tree/_tree_item.html.haml +++ b/app/views/projects/tree/_tree_item.html.haml @@ -1,9 +1,9 @@ %tr{ class: "tree-item #{tree_hex_class(tree_item)}" } %td.tree-item-file-name = tree_icon(type, tree_item.mode, tree_item.name) - %span.str-truncated - - path = flatten_tree(tree_item) - = link_to path, namespace_project_tree_path(@project.namespace, @project, tree_join(@id || @commit.id, path)) + - path = flatten_tree(tree_item) + = link_to namespace_project_tree_path(@project.namespace, @project, tree_join(@id || @commit.id, path)), title: path do + %span.str-truncated= path %td.tree_time_ago.cgray = render 'projects/tree/spinner' %td.hidden-xs.tree_commit -- cgit v1.2.1 From cfbf88f0298aee71e89650dae368800ea7b235d9 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone <annabel.dunstone@gmail.com> Date: Thu, 16 Jun 2016 08:15:01 -0500 Subject: Fix indentation scss-lint errors --- app/assets/stylesheets/framework/header.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 78e6f5914a5..a7bcb456560 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -8,8 +8,8 @@ &:hover, &.highlight { - fill: lighten($path-color, 25%); - transition: all 0.1s; + fill: lighten($path-color, 25%); + transition: all 0.1s; } } -- cgit v1.2.1 From 5d33af5ee5d13ca35669d7277b6cdd6357e0bca6 Mon Sep 17 00:00:00 2001 From: Paco Guzman <pacoguzmanp@gmail.com> Date: Thu, 16 Jun 2016 16:07:07 +0200 Subject: Use Git cached counters on project show page Besides when building the repository cache we cache those git counters too --- CHANGELOG | 1 + app/models/repository.rb | 2 +- app/views/projects/show.html.haml | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 03ca4be80da..27414381971 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -110,6 +110,7 @@ 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 - Update tanuki logo highlight/loading colors + - Use Git cached counters for branches and tags on project page v 8.8.5 - Import GitHub repositories respecting the API rate limit !4166 diff --git a/app/models/repository.rb b/app/models/repository.rb index e5b277cb198..65d1bad511d 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -243,7 +243,7 @@ class Repository end def cache_keys - %i(size branch_names tag_names commit_count + %i(size branch_names tag_names branch_count tag_count commit_count readme version contribution_guide changelog license_blob license_key gitignore) end diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index 4afa902b4eb..e9ca46a74bf 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -23,10 +23,10 @@ #{'Commit'.pluralize(@project.commit_count)} (#{number_with_delimiter(@project.commit_count)}) %li = link_to namespace_project_branches_path(@project.namespace, @project) do - #{'Branch'.pluralize(@repository.branch_names.count)} (#{number_with_delimiter(@repository.branch_names.count)}) + #{'Branch'.pluralize(@repository.branch_count)} (#{number_with_delimiter(@repository.branch_count)}) %li = link_to namespace_project_tags_path(@project.namespace, @project) do - #{'Tag'.pluralize(@repository.tag_names.count)} (#{number_with_delimiter(@repository.tag_names.count)}) + #{'Tag'.pluralize(@repository.tag_count)} (#{number_with_delimiter(@repository.tag_count)}) - if default_project_view != 'readme' && @repository.readme %li -- cgit v1.2.1 From 91253a3afd520fde70ae3f3c7057fccabd54c472 Mon Sep 17 00:00:00 2001 From: Stan Hu <stanhu@gmail.com> Date: Thu, 16 Jun 2016 15:56:39 -0700 Subject: Use gitlab-git 10.2.0 --- Gemfile | 2 +- Gemfile.lock | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Gemfile b/Gemfile index 1a7af66fac4..435bccaf04b 100644 --- a/Gemfile +++ b/Gemfile @@ -52,7 +52,7 @@ gem "browser", '~> 2.0.3' # Extracting information from a git repository # Provide access to Gitlab::Git library -gem "gitlab_git", '~> 10.0' +gem "gitlab_git", '~> 10.2' # LDAP Auth # GitLab fork with several improvements to original library. For full list of changes diff --git a/Gemfile.lock b/Gemfile.lock index 2b28cb0d916..e5d0f8119dd 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -277,7 +277,7 @@ GEM posix-spawn (~> 0.3) gitlab_emoji (0.3.1) gemojione (~> 2.2, >= 2.2.1) - gitlab_git (10.1.4) + gitlab_git (10.2.0) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) @@ -874,7 +874,7 @@ DEPENDENCIES github-markup (~> 1.3.1) gitlab-flowdock-git-hook (~> 1.0.1) gitlab_emoji (~> 0.3.0) - gitlab_git (~> 10.0) + gitlab_git (~> 10.2) gitlab_meta (= 7.0) gitlab_omniauth-ldap (~> 1.2.1) gollum-lib (~> 4.1.0) -- cgit v1.2.1 From dd1f56b5c4aa630d3f84e41c12af666f53e88dea Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Wed, 25 May 2016 17:49:34 -0400 Subject: Customize the Devise `password_change` emails --- app/views/devise/mailer/password_change.html.haml | 10 ++++++++++ app/views/devise/mailer/password_change.text.erb | 7 +++++++ 2 files changed, 17 insertions(+) create mode 100644 app/views/devise/mailer/password_change.html.haml create mode 100644 app/views/devise/mailer/password_change.text.erb diff --git a/app/views/devise/mailer/password_change.html.haml b/app/views/devise/mailer/password_change.html.haml new file mode 100644 index 00000000000..3349ee84807 --- /dev/null +++ b/app/views/devise/mailer/password_change.html.haml @@ -0,0 +1,10 @@ +.center + #content + %h2 Hello, #{@resource.name}! + %p + The password for your GitLab account on + #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)} + has successfully been changed. + %p + If you did not initiate this change, please contact your administrator + immediately. diff --git a/app/views/devise/mailer/password_change.text.erb b/app/views/devise/mailer/password_change.text.erb new file mode 100644 index 00000000000..95923d9f8de --- /dev/null +++ b/app/views/devise/mailer/password_change.text.erb @@ -0,0 +1,7 @@ +Hello, <%= @resource.name %>! + +The password for your GitLab account on <%= Gitlab.config.gitlab.url %> +has successfully been changed. + +If you did not initiate this change, please contact your administrator +immediately. -- cgit v1.2.1 From 3a5315d3ceed547de47685ba1a57b31cc8b67c96 Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Wed, 25 May 2016 17:51:52 -0400 Subject: Customize the Devise `reset_password_instructions` email --- app/views/devise/mailer/reset_password_instructions.html.erb | 8 -------- .../devise/mailer/reset_password_instructions.html.haml | 12 ++++++++++++ app/views/devise/mailer/reset_password_instructions.text.erb | 10 ++++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) delete mode 100644 app/views/devise/mailer/reset_password_instructions.html.erb create mode 100644 app/views/devise/mailer/reset_password_instructions.html.haml create mode 100644 app/views/devise/mailer/reset_password_instructions.text.erb diff --git a/app/views/devise/mailer/reset_password_instructions.html.erb b/app/views/devise/mailer/reset_password_instructions.html.erb deleted file mode 100644 index 23b31da92d8..00000000000 --- a/app/views/devise/mailer/reset_password_instructions.html.erb +++ /dev/null @@ -1,8 +0,0 @@ -<p>Hello <%= @resource.email %>!</p> - -<p>Someone has requested a link to change your password, and you can do this through the link below.</p> - -<p><%= link_to 'Change your password', edit_password_url(@resource, reset_password_token: @token) %></p> - -<p>If you didn't request this, please ignore this email.</p> -<p>Your password won't change until you access the link above and create a new one.</p> diff --git a/app/views/devise/mailer/reset_password_instructions.html.haml b/app/views/devise/mailer/reset_password_instructions.html.haml new file mode 100644 index 00000000000..e91c9522520 --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.html.haml @@ -0,0 +1,12 @@ +.center + #content + %h2 Hello, #{@resource.name}! + %p + Someone, hopefully you, has requested to reset the password for your + GitLab account on #{link_to(Gitlab.config.gitlab.url, Gitlab.config.gitlab.url)}. + %p + If you did not perform this request, you can safely ignore this email. + %p + Otherwise, click the link below to complete the process. + #cta + = link_to('Reset password', edit_password_url(@resource, reset_password_token: @token)) diff --git a/app/views/devise/mailer/reset_password_instructions.text.erb b/app/views/devise/mailer/reset_password_instructions.text.erb new file mode 100644 index 00000000000..116313ee11c --- /dev/null +++ b/app/views/devise/mailer/reset_password_instructions.text.erb @@ -0,0 +1,10 @@ +Hello, <%= @resource.name %>! + +Someone, hopefully you, has requested to reset the password for your GitLab +account on <%= Gitlab.config.gitlab.url %> + +If you did not perform this request, you can safely ignore this email. + +Otherwise, click the link below to complete the process: + +<%= edit_password_url(@resource, reset_password_token: @token) %> -- cgit v1.2.1 From 933cd3478d8930fa5472d509d78bb7dd481d2360 Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Wed, 25 May 2016 17:53:13 -0400 Subject: Customize the Devise `unlock_instructions` email --- app/views/devise/mailer/unlock_instructions.html.haml | 19 +++++++++---------- app/views/devise/mailer/unlock_instructions.text.erb | 7 +++++++ 2 files changed, 16 insertions(+), 10 deletions(-) create mode 100644 app/views/devise/mailer/unlock_instructions.text.erb diff --git a/app/views/devise/mailer/unlock_instructions.html.haml b/app/views/devise/mailer/unlock_instructions.html.haml index 52b327e20c5..9990d1ccac6 100644 --- a/app/views/devise/mailer/unlock_instructions.html.haml +++ b/app/views/devise/mailer/unlock_instructions.html.haml @@ -1,10 +1,9 @@ -%p -Hello #{@resource.name}! - -%p - Your GitLab account has been locked due to an excessive amount of unsuccessful - sign in attempts. Your account will automatically unlock in - = time_ago_in_words(Devise.unlock_in.from_now) - or you may click the link below to unlock now. - -%p= link_to 'Unlock your account', unlock_url(@resource, unlock_token: @token) +.center + #content + %h2 Hello, #{@resource.name}! + %p + Your GitLab account has been locked due to an excessive amount of unsuccessful + sign in attempts. Your account will automatically unlock in #{time_ago_in_words(Devise.unlock_in.from_now)} + or you may click the link below to unlock now. + #cta + = link_to('Unlock account', unlock_url(@resource, unlock_token: @token)) diff --git a/app/views/devise/mailer/unlock_instructions.text.erb b/app/views/devise/mailer/unlock_instructions.text.erb new file mode 100644 index 00000000000..3aea3e20145 --- /dev/null +++ b/app/views/devise/mailer/unlock_instructions.text.erb @@ -0,0 +1,7 @@ +Hello, <%= @resource.name %>! + +Your GitLab account has been locked due to an excessive amount of unsuccessful +sign in attempts. Your account will automatically unlock in <%= time_ago_in_words(Devise.unlock_in.from_now) %> +or you may click the link below to unlock now. + +<%= unlock_url(@resource, unlock_token: @token) %> -- cgit v1.2.1 From 7b66dcf65e240723ae6c8772492bbb79cf4a348e Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Wed, 25 May 2016 17:53:26 -0400 Subject: Add previews for all customized Devise emails --- spec/mailers/previews/devise_mailer_preview.rb | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/spec/mailers/previews/devise_mailer_preview.rb b/spec/mailers/previews/devise_mailer_preview.rb index dc3062a4332..d6588efc486 100644 --- a/spec/mailers/previews/devise_mailer_preview.rb +++ b/spec/mailers/previews/devise_mailer_preview.rb @@ -1,11 +1,30 @@ class DeviseMailerPreview < ActionMailer::Preview def confirmation_instructions_for_signup - user = User.new(name: 'Jane Doe', email: 'signup@example.com') - DeviseMailer.confirmation_instructions(user, 'faketoken', {}) + DeviseMailer.confirmation_instructions(unsaved_user, 'faketoken', {}) end def confirmation_instructions_for_new_email user = User.last + user.unconfirmed_email = 'unconfirmed@example.com' + DeviseMailer.confirmation_instructions(user, 'faketoken', {}) end + + def reset_password_instructions + DeviseMailer.reset_password_instructions(unsaved_user, 'faketoken', {}) + end + + def unlock_instructions + DeviseMailer.unlock_instructions(unsaved_user, 'faketoken', {}) + end + + def password_change + DeviseMailer.password_change(unsaved_user, {}) + end + + private + + def unsaved_user + User.new(name: 'Jane Doe', email: 'jdoe@example.com') + end end -- cgit v1.2.1 From f63bc8222def552700d3f3d30680e34051ece616 Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Thu, 9 Jun 2016 22:04:54 -0400 Subject: Center the header logo for all Devise emails --- CHANGELOG | 1 + app/assets/stylesheets/mailers/devise.scss | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 043eb5a0d1f..480c02f06c3 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -13,6 +13,7 @@ v 8.9.0 (unreleased) - Fix an issue where note polling stopped working if a window was in the background during a refresh. - Make EmailsOnPushWorker use Sidekiq mailers queue + - Redesign all Devise emails. !4297 - 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) diff --git a/app/assets/stylesheets/mailers/devise.scss b/app/assets/stylesheets/mailers/devise.scss index 28611a5ec81..9495c5b3f37 100644 --- a/app/assets/stylesheets/mailers/devise.scss +++ b/app/assets/stylesheets/mailers/devise.scss @@ -38,6 +38,10 @@ table { margin: 0 auto; text-align: left; width: 600px; + + & > td { + text-align: center; + } } &#body { -- cgit v1.2.1 From 03ba240fddb30800d99bb251347edd67a4fa830f Mon Sep 17 00:00:00 2001 From: Robert Speicher <rspeicher@gmail.com> Date: Thu, 16 Jun 2016 20:26:29 -0400 Subject: Update CHANGELOG for !4659 [ci skip] --- CHANGELOG | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 058d4e4cb82..e699515f238 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -69,7 +69,7 @@ v 8.9.0 (unreleased) - Measure queue duration between gitlab-workhorse and Rails - Added Gfm autocomplete for labels - Make Omniauth providers specs to not modify global configuration - - Remove unused JiraIssue class and replace references with ExternalIssue + - Remove unused JiraIssue class and replace references with ExternalIssue. !4659 (Ilan Shamir) - Make authentication service for Container Registry to be compatible with < Docker 1.11 - Add Application Setting to configure Container Registry token expire delay (default 5min) - Cache assigned issue and merge request counts in sidebar nav -- cgit v1.2.1