From 11dda8db29a4843026464c0a61f65ada20646e3b Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Mon, 4 Jan 2016 14:51:59 +0100 Subject: [PATCH 001/797] Add note in upgrade guide to choose the correct version tag [ci skip] --- doc/update/8.2-to-8.3.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/doc/update/8.2-to-8.3.md b/doc/update/8.2-to-8.3.md index 3748941b78..e028975d4e 100644 --- a/doc/update/8.2-to-8.3.md +++ b/doc/update/8.2-to-8.3.md @@ -1,5 +1,14 @@ # From 8.2 to 8.3 +Make sure you view this update guide from the tag (version) of GitLab you would +like to install. In most cases this should be the highest numbered production +tag (without rc in it). You can select the tag in the version dropdown at the +top left corner of GitLab (below the menu bar). + +If the highest number stable branch is unclear please check the +[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation +guide links by version. + **NOTE:** GitLab 8.0 introduced several significant changes related to installation and configuration which *are not duplicated here*. Be sure you're already running a working version of at least 8.0 before proceeding with this From a97c51580ee996046725d570bc36b96feb9982b5 Mon Sep 17 00:00:00 2001 From: Chia Yu Pai Date: Fri, 15 Jan 2016 14:22:36 +0800 Subject: [PATCH 002/797] Update github.md add default callback URL path --- doc/integration/github.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/integration/github.md b/doc/integration/github.md index a789d2c814..dbcb2d6a00 100644 --- a/doc/integration/github.md +++ b/doc/integration/github.md @@ -18,6 +18,7 @@ GitHub will generate an application ID and secret key for you to use. - Homepage URL: The URL to your GitLab installation. 'https://gitlab.company.com' - Application description: Fill this in if you wish. - Authorization callback URL: 'https://gitlab.company.com/' + - If install from source, default callback URL is '${YOUR_DOMAIN}/import/github/callback' 1. Select "Register application". 1. You should now see a Client ID and Client Secret near the top right of the page (see screenshot). From 9597920c6371db5555415705643ad26026d198fb Mon Sep 17 00:00:00 2001 From: James Lopez Date: Thu, 18 Feb 2016 18:47:35 +0100 Subject: [PATCH 003/797] attempting to fix omniauth problem --- app/controllers/oauth/authorizations_controller.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 24025d8c72..874af5de24 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -7,6 +7,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController if pre_auth.authorizable? if skip_authorization? || matching_token? auth = authorization.authorize + reset_session redirect_to auth.redirect_uri else render "doorkeeper/authorizations/new" From ce45389a4d9b9405112a22daaa4e6808201414e0 Mon Sep 17 00:00:00 2001 From: James Lopez Date: Fri, 19 Feb 2016 14:22:06 +0100 Subject: [PATCH 004/797] another attempt to fix oauth issue --- app/controllers/oauth/authorizations_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/oauth/authorizations_controller.rb b/app/controllers/oauth/authorizations_controller.rb index 874af5de24..c721dca58d 100644 --- a/app/controllers/oauth/authorizations_controller.rb +++ b/app/controllers/oauth/authorizations_controller.rb @@ -7,7 +7,7 @@ class Oauth::AuthorizationsController < Doorkeeper::AuthorizationsController if pre_auth.authorizable? if skip_authorization? || matching_token? auth = authorization.authorize - reset_session + session.delete(:user_return_to) redirect_to auth.redirect_uri else render "doorkeeper/authorizations/new" From acf9778e77e5d6e36cb3eb750e88adaa5881a94a Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 26 Feb 2016 19:34:14 -0800 Subject: [PATCH 005/797] Use specialized system notes when MR is (un)marked as WIP --- CHANGELOG | 1 + app/models/merge_request.rb | 8 +++++++- app/services/merge_requests/base_service.rb | 16 ++++++++++++++++ app/services/system_note_service.rb | 12 ++++++++++++ 4 files changed, 36 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 5521c1286a..5ad85ab8cc 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.6.0 (unreleased) - Improve the formatting for the user page bio (Connor Shea) - Fix avatar stretching by providing a cropping feature (Johann Pardanaud) + - Use specialized system notes when MR is (un)marked as WIP v 8.5.1 - Fix group projects styles diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 1543ef311d..04c378691f 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -259,8 +259,14 @@ class MergeRequest < ActiveRecord::Base self.target_project.events.where(target_id: self.id, target_type: "MergeRequest", action: Event::CLOSED).last end + WIP_REGEX = /\A\[?WIP(\]|:| )\s*/i.freeze + def work_in_progress? - !!(title =~ /\A\[?WIP(\]|:| )/i) + title =~ WIP_REGEX + end + + def wipless_title + self.title.sub(WIP_REGEX, "") end def mergeable? diff --git a/app/services/merge_requests/base_service.rb b/app/services/merge_requests/base_service.rb index 7b306a8a53..9370b4c01a 100644 --- a/app/services/merge_requests/base_service.rb +++ b/app/services/merge_requests/base_service.rb @@ -5,6 +5,22 @@ module MergeRequests SystemNoteService.change_status(merge_request, merge_request.target_project, current_user, merge_request.state, nil) end + def create_title_change_note(issuable, old_title) + wipless_old_title = old_title.sub(MergeRequest::WIP_REGEX, "") + wipless_new_title = issuable.title.sub(MergeRequest::WIP_REGEX, "") + + removed_wip = wipless_old_title == issuable.title + added_wip = wipless_new_title == old_title + + if removed_wip + SystemNoteService.remove_merge_request_wip(issuable, issuable.project, current_user) + elsif added_wip + SystemNoteService.add_merge_request_wip(issuable, issuable.project, current_user) + else + super + end + end + def hook_data(merge_request, action) hook_data = merge_request.to_hook_data(current_user) merge_request_url = Gitlab::UrlBuilder.new(:merge_request).build(merge_request.id) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index edced01081..e579ca7656 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -144,6 +144,18 @@ class SystemNoteService create_note(noteable: noteable, project: project, author: author, note: body) end + def self.remove_merge_request_wip(noteable, project, author) + body = 'Unmarked this merge request as Work In Progress' + + create_note(noteable: noteable, project: project, author: author, note: body) + end + + def self.add_merge_request_wip(noteable, project, author) + body = 'Marked this merge request as **Work In Progress**' + + create_note(noteable: noteable, project: project, author: author, note: body) + end + # Called when the title of a Noteable is changed # # noteable - Noteable object that responds to `title` From 09ff9bc722e8ee98223c5b8ecec3b839990c5f82 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 26 Feb 2016 19:34:32 -0800 Subject: [PATCH 006/797] Easily (un)mark merge request as WIP using link Fixes #3768 --- CHANGELOG | 1 + .../javascripts/issuable_form.js.coffee | 41 +++++++++++++++++++ .../projects/merge_requests_controller.rb | 11 ++++- .../merge_requests/widget/open/_wip.html.haml | 8 +++- app/views/shared/issuable/_form.html.haml | 12 ++++-- config/routes.rb | 1 + 6 files changed, 67 insertions(+), 7 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 5ad85ab8cc..c5663717e6 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -3,6 +3,7 @@ Please view this file on the master branch, on stable branches it's out of date. v 8.6.0 (unreleased) - Improve the formatting for the user page bio (Connor Shea) - Fix avatar stretching by providing a cropping feature (Johann Pardanaud) + - Easily (un)mark merge request as WIP using link - Use specialized system notes when MR is (un)marked as WIP v 8.5.1 diff --git a/app/assets/javascripts/issuable_form.js.coffee b/app/assets/javascripts/issuable_form.js.coffee index 48c249943f..db494347a2 100644 --- a/app/assets/javascripts/issuable_form.js.coffee +++ b/app/assets/javascripts/issuable_form.js.coffee @@ -1,4 +1,5 @@ class @IssuableForm + wipRegex: /^\[?WIP(\]|:| )\s*/i constructor: (@form) -> GitLab.GfmAutoComplete.setup() new UsersSelect() @@ -14,6 +15,8 @@ class @IssuableForm @form.on "submit", @resetAutosave @form.on "click", ".btn-cancel", @resetAutosave + @initWip() + initAutosave: -> new Autosave @titleField, [ document.location.pathname, @@ -30,3 +33,41 @@ class @IssuableForm resetAutosave: => @titleField.data("autosave").reset() @descriptionField.data("autosave").reset() + + initWip: -> + return unless @form.find(".js-wip-explanation").length + + @form.on "click", ".js-remove-wip", @removeWip + + @form.on "click", ".js-add-wip", @addWip + + @titleField.on "change", @renderWipExplanation + + @renderWipExplanation() + + workInProgress: -> + @titleField.val().match(@wipRegex) + + renderWipExplanation: => + if @workInProgress() + @form.find(".js-wip-explanation").show() + @form.find(".js-no-wip-explanation").hide() + else + @form.find(".js-wip-explanation").hide() + @form.find(".js-no-wip-explanation").show() + + removeWip: (event) => + event.preventDefault() + + return unless @workInProgress() + @titleField.val @titleField.val().replace(@wipRegex, "") + + @renderWipExplanation() + + addWip: (event) => + event.preventDefault() + + return if @workInProgress() + @titleField.val "WIP: #{@titleField.val()}" + + @renderWipExplanation() diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 5fe2169460..c0375021ab 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -2,7 +2,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController before_action :module_enabled before_action :merge_request, only: [ :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check, - :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds + :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds, :remove_wip ] before_action :closes_issues, only: [:edit, :update, :show, :diffs, :commits, :builds] before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds] @@ -17,7 +17,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController before_action :authorize_create_merge_request!, only: [:new, :create] # Allow modify merge_request - before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :sort] + before_action :authorize_update_merge_request!, only: [:close, :edit, :update, :remove_wip, :sort] def index terms = params['issue_search'] @@ -161,6 +161,13 @@ class Projects::MergeRequestsController < Projects::ApplicationController end end + def remove_wip + MergeRequests::UpdateService.new(project, current_user, title: @merge_request.wipless_title).execute(@merge_request) + + redirect_to namespace_project_merge_request_path(@project.namespace, @project, @merge_request), + notice: "The merge request can now be merged." + end + def merge_check @merge_request.check_if_can_be_merged diff --git a/app/views/projects/merge_requests/widget/open/_wip.html.haml b/app/views/projects/merge_requests/widget/open/_wip.html.haml index 0cf16542cc..3f62818280 100644 --- a/app/views/projects/merge_requests/widget/open/_wip.html.haml +++ b/app/views/projects/merge_requests/widget/open/_wip.html.haml @@ -2,4 +2,10 @@ This merge request is currently a Work In Progress %p - When this merge request is ready, remove the "WIP" prefix from the title to allow it to be merged. + When this merge request is ready, + - text = 'remove the "WIP" prefix from the title' + - if can?(current_user, :update_merge_request, @merge_request) + = link_to text, remove_wip_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), method: :post + - else + = text + to allow it to be merged. diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 90dc006248..d93395a3e8 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -13,11 +13,15 @@ - if issuable.is_a?(MergeRequest) %p.help-block - - if issuable.work_in_progress? - Remove the WIP prefix from the title to allow this + .js-wip-explanation + %a{href: "#", class: "js-remove-wip", data: { }} + Remove the WIP prefix from the title + to allow this Work In Progress merge request to be merged when it's ready. - - else - Start the title with [WIP] or WIP: to prevent a + .js-no-wip-explanation + %a{href: "#", class: "js-add-wip"} + Start the title with [WIP] or WIP: + to prevent a Work In Progress merge request from being merged before it's ready. .form-group.detail-page-description = f.label :description, 'Description', class: 'control-label' diff --git a/config/routes.rb b/config/routes.rb index 1485b64da1..01a73bb39a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -599,6 +599,7 @@ Rails.application.routes.draw do post :cancel_merge_when_build_succeeds get :ci_status post :toggle_subscription + post :remove_wip end collection do From da866795edcf77298741f7acc70dd49daeec9eed Mon Sep 17 00:00:00 2001 From: Chia Yu Pai Date: Tue, 1 Mar 2016 18:30:22 +0800 Subject: [PATCH 007/797] Update github default callback url --- doc/integration/github.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/doc/integration/github.md b/doc/integration/github.md index dbcb2d6a00..ce2434330e 100644 --- a/doc/integration/github.md +++ b/doc/integration/github.md @@ -17,8 +17,7 @@ GitHub will generate an application ID and secret key for you to use. - Application name: This can be anything. Consider something like "\'s GitLab" or "\'s GitLab" or something else descriptive. - Homepage URL: The URL to your GitLab installation. 'https://gitlab.company.com' - Application description: Fill this in if you wish. - - Authorization callback URL: 'https://gitlab.company.com/' - - If install from source, default callback URL is '${YOUR_DOMAIN}/import/github/callback' + - Default authorization callback URL is '${YOUR_DOMAIN}/import/github/callback' 1. Select "Register application". 1. You should now see a Client ID and Client Secret near the top right of the page (see screenshot). From 33ba32e6cc12eb8a8bddd957bb99bd2df7628a70 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 29 Feb 2016 14:47:21 +0000 Subject: [PATCH 008/797] Applications tab on profile settings Closes #13855 --- app/assets/stylesheets/pages/profile.scss | 14 ++ .../oauth/applications_controller.rb | 6 +- app/controllers/profiles_controller.rb | 1 + .../applications/_delete_form.html.haml | 8 +- .../doorkeeper/applications/_form.html.haml | 31 ++-- app/views/profiles/applications.html.haml | 145 ++++++++++-------- 6 files changed, 119 insertions(+), 86 deletions(-) diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 4826b994e3..a3fc127e40 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -175,3 +175,17 @@ color: $profile-settings-link-color; } } + +.profile-settings-message { + line-height: 32px; + color: #9E8E60; + background-color: #FBF2D9; + border: 1px solid #F0E2BB; + border-radius: 3px; +} + +.oauth-applications { + form { + display: inline-block; + } +} diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index dc22101cd5..d983ae0b8c 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -11,6 +11,10 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController head :forbidden and return end + def new + redirect_to applications_profile_url + end + def create @application = Doorkeeper::Application.new(application_params) @@ -20,7 +24,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) redirect_to oauth_application_url(@application) else - render :new + redirect_to applications_profile_url, flash: {application: @application} end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fa7a114896..75eb9bdb96 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -13,6 +13,7 @@ class ProfilesController < Profiles::ApplicationController @authorized_tokens = current_user.oauth_authorized_tokens @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) @authorized_apps = @authorized_tokens.map(&:application).uniq - [nil] + @application = flash[:application] || Doorkeeper::Application.new end def update diff --git a/app/views/doorkeeper/applications/_delete_form.html.haml b/app/views/doorkeeper/applications/_delete_form.html.haml index 6a5c917049..001a711b1d 100644 --- a/app/views/doorkeeper/applications/_delete_form.html.haml +++ b/app/views/doorkeeper/applications/_delete_form.html.haml @@ -1,4 +1,10 @@ - submit_btn_css ||= 'btn btn-link btn-remove btn-sm' = form_tag oauth_application_path(application) do %input{:name => "_method", :type => "hidden", :value => "delete"}/ - = submit_tag 'Destroy', onclick: "return confirm('Are you sure?')", class: submit_btn_css \ No newline at end of file + - if defined? small + = button_tag type: "submit", class: "btn btn-transparent", data: { confirm: "Are you sure?" } do + %span.sr-only + Destroy + = icon('trash') + - else + = submit_tag 'Destroy', data: { confirm: "Are you sure?" }, class: submit_btn_css diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 98a61ab211..0df8362d4a 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -1,4 +1,4 @@ -= form_for application, url: doorkeeper_submit_path(application), html: {class: 'form-horizontal', role: 'form'} do |f| += form_for application, url: doorkeeper_submit_path(application), html: {role: 'form'} do |f| - if application.errors.any? .alert.alert-danger %ul @@ -6,25 +6,20 @@ %li= msg .form-group - = f.label :name, class: 'control-label' - - .col-sm-10 - = f.text_field :name, class: 'form-control', required: true + = f.label :name, class: 'label-light' + = f.text_field :name, class: 'form-control', required: true .form-group - = f.label :redirect_uri, class: 'control-label' - - .col-sm-10 - = f.text_area :redirect_uri, class: 'form-control', required: true + = f.label :redirect_uri, class: 'label-light' + = f.text_area :redirect_uri, class: 'form-control', required: true + %span.help-block + Use one line per URI + - if Doorkeeper.configuration.native_redirect_uri %span.help-block - Use one line per URI - - if Doorkeeper.configuration.native_redirect_uri - %span.help-block - Use - %code= Doorkeeper.configuration.native_redirect_uri - for local tests + Use + %code= Doorkeeper.configuration.native_redirect_uri + for local tests - .form-actions - = f.submit 'Submit', class: "btn btn-create" - = link_to "Cancel", applications_profile_path, class: "btn btn-cancel" + .prepend-top-default + = f.submit 'Add application', class: "btn btn-create" diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml index 86f3582340..911ba9f87f 100644 --- a/app/views/profiles/applications.html.haml +++ b/app/views/profiles/applications.html.haml @@ -1,70 +1,83 @@ - page_title "Applications" - header_title page_title, applications_profile_path -.alert.alert-help.prepend-top-default - - if user_oauth_applications? - Manage applications that can use GitLab as an OAuth provider, - and applications that you've authorized to use your account. - - else - Manage applications that you've authorized to use your account. +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + - if user_oauth_applications? + Manage applications that can use GitLab as an OAuth provider, + and applications that you've authorized to use your account. + - else + Manage applications that you've authorized to use your account. + .col-lg-9 + - if user_oauth_applications? + %h5 + Add new application + = render 'doorkeeper/applications/form', application: @application + %hr + - if user_oauth_applications? + .oauth-applications + %h5 + Your applications (#{@applications.size}) + - if @applications.any? + .table-responsive + %table.table.table-striped + %thead + %tr + %th Name + %th Callback URL + %th Clients + %th{width: 105} + %tbody + - @applications.each do |application| + %tr{:id => "application_#{application.id}"} + %td= link_to application.name, oauth_application_path(application) + %td + - application.redirect_uri.split.each do |uri| + %div= uri + %td= application.access_tokens.count + %td + = link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do + %span.sr-only + Edit + = icon('pencil') + = render 'doorkeeper/applications/delete_form', application: application, small: true + - else + .profile-settings-message.text-center + You don't have any applications + .oauth-authorized-applications.prepend-top-20 + - if user_oauth_applications? + %h5 + Authorized applications (#{@authorized_tokens.size}) -- if user_oauth_applications? - .oauth-applications - %h3 - Your applications - .pull-right - = link_to 'New Application', new_oauth_application_path, class: 'btn btn-success' - - if @applications.any? - .table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Callback URL - %th Clients - %th - %th - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td - - application.redirect_uri.split.each do |uri| - %div= uri - %td= application.access_tokens.count - %td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link btn-sm' - %td= render 'doorkeeper/applications/delete_form', application: application - -.oauth-authorized-applications.prepend-top-20 - - if user_oauth_applications? - %h3 - Authorized applications - - - if @authorized_tokens.any? - .table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Authorized At - %th Scope - %th - %tbody - - @authorized_apps.each do |app| - - token = app.authorized_tokens.order('created_at desc').first - %tr{:id => "application_#{app.id}"} - %td= app.name - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', application: app - - @authorized_anonymous_tokens.each do |token| - %tr - %td - Anonymous - %div.help-block - %em Authorization was granted by entering your username and password in the application. - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', token: token - - else - %p.light You don't have any authorized applications + - if @authorized_tokens.any? + .table-responsive + %table.table.table-striped + %thead + %tr + %th Name + %th Authorized At + %th Scope + %th + %tbody + - @authorized_apps.each do |app| + - token = app.authorized_tokens.order('created_at desc').first + %tr{:id => "application_#{app.id}"} + %td= app.name + %td= token.created_at + %td= token.scopes + %td= render 'doorkeeper/authorized_applications/delete_form', application: app + - @authorized_anonymous_tokens.each do |token| + %tr + %td + Anonymous + %div.help-block + %em Authorization was granted by entering your username and password in the application. + %td= token.created_at + %td= token.scopes + %td= render 'doorkeeper/authorized_applications/delete_form', token: token + - else + .profile-settings-message.text-center + You don't have any authorized applications From c2377a11957c6e5f8514ff0d68b2af343d3427d0 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 1 Mar 2016 14:37:49 +0000 Subject: [PATCH 009/797] Fixed failing application settings tests --- app/views/doorkeeper/applications/_form.html.haml | 2 +- features/profile/profile.feature | 3 +-- features/steps/profile/profile.rb | 10 +++------- 3 files changed, 5 insertions(+), 10 deletions(-) diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 0df8362d4a..906b067615 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -22,4 +22,4 @@ for local tests .prepend-top-default - = f.submit 'Add application', class: "btn btn-create" + = f.submit 'Save application', class: "btn btn-create" diff --git a/features/profile/profile.feature b/features/profile/profile.feature index 168d9d30b5..447dd92a45 100644 --- a/features/profile/profile.feature +++ b/features/profile/profile.feature @@ -76,8 +76,7 @@ Feature: Profile Scenario: I can manage application Given I visit profile applications page - Then I click on new application button - And I should see application form + Then I should see application form Then I fill application form out and submit And I see application Then I click edit diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb index 0c60328583..b7d0a17b98 100644 --- a/features/steps/profile/profile.rb +++ b/features/steps/profile/profile.rb @@ -180,18 +180,14 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end end - step 'I click on new application button' do - click_on 'New Application' - end - step 'I should see application form' do - expect(page).to have_content "New Application" + expect(page).to have_content "Add new application" end step 'I fill application form out and submit' do fill_in :doorkeeper_application_name, with: 'test' fill_in :doorkeeper_application_redirect_uri, with: 'https://test.com' - click_on "Submit" + click_on "Save application" end step 'I see application' do @@ -211,7 +207,7 @@ class Spinach::Features::Profile < Spinach::FeatureSteps step 'I change name of application and submit' do expect(page).to have_content "Edit application" fill_in :doorkeeper_application_name, with: 'test_changed' - click_on "Submit" + click_on "Save application" end step 'I see that application was changed' do From be390cff65de0bf6f0fc1f059c4243fcb85d43d0 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 1 Mar 2016 15:08:17 +0000 Subject: [PATCH 010/797] Fixed Ruby style error --- app/controllers/oauth/applications_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index d983ae0b8c..e20446b2cc 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -24,7 +24,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) redirect_to oauth_application_url(@application) else - redirect_to applications_profile_url, flash: {application: @application} + redirect_to applications_profile_url, flash: { application: @application } end end From b03df1758bfa592c0bb6804f1f6bf540b792f3e9 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 2 Mar 2016 09:15:06 +0000 Subject: [PATCH 011/797] Moved scss values into variables Fixed heading weight --- app/assets/stylesheets/framework/variables.scss | 5 +++++ app/assets/stylesheets/pages/profile.scss | 8 ++++---- app/views/profiles/applications.html.haml | 6 +++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 6fc62f7f20..5ab40f1430 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -101,6 +101,11 @@ $border-red-dark: #CA264F; $help-well-bg: #FAFAFA; $help-well-border: #E5E5E5; +$settings-message-bg: #FBF2D9; +$settings-message-color: #9E8E60; +$settings-message-border: #F0E2BB; +$settings-message-radius: 3px; + /* header */ $light-grey-header: #faf9f9; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index a3fc127e40..efb592b1c0 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -178,10 +178,10 @@ .profile-settings-message { line-height: 32px; - color: #9E8E60; - background-color: #FBF2D9; - border: 1px solid #F0E2BB; - border-radius: 3px; + color: $settings-message-color; + background-color: $settings-message-bg; + border: 1px solid $settings-message-border; + border-radius: $settings-message-radius; } .oauth-applications { diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml index 911ba9f87f..e072c18beb 100644 --- a/app/views/profiles/applications.html.haml +++ b/app/views/profiles/applications.html.haml @@ -13,7 +13,7 @@ Manage applications that you've authorized to use your account. .col-lg-9 - if user_oauth_applications? - %h5 + %h5.prepend-top-0 Add new application = render 'doorkeeper/applications/form', application: @application %hr @@ -23,7 +23,7 @@ Your applications (#{@applications.size}) - if @applications.any? .table-responsive - %table.table.table-striped + %table.table %thead %tr %th Name @@ -47,7 +47,7 @@ - else .profile-settings-message.text-center You don't have any applications - .oauth-authorized-applications.prepend-top-20 + .oauth-authorized-applications.prepend-top-20.append-bottom-default - if user_oauth_applications? %h5 Authorized applications (#{@authorized_tokens.size}) From 9f673aa498fed240db5c408b10d9107cec938aa3 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 3 Mar 2016 08:38:56 +0000 Subject: [PATCH 012/797] Removed page specific variables --- app/assets/stylesheets/framework/variables.scss | 7 +++---- app/assets/stylesheets/pages/profile.scss | 12 ++++++++---- app/views/profiles/applications.html.haml | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 5ab40f1430..406effbb6a 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -101,10 +101,9 @@ $border-red-dark: #CA264F; $help-well-bg: #FAFAFA; $help-well-border: #E5E5E5; -$settings-message-bg: #FBF2D9; -$settings-message-color: #9E8E60; -$settings-message-border: #F0E2BB; -$settings-message-radius: 3px; +$warning-message-bg: #FBF2D9; +$warning-message-color: #9E8E60; +$warning-message-border: #F0E2BB; /* header */ $light-grey-header: #faf9f9; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index efb592b1c0..1612b090fe 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -178,14 +178,18 @@ .profile-settings-message { line-height: 32px; - color: $settings-message-color; - background-color: $settings-message-bg; - border: 1px solid $settings-message-border; - border-radius: $settings-message-radius; + color: $warning-message-color; + background-color: $warning-message-bg; + border: 1px solid $warning-message-border; + border-radius: $border-radius-base; } .oauth-applications { form { display: inline-block; } + + .last-heading { + width: 105px; + } } diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml index e072c18beb..7c0f700d68 100644 --- a/app/views/profiles/applications.html.haml +++ b/app/views/profiles/applications.html.haml @@ -29,7 +29,7 @@ %th Name %th Callback URL %th Clients - %th{width: 105} + %th.last-heading %tbody - @applications.each do |application| %tr{:id => "application_#{application.id}"} From e632bd26e4b70b100e5c9891b4b2cc7e267080c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 1 Mar 2016 12:32:20 +0100 Subject: [PATCH 013/797] Fixes "iid of max iid" in Issuable sidebar for merged MR Fixes #13928 --- CHANGELOG | 1 + app/helpers/issuables_helper.rb | 6 +++++- features/project/merge_requests.feature | 9 ++++++++- features/steps/project/merge_requests.rb | 20 ++++++++++++++++++++ features/steps/shared/issuable.rb | 4 ++++ spec/factories/merge_requests.rb | 5 +++++ 6 files changed, 43 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index a98bdd26f7..658ed5aef7 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -16,6 +16,7 @@ v 8.6.0 (unreleased) - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users - Don't show Issues/MRs from archived projects in Groups view + - Fix wrong "iid of max iid" in Issuable sidebar for some merged MRs - Increase the notes polling timeout over time (Roberto Dip) - Show labels in dashboard and group milestone views - Add main language of a project in the list of projects (Tiago Botelho) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 91a3aa371e..2dfeddf736 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -31,7 +31,11 @@ module IssuablesHelper end def issuable_state_scope(issuable) - issuable.open? ? :opened : :closed + if issuable.respond_to?(:merged?) && issuable.merged? + :merged + else + issuable.open? ? :opened : :closed + end end end diff --git a/features/project/merge_requests.feature b/features/project/merge_requests.feature index f8d9fe1854..74685d24a7 100644 --- a/features/project/merge_requests.feature +++ b/features/project/merge_requests.feature @@ -46,11 +46,18 @@ Feature: Project Merge Requests Then I should see "Feature NS-03" in merge requests And I should see "Bug NS-04" in merge requests - Scenario: I visit merge request page + Scenario: I visit an open merge request page Given I click link "Bug NS-04" Then I should see merge request "Bug NS-04" And I should see "1 of 1" in the sidebar + Scenario: I visit a merged merge request page + Given project "Shop" have "Feature NS-05" merged merge request + And I click link "Merged" + And I click link "Feature NS-05" + Then I should see merge request "Feature NS-05" + And I should see "3 of 3" in the sidebar + Scenario: I close merge request page Given I click link "Bug NS-04" And I click link "Close" diff --git a/features/steps/project/merge_requests.rb b/features/steps/project/merge_requests.rb index c19b15bc9e..2eb98e91db 100644 --- a/features/steps/project/merge_requests.rb +++ b/features/steps/project/merge_requests.rb @@ -16,10 +16,18 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps click_link "Bug NS-04" end + step 'I click link "Feature NS-05"' do + click_link "Feature NS-05" + end + step 'I click link "All"' do click_link "All" end + step 'I click link "Merged"' do + click_link "Merged" + end + step 'I click link "Closed"' do click_link "Closed" end @@ -40,6 +48,10 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps expect(page).to have_content "Bug NS-04" end + step 'I should see merge request "Feature NS-05"' do + expect(page).to have_content "Feature NS-05" + end + step 'I should not see "master" branch' do expect(find('.merge-request-info')).not_to have_content "master" end @@ -120,6 +132,14 @@ class Spinach::Features::ProjectMergeRequests < Spinach::FeatureSteps author: project.users.first) end + step 'project "Shop" have "Feature NS-05" merged merge request' do + create(:merged_merge_request, + title: "Feature NS-05", + source_project: project, + target_project: project, + author: project.users.first) + end + step 'project "Shop" have "Bug NS-07" open merge request with rebased branch' do create(:merge_request, :rebased, title: "Bug NS-07", diff --git a/features/steps/shared/issuable.rb b/features/steps/shared/issuable.rb index ae10c6069a..7c094bab8a 100644 --- a/features/steps/shared/issuable.rb +++ b/features/steps/shared/issuable.rb @@ -147,6 +147,10 @@ module SharedIssuable expect_sidebar_content('2 of 2') end + step 'I should see "3 of 3" in the sidebar' do + expect_sidebar_content('3 of 3') + end + step 'I click link "Next" in the sidebar' do page.within '.issuable-sidebar' do click_link 'Next' diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index ca1c636fce..a9df5fa1d3 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -56,6 +56,10 @@ FactoryGirl.define do target_branch "feature" end + trait :merged do + state :merged + end + trait :closed do state :closed end @@ -84,6 +88,7 @@ FactoryGirl.define do merge_user author end + factory :merged_merge_request, traits: [:merged] factory :closed_merge_request, traits: [:closed] factory :reopened_merge_request, traits: [:reopened] factory :merge_request_with_diffs, traits: [:with_diffs] From befa7a9c170327ecc4ce698f416450dc3542942c Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Wed, 9 Mar 2016 13:25:39 -0500 Subject: [PATCH 014/797] Don't remove `ProjectSnippet#expires_at` from API See https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/3103 This partially reverts commit 836d5930332797192094ce4a3c8083e96f7e8c53. --- doc/api/notes.md | 1 + doc/api/project_snippets.md | 1 + doc/web_hooks/web_hooks.md | 1 + lib/api/entities.rb | 3 +++ spec/requests/api/project_snippets_spec.rb | 18 ++++++++++++++++++ 5 files changed, 24 insertions(+) create mode 100644 spec/requests/api/project_snippets_spec.rb diff --git a/doc/api/notes.md b/doc/api/notes.md index 85d4f0bafa..d4d63e825a 100644 --- a/doc/api/notes.md +++ b/doc/api/notes.md @@ -145,6 +145,7 @@ Parameters: "state": "active", "created_at": "2013-09-30T13:46:01Z" }, + "expires_at": null, "updated_at": "2013-10-02T07:34:20Z", "created_at": "2013-10-02T07:34:20Z" } diff --git a/doc/api/project_snippets.md b/doc/api/project_snippets.md index fb802102e3..a7acf37b5b 100644 --- a/doc/api/project_snippets.md +++ b/doc/api/project_snippets.md @@ -51,6 +51,7 @@ Parameters: "state": "active", "created_at": "2012-05-23T08:00:58Z" }, + "expires_at": null, "updated_at": "2012-06-28T10:52:04Z", "created_at": "2012-06-28T10:52:04Z" } diff --git a/doc/web_hooks/web_hooks.md b/doc/web_hooks/web_hooks.md index e2b53c45ab..b82306bd1d 100644 --- a/doc/web_hooks/web_hooks.md +++ b/doc/web_hooks/web_hooks.md @@ -582,6 +582,7 @@ X-Gitlab-Event: Note Hook "created_at": "2015-04-09 02:40:38 UTC", "updated_at": "2015-04-09 02:40:38 UTC", "file_name": "test.rb", + "expires_at": null, "type": "ProjectSnippet", "visibility_level": 0 } diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 5b5b8bd044..b49af093a1 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -144,6 +144,9 @@ module API expose :id, :title, :file_name expose :author, using: Entities::UserBasic expose :updated_at, :created_at + + # TODO (rspeicher): Deprecated; remove in 9.0 + expose(:expires_at) { |snippet| nil } end class ProjectEntity < Grape::Entity diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb new file mode 100644 index 0000000000..3722ddf5a3 --- /dev/null +++ b/spec/requests/api/project_snippets_spec.rb @@ -0,0 +1,18 @@ +require 'rails_helper' + +describe API::API, api: true do + include ApiHelpers + + describe 'GET /projects/:project_id/snippets/:id' do + # TODO (rspeicher): Deprecated; remove in 9.0 + it 'always exposes expires_at as nil' do + admin = create(:admin) + snippet = create(:project_snippet, author: admin) + + get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}", admin) + + expect(json_response).to have_key('expires_at') + expect(json_response['expires_at']).to be_nil + end + end +end From 3d99ffc862b76c4dcc560aa8d8f4f9c1b7dd3f81 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 2 Mar 2016 11:33:55 +0000 Subject: [PATCH 015/797] Edited timeago text on comments Unified the 'edited text' to be the same in descriptions and comments Closes #5538 --- app/assets/javascripts/notes.js.coffee | 1 + app/assets/stylesheets/pages/issuable.scss | 8 +++++ app/helpers/application_helper.rb | 30 ++++++++++++++----- app/views/projects/issues/show.html.haml | 5 +--- .../merge_requests/show/_mr_box.html.haml | 5 +--- app/views/projects/notes/_note.html.haml | 10 +------ 6 files changed, 35 insertions(+), 24 deletions(-) diff --git a/app/assets/javascripts/notes.js.coffee b/app/assets/javascripts/notes.js.coffee index c95ead22e6..063304ce89 100644 --- a/app/assets/javascripts/notes.js.coffee +++ b/app/assets/javascripts/notes.js.coffee @@ -328,6 +328,7 @@ class @Notes updateNote: (_xhr, note, _status) => # Convert returned HTML to a jQuery object so we can modify it further $html = $(note.html) + $('.js-timeago', $html).timeago() $html.syntaxHighlight() $html.find('.js-task-list-container').taskList('enable') diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 1310e6ad7c..e417869cc0 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -262,3 +262,11 @@ color: $gray-darkest; } } + +.edited-text { + color: $gray-darkest; + + .author_link { + color: $gray-darkest; + } +} diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 368969c647..cc4d2a8877 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -182,20 +182,36 @@ module ApplicationHelper # Returns an HTML-safe String def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago', skip_js: false) element = content_tag :time, time.to_s, - class: "#{html_class} js-timeago js-timeago-pending", + class: "#{html_class} js-timeago", datetime: time.to_time.getutc.iso8601, title: time.in_time_zone.to_s(:medium), data: { toggle: 'tooltip', placement: placement, container: 'body' } - unless skip_js - element << javascript_tag( - "$('.js-timeago-pending').removeClass('js-timeago-pending').timeago()" - ) - end - element end + def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', skip_js: false, include_author: false) + return nil if object.updated_at == object.created_at + + content_tag :small, class: "edited-text" do + output = content_tag :span do + "Edited " + end + output += time_ago_with_tooltip(object.updated_at) + + if include_author + if object.updated_by && object.updated_by != object.author + output += content_tag :span do + " by " + end + output += link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) + end + end + + output + end + end + def render_markup(file_name, file_content) if gitlab_markdown?(file_name) Haml::Helpers.preserve(markdown(file_content)) diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 617b043780..f507fd7f5b 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -63,10 +63,7 @@ = markdown(@issue.description, cache_key: [@issue, "description"]) %textarea.hidden.js-task-list-field = @issue.description - - if @issue.updated_at != @issue.created_at - %small - Edited - = time_ago_with_tooltip(@issue.updated_at, placement: 'bottom', html_class: 'issue_edited_ago') + = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue_edited_ago') .merge-requests = render 'merge_requests' diff --git a/app/views/projects/merge_requests/show/_mr_box.html.haml b/app/views/projects/merge_requests/show/_mr_box.html.haml index 602f787e6c..a23bd8d18d 100644 --- a/app/views/projects/merge_requests/show/_mr_box.html.haml +++ b/app/views/projects/merge_requests/show/_mr_box.html.haml @@ -11,7 +11,4 @@ %textarea.hidden.js-task-list-field = @merge_request.description - - if @merge_request.updated_at != @merge_request.created_at - %small - Edited - = time_ago_with_tooltip(@merge_request.updated_at, placement: 'bottom') + = edited_time_ago_with_tooltip(@merge_request, placement: 'bottom') diff --git a/app/views/projects/notes/_note.html.haml b/app/views/projects/notes/_note.html.haml index 52972576af..2cf32e6093 100644 --- a/app/views/projects/notes/_note.html.haml +++ b/app/views/projects/notes/_note.html.haml @@ -27,20 +27,13 @@ %span.note-last-update %a{name: dom_id(note), href: "##{dom_id(note)}", title: 'Link here'} = time_ago_with_tooltip(note.created_at, placement: 'bottom', html_class: 'note_created_ago') - - if note.updated_at != note.created_at - %span.note-updated-at - · - = icon('edit', title: 'edited') - = time_ago_with_tooltip(note.updated_at, placement: 'bottom', html_class: 'note_edited_ago') - - if note.updated_by && note.updated_by != note.author - by #{link_to_member(note.project, note.updated_by, avatar: false, author_class: nil)} - .note-body{class: note_editable?(note) ? 'js-task-list-container' : ''} .note-text = preserve do = markdown(note.note, pipeline: :note, cache_key: [note, "note"]) - if note_editable?(note) = render 'projects/notes/edit_form', note: note + = edited_time_ago_with_tooltip(note, placement: 'bottom', html_class: 'note_edited_ago', include_author: true) - if note.attachment.url .note-attachment @@ -54,4 +47,3 @@ = link_to delete_attachment_namespace_project_note_path(note.project.namespace, note.project, note), title: 'Delete this attachment', method: :delete, remote: true, data: { confirm: 'Are you sure you want to remove the attachment?' }, class: 'danger js-note-attachment-delete' do = icon('trash-o', class: 'cred') - .clear From 538f3e0d716d0f8c107af030fb318808f9e284ac Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 3 Mar 2016 15:07:45 +0000 Subject: [PATCH 016/797] Fixed ruby issues from feedback Fixed failing tests --- app/helpers/application_helper.rb | 19 +++++++------------ app/views/projects/_last_commit.html.haml | 2 +- app/views/projects/blame/show.html.haml | 2 +- app/views/projects/commits/_commit.html.haml | 2 +- spec/helpers/application_helper_spec.rb | 15 ++------------- 5 files changed, 12 insertions(+), 28 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index cc4d2a8877..f99a14c0e0 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -168,7 +168,6 @@ module ApplicationHelper # time - Time object # placement - Tooltip placement String (default: "top") # html_class - Custom class for `time` element (default: "time_ago") - # skip_js - When true, exclude the `script` tag (default: false) # # By default also includes a `script` element with Javascript necessary to # initialize the `timeago` jQuery extension. If this method is called many @@ -180,7 +179,7 @@ module ApplicationHelper # `html_class` argument is provided. # # Returns an HTML-safe String - def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago', skip_js: false) + def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago') element = content_tag :time, time.to_s, class: "#{html_class} js-timeago", datetime: time.to_time.getutc.iso8601, @@ -190,21 +189,17 @@ module ApplicationHelper element end - def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', skip_js: false, include_author: false) - return nil if object.updated_at == object.created_at + def edited_time_ago_with_tooltip(object, placement: 'top', html_class: 'time_ago', include_author: false) + return if object.updated_at == object.created_at content_tag :small, class: "edited-text" do - output = content_tag :span do - "Edited " - end - output += time_ago_with_tooltip(object.updated_at) + output = content_tag(:span, "Edited ") + output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class) if include_author if object.updated_by && object.updated_by != object.author - output += content_tag :span do - " by " - end - output += link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) + output << content_tag(:span, " by ") + output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) end end diff --git a/app/views/projects/_last_commit.html.haml b/app/views/projects/_last_commit.html.haml index 386d72e778..a47aea7ff1 100644 --- a/app/views/projects/_last_commit.html.haml +++ b/app/views/projects/_last_commit.html.haml @@ -8,5 +8,5 @@ = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message" · - #{time_ago_with_tooltip(commit.committed_date, skip_js: true)} by + #{time_ago_with_tooltip(commit.committed_date)} by = commit_author_link(commit, avatar: true, size: 24) diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 5f9a92ff93..1ae83c2383 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -29,7 +29,7 @@ .light = commit_author_link(commit, avatar: false) authored - #{time_ago_with_tooltip(commit.committed_date, skip_js: true)} + #{time_ago_with_tooltip(commit.committed_date)} %td.line-numbers - line_count = blame_group[:lines].count - (current_line...(current_line + line_count)).each do |i| diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 7f2903589a..4df4a8ca9e 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -38,5 +38,5 @@ = commit_author_link(commit, avatar: true, size: 24) authored .committed_ago - #{time_ago_with_tooltip(commit.committed_date, skip_js: true)}   + #{time_ago_with_tooltip(commit.committed_date)}   = link_to_browse_code(project, commit) diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 8013b31524..2c27e6a031 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -263,29 +263,18 @@ describe ApplicationHelper do end it 'includes a default js-timeago class' do - expect(element.attr('class')).to eq 'time_ago js-timeago js-timeago-pending' + expect(element.attr('class')).to eq 'time_ago js-timeago' end it 'accepts a custom html_class' do expect(element(html_class: 'custom_class').attr('class')). - to eq 'custom_class js-timeago js-timeago-pending' + to eq 'custom_class js-timeago' end it 'accepts a custom tooltip placement' do expect(element(placement: 'bottom').attr('data-placement')).to eq 'bottom' end - it 're-initializes timeago Javascript' do - el = element.next_element - - expect(el.name).to eq 'script' - expect(el.text).to include "$('.js-timeago-pending').removeClass('js-timeago-pending').timeago()" - end - - it 'allows the script tag to be excluded' do - expect(element(skip_js: true)).not_to include 'script' - end - it 'converts to Time' do expect { helper.time_ago_with_tooltip(Date.today) }.not_to raise_error end From 12506abfe794e75fcaa879b92bee09b8da57bf46 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 3 Mar 2016 17:04:32 +0000 Subject: [PATCH 017/797] Single if statement --- app/helpers/application_helper.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f99a14c0e0..212fecc3e7 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -196,11 +196,9 @@ module ApplicationHelper output = content_tag(:span, "Edited ") output << time_ago_with_tooltip(object.updated_at, placement: placement, html_class: html_class) - if include_author - if object.updated_by && object.updated_by != object.author - output << content_tag(:span, " by ") - output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) - end + if include_author && object.updated_by && object.updated_by != object.author + output << content_tag(:span, " by ") + output << link_to_member(object.project, object.updated_by, avatar: false, author_class: nil) end output From cd1d045fe215bb06a1cc96e12cefda5ef71058a0 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 9 Mar 2016 16:29:15 +0000 Subject: [PATCH 018/797] Fixed issue with timeago not firing --- app/helpers/application_helper.rb | 10 ++++++++-- app/views/projects/_last_commit.html.haml | 2 +- app/views/projects/blame/show.html.haml | 2 +- app/views/projects/commits/_commit.html.haml | 2 +- spec/helpers/application_helper_spec.rb | 15 +++++++++++++-- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 212fecc3e7..5cd494079b 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -179,13 +179,19 @@ module ApplicationHelper # `html_class` argument is provided. # # Returns an HTML-safe String - def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago') + def time_ago_with_tooltip(time, placement: 'top', html_class: 'time_ago', skip_js: false) element = content_tag :time, time.to_s, - class: "#{html_class} js-timeago", + class: "#{html_class} js-timeago #{"js-timeago-pending" unless skip_js}", datetime: time.to_time.getutc.iso8601, title: time.in_time_zone.to_s(:medium), data: { toggle: 'tooltip', placement: placement, container: 'body' } + unless skip_js + element << javascript_tag( + "$('.js-timeago-pending').removeClass('js-timeago-pending').timeago()" + ) + end + element end diff --git a/app/views/projects/_last_commit.html.haml b/app/views/projects/_last_commit.html.haml index a47aea7ff1..386d72e778 100644 --- a/app/views/projects/_last_commit.html.haml +++ b/app/views/projects/_last_commit.html.haml @@ -8,5 +8,5 @@ = link_to commit.short_id, namespace_project_commit_path(project.namespace, project, commit), class: "commit_short_id" = link_to_gfm commit.title, namespace_project_commit_path(project.namespace, project, commit), class: "commit-row-message" · - #{time_ago_with_tooltip(commit.committed_date)} by + #{time_ago_with_tooltip(commit.committed_date, skip_js: true)} by = commit_author_link(commit, avatar: true, size: 24) diff --git a/app/views/projects/blame/show.html.haml b/app/views/projects/blame/show.html.haml index 1ae83c2383..5f9a92ff93 100644 --- a/app/views/projects/blame/show.html.haml +++ b/app/views/projects/blame/show.html.haml @@ -29,7 +29,7 @@ .light = commit_author_link(commit, avatar: false) authored - #{time_ago_with_tooltip(commit.committed_date)} + #{time_ago_with_tooltip(commit.committed_date, skip_js: true)} %td.line-numbers - line_count = blame_group[:lines].count - (current_line...(current_line + line_count)).each do |i| diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 4df4a8ca9e..7f2903589a 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -38,5 +38,5 @@ = commit_author_link(commit, avatar: true, size: 24) authored .committed_ago - #{time_ago_with_tooltip(commit.committed_date)}   + #{time_ago_with_tooltip(commit.committed_date, skip_js: true)}   = link_to_browse_code(project, commit) diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 2c27e6a031..8013b31524 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -263,18 +263,29 @@ describe ApplicationHelper do end it 'includes a default js-timeago class' do - expect(element.attr('class')).to eq 'time_ago js-timeago' + expect(element.attr('class')).to eq 'time_ago js-timeago js-timeago-pending' end it 'accepts a custom html_class' do expect(element(html_class: 'custom_class').attr('class')). - to eq 'custom_class js-timeago' + to eq 'custom_class js-timeago js-timeago-pending' end it 'accepts a custom tooltip placement' do expect(element(placement: 'bottom').attr('data-placement')).to eq 'bottom' end + it 're-initializes timeago Javascript' do + el = element.next_element + + expect(el.name).to eq 'script' + expect(el.text).to include "$('.js-timeago-pending').removeClass('js-timeago-pending').timeago()" + end + + it 'allows the script tag to be excluded' do + expect(element(skip_js: true)).not_to include 'script' + end + it 'converts to Time' do expect { helper.time_ago_with_tooltip(Date.today) }.not_to raise_error end From c7730121c16e41d797e7f7e00f7a6ad96d78bbb4 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 10 Mar 2016 08:39:32 +0000 Subject: [PATCH 019/797] Fixed failing rubocop test --- app/helpers/application_helper.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 5cd494079b..f07c79ec61 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -186,11 +186,11 @@ module ApplicationHelper title: time.in_time_zone.to_s(:medium), data: { toggle: 'tooltip', placement: placement, container: 'body' } - unless skip_js - element << javascript_tag( - "$('.js-timeago-pending').removeClass('js-timeago-pending').timeago()" - ) - end + unless skip_js + element << javascript_tag( + "$('.js-timeago-pending').removeClass('js-timeago-pending').timeago()" + ) + end element end From f2a9ee258e0ee3a6fe0cb614e4b73c56dcd7339d Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Tue, 1 Mar 2016 12:22:29 -0300 Subject: [PATCH 020/797] Add permission level to groups --- app/controllers/explore/groups_controller.rb | 2 +- app/controllers/groups_controller.rb | 2 +- app/finders/groups_finder.rb | 31 +++++++++++++++++++ app/helpers/groups_helper.rb | 4 +++ app/helpers/visibility_level_helper.rb | 13 ++++++++ app/models/ability.rb | 5 +-- app/models/group.rb | 25 +++++++++------ app/views/groups/edit.html.haml | 2 ++ app/views/groups/new.html.haml | 2 ++ app/views/shared/_group_tips.html.haml | 1 - ...01124843_add_visibility_level_to_groups.rb | 9 ++++++ db/schema.rb | 10 +++--- spec/finders/groups_finder_spec.rb | 25 +++++++++++++++ spec/helpers/groups_helper.rb | 15 +++++++++ spec/helpers/visibility_level_helper_spec.rb | 8 +++++ spec/models/group_spec.rb | 18 +++++++++++ 16 files changed, 153 insertions(+), 19 deletions(-) create mode 100644 app/finders/groups_finder.rb create mode 100644 db/migrate/20160301124843_add_visibility_level_to_groups.rb create mode 100644 spec/finders/groups_finder_spec.rb diff --git a/app/controllers/explore/groups_controller.rb b/app/controllers/explore/groups_controller.rb index a9bf4321f7..9575a87ee4 100644 --- a/app/controllers/explore/groups_controller.rb +++ b/app/controllers/explore/groups_controller.rb @@ -1,6 +1,6 @@ class Explore::GroupsController < Explore::ApplicationController def index - @groups = Group.order_id_desc + @groups = GroupsFinder.new.execute(current_user) @groups = @groups.search(params[:search]) if params[:search].present? @groups = @groups.sort(@sort = params[:sort]) @groups = @groups.page(params[:page]).per(PER_PAGE) diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index f05c29e997..13de19bc14 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -131,7 +131,7 @@ class GroupsController < Groups::ApplicationController end def group_params - params.require(:group).permit(:name, :description, :path, :avatar, :public) + params.require(:group).permit(:name, :description, :path, :avatar, :public, :visibility_level) end def load_events diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb new file mode 100644 index 0000000000..a3a8cd541d --- /dev/null +++ b/app/finders/groups_finder.rb @@ -0,0 +1,31 @@ +class GroupsFinder + def execute(current_user = nil) + + segments = all_groups(current_user) + + if segments.length > 1 + union = Gitlab::SQL::Union.new(segments.map { |s| s.select(:id) }) + Group.where("namespaces.id IN (#{union.to_sql})").order_id_desc + else + segments.first + end + end + + private + + def all_groups(current_user) + if current_user + [current_user.authorized_groups, public_and_internal_groups] + else + [Group.public_only] + end + end + + def public_groups + Group.unscoped.public_only + end + + def public_and_internal_groups + Group.unscoped.public_and_internal_only + end +end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index 1d36969cd6..b1f0a765bb 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -19,6 +19,10 @@ module GroupsHelper end end + def can_change_group_visibility_level?(group) + can?(current_user, :change_visibility_level, group) + end + def group_icon(group) if group.is_a?(String) group = Group.find_by(path: group) diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 71d33b445c..c47342534a 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -19,6 +19,8 @@ module VisibilityLevelHelper case form_model when Project project_visibility_level_description(level) + when Group + group_visibility_level_description(level) when Snippet snippet_visibility_level_description(level, form_model) end @@ -35,6 +37,17 @@ module VisibilityLevelHelper end end + def group_visibility_level_description(level) + case level + when Gitlab::VisibilityLevel::PRIVATE + "The group can be accessed only by members." + when Gitlab::VisibilityLevel::INTERNAL + "The group can be accessed by any logged user." + when Gitlab::VisibilityLevel::PUBLIC + "The group can be accessed without any authentication." + end + end + def snippet_visibility_level_description(level, snippet = nil) case level when Gitlab::VisibilityLevel::PRIVATE diff --git a/app/models/ability.rb b/app/models/ability.rb index fe9e0aab71..c84ded6160 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -275,11 +275,12 @@ class Ability rules << :read_group end - # Only group masters and group owners can create new projects in group + # Only group masters and group owners can create new projects and change permission level if group.has_master?(user) || group.has_owner?(user) || user.admin? rules += [ :create_projects, - :admin_milestones + :admin_milestones, + :change_visibility_level ] end diff --git a/app/models/group.rb b/app/models/group.rb index 76042b3e3f..26914f5554 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -2,15 +2,16 @@ # # Table name: namespaces # -# id :integer not null, primary key -# name :string(255) not null -# path :string(255) not null -# owner_id :integer -# created_at :datetime -# updated_at :datetime -# type :string(255) -# description :string(255) default(""), not null -# avatar :string(255) +# id :integer not null, primary key +# name :string(255) not null +# path :string(255) not null +# owner_id :integer +# visibility_level :integer default(20), not null +# created_at :key => "value", datetime +# updated_at :datetime +# type :string(255) +# description :string(255) default(""), not null +# avatar :string(255) # require 'carrierwave/orm/activerecord' @@ -18,8 +19,10 @@ require 'file_size_validator' class Group < Namespace include Gitlab::ConfigHelper + include Gitlab::VisibilityLevel include Referable + has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members has_many :users, through: :group_members @@ -32,6 +35,10 @@ class Group < Namespace after_create :post_create_hook after_destroy :post_destroy_hook + scope :public_only, -> { where(visibility_level: Group::PUBLIC) } + scope :public_and_internal_only, -> { where(visibility_level: [Group::PUBLIC, Group::INTERNAL] ) } + + class << self def search(query) where("LOWER(namespaces.name) LIKE :query or LOWER(namespaces.path) LIKE :query", query: "%#{query.downcase}%") diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 3430f56a9c..ea223d2209 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -23,6 +23,8 @@ %hr = link_to 'Remove avatar', group_avatar_path(@group.to_param), data: { confirm: "Group avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar" + = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: can_change_group_visibility_level?(@group), form_model: @group + .form-actions = f.submit 'Save group', class: "btn btn-save" diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 4bc31cabea..1526ca4263 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -17,6 +17,8 @@ .col-sm-10 = render 'shared/choose_group_avatar_button', f: f + = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: true, form_model: @group + .form-group .col-sm-offset-2.col-sm-10 = render 'shared/group_tips' diff --git a/app/views/shared/_group_tips.html.haml b/app/views/shared/_group_tips.html.haml index e5cf783beb..46e4340511 100644 --- a/app/views/shared/_group_tips.html.haml +++ b/app/views/shared/_group_tips.html.haml @@ -1,6 +1,5 @@ %ul %li A group is a collection of several projects - %li Groups are private by default %li Members of a group may only view projects they have permission to access %li Group project URLs are prefixed with the group namespace %li Existing projects may be moved into a group diff --git a/db/migrate/20160301124843_add_visibility_level_to_groups.rb b/db/migrate/20160301124843_add_visibility_level_to_groups.rb new file mode 100644 index 0000000000..b0afe795f4 --- /dev/null +++ b/db/migrate/20160301124843_add_visibility_level_to_groups.rb @@ -0,0 +1,9 @@ +class AddVisibilityLevelToGroups < ActiveRecord::Migration + def change + #All groups will be private when created + add_column :namespaces, :visibility_level, :integer, null: false, default: 0 + + #Set all existing groups to public + Group.update_all(visibility_level: 20) + end +end diff --git a/db/schema.rb b/db/schema.rb index a74b86d8e2..d0e3bb769d 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,8 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20160309140734) do - +ActiveRecord::Schema.define(version: 20160305220806) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -568,14 +567,15 @@ ActiveRecord::Schema.define(version: 20160309140734) do add_index "milestones", ["title"], name: "index_milestones_on_title", using: :btree create_table "namespaces", force: :cascade do |t| - t.string "name", null: false - t.string "path", null: false + 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 "description", default: "", null: false t.string "avatar" + t.integer "visibility_level", default: 0, null: false end add_index "namespaces", ["created_at", "id"], name: "index_namespaces_on_created_at_and_id", using: :btree diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb new file mode 100644 index 0000000000..ed24954af7 --- /dev/null +++ b/spec/finders/groups_finder_spec.rb @@ -0,0 +1,25 @@ +require 'spec_helper' + +describe GroupsFinder do + describe '#execute' do + let(:user) { create(:user) } + let!(:private_group) { create(:group, visibility_level: 0) } + let!(:internal_group) { create(:group, visibility_level: 10) } + let!(:public_group) { create(:group, visibility_level: 20) } + let(:finder) { described_class.new } + + describe 'execute' do + describe 'without a user' do + subject { finder.execute } + + it { is_expected.to eq([public_group]) } + end + + describe 'with a user' do + subject { finder.execute(user) } + + it { is_expected.to eq([public_group, internal_group]) } + end + end + end +end diff --git a/spec/helpers/groups_helper.rb b/spec/helpers/groups_helper.rb index 4ea90a80a9..01ec9e5a07 100644 --- a/spec/helpers/groups_helper.rb +++ b/spec/helpers/groups_helper.rb @@ -18,4 +18,19 @@ describe GroupsHelper do expect(group_icon(group.path)).to match('group_avatar.png') end end + + describe 'permissions' do + let(:group) { create(:group) } + let!(:user) { create(:user) } + + before do + allow(self).to receive(:current_user).and_return(user) + allow(self).to receive(:can?) { true } + end + + it 'checks user ability to change permissions' do + expect(self).to receive(:can?).with(user, :change_visibility_level, group) + can_change_group_visibility_level?(group) + end + end end diff --git a/spec/helpers/visibility_level_helper_spec.rb b/spec/helpers/visibility_level_helper_spec.rb index cd7596a763..ef60ef75fb 100644 --- a/spec/helpers/visibility_level_helper_spec.rb +++ b/spec/helpers/visibility_level_helper_spec.rb @@ -8,6 +8,7 @@ describe VisibilityLevelHelper do end let(:project) { build(:project) } + let(:group) { build(:group) } let(:personal_snippet) { build(:personal_snippet) } let(:project_snippet) { build(:project_snippet) } @@ -19,6 +20,13 @@ describe VisibilityLevelHelper do end end + context 'used with a Group' do + it 'delegates groups to #group_visibility_level_description' do + expect(visibility_level_description(Gitlab::VisibilityLevel::PRIVATE, group)) + .to match /group/i + end + end + context 'called with a Snippet' do it 'delegates snippets to #snippet_visibility_level_description' do expect(visibility_level_description(Gitlab::VisibilityLevel::INTERNAL, project_snippet)) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 3c995053ee..25aa77dc4e 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -56,6 +56,24 @@ describe Group, models: true do end end + describe 'scopes' do + let!(:private_group) { create(:group, visibility_level: 0) } + let!(:internal_group) { create(:group, visibility_level: 10) } + let!(:public_group) { create(:group, visibility_level: 20) } + + describe 'public_only' do + subject { described_class.public_only } + + it{ is_expected.to eq([public_group]) } + end + + describe 'public_and_internal_only' do + subject { described_class.public_and_internal_only } + + it{ is_expected.to eq([public_group, internal_group]) } + end + end + describe '#to_reference' do it 'returns a String reference to the object' do expect(group.to_reference).to eq "@#{group.name}" From 5551ccd7201ea6b45a2e2721502ba55e8f525d8f Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Wed, 2 Mar 2016 19:13:50 -0300 Subject: [PATCH 021/797] Code improvements --- app/controllers/groups_controller.rb | 4 ++-- app/controllers/users_controller.rb | 10 ++++++++++ app/finders/groups_finder.rb | 13 ++----------- app/helpers/groups_helper.rb | 2 +- app/models/ability.rb | 18 +++++++++++------- app/models/concerns/shared_scopes.rb | 8 ++++++++ app/models/group.rb | 10 +++++----- app/models/project.rb | 3 +-- ...301124843_add_visibility_level_to_groups.rb | 5 +---- db/schema.rb | 2 +- 10 files changed, 42 insertions(+), 33 deletions(-) create mode 100644 app/models/concerns/shared_scopes.rb diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 13de19bc14..6532eee160 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -9,7 +9,7 @@ class GroupsController < Groups::ApplicationController before_action :group, except: [:index, :new, :create] # Authorize - before_action :authorize_read_group!, except: [:index, :show, :new, :create, :autocomplete] + before_action :authorize_read_group!, except: [:index, :new, :create] before_action :authorize_admin_group!, only: [:edit, :update, :destroy, :projects] before_action :authorize_create_group!, only: [:new, :create] @@ -105,7 +105,7 @@ class GroupsController < Groups::ApplicationController # Dont allow unauthorized access to group def authorize_read_group! - unless @group and (@projects.present? or can?(current_user, :read_group, @group)) + unless can?(current_user, :read_group, @group) if current_user.nil? return authenticate_user! else diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e10c633690..d26a1ce673 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,6 +3,16 @@ class UsersController < ApplicationController before_action :set_user def show +<<<<<<< HEAD +======= + @contributed_projects = contributed_projects.joined(@user).reject(&:forked?) + + @projects = PersonalProjectsFinder.new(@user).execute(current_user) + @projects = @projects.page(params[:page]).per(PER_PAGE) + + @groups = @user.groups.order_id_desc + +>>>>>>> Code improvements respond_to do |format| format.html diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index a3a8cd541d..ce62f5e762 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -1,6 +1,5 @@ class GroupsFinder def execute(current_user = nil) - segments = all_groups(current_user) if segments.length > 1 @@ -15,17 +14,9 @@ class GroupsFinder def all_groups(current_user) if current_user - [current_user.authorized_groups, public_and_internal_groups] + [current_user.authorized_groups, Group.unscoped.public_and_internal_only] else - [Group.public_only] + [Group.unscoped.public_only] end end - - def public_groups - Group.unscoped.public_only - end - - def public_and_internal_groups - Group.unscoped.public_and_internal_only - end end diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index b1f0a765bb..d918d8acd2 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -28,7 +28,7 @@ module GroupsHelper group = Group.find_by(path: group) end - if group && group.avatar.present? + if group && can?(current_user, :read_group, group) && group.avatar.present? group.avatar.url else 'no_group_avatar.png' diff --git a/app/models/ability.rb b/app/models/ability.rb index c84ded6160..ec5587d8fa 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -83,7 +83,7 @@ class Ability subject.group end - if group && group.projects.public_only.any? + if group && group.public? [:read_group] else [] @@ -271,16 +271,13 @@ class Ability def group_abilities(user, group) rules = [] - if user.admin? || group.users.include?(user) || ProjectsFinder.new.execute(user, group: group).any? - rules << :read_group - end + rules << :read_group if can_read_group?(user, group) # Only group masters and group owners can create new projects and change permission level if group.has_master?(user) || group.has_owner?(user) || user.admin? rules += [ :create_projects, - :admin_milestones, - :change_visibility_level + :admin_milestones ] end @@ -289,13 +286,20 @@ class Ability rules += [ :admin_group, :admin_namespace, - :admin_group_member + :admin_group_member, + :change_visibility_level ] end rules.flatten end + def can_read_group?(user, group) + is_project_member = ProjectsFinder.new.execute(user, group: group).any? + internal_group_allowed = group.internal? && user.present? + user.admin? || group.users.include?(user) || is_project_member || group.public? || internal_group_allowed + end + def namespace_abilities(user, namespace) rules = [] diff --git a/app/models/concerns/shared_scopes.rb b/app/models/concerns/shared_scopes.rb new file mode 100644 index 0000000000..f576d2c082 --- /dev/null +++ b/app/models/concerns/shared_scopes.rb @@ -0,0 +1,8 @@ +module SharedScopes + extend ActiveSupport::Concern + + included do + scope :public_only, -> { where(visibility_level: Group::PUBLIC) } + scope :public_and_internal_only, -> { where(visibility_level: [Group::PUBLIC, Group::INTERNAL] ) } + end +end diff --git a/app/models/group.rb b/app/models/group.rb index 26914f5554..d1a1817f0f 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -21,7 +21,7 @@ class Group < Namespace include Gitlab::ConfigHelper include Gitlab::VisibilityLevel include Referable - + include SharedScopes has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members @@ -35,10 +35,6 @@ class Group < Namespace after_create :post_create_hook after_destroy :post_destroy_hook - scope :public_only, -> { where(visibility_level: Group::PUBLIC) } - scope :public_and_internal_only, -> { where(visibility_level: [Group::PUBLIC, Group::INTERNAL] ) } - - class << self def search(query) where("LOWER(namespaces.name) LIKE :query or LOWER(namespaces.path) LIKE :query", query: "%#{query.downcase}%") @@ -69,6 +65,10 @@ class Group < Namespace name end + def visibility_level_field + visibility_level + end + def avatar_url(size = nil) if avatar.present? [gitlab_config.url, avatar.url].join diff --git a/app/models/project.rb b/app/models/project.rb index 426464dee8..6e86c7cc88 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -52,6 +52,7 @@ class Project < ActiveRecord::Base include AfterCommitQueue include CaseSensitivity include TokenAuthenticatable + include SharedScopes extend Gitlab::ConfigHelper @@ -213,8 +214,6 @@ class Project < ActiveRecord::Base scope :in_group_namespace, -> { joins(:group) } scope :personal, ->(user) { where(namespace_id: user.namespace_id) } scope :joined, ->(user) { where('namespace_id != ?', user.namespace_id) } - scope :public_only, -> { where(visibility_level: Project::PUBLIC) } - scope :public_and_internal_only, -> { where(visibility_level: Project.public_and_internal_levels) } scope :non_archived, -> { where(archived: false) } scope :for_milestones, ->(ids) { joins(:milestones).where('milestones.id' => ids).distinct } diff --git a/db/migrate/20160301124843_add_visibility_level_to_groups.rb b/db/migrate/20160301124843_add_visibility_level_to_groups.rb index b0afe795f4..86dc07f433 100644 --- a/db/migrate/20160301124843_add_visibility_level_to_groups.rb +++ b/db/migrate/20160301124843_add_visibility_level_to_groups.rb @@ -1,9 +1,6 @@ class AddVisibilityLevelToGroups < ActiveRecord::Migration def change #All groups will be private when created - add_column :namespaces, :visibility_level, :integer, null: false, default: 0 - - #Set all existing groups to public - Group.update_all(visibility_level: 20) + add_column :namespaces, :visibility_level, :integer, null: false, default: 20 end end diff --git a/db/schema.rb b/db/schema.rb index d0e3bb769d..d2f311e42d 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: 20160305220806) do +ActiveRecord::Schema.define(version: 20160301124843) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" From bd59e59d01c5e845c7f7d451feaa1488670f20de Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Thu, 3 Mar 2016 16:52:38 -0300 Subject: [PATCH 022/797] Add visibility level icon and a couple of specs --- app/helpers/groups_helper.rb | 6 ++- app/views/shared/groups/_group.html.haml | 3 ++ ...01124843_add_visibility_level_to_groups.rb | 2 +- spec/controllers/groups_controller_spec.rb | 38 +++++++++++++++++++ 4 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb index d918d8acd2..42e09149bd 100644 --- a/app/helpers/groups_helper.rb +++ b/app/helpers/groups_helper.rb @@ -28,7 +28,7 @@ module GroupsHelper group = Group.find_by(path: group) end - if group && can?(current_user, :read_group, group) && group.avatar.present? + if group && group.avatar.present? group.avatar.url else 'no_group_avatar.png' @@ -43,4 +43,8 @@ module GroupsHelper full_title end end + + def group_visibility_description(group) + "#{visibility_level_label(group.visibility_level)} - #{group_visibility_level_description(group.visibility_level)}" + end end diff --git a/app/views/shared/groups/_group.html.haml b/app/views/shared/groups/_group.html.haml index fb9a8db088..82eeb2088c 100644 --- a/app/views/shared/groups/_group.html.haml +++ b/app/views/shared/groups/_group.html.haml @@ -21,6 +21,9 @@ = icon('users') = number_with_delimiter(group.users.count) + %span{title: group_visibility_description(group)} + = visibility_level_icon(group.visibility_level, fw: false) + = image_tag group_icon(group), class: "avatar s40 hidden-xs" = link_to group, class: 'group-name title' do = group.name diff --git a/db/migrate/20160301124843_add_visibility_level_to_groups.rb b/db/migrate/20160301124843_add_visibility_level_to_groups.rb index 86dc07f433..cef553981e 100644 --- a/db/migrate/20160301124843_add_visibility_level_to_groups.rb +++ b/db/migrate/20160301124843_add_visibility_level_to_groups.rb @@ -1,6 +1,6 @@ class AddVisibilityLevelToGroups < ActiveRecord::Migration def change - #All groups will be private when created + #All groups public by default add_column :namespaces, :visibility_level, :integer, null: false, default: 20 end end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index 938e97298b..e7ead824d2 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -20,4 +20,42 @@ describe GroupsController do end end end + + describe 'GET show' do + let(:group) { create(:group, visibility_level: 20) } + + it 'checks if group can be read' do + expect(controller).to receive(:authorize_read_group!) + get :show, id: group.path + end + end + + describe 'POST create' do + before { sign_in(create(:user)) } + + it 'checks if group can be created' do + expect(controller).to receive(:authorize_create_group!) + post :create, { group: { name: "any params" } } + end + end + + describe 'DELETE destroy' do + before { sign_in(create(:user)) } + let(:group) { create(:group, visibility_level: 20) } + + it 'checks if group can be deleted' do + expect(controller).to receive(:authorize_admin_group!) + delete :destroy, id: group.path + end + end + + describe 'PUT update' do + before { sign_in(create(:user)) } + let(:group) { create(:group, visibility_level: 20) } + + it 'checks if group can be updated' do + expect(controller).to receive(:authorize_admin_group!) + put :update, id: group.path, group: { name: 'test' } + end + end end From c3e70280dffe7ee0859ebd73b902d424ca5f809a Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Tue, 8 Mar 2016 21:01:33 -0300 Subject: [PATCH 023/797] Prevent projects to have higher visibility than groups Prevent Groups to have smaller visibility than projects Add default_group_visibility_level to configuration Code improvements --- app/assets/stylesheets/framework/common.scss | 32 ++++++++++++ .../admin/application_settings_controller.rb | 1 + app/controllers/groups_controller.rb | 2 +- app/controllers/namespaces_controller.rb | 2 +- app/controllers/users_controller.rb | 5 +- app/finders/joined_groups_finder.rb | 45 ++++++++++++++++ app/helpers/visibility_level_helper.rb | 4 ++ app/models/ability.rb | 3 +- app/models/application_setting.rb | 1 + app/models/concerns/shared_scopes.rb | 8 --- app/models/group.rb | 1 - app/models/project.rb | 7 +-- app/services/groups/base_service.rb | 9 ++++ app/services/groups/update_service.rb | 44 ++++++++++++++++ app/services/projects/create_service.rb | 4 +- .../application_settings/_form.html.haml | 4 ++ app/views/groups/new.html.haml | 2 +- app/views/groups/show.html.haml | 12 ++--- app/views/layouts/header/_default.html.haml | 5 +- app/views/projects/_home_panel.html.haml | 2 +- config/gitlab.yml.example | 4 ++ ...roup_visibility_to_application_settings.rb | 11 ++++ db/schema.rb | 3 +- lib/api/entities.rb | 1 + lib/gitlab/current_settings.rb | 1 + lib/gitlab/visibility_level.rb | 7 +++ spec/controllers/groups_controller_spec.rb | 1 + spec/finders/joined_groups_finder_spec.rb | 51 +++++++++++++++++++ spec/models/project_spec.rb | 15 ++++++ spec/services/groups/update_service_spec.rb | 51 +++++++++++++++++++ 30 files changed, 304 insertions(+), 34 deletions(-) create mode 100644 app/finders/joined_groups_finder.rb delete mode 100644 app/models/concerns/shared_scopes.rb create mode 100644 app/services/groups/base_service.rb create mode 100644 app/services/groups/update_service.rb create mode 100644 db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb create mode 100644 spec/finders/joined_groups_finder_spec.rb create mode 100644 spec/services/groups/update_service_spec.rb diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index c98e43ad09..18044b25b8 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -383,3 +383,35 @@ table { margin-right: -$gl-padding; border-top: 1px solid $border-color; } +.message { + border: 1px solid #ccc; + padding: 10px; + color: #333; +} +.message { + border: 1px solid #ccc; + padding: 10px; + color: #333; +} + +.group-projects-show-title{ + h1 { + color: #313236; + margin: 0; + margin-bottom: 6px; + font-size: 23px; + font-weight: normal; + } + + .visibility-icon { + display: inline-block; + margin-left: 5px; + font-size: 18px; + color: $gray; + } + + p { + padding: 0 $gl-padding; + color: #5c5d5e; + } + } diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index 04a99d8c84..ed9f603138 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -61,6 +61,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :session_expire_delay, :default_project_visibility, :default_snippet_visibility, + :default_group_visibility, :restricted_signup_domains_raw, :version_check_enabled, :admin_notification_email, diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 6532eee160..54f14e62ea 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -79,7 +79,7 @@ class GroupsController < Groups::ApplicationController end def update - if @group.update_attributes(group_params) + if Groups::UpdateService.new(@group, current_user, group_params).execute redirect_to edit_group_path(@group), notice: "Group '#{@group.name}' was successfully updated." else render action: "edit" diff --git a/app/controllers/namespaces_controller.rb b/app/controllers/namespaces_controller.rb index 282012c60a..5a94dcb0db 100644 --- a/app/controllers/namespaces_controller.rb +++ b/app/controllers/namespaces_controller.rb @@ -14,7 +14,7 @@ class NamespacesController < ApplicationController if user redirect_to user_path(user) - elsif group + elsif group && can?(current_user, :read_group, namespace) redirect_to group_path(group) elsif current_user.nil? authenticate_user! diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index d26a1ce673..7b32572f82 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,16 +3,13 @@ class UsersController < ApplicationController before_action :set_user def show -<<<<<<< HEAD -======= @contributed_projects = contributed_projects.joined(@user).reject(&:forked?) @projects = PersonalProjectsFinder.new(@user).execute(current_user) @projects = @projects.page(params[:page]).per(PER_PAGE) - @groups = @user.groups.order_id_desc + @groups = JoinedGroupsFinder.new(@user).execute(current_user) ->>>>>>> Code improvements respond_to do |format| format.html diff --git a/app/finders/joined_groups_finder.rb b/app/finders/joined_groups_finder.rb new file mode 100644 index 0000000000..131b518563 --- /dev/null +++ b/app/finders/joined_groups_finder.rb @@ -0,0 +1,45 @@ +#Shows only authorized groups of a user +class JoinedGroupsFinder + def initialize(user = nil) + @user = user + end + + # Finds the groups of the source user, optionally limited to those visible to + # the current user. + # + # current_user - If given the groups of "@user" will only include the groups + # "current_user" can also see. + # + # Returns an ActiveRecord::Relation. + def execute(current_user = nil) + if current_user + relation = groups_visible_to_user(current_user) + else + relation = public_groups + end + + relation.order_id_desc + end + + private + + # Returns the groups the user in "current_user" can see. + # + # This list includes all public/internal projects as well as the projects of + # "@user" that "current_user" also has access to. + def groups_visible_to_user(current_user) + base = @user.authorized_groups.visible_to_user(current_user) + extra = public_and_internal_groups + union = Gitlab::SQL::Union.new([base.select(:id), extra.select(:id)]) + + Group.where("namespaces.id IN (#{union.to_sql})") + end + + def public_groups + @user.authorized_groups.public_only + end + + def public_and_internal_groups + @user.authorized_groups.public_and_internal_only + end +end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index c47342534a..930cc88363 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -80,6 +80,10 @@ module VisibilityLevelHelper current_application_settings.default_snippet_visibility end + def default_group_visibility + current_application_settings.default_group_visibility + end + def skip_level?(form_model, level) form_model.is_a?(Project) && !form_model.visibility_level_allowed?(level) diff --git a/app/models/ability.rb b/app/models/ability.rb index ec5587d8fa..1c9b15069a 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -296,8 +296,7 @@ class Ability def can_read_group?(user, group) is_project_member = ProjectsFinder.new.execute(user, group: group).any? - internal_group_allowed = group.internal? && user.present? - user.admin? || group.users.include?(user) || is_project_member || group.public? || internal_group_allowed + user.admin? || group.public? || group.internal? || group.users.include?(user) end def namespace_abilities(user, namespace) diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 269056e0e7..c4879598c4 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -18,6 +18,7 @@ # max_attachment_size :integer default(10), not null # default_project_visibility :integer # default_snippet_visibility :integer +# default_group_visibility :integer # restricted_signup_domains :text # user_oauth_applications :boolean default(TRUE) # after_sign_out_path :string(255) diff --git a/app/models/concerns/shared_scopes.rb b/app/models/concerns/shared_scopes.rb deleted file mode 100644 index f576d2c082..0000000000 --- a/app/models/concerns/shared_scopes.rb +++ /dev/null @@ -1,8 +0,0 @@ -module SharedScopes - extend ActiveSupport::Concern - - included do - scope :public_only, -> { where(visibility_level: Group::PUBLIC) } - scope :public_and_internal_only, -> { where(visibility_level: [Group::PUBLIC, Group::INTERNAL] ) } - end -end diff --git a/app/models/group.rb b/app/models/group.rb index d1a1817f0f..02b9a968dc 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -21,7 +21,6 @@ class Group < Namespace include Gitlab::ConfigHelper include Gitlab::VisibilityLevel include Referable - include SharedScopes has_many :group_members, dependent: :destroy, as: :source, class_name: 'GroupMember' alias_method :members, :group_members diff --git a/app/models/project.rb b/app/models/project.rb index 6e86c7cc88..ae5f6e2417 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -52,7 +52,6 @@ class Project < ActiveRecord::Base include AfterCommitQueue include CaseSensitivity include TokenAuthenticatable - include SharedScopes extend Gitlab::ConfigHelper @@ -934,8 +933,10 @@ class Project < ActiveRecord::Base end def visibility_level_allowed?(level) - return true unless forked? - Gitlab::VisibilityLevel.allowed_fork_levels(forked_from_project.visibility_level).include?(level.to_i) + allowed_by_forks = forked? ? Gitlab::VisibilityLevel.allowed_fork_levels(forked_from_project.visibility_level).include?(level.to_i) : true + allowed_by_groups = group.present? ? level.to_i <= group.visibility_level : true + + allowed_by_forks && allowed_by_groups end def runners_token diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb new file mode 100644 index 0000000000..5becd475d3 --- /dev/null +++ b/app/services/groups/base_service.rb @@ -0,0 +1,9 @@ +module Groups + class BaseService + attr_accessor :group, :current_user, :params + + def initialize(group, user, params = {}) + @group, @current_user, @params = group, user, params.dup + end + end +end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb new file mode 100644 index 0000000000..acb6c529c1 --- /dev/null +++ b/app/services/groups/update_service.rb @@ -0,0 +1,44 @@ +#Checks visibility level permission check before updating a group +#Do not allow to put Group visibility level smaller than its projects +#Do not allow unauthorized permission levels + +module Groups + class UpdateService < Groups::BaseService + def execute + visibility_level_allowed?(params[:visibility_level]) ? group.update_attributes(params) : false + end + + private + + def visibility_level_allowed?(level) + return true unless level.present? + + allowed_by_projects = visibility_by_project(level) + allowed_by_user = visibility_by_user(level) + + allowed_by_projects && allowed_by_user + end + + def visibility_by_project(level) + projects_visibility = group.projects.pluck(:visibility_level) + + allowed_by_projects = !projects_visibility.any?{|project_visibility| level.to_i < project_visibility } + add_error_message("Cannot be changed. There are projects with higher visibility permissions.") unless allowed_by_projects + allowed_by_projects + end + + def visibility_by_user(level) + allowed_by_user = Gitlab::VisibilityLevel.allowed_for?(current_user, level) + add_error_message("You are not authorized to set this permission level.") unless allowed_by_user + allowed_by_user + end + + def add_error_message(message) + level_name = Gitlab::VisibilityLevel.level_name(params[:visibility_level]) + group.errors.add(:visibility_level, message) + end + end +end + + + diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index a6820183be..522fae7950 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -11,8 +11,8 @@ module Projects # Make sure that the user is allowed to use the specified visibility # level - unless Gitlab::VisibilityLevel.allowed_for?(current_user, - params[:visibility_level]) + + unless Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level]) && @project.visibility_level_allowed?(@project.visibility_level) deny_visibility_level(@project) return @project end diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index b30dfd109e..0350995d03 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -19,6 +19,10 @@ = f.label :default_snippet_visibility, class: 'control-label col-sm-2' .col-sm-10 = render('shared/visibility_radios', model_method: :default_snippet_visibility, form: f, selected_level: @application_setting.default_snippet_visibility, form_model: ProjectSnippet.new) + .form-group.group-visibility-level-holder + = f.label :default_group_visibility, class: 'control-label col-sm-2' + .col-sm-10 + = render('shared/visibility_radios', model_method: :default_group_visibility, form: f, selected_level: @application_setting.default_group_visibility, form_model: Group.new) .form-group = f.label :restricted_visibility_levels, class: 'control-label col-sm-2' .col-sm-10 diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml index 1526ca4263..30ab8aeba1 100644 --- a/app/views/groups/new.html.haml +++ b/app/views/groups/new.html.haml @@ -17,7 +17,7 @@ .col-sm-10 = render 'shared/choose_group_avatar_button', f: f - = render 'shared/visibility_level', f: f, visibility_level: @group.visibility_level, can_change_visibility_level: true, form_model: @group + = render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group .form-group .col-sm-offset-2.col-sm-10 diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 6148d8cb3d..0a8c2da720 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -1,8 +1,5 @@ - @no_container = true -- unless can?(current_user, :read_group, @group) - - @disable_search_panel = true - = content_for :meta_tags do - if current_user = auto_discovery_link_tag(:atom, group_url(@group, format: :atom, private_token: current_user.private_token), title: "#{@group.name} activity") @@ -17,8 +14,12 @@ .avatar-holder = link_to group_icon(@group), target: '_blank' do = image_tag group_icon(@group), class: "avatar group-avatar s90" - .cover-title - = @group.name + .group-projects-show-title + %h1 + = @group.name + + %span.visibility-icon.has_tooltip{data: { container: 'body', placement: 'left' }, title: "#{visibility_level_label(@group.visibility_level)} - #{project_visibility_level_description(@group.visibility_level)}"} + = visibility_level_icon(@group.visibility_level, fw: false) .cover-desc.username @#{@group.path} @@ -27,7 +28,6 @@ .cover-desc.description = markdown(@group.description, pipeline: :description) - %ul.nav-links %li.active = link_to "#activity", 'data-toggle' => 'tab' do diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 77d01a7736..714da410f5 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -7,9 +7,8 @@ .navbar-collapse.collapse %ul.nav.navbar-nav.pull-right - - unless @disable_search_panel - %li.hidden-sm.hidden-xs - = render 'layouts/search' + %li.hidden-sm.hidden-xs + = render 'layouts/search' %li.visible-sm.visible-xs = link_to search_path, title: 'Search', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do = icon('search') diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index b45df44f27..dd16f504c9 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -2,7 +2,7 @@ .project-home-panel.cover-block.clearfix{:class => ("empty-project" if empty_repo)} .project-identicon-holder = project_icon(@project, alt: '', class: 'project-avatar avatar s90') - .project-home-desc + .group-projects-show-title %h1 = @project.name %span.visibility-icon.has_tooltip{data: { container: 'body' }, diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 05f127d622..fc808e228d 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -90,6 +90,10 @@ production: &base snippets: false builds: true + ## Default group features settings + default_groups_features: + visibility_level: 20 + ## Webhook settings # Number of seconds to wait for HTTP response after sending webhook HTTP POST request (default: 10) # webhook_timeout: 10 diff --git a/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb b/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb new file mode 100644 index 0000000000..c2bdd7b31f --- /dev/null +++ b/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb @@ -0,0 +1,11 @@ +class AddDefaultGroupVisibilityToApplicationSettings < ActiveRecord::Migration + def up + add_column :application_settings, :default_group_visibility, :integer + visibility = Settings.gitlab.default_groups_features['visibility_level'] + execute("update application_settings set default_group_visibility = #{visibility}") + end + + def down + remove_column :application_settings, :default_group_visibility + end +end diff --git a/db/schema.rb b/db/schema.rb index d2f311e42d..1d4e18c5f9 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: 20160301124843) do +ActiveRecord::Schema.define(version: 20160308212903) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -75,6 +75,7 @@ ActiveRecord::Schema.define(version: 20160301124843) do t.boolean "akismet_enabled", default: false t.string "akismet_api_key" t.boolean "email_author_in_body", default: false + t.integer "default_group_visibility" end create_table "audit_events", force: :cascade do |t| diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 5b5b8bd044..efad239012 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -332,6 +332,7 @@ module API expose :session_expire_delay expose :default_project_visibility expose :default_snippet_visibility + expose :default_group_visibility expose :restricted_signup_domains expose :user_oauth_applications expose :after_sign_out_path diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb index 761b63e98f..a50d8e3d5a 100644 --- a/lib/gitlab/current_settings.rb +++ b/lib/gitlab/current_settings.rb @@ -29,6 +29,7 @@ module Gitlab session_expire_delay: Settings.gitlab['session_expire_delay'], default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], + default_group_visibility: Settings.gitlab.default_groups_features['visibility_level'], restricted_signup_domains: Settings.gitlab['restricted_signup_domains'], import_sources: ['github','bitbucket','gitlab','gitorious','google_code','fogbugz','git'], shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb index 3160a3c758..f6e0dd6afc 100644 --- a/lib/gitlab/visibility_level.rb +++ b/lib/gitlab/visibility_level.rb @@ -12,6 +12,13 @@ module Gitlab PUBLIC = 20 unless const_defined?(:PUBLIC) class << self + def included(base) + base.class_eval do + scope :public_only, -> { where(visibility_level: PUBLIC) } + scope :public_and_internal_only, -> { where(visibility_level: [PUBLIC, INTERNAL] ) } + end + end + def values options.values end diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb index e7ead824d2..91db3fd1ee 100644 --- a/spec/controllers/groups_controller_spec.rb +++ b/spec/controllers/groups_controller_spec.rb @@ -54,6 +54,7 @@ describe GroupsController do let(:group) { create(:group, visibility_level: 20) } it 'checks if group can be updated' do + expect_any_instance_of(Groups::UpdateService).to receive(:execute) expect(controller).to receive(:authorize_admin_group!) put :update, id: group.path, group: { name: 'test' } end diff --git a/spec/finders/joined_groups_finder_spec.rb b/spec/finders/joined_groups_finder_spec.rb new file mode 100644 index 0000000000..e2f6c59363 --- /dev/null +++ b/spec/finders/joined_groups_finder_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe JoinedGroupsFinder do + describe '#execute' do + let!(:profile_owner) { create(:user) } + let!(:profile_visitor) { create(:user) } + + let!(:private_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE) } + let!(:private_group_2) { create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE) } + let!(:internal_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } + let!(:internal_group_2) { create(:group, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } + let!(:public_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let!(:public_group_2) { create(:group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let!(:finder) { described_class.new(profile_owner) } + + describe 'execute' do + context 'without a user only shows public groups from profile owner' do + before { public_group.add_user(profile_owner, Gitlab::Access::MASTER)} + subject { finder.execute } + + it { is_expected.to eq([public_group]) } + end + + context 'only shows groups where both users are authorized to see' do + subject { finder.execute(profile_visitor) } + + before do + private_group.add_user(profile_owner, Gitlab::Access::MASTER) + private_group.add_user(profile_visitor, Gitlab::Access::DEVELOPER) + internal_group.add_user(profile_owner, Gitlab::Access::MASTER) + public_group.add_user(profile_owner, Gitlab::Access::MASTER) + end + + it { is_expected.to eq([public_group, internal_group, private_group]) } + end + + context 'shows group if profile visitor is in one of its projects' do + before do + public_group.add_user(profile_owner, Gitlab::Access::MASTER) + private_group.add_user(profile_owner, Gitlab::Access::MASTER) + project = create(:project, :private, group: private_group, name: 'B', path: 'B') + project.team.add_user(profile_visitor, Gitlab::Access::DEVELOPER) + end + + subject { finder.execute(profile_visitor) } + + it { is_expected.to eq([public_group, private_group]) } + end + end + end +end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 2fa38a5d3d..9efaffbb57 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -583,6 +583,21 @@ describe Project, models: true do it { expect(forked_project.visibility_level_allowed?(Gitlab::VisibilityLevel::PUBLIC)).to be_falsey } end + context 'when checking projects from groups' do + let(:private_group) { create(:group, visibility_level: 0) } + let(:internal_group) { create(:group, visibility_level: 10) } + + let(:private_project) { create :project, group: private_group, visibility_level: Gitlab::VisibilityLevel::PRIVATE } + let(:internal_project) { create :project, group: internal_group, visibility_level: Gitlab::VisibilityLevel::INTERNAL } + + context 'when group is private project can not be internal' do + it { expect(private_project.visibility_level_allowed?(Gitlab::VisibilityLevel::INTERNAL)).to be_falsey } + end + + context 'when group is internal project can not be public' do + it { expect(internal_project.visibility_level_allowed?(Gitlab::VisibilityLevel::PUBLIC)).to be_falsey } + end + end end describe '#rename_repo' do diff --git a/spec/services/groups/update_service_spec.rb b/spec/services/groups/update_service_spec.rb new file mode 100644 index 0000000000..c759e32342 --- /dev/null +++ b/spec/services/groups/update_service_spec.rb @@ -0,0 +1,51 @@ +require 'spec_helper' + +describe Groups::UpdateService, services: true do + let!(:user) { create(:user) } + let!(:private_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE) } + let!(:internal_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } + let!(:public_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + + describe "execute" do + context "project visibility_level validation" do + + context "public group with public projects" do + let!(:service) { described_class.new(public_group, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL ) } + + before do + public_group.add_user(user, Gitlab::Access::MASTER) + create(:project, :public, group: public_group, name: 'B', path: 'B') + end + + it "cant downgrade permission level" do + expect(service.execute).to be_falsy + expect(public_group.errors.count).to eq(1) + end + end + + context "internal group with internal project" do + let!(:service) { described_class.new(internal_group, user, visibility_level: Gitlab::VisibilityLevel::PRIVATE ) } + + before do + internal_group.add_user(user, Gitlab::Access::MASTER) + create(:project, :internal, group: internal_group, name: 'B', path: 'B') + end + + it "cant downgrade permission level" do + expect(service.execute).to be_falsy + expect(internal_group.errors.count).to eq(1) + end + end + end + end + + context "unauthorized visibility_level validation" do + let!(:service) { described_class.new(internal_group, user, visibility_level: 99 ) } + before { internal_group.add_user(user, Gitlab::Access::MASTER) } + + it "does not change permission level" do + expect(service.execute).to be_falsy + expect(internal_group.errors.count).to eq(1) + end + end +end From 96fc1d90927624345c7426b28fb3fd135e901e60 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Wed, 9 Mar 2016 13:57:57 -0300 Subject: [PATCH 024/797] Add security specs --- app/models/ability.rb | 2 +- .../security/group/internal_access_spec.rb | 104 ++++++++++++++++++ .../security/group/private_access_spec.rb | 104 ++++++++++++++++++ .../security/group/public_access_spec.rb | 104 ++++++++++++++++++ spec/features/security/group_access_spec.rb | 40 ------- spec/support/group_access_helper.rb | 17 +++ 6 files changed, 330 insertions(+), 41 deletions(-) create mode 100644 spec/features/security/group/internal_access_spec.rb create mode 100644 spec/features/security/group/private_access_spec.rb create mode 100644 spec/features/security/group/public_access_spec.rb create mode 100644 spec/support/group_access_helper.rb diff --git a/app/models/ability.rb b/app/models/ability.rb index 1c9b15069a..fe460ccdac 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -296,7 +296,7 @@ class Ability def can_read_group?(user, group) is_project_member = ProjectsFinder.new.execute(user, group: group).any? - user.admin? || group.public? || group.internal? || group.users.include?(user) + user.admin? || group.public? || group.internal? || is_project_member || group.users.include?(user) end def namespace_abilities(user, namespace) diff --git a/spec/features/security/group/internal_access_spec.rb b/spec/features/security/group/internal_access_spec.rb new file mode 100644 index 0000000000..69a0fbb446 --- /dev/null +++ b/spec/features/security/group/internal_access_spec.rb @@ -0,0 +1,104 @@ +require 'rails_helper' + +describe 'Internal group access', feature: true do + include AccessMatchers + include GroupAccessHelper + + + + describe 'GET /groups/:path' do + subject { group_path(group(Gitlab::VisibilityLevel::INTERNAL)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end + + describe 'GET /groups/:path/issues' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::INTERNAL)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end + + describe 'GET /groups/:path/merge_requests' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::INTERNAL)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end + + + describe 'GET /groups/:path/group_members' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::INTERNAL)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end + + describe 'GET /groups/:path/edit' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::INTERNAL)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end +end diff --git a/spec/features/security/group/private_access_spec.rb b/spec/features/security/group/private_access_spec.rb new file mode 100644 index 0000000000..0d01310b44 --- /dev/null +++ b/spec/features/security/group/private_access_spec.rb @@ -0,0 +1,104 @@ +require 'rails_helper' + +describe 'Private group access', feature: true do + include AccessMatchers + include GroupAccessHelper + + + + describe 'GET /groups/:path' do + subject { group_path(group(Gitlab::VisibilityLevel::PRIVATE)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to_not be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end + + describe 'GET /groups/:path/issues' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::PRIVATE)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to_not be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end + + describe 'GET /groups/:path/merge_requests' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::PRIVATE)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to_not be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end + + + describe 'GET /groups/:path/group_members' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::PRIVATE)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to_not be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end + + describe 'GET /groups/:path/edit' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::PRIVATE)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to_not be_allowed_for :user } + it { is_expected.to_not be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to_not be_allowed_for :visitor } + end + end +end diff --git a/spec/features/security/group/public_access_spec.rb b/spec/features/security/group/public_access_spec.rb new file mode 100644 index 0000000000..75d208f294 --- /dev/null +++ b/spec/features/security/group/public_access_spec.rb @@ -0,0 +1,104 @@ +require 'rails_helper' + +describe 'Public group access', feature: true do + include AccessMatchers + include GroupAccessHelper + + + + describe 'GET /groups/:path' do + subject { group_path(group(Gitlab::VisibilityLevel::PUBLIC)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to be_allowed_for :visitor } + end + end + + describe 'GET /groups/:path/issues' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::PUBLIC)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to be_allowed_for :visitor } + end + end + + describe 'GET /groups/:path/merge_requests' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::PUBLIC)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to be_allowed_for :visitor } + end + end + + + describe 'GET /groups/:path/group_members' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::PUBLIC)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to be_allowed_for :visitor } + end + end + + describe 'GET /groups/:path/edit' do + subject { issues_group_path(group(Gitlab::VisibilityLevel::PUBLIC)) } + + context "when user not in group project" do + it { is_expected.to be_allowed_for group_member(:owner) } + it { is_expected.to be_allowed_for group_member(:master) } + it { is_expected.to be_allowed_for group_member(:reporter) } + it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :visitor } + end + + context "when user in group project" do + it { is_expected.to be_allowed_for project_group_member(:user) } + it { is_expected.to be_allowed_for :visitor } + end + end +end diff --git a/spec/features/security/group_access_spec.rb b/spec/features/security/group_access_spec.rb index 65f8073c69..0194581dfd 100644 --- a/spec/features/security/group_access_spec.rb +++ b/spec/features/security/group_access_spec.rb @@ -43,8 +43,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end context 'with mixed projects' do @@ -55,8 +53,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end context 'with internal projects' do @@ -67,8 +63,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end context 'with no projects' do @@ -77,8 +71,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end end @@ -93,8 +85,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end context 'with mixed projects' do @@ -105,8 +95,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end context 'with internal projects' do @@ -117,8 +105,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_denied_for :visitor } end context 'with no projects' do @@ -127,8 +113,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_denied_for :user } - it { is_expected.to be_denied_for :visitor } end end @@ -143,8 +127,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end context 'with mixed projects' do @@ -155,8 +137,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end context 'with internal projects' do @@ -167,8 +147,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_denied_for :visitor } end context 'with no projects' do @@ -177,8 +155,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_denied_for :user } - it { is_expected.to be_denied_for :visitor } end end @@ -193,8 +169,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end context 'with mixed projects' do @@ -205,8 +179,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_allowed_for :visitor } end context 'with internal projects' do @@ -217,8 +189,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_allowed_for :user } - it { is_expected.to be_denied_for :visitor } end context 'with no projects' do @@ -227,8 +197,6 @@ describe 'Group access', feature: true do it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_denied_for :user } - it { is_expected.to be_denied_for :visitor } end end @@ -243,8 +211,6 @@ describe 'Group access', feature: true do it { is_expected.to be_denied_for group_member(:reporter) } it { is_expected.to be_denied_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_denied_for :user } - it { is_expected.to be_denied_for :visitor } end context 'with mixed projects' do @@ -255,8 +221,6 @@ describe 'Group access', feature: true do it { is_expected.to be_denied_for group_member(:reporter) } it { is_expected.to be_denied_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_denied_for :user } - it { is_expected.to be_denied_for :visitor } end context 'with internal projects' do @@ -267,8 +231,6 @@ describe 'Group access', feature: true do it { is_expected.to be_denied_for group_member(:reporter) } it { is_expected.to be_denied_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_denied_for :user } - it { is_expected.to be_denied_for :visitor } end context 'with no projects' do @@ -277,8 +239,6 @@ describe 'Group access', feature: true do it { is_expected.to be_denied_for group_member(:reporter) } it { is_expected.to be_denied_for group_member(:guest) } it { is_expected.to be_allowed_for :admin } - it { is_expected.to be_denied_for :user } - it { is_expected.to be_denied_for :visitor } end end end diff --git a/spec/support/group_access_helper.rb b/spec/support/group_access_helper.rb new file mode 100644 index 0000000000..a1a8fb2bd7 --- /dev/null +++ b/spec/support/group_access_helper.rb @@ -0,0 +1,17 @@ +module GroupAccessHelper + def group(visibility_level=0) + @group ||= create(:group, visibility_level: visibility_level) + end + + def project_group_member(access_level) + project = create(:project, visibility_level: group.visibility_level, group: group, name: 'B', path: 'B') + + create(:user).tap { |user| project.team.add_user(user, Gitlab::Access::DEVELOPER) } + end + + def group_member(access_level, grp=group()) + level = Object.const_get("Gitlab::Access::#{access_level.upcase}") + + create(:user).tap { |user| grp.add_user(user, level) } + end +end From de251bcf6d0f1db8858fa38ac14e108c1b9ea00f Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Thu, 10 Mar 2016 10:33:09 -0300 Subject: [PATCH 025/797] Fix conflicts --- db/schema.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/db/schema.rb b/db/schema.rb index 1d4e18c5f9..082d681a17 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -12,6 +12,7 @@ # It's strongly recommended that you check this file into your version control system. ActiveRecord::Schema.define(version: 20160308212903) do + # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" From c8d06b7001e03ed3f3a63e146e6f3b0f615f4d94 Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Thu, 10 Mar 2016 11:09:31 -0500 Subject: [PATCH 026/797] Set link title for todos. Closes #14042 --- 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 4b745a5b96..f41a4e15cb 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,7 +16,7 @@ module TodosHelper def todo_target_link(todo) target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo) + link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), {title: todo.target.nil? ? h(target) : h(todo.target.title)} end def todo_target_path(todo) From c554d0cf6451c8807373a74f5d6cac36212c6c91 Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Thu, 10 Mar 2016 13:29:59 -0500 Subject: [PATCH 027/797] Rubocop --- 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 f41a4e15cb..03523e9369 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,7 +16,7 @@ module TodosHelper def todo_target_link(todo) target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), {title: todo.target.nil? ? h(target) : h(todo.target.title)} + link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), { title: todo.target.nil? ? h(target) : h(todo.target.title) } end def todo_target_path(todo) From 75ba7d5d4f13e4c4e4f53ff03a61a0f447ea0ce2 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 10 Mar 2016 20:32:08 -0500 Subject: [PATCH 028/797] Prepare docs for 8.6 RC1 [ci skip] --- doc/install/installation.md | 4 +- doc/update/8.5-to-8.6.md | 145 ++++++++++++++++++++++++++++++++++++ 2 files changed, 147 insertions(+), 2 deletions(-) create mode 100644 doc/update/8.5-to-8.6.md diff --git a/doc/install/installation.md b/doc/install/installation.md index 0fd54be58b..4f01139726 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -233,9 +233,9 @@ sudo usermod -aG redis git ### Clone the Source # Clone GitLab repository - sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-5-stable gitlab + sudo -u git -H git clone https://gitlab.com/gitlab-org/gitlab-ce.git -b 8-6-stable gitlab -**Note:** You can change `8-5-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! +**Note:** You can change `8-6-stable` to `master` if you want the *bleeding edge* version, but never install master on a production server! ### Configure It diff --git a/doc/update/8.5-to-8.6.md b/doc/update/8.5-to-8.6.md new file mode 100644 index 0000000000..e42f691cd3 --- /dev/null +++ b/doc/update/8.5-to-8.6.md @@ -0,0 +1,145 @@ +# From 8.5 to 8.6 + +### 1. Stop server + + sudo service gitlab stop + +### 2. Backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production +``` + +### 3. Get latest code + +```bash +sudo -u git -H git fetch --all +sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically +``` + +For GitLab Community Edition: + +```bash +sudo -u git -H git checkout 8-6-stable +``` + +OR + +For GitLab Enterprise Edition: + +```bash +sudo -u git -H git checkout 8-6-stable-ee +``` + +### 4. Update gitlab-shell + +```bash +cd /home/git/gitlab-shell +sudo -u git -H git fetch --all +sudo -u git -H git checkout v2.6.10 +``` + +### 5. Update gitlab-workhorse + +Install and compile gitlab-workhorse. This requires +[Go 1.5](https://golang.org/dl) which should already be on your system from +GitLab 8.1. + +```bash +cd /home/git/gitlab-workhorse +sudo -u git -H git fetch --all +sudo -u git -H git checkout 0.6.5 +sudo -u git -H make +``` + +### 6. Install libs, migrations, etc. + +```bash +cd /home/git/gitlab + +# MySQL installations (note: the line below states '--without postgres') +sudo -u git -H bundle install --without postgres development test --deployment + +# PostgreSQL installations (note: the line below states '--without mysql') +sudo -u git -H bundle install --without mysql development test --deployment + +# Optional: clean up old gems +sudo -u git -H bundle clean + +# Run database migrations +sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production + +# Clean up assets and cache +sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production + +``` + +### 7. Update configuration files + +#### New configuration options for `gitlab.yml` + +There are new configuration options available for [`gitlab.yml`](config/gitlab.yml.example). View them with the command below and apply them manually to your current `gitlab.yml`: + +```sh +git diff origin/8-5-stable:config/gitlab.yml.example origin/8-6-stable:config/gitlab.yml.example +``` + +#### Nginx configuration + +Ensure you're still up-to-date with the latest NGINX configuration changes: + +```sh +# For HTTPS configurations +git diff origin/8-5-stable:lib/support/nginx/gitlab-ssl origin/8-6-stable:lib/support/nginx/gitlab-ssl + +# For HTTP configurations +git diff origin/8-5-stable:lib/support/nginx/gitlab origin/8-6-stable:lib/support/nginx/gitlab +``` + +If you are using Apache instead of NGINX please see the updated [Apache templates]. +Also note that because Apache does not support upstreams behind Unix sockets you +will need to let gitlab-workhorse listen on a TCP port. You can do this +via [/etc/default/gitlab]. + +[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache +[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-6-stable/lib/support/init.d/gitlab.default.example#L37 + +#### Init script + +Ensure you're still up-to-date with the latest init script changes: + + sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab + +### 8. Start application + + sudo service gitlab start + sudo service nginx restart + +### 9. Check application status + +Check if GitLab and its environment are configured correctly: + + sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production + +To make sure you didn't miss anything run a more thorough check: + + sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production + +If all items are green, then congratulations, the upgrade is complete! + +## Things went south? Revert to previous version (8.5) + +### 1. Revert the code to the previous version + +Follow the [upgrade guide from 8.4 to 8.5](8.4-to-8.5.md), except for the +database migration (the backup is already migrated to the previous version). + +### 2. Restore from the backup + +```bash +cd /home/git/gitlab +sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production +``` + +If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above. From 20c663e182f0c54e1cc37dadf3aab3542e888e2d Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Fri, 11 Mar 2016 05:40:17 +0000 Subject: [PATCH 029/797] Fix requirements.md link and improve PostgreSQL commands - Point requirements.md link to the correct location - Link to gitlab installation.md document instead of github - Use psql command switch to run specified SQL commands for creation of user and database permissions. --- doc/install/installation.md | 15 ++++----------- doc/install/requirements.md | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/doc/install/installation.md b/doc/install/installation.md index 0fd54be58b..9037a332a3 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -22,7 +22,7 @@ If the highest number stable branch is unclear please check the [GitLab Blog](ht This guide is long because it covers many cases and includes all commands you need, this is [one of the few installation scripts that actually works out of the box](https://twitter.com/robinvdvleuten/status/424163226532986880). -This installation guide was created for and tested on **Debian/Ubuntu** operating systems. Please read [doc/install/requirements.md](./requirements.md) for hardware and operating system requirements. If you want to install on RHEL/CentOS we recommend using the [Omnibus packages](https://about.gitlab.com/downloads/). +This installation guide was created for and tested on **Debian/Ubuntu** operating systems. Please read [requirements.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/requirements.md) for hardware and operating system requirements. If you want to install on RHEL/CentOS we recommend using the [Omnibus packages](https://about.gitlab.com/downloads/). This is the official installation guide to set up a production server. To set up a **development installation** or for many other installation options please see [the installation section of the readme](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/README.md#installation). @@ -160,19 +160,12 @@ We recommend using a PostgreSQL database. For MySQL check [MySQL setup guide](da # Install the database packages sudo apt-get install -y postgresql postgresql-client libpq-dev - - # Login to PostgreSQL - sudo -u postgres psql -d template1 - + # Create a user for GitLab - # Do not type the 'template1=#', this is part of the prompt - template1=# CREATE USER git CREATEDB; + sudo -u postgres psql -d template1 -c "CREATE USER git CREATEDB;" # Create the GitLab production database & grant all privileges on database - template1=# CREATE DATABASE gitlabhq_production OWNER git; - - # Quit the database session - template1=# \q + sudo -u postgres psql -d template1 -c "CREATE DATABASE gitlabhq_production OWNER git;" # Try connecting to the new database with the new user sudo -u git -H psql -d gitlabhq_production diff --git a/doc/install/requirements.md b/doc/install/requirements.md index 8df142c531..a4f78613e3 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -22,7 +22,7 @@ For the installations options please see [the installation page on the GitLab we - FreeBSD On the above unsupported distributions is still possible to install GitLab yourself. -Please see the [installation from source guide](https://github.com/gitlabhq/gitlabhq/blob/master/doc/install/installation.md) and the [unofficial installation guides](https://github.com/gitlabhq/gitlab-public-wiki/wiki/Unofficial-Installation-Guides) on the public wiki for more information. +Please see the [installation from source guide](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/installation.md) and the [unofficial installation guides](https://github.com/gitlabhq/gitlab-public-wiki/wiki/Unofficial-Installation-Guides) on the public wiki for more information. ### Non-Unix operating systems such as Windows From ad4d3a075fc338280baaf6240861c9de7aa312ad Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 11 Mar 2016 13:39:11 +0100 Subject: [PATCH 030/797] Describe special YAML features: the use of anchors and hidden jobs --- CHANGELOG | 2 + doc/ci/yaml/README.md | 71 ++++++++++++++++++++ lib/ci/gitlab_ci_yaml_processor.rb | 2 + spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 29 ++++++++ 4 files changed, 104 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 1847c5193a..66675e3942 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -16,6 +16,8 @@ v 8.6.0 (unreleased) - Return empty array instead of 404 when commit has no statuses in commit status API - Decrease the font size and the padding of the `.anchor` icons used in the README (Roberto Dip) - Rewrite logo to simplify SVG code (Sean Lang) + - Allow to use YAML anchors when parsing the `.gitlab-ci.yml` (Pascal Bach) + - Ignore jobs that start with `.` (hidden jobs) - Add support for cross-project label references - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 051eaa0415..ec57ac5789 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -509,6 +509,77 @@ rspec: The cache is provided on best effort basis, so don't expect that cache will be always present. For implementation details please check GitLab Runner. +## Special features + +It's possible special YAML features like anchors and map merging. +Thus allowing to greatly reduce the complexity of `.gitlab-ci.yml`. + +#### Anchors + +You can read more about YAML features [here](https://learnxinyminutes.com/docs/yaml/). + +```yaml +.job_template: &job_definition + image: ruby:2.1 + services: + - postgres + - redis + +test1: + << *job_definition + script: + - test project + +test2: + << *job_definition + script: + - test project +``` + +The above example uses anchors and map merging. +It will create a two jobs: `test1` and `test2` that will have the parameters of `.job_template` and custom `script` defined. + +```yaml +.job_template: &job_definition + script: + - test project + +.postgres_services: + services: &postgres_definition + - postgres + - ruby + +.mysql_services: + services: &mysql_definition + - mysql + - ruby + +test:postgres: + << *job_definition + services: *postgres_definition + +test:mysql: + << *job_definition + services: *mysql_definition +``` + +The above example uses anchors to define two set of services. +It will create a two jobs: `test:postgres` and `test:mysql` that will have the script defined in `.job_template` +and one, the service defined in `.postgres_services` and second the services defined in `.mysql_services`. + +### Hidden jobs + +The jobs that start with `.` will be not processed by GitLab. + +Example of such hidden jobs: +```yaml +.job_name: + script: + - rake spec +``` + +The `.job_name` will be ignored. You can use this feature to ignore jobs, or use them as templates with special YAML features. + ## Validate the .gitlab-ci.yml Each instance of GitLab CI has an embedded debug tool called Lint. diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 1a3f662811..8ece73eec0 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -60,6 +60,7 @@ module Ci @jobs = {} @config.each do |key, job| + next if key.to_s.start_with?('.') stage = job[:stage] || job[:type] || DEFAULT_STAGE @jobs[key] = { stage: stage }.merge(job) end @@ -81,6 +82,7 @@ module Ci services: job[:services] || @services, artifacts: job[:artifacts], cache: job[:cache] || @cache, + dependencies: job[:dependencies], }.compact } end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index f3394910c5..665a65fe35 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -427,6 +427,35 @@ module Ci end end + describe "Hidden jobs" do + let(:config) do + YAML.dump({ + '.hidden_job' => { script: 'test' }, + 'normal_job' => { script: 'test' } + }) + end + + let(:config_processor) { GitlabCiYamlProcessor.new(config) } + + subject { config_processor.builds_for_stage_and_ref("test", "master") } + + it "doesn't create jobs that starts with dot" do + expect(subject.size).to eq(1) + expect(subject.first).to eq({ + except: nil, + stage: "test", + stage_idx: 1, + name: :normal_job, + only: nil, + commands: "\ntest", + tag_list: [], + options: {}, + when: "on_success", + allow_failure: false + }) + end + end + describe "Error handling" do it "fails to parse YAML" do expect{GitlabCiYamlProcessor.new("invalid: yaml: test")}.to raise_error(Psych::SyntaxError) From d300ecf8d9e886ee7cff9b883bfcdbdb1e49769b Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 11 Mar 2016 13:43:57 +0100 Subject: [PATCH 031/797] Allow to pass name of created artifacts archive in `.gitlab-ci.yml` --- CHANGELOG | 1 + doc/ci/yaml/README.md | 61 ++++++++++++++++++++ lib/ci/gitlab_ci_yaml_processor.rb | 4 ++ spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 10 +++- 4 files changed, 75 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 66675e3942..c171d662b8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -18,6 +18,7 @@ v 8.6.0 (unreleased) - Rewrite logo to simplify SVG code (Sean Lang) - Allow to use YAML anchors when parsing the `.gitlab-ci.yml` (Pascal Bach) - Ignore jobs that start with `.` (hidden jobs) + - Allow to pass name of created artifacts archive in `.gitlab-ci.yml` - Add support for cross-project label references - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index ec57ac5789..9a1f86cec4 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -453,6 +453,67 @@ release-job: The artifacts will be sent to GitLab after a successful build and will be available for download in the GitLab UI. +#### artifacts:name + +_**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.0._ + +The `name` directive allows you to define the name of created artifacts archive. + +Currently the `artifacts` is used. +It may be useful when you will want to download the archive from GitLab. +You could possible have the unique name of every archive. + +The `artifacts:name` variable can use any of the [predefined variables](../variables/README.md). + +--- + +**Example configurations** + +To create a archive with a name of current build: + +```yaml +job: + artifacts: + name: "$CI_BUILD_NAME" +``` + +To create a archive with a name of current branch or tag: + +```yaml +job: + artifacts: + name: "$CI_BUILD_REF_NAME" + untracked: true +``` + +To create a archive with a name of current branch or tag: + +```yaml +job: + artifacts: + name: "${CI_BUILD_NAME}_${CI_BUILD_REF_NAME}" + untracked: true +``` + +To create a archive with a name of stage and branch name: + +```yaml +job: + artifacts: + name: "${CI_BUILD_STAGE}_${CI_BUILD_REF_NAME}" + untracked: true +``` + +If you use **Windows Batch** to run your shell scripts you need to replace +`$` with `%`: + +```yaml +job: + artifacts: + name: "%CI_BUILD_STAGE%_%CI_BUILD_REF_NAME%" + untracked: true +``` + ### cache _**Note:** Introduced in GitLab Runner v0.7.0._ diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 8ece73eec0..ce3d013826 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -218,6 +218,10 @@ module Ci end def validate_job_artifacts!(name, job) + if job[:artifacts][:name] && !validate_string(job[:artifacts][:name]) + raise ValidationError, "#{name} job: artifacts:name parameter should be a string" + end + if job[:artifacts][:untracked] && !validate_boolean(job[:artifacts][:untracked]) raise ValidationError, "#{name} job: artifacts:untracked parameter should be an boolean" end diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index 665a65fe35..44d2b9eb1f 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -397,7 +397,7 @@ module Ci services: ["mysql"], before_script: ["pwd"], rspec: { - artifacts: { paths: ["logs/", "binaries/"], untracked: true }, + artifacts: { paths: ["logs/", "binaries/"], untracked: true, name: "custom_name" }, script: "rspec" } }) @@ -417,6 +417,7 @@ module Ci image: "ruby:2.1", services: ["mysql"], artifacts: { + name: "custom_name", paths: ["logs/", "binaries/"], untracked: true } @@ -619,6 +620,13 @@ module Ci end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: when parameter should be on_success, on_failure or always") end + it "returns errors if job artifacts:name is not an a string" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { name: 1 } } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: artifacts:name parameter should be a string") + end + it "returns errors if job artifacts:untracked is not an array of strings" do config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", artifacts: { untracked: "string" } } }) expect do From 9a271d80128451eecc9a301d5e9924c09740be07 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 11 Mar 2016 14:15:13 +0100 Subject: [PATCH 032/797] Allow to define on which builds the current one depends on --- CHANGELOG | 1 + doc/ci/yaml/README.md | 55 ++++++++++++++++++++ lib/ci/gitlab_ci_yaml_processor.rb | 21 +++++++- spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 45 ++++++++++++++++ 4 files changed, 121 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index c171d662b8..fa634cc20d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -22,6 +22,7 @@ v 8.6.0 (unreleased) - Add support for cross-project label references - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users + - Allow to define on which builds the current one depends on - Fix bug where Bitbucket `closed` issues were imported as `opened` (Iuri de Silvio) - Don't show Issues/MRs from archived projects in Groups view - Increase the notes polling timeout over time (Roberto Dip) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 9a1f86cec4..fb62ed25d6 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -241,6 +241,7 @@ job_name: | tags | no | Defines a list of tags which are used to select runner | | allow_failure | no | Allow build to fail. Failed build doesn't contribute to commit status | | when | no | Define when to run build. Can be `on_success`, `on_failure` or `always` | +| dependencies | no | Define a builds that this build depends on | | artifacts | no | Define list build artifacts | | cache | no | Define list of files that should be cached between subsequent runs | @@ -514,6 +515,60 @@ job: untracked: true ``` +### dependencies + +_**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.1._ + +This feature should be used with `artifacts` and allows to define artifacts passing between different builds. + +`artifacts` from previous stages are passed by default. + +To use a feature define `dependencies` in context of the build and pass +a list of all previous builds from which the artifacts should be downloaded. +You can only define a builds from stages that are executed before this one. +Error will be shown if you define builds from current stage or next stages. + +How to use artifacts passing between stages: + +``` +build:osx: + stage: build + script: ... + artifacts: + paths: + - binaries/ + +build:linux: + stage: build + script: ... + artifacts: + paths: + - binaries/ + +test:osx: + stage: test + script: ... + dependencies: + - build:osx + +test:linux: + stage: test + script: ... + dependencies: + - build:linux + +deploy: + stage: deploy + script: ... +``` + +The above will create a build artifacts for two jobs: `build:osx` and `build:linux`. +When executing the `test:osx` the artifacts for `build:osx` will be downloaded and extracted in context of the build. +The same happens for `test:linux` and artifacts from `build:linux`. + +The job `deploy` will download artifacts from all previous builds. +However, only the `build:osx` and `build:linux` exports artifacts so only these will be downloaded. + ### cache _**Note:** Introduced in GitLab Runner v0.7.0._ diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index ce3d013826..04b58cf1cd 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -5,7 +5,9 @@ module Ci DEFAULT_STAGES = %w(build test deploy) DEFAULT_STAGE = 'test' ALLOWED_YAML_KEYS = [:before_script, :image, :services, :types, :stages, :variables, :cache] - ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, :allow_failure, :type, :stage, :when, :artifacts, :cache] + ALLOWED_JOB_KEYS = [:tags, :script, :only, :except, :type, :image, :services, + :allow_failure, :type, :stage, :when, :artifacts, :cache, + :dependencies] attr_reader :before_script, :image, :services, :variables, :path, :cache @@ -145,6 +147,7 @@ module Ci validate_job_stage!(name, job) if job[:stage] validate_job_cache!(name, job) if job[:cache] validate_job_artifacts!(name, job) if job[:artifacts] + validate_job_dependencies!(name, job) if job[:dependencies] end private @@ -231,6 +234,22 @@ module Ci end end + def validate_job_dependencies!(name, job) + if !validate_array_of_strings(job[:dependencies]) + raise ValidationError, "#{name} job: dependencies parameter should be an array of strings" + end + + stage_index = stages.index(job[:stage]) + + job[:dependencies].each do |dependency| + raise ValidationError, "#{name} job: undefined dependency: #{dependency}" unless @jobs[dependency] + + unless stages.index(@jobs[dependency][:stage]) < stage_index + raise ValidationError, "#{name} job: dependency #{dependency} is not defined in prior stages" + end + end + 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 44d2b9eb1f..fe5096989b 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -428,6 +428,44 @@ module Ci end end + describe "Dependencies" do + let(:config) do + { + build1: { stage: 'build', script: 'test' }, + build2: { stage: 'build', script: 'test' }, + test1: { stage: 'test', script: 'test', dependencies: dependencies }, + test2: { stage: 'test', script: 'test' }, + deploy: { stage: 'test', script: 'test' } + } + end + + subject { GitlabCiYamlProcessor.new(YAML.dump(config)) } + + context 'no dependencies' do + let(:dependencies) { } + + it { expect { subject }.to_not raise_error } + end + + context 'dependencies to builds' do + let(:dependencies) { [:build1, :build2] } + + it { expect { subject }.to_not raise_error } + end + + context 'undefined dependency' do + let(:dependencies) { [:undefined] } + + it { expect { subject }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'test1 job: undefined dependency: undefined') } + end + + context 'dependencies to deploy' do + let(:dependencies) { [:deploy] } + + it { expect { subject }.to raise_error(GitlabCiYamlProcessor::ValidationError, 'test1 job: dependency deploy is not defined in prior stages') } + end + end + describe "Hidden jobs" do let(:config) do YAML.dump({ @@ -682,6 +720,13 @@ module Ci GitlabCiYamlProcessor.new(config) end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: cache:paths parameter should be an array of strings") end + + it "returns errors if job dependencies is not an array of strings" do + config = YAML.dump({ types: ["build", "test"], rspec: { script: "test", dependencies: "string" } }) + expect do + GitlabCiYamlProcessor.new(config) + end.to raise_error(GitlabCiYamlProcessor::ValidationError, "rspec job: dependencies parameter should be an array of strings") + end end end end From fb5c2147a9f2b3acc6ad5297c737da0f5546c247 Mon Sep 17 00:00:00 2001 From: Artem Sidorenko Date: Sat, 27 Feb 2016 09:28:00 +0100 Subject: [PATCH 033/797] Do not serve anything via nginx as we have workhorse Otherwise this might 'hide' problems https://github.com/gitlabhq/gitlabhq/issues/10053#issuecomment-188919319 --- lib/support/nginx/gitlab | 11 ++++++++++- lib/support/nginx/gitlab-ssl | 10 +++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/support/nginx/gitlab b/lib/support/nginx/gitlab index fc5475c4ee..1324e4cd26 100644 --- a/lib/support/nginx/gitlab +++ b/lib/support/nginx/gitlab @@ -30,7 +30,6 @@ server { listen [::]:80 default_server; server_name YOUR_SERVER_FQDN; ## Replace this with something like gitlab.example.com server_tokens off; ## Don't show the nginx version number, a security best practice - root /home/git/gitlab/public; ## See app/controllers/application_controller.rb for headers set @@ -57,4 +56,14 @@ server { proxy_pass http://gitlab-workhorse; } + + error_page 404 /404.html; + error_page 422 /422.html; + error_page 500 /500.html; + error_page 502 /502.html; + location ~ ^/(404|422|500|502)\.html$ { + root /home/git/gitlab/public; + internal; + } + } diff --git a/lib/support/nginx/gitlab-ssl b/lib/support/nginx/gitlab-ssl index 1e5f85413e..af6ea9ed70 100644 --- a/lib/support/nginx/gitlab-ssl +++ b/lib/support/nginx/gitlab-ssl @@ -45,7 +45,6 @@ server { listen [::]:443 ipv6only=on ssl default_server; server_name YOUR_SERVER_FQDN; ## Replace this with something like gitlab.example.com server_tokens off; ## Don't show the nginx version number, a security best practice - root /home/git/gitlab/public; ## Strong SSL Security ## https://raymii.org/s/tutorials/Strong_SSL_Security_On_nginx.html & https://cipherli.st/ @@ -101,4 +100,13 @@ server { proxy_set_header X-Forwarded-Proto $scheme; proxy_pass http://gitlab-workhorse; } + + error_page 404 /404.html; + error_page 422 /422.html; + error_page 500 /500.html; + error_page 502 /502.html; + location ~ ^/(404|422|500|502)\.html$ { + root /home/git/gitlab/public; + internal; + } } From 8fa2469bebf814a821da7883addc7c6c37e4a704 Mon Sep 17 00:00:00 2001 From: Artem Sidorenko Date: Sat, 27 Feb 2016 11:59:29 +0100 Subject: [PATCH 034/797] Lets have CSS and logo inline This would work independenty from location and configuration https://gitlab.com/gitlab-org/omnibus-gitlab/merge_requests/662#note_3973448 --- CHANGELOG | 1 + public/404.html | 44 ++++++++++++++++++++++++++++++++++++++++++-- public/422.html | 45 ++++++++++++++++++++++++++++++++++++++++++--- public/500.html | 44 ++++++++++++++++++++++++++++++++++++++++++-- public/502.html | 44 ++++++++++++++++++++++++++++++++++++++++++-- public/deploy.html | 41 +++++++++++++++++++++++++++++++++++++++-- public/logo.svg | 9 --------- public/static.css | 36 ------------------------------------ 8 files changed, 208 insertions(+), 56 deletions(-) delete mode 100644 public/logo.svg delete mode 100644 public/static.css diff --git a/CHANGELOG b/CHANGELOG index c1c90903d5..2c67ee244f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -32,6 +32,7 @@ v 8.5.5 - Prevent a 500 error in Todos when author was removed - Fix pagination for filtered dashboard and explore pages - Fix "Show all" link behavior + - HTTP error pages work independently from location and config (Artem Sidorenko) v 8.5.4 - Do not cache requests for badges (including builds badge) diff --git a/public/404.html b/public/404.html index a0106bc760..4862770cc2 100644 --- a/public/404.html +++ b/public/404.html @@ -2,11 +2,51 @@ The page you're looking for could not be found (404) - + -

404

+

+
+ 404 +

The page you're looking for could not be found.


Make sure the address is correct and that the page hasn't moved.

diff --git a/public/422.html b/public/422.html index 026997b48e..055b0bde16 100644 --- a/public/422.html +++ b/public/422.html @@ -2,12 +2,51 @@ The change you requested was rejected (422) - + - -

422

+

+
+ 422 +

The change you requested was rejected.


Make sure you have access to the thing you tried to change.

diff --git a/public/500.html b/public/500.html index 08c11bbd05..3d59d1392f 100644 --- a/public/500.html +++ b/public/500.html @@ -2,10 +2,50 @@ Something went wrong (500) - + -

500

+

+
+ 500 +

Whoops, something went wrong on our end.


Try refreshing the page, or going back and attempting the action again.

diff --git a/public/502.html b/public/502.html index 9480a92843..67dfd8a274 100644 --- a/public/502.html +++ b/public/502.html @@ -2,10 +2,50 @@ GitLab is not responding (502) - + -

502

+

+
+ 502 +

Whoops, GitLab is taking too much time to respond.


Try refreshing the page, or going back and attempting the action again.

diff --git a/public/deploy.html b/public/deploy.html index 3822ed4b64..48976dacf4 100644 --- a/public/deploy.html +++ b/public/deploy.html @@ -2,12 +2,49 @@ Deploy in progress - +

-
+
Deploy in progress

Please try again in a few minutes.

diff --git a/public/logo.svg b/public/logo.svg deleted file mode 100644 index fc4553137f..0000000000 --- a/public/logo.svg +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - diff --git a/public/static.css b/public/static.css deleted file mode 100644 index 0a2b6060d4..0000000000 --- a/public/static.css +++ /dev/null @@ -1,36 +0,0 @@ -body { - color: #666; - text-align: center; - font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; - margin: 0; - width: 800px; - margin: auto; - font-size: 14px; -} - -h1 { - font-size: 56px; - line-height: 100px; - font-weight: normal; - color: #456; -} - -h2 { - font-size: 24px; - color: #666; - line-height: 1.5em; -} - -h3 { - color: #456; - font-size: 20px; - font-weight: normal; - line-height: 28px; -} - -hr { - margin: 18px 0; - border: 0; - border-top: 1px solid #EEE; - border-bottom: 1px solid white; -} From 52c934a8eb78a331cb7f246fdc547604edf589aa Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Thu, 10 Mar 2016 20:30:50 -0500 Subject: [PATCH 035/797] Title attributes for activity feed --- app/helpers/events_helper.rb | 8 ++++---- app/helpers/projects_helper.rb | 2 +- app/helpers/todos_helper.rb | 2 +- app/views/events/_commit.html.haml | 2 +- app/views/events/_event_last_push.html.haml | 2 +- app/views/events/event/_common.html.haml | 2 +- app/views/events/event/_push.html.haml | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index e5fcaab955..babfc0d8c8 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -3,7 +3,7 @@ module EventsHelper author = event.author if author - link_to author.name, user_path(author.username) + link_to author.name, user_path(author.username), title: author.name else event.author_name end @@ -159,7 +159,7 @@ module EventsHelper link_to( namespace_project_commit_path(event.project.namespace, event.project, event.note_commit_id, - anchor: dom_id(event.target)), + anchor: dom_id(event.target), title: event.target_title), class: "commit_short_id" ) do "#{event.note_target_type} #{event.note_short_commit_id}" @@ -167,11 +167,11 @@ module EventsHelper elsif event.note_project_snippet? link_to(namespace_project_snippet_path(event.project.namespace, event.project, - event.note_target)) do + event.note_target), title: event.project.name) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end else - link_to event_note_target_path(event) do + link_to event_note_target_path(event), title: event.note_target.to_reference do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c8061fcdc5..c10b49cb6b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -8,7 +8,7 @@ module ProjectsHelper end def link_to_project(project) - link_to [project.namespace.becomes(Namespace), project] do + link_to [project.namespace.becomes(Namespace), project], title: project.name do title = content_tag(:span, project.name, class: 'project-name') if project.namespace diff --git a/app/helpers/todos_helper.rb b/app/helpers/todos_helper.rb index 03523e9369..07ddc691d8 100644 --- a/app/helpers/todos_helper.rb +++ b/app/helpers/todos_helper.rb @@ -16,7 +16,7 @@ module TodosHelper def todo_target_link(todo) target = todo.target_type.titleize.downcase - link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), { title: todo.target.nil? ? h(target) : h(todo.target.title) } + link_to "#{target} #{todo.target.to_reference}", todo_target_path(todo), { title: h(todo.target.title) } end def todo_target_path(todo) diff --git a/app/views/events/_commit.html.haml b/app/views/events/_commit.html.haml index 4ba8b84fd9..dce4081288 100644 --- a/app/views/events/_commit.html.haml +++ b/app/views/events/_commit.html.haml @@ -1,5 +1,5 @@ %li.commit .commit-row-title - = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '' + = link_to truncate_sha(commit[:id]), namespace_project_commit_path(project.namespace, project, commit[:id]), class: "commit_short_id", alt: '', title: truncate_sha(commit[:id]) · = markdown event_commit_title(commit[:message]), project: project, pipeline: :single_line diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml index abea86b026..fc60241ae7 100644 --- a/app/views/events/_event_last_push.html.haml +++ b/app/views/events/_event_last_push.html.haml @@ -3,7 +3,7 @@ .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 + = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: event.project.name do %strong= event.ref_name %span at %strong= link_to_project event.project diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index e9e16a7646..e7bc5b7594 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -4,7 +4,7 @@ = event_action_name(event) - if event.target - %strong= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target] + %strong= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], title: event.target.to_reference = event_preposition(event) diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 8bed5cdb9c..b7470db81e 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -5,7 +5,7 @@ %strong= event.ref_name - else %strong - = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name) + = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: event.target_title at = link_to_project event.project From ea5f4cae53eb571b250fcbaa3649cefe3083a636 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Fri, 11 Mar 2016 17:47:05 +0100 Subject: [PATCH 036/797] Bring ProjectGroupLink model and migrations from EE Signed-off-by: Dmitriy Zaporozhets --- app/models/group.rb | 2 ++ app/models/project.rb | 2 ++ app/models/project_group_link.rb | 36 +++++++++++++++++++ ...130711063759_create_project_group_links.rb | 10 ++++++ ...102832_add_access_to_project_group_link.rb | 5 +++ db/schema.rb | 14 ++++++-- spec/factories/project_group_links.rb | 6 ++++ spec/models/project_group_link_spec.rb | 17 +++++++++ 8 files changed, 89 insertions(+), 3 deletions(-) create mode 100644 app/models/project_group_link.rb create mode 100644 db/migrate/20130711063759_create_project_group_links.rb create mode 100644 db/migrate/20130820102832_add_access_to_project_group_link.rb create mode 100644 spec/factories/project_group_links.rb create mode 100644 spec/models/project_group_link_spec.rb diff --git a/app/models/group.rb b/app/models/group.rb index 76042b3e3f..e307624e96 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -23,6 +23,8 @@ 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 :project_group_links, dependent: :destroy + has_many :shared_projects, through: :project_group_links, source: :project validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } diff --git a/app/models/project.rb b/app/models/project.rb index 65829bec77..2232637f1f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -151,6 +151,8 @@ class Project < ActiveRecord::Base has_many :releases, dependent: :destroy has_many :lfs_objects_projects, dependent: :destroy has_many :lfs_objects, through: :lfs_objects_projects + has_many :project_group_links, dependent: :destroy + has_many :invited_groups, through: :project_group_links, source: :group has_many :todos, dependent: :destroy has_one :import_data, dependent: :destroy, class_name: "ProjectImportData" diff --git a/app/models/project_group_link.rb b/app/models/project_group_link.rb new file mode 100644 index 0000000000..e52a6bd7c8 --- /dev/null +++ b/app/models/project_group_link.rb @@ -0,0 +1,36 @@ +class ProjectGroupLink < ActiveRecord::Base + GUEST = 10 + REPORTER = 20 + DEVELOPER = 30 + MASTER = 40 + + belongs_to :project + belongs_to :group + + validates :project_id, presence: true + validates :group_id, presence: true + validates :group_id, uniqueness: { scope: [:project_id], message: "already shared with this group" } + validates :group_access, presence: true + validates :group_access, inclusion: { in: Gitlab::Access.values }, presence: true + validate :different_group + + def self.access_options + Gitlab::Access.options + end + + def self.default_access + DEVELOPER + end + + def human_access + self.class.access_options.key(self.group_access) + end + + private + + def different_group + if self.group && self.project && self.project.group == self.group + errors.add(:base, "Project cannot be shared with the project it is in.") + end + end +end diff --git a/db/migrate/20130711063759_create_project_group_links.rb b/db/migrate/20130711063759_create_project_group_links.rb new file mode 100644 index 0000000000..395083f2a0 --- /dev/null +++ b/db/migrate/20130711063759_create_project_group_links.rb @@ -0,0 +1,10 @@ +class CreateProjectGroupLinks < ActiveRecord::Migration + def change + create_table :project_group_links do |t| + t.integer :project_id, null: false + t.integer :group_id, null: false + + t.timestamps + end + end +end diff --git a/db/migrate/20130820102832_add_access_to_project_group_link.rb b/db/migrate/20130820102832_add_access_to_project_group_link.rb new file mode 100644 index 0000000000..00e3947a6b --- /dev/null +++ b/db/migrate/20130820102832_add_access_to_project_group_link.rb @@ -0,0 +1,5 @@ +class AddAccessToProjectGroupLink < ActiveRecord::Migration + def change + add_column :project_group_links, :group_access, :integer, null: false, default: ProjectGroupLink.default_access + end +end diff --git a/db/schema.rb b/db/schema.rb index a74b86d8e2..89e8c9e67b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -656,6 +656,14 @@ ActiveRecord::Schema.define(version: 20160309140734) do 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" @@ -749,9 +757,9 @@ ActiveRecord::Schema.define(version: 20160309140734) do t.string "type" t.string "title" t.integer "project_id" - t.datetime "created_at", null: false - t.datetime "updated_at", null: false - t.boolean "active", null: false + 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 diff --git a/spec/factories/project_group_links.rb b/spec/factories/project_group_links.rb new file mode 100644 index 0000000000..e73cc05f9d --- /dev/null +++ b/spec/factories/project_group_links.rb @@ -0,0 +1,6 @@ +FactoryGirl.define do + factory :project_group_link do + project + group + end +end diff --git a/spec/models/project_group_link_spec.rb b/spec/models/project_group_link_spec.rb new file mode 100644 index 0000000000..2fa6715fca --- /dev/null +++ b/spec/models/project_group_link_spec.rb @@ -0,0 +1,17 @@ +require 'spec_helper' + +describe ProjectGroupLink do + describe "Associations" do + it { should belong_to(:group) } + it { should belong_to(:project) } + end + + describe "Validation" do + let!(:project_group_link) { create(:project_group_link) } + + it { should validate_presence_of(:project_id) } + it { should validate_uniqueness_of(:group_id).scoped_to(:project_id).with_message(/already shared/) } + it { should validate_presence_of(:group_id) } + it { should validate_presence_of(:group_access) } + end +end From 25c2349200153db914ab498bb4b14099a79db01a Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Fri, 11 Mar 2016 17:59:51 +0100 Subject: [PATCH 037/797] Added 8.6 upgrade guide for PostgreSQL users --- doc/update/8.5-to-8.6.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/doc/update/8.5-to-8.6.md b/doc/update/8.5-to-8.6.md index e42f691cd3..a8167b2cf0 100644 --- a/doc/update/8.5-to-8.6.md +++ b/doc/update/8.5-to-8.6.md @@ -105,6 +105,23 @@ via [/etc/default/gitlab]. [Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache [/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-6-stable/lib/support/init.d/gitlab.default.example#L37 +### 8. Updates for PostgreSQL Users + +Starting with 8.6 users using GitLab in combination with PostgreSQL are required +to have the "pg_trgm" extension enabled for all GitLab databases. If you're +using GitLab's Omnibus packages there's nothing you'll need to do manually as +this extension is enabled automatically. Users who install GitLab without using +Omnibus (e.g. by building from source) have to enable this extension manually. +To enable this extension run the following SQL command as a PostgreSQL super +user for _every_ GitLab database: + + CREATE EXTENSION IF NOT EXISTS pg_trgm; + +Certain operating systems might require the installation of extra packages for +this extension to be available. For example, users using Ubuntu will have to +install the "postgresql-contrib" package in order for this extension to be +available. + #### Init script Ensure you're still up-to-date with the latest init script changes: From 746ac56b6f92991bc15d3cd787094b9555825ac3 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Fri, 11 Mar 2016 18:37:46 +0100 Subject: [PATCH 038/797] Add functionality to setup share of project with group via project settings Signed-off-by: Dmitriy Zaporozhets --- app/assets/javascripts/dispatcher.js.coffee | 2 + .../projects/group_links_controller.rb | 23 +++++++++++ app/models/project.rb | 5 +++ .../layouts/nav/_project_settings.html.haml | 6 +++ .../projects/group_links/index.html.haml | 41 +++++++++++++++++++ config/routes.rb | 2 + 6 files changed, 79 insertions(+) create mode 100644 app/controllers/projects/group_links_controller.rb create mode 100644 app/views/projects/group_links/index.html.haml diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 54b28f2dd8..e964bba132 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -103,6 +103,8 @@ class Dispatcher new ProjectFork() when 'projects:artifacts:browse' new BuildArtifacts() + when 'projects:group_links:index' + new GroupsSelect() switch path.first() when 'admin' diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb new file mode 100644 index 0000000000..4159e53bfa --- /dev/null +++ b/app/controllers/projects/group_links_controller.rb @@ -0,0 +1,23 @@ +class Projects::GroupLinksController < Projects::ApplicationController + layout 'project_settings' + before_action :authorize_admin_project! + + def index + @group_links = project.project_group_links.all + end + + def create + link = project.project_group_links.new + link.group_id = params[:link_group_id] + link.group_access = params[:link_group_access] + link.save + + redirect_to namespace_project_group_links_path(project.namespace, project) + end + + def destroy + project.project_group_links.find(params[:id]).destroy + + redirect_to namespace_project_group_links_path(project.namespace, project) + end +end diff --git a/app/models/project.rb b/app/models/project.rb index 2232637f1f..5686545972 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -880,6 +880,11 @@ class Project < ActiveRecord::Base jira_tracker? && jira_service.active end + def allowed_to_share_with_group? + # TODO: replace with logic + true + end + def ci_commit(sha) ci_commits.find_by(sha: sha) end diff --git a/app/views/layouts/nav/_project_settings.html.haml b/app/views/layouts/nav/_project_settings.html.haml index 3359716202..dc3050f02e 100644 --- a/app/views/layouts/nav/_project_settings.html.haml +++ b/app/views/layouts/nav/_project_settings.html.haml @@ -13,6 +13,12 @@ = icon('pencil-square-o fw') %span Project Settings + - 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 + = icon('share-square-o fw') + %span + Groups = nav_link(controller: :deploy_keys) do = link_to namespace_project_deploy_keys_path(@project.namespace, @project), title: 'Deploy Keys' do = icon('key fw') diff --git a/app/views/projects/group_links/index.html.haml b/app/views/projects/group_links/index.html.haml new file mode 100644 index 0000000000..13f5fc141f --- /dev/null +++ b/app/views/projects/group_links/index.html.haml @@ -0,0 +1,41 @@ +- page_title "Groups" +%h3.page_title Share project with other groups +%p.light + Projects can be stored in only one group at once. However you can share a project with other groups here. +%hr +- if @group_links.present? + .enabled-groups.panel.panel-default + .panel-heading + Already shared with + %ul.well-list + - @group_links.each do |group_link| + - group = group_link.group + %li + .pull-right + = link_to namespace_project_group_link_path(@project.namespace, @project, group_link), method: :delete, class: 'btn btn-sm' do + %i.icon-remove + disable sharing + = link_to group do + %strong + %i.icon-folder-open + = group.name + %br + .light up to #{group_link.human_access} + + +.available-groups + %h4 + Can be shared with + %div + = form_tag namespace_project_group_links_path(@project.namespace, @project), method: :post, class: 'form-horizontal' do + .form-group + = label_tag :link_group_id, 'Group', class: 'control-label' + .col-sm-10 + = groups_select_tag(:link_group_id, skip_group: @project.group.try(:path)) + .form-group + = label_tag :link_group_access, 'Max access level', class: 'control-label' + .col-sm-10 + = select_tag :link_group_access, options_for_select(ProjectGroupLink.access_options, ProjectGroupLink.default_access), class: "form-control" + .form-actions + = submit_tag "Share", class: "btn btn-create" + diff --git a/config/routes.rb b/config/routes.rb index a918b5bd3f..a0e0cc87f5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -701,6 +701,8 @@ Rails.application.routes.draw do end end + resources :group_links, only: [:index, :create, :destroy], constraints: { id: /\d+/ } + resources :notes, only: [:index, :create, :destroy, :update], constraints: { id: /\d+/ } do member do delete :delete_attachment From f8163c81e7c765ea654ea81818eb3f8a9da7648e Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Fri, 11 Mar 2016 18:42:16 +0100 Subject: [PATCH 039/797] Show shared projects on group page Signed-off-by: Dmitriy Zaporozhets --- app/controllers/groups_controller.rb | 2 ++ app/views/groups/_shared_projects.html.haml | 18 ++++++++++++++++++ app/views/groups/show.html.haml | 7 +++++++ 3 files changed, 27 insertions(+) create mode 100644 app/views/groups/_shared_projects.html.haml diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index f05c29e997..bd6b44bdbb 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -46,6 +46,8 @@ class GroupsController < Groups::ApplicationController @projects = @projects.sort(@sort = params[:sort]) @projects = @projects.page(params[:page]).per(PER_PAGE) if params[:filter_projects].blank? + @shared_projects = @group.shared_projects + respond_to do |format| format.html diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml new file mode 100644 index 0000000000..d707ad4272 --- /dev/null +++ b/app/views/groups/_shared_projects.html.haml @@ -0,0 +1,18 @@ +- if projects.present? + .panel.panel-default + .panel-heading + Projects shared with + %strong #{@group.name} + (#{projects.count}) + %ul.well-list + - projects.each do |project| + %li.project-row + = link_to namespace_project_path(project.namespace, project), class: dom_class(project) do + %span.namespace-name + - if project.namespace + = project.namespace.human_name + \/ + %span.project-name + = truncate(project.name, length: 25) + %span.arrow + %i.icon-angle-right diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 6148d8cb3d..c64c986b5a 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -35,6 +35,10 @@ %li = link_to "#projects", 'data-toggle' => 'tab' do Projects + - if @shared_projects.present? + %li + = link_to "#shared", 'data-toggle' => 'tab' do + Shared Projects - if can?(current_user, :read_group, @group) %div{ class: container_class } @@ -52,6 +56,9 @@ .tab-pane#projects = render "projects", projects: @projects + .tab-pane#shared + = render "shared_projects", projects: @shared_projects + - else %p.nav-links.no-top No projects to show From 8901336c78a9075a6a64205500e6019c40fd632f Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Fri, 11 Mar 2016 18:46:01 +0100 Subject: [PATCH 040/797] Allow users to access project shared with their group Signed-off-by: Dmitriy Zaporozhets --- app/models/project_team.rb | 52 +++++++++++++++++++++++++++++++++++++- 1 file changed, 51 insertions(+), 1 deletion(-) diff --git a/app/models/project_team.rb b/app/models/project_team.rb index 9629c7e1bb..70a8bbaba6 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -160,7 +160,27 @@ class ProjectTeam end end - access.max + if project.invited_groups.any? && project.allowed_to_share_with_group? + access << max_invited_level(user_id) + end + + access.compact.max + end + + + def max_invited_level(user_id) + project.project_group_links.map do |group_link| + invited_group = group_link.group + access = invited_group.group_members.find_by(user_id: user_id).try(:access_field) + + # If group member has higher access level we should restrict it + # to max allowed access level + if access && access > group_link.group_access + access = group_link.group_access + end + + access + end.compact.max end private @@ -168,6 +188,35 @@ class ProjectTeam def fetch_members(level = nil) project_members = project.project_members group_members = group ? group.group_members : [] + 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 + + if level + int_level = GroupMember.access_level_roles[level.to_s.singularize.titleize] + + # Skip group members if we ask for masters + # but max group access is developers + next if int_level > group_link.group_access + + # If we ask for developers and max + # group access is developers we need to provide + # both group master, developers as devs + if int_level == group_link.group_access + im.where("access_level >= ?)", group_link.group_access) + else + im.send(level) + end + end + + invited_members << im + end + + invited_members = invited_members.flatten.compact + end if level project_members = project_members.send(level) @@ -175,6 +224,7 @@ class ProjectTeam end user_ids = project_members.pluck(:user_id) + user_ids.push(*invited_members.map(&:user_id)) if invited_members.any? user_ids.push(*group_members.pluck(:user_id)) if group User.where(id: user_ids) From 068fd5de8a45ef0814c500df10d3b9d39496fcd9 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Fri, 11 Mar 2016 18:55:17 +0100 Subject: [PATCH 041/797] Add finders logic and tests for shared projects feature Signed-off-by: Dmitriy Zaporozhets --- app/finders/projects_finder.rb | 3 +- app/models/user.rb | 3 +- app/views/admin/groups/show.html.haml | 16 ++++++ features/project/group_links.feature | 16 ++++++ features/steps/project/project_group_links.rb | 50 +++++++++++++++++++ 5 files changed, 86 insertions(+), 2 deletions(-) create mode 100644 features/project/group_links.feature create mode 100644 features/steps/project/project_group_links.rb diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 3b4e0362e0..93fc7c4b01 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -43,7 +43,8 @@ class ProjectsFinder if current_user [ group_projects_for_user(current_user, group), - group.projects.public_and_internal_only + group.projects.public_and_internal_only, + group.shared_projects.visible_to_user(current_user) ] else [group.projects.public_only] diff --git a/app/models/user.rb b/app/models/user.rb index 505a547d8e..e9d1f47943 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -818,7 +818,8 @@ class User < ActiveRecord::Base def projects_union Gitlab::SQL::Union.new([personal_projects.select(:id), groups_projects.select(:id), - projects.select(:id)]) + projects.select(:id), + groups.joins(:shared_projects).select(:project_id)]) end def ci_projects_union diff --git a/app/views/admin/groups/show.html.haml b/app/views/admin/groups/show.html.haml index f7fd156b84..264fa1bf0c 100644 --- a/app/views/admin/groups/show.html.haml +++ b/app/views/admin/groups/show.html.haml @@ -50,6 +50,22 @@ .panel-footer = paginate @projects, param_name: 'projects_page', theme: 'gitlab' + - if @group.shared_projects.any? + .panel.panel-default + .panel-heading + Projects shared with #{@group.name} + %span.badge + #{@group.shared_projects.count} + %ul.well-list + - @group.shared_projects.sort_by(&:name).each do |project| + %li + %strong + = link_to project.name_with_namespace, [:admin, project.namespace.becomes(Namespace), project] + %span.label.label-gray + = repository_size(project) + %span.pull-right.light + %span.monospace= project.path_with_namespace + ".git" + .col-md-6 - if can?(current_user, :admin_group_member, @group) .panel.panel-default diff --git a/features/project/group_links.feature b/features/project/group_links.feature new file mode 100644 index 0000000000..2657c4487a --- /dev/null +++ b/features/project/group_links.feature @@ -0,0 +1,16 @@ +Feature: Project Group Links + Background: + Given I sign in as a user + And I own project "Shop" + And project "Shop" is shared with group "Ops" + And project "Shop" is not shared with group "Market" + And I visit project group links page + + Scenario: I should see list of groups + Then I should see project already shared with group "Ops" + Then I should see project is not shared with group "Market" + + @javascript + Scenario: I share project with group + When I select group "Market" for share + Then I should see project is shared with group "Market" diff --git a/features/steps/project/project_group_links.rb b/features/steps/project/project_group_links.rb new file mode 100644 index 0000000000..739a85e5fa --- /dev/null +++ b/features/steps/project/project_group_links.rb @@ -0,0 +1,50 @@ +class Spinach::Features::ProjectGroupLinks < Spinach::FeatureSteps + include SharedAuthentication + include SharedProject + include SharedPaths + include Select2Helper + + step 'I should see project already shared with group "Ops"' do + page.within '.enabled-groups' do + expect(page).to have_content "Ops" + end + end + + step 'I should see project is not shared with group "Market"' do + page.within '.enabled-groups' do + expect(page).not_to have_content "Market" + end + end + + step 'I select group "Market" for share' do + group = Group.find_by(path: 'market') + select2(group.id, from: "#link_group_id") + select "Master", from: 'link_group_access' + click_button "Share" + end + + step 'I should see project is shared with group "Market"' do + page.within '.enabled-groups' do + expect(page).to have_content "Market" + end + end + + step 'project "Shop" is shared with group "Ops"' do + group = create(:group, name: 'Ops') + share_link = project.project_group_links.new(group_access: Gitlab::Access::MASTER) + share_link.group_id = group.id + share_link.save! + end + + step 'project "Shop" is not shared with group "Market"' do + create(:group, name: 'Market', path: 'market') + end + + step 'I visit project group links page' do + visit namespace_project_group_links_path(project.namespace, project) + end + + def project + @project ||= Project.find_by_name "Shop" + end +end From 9a95b15552fa8920800274324aa65900360e8038 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Fri, 11 Mar 2016 19:03:19 +0100 Subject: [PATCH 042/797] Add share project from group lock Signed-off-by: Dmitriy Zaporozhets --- app/controllers/groups_controller.rb | 2 +- app/models/project.rb | 3 +-- app/views/groups/edit.html.haml | 9 +++++++++ db/migrate/20150930110012_add_group_share_lock.rb | 5 +++++ db/schema.rb | 7 ++++--- 5 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 db/migrate/20150930110012_add_group_share_lock.rb diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index bd6b44bdbb..e8d9d9fb34 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -133,7 +133,7 @@ class GroupsController < Groups::ApplicationController end def group_params - params.require(:group).permit(:name, :description, :path, :avatar, :public) + params.require(:group).permit(:name, :description, :path, :avatar, :public, :share_with_group_lock) end def load_events diff --git a/app/models/project.rb b/app/models/project.rb index 5686545972..40f8c4c83d 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -881,8 +881,7 @@ class Project < ActiveRecord::Base end def allowed_to_share_with_group? - # TODO: replace with logic - true + !namespace.share_with_group_lock end def ci_commit(sha) diff --git a/app/views/groups/edit.html.haml b/app/views/groups/edit.html.haml index 3430f56a9c..83936d39b1 100644 --- a/app/views/groups/edit.html.haml +++ b/app/views/groups/edit.html.haml @@ -23,6 +23,15 @@ %hr = link_to 'Remove avatar', group_avatar_path(@group.to_param), data: { confirm: "Group avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar" + .form-group + %hr + = f.label :share_with_group_lock, class: 'control-label' do + Share with group lock + .col-sm-10 + .checkbox + = f.check_box :share_with_group_lock + %span.descr Prevent sharing a project with another group within this group + .form-actions = f.submit 'Save group', class: "btn btn-save" diff --git a/db/migrate/20150930110012_add_group_share_lock.rb b/db/migrate/20150930110012_add_group_share_lock.rb new file mode 100644 index 0000000000..78d1a4538f --- /dev/null +++ b/db/migrate/20150930110012_add_group_share_lock.rb @@ -0,0 +1,5 @@ +class AddGroupShareLock < ActiveRecord::Migration + def change + add_column :namespaces, :share_with_group_lock, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 89e8c9e67b..20dbb4f5a3 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -568,14 +568,15 @@ ActiveRecord::Schema.define(version: 20160309140734) do add_index "milestones", ["title"], name: "index_milestones_on_title", using: :btree create_table "namespaces", force: :cascade do |t| - t.string "name", null: false - t.string "path", null: false + 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 "description", default: "", null: false t.string "avatar" + t.boolean "share_with_group_lock", default: false end add_index "namespaces", ["created_at", "id"], name: "index_namespaces_on_created_at_and_id", using: :btree From e8c723543cfc4c1d905a5794a2da1bef7689d784 Mon Sep 17 00:00:00 2001 From: Baldinof Date: Wed, 9 Mar 2016 15:25:48 +0100 Subject: [PATCH 043/797] Close merge requests when removing fork relation --- CHANGELOG | 1 + app/controllers/projects_controller.rb | 2 +- app/models/merge_request.rb | 1 + app/models/project.rb | 12 +++++++++++- spec/models/project_spec.rb | 19 +++++++++++++++++++ 5 files changed, 33 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index c1c90903d5..84739fab82 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -26,6 +26,7 @@ v 8.6.0 (unreleased) - Show labels in dashboard and group milestone views - Add main language of a project in the list of projects (Tiago Botelho) - Add ability to show archived projects on dashboard, explore and group pages + - Remove fork link closes all merge requests opened on source project (Florent Baldino) v 8.5.5 - Ensure removing a project removes associated Todo entries diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index aea08ecce3..a26d11459f 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -72,7 +72,7 @@ class ProjectsController < ApplicationController def remove_fork return access_denied! unless can?(current_user, :remove_fork_project, @project) - if @project.unlink_fork + if @project.unlink_fork(current_user) flash[:notice] = 'The fork relationship has been removed.' end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index c1e18bb3cc..18ec48b57f 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -137,6 +137,7 @@ class MergeRequest < ActiveRecord::Base scope :by_milestone, ->(milestone) { where(milestone_id: milestone) } scope :in_projects, ->(project_ids) { where("source_project_id in (:project_ids) OR target_project_id in (:project_ids)", project_ids: project_ids) } scope :of_projects, ->(ids) { where(target_project_id: ids) } + scope :from_project, ->(project) { where(source_project_id: project.id) } scope :merged, -> { with_state(:merged) } scope :closed_and_merged, -> { with_states(:closed, :merged) } diff --git a/app/models/project.rb b/app/models/project.rb index 65829bec77..859758293e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -890,12 +890,22 @@ class Project < ActiveRecord::Base self.builds_enabled = true end - def unlink_fork + def unlink_fork(user) if forked? forked_from_project.lfs_objects.find_each do |lfs_object| lfs_object.projects << self end + merge_requests = forked_from_project.merge_requests.opened.from_project(self) + + unless merge_requests.empty? + close_service = MergeRequests::CloseService.new(self, user) + + merge_requests.each do |mr| + close_service.execute(mr) + end + end + forked_project_link.destroy end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 2fa38a5d3d..ba4fb2f822 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -647,4 +647,23 @@ describe Project, models: true do project.expire_caches_before_rename('foo') end end + + describe '#unlink_fork' do + let(:fork_link) { create(:forked_project_link) } + let(:fork_project) { fork_link.forked_to_project } + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request, source_project: fork_project, target_project: fork_link.forked_from_project) } + let!(:close_service) { MergeRequests::CloseService.new(fork_project, user) } + + it 'remove fork relation and close all pending merge requests' do + allow(MergeRequests::CloseService).to receive(:new). + with(fork_project, user). + and_return(close_service) + + expect(close_service).to receive(:execute).with(merge_request) + expect(fork_project.forked_project_link).to receive(:destroy) + + fork_project.unlink_fork(user) + end + end end From af29ed3773e1071fe6071d1dc358402d61d223c2 Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Fri, 11 Mar 2016 15:53:27 -0500 Subject: [PATCH 044/797] Escape! --- app/helpers/events_helper.rb | 8 ++++---- app/helpers/projects_helper.rb | 2 +- app/views/events/_event_last_push.html.haml | 2 +- app/views/events/event/_common.html.haml | 2 +- app/views/events/event/_push.html.haml | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index babfc0d8c8..6cda2d8a65 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -3,7 +3,7 @@ module EventsHelper author = event.author if author - link_to author.name, user_path(author.username), title: author.name + link_to author.name, user_path(author.username), title: h(author.name) else event.author_name end @@ -159,7 +159,7 @@ module EventsHelper link_to( namespace_project_commit_path(event.project.namespace, event.project, event.note_commit_id, - anchor: dom_id(event.target), title: event.target_title), + anchor: dom_id(event.target), title: h(event.target_title)), class: "commit_short_id" ) do "#{event.note_target_type} #{event.note_short_commit_id}" @@ -167,11 +167,11 @@ module EventsHelper elsif event.note_project_snippet? link_to(namespace_project_snippet_path(event.project.namespace, event.project, - event.note_target), title: event.project.name) do + event.note_target), title: h(event.project.name)) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end else - link_to event_note_target_path(event), title: event.note_target.to_reference do + link_to event_note_target_path(event), title: h(event.note_target.to_reference) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index c10b49cb6b..b5acb80b72 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -8,7 +8,7 @@ module ProjectsHelper end def link_to_project(project) - link_to [project.namespace.becomes(Namespace), project], title: project.name do + link_to [project.namespace.becomes(Namespace), project], title: h(project.name) do title = content_tag(:span, project.name, class: 'project-name') if project.namespace diff --git a/app/views/events/_event_last_push.html.haml b/app/views/events/_event_last_push.html.haml index fc60241ae7..5753158c24 100644 --- a/app/views/events/_event_last_push.html.haml +++ b/app/views/events/_event_last_push.html.haml @@ -3,7 +3,7 @@ .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), title: event.project.name do + = link_to namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.project.name) do %strong= event.ref_name %span at %strong= link_to_project event.project diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index e7bc5b7594..e9e16a7646 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -4,7 +4,7 @@ = event_action_name(event) - if event.target - %strong= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], title: event.target.to_reference + %strong= link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target] = event_preposition(event) diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index b7470db81e..235bd46107 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -5,7 +5,7 @@ %strong= event.ref_name - else %strong - = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: event.target_title + = link_to event.ref_name, namespace_project_commits_path(event.project.namespace, event.project, event.ref_name), title: h(event.target_title) at = link_to_project event.project From a63eba9a2bebd6e33c3d1051a0d2fd08e024f546 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Tue, 8 Mar 2016 20:04:13 -0500 Subject: [PATCH 045/797] Add unit specs for `Note#active?` --- app/models/note.rb | 33 +++++++++++++-------- spec/models/note_spec.rb | 62 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 82 insertions(+), 13 deletions(-) diff --git a/app/models/note.rb b/app/models/note.rb index 8b0610ff77..0ea61b2495 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -172,26 +172,29 @@ class Note < ActiveRecord::Base Note.where(noteable_id: noteable_id, noteable_type: noteable_type, line_code: line_code).last.try(:diff) end - # Check if such line of code exists in merge request diff - # If exists - its active discussion - # If not - its outdated diff + # Check if this note is part of an "active" discussion + # + # This will always return true for anything except MergeRequest noteables, + # which have special logic. + # + # If the note's current diff cannot be matched in the MergeRequest's current + # diff, it's considered inactive. def active? return true unless self.diff return false unless noteable return @active if defined?(@active) - diffs = noteable.diffs(Commit.max_diff_options) - notable_diff = diffs.find { |d| d.new_path == self.diff.new_path } + noteable_diff = find_noteable_diff - return @active = false if notable_diff.nil? + if noteable_diff + parsed_lines = Gitlab::Diff::Parser.new.parse(noteable_diff.diff.each_line) - parsed_lines = Gitlab::Diff::Parser.new.parse(notable_diff.diff.each_line) - # We cannot use ||= because @active may be false - @active = parsed_lines.any? { |line_obj| line_obj.text == diff_line } - end + @active = parsed_lines.any? { |line_obj| line_obj.text == diff_line } + else + @active = false + end - def outdated? - !active? + @active end def diff_file_index @@ -375,6 +378,12 @@ class Note < ActiveRecord::Base private + # Find the diff on noteable that matches our own + def find_noteable_diff + diffs = noteable.diffs(Commit.max_diff_options) + diffs.find { |d| d.new_path == self.diff.new_path } + end + def awards_supported? (for_issue? || for_merge_request?) && !for_diff_line? end diff --git a/spec/models/note_spec.rb b/spec/models/note_spec.rb index cd620ea544..b3ed7d6f8b 100644 --- a/spec/models/note_spec.rb +++ b/spec/models/note_spec.rb @@ -152,7 +152,7 @@ describe Note, models: true do end end - describe :grouped_awards do + describe '.grouped_awards' do before do create :note, note: "smile", is_award: true create :note, note: "smile", is_award: true @@ -169,6 +169,66 @@ describe Note, models: true do end end + describe '#active?' do + it 'is always true when the note has no associated diff' do + note = build(:note) + + expect(note).to receive(:diff).and_return(nil) + + expect(note).to be_active + end + + it 'is never true when the note has no noteable associated' do + note = build(:note) + + expect(note).to receive(:diff).and_return(double) + expect(note).to receive(:noteable).and_return(nil) + + expect(note).not_to be_active + end + + it 'returns the memoized value if defined' do + note = build(:note) + + expect(note).to receive(:diff).and_return(double) + expect(note).to receive(:noteable).and_return(double) + + note.instance_variable_set(:@active, 'foo') + expect(note).not_to receive(:find_noteable_diff) + + expect(note.active?).to eq 'foo' + end + + context 'for a merge request noteable' do + it 'is false when noteable has no matching diff' do + merge = build_stubbed(:merge_request, :simple) + note = build(:note, noteable: merge) + + allow(note).to receive(:diff).and_return(double) + expect(note).to receive(:find_noteable_diff).and_return(nil) + + expect(note).not_to be_active + end + + it 'is true when noteable has a matching diff' do + merge = create(:merge_request, :simple) + + # Generate a real line_code value so we know it will match. We use a + # random line from a random diff just for funsies. + diff = merge.diffs.to_a.sample + line = Gitlab::Diff::Parser.new.parse(diff.diff.each_line).to_a.sample + code = Gitlab::Diff::LineCode.generate(diff.new_path, line.new_pos, line.old_pos) + + # We're persisting in order to trigger the set_diff callback + note = create(:note, noteable: merge, line_code: code) + + # Make sure we don't get a false positive from a guard clause + expect(note).to receive(:find_noteable_diff).and_call_original + expect(note).to be_active + end + end + end + describe "editable?" do it "returns true" do note = build(:note) From aff1058c61cb8bc2ffbec42babb935828aa766ec Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Fri, 11 Mar 2016 15:32:12 -0500 Subject: [PATCH 046/797] Fix ordering in 8.5-to-8.6 update guide [ci skip] --- doc/update/8.5-to-8.6.md | 40 +++++++++++++++++++++------------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/doc/update/8.5-to-8.6.md b/doc/update/8.5-to-8.6.md index a8167b2cf0..9a1b94ab36 100644 --- a/doc/update/8.5-to-8.6.md +++ b/doc/update/8.5-to-8.6.md @@ -105,35 +105,37 @@ via [/etc/default/gitlab]. [Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache [/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-6-stable/lib/support/init.d/gitlab.default.example#L37 -### 8. Updates for PostgreSQL Users - -Starting with 8.6 users using GitLab in combination with PostgreSQL are required -to have the "pg_trgm" extension enabled for all GitLab databases. If you're -using GitLab's Omnibus packages there's nothing you'll need to do manually as -this extension is enabled automatically. Users who install GitLab without using -Omnibus (e.g. by building from source) have to enable this extension manually. -To enable this extension run the following SQL command as a PostgreSQL super -user for _every_ GitLab database: - - CREATE EXTENSION IF NOT EXISTS pg_trgm; - -Certain operating systems might require the installation of extra packages for -this extension to be available. For example, users using Ubuntu will have to -install the "postgresql-contrib" package in order for this extension to be -available. - #### Init script Ensure you're still up-to-date with the latest init script changes: sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab -### 8. Start application +### 8. Updates for PostgreSQL Users + +Starting with 8.6 users using GitLab in combination with PostgreSQL are required +to have the `pg_trgm` extension enabled for all GitLab databases. If you're +using GitLab's Omnibus packages there's nothing you'll need to do manually as +this extension is enabled automatically. Users who install GitLab without using +Omnibus (e.g. by building from source) have to enable this extension manually. +To enable this extension run the following SQL command as a PostgreSQL super +user for _every_ GitLab database: + +```sql +CREATE EXTENSION IF NOT EXISTS pg_trgm; +``` + +Certain operating systems might require the installation of extra packages for +this extension to be available. For example, users using Ubuntu will have to +install the `postgresql-contrib` package in order for this extension to be +available. + +### 9. Start application sudo service gitlab start sudo service nginx restart -### 9. Check application status +### 10. Check application status Check if GitLab and its environment are configured correctly: From 55ceda12045ffb59187b7104a3b28a34e28a8b87 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Sat, 12 Mar 2016 14:45:14 +0100 Subject: [PATCH 047/797] Bring shared project feature tests from EE Signed-off-by: Dmitriy Zaporozhets --- features/admin/groups.feature | 5 +++ features/project/team_management.feature | 5 +++ features/steps/admin/groups.rb | 19 ++++++++++ features/steps/project/team_management.rb | 19 ++++++++++ spec/finders/projects_finder_spec.rb | 34 +++++++++++++++++- spec/models/project_team_spec.rb | 44 +++++++++++++++++++++++ 6 files changed, 125 insertions(+), 1 deletion(-) diff --git a/features/admin/groups.feature b/features/admin/groups.feature index 2edb3964f7..ab7de7ac31 100644 --- a/features/admin/groups.feature +++ b/features/admin/groups.feature @@ -21,6 +21,11 @@ Feature: Admin Groups When I select user "John Doe" from user list as "Reporter" Then I should see "John Doe" in team list in every project as "Reporter" + Scenario: Shared projects + Given group has shared projects + When I visit group page + Then I should see project shared with group + @javascript Scenario: Remove user from group Given we have user "John Doe" in group diff --git a/features/project/team_management.feature b/features/project/team_management.feature index 06fb45c8bd..5888662fc3 100644 --- a/features/project/team_management.feature +++ b/features/project/team_management.feature @@ -39,3 +39,8 @@ Feature: Project Team Management And I click link "Import team from another project" And I submit "Website" project for import team Then I should see "Mike" in team list as "Reporter" + + Scenario: See all members of projects shared group + Given I share project with group "OpenSource" + And I visit project "Shop" team page + Then I should see "Opensource" group user listing diff --git a/features/steps/admin/groups.rb b/features/steps/admin/groups.rb index 43fd91d0d4..e1f1db2872 100644 --- a/features/steps/admin/groups.rb +++ b/features/steps/admin/groups.rb @@ -73,6 +73,21 @@ class Spinach::Features::AdminGroups < Spinach::FeatureSteps end end + step 'group has shared projects' do + share_link = shared_project.project_group_links.new(group_access: Gitlab::Access::MASTER) + share_link.group_id = current_group.id + share_link.save! + end + + step 'I visit group page' do + visit admin_group_path(current_group) + end + + step 'I should see project shared with group' do + expect(page).to have_content(shared_project.name_with_namespace) + expect(page).to have_content "Projects shared with" + end + step 'we have user "John Doe" in group' do current_group.add_reporter(user_john) end @@ -123,6 +138,10 @@ class Spinach::Features::AdminGroups < Spinach::FeatureSteps @group ||= Group.first end + def shared_project + @shared_project ||= create(:empty_project) + end + def user_john @user_john ||= User.find_by(name: "John Doe") end diff --git a/features/steps/project/team_management.rb b/features/steps/project/team_management.rb index caad52def7..3fbcf770b6 100644 --- a/features/steps/project/team_management.rb +++ b/features/steps/project/team_management.rb @@ -123,4 +123,23 @@ class Spinach::Features::ProjectTeamManagement < Spinach::FeatureSteps click_link('Remove user from team') end end + + step 'I share project with group "OpenSource"' do + project = Project.find_by(name: 'Shop') + os_group = create(:group, name: 'OpenSource') + create(:project, group: os_group) + @os_user1 = create(:user) + @os_user2 = create(:user) + os_group.add_owner(@os_user1) + os_group.add_user(@os_user2, Gitlab::Access::DEVELOPER) + share_link = project.project_group_links.new(group_access: Gitlab::Access::MASTER) + share_link.group_id = os_group.id + share_link.save! + end + + step 'I should see "Opensource" group user listing' do + expect(page).to have_content("Shared with OpenSource group, members with Master role (2)") + expect(page).to have_content(@os_user1.name) + expect(page).to have_content(@os_user2.name) + end end diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index f32641ef0f..fae0da9d89 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -17,6 +17,10 @@ describe ProjectsFinder do create(:project, :public, group: group, name: 'C', path: 'C') end + let!(:shared_project) do + create(:project, :private, name: 'D', path: 'D') + end + let(:finder) { described_class.new } describe 'without a group' do @@ -56,7 +60,35 @@ describe ProjectsFinder do describe 'with a user' do subject { finder.execute(user, group: group) } - it { is_expected.to eq([public_project, internal_project]) } + describe 'without shared projects' do + it { is_expected.to eq([public_project, internal_project]) } + end + + describe 'with shared projects and group membership' do + before do + group.add_user(user, Gitlab::Access::DEVELOPER) + + shared_project.project_group_links. + create(group_access: Gitlab::Access::MASTER, group: group) + end + + it do + is_expected.to eq([shared_project, public_project, internal_project]) + end + end + + describe 'with shared projects and project membership' do + before do + shared_project.team.add_user(user, Gitlab::Access::DEVELOPER) + + shared_project.project_group_links. + create(group_access: Gitlab::Access::MASTER, group: group) + end + + it do + is_expected.to eq([shared_project, public_project, internal_project]) + end + end end end end diff --git a/spec/models/project_team_spec.rb b/spec/models/project_team_spec.rb index 7b63da005f..bacb17a888 100644 --- a/spec/models/project_team_spec.rb +++ b/spec/models/project_team_spec.rb @@ -67,6 +67,50 @@ 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) + 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 + end + end + describe "#human_max_access" do it 'returns Master role' do user = create(:user) From e12e937b943be2d9dce062cde10b02b6474e170b Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Sat, 12 Mar 2016 15:05:52 +0100 Subject: [PATCH 048/797] Bring share with group feature documentation from EE Signed-off-by: Dmitriy Zaporozhets --- doc/workflow/README.md | 2 ++ doc/workflow/groups/max_access_level.png | Bin 0 -> 135354 bytes .../other_group_sees_shared_project.png | Bin 0 -> 118382 bytes .../groups/share_project_with_groups.png | Bin 0 -> 118868 bytes .../share_projects_with_other_groups.md | 30 ++++++++++++++++++ doc/workflow/share_with_group.md | 13 ++++++++ doc/workflow/share_with_group.png | Bin 0 -> 53784 bytes 7 files changed, 45 insertions(+) create mode 100644 doc/workflow/groups/max_access_level.png create mode 100644 doc/workflow/groups/other_group_sees_shared_project.png create mode 100644 doc/workflow/groups/share_project_with_groups.png create mode 100644 doc/workflow/share_projects_with_other_groups.md create mode 100644 doc/workflow/share_with_group.md create mode 100644 doc/workflow/share_with_group.png diff --git a/doc/workflow/README.md b/doc/workflow/README.md index 2ac32373ce..25893f948e 100644 --- a/doc/workflow/README.md +++ b/doc/workflow/README.md @@ -13,6 +13,8 @@ - [Project forking workflow](forking_workflow.md) - [Project users](add-user/add-user.md) - [Protected branches](protected_branches.md) +- [Sharing a project with a group](share_with_group.md) +- [Share projects with other groups](share_projects_with_other_groups.md) - [Web Editor](web_editor.md) - [Releases](releases.md) - [Milestones](milestones.md) diff --git a/doc/workflow/groups/max_access_level.png b/doc/workflow/groups/max_access_level.png new file mode 100644 index 0000000000000000000000000000000000000000..71106a8a5a0d4fc4f358a6777b50809511e6038e GIT binary patch literal 135354 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

J8ElVp(pbH?Uv#eXBi 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 o1u^O@?KdM+F%W`2G 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

wQN zql@7v*Rmn6uGI}OFPcl7Y;m2d>^U$%@ zou>cUv|VM8)dcPh_bo-;?g3!_JWJSK9ACX+Ap*fDPI~;q;Ogyw(6&jNI#gZ7V==ZM z3uBs#!%q<}uUyQ-ojHPS$sp4A{`ewGOr)aavdW~k&)T<-W-$d)S{}w5A4mwtD|%uU z?@fFFDA;@tgpP~N2(rK!Vx^129C4{kZj^YBjPr?W`mE@r{?$2ki*;Ndv90aLo)odI zn%sV!n%O-<(c4q_i=u&ktuUmsb=b2eW5)M+tAvlp-J)OQ`jD6kx)K*3evC?_7a?}8 z7(|Kmp*yxMQY>N8>)`JshEs2tvj2iF_MDxV<`#*d^h6IfSy=m=euOr6rQbWpvE{el zRl|K#G{W&}LxEJu$Q$~I67_X&YHr(=+6GDeyhKba0oH5M%Bvl=MFk;sbP~@CF!2Z+%hLt&-CBF-~Ys%WuFEVi(`2~s+ZRjmswVC zzq(NRa$6l}Mv&kKmb7F%j4u;N%S{;%2WQ0zm-SDCeqjZCBeatJT=<5-_n4ii^J~-|s}A9xJ@Ezc+}BH5UU`)7y-p)9E>rCCH+Hm~f((b@g_)_>^mRQ#LlX=QO&C?5GCp{tNjb+ ziXf0QLtpFRrkknY*jtavis4F%w4$#lRF##!BJFUkddDODSyVp?+k_@cQkRQ^zGluu z@;7#`XSoHBAhJ&JO1KNy%wN>o|3TI!y)>>z3tPi^3i?2x>Re zld(=c*S-SE3OaF-Dq==+58O}qOmNfZ(!OUC@YCUp1coQGCKSgUSXgK@6{DaX{T#U? z4Zt1HxK!e3;4KVT*V}}~0Rjyy)@4F(@j{DHb{+rVUDKwA-m;xqUyrfP-%%+GsF>X? z$`x|-n15-tS&6nDn|f`n({{kpP>u;jYF1Qv7l~OIZXSJ#a5`(1_o6FSlbsJs!-yP^ z8~?o@gWwMF9Y_th;3j!hG_;}8kjnj`DnsX4=yk#^>HZc(DoLM0LzmPpStA1tYH|HFUj)|gw(OAx-U zB|DDNvCw>;gEPu`B@Mdpj-uR{{F3?Ra9HAaA=`45EdF3R3iFcUOq{#V+|i2B5;X;~*w1UZ*)0SYLh`Q~SD;TwLw z>TL)-G@^4WkSA4(@*TNmdBufi+Va{@i6>O=;t1HU*{4n@2rsRcdd2>+^mFD*g17^bCA9-zm zt#iNeaDbK=a+-jtMvk)~Tk(iV6ZH$E#JKGgR8S7QzB4LB;?Pa~rU7=J41!+V7%0DF&NSY&R*mg`!W*IvWBvz!f%L zbcOC0z(c;`#13VE7zI)_tt(28p9jqZjh`tX=9cSZhHCS75 z5kivYTkrBs2i??caM@$Ps`FB6pR7|?z@ephjeonzdFb&mdofvzUU1e7?C+!2ChV7Z z|F*G#yn~b3>qA`bCvVhAS8~R^7!-GVFd^39bit2_U8eYMM#wLf!e0$QNfZptw_}cb z!sgmV8zEkJjq^%W`A)b*mTgxQ@8H^)Mj`+L(-b`u4`*UjUIytem0f>T9sw{&I(45` zdMwJsNeTzK&#@zWEJ$Xuu0-yB8im@nF>r?(QQKcvzMd1n33HDQ8C6B5t!K@>AXsj!5^ztwTj#}(|Jux@FBl(aOOJo1?C^ZZFw}v|~VaLS6%0Bo;8g_s_#l-m$DktMouTPPdUpc}D zW9utBzH;XcqjXT|R4t_*pSHeovR$W+T`2WT%H0D=?9M9f>OISVc|A_LeKB+@yVu14 z331<^U7aCHD=l-j8$*xXwl=BJm1x74+4k-6z~bNRPI5UeP_WgJeK4xFc%N__=#1S6 zdj%v!`ybIhU3}4AIhTzgI}JLu#@eAlAU4-O>fCdf#{y@7DoeW`uNU#yihG-!kMoE! zd7bHxj*cid=hvW;s~Gh`R#;|Z>2z9kd%L^R7)$V*3)89(=-XgDIf#_|ewtX)bmVYh zlPU>O3Vek8a>O@lZpSJ1hXFlFyEOTJ^|8GCuUk8?XFZGG4NS%hW^XX)ta>6+qkeJq z4lTMK3PWDumJ}#(C&mzWj)1}yeUiziQA6>%<&{JIO(x|0t=UF?BA-!dA2L|M?}%`+O^u8UQbb)yO(%)*_*GcSX3;U4Z#v)Q3_NM$Or-hgWO|caCHgy|cgq>8)V=1cT?qMC9_Q22f2IUdCSp8&unqMe~p(4C0|co6)h~Q z@YkUtnFtl)wq2dE}#s0BTnr=oG>&*?WJxvxa=Qy^n$%zv)uAHOQ z2Y(zb4H*-bI&=>3?Qff1@hsTCv0tH_bv4=jBr|{I#gvG&-nM({+822$8(A^9ER*(L z=kz%Lh@#;>SKGACR~q0PnqiGK>(uC7&)M7+E0-OPt`r4oK47-5_l|-N_*i96!e%|k zXj#+b5356Be|lL|QIW(L8K|_<>ftgd9wTY~4>~c}-9io>)kNLs7R?c) zB|hIKPTj^Sel|ikiwRo2nJmP#dlo8X=Swoc{%%Q1_fcb-sl28Nv!uK6Y=U<$w_~Zer?MVkILXR1qcSHrhh!pz>^WGP%#}5OnE!v+IE- zq1@MpV0y*_X>$L8SKvxa!5z_%H)?c@3^!&c&;%A-?g~I=mS$HUujI-{q0VYvHB$9y{Vs4_<9r#^T$%e zmhbg}qk&LQ{scf0iPKXlm&cUO5PEF!ZPQef1hdwoHrWO@J8o&XvMpR^OJSyD zLmKvDaGePrd|#Tz56PVs+UEH^gT7x)%G0FG2U`yroq*qPlN{a3p7EA2ZG0fWK z!r~@h-(esg;{kK&aD@T)Y8U{trDbp z2GOSpv2VzL>;)s{!V6o307vh`ylesYtgNh!jLmX6S5)uHiPv$(0@zrGOjhdDK z{;bWsIX|_`O!mt+DC3cN$krEhEy`d1x#Mh$Gd*fYpIg)Ej~A|w#$P=Xq9r|sJEQ=B z?8*UzgT?qZG4e=U`5Vs|F_I!1Cc2og{<~;f5Mf<~Qhb2H3JJKL#x>8BkY3<|(1a)a zoSSR2DjepiZd9Tc?@h)F!H6`HPkdi++XsT+9KGhM>mNk;OOI)FK;|E(d7kffk(PSa zt;6*dgCxZZjq0JvL*QEJHa5ZYDIK@69qv6&uV-}Ui_YTN2JOIu_4f81i5v>Bc+hnh zmN+=+ch-8ZZ<%dK`sHI96inj>7M4(-s$JBtb7yiooNqXGZsP2CC*0!H6!1bD!h85- zS9W4T>y8T|{wWC1MuI#Xayr*kj~Hqu2RoJ_*VD41QkOg=!DD0_GhVc3#0`j9X2f0I z`jveX1S%Wz_Jlevds0ZqDL{&*+ZmITJ=hc8r;mb*Mg1EaFhAbaKk@s{h{b(Ki;_7e zdQ}v}$-8Wd6uBE?07YBoR4@6hN)3O`ksRcDps!7$CU3^8M@A5hf7FZdj`=ZXo9hte zfD1vk5_)X)60sPq@#{3!fwl`(od zdq?Uf))<` zx`6ETmL(QsMfLRhW;DoB(QRli&IFz6`Lune++1b44`6ir&K4B)+d9meDC&&dV6vvv zH)*9@lus+Vlt(X&f`>kRc+Jo0H(hBo#rce(1C0^FauOp%!5UoUke8-+2tBxh&e+GQ z0*3W>gHb*abZBM27}R1(0T}`66!v-!+FRU`8~xFhk~J$(#^?9cHIK+&Mg>IWf}kU0 zyrocjrgBad(|{>4hFK+L8GgSa$~@^E<@fca=M%>}H|j~!&1UXJ>+{>UGBBbkef{lp z9lKY!l+U4D&NFw;IX~4IWa9f;v4tw<7u?f`piuWB_(7}1H{50t-ukp#Aj1E zEYu4MdYWpIVJk2Fv4-XKBG^mp+=+C~aK?USsd+frw&AVE0|Do~s3)@{8 z5F`K9L>b`3g>1QBt8Fb%`6v)6K~#s8xK*-K1Kd=<#+s5_{mvg*ivuN~FDl{OEdO-H zQUvPo*kVERZlcZio7X*IkoYKzGdKI-+vPz}!Ec)9nL zNb`O!m$>hViN9^(#cmFuOueE+2@J%iE;~bPPot-KJYw<{hN-J9h*14Hw7pmoXd-6r z2aN$j^<;xmcnC1%a5|0dCK<)Z#PtjEqA0G781<$B+ zRh4*ZROXFADyZS0Bd_<{tb>hZbd(X?P4@FaOgt`CfXWwWloL(M8M!unEmwJG!?p3^k zxrFVplK(jDBp&4nM`4_rYv|1z*PA0r7vd4|pcs*e)5kNG`$V*{+!lPdt<;DSg6&-^ z#1SRzB0s-QD8O4kQPPIa!_MpUIQ2aN_O8c!Wmfu#*Z+RbyLfg`NF!LdKj6K`>69wL zyvk#_@PS>8E=0#ibzHZn%U3V-$&%HHN2J6a&IbQgdAsq%rPU>De?NH38 zHa!A26dHAQmmxwio;K1@*1K}VWvgyACFL?`-yOmR`-=wkOBb_p>ZD(Gu8Mlt6nqrpA!q}q5%2&tW)1s2DTeGu6n}Sm&~b1x{;)^v$H?^L)&U~ zAeGB5iYCibg|EO9<^A>CyIwXrDylC}I{qXIP-qKJ>q2@oY-d?qiLUyS23zX z{CXc-?~H>zr0f1j#wymPp4L#xbA5Y@RS$7RTcm0K03+p|D994!SbhZq4Q=6AQud$* z_vzD&?Ltu+C%yb=;pLSy70pq2YaABVH$MHXX}Q6~CcHdBDykv#8d_>G&b%sWuEecp z?e+Em^uq)LLkzd#OifwocDxE=h+f^%YWH77XrJ(u_NxH8qb9joGJvlI97;R+lZwXM z*0HetZr^Tc8cybFl8P|Evp(CZ=O_duR$HRtkSe}PZq$QVznRd$-qJvd)%VSHIrV<&Owst zTI!Iz`!SeGO{JiC#UkmprvawOP4&C?**%mqu=!~%dxx@PUbX@+`TOXlH~>&BD+P}KNPg>-1IV|@Uq_gJmEG&3C?3_AF*^vvM~0HU5^6Z z?MP-bM=t*CrSb6IbxS#(R2Dvc(`Qi*GKg^&E@x`YY6;+)R>&Pspt&qn_`0-ktlf8W zUbuFpC-J&dK84GmRX%t>o2E=0=~h0fKH8Y7BWk9mWx~Ye-LBL>>jLd8r#C(dNG#YB zA5G@P0lQ=26mA7FgtPwh(2};Ut8(oV(vKBPpo5pvb?%%C7JtUHRp!Y0dE~8dqUt)- zsAJiY@+sar4icWSgQ~k{b3cf_iOt)bzJNQ3BFrIL$%!w*HCGBb2P7V1UnX&;86-1SDk~@ zaB1C93PP05H5-%vc2>|WQ-AgS3JnAo^h~et$F~I+r~`Tu&WC0z38pHu-KgK(DsHoo zWp@OFIA1~BzosJs8k=Edpws{JwvyM|DV3f&QniYqQ=`jOOahkcNE-mHaM-R z8UgC?%p_=?7&mv;D~dbzVTrI2nHa!EgE8t~dW|mBmWfXSW z?{Y7?J}yTxI}+#Xs_YVI+v~0q-V^OBUIOm*MgRhL*7QJ$@NvU_J{B?)(IQPh9E&rb z)LRlG=hbb?QLb92li=9B1p~!fOSk-NZP=Ono+yU%aJ#>n!t>C}30A}3u=vka^ zuyFeHkH8mVS8<#k8W zkogu$Lx}ww6~OP`XHc`X`TYTVPQj_J>dwl_jEs!@ z;>!qUlkuEddGWhoqe!Ege8)HXo6obz`%B^wAYd=lsE6y|=o8N66++-PvZ)zZC$3AFG8*4hs z@bi>b@*2Y=9%GwL3Ems3Yx5SyeSrG)=P_#o=39|#XM#iDMhVB8(-)K>0jv zA3tz}QKra)`taW!zBmydJdrhDQQOkhHIUl?z5Z~4KI(+0r4#2bX)Pn8h9vx8|NgLL zMwXjzg#Gt7|M4Ss%8yi`Hq)U8)#5(j-~aC5N6L;(w*SX({%wiujDHlgJ$x|vzZK*o zVKDNK?)r~T`^*1T0C+J;XDE4=5c$7tj{m$!6-xQ(-&W&4FWRD@fJBBY(*r)^@88M? z^r%AEzf|DAee9N&@{!n1bGhRD`S1GXA2qz_#6nE}=6r69NjCWXZ+ZjNc$&f|B0BN)b3Ln zgHzJ~_SVO-9})onR~FR&wby@1$^Xg3e-8Zr8$SM0Rz1@4UERSveIK@I%PNF$%#?72&BJN4aoz{vV% zMWt6z@4N1>H#t8phg*zAiG^& zPBVrUm_%U>Y`>OSFf1>w|AxPA@m;bQF{E!Hb)XRi+8P^CvD& zVrO9FlAA`^V$8!>U!Gjz6?dkr{GbBE$rkVuuw8^itjze|Cggt=q3%dwP{nb~*iM$z z;{6^#adU)iZ5fiexgvKq*sN&&;r>>g!9X7{Sm*fW8%J**9Ih>+8i-&qyZl z1)Kds{HL2Sn27ZRU7FQkMue}IlPE$yK!wY@fu_qgWSe%D25RW&ECG=M1+FB$zW4FI z0cQE;_o)(7^%vH4wHoZMDpS9tLg#^HK05x96cx z?x>33W&KlsC{qy!gQn{N@V}8k-yliQ`viS+zHL1S@_XnFgn8Icvnhw(7V!Vva@JHu zp+DN#85Z2MY;p-?{diAdp(hVt#_+JkXJ`&I=BU5pj3?VEY6}o9eKGh7UAE>>_3K%F%R}AC=k$8sWLV9&7{yW%<(`J zURg^zhx0kEM!o4)ZP8v5;I)b(APaK|Vv@$^BQSg46r=E&h;B-vL2zTyIPps1L&YhL z2eI|ZM@y-uMdFhsI<%}AB6n4q?sc@p++{Mbg2xtQi$Gp&%eSac-{1lHQEr_Q#caY; z<-jZY|6B(APk{lMQ6QsotFZnJ_ONb(w=ad+BpVel;^#6PONz>i*Mm zVZ86Fu$jfwqKbJYCA1t#3mh77sc++30-Plp@)3>wNt*UjHv>v)?~1pU3jDRu_8E^+2D2*xJyWwToTN=`nw z?7ZjvfZ+Y`3EyS&qqKmQnp()qi^rfGQJ#XEq2T9wbJC7V$-d~0%xflD|IRD!vp2IqNp%aTAP$yUJ@tn+suxm^392#q#_tj!1?Is!c= zb(kKX)O%L&#Z_+;>6x>e#&>$|OMrGN!uF5=F=DMFt4jL=v>!UXAVS@h`ovflJBu&V zsdg9YH3o?QCz%~|jwhr0I&(5|QTg1hTzUtt5P^sEN~pM&-+LM(qLwKf7bp|kKxv6k z&4Z85!f&IJ8nnfh_9WFMtl=WsgXtd3}x!dnKXf(J!lag)+^^PL=dIcGNq5swe z&>1th2$}UR$ro-{b|GYtbEeHPzlQ1hB-`~fIMGRbIyH4>xnKO%?k>_ENJc0Zbo*|D zik^?!Okg8GhR(-|WKq=Vvpcwx9^V70&?#C&5o&{uR_HG0m_}sM#u~3_xIm%Mh@l8c zE;u6}ECXnjp2%KfZI5lmalgANak*gCcHp(!;^IaDj#$Z(b@FP^NEk@$vc{sb3H@~y z0~hpRO`0XS#6WBfMNVRaKhEy~Ss{trzrx5TZ>=_K#6$rs?L^7U7@~$V@-i*D`!f3A z8$}2)gWIvx?-X`OMHGWmpItJZKvn1c6}%Z|j||j7I;KlGS|}DXtx+(?^tEM=9^Ujs z$9;{@_l8Y(&~2U1J)TLp7$WzaDT05k42dlYQghIqEIX1i{B0{rbAu-L0Q_N?KH~uO zS*3f@qVH#sY*=@;qD42{xDdy8_C8}qE@Lo!FA}satZ%AK6bb3#RYjXQzKWJcm~9f^ zehgcYQc}$l4XT{{FlyNXMWE$&x`m`{D!)+sd_qhRcjtKq<>Z1rX1i87BySH z(cCo*!Ms932_W1xC$KS$MUfG~5;(laVRP%G1uQvF>3M6iQn7sjB;iflGZ+mROG~t+ zv}xJ3GzcYSahFq_pD1YI`0Lp47#=d88FI6zoj)XSWOjUdU+%;ppU4!@5ugMU6q+hE zBEP}O^zoh2Z4~?`ID#`XV?@71L-AF>k}?;}8M>>Uw;hLWeN+8kezLj1Th^ zlRfebIrZCu>Js#zSM{uB)sGa{#F|!#!Tl0-8orX2(Ldn4Kzyc`ISXke9s^K|E8u;M|WD?f99b7((<{`$m7J1i%bF46s==nr$+GCn>7cM0tAN0scr z8{+vK0#P;#?|ybsVZP@#^bs{CT0^LaLo@clgh0;4M#bGxRr4#TLA@>aDg9F<4!^Z$ zhsz@a%_qJ4x6*^K-eEVJD0{sX3V|!s&bm_h+XXQ$2WedJ2LS9N+BptJv{QqE z3kLBwD-_7vS$eoSN+QJQ8JM)*cV=-zP8=+beywj4r{fN+)X5? z`(>L;Hc~`ND`eMXTUw%mnRa(lW(#csCKW+aW@_L}EvvWRB|fJE zr?Y3olQmo+Ii3Bh0sovDK)76o656J$vl7#0l@)cA1~2)^jS3%V0+Nqcmun}w!D98N z@XyK)QG_h|Y%1?&(7j>Iwi}aTt9LV<1p6AoiRUQlwZEkUelI;UyQwq94@*9L*3|6{ z4oO+Od;3|IyLM`V>3Kl-8tL+~rhjVZUPx@0;g#u_eGIN!M@z5v zteasSuT>c4UkoM?Z!CoKT-h%jWwx^01}B{k!n=HPWbflg+<(zVwmeYjT#nGvX>9$p zO{;#OHA~OvsN_m%^HZ^&rAX43+sC7#EC5F->Yrf$uH~cB@B#U#N$XhvFB})x{CQ3j7A;K_%PO)Mzs-+;<8Qs!xAfHa>X1UvKb@Yene4_mtQ8 z+igTVs&(?haSmRymg&D=r~&%>%?Jv<-k}m15S_+4#2svBak2$Bnt8i|=mNOVsz!;} zozb50t|cP+X++T5;rqw3qcWrl2eQ?wh{2*?1lgabS?T85jwPjxLFCDx=haNvf*$tyC^%=y;0 z)0M}e4Qf`|g8kzAo8Rfpe40~CZDediR-U$geWlvSnL#dxbWrRR&Z$v#!ZdiWf@#s+ zOjag=u5mA;wcD;2SRVU0SOdv+KUqfB8nKAY&`)G=Y9uhb;qc!4aeiM~{Vea%#W2<1 zPi;7%!$mWUrSUPa3%gb?Q4*^x1FdWRQ(D&kd?6GQbN$*`2)|Us>uM2=8=^N-xFxNU z@yFJ6rFS4{`9{i)d<{C+4>q=ZlkM01`&z%K@%=R@lw;RkEq(4C*&w`;9;Vr&^;Ms|t-f(%IF#e&z72!!gY8(CQ0l z6GD3EE(Wrd;}5!CzB`S%#Ks+bL!pb_R;%eQ63g+`AW~-OeCXI;@rgF7%W_oIf$0^X zERt`5Fj9{O)wYgQOdH<1$5Mk~peLF^3_pxM5jT2b)YK9;;Mp+pKJ*!AO6TO&SSq$M zEJys-E^#58VpD_|9<6R-LJwzVuA#nljH6D&q*hoHaO<{+h@~sv=jIV_W zp;vgQpXE=K*#LmnMB zi^-TwZnKaU#xLFx1AJ%R!(4?dRg#BC6|^+S)1DU#lzC%mx$Ngwf1_0q^Db8p+=-ng zA6z^s;XI#FH?@>20?kRM-{spb6Y*plT3iv)&R_Q45juKlQ1-iTl z-JYMjxgmW&8gw0>o%M#zfy=*aMb*hXe>~;RrrvX4ZcJl3nmSW|8uRO|a@Nod3+c?8 zn(IDO62!X*imqx=<<;NXYzQt)da3{7 zt|qlya+!Uxb%%O4iBfGnB;x{Cg+|?v^vZQBkT1tyB*HR*LG-WY-))x9HT}@jhRNNp zKLE^1IN$8=Qm%ARU0$__Vx6Oc1C=Zuv9)-R_7!)(-1y=p1H`QR8}ljeUc<2b#^Is! zHqmKGqsPqE=n(@RuqFs~oTx^jW84`&l?=)d-OIt>g(e){eTsTKOG5&dxEO2D&-5@+ zTAb|MW#CkQrj?_kmznXSK%KB+r=*C^m`AM+3Y%AoaZq~MUpH(=nn*p4e1II(@3}Kd zBu*-)Yz*-v7#&Y%<2lL-k*^uom|Ov-MNH-&+<3G+v#7$>z;oX|S@5kBcvauRBE?;X zJ4Ge+eScx)Su5qfzjt1$EcvJq(_}nrTNXgXJlg_ z`Lg7tBoi|5_S4Qz9||fD(zKE+;x((TBd^QfJ&cT2FY$XF)ocsiGX_@_j#XVF+g0Op zzgkVbObumoBzT+E+K^x0_fw7Hqw!fYp^U{Rv5mkKr0{aD{JAt>d!M^0JKRlO;fG-u z{FvxwcTge>LeJo@yl*l$QIS7O%L-rhc>ih^H?M=^dZXzA->NMYrYZ}vOw?RJsdqd%21N^3#>WG3xsdoOucPg*<8!gK! z83Ieyky;d#@I?5smMqSVI}#eEqku8-hNv_+73n~KuA503g@$ti4&ORISxSP6o| zfzhAC)4p^Bb?)Hz);sVKF^hX3nt1uPj|dDcT7D4(f2+`KTV~l~v}uVesW#=eK>D4Y z2Hu*zV`XD@R(o7V14Fm+(_xaHp<6`aOY30hk#j6BEi16cZ@Rrab+I?bf+#Vq zu0a_#s_(ySZ$Mn%eCZ6^4IWwb1koaI#4Hr!Jwxelsaa&!6#E({^8G{rE<1iH4B?f)PR+XX%__OdSq2=Tess$(mENTh4_gbcS2(cGSBh#fNpw(-%X((BgDu%45 z2%RWz<5tCSF4m~i6h^w%*2l3p6D^Y$8A|O= z)Nof7&Pb8{bv0hb-+gKFqHOA?{0G>~-$#UqSWF#QuY*zEKi-aoso!kJia zV6`16hOmfQekJ!D3+Fw+-xpJ(^fN!3J{Dd5w~U379VuN3i({y_Cla$8Csjq5omPAv zRX|Wv-6MZx<=ZFS&9>Sj+w#h<_to0sNv-T{xP^G^j?0gGX#F-TUlp<1OwR_xZPu&j zY&Wa-r+Yh}c9Qw@$}49R(wS8)`+j23FP8ZioW8yM2&_YwO!hpGWRBtAH zOKVo$6A4(O%S z7W5_!cR*x4D2y*r5TS%J9(s72CGf3|B-r4_huThCjvB3_fqG8NkHMw>EHD@{@fF>o z7N(+~>YDlDxhS^cnKe?YhDHffrm=w3?$LJSG%BA4hdPmR)N+FGx*_np0|%%5e0#)? zvOrsKt|n-~^>HJYAM-TaS3|@uwzCSDd3OlVLRCo27Yo8^EIqTd6BWz}|K)M}5Bua8 z!B^v@y@D^pSoV(#o94(D@Uq>#0sYBo22-dd7&CCvq8n3+j3lC~Y+Brbzaz!(-u#8g zbGzIC+--L}Z4JS0ACoI5N2D_Yv63UrpbXYr&JpL^8;2@0R`U^J7)#M|OU()wnuP=C z+$EZIVk z|AolS#0XXw>j+O7elB)Y;_7mdNs&IP7TyvrEdiX`nd+Z>UujX~@Oiw}?QLGXYrbA* z4EdQS#uh~IGfg3)E9h+Cb8VaF0h4waeGrEs{xG0jRBt^%pxBBW5J?zdq`Cynr;pa_}a31 zd6j<6=33PA`wqR!R*5cQ^C6-Njhf!zl4lb9W{Lbgq22lnCi0&DD?vx3$Z$&Isb`VC zZL^1RYjJs`BF$hNgJ+{!(gs1WZ5H(DMgx4f6!g(Kk99+WNyK0K75fW_4uLZyT$2V` z{V&A&uy6N2IT;KH$BE={Ks_*s^Hx3~`D3W?LG8r|wj(ca1vuhRs8op0Cv6&$(Tf&p z`u|1{X<0%hD8IN}l~Kb@94CkjolxgWPxFJQgC$L>Fa^w#f`CeZQ@7&p!Wg0RJELS$ zg2C+LG&v#f@|v0VpcGA@uZV2KYP^xlNJ}^-lZnW!qfUs60mpFiIo z>4{+CvGb>+uf*Ro#_gn+7p-B%HUW9QA5SSanWyDv2k8JN#}K-;u`K!;LcwqQHgvK= z-SJ(=G>-WT)z7SWw91Cih6NY(xT%81U?TPrNOnquo5-Vr-*yd_{M2m|jXA5{85CTB zpK}NGJ6Ac}#)-08h$tK~0!7k8L3=>(&aKvYe)@_36;`p;hFZ1P)->7qM!_kDRsRmT zoNZ2(C!9e_c9|?ftgRejJJ@CX1gF}?FKz8c5Iz${qrL=oA&R>zdX?stnsKR{h_97oK~AhrG@3Rn zX8O3$flD3Ya#iBVd~^D^(jKgPPAGA>*hD6?23@R1^|I9~FemJIDQPNtcL2gdSD5}^ zq1V;cGfZ(fNSZZe#4vA|*9&mTJj=VsJ<~q|ey{a%9xMC0nXUpFV4_N{F^J@8nll82 z{T@DhkAg_?7(7uAaYM&a?6p)R$jAqVidV3+iw`R$^7#M5ZFwS=Ef1H<_V1{^?KEcQ zCzz^T0A)@`t$t9*08L`q)4kE8tOSog*-5s6;8gO-Nx^Xk-u&X?f$eQnh|+27LZcV9 z;B&(4YC9^oPUAW>33Jk@eq$O$+<@F18frkyYF)AL2f>v9;_^f|jDv$M%N`L*1aKB8 z5f_)_F|NkcH#8Ia!AVHk#5^SCn76l|@+k>~eDnkm8oHk*Yo)&hVA5fgmP|O-&N{!K z(v3WLP)Tg92@l{K7iBb{Qvc}>STtZsYo<}w88K0;$97zBYd7j2`sRp^L5)VJzfl`m zea{Vw?ZA{mW>ZY`_U1_SWq`rFtAa*Iy`U+ApKI@|3Q#Q5cbRcg^SDB=MSd@+(NFfZ zGs%C>(hK{%b#QxMIDT_<_j6cBYzNs|NeRv3G1VwvRG$g`P|K0O&m^1afr68gm-}N% z3z4>t7WuSRa`ekJ1O=MqMO9yWNg1DBCvTcroW(5dSaWP z`~arQ^_12vd+03YvtM*{be2{6=fofksau7MCuI3v1E!}H0s;bF3hp8T4ek_$X z?})t?vs_`?8Ey<&#H(sLXN?yAK}4nM`jd!SCT2Cqh=$v=Z>aHkkk9eudaB0l{0|yl zcO-u|4&hcVc*4XSoai??jY|Z9AN7#^3E@QhLIV1^lZmq0;EoB3i3$3>c~PaaCl(k- zyE+0EBQHg@AIN3X5SpVv&HN_t1*539fp8aCMICdZfpp>~%*ss_rmwB;>PQD%C)M3) zSb)uMpcQJdgRx$Jw?%7Jjo4ER z)_BD@ffN3^KToZit{W(`exV$MfJuATL!i-4aIoF-yi>zL^f_bq)g>h*NcIPwP;R^M zdDu??JGTh@pG(z$#UzdiKJLZ(`u=APR#A#6nhG-L7PM3r;kEh}qwlN&qcTGUSXtic z>4!V0$q;8HUrZ)2L~3YJckylI5Q7bld*|rZd=@3NyB0;_NS_MAP8JXvrT&Nc7W#oU z^kl}en*L9bSn^}Pz=%7DKnq5e#m7{ge((JdGO(o1tp((82C_mlG8Y9gMI7amM{?5d3*C!^C-d)y|?ngW03`4X1Yg zZH^6;)4o#M0O?D49i4MV1eE`UUs;Lw5ooc1s@XaPm0wu6 z?T^6AUuQgav1l0m2OY;rh!>OuMaTQq5fuZ&a2pVc$zu9To6GsH)%zC~XeG}_4P;&S zefA&We6KekAIc%|n8d@)(ad*Q*%_eNvt?_fBJxE54)h%w;5cUZ)z7A4F#I~xDhqx#RD^F5L9NdzpQ zy+_VG{DG`DTCEU~lZ)Xffx_j~*lr2peshvqi4F_vJ!{&q#y`k(CwAXgFYMO@ibGK#FJwpRlyr<{3+?hM=*P|24_QHMuGWJV@0``smbCPERP+@_sYX^r5PWG z-MZduzG@@n#&gh?6u;s6zQACN{J9cjceRnt-tX zv)^vYP+DoM$H#Cv(3~s0c8MJtY--BNQPWphO<~JbzIO*zo*J~@4t}k9b=VwUA22ji zuagE-iw^qzbFA>C@n;+_dpX&t3HXy%g9uR|Fzu1J^LAd7wW}}6pVRemS|q=>n(p&B(HJUR3C&1V zm7+W;Jo7o2Xx_W5L!|vcAfLM5+~d^YPh*rue1#Gu5Y2|?!EO{f-}IUAyUQU|L|b1q z9e2i+LDGgp?n{M;XcVvSS2)fYm>8RW{Y84xva50Scj}R6cfa4pX(yuQC zvIx9PcpZJT%Qj~e@3Zb;${5x3&tE0L`VjHU>ElQbu52&#*gCn`pra#CRmuqC(_gT~ zYIEhioSh%$OYsJ$x3CJgtvt z?MZWR(c>*R0;aISCkL}*?UIBrDoRRDl6yFt*sUd2hOC!oQ7i##<(Rl3KMljIBd#OBP zV&QMFeLJUeS>{=K`E{1dTy#(YJcGOeMOj{7UTZL_#@&_c_$yy7aqVXow#tn2E^bzX zr}#)PP7>)v5*y#ak#k3WUEQ5wLdLTzj zkz8yC%%HiM)XLjAxrnHk&Le`;93ErceT=tGVy8C7FN2>zU=ejlS!LL82gXZ5B=jh! zm*apbH|-|O86g-(lZ59Ae1CdTR;SF!LmBh&iH3#eueDUL>U3Qh1H!>MW^&lu^loL{ zyvqFtf7|HtZ}%d)HRh@(=~+#yYj>Ay8{5T>MQ8K;gVzh18F;?cTF_ntGpcm+&!2g` z8wO50KX5ji^r8M^rtvM4=st3uRJMvt*r)~<6coJJiRSCo^x}rapf@fbP>KwII{&b z>wY!b!k}Q~iAlKtc<-EJ8gbzUnq<-S)0CWq?+{-aHk=7>9g)fsOGE@BSUm`jB;{0R ze|W=lBeLt^#-+ZiVtyZ1;OqlAs?|&CdxGXHKX8cz6Vq0GNg9dT%24GMKK%8OVx8>=dbkX-@PGEu3ERZphnngpa+@Q^v@D4m5OV? z#SBgX&xmVMW<{m89G;(e7wzO^VPFR@YVNlhOQqW1&aKZD-NP{m?H^dA4vR>ZP)Dw- ztpsGdiwJMOoUcu~ z;bZ#B&yeM6R@bpwgm-1&1&9^GCH~eG&!9}?+FLzi z1~UZ2D`F{9S89&55*1!iTMknJ7S*HsfH=;Uqcz?ukc-utFOv4P2SDep+9?Tou- z*k0ovU4Rf{=c3p9;dNhN&NaU)i{>g%5h@%nDwO!SrM*VZgXYt)MH7FTOiawsCQ})?pXt}igB5r! zWh5pxPaoPPbZ1zmyJq*noT7VG)?aHL`R*Lf+?VvcF~0W;Rsxx14!nmmLQH{jDs6P7V?o^ktn; z{Wiv{aIX;%_Ppv*vh?e8o+rNT)UhZBIK7$sBtUA;d&LNc4ak^R;6R7a-f+b6ID(A2a+cS`w4F^2Vy3uk*R- z@kZHIw<|ll-Eo|Volf%}jN*i>=j#)o9$rS|V7>3W5N{;)E>InR5b%BFK5ZEbC-)&790 zt*eVkP7VPvsYizA0vd?X626>1zyNX)Dc!GQx}4LBwGA&}E??zJUgV2yk6=6LvRS@H_dtpmvSrTDyj4oel-uokC6~4hwKU& zzeqCG?+HC#F`I)xLbpMd&&%w7&COnHb)(#WbwW3uaL3Og;5AViwfaPqJsi}pp|U1f z97~X*&MEP69!%Q0MA%FOsT{u_X3xX3O@3>_VT<8NGuDj znvfwhe94?DyZsXJqn#T15B?#8+4Ps6JxJy5)as*2O%`bDC=hlo??}l^R@_fbLw$+L zHrs6jJhrM@K=LZOKXERt-x zd#+3)GMM1vMB!p}OVwR~x=)g%)Ztfla;N~>9Z~(T8}eEr44iqd^J(wer>)ei42Z0C zVg{=bwHw&s!6-)?ebr8ineaQqrIF~Eg#F>3Gcv!+^=_M zDL!U1MPDmnekIgGV~I#J=q-Ld!@UhX4AuAi)?H>wprcC(ZIHua@In%CND#WpL$Grj z`P{%&HWK!$iChE~z&|0-x7jhlvx0I2G>6{)(y#Kgo^umYnBQ3OvC&#ZIUy%^8+X<` zZ+U29-S^XBMI?1)I6|!=Z?`_i{H|+TzNb7QdsHL}>5JNpnSAl(5*e&g1}SMv?$^!` zaIDeAtr`lh*dQy;{%Hxy#*W*>A=&Iz)Z~CmC{MZM4hWmdg+a5E6hOd68ZgYSIgsM1 zNUG@RN5;G`ni3A4hZ1&>vYrQA7Q|t@nj#+h@)z z;o}YDI3-A>^IVYLojpVAdKU0LR|7|dQkkTZK#ntb{x&w>Aq?wf>7?nM2MJU~Qn9sB}R}f zHS+O+^pOs=$Kca-S4gXs4XfWTXxcJ&0n_51JNhysviSxeA^P-8GW5g-2j!`;Ib_}9 zO3EcYaS+@RsPJMj$Av4*G9Zb!gW^W?u7|Bsczlx5C<^sl_$gz7*_o+yynDHvbbr0L zx#&8-v>jH&wT%_T>#_9yiNJkoCr!<(8n7)vN`b$Rb-wUCp#79<(i}T&3oC)?gYIK2){@1Cz+D>SPN6fZ66UuEX=hlZ zHywhXsu}%~JJU>Y@{sUbUO)5{vQ_~Lf-E8~yzo(1*Zx8gKk9o26O5*+PxF{b7RrvamQrFo=gx48-{Uab*!0mD>-> zdaVy){Wp0jm0Nc%K9LG)hQg)zTZ=qHpI;)~F>`nvBj<8GnK>$F@RhhWnBpRy*JMb@ z%RGuXHfliYB8upZJ_yrfV28wByDp~H;!@AIhlDIHAQGHgrkKG7uRMwIzc!9*+<*QN zb|!fGrAG9=Bb!p*lPGBCa`;t_blxCbWNOL?AY{N+a!kA8XZcvNF5`rl}IPbn+{_w`PiVwsZG}79DW6 z*58pER2T-{GWr!nXE%Og+5wgN?h-&DJ z!q;!m=pCdZoi{u4BHNp#gvhGsbOGf!kJH4*aH(dPrUnZlCxbD@ zD@ePTISo6}VWboVX?{66c2tSw zb2pEbnH_*%%y7Z|y7IO?NNc59N&_3iczKOFkTK-Z$I)ll0p&*L^I-HhNE>WRW>_5Z zb9h!|QG!PuhJ)*$>5t+y)kCA}G?Q=-=9(%f5Ivm?qbag>R-DBydpf4e^df**ewK3> z(X}T6GAv9_1hNj>XwztYJIh~<`Psqa!xbu&B`M>B<8t**%7@cWu0KVcW6UZkX#oO} zv&+<0Wo^&{C4EcjJR=oOmM}3+QZ%vVBue>M)g{A_)`xrW0)nsei#i{Oon~PpPSI&B zmT~as>!ao~m0Xh#fD>BwZR_mtz@U}o7Z~+}<;|#-Jk}FsivnabP2#0^8%>j21s&e; z1Dle{=s{cc(I+#qqsE5cUX|SCp(#ZSCVsDORj511*gNKL5B_|YG<=Obz2xRitfW(EG}c5O9!`WPj@PvOkAI#i z!bAz#4hraqA8vgMxYXW(-doH;SpDZ-s%B0oOg;#k@hOK{M~Q5Oc$r{$ywb+;Pmu6R zX<|kpZ+rBFWG%TwpUa09_gF}oWJ@kb~o7k%q8Bg#b-hbZK=(;06Qklm)rMEY-EM{ez+KLEJbLd)adXDiS9ycU~-rN`F$Mg z#U;3P^(ni=@w{GB6}#&S`SorgCO$ze)}ZnmY^r?DW4_pXUR1=(dwQRHtjmkMjuCTJ)4ez-(6*)#lNr!O^+iO)sREYUs^21cZ&mNT6RLa z@*x_LPBbL7i5jN-f%8sLd!V5m8M|L`xH9E1O|xoccbi?1KrjG`V?0x*JYz!NGCT!g zwwUDrrXLtAe8dX&iOws6M@%-GxievFJqlijPcNyhfqwwWYvluYVMX71Ucr}^dfD-{ zFE>bgKOW@~YOmtfc}`wV+6ub_nhn+R)y~7O)W?V0e_OUNGlFG!gkKsp)LN8^<~rFM zB<*_1$ms-17BOEQ=fXLWT7IjXK+tRrgV@A)=SA;7;X_l`ggX0)l!OdrC>DC#kW~2N zXH%xDYwT2NC26IKC|2#Wqx`M<{Qmer7BL?!%5N9C?Ft2dvk260)8`_2>Q-1xFSxEXMa zVS}jr6?Z9et~GC$50-GA73y%cjofw@aU6X0Y-`!1fVhrpj8J=j-Yha=z8U9+)T<3S^~ zA@WRCk-{?zDL0pH^9-V^?o@QM$2HWnXqubE{Z_y}Zlkor1$396qRlA+>z7%590Uonup? z@R{NGf#hNuWU?h!=9_j^JTUZJMvyY$LO7vWd68i zN;MBE7oJ0Bwj-%l?B?aJITbo8%4>%O3f2_O%{}QDD#W`f$6RuWHHM{UnXRilGs<~t zMAA9CXB=gI)LqHJRfSgWF3vOjS7Z`)Mqf?q?HY$eN2#wI9GC(EK~#gDd$bxMS1PND z9FTkfzBbfwvVOH#Fh=t?1)2=L5%U%dR%_lwI&70Ao3Q4i>&xZXezkTN#zY!VC`HJ| znGy5H%*ZCH&`+TC^O0m+`7O9;WZ!hbggJfM%#P&c%}isfPF%h38LT1)!kLr?$vIub zo5YMS9^=tcQogKYJ#RbrEb)KC*1 zi2379HGb8^OS(TRZ1m$7e4%O6JEXn2!G2NpLMD9G%(9bua>mEP*+AOJ=8rzNnrsv! zNC?@zZTqFF6!C)ViuJ2q&eD0Ev@LaX;vA{qqD`&Mif=p|f1=9HkakT>@#o1773Bjy z`FK*U_Jmh#Vz~4AQV`1Hft{3&%e_o0#do65$vOi+lz^dDhQj?JiLXP0)AxOeo$o?A zp+_>}mPN}+S_!OrXaULixeTz^ZR~K^wXv=vAsf{Zo_7k4a&{m|ZBQV9C zLjo}mh2v^n3Td*eV^l7wHax+5HglxfB_t4KBO;;X4qfKq3i}wFmNh>|{7>gkSMA2S z)~rTo!(&@u)9Dj&;B~DA2Cuf;C0aUN;s;B)Ew$d5=1;lw*&|X~S^>`D7AjjB$f@|+qlV>B(wWmA4_`*f~6&dbxw z8LpEnCK|;aPh4REDOfL}Xqhusuehyuu@Q1}{hF|j$}{OKwIQPHaE9)!=?6U8Oy?9sG{Xs5*VqwWYB?+iNS z)?EpTe~nEr?IWZY1lM&(loa1#%Av$FoPP#6)_dwjB+iti`xm92pkUW7aC zRjuBxhMPdvhlr;!n^MU7$t0BpkWp-%(8Tl8F6voPU3L<`@E96bp_EDSg;Ad0 zk+sfdaPe9_J~t4Aed-MtA_hj`4&%eB79b*;c_ep8N87oR2r(huVW?qGt#z?U<1jP) z1Iw+D&YH(ds#H{&-Jq-ll4OkQ#Jeq2% zmd)E&04=d(e)Un$iWvW1Dy)}pO$yDKMa|7m-JB9}iu(uEq+@fY5^3)CsFY}1OEaVK zy+h`~n>$|b1^X@@Z&z=svYC1`&uDF#@Y$R@x!I{(SZQ1t-B#2#4|C=%b`IT@(M>Z& zjcFqk)IwG#()yk$9h3TGW9F{7Cc$F)y=}qe@vh}ODKO@h_-usz_zPWEsAk1OgC67g zBljq@>y?q1AS3*YL)4D=y|rhR@hV}Nhe7lVMhLxDkIhBXn$GSQG03cGE;6n1Kvbl2 ze{Z5$f(~shAdl9KCh=MAs!s6?6+E^El?*GaO@;SUmHZk+T9SCUp;V&6g9P`H24=@+xn2mx%=L~DrRUPZsbP4o_>IDu3nR%4d^C@L z-R$2eL8HQQrdFz89SkfTJw`qCW$p&nPMYb+ zp;75HS>jos;7&=qohMgEiHeRcxU#Ms6R-r`-j>Y$g&`9X-!GHGnpV)qK8LQ7y@(D2 zL_2PLUV*p#8^Uqjj5eLdrq`}PBSN>O3l4nWHuLw1xS@8X=7fYDWRt*n(i7FeRZHiok2GVq2 z$_GfYwSI+o&PTK#4Os2iFO@judza7||0vBkVV~6+^QAefE!LK~G8^7QZ=Y%iw`bj& zb)@|4H3x4T{-Qn-s?hkx=Y9{aqwx9;`qIzZYGm}C(ZT|Ej@esy$2#m2F=`mY(^fK{ zCruc#TnD-*#-|(O#~{(XPhX43w~xn7z6Ti^^j6hJR`WRxp1*S3XSTMs@&!d!&le7^ za_rml{{C~da=KqLJ~t?#y2xBuiMDw3-VJ-v(93u(_Mx{dwLW^XzTk0tb+<=*&oaT% z-_D-ZihbYT>v_15IIz04Y3r#_I2}1(ch@N8>d^Z2d?Y-moFdZUsTDdnhwv<0YMGtK z!RVc+>0F0(Jy&M<{wXmQN$+GNdYJF$mDD>2qvTtou|Iaz0?_*&vW-Qoc;afUf?2NMJeAJO#s&m^2+r zsGN6k=^Q1CI4#Oi{!~dxU2#Q41(RXplfU9^>t5+zzIvl)u`8S#o~PFO-15<2si8on zx$^T&%bWb^t)u5BC3QZt;v>asE%kX*1dGn1k2G+QRQ#Z@=A|)YdAl_PFgkZk^b``rClGLcOTtL_$ zWp9o3h2l0m%)>fxR2uM0k!d?U{LZVoxp2NUX$71a^33u$tgakzoW9T67Ol$k5f?uuff><@=L#xhHpFVrQ@ztzPKe zxvRw6vgonH+@7swwWa&sB=&49^36LZa%e!{YkO&~)D;P6$z)Q2(+eBTdZ;0hcpn9w=oD3bAOfe;}|G7a~?N}Nw$E$c0{ zSxQWIO>vey^!NSN7Ff@_+uG5~3q;SQ>{ZyN8cd9hX<%jlgSr zAFaft4<;H|QeZd1?TKM}w#d%KK3erg*jzQZcQ5BMefPQ{fKJ> z8r`a|6(0mJjuY2MGH^7{{B_EKbK64S2{Fb!={w$KR@zc?Ee#mBEm*>vTn1F<+--&T zlXbq9YqcVy#QR(qMKr8}UH%eu3?nEI-ljL%J#le{I17J0S|t99B7hsc_w&hQ&8af4 zw#uzsrdKk9R#I%gm6%<&I(9Tun~-=#DPJl~YB-i;Bc`JLO|icT>0jsjpO5^L5GhtY zaegE^pD{uVfByMLzrXeULf!Sz7N`BP&Ejv1{LfS3J_7n3NdL3=|9t=7Z%@zzf_3C# zY(HP&zu^3bpX$Z{BgX&y3PV*Nfvqnd*RF^Ez3Bfv5(p$V*`E79uAj}1gQ*+S1i$W~ z&G|=={IzSS&_}|502%QAvO&Oi(D$-sB}@PFR)L^^{=@nHwJH!4&tE)F+ipA{a<)*b zU-ms0yl0&^NOLt9n6OQ4{4Rlq2UR)Mw+fnL||M|d}K#nzFts=wtOEN#}A_pp(e36H*B&DD+e2awAq0kNM$yLx)8S(+*^y4X7}uywXf zlD!su^!n@TACD$`1qP3r+;*vD50$9z7YLRw z5;gH95lJiC`uO<9EzUg~;!FwqGF%R3Sr@jKlqVFCWZ7tDNH+OCj!PNu&^t`0qH$?v7a4kt&R~lQK;Sz-bhX zWr|LIu+ze)JP{Im;>IH2)EvXYTYB&wui3>7Ed=mRh!?=RGJ`AEfHU4u0kqbiKNMII zt=0pW}t<~BkEAs2E{)5knZ0^{Ssxw$nrk`y5;oS>TKYI`f_maF(m zp@JOD1196$HfD~AU93#ZT?_{e7N__?E1?;SJV_-9ToGlw-DXPID&+qx+&9jEg8kvF z!#m&XCV8sz7rfp^a#fKgs6NXtjI?)t^#P3Uq97dWBm(-kM$ATs&!zgn^23XIvvfNX zyr)Vqd_}GwwkzIyh>B)wFcMs#jKqApZK-cGyT_ds8~bu^2TKLiQ7NshIj2L9`pm;E zuIt@I$_DBouu??*6?L$xPi1UwtjPy;xG|nWD>{%L)~;xzv#x_Ah7Z%Fn}q);O!DL%$btl{j)JIPm<8u|Yzy}1Sfc2RXy#b61Y8rZPgpdT&8opFHXYo4Y zusdHwutrfURpQ(O2QPVbe>Xkw2kEOOnn*-a2e>-d|0TzSJt_vAx?_Ie*aZ#b{d`M{3VH4_ezZ3iT!sXsSg@ zEpawCE7tVE3KNA+8QNE>pDtUq|@Y6j9YtPMHq;;1e3cM7ybZ+p=N< z6T=9t!H-D`mWYJ!&<;c|@GYeV1l^rkiNPBPmAR*rPg?chj0JRF z`F~TBf6+`A53y{;2Nk_lVVy>;BU76HWrZ4|%RbLcIcWvL-g=s8SsS#@EI(+qI$74# z88k=n&ogG}6!xDkpOQC+mkyO}=5?Z>QL&V8^@`v(BwN zUUt)Xb*ZO4a2zwsQlHl;Y450D$QM5rp;OABZjdBGy`rDQ{KGT(+CY<7-aDMVD-N~E;pXE25nb`xBEvrs8zN|8V{+g>to56xhKqoE zFuWPKuKDx_kKw;)fC@;lkG%hnEM#A4Z*?y@VADUWrxb=Bi^8F_aCSdOpJKd(U zY#@g>9iGFWu8l$5go?v>3-@ox+?a!S$UQE9TvZU^;#?qQ8gY@)LSxuF!;n26`ETduh)Y>B#O$*{urRg)UnKjX@#B=@akEA=Snj zJGd1b7 zX7^ApROcC|u~*t9N1;ic!rPIt8L_0_O@}f5W+8VGpin`|rEuA;a+~sk{dVx)5r4eJ zaAvKR<#bv8g*>e-!Ff?x)kI7e0lz+Pg2astC*9ecG3-@gu#`uH&)e1~3vC3k3sI+O z_PCm{D-bAggU-J+54+^!P#QQs^s-fUCnz0Hj4b;ESQ7mlJRYH?D^X6wDXG>A(ZUW= zA|6_3vB-wNVyXfbWT)sIisO<8>uoAdo7DTCekU|fh#*E?$ZW!q836`yDK?%LQL7X| zSG_#1n}gsjTxyvL)CcH2NAMTF!#IcpSq}Vo#Jem}^5M3dwUAVzR2<9e6MhQ`1#j5) zkHnjt9P1FBzjoe13bU4?)DdA7n#uuTsJRYd>pI5kE%Ass(_OaQI=ehYfxN?4@y(&oRo z^FOT#n|+Ak^=5_Z(R?Xg-Fc9Nu*I}|grM!_Q?Mh?qP1w}hCJQv9-?|;#nVS_+=b=b zF`vx>Vj-`<^=-HmM}|(f+B}??s*Q`oaO5^CQthI4ID=8pEw5=6QGRB4z1BHB3wI1H zseuSvnivv+;V~jQri|QvRf|McjfQ~DMhrAb#)V%}A#v%AIFrY;pj()|`=rbka18;0 z`fmq!1qSt%7?(3e0tJ>h=k(Vuk&|3il3;x$@Lq3v)8ZAC!s(GrN_E&Y0=9=-v=*hV z>0LFmTy}(j0+!OS>hL*Y$pM@evlb5J^oU<*)Ew566@x$h5>=l!RqLp+@l=qyI*)-vXu7eN>eO_0KmU z)QoiJDHQrvoLxSyl%G|K*KM-hD4Z;@6J;JD!{CpvH^J%Q1@EVlYu3N2mMxRt%b!i& zWELb1wUX?5{lU@{4+g57{B7QZFu4a8#X;!Gn?BOW<1eb3)MuiLcmc48rT1BGL?!pz z8MFfj>s7Kf;6+ofL(rh1FkH_KNgVwpWTYmuL`&*=I&RWB5wWZ4J-w?4#$L~2QS$v* zW5-~do9-xH%oy5qxM?jR2(chlOi#402pVh^y(Mg%A6YoTxEd|H=5=26c`;>uhqm9J z`yC`_y;8VqA~{a|KrMqhe7*K?+Dm-R(+EGzs8bjW35zih?;`!EFT+@$beRz($$Uf? zvDaEy*~Dgc;>4^(@Lwv9%c2ys&Pib-WY^*|!r|D-G_zthEwO zklpkzS7J>LPeIxCRm)x{$@Ug|WGL=}49?eV@;F)i)Zo*cGpK5W0KoPt>3Dol3aUJ) zt#8Bp14CM01lbEMmfgReJ=?zMXHu;&RmlOQOIeI zn-O`WoFR&QR?5qwH~t>?+PH(gFrHaH);bz50JFD7I#)j$90HZk&V#)3Xq>SNGtA8l zaP_Lpr*~R}#dj|z$5l16Q1Q6@4vi#X=w_DV%7cm)3|}>~UzQRFQ}|YoVjY=PzKaT2 zG_Ixifn67OLbd2uE@3!R@oBkWp`^^kVB-LDfwq{M1nVszpqqXH`#Y@v9sU&gyv2OF zcKYOd@5gj6^OhtD4!h@QSq6p6zJ=`Z&sC<MCT38R-IjB zRQtY6Wrz3Er-gtvu<=KUjV0g{VvIuT%=fGS-1N>P3*D6aonVB|7wU;XF?#CTbNNyN z`E;^EPM7b9bEF^@hEZUCHO4!9WSuT5cc;;9hddHnLXfbXfVL>!g#Ekfje=8s-EkWn z>;e64|56cv;cTk540A`S5;E`6_|A_!fp6|ynpPjdSjK(O+l_LOv{br?nJrsS&k>OU zH&m|VUZEug7ye;u#-NZ8A4g9@GXi>x>>Juk&QA;ND!vpbmJ23VnM8^I2K#k?Q$Sq> z6?4`I%^dHGxvJ%Uf?_&vT5bN^iyL=%2`ZWN0OqJCn-F1Zp*Y-)g)z*a84HI_#4nqo zU@T+0f$*v4M>IM1Ha>I-tl{3BozCXo{jFH7^F3yjp(Hr!tBuY^s~xwp=h$=U7S-oZ z5}(U0S@Mjq4dW_8@+mgY>uA|mRgly)Mximfds^J5TX@vDKYgO~g8%w`g1N0&XkH^* zYk97SR$-tq7;>|mO^VL*S+pdyjR5_p@ESYhcZyFv-4=fjBR0{2NwV2$q`0EO%5GSp zhCJVjT!SBTUv`I`11AdHKfJlW)`RtqeZug9U!qcT4p%;EbYEGqVQLV;**x1MHlGShuYu_`maE5cxKD_}iEh?mmN zv#6asFhqDhhll$#V=}M(aA)Zpe5c7zUdlvE9C^WEU2zH42p16iL+Ss9on(h0+2flM z);}UfgP}LAg26m|`jxKzQ;4X}u9l}04-C3dWFq1;1|M+&w{`+`q2AK8-O}WOjG`_j za`B54`#~b1>h?0&ErUdq&UL`<9h&e@UxW5WI zM&+ke8qZgDN00{Z+$TXjnl zclX;5PAoL)iFYpLBH-BY^;TUZh0Y_XKZ!FD%EGF~3bZxqtV?z^?<|UIFyN}F-AZ7l zmDipv&vk#?;xo4~`H`((Tk9~hHSYaA4k-Kd8)P@@q)|ZUs3{U`-~n9KAS(FI{jGWh z$&@L&_j$5oB5S3BkKe+tb&fZAUrHHftD6wK>^l$l8k`- zd8+uVzy}N231f{YRE)1kff!XE{H6V(ge+qH^qpx17H=aK2P;9-mL}JN4)*PpmsCD)7~= zWoN!TWZ0_W5h5p2R7_O3C6BR4vz|H~;Yuds5>=jT%zs$_-bKnriwSaX+AP;TuuA#4$2k7dH)2+8UNeqv=Z6?wj8jrwH0{0+Lf!& zA%qW=Nus*ePlsC>s ztBm+*&zId5JHF;G9Uni*m(+R9$j=t*x7Jlm<1Tn@MprkGtP}vjD%%5g2c^=~iiJEe z3$R2|VPo4dBX+gZA|La&WMpscj*)3MOGar2J@=+N#Od*#0qgrhi7Cj5MaN$j)##jL zJA6DRF_G`&;4(V|><&O^GCi3aQob1Re=z_gk{ar4 z!n1+DOZtDK%24rl+Rkm;b@Bh;?>`Lj1fUJ*LG)Kh>2D${rHlnVWsk@xa7aJT9aC&cXiEE55TZOI;!xN6eLxyW%_ovnex6q27+06l&i$bIuxz93K`bjtsbE23%# zOBVxfb4-h+hR6MGV?WD#ZlKHvu@s~_IT_38Y$dce7zr4uP!?(-obTGio*x{XV$~ZL zmwfyJah=50FCv(xab#`Q55jITonU=*&<4F&3dmEi=V&sb;DrkfG~U3LJlif+u^0P? zV7NRTFvVQl!Q(u>ad*BR&FOfgyHKUqbl&l7v)Kdr^7eeb1X*-Sl1yt*QDB91-|wUK zCD-M?G6ZJ&4Md8~RXL`(~1(5+-U=n!CO!{K%GF86+&0rxUO*N8vo*16O*qxt@83gatB z(+_v9COe8GO6Q&%xyWks#eG#2-279OVAmD+GM!Gs%*@Opoz6G9gN2`qxfweT5P3%6m&=&CQ4k?%H-tg9 z+d)4uda8AY!TN@Hw|B{aUinAvS{&_Ec9h#4ul8BBr@jKR0^>``pHu2_3du8!hu7I@^@o)etJ)xN`!;vm5%lWei~hp^^x&s7N?D3Z%%bVE3ze---W zzcuryXa}61^5X&jg?zTZmq)w1ssJ_o^%X=imobBr>CCw-IkdezNJ=n^_3grP4JY0h zJ~m@>N`{R~c$UbUo;Tjh zmiO@U%j@y3SfT2R0}{~&46gk+E*{4n9$J5y(36`z_a=|>KcF7_bL)*db#K3>k$fM* z?E45(-5CTX72K^Nl;a-J?UPVht>16_LG5Q0Q?}B2tjR_`WkJ&0q4R44jUrF{i8a-wHFk9!fPYNZ*D@?ausKV7{jwt!Ycq+~Ao{hXfvi6FUX0ZyO;VR zy%Z0IwGq)~z9cj4GnlvJ6Fi(zMW1_ZmS8>ZnmmzThr(WNEk~!V(O0qtBEVNmoM?$s z%8lmlm~-VC;opXoCq4Di%Co|4_ieoQHyMRmIssxkG_Qp4-%UHIyw(2FJV(}Du7aQG>Ku(Uh|9~hg zytUE&y3lM&P7#q2Oa-%kN3bHchsxF6S(|76;T4|7S(x}xAMCDDD}aIgLq<%w-w3s- z4f`JLQb1E)c*tTc)b&?+U6Wa4W&5DbgIUZ|#gbrG`)ax`h%mO#)h2hzS0A}$>h6+0 zbyW%SN3UHmw;+qA>T+KPe2aji{qconp{-WkUJ`p!nw|urHu)5~geN=0_%?mKijN*t z3`S0s#h6gJ@^f(MY1>4u0>Zbl^$7bQX%llV>b1jl-u|}6^=@UHw8=G;An~IXcpKaA zK22TAT?Bsy4jY{=ljU{6^QJF4l`KDGn35b*7#6Ln&%Cxf-FgFtA}zB%52h%hZoA8q zV^3E{`{sFt6BsX#_J9wBYgHOIE&aDr#_ZLYPbS+&l-=JG<5j=*Q~EI)#C;+N?50Fk z2;!!#HQ1R$0z9~?JGcna5)G6F6P8r&B!9b-vpA6?1x%&Jfy-xw1gzZUMQ`a>=Sco|R;j}<(Zu;W&<3o>JW ztJ@H}^CPUmo;2GoX@mR_U^KyeG$>ay*UkP90m0w$e{O{?7vpq8!6Sp!zD~=Kw&Cd_ngg>T&poV1YFyH616b z!r4}eEMmQt8(~Sm*f-j-}S$dCS%}%qy_JD}HpUV=&DqyC$-*Ily-G^z0bVf+}(9(Fa&OQ!2Pb% zg0sdAmIQ@ODC>G62(H#0L)2_{qni(1;#NeO4uae(*@pVxC-0)3Xncz11Nc*%ez=s8_gOC5A_CwF1}&jYO__=qcYp`)=0 zsT?87-^;Da04ZDgx5g}F9Tx)f5(e0QJUs?GK!q1Rs9oz(^ErctlcY)soO?Z=V>of7 zo@PDLBA0R9?RYq#4)uXQ#uKuzEUp5m!AG=s%k6s4e^CY>kqjCHMSn}m@hv}0`3sh);*9-^X!^p{y(f{9hmMzw-$;=;+y9JRgxUaoqg{wn|K{WeFb8(bY!Z z@N~R75C-KSwFqr{&u%vt(o#U#aW~e#fK>7x2dDR8sj*|%py>qS4-SR<9qRM5{aDo8 zR%G_#N%XDoZ2eg}Xn3+v2M1kKvXLdo)id}e`~)o>c;26^L1HTlmKqadwCd(~*;8w| zalRL0fB)rO_~M=m89y^*L|&uV&d|Z)`7@ML37o%~VROOrM4<$o1=1T4YbKzJPuNi; z@KleDh1H-(jP}tAi8Wm`FcthvsS@;UatfvMgxzXyFU%Mi2wn7Zc>QeivH2v#fp9dh4si z%LgxqHBVPuEag~RWKy@bs3fAY0h{;qtUOpQvh$9S4{ilm@|2YW3qEP5q}G*XKlx38 zC1*{{E09sXz<~KJ7~-81u&OmmJjjpKiST~?AUJ?FJyIsH_1kBA%4{3{+Y(gyogNVE z|Lz4a))vq%f`;P%?S#uslgTcrGWiQr6_W!rV!@)`*oLrtd}K_vVTz;ZxzL+wrwdH! z+>4d%?MTcG#+1&lpS4r#{|zN5m~PI4AFQ2XO3jD z=}xHD9gnp;+<;HXU*fBeg0reOMa^l6c98@t%sI_WUC6b-7T6eBw--O$WYr78A(jHt z34Bv>gD(})0KX*N9$Oy0V!q0A24qxY+JN~ z;{|AYK07ys{uqaJ9^&~AmU2$!H!-1NwO)&`-5%)f?-#=J0M2mD$;koEaaCn!SCxYL zv@sPSCN^Od{Oj#mBeJWsNQ-}@r*wARTsvvJ@G-H?d*p{sY0hlESS>6@{UMDI*GCx| zE^clI^cu5uaUxevLr7pP`|m{C7OM?1dN#i)hE17nbZk4JKyonN#u7_tt|-_J^!AtP4V`kiX?i$5XitA6B*Tp< zo&6X;VH;k@F4T`!*NQ5VXYyEgi^Zz%uAg~%%`lqlj%1!64C}R2-z$btQuGm#`4$VI z2kMfW3l~2<+{K?)vk!zs^Q}iZO)qawgy~3vZXMlT4w*;yia>ea$x>;tXxsy-HjsKTzEZewt`^+VacA6$=cD!A ze&I749z6g~=A~W&&xL9f6mmSHp{%}%w>)4s`+Ra&E_}gAYPLj4!l~_<0KpC3@>6C% z*sjoLB1#Q7Y%D8~oRxIWp~%UCfkT-qdM<4YE6sy+mhi}L5OK(2 zD_4)*aaUlbTH@=$eQ6cxlb=&|+PcBPd^@?klc)c#JB`7?!I{mpQJ(Skc)gBp@K11I z-ph|u+wTds7N4VgVL(?ih4zDY-gc=M*_+Kty@0lfWfP*6$#Of^5=#+9`4r027;2wN zdGJjywB-B`^UgY|4Sba?!D^Rq6fJd-_3~we&NYUg?%=oPh?xIY558P)8Sx61D zOZg+l8-glrlRViJB;MK~&ifGCWhcdpO?U$`UN~>Fr?IGyOlz*rh~K?J5{3_WtXPGe zZ+!(JDf6rzF|9@L6NX5qoW~j-=5H=tMo0Fn!5Bn{O$Ho1@F)lr?*=Z0&a*qy#~XIs zy&kDbygJldT`-;SSA7o@vxVM^Q&`*DUuJVro;$Mqe}sGaDw^*TJ+FPMt0eSq)OiIZ zcw59?@3@T}{i^Mk8jRS}U5WQ4r(>`bZhVU|iyZA5b3=eLzU3o%y z6XreMmnUfwVK=EAE)ub#>00TVN~=$XF^g3dNgGvbLwwJT2_V%=cKoeQPZ0`Sr_QHx z&yuyk3I`&FVvuSxBNd!gXkI^Dv-S`9Rc{78QK{e+027kSet(f1+s_mPj-I1Tg$x5* zE@o{WvdoYZ<}=AirpD6x>p$Z~S!VbJuOAx;YBEFN9gePwIKTkqPejM$K}Z;9Do8H6=|wSZvkjRQo5)W6c^MDiZ+ z*nDiMzxsC4fuPzpYf(<5zaKW2u_XQ#xtS?bYNA+(XF=-$IA%!PN427DxpsW0dTj|< zO+YjkP2Dqv*a0e)w+fkOSh(HDKz4z58ZhX_7ii+teRfNJl!@u%H^(k z?L$$fA3?Xy2Ah3r1RNH!=LQIVs&ns#Z~J5)i@NVlQ)|Q7f0eU&OgNA>F07f7k{N_Ynf?i;hSO9N(7_dKJXGc-kR~)r;r}P}$qa zTc;X*_aGj)tqkAbn68%DEmp`#U-fkv|C!QhgSo_Y+U3pw;7^!M$HH(yz~#VQr(hZE z+#eq-&G5|*&8_CSkH&$_TIZgbsoFkx8h}o@HDe3#v}1+tTq@VPUo?DtqpE5!h{9hQ2_kA8ZOkQUCUJxZ%HVPgZpj! zc0NPEg2zGx+MRnozt~X;^caxB>I(8gF7&lSNQkCh9wSq!{zJ8?g1pX-#+O>aVah{mpCdFB4-FCo&mrrJC+_*v12qEz1d z0!}pZ71HUP!%yUxHI5rs{*WMKpU6{m`JC5*O5T`ZV9nFkrt$r?W&h%s5p|V_c~@hO za|7!?#WTGw!{OkX>pp>G8Y_aHp5DXrd1pOiG00`>c?X=1&N{aNb0%yrR53xL4T<_l zI>bX;uo&a`2G6SuSUpD#;%B4c@u@ga266<9WgfrF5zL%HSNDZwm3}^L0_aN0mYgV0 zsFQY{{KQ9@bFzLyGsK=?gI4qQ2d@Xvs2eu|ji~(Wj5)mTduQVh%6?P5`$%6EWFBRC zbR&Q*5g^y!2?OuOA5t{L^xOOZ4E6!Di!HD2^qHpRM$dC1=h3Vi7A+C^${KB)6?B;g z)bn)lVjeHl;O<-BYa&MDVy9dtJVpu1BDav< zU0JBnWCrtX%ka^DXCs#>F3dXbid?hc zv>!h|1cCKY_x3e`M(6z8f9+>i4xK7~FcT1Z_aW)t)BGt1gIlV*ck8X=nej%KIx~$#RVg3O#1khvnImn!l5Nc<|BXC^y-DMg7HuWt$?|yahnH>SR7h&PU+Lf zq-%38&jm=Wq~ea4YO(dB@qWdjU$U{I>2VcfQh@x}+FDWbsjPTB&D)d){A+&uAB%Vd zflP-o*}fhWW1kia(x;LVP|Rp&ZL*Y}%ikd@TO-qAvkXL z@>4q1G+4ZC{TY{W#+F?T*7x|38HjjgPI+LNPk!>8oaH59lY(~Z&{pw^8kwZWMmOw{ zxE9Cn#@D2&5VQ*?%}(@0W0T!^2BKw53J?5bSDv&2get3KndhV$Jr}HDbDcwz74E%t*|zH;9L}KAxZiS~yqq+AzK#ICFmOLHuAkZ{}a zDxfc=$y`5m?R^$Y@c^@Ci0mr!^|AqQN0Vpma!-^`AXd~)mx}RxBvVt*=m*3vV$aPm zgR@ibh(tUJc*>SV$?zWd-qrhtOm}ruq5V3TkiI$?rWb>`1dJjqd@A*lx$O#me4R}_ zX)WG5h^n{>fBYcEfl^}!o;vG+t96WnjD|_gks6NuG0CKm5V`}ED0htZv*k!s-S;2H zrC)Oj?1Tw7KwW-rl))5O7Z<47mL!cxLLMqTJ}b7F=66-Hu18Hr8!VqegswFRy{c3H zaswfNjutX^R@iwIpRzsS%Pf!VUp&`STx>&a6?_k_wG%Dvf|fL{Rw4R|xAjhO6IWGP zOoqQ4uHbaL+k5Y&5U3@jm?RMy6~EzWK1~aAEBCU%=ql2(ZrtLu=zWs{QAXr3u@ZjB zhN&j3hFjr`cvx7aW0T^(^B+|i8+ULX&!>$KN2Qf^8SJdAm~K(}uX;{y;H_bMGKp6x zG7P3F79@RUBEqnmsgZq2bZD`5tgxM4Owi$LsR2*5t-4e0>pAZ;GqrI)E5RnN(oAECf9-5_|K5M632AD3i^24d!ux9X#n zbH@W5fu)y_aUzZr{xOfy8v}zz!Gq6@tj55Q8LJil}j0 zNCso6o^}Z{rb3Vgxz!$yR9m~ftHpecTdm-xHYTbnBYYNC-?dY6vQ38=*~x3Mxl`*B zo>V=q$E?{CV*=vsPS}=rz$=Z9!4#y545A76=(`ttA4nb0cr)KKJZPQx2Puh!1F17( zLfaSa#&GeI?so$7;uqRpxGbJomK@$-9VZ)pqD7r8_`)H2nJ5pUi&ktCeSD7ApIy>g zVj;tihaQ4_4_m!7g9>s@^&j24xOI+~SoN9xIT(|G9^HPdLj0AN?HQ7J0_@-}Z0CTp zY}Fk#nVx*;%{ouzF}FF`?B|8ODCwsNlr;Q@f0y(oQ1~{nyDp?Y5<61&_W`wNG$x(8 ziJ&4!STe0zoN*IWmXQgK0`XevE1k5MTVpMB=isPrXx#9i*sw#gX!Cp0GnN-F=s>_90$v1ZkE9 zQcX$FKRGL%CG*ha^b7wLvs3^_N1Gh)5WhDI_n`8q58t6{9eksVyp@gmwCl z5t}|0xk1@(zpNkhr)~R+u(Kr`BTgPc5G&RKGt4w-+}xmekW1$G*8t0%pzVWv3^IYL z!7@l%J^6j#znKItZ4VU^4tBOQnQH{O(N%kpecqksLnaMnAkmksb=Gc%tiG8hSn`6t2Wgui?R2L_BD`@N?C@-@ zekp}-yIDfRUfM=gu82SFZ>=}TSkcgv?MzBi8`&c^fus9?*NWGDH5H!54N+;nKRD4P zwYmfAVz*E$s`ErJN5>MWlo=OH_jy5*u#v;B8HOLy#b~QqWZL?zHh?H+h8%s zQq|2NZO#$nJj$gbgNwQ^Yo=i~xg{e@M0bE~p^X%KvOprxdD(cf-8Z#6ot|UWhk9)- zhd z)md3YGsnO&E(4)xloN$ASsucNftPSPLN5o=7vtJobJRS4hcA79$ zZ;OtIO74400p4F2h)nC(+9@~tebV`BN@x#W-spvYw2%}Qn@mJ0>C6;b-VSwoC8HGK za6|jM(Z-h#n>+&xc$2NGw_-ZU`!h}^!n`6Hh!3glu%=~LPAR?oB3HAc#?3ur5{+5Yu3*lMtRxu_@$g!7o zpe6IZ;B*Z?FghaImO_q{VdG;zE3Uq`-qvnNO3tBV1hUXAI|QOqsKIBwW}jk4BIwm! zLz*mCC}$sWrZ_ldF+Y``(xBrvQ=hgOd`Sm~U1*jWdE@Y?YN7&)K~rQ+T3}~)JgAEE zh&<5oQq(6l9cx*uW3BS=<-G%rui&CxWkz*pS-U?hX&o8e%cr3+Wc&Z)?k%J0O15_4 z;O;H~0tpb@-Q6J}xO;GSciCugNrD6kuEAY{ySoQ>m%HfndphYpJ?{O+`|tb39pbTMe zt{MdlY^UUVm^KqL^U8Lfv3*ilhrDjwohdSMpl1;K>dA)8v=Y_!7MQ(B7or3+NAD2i zrYYTQWO!fV#%UE972X%NH72ILY+`8$`=!W9$IS;C1Vw^vPt_euWq6KeS?Yp;`KjzK zQ~Ig*cqvMKZ7LBQH4oGQDz5W)%UsL4!z2w&MF^901ZlC(wMTM>=)Dn3L%R-Yo6es( z?V+h6q@E~tazvDd6hpOdr0=tR#_Ond9-+?b+|Kjw2;-1dRHl;O78$qY@h3ExY3Do6 zCEirXHbyiRTkl$)uT7MvPq7vNB0%r27Xa}tj;e^b>YZc$G+ohdCG zH=meq9%x=fW3T7mJsg{MW)apVu-Pf(ue*LHCUXVmVL2!{|20J{=qOcS*1kpOo{*c7DpR+9cd?uLb!#+laSvH7QI8!tTdgN`<%J zqF2pzU*rb7{uq;VRmil4;|ByJ#`S_MCU*`r(A1sPgr$-U;S?Nek=a4E(&M813NfSk z(YdLs?&5*}NpCGzv+{F>oX!EL0&|uFKjp#5+0UOh&Q!6d_LdjQu)-&-4L9x799CI> zwT2)OZz74c)8fiAqFj&g?9n%MZK#0QXrjArfN$pgsMVe)W6BF^B5~I9*O}_*B?n zp2`x0wi3cEMr~X0q2|^Lo|M?e%%iT$vqkv#!#5-DH}I~uzl2FlN;t47uBP|&^Z=|y z3(x?UXr05>p4gH$ykOnEN2V`JcB|@PE>%i?AfN013cU)bVJTcIRa9)iVV4`$)t(o)P8F3nFXq0}cuu<+n>UDS?Oxx%s&Ji|iA{`h9?Z~yt{ToQnT z<>2CK8yb>NmGwB=e)A$AwE7_HrKeFo5jdQ+t^@W)6NBfl#4Jfq!%*dLmHAk zD!MbmQ{&Kf4c1hqaivyW@tT)fD84L=EH~AwO2&&~RrgL-jxVP=-^HeHOX0Px-L-b1 z#&B1ri#?t_^KQRCdiGcSU%04+!>AFo3x|DA8IuOSP56@VrAKzG*?1(@AP$1FhJBOWdI6*-{{7&W}9z~W9X4>ybY1@(0 z+DIPFdct`FU|lku56wPoaUgD0%(X1g!J*YNFVB93(|mbGP-O|lr-nwAVf30cWi>ld zC!jes>_*Ck-H4hm6(cMp53>r#%eS2&bEq8(#%teFsO^I7(OlvtR2GCo#=)b!J2a8| zJgAxD86RB^rM>#R9HeJCBwEgx=@tRqRv+^c=MR$2A#I1zS2Mo;h+aqV!+0VR**ZQ zHCuvhO8C@B9_rz;Tj8p|AVkQa5lCaRRE1JwMva+4q?b(#(ObAjExVTP*Ti^z?g}#V zG+T1oo2kIycFvz7+JobTjVEctES7DrOSYEwx#9s2ZU|r`yJ>qxg=ydAZN;TCN+gp4 zY%_SBrHpIq9mld+up zrdnZ)w7R-_)P_Kx4$x*?X?9rSYdh*u)*OuSBZEH)M>u)e*nK6;9fOT=q1WIr>8yqHrTQ zE}sFsoHi3C&zu@D(Z9L1P>KO;2M}7UmgM~8n41$Ngv*8tp7zsqP^k;RzQ_1xP~*JH2#UrsD*-FIi4=jLXIIqtSK|fCCnFa&lEewcj_&R(VE4i0a-al!i*g2t zA|oRw-*TUW$i(|G=4L6s;{1wtD=E5e)+ zoEXW>YIQ)a?Nju9p1Lq;d+Ruj+Y@Y>{eK+ zUP*~-Msf$=a-(#c_@_lDU`8DcfXWQdd0cL2MNFIvV5_q!ivL9hkbVV~6kBs8JCmiB zE*y-^;dRI1^>A%cM6NbU1(1W==QK`4*ERB2TD#C+CFnZ<2$hA9$Z`{6iw^Er7vDCpILv_Uz|JCEjTlcOX~y}j26%}983Zp_uTE+zvexE zDJTF=cydSQtM6VXuslaC5V$-p3<-2v@T>(_ch{$-)z#urM7$@J6Vy9Z@1X;N(j^PT z+~P${cQYd>PzmQSy{g`kC}o?u;6b3Tw^~bYw#4b0cTa^Vikxlicpg2fytZR96&sCMtI_PUgD+WYPIO_(<#ddhL zxMoYk(4>KKhnr<{fxNSf$5*nwbw&r|x3N%TGDg*G!XwUG)T;LzTkrYZ5xF`C+s%I1 zSAA7s+2@4@W5@r_M%)MWFWat_7|G#S`^km=PiGP455Fpxq-C?;{oyPE_3fX|BEsO^ zk*TM zl3;JwOJA85QOWY1*4BTkFClFpd1l5Y&RoG-{}(*{mxRT46$@|{3CE75(rIc)K%W>K zl+#F{R*}=luK*CVmkD{l36RXLD{S*1vwyM_-&R1L|Gz4bWb`%AtZ?cD{GpN3wO_s2 zBm79?8hpB|1EmeBz+C(cPqcJ(y+`;eL@UkTQ0KqTAIussFxI(*|DN;yG*tS__~Y?& zqq}dx^~2+{2cg%J)b{3z#ThyStSl@REQtKa7$??jH}wxerL{Vf7Ib}f$!+xCCUpqB z=-N@FCuw9lm=aMlg5LA9YNa`pKW?4!ZfWEys)fXB6Gz!MRS#{Q-g|uBN%`oD^S{$(_Pg+)~gq?%Y)o&O6rLaZk@LioGY)6sw7>;oAs zgGB%3Z`tH;Igc+q4Ip}f@;d%s7#c!785)8&Uo|-V`*!NER{gAH$BOzAT6c34vSXn4Xk0gt%S9)c(_-h_e1k*tRegLb^O~Ee_{4_`NB&z_A%J{&AEl zd+*CY8|#Lf6`8oV)>d=!GkJOWhUa#(%Tj$^yM_!T|HU*->LoDqLX%WOfsua?US^&{ z!XV~6LAU5Nect}2V=t;TBZMK`c}yH+0AA`KKRT9+_LVljz~nW8DP4X_e%5QnzFaYb zYj|ag#qfc-smzG%gAcT3<8PFACQ2!F6>_w5!O8wkkOd}Sx{Gh_MRMJh;t4D;@V-oR z(RQd_t+u@+mN)e6DUw>J&=yt;h-46xs~|WfaL#ALYw1)830nD_nkOQa6^0L2h|l zYIZqdk*gcsz2>ySwD#I9l5hfELZ}q852Z$ys7;HWguN0wtiHGDWiBQH=1DkHWl0#G zhTZJb<*bAzXKN)>U!ZQH@Pk|TJJ0bFmIn2Pf{I`t>XMEVhgS{sM(Ubz)Q!d3ycdjL zvK@D#m8UN(oJ#x-f23@4T4CQzZ+)iab3<{0h9RS(Ln|#4zZC?I z_|rsJrz#xHDF0(({L3Qv6fcmCp4L+uS!dPo&g5m^eGp3Rl8eRyMPes!{&v*&?BhB_ zi#{j2W=)k{T>1$vIY1cDZSf}RX+seRg*3F9vmyf*)grjsp9S%K3-tb+^IZaiy}ZxY zl$AhLBS%+s$O{&b-cE-kcYWP~X5ixhC)3a%bBbX1IBPXABC!*)(`F^0nXAXqGRFao zD9do?I$>@?2kLU5tK3mLphHuN?V4_h@>JAmsB~fH8 zO1)-aa4DGwNq#h2+Y2hg&5$ceIER0y*!`G-21I@w~Os)r0 z=R)^RET08q@hkMAPu3CagzNgm2WoYZ{+$TC74U^OkY9v@K|VInY;MlNJ}2B@?uFsg zL2{%YQY#Ixd9bBWTCp zqQasge(d0(F!5p+o?-fpx?^E;1#vBQtE_K{eXB&?xP-C0JHnGHQxnr^uh43{m>ps(v9d5}9NiX6svoehUWZs}>WpFGemzz)_p2DOe>S6aG zp`k4fp6X!>;w4n~$;wKxXwxn1?-S`fJB|5YO;QPm+Nwy~sBqmrz=m>!-IJb+iu4dp zQ}cGW&>w}AI~`SAI)dqoTL;{ul69f^YYDD79?u`SUNL>kqv=(DN%4AMWtG~vziYee zuz=WmJE5JShBq}&_3nyzLFjCA(?5M+uFP4Mw|^8Sg!jHJuB@AI0cwHcZJCXFQqo~z zYjL)j&omTuH8DE&t?m0MshVxNl@~+gHs^$2xQVJ!5??xZz1ka-#m6BCM5%rBG+AX=ck;Umv(`%jH^`j2b!$j#|VMHPgt}FuGG8a?e zw1>A|CeWvNusW1TXhX>)F^Ry@fpjRAs~!$+Ae0a$`m9@7|B+m@=uqh1)n1ekzzyAL z!u7nqe*OA&9^k6W6!BPF$D!SCPd}XQ2xA+;gzk#Ru2^M5MY_-tgJrIixre%A}CZa!#FJ8O>+haE0A-tYaAO z2%dIc-pxsF0ThC%*w(ggtbY{6H!%CIG)<^1y)>_;p3&P#jA0FOg^WE#^ z9$stLaouqA*B z8BC$la-aLSQ^0H+b8fCCYH37|%nJ7tu?SmPv6WYmmogN*ty$VjovzcPx41=C+1XS= zy>+&n_q`qTWX$u&PwC~pfu)||xEL^g&+U%A_lV}EJ5f%pt2U+1oET=5RhiZGy*SkHzo?_W(cI8IeCJ_+w_rEET9KW^Ek6&g_Cx@FQx-G0+sI zRtG=U_k3p*6|N|3P-oJ$P<`uNZ&jn#Uk9h@28zSr_D93vA+Cs18G+K`s?Q8cihGWQ z5nfy2Ap0C9^c_a$*@x=-o~sgAgT8*ne=3}O?MMOj^~QQ26-s+}R8%)mRI>#XwJcWi zxDe2YJ{1+rbUIDp>FFfLRhMg>I$~DnY%CZ7y>drxY-nJhkxB3LXgai{q>Q_|$ehj^IbZmh24pT_cX#i=goySMwyd7%BDnQ-xjc!P4$HyJ zblcz{@;A00uqXIn#KgoNPG?Rlv4(euKp|mRm`Azm{cxcp1tRV>*d*QQ_*A2R{SGHW zHQVgz6}D_$L&hOB;hSUi>9Df7nORa^E?6?Lo@1DmZ><1+PaFW+sTtKTGutuFD#L6v zy$?&}^R~^WYYYSt?8(xM^vVV&=JiecK8WU|P)?g@u1jv>(Mhz?{yl(D20vASk##D| zISgHcdjRKw*0wfq#T*&Ik6ey%GaZd?=R(TL*nGEp%ofw7f}98IFYU7Nf4e8%Mg0hspDXeC81GSO&?m*9?%ppaGz6hg=I`axg2sBAt zutB2;4gEfp!`+$-O0qSN^is?*2%SP_^bt6-$2yDMDXs6b9C}}>6iKGI zE;6ZDCrcb`Oup&zgI9+J4~^vDyc$DWD-#pc*GBpn&-lr}g2m=^^D4L=Nx#jJR&!pR znlo{KuFs-#b-g_AE4hP)>ub8RR0gG3j&7*TU}=&r7;#<0H$OlV(Er8EqPO&!31HBH zlgop&-w}$@GdS1<{D^;M>*B%z4i1irjh%qQ!G?u4ktH65-9*C(a!2EqVo! zn@~?dVgo?d&}&fVO}Tp5YybTN=qf}(W{Q}A`G>*Y?JH5|o~!FYdR0zO#GI0F)T{=O zf5wBWr(6n7KrA~#z(}};eZ36)w7$iANbK7h>aP^*sHYGe7f0AX2%D0|CGlJ~sl85z#%w0!R{|CgLqA%t97^e0EPt>N)+iep zeH&3d;c2HfFFXhl-@QKSqUCj_67}7&D)RVk(b`L^Og4>i1J^Kc0H+dlbK`oF;`cl| z21M?sgBP>rC9QpZ2%o(lIMFdMUhFF8e`yL7sVd}noz|G}g5t?%HJUwBLqd&EVPm>!8Mthsyq$kr|5L-*JYtuAfT6sVwwqm_mU1qtDjW%c=!Rfo^(-W^+vq7_0 zkJu$J#l>;C?-tN)KJ2Xi?1obS58MyZ+`yPt?^b@H^-#hHzVJUmdb z3e)0JC#quG&!=j!RH3`u=o)?4%;q%b3d*;CpL$WeN|Y-!Q@?07oaIXnD7liHRabHD zEnK1TmwAyo32X~RjY`+|%O_|OEO-)jypPP6czm6&KOKYp(wX{-+Hg6iIy_h8bo5s3U8E)A+*z=xoq@B5A zx^Lm$#4*tM7oNF#PR)s@7pgN=qTOQLsLA873*1%jwtj3pPo%ax(DqozK2~tQsXeVK?mYUJ5H=#ozC3?PDQGTyR#CPO&S|ab$5U3 zL5M2aODiPBk%f4SIYxRW^y2sdAt> z&)2}vD`?-M{m&7LgjME`P%(P`eXMBgTkjq>n5GAm{=WC63)G@7o6Omr+r=-m0>g!w zNT>DIt8cVei<{A2r2sN?cd8@`K>aBp61y!sW%J-6LpIP(@q1}n3mx!U;_WSqhD}z$ z9k80M85zH~6bC-kM8EdFep~wfJUMWqL@$i$B*9ej3YV*`c;1^F#P&bp$pPb3K|=pr_3iSiws~p+3rm< zX45;mHO(Fa?UZcyXIU%Xj|eTqH&^C?*htw38jtx=b+%Q);*-E$L-bZvvD-*>M4sdE zp3dPG;u$A1Oc!Y=*(u}hO^C;xGD9x4)k69Qbm14Fmkxf_2P0t2>9|UTjyPO>7mh1~ zfwqn9eh8A=<2BZ`Y=J~G;{$b?qfjkT)TBBfIJ#Zw?$1`is#ZQDtD6!$OV|i$P}Ag? zTgLo_JpWrsxrOj0iVe@Sj^MWBw?wUNj5Mk0sZI&9jXdBA)?|49kKeUJ>Thb*x5@^k9*2XE3s<$V3F_| z>1vfYk!eo-g(hDKO}!EH!-)5^Ok)8r)>Yw)A4{S8a6Cd_M{x94?U|TO%uwAJ~}@a6%cqPBqVfnKBd#XxJbnG+C^D8LFOpp zwPMZ_ol(>-iFGGX-sD|--G-iOJqK4wq<_t^=H<9nNL=y@k(e_5dn$bA0|@h&p%;eN zTcwDqehJWOuM8JRO^8#Ivl2KiQVV@jjz00@;YlZuSzHSlq4e*suy*4hmmiY=?w06N z6`rj6)w{^m7fCc$#S~+ThCjM9rTyR3k4Yf%F`;QpmiW)De&7C7L#QG0S;Jv~WUh+2 z$1G!M;NOs@2RmP>SGc}<&m9+Bz79$x+#(U%H6MSglcxi@_q1+AsCR1GC z7@lbMoWhp9Z6=ebj~O)@Y(+6w4zM6chJ%BP<_tBW3IjGeYUWUBrhvkFXOeQh5WE^z zy*Gwms$VwpsDHFzJkUH=E~nfHm{IGM*`PAUc8UAmQadx#GdK{K92j^)?pcXe_TL;8 zo&>vaJ)z;s%F20~wM>R7OoP{iiSmVn=w8?T4$?9N0+H_bWQHNA^WQBb{#<6SpFG`Qep_?DRqp{9$NpE;z(_+NYlMW8Q_a=T&@gVl z)y@IaB@8?vAt6tt2DpO=^Pggf+4eN>VCV*wax3M>j5%-q{H=o5;sfiNE~~2lDMk z)vXKd7BIm08QxpQutTDvRKPpVcc)Bzu1pOo9+~JBYVC{~&PJu*FE((lFCqPoGp0KL zs0y8y{KcQB0VS3%xAQ^E(H)`*qPXE$(~|xLHZFkdq-TL+4Sk!f3P!9b}nYI3!#LaX=rKSAEmCV zKB%jhTY7Iej-R}?VYv6UHeV@ntmIgpNs4G{A__s=7@Ur{zVCmmLbT{zF+*zUuOy62 z752&p-x~Z#J^ttqgR;E5{EV5oLZ}rK&#q<00Dm*e8)H&z;u(^hg+?TvAWR%t#L|(^e!QYh@&(?_ z>??iWDT+M)P4DQY0b+M_o>C(fZ;xh=on;K4n+rMLWH$ zM=6cXs~n`SfeOtdS5!^Ni+KCK0lW{Y5BDV!e09iPb3-i#eiS<(7nk|OEK#2;KiG%j zjygxs%9~q6WtwSU79f!dPvxlKg@xG-@91rb8qQtBgBYVW;Za;Y~Av-b$#&?^|I z30!9mwiOt|0zp794&^c<2jA$8sx;%iHr5}5v_|VG3N;0(=+DSlYt9`+#ej)N>@>H|TWVAkW$^7V3Ep8uQ zN`N}!9HVV1fARZ`D>}0Kw&(u#>&sJTIx(*<43{3TOhz$8e6Gq@QEOb#Y4q9j8l2N% zGb(sweF3K=ELd2TVokjJn{)rKuX8{0%Q|2DjMjfH6=5LcA+gr{Y{=&w;{*L&@@sGH zt^8$Bk>;2&$*WgL-lsQ+^-lY3v$p%XnFDPBx&+zrHRYNnk; zyq$4K7~!SK-E=!+QZs`rFoHX3Tns}6GH5uR<;WOWksqrSbfQ<9m2=`E3x;AI_qbH-i8VPhAS7xNFma2Nl!bAZnzsQ!aQj_P(_HtOOZ~#4* zL2>y4`X@4&kbN4%joxCznkwc=&mDgNDfXF*hDy4GjxJqGLbpj^H5}0n6)8;;xa;a4 z*&c1fzIc9X@U5D8^wQ$3-$0%E2`2{s_n86f{&v3?R;2en)E+9HkhYR_Z$+KE@IU72 zos#OD<#}W2gm~TdYXn#8SO+H*d!nIoDUS2{Kx`TIXe8v<1s+#C=r<-7y&)u=J?{*Q z=M%Vby6Z{Z$1{(=ahfXD($mjOZCwJ+D^GSv<3p{;zi*AJP`!tjrl4kWqCe=`zIK%9 z#zaNd1qfiqX=+i6d>Pj&`fL6nTE%rNVEQF<_RC=EJSk#MolltGkUR!zPBicZ_k@tauB& z{&%2@9qZ@Tpja6HBq}0qPjC7ljVe7e$7jCmM9Eq zu}11^@Ubl-m+fHF%_{j&5S6uSA-doe@uqt@7xc|DCAN>sQ6x;adBxTqhzYfWiF>l>(Di z_lU808-D)Duou`AU2eeD4NZ0i?PxCW#ke?I%T29ih;RQk}! z8fK~W>cf{X;sL^{DecMNPULKLtX_u=BY|?Y*0x%N1OcJV-kdPIzuMVsaV`OBRfX=$ z(R8b1EmjOc^)tb3FC8@K$#@-kk6=(vkw~!)k`sL#*ed?2D$}OiBfQ3B6YT|s!AFTV zwk<}4LMmtCyfIOn*Y(Lq+8Sp{RE(NhG_@h{q$H^x5GSQl-q+KDW#?4*Azb5B5&?o$OO@Cwbv(M1)JiJ zoyy?)^v0P+(w^d^w1Ib3!rBUId)OG!@tL*g)Wt-uU^X*sbdqGoGP_-0?rGoDCg97s zr{lQSA*iURuvpHhB;oBEsiG&dyWR>C~HCZ#DF6XW|*jXvgfZMl?R;cy(OlP^2>-umbCmWWkx;AAxm zrWPlh5aQq=*(zth{1~c~(3Lw*Ojm&rTny1UeWB~sVx@Uai`4vSI>_1N%3v(py{6oq zQ8}cKsM?BIXet8ZcvwLuvDUBZ@~FOn6pdrbjLAl{(sfDaW?n!)g-9(xb%Ssr7;C|n z)BN(;qE3S>r?*F34c-blTEf98JmTgBcopHw3({)p9J;O%>hD*yoX7-=pTn>o%y89s z6&))NW>g)11p6F%wU(&J=^k&3?g+-ja(F8hCv!JsoDpz2A~)S`z<($q?9|ucN85H3 zyEHywhh+-6XO8Tk*o_=WV4gr2WI@J%e34mv7qsNX3-%aF-Elm)-NHQu7o4j@Lqnr$ zO+VUL`pXOkUJhycFo%BT4?HrUM3@4>e4elvLiF|)VFqNdrau8!^^#+CKzPE zx*bo?4%K20uht0>hzWM-cpUVTH?g-s!mjWUwY{_ru1dl*qnKRlAa4HdwSDupAt||# zknRc-2Nq??0nywA%Qw~0bQ+o zcn3Ffo%18XMyzkjnEBxEi5V+ubxp4?;QQ+zU)Zr|zADwk=q$uXM1K?V zyoy14spN5dL<(!%rmNBsVTFR-;+wO-&mklvWHj~J1lt(j=sg?&S z1be`9cmZZDXR1kaNJxkPfK(BkcBmT~8-pHS3@B)QiK2Wn zViukJ5kxgnKn=$wUghRuinzmk~1GrBPO}vQePGaX0c%; z>nD^am%0S>(d%Q*8&O6_NBaO&4uS)>!gGq-`eW5xL$bf(v+tI`|2LvX@YNR|n44@( zcCVfigZ%%;qAP{h1ta##&)UJABRFYiM~}SF@kIkrf!=BvuMD~xQiVpLSb@0Do5YNv z63WR*sx;;&4{?CQ!>8@1Ekb}~C%C+R##j<1u8!8twC;VH$#LO{l0lYAMoSApzByS+ zLuLH6Kppv$tjO>=+9QaaF<1}79*x9v{DZv?WJ2ti=!HQ`UBEuE7m@vV;is2Y0vtrQ z-X{V&F)@aUM@}Q|#Aj|2KB|%RS6-m9?=kjiiqWt1cfslDbIbQYzn@^!TTbbYLOzpx zc1G4F{+W+Mf#H2Ay2lk%$FP@v)hH4(r7+Lfpm$ij*Z*{HxBf(TqXleMdghsdyUl2qKPqaKqhM>w_qZh(Q-9g6gCEn0+mh#tUbo+G^-z92#{tLM-mIr{>ah}auEE1W{h`hIj?ydQl^-N0ieu@qXPxUm>SjEm*Dsj&_G>S=m`!D zo+W2ze=_=&kdX<9h=}-br$LTDuUd}gY{Py*%oVun+$rl{XqKULxrjUbU9eVIueC0! z+3|E*c~7FWy(id^ZU^ySM-pl}aepj_1U ziPybjV4v9*u)Yik2w0x8ZpPBt@FaM$qXvq(jGi}VLjLy~sZnJ-G=$9)HUgIi`y;4{ zXhf?~DAiq9`L{2*nb5sNEY_iVZZXRK^8|iNZxqr=pXiyQUgy%svlZp#rH0?pUr|~Z z=cJ~(1Eu4hO7xSY#Kc4V%>1Bgkj!HUFNtu!D8la-G>sTMkD=}qyFa#K4Acdi_ft*CnZgY2nYx)?}wi? z-<=@jD;I?ge&nWpO#z%w0`XbRS~@$S*sbPU7V4e#tgIq$FAuXs5wcQ6Oqq{T8>0K& zd$16+o>jwhHEj6W^KaR>qq;2cN0w zk?q+YDT@u#2VGn~6z(tF%GIpo4ea=|<|=|tz4X&HX1tl!uZwh-Y#F*D_THHJT5w_l zQ9OMWJ}D_lAm_)|w}H)r`89{&+O=gZ#NKyc|E<3C5T{l53F})mP~KMm&Z-Ge*ZR{q z6~Bwp@b?US<|hW2KGWq!2v5R2_9YJtp!x=M*QB>FnnY|=PbGWFhPX!YT&R0$wsj_h zQ=Q|yO+?)DoSWi!rR{u}{_Zi#T%Q*hew3X?tW5G1q%G z%pd15G9!|7In@)bKFhk*Deift{r|O^wEp#GFJMG_C zj=)cU)JA$Nt=QgXgtwCQ$s0abq_yAsyz(c6 z*CDuknvX6bzA3EctAM-slkGm)H$z8wo@Vfq!+^W`*5A8c#1#VzJbPD7&;52Km^jY7 z`rEg`dbN*;xa1*5!&UqU!R9)*^NMkY$F2UyjuT!5?4socOi#T{^aay$HGnx1xNa- zOt`Q3C;`Qm<<<(F^zokF0!0V3?~Ne99YY8%fmz|YL_dtJQnFl({<0VWhr%%^221a&l`V9h0SA<;lfOVTKHZoQ6a=ixj7S*t%L0*^0H=Utgl(NO|7=#C;5 z7Yn+Iz(j!Rtpy6hZ3bGtci3+MR!Uqm29qV$?>mY@-4n^X*d%aYcF-l3Juh|xMBmhM zu|IbV*pvw8q%p9vDuK6C$bag1xW9dIiqj0p_X7PoW_S79miP%hbBjdyVek;4`wRDm z&A+gdX5Gg?FveG#eVd*`Lzs#bvCM|HQS9ew(!XIx`*#0k53Sq&9Ht}Fsh%j0U^VLg z_F~P9ba<-o^<%mwa{-8awPI5~^tHY)G38*RUph+ru-QB$3wrkl%`V+QXAP}-fOLlK ziq7U(5#xfj&1(rKmSVIfHwtyPRWL!S+W<329TZmfNAm7s2Zo1eK3$q|;&QS$4e?u< z^{L{T{@%A&^l(!fo+m{Bz`$N8%J!)7G3llyB;;6BTg<#omh=F~RXyUsHn#lZN9g$Y zJ3!Ky)gswXsEx6rqg(yRv&V%V9LMc!!~6p$z-y2GtxQNE%H+hz^f^))c7zfEX|%&u zF)Zp~yWfP~w6m=9DaVXnq{mxOGQ^EjrC24b`V!Nj-=H17t*?GybFHc{MkDqTAx1^a z;i8C04}QmD9YWvEoP=@^&!+El+-Zog9Pbrreq_s@a)MFiBt63K%xxlI0) zPvB^YjNadt{C_qq-xIPH7v*c$qTVwJqiC1;8rx`22&3N6t1FjE(=lk_AVgoWtT%S( zeI-(TnjMZik#@rMemF`5WZ1IMev`V+J$8G8+^RT4q#s39=BV`LRcLS>F+fw+ zt!>-1QW14UFa~z?j0hYLNz>niSk(s7D|Z&PI^wI_zv{5(g;L7oZ^D2y+~kABchD_# znsH;Kxb17Al6@2GxNMp)m*ZEX7#IoEcqffI2p#e*VYW>DJNvh2k95+x!R_5$WDkl^ zun7=-{-r4iCS^N#yc7f5e?KAU@wv*-MNAp#YVeXzO>lKdS4GB3xH14lkZTas#lZr| zy7^wY_WHx!&DrfC!3Gu^u8z~BmZ4cC17is?HR#UA$}3(oW>M%%LY_V}0`v7Kzw7sb zL7{JZqrF1kJuIfP>}0-_<&nNM!i^%=_%hRjWi1RM?KYi8nELNq_5-{xU~t;f+Y4`( zX^<@yA9od}nUBIrUP}j(`AkJ@$F$z)ez{(~!H-=(6c#LFcO%dox7&phL(N(OFyQCK zEa?H>`vU_9a5KS$CB+5OWzs*y={ES(!V`mT_c7@ zz&&!?dgVaqE*tSGCr*6=jK9Z`Utx?Xgq}FWe=->VlebVR2tJ8&(&>5V|LqGg(|rIJ zwWLFcU&Ho)rt}}5^#3D~Q_{O-^Z&@bdK+3kayv(V_~6&yF9V1fh>g4&2uXg6U`Gy+ z0LU5uGzZUe+Mmr+tJoalgmW({eZmW;6F=entTr2IEl=rqi<+5H0oq$=b93{gl$1YoN3pMreS1cA^AO-)U`0VB?7o^EIKP<_u*I79L4W%t(k+|iKm zWAvn#--VO#Q}+x3lZ28M%xjj0R`TLcke^Z%ELp#8bQFldnqdp&)W1Fd^fZJXYl7Y( z_!DLb1SlzKpC9B>(jV^sybY;00-GcW6_D!|U~A&9KLu(1Zi(UT>vM;vgY9^2BFyk0 zL&8sQXkzlH#!Qqe5c<^Ts^eYZ&+-nYixW7I4&X;@&U}nE`CXFL8{uZjB{e29 z1SBDWZ+$6M_%G~+r|gCk06(udJ@s7G{&C#;&tj5#q6NQtqyPNpNVVvYqQrLdk2sEV4*Ym`EY{mBp+$2uLN9Nd-ACZxp@g}_x}w4R{ezH|1CR8)&;mWSdJG8II2(r*4d+rV}Q9i*mmF7=Seg)#%vRaW8erothFb zwqKtJupd(y)fxh#UENm{^J<1WN%K#J3rV zu*j~Aqv!9BZYUf0r=gTis1hf8AvEtFP|FE#S0)-R%}vh-ynX{)?+`F=PaA8_U{Hv3 zfqrIBdX^M+Yh-5Tg$~3uHam^QhR8th>?ds5RI;4-X>_A#(mSt&TN% zpPZe-MH-Q!TUfoNlI~(QZ;V@7sqqOirF`h56N(UVQUjx3Cw-jAa*HoSjn{qMTgS_T z1zeW*3X&;h$WWhDeTa`gB}G4fn*A;wU==&pmKeg9q>_-K`yIupjA*W4t9STAZn6jw z*bMBig&&ndGRDymL-UH|OQWqbvHV#X=>yC7)(Njo+?~ut2XK41>-qT0*e;uTq;nq7 zGq|+fyJ3R8FT@=z(0K^eNp`1jEv|TQO-2o+&CgD*DWGe{MXpRLY}>3bjgg%u9g{ub zKFh<4Rp6a2TP21bofnkt8H2rg^c z!V&885UkjOul0-57BNCur}k0S_RiYh7##`^P~qNPDka;^Kyq%Vt4jOS!H&ym50;y| zzJaI1Orr`p5r{p$z1b6Jw?B~{3asf+VtO+X3U!f36O)*aT0Avq1~3-^AQ0#Tm&56G zELUQqf&Er*40HODb+}3?$LRU60np2s&kr!EX1Xj=}3BM zFE)yH6`@p;MPXbNikd~KA$kvXlp)P&=-I+n^69$oy^?(1C)t+9N>z_a2HeEm%izQQ zOU64S^F0LZ5P+_jA-RTQvoF9=%sUL_F}8}l5)KR2tXYkI;lZK;=~cvV*Lb;dD@Q*c zIU91V)3pQM-uv1p)qT3+y>Ff~LF1w;N|Qlw%Z)zf9lvUnj_BuFHRoujeMGrM>)=b{ z!2o%0le8I}a8YhL%KP~+c)w;?)N1@~|I4LIUuwD4(^|nRRtg-v%TXHvkAZ`@cZ%|%`B zWG+r*P6I+*TU^13T+8v({zSH>S{k3wFs#oLM%~e1>w^jeyqFSf;?)Og6tzK*qyE zh~U{4gLeTD)a(HFIA=|`x=yJ%|9{&1?x-fS?p;M4bSwxch%}|DAczdTgc(Fc6b2a} zNQn>-q!&XA1hD~zA{Ibe6ztT{J1CHdKq3kR5<+MaAS5xA1PCGb)%j+|`G((G_n*7& zy6cXAxI)T#&pCVVXFq$Nckg#BCsNABV9Qw5f{3;(Zxg7IBosIPIJrP_cn;BKrD2R3 z(Gdh}Dee$q( zbNAiK&Ce^uM@LlF*s$@=;N8%v=tR^wGynLcx>1e>b&K_&op9l9$`*w5jnNw|qb2c1 z2x92zImv|00ah>N5Kbpr8$c3Xv9(QlwC8hi8 zxpfKfWlUS#Z>pOMKK$HKAK`i&;1_Y_hcDe&H#QPh45iImu1 zha(d2x1O7=cSA#rhHKU(T?4ryIEQ4>pWU^5(Z`jFT@o`#M1{qxY%T2EK2d}aTYmJS z$9RK|_JWIr_LdGy-m$8R8}nZ)Tc$;~r6^5; zBMUqv`)x_)!kB91>tM+#tofx-pMC5+D^n)-z2~M!uTB(CY%QG${qU)B&%wdOtu-K{ z$;|a%pWMonkSao6Zu1&)-{s?ZBUC*H+xOwr*3zNWaAW3fottvUU^?y}F{8I`)R+3# z?W8%uy^d~n=dOpIfII7qMv0zU*?I`};!NiWH=ifAJUfHQYCX>x&G9*`{BDrP> zOB^SYD*HWUt%_*wLHnLa5Wmd1A!>h%69?1!Lu>WBonL(1l9?p}P0y=tDoudSlJ7&* z_JzWCTW*>3hUs8`#XRc~7Nosd+D}`{z6^dhU3ujF;HkTV^~OH->4YaFUJYSnlT}`8 zRUiAz-9UPuf96=j`S4%)eyi~eq?H=^;*;{IQ zk6+s*|D-jPA$K<~uVnaE1wd)vJIK8Q@;hsfOpayM)=zBHplSc4v2Q|Yc2;OULR$TH z=cBfIcRBZ0UYxGStY5lq!{0y4BGiT{vtS6xbL3Y$j|oZ6rsS`u#ezYP2on!Rk4nLx z6mQ?#HU3kC%rAnp0erxR)59;w?aGwufV7_2CIFn(djWU@8l*{=baS>nqa z?IkKRXf!W#$M9RRhYSzok0sz*whPN==Is0<+2D?LVf6$aBn-JUJwNZT8E9iA^v}6Bo z89$inVU)jTBZkTXv-pp*7n+KIhE8KZ1FG5Q|K zLF+-qY=_@AC!fA?7igel_$oWxzR>=7(m|~slO@ITI*-V0PCyP`M+HnxlB16)D+`KM zIXy0fD-X`Pi=>`Fp{^SG&Qr>|+ixEWkDgWr|?vS6oujQDkA|;1OjWux8s+f z?EEtxDwfW>)O>HZS+p;!1w7hTsyG&elDR$>soeFkU(~H*H`HA2#B=@2F~dLC;4z}v z;pgL;62%fhvTqX$Uq#fpKy!>(npw?^8$Gg>rpX2wWh#S0abp^pXdm@~-DO9oz+3lh z+jR{Q@OnLG^;Y65<1gMf^xT|IJVq;?yr4FUoer&q^#6S26Z?3u@@UWIgdd&reJ8cI zmx7(%R#p_$VsvYhgx=1l$(g*e=;&k$O+-CcoHZH(9o~jrzUg!uwCOeNp$hHt&@mSz zYz6`<&Rj+?F7dWZ7H{*OO!I0U0Ll87t>m8^bfc$2Zlm=@lGr}FZXei zRNS>KuU@i!b~b{CM2?ds-6=J`WzRYVr)T_zxLk$ zGY)!Rb8T(Nbv>t0t-o*vXk05NFCWZrX82~7VT3edDp~?c1!= zV7z^M%=kC$P0jAsjz00KewdwpZF&$jdQatgJASUn(8gjbgPQpm{VsWD%-zA~{7q?= zgIhuSyo<7tk0MosSy=X=w-8XcJwxo3czV=~z>|Op>SMRbUv6SAWW6oWu`T93ZfR5p+=r$1 z!obE`>lLPDnlQ~NuHzGjPYuf_gDOsH4h3cio`YswI{h;9^W1b|$AtcPbLPx3WK5{w zB&HeCjlOLrm|)f*rKy}WUu~XrMjai5i9f?2Nr{IEnzr|jO-tV1Tps#@@IiL-uYp^p z^&VGzgxJ=&ngmI?3y8bT@P2+73YWbe{@&xg@gS)*-S5?dmMDAlY8#4mK^m1ba0jer z70bPKvcX{p((?m-HL3k>OZYG6gQZ~g;|0B)%N}H%FWec)XLtM?+`iELOS5nJeLPUg zsqC2H9jpxxP(u9r(9ZQHA>K9(?JXg|n{0HmdEIn<2hy;8Zo>Ju{n>i?uxDr0-#S6m zuRcOu*xi5d4ZK-B>yg7R(TaLVi4)d?Keo&`JzVnX;a(askbbc66HP~WI!F0< zqh5rc8mbio&L1$eUm50yTgLZ_irxTD>JlYg>Qo5w%y%oj=CWfz_bhKxwSOAH`*=u+ zmE`2z@VTk9-Qc|<$!+3H_m>j|x1xH^L-wX^xAnAlag9$!&qwek<#cL(eEpk-shwUh zt};Os>6qHO^X2pId-gPO8|D?pu@gUNpbW&_yR8>)&NVz0(3&KfK1Cj29=W^;3I1$w zgLw43?{rIn`i&4=EGN$L%Wv&B1>N%#NJN7>?@yWr4PUugn$WlH7ilbX{oUrWIs5$$ zM%2R^SLLb~jc>l`nUu73kQu=5?m3gRc5Lg+Cne-)U~N*t9l3x9)EcQ9l3m78hg7}w zpC>t8Kakeq@QCBbOz~WwaPxIfbZoHyMbj<6cF5(#_=Canrg!3v0x^Cau{u!gOKZiB z?(8p@yEAJYC6bzuLSlSt-=8#(u4i6Af3Sst6V!5*iaT=V>cF*nKfZ~xIeT2(I?XgP zLNjKk!J;hldTw0E(}0M=0!GG#bgMRWWUxM_dpia}KKj(zAA&Wto=xMP zP?jylZddG_tY3X6c}`2YoBSBR`Cd)Mm-k&IZDquMQAbBPZLN{4xTiJE` zF+#}Oo1T()a~~=;b8XDl%GLQQGJLRq5H7y2(Ai>Ap<3cvWLLYoi3jKw&Oe!9EIXma zRQ2eJO>I}-*I#i?{S9otlt#^WFm~iu`Pz68C{=zlh=%q)Pd2W3s!IX!SFAlRqu1V_dUNkq@u!&Jq8<-biDO$_R_{rt z&WQ4QFVDaDQf7EPRp;}iL0ap4RYR}m+ZP8sLVtLJiXZjMDrvsYJ+i!O_8P5WZLGz> zm6EtcN$}i_0yDL7q1$5%Wv1g|R{4GY$vc?Ir@M&r#d05A=61fxqW9KA2ljk9XmC(H z*O8OJ^O%?{*$r+mu-6NBc`+b&#ZR@oLDD5dH*0^({^s?upxNo0UA`hxeW7Pi3xSu-zX&wzuq6nkuXA7J4dP!kOdb=mra1zC12&ZCChqc-T4e z9nAJqK4Rz69M<8UCML#6#Khg zq}L`vEPtYJ>ST5MfzZk-P>E?B&8S)jOQr89=vWkcCw^WH8I@q&8~IfwwSC}qs*J`SPRDf`HNj?k$D?ROZg!dF8DmRB}6Z&{6o1^tKu$ zli#MJit;KhJ}uAOe(HxyE9?l}CFxs;V&Bnj_UG+RhT?4_l5vbZicIr{*oWKq(zwCe z=2r)OPTgS<9*WzY)02#mmVrKhQrD>!jgHzWhVOopzklSz<@MXkU-q`Q$&ih1KgJ~& zX987;dl>Rx0t5|)4Xbw?ZZHjc9qOd(t7&$LR*EVM>g^rR;bY2dchL38w9rU}fn^^VJn-U@FCC+i-6R6Q- zeTkM!=g%CNn(Ax4gE*+V4+s+(@+V#t9#@;&uXoB+tZm@%UYCcD;TqyyWb2<>x1IzZ z9rhW|wYlEbkvBa?mmL^5wbih=qu?cwd@}2I;B=0b!FpX@+ud=zgEn++b6-{Ak!OXD zmwUA>eFWLgaKMx$>0Om2KZLTjWUeLu*J;pQerOEf1$C~-T|aQzY`3cz0rOH{=jfMr zzzGHDq=e)?#Y4>2qg{_QX9<-Hz8#6#rk&>TdseeA&IsA64UZV|T+{?Q%6c zY_F~FlIV?wB*(4HYAxXwSYGDJ?6oTh`LRPSsdLkZnz!=NnJW!j2!bn5pas$i=X`E5 zaxdN~RY5*_4{ukGm@_&ts^yiy^>X|DmIAR?ssV4Vwj>#*wzebcHMD-e{h(gG>X#d* z)gP3hJTgxoT+)PZzJOJ2wYx#naD|}^Wt*ftQ!Wd3=Y_Qxp}6`tYKI!hH0O3 zdHIgSd%Je@X?EtOox>&fWq)1}q1PXy)Z+uGo}n&V6XFKsZ$FOtB>vdUI;lQVrm&4O z-Ut`(6%Nu_YRPAcO&dpRXEjDiPtC)=KF~QY>)M#Z`&|SSaff9cl=i&T-Q?2+#sl*A z=O15>PE1PIR$N`uCqs6DgkI7VuVD4}9eD+iVa12;P0Av7Z5NZfV$u%U{%}(nnSK6e z6|YfbV6;>7_6B0TbGzgFy!xQv*RP5;=L1g{Rq@(U*fCP4SjN43Qg-N+UP<<+)H$bG zxfcpA{ITaKbRU@Kl_rP!M-2}C{nY2|zMEt5*3ulh&+7wO>WL9wMxOq&2dVJQ9SMgE_g}G89I`i3^ zVwzD+ZJJ&&;H4Hd;L)UTobDAA`%75=7vFZ-##m|z%%Wixop#9efxLquf~wwIKT#4< zJr>$|pST~3Vtz;qB^jaCZm=f^O-e7qm+I)_6BGyd+N)IlHb|SXxJ$oU;#QWGUSA1< zAv9OX7_n%C^vP(IGp1c?=-rVVnrrU}ufF4b2VRBfAiLhpo{x=ig7ZpSUXj1t4_gbD=%Cr zC6S$7w_7TGY{JOY@)m6cs1tm_+}LQ%#dT$uIms)Yf-&OItm`k#L_d$2OD~}Ba5#SQ z4c3^GVL;g#z5ZqCAS;?`JjPO9|M)z$amJ3G92@P{z)+4YiJ5q2>b^1!zs>Yr?{F*a z=X*6wT*XpJ;_;7=R~ON29E4lPF0n*WT%$ef&Jqln_pp1?BuN|yf1zzcl@Y#JA?qEx zOod!Y4L}9Rf}&yYArKs4aB|Ilp@$jk%Y8To&xb7C#-i48Ka%t6w{FkLGg)tgKu{wk z_M-pURA6^cJTu`@xiF>;!2?dNni2+^UxHO%$(8Ot9CT3tNjc ze&u#p8=h|kpW(z>HgMEO1`uPrnX=8=G+358HQ_O%>cmfZ%b8(I6{X&R zh)=%6KtqNnos<`4!(DFSLrA`qwNCc9Z(e`%dZ24xKs5IRyDy`L88Kf+&*ifnF0Q%w z6Y^p`(vn<5QH3ot_t;Z9E!DhPCv|TMXLqVMA0OCM-xO1edCgQnOsGJ}G6){XI~BC{ zid_m>x$VZbC4Rb1QER5Tm{vS6yAXY5V1)aOU1uGe&uehtjpI04v7d2=d2p7yJMn=h z9ZfNe)@n}<^eJ8M$5zR5n*+VWo0#FiERCZB=aEc)eIgo0A4?6imVMqhcAHQc-!iw2 zUW$U7bHjBSUpMhmysBQf3=q|ur!Zp%Ii~M|K@LSe*u!%JO0+= z?4H=YNf!_QuF+prdvx^2xz8wi=y^j}Q{ickCX(g^9-;>S_Y{bPMZ5>gDZ5`>#Kk9sTG{K1ub zhd;J_b4)$2VLhG*y{!`S)M>uGg}WBkkzDFus%Ol(r)>Bn7mhV2l45zs>2>PKOr-8d zoL+!!hD_aTB>l5`V?opWQfVMTU9G<)ViideilIHDf9Mf%tuNUPdB}Q;in!81^K#Y7 ziR8I=g`z%|z&kkA=7!}}v}XFinEDL-d;{WVidxkBnyQRwyy$E%aqD(Jn?vhwn8Rc0 z%g9n%CsV2`3W*)O^A$^e2bH)Ov6mJj-lewllx_LASch6#NyC7n&z`udtph9f!Bmfi z(uLUOgb3cLMy8kQtS9PBOb56x#z2ImCq~ctM9`xiZELy2zweeee@$3-fYA(JK6uB} zoET|7URA1wO&KAl?14B2Pd6@DML#qx8I(X4s~N&nsUA%<<2DKN+nQl5ybg;j-rSEF&6NwlAGHfO1JeDx#ivyqJ(BWBNY`;X(6!!> zmGOg&{?+LRe0NR0i%R!IC?$qd1!vT~aJkxR1~OXS&DZ1NGV({l8)Z)ZY{0vSjXf#L z%e+sYqc%FrE|puaeRc)5ZS9mQ$x-M@Zk>&}>RAY35O;Guo}$1oTNJuxqM$BLscyh| z3Q6lRm%|tG^xL{NXb?6x_%SxZF&wn;-6mwln<&U>O4N~(K?zPj-J~IF2b=WqCnx9h{V>PS%d3Aod(VUx*FEu9=ml&%% zjP=$1UYmAoJNZZowl;re&7MXD;>s{qhd2u30JmB;Ny#g|roi%pBqN0Z;B6gJ3j+F4 zFxN6fQM@@Xl79`kdI4pSOION6{i&U;TYesWo*h73){wJ_USFZsn1fb(McO54j%C@v zSOLFac6{}Wtv3Z&k{xh<7f89Ww&2@ae+)6&y1gC3c>ag~{BHGMf9H7YkVSNQb42R- zZ|nT~r<>yT-c?{zH~M6=fEOCLzT?`wk^Y~S^!*EkM4rT7-sLZ(11_lwTvDthI@M~! zfi2u)fB3Iy{621w*v>N|s19{X!FO)!uYpbO4WhoEz+YqJbO56CVs&w*|Dq9pefo8Y zUK`Hj|E(p) z1`OAWy{X|JyLwq+l%{lf@uc&uKXmDD+r&M7$fA)lj+6gG22YNji~XY)!1tnWP1>rE z==Hxp&cDC?zp__*@Y8A~zx*!ZT>>@HtOa^aAOgbe$)JvnYXVZ%-)8i>RV8pWX2fai-@2-OJ-O`?Cf5X~8zECC&T!gMm z+}LmgF97Ce(yX1K^oQmAZ41KyYpI#=7XN-Sf1KP(C1R?k z=7rJtc-R}gVlsmIf<#(hOr`^_V#W=%lc*j;MG|jN_`dY`facJOn4Z5~H*Smis7|8L zn#$s)-?vXZu8mL$T6x2NZA6gBhhc$d_;g?Jd;h@Z_1~)@If_H;0!tNN642XHd=NK z$4)Bb+xzP99euGE8@SUh28C*%=%+EWBL$v<=l`||5r+T|0J=*l@%kNe>_&suo_}E} zuyUDxEu2>^{W5UhX!0pv&Io}BiCx=CiwB6h8cy@km?#bvcDOwc>bjr#)hufpKC&(tacZ`Ha=-FZQXWSJM` zNm_|dQjdAw#NZ&%yl_FvX)sX=zi5Y7PyrQ!fUsG+3=#eMx z%;Uz;19ue=K%Dx+KSu-gD+ZK7R~Ed5V7&ZrC$XY=NsnIFHkc+)tul9wq~ARhKyQ*= zFJSivu9?%Zx3{vCjn@X)vu>!=DoY}=1)i{idKR9;`50R}`}wjWOXCPqKoQ5?w6 zdY#(Rx85&kJZwx*3!tpQ2-UI(TrAmmK_1k*p8CSMF7)G#c#6#W#}^}g%}d3B0U+)h zia&o!G`uE^u|n@{n?}Vh(&^#rUmQpafvW{!pQKR%oLxIUhf&aLxsy*q;qWCODUin# zWO@?}*M*B?SVr{Qpp^;s3)IR)SeqxcD`2~_Vo*2}2Eh$o@$4O)M~FP4^B_nJthNbMk->4h9w z7j{eA-WLxy5p;TM9{0SuA1HdppU&q#MEUchsW%Ns{FPpc zKRwO*N=tOlEqaaQP6VwJ_|-fnN6J5zKcgPVU!;lIrtFHG zfCutpN$>(VzK3qhs@h-E{LE;zW~4FmL^O(}?!rAzPj^yG6hDOc9*Zv_0M@erR}MJ! zZK~DBz66f&^A1}?!?-izyaIL~DAJevsb$r@VNruJjF`+CD?HphKgJepqj++XyQ~bO z5v0z;La}Ia9o4vzHz*2(`$M+zEv<~f3{r)f@yFuTLN?oac&K>{9b3a&kDb58wv=rw z=DncIZ=tU^xPCxT+ggTt`W3%NxwtwYUxWXAzE<#e4`C|6#72*ev27`fOZ08P znJ0Z47GK9fqP%t1rhG~K{^shYs|g)CW*#eRz3O`?28N3aUIn|53BTO1{>t>;N=riN z-Q|#m&!r5%j2OBiU6FvW<$iV!YD9EoLr8hc97L@VHH#^Yu++PbB?|KAmgw=uM$ulk z{^RZbgRT|(B0e0fc^z6#Caj2$nl&QiMmU;cK+F<7BCl0ouftUAN7U;2f8Gup%} zeP!oI*|!Y%KGZ_Rb<8%1L3C65hvx9;_%7M`D*F2|egv&D&PW8sJm>xp(dH9DLGJQbD!Ae_m17n+VnUKWe#vF1uq=A|T zCz^@7EPiVH!i~q{AQLf-DPjD?Skw14sjcbx9G7hvhDna(nbJ$VvPyVt8wJsL+pCwl-1_6wyMkkh&T1{zQiJ|KRBCNR| z=rsYpG{JUb>-uFII=rO5K89dt?Vpj$FB5FnjNJY`K%N7rx#5yzknuMoo8Bt7vLbKY z6h{5Dd**wH0M<~v6Yy5W_lbA^=D+_?y1%XW+gRUArF8)C8Z}lvrr%%sFQy^|aG)Ct zCBqLk9NJ0zuEL1^tKA#&r=TD}1%z-7A+{S1?dJkeGul&ah3_=u&x^Fa0l?T;m-+tu zhC`RZfNA}^;J+k1`sGdC>uf+RF2!yI;H8nL_wML_n{AxL_T}^A5{+9OzL)=BTmBp1 zYHT|VGX8w$`xihbMuogLmOR)r@00BL;Z2zbBpl+kp-UzmtLv z@UlEvh!H<-=vfiL%Mi@9P}c#p64jH;EcpGH|1aY4d+OYOyT_iBv|-Ivn{wAh2Hxc| zh=y>*aGLT=@6}>=-PCxDj^yr{GWiRhzO&9bxiZy^A*xYE56P2D--DV_2PY6&VH%>>`1990-TL+5Mp*rgE{Btg zE8XK6G3<$YFc`)N9zt}DN2VGPyyPr$ym`;8IEO=G7${hdEs0lWPT?`aV{3(GH$45g z^sp69;f}mhk#RZsVoISR7p9uXo_T;?g7H5Mai3O^tV9C2<0~KP0tb|h+RwZw;SP7X zvde3tuN;wIxcOfj_&dD~x??ga3bcb?7z|aw?8$BhSAUK zkQc|OM$Ab5xPKs=gpFU8)(dN7gr*HKpXYw0Q_c8SyR_NfLKi2*U{*j)(LrQbwA_wd z`b!}gODa_#q@hf#!olKGv7tq5`E*ph11@Iy#QOA@Ew)@57Tc249}$yIlu2jKNy6GI z`ob`M-^u&WlLtSwUq$nWnwiVmy%2UHgS=|mfIt~O^1n#1QrmCf@4a_j1h2PW72 zsNex%W>-BF#;i9lfoQu@2iVUSXvDC3i}Cx~P6eVFWqRo_*TdW@Tzg<1 z1nXmf@7x+ZP*;=ny~+G%p>aI%1S!{aKSKS~vX6miyCVUZWsWfGNEe16Q5c$^tH?<~ z_*0jg3GnkG*w30n^|K%K)M=if*c0g;a(Sphby5{B)Tb!0^rB>FfyZ@SF7hR6qfMNv zG3n{`Y#8WuWe@%IvthNhF|!`32cm&%u5@ZSGOH2fNM(8jL4jqFopbs{%hkmN+nOG* z((?*g*aIS@Cbz|L7?0rKz%k+X12qj(70fqYF{#Sr5qyKUX?rX!igFa zg8Ho!(dUS9*RZEf4#yPYf4iqE<74MuC2O5iuLP*q3|GsZ#A_s$(wDcSQFNRq&<-hP z24|C)_U=|nd7%L@c}cG}I_Ma5emphpV-R*{{n47LK>?;f`q>CnxDJ%g(=Sc!M^G53 z23d-2>?eg_=kco|C@2l03YM~!5(#xCAZ4|m(oN{<7pWaGy?8LYAmtvkDi=+j1>4?l)&oIdcyIGg&;gcI z+MyJLQhfaLzn`!FF3wg7u>`}fnxelDZR2IEPC}QYctz)eL0&}=PHNbL9Gw9ixX)&G z*vvz?3!Rjk7L{!=9&y(=SYND5kVc)_qI5<&75qTTX#mIagehI;vJT>#G=-%&3GG4Y z3Fe95q|ZiN{V5Ms;*T2z_i*uqoNrerK59U+^--B1?b1{Z53MDPyaz>&bb_#$5SJ38 z$c}sm>f|z56|4Qa=AlB$&f@!flOuVoZ`ngPGYpNhDP3)^U+&YBpiRJVui0f}3+_@V zEuykuGRKTLiR!}0jbxyKogu7O-)n~oc5LH5y^`yI<%R)UZ~fpRAuQ$duqUi>ver7u z+(5Jq5JB6AM7>A*WGsK6x~?SELD*1Kj;Q}ex1q%~?K~x;Zdon=LR>L4+K~Y6?u;nL zjsyA&bD`iW%2QZG+=^CpCc2gYHh^+31j<`T2|0=u@H#{l$Ds=$G44yDtE@p~>SZ!^ zeB0?x1^-3OL{d$-QaWo;^X&Ou*sMVnkcNyRGoAjD#-qt-Ylzu^MQUy?lplNo^y+8-h?A|eMqK|%bF&L_~v_%Yr=nM zkc9>4Ts<3JTt`z#gi;5Klt9*c{%t0%YV0DoS9DRz;GnWAm?1h*1p-EUaAqx~3R)@! zIJ{j?vun_J6Qzgk6o82gmv#;TiZ?1Pp2P9U9vInY!58y zT;fF-+jb*GxMqnFDV_#2snq)P90(LBkoYG+_^I8Etgk-_eI(iqSdzg)Y;S5<^emW7 z8+;8ek*?N5TQkU5%+yp4x*ikeiyf(222>Z6&@&<2s$6I=qB@kH1pL@Io!9ZsJ@sIh)>rc}=C2mS(lnPt z)h)(rM_uou>a+n!0X%b{dF+s~>umGUy^b3br+Zqw}*lbU9^bcErAo-&u$ zIBouF0-3O}?=?F#AM0423u`aPMG^6*0^7@_7s{KiH2I+n;f~U@5LHqYgVam0Ow`mq z2DcC?)jtGKv4ScTy*Y)q3SKNYWyDSt&!DzPuq&i3#*PT%MHUuB(as=yuOeJm7$Yq- zye(G9nkxB_W{Nhj^iRRM~4zqLwwLIUyvrw2fl3=McM7*ScUxCBd11=@bZW~pdXCz z$2QvWCfXY8>#TEi#R?}>NH4M#a6J-S` zwZlg0$M45W!W-B>ju)T{YdX5Uvm$11<7Gp`&q25yZ6ZnM?UCujwOu-`0tj~Hw z+IDPjWc|f*2(4L~=ZShJfdLOxk+Jy@5G;T}DFvvx6h2DWWvvLU-%`@GqL;@j?jxNO z!EW>Y1va#e8(D?&^-(3-@?xBq7DR6XdQkcP+aX-k0ey{hc>C}X7=?&K zUov{rVFUU7DC#H>7BI zH!BHne3B|??ca-_V6lGp6jDqzhw6&RsO*K+C_h)scoGo39D(^`wium7&`)7P&nHn` zpsv;OL)lzULwq4rPZ<+p$ax2Lk)^BZsjmWDbwf_8E&gs8QdC5r1C+Np7u{UaNhYQk z6`;^W2IpM`W~$wow`MHHYyOIec&C1RRpHcJlK(x05m?2^!)hOt&uCW9g=l~QAF*sKGoA%4rJ0Jlbs?8PmA7`_5r=Ex8pV3>e0dHs*ZWDXcp z(LjvBssz%|o$Z^;8Z;OmDuq>t8m>xo=>rwbb%817Mq*toDW~pwzE?>DsQlJIH4q$i zo=X~FmlsX}jYtjH@4!X7`_3^=8=h?k+&n-_L!nj=fO&yYiDL(7_d7InsOHj&P)9Gn zq7-ntGiqbG!A0LSUUJDr17^39s5MjqQv$dcraWck>-1LJbYlMPfW4q?pbDTYEpxV5&7thj3Y6YEB0elP8~%As3R#ar zg>v-?i>zrBIt1{%wO{HjKID)HOK(IDvAZMVGIor#C$EDI2Kv#%%FrrVK=1~$nUY@S zuUG&}2Y+=O*i~WMjr{JQ^Pz>Xx^i}f%U4TE>X3;U*Txp4pu2y*48n$z!7fl(Oi}U$ znxFbN%F`4=?v}Vo=&Ao$~2dfCq|JOAh@rUPx^tORW(6pgakU=`9bw{?uZt;= zg?r_CdCco#W{03WxA(XlDWv{cmzo4s#8d>X6MYb$xnU}VzQZFxBg}aKhn6aZRW96L zD91$Ye&)G~?8JNA<59{K5Y(ygYd~#Dxam!iZ3x08>Ypvbe+?3#ikz8Pq%OP5;B!vW zXGgs})?iEOXX5%?jsu}6>l4t1>A!Qz0Ctr%oAgKz*lD=}_yV<2_r(q){QMBIU6ez( zrCxj(uBRR&ULghos0c(opM$04094K2nW+URV2Q;?J3&I-%Huk1tdAiH?bTh#i?V8$ z*T@N7rO2hQ4s_u^7@{38<+wTx<+B*^CNt24BelsrpgOd9L%H)rU#`^oabUwr?h<&! zfZjkVhegEK#gs?*Sta*t0+DR`unrAK1_kQJdbOMhVpJs+)1g(A8ZKg39{35c3E0^z z&xH`H0T>-z1VoRHCuMkkUb&&O;N&YCuHGmP?coi$s82o(Y)u&;Ofo#nrsWvBVnfyk zbI4PlvtAbTHI=t$21Toqcwo#z<6Nm-s-hm@Vnp=E!PSF>bnpdkFN3S6JfZi3Nr>bP zf`Kd!5dW|1!@6fIjS6HxDgT)>`s)S2#=Gw2Yvs{BVZ+BTvk}#_!NHuI3}{RX1i~Fk z(Lc*TPm^O&Xhhz9s7-3e?D9vpYfFIp%x$uXGhhZ+{S0N520RU_i+Mx_)(6DF8+hb$ zFN0>6C)cD>oAUc2fgp&Nr7!kBBrbL&eIA*$f*F!xjI=>hNj)?maAChgCmpAiW@Q6e zxK1OnCfnF4#}_GnhwpXbn*yr*Vq{BsXm&Xm<10-krZ#x_IU1tWsr_1d>1aDxZZ_Re zlUwDgWl!CBmoHwj%fFh725S8yecEZ@YG|WuAv%YEf5p=W0q`|L_4M=lvK&#(MJG|E z%Uf)kk`<-~Wg0qwovBC>1YF$^E+Tn}uCGjTB*d(OQ#_>s$)>|(Ap`i@4K+zq{)4i4 z#u7JTSP5E%3j>W{D*#fP%NthVI(jt@6cTw~q0mUPI*hDrOmH1MFGYHhT09FRcF{3k ziA8b;7pC_jm4NV11rj5@CC_lBEI+1p-ol3Y@*=Up+k1^m@mC#`WeR@3Nfv$VdUdVto3F$Q>l8A=qg0Dy(=z+~cZ$P<9BXVRwgGK?o!)IECYz-a%^|E~I!3<>a7XM)FXo zFKv)WH)hqb-zth`&~?O^(5_Xjks4o6$v$3^XRbpJVgLx2pg;;Pmrz4nL->)6FawES zI|Tf9o;|3%wfxi-9MRu!svjue(dWEcSVhkP`{}t5E?G*;mwr~_cnSmUnwrW=o+aPQ zk@OFXo-a4TjyH6K7}9j}t1CcaU0*|&v$;T)p|=Bnu$1l@>a%g4lD8kM{6=3}IlytE z$_0BeZ)p&m4CJzLcp&AYB*nMEY8s0P?5obIx8E?!$`d_cS&f;YwbOElI1&Nu zpGrqfIHuAyssn}^lS(hzQvLcEqR%9ck9f|%ZpugoECvijzP^jR1mMfmECf!US082v zABrp-_mK1?gd!au#|y08PeV^PWIHaj*kP9qjr5KXr`_y!6&E z@j>75N(g~N>+Hh#03*}u(Le%RhXbGrj$GRWN$erx9JP#r*&^dhSwx1Lee&mFyHMc& zs0}Tft6p|&tlmd8u-?Z?x{&THxdaBtudjVF{b2j$O`Eo)TAcd%x8o#rLcp^Tya!d^ zt2nE|&yT!Z9=SgxR}Z!hhD*Ye=o@dsFPCcCXZ5XUbG`T(ZY1tG3tZ!Oo>9lh(T}yJ zo5C+vam;8+zdH0dX;-T@&sM`7MY(hs`E|pF;sQ#<2P~RXa!*zV0Q1AQgBj9GuX(h_ zPXX2s=bWj-ggvl3VLLht|VrB_#imfQd*TfDBn5nAKAN>D0B=hZ1r#?q=ig zunlAPJ=LwxJBMJ@QUmox?MDqTx}KuBPE(iCg3hA`$$4!Kk*+%NPf`Zduq&1I+@w8+ z`=5-xUzfeZPZwUY1Am@bHM5pCbCtm1Zb+$uPHeT(acr1bJx@gicigxuMW#t}>H}OB zHJkYLoD-@4jYFvzXvKY~8*&4nPcx_;} m8|=(KhyB0x4%=arFSr=A4ymbkII;=&XJKY_s^phT*Z&vRH4`lW literal 0 HcmV?d00001 diff --git a/doc/workflow/groups/other_group_sees_shared_project.png b/doc/workflow/groups/other_group_sees_shared_project.png new file mode 100644 index 0000000000000000000000000000000000000000..cbf2c3c1fdc80ae5c0e6af976938a0fe49edaff1 GIT binary patch literal 118382 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

&u2BYeCnm2 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

*jrsTRTM_WTOq{ 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 AY$kpz>i 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%0A&n3q!b2_#RCN`EAnFsmeJYi_p~b2%-r3)V~h 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 literal 0 HcmV?d00001 diff --git a/doc/workflow/groups/share_project_with_groups.png b/doc/workflow/groups/share_project_with_groups.png new file mode 100644 index 0000000000000000000000000000000000000000..a5dbc89fe90870bb66ae04b436da94493549a7fe GIT binary patch literal 118868 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

J8ElVp(pbH?Uv#eXBi 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 o1u^O@?KdM+F%W`2G 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∓YjV9G^9@-amZjE-aOjHb9_UO5xak*v)Z4y%Z@|j&b!X%#NyCbuusFUTfvfW zw~&$j8vT;D?M17>(%U?sZkw3F91Bphpk<15k)zkL$+ao>Fu)fcO)w!oB@wB&Q5@uqzT+s z=P4wh&+3h{W9h_k+@Q5}6SR|nfFs7kR04I4c`;G}#4z{GQxh#pgrp?nX!?7%MC;*m zo1#^4#@4eB(Oy<7&Gs9|$4CeWp$EKH5fQ)AJFMdCgXuJizN@gS@E~`}dyziMx1?wv zzVmFsquFhJkuEpS>^$|9G6;vv=q8!nujvP(hg2*zl@Drz1m)~&$EtTaR6crJRR{sD ztR3Y8ICN=5zQZM?XGU}8lN6{d!6NmrpNGg#cPhj`%JiIO3z%7>TT=&nX{7ONY5MX^RO?uhZu(Sd>Nth-N=h6R_4JwLRQO zp}uVkKYh7IpEyUleJY|I5s_Q~1n{?|6NQ{gYl(i7A2$r59quJ8A?5QKiM8fFm&nUy zlDs|JRN~r_sKeM>Yv&(J=S3(}3bkBm2KkX$Y7sHM*}sJK0#cYx)gBjRMW^7Wp?^#- zB1)S1WNlVjmUx)1{Sia;5!X4}!ruUzfY{kT<)$AIeo(uYU z!r(%L=c{=W8=Fe9q;xx~{N6%Jdj!ot=fleGW{P<=z;YFAmhe)ZO+hwvG4p`AlIDk2 zl{j>*<=yg93^_L;Wr(_8^No~F2(OG|?j|?l@K!4L?(#0a(le^qx4B`PC=pW^s#2;K z;Z@nv5Ctk$xw1l5guybWCU9S$tWrItq6J{N*Ww1vlIP2pr&?pVRBW*Y_Ji*X4G<8=jsf=Wt zp%Xlxz;#J=LMg7p(sg%y&f8YrYzC`(<_aYFn(snmqmsmjDuDPCR{dv*K~*n% z*RXUZ<0>;S(MbBU1rZbT^M@f*p50JM7|L|}g9Y~74W}Xe+So-1c48AENp&35 zQy&ua#+;LWm(t*ABkqqR?u;h8zwQ7d;7`bE_ip(Vq5HkX znrM$c#G^}HqMmFY$m!N@MA0Q2hEv^b!-zEDZZ2kU;)I>GMhO({QzV#g#R*j^@4+mS z!rlFJpH^q;_beCDvdlgZ5-Hm;Gz1TdpCixUkCK120vJ?MnYo~B!;)c}KbN+H41Mq9 zg`xF>d++0#@CfcD_))6HgzOeve3M^X?^{|5i>Fp<4Ax{o80vp1%#A2lE{yd+zv4tQqGXEg1nI~@90(}Ub` zzduo5k)fW@H^0A9_351jnh#TukIk|Im=U$<+e5t?J}_-_Pu2%>-`9TZlOg@?t8oyl zh_r-vI&6z)#w?-o^4u8aj#9%g-d7!&fA~)Ez2ErqICEw2?T}m_XEbW_fL;;v+pgE! zD10ZRX&1lYF+Ugj6Gowep6TR=O%O1;uap)|Hyp04YUxrMKWJfE^2n{~ENv-uW2mFf zOL;THm9JhGaR}<2yA$f7@rOU1*^FS2))#;Rve^S&QCY)Q{5+nVi0S~DPL!7=%$ocA z(78*Z8tRWr#!f3sULS1<9b@hKP@kKMg8c{WIWBN=mAZd$5TMZN{t;9;nOA zd&h3kEG5)WanoWR4rmTb9uAZ+sGkPP z=wd?mXB#DpbndesaZJ<(^#7DNfjHP840Lop#O@LVmZAejQA=uG#h%{waur$K-GC0!B>H`n6om7tn!c->d6?4A~eV~uo% z;I0|TB;`(x=YYnZ9oBCXt%Cx>ZZjC#r(u2PPSG(<-70j>H<)&2x?gv5k_CB+Hp`8| z2tXLeF9^TvFci+)@@?W*t3Qc+??r}8(;IBR0H==p8Q;;wvX36g#u`jh+NCn{QoKc* z9$kr?8owH3*-Yr4Fr)99#wy+)_g&WRSZ=0XZf5`HDKh&@Sp3)Jv@F=26+6Q(M(@8b zWhH(NC=b8`NcnwIe%S)OFn)sL5WfxNH(mQ19KW3XgU!gihfhpNA>-$7zekQD%MrkY zU2btixmZK`eINqc@St~TNly}uDO(m-Uxz&#T|MRJ_u`y8j zhl4ajeGz98|G92`A^BHv_=JBAh}hnd(K_pxMWrH+Nbz(wKSaab6fpII)1s7{fXHdKAh40+dl|+ zZcd#3zs%#`?ze2tPXU+&Famya9)G)~zykP=VaARs ze6035us3wRN*_jEL1D5&o4@sHN}^Cbp*@hQoy1K3H=+2?9oEkgn0!D9NoHG^6&g)G zpmZa$ckj^C)6;KHJzqjWC}##0=xgpNpKvWSs?xpD@H6*uAr-=cPmlVYe_q4aN^%2; z9|cC(@kvLToV7^0uIQl1JU3uc_XN{i;eMEv2BRgLM^6a0p70aBn$|{+2Eig4MW5M} zr~iZQzX>f59>}vH4mT@1v_D<_R@uC>3a^FxORncO&Tx)|K>PCO!_x5zH$nRWuAVS_9L+4> zj4iNv<&}QT0GO!jA-vNpGM6_%-Tw4I>W|`-2}T#9xeoTA_wHZx`E&IvTQp@B6N_WZ zv-M7uCL3}#i$y;$2xyY`?>}eZaM~E$i65!w)D+@69+5tBEs(KqL!zxi5)->d?{Xl| zUApEJEl9m381X*#8mmOjTFZ4$IbYaOK~=*F^~KMZb|p;18!W7VJv9>6U8z=i`_^!v z#_Bpb=wpscEYU41I~-g}%45l!DVe_+j%V|noSj)gLP5Quq4`8ji(7!Y>=M${ zV@>&{21%RGS_RUhB}@A>9Y_TRH`hz?zys{bm)MPOtOl^g2fwm5&InFqH6wK)B`SeX zCR6+%xNQP(mFDhXCjB8Pr_V|t3swS zYXq2@N?^WF4P}2gE0V$IUTLw!mYbgsteevkO5?H*QLVK>w+%)~@5x&>PYwSD9oSZ0 zS?&DEPs{uEyXoG_Q7!BfSEf3?&I>l2R}6~i)gi1cJyRC|2bp0hJVScz13P)^VMqrj zW4E02l?AV+us5adQ)IuDS!`&czlL43_j>N0RaNmeDZihkM|ESeQ-J0{uCIE9JDTGQGM%vFZp2ZC&z^~fEndL- z-Rcmc{>tM9fkAG8(D&k>+=TTT359!eV|%CNf6>uQw4dfuSki$UST8L-L_-g1f&dXN z>I-VZF!0zz$Ym#MggY!yosgi+%<$k~Ny>OGNFWo`PaR#K)u65~*N=AM&Z^>m3oBjA zM{R`di2aeOe1o5*OsEfe1ZiF_ml$CnvNjRoCqHq>B1pB%-Nqy!ob1_oca$(W_!h>` zO=;CjwbN-~VNckS6iZS&>rg#~vL1PFW`PRT1_D=H37Zqd*&sKY_J7J!=$Gy|Q5lH8 z7Iyv$`N_qq0{lq|(v^=+`BTgYl zM-kciL1W~do8cZGuNjqUOX69{)5xWwxe2Ez{@FX#HxE0QJz|y}eP@_fHk!VIQsJb_ z0qKqZ1|kn(gZ9k}sf8S^Ozn0Lm3njBpR{@F#SpDHwkalXb2ARNmz->2vnirJC}Px0 zG&kL(xWZs2zb3JOR-QFzGopO7r-Ymf$>zh>iT(VP16HeW%H^6z#C0?^&YJTpJZd<6 zU=^JK<1=49d#!O>Z-~jH@lQ?xyK(DHV(bu8o#7$hUdclE0A{C?QD2GE7#GsZ&4Np` zUKbpZh55PxFV5?In)tGij<89hiI6Ld4F6C&obom+Oy=zv;Gl~%y$BG#WHym)70DZ) zU4JuccjB<2P6f53a}U>3EOG}a)0Ctl-#eTfqK5(ifdZ=i3T3(48eCiZe!0!PC9L(N zE;)!@<`-%3R(nID;K*h&@hTjTRi0vVCM0D?ijVE*CW>!=+#=iJaC`=;^a>niZ1+o5 zbbGqD2YGJvSylPJcDY{>D(%_^b+ zy(Xvzl#d-ItMnNX;>0$I1A(7^%yUPGeM{9_-3FW0S!bLb$zzmv=Qvyla$jLqB)3Js zQR-Yh@55@paPlOKb$G>M!eYAz9J=_Yj3e5Wn7mbAi^m701?<2L1bPl>SI$&u6W7<* zS5{UEd~+G{-1@RQdJ*xf-ISGp982Ydz|J+0?@q{z z$xe!DSMgV)U7Dxh$kaH+k6GJp=ftMb>e)VX!`PfDwwIT>)F*--L4Ty}M1h0+43IxI zS*a@oD)1$iyIA!ZyyqhH?SFyNgP2ZHBvptM!va-qCo!qQUz3u8jYgBLMXjOupf7f5 zabt?r8_K_;&HRz7guD1xB)QGVX(+4p%tJwOOovs_n-MNVK}A0S*lydWF@`I)pLV}i zDJ!W@Selx372VxxYaY5kXluq4`NVHSJG`aW%*voZlh}4fDW!l)q}(j<@Z8^wVyn`Qyn`o;lg9aBQgDY%uR7)n^vAzz0VwWhRxyH>k_&zS1ATUOc1Tk1 z?zQai0mv(;T(3tnp}T~D3uEXzL@EZ1Ld2gmyim0JU(N!Js=y9Od=1p}+82BP?VbG5 z?>@5A2>3ymqP=VTF#Zb<$<6jSf{hY0HvH#!FUi4)g$5uj)!yxs-VN_>6#=gA)X@6woMSCgRQcrYBqeo;nHrr=kveaFWOJF`X6 zC)F$-3)MjIZPay1;00CH@A2k8kpL*##G?TP3`p+!&g%d1&Hu+I8Y1hkL*JIu`zzrU z6uT9A%2P0woeb0ek5Di@TWYVK-h9D#p6h#%DF+wRS^=@u<(8+g{eRh2stSPE(bt` zqd~2cJll(TWV}CtGS3>Ps1{IvM$aMeTRJcK_Cep?EN+AIYA5+UN8q_ra?UsC5+WOc z#{*NBw0oP_4w^!=3Mgh|y2t8XdiK>v&*%0x&rHOL`dNo=(m@U7B^v@}p*%_0evq)3 zt7p6G<5rG}9CZ2yg)axkLr?;bCr_B(eJ(Fe15bV7!&+ci-7~yxRZ?0z3j8%*X4TG| zKD`0XBEZ8}ntrDMDH9Bc=Afgaw?}^`xip}Ecg6*hF1nx6_178~R(fF-)cfn2g_LiA z4X^IK7&)#8_Q{oMphG`VtGI-Vc`}KIl}g;Q*p*mWcX?5hifiaz+Prrnn4{`v8d4M? z27CVA^D4}wVC-O2bYz!U=kn$db#j|i&Qh_woaS>8pH3&>H72RP;jfNwtl0X`mOyzD z39-d3j;DQvaydia!|bWfCcqz2PqW|_z<$mv40fft>^vK}If}eU)Vv&qW^gDAWDtTG z&j~NRO|4g7E8bJZ2gJN89dSVjcOU-`=w&#iP5EejYJIHm6`)@0pOTq%8k_m}tJq%3 z2PE>J(XznqzwJb3c&G;Wo)?{2pLiXGTF;-UQb`*O#Mk2hUmU}%8pO6JFRgp5Np~Vn z3J+Os5cyo+VI1$$c_nOG2$ySzAB$Dc?5ep$bWdBSR|*oIN2>JHZO@a)YYAIE78E)P z78x(CqCY<~=A?z=j){3h)QO#Bhi{7z@Rn&15C-0E0?3xL5KMPBB}cGIS229KtLkRCtrt`BX>m(yr2M9;>26@(Pj3$hpPnC1qr7$P??68% zYm8+$xVZ2xUYA(Ra4pM&&LJKsDT)0h7eq=r*d9D-WL%TLbx+5*`GtM=6ei~_oyL|H z+QWfCVtvmq$nrT#dRyZBw^CZc29qq5ZwO+Z9+{?O2toxa_T|<1n_WzAU5wUF&Pawy0u%56eA1E4aiav(e=k-6)%haBK9o59kLA#_m|?Tfy(74C!IPWVyHm z>tq32-f!OJs@-!4-aSNan8JN~pW~msYLDn_fd@_z>3_VBL+5JD%MikMSpy*5?1Cfs zwuJP3XUzZQEP|to9fSDzQEGozO%nEJoV6SM?DSya>9kJ8=gJO=eR&aWPYJOW%wNN8 zwCP>-OjODCO^k>(AmApI=xW{AD+9~}`WE-Ei}c&^j!1q`_FOoAjz}oh&|<0TyQ8Is zis^@9E&gv-?`4_=$R(Rxrr%`6)PK z1$0nYm~fQmTT8$**k*EC=UVHUKsJGY3pUp(Us$51*?P+%Vm*-)4F4lPpcIwzSc7L{ z-wC3n=k_sBt8-^wheQu~$FC2Jp_|$Dv-*)XVOW{q^FU=phuQvVg3C#_pj4>-p2Sy2 z=niGvQuNjU$BrV+XxQ`<-;oKb*5x8h`49S0Hufm_k(MZz==@ZCR(P)-uBdKrN4!aV zTIQ^#`7wP5COA3V+&O==UVbi`bF~3g1XZ2UtMyzKokHR6b!Y3WP2ioBlw7mNyMP}% zd4YY<^Y9%H(tVAp{}}6pP{zg9!RU(eics5k_!Q=>C{bYfx{lcj+uML%%kL#h;nwa{ zEA`xcwj(M9&sQ@$J@Cc#Y~QjVb{wfV@}sx*`fjfJ9{im|WCX06F(Scek7bEzNAE}2 z6-R;wx74%CIZELL@vt*H|D;=sPT2MhjGy+2LfmZivWddG?6HkQMF|ZK)JeqW7hX*oEnLbin{D> zs951t>2(bw9#hDAb$kM?Uu1Ax*unekRsG1d$u~qlaY++vl|Tk}_mmqF-x= z0cYvSu$lV&xhgynm!LS*sN3@nE$qNNFh(Y=OI?>5R`Y>g>%p9g2Q?t^Y9(km(XZgg z{dWz6a=VrDbi)@n3 zWceANOG^C^0Xa={op!v6G?tlPsfnAZ<7=C%Fxpaf;R*Kd6q4|J1?6cSq@e(eWYY5iVIR{dt)E_9P+^ZaItkV0i?y zmk3tv=B1}4PrW?-7Ik+-^DK5ie_6i}ratsD{^Z(1s2#rMN?QSZIp(?tgHoJ1k=S5ze!{?;C-9cO9S$nwL)E=09je{iU*R`zJkm^$1f_-p z3yZZjLO3|MgRE+cnPSXGVH&}@^7D9aAn9iwCvoquKp2z-@{~UkLIgyhY|0ipU1Pbs ze6|Vnnw+{yB;FCI=5c?mpsa7D1@ED@I#>6R$w&6-4X(r4V*HTPU~g|D9f%L!Q*AJs zI)l^vB$Y^EMpZ&QTp&WK-t@L_!+eaRF$8CA|IGPJ6A!Lp0iSuys>)oG7gcGQ$r*gM zhqa@%m)FkFLBEn!k0N!XPW+A>OKhwl$GL$`oZ|vv5$URb*0DKjWAt4Rtfn7pv~}X; zK9L(=S9z?hTo4iw*wgaoC)R6xTLky@No4PoZszQ8*6Jyb?noFJpe3(yp41$FC?yyG zNpvJRuURxb=d87imF~VV$W_wsA@+4?A_;gb=RU{%lT4%C5}wpZh*myLWLW#F;s^V~ z1EU3+O|%CVKi8)#Mh?x1*C76`@yDUZU7@T)fd=fwitqwmlVEnQ~|*kxz;K_^V@ zwZy0H%}7$B^a2^X2SC#XZzm#R;M&r7&W!e|x9`!5sqs|>%A@Pu3vjn$vu^a&V2XuvSPJ@=egfIU557R;?SWQ|`+bk&QLB|uScu%5W zQ+*d9S2p<0wY(gU3S_6|a#k+qx~|oiQW2*mN2bOcdIn+lhzZ`^^Ox1MtmX7p(dY-o zPrt#l-jwPB9t{a=wlife>=4k10(N%qPgZB1aPv#w>2^U1Xx*J>MROZm}SZuASQ~@%HAX#D@}GX>^_56>JS{PUvY z{w&t`EHO$8D`~=MIcFlGXXDo6+l$8`+-CiW!(M*<+sE+vxHimF`s&UohlaD>8)%idWCpwCPfP@zvcwP^M#98-?GEWeUK7GSWp}W|YlQxNb2p@R z36l9ok-V$p1Mx%hGc_SIjGmyQ^=>R`w0cdq*ZeP^jc#bRua75=^WbnE5>_j*cPAV9 z`JA3;_#WL5u%22)il%CpXkl>6-Bh-Xu+xh|>S^wDv;Xz-=-?!okT&{g7{Q%iBcZPFmdmEq0fZ@lTFll3N~2)v|TS z=DU8KB#5Hk=3)3BlD{O;gC7K|{BZyz8O}gAW(@%rt7_0|v85kVaz~(cuzLb^5PZt3 z!>tNtyAg150dDmx-e&g=4UOYC_q-ou#7uKqDPD0kMnUTp3>N-Dsa=3c_}06x4Cg|N z8&)H3`b7-vRV=R=>9pBUo_ZyS=&GK)fw9CUy3_vU8nE6047_Pz3Q(-E~+G0mHvg)|bCGj`1x zPLHUhx(D!jwXrcevW@`X- zxo+-oP63I#62&t7Y{)!6KaXQ%l#-N0 z9V0*es@1#*dToYa;^L{%b{E;)!U8+=I}3Ku?I6^JRLs&>r7}4}`?cEeKT;PAr%bQ^ zC|HnxQlc)GI~}L4N`d{o%sNTF^GXEiM{+zqG5(y~jZ^~*2`%=o0wo2}U7d~xX%wZR zV+Halzm#}ksp6{YeaUAnWktE{C%HMLq@;!51bUj{nWRAk_%LT=b=cX1h%sRl~Kg)uWhT(mx zxc>CcL8UNfIG*i{r(WSpg%8^n!!8(9kszl1 zcf?EkS+YTq2}A4auMiJL;ajlH)0^$%smAbT?bZ5HG4BglPyz!H`Rrf8#bg@zHwD!L<8m zM*+V-9vp0wq+*$ITrV|7q7Yf$&5UYv)*% zztFO?M5v?Vd)hhViM>0wHjMrMs*EMm0H)9Y3J*8QIe7I3#M5=zurboaR6HO>*CCGPs(bX zr-8B-sxcS&^h17VpGVC5fR+d(`eBU@F(Q)Ch#5|P5*fL(1eH2U`otk0&jJjO~vuRCr zrWq|DIi4nP`mR#u+VYlyDB^t5Jz!C|^4YhC`BpvWj=06h3n{bJzHL82FviC<921kDHu4MTClyMc;pj95 z9^yjj} zEer9$VuDm>5Pg*0FQCxS(c{*wYK%rRp@X(HpFo;4G&(3yRZ~;L(xSpQE$z-#V8gC!MRzHPigShY)E%z+3WWaZhML!nJ4hrhtnvPo z9Yv=mtvi!AlrJ?z+)6N{041i@G<%`yKhqXoHxkVeKlugd3KG7_c27?b zsE4B2Y$xZ_4e~ThrgGt=Q#rD7GooZR-_vm>y-uPKK7hX7bVArrSQV|!0$+b`j z?R_Mnm4sgo%8=Inf9-vHT+;U!_tt8ywRLmmDle^zuDq?B_Y3V(&P-jI;+>|YC88mM znhLYFmG`Bkd1YHGryzuisGwLWWGZAPD2NoOqzH;PRBq3=?YB$IJ%2y1=T(1wU-7+s zF6X?@`<%}?$L~8R>mE*yEul5nH-tp45iS3EXfij`;InZ1fGcvA(O~+T-rmNsPv5&c z+j4#9omlPp4F@0h{d}%}oA$Hx#vgC(gJ(}XSfOpi?}tQ({MP+;ys5g`jTrpJY91MS z8K~97j4}$j|#<49d^p0Pvf3TMK{-!!K1Z7u3FD<>YrVsqQ8mN-w zyxHO;MeOW){C51F@J@C+@@mArbLV#mNHe^5(w3t)nG^v=?}iomTx8o%9O~;LO zD0y$x_r2TFzHFMli{zd^=t#Zud9MGu07-G+UGMCDd&iGHbKJb$ZL8Vaeg3PP508iT zW)!du@#`!0f8MhW$SA-*2!C&A%f18KH_kADW?7rmy`N{BJo<3$eellZqvxK*-1IzO z^peH?UlJy;S$TngU17c#=4)*hwcZ{`sDY#^)+|g3^Ky-xMVB_ZwY8V49 zsGm)L_){H_FtADQ(&i?h7V%4yb5-t}YZF=;EA(7=pSn}f-iOo|zMFK-t*<{hxnA`F zY1fe<<@b{7L!HUt!m3-q$x{EiJ_hy8b)_W5*W0Z$cea{uZO!|*x6*&ag7fK*;J!9S zZTESUV#n!qf9u*m)D9>$yabeCy1YHYJHM{@^Ibc4z5$d{3^c-bw=PeZ1K!%RnbEGJ z8^VKwgS*QdeUlqQJ&X%$E(jV^DePfo@dI-}mp$jJE0+xv%Z4-4PpjZIDa zY%;oRzMBlT|ESjO%C^RZJNpkj4z38>?{5aqc7~*0!yj!v729yocd+~MyZXiNm0?S> zhSaG6L4o^B9%trUA^(%}{|SC?0EftjyieDYNS^^!K_B(~S^_vm!A->5UVfCB;^278 zk3ViJcTU`C3bNWN{%R9W9lWqg<1a5I#M}KGUOaB;H`_}>!Y@mW==2uyc+-Ip?UyeA1?o76gYV0>GUN#bQ|j6kHHhWF5W*= zW=SqCzBT$5*_`}E#otQslC^5vz3Im~*#9rhLq0Kkad7(PyC3K`rY^pybVUbq((?+}=j=7+5tfgC-WmNx@Y_o#e_`aW()+q?=SOc|RJ!Qw zeFhxWLg0^K?|i0S2$Bd=Kq=TXmbTo+ByCyp-5YPNTHUbrO%IXm*}lfLt5(0WY3HGD zU;O9a7cO3_^4op%XkA-g`3boBRLZ6w_Y50XTufQ%*KgbU_U!afe|nc{@zOj0{7S>x z^kV*fuRp*1bX_{tcp0u9tt}PTp0Q^n%IT7fH*1mxkNmdh)7-UFc@WLxO7YK3<6^>I>~ZPo$LgC($Tn=VqGWMKF_oQ` z$k%KmgGxX8$86jBK6;AK^SRs%{e7nC9;;bY?a_52j~L#0)84+L|Gf8&^csU{Pf?A1 z{XNrn)$fR{{}`O_vQdx07a}2JP8FXSYvKiK@>-7Bp!hm|2Tcq}Tlmg}~Od zC9G4A^xF23pV**{{Mn1{F_-?i$A?#G-V^vH1QsO&(|PU=$^&MHlsE<6@yqv$XVyatzcvNyEp{r289NBe-d}aZmJ1f3T8vY?F1|E0U!z2$hX$2~$dk>3B z;k#ay&h*OThw>NCwPQ>}F91#+Nr+Z;QSX85XgV5o#)sJyls`^my;hya_k^-n6&H=2@b?BCCx4n|lq~JEv0J4Q(;8+~6Ia z#4tl_P^zqbAnY<@+Z5W}kjBgoKuqN*ntZ|xJ^iy;nQ0p_o-`?pc`SIBbKK#Vg#C}z zxl^-dPX>iNeTbv01F7nIDL*9g&Q&JqR6@}5R)r5{xo`2&Ww#HM!zpng@tu3&-3OTM zJBFu!NMa!>^ftOJJ}o#CsR^Me9u1P7|Ine}>y%{pqO~U6u>R>8#mrm652qYY3FcIU zyp7ltINoi#(P6gSIxnSAO$kU*8hO8DZLz2@OleG3`PLgBWJsVVGUiK)D4CIYU#Aa(jLFGf}W;TF*O++pAn&OH3DG=G;Fq{QBsj2!f1#(eX6_} zT9)wG;{aj|xGS0h$WWf!Vff;+%W-$A$T)e@T10p=Ox#>#ML@26F#nQ z&~b~n7@!VU!mnI$ZidXZCSF2_*LH=Ee(XU5(`MS1;}t`Tw;;~-s>@S^%u#q!st)J5 zYXiu2EYUOJ+_Q|@G1ekVIheVIUh8wXc`9Vmh>494PgI;yfMyD|ZVcHzr7QQuemE86 z%p7t6`bdeGn7O8a1Z@-PV|R(shn20fLWcVN$cY!0(0TKK?At~4@wm({Aa_zG8xZZ9 zjl)J<_5E84zOaQ#y(u&SZwW*H{b*^+?9 z=}ZSX-ltu${@^yl15mk}fsOZ+@_t=*WPiF?rX}-auKfqtYp+-bhIc6Jr=b~ zkFn8Zy6rd2^xd_2XphuBFZ;vB?#9Wd)yqWPg&~Yi10Ot71bccAde<5`F1*rZpbbkM z-$ChUQ3?zat?38cR9C+jn3*;c-Maa<3z#1IWRRz*0NZr1P)Mou+=SW7YB~i^9mSez z6^$X&y=vbxoQn?f>15KcoU-AhacV|f zJJ4&*UARZv>4QE9z$K?S<~&k%je4*(Tux_9}L(Thq0>jc$bm-B<^P_FST`#Kk>?8OxkDa%hXx= z>f`?uR)92rFmO&Uwz}L`h7P5F#Y##!O%oCWI0Z&apo*~ojs|7g0JY%TRzkRCZ&7`W zQ-3P0bm>`znFnL^A!?C91S5N!;)M-X3@aB}&4-4BC4T1OpZoJVx(BI@AxED*3D_I8 z4Jz85JQ9!OR z!7Rn;zAzk43zwu&G+rUi;gRZPX>Mj3Sok3`bpfG(GBuJUWfm2(3!*GiP?YQpo%lEV z_O3*2SlKcY^8qon@3Kgs8pU`jP7Nifauj(`$X4CLw+8LXSh`bdD!854I`*5q&JC1} z(2Z;q6$rqWZc{B(H5ndc2x3fZS%&Iy2bF$s4w}lyY96i;w%E1SPc9^> zEQL9{QJ|e#KP?6piHEg@rD9U!dz0bv`|Lbo8S2aSFYFoj=#VYAW*vl41EhzAEG`2J z+7dIhIAW-@h%eS;gd5A8FcURKP zHY}@fViWY-*OM*mXA0IAVUIL#At_8<>L;Q*odgF2_ruX2sn(F53Jonu$=1Y|A-`54 z#pJy9UaFG*SbMW^0c)HM836X|x-R^T`WXfDA$_i-K-8{5WmyZ*gQvlp%>EL5;3B;; z@P)sEe#6S=cQ?3!+~y_Jgs4G?D8^-oCNoJY^~t3Ws81s{YJ4qdlcL3=E+E!q_|mN_ z`$1u|kJ>?L(*qQdjlGF}l*yC5#hBfoj}%`G&EH%r{+=x~A4az0#P^)O2~Csh8!*|L z(!};18ytUDU5|g8%b?K74#Vg~WzV}Wv%m(=^bAaG+(a-`a*_&3gd8-yo=tIQs5F^p zTnDe3xOS89D%r6ZZa@Rnn@(_Ci_&d>wzfI>T|gM^JWlmj`;~sm-m{AT`6(?HofK?A zjqM2(fLxR|ByP8Rc!!TXir}Ip0JQy!b&&?LGs{uD zeUaTa4pi5byBAhdL&8TCz!4bp{zi}?aO$i2${s+E5s+A*Ni4kS#MkWBnf+SgE1TdB zIf0~|xmdf1LBehcm^xwgX^@#T`^-2O7W3)PI;M4oYS;4bGV^qs`5n_%v!6KR@x(M?X0QXyluq)Zmxo@J}ECK9~5% z=VIJfu?Lems04eyvgQu26`M!>8?q0bVr~WkNbrj5&U;@-@tg0^Dr@MBbMsBvtpDSWYd&~s_yZTxi-YOk>HjhG^zkhN zi4=i26QYtWcAc?jp6QCso*Kfi6D&2^0g^M+scmr;nDc7s(%O#oiD`11a38xr!b$&o zzA_llu5t7#!XMgoVO6m@Rq~m8cJA%ZFK<6O1o)wl>SkUQdczS=V7zfa!32gFR@!iL$ zfVrtJ2m&a&Nj4DvGD9!-VekF<<-Y_NxH#o(aHFYQh~@LsSNjvgjIy>`W=e^4bOP5p z>F#vNM`30Rx8PH>GhpP_S0>%IVDYr5oj=<*cvmiC>(p#9Cpa!g&&TC}H&6{f0FV>$ zz4g}FemMV}!OoJ#c^tSS9~zenWMX9JQZdstjT1A6;j9#^>`^GQtoG_7S&{nkV!1M8 zi@E*5xQP3~6c#T4-IL!qmI6wCe8yj}RMUv+w1rP?os@ZG!M-^qdrO$N*FHo~3CdC4 z`JN*G^p$l_hwXVW!go+-yT&T_YM+^CKMK<=bD1Qju`^Hmg52xIL7K|N1q$8hvJq+^ zAU~Pe>dJD0-(4nA>55{9sNVd=WtqA7*SE$9d2vT07Fg_@8!diSO}q*D3I$q6Z98=J+Uq=n~iL($I9_MMG8zp+<L_~Q{fe0&tzx7!d5-ixV?o=&?VwqCl%4r^;0!*p4YSEijz*|E zE4Ox7Zah|odP=J7;QOQ^hn8-APnyou51r0TVvl!;y7S8Q*LDZ~86|_^S>q{2+oNbI zBlxs&vA)bLQE~mSV%)RnSawhFCNui1k>Xyd?yL6ISn&mK`v-WhQxw+_JhXJFK(DF^f#uQsg2QNizj+yIhockfeT%I!0sgV2T zZm(%lH_OAU2r0z|?$~9P!#H`?tZ=UAB|GzCM^0VVpKi)9TIhvA?9nnZH#hcUDI4gx zBF!TUeEjJ1HF1Zv5h(G)iKK)NSde-K&kxS`!(4ad3?z?r2LNtD0BLUK_{GJx-FaDD z7YQdx*A#luVMAUY%f;N=S^lVTG&4)A!M3z^>9p~NEjxYd){DOL&C6+NhDe9g=GTz! zcy2O@zjqGK39Lj$2{sv&rQLn*yS0m`4k#1uy#k3JI;Uu@V`q!Q>Ob9zRo-nxXvb>m zCe5*id#`NlMh+()p^5J9II>Pqh3-l1%6ArfRbut0oW+(0pyPhG3psrR7Mfk|%ib;q0fi%$%a)hC9!jzac`E%0K^x)>ziZpn1 z6r9oN&dA(hm0>+RkQO6um5MG&kn2RQxw$sO%2o%>cVC2e-jXnr!B$g*gU4eV7g`tX zqvlvyeu`Tgv4Xu!I6pdn6$o&@=CI&*)Q%DS{3}^H|(riki`~x5plf;uUl*g_%U$ct*z~H z#nphMr896zhVH-yEG967oQgKo@Gen-uy>|)>QdpAG@mQ>y2qa#s8ijYDfMx*Dzu={ zRM$E(2bORG(b|gbQ;uj+#TXXu-7bo253_dJvrwuV@(Ph)m@M!-XQ|B*t{a!MZ#0cf zn-oT{)nC&T-^OADVQ%L+_Z1cL3PP|}s>tu1jKyFbp!IaJc~M7aT(JrLeD^mbX^GVm zm7PW1<|$U0`!hbfsVi`H_I6dirQo?XPrs>RkRYvggr<2`l2=iHPo+}htz18RBVXHoxng;TqPob<-Z%@A(I?lULe6(UIw*Or z2hErdRhTC=ni+>>d(bB9C4)r9WL536n>OmdY2=?=d|>!Rlw4^x_g)iS9SNtq)ZSKY z`n+h9+iYVI5_GeCak9Nz;sRP2jF6w5Xa^-yUW6$nHO^$`j~2FTjy7_5%AYRnwxXlv zQbvleikCjhAm9Txcg>h}{x2G7Q`+SF5J|VGAb=KCLa@S@Ch*LfZKAz7IC4_e`}oFI)(mq*^JQA~=&ti@CaO zQ?8m{N``S!OgYv6Y`qkTZF8=5*L)V}q#+?rHaaDW{7mlI~>DHrXPT z^wz&kQQH%MS@^0x6fjoRB?_&_ruz0ku?a0!&m6DnvYEy`cU8OT=UuKI$!(ORbX6oU zKDKv)5^o4K=TU!z93Q9Mx9{SqM9nxtofM>g)ZMvNuK0`;Dn!ge9}YVo61Z7Ng?d@s0A1a+q2}VBy#UsQ zr3lM!0{uIAX<@sS_9=Hgxw`u)p4K3cRp>!`Z;&JcJ}?~mIKC%GL7i&)DL7$57vUU# z;VDz`wLM~2f0G?SHOUm2DBTWt3*v@cz1qhn$rx_q{^9rqvu4>bAaauumqipTpQU{) z&o$|GabPVkW*oq&{AO!|3AsLr!VEWEei6gn=R%}urdiR!SGa#t{w=k5 zagE}nz1ze06ZIBAB5*UE(tgh;bzh+%EzUxw{fPX1(a_^GI}upiX0^lVXf7dat1gK@ z6F^3J8>V)*l9qW{qFG_Uc4ZCCoX5 zgPCg#EVHv(6VitGqq3+eyQ{ac!<( zH3~(~PaB?{DA*iPd2Dmp4tAJw@Vh{jp`N5nEA9oIP8_z>!@As{JZ+SleCr%NhZAb@ z7W;1`_KSccf=ZF2iR!Fjs+uYuNikrE4zH&QnUTdt?iCkM%J+q_cYV?J-i^AO@bL~w zjfwNZoU-F?s2tHVhDV436@@8VQ5jEWBRWMi1}($KZL&|AI!LXT4*<2ScLE6cx>c&h=rkt13 z9pV%shZNE6aN%yB910yZb%PXBo#Whv0Xn3yW`$I z&K&O3>`pW27}T3PFx3_z4vhxBCsiMdNr_Fy!`Or&c5)z=8(NlGUFY5 zjvuZWTgSSgkBA(Nz&7JU>-KXJu=pFjX;BTj^}^l&D_dn}gvQUl**h^OP+)6^b{7;` za|C@1iE^PC<%bXIlclgsLx2!x!2H*gra)?JzAsg1i}Oc9mTvThR+<$;Q8#RyRDq?Z zgoG-uf4jj+&8y}Aa{Q~hVBH_~53+W9_AkXjNB`yc4{0QmH}igRYbD4Jes&WbEWsSa zYRX>t{Jlu+JcWfjpz9#{@go+w<){@p^$pN)0)ut$g`eLy|Jp8R<_8K-yWLG z4n%O$N|l{;?h!36&kLfwzO-v*oi@wO@F8=4=u1f{@0FeLrBry#HI+6ty>IPw6s7!J zPY{CeC4x|IzUr){%e;1CV7PyJO@Gg}?GRv_)-FGVy}ENRZrZogW772%mhs|MZQs6l zP(2NMDR<#7S1`YQ_!S@MciDI^0LaW}+bHC`s&N3t1U_f<`g8x@s@IFTf=s*?m6a6i zwWz!nl|SX;HAMeqj{b4++5r7+{r_TXuSMk_67rY%eJv`lMdi;R&L3vwwRwGQUSFG6 zz-+u$gx8AjS`l6=!s{^3KM2BW7X6wHp8>s` zk;^74C}N)OOrLwUH4|_)j=3@2e6?5nv{Kp60p`_>PO-ZC

dYYuVeUq?tM&{nm}E$4hD79?jd(5?jhW@p&F(0W9*DyNl-AKiDsA?PTW+-mVjkp7IJh48apVf@P=tIS_Fdiy#`K$nM6Lt#*KK9CS zKClFBW9#eJ^+tnQV~Zz!P$PLqpcWFzRZS+?0!YNyoKIBWah?IU>k~;0Sdx>mw5JCh zTFH`eoBWL(i4e(9^Aw5}E|^|cMCal70S(xb2RoM&uJgK2W;`h=K(e};EoDi3V|$6# zs!@!6mXu{jRXOpST3<=lc`-81`qNv;M@QH3n|n4k$egUD zc8U^BRW2$v)Lx2`H3+bMSbnaOPxc0Bugy{HJj(2ariblEZ$t0i|JUYTlcK5*^0woIUs-=DcsoG}P zy3~n75D<<_eC&ol2_#bs6`3eiAeIR7I0v>L>TyD;{+-u#F=7`EovpCkQ9p#CmU)-eHtDJJzDY#_M@Tsdc&*$QtRLJ@+ zfBcrdezub*U-$&2sci(&IXf(eG@S;&5rPlcuB4YfHHSu5%~tovO3@X>W!S|=7oP@G*tZ=_R0md&ohRLcXj=jM(UY?D{=QEK#N4HDV zE)+&xDD+IIIMnym+(iHbx34*<^uNV}mCF_$G!=895rf7CP)SS-ZES2=%-e>0oD0T7 zD6`O*5In=~)-Zn7-rj?@&;Aj%!2VOKbRNAI6-EH`%nu~Bu;nzEH;|mz=L%YF>EW`H zLDmWDBl6vP?SsUT^W2j!8^k}PXn?V*n29?fYaqBlIM>-YR0TH|oa!5{aTG7hmVp<{ zGRWqI$U>C-%hS93BlP*E^9iOIh~1j@@x}lc9z?MV*eOe5)IJ$FE!&R86TUo;q2+LS z*WY2O9<-moo*mU0klUZOAT9GH#h9;TIAWR(jm=e`q}J4hHc?~Z9$uYg_DGp34P0{C z!ZXKf&Q9$di)Y~w_AU{>a9LFfIB7c{f1x?QJ^72${7pT{nx_7#JU@{ANf{g&mKfp! z{P7mk9c>3EGtl~ld{;R_c>BFj0woX*s&MG84O^k&V=AWQ8zW?N4MlrzaBmXI>%!{nJU>t z6i~O+5X-mqN0{MeKayxhk{Zkpgv^?A-mk&>dOU-7J`KVx1W5fr+$zVxm+Gnig!h5? z^lq_V6>4m*_GB(`!mbdpf0!c{!SFDOWS)<#sYcLXT(zCufe2A6ur&+Up7dSa!}4m# z=+)*J9Az+jaI@=iph{4M^vO`Qij?|o;C_!%%~8<;|7J!{5~28(*LxDjCVwNxid*s; zULZv&BwUSnQUCRdbOf7z?MQc_#wUpeBH>9 z1=5@Bes>sK;wzc^ftzS=*NPUM&hhftB00=!mz;+BrAEy1713Y;;VQ|=G>e-w3?ZwK zZjGH=+F>-pRp_W=U~Rd34-QD`qFE#hEFArBh4+J3A`ExVtb!3>AHSWQ!wXEZP2pZWx|rAGpL51J&%#e)yFUzxaZv?Mlx0-VUtO8i@5EFc z8XH}{6&|R}D=Q^+zu6E0;B+Ss8J0tl`r#)^f{~$g2t_Pr99+0o=;Z}SN!8FX&^jK& zuCg3acUsVZ&}5R4EHCx)_i@htGDG%k9qUAf(5aF&mB+1!4=W6@_GCbTWHxRR2UfKN zJGN5(SbC4aZ!V!JksbTZ0+6gK)Tpi&D|6uiU=`upF)M zng4hS_^p9r>M;ayhzk!nQJ+JRb+U+#fl-~!K<5p1fE$f=8E?L?Ors4j2m-1U_xnSm zKQE_JQ&Nfyj?euM8(W`D4XOzT=tM-noD1l`V?WDLgX)-28^%TS6BU4V-V_15 z6orS((DuWq;eA0qQTdH6B$~pmxFkX!$|v8ghTZw8nEBP=??W{^M8C4~%mvu6ty*YurWWB4f#Y9g4*DeK@sPPCKs|GO0D%lG% zb?Kq1QxU^H%iG2d*n3H(i9M?ci}ncwQ$b;h5?_s$HW_t}X|_wdQjOZ3y{n3K0Kiw< zRLGoo=)D(g{AFceSx=2wa3#Stuw2p~K;}0KS=z#DG)ClgOOGu+$aGq|-2vVLPC!Jo zperisdM(=M`emha8*jRV<3fY%1Q=A1Lj z%_55dWwG-J$&z@T{Jfqgo@gE{wxYpbUc*cMhA_NP=SqMmy7xv_{VG3+%o%NhQA2S&-~Qy5?QGQb>@(+> z)q>pqvfhy~pPqA{mkP@WdlX|~j&ZH|^~td?8qd)#(8A=1j(Xn_B-T9E&7aTZBBk4} zA0wdquYb%!@EieFQrO_SIXN89Fej`?ce>W_zB|ij>8qOB8z9N|v*-0%`iafu1thz$ z2z`L6%_Ceq%ljG-cm^&M$-x0Os_wY|ZzDD4J0Vd=yu((=h~kvRn)>w2a^W6IjQ2Co~Om zN*!qMs^fh_Tr*4=F&LMJimefX%*27ww26%8?Lq*~;@G$)+c3?!s!AJxvk#b_G^5_t z(i$L=dQuF4XP7+~f)DnVl4yB$9;Gsr1-I(g^FU{TA2@ma+a2vasj++lsey&M9#pv; zy@L5$eS7PJRKe+6jA~Z$AOX!M7>0KM*u$unQ}}3BRWdA8k=bv{=of5}PLUYqb_CWy zW4|p=>|`N{TcMq2d)sW(WBx9+Mo&C!jlOG89R0;I zb>`lLno`naRJ^@(e?KvK8xd7eAP(f9+!=LPWT-V?f2!mrpf{`&nimcY?umK=w^HC? z;9&a0%?(jt{0r0qTg+YoOk$>lw~dGn22&Zp;HsfLXga4xfoFC^(-@|at_lh(W!J&? zYsS5|NWA>R>tQ}9oUNI(p&WYBg6;L>s65ielMx*@57Yjw`cJ-a-5`cd7-)0?u*MPaB#w?Xi1l$!5+#3-7(h3spgSg<{Gw<) zX-R>QrgM*4mi|7wlo}7=E(nY>$j9W4ybQi%-q~uN%JCqiuXa z)BBzDp(;Q!$#txzWSIrbg&(-zUj`@+bKd6mUrAQ~xVW>l>N*8y!$gthXvm>Tjf57cPm_oUZ1vx(3g(Z97)U(5f_MR{gv+vJS zGI7}1iL&gy;d^0xR8MC>O(;-dshU5}s)3$#meP*QLuJ`#s?fPnRms4z$3#^%>9v?C z{{x5mR`2%#*O^JdF46`;MUi8>1t104WDU@GLpp>v_m@yKz{>*o1TtW}s)5vg7WTR8 z0V0Tl1<%^!Du6IbN-vFs{^e69e+Nr30W<(83zn4>kfP0E_QK7#m1(ji$$_=6Zv&37<}EBFP538BW`#&{2hFyd(}C?MB}x;?RbFRK8) zM**Gp}*6M%>TBhcJr#ff3H z_DsHZtU<*kO8s18U6{2`T>Vl$2r)3>5(Z4UrKNj@N4`(dL+mb2G6KK*6C*yXbTt8> zlQbaS&etiuS52z5UE2%KV+_ML_(Ab;<*w7lkv=v?QVADtJ-DCSIzuE)yuNyRkl7Ws?yv$ zC#~-IdO%<2U2ThUWp zx!c2KaLw%4{%?ZpOEj6vh=Lj$tBOZ}y;Mv}0J3enKe;SIMrO`a_uX`~1>D{RTE>HR z;dY{z`=Rp(XA4EG0Aif5@gGfS!0nmc+c5bMWp>m?5LichzPe=hDo~Fz6 z3U&?k5Yfi%KH^Q~QJ(=py+Ri{PrSTs2ROQ1OT2<;X|62sB{lo!fL*v#%!wf_85)+t zI*C}0_@i~lzxZ`b^Py7b7=qcka@TI3+^zPeM>dZxeemXUe zEtB{ABYS!r1NXc2mOcm5CYp&aaD})MmwH;IcXk#5cs|MY+i(C+CXlpApy35*EGW<= z)_Vt(7%0Qzo;3S`QzPcbSSB?^>sXeSVwQ}_L(>IQlC;x!fH883q=mB1vHr4ZK7!f7 zL&npNb^ueUhI%`57%SA!y|d|tTROb+QKnxx7|MsyMS%Au4y+ck#+iIl2`HAu80!aI zyK~%X?b%a;0J17VAAkx>0q}RTSYA$W!LM)$h15`9S+9vvs%$Goww1y-$vl%WZ_cUU z#(^=u6J>qYGTZ;yfn)Tx(;mHcVY1E%8qN^P#0he9N_~OImv9OzT)+YN8Wi!1vR#uc zIyU_Anv}PaBYgMzFmt$(Gqh+)EsPFR0xMAiQGSiA3D)3ee_OJx zfSx%gA!`~0ph}~zh83O8!gbkQEkNIDF|h99F-aMGc3DwU0DThBVJh{pr7!@>0zEcY zp{5Y#N3Nc`a&iXYSyVEqM$=?@P@k>1q~Iv3Owt05sZEj*$H)@kx!cwJrhWp{pI%eB z9V$7uQV`-3a@)y_$6niTj+$mdi2{Y^IS!jZBW#JFN*QJL#k94w%$&EmIe>fSuV}tJ z5$Eq}##nH20n;>eh+IcNb?^h|0>EeQmpN$ya{S80et@ij+yd#M6&YVc&LNPa zQy|@hO|1$5=oDx;4@?Jm)&w^QVC*88s~;6qsM)W>q$q2LzIyk|Hxmx%bZotVwB3(a zf4#Hl2}*VLoBY(TZ{0N7!L=#ihpDpIvbzL$f;To;=G(=~^>GObj=eS|dE6(FwlZ#N zgW}+tC)yI9R6if=^;aBQplb}dt^e?IeAw*Gr0Y`XAd%xA+&^onswng!)#58*(KUMov8eRRy|Cc(IQDOQ~PrU7K{xL`QTAFPiR) zbIstFG`j6>3Njg;IF{2FjIfNw2JMi6O5RTEj3W}YOr$2O9w?Lzk+!18=dvei{8Lm> zr?IVNS8eAT#>!lXqtQbPvVvA->`-6y&B5+~m3V8T`%iQtu2TLa+9!waIH;6Xb1w8@ zThYQ9S)bBWd0ahP$>OLzMb6f?QBgTFX|eUhrsG4BS~jmOC`8#wCa0a~T7KZBU1-9k zmFur0GSldEZTx(+aQG8fO^~p)|HM>Os&Q=JB%4wk-5D&l&=- zz&wc9eO^HsgStqok`MelsPW>fQ~CoNX_Sb+N^l9=1(%ABUM0Rp4cCTY{=QRp={GHOkzvz1wcOIj-b*4e$Phda}lc z=xXcj6CW7d%0Pm;rWWd37slDeqi!W;nkpkAvH5vOxifBlX%Jn=E+Wovr(6(l8;PU% zs2QaHv6Yrawfaa=jcnX`iRUY(rSq)-J`O{BGWpFkacD>AaZ4pQc;s?Ld0KgBN1dc5^2sYyawMvArE|&imeZ3R=YT)lDlLd2mk{BRiHI7^lTrj~f~<5vb34ZR5&rYXVb`FL z)-mYNeSGwI=sZ5Un6gq}2@vZ3@1@m+_M!7nf^uy5oa5+gUES4o&enAl{1k0+D4A0o z<6oTnIUZ9B;1fk3;oSs)^BN?B7}_?U>mu zb0dS`7V!Kj*}^T5v<~qfOOv=7WgVrW@XM(a)M`l+HEo;@(`+chnoZr|Q~3rD`n27F zD?!oX;Ht`^I;w`6<~8-J5+$2TaJ;Z=Wu`G}0Y6BrK+apN*f5N>T24Ff>MH$J_p9>Q z(>p3^SW-nR_Z)^j*T1B7Nm)t7&_A?+t)V^=9~FeKsAuJlO=Ct$P`!{etvu!VO2YW` zqv?tD15s=YN^VJ)`cR+Kq-j%55=&72#v-5e(tp8b2yQ!4SX!l literal 0 HcmV?d00001 diff --git a/doc/workflow/share_projects_with_other_groups.md b/doc/workflow/share_projects_with_other_groups.md new file mode 100644 index 0000000000..4c59f59c58 --- /dev/null +++ b/doc/workflow/share_projects_with_other_groups.md @@ -0,0 +1,30 @@ +# Share Projects with other Groups + +In GitLab Enterprise Edition you can share projects with other groups. +This makes it possible to add a group of users to a project with a single action. + +## Groups as collections of users + +In GitLab Community Edition groups are used primarily to [create collections of projects](groups.md). +In GitLab Enterprise Edition you can also take advantage of the fact that groups define collections of _users_, namely the group members. + +## Sharing a project with a group of users + +The primary mechanism to give a group of users, say 'Engineering', access to a project, say 'Project Acme', in GitLab is to make the 'Engineering' group the owner of 'Project Acme'. +But what if 'Project Acme' already belongs to another group, say 'Open Source'? +This is where the (Enterprise Edition only) group sharing feature can be of use. + +To share 'Project Acme' with the 'Engineering' group, go to the project settings page for 'Project Acme' and use the left navigation menu to go to the 'Groups' section. + +![The 'Groups' section in the project settings screen (Enterprise Edition only)](groups/share_project_with_groups.png) + +Now you can add the 'Engineering' group with the maximum access level of your choice. +After sharing 'Project Acme' with 'Engineering', the project is listed on the group dashboard. + +!['Project Acme' is listed as a shared project for 'Engineering'](groups/other_group_sees_shared_project.png) + +## Maximum access level + +!['Project Acme' is shared with 'Engineering' with a maximum access level of 'Developer'](groups/max_access_level.png) + +In the screenshot above, the maximum access level of 'Developer' for members from 'Engineering' means that users with higher access levels in 'Engineering' ('Master' or 'Owner') will only have 'Developer' access to 'Project Acme'. diff --git a/doc/workflow/share_with_group.md b/doc/workflow/share_with_group.md new file mode 100644 index 0000000000..3b7690973c --- /dev/null +++ b/doc/workflow/share_with_group.md @@ -0,0 +1,13 @@ +# Sharing a project with a group + +If you want to share a single project in a group with another group, +you can do so easily. By setting the permission you can quickly +give a select group of users access to a project in a restricted manner. + +In a project go to the project settings -> groups. + +Now you can select a group that you want to share this project with and with +which maximum access level. Users in that group are able to access this project +with their set group access level, up to the maximum level that you've set. + +![Share a project with a group](share_with_group.png) diff --git a/doc/workflow/share_with_group.png b/doc/workflow/share_with_group.png new file mode 100644 index 0000000000000000000000000000000000000000..a0ca6f145521ca7a7e359cc53df73e69f21cecff GIT binary patch literal 53784 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 literal 0 HcmV?d00001 From e1dffa32db3d2ac877c0fc6f1728566d46c9e93f Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Sat, 12 Mar 2016 18:01:55 +0100 Subject: [PATCH 049/797] Render project members from shared group Signed-off-by: Dmitriy Zaporozhets --- .../projects/project_members_controller.rb | 1 + .../_shared_group_members.html.haml | 21 +++++++++++++++++++ .../projects/project_members/index.html.haml | 3 +++ 3 files changed, 25 insertions(+) create mode 100644 app/views/projects/project_members/_shared_group_members.html.haml diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 8364fc293b..e7bddc4a6f 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -27,6 +27,7 @@ class Projects::ProjectMembersController < Projects::ApplicationController end @project_member = @project.project_members.new + @project_group_links = @project.project_group_links end def create diff --git a/app/views/projects/project_members/_shared_group_members.html.haml b/app/views/projects/project_members/_shared_group_members.html.haml new file mode 100644 index 0000000000..62888e4193 --- /dev/null +++ b/app/views/projects/project_members/_shared_group_members.html.haml @@ -0,0 +1,21 @@ +- @project_group_links.each do |group_links| + - shared_group = group_links.group + - shared_group_users_count = group_links.group.group_members.count + .panel.panel-default + .panel-heading + Shared with + %strong #{shared_group.name} + group, members with + %strong #{group_links.human_access} + role (#{shared_group_users_count}) + - if current_user.can?(:admin_group, shared_group) + .panel-head-actions + = link_to group_group_members_path(shared_group), class: 'btn btn-sm' do + %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 + - 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/index.html.haml b/app/views/projects/project_members/index.html.haml index 0f8848a5cb..ebcfc907eb 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -18,3 +18,6 @@ - if @group = render "group_members", members: @group_members + + - if @project_group_links.any? && @project.allowed_to_share_with_group? + = render "shared_group_members" From 19d1455ac136ab06c2153714761eadfb9ada0f0d Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sat, 12 Mar 2016 20:22:38 +0200 Subject: [PATCH 050/797] Change notes to new styleguide using blockquotes --- doc/ci/yaml/README.md | 37 +++++++++++++++++++++---------------- 1 file changed, 21 insertions(+), 16 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index fb62ed25d6..a4d621c0e5 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -116,7 +116,8 @@ Alias for [stages](#stages). ### variables -_**Note:** Introduced in GitLab Runner v0.5.0._ +>**Note:** +Introduced in GitLab Runner v0.5.0. GitLab CI allows you to add to `.gitlab-ci.yml` variables that are set in build environment. The variables are stored in the git repository and are meant to @@ -153,7 +154,8 @@ cache: #### cache:key -_**Note:** Introduced in GitLab Runner v1.0.0._ +>**Note:** +Introduced in GitLab Runner v1.0.0. The `key` directive allows you to define the affinity of caching between jobs, allowing to have a single cache for all jobs, @@ -394,12 +396,12 @@ The above script will: ### artifacts -_**Note:** Introduced in GitLab Runner v0.7.0 for non-Windows platforms._ - -_**Note:** Limited Windows support was added in GitLab Runner v.1.0.0. -Currently not all executors are supported._ - -_**Note:** Build artifacts are only collected for successful builds._ +>**Notes:** +> +> - Introduced in GitLab Runner v0.7.0 for non-Windows platforms. +> - Limited Windows support was added in GitLab Runner v.1.0.0. +> - Currently not all executors are supported. +> - Build artifacts are only collected for successful builds. `artifacts` is used to specify list of files and directories which should be attached to build after success. Below are some examples. @@ -456,12 +458,13 @@ be available for download in the GitLab UI. #### artifacts:name -_**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.0._ +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.0. The `name` directive allows you to define the name of created artifacts archive. -Currently the `artifacts` is used. -It may be useful when you will want to download the archive from GitLab. +Currently the `artifacts` is used. +It may be useful when you will want to download the archive from GitLab. You could possible have the unique name of every archive. The `artifacts:name` variable can use any of the [predefined variables](../variables/README.md). @@ -517,13 +520,14 @@ job: ### dependencies -_**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.1._ +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.1. This feature should be used with `artifacts` and allows to define artifacts passing between different builds. `artifacts` from previous stages are passed by default. -To use a feature define `dependencies` in context of the build and pass +To use a feature define `dependencies` in context of the build and pass a list of all previous builds from which the artifacts should be downloaded. You can only define a builds from stages that are executed before this one. Error will be shown if you define builds from current stage or next stages. @@ -537,7 +541,7 @@ build:osx: artifacts: paths: - binaries/ - + build:linux: stage: build script: ... @@ -571,7 +575,8 @@ However, only the `build:osx` and `build:linux` exports artifacts so only these ### cache -_**Note:** Introduced in GitLab Runner v0.7.0._ +>**Note:** +Introduced in GitLab Runner v0.7.0. `cache` is used to specify list of files and directories which should be cached between builds. Below are some examples: @@ -665,7 +670,7 @@ It will create a two jobs: `test1` and `test2` that will have the parameters of - postgres - ruby -.mysql_services: +.mysql_services: services: &mysql_definition - mysql - ruby From 49e51753001d0b54adf785792141844c0e89dd70 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sun, 13 Mar 2016 09:33:10 +0200 Subject: [PATCH 051/797] Refactor artifacts:name --- doc/ci/yaml/README.md | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index a4d621c0e5..83928b8159 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -461,19 +461,16 @@ be available for download in the GitLab UI. >**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.0. -The `name` directive allows you to define the name of created artifacts archive. - -Currently the `artifacts` is used. -It may be useful when you will want to download the archive from GitLab. -You could possible have the unique name of every archive. - -The `artifacts:name` variable can use any of the [predefined variables](../variables/README.md). +The `name` directive allows you to define the name of the created artifacts +archive. That way, you can have a unique name of every archive which could be +useful when you'd like to download the archive from GitLab. The `artifacts:name` +variable can make use of any of the [predefined variables](../variables/README.md). --- **Example configurations** -To create a archive with a name of current build: +To create an archive with a name of the current build: ```yaml job: @@ -481,7 +478,8 @@ job: name: "$CI_BUILD_NAME" ``` -To create a archive with a name of current branch or tag: +To create an archive with a name of the current branch or tag including only +the files that are untracked by Git: ```yaml job: @@ -490,7 +488,8 @@ job: untracked: true ``` -To create a archive with a name of current branch or tag: +To create an archive with a name of the current build and the current branch or +tag including only the files that are untracked by Git: ```yaml job: @@ -499,7 +498,7 @@ job: untracked: true ``` -To create a archive with a name of stage and branch name: +To create an archive with a name of the current [stage](#stages) and branch name: ```yaml job: @@ -508,6 +507,8 @@ job: untracked: true ``` +--- + If you use **Windows Batch** to run your shell scripts you need to replace `$` with `%`: From 372abbe7f95d89290d46ef356fc13cc0a886c317 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sun, 13 Mar 2016 09:34:46 +0200 Subject: [PATCH 052/797] Refactor YAML anchors and explain the examples --- doc/ci/yaml/README.md | 106 +++++++++++++++++++++++++++++++++--------- 1 file changed, 83 insertions(+), 23 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 83928b8159..244cec71c8 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -631,35 +631,78 @@ rspec: The cache is provided on best effort basis, so don't expect that cache will be always present. For implementation details please check GitLab Runner. -## Special features +## Special YAML features -It's possible special YAML features like anchors and map merging. -Thus allowing to greatly reduce the complexity of `.gitlab-ci.yml`. +It's possible to use special YAML features like anchors (`&`), aliases (`*`) +and map merging (`<<`), which will allow you to greatly reduce the complexity +of `.gitlab-ci.yml`. -#### Anchors +Read more about the various [YAML features](https://learnxinyminutes.com/docs/yaml/). -You can read more about YAML features [here](https://learnxinyminutes.com/docs/yaml/). +### Anchors + +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.1. + +YAML also has a handy feature called 'anchors', which let you easily duplicate +content across your document. Anchors can be used to duplicate/inherit +properties, and is a perfect example to be used with [hidden jobs](#hidden-jobs) +to provide templates for your jobs. + +The following example uses anchors and map merging. It will create two jobs, +`test1` and `test2`, that will inherit the parameters of `.job_template`, each +having their own custom `script` defined: ```yaml -.job_template: &job_definition +.job_template: &job_definition # Hidden job that defines an anchor named 'job_definition' image: ruby:2.1 services: - postgres - redis test1: - << *job_definition + <<: *job_definition # Merge the contents of the 'job_definition' alias script: - - test project + - test1 project test2: - << *job_definition + <<: *job_definition # Merge the contents of the 'job_definition' alias script: - - test project + - test2 project ``` -The above example uses anchors and map merging. -It will create a two jobs: `test1` and `test2` that will have the parameters of `.job_template` and custom `script` defined. +`&` sets up the name of the anchor (`job_definition`), `<<` means "merge the +given hash into the current one", and `*` includes the named anchor +(`job_definition` again). The expanded version looks like this: + +```yaml +.job_template: + image: ruby:2.1 + services: + - postgres + - redis + +test1: + image: ruby:2.1 + services: + - postgres + - redis + script: + - test1 project + +test2: + image: ruby:2.1 + services: + - postgres + - redis + script: + - test2 project +``` + +Let's see another one example. This time we will use anchors to define two sets +of services. This will create two jobs, `test:postgres` and `test:mysql`, that +will share the `script` directive defined in `.job_template`, and the `services` +directive defined in `.postgres_services` and `.mysql_services` respectively: ```yaml .job_template: &job_definition @@ -685,22 +728,39 @@ test:mysql: services: *mysql_definition ``` -The above example uses anchors to define two set of services. -It will create a two jobs: `test:postgres` and `test:mysql` that will have the script defined in `.job_template` -and one, the service defined in `.postgres_services` and second the services defined in `.mysql_services`. +The expanded version looks like this: -### Hidden jobs - -The jobs that start with `.` will be not processed by GitLab. - -Example of such hidden jobs: ```yaml -.job_name: +.job_template: script: - - rake spec + - test project + +.postgres_services: + services: + - postgres + - ruby + +.mysql_services: + services: + - mysql + - ruby + +test:postgres: + script: + - test project + services: + - postgres + - ruby + +test:mysql: + script: + - test project + services: + - mysql + - ruby ``` -The `.job_name` will be ignored. You can use this feature to ignore jobs, or use them as templates with special YAML features. +You can see that the hidden jobs are conveniently used as templates. ## Validate the .gitlab-ci.yml From de7c3316b08e7763ac860503578e7fa4c78d2b9d Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sun, 13 Mar 2016 09:35:40 +0200 Subject: [PATCH 053/797] Add hidden jobs --- doc/ci/yaml/README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 244cec71c8..5f3a53dcf8 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -631,6 +631,24 @@ rspec: The cache is provided on best effort basis, so don't expect that cache will be always present. For implementation details please check GitLab Runner. +## Hidden jobs + +>**Note:** +Introduced in GitLab 8.6 and GitLab Runner v1.1.1. + +Jobs that start with a dot (`.`) will be not processed by GitLab CI. You can +use this feature to ignore jobs, or use the +[special YAML features](#special-yaml-features) and transform the hidden jobs +into templates. + +In the following example, `.job_name` will be ignored: + +```yaml +.job_name: + script: + - rake spec +``` + ## Special YAML features It's possible to use special YAML features like anchors (`&`), aliases (`*`) From d8eeeb692ed61454eb06e3276f368f4dc0f11d81 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Sun, 13 Mar 2016 09:57:36 +0200 Subject: [PATCH 054/797] Refactor dependencies directive [ci skip] --- doc/ci/yaml/README.md | 47 ++++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 21 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 5f3a53dcf8..beaa86250a 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -236,14 +236,14 @@ job_name: | Keyword | Required | Description | |---------------|----------|-------------| | script | yes | Defines a shell script which is executed by runner | -| stage | no (default: `test`) | Defines a build stage | +| stage | no | Defines a build stage (default: `test`) | | type | no | Alias for `stage` | | only | no | Defines a list of git refs for which build is created | | except | no | Defines a list of git refs for which build is not created | | tags | no | Defines a list of tags which are used to select runner | | allow_failure | no | Allow build to fail. Failed build doesn't contribute to commit status | | when | no | Define when to run build. Can be `on_success`, `on_failure` or `always` | -| dependencies | no | Define a builds that this build depends on | +| dependencies | no | Define other builds that a build depends on so that you can pass artifacts between them| | artifacts | no | Define list build artifacts | | cache | no | Define list of files that should be cached between subsequent runs | @@ -404,7 +404,10 @@ The above script will: > - Build artifacts are only collected for successful builds. `artifacts` is used to specify list of files and directories which should be -attached to build after success. Below are some examples. +attached to build after success. To pass artifacts between different builds, +see [dependencies](#dependencies). + +Below are some examples. Send all files in `binaries` and `.config`: @@ -524,56 +527,58 @@ job: >**Note:** Introduced in GitLab 8.6 and GitLab Runner v1.1.1. -This feature should be used with `artifacts` and allows to define artifacts passing between different builds. +This feature should be used in conjunction with [`artifacts`](#artifacts) and +allows you to define the artifacts to pass between different builds. -`artifacts` from previous stages are passed by default. +Note that `artifacts` from previous [stages](#stages) are passed by default. -To use a feature define `dependencies` in context of the build and pass +To use this feature, define `dependencies` in context of the job and pass a list of all previous builds from which the artifacts should be downloaded. -You can only define a builds from stages that are executed before this one. -Error will be shown if you define builds from current stage or next stages. +You can only define builds from stages that are executed before the current one. +An error will be shown if you define builds from the current stage or next ones. -How to use artifacts passing between stages: +--- + +In the following example, we define two jobs with artifacts, `build:osx` and +`build:linux`. When the `test:osx` is executed, the artifacts from `build:osx` +will be downloaded and extracted in the context of the build. The same happens +for `test:linux` and artifacts from `build:linux`. + +The job `deploy` will download artifacts from all previous builds because of +the [stage](#stages) precedence: ``` build:osx: stage: build - script: ... + script: make build:osx artifacts: paths: - binaries/ build:linux: stage: build - script: ... + script: make build:linux artifacts: paths: - binaries/ test:osx: stage: test - script: ... + script: make test:osx dependencies: - build:osx test:linux: stage: test - script: ... + script: make test:linux dependencies: - build:linux deploy: stage: deploy - script: ... + script: make deploy ``` -The above will create a build artifacts for two jobs: `build:osx` and `build:linux`. -When executing the `test:osx` the artifacts for `build:osx` will be downloaded and extracted in context of the build. -The same happens for `test:linux` and artifacts from `build:linux`. - -The job `deploy` will download artifacts from all previous builds. -However, only the `build:osx` and `build:linux` exports artifacts so only these will be downloaded. - ### cache >**Note:** From e8b3b92ddebc47595fe4b69dc5b5a3a6dd1365ab Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Sun, 13 Mar 2016 11:46:16 +0100 Subject: [PATCH 055/797] Bring share project with group API from EE Signed-off-by: Dmitriy Zaporozhets --- doc/api/projects.md | 14 ++++++++++++ lib/api/entities.rb | 4 ++++ lib/api/projects.rb | 27 ++++++++++++++++++++++ spec/requests/api/projects_spec.rb | 36 ++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+) diff --git a/doc/api/projects.md b/doc/api/projects.md index 9e9486cd87..3703f4b327 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -619,6 +619,20 @@ Revoking team membership for a user who is not currently a team member is consid Please note that the returned JSON currently differs slightly. Thus you should not rely on the returned JSON structure. +### Share project with group + +Allow to share project with group. + +``` +POST /projects/:id/share +``` + +Parameters: + +- `id` (required) - The ID of a project +- `group_id` (required) - The ID of a group +- `group_access` (required) - Level of permissions for sharing + ## Hooks Also called Project Hooks and Webhooks. diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 5b5b8bd044..d96eaef7c0 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -243,6 +243,10 @@ module API end end + class ProjectGroupLink < Grape::Entity + expose :id, :project_id, :group_id, :group_access + end + class Namespace < Grape::Entity expose :id, :path, :kind end diff --git a/lib/api/projects.rb b/lib/api/projects.rb index 6067c8b4a5..6fcb5261e4 100644 --- a/lib/api/projects.rb +++ b/lib/api/projects.rb @@ -290,6 +290,33 @@ module API end end + # Share project with group + # + # Parameters: + # id (required) - The ID of a project + # group_id (required) - The ID of a group + # group_access (required) - Level of permissions for sharing + # + # Example Request: + # POST /projects/:id/share + post ":id/share" do + authorize! :admin_project, user_project + required_attributes! [:group_id, :group_access] + + unless user_project.allowed_to_share_with_group? + return render_api_error!("The project sharing with group is disabled", 400) + end + + link = user_project.project_group_links.new + link.group_id = params[:group_id] + link.group_access = params[:group_access] + if link.save + present link, with: Entities::ProjectGroupLink + else + render_api_error!(link.errors.full_messages.first, 409) + end + end + # Upload a file # # Parameters: diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index 9f2365a483..a6699cdc81 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -747,6 +747,42 @@ describe API::API, api: true do end end + describe "POST /projects/:id/share" do + let(:group) { create(:group) } + + it "should share project with group" do + expect do + post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: Gitlab::Access::DEVELOPER + end.to change { ProjectGroupLink.count }.by(1) + + expect(response.status).to eq 201 + expect(json_response['group_id']).to eq group.id + expect(json_response['group_access']).to eq Gitlab::Access::DEVELOPER + end + + it "should return a 400 error when group id is not given" do + post api("/projects/#{project.id}/share", user), group_access: Gitlab::Access::DEVELOPER + expect(response.status).to eq 400 + end + + it "should return a 400 error when access level is not given" do + post api("/projects/#{project.id}/share", user), group_id: group.id + expect(response.status).to eq 400 + end + + it "should return a 400 error when sharing is disabled" do + project.namespace.update(share_with_group_lock: true) + post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: Gitlab::Access::DEVELOPER + expect(response.status).to eq 400 + end + + it "should return a 409 error when wrong params passed" do + post api("/projects/#{project.id}/share", user), group_id: group.id, group_access: 1234 + expect(response.status).to eq 409 + expect(json_response['message']).to eq 'Group access is not included in the list' + end + end + describe 'GET /projects/search/:query' do let!(:query) { 'query'} let!(:search) { create(:empty_project, name: query, creator_id: user.id, namespace: user.namespace) } From 1cefb73a9c067b1e2367a28b5c6852cf52d6b886 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Mon, 7 Mar 2016 10:06:54 +0100 Subject: [PATCH 056/797] Check redirect path in the continue_params Fixes https://dev.gitlab.org/gitlab/gitlabhq/issues/2649 https://gitlab.com/gitlab-org/gitlab-ce/issues/13956 --- app/controllers/concerns/continue_to_params.rb | 13 +++++++++++++ app/controllers/projects/forks_controller.rb | 13 ++----------- app/controllers/projects/imports_controller.rb | 12 ++---------- 3 files changed, 17 insertions(+), 21 deletions(-) create mode 100644 app/controllers/concerns/continue_to_params.rb diff --git a/app/controllers/concerns/continue_to_params.rb b/app/controllers/concerns/continue_to_params.rb new file mode 100644 index 0000000000..8b6c705196 --- /dev/null +++ b/app/controllers/concerns/continue_to_params.rb @@ -0,0 +1,13 @@ +module ContinueToParams + extend ActiveSupport::Concern + + def continue_params + continue_params = params[:continue] + return nil unless continue_params + + continue_params = continue_params.permit(:to, :notice, :notice_now) + continue_params[:to] = root_url unless continue_params[:to].start_with?('/') + + continue_params + end +end diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index 7b202f3862..c4884c13b1 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -1,4 +1,6 @@ class Projects::ForksController < Projects::ApplicationController + include ContinueToParams + # Authorize before_action :require_non_empty_project before_action :authorize_download_code! @@ -53,15 +55,4 @@ class Projects::ForksController < Projects::ApplicationController render :error end end - - private - - def continue_params - continue_params = params[:continue] - if continue_params - continue_params.permit(:to, :notice, :notice_now) - else - nil - end - end end diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index 196996f175..3756fc9139 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -1,4 +1,6 @@ class Projects::ImportsController < Projects::ApplicationController + include ContinueToParams + # Authorize before_action :authorize_admin_project! before_action :require_no_repo, only: [:new, :create] @@ -44,16 +46,6 @@ class Projects::ImportsController < Projects::ApplicationController private - def continue_params - continue_params = params[:continue] - - if continue_params - continue_params.permit(:to, :notice, :notice_now) - else - nil - end - end - def finished_notice if @project.forked? 'The project was successfully forked.' From dfb96ed84bd7533abc411b148f0b27bf65321b3e Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Mon, 7 Mar 2016 10:36:16 +0100 Subject: [PATCH 057/797] ContinueToParams -> ContinueParams --- CHANGELOG | 1 + .../concerns/{continue_to_params.rb => continue_params.rb} | 4 ++-- app/controllers/projects/forks_controller.rb | 2 +- app/controllers/projects/imports_controller.rb | 2 +- 4 files changed, 5 insertions(+), 4 deletions(-) rename app/controllers/concerns/{continue_to_params.rb => continue_params.rb} (69%) diff --git a/CHANGELOG b/CHANGELOG index d4554b9619..1929b6306d 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -45,6 +45,7 @@ v 8.5.3 - Show commit message in JIRA mention comment - Makes issue page and merge request page usable on mobile browsers. - Improved UI for profile settings + - Continue parameters are checked to ensure redirection goes to the same instance v 8.5.2 - Fix sidebar overlapping content when screen width was below 1200px diff --git a/app/controllers/concerns/continue_to_params.rb b/app/controllers/concerns/continue_params.rb similarity index 69% rename from app/controllers/concerns/continue_to_params.rb rename to app/controllers/concerns/continue_params.rb index 8b6c705196..2ff7250922 100644 --- a/app/controllers/concerns/continue_to_params.rb +++ b/app/controllers/concerns/continue_params.rb @@ -1,4 +1,4 @@ -module ContinueToParams +module ContinueParams extend ActiveSupport::Concern def continue_params @@ -6,7 +6,7 @@ module ContinueToParams return nil unless continue_params continue_params = continue_params.permit(:to, :notice, :notice_now) - continue_params[:to] = root_url unless continue_params[:to].start_with?('/') + return unless continue_params[:to] && continue_params[:to].start_with?('/') continue_params end diff --git a/app/controllers/projects/forks_controller.rb b/app/controllers/projects/forks_controller.rb index c4884c13b1..a1b8632df9 100644 --- a/app/controllers/projects/forks_controller.rb +++ b/app/controllers/projects/forks_controller.rb @@ -1,5 +1,5 @@ class Projects::ForksController < Projects::ApplicationController - include ContinueToParams + include ContinueParams # Authorize before_action :require_non_empty_project diff --git a/app/controllers/projects/imports_controller.rb b/app/controllers/projects/imports_controller.rb index 3756fc9139..7756f0f0ed 100644 --- a/app/controllers/projects/imports_controller.rb +++ b/app/controllers/projects/imports_controller.rb @@ -1,5 +1,5 @@ class Projects::ImportsController < Projects::ApplicationController - include ContinueToParams + include ContinueParams # Authorize before_action :authorize_admin_project! From 5352ec2e21ba72d77a542b158ce1a98a1a3a9389 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Mon, 7 Mar 2016 11:45:14 +0100 Subject: [PATCH 058/797] Fix denting and spec --- CHANGELOG | 2 +- app/controllers/concerns/continue_params.rb | 2 +- spec/controllers/projects/imports_controller_spec.rb | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 1929b6306d..7c63414e58 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -29,6 +29,7 @@ v 8.6.0 (unreleased) - Add main language of a project in the list of projects (Tiago Botelho) - Add ability to show archived projects on dashboard, explore and group pages - Move group activity to separate page + - Continue parameters are checked to ensure redirection goes to the same instance v 8.5.5 - Ensure removing a project removes associated Todo entries @@ -45,7 +46,6 @@ v 8.5.3 - Show commit message in JIRA mention comment - Makes issue page and merge request page usable on mobile browsers. - Improved UI for profile settings - - Continue parameters are checked to ensure redirection goes to the same instance v 8.5.2 - Fix sidebar overlapping content when screen width was below 1200px diff --git a/app/controllers/concerns/continue_params.rb b/app/controllers/concerns/continue_params.rb index 2ff7250922..0a995c45bd 100644 --- a/app/controllers/concerns/continue_params.rb +++ b/app/controllers/concerns/continue_params.rb @@ -5,7 +5,7 @@ module ContinueParams continue_params = params[:continue] return nil unless continue_params - continue_params = continue_params.permit(:to, :notice, :notice_now) + continue_params = continue_params.permit(:to, :notice, :notice_now) return unless continue_params[:to] && continue_params[:to].start_with?('/') continue_params diff --git a/spec/controllers/projects/imports_controller_spec.rb b/spec/controllers/projects/imports_controller_spec.rb index 0147bd2b95..2acbba469e 100644 --- a/spec/controllers/projects/imports_controller_spec.rb +++ b/spec/controllers/projects/imports_controller_spec.rb @@ -19,7 +19,7 @@ describe Projects::ImportsController do end it 'sets flash.now if params is present' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { notice_now: 'Started' } + get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { to: '/', notice_now: 'Started' } expect(flash.now[:notice]).to eq 'Started' end @@ -45,7 +45,7 @@ describe Projects::ImportsController do end it 'sets flash.now if params is present' do - get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { notice_now: 'In progress' } + get :show, namespace_id: project.namespace.to_param, project_id: project.to_param, continue: { to: '/', notice_now: 'In progress' } expect(flash.now[:notice]).to eq 'In progress' end From 42fcd3881fcece5c9bd4b720460d6cade573b151 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Thu, 10 Mar 2016 22:08:11 +0100 Subject: [PATCH 059/797] External Users The user has the rights of a public user execpt it can never create a project, group, or team. Also it cant view internal projects. --- app/controllers/admin/users_controller.rb | 2 +- app/finders/projects_finder.rb | 11 +++-- app/models/ability.rb | 34 +++++++-------- app/models/user.rb | 12 ++++++ app/views/admin/users/_form.html.haml | 5 +++ app/views/admin/users/show.html.haml | 4 ++ ...160310185910_add_external_flag_to_users.rb | 5 +++ db/schema.rb | 3 +- lib/api/users.rb | 5 ++- .../security/project/internal_access_spec.rb | 37 ++++++++++++----- .../security/project/private_access_spec.rb | 34 ++++++++++----- .../security/project/public_access_spec.rb | 41 ++++++++++++++----- spec/models/user_spec.rb | 16 ++++++++ spec/requests/api/users_spec.rb | 20 +++++++++ spec/support/matchers/access_matchers.rb | 2 + 15 files changed, 171 insertions(+), 60 deletions(-) create mode 100644 db/migrate/20160310185910_add_external_flag_to_users.rb diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 87f4fb455b..be192964a9 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -150,7 +150,7 @@ class Admin::UsersController < Admin::ApplicationController :email, :remember_me, :bio, :name, :username, :skype, :linkedin, :twitter, :website_url, :color_scheme_id, :theme_id, :force_random_password, :extern_uid, :provider, :password_expires_at, :avatar, :hide_no_ssh_key, :hide_no_password, - :projects_limit, :can_create_group, :admin, :key_id + :projects_limit, :can_create_group, :admin, :key_id, :external ) end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 0e5a8f5ee0..711d9019a3 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -51,13 +51,12 @@ class ProjectsFinder end def all_projects(current_user) - if current_user - [ - current_user.authorized_projects, - public_and_internal_projects - ] + return [Project.public_only] unless current_user + + if current_user.external? + [current_user.authorized_projects, public_projects] else - [Project.public_only] + [current_user.authorized_projects, public_and_internal_projects] end end diff --git a/app/models/ability.rb b/app/models/ability.rb index fe9e0aab71..ccac08b7d3 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -109,23 +109,10 @@ class Ability key = "/user/#{user.id}/project/#{project.id}" RequestStore.store[key] ||= begin - team = project.team + # Push abilities on the users team role + rules.push(*project_team_rules(project.team, user)) - # Rules based on role in project - if team.master?(user) - rules.push(*project_master_rules) - - elsif team.developer?(user) - rules.push(*project_dev_rules) - - elsif team.reporter?(user) - rules.push(*project_report_rules) - - elsif team.guest?(user) - rules.push(*project_guest_rules) - end - - if project.public? || project.internal? + if project.public? || (project.internal? && !user.external?) rules.push(*public_project_rules) # Allow to read builds for internal projects @@ -148,6 +135,19 @@ class Ability end end + def project_team_rules(team, user) + # Rules based on role in project + if team.master?(user) + project_master_rules + elsif team.developer?(user) + project_dev_rules + elsif team.reporter?(user) + project_report_rules + elsif team.guest?(user) + project_guest_rules + end + end + def public_project_rules @public_project_rules ||= project_guest_rules + [ :download_code, @@ -356,7 +356,7 @@ class Ability ] end - if snippet.public? || snippet.internal? + if snippet.public? || (snippet.internal? && !user.external?) rules << :read_personal_snippet end diff --git a/app/models/user.rb b/app/models/user.rb index 043bc825ad..adff238c60 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -59,6 +59,7 @@ # hide_project_limit :boolean default(FALSE) # unlock_token :string # otp_grace_period_started_at :datetime +# external :boolean default(FALSE) # require 'carrierwave/orm/activerecord' @@ -77,6 +78,7 @@ class User < ActiveRecord::Base add_authentication_token_field :authentication_token default_value_for :admin, false + default_value_for :external, false default_value_for :can_create_group, gitlab_config.default_can_create_group default_value_for :can_create_team, false default_value_for :hide_no_ssh_key, false @@ -179,6 +181,7 @@ class User < ActiveRecord::Base after_update :update_emails_with_primary_email, if: ->(user) { user.email_changed? } before_save :ensure_authentication_token + before_save :ensure_external_user_rights after_save :ensure_namespace_correct after_initialize :set_projects_limit after_create :post_create_hook @@ -848,4 +851,13 @@ class User < ActiveRecord::Base def send_devise_notification(notification, *args) devise_mailer.send(notification, self, *args).deliver_later end + + def ensure_external_user_rights + return unless self.external? + + self.can_create_team = false + self.can_create_group = false + self.projects_limit = 0 + self.hide_project_limit = true + end end diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index e18dd9bc90..5506827edc 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -61,6 +61,11 @@ .col-sm-10 You cannot remove your own admin rights - else .col-sm-10= f.check_box :admin + + .form-group + = f.label :external, class: 'control-label' + .col-sm-10= f.check_box :external + %fieldset %legend Profile .form-group diff --git a/app/views/admin/users/show.html.haml b/app/views/admin/users/show.html.haml index 2bdbae1958..d37489bebe 100644 --- a/app/views/admin/users/show.html.haml +++ b/app/views/admin/users/show.html.haml @@ -47,6 +47,10 @@ - else Disabled + %li + %span.light External User: + %strong + = @user.external? ? "Yes" : "No" %li %span.light Can create groups: %strong diff --git a/db/migrate/20160310185910_add_external_flag_to_users.rb b/db/migrate/20160310185910_add_external_flag_to_users.rb new file mode 100644 index 0000000000..54937f1eb7 --- /dev/null +++ b/db/migrate/20160310185910_add_external_flag_to_users.rb @@ -0,0 +1,5 @@ +class AddExternalFlagToUsers < ActiveRecord::Migration + def change + add_column :users, :external, :boolean, default: false + end +end diff --git a/db/schema.rb b/db/schema.rb index 3ac6203632..16e28d7a3f 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: 20160309140734) do +ActiveRecord::Schema.define(version: 20160310185910) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -929,6 +929,7 @@ ActiveRecord::Schema.define(version: 20160309140734) do 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 diff --git a/lib/api/users.rb b/lib/api/users.rb index fd2128bd17..c574f042a6 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -61,19 +61,20 @@ module API # admin - User is admin - true or false (default) # can_create_group - User can create groups - true or false # confirm - Require user confirmation - true (default) or false + # external - Is user an external user - true or false(default) # Example Request: # POST /users post do authenticated_as_admin! required_attributes! [:email, :password, :name, :username] - attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :projects_limit, :username, :bio, :can_create_group, :admin, :confirm] + attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :projects_limit, :username, :bio, :can_create_group, :admin, :confirm, :external] admin = attrs.delete(:admin) confirm = !(attrs.delete(:confirm) =~ (/(false|f|no|0)$/i)) user = User.build_user(attrs) user.admin = admin unless admin.nil? user.skip_confirmation! unless confirm - identity_attrs = attributes_for_keys [:provider, :extern_uid] + if identity_attrs.any? user.identities.build(identity_attrs) end diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 57563add74..31bd80e7cc 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -34,6 +34,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -45,6 +46,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -56,6 +58,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -67,6 +70,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -78,6 +82,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -89,22 +94,21 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 - before do - commit = project.repository.commit - path = '.gitignore' - @blob_path = namespace_project_blob_path(project.namespace, project, File.join(commit.id, path)) - end + let(:commit) { project.repository.commit } + subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore')) } - it { expect(@blob_path).to be_allowed_for master } - it { expect(@blob_path).to be_allowed_for reporter } - it { expect(@blob_path).to be_allowed_for :admin } - it { expect(@blob_path).to be_allowed_for guest } - it { expect(@blob_path).to be_allowed_for :user } - it { expect(@blob_path).to be_denied_for :visitor } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for guest } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } + it { is_expected.to be_denied_for :visitor } end describe "GET /:project_path/edit" do @@ -115,6 +119,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -126,6 +131,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -137,6 +143,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -149,6 +156,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -160,6 +168,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -171,6 +180,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -182,6 +192,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -193,6 +204,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -209,6 +221,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -225,6 +238,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_denied_for :external } it { is_expected.to be_denied_for :visitor } end @@ -236,6 +250,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 end diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index a1e111c6ca..13ff29c98a 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -34,6 +34,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -45,6 +46,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -56,6 +58,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -78,6 +81,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -89,22 +93,21 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 - before do - commit = project.repository.commit - path = '.gitignore' - @blob_path = namespace_project_blob_path(project.namespace, project, File.join(commit.id, path)) - end + let(:commit) { project.repository.commit } + subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore'))} - it { expect(@blob_path).to be_allowed_for master } - it { expect(@blob_path).to be_allowed_for reporter } - it { expect(@blob_path).to be_allowed_for :admin } - it { expect(@blob_path).to be_denied_for guest } - it { expect(@blob_path).to be_denied_for :user } - it { expect(@blob_path).to be_denied_for :visitor } + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_allowed_for :admin } + 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/edit" do @@ -115,6 +118,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -126,6 +130,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -137,6 +142,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -149,6 +155,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -160,6 +167,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -171,6 +179,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -187,6 +196,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -203,6 +213,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -214,6 +225,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 end diff --git a/spec/features/security/project/public_access_spec.rb b/spec/features/security/project/public_access_spec.rb index b98476f854..4e13507636 100644 --- a/spec/features/security/project/public_access_spec.rb +++ b/spec/features/security/project/public_access_spec.rb @@ -38,6 +38,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -49,6 +50,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -60,6 +62,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -71,6 +74,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -82,6 +86,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -93,6 +98,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -107,6 +113,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -118,6 +125,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 end @@ -135,6 +143,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -146,23 +155,22 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 end describe "GET /:project_path/blob" do - before do - commit = project.repository.commit - path = '.gitignore' - @blob_path = namespace_project_blob_path(project.namespace, project, File.join(commit.id, path)) - end + let(:commit) { project.repository.commit } - it { expect(@blob_path).to be_allowed_for master } - it { expect(@blob_path).to be_allowed_for reporter } - it { expect(@blob_path).to be_allowed_for :admin } - it { expect(@blob_path).to be_allowed_for guest } - it { expect(@blob_path).to be_allowed_for :user } - it { expect(@blob_path).to be_allowed_for :visitor } + subject { namespace_project_blob_path(project.namespace, project, File.join(commit.id, '.gitignore')) } + + it { is_expected.to be_allowed_for master } + it { is_expected.to be_allowed_for reporter } + it { is_expected.to be_allowed_for :admin } + it { is_expected.to be_allowed_for guest } + it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :visitor } end describe "GET /:project_path/edit" do @@ -173,6 +181,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -184,6 +193,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -195,6 +205,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -207,6 +218,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -218,6 +230,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -229,6 +242,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -240,6 +254,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -251,6 +266,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 @@ -267,6 +283,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -283,6 +300,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } + it { is_expected.to be_allowed_for :external } it { is_expected.to be_allowed_for :visitor } end @@ -294,6 +312,7 @@ describe "Public Project Access", feature: true do it { is_expected.to be_allowed_for :admin } 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 end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 909b679659..8642654ad3 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -206,6 +206,21 @@ describe User, models: true do it { is_expected.to respond_to(:is_admin?) } it { is_expected.to respond_to(:name) } it { is_expected.to respond_to(:private_token) } + it { is_expected.to respond_to(:external?) } + end + + describe 'before save hook' do + context 'when saving an external user' do + let(:user) { create(:user) } + let(:external_user) { create(:user, external: true) } + + it "sets other properties aswell" do + expect(external_user.can_create_team).to be_falsey + expect(external_user.can_create_group).to be_falsey + expect(external_user.hide_project_limit).to be_truthy + expect(external_user.projects_limit).to be 0 + end + end end describe '#confirm' do @@ -430,6 +445,7 @@ describe User, models: true do expect(user.projects_limit).to eq(Gitlab.config.gitlab.default_projects_limit) expect(user.can_create_group).to eq(Gitlab.config.gitlab.default_can_create_group) expect(user.theme_id).to eq(Gitlab.config.gitlab.default_theme) + expect(user.external).to be_falsey end end diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 96e8c8c51f..5366a7bd06 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -120,6 +120,26 @@ describe API::API, api: true do expect(response.status).to eq(201) end + it 'creates non-external users by default' do + post api("/users", admin), attributes_for(:user) + expect(response.status).to eq(201) + + user_id = json_response['id'] + new_user = User.find(user_id) + expect(new_user).not_to eq nil + expect(new_user.external).to be_falsy + end + + it 'should allow an external user to be created' do + post api("/users", admin), attributes_for(:user, external: true) + expect(response.status).to eq(201) + + user_id = json_response['id'] + new_user = User.find(user_id) + expect(new_user).not_to eq nil + expect(new_user.external).to be_truthy + end + it "should not create user with invalid email" do post api('/users', admin), email: 'invalid email', diff --git a/spec/support/matchers/access_matchers.rb b/spec/support/matchers/access_matchers.rb index 558e8b1612..4e007c777e 100644 --- a/spec/support/matchers/access_matchers.rb +++ b/spec/support/matchers/access_matchers.rb @@ -15,6 +15,8 @@ module AccessMatchers logout when :admin login_as(create(:admin)) + when :external + login_as(create(:user, external: true)) when User login_as(user) else From fc8d64b3a0fc5aeb048b2bce79b2ae93a4fcc798 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Sat, 12 Mar 2016 15:02:38 +0100 Subject: [PATCH 060/797] Remove Project#publicish --- app/models/project.rb | 6 ------ .../dashboard/projects/_zero_authorized_projects.html.haml | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index 1f18ad7816..51952ba72a 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -252,12 +252,6 @@ class Project < ActiveRecord::Base where('projects.last_activity_at < ?', 6.months.ago) end - def publicish(user) - visibility_levels = [Project::PUBLIC] - visibility_levels << Project::INTERNAL if user - where(visibility_level: visibility_levels) - end - def with_push joins(:events).where('events.action = ?', Event::PUSHED) end diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index c3efa7727b..65edc05b79 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -1,4 +1,4 @@ -- publicish_project_count = Project.publicish(current_user).count +- publicish_project_count = ProjectsFinder.new.execute(current_user).count %h3.page-title Welcome to GitLab! %p.light Self hosted Git management application. %hr @@ -18,7 +18,7 @@ - if current_user.can_create_project? .link_holder = link_to new_project_path, class: "btn btn-new" do - %i.fa.fa-plus + =icon('plus') New Project - if current_user.can_create_group? From 76eeb316df2f256d0d3c41d97421f709a21a02a8 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Sun, 13 Mar 2016 10:05:32 +0100 Subject: [PATCH 061/797] Create an external users tab on Admin user list Also incorporates the review into this, mainly spec changes. --- CHANGELOG | 1 + app/finders/projects_finder.rb | 13 ++++++++---- app/models/user.rb | 5 +++-- app/views/admin/users/_form.html.haml | 3 ++- app/views/admin/users/index.html.haml | 4 ++++ .../security/project/internal_access_spec.rb | 20 +++++++++++++++++++ .../security/project/private_access_spec.rb | 18 +++++++++++++++++ spec/models/user_spec.rb | 1 - 8 files changed, 57 insertions(+), 8 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 07b187b31a..f9c6f6e23e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -30,6 +30,7 @@ v 8.6.0 (unreleased) - Add main language of a project in the list of projects (Tiago Botelho) - Add ability to show archived projects on dashboard, explore and group pages - Move group activity to separate page + - Create external users which are excluded of internal and private projects unless access was explicitly granted v 8.5.5 - Ensure removing a project removes associated Todo entries diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 711d9019a3..4a6c2fbd71 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -40,18 +40,23 @@ class ProjectsFinder private def group_projects(current_user, group) - if current_user + return [group.projects.public_only] unless current_user + + if current_user.external? + [ + group_projects_for_user(current_user, group), + group.projects.public_only + ] + else [ group_projects_for_user(current_user, group), group.projects.public_and_internal_only ] - else - [group.projects.public_only] end end def all_projects(current_user) - return [Project.public_only] unless current_user + return [public_projects] unless current_user if current_user.external? [current_user.authorized_projects, public_projects] diff --git a/app/models/user.rb b/app/models/user.rb index adff238c60..34f1cfc0be 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -229,6 +229,7 @@ class User < ActiveRecord::Base # Scopes scope :admins, -> { where(admin: true) } scope :blocked, -> { with_states(:blocked, :ldap_blocked) } + scope :external, -> { where(external: true) } scope :active, -> { with_state(:active) } scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all } scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members)') } @@ -284,6 +285,8 @@ class User < ActiveRecord::Base self.with_two_factor when 'wop' self.without_projects + when 'external' + self.external else self.active end @@ -855,9 +858,7 @@ class User < ActiveRecord::Base def ensure_external_user_rights return unless self.external? - self.can_create_team = false self.can_create_group = false self.projects_limit = 0 - self.hide_project_limit = true end end diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index 5506827edc..b910040a16 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -58,13 +58,14 @@ = f.label :admin, class: 'control-label' - if current_user == @user .col-sm-10= f.check_box :admin, disabled: true - .col-sm-10 You cannot remove your own admin rights + .col-sm-10 You cannot remove your own admin rights. - else .col-sm-10= f.check_box :admin .form-group = f.label :external, class: 'control-label' .col-sm-10= f.check_box :external + .col-sm-10 External users can not see internal or private projects unless access is explicitly granted. Also, external user can not create projects or groups. %fieldset %legend Profile diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index b6b1168bd3..4394e18100 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -19,6 +19,10 @@ = 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 diff --git a/spec/features/security/project/internal_access_spec.rb b/spec/features/security/project/internal_access_spec.rb index 31bd80e7cc..f88c591d89 100644 --- a/spec/features/security/project/internal_access_spec.rb +++ b/spec/features/security/project/internal_access_spec.rb @@ -8,10 +8,12 @@ describe "Internal Project Access", feature: true do let(:master) { create(:user) } let(:guest) { create(:user) } let(:reporter) { create(:user) } + let(:external_team_member) { create(:user, external: true) } before do # full access project.team << [master, :master] + project.team << [external_team_member, :master] # readonly project.team << [reporter, :reporter] @@ -35,6 +37,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -47,6 +50,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -59,6 +63,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -71,6 +76,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -83,6 +89,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -95,6 +102,7 @@ describe "Internal Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -108,6 +116,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -120,6 +129,7 @@ describe "Internal Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -132,6 +142,7 @@ describe "Internal Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -144,6 +155,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -157,6 +169,7 @@ describe "Internal Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -169,6 +182,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -181,6 +195,7 @@ describe "Internal Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -193,6 +208,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -205,6 +221,7 @@ describe "Internal Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -222,6 +239,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -239,6 +257,7 @@ describe "Internal Project Access", feature: true do it { is_expected.to be_allowed_for guest } it { is_expected.to be_allowed_for :user } it { is_expected.to be_denied_for :external } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -251,6 +270,7 @@ describe "Internal Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end end diff --git a/spec/features/security/project/private_access_spec.rb b/spec/features/security/project/private_access_spec.rb index 13ff29c98a..19f287ce7a 100644 --- a/spec/features/security/project/private_access_spec.rb +++ b/spec/features/security/project/private_access_spec.rb @@ -8,10 +8,12 @@ describe "Private Project Access", feature: true do let(:master) { create(:user) } let(:guest) { create(:user) } let(:reporter) { create(:user) } + let(:external_team_member) { create(:user, external: true) } before do # full access project.team << [master, :master] + project.team << [external_team_member, :master] # readonly project.team << [reporter, :reporter] @@ -35,6 +37,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -47,6 +50,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -59,6 +63,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -70,6 +75,7 @@ describe "Private Project Access", feature: true do it { is_expected.to be_allowed_for :admin } it { is_expected.to be_denied_for guest } it { is_expected.to be_denied_for :user } + it { is_expected.to be_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -82,6 +88,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -94,6 +101,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -107,6 +115,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -119,6 +128,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -131,6 +141,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -143,6 +154,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -156,6 +168,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -168,6 +181,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -180,6 +194,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -197,6 +212,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -214,6 +230,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end @@ -226,6 +243,7 @@ describe "Private Project Access", feature: true do 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_allowed_for external_team_member } it { is_expected.to be_denied_for :visitor } end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 8642654ad3..ca0c84c863 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -217,7 +217,6 @@ describe User, models: true do it "sets other properties aswell" do expect(external_user.can_create_team).to be_falsey expect(external_user.can_create_group).to be_falsey - expect(external_user.hide_project_limit).to be_truthy expect(external_user.projects_limit).to be 0 end end From a181722ae4dc0b1bb564d34b30e30f823cc72bc0 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 13 Mar 2016 13:39:28 -0700 Subject: [PATCH 062/797] Improve award emoji test reliability and eliminate sleeps Closes #14129 --- features/steps/project/issues/award_emoji.rb | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/features/steps/project/issues/award_emoji.rb b/features/steps/project/issues/award_emoji.rb index 135e1d016a..ce2554bc80 100644 --- a/features/steps/project/issues/award_emoji.rb +++ b/features/steps/project/issues/award_emoji.rb @@ -13,7 +13,6 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps thumbsup = page.first('.award-control') thumbsup.click thumbsup.hover - sleep 0.3 end end @@ -46,12 +45,10 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps end step 'I have award added' do - sleep 0.2 - page.within '.awards' do expect(page).to have_selector '.js-emoji-btn' expect(page.find('.js-emoji-btn.active .js-counter')).to have_content '1' - expect(page.find('.js-emoji-btn.active')['data-original-title']).to eq('me') + expect(page).to have_css(".js-emoji-btn.active[data-original-title='me']") end end From 8f21e2ae408a4ebd0e115846b9a639e7ce09a126 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Fri, 11 Mar 2016 23:38:25 -0500 Subject: [PATCH 063/797] Let `oauth/applications#index` handle the `profiles#applications` route Previously we were doing all of kinds of code gymnastics and flash abuse in order to work with a Doorkeeper controller but have it _appear_ at the `/profile/applications` path. Fortunately we can just tell Rails to use a different controller to handle that route, and we get the best of both worlds. --- .../oauth/applications_controller.rb | 28 +++--- app/controllers/profiles_controller.rb | 8 -- .../doorkeeper/applications/index.html.haml | 98 +++++++++++++++---- app/views/layouts/nav/_profile.html.haml | 2 +- app/views/profiles/applications.html.haml | 83 ---------------- config/routes.rb | 2 +- 6 files changed, 97 insertions(+), 124 deletions(-) delete mode 100644 app/views/profiles/applications.html.haml diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index e20446b2cc..d1e4ac10f6 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -8,11 +8,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController layout 'profile' def index - head :forbidden and return - end - - def new - redirect_to applications_profile_url + set_index_vars end def create @@ -24,18 +20,11 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :create]) redirect_to oauth_application_url(@application) else - redirect_to applications_profile_url, flash: { application: @application } + set_index_vars + render :index end end - def destroy - if @application.destroy - flash[:notice] = I18n.t(:notice, scope: [:doorkeeper, :flash, :applications, :destroy]) - end - - redirect_to applications_profile_url - end - private def verify_user_oauth_applications_enabled @@ -44,6 +33,17 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController redirect_to applications_profile_url end + def set_index_vars + @applications = current_user.oauth_applications + @authorized_tokens = current_user.oauth_authorized_tokens + @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) + @authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?) + + # Don't overwrite a value possibly set by `create` + @application ||= Doorkeeper::Application.new + end + + # Override Doorkeeper to scope to the current user def set_application @application = current_user.oauth_applications.find(params[:id]) end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 75eb9bdb96..50b8f38eec 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -8,14 +8,6 @@ class ProfilesController < Profiles::ApplicationController def show end - def applications - @applications = current_user.oauth_applications - @authorized_tokens = current_user.oauth_authorized_tokens - @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) - @authorized_apps = @authorized_tokens.map(&:application).uniq - [nil] - @application = flash[:application] || Doorkeeper::Application.new - end - def update user_params.except!(:email) if @user.ldap_user? diff --git a/app/views/doorkeeper/applications/index.html.haml b/app/views/doorkeeper/applications/index.html.haml index ba4c5b86ef..ea0b66c932 100644 --- a/app/views/doorkeeper/applications/index.html.haml +++ b/app/views/doorkeeper/applications/index.html.haml @@ -1,19 +1,83 @@ - page_title "Applications" -%h3.page-title Your applications -%p= link_to 'New Application', new_oauth_application_path, class: 'btn btn-success' +- header_title page_title, applications_profile_path -.table-holder - %table.table.table-striped - %thead - %tr - %th Name - %th Callback URL - %th - %th - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td= application.redirect_uri - %td= link_to 'Edit', edit_oauth_application_path(application), class: 'btn btn-link' - %td= render 'delete_form', application: application +.row.prepend-top-default + .col-lg-3.profile-settings-sidebar + %h4.prepend-top-0 + = page_title + %p + - if user_oauth_applications? + Manage applications that can use GitLab as an OAuth provider, + and applications that you've authorized to use your account. + - else + Manage applications that you've authorized to use your account. + .col-lg-9 + - if user_oauth_applications? + %h5.prepend-top-0 + Add new application + = render 'form', application: @application + %hr + - if user_oauth_applications? + .oauth-applications + %h5 + Your applications (#{@applications.size}) + - if @applications.any? + .table-responsive + %table.table + %thead + %tr + %th Name + %th Callback URL + %th Clients + %th.last-heading + %tbody + - @applications.each do |application| + %tr{id: "application_#{application.id}"} + %td= link_to application.name, oauth_application_path(application) + %td + - application.redirect_uri.split.each do |uri| + %div= uri + %td= application.access_tokens.count + %td + = link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do + %span.sr-only + Edit + = icon('pencil') + = render 'delete_form', application: application, small: true + - else + .profile-settings-message.text-center + You don't have any applications + .oauth-authorized-applications.prepend-top-20.append-bottom-default + - if user_oauth_applications? + %h5 + Authorized applications (#{@authorized_tokens.size}) + + - if @authorized_tokens.any? + .table-responsive + %table.table.table-striped + %thead + %tr + %th Name + %th Authorized At + %th Scope + %th + %tbody + - @authorized_apps.each do |app| + - token = app.authorized_tokens.order('created_at desc').first + %tr{id: "application_#{app.id}"} + %td= app.name + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', application: app + - @authorized_anonymous_tokens.each do |token| + %tr + %td + Anonymous + %div.help-block + %em Authorization was granted by entering your username and password in the application. + %td= token.created_at + %td= token.scopes + %td= render 'delete_form', token: token + - else + .profile-settings-message.text-center + You don't have any authorized applications diff --git a/app/views/layouts/nav/_profile.html.haml b/app/views/layouts/nav/_profile.html.haml index f3ded04419..3b9d31a6fc 100644 --- a/app/views/layouts/nav/_profile.html.haml +++ b/app/views/layouts/nav/_profile.html.haml @@ -17,7 +17,7 @@ = icon('gear fw') %span Account - = nav_link(path: ['profiles#applications', 'applications#edit', 'applications#show', 'applications#new', 'applications#create']) do + = nav_link(controller: 'oauth/applications') do = link_to applications_profile_path, title: 'Applications' do = icon('cloud fw') %span diff --git a/app/views/profiles/applications.html.haml b/app/views/profiles/applications.html.haml deleted file mode 100644 index 7c0f700d68..0000000000 --- a/app/views/profiles/applications.html.haml +++ /dev/null @@ -1,83 +0,0 @@ -- page_title "Applications" -- header_title page_title, applications_profile_path - -.row.prepend-top-default - .col-lg-3.profile-settings-sidebar - %h4.prepend-top-0 - = page_title - %p - - if user_oauth_applications? - Manage applications that can use GitLab as an OAuth provider, - and applications that you've authorized to use your account. - - else - Manage applications that you've authorized to use your account. - .col-lg-9 - - if user_oauth_applications? - %h5.prepend-top-0 - Add new application - = render 'doorkeeper/applications/form', application: @application - %hr - - if user_oauth_applications? - .oauth-applications - %h5 - Your applications (#{@applications.size}) - - if @applications.any? - .table-responsive - %table.table - %thead - %tr - %th Name - %th Callback URL - %th Clients - %th.last-heading - %tbody - - @applications.each do |application| - %tr{:id => "application_#{application.id}"} - %td= link_to application.name, oauth_application_path(application) - %td - - application.redirect_uri.split.each do |uri| - %div= uri - %td= application.access_tokens.count - %td - = link_to edit_oauth_application_path(application), class: "btn btn-transparent append-right-5" do - %span.sr-only - Edit - = icon('pencil') - = render 'doorkeeper/applications/delete_form', application: application, small: true - - else - .profile-settings-message.text-center - You don't have any applications - .oauth-authorized-applications.prepend-top-20.append-bottom-default - - if user_oauth_applications? - %h5 - Authorized applications (#{@authorized_tokens.size}) - - - if @authorized_tokens.any? - .table-responsive - %table.table.table-striped - %thead - %tr - %th Name - %th Authorized At - %th Scope - %th - %tbody - - @authorized_apps.each do |app| - - token = app.authorized_tokens.order('created_at desc').first - %tr{:id => "application_#{app.id}"} - %td= app.name - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', application: app - - @authorized_anonymous_tokens.each do |token| - %tr - %td - Anonymous - %div.help-block - %em Authorization was granted by entering your username and password in the application. - %td= token.created_at - %td= token.scopes - %td= render 'doorkeeper/authorized_applications/delete_form', token: token - - else - .profile-settings-message.text-center - You don't have any authorized applications diff --git a/config/routes.rb b/config/routes.rb index a918b5bd3f..92aaedf7b6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -295,7 +295,7 @@ Rails.application.routes.draw do resource :profile, only: [:show, :update] do member do get :audit_log - get :applications + get :applications, to: 'oauth/applications#index' put :reset_private_token put :update_username From ab0af56201413fea81cf42367671ec9a845315c7 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 13 Mar 2016 17:22:10 -0700 Subject: [PATCH 064/797] Use Capybara find methods and remove sleeps in feature specs in "All Issues" filter --- app/views/shared/issuable/_filter.html.haml | 2 +- features/steps/dashboard/issues.rb | 13 +++++-------- 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index c3fbba2ba5..e62d80aeb2 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -15,7 +15,7 @@ .filter-item.inline - if params[:assignee_id] = hidden_field_tag(:assignee_id, params[:assignee_id]) - = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-filter-submit", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", placeholder: "Search assignee", data: { any_user: "Any Author", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id" } }) .filter-item.inline.milestone-filter diff --git a/features/steps/dashboard/issues.rb b/features/steps/dashboard/issues.rb index d723300f48..3072c790a4 100644 --- a/features/steps/dashboard/issues.rb +++ b/features/steps/dashboard/issues.rb @@ -44,14 +44,11 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps end step 'I click "All" link' do - execute_script('$(".js-user-search").first().click()') - sleep 1 - execute_script('$(".js-user-search").first().parent().find("li a").first().click()') - sleep 1 - execute_script('$(".js-user-search").eq(1).click()') - sleep 1 - execute_script('$(".js-user-search").eq(1).parent().find("li a").first().click()') - sleep 1 + find('.js-author-search').click + find('.dropdown-menu-user-full-name', match: :first).click + + find('.js-assignee-search').click + find('.dropdown-menu-user-full-name', match: :first).click end def should_see(issue) From dc3c9f5a7f50858269901439605e518d2d1c2e04 Mon Sep 17 00:00:00 2001 From: Josh Frye Date: Sun, 13 Mar 2016 21:57:39 -0400 Subject: [PATCH 065/797] Remove last to_reference --- app/helpers/events_helper.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 6cda2d8a65..37a888d9c6 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -171,7 +171,7 @@ module EventsHelper "#{event.note_target_type} #{truncate event.note_target.to_reference}" end else - link_to event_note_target_path(event), title: h(event.note_target.to_reference) do + link_to event_note_target_path(event) do "#{event.note_target_type} #{truncate event.note_target.to_reference}" end end From f0c73a5bdbbf07c6668b9ee50f38ba4f4813eabd Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 13 Mar 2016 22:09:42 -0700 Subject: [PATCH 066/797] Remove sleeps from network graph feature spec --- features/project/network_graph.feature | 3 ++- features/steps/project/network_graph.rb | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/features/project/network_graph.feature b/features/project/network_graph.feature index 6cc89a15a7..89a02706bd 100644 --- a/features/project/network_graph.feature +++ b/features/project/network_graph.feature @@ -34,9 +34,10 @@ Feature: Project Network Graph @javascript Scenario: I should filter selected tag When I switch ref to "v1.0.0" + Then page should have "v1.0.0" in title Then page should have content not containing "v1.0.0" When click "Show only selected branch" checkbox - Then page should not have content not containing "v1.0.0" + Then page should only have content from "v1.0.0" When click "Show only selected branch" checkbox Then page should have content not containing "v1.0.0" diff --git a/features/steps/project/network_graph.rb b/features/steps/project/network_graph.rb index 7a83d32a24..9b59b68267 100644 --- a/features/steps/project/network_graph.rb +++ b/features/steps/project/network_graph.rb @@ -41,17 +41,14 @@ class Spinach::Features::ProjectNetworkGraph < Spinach::FeatureSteps When 'I switch ref to "feature"' do select 'feature', from: 'ref' - sleep 2 end When 'I switch ref to "v1.0.0"' do select 'v1.0.0', from: 'ref' - sleep 2 end When 'click "Show only selected branch" checkbox' do find('#filter_ref').click - sleep 2 end step 'page should have content not containing "v1.0.0"' do @@ -60,7 +57,11 @@ class Spinach::Features::ProjectNetworkGraph < Spinach::FeatureSteps end end - step 'page should not have content not containing "v1.0.0"' do + step 'page should have "v1.0.0" in title' do + expect(page).to have_css 'title', text: 'Network · v1.0.0', visible: false + end + + step 'page should only have content from "v1.0.0"' do page.within '.network-graph' do expect(page).not_to have_content 'Change some files' end From 7ebb932070f03e8fb6116b9f54148cae2d782776 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Mon, 14 Mar 2016 10:14:32 +0200 Subject: [PATCH 067/797] Add yaml definition to codeblock [ci skip] --- doc/ci/yaml/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index beaa86250a..4d27c07913 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -547,7 +547,7 @@ for `test:linux` and artifacts from `build:linux`. The job `deploy` will download artifacts from all previous builds because of the [stage](#stages) precedence: -``` +```yaml build:osx: stage: build script: make build:osx From 9d6e0c5071e640685f16d5f6045e89851db4b868 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 4 Mar 2016 09:45:05 +0000 Subject: [PATCH 068/797] Left sidebar overlaps the content on mobile devices --- app/assets/javascripts/sidebar.js.coffee | 17 +++++++++++++---- app/assets/stylesheets/framework/header.scss | 10 +++++----- app/assets/stylesheets/framework/sidebar.scss | 8 ++++++-- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index cff309c597..dfa69dd6a4 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -1,8 +1,8 @@ -$(document).on("click", '.toggle-nav-collapse', (e) -> - e.preventDefault() - collapsed = 'page-sidebar-collapsed' - expanded = 'page-sidebar-expanded' +mobileWidth = 768 +collapsed = 'page-sidebar-collapsed' +expanded = 'page-sidebar-expanded' +toggleSidebar = -> $('.page-with-sidebar').toggleClass("#{collapsed} #{expanded}") $('header').toggleClass("header-collapsed header-expanded") $('.sidebar-wrapper').toggleClass("sidebar-collapsed sidebar-expanded") @@ -14,4 +14,13 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> niceScrollBars.updateScrollBar(); ), 300 +$(document).on("click", '.toggle-nav-collapse', (e) -> + e.preventDefault() + + toggleSidebar() ) + +$(document).ready -> + if $(window).width() < mobileWidth + if $('.page-with-sidebar').hasClass(expanded) + toggleSidebar() diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index e624982c5c..3d3d329561 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -141,17 +141,17 @@ header { margin-left: $sidebar_collapsed_width; } -@media (max-width: $screen-md-max) { +@media (max-width: $screen-sm) { .header-collapsed { margin-left: $sidebar_collapsed_width; } - .header-expanded { - margin-left: $sidebar_width; - } + .header-expanded { + margin-left: $sidebar_collapsed_width; + } } -@media(min-width: $screen-md-max) { +@media(min-width: $screen-sm) { .header-collapsed { @include collapsed-header; } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 6b382e4b1b..0f83bd8b55 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -39,7 +39,7 @@ } .sidebar-wrapper { - z-index: 99; + z-index: 999; background: $background-color; } @@ -203,7 +203,11 @@ } @mixin expanded-sidebar { - padding-left: $sidebar_width; + padding-left: $sidebar_collapsed_width; + + @media (min-width: $screen-sm-min) { + padding-left: $sidebar_width; + } &.right-sidebar-collapsed { /* Extra small devices (phones, less than 768px) */ From b155d54941ab5bb4eb07c1774ba3a2dfbb9f2707 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 10 Mar 2016 16:55:44 +0000 Subject: [PATCH 069/797] Removed deprecated bootstrap variables --- 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 3d3d329561..9aa97f6ac4 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -141,7 +141,7 @@ header { margin-left: $sidebar_collapsed_width; } -@media (max-width: $screen-sm) { +@media (max-width: $screen-sm-min) { .header-collapsed { margin-left: $sidebar_collapsed_width; } @@ -151,7 +151,7 @@ header { } } -@media(min-width: $screen-sm) { +@media(min-width: $screen-sm-min) { .header-collapsed { @include collapsed-header; } From ce9bbce78a258b43181766bcbebad7add90968ff Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 10 Mar 2016 17:04:45 +0000 Subject: [PATCH 070/797] changed jquery document ready --- app/assets/javascripts/sidebar.js.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index dfa69dd6a4..1ffb6619ab 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -20,7 +20,7 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> toggleSidebar() ) -$(document).ready -> +$ -> if $(window).width() < mobileWidth if $('.page-with-sidebar').hasClass(expanded) toggleSidebar() From f0f6723fe02bfb1a79c1cbefed156a420dddc352 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 11 Mar 2016 15:11:11 +0000 Subject: [PATCH 071/797] Created bootstrap breakpoint class This class checks the current bootstrap breakpoint --- app/assets/javascripts/application.js.coffee | 27 +++---------------- app/assets/javascripts/breakpoints.coffee | 24 +++++++++++++++++ app/assets/javascripts/sidebar.js.coffee | 7 +++-- app/assets/stylesheets/framework/header.scss | 18 +++++-------- app/assets/stylesheets/framework/sidebar.scss | 2 +- 5 files changed, 41 insertions(+), 37 deletions(-) create mode 100644 app/assets/javascripts/breakpoints.coffee diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 1212e89975..9e114a80c2 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -256,35 +256,15 @@ $ -> $('.right-sidebar') .hasClass('right-sidebar-collapsed'), { path: '/' }) - bootstrapBreakpoint = undefined; - checkBootstrapBreakpoints = -> - if $('.device-xs').is(':visible') - bootstrapBreakpoint = "xs" - else if $('.device-sm').is(':visible') - bootstrapBreakpoint = "sm" - else if $('.device-md').is(':visible') - bootstrapBreakpoint = "md" - else if $('.device-lg').is(':visible') - bootstrapBreakpoint = "lg" - - setBootstrapBreakpoints = -> - if $('.device-xs').length - return - - $("body") - .append('

'+ - '
'+ - '
'+ - '
') - checkBootstrapBreakpoints() - fitSidebarForSize = -> oldBootstrapBreakpoint = bootstrapBreakpoint checkBootstrapBreakpoints() + bootstrapBreakpoint = breakpoints.getBreakpointSize() if bootstrapBreakpoint != oldBootstrapBreakpoint $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) checkInitialSidebarSize = -> + bootstrapBreakpoint = breakpoints.getBreakpointSize() if bootstrapBreakpoint is "xs" or "sm" $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) @@ -293,6 +273,7 @@ $ -> .on "resize", (e) -> fitSidebarForSize() - setBootstrapBreakpoints() checkInitialSidebarSize() + breakpoints = new Breakpoints() + bootstrapBreakpoint = breakpoints.getBreakpointSize() new Aside() diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee new file mode 100644 index 0000000000..fd2ee8efa2 --- /dev/null +++ b/app/assets/javascripts/breakpoints.coffee @@ -0,0 +1,24 @@ +class @Breakpoints + BREAKPOINTS = ["xs", "sm", "md", "lg"] + + constructor: -> + @setup() + + setup: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + + if $(allDeviceSelector.join(",")).length + return + + # Create all the elements + $.each BREAKPOINTS, (i, breakpoint) -> + $("body").append "
" + + getBreakpointSize: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + + $visibleDevice = $(allDeviceSelector.join(",")).filter(":visible") + + return $visibleDevice.attr("class").split("visible-")[1] diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index 1ffb6619ab..5c68690979 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -1,4 +1,4 @@ -mobileWidth = 768 +mobileWidth = 991 collapsed = 'page-sidebar-collapsed' expanded = 'page-sidebar-expanded' @@ -21,6 +21,9 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> ) $ -> - if $(window).width() < mobileWidth + breakpoints = new Breakpoints() + size = breakpoints.getBreakpointSize() + + if size is "xs" or size is "sm" if $('.page-with-sidebar').hasClass(expanded) toggleSidebar() diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 9aa97f6ac4..a73643b35a 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -141,22 +141,18 @@ header { margin-left: $sidebar_collapsed_width; } -@media (max-width: $screen-sm-min) { - .header-collapsed { - margin-left: $sidebar_collapsed_width; - } +.header-collapsed { + margin-left: $sidebar_collapsed_width; - .header-expanded { - margin-left: $sidebar_collapsed_width; + @media (min-width: $screen-sm-max) { + @include collapsed-header; } } -@media(min-width: $screen-sm-min) { - .header-collapsed { - @include collapsed-header; - } +.header-expanded { + margin-left: $sidebar_collapsed_width; - .header-expanded { + @media (min-width: $screen-sm-max) { margin-left: $sidebar_width; } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 0f83bd8b55..24608e6cff 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -205,7 +205,7 @@ @mixin expanded-sidebar { padding-left: $sidebar_collapsed_width; - @media (min-width: $screen-sm-min) { + @media (min-width: $screen-sm-max) { padding-left: $sidebar_width; } From 23b0eeca280de8c1f36f863396cb0aee912c56b9 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Fri, 11 Mar 2016 15:13:47 +0000 Subject: [PATCH 072/797] removed un-used function call --- app/assets/javascripts/application.js.coffee | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index 9e114a80c2..beee6f2ec6 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -108,6 +108,9 @@ window.onload = -> setTimeout shiftWindow, 100 $ -> + breakpoints = new Breakpoints() + bootstrapBreakpoint = breakpoints.getBreakpointSize() + $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") # Click a .js-select-on-focus field, select the contents @@ -258,7 +261,6 @@ $ -> fitSidebarForSize = -> oldBootstrapBreakpoint = bootstrapBreakpoint - checkBootstrapBreakpoints() bootstrapBreakpoint = breakpoints.getBreakpointSize() if bootstrapBreakpoint != oldBootstrapBreakpoint $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) @@ -274,6 +276,4 @@ $ -> fitSidebarForSize() checkInitialSidebarSize() - breakpoints = new Breakpoints() - bootstrapBreakpoint = breakpoints.getBreakpointSize() new Aside() From 1a5992b5eb15a58223925877e90f7642aac6ce2d Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 14 Mar 2016 11:51:07 +0000 Subject: [PATCH 073/797] Changed breakpoint size Changed breakpoint class into singleton --- app/assets/javascripts/application.js.coffee | 7 ++- app/assets/javascripts/breakpoints.coffee | 43 +++++++++++-------- app/assets/javascripts/sidebar.js.coffee | 4 +- app/assets/stylesheets/framework/header.scss | 4 +- app/assets/stylesheets/framework/sidebar.scss | 4 +- 5 files changed, 34 insertions(+), 28 deletions(-) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index beee6f2ec6..e9c6196e92 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -108,8 +108,7 @@ window.onload = -> setTimeout shiftWindow, 100 $ -> - breakpoints = new Breakpoints() - bootstrapBreakpoint = breakpoints.getBreakpointSize() + bootstrapBreakpoint = bp.getBreakpointSize() $(".nicescroll").niceScroll(cursoropacitymax: '0.4', cursorcolor: '#FFF', cursorborder: "1px solid #FFF") @@ -261,12 +260,12 @@ $ -> fitSidebarForSize = -> oldBootstrapBreakpoint = bootstrapBreakpoint - bootstrapBreakpoint = breakpoints.getBreakpointSize() + bootstrapBreakpoint = bp.getBreakpointSize() if bootstrapBreakpoint != oldBootstrapBreakpoint $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) checkInitialSidebarSize = -> - bootstrapBreakpoint = breakpoints.getBreakpointSize() + bootstrapBreakpoint = bp.getBreakpointSize() if bootstrapBreakpoint is "xs" or "sm" $(document).trigger('breakpoint:change', [bootstrapBreakpoint]) diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee index fd2ee8efa2..73a42d9998 100644 --- a/app/assets/javascripts/breakpoints.coffee +++ b/app/assets/javascripts/breakpoints.coffee @@ -1,24 +1,33 @@ class @Breakpoints - BREAKPOINTS = ["xs", "sm", "md", "lg"] + instance = null; - constructor: -> - @setup() + class BreakpointInstance + BREAKPOINTS = ["xs", "sm", "md", "lg"] - setup: -> - allDeviceSelector = BREAKPOINTS.map (breakpoint) -> - ".device-#{breakpoint}" + constructor: -> + @setup() - if $(allDeviceSelector.join(",")).length - return + setup: -> + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" - # Create all the elements - $.each BREAKPOINTS, (i, breakpoint) -> - $("body").append "
" + return if $(allDeviceSelector.join(",")).length - getBreakpointSize: -> - allDeviceSelector = BREAKPOINTS.map (breakpoint) -> - ".device-#{breakpoint}" + # Create all the elements + $.each BREAKPOINTS, (i, breakpoint) -> + $("body").append "
" - $visibleDevice = $(allDeviceSelector.join(",")).filter(":visible") - - return $visibleDevice.attr("class").split("visible-")[1] + getBreakpointSize: -> + @setup() + + allDeviceSelector = BREAKPOINTS.map (breakpoint) -> + ".device-#{breakpoint}" + + $visibleDevice = $(allDeviceSelector.join(",")).filter(":visible") + + return $visibleDevice.attr("class").split("visible-")[1] + + @get: -> + return instance ?= new BreakpointInstance + +@bp = Breakpoints.get() diff --git a/app/assets/javascripts/sidebar.js.coffee b/app/assets/javascripts/sidebar.js.coffee index 5c68690979..eea3f5ee91 100644 --- a/app/assets/javascripts/sidebar.js.coffee +++ b/app/assets/javascripts/sidebar.js.coffee @@ -1,4 +1,3 @@ -mobileWidth = 991 collapsed = 'page-sidebar-collapsed' expanded = 'page-sidebar-expanded' @@ -21,8 +20,7 @@ $(document).on("click", '.toggle-nav-collapse', (e) -> ) $ -> - breakpoints = new Breakpoints() - size = breakpoints.getBreakpointSize() + size = bp.getBreakpointSize() if size is "xs" or size is "sm" if $('.page-with-sidebar').hasClass(expanded) diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index a73643b35a..4c4033e3ae 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -144,7 +144,7 @@ header { .header-collapsed { margin-left: $sidebar_collapsed_width; - @media (min-width: $screen-sm-max) { + @media (min-width: $screen-md-min) { @include collapsed-header; } } @@ -152,7 +152,7 @@ header { .header-expanded { margin-left: $sidebar_collapsed_width; - @media (min-width: $screen-sm-max) { + @media (min-width: $screen-md-min) { margin-left: $sidebar_width; } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 24608e6cff..26df9acd2a 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -34,7 +34,7 @@ @media (min-width: $screen-sm-min) { padding-right: $gutter_width; } - + } } @@ -205,7 +205,7 @@ @mixin expanded-sidebar { padding-left: $sidebar_collapsed_width; - @media (min-width: $screen-sm-max) { + @media (min-width: $screen-md-min) { padding-left: $sidebar_width; } From 0672258915a0cf444802ffc50ad1cd914f4f11d4 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Wed, 9 Mar 2016 16:24:02 +0100 Subject: [PATCH 074/797] Cleanup CiCommit and CiBuild - Remove all view related methods from Ci::Build and CommitStatus - Remove unused Ci::Commit and Ci::Build methods - Use polymorphism to render different types of CommitStatus --- app/models/ci/build.rb | 32 +------- app/models/ci/commit.rb | 32 +------- app/models/commit_status.rb | 18 +---- app/services/ci/image_for_build_service.rb | 2 +- app/services/create_commit_builds_service.rb | 1 - app/views/admin/builds/_build.html.haml | 21 +++-- app/views/ci/commits/_commit.html.haml | 32 -------- app/views/projects/builds/index.html.haml | 3 +- app/views/projects/builds/show.html.haml | 19 ++--- app/views/projects/ci/builds/_build.html.haml | 78 ++++++++++++++++++ app/views/projects/commit/_builds.html.haml | 7 +- .../commit_statuses/_commit_status.html.haml | 79 ------------------- .../_generic_commit_status.html.haml | 60 ++++++++++++++ doc/api/builds.md | 7 -- features/steps/shared/builds.rb | 2 +- lib/api/entities.rb | 7 -- spec/models/build_spec.rb | 32 +------- spec/models/ci/commit_spec.rb | 46 +---------- 18 files changed, 174 insertions(+), 304 deletions(-) delete mode 100644 app/views/ci/commits/_commit.html.haml create mode 100644 app/views/projects/ci/builds/_build.html.haml delete mode 100644 app/views/projects/commit_statuses/_commit_status.html.haml create mode 100644 app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 1227458e52..6c1ca8db24 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -128,7 +128,7 @@ module Ci end def retried? - !self.commit.latest_builds_for_ref(self.ref).include?(self) + !self.commit.latest_statuses_for_ref(self.ref).include?(self) end def depends_on_builds @@ -309,22 +309,6 @@ module Ci project.valid_runners_token? token end - def target_url - namespace_project_build_url(project.namespace, project, self) - end - - def cancel_url - if active? - cancel_namespace_project_build_path(project.namespace, project, self) - end - end - - def retry_url - if retryable? - retry_namespace_project_build_path(project.namespace, project, self) - end - end - def can_be_served?(runner) (tag_list - runner.tag_list).empty? end @@ -333,7 +317,7 @@ module Ci project.any_runners? { |runner| runner.active? && runner.online? && can_be_served?(runner) } end - def show_warning? + def stuck? pending? && !any_runners_online? end @@ -348,18 +332,6 @@ module Ci artifacts_file.exists? end - def artifacts_download_url - if artifacts? - download_namespace_project_build_artifacts_path(project.namespace, project, self) - end - end - - def artifacts_browse_url - if artifacts_metadata? - browse_namespace_project_build_artifacts_path(project.namespace, project, self) - end - end - def artifacts_metadata? artifacts? && artifacts_metadata.exists? end diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index ecbd2078b1..12c60158d4 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -25,8 +25,6 @@ module Ci has_many :builds, class_name: 'Ci::Build' has_many :trigger_requests, dependent: :destroy, class_name: 'Ci::TriggerRequest' - scope :ordered, -> { order('CASE WHEN ci_commits.committed_at IS NULL THEN 0 ELSE 1 END', :committed_at, :id) } - validates_presence_of :sha validate :valid_commit_sha @@ -42,16 +40,6 @@ module Ci project.id end - def last_build - builds.order(:id).last - end - - def retry - latest_builds.each do |build| - Ci::Build.retry(build) - end - end - def valid_commit_sha if self.sha == Gitlab::Git::BLANK_SHA self.errors.add(:sha, " cant be 00000000 (branch removal)") @@ -121,12 +109,8 @@ module Ci @latest_statuses ||= statuses.latest.to_a end - def latest_builds - @latest_builds ||= builds.latest.to_a - end - - def latest_builds_for_ref(ref) - latest_builds.select { |build| build.ref == ref } + def latest_statuses_for_ref(ref) + latest_statuses.select { |status| status.ref == ref } end def retried @@ -170,7 +154,7 @@ module Ci end def duration - duration_array = latest_statuses.map(&:duration).compact + duration_array = statuses.map(&:duration).compact duration_array.reduce(:+).to_i end @@ -183,16 +167,12 @@ module Ci end def coverage - coverage_array = latest_builds.map(&:coverage).compact + coverage_array = latest_statuses.map(&:coverage).compact if coverage_array.size >= 1 '%.2f' % (coverage_array.reduce(:+) / coverage_array.size) end end - def matrix_for_ref?(ref) - latest_builds_for_ref(ref).size > 1 - end - def config_processor return nil unless ci_yaml_file @config_processor ||= Ci::GitlabCiYamlProcessor.new(ci_yaml_file, project.path_with_namespace) @@ -218,10 +198,6 @@ module Ci git_commit_message =~ /(\[ci skip\])/ if git_commit_message end - def update_committed! - update!(committed_at: DateTime.now) - end - private def save_yaml_error(error) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 7ef5083632..3b1aa0f5c8 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -125,23 +125,7 @@ class CommitStatus < ActiveRecord::Base end end - def cancel_url - nil - end - - def retry_url - nil - end - - def show_warning? + def stuck? false end - - def artifacts_download_url - nil - end - - def artifacts_browse_url - nil - end end diff --git a/app/services/ci/image_for_build_service.rb b/app/services/ci/image_for_build_service.rb index 005a5c4661..50c95ced8a 100644 --- a/app/services/ci/image_for_build_service.rb +++ b/app/services/ci/image_for_build_service.rb @@ -3,7 +3,7 @@ module Ci def execute(project, opts) sha = opts[:sha] || ref_sha(project, opts[:ref]) - commit = project.ci_commits.ordered.find_by(sha: sha) + commit = project.ci_commits.find_by(sha: sha) image_name = image_for_commit(commit) image_path = Rails.root.join('public/ci', image_name) diff --git a/app/services/create_commit_builds_service.rb b/app/services/create_commit_builds_service.rb index 31b407efeb..69d5c42a87 100644 --- a/app/services/create_commit_builds_service.rb +++ b/app/services/create_commit_builds_service.rb @@ -33,7 +33,6 @@ class CreateCommitBuildsService unless commit.skip_ci? # Create builds for commit tag = Gitlab::Git.tag_ref?(origin_ref) - commit.update_committed! commit.create_builds(ref, tag, user) end diff --git a/app/views/admin/builds/_build.html.haml b/app/views/admin/builds/_build.html.haml index 34d955568f..588ad76742 100644 --- a/app/views/admin/builds/_build.html.haml +++ b/app/views/admin/builds/_build.html.haml @@ -4,13 +4,13 @@ = ci_status_with_icon(build.status) %td.build-link - - if can?(current_user, :read_build, project) && build.target_url - = link_to build.target_url do + - if can?(current_user, :read_build, build.project) + = link_to namespace_project_build_url(build.project.namespace, build.project, build) do %strong Build ##{build.id} - else %strong Build ##{build.id} - - if build.show_warning? + - if build.stuck? %i.fa.fa-warning.text-warning %td @@ -18,11 +18,11 @@ = link_to project.name_with_namespace, admin_namespace_project_path(project.namespace, project), class: "monospace" %td - = link_to build.short_sha, namespace_project_commit_path(project.namespace, project, build.sha), class: "monospace" + = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace" %td - if build.ref - = link_to build.ref, namespace_project_commits_path(project.namespace, project, build.ref) + = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref) - else .light none @@ -61,13 +61,12 @@ %td .pull-right - if can?(current_user, :read_build, project) && build.artifacts? - = link_to build.artifacts_download_url, title: 'Download artifacts' do + = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do %i.fa.fa-download - if can?(current_user, :update_build, build.project) - if build.active? - - if build.cancel_url - = link_to build.cancel_url, method: :post, title: 'Cancel' do - %i.fa.fa-remove.cred - - elsif defined?(allow_retry) && allow_retry && build.retry_url - = link_to build.retry_url, method: :post, title: 'Retry' do + = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do + %i.fa.fa-remove.cred + - elsif defined?(allow_retry) && allow_retry && build.retryable? + = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do %i.fa.fa-repeat diff --git a/app/views/ci/commits/_commit.html.haml b/app/views/ci/commits/_commit.html.haml deleted file mode 100644 index 11163813f3..0000000000 --- a/app/views/ci/commits/_commit.html.haml +++ /dev/null @@ -1,32 +0,0 @@ -%tr.build - %td.status - = ci_status_with_icon(commit.status) - - if commit.running? - · - = commit.stage - - - %td.build-link - = link_to ci_status_path(commit) do - %strong #{commit.short_sha} - - %td.build-message - %span= truncate_first_line(commit.git_commit_message) - - %td.build-branch - - unless @ref - %span - - commit.refs.each do |ref| - = link_to truncate(ref, length: 25), ci_project_path(@project, ref: ref) - - %td.duration - - if commit.duration > 0 - #{time_interval_in_words commit.duration} - - %td.timestamp - - if commit.finished_at - %span #{time_ago_in_words commit.finished_at} ago - - - if commit.coverage - %td.coverage - #{commit.coverage}% diff --git a/app/views/projects/builds/index.html.haml b/app/views/projects/builds/index.html.haml index 14f1d3226b..811d304ea7 100644 --- a/app/views/projects/builds/index.html.haml +++ b/app/views/projects/builds/index.html.haml @@ -55,7 +55,6 @@ %th Coverage %th - - @builds.each do |build| - = render 'projects/commit_statuses/commit_status', commit_status: build, commit_sha: true, stage: true, coverage: @project.build_coverage_enabled?, allow_retry: true + = render @builds, commit_sha: true, stage: true, allow_retry: true, coverage: @project.build_coverage_enabled? = paginate @builds, theme: 'gitlab' diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index be7cc0f256..dbbf382fa2 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -13,9 +13,10 @@ = link_to "merge request ##{merge_request.iid}", merge_request_path(merge_request) #up-build-trace - - if @commit.matrix_for_ref?(@build.ref) + - builds = @build.commit.builds.similar(@build).latest.ordered.to_a + - if builds.size > 1 %ul.nav-links.no-top.no-bottom - - @commit.latest_builds_for_ref(@build.ref).each do |build| + - builds.each do |build| %li{class: ('active' if build == @build) } = link_to namespace_project_build_path(@project.namespace, @project, build) do = ci_icon_for_status(build.status) @@ -44,7 +45,7 @@ .pull-right #{time_ago_with_tooltip(@build.finished_at) if @build.finished_at} - - if @build.show_warning? + - if @build.stuck? - unless @build.any_runners_online? .bs-callout.bs-callout-warning %p @@ -100,12 +101,12 @@ %h4.title Build artifacts .center .btn-group{ role: :group } - = link_to @build.artifacts_download_url, class: 'btn btn-sm btn-primary' do + = link_to download_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do = icon('download') Download - if @build.artifacts_metadata? - = link_to @build.artifacts_browse_url, class: 'btn btn-sm btn-primary' do + = link_to browse_namespace_project_build_artifacts_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary' do = icon('folder-open') Browse @@ -115,10 +116,10 @@ - if can?(current_user, :update_build, @project) .center .btn-group{ role: :group } - - if @build.cancel_url - = link_to "Cancel", @build.cancel_url, class: 'btn btn-sm btn-danger', method: :post - - elsif @build.retry_url - = link_to "Retry", @build.retry_url, class: 'btn btn-sm btn-primary', method: :post + - if @build.active? + = link_to "Cancel", cancel_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-danger', method: :post + - elsif @build.retryable? + = link_to "Retry", retry_namespace_project_build_path(@project.namespace, @project, @build), class: 'btn btn-sm btn-primary', method: :post - if @build.erasable? = link_to erase_namespace_project_build_path(@project.namespace, @project, @build), diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml new file mode 100644 index 0000000000..195fd4a9d5 --- /dev/null +++ b/app/views/projects/ci/builds/_build.html.haml @@ -0,0 +1,78 @@ +%tr.build + %td.status + - if can?(current_user, :read_build, build) + = link_to namespace_project_build_url(build.project.namespace, build.project, build), class: "ci-status ci-#{build.status}" do + = ci_icon_for_status(build.status) + = build.status + - else + = ci_status_with_icon(build.status) + + %td.build-link + - if can?(current_user, :read_build, build) + = link_to namespace_project_build_url(build.project.namespace, build.project, build) do + %strong ##{build.id} + - else + %strong ##{build.id} + + - if build.stuck? + %i.fa.fa-warning.text-warning + + - if defined?(commit_sha) && commit_sha + %td + = link_to build.short_sha, namespace_project_commit_path(build.project.namespace, build.project, build.sha), class: "monospace" + + %td + - if build.ref + = link_to build.ref, namespace_project_commits_path(build.project.namespace, build.project, build.ref) + - else + .light none + + - if defined?(runner) && runner + %td + - if build.try(:runner) + = runner_link(build.runner) + - else + .light none + + - if defined?(stage) && stage + %td + = build.stage + + %td + = build.name + + .pull-right + - if build.tags.any? + - build.tags.each do |tag| + %span.label.label-primary + = tag + - if build.try(:trigger_request) + %span.label.label-info triggered + - if build.try(:allow_failure) + %span.label.label-danger allowed to fail + + %td.duration + - if build.duration + #{duration_in_words(build.finished_at, build.started_at)} + + %td.timestamp + - if build.finished_at + %span #{time_ago_with_tooltip(build.finished_at)} + + - if defined?(coverage) && coverage + %td.coverage + - if build.try(:coverage) + #{build.coverage}% + + %td + .pull-right + - if can?(current_user, :read_build, build) && build.artifacts? + = link_to download_namespace_project_build_artifacts_path(build.project.namespace, build.project, build), title: 'Download artifacts' do + %i.fa.fa-download + - if can?(current_user, :update_build, build) + - if build.active? + = link_to cancel_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Cancel' do + %i.fa.fa-remove.cred + - elsif defined?(allow_retry) && allow_retry && build.retryable? + = link_to retry_namespace_project_build_path(build.project.namespace, build.project, build, return_to: request.original_url), method: :post, title: 'Retry' do + %i.fa.fa-repeat diff --git a/app/views/projects/commit/_builds.html.haml b/app/views/projects/commit/_builds.html.haml index befad27666..003b7c18d0 100644 --- a/app/views/projects/commit/_builds.html.haml +++ b/app/views/projects/commit/_builds.html.haml @@ -43,8 +43,8 @@ %th Coverage %th - @ci_commit.refs.each do |ref| - = render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.statuses.for_ref(ref).latest.ordered, - locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true } + - builds = @ci_commit.statuses.for_ref(ref).latest.ordered + = render builds, coverage: @ci_commit.project.build_coverage_enabled?, stage: true, allow_retry: true - if @ci_commit.retried.any? .gray-content-block.second-block @@ -64,5 +64,4 @@ - if @ci_commit.project.build_coverage_enabled? %th Coverage %th - = render partial: "projects/commit_statuses/commit_status", collection: @ci_commit.retried, - locals: { coverage: @ci_commit.project.build_coverage_enabled?, stage: true } + = render @ci_commit.retried, coverage: @ci_commit.project.build_coverage_enabled?, stage: true diff --git a/app/views/projects/commit_statuses/_commit_status.html.haml b/app/views/projects/commit_statuses/_commit_status.html.haml deleted file mode 100644 index a3449d1ae0..0000000000 --- a/app/views/projects/commit_statuses/_commit_status.html.haml +++ /dev/null @@ -1,79 +0,0 @@ -%tr.commit_status - %td.status - - if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url - = link_to commit_status.target_url, class: "ci-status ci-#{commit_status.status}" do - = ci_icon_for_status(commit_status.status) - = commit_status.status - - else - = ci_status_with_icon(commit_status.status) - - %td.commit_status-link - - if can?(current_user, :read_commit_status, commit_status) && commit_status.target_url - = link_to commit_status.target_url do - %strong ##{commit_status.id} - - else - %strong ##{commit_status.id} - - - if commit_status.show_warning? - %i.fa.fa-warning.text-warning{data: { toggle: "tooltip" }, title: "This build is stuck, open it to know more"} - - - if defined?(commit_sha) && commit_sha - %td - = link_to commit_status.short_sha, namespace_project_commit_path(commit_status.project.namespace, commit_status.project, commit_status.sha), class: "monospace" - - %td - - if commit_status.ref - = link_to commit_status.ref, namespace_project_commits_path(commit_status.project.namespace, commit_status.project, commit_status.ref) - - else - .light none - - - if defined?(runner) && runner - %td - - if commit_status.try(:runner) - = runner_link(commit_status.runner) - - else - .light none - - - if defined?(stage) && stage - %td - = commit_status.stage - - %td - = commit_status.name - - .pull-right - - if commit_status.tags.any? - - commit_status.tags.each do |tag| - %span.label.label-primary - = tag - - if commit_status.try(:trigger_request) - %span.label.label-info triggered - - if commit_status.try(:allow_failure) - %span.label.label-danger allowed to fail - - %td.duration - - if commit_status.duration - #{duration_in_words(commit_status.finished_at, commit_status.started_at)} - - %td.timestamp - - if commit_status.finished_at - %span #{time_ago_with_tooltip(commit_status.finished_at)} - - - if defined?(coverage) && coverage - %td.coverage - - if commit_status.try(:coverage) - #{commit_status.coverage}% - - %td - .pull-right - - if can?(current_user, :read_commit_status, commit_status) && commit_status.artifacts_download_url - = link_to commit_status.artifacts_download_url, title: 'Download artifacts' do - %i.fa.fa-download - - if can?(current_user, :update_commit_status, commit_status) - - if commit_status.active? - - if commit_status.cancel_url - = link_to commit_status.cancel_url, method: :post, title: 'Cancel' do - %i.fa.fa-remove.cred - - elsif defined?(allow_retry) && allow_retry && commit_status.retry_url - = link_to commit_status.retry_url, method: :post, title: 'Retry' do - %i.fa.fa-repeat diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml new file mode 100644 index 0000000000..ac29f323b4 --- /dev/null +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -0,0 +1,60 @@ +%tr.generic_commit_status + %td.status + - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url + = link_to generic_commit_status.target_url, class: "ci-status ci-#{generic_commit_status.status}" do + = ci_icon_for_status(generic_commit_status.status) + = generic_commit_status.status + - else + = ci_status_with_icon(generic_commit_status.status) + + %td.generic_commit_status-link + - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url + = link_to generic_commit_status.target_url do + %strong ##{generic_commit_status.id} + - else + %strong ##{generic_commit_status.id} + + - if defined?(commit_sha) && commit_sha + %td + = link_to generic_commit_status.short_sha, namespace_project_commit_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.sha), class: "monospace" + + %td + - if generic_commit_status.ref + = link_to generic_commit_status.ref, namespace_project_commits_path(generic_commit_status.project.namespace, generic_commit_status.project, generic_commit_status.ref) + - else + .light none + + - if defined?(runner) && runner + %td + - if generic_commit_status.try(:runner) + = runner_link(generic_commit_status.runner) + - else + .light none + + - if defined?(stage) && stage + %td + = generic_commit_status.stage + + %td + = generic_commit_status.name + + .pull-right + - if generic_commit_status.tags.any? + - generic_commit_status.tags.each do |tag| + %span.label.label-primary + = tag + + %td.duration + - if generic_commit_status.duration + #{duration_in_words(generic_commit_status.finished_at, generic_commit_status.started_at)} + + %td.timestamp + - if generic_commit_status.finished_at + %span #{time_ago_with_tooltip(generic_commit_status.finished_at)} + + - if defined?(coverage) && coverage + %td.coverage + - if generic_commit_status.try(:coverage) + #{generic_commit_status.coverage}% + + %td diff --git a/doc/api/builds.md b/doc/api/builds.md index d3ce72e59f..4c0a47d1ea 100644 --- a/doc/api/builds.md +++ b/doc/api/builds.md @@ -33,7 +33,6 @@ Example of response }, "coverage": null, "created_at": "2015-12-24T15:51:21.802Z", - "download_url": null, "artifacts_file": { "filename": "artifacts.zip", "size": 1000 @@ -75,7 +74,6 @@ Example of response }, "coverage": null, "created_at": "2015-12-24T15:51:21.727Z", - "download_url": null, "artifacts_file": null, "finished_at": "2015-12-24T17:54:24.921Z", "id": 6, @@ -139,7 +137,6 @@ Example of response }, "coverage": null, "created_at": "2016-01-11T10:13:33.506Z", - "download_url": null, "artifacts_file": null, "finished_at": "2016-01-11T10:14:09.526Z", "id": 69, @@ -164,7 +161,6 @@ Example of response }, "coverage": null, "created_at": "2015-12-24T15:51:21.957Z", - "download_url": null, "artifacts_file": null, "finished_at": "2015-12-24T17:54:33.913Z", "id": 9, @@ -226,7 +222,6 @@ Example of response }, "coverage": null, "created_at": "2015-12-24T15:51:21.880Z", - "download_url": null, "artifacts_file": null, "finished_at": "2015-12-24T17:54:31.198Z", "id": 8, @@ -315,7 +310,6 @@ Example of response }, "coverage": null, "created_at": "2016-01-11T10:13:33.506Z", - "download_url": null, "artifacts_file": null, "finished_at": "2016-01-11T10:14:09.526Z", "id": 69, @@ -362,7 +356,6 @@ Example of response }, "coverage": null, "created_at": "2016-01-11T10:13:33.506Z", - "download_url": null, "artifacts_file": null, "finished_at": null, "id": 69, diff --git a/features/steps/shared/builds.rb b/features/steps/shared/builds.rb index f33ed7834f..c4c7672a43 100644 --- a/features/steps/shared/builds.rb +++ b/features/steps/shared/builds.rb @@ -68,7 +68,7 @@ module SharedBuilds end step 'I see the build' do - page.within('.commit_status') do + page.within('.build') do expect(page).to have_content "##{@build.id}" expect(page).to have_content @build.sha[0..7] expect(page).to have_content @build.ref diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 5b5b8bd044..de58ef176c 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -401,13 +401,6 @@ module API expose :id, :status, :stage, :name, :ref, :tag, :coverage expose :created_at, :started_at, :finished_at expose :user, with: User - # TODO: download_url in Ci:Build model is an GitLab Web Interface URL, not API URL. We should think on some API - # for downloading of artifacts (see: https://gitlab.com/gitlab-org/gitlab-ce/issues/4255) - expose :download_url do |repo_obj, options| - if options[:user_can_download_artifacts] - repo_obj.artifacts_download_url - end - end expose :artifacts_file, using: BuildArtifactFile, if: -> (build, opts) { build.artifacts? } expose :commit, with: RepoCommit do |repo_obj, _options| if repo_obj.respond_to?(:commit) diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index e3d3d45365..f4f817297b 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -312,8 +312,8 @@ describe Ci::Build, models: true do end end - describe :show_warning? do - subject { build.show_warning? } + describe :stuck? do + subject { build.stuck? } %w(pending).each do |state| context "if commit_status.status is #{state}" do @@ -343,34 +343,6 @@ describe Ci::Build, models: true do end end - describe :artifacts_download_url do - subject { build.artifacts_download_url } - - context 'artifacts file does not exist' do - before { build.update_attributes(artifacts_file: nil) } - it { is_expected.to be_nil } - end - - context 'artifacts file exists' do - let(:build) { create(:ci_build, :artifacts) } - it { is_expected.to_not be_nil } - end - end - - describe :artifacts_browse_url do - subject { build.artifacts_browse_url } - - it "should be nil if artifacts browser is unsupported" do - allow(build).to receive(:artifacts_metadata?).and_return(false) - is_expected.to be_nil - end - - it 'should not be nil if artifacts browser is supported' do - allow(build).to receive(:artifacts_metadata?).and_return(true) - is_expected.to_not be_nil - end - end - describe :artifacts? do subject { build.artifacts? } diff --git a/spec/models/ci/commit_spec.rb b/spec/models/ci/commit_spec.rb index 4dc309a425..c51444f487 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -23,7 +23,7 @@ describe Ci::Commit, models: true do 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(:commit_statuses) } it { is_expected.to have_many(:trigger_requests) } it { is_expected.to have_many(:builds) } it { is_expected.to validate_presence_of :sha } @@ -32,50 +32,6 @@ describe Ci::Commit, models: true do it { is_expected.to respond_to :git_author_email } it { is_expected.to respond_to :short_sha } - describe :ordered do - let(:project) { FactoryGirl.create :empty_project } - - it 'returns ordered list of commits' do - commit1 = FactoryGirl.create :ci_commit, committed_at: 1.hour.ago, project: project - commit2 = FactoryGirl.create :ci_commit, committed_at: 2.hours.ago, project: project - expect(project.ci_commits.ordered).to eq([commit2, commit1]) - end - - it 'returns commits ordered by committed_at and id, with nulls last' do - commit1 = FactoryGirl.create :ci_commit, committed_at: 1.hour.ago, project: project - commit2 = FactoryGirl.create :ci_commit, committed_at: nil, project: project - commit3 = FactoryGirl.create :ci_commit, committed_at: 2.hours.ago, project: project - commit4 = FactoryGirl.create :ci_commit, committed_at: nil, project: project - expect(project.ci_commits.ordered).to eq([commit2, commit4, commit3, commit1]) - end - end - - describe :last_build do - subject { commit.last_build } - before do - @first = FactoryGirl.create :ci_build, commit: commit, created_at: Date.yesterday - @second = FactoryGirl.create :ci_build, commit: commit - end - - it { is_expected.to be_a(Ci::Build) } - it('returns with the most recently created build') { is_expected.to eq(@second) } - end - - describe :retry do - before do - @first = FactoryGirl.create :ci_build, commit: commit, created_at: Date.yesterday - @second = FactoryGirl.create :ci_build, commit: commit - end - - it "creates only a new build" do - expect(commit.builds.count(:all)).to eq 2 - expect(commit.statuses.count(:all)).to eq 2 - commit.retry - expect(commit.builds.count(:all)).to eq 3 - expect(commit.statuses.count(:all)).to eq 3 - end - end - describe :valid_commit_sha do context 'commit.sha can not start with 00000000' do before do From f32e28f6faab176845a780d5b4d26881c08bcfec Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Thu, 10 Mar 2016 11:06:33 +0100 Subject: [PATCH 075/797] Fix commit_spec: invalid validation --- 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 c51444f487..412842337b 100644 --- a/spec/models/ci/commit_spec.rb +++ b/spec/models/ci/commit_spec.rb @@ -23,7 +23,7 @@ describe Ci::Commit, models: true do let(:commit) { FactoryGirl.create :ci_commit, project: project } it { is_expected.to belong_to(:project) } - it { is_expected.to have_many(:commit_statuses) } + 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 } From 16592e2b45d42e22f9d1d595a1f44821c7b30441 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 14 Mar 2016 13:33:26 +0100 Subject: [PATCH 076/797] Fix review comments - Remove unused Gitlab::Application.routes.url_helpers from Ci::Build - Remove too much logic from a view, use Ci::Commit.matrix_builds - Use ci_status_with_icon - Don't describe symbols --- app/models/ci/build.rb | 2 -- app/models/ci/commit.rb | 6 ++++ app/views/projects/builds/show.html.haml | 2 +- app/views/projects/ci/builds/_build.html.haml | 3 +- .../_generic_commit_status.html.haml | 3 +- spec/models/build_spec.rb | 36 +++++++++---------- 6 files changed, 27 insertions(+), 25 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 6c1ca8db24..7d33838044 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -37,8 +37,6 @@ module Ci class Build < CommitStatus - include Gitlab::Application.routes.url_helpers - LAZY_ATTRIBUTES = ['trace'] belongs_to :runner, class_name: 'Ci::Runner' diff --git a/app/models/ci/commit.rb b/app/models/ci/commit.rb index 12c60158d4..f4cf7034b1 100644 --- a/app/models/ci/commit.rb +++ b/app/models/ci/commit.rb @@ -113,6 +113,12 @@ module Ci latest_statuses.select { |status| status.ref == ref } end + def matrix_builds(build = nil) + matrix_builds = builds.latest.ordered + matrix_builds = matrix_builds.similar(build) if build + matrix_builds.to_a + end + def retried @retried ||= (statuses.order(id: :desc) - statuses.latest) end diff --git a/app/views/projects/builds/show.html.haml b/app/views/projects/builds/show.html.haml index dbbf382fa2..b02aee3db2 100644 --- a/app/views/projects/builds/show.html.haml +++ b/app/views/projects/builds/show.html.haml @@ -13,7 +13,7 @@ = link_to "merge request ##{merge_request.iid}", merge_request_path(merge_request) #up-build-trace - - builds = @build.commit.builds.similar(@build).latest.ordered.to_a + - builds = @build.commit.matrix_builds(@build) - if builds.size > 1 %ul.nav-links.no-top.no-bottom - builds.each do |build| diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 195fd4a9d5..7123efffd5 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -2,8 +2,7 @@ %td.status - if can?(current_user, :read_build, build) = link_to namespace_project_build_url(build.project.namespace, build.project, build), class: "ci-status ci-#{build.status}" do - = ci_icon_for_status(build.status) - = build.status + = ci_status_with_icon(build.status) - else = ci_status_with_icon(build.status) diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index ac29f323b4..4143ea1306 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -2,8 +2,7 @@ %td.status - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url = link_to generic_commit_status.target_url, class: "ci-status ci-#{generic_commit_status.status}" do - = ci_icon_for_status(generic_commit_status.status) - = generic_commit_status.status + = ci_status_with_icon(generic_commit_status.status) - else = ci_status_with_icon(generic_commit_status.status) diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index f4f817297b..b745780804 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -9,7 +9,7 @@ describe Ci::Build, models: true do it { is_expected.to respond_to :trace_html } - describe :first_pending do + describe '#first_pending' do let(:first) { FactoryGirl.create :ci_build, commit: commit, status: 'pending', created_at: Date.yesterday } let(:second) { FactoryGirl.create :ci_build, commit: commit, status: 'pending' } before { first; second } @@ -19,7 +19,7 @@ describe Ci::Build, models: true do it('returns with the first pending build') { is_expected.to eq(first) } end - describe :create_from do + describe '#create_from' do before do build.status = 'success' build.save @@ -33,7 +33,7 @@ describe Ci::Build, models: true do end end - describe :ignored? do + describe '#ignored?' do subject { build.ignored? } context 'if build is not allowed to fail' do @@ -69,7 +69,7 @@ describe Ci::Build, models: true do end end - describe :trace do + describe '#trace' do subject { build.trace_html } it { is_expected.to be_empty } @@ -101,7 +101,7 @@ describe Ci::Build, models: true do # it { is_expected.to eq(commit.project.timeout) } # end - describe :options do + describe '#options' do let(:options) do { image: "ruby:2.1", @@ -122,25 +122,25 @@ describe Ci::Build, models: true do # it { is_expected.to eq(project.allow_git_fetch) } # end - describe :project do + describe '#project' do subject { build.project } it { is_expected.to eq(commit.project) } end - describe :project_id do + describe '#project_id' do subject { build.project_id } it { is_expected.to eq(commit.project_id) } end - describe :project_name do + describe '#project_name' do subject { build.project_name } it { is_expected.to eq(project.name) } end - describe :extract_coverage do + describe '#extract_coverage' do context 'valid content & regex' do subject { build.extract_coverage('Coverage 1033 / 1051 LOC (98.29%) covered', '\(\d+.\d+\%\) covered') } @@ -172,7 +172,7 @@ describe Ci::Build, models: true do end end - describe :variables do + describe '#variables' do context 'returns variables' do subject { build.variables } @@ -242,7 +242,7 @@ describe Ci::Build, models: true do end end - describe :can_be_served? do + describe '#can_be_served?' do let(:runner) { FactoryGirl.create :ci_runner } before { build.project.runners << runner } @@ -277,7 +277,7 @@ describe Ci::Build, models: true do end end - describe :any_runners_online? do + describe '#any_runners_online?' do subject { build.any_runners_online? } context 'when no runners' do @@ -312,7 +312,7 @@ describe Ci::Build, models: true do end end - describe :stuck? do + describe '#stuck?' do subject { build.stuck? } %w(pending).each do |state| @@ -343,7 +343,7 @@ describe Ci::Build, models: true do end end - describe :artifacts? do + describe '#artifacts?' do subject { build.artifacts? } context 'artifacts archive does not exist' do @@ -358,7 +358,7 @@ describe Ci::Build, models: true do end - describe :artifacts_metadata? do + describe '#artifacts_metadata?' do subject { build.artifacts_metadata? } context 'artifacts metadata does not exist' do it { is_expected.to be_falsy } @@ -370,7 +370,7 @@ describe Ci::Build, models: true do end end - describe :repo_url do + describe '#repo_url' do let(:build) { FactoryGirl.create :ci_build } let(:project) { build.project } @@ -384,7 +384,7 @@ describe Ci::Build, models: true do it { is_expected.to include(project.web_url[7..-1]) } end - describe :depends_on_builds do + describe '#depends_on_builds' do let!(:build) { FactoryGirl.create :ci_build, commit: commit, name: 'build', stage_idx: 0, stage: 'build' } let!(:rspec_test) { FactoryGirl.create :ci_build, commit: commit, name: 'rspec', stage_idx: 1, stage: 'test' } let!(:rubocop_test) { FactoryGirl.create :ci_build, commit: commit, name: 'rubocop', stage_idx: 1, stage: 'test' } @@ -416,7 +416,7 @@ describe Ci::Build, models: true do created_at: created_at) end - describe :merge_request do + describe '#merge_request' do context 'when a MR has a reference to the commit' do before do @merge_request = create_mr(build, commit, factory: :merge_request) From a965b03369594736d586f5a04a14de4451b9edd1 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 14 Mar 2016 13:02:40 +0000 Subject: [PATCH 077/797] Removed call to setup from breakpoint size method --- app/assets/javascripts/breakpoints.coffee | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee index 73a42d9998..1ffaaddb05 100644 --- a/app/assets/javascripts/breakpoints.coffee +++ b/app/assets/javascripts/breakpoints.coffee @@ -14,12 +14,11 @@ class @Breakpoints return if $(allDeviceSelector.join(",")).length # Create all the elements - $.each BREAKPOINTS, (i, breakpoint) -> - $("body").append "
" + els = $.map BREAKPOINTS, (breakpoint) -> + "
" + $("body").append els.join('') getBreakpointSize: -> - @setup() - allDeviceSelector = BREAKPOINTS.map (breakpoint) -> ".device-#{breakpoint}" @@ -30,4 +29,5 @@ class @Breakpoints @get: -> return instance ?= new BreakpointInstance -@bp = Breakpoints.get() +$ => + @bp = Breakpoints.get() From 543b85a0f555fafa2c5d257872bec25a4c21bd01 Mon Sep 17 00:00:00 2001 From: Jasper Denkers Date: Mon, 14 Mar 2016 13:21:31 +0000 Subject: [PATCH 078/797] Fix typo in Ruby CI test and deploy example --- doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md index c1bb47e429..f5645d586a 100644 --- a/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md +++ b/doc/ci/examples/test-and-deploy-ruby-application-to-heroku.md @@ -1,5 +1,5 @@ ## Test and Deploy a ruby application -This example will guide you how to run tests in your Ruby application and deploy it automatiacally as Heroku application. +This example will guide you how to run tests in your Ruby application and deploy it automatically as Heroku application. You can checkout the example [source](https://gitlab.com/ayufan/ruby-getting-started) and check [CI status](https://gitlab.com/ayufan/ruby-getting-started/builds?scope=all). From 35aca2d5fdd8d59e2e8ed4e6ce28838ea5bc7f86 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 14 Mar 2016 13:49:53 +0000 Subject: [PATCH 079/797] Fixed tests for MR & issue filters --- app/assets/javascripts/gl_dropdown.js.coffee | 4 +++- app/views/shared/issuable/_filter.html.haml | 2 +- features/steps/dashboard/issues.rb | 8 +++----- features/steps/dashboard/merge_requests.rb | 20 +++++++------------- 4 files changed, 14 insertions(+), 20 deletions(-) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 8e1449bc59..b94de4c7b5 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -238,13 +238,15 @@ class GitLabDropdown selectedObject = @renderedData[selectedIndex] value = if @options.id then @options.id(selectedObject, el) else selectedObject.id + if !value? + field.remove() + if @options.multiSelect oldValue = field.val() if oldValue value = "#{oldValue},#{value}" else @dropdown.find(ACTIVE_CLASS).removeClass ACTIVE_CLASS - field.remove() # Toggle active class for the tick mark el.toggleClass "is-active" diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index e62d80aeb2..4df96a06db 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -15,7 +15,7 @@ .filter-item.inline - if params[:assignee_id] = hidden_field_tag(:assignee_id, params[:assignee_id]) - = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", + = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee", placeholder: "Search assignee", data: { any_user: "Any Author", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id" } }) .filter-item.inline.milestone-filter diff --git a/features/steps/dashboard/issues.rb b/features/steps/dashboard/issues.rb index 3072c790a4..f4a5686553 100644 --- a/features/steps/dashboard/issues.rb +++ b/features/steps/dashboard/issues.rb @@ -36,11 +36,9 @@ class Spinach::Features::DashboardIssues < Spinach::FeatureSteps end step 'I click "Authored by me" link' do - execute_script('$("#assignee_id").val("")') - execute_script('$(".js-user-search").first().click()') - sleep 1 - execute_script("$('.dropdown-content li:contains(\"#{current_user.to_reference}\") a').click()") - sleep 1 + find("#assignee_id").set("") + find(".js-author-search", match: :first).click + find(".dropdown-menu-author li a", match: :first, text: current_user.to_reference).click end step 'I click "All" link' do diff --git a/features/steps/dashboard/merge_requests.rb b/features/steps/dashboard/merge_requests.rb index 7fc0e444e8..a2adc87f8e 100644 --- a/features/steps/dashboard/merge_requests.rb +++ b/features/steps/dashboard/merge_requests.rb @@ -40,22 +40,16 @@ class Spinach::Features::DashboardMergeRequests < Spinach::FeatureSteps end step 'I click "Authored by me" link' do - execute_script('$("#assignee_id").val("")') - execute_script('$(".js-user-search").first().click()') - sleep 0.5 - execute_script("$('.dropdown-content li:contains(\"#{current_user.to_reference}\") a').click()") - sleep 2 + find("#assignee_id").set("") + find(".js-author-search", match: :first).click + find(".dropdown-menu-author li a", match: :first, text: current_user.to_reference).click end step 'I click "All" link' do - execute_script('$(".js-user-search").first().click()') - sleep 0.5 - execute_script('$(".js-user-search").first().parent().find("li a").first().click()') - sleep 2 - execute_script('$(".js-user-search").eq(1).click()') - sleep 0.5 - execute_script('$(".js-user-search").eq(1).parent().find("li a").first().click()') - sleep 2 + find(".js-author-search").click + find(".dropdown-menu-author li a", match: :first).click + find(".js-assignee-search").click + find(".dropdown-menu-assignee li a", match: :first).click end def should_see(merge_request) From a02fe251df7ea7316f51850fe603e7e5ac4431e2 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 14 Mar 2016 15:18:52 +0100 Subject: [PATCH 080/797] Allow project housekeeping only once an hour --- app/controllers/projects_controller.rb | 4 +- app/services/projects/housekeeping_service.rb | 14 +++++++ .../projects/housekeeping_service_spec.rb | 40 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 spec/services/projects/housekeeping_service_spec.rb diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index c70add86a2..2a3dc5c79f 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -170,10 +170,10 @@ class ProjectsController < ApplicationController end def housekeeping - ::Projects::HousekeepingService.new(@project).execute + message = ::Projects::HousekeepingService.new(@project).execute respond_to do |format| - flash[:notice] = "Housekeeping successfully started." + flash[:notice] = message format.html { redirect_to project_path(@project) } end end diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 0db85ac214..11be5b1bad 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -9,12 +9,26 @@ module Projects class HousekeepingService < BaseService include Gitlab::ShellAdapter + LEASE_TIMEOUT = 3600 + def initialize(project) @project = project end def execute + if !try_obtain_lease + return "Housekeeping was already triggered in the past #{LEASE_TIMEOUT / 60} minutes" + end + GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) + return "Housekeeping successfully started" + end + + private + + def try_obtain_lease + lease = ::Gitlab::ExclusiveLease.new("project_housekeeping:#{@project.id}", timeout: LEASE_TIMEOUT) + lease.try_obtain end end end diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb new file mode 100644 index 0000000000..7cddeb5c35 --- /dev/null +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +describe Projects::HousekeepingService do + subject { Projects::HousekeepingService.new(project) } + let(:project) { create :project } + + describe :execute do + before do + project.pushes_since_gc = 3 + project.save! + end + + it 'enqueues a sidekiq job' do + expect(subject).to receive(:try_obtain_lease).and_return(true) + expect(GitlabShellWorker).to receive(:perform_async).with(:gc, project.path_with_namespace) + + expect(subject.execute).to include('successfully started') + expect(project.pushes_since_gc).to eq(0) + end + + it 'does not enqueue a job when no lease can be obtained' do + expect(subject).to receive(:try_obtain_lease).and_return(false) + expect(GitlabShellWorker).not_to receive(:perform_async) + + expect(subject.execute).to include('already triggered') + expect(project.pushes_since_gc).to eq(3) + end + end + + describe :needed? do + it 'when the count is low enough' do + expect(subject.needed?).to eq(false) + end + + it 'when the count is high enough' do + allow(project).to receive(:pushes_since_gc).and_return(10) + expect(subject.needed?).to eq(true) + end + end +end \ No newline at end of file From 021d53c96d308df7c721984435442993357a3414 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 14 Mar 2016 16:49:24 +0100 Subject: [PATCH 081/797] Run 'git gc' every 10 pushes --- app/services/git_push_service.rb | 8 ++++++ app/services/projects/housekeeping_service.rb | 16 +++++++++-- ...0314143402_projects_add_pushes_since_gc.rb | 5 ++++ db/schema.rb | 3 ++- spec/services/git_push_service_spec.rb | 27 +++++++++++++++++++ .../projects/housekeeping_service_spec.rb | 10 ++++++- 6 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20160314143402_projects_add_pushes_since_gc.rb diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index bd31a61774..e50cbdfb60 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -49,6 +49,8 @@ class GitPushService < BaseService # Update merge requests that may be affected by this push. A new branch # could cause the last commit of a merge request to change. update_merge_requests + + perform_housekeeping end def update_main_language @@ -73,6 +75,12 @@ class GitPushService < BaseService ProjectCacheWorker.perform_async(@project.id) end + def perform_housekeeping + housekeeping = Projects::HousekeepingService.new(@project) + housekeeping.increment! + housekeeping.execute if housekeeping.needed? + end + def process_default_branch @push_commits = project.repository.commits(params[:newrev]) diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 11be5b1bad..83bdedf7a8 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -19,9 +19,21 @@ module Projects if !try_obtain_lease return "Housekeeping was already triggered in the past #{LEASE_TIMEOUT / 60} minutes" end - + GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) - return "Housekeeping successfully started" + @project.pushes_since_gc = 0 + @project.save! + + "Housekeeping successfully started" + end + + def needed? + @project.pushes_since_gc >= 10 + end + + def increment! + @project.pushes_since_gc += 1 + @project.save! end private diff --git a/db/migrate/20160314143402_projects_add_pushes_since_gc.rb b/db/migrate/20160314143402_projects_add_pushes_since_gc.rb new file mode 100644 index 0000000000..5d30a38bc9 --- /dev/null +++ b/db/migrate/20160314143402_projects_add_pushes_since_gc.rb @@ -0,0 +1,5 @@ +class ProjectsAddPushesSinceGc < ActiveRecord::Migration + def change + add_column :projects, :pushes_since_gc, :integer, default: 0 + end +end diff --git a/db/schema.rb b/db/schema.rb index 3ac6203632..cf3f8245b3 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: 20160309140734) do +ActiveRecord::Schema.define(version: 20160314143402) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -711,6 +711,7 @@ ActiveRecord::Schema.define(version: 20160309140734) do t.boolean "pending_delete", default: false t.boolean "public_builds", default: true, null: false t.string "main_language" + t.integer "pushes_since_gc", default: 0 end add_index "projects", ["builds_enabled", "shared_runners_enabled"], name: "index_projects_on_builds_enabled_and_shared_runners_enabled", using: :btree diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index a7e2e1b179..ebf3ec1f5f 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -401,6 +401,33 @@ describe GitPushService, services: true do end end + describe "housekeeping" do + let(:housekeeping) { instance_double('Projects::HousekeepingService', increment!: nil, needed?: false) } + + before do + allow(Projects::HousekeepingService).to receive(:new).and_return(housekeeping) + end + + it 'does not perform housekeeping when not needed' do + expect(housekeeping).not_to receive(:execute) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + + it 'performs housekeeping when needed' do + expect(housekeeping).to receive(:needed?).and_return(true) + expect(housekeeping).to receive(:execute) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + + it 'increments the push counter' do + expect(housekeeping).to receive(:increment!) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + end + def execute_service(project, user, oldrev, newrev, ref) service = described_class.new(project, user, oldrev: oldrev, newrev: newrev, ref: ref ) service.execute diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb index 7cddeb5c35..32552d882a 100644 --- a/spec/services/projects/housekeeping_service_spec.rb +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -37,4 +37,12 @@ describe Projects::HousekeepingService do expect(subject.needed?).to eq(true) end end -end \ No newline at end of file + + describe :increment! do + it 'increments the pushes_since_gc counter' do + expect(project.pushes_since_gc).to eq(0) + subject.increment! + expect(project.pushes_since_gc).to eq(1) + end + end +end From d26bd026d92d9b06e207cc7ecf2d9e899151c155 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Mon, 14 Mar 2016 11:53:42 -0400 Subject: [PATCH 082/797] Update GITLAB_SHELL_VERSION to match EE [ci skip] --- GITLAB_SHELL_VERSION | 2 +- doc/update/8.5-to-8.6.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index a04abec914..bc02b8685c 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -2.6.10 +2.6.11 diff --git a/doc/update/8.5-to-8.6.md b/doc/update/8.5-to-8.6.md index 9a1b94ab36..024f6e8a43 100644 --- a/doc/update/8.5-to-8.6.md +++ b/doc/update/8.5-to-8.6.md @@ -37,7 +37,7 @@ sudo -u git -H git checkout 8-6-stable-ee ```bash cd /home/git/gitlab-shell sudo -u git -H git fetch --all -sudo -u git -H git checkout v2.6.10 +sudo -u git -H git checkout v2.6.11 ``` ### 5. Update gitlab-workhorse From 09d3cdfd9eed89023a6dc0244df7165a1b3dc52a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 14 Mar 2016 16:03:17 +0000 Subject: [PATCH 083/797] Changed project home icon Closes #14196 --- app/views/layouts/nav/_project.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 319974e12c..0ae83ee01e 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -16,7 +16,7 @@ = nav_link(path: 'projects#show', html_options: {class: 'home'}) do = link_to project_path(@project), title: 'Project', class: 'shortcuts-project' do - = icon('home fw') + = icon('bookmark fw') %span Project = nav_link(path: 'projects#activity') do From 3cc040e11083b80e3d1c872240e2420bf74ae170 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 13 Mar 2016 22:52:19 -0700 Subject: [PATCH 084/797] Bump Capybara gem to 2.6.2 --- CHANGELOG | 1 + Gemfile | 2 +- Gemfile.lock | 5 +++-- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b120810ebd..8abab4f141 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.6.0 (unreleased) - Support Golang subpackage fetching (Stan Hu) + - Bump Capybara gem to 2.6.2 (Stan Hu) - Contributions to forked projects are included in calendar - Improve the formatting for the user page bio (Connor Shea) - Removed the default password from the initial admin account created during diff --git a/Gemfile b/Gemfile index 1550afb1b5..26b61c2fa5 100644 --- a/Gemfile +++ b/Gemfile @@ -273,7 +273,7 @@ group :development, :test do # Generate Fake data gem 'ffaker', '~> 2.0.0' - gem 'capybara', '~> 2.4.0' + gem 'capybara', '~> 2.6.2' gem 'capybara-screenshot', '~> 1.0.0' gem 'poltergeist', '~> 1.9.0' diff --git a/Gemfile.lock b/Gemfile.lock index d4e28db00d..f719dd60e3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,7 +108,8 @@ GEM thor (~> 0.18) byebug (8.2.1) cal-heatmap-rails (3.5.1) - capybara (2.4.4) + capybara (2.6.2) + addressable mime-types (>= 1.16) nokogiri (>= 1.3.3) rack (>= 1.0.0) @@ -901,7 +902,7 @@ DEPENDENCIES bundler-audit byebug cal-heatmap-rails (~> 3.5.0) - capybara (~> 2.4.0) + capybara (~> 2.6.2) capybara-screenshot (~> 1.0.0) carrierwave (~> 0.10.0) charlock_holmes (~> 0.7.3) From a6f5304280d95c496b7669d1553cc6c246f11f94 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Mon, 14 Mar 2016 19:28:23 +0200 Subject: [PATCH 085/797] Merge duplicate cache entry in CI docs [ci skip] --- doc/ci/yaml/README.md | 108 +++++++++++++++++++----------------------- 1 file changed, 48 insertions(+), 60 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 051eaa0415..4dd5fe1f76 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -134,6 +134,9 @@ thus allowing to fine tune them. ### cache +>**Note:** +Introduced in GitLab Runner v0.7.0. + `cache` is used to specify a list of files and directories which should be cached between builds. @@ -142,18 +145,59 @@ cached between builds. If `cache` is defined outside the scope of the jobs, it means it is set globally and all jobs will use its definition. -To cache all git untracked files and files in `binaries`: +Cache all files in `binaries` and `.config`: + +```yaml +rspec: + script: test + cache: + paths: + - binaries/ + - .config +``` + +Cache all Git untracked files: + +```yaml +rspec: + script: test + cache: + untracked: true +``` + +Cache all Git untracked files and files in `binaries`: + +```yaml +rspec: + script: test + cache: + untracked: true + paths: + - binaries/ +``` + +Locally defined cache overwrites globally defined options. This will cache only +`binaries/`: ```yaml cache: - untracked: true paths: - - binaries/ + - my/files + +rspec: + script: test + cache: + paths: + - binaries/ ``` +The cache is provided on best effort basis, so don't expect that cache will be +always present. For implementation details please check GitLab Runner. + #### cache:key -_**Note:** Introduced in GitLab Runner v1.0.0._ +>**Note:** +Introduced in GitLab Runner v1.0.0. The `key` directive allows you to define the affinity of caching between jobs, allowing to have a single cache for all jobs, @@ -453,62 +497,6 @@ release-job: The artifacts will be sent to GitLab after a successful build and will be available for download in the GitLab UI. -### cache - -_**Note:** Introduced in GitLab Runner v0.7.0._ - -`cache` is used to specify list of files and directories which should be cached -between builds. Below are some examples: - -Cache all files in `binaries` and `.config`: - -```yaml -rspec: - script: test - cache: - paths: - - binaries/ - - .config -``` - -Cache all git untracked files: - -```yaml -rspec: - script: test - cache: - untracked: true -``` - -Cache all git untracked files and files in `binaries`: - -```yaml -rspec: - script: test - cache: - untracked: true - paths: - - binaries/ -``` - -Locally defined cache overwrites globally defined options. This will cache only -`binaries/`: - -```yaml -cache: - paths: - - my/files - -rspec: - script: test - cache: - paths: - - binaries/ -``` - -The cache is provided on best effort basis, so don't expect that cache will be -always present. For implementation details please check GitLab Runner. - ## Validate the .gitlab-ci.yml Each instance of GitLab CI has an embedded debug tool called Lint. From 6c791f7da7d87a496a0e0d1c8179badcb4490174 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Mon, 14 Mar 2016 18:31:14 +0100 Subject: [PATCH 086/797] Redesign project tabs on group page Signed-off-by: Dmitriy Zaporozhets --- app/assets/stylesheets/framework/blocks.scss | 4 +++ app/views/groups/_projects.html.haml | 13 +------- app/views/groups/_shared_projects.html.haml | 19 +---------- app/views/groups/show.html.haml | 33 +++++++++++++------- 4 files changed, 27 insertions(+), 42 deletions(-) diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 6edabe2013..d20b77ffae 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -120,6 +120,10 @@ .cover-desc { padding: 0 $gl-padding 3px; color: $gl-text-color; + + &.username:last-child { + padding-bottom: $gl-padding; + } } .cover-controls { diff --git a/app/views/groups/_projects.html.haml b/app/views/groups/_projects.html.haml index 7cd8e9bea4..cca7dc27b1 100644 --- a/app/views/groups/_projects.html.haml +++ b/app/views/groups/_projects.html.haml @@ -1,12 +1 @@ -.top-area - .nav-controls - = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| - - if @projects.present? - = search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false - = render 'shared/projects/dropdown' - - if can? current_user, :create_projects, @group - = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do - = icon('plus') - New Project - -= render 'shared/projects/list', projects: @projects, stars: false, skip_namespace: true += render 'shared/projects/list', projects: projects, stars: false, skip_namespace: true diff --git a/app/views/groups/_shared_projects.html.haml b/app/views/groups/_shared_projects.html.haml index d707ad4272..b1694c919d 100644 --- a/app/views/groups/_shared_projects.html.haml +++ b/app/views/groups/_shared_projects.html.haml @@ -1,18 +1 @@ -- if projects.present? - .panel.panel-default - .panel-heading - Projects shared with - %strong #{@group.name} - (#{projects.count}) - %ul.well-list - - projects.each do |project| - %li.project-row - = link_to namespace_project_path(project.namespace, project), class: dom_class(project) do - %span.namespace-name - - if project.namespace - = project.namespace.human_name - \/ - %span.project-name - = truncate(project.name, length: 25) - %span.arrow - %i.icon-angle-right += render 'shared/projects/list', projects: projects, stars: false, skip_namespace: false diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index de314a4190..23a34ac36d 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -27,24 +27,33 @@ .cover-desc.description = markdown(@group.description, pipeline: :description) - - %ul.nav-links - %li.active - = link_to "#projects", 'data-toggle' => 'tab' do - Projects - - if @shared_projects.present? - %li - = link_to "#shared", 'data-toggle' => 'tab' do - Shared Projects - - if can?(current_user, :read_group, @group) %div{ class: container_class } + .top-area + %ul.nav-links + %li.active + = link_to "#projects", 'data-toggle' => 'tab' do + All Projects + - if @shared_projects.present? + %li + = link_to "#shared", 'data-toggle' => 'tab' do + Shared Projects + .nav-controls + = form_tag request.original_url, method: :get, class: 'project-filter-form', id: 'project-filter-form' do |f| + = search_field_tag :filter_projects, nil, placeholder: 'Filter by name', class: 'projects-list-filter form-control', spellcheck: false + = render 'shared/projects/dropdown' + - if can? current_user, :create_projects, @group + = link_to new_project_path(namespace_id: @group.id), class: 'btn btn-new pull-right' do + = icon('plus') + New Project + .tab-content .tab-pane.active#projects = render "projects", projects: @projects - .tab-pane#shared - = render "shared_projects", projects: @shared_projects + - if @shared_projects.present? + .tab-pane#shared + = render "shared_projects", projects: @shared_projects - else %p.nav-links.no-top From 0beae70efaafc361cf15c13231bdc5ed6de8569f Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Mon, 14 Mar 2016 18:46:38 +0100 Subject: [PATCH 087/797] Use strings instead of symbols --- spec/services/projects/housekeeping_service_spec.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb index 32552d882a..4c3577149f 100644 --- a/spec/services/projects/housekeeping_service_spec.rb +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -4,7 +4,7 @@ describe Projects::HousekeepingService do subject { Projects::HousekeepingService.new(project) } let(:project) { create :project } - describe :execute do + describe 'execute' do before do project.pushes_since_gc = 3 project.save! @@ -27,7 +27,7 @@ describe Projects::HousekeepingService do end end - describe :needed? do + describe 'needed?' do it 'when the count is low enough' do expect(subject.needed?).to eq(false) end @@ -38,7 +38,7 @@ describe Projects::HousekeepingService do end end - describe :increment! do + describe 'increment!' do it 'increments the pushes_since_gc counter' do expect(project.pushes_since_gc).to eq(0) subject.increment! From cef1f4f2d9bad1f246a3d34d454f7fc419988aa9 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Mon, 14 Mar 2016 20:04:05 +0000 Subject: [PATCH 088/797] Fixes issue with unassigned not working in dropdown Also allowed issues to be bulk unassigned --- app/assets/javascripts/gl_dropdown.js.coffee | 2 +- app/views/shared/issuable/_filter.html.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index b94de4c7b5..4f03847775 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -251,7 +251,7 @@ class GitLabDropdown # Toggle active class for the tick mark el.toggleClass "is-active" - if value + if value? if !field.length # Create hidden input for form input = "" diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 4df96a06db..42a3c2c3f0 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -94,7 +94,7 @@ %a{href: "#", data: {id: "close"}} Closed .filter-item.inline = dropdown_tag("Assignee", options: { toggle_class: "js-user-search", title: "Assign to", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable", - placeholder: "Search authors", data: { first_user: (current_user.username if current_user), current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } }) + placeholder: "Search authors", data: { first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: @project.id, field_name: "update[assignee_id]" } }) .filter-item.inline = dropdown_tag("Milestone", options: { title: "Assign milestone", toggle_class: 'js-milestone-select', filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search milestones", data: { show_no: true, field_name: "update[milestone_id]", project_id: @project.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :js), use_id: true } }) From ab418e27a9121704d623343417126b0bdae08e79 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Mon, 14 Mar 2016 22:06:23 +0100 Subject: [PATCH 089/797] Improve external users feature --- app/finders/projects_finder.rb | 4 ++-- app/views/admin/users/_form.html.haml | 2 +- app/views/admin/users/index.html.haml | 6 ++++-- .../dashboard/projects/_zero_authorized_projects.html.haml | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 70c073f7d5..3a5fc5b590 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -47,9 +47,9 @@ class ProjectsFinder group.shared_projects.visible_to_user(current_user) ] if current_user.external? - user_group_projects.push(group.projects.public_only) + user_group_projects << group.projects.public_only else - user_group_projects.push(group.projects.public_and_internal_only) + user_group_projects << group.projects.public_and_internal_only end end diff --git a/app/views/admin/users/_form.html.haml b/app/views/admin/users/_form.html.haml index b910040a16..d2527ede99 100644 --- a/app/views/admin/users/_form.html.haml +++ b/app/views/admin/users/_form.html.haml @@ -65,7 +65,7 @@ .form-group = f.label :external, class: 'control-label' .col-sm-10= f.check_box :external - .col-sm-10 External users can not see internal or private projects unless access is explicitly granted. Also, external user can not create projects or groups. + .col-sm-10 External users cannot see internal or private projects unless access is explicitly granted. Also, external users cannot create projects or groups. %fieldset %legend Profile diff --git a/app/views/admin/users/index.html.haml b/app/views/admin/users/index.html.haml index 4394e18100..0ee8dc962b 100644 --- a/app/views/admin/users/index.html.haml +++ b/app/views/admin/users/index.html.haml @@ -74,12 +74,14 @@ %li .list-item-name - if user.blocked? - %i.fa.fa-lock.cred + = icon("lock", class: "cred") - else - %i.fa.fa-user.cgreen + = 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 diff --git a/app/views/dashboard/projects/_zero_authorized_projects.html.haml b/app/views/dashboard/projects/_zero_authorized_projects.html.haml index 65edc05b79..d54c7cad7b 100644 --- a/app/views/dashboard/projects/_zero_authorized_projects.html.haml +++ b/app/views/dashboard/projects/_zero_authorized_projects.html.haml @@ -18,7 +18,7 @@ - if current_user.can_create_project? .link_holder = link_to new_project_path, class: "btn btn-new" do - =icon('plus') + = icon('plus') New Project - if current_user.can_create_group? From 41de7b345b0abdaba2f0d7614ebdb1cc7310a5fb Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Mon, 14 Mar 2016 16:07:51 -0400 Subject: [PATCH 090/797] Be more intelligent about sanitizing links with unsafe protocols This prevents false matches on relative links like `[database](database.md)`. Closes https://gitlab.com/gitlab-org/gitlab-ce/issues/14220 --- lib/banzai/filter/sanitization_filter.rb | 9 +++++++-- .../banzai/filter/sanitization_filter_spec.rb | 20 +++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/banzai/filter/sanitization_filter.rb b/lib/banzai/filter/sanitization_filter.rb index abd79b329a..e801151960 100644 --- a/lib/banzai/filter/sanitization_filter.rb +++ b/lib/banzai/filter/sanitization_filter.rb @@ -7,7 +7,7 @@ module Banzai # # Extends HTML::Pipeline::SanitizationFilter with a custom whitelist. class SanitizationFilter < HTML::Pipeline::SanitizationFilter - UNSAFE_PROTOCOLS = %w(javascript :javascript data vbscript).freeze + UNSAFE_PROTOCOLS = %w(data javascript vbscript).freeze def whitelist whitelist = super @@ -64,7 +64,12 @@ module Banzai return unless node.name == 'a' return unless node.has_attribute?('href') - if node['href'].start_with?(*UNSAFE_PROTOCOLS) + begin + uri = Addressable::URI.parse(node['href']) + uri.scheme.strip! if uri.scheme + + node.remove_attribute('href') if UNSAFE_PROTOCOLS.include?(uri.scheme) + rescue Addressable::URI::InvalidURIError node.remove_attribute('href') end end diff --git a/spec/lib/banzai/filter/sanitization_filter_spec.rb b/spec/lib/banzai/filter/sanitization_filter_spec.rb index 4a7b00c766..27ce312b11 100644 --- a/spec/lib/banzai/filter/sanitization_filter_spec.rb +++ b/spec/lib/banzai/filter/sanitization_filter_spec.rb @@ -149,10 +149,20 @@ describe Banzai::Filter::SanitizationFilter, lib: true do output: '
' }, + 'protocol-based JS injection: invalid URL char' => { + input: '', + output: '' + }, + 'protocol-based JS injection: spaces and entities' => { input: 'foo', output: 'foo' }, + + 'protocol whitespace' => { + input: '', + output: '' + } } protocols.each do |name, data| @@ -177,6 +187,16 @@ describe Banzai::Filter::SanitizationFilter, lib: true do expect(output.to_html).to eq 'XSS' end + it 'disallows invalid URIs' do + expect(Addressable::URI).to receive(:parse).with('foo://example.com'). + and_raise(Addressable::URI::InvalidURIError) + + input = 'Foo' + output = filter(input) + + expect(output.to_html).to eq 'Foo' + end + it 'allows non-standard anchor schemes' do exp = %q{IRC} act = filter(exp) From d0d7dfb2f5c15f64746ff2df6f579bc06b3a80b3 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Mon, 14 Mar 2016 15:41:36 -0700 Subject: [PATCH 091/797] Bump gitlab_git to 9.0.3 --- CHANGELOG | 1 + Gemfile.lock | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 24f303fc21..d38646ece6 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.6.0 (unreleased) + - Bump gitlab_git to 9.0.3 (Stan Hu) - Support Golang subpackage fetching (Stan Hu) - Bump Capybara gem to 2.6.2 (Stan Hu) - Contributions to forked projects are included in calendar diff --git a/Gemfile.lock b/Gemfile.lock index f719dd60e3..9772e7fdd3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -359,7 +359,7 @@ GEM posix-spawn (~> 0.3) gitlab_emoji (0.3.1) gemojione (~> 2.2, >= 2.2.1) - gitlab_git (9.0.1) + gitlab_git (9.0.3) activesupport (~> 4.0) charlock_holmes (~> 0.7.3) github-linguist (~> 4.7.0) From 5960e9954934d367d02f76f51d31d62235d4aa89 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Tue, 15 Mar 2016 00:36:11 +0100 Subject: [PATCH 092/797] Remove allowed to fail by ignoring RedCloth --- .gitlab-ci.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd013d50fa..1ba1d8d846 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -150,11 +150,10 @@ bundler:audit: stage: test script: - "bundle exec bundle-audit update" - - "bundle exec bundle-audit check" + - "bundle exec bundle-audit check --ignore OSVDB-115941" tags: - ruby - mysql - allow_failure: true # Ruby 2.2 jobs From 8d8b457cebdfd0790157cd54fd1f24e46fbf0785 Mon Sep 17 00:00:00 2001 From: connorshea Date: Fri, 11 Mar 2016 12:33:43 -0700 Subject: [PATCH 093/797] Add SCSS Lint, CSSComb config file, run SCSS Lint in GitLab CI, add documentation for SCSS Style Guide. See !3069 for more information. --- .csscomb.json | 16 +++ .gitlab-ci.yml | 8 ++ .scss-lint.yml | 158 +++++++++++++++++++++++ CONTRIBUTING.md | 2 + Gemfile | 1 + Gemfile.lock | 4 + doc/development/scss_styleguide.md | 194 +++++++++++++++++++++++++++++ lib/tasks/scss-lint.rake | 10 ++ 8 files changed, 393 insertions(+) create mode 100644 .csscomb.json create mode 100644 .scss-lint.yml create mode 100644 doc/development/scss_styleguide.md create mode 100644 lib/tasks/scss-lint.rake diff --git a/.csscomb.json b/.csscomb.json new file mode 100644 index 0000000000..e353e6a63d --- /dev/null +++ b/.csscomb.json @@ -0,0 +1,16 @@ +{ + "always-semicolon": true, + "color-case": "lower", + "block-indent": " ", + "color-shorthand": true, + "element-case": "lower", + "space-before-colon": "", + "space-after-colon": " ", + "space-before-combinator": " ", + "space-after-combinator": " ", + "space-between-declarations": "\n", + "space-before-opening-brace": " ", + "space-after-opening-brace": "\n", + "space-before-closing-brace": "\n", + "unitless-zero": true +} diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd013d50fa..b5f53725f9 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -122,6 +122,14 @@ rubocop: - ruby - mysql +scss-lint: + stage: test + script: + - bundle exec rake scss_lint + tags: + - ruby + allow_failure: true + brakeman: stage: test script: diff --git a/.scss-lint.yml b/.scss-lint.yml new file mode 100644 index 0000000000..e350b2073c --- /dev/null +++ b/.scss-lint.yml @@ -0,0 +1,158 @@ +# Linter Documentation: +# https://github.com/brigade/scss-lint/blob/master/lib/scss_lint/linter/README.md + +scss_files: 'app/assets/stylesheets/**/*.scss' + +exclude: + - 'app/assets/stylesheets/pages/emojis.scss' + +linters: + BangFormat: + enabled: false + + BorderZero: + enabled: false + + ColorKeyword: + enabled: false + + ColorVariable: + enabled: false + + Comment: + enabled: false + + DeclarationOrder: + enabled: false + + # `scss-lint:disable` control comments should be preceded by a comment + # explaining why these linters are being disabled for this file. + # See https://github.com/brigade/scss-lint#disabling-linters-via-source for + # more information. + DisableLinterReason: + enabled: true + + DuplicateProperty: + enabled: false + + EmptyLineBetweenBlocks: + enabled: false + + EmptyRule: + enabled: false + + FinalNewline: + enabled: false + + # HEX colors should use three-character values where possible. + HexLength: + enabled: true + + # HEX color values should use lower-case colors to differentiate between + # letters and numbers, e.g. `#E3E3E3` vs. `#e3e3e3`. + HexNotation: + enabled: true + + IdSelector: + enabled: false + + ImportPath: + enabled: false + + ImportantRule: + enabled: false + + # Indentation should always be done in increments of 2 spaces. + Indentation: + enabled: true + width: 2 + + LeadingZero: + enabled: false + + MergeableSelector: + enabled: false + + NameFormat: + enabled: false + + NestingDepth: + enabled: false + + PlaceholderInExtend: + enabled: false + + PropertySortOrder: + enabled: false + + PropertySpelling: + enabled: false + + PseudoElement: + enabled: false + + QualifyingElement: + enabled: false + + SelectorDepth: + enabled: false + + # Selectors should always use hyphenated-lowercase, rather than camelCase or + # snake_case. + SelectorFormat: + enabled: true + convention: hyphenated_lowercase + + # Prefer the shortest shorthand form possible for properties that support it. + Shorthand: + enabled: true + + # Each property should have its own line, except in the special case of + # single line rulesets. + SingleLinePerProperty: + enabled: true + allow_single_line_rule_sets: true + + SingleLinePerSelector: + enabled: false + + SpaceAfterComma: + enabled: false + + # Properties should be formatted with a single space separating the colon + # from the property's value. + SpaceAfterPropertyColon: + enabled: true + + # Properties should be formatted with no space between the name and the + # colon. + SpaceAfterPropertyName: + enabled: true + + SpaceAroundOperator: + enabled: false + + SpaceBeforeBrace: + enabled: false + + StringQuotes: + enabled: false + + TrailingSemicolon: + enabled: false + + TrailingWhitespace: + enabled: false + + UnnecessaryMantissa: + enabled: false + + UnnecessaryParentReference: + enabled: false + + VendorPrefix: + enabled: false + + # Omit length units on zero values, e.g. `0px` vs. `0`. + ZeroUnit: + enabled: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 30c9742904..7540fa1afc 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -427,6 +427,7 @@ merge request: 1. [Rails](https://github.com/bbatsov/rails-style-guide) 1. [Testing](https://github.com/thoughtbot/guides/tree/master/style/testing) 1. [CoffeeScript](https://github.com/thoughtbot/guides/tree/master/style/coffeescript) +1. [SCSS styleguide][scss-styleguide] 1. [Shell commands](doc/development/shell_commands.md) created by GitLab contributors to enhance security 1. [Database Migrations](doc/development/migration_style_guide.md) @@ -494,6 +495,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [rss-source]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#source-code-layout [rss-naming]: https://github.com/bbatsov/ruby-style-guide/blob/master/README.md#naming [doc-styleguide]: doc/development/doc_styleguide.md "Documentation styleguide" +[scss-styleguide]: doc/development/scss_styleguide.md "SCSS styleguide" [gitlab-design]: https://gitlab.com/gitlab-org/gitlab-design [free Antetype viewer (Mac OSX only)]: https://itunes.apple.com/us/app/antetype-viewer/id824152298?mt=12 [`gitlab1.atype` file]: https://gitlab.com/gitlab-org/gitlab-design/tree/master/gitlab1.atype/ diff --git a/Gemfile b/Gemfile index 1550afb1b5..d022ac96bf 100644 --- a/Gemfile +++ b/Gemfile @@ -286,6 +286,7 @@ group :development, :test do gem 'spring-commands-teaspoon', '~> 0.0.2' gem 'rubocop', '~> 0.35.0', require: false + gem 'scss_lint', '~> 0.47.0', require: false gem 'coveralls', '~> 0.8.2', require: false gem 'simplecov', '~> 0.10.0', require: false gem 'flog', require: false diff --git a/Gemfile.lock b/Gemfile.lock index d4e28db00d..b69980af2b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -717,6 +717,9 @@ GEM sawyer (0.6.0) addressable (~> 2.3.5) faraday (~> 0.8, < 0.10) + scss_lint (0.47.1) + rake (>= 0.9, < 11) + sass (~> 3.4.15) sdoc (0.3.20) json (>= 1.1.3) rdoc (~> 3.10) @@ -1008,6 +1011,7 @@ DEPENDENCIES ruby-fogbugz (~> 0.2.1) sanitize (~> 2.0) sass-rails (~> 5.0.0) + scss_lint (~> 0.47.0) sdoc (~> 0.3.20) seed-fu (~> 2.3.5) select2-rails (~> 3.5.9) diff --git a/doc/development/scss_styleguide.md b/doc/development/scss_styleguide.md new file mode 100644 index 0000000000..6c48c25448 --- /dev/null +++ b/doc/development/scss_styleguide.md @@ -0,0 +1,194 @@ +# SCSS styleguide + +This style guide recommends best practices for SCSS to make styles easy to read, +easy to maintain, and performant for the end-user. + +## Rules + +### Naming + +CSS classes should use the `lowercase-hyphenated` format rather than +`snake_case` or `camelCase`. + +```scss +// Bad +.class_name { + color: #fff; +} + +// Bad +.className { + color: #fff; +} + +// Good +.class-name { + color: #fff; +} +``` + +### Formatting + +You should always use a space before a brace, braces should be on the same +line, each property should each get its own line, and there should be a space +between the property and its value. + +```scss +// Bad +.container-item { + width: 100px; height: 100px; + margin-top: 0; +} + +// Bad +.container-item +{ + width: 100px; + height: 100px; + margin-top: 0; +} + +// Bad +.container-item{ + width:100px; + height:100px; + margin-top:0; +} + +// Good +.container-item { + width: 100px; + height: 100px; + margin-top: 0; +} +``` + +Note that there is an exception for single-line rulesets, although these are +not typically recommended. + +```scss +p { margin: 0; padding: 0; } +``` + +### Colors + +HEX (hexadecimal) colors short-form should use shortform where possible, and +should use lower case letters to differenciate between letters and numbers, e. +g. `#E3E3E3` vs. `#e3e3e3`. + +```scss +// Bad +p { + color: #ffffff; +} + +// Bad +p { + color: #FFFFFF; +} + +// Good +p { + color: #fff; +} +``` + +### Indentation + +Indentation should always use two spaces for each indentation level. + +```scss +// Bad, four spaces +p { + color: #f00; +} + +// Good +p { + color: #f00; +} +``` + +### Semicolons + +Always include semicolons after every property. When the stylesheets are +minified, the semicolons will be removed automatically. + +```scss +// Bad +.container-item { + width: 100px; + height: 100px +} + +// Good +.container-item { + width: 100px; + height: 100px; +} +``` + +### Shorthand + +The shorthand form should be used for properties that support it. + +```scss +// Bad +margin: 10px 15px 10px 15px; +padding: 10px 10px 10px 10px; + +// Good +margin: 10px 15px; +padding: 10px; +``` + +### Zero Units + +Omit length units on zero values, they're unnecessary and not including them +is slightly more performant. + +```scss +// Bad +.item-with-padding { + padding: 0px; +} + +// Good +.item-with-padding { + padding: 0; +} +``` + +### Selectors with a `js-` Prefix +Do not use any selector prefixed with `js-` for styling purposes. These +selectors are intended for use only with JavaScript to allow for removal or +renaming without breaking styling. + +## Linting + +We use [SCSS Lint][scss-lint] to check for style guide conformity. It uses the +ruleset in `.scss-lint.yml`, which is located in the home directory of the +project. + +To check if any warnings will be produced by your changes, you can run `rake +scss_lint` in the GitLab directory. SCSS Lint will also run in GitLab CI to +catch any warnings. + +If the Rake task is throwing warnings you don't understand, SCSS Lint's +documentation includes [a full list of their linters][scss-lint-documentation]. + +### Fixing issues + +If you want to automate changing a large portion of the codebase to conform to +the SCSS style guide, you can use [CSSComb][csscomb]. First install +[Node][node] and [NPM][npm], then run `npm install csscomb -g` to install +CSSComb globally (system-wide). Run it in the GitLab directory with +`csscomb app/assets/stylesheets` to automatically fix issues with CSS/SCSS. + +Note that this won't fix every problem, but it should fix a majority. + +[csscomb]: https://github.com/csscomb/csscomb.js +[node]: https://github.com/nodejs/node +[npm]: https://www.npmjs.com/ +[scss-lint]: https://github.com/brigade/scss-lint +[scss-lint-documentation]: https://github.com/brigade/scss-lint/blob/master/lib/scss_lint/linter/README.md diff --git a/lib/tasks/scss-lint.rake b/lib/tasks/scss-lint.rake new file mode 100644 index 0000000000..250fd8699e --- /dev/null +++ b/lib/tasks/scss-lint.rake @@ -0,0 +1,10 @@ +unless Rails.env.production? + require 'scss_lint/rake_task' + + SCSSLint::RakeTask.new do |t| + t.config = '.scss-lint.yml' + # See https://github.com/brigade/scss-lint/issues/726 + # Hack, otherwise linter won't respect scss_files option in config file. + t.files = [] + end +end From 4fc2dcc4f479f0d2676004406b247548bfc64c86 Mon Sep 17 00:00:00 2001 From: Jacob Schatz Date: Mon, 14 Mar 2016 23:23:30 -0400 Subject: [PATCH 094/797] Fix bug with JS error from dropdowns in filter area --- app/assets/javascripts/breakpoints.coffee | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/breakpoints.coffee b/app/assets/javascripts/breakpoints.coffee index 1ffaaddb05..5457430f92 100644 --- a/app/assets/javascripts/breakpoints.coffee +++ b/app/assets/javascripts/breakpoints.coffee @@ -10,7 +10,6 @@ class @Breakpoints setup: -> allDeviceSelector = BREAKPOINTS.map (breakpoint) -> ".device-#{breakpoint}" - return if $(allDeviceSelector.join(",")).length # Create all the elements @@ -18,12 +17,17 @@ class @Breakpoints "
" $("body").append els.join('') - getBreakpointSize: -> + visibleDevice: -> allDeviceSelector = BREAKPOINTS.map (breakpoint) -> ".device-#{breakpoint}" + $(allDeviceSelector.join(",")).filter(":visible") - $visibleDevice = $(allDeviceSelector.join(",")).filter(":visible") - + getBreakpointSize: -> + $visibleDevice = @visibleDevice + # the page refreshed via turbolinks + if not $visibleDevice().length + @setup() + $visibleDevice = @visibleDevice() return $visibleDevice.attr("class").split("visible-")[1] @get: -> From ae090b1c8a572b0dca83e81f99adce01a50713bf Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Tue, 15 Mar 2016 04:12:24 +0000 Subject: [PATCH 095/797] Revert to relative requirements.md link use gitlab.com link for installation guides. --- doc/install/installation.md | 2 +- doc/install/requirements.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/install/installation.md b/doc/install/installation.md index 9037a332a3..303516840b 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -22,7 +22,7 @@ If the highest number stable branch is unclear please check the [GitLab Blog](ht This guide is long because it covers many cases and includes all commands you need, this is [one of the few installation scripts that actually works out of the box](https://twitter.com/robinvdvleuten/status/424163226532986880). -This installation guide was created for and tested on **Debian/Ubuntu** operating systems. Please read [requirements.md](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/requirements.md) for hardware and operating system requirements. If you want to install on RHEL/CentOS we recommend using the [Omnibus packages](https://about.gitlab.com/downloads/). +This installation guide was created for and tested on **Debian/Ubuntu** operating systems. Please read [requirements.md](requirements.md) for hardware and operating system requirements. If you want to install on RHEL/CentOS we recommend using the [Omnibus packages](https://about.gitlab.com/downloads/). This is the official installation guide to set up a production server. To set up a **development installation** or for many other installation options please see [the installation section of the readme](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/README.md#installation). diff --git a/doc/install/requirements.md b/doc/install/requirements.md index a4f78613e3..a7dfa7ddf5 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -22,7 +22,7 @@ For the installations options please see [the installation page on the GitLab we - FreeBSD On the above unsupported distributions is still possible to install GitLab yourself. -Please see the [installation from source guide](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/installation.md) and the [unofficial installation guides](https://github.com/gitlabhq/gitlab-public-wiki/wiki/Unofficial-Installation-Guides) on the public wiki for more information. +Please see the [installation from source guide](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/installation.md) and the [installation guides](https://about.gitlab.com/installation/) for more information. ### Non-Unix operating systems such as Windows From 30b36c92c386e93b432166fb6f9dd973882a6d82 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Tue, 15 Mar 2016 11:03:43 +0100 Subject: [PATCH 096/797] Use an exception to pass messages --- app/controllers/projects_controller.rb | 15 ++++++++---- app/services/git_push_service.rb | 1 + app/services/projects/housekeeping_service.rb | 19 ++++++++------- spec/services/git_push_service_spec.rb | 24 ++++++++++++++----- .../projects/housekeeping_service_spec.rb | 6 ++--- 5 files changed, 42 insertions(+), 23 deletions(-) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 2a3dc5c79f..36f37221c5 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -170,12 +170,17 @@ class ProjectsController < ApplicationController end def housekeeping - message = ::Projects::HousekeepingService.new(@project).execute + ::Projects::HousekeepingService.new(@project).execute - respond_to do |format| - flash[:notice] = message - format.html { redirect_to project_path(@project) } - end + redirect_to( + project_path(@project), + notice: "Housekeeping successfully started" + ) + rescue ::Projects::HousekeepingService::LeaseTaken => ex + redirect_to( + edit_project_path(@project), + alert: ex.to_s + ) end def toggle_star diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index e50cbdfb60..4313de0cca 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -79,6 +79,7 @@ class GitPushService < BaseService housekeeping = Projects::HousekeepingService.new(@project) housekeeping.increment! housekeeping.execute if housekeeping.needed? + rescue Projects::HousekeepingService::LeaseTaken end def process_default_branch diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index 83bdedf7a8..bccd67d3db 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -11,20 +11,22 @@ module Projects LEASE_TIMEOUT = 3600 + class LeaseTaken < StandardError + def to_s + "Somebody already triggered housekeeping for this project in the past #{LEASE_TIMEOUT / 60} minutes" + end + end + def initialize(project) @project = project end def execute - if !try_obtain_lease - return "Housekeeping was already triggered in the past #{LEASE_TIMEOUT / 60} minutes" - end + raise LeaseTaken if !try_obtain_lease GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) - @project.pushes_since_gc = 0 - @project.save! - - "Housekeeping successfully started" + ensure + @project.update_column(:pushes_since_gc, 0) end def needed? @@ -32,8 +34,7 @@ module Projects end def increment! - @project.pushes_since_gc += 1 - @project.save! + @project.increment!(:pushes_since_gc) end private diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index ebf3ec1f5f..145bc93756 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -402,7 +402,7 @@ describe GitPushService, services: true do end describe "housekeeping" do - let(:housekeeping) { instance_double('Projects::HousekeepingService', increment!: nil, needed?: false) } + let(:housekeeping) { Projects::HousekeepingService.new(project) } before do allow(Projects::HousekeepingService).to receive(:new).and_return(housekeeping) @@ -414,16 +414,28 @@ describe GitPushService, services: true do execute_service(project, user, @oldrev, @newrev, @ref) end - it 'performs housekeeping when needed' do - expect(housekeeping).to receive(:needed?).and_return(true) - expect(housekeeping).to receive(:execute) + context 'when housekeeping is needed' do + before do + allow(housekeeping).to receive(:needed?).and_return(true) + end - execute_service(project, user, @oldrev, @newrev, @ref) + it 'performs housekeeping' do + expect(housekeeping).to receive(:execute) + + execute_service(project, user, @oldrev, @newrev, @ref) + end + + it 'does not raise an exception' do + allow(housekeeping).to receive(:try_obtain_lease).and_return(false) + + execute_service(project, user, @oldrev, @newrev, @ref) + end end + it 'increments the push counter' do expect(housekeeping).to receive(:increment!) - + execute_service(project, user, @oldrev, @newrev, @ref) end end diff --git a/spec/services/projects/housekeeping_service_spec.rb b/spec/services/projects/housekeeping_service_spec.rb index 4c3577149f..93bf1b81fb 100644 --- a/spec/services/projects/housekeeping_service_spec.rb +++ b/spec/services/projects/housekeeping_service_spec.rb @@ -14,7 +14,7 @@ describe Projects::HousekeepingService do expect(subject).to receive(:try_obtain_lease).and_return(true) expect(GitlabShellWorker).to receive(:perform_async).with(:gc, project.path_with_namespace) - expect(subject.execute).to include('successfully started') + subject.execute expect(project.pushes_since_gc).to eq(0) end @@ -22,8 +22,8 @@ describe Projects::HousekeepingService do expect(subject).to receive(:try_obtain_lease).and_return(false) expect(GitlabShellWorker).not_to receive(:perform_async) - expect(subject.execute).to include('already triggered') - expect(project.pushes_since_gc).to eq(3) + expect { subject.execute }.to raise_error(Projects::HousekeepingService::LeaseTaken) + expect(project.pushes_since_gc).to eq(0) end end From c51c901916c9092a97f6a11f7bc64ad25536ea19 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 15 Mar 2016 11:06:50 +0100 Subject: [PATCH 097/797] Return the external issue tracker even if it's null This solves the problem with caching the nil value with instance variable. Without this the every time we ask for external_issue_tracker we built AR and potentially do SQL query --- app/models/project.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/models/project.rb b/app/models/project.rb index 79e0cc7b23..346cd6222c 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -508,6 +508,7 @@ class Project < ActiveRecord::Base end def external_issue_tracker + return @external_issue_tracker if defined?(@external_issue_tracker) @external_issue_tracker ||= services.issue_trackers.active.without_defaults.first end From 76350e2ede187a8bd15e343c30537c90ee557aa7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Thu, 10 Mar 2016 17:15:14 +0100 Subject: [PATCH 098/797] Ensure "new SSH key" email do not ends up as dead Sidekiq jobs Related to #2235. This is done by: 1. Delaying the notification sending after the SSH key is commited in DB 2. Gracefully exit the mailer method if the record cannot be found --- CHANGELOG | 1 + app/mailers/emails/profile.rb | 5 ++++- app/models/key.rb | 3 ++- spec/mailers/emails/profile_spec.rb | 6 +++++- 4 files changed, 12 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d38646ece6..27c595585a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -25,6 +25,7 @@ v 8.6.0 (unreleased) - Allow to pass name of created artifacts archive in `.gitlab-ci.yml` - Refactor and greatly improve search performance - Add support for cross-project label references + - Ensure "new SSH key" email do not ends up as dead Sidekiq jobs - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users - Allow to define on which builds the current one depends on diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 3a83b08310..256cbcd73a 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -14,7 +14,10 @@ module Emails end def new_ssh_key_email(key_id) - @key = Key.find(key_id) + @key = Key.find_by_id(key_id) + + return unless @key + @current_user = @user = @key.user @target_url = user_url(@user) mail(to: @user.notification_email, subject: subject("SSH key was added to your account")) diff --git a/app/models/key.rb b/app/models/key.rb index 406a1257b5..0282ad1813 100644 --- a/app/models/key.rb +++ b/app/models/key.rb @@ -16,6 +16,7 @@ require 'digest/md5' class Key < ActiveRecord::Base + include AfterCommitQueue include Sortable belongs_to :user @@ -62,7 +63,7 @@ class Key < ActiveRecord::Base end def notify_user - NotificationService.new.new_key(self) + run_after_commit { NotificationService.new.new_key(self) } end def post_create_hook diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 5b575da34f..c6758ccad3 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -11,7 +11,7 @@ describe Notify do let(:example_site_path) { root_path } let(:new_user) { create(:user, email: new_user_address, created_by_id: 1) } let(:token) { 'kETLwRaayvigPq_x3SNM' } - + subject { Notify.new_user_email(new_user.id, token) } it_behaves_like 'an email sent from GitLab' @@ -77,6 +77,10 @@ describe Notify do it 'includes a link to ssh keys page' do is_expected.to have_body_text /#{profile_keys_path}/ end + + context 'with SSH key that does not exist' do + it { expect { Notify.new_ssh_key_email('foo') }.not_to raise_error } + end end describe 'user added email' do From 090368f6c6edf5727b3e8c5ed69ccaa31e8a2c5f Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Tue, 15 Mar 2016 12:46:21 +0200 Subject: [PATCH 099/797] Windows CI support is full since Runner v1.0.0 [ci skip] --- doc/ci/yaml/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 4d27c07913..5158e3c387 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -399,7 +399,7 @@ The above script will: >**Notes:** > > - Introduced in GitLab Runner v0.7.0 for non-Windows platforms. -> - Limited Windows support was added in GitLab Runner v.1.0.0. +> - Windows support was added in GitLab Runner v.1.0.0. > - Currently not all executors are supported. > - Build artifacts are only collected for successful builds. From b5c8f5727b5261d86ce52b976a355e7f223a563a Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 15 Mar 2016 11:34:49 +0100 Subject: [PATCH 100/797] Hide builds from Project's settings when the feature is disabled --- CHANGELOG | 1 + app/assets/javascripts/project_new.js.coffee | 13 ++++ app/views/projects/_builds_settings.html.haml | 60 +++++++++++++++++ app/views/projects/edit.html.haml | 65 +------------------ 4 files changed, 76 insertions(+), 63 deletions(-) create mode 100644 app/views/projects/_builds_settings.html.haml diff --git a/CHANGELOG b/CHANGELOG index d38646ece6..473acdaa5e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -22,6 +22,7 @@ v 8.6.0 (unreleased) - Rewrite logo to simplify SVG code (Sean Lang) - Allow to use YAML anchors when parsing the `.gitlab-ci.yml` (Pascal Bach) - Ignore jobs that start with `.` (hidden jobs) + - Hide builds from project's settings when the feature is disabled - Allow to pass name of created artifacts archive in `.gitlab-ci.yml` - Refactor and greatly improve search performance - Add support for cross-project label references diff --git a/app/assets/javascripts/project_new.js.coffee b/app/assets/javascripts/project_new.js.coffee index fecdb9fc2e..63dee4ed5d 100644 --- a/app/assets/javascripts/project_new.js.coffee +++ b/app/assets/javascripts/project_new.js.coffee @@ -3,3 +3,16 @@ class @ProjectNew $('.project-edit-container').on 'ajax:before', => $('.project-edit-container').hide() $('.save-project-loader').show() + @toggleSettings() + @toggleSettingsOnclick() + + + toggleSettings: -> + checked = $("#project_builds_enabled").prop("checked") + if checked + $('.builds-feature').show() + else + $('.builds-feature').hide() + + toggleSettingsOnclick: -> + $("#project_builds_enabled").on 'click', @toggleSettings diff --git a/app/views/projects/_builds_settings.html.haml b/app/views/projects/_builds_settings.html.haml new file mode 100644 index 0000000000..95ab9ecf3e --- /dev/null +++ b/app/views/projects/_builds_settings.html.haml @@ -0,0 +1,60 @@ +%fieldset.builds-feature + %legend + Builds: + .form-group + .col-sm-offset-2.col-sm-10 + %p Get recent application code using the following command: + .radio + = f.label :build_allow_git_fetch_false do + = f.radio_button :build_allow_git_fetch, 'false' + %strong git clone + %br + %span.descr Slower but makes sure you have a clean dir before every build + .radio + = f.label :build_allow_git_fetch_true do + = f.radio_button :build_allow_git_fetch, 'true' + %strong git fetch + %br + %span.descr Faster + + .form-group + = f.label :build_timeout_in_minutes, 'Timeout', class: 'control-label' + .col-sm-10 + = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' + %p.help-block per build in minutes + .form-group + = f.label :build_coverage_regex, "Test coverage parsing", class: 'control-label' + .col-sm-10 + .input-group + %span.input-group-addon / + = f.text_field :build_coverage_regex, class: 'form-control', placeholder: '\(\d+.\d+\%\) covered' + %span.input-group-addon / + %p.help-block + We will use this regular expression to find test coverage output in build trace. + Leave blank if you want to disable this feature + .bs-callout.bs-callout-info + %p Below are examples of regex for existing tools: + %ul + %li + Simplecov (Ruby) - + %code \(\d+.\d+\%\) covered + %li + pytest-cov (Python) - + %code \d+\%\s*$ + %li + phpunit --coverage-text --colors=never (PHP) - + %code ^\s*Lines:\s*\d+.\d+\% + + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :public_builds do + = f.check_box :public_builds + %strong Public builds + .help-block Allow everyone to access builds for Public and Internal projects + + .form-group + = f.label :runners_token, "Runners token", class: 'control-label' + .col-sm-10 + = f.text_field :runners_token, class: "form-control", placeholder: 'xEeFCaDAB89' + %p.help-block The secure token used to checkout project. diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index f2e56081af..6d872cd0b2 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -84,6 +84,8 @@ %br %span.descr Share code pastes with others out of git repository + = render 'builds_settings', f: f + %fieldset.features %legend Project avatar: @@ -110,69 +112,6 @@ %hr = link_to 'Remove avatar', namespace_project_avatar_path(@project.namespace, @project), data: { confirm: "Project avatar will be removed. Are you sure?"}, method: :delete, class: "btn btn-remove btn-sm remove-avatar" - %fieldset.features - %legend - Continuous Integration - .form-group - .col-sm-offset-2.col-sm-10 - %p Get recent application code using the following command: - .radio - = f.label :build_allow_git_fetch_false do - = f.radio_button :build_allow_git_fetch, 'false' - %strong git clone - %br - %span.descr Slower but makes sure you have a clean dir before every build - .radio - = f.label :build_allow_git_fetch_true do - = f.radio_button :build_allow_git_fetch, 'true' - %strong git fetch - %br - %span.descr Faster - - .form-group - = f.label :build_timeout_in_minutes, 'Timeout', class: 'control-label' - .col-sm-10 - = f.number_field :build_timeout_in_minutes, class: 'form-control', min: '0' - %p.help-block per build in minutes - .form-group - = f.label :build_coverage_regex, "Test coverage parsing", class: 'control-label' - .col-sm-10 - .input-group - %span.input-group-addon / - = f.text_field :build_coverage_regex, class: 'form-control', placeholder: '\(\d+.\d+\%\) covered' - %span.input-group-addon / - %p.help-block - We will use this regular expression to find test coverage output in build trace. - Leave blank if you want to disable this feature - .bs-callout.bs-callout-info - %p Below are examples of regex for existing tools: - %ul - %li - Simplecov (Ruby) - - %code \(\d+.\d+\%\) covered - %li - pytest-cov (Python) - - %code \d+\%\s*$ - %li - phpunit --coverage-text --colors=never (PHP) - - %code ^\s*Lines:\s*\d+.\d+\% - - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = f.label :public_builds do - = f.check_box :public_builds - %strong Public builds - .help-block Allow everyone to access builds for Public and Internal projects - - %fieldset.features - %legend - Advanced settings - .form-group - = f.label :runners_token, "CI token", class: 'control-label' - .col-sm-10 - = f.text_field :runners_token, class: "form-control", placeholder: 'xEeFCaDAB89' - %p.help-block The secure token used to checkout project. .form-actions = f.submit 'Save changes', class: "btn btn-save" From a8f1c5ed03dff11a59a87a25ba2bde837963bb00 Mon Sep 17 00:00:00 2001 From: Jason Roehm Date: Tue, 15 Mar 2016 09:27:33 -0400 Subject: [PATCH 101/797] add 'triggers' keyword to gitlab-ci.yml 'only' and 'except' fields to allow control over whether triggers will cause jobs to run --- app/services/ci/create_builds_service.rb | 2 +- lib/ci/gitlab_ci_yaml_processor.rb | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/app/services/ci/create_builds_service.rb b/app/services/ci/create_builds_service.rb index 002f7ba127..2cd51a7610 100644 --- a/app/services/ci/create_builds_service.rb +++ b/app/services/ci/create_builds_service.rb @@ -1,7 +1,7 @@ module Ci class CreateBuildsService def execute(commit, stage, ref, tag, user, trigger_request, status) - builds_attrs = commit.config_processor.builds_for_stage_and_ref(stage, ref, tag) + builds_attrs = commit.config_processor.builds_for_stage_and_ref(stage, ref, tag, trigger_request) # check when to create next build builds_attrs = builds_attrs.select do |build_attrs| diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index c89e1b5101..bc6fea9a28 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -26,8 +26,8 @@ module Ci validate! end - def builds_for_stage_and_ref(stage, ref, tag = false) - builds.select{|build| build[:stage] == stage && process?(build[:only], build[:except], ref, tag)} + 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)} end def builds @@ -266,21 +266,21 @@ module Ci value.in?([true, false]) end - def process?(only_params, except_params, ref, tag) + def process?(only_params, except_params, ref, tag, trigger_request) if only_params.present? - return false unless matching?(only_params, ref, tag) + return false unless matching?(only_params, ref, tag, trigger_request) end if except_params.present? - return false if matching?(except_params, ref, tag) + return false if matching?(except_params, ref, tag, trigger_request) end true end - def matching?(patterns, ref, tag) + def matching?(patterns, ref, tag, trigger_request) patterns.any? do |pattern| - match_ref?(pattern, ref, tag) + match_ref?(pattern, ref, tag, trigger_request) end end @@ -289,6 +289,7 @@ module Ci return false if path && path != self.path return true if tag && pattern == 'tags' return true if !tag && pattern == 'branches' + return true if trigger_request != nil && pattern == 'triggers' if pattern.first == "/" && pattern.last == "/" Regexp.new(pattern[1...-1]) =~ ref From c96e037dcbe127c6525058a7884fc7c5f2708979 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 15 Mar 2016 14:26:15 +0100 Subject: [PATCH 102/797] Fix double borders around the CI status --- app/helpers/ci_status_helper.rb | 10 +++++++--- app/views/projects/ci/builds/_build.html.haml | 3 +-- .../_generic_commit_status.html.haml | 3 +-- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index f20779f2fb..391d74ebdb 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -12,9 +12,13 @@ module CiStatusHelper ci_label_for_status(ci_commit.status) end - def ci_status_with_icon(status) - content_tag :span, class: "ci-status ci-#{status}" do - ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) + def ci_status_with_icon(status, target = nil) + content = ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) + clazz = "ci-status ci-#{status}" + if target + link_to content, target, class: clazz + else + content_tag :span, content, class: clazz end end diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 7123efffd5..d22d1da840 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -1,8 +1,7 @@ %tr.build %td.status - if can?(current_user, :read_build, build) - = link_to namespace_project_build_url(build.project.namespace, build.project, build), class: "ci-status ci-#{build.status}" do - = ci_status_with_icon(build.status) + = ci_status_with_icon(build.status, namespace_project_build_url(build.project.namespace, build.project, build)) - else = ci_status_with_icon(build.status) diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index 4143ea1306..c15386b488 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -1,8 +1,7 @@ %tr.generic_commit_status %td.status - if can?(current_user, :read_commit_status, generic_commit_status) && generic_commit_status.target_url - = link_to generic_commit_status.target_url, class: "ci-status ci-#{generic_commit_status.status}" do - = ci_status_with_icon(generic_commit_status.status) + = ci_status_with_icon(generic_commit_status.status, generic_commit_status.target_url) - else = ci_status_with_icon(generic_commit_status.status) From 6c1badbd108a12d1217596a3319f9bd3ca7319b8 Mon Sep 17 00:00:00 2001 From: Jason Roehm Date: Tue, 15 Mar 2016 09:35:38 -0400 Subject: [PATCH 103/797] add tests for only/except 'triggers' keyword --- spec/lib/ci/gitlab_ci_yaml_processor_spec.rb | 44 ++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb index fab6412d29..b79b8147ce 100644 --- a/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb +++ b/spec/lib/ci/gitlab_ci_yaml_processor_spec.rb @@ -97,6 +97,28 @@ module Ci expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(0) end + it "returns builds if only has a triggers keyword specified and a trigger is provided" do + config = YAML.dump({ + before_script: ["pwd"], + rspec: { script: "rspec", type: type, only: ["triggers"] } + }) + + config_processor = GitlabCiYamlProcessor.new(config, path) + + expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, true).size).to eq(1) + end + + it "does not return builds if only has a triggers keyword specified and no trigger is provided" do + config = YAML.dump({ + before_script: ["pwd"], + rspec: { script: "rspec", type: type, only: ["triggers"] } + }) + + config_processor = GitlabCiYamlProcessor.new(config, path) + + expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(0) + end + it "returns builds if only has current repository path" do config = YAML.dump({ before_script: ["pwd"], @@ -203,6 +225,28 @@ module Ci expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(1) end + it "does not return builds if except has a triggers keyword specified and a trigger is provided" do + config = YAML.dump({ + before_script: ["pwd"], + rspec: { script: "rspec", type: type, except: ["triggers"] } + }) + + config_processor = GitlabCiYamlProcessor.new(config, path) + + expect(config_processor.builds_for_stage_and_ref(type, "deploy", false, true).size).to eq(0) + end + + it "returns builds if except has a triggers keyword specified and no trigger is provided" do + config = YAML.dump({ + before_script: ["pwd"], + rspec: { script: "rspec", type: type, except: ["triggers"] } + }) + + config_processor = GitlabCiYamlProcessor.new(config, path) + + expect(config_processor.builds_for_stage_and_ref(type, "deploy").size).to eq(1) + end + it "does not return builds if except has current repository path" do config = YAML.dump({ before_script: ["pwd"], From b6ede127294fd0df63facb9a65791c018067db9f Mon Sep 17 00:00:00 2001 From: Jason Roehm Date: Tue, 15 Mar 2016 09:46:09 -0400 Subject: [PATCH 104/797] add some documentation for `triggers` keyword [ci skip] --- doc/ci/yaml/README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 5158e3c387..510fab7340 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -286,7 +286,7 @@ There are a few rules that apply to the usage of refs policy: * `only` and `except` are inclusive. If both `only` and `except` are defined in a job specification, the ref is filtered by `only` and `except`. * `only` and `except` allow the use of regular expressions. -* `only` and `except` allow the use of special keywords: `branches` and `tags`. +* `only` and `except` allow the use of special keywords: `branches`, `tags`, and `triggers`. * `only` and `except` allow to specify a repository path to filter jobs for forks. @@ -303,6 +303,17 @@ job: - branches ``` +In this example, `job` will run only for refs that are tagged, or if a build is explicitly requested +via an API trigger. + +```yaml +job: + # use special keywords + only: + - tags + - triggers +``` + The repository path can be used to have jobs executed only for the parent repository and not forks: From c57c9c41e02907959a5dd78f2896734f0034aafc Mon Sep 17 00:00:00 2001 From: Jason Roehm Date: Tue, 15 Mar 2016 09:47:47 -0400 Subject: [PATCH 105/797] fix rubocop violation --- lib/ci/gitlab_ci_yaml_processor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index bc6fea9a28..610c56ddd9 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -289,7 +289,7 @@ module Ci return false if path && path != self.path return true if tag && pattern == 'tags' return true if !tag && pattern == 'branches' - return true if trigger_request != nil && pattern == 'triggers' + return true if !trigger_request.nil? && pattern == 'triggers' if pattern.first == "/" && pattern.last == "/" Regexp.new(pattern[1...-1]) =~ ref From 1714883107b7b8b8f2ef8c2836acc2866362738e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 15 Mar 2016 13:20:54 +0100 Subject: [PATCH 106/797] Revert "Merge branch 'avatar-cropping' into 'master' " This reverts commit 01160fc06182de89c400af174861f6545ad6ceb8, reversing changes made to 4bff9daf8b6d85e9c78565e21cfaa3f6d36f0282. --- CHANGELOG | 1 - Gemfile | 3 - Gemfile.lock | 2 - app/assets/javascripts/application.js.coffee | 1 - app/assets/javascripts/profile.js.coffee | 48 +- app/assets/stylesheets/application.scss | 1 - app/assets/stylesheets/framework/mixins.scss | 6 - app/assets/stylesheets/pages/profile.scss | 36 - app/controllers/profiles_controller.rb | 3 - app/models/user.rb | 8 - app/uploaders/avatar_uploader.rb | 11 - app/views/profiles/show.html.haml | 19 - features/steps/profile/profile.rb | 20 +- .../controllers/namespaces_controller_spec.rb | 2 +- .../profiles/avatars_controller_spec.rb | 2 +- spec/controllers/uploads_controller_spec.rb | 2 +- spec/factories/users.rb | 7 - spec/helpers/application_helper_spec.rb | 6 +- spec/models/user_spec.rb | 26 - vendor/assets/javascripts/cropper.js | 2972 ----------------- vendor/assets/stylesheets/cropper.css | 379 --- 21 files changed, 17 insertions(+), 3538 deletions(-) delete mode 100755 vendor/assets/javascripts/cropper.js delete mode 100755 vendor/assets/stylesheets/cropper.css diff --git a/CHANGELOG b/CHANGELOG index d38646ece6..039ef5587a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,7 +10,6 @@ v 8.6.0 (unreleased) setup. A password can be provided during setup (see installation docs), or GitLab will ask the user to create a new one upon first visit. - Fix issue when pushing to projects ending in .wiki - - Fix avatar stretching by providing a cropping feature (Johann Pardanaud) - Don't load all of GitLab in mail_room - Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set - Memoize @group in Admin::GroupsController (Yatish Mehta) diff --git a/Gemfile b/Gemfile index 26b61c2fa5..2f14b9965b 100644 --- a/Gemfile +++ b/Gemfile @@ -77,9 +77,6 @@ gem "haml-rails", '~> 0.9.0' # Files attachments gem "carrierwave", '~> 0.10.0' -# Image editing -gem "mini_magick", '~> 4.4.0' - # Drag and Drop UI gem 'dropzonejs-rails', '~> 0.7.1' diff --git a/Gemfile.lock b/Gemfile.lock index 9772e7fdd3..14b03bab5d 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -469,7 +469,6 @@ GEM method_source (0.8.2) mime-types (1.25.1) mimemagic (0.3.0) - mini_magick (4.4.0) mini_portile2 (2.0.0) minitest (5.7.0) mousetrap-rails (1.4.6) @@ -957,7 +956,6 @@ DEPENDENCIES loofah (~> 2.0.3) mail_room (~> 0.6.1) method_source (~> 0.8) - mini_magick (~> 4.4.0) minitest (~> 5.7.0) mousetrap-rails (~> 1.4.6) mysql2 (~> 0.3.16) diff --git a/app/assets/javascripts/application.js.coffee b/app/assets/javascripts/application.js.coffee index e9c6196e92..d415bbd347 100644 --- a/app/assets/javascripts/application.js.coffee +++ b/app/assets/javascripts/application.js.coffee @@ -42,7 +42,6 @@ #= require jquery.nicescroll #= require_tree . #= require fuzzaldrin-plus -#= require cropper.js window.slugify = (text) -> text.replace(/[^-a-zA-Z0-9]+/g, '_').toLowerCase() diff --git a/app/assets/javascripts/profile.js.coffee b/app/assets/javascripts/profile.js.coffee index 59d44c30be..20f8744055 100644 --- a/app/assets/javascripts/profile.js.coffee +++ b/app/assets/javascripts/profile.js.coffee @@ -17,52 +17,14 @@ class @Profile $('.update-notifications').on 'ajax:complete', -> $(this).find('.btn-save').enable() - # Avatar management + $('.js-choose-user-avatar-button').bind "click", -> + form = $(this).closest("form") + form.find(".js-user-avatar-input").click() - $avatarInput = $('.js-user-avatar-input') - $filename = $('.js-avatar-filename') - $modalCrop = $('.modal-profile-crop') - $modalCropImg = $('.modal-profile-crop-image') - - $('.js-choose-user-avatar-button').on "click", -> - $form = $(this).closest("form") - $form.find(".js-user-avatar-input").click() - - $modalCrop.on 'shown.bs.modal', -> - setTimeout ( -> # The cropper must be asynchronously initialized - $modalCropImg.cropper - aspectRatio: 1 - modal: false - scalable: false - rotatable: false - zoomable: false - - crop: (event) -> - ['x', 'y'].forEach (key) -> - $("#user_avatar_crop_#{key}").val(Math.floor(event[key])) - $("#user_avatar_crop_size").val(Math.floor(event.width)) - ), 0 - - $modalCrop.on 'hidden.bs.modal', -> - $modalCropImg.attr('src', '').cropper('destroy') - $avatarInput.val('') - $filename.text($filename.data('label')) - - $('.js-upload-user-avatar').on 'click', -> - $('.edit-user').submit() - - $avatarInput.on "change", -> + $('.js-user-avatar-input').bind "change", -> form = $(this).closest("form") filename = $(this).val().replace(/^.*[\\\/]/, '') - $filename.data('label', $filename.text()).text(filename) - - reader = new FileReader - - reader.onload = (event) -> - $modalCrop.modal('show') - $modalCropImg.attr('src', event.target.result) - - fileData = reader.readAsDataURL(this.files[0]) + form.find(".js-avatar-filename").text(filename) $ -> # Extract the SSH Key title from its comment diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index e2d590f4df..2d301d21ab 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -9,7 +9,6 @@ *= require_self *= require dropzone/basic *= require cal-heatmap - *= require cropper.css */ /* diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 368bbfe535..1d5000fe38 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -41,12 +41,6 @@ transition: $transition; } -@mixin transform($transform) { - -webkit-transform: $transform; - -ms-transform: $transform; - transform: $transform; -} - /** * Prefilled mixins * Mixins with fixed values diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 248c56e459..13069a2531 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -109,42 +109,6 @@ } } -.modal-profile-crop { - .modal-dialog { - width: 500px; - } - - .modal-body { - p { - display: table; - margin: auto; - overflow: hidden; - } - - img { - display: block; - max-width: 400px; - max-height: 400px; - } - - .cropper-bg { - background: none; - } - - .cropper-crop-box { - box-sizing: content-box; - border: 999px solid transparentize(#ccc, 0.5); - @include transform(translate(-999px, -999px)); - } - } -} - -@media (max-width: 520px) { - .modal-profile-crop .modal-dialog { - width: auto; - } -} - .key-list-item { .key-list-item-info { @media (min-width: $screen-sm-min) { diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index fa7a114896..28803164fc 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -65,9 +65,6 @@ class ProfilesController < Profiles::ApplicationController def user_params params.require(:user).permit( - :avatar_crop_x, - :avatar_crop_y, - :avatar_crop_size, :avatar, :bio, :email, diff --git a/app/models/user.rb b/app/models/user.rb index 8871b0ab9f..68b242888a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -98,9 +98,6 @@ class User < ActiveRecord::Base # Virtual attribute for authenticating by either username or email attr_accessor :login - # Virtual attributes to define avatar cropping - attr_accessor :avatar_crop_x, :avatar_crop_y, :avatar_crop_size - # # Relations # @@ -166,11 +163,6 @@ class User < ActiveRecord::Base validate :owns_public_email, if: ->(user) { user.public_email_changed? } validates :avatar, file_size: { maximum: 200.kilobytes.to_i } - validates :avatar_crop_x, :avatar_crop_y, :avatar_crop_size, - numericality: { only_integer: true }, - presence: true, - if: ->(user) { user.avatar? && user.avatar_changed? } - before_validation :generate_password, on: :create before_validation :restricted_signup_domains, on: :create before_validation :sanitize_attrs diff --git a/app/uploaders/avatar_uploader.rb b/app/uploaders/avatar_uploader.rb index 2c72df44ff..6135c3ad96 100644 --- a/app/uploaders/avatar_uploader.rb +++ b/app/uploaders/avatar_uploader.rb @@ -2,22 +2,11 @@ class AvatarUploader < CarrierWave::Uploader::Base include UploaderHelper - include CarrierWave::MiniMagick storage :file after :store, :reset_events_cache - process :cropper - - def cropper - return unless model.respond_to?(:avatar_crop_size) && model.valid? - - manipulate! do |img| - img.crop "#{model.avatar_crop_size}x#{model.avatar_crop_size}+#{model.avatar_crop_x}+#{model.avatar_crop_y}" - end - end - def store_dir "uploads/#{model.class.to_s.underscore}/#{mounted_as}/#{model.id}" end diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 3d1ba49491..cd582ba706 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,7 +1,4 @@ = form_for @user, url: profile_path, method: :put, html: { multipart: true, class: "edit-user prepend-top-default" }, authenticity_token: true do |f| - = f.hidden_field :avatar_crop_x - = f.hidden_field :avatar_crop_y - = f.hidden_field :avatar_crop_size -if @user.errors.any? %div.alert.alert-danger %ul @@ -97,19 +94,3 @@ .prepend-top-default.append-bottom-default = f.submit 'Update profile settings', class: "btn btn-success" = link_to "Cancel", user_path(current_user), class: "btn btn-cancel" - -.modal.modal-profile-crop - .modal-dialog - .modal-content - .modal-header - %button.close{type: 'button', data: {dismiss: 'modal'}} - %span - × - %h4.modal-title - Crop your new profile picture - .modal-body - %p - %img.modal-profile-crop-image - .modal-footer - %button.btn.btn-primary.js-upload-user-avatar{:type => "button"} - Set new profile picture diff --git a/features/steps/profile/profile.rb b/features/steps/profile/profile.rb index d9436e9e21..bf9534be8b 100644 --- a/features/steps/profile/profile.rb +++ b/features/steps/profile/profile.rb @@ -27,7 +27,9 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end step 'I change my avatar' do - attach_avatar + attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) + click_button "Update profile settings" + @user.reload end step 'I should see new avatar' do @@ -40,7 +42,9 @@ class Spinach::Features::Profile < Spinach::FeatureSteps end step 'I have an avatar' do - attach_avatar + attach_file(:user_avatar, File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif')) + click_button "Update profile settings" + @user.reload end step 'I remove my avatar' do @@ -229,16 +233,4 @@ class Spinach::Features::Profile < Spinach::FeatureSteps step "I see that application is removed" do expect(page.find(".oauth-applications")).not_to have_content "test_changed" end - - def attach_avatar - attach_file :user_avatar, Rails.root.join(*%w(spec fixtures banana_sample.gif)) - - page.find('#user_avatar_crop_x', visible: false).set('0') - page.find('#user_avatar_crop_y', visible: false).set('0') - page.find('#user_avatar_crop_size', visible: false).set('256') - - click_button "Update profile settings" - - @user.reload - end end diff --git a/spec/controllers/namespaces_controller_spec.rb b/spec/controllers/namespaces_controller_spec.rb index d4a380cc2e..7743695871 100644 --- a/spec/controllers/namespaces_controller_spec.rb +++ b/spec/controllers/namespaces_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe NamespacesController do - let!(:user) { create(:user, :with_avatar) } + let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } describe "GET show" do context "when the namespace belongs to a user" do diff --git a/spec/controllers/profiles/avatars_controller_spec.rb b/spec/controllers/profiles/avatars_controller_spec.rb index 85dff009bc..ad5855df0a 100644 --- a/spec/controllers/profiles/avatars_controller_spec.rb +++ b/spec/controllers/profiles/avatars_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe Profiles::AvatarsController do - let(:user) { create(:user, :with_avatar) } + let(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png")) } before do sign_in(user) diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb index 0d9f4b299b..af5d043cf0 100644 --- a/spec/controllers/uploads_controller_spec.rb +++ b/spec/controllers/uploads_controller_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe UploadsController do - let!(:user) { create(:user, :with_avatar) } + let!(:user) { create(:user, avatar: fixture_file_upload(Rails.root + "spec/fixtures/dk.png", "image/png")) } describe "GET show" do context "when viewing a user avatar" do diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 785c2a3d81..a5c60c51c5 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -23,13 +23,6 @@ FactoryGirl.define do end end - trait :with_avatar do - avatar { fixture_file_upload(Rails.root.join(*%w(spec fixtures dk.png)), 'image/png') } - avatar_crop_x 0 - avatar_crop_y 0 - avatar_crop_size 256 - end - factory :omniauth_user do transient do extern_uid '123456' diff --git a/spec/helpers/application_helper_spec.rb b/spec/helpers/application_helper_spec.rb index 8013b31524..f6c1005d26 100644 --- a/spec/helpers/application_helper_spec.rb +++ b/spec/helpers/application_helper_spec.rb @@ -77,7 +77,7 @@ describe ApplicationHelper do let(:avatar_file_path) { File.join(Rails.root, 'spec', 'fixtures', 'banana_sample.gif') } it 'should return an url for the avatar' do - user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) + user = create(:user, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user.email).to_s). to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") @@ -88,7 +88,7 @@ describe ApplicationHelper do # Must be stubbed after the stub above, and separately stub_config_setting(url: Settings.send(:build_gitlab_url)) - user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) + user = create(:user, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user.email).to_s). to match("/gitlab/uploads/user/avatar/#{user.id}/banana_sample.gif") @@ -102,7 +102,7 @@ describe ApplicationHelper do describe 'using a User' do it 'should return an URL for the avatar' do - user = create(:user, :with_avatar, avatar: File.open(avatar_file_path)) + user = create(:user, avatar: File.open(avatar_file_path)) expect(helper.avatar_icon(user).to_s). to match("/uploads/user/avatar/#{user.id}/banana_sample.gif") diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 909b679659..6290ab3ebe 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -174,32 +174,6 @@ describe User, models: true do end end end - - describe 'avatar' do - it 'only validates when avatar is present and changed' do - user = build(:user, :with_avatar) - - user.avatar_crop_x = nil - user.avatar_crop_y = nil - user.avatar_crop_size = nil - - expect(user).not_to be_valid - expect(user.errors.keys). - to match_array %i(avatar_crop_x avatar_crop_y avatar_crop_size) - end - - it 'does not validate when avatar has not changed' do - user = create(:user, :with_avatar) - - expect { user.avatar_crop_x = nil }.not_to change(user, :valid?) - end - - it 'does not validate when avatar is not present' do - user = create(:user) - - expect { user.avatar_crop_y = nil }.not_to change(user, :valid?) - end - end end describe "Respond to" do diff --git a/vendor/assets/javascripts/cropper.js b/vendor/assets/javascripts/cropper.js deleted file mode 100755 index 84aa6119ec..0000000000 --- a/vendor/assets/javascripts/cropper.js +++ /dev/null @@ -1,2972 +0,0 @@ -/*! - * Cropper v2.2.5 - * https://github.com/fengyuanchen/cropper - * - * Copyright (c) 2014-2016 Fengyuan Chen and contributors - * Released under the MIT license - * - * Date: 2016-01-18T05:42:50.800Z - */ - -(function (factory) { - if (typeof define === 'function' && define.amd) { - // AMD. Register as anonymous module. - define(['jquery'], factory); - } else if (typeof exports === 'object') { - // Node / CommonJS - factory(require('jquery')); - } else { - // Browser globals. - factory(jQuery); - } -})(function ($) { - - 'use strict'; - - // Globals - var $window = $(window); - var $document = $(document); - var location = window.location; - var ArrayBuffer = window.ArrayBuffer; - var Uint8Array = window.Uint8Array; - var DataView = window.DataView; - var btoa = window.btoa; - - // Constants - var NAMESPACE = 'cropper'; - - // Classes - var CLASS_MODAL = 'cropper-modal'; - var CLASS_HIDE = 'cropper-hide'; - var CLASS_HIDDEN = 'cropper-hidden'; - var CLASS_INVISIBLE = 'cropper-invisible'; - var CLASS_MOVE = 'cropper-move'; - var CLASS_CROP = 'cropper-crop'; - var CLASS_DISABLED = 'cropper-disabled'; - var CLASS_BG = 'cropper-bg'; - - // Events - var EVENT_MOUSE_DOWN = 'mousedown touchstart pointerdown MSPointerDown'; - var EVENT_MOUSE_MOVE = 'mousemove touchmove pointermove MSPointerMove'; - var EVENT_MOUSE_UP = 'mouseup touchend touchcancel pointerup pointercancel MSPointerUp MSPointerCancel'; - var EVENT_WHEEL = 'wheel mousewheel DOMMouseScroll'; - var EVENT_DBLCLICK = 'dblclick'; - var EVENT_LOAD = 'load.' + NAMESPACE; - var EVENT_ERROR = 'error.' + NAMESPACE; - var EVENT_RESIZE = 'resize.' + NAMESPACE; // Bind to window with namespace - var EVENT_BUILD = 'build.' + NAMESPACE; - var EVENT_BUILT = 'built.' + NAMESPACE; - var EVENT_CROP_START = 'cropstart.' + NAMESPACE; - var EVENT_CROP_MOVE = 'cropmove.' + NAMESPACE; - var EVENT_CROP_END = 'cropend.' + NAMESPACE; - var EVENT_CROP = 'crop.' + NAMESPACE; - var EVENT_ZOOM = 'zoom.' + NAMESPACE; - - // RegExps - var REGEXP_ACTIONS = /e|w|s|n|se|sw|ne|nw|all|crop|move|zoom/; - var REGEXP_DATA_URL = /^data\:/; - var REGEXP_DATA_URL_HEAD = /^data\:([^\;]+)\;base64,/; - var REGEXP_DATA_URL_JPEG = /^data\:image\/jpeg.*;base64,/; - - // Data keys - var DATA_PREVIEW = 'preview'; - var DATA_ACTION = 'action'; - - // Actions - var ACTION_EAST = 'e'; - var ACTION_WEST = 'w'; - var ACTION_SOUTH = 's'; - var ACTION_NORTH = 'n'; - var ACTION_SOUTH_EAST = 'se'; - var ACTION_SOUTH_WEST = 'sw'; - var ACTION_NORTH_EAST = 'ne'; - var ACTION_NORTH_WEST = 'nw'; - var ACTION_ALL = 'all'; - var ACTION_CROP = 'crop'; - var ACTION_MOVE = 'move'; - var ACTION_ZOOM = 'zoom'; - var ACTION_NONE = 'none'; - - // Supports - var SUPPORT_CANVAS = $.isFunction($('')[0].getContext); - - // Maths - var num = Number; - var min = Math.min; - var max = Math.max; - var abs = Math.abs; - var sin = Math.sin; - var cos = Math.cos; - var sqrt = Math.sqrt; - var round = Math.round; - var floor = Math.floor; - - // Utilities - var fromCharCode = String.fromCharCode; - - function isNumber(n) { - return typeof n === 'number' && !isNaN(n); - } - - function isUndefined(n) { - return typeof n === 'undefined'; - } - - function toArray(obj, offset) { - var args = []; - - // This is necessary for IE8 - if (isNumber(offset)) { - args.push(offset); - } - - return args.slice.apply(obj, args); - } - - // Custom proxy to avoid jQuery's guid - function proxy(fn, context) { - var args = toArray(arguments, 2); - - return function () { - return fn.apply(context, args.concat(toArray(arguments))); - }; - } - - function isCrossOriginURL(url) { - var parts = url.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i); - - return parts && ( - parts[1] !== location.protocol || - parts[2] !== location.hostname || - parts[3] !== location.port - ); - } - - function addTimestamp(url) { - var timestamp = 'timestamp=' + (new Date()).getTime(); - - return (url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp); - } - - function getCrossOrigin(crossOrigin) { - return crossOrigin ? ' crossOrigin="' + crossOrigin + '"' : ''; - } - - function getImageSize(image, callback) { - var newImage; - - // Modern browsers - if (image.naturalWidth) { - return callback(image.naturalWidth, image.naturalHeight); - } - - // IE8: Don't use `new Image()` here (#319) - newImage = document.createElement('img'); - - newImage.onload = function () { - callback(this.width, this.height); - }; - - newImage.src = image.src; - } - - function getTransform(options) { - var transforms = []; - var rotate = options.rotate; - var scaleX = options.scaleX; - var scaleY = options.scaleY; - - if (isNumber(rotate)) { - transforms.push('rotate(' + rotate + 'deg)'); - } - - if (isNumber(scaleX) && isNumber(scaleY)) { - transforms.push('scale(' + scaleX + ',' + scaleY + ')'); - } - - return transforms.length ? transforms.join(' ') : 'none'; - } - - function getRotatedSizes(data, isReversed) { - var deg = abs(data.degree) % 180; - var arc = (deg > 90 ? (180 - deg) : deg) * Math.PI / 180; - var sinArc = sin(arc); - var cosArc = cos(arc); - var width = data.width; - var height = data.height; - var aspectRatio = data.aspectRatio; - var newWidth; - var newHeight; - - if (!isReversed) { - newWidth = width * cosArc + height * sinArc; - newHeight = width * sinArc + height * cosArc; - } else { - newWidth = width / (cosArc + sinArc / aspectRatio); - newHeight = newWidth / aspectRatio; - } - - return { - width: newWidth, - height: newHeight - }; - } - - function getSourceCanvas(image, data) { - var canvas = $('')[0]; - var context = canvas.getContext('2d'); - var x = 0; - var y = 0; - var width = data.naturalWidth; - var height = data.naturalHeight; - var rotate = data.rotate; - var scaleX = data.scaleX; - var scaleY = data.scaleY; - var scalable = isNumber(scaleX) && isNumber(scaleY) && (scaleX !== 1 || scaleY !== 1); - var rotatable = isNumber(rotate) && rotate !== 0; - var advanced = rotatable || scalable; - var canvasWidth = width; - var canvasHeight = height; - var translateX; - var translateY; - var rotated; - - if (scalable) { - translateX = width / 2; - translateY = height / 2; - } - - if (rotatable) { - rotated = getRotatedSizes({ - width: width, - height: height, - degree: rotate - }); - - canvasWidth = rotated.width; - canvasHeight = rotated.height; - translateX = rotated.width / 2; - translateY = rotated.height / 2; - } - - canvas.width = canvasWidth; - canvas.height = canvasHeight; - - if (advanced) { - x = -width / 2; - y = -height / 2; - - context.save(); - context.translate(translateX, translateY); - } - - if (rotatable) { - context.rotate(rotate * Math.PI / 180); - } - - // Should call `scale` after rotated - if (scalable) { - context.scale(scaleX, scaleY); - } - - context.drawImage(image, floor(x), floor(y), floor(width), floor(height)); - - if (advanced) { - context.restore(); - } - - return canvas; - } - - function getTouchesCenter(touches) { - var length = touches.length; - var pageX = 0; - var pageY = 0; - - if (length) { - $.each(touches, function (i, touch) { - pageX += touch.pageX; - pageY += touch.pageY; - }); - - pageX /= length; - pageY /= length; - } - - return { - pageX: pageX, - pageY: pageY - }; - } - - function getStringFromCharCode(dataView, start, length) { - var str = ''; - var i; - - for (i = start, length += start; i < length; i++) { - str += fromCharCode(dataView.getUint8(i)); - } - - return str; - } - - function getOrientation(arrayBuffer) { - var dataView = new DataView(arrayBuffer); - var length = dataView.byteLength; - var orientation; - var exifIDCode; - var tiffOffset; - var firstIFDOffset; - var littleEndian; - var endianness; - var app1Start; - var ifdStart; - var offset; - var i; - - // Only handle JPEG image (start by 0xFFD8) - if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) { - offset = 2; - - while (offset < length) { - if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) { - app1Start = offset; - break; - } - - offset++; - } - } - - if (app1Start) { - exifIDCode = app1Start + 4; - tiffOffset = app1Start + 10; - - if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') { - endianness = dataView.getUint16(tiffOffset); - littleEndian = endianness === 0x4949; - - if (littleEndian || endianness === 0x4D4D /* bigEndian */) { - if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) { - firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian); - - if (firstIFDOffset >= 0x00000008) { - ifdStart = tiffOffset + firstIFDOffset; - } - } - } - } - } - - if (ifdStart) { - length = dataView.getUint16(ifdStart, littleEndian); - - for (i = 0; i < length; i++) { - offset = ifdStart + i * 12 + 2; - - if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) { - - // 8 is the offset of the current tag's value - offset += 8; - - // Get the original orientation value - orientation = dataView.getUint16(offset, littleEndian); - - // Override the orientation with the default value: 1 - dataView.setUint16(offset, 1, littleEndian); - break; - } - } - } - - return orientation; - } - - function dataURLToArrayBuffer(dataURL) { - var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, ''); - var binary = atob(base64); - var length = binary.length; - var arrayBuffer = new ArrayBuffer(length); - var dataView = new Uint8Array(arrayBuffer); - var i; - - for (i = 0; i < length; i++) { - dataView[i] = binary.charCodeAt(i); - } - - return arrayBuffer; - } - - // Only available for JPEG image - function arrayBufferToDataURL(arrayBuffer) { - var dataView = new Uint8Array(arrayBuffer); - var length = dataView.length; - var base64 = ''; - var i; - - for (i = 0; i < length; i++) { - base64 += fromCharCode(dataView[i]); - } - - return 'data:image/jpeg;base64,' + btoa(base64); - } - - function Cropper(element, options) { - this.$element = $(element); - this.options = $.extend({}, Cropper.DEFAULTS, $.isPlainObject(options) && options); - this.isLoaded = false; - this.isBuilt = false; - this.isCompleted = false; - this.isRotated = false; - this.isCropped = false; - this.isDisabled = false; - this.isReplaced = false; - this.isLimited = false; - this.wheeling = false; - this.isImg = false; - this.originalUrl = ''; - this.canvas = null; - this.cropBox = null; - this.init(); - } - - Cropper.prototype = { - constructor: Cropper, - - init: function () { - var $this = this.$element; - var url; - - if ($this.is('img')) { - this.isImg = true; - - // Should use `$.fn.attr` here. e.g.: "img/picture.jpg" - this.originalUrl = url = $this.attr('src'); - - // Stop when it's a blank image - if (!url) { - return; - } - - // Should use `$.fn.prop` here. e.g.: "http://example.com/img/picture.jpg" - url = $this.prop('src'); - } else if ($this.is('canvas') && SUPPORT_CANVAS) { - url = $this[0].toDataURL(); - } - - this.load(url); - }, - - // A shortcut for triggering custom events - trigger: function (type, data) { - var e = $.Event(type, data); - - this.$element.trigger(e); - - return e; - }, - - load: function (url) { - var options = this.options; - var $this = this.$element; - var read; - var xhr; - - if (!url) { - return; - } - - // Trigger build event first - $this.one(EVENT_BUILD, options.build); - - if (this.trigger(EVENT_BUILD).isDefaultPrevented()) { - return; - } - - this.url = url; - this.image = {}; - - if (!options.checkOrientation || !ArrayBuffer) { - return this.clone(); - } - - read = $.proxy(this.read, this); - - // XMLHttpRequest disallows to open a Data URL in some browsers like IE11 and Safari - if (REGEXP_DATA_URL.test(url)) { - return REGEXP_DATA_URL_JPEG.test(url) ? - read(dataURLToArrayBuffer(url)) : - this.clone(); - } - - xhr = new XMLHttpRequest(); - - xhr.onerror = xhr.onabort = $.proxy(function () { - this.clone(); - }, this); - - xhr.onload = function () { - read(this.response); - }; - - xhr.open('get', url); - xhr.responseType = 'arraybuffer'; - xhr.send(); - }, - - read: function (arrayBuffer) { - var options = this.options; - var orientation = getOrientation(arrayBuffer); - var image = this.image; - var rotate; - var scaleX; - var scaleY; - - if (orientation > 1) { - this.url = arrayBufferToDataURL(arrayBuffer); - - switch (orientation) { - - // flip horizontal - case 2: - scaleX = -1; - break; - - // rotate left 180° - case 3: - rotate = -180; - break; - - // flip vertical - case 4: - scaleY = -1; - break; - - // flip vertical + rotate right 90° - case 5: - rotate = 90; - scaleY = -1; - break; - - // rotate right 90° - case 6: - rotate = 90; - break; - - // flip horizontal + rotate right 90° - case 7: - rotate = 90; - scaleX = -1; - break; - - // rotate left 90° - case 8: - rotate = -90; - break; - } - } - - if (options.rotatable) { - image.rotate = rotate; - } - - if (options.scalable) { - image.scaleX = scaleX; - image.scaleY = scaleY; - } - - this.clone(); - }, - - clone: function () { - var options = this.options; - var $this = this.$element; - var url = this.url; - var crossOrigin = ''; - var crossOriginUrl; - var $clone; - - if (options.checkCrossOrigin && isCrossOriginURL(url)) { - crossOrigin = $this.prop('crossOrigin'); - - if (crossOrigin) { - crossOriginUrl = url; - } else { - crossOrigin = 'anonymous'; - - // Bust cache (#148) when there is not a "crossOrigin" property - crossOriginUrl = addTimestamp(url); - } - } - - this.crossOrigin = crossOrigin; - this.crossOriginUrl = crossOriginUrl; - this.$clone = $clone = $(''); - - if (this.isImg) { - if ($this[0].complete) { - this.start(); - } else { - $this.one(EVENT_LOAD, $.proxy(this.start, this)); - } - } else { - $clone. - one(EVENT_LOAD, $.proxy(this.start, this)). - one(EVENT_ERROR, $.proxy(this.stop, this)). - addClass(CLASS_HIDE). - insertAfter($this); - } - }, - - start: function () { - var $image = this.$element; - var $clone = this.$clone; - - if (!this.isImg) { - $clone.off(EVENT_ERROR, this.stop); - $image = $clone; - } - - getImageSize($image[0], $.proxy(function (naturalWidth, naturalHeight) { - $.extend(this.image, { - naturalWidth: naturalWidth, - naturalHeight: naturalHeight, - aspectRatio: naturalWidth / naturalHeight - }); - - this.isLoaded = true; - this.build(); - }, this)); - }, - - stop: function () { - this.$clone.remove(); - this.$clone = null; - }, - - build: function () { - var options = this.options; - var $this = this.$element; - var $clone = this.$clone; - var $cropper; - var $cropBox; - var $face; - - if (!this.isLoaded) { - return; - } - - // Unbuild first when replace - if (this.isBuilt) { - this.unbuild(); - } - - // Create cropper elements - this.$container = $this.parent(); - this.$cropper = $cropper = $(Cropper.TEMPLATE); - this.$canvas = $cropper.find('.cropper-canvas').append($clone); - this.$dragBox = $cropper.find('.cropper-drag-box'); - this.$cropBox = $cropBox = $cropper.find('.cropper-crop-box'); - this.$viewBox = $cropper.find('.cropper-view-box'); - this.$face = $face = $cropBox.find('.cropper-face'); - - // Hide the original image - $this.addClass(CLASS_HIDDEN).after($cropper); - - // Show the clone image if is hidden - if (!this.isImg) { - $clone.removeClass(CLASS_HIDE); - } - - this.initPreview(); - this.bind(); - - options.aspectRatio = max(0, options.aspectRatio) || NaN; - options.viewMode = max(0, min(3, round(options.viewMode))) || 0; - - if (options.autoCrop) { - this.isCropped = true; - - if (options.modal) { - this.$dragBox.addClass(CLASS_MODAL); - } - } else { - $cropBox.addClass(CLASS_HIDDEN); - } - - if (!options.guides) { - $cropBox.find('.cropper-dashed').addClass(CLASS_HIDDEN); - } - - if (!options.center) { - $cropBox.find('.cropper-center').addClass(CLASS_HIDDEN); - } - - if (options.cropBoxMovable) { - $face.addClass(CLASS_MOVE).data(DATA_ACTION, ACTION_ALL); - } - - if (!options.highlight) { - $face.addClass(CLASS_INVISIBLE); - } - - if (options.background) { - $cropper.addClass(CLASS_BG); - } - - if (!options.cropBoxResizable) { - $cropBox.find('.cropper-line, .cropper-point').addClass(CLASS_HIDDEN); - } - - this.setDragMode(options.dragMode); - this.render(); - this.isBuilt = true; - this.setData(options.data); - $this.one(EVENT_BUILT, options.built); - - // Trigger the built event asynchronously to keep `data('cropper')` is defined - setTimeout($.proxy(function () { - this.trigger(EVENT_BUILT); - this.isCompleted = true; - }, this), 0); - }, - - unbuild: function () { - if (!this.isBuilt) { - return; - } - - this.isBuilt = false; - this.isCompleted = false; - this.initialImage = null; - - // Clear `initialCanvas` is necessary when replace - this.initialCanvas = null; - this.initialCropBox = null; - this.container = null; - this.canvas = null; - - // Clear `cropBox` is necessary when replace - this.cropBox = null; - this.unbind(); - - this.resetPreview(); - this.$preview = null; - - this.$viewBox = null; - this.$cropBox = null; - this.$dragBox = null; - this.$canvas = null; - this.$container = null; - - this.$cropper.remove(); - this.$cropper = null; - }, - - render: function () { - this.initContainer(); - this.initCanvas(); - this.initCropBox(); - - this.renderCanvas(); - - if (this.isCropped) { - this.renderCropBox(); - } - }, - - initContainer: function () { - var options = this.options; - var $this = this.$element; - var $container = this.$container; - var $cropper = this.$cropper; - - $cropper.addClass(CLASS_HIDDEN); - $this.removeClass(CLASS_HIDDEN); - - $cropper.css((this.container = { - width: max($container.width(), num(options.minContainerWidth) || 200), - height: max($container.height(), num(options.minContainerHeight) || 100) - })); - - $this.addClass(CLASS_HIDDEN); - $cropper.removeClass(CLASS_HIDDEN); - }, - - // Canvas (image wrapper) - initCanvas: function () { - var viewMode = this.options.viewMode; - var container = this.container; - var containerWidth = container.width; - var containerHeight = container.height; - var image = this.image; - var imageNaturalWidth = image.naturalWidth; - var imageNaturalHeight = image.naturalHeight; - var is90Degree = abs(image.rotate) === 90; - var naturalWidth = is90Degree ? imageNaturalHeight : imageNaturalWidth; - var naturalHeight = is90Degree ? imageNaturalWidth : imageNaturalHeight; - var aspectRatio = naturalWidth / naturalHeight; - var canvasWidth = containerWidth; - var canvasHeight = containerHeight; - var canvas; - - if (containerHeight * aspectRatio > containerWidth) { - if (viewMode === 3) { - canvasWidth = containerHeight * aspectRatio; - } else { - canvasHeight = containerWidth / aspectRatio; - } - } else { - if (viewMode === 3) { - canvasHeight = containerWidth / aspectRatio; - } else { - canvasWidth = containerHeight * aspectRatio; - } - } - - canvas = { - naturalWidth: naturalWidth, - naturalHeight: naturalHeight, - aspectRatio: aspectRatio, - width: canvasWidth, - height: canvasHeight - }; - - canvas.oldLeft = canvas.left = (containerWidth - canvasWidth) / 2; - canvas.oldTop = canvas.top = (containerHeight - canvasHeight) / 2; - - this.canvas = canvas; - this.isLimited = (viewMode === 1 || viewMode === 2); - this.limitCanvas(true, true); - this.initialImage = $.extend({}, image); - this.initialCanvas = $.extend({}, canvas); - }, - - limitCanvas: function (isSizeLimited, isPositionLimited) { - var options = this.options; - var viewMode = options.viewMode; - var container = this.container; - var containerWidth = container.width; - var containerHeight = container.height; - var canvas = this.canvas; - var aspectRatio = canvas.aspectRatio; - var cropBox = this.cropBox; - var isCropped = this.isCropped && cropBox; - var minCanvasWidth; - var minCanvasHeight; - var newCanvasLeft; - var newCanvasTop; - - if (isSizeLimited) { - minCanvasWidth = num(options.minCanvasWidth) || 0; - minCanvasHeight = num(options.minCanvasHeight) || 0; - - if (viewMode) { - if (viewMode > 1) { - minCanvasWidth = max(minCanvasWidth, containerWidth); - minCanvasHeight = max(minCanvasHeight, containerHeight); - - if (viewMode === 3) { - if (minCanvasHeight * aspectRatio > minCanvasWidth) { - minCanvasWidth = minCanvasHeight * aspectRatio; - } else { - minCanvasHeight = minCanvasWidth / aspectRatio; - } - } - } else { - if (minCanvasWidth) { - minCanvasWidth = max(minCanvasWidth, isCropped ? cropBox.width : 0); - } else if (minCanvasHeight) { - minCanvasHeight = max(minCanvasHeight, isCropped ? cropBox.height : 0); - } else if (isCropped) { - minCanvasWidth = cropBox.width; - minCanvasHeight = cropBox.height; - - if (minCanvasHeight * aspectRatio > minCanvasWidth) { - minCanvasWidth = minCanvasHeight * aspectRatio; - } else { - minCanvasHeight = minCanvasWidth / aspectRatio; - } - } - } - } - - if (minCanvasWidth && minCanvasHeight) { - if (minCanvasHeight * aspectRatio > minCanvasWidth) { - minCanvasHeight = minCanvasWidth / aspectRatio; - } else { - minCanvasWidth = minCanvasHeight * aspectRatio; - } - } else if (minCanvasWidth) { - minCanvasHeight = minCanvasWidth / aspectRatio; - } else if (minCanvasHeight) { - minCanvasWidth = minCanvasHeight * aspectRatio; - } - - canvas.minWidth = minCanvasWidth; - canvas.minHeight = minCanvasHeight; - canvas.maxWidth = Infinity; - canvas.maxHeight = Infinity; - } - - if (isPositionLimited) { - if (viewMode) { - newCanvasLeft = containerWidth - canvas.width; - newCanvasTop = containerHeight - canvas.height; - - canvas.minLeft = min(0, newCanvasLeft); - canvas.minTop = min(0, newCanvasTop); - canvas.maxLeft = max(0, newCanvasLeft); - canvas.maxTop = max(0, newCanvasTop); - - if (isCropped && this.isLimited) { - canvas.minLeft = min( - cropBox.left, - cropBox.left + cropBox.width - canvas.width - ); - canvas.minTop = min( - cropBox.top, - cropBox.top + cropBox.height - canvas.height - ); - canvas.maxLeft = cropBox.left; - canvas.maxTop = cropBox.top; - - if (viewMode === 2) { - if (canvas.width >= containerWidth) { - canvas.minLeft = min(0, newCanvasLeft); - canvas.maxLeft = max(0, newCanvasLeft); - } - - if (canvas.height >= containerHeight) { - canvas.minTop = min(0, newCanvasTop); - canvas.maxTop = max(0, newCanvasTop); - } - } - } - } else { - canvas.minLeft = -canvas.width; - canvas.minTop = -canvas.height; - canvas.maxLeft = containerWidth; - canvas.maxTop = containerHeight; - } - } - }, - - renderCanvas: function (isChanged) { - var canvas = this.canvas; - var image = this.image; - var rotate = image.rotate; - var naturalWidth = image.naturalWidth; - var naturalHeight = image.naturalHeight; - var aspectRatio; - var rotated; - - if (this.isRotated) { - this.isRotated = false; - - // Computes rotated sizes with image sizes - rotated = getRotatedSizes({ - width: image.width, - height: image.height, - degree: rotate - }); - - aspectRatio = rotated.width / rotated.height; - - if (aspectRatio !== canvas.aspectRatio) { - canvas.left -= (rotated.width - canvas.width) / 2; - canvas.top -= (rotated.height - canvas.height) / 2; - canvas.width = rotated.width; - canvas.height = rotated.height; - canvas.aspectRatio = aspectRatio; - canvas.naturalWidth = naturalWidth; - canvas.naturalHeight = naturalHeight; - - // Computes rotated sizes with natural image sizes - if (rotate % 180) { - rotated = getRotatedSizes({ - width: naturalWidth, - height: naturalHeight, - degree: rotate - }); - - canvas.naturalWidth = rotated.width; - canvas.naturalHeight = rotated.height; - } - - this.limitCanvas(true, false); - } - } - - if (canvas.width > canvas.maxWidth || canvas.width < canvas.minWidth) { - canvas.left = canvas.oldLeft; - } - - if (canvas.height > canvas.maxHeight || canvas.height < canvas.minHeight) { - canvas.top = canvas.oldTop; - } - - canvas.width = min(max(canvas.width, canvas.minWidth), canvas.maxWidth); - canvas.height = min(max(canvas.height, canvas.minHeight), canvas.maxHeight); - - this.limitCanvas(false, true); - - canvas.oldLeft = canvas.left = min(max(canvas.left, canvas.minLeft), canvas.maxLeft); - canvas.oldTop = canvas.top = min(max(canvas.top, canvas.minTop), canvas.maxTop); - - this.$canvas.css({ - width: canvas.width, - height: canvas.height, - left: canvas.left, - top: canvas.top - }); - - this.renderImage(); - - if (this.isCropped && this.isLimited) { - this.limitCropBox(true, true); - } - - if (isChanged) { - this.output(); - } - }, - - renderImage: function (isChanged) { - var canvas = this.canvas; - var image = this.image; - var reversed; - - if (image.rotate) { - reversed = getRotatedSizes({ - width: canvas.width, - height: canvas.height, - degree: image.rotate, - aspectRatio: image.aspectRatio - }, true); - } - - $.extend(image, reversed ? { - width: reversed.width, - height: reversed.height, - left: (canvas.width - reversed.width) / 2, - top: (canvas.height - reversed.height) / 2 - } : { - width: canvas.width, - height: canvas.height, - left: 0, - top: 0 - }); - - this.$clone.css({ - width: image.width, - height: image.height, - marginLeft: image.left, - marginTop: image.top, - transform: getTransform(image) - }); - - if (isChanged) { - this.output(); - } - }, - - initCropBox: function () { - var options = this.options; - var canvas = this.canvas; - var aspectRatio = options.aspectRatio; - var autoCropArea = num(options.autoCropArea) || 0.8; - var cropBox = { - width: canvas.width, - height: canvas.height - }; - - if (aspectRatio) { - if (canvas.height * aspectRatio > canvas.width) { - cropBox.height = cropBox.width / aspectRatio; - } else { - cropBox.width = cropBox.height * aspectRatio; - } - } - - this.cropBox = cropBox; - this.limitCropBox(true, true); - - // Initialize auto crop area - cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); - cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); - - // The width of auto crop area must large than "minWidth", and the height too. (#164) - cropBox.width = max(cropBox.minWidth, cropBox.width * autoCropArea); - cropBox.height = max(cropBox.minHeight, cropBox.height * autoCropArea); - cropBox.oldLeft = cropBox.left = canvas.left + (canvas.width - cropBox.width) / 2; - cropBox.oldTop = cropBox.top = canvas.top + (canvas.height - cropBox.height) / 2; - - this.initialCropBox = $.extend({}, cropBox); - }, - - limitCropBox: function (isSizeLimited, isPositionLimited) { - var options = this.options; - var aspectRatio = options.aspectRatio; - var container = this.container; - var containerWidth = container.width; - var containerHeight = container.height; - var canvas = this.canvas; - var cropBox = this.cropBox; - var isLimited = this.isLimited; - var minCropBoxWidth; - var minCropBoxHeight; - var maxCropBoxWidth; - var maxCropBoxHeight; - - if (isSizeLimited) { - minCropBoxWidth = num(options.minCropBoxWidth) || 0; - minCropBoxHeight = num(options.minCropBoxHeight) || 0; - - // The min/maxCropBoxWidth/Height must be less than containerWidth/Height - minCropBoxWidth = min(minCropBoxWidth, containerWidth); - minCropBoxHeight = min(minCropBoxHeight, containerHeight); - maxCropBoxWidth = min(containerWidth, isLimited ? canvas.width : containerWidth); - maxCropBoxHeight = min(containerHeight, isLimited ? canvas.height : containerHeight); - - if (aspectRatio) { - if (minCropBoxWidth && minCropBoxHeight) { - if (minCropBoxHeight * aspectRatio > minCropBoxWidth) { - minCropBoxHeight = minCropBoxWidth / aspectRatio; - } else { - minCropBoxWidth = minCropBoxHeight * aspectRatio; - } - } else if (minCropBoxWidth) { - minCropBoxHeight = minCropBoxWidth / aspectRatio; - } else if (minCropBoxHeight) { - minCropBoxWidth = minCropBoxHeight * aspectRatio; - } - - if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) { - maxCropBoxHeight = maxCropBoxWidth / aspectRatio; - } else { - maxCropBoxWidth = maxCropBoxHeight * aspectRatio; - } - } - - // The minWidth/Height must be less than maxWidth/Height - cropBox.minWidth = min(minCropBoxWidth, maxCropBoxWidth); - cropBox.minHeight = min(minCropBoxHeight, maxCropBoxHeight); - cropBox.maxWidth = maxCropBoxWidth; - cropBox.maxHeight = maxCropBoxHeight; - } - - if (isPositionLimited) { - if (isLimited) { - cropBox.minLeft = max(0, canvas.left); - cropBox.minTop = max(0, canvas.top); - cropBox.maxLeft = min(containerWidth, canvas.left + canvas.width) - cropBox.width; - cropBox.maxTop = min(containerHeight, canvas.top + canvas.height) - cropBox.height; - } else { - cropBox.minLeft = 0; - cropBox.minTop = 0; - cropBox.maxLeft = containerWidth - cropBox.width; - cropBox.maxTop = containerHeight - cropBox.height; - } - } - }, - - renderCropBox: function () { - var options = this.options; - var container = this.container; - var containerWidth = container.width; - var containerHeight = container.height; - var cropBox = this.cropBox; - - if (cropBox.width > cropBox.maxWidth || cropBox.width < cropBox.minWidth) { - cropBox.left = cropBox.oldLeft; - } - - if (cropBox.height > cropBox.maxHeight || cropBox.height < cropBox.minHeight) { - cropBox.top = cropBox.oldTop; - } - - cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); - cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); - - this.limitCropBox(false, true); - - cropBox.oldLeft = cropBox.left = min(max(cropBox.left, cropBox.minLeft), cropBox.maxLeft); - cropBox.oldTop = cropBox.top = min(max(cropBox.top, cropBox.minTop), cropBox.maxTop); - - if (options.movable && options.cropBoxMovable) { - - // Turn to move the canvas when the crop box is equal to the container - this.$face.data(DATA_ACTION, (cropBox.width === containerWidth && cropBox.height === containerHeight) ? ACTION_MOVE : ACTION_ALL); - } - - this.$cropBox.css({ - width: cropBox.width, - height: cropBox.height, - left: cropBox.left, - top: cropBox.top - }); - - if (this.isCropped && this.isLimited) { - this.limitCanvas(true, true); - } - - if (!this.isDisabled) { - this.output(); - } - }, - - output: function () { - this.preview(); - - if (this.isCompleted) { - this.trigger(EVENT_CROP, this.getData()); - } else if (!this.isBuilt) { - - // Only trigger one crop event before complete - this.$element.one(EVENT_BUILT, $.proxy(function () { - this.trigger(EVENT_CROP, this.getData()); - }, this)); - } - }, - - initPreview: function () { - var crossOrigin = getCrossOrigin(this.crossOrigin); - var url = crossOrigin ? this.crossOriginUrl : this.url; - - this.$preview = $(this.options.preview); - this.$viewBox.html(''); - this.$preview.each(function () { - var $this = $(this); - - // Save the original size for recover - $this.data(DATA_PREVIEW, { - width: $this.width(), - height: $this.height(), - html: $this.html() - }); - - /** - * Override img element styles - * Add `display:block` to avoid margin top issue - * (Occur only when margin-top <= -height) - */ - $this.html( - '' - ); - }); - }, - - resetPreview: function () { - this.$preview.each(function () { - var $this = $(this); - var data = $this.data(DATA_PREVIEW); - - $this.css({ - width: data.width, - height: data.height - }).html(data.html).removeData(DATA_PREVIEW); - }); - }, - - preview: function () { - var image = this.image; - var canvas = this.canvas; - var cropBox = this.cropBox; - var cropBoxWidth = cropBox.width; - var cropBoxHeight = cropBox.height; - var width = image.width; - var height = image.height; - var left = cropBox.left - canvas.left - image.left; - var top = cropBox.top - canvas.top - image.top; - - if (!this.isCropped || this.isDisabled) { - return; - } - - this.$viewBox.find('img').css({ - width: width, - height: height, - marginLeft: -left, - marginTop: -top, - transform: getTransform(image) - }); - - this.$preview.each(function () { - var $this = $(this); - var data = $this.data(DATA_PREVIEW); - var originalWidth = data.width; - var originalHeight = data.height; - var newWidth = originalWidth; - var newHeight = originalHeight; - var ratio = 1; - - if (cropBoxWidth) { - ratio = originalWidth / cropBoxWidth; - newHeight = cropBoxHeight * ratio; - } - - if (cropBoxHeight && newHeight > originalHeight) { - ratio = originalHeight / cropBoxHeight; - newWidth = cropBoxWidth * ratio; - newHeight = originalHeight; - } - - $this.css({ - width: newWidth, - height: newHeight - }).find('img').css({ - width: width * ratio, - height: height * ratio, - marginLeft: -left * ratio, - marginTop: -top * ratio, - transform: getTransform(image) - }); - }); - }, - - bind: function () { - var options = this.options; - var $this = this.$element; - var $cropper = this.$cropper; - - if ($.isFunction(options.cropstart)) { - $this.on(EVENT_CROP_START, options.cropstart); - } - - if ($.isFunction(options.cropmove)) { - $this.on(EVENT_CROP_MOVE, options.cropmove); - } - - if ($.isFunction(options.cropend)) { - $this.on(EVENT_CROP_END, options.cropend); - } - - if ($.isFunction(options.crop)) { - $this.on(EVENT_CROP, options.crop); - } - - if ($.isFunction(options.zoom)) { - $this.on(EVENT_ZOOM, options.zoom); - } - - $cropper.on(EVENT_MOUSE_DOWN, $.proxy(this.cropStart, this)); - - if (options.zoomable && options.zoomOnWheel) { - $cropper.on(EVENT_WHEEL, $.proxy(this.wheel, this)); - } - - if (options.toggleDragModeOnDblclick) { - $cropper.on(EVENT_DBLCLICK, $.proxy(this.dblclick, this)); - } - - $document. - on(EVENT_MOUSE_MOVE, (this._cropMove = proxy(this.cropMove, this))). - on(EVENT_MOUSE_UP, (this._cropEnd = proxy(this.cropEnd, this))); - - if (options.responsive) { - $window.on(EVENT_RESIZE, (this._resize = proxy(this.resize, this))); - } - }, - - unbind: function () { - var options = this.options; - var $this = this.$element; - var $cropper = this.$cropper; - - if ($.isFunction(options.cropstart)) { - $this.off(EVENT_CROP_START, options.cropstart); - } - - if ($.isFunction(options.cropmove)) { - $this.off(EVENT_CROP_MOVE, options.cropmove); - } - - if ($.isFunction(options.cropend)) { - $this.off(EVENT_CROP_END, options.cropend); - } - - if ($.isFunction(options.crop)) { - $this.off(EVENT_CROP, options.crop); - } - - if ($.isFunction(options.zoom)) { - $this.off(EVENT_ZOOM, options.zoom); - } - - $cropper.off(EVENT_MOUSE_DOWN, this.cropStart); - - if (options.zoomable && options.zoomOnWheel) { - $cropper.off(EVENT_WHEEL, this.wheel); - } - - if (options.toggleDragModeOnDblclick) { - $cropper.off(EVENT_DBLCLICK, this.dblclick); - } - - $document. - off(EVENT_MOUSE_MOVE, this._cropMove). - off(EVENT_MOUSE_UP, this._cropEnd); - - if (options.responsive) { - $window.off(EVENT_RESIZE, this._resize); - } - }, - - resize: function () { - var restore = this.options.restore; - var $container = this.$container; - var container = this.container; - var canvasData; - var cropBoxData; - var ratio; - - // Check `container` is necessary for IE8 - if (this.isDisabled || !container) { - return; - } - - ratio = $container.width() / container.width; - - // Resize when width changed or height changed - if (ratio !== 1 || $container.height() !== container.height) { - if (restore) { - canvasData = this.getCanvasData(); - cropBoxData = this.getCropBoxData(); - } - - this.render(); - - if (restore) { - this.setCanvasData($.each(canvasData, function (i, n) { - canvasData[i] = n * ratio; - })); - this.setCropBoxData($.each(cropBoxData, function (i, n) { - cropBoxData[i] = n * ratio; - })); - } - } - }, - - dblclick: function () { - if (this.isDisabled) { - return; - } - - if (this.$dragBox.hasClass(CLASS_CROP)) { - this.setDragMode(ACTION_MOVE); - } else { - this.setDragMode(ACTION_CROP); - } - }, - - wheel: function (event) { - var e = event.originalEvent || event; - var ratio = num(this.options.wheelZoomRatio) || 0.1; - var delta = 1; - - if (this.isDisabled) { - return; - } - - event.preventDefault(); - - // Limit wheel speed to prevent zoom too fast - if (this.wheeling) { - return; - } - - this.wheeling = true; - - setTimeout($.proxy(function () { - this.wheeling = false; - }, this), 50); - - if (e.deltaY) { - delta = e.deltaY > 0 ? 1 : -1; - } else if (e.wheelDelta) { - delta = -e.wheelDelta / 120; - } else if (e.detail) { - delta = e.detail > 0 ? 1 : -1; - } - - this.zoom(-delta * ratio, event); - }, - - cropStart: function (event) { - var options = this.options; - var originalEvent = event.originalEvent; - var touches = originalEvent && originalEvent.touches; - var e = event; - var touchesLength; - var action; - - if (this.isDisabled) { - return; - } - - if (touches) { - touchesLength = touches.length; - - if (touchesLength > 1) { - if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { - e = touches[1]; - this.startX2 = e.pageX; - this.startY2 = e.pageY; - action = ACTION_ZOOM; - } else { - return; - } - } - - e = touches[0]; - } - - action = action || $(e.target).data(DATA_ACTION); - - if (REGEXP_ACTIONS.test(action)) { - if (this.trigger(EVENT_CROP_START, { - originalEvent: originalEvent, - action: action - }).isDefaultPrevented()) { - return; - } - - event.preventDefault(); - - this.action = action; - this.cropping = false; - - // IE8 has `event.pageX/Y`, but not `event.originalEvent.pageX/Y` - // IE10 has `event.originalEvent.pageX/Y`, but not `event.pageX/Y` - this.startX = e.pageX || originalEvent && originalEvent.pageX; - this.startY = e.pageY || originalEvent && originalEvent.pageY; - - if (action === ACTION_CROP) { - this.cropping = true; - this.$dragBox.addClass(CLASS_MODAL); - } - } - }, - - cropMove: function (event) { - var options = this.options; - var originalEvent = event.originalEvent; - var touches = originalEvent && originalEvent.touches; - var e = event; - var action = this.action; - var touchesLength; - - if (this.isDisabled) { - return; - } - - if (touches) { - touchesLength = touches.length; - - if (touchesLength > 1) { - if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { - e = touches[1]; - this.endX2 = e.pageX; - this.endY2 = e.pageY; - } else { - return; - } - } - - e = touches[0]; - } - - if (action) { - if (this.trigger(EVENT_CROP_MOVE, { - originalEvent: originalEvent, - action: action - }).isDefaultPrevented()) { - return; - } - - event.preventDefault(); - - this.endX = e.pageX || originalEvent && originalEvent.pageX; - this.endY = e.pageY || originalEvent && originalEvent.pageY; - - this.change(e.shiftKey, action === ACTION_ZOOM ? event : null); - } - }, - - cropEnd: function (event) { - var originalEvent = event.originalEvent; - var action = this.action; - - if (this.isDisabled) { - return; - } - - if (action) { - event.preventDefault(); - - if (this.cropping) { - this.cropping = false; - this.$dragBox.toggleClass(CLASS_MODAL, this.isCropped && this.options.modal); - } - - this.action = ''; - - this.trigger(EVENT_CROP_END, { - originalEvent: originalEvent, - action: action - }); - } - }, - - change: function (shiftKey, event) { - var options = this.options; - var aspectRatio = options.aspectRatio; - var action = this.action; - var container = this.container; - var canvas = this.canvas; - var cropBox = this.cropBox; - var width = cropBox.width; - var height = cropBox.height; - var left = cropBox.left; - var top = cropBox.top; - var right = left + width; - var bottom = top + height; - var minLeft = 0; - var minTop = 0; - var maxWidth = container.width; - var maxHeight = container.height; - var renderable = true; - var offset; - var range; - - // Locking aspect ratio in "free mode" by holding shift key (#259) - if (!aspectRatio && shiftKey) { - aspectRatio = width && height ? width / height : 1; - } - - if (this.limited) { - minLeft = cropBox.minLeft; - minTop = cropBox.minTop; - maxWidth = minLeft + min(container.width, canvas.width); - maxHeight = minTop + min(container.height, canvas.height); - } - - range = { - x: this.endX - this.startX, - y: this.endY - this.startY - }; - - if (aspectRatio) { - range.X = range.y * aspectRatio; - range.Y = range.x / aspectRatio; - } - - switch (action) { - // Move crop box - case ACTION_ALL: - left += range.x; - top += range.y; - break; - - // Resize crop box - case ACTION_EAST: - if (range.x >= 0 && (right >= maxWidth || aspectRatio && - (top <= minTop || bottom >= maxHeight))) { - - renderable = false; - break; - } - - width += range.x; - - if (aspectRatio) { - height = width / aspectRatio; - top -= range.Y / 2; - } - - if (width < 0) { - action = ACTION_WEST; - width = 0; - } - - break; - - case ACTION_NORTH: - if (range.y <= 0 && (top <= minTop || aspectRatio && - (left <= minLeft || right >= maxWidth))) { - - renderable = false; - break; - } - - height -= range.y; - top += range.y; - - if (aspectRatio) { - width = height * aspectRatio; - left += range.X / 2; - } - - if (height < 0) { - action = ACTION_SOUTH; - height = 0; - } - - break; - - case ACTION_WEST: - if (range.x <= 0 && (left <= minLeft || aspectRatio && - (top <= minTop || bottom >= maxHeight))) { - - renderable = false; - break; - } - - width -= range.x; - left += range.x; - - if (aspectRatio) { - height = width / aspectRatio; - top += range.Y / 2; - } - - if (width < 0) { - action = ACTION_EAST; - width = 0; - } - - break; - - case ACTION_SOUTH: - if (range.y >= 0 && (bottom >= maxHeight || aspectRatio && - (left <= minLeft || right >= maxWidth))) { - - renderable = false; - break; - } - - height += range.y; - - if (aspectRatio) { - width = height * aspectRatio; - left -= range.X / 2; - } - - if (height < 0) { - action = ACTION_NORTH; - height = 0; - } - - break; - - case ACTION_NORTH_EAST: - if (aspectRatio) { - if (range.y <= 0 && (top <= minTop || right >= maxWidth)) { - renderable = false; - break; - } - - height -= range.y; - top += range.y; - width = height * aspectRatio; - } else { - if (range.x >= 0) { - if (right < maxWidth) { - width += range.x; - } else if (range.y <= 0 && top <= minTop) { - renderable = false; - } - } else { - width += range.x; - } - - if (range.y <= 0) { - if (top > minTop) { - height -= range.y; - top += range.y; - } - } else { - height -= range.y; - top += range.y; - } - } - - if (width < 0 && height < 0) { - action = ACTION_SOUTH_WEST; - height = 0; - width = 0; - } else if (width < 0) { - action = ACTION_NORTH_WEST; - width = 0; - } else if (height < 0) { - action = ACTION_SOUTH_EAST; - height = 0; - } - - break; - - case ACTION_NORTH_WEST: - if (aspectRatio) { - if (range.y <= 0 && (top <= minTop || left <= minLeft)) { - renderable = false; - break; - } - - height -= range.y; - top += range.y; - width = height * aspectRatio; - left += range.X; - } else { - if (range.x <= 0) { - if (left > minLeft) { - width -= range.x; - left += range.x; - } else if (range.y <= 0 && top <= minTop) { - renderable = false; - } - } else { - width -= range.x; - left += range.x; - } - - if (range.y <= 0) { - if (top > minTop) { - height -= range.y; - top += range.y; - } - } else { - height -= range.y; - top += range.y; - } - } - - if (width < 0 && height < 0) { - action = ACTION_SOUTH_EAST; - height = 0; - width = 0; - } else if (width < 0) { - action = ACTION_NORTH_EAST; - width = 0; - } else if (height < 0) { - action = ACTION_SOUTH_WEST; - height = 0; - } - - break; - - case ACTION_SOUTH_WEST: - if (aspectRatio) { - if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) { - renderable = false; - break; - } - - width -= range.x; - left += range.x; - height = width / aspectRatio; - } else { - if (range.x <= 0) { - if (left > minLeft) { - width -= range.x; - left += range.x; - } else if (range.y >= 0 && bottom >= maxHeight) { - renderable = false; - } - } else { - width -= range.x; - left += range.x; - } - - if (range.y >= 0) { - if (bottom < maxHeight) { - height += range.y; - } - } else { - height += range.y; - } - } - - if (width < 0 && height < 0) { - action = ACTION_NORTH_EAST; - height = 0; - width = 0; - } else if (width < 0) { - action = ACTION_SOUTH_EAST; - width = 0; - } else if (height < 0) { - action = ACTION_NORTH_WEST; - height = 0; - } - - break; - - case ACTION_SOUTH_EAST: - if (aspectRatio) { - if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) { - renderable = false; - break; - } - - width += range.x; - height = width / aspectRatio; - } else { - if (range.x >= 0) { - if (right < maxWidth) { - width += range.x; - } else if (range.y >= 0 && bottom >= maxHeight) { - renderable = false; - } - } else { - width += range.x; - } - - if (range.y >= 0) { - if (bottom < maxHeight) { - height += range.y; - } - } else { - height += range.y; - } - } - - if (width < 0 && height < 0) { - action = ACTION_NORTH_WEST; - height = 0; - width = 0; - } else if (width < 0) { - action = ACTION_SOUTH_WEST; - width = 0; - } else if (height < 0) { - action = ACTION_NORTH_EAST; - height = 0; - } - - break; - - // Move canvas - case ACTION_MOVE: - this.move(range.x, range.y); - renderable = false; - break; - - // Zoom canvas - case ACTION_ZOOM: - this.zoom((function (x1, y1, x2, y2) { - var z1 = sqrt(x1 * x1 + y1 * y1); - var z2 = sqrt(x2 * x2 + y2 * y2); - - return (z2 - z1) / z1; - })( - abs(this.startX - this.startX2), - abs(this.startY - this.startY2), - abs(this.endX - this.endX2), - abs(this.endY - this.endY2) - ), event); - this.startX2 = this.endX2; - this.startY2 = this.endY2; - renderable = false; - break; - - // Create crop box - case ACTION_CROP: - if (!range.x || !range.y) { - renderable = false; - break; - } - - offset = this.$cropper.offset(); - left = this.startX - offset.left; - top = this.startY - offset.top; - width = cropBox.minWidth; - height = cropBox.minHeight; - - if (range.x > 0) { - action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST; - } else if (range.x < 0) { - left -= width; - action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST; - } - - if (range.y < 0) { - top -= height; - } - - // Show the crop box if is hidden - if (!this.isCropped) { - this.$cropBox.removeClass(CLASS_HIDDEN); - this.isCropped = true; - - if (this.limited) { - this.limitCropBox(true, true); - } - } - - break; - - // No default - } - - if (renderable) { - cropBox.width = width; - cropBox.height = height; - cropBox.left = left; - cropBox.top = top; - this.action = action; - - this.renderCropBox(); - } - - // Override - this.startX = this.endX; - this.startY = this.endY; - }, - - // Show the crop box manually - crop: function () { - if (!this.isBuilt || this.isDisabled) { - return; - } - - if (!this.isCropped) { - this.isCropped = true; - this.limitCropBox(true, true); - - if (this.options.modal) { - this.$dragBox.addClass(CLASS_MODAL); - } - - this.$cropBox.removeClass(CLASS_HIDDEN); - } - - this.setCropBoxData(this.initialCropBox); - }, - - // Reset the image and crop box to their initial states - reset: function () { - if (!this.isBuilt || this.isDisabled) { - return; - } - - this.image = $.extend({}, this.initialImage); - this.canvas = $.extend({}, this.initialCanvas); - this.cropBox = $.extend({}, this.initialCropBox); - - this.renderCanvas(); - - if (this.isCropped) { - this.renderCropBox(); - } - }, - - // Clear the crop box - clear: function () { - if (!this.isCropped || this.isDisabled) { - return; - } - - $.extend(this.cropBox, { - left: 0, - top: 0, - width: 0, - height: 0 - }); - - this.isCropped = false; - this.renderCropBox(); - - this.limitCanvas(true, true); - - // Render canvas after crop box rendered - this.renderCanvas(); - - this.$dragBox.removeClass(CLASS_MODAL); - this.$cropBox.addClass(CLASS_HIDDEN); - }, - - /** - * Replace the image's src and rebuild the cropper - * - * @param {String} url - */ - replace: function (url) { - if (!this.isDisabled && url) { - if (this.isImg) { - this.isReplaced = true; - this.$element.attr('src', url); - } - - // Clear previous data - this.options.data = null; - this.load(url); - } - }, - - // Enable (unfreeze) the cropper - enable: function () { - if (this.isBuilt) { - this.isDisabled = false; - this.$cropper.removeClass(CLASS_DISABLED); - } - }, - - // Disable (freeze) the cropper - disable: function () { - if (this.isBuilt) { - this.isDisabled = true; - this.$cropper.addClass(CLASS_DISABLED); - } - }, - - // Destroy the cropper and remove the instance from the image - destroy: function () { - var $this = this.$element; - - if (this.isLoaded) { - if (this.isImg && this.isReplaced) { - $this.attr('src', this.originalUrl); - } - - this.unbuild(); - $this.removeClass(CLASS_HIDDEN); - } else { - if (this.isImg) { - $this.off(EVENT_LOAD, this.start); - } else if (this.$clone) { - this.$clone.remove(); - } - } - - $this.removeData(NAMESPACE); - }, - - /** - * Move the canvas with relative offsets - * - * @param {Number} offsetX - * @param {Number} offsetY (optional) - */ - move: function (offsetX, offsetY) { - var canvas = this.canvas; - - this.moveTo( - isUndefined(offsetX) ? offsetX : canvas.left + num(offsetX), - isUndefined(offsetY) ? offsetY : canvas.top + num(offsetY) - ); - }, - - /** - * Move the canvas to an absolute point - * - * @param {Number} x - * @param {Number} y (optional) - */ - moveTo: function (x, y) { - var canvas = this.canvas; - var isChanged = false; - - // If "y" is not present, its default value is "x" - if (isUndefined(y)) { - y = x; - } - - x = num(x); - y = num(y); - - if (this.isBuilt && !this.isDisabled && this.options.movable) { - if (isNumber(x)) { - canvas.left = x; - isChanged = true; - } - - if (isNumber(y)) { - canvas.top = y; - isChanged = true; - } - - if (isChanged) { - this.renderCanvas(true); - } - } - }, - - /** - * Zoom the canvas with a relative ratio - * - * @param {Number} ratio - * @param {jQuery Event} _event (private) - */ - zoom: function (ratio, _event) { - var canvas = this.canvas; - - ratio = num(ratio); - - if (ratio < 0) { - ratio = 1 / (1 - ratio); - } else { - ratio = 1 + ratio; - } - - this.zoomTo(canvas.width * ratio / canvas.naturalWidth, _event); - }, - - /** - * Zoom the canvas to an absolute ratio - * - * @param {Number} ratio - * @param {jQuery Event} _event (private) - */ - zoomTo: function (ratio, _event) { - var options = this.options; - var canvas = this.canvas; - var width = canvas.width; - var height = canvas.height; - var naturalWidth = canvas.naturalWidth; - var naturalHeight = canvas.naturalHeight; - var originalEvent; - var newWidth; - var newHeight; - var offset; - var center; - - ratio = num(ratio); - - if (ratio >= 0 && this.isBuilt && !this.isDisabled && options.zoomable) { - newWidth = naturalWidth * ratio; - newHeight = naturalHeight * ratio; - - if (_event) { - originalEvent = _event.originalEvent; - } - - if (this.trigger(EVENT_ZOOM, { - originalEvent: originalEvent, - oldRatio: width / naturalWidth, - ratio: newWidth / naturalWidth - }).isDefaultPrevented()) { - return; - } - - if (originalEvent) { - offset = this.$cropper.offset(); - center = originalEvent.touches ? getTouchesCenter(originalEvent.touches) : { - pageX: _event.pageX || originalEvent.pageX || 0, - pageY: _event.pageY || originalEvent.pageY || 0 - }; - - // Zoom from the triggering point of the event - canvas.left -= (newWidth - width) * ( - ((center.pageX - offset.left) - canvas.left) / width - ); - canvas.top -= (newHeight - height) * ( - ((center.pageY - offset.top) - canvas.top) / height - ); - } else { - - // Zoom from the center of the canvas - canvas.left -= (newWidth - width) / 2; - canvas.top -= (newHeight - height) / 2; - } - - canvas.width = newWidth; - canvas.height = newHeight; - this.renderCanvas(true); - } - }, - - /** - * Rotate the canvas with a relative degree - * - * @param {Number} degree - */ - rotate: function (degree) { - this.rotateTo((this.image.rotate || 0) + num(degree)); - }, - - /** - * Rotate the canvas to an absolute degree - * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#rotate() - * - * @param {Number} degree - */ - rotateTo: function (degree) { - degree = num(degree); - - if (isNumber(degree) && this.isBuilt && !this.isDisabled && this.options.rotatable) { - this.image.rotate = degree % 360; - this.isRotated = true; - this.renderCanvas(true); - } - }, - - /** - * Scale the image - * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#scale() - * - * @param {Number} scaleX - * @param {Number} scaleY (optional) - */ - scale: function (scaleX, scaleY) { - var image = this.image; - var isChanged = false; - - // If "scaleY" is not present, its default value is "scaleX" - if (isUndefined(scaleY)) { - scaleY = scaleX; - } - - scaleX = num(scaleX); - scaleY = num(scaleY); - - if (this.isBuilt && !this.isDisabled && this.options.scalable) { - if (isNumber(scaleX)) { - image.scaleX = scaleX; - isChanged = true; - } - - if (isNumber(scaleY)) { - image.scaleY = scaleY; - isChanged = true; - } - - if (isChanged) { - this.renderImage(true); - } - } - }, - - /** - * Scale the abscissa of the image - * - * @param {Number} scaleX - */ - scaleX: function (scaleX) { - var scaleY = this.image.scaleY; - - this.scale(scaleX, isNumber(scaleY) ? scaleY : 1); - }, - - /** - * Scale the ordinate of the image - * - * @param {Number} scaleY - */ - scaleY: function (scaleY) { - var scaleX = this.image.scaleX; - - this.scale(isNumber(scaleX) ? scaleX : 1, scaleY); - }, - - /** - * Get the cropped area position and size data (base on the original image) - * - * @param {Boolean} isRounded (optional) - * @return {Object} data - */ - getData: function (isRounded) { - var options = this.options; - var image = this.image; - var canvas = this.canvas; - var cropBox = this.cropBox; - var ratio; - var data; - - if (this.isBuilt && this.isCropped) { - data = { - x: cropBox.left - canvas.left, - y: cropBox.top - canvas.top, - width: cropBox.width, - height: cropBox.height - }; - - ratio = image.width / image.naturalWidth; - - $.each(data, function (i, n) { - n = n / ratio; - data[i] = isRounded ? round(n) : n; - }); - - } else { - data = { - x: 0, - y: 0, - width: 0, - height: 0 - }; - } - - if (options.rotatable) { - data.rotate = image.rotate || 0; - } - - if (options.scalable) { - data.scaleX = image.scaleX || 1; - data.scaleY = image.scaleY || 1; - } - - return data; - }, - - /** - * Set the cropped area position and size with new data - * - * @param {Object} data - */ - setData: function (data) { - var options = this.options; - var image = this.image; - var canvas = this.canvas; - var cropBoxData = {}; - var isRotated; - var isScaled; - var ratio; - - if ($.isFunction(data)) { - data = data.call(this.element); - } - - if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { - if (options.rotatable) { - if (isNumber(data.rotate) && data.rotate !== image.rotate) { - image.rotate = data.rotate; - this.isRotated = isRotated = true; - } - } - - if (options.scalable) { - if (isNumber(data.scaleX) && data.scaleX !== image.scaleX) { - image.scaleX = data.scaleX; - isScaled = true; - } - - if (isNumber(data.scaleY) && data.scaleY !== image.scaleY) { - image.scaleY = data.scaleY; - isScaled = true; - } - } - - if (isRotated) { - this.renderCanvas(); - } else if (isScaled) { - this.renderImage(); - } - - ratio = image.width / image.naturalWidth; - - if (isNumber(data.x)) { - cropBoxData.left = data.x * ratio + canvas.left; - } - - if (isNumber(data.y)) { - cropBoxData.top = data.y * ratio + canvas.top; - } - - if (isNumber(data.width)) { - cropBoxData.width = data.width * ratio; - } - - if (isNumber(data.height)) { - cropBoxData.height = data.height * ratio; - } - - this.setCropBoxData(cropBoxData); - } - }, - - /** - * Get the container size data - * - * @return {Object} data - */ - getContainerData: function () { - return this.isBuilt ? this.container : {}; - }, - - /** - * Get the image position and size data - * - * @return {Object} data - */ - getImageData: function () { - return this.isLoaded ? this.image : {}; - }, - - /** - * Get the canvas position and size data - * - * @return {Object} data - */ - getCanvasData: function () { - var canvas = this.canvas; - var data = {}; - - if (this.isBuilt) { - $.each([ - 'left', - 'top', - 'width', - 'height', - 'naturalWidth', - 'naturalHeight' - ], function (i, n) { - data[n] = canvas[n]; - }); - } - - return data; - }, - - /** - * Set the canvas position and size with new data - * - * @param {Object} data - */ - setCanvasData: function (data) { - var canvas = this.canvas; - var aspectRatio = canvas.aspectRatio; - - if ($.isFunction(data)) { - data = data.call(this.$element); - } - - if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { - if (isNumber(data.left)) { - canvas.left = data.left; - } - - if (isNumber(data.top)) { - canvas.top = data.top; - } - - if (isNumber(data.width)) { - canvas.width = data.width; - canvas.height = data.width / aspectRatio; - } else if (isNumber(data.height)) { - canvas.height = data.height; - canvas.width = data.height * aspectRatio; - } - - this.renderCanvas(true); - } - }, - - /** - * Get the crop box position and size data - * - * @return {Object} data - */ - getCropBoxData: function () { - var cropBox = this.cropBox; - var data; - - if (this.isBuilt && this.isCropped) { - data = { - left: cropBox.left, - top: cropBox.top, - width: cropBox.width, - height: cropBox.height - }; - } - - return data || {}; - }, - - /** - * Set the crop box position and size with new data - * - * @param {Object} data - */ - setCropBoxData: function (data) { - var cropBox = this.cropBox; - var aspectRatio = this.options.aspectRatio; - var isWidthChanged; - var isHeightChanged; - - if ($.isFunction(data)) { - data = data.call(this.$element); - } - - if (this.isBuilt && this.isCropped && !this.isDisabled && $.isPlainObject(data)) { - - if (isNumber(data.left)) { - cropBox.left = data.left; - } - - if (isNumber(data.top)) { - cropBox.top = data.top; - } - - if (isNumber(data.width)) { - isWidthChanged = true; - cropBox.width = data.width; - } - - if (isNumber(data.height)) { - isHeightChanged = true; - cropBox.height = data.height; - } - - if (aspectRatio) { - if (isWidthChanged) { - cropBox.height = cropBox.width / aspectRatio; - } else if (isHeightChanged) { - cropBox.width = cropBox.height * aspectRatio; - } - } - - this.renderCropBox(); - } - }, - - /** - * Get a canvas drawn the cropped image - * - * @param {Object} options (optional) - * @return {HTMLCanvasElement} canvas - */ - getCroppedCanvas: function (options) { - var originalWidth; - var originalHeight; - var canvasWidth; - var canvasHeight; - var scaledWidth; - var scaledHeight; - var scaledRatio; - var aspectRatio; - var canvas; - var context; - var data; - - if (!this.isBuilt || !this.isCropped || !SUPPORT_CANVAS) { - return; - } - - if (!$.isPlainObject(options)) { - options = {}; - } - - data = this.getData(); - originalWidth = data.width; - originalHeight = data.height; - aspectRatio = originalWidth / originalHeight; - - if ($.isPlainObject(options)) { - scaledWidth = options.width; - scaledHeight = options.height; - - if (scaledWidth) { - scaledHeight = scaledWidth / aspectRatio; - scaledRatio = scaledWidth / originalWidth; - } else if (scaledHeight) { - scaledWidth = scaledHeight * aspectRatio; - scaledRatio = scaledHeight / originalHeight; - } - } - - // The canvas element will use `Math.floor` on a float number, so floor first - canvasWidth = floor(scaledWidth || originalWidth); - canvasHeight = floor(scaledHeight || originalHeight); - - canvas = $('')[0]; - canvas.width = canvasWidth; - canvas.height = canvasHeight; - context = canvas.getContext('2d'); - - if (options.fillColor) { - context.fillStyle = options.fillColor; - context.fillRect(0, 0, canvasWidth, canvasHeight); - } - - // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage - context.drawImage.apply(context, (function () { - var source = getSourceCanvas(this.$clone[0], this.image); - var sourceWidth = source.width; - var sourceHeight = source.height; - var args = [source]; - - // Source canvas - var srcX = data.x; - var srcY = data.y; - var srcWidth; - var srcHeight; - - // Destination canvas - var dstX; - var dstY; - var dstWidth; - var dstHeight; - - if (srcX <= -originalWidth || srcX > sourceWidth) { - srcX = srcWidth = dstX = dstWidth = 0; - } else if (srcX <= 0) { - dstX = -srcX; - srcX = 0; - srcWidth = dstWidth = min(sourceWidth, originalWidth + srcX); - } else if (srcX <= sourceWidth) { - dstX = 0; - srcWidth = dstWidth = min(originalWidth, sourceWidth - srcX); - } - - if (srcWidth <= 0 || srcY <= -originalHeight || srcY > sourceHeight) { - srcY = srcHeight = dstY = dstHeight = 0; - } else if (srcY <= 0) { - dstY = -srcY; - srcY = 0; - srcHeight = dstHeight = min(sourceHeight, originalHeight + srcY); - } else if (srcY <= sourceHeight) { - dstY = 0; - srcHeight = dstHeight = min(originalHeight, sourceHeight - srcY); - } - - // All the numerical parameters should be integer for `drawImage` (#476) - args.push(floor(srcX), floor(srcY), floor(srcWidth), floor(srcHeight)); - - // Scale destination sizes - if (scaledRatio) { - dstX *= scaledRatio; - dstY *= scaledRatio; - dstWidth *= scaledRatio; - dstHeight *= scaledRatio; - } - - // Avoid "IndexSizeError" in IE and Firefox - if (dstWidth > 0 && dstHeight > 0) { - args.push(floor(dstX), floor(dstY), floor(dstWidth), floor(dstHeight)); - } - - return args; - }).call(this)); - - return canvas; - }, - - /** - * Change the aspect ratio of the crop box - * - * @param {Number} aspectRatio - */ - setAspectRatio: function (aspectRatio) { - var options = this.options; - - if (!this.isDisabled && !isUndefined(aspectRatio)) { - - // 0 -> NaN - options.aspectRatio = max(0, aspectRatio) || NaN; - - if (this.isBuilt) { - this.initCropBox(); - - if (this.isCropped) { - this.renderCropBox(); - } - } - } - }, - - /** - * Change the drag mode - * - * @param {String} mode (optional) - */ - setDragMode: function (mode) { - var options = this.options; - var croppable; - var movable; - - if (this.isLoaded && !this.isDisabled) { - croppable = mode === ACTION_CROP; - movable = options.movable && mode === ACTION_MOVE; - mode = (croppable || movable) ? mode : ACTION_NONE; - - this.$dragBox. - data(DATA_ACTION, mode). - toggleClass(CLASS_CROP, croppable). - toggleClass(CLASS_MOVE, movable); - - if (!options.cropBoxMovable) { - - // Sync drag mode to crop box when it is not movable(#300) - this.$face. - data(DATA_ACTION, mode). - toggleClass(CLASS_CROP, croppable). - toggleClass(CLASS_MOVE, movable); - } - } - } - }; - - Cropper.DEFAULTS = { - - // Define the view mode of the cropper - viewMode: 0, // 0, 1, 2, 3 - - // Define the dragging mode of the cropper - dragMode: 'crop', // 'crop', 'move' or 'none' - - // Define the aspect ratio of the crop box - aspectRatio: NaN, - - // An object with the previous cropping result data - data: null, - - // A jQuery selector for adding extra containers to preview - preview: '', - - // Re-render the cropper when resize the window - responsive: true, - - // Restore the cropped area after resize the window - restore: true, - - // Check if the current image is a cross-origin image - checkCrossOrigin: true, - - // Check the current image's Exif Orientation information - checkOrientation: true, - - // Show the black modal - modal: true, - - // Show the dashed lines for guiding - guides: true, - - // Show the center indicator for guiding - center: true, - - // Show the white modal to highlight the crop box - highlight: true, - - // Show the grid background - background: true, - - // Enable to crop the image automatically when initialize - autoCrop: true, - - // Define the percentage of automatic cropping area when initializes - autoCropArea: 0.8, - - // Enable to move the image - movable: true, - - // Enable to rotate the image - rotatable: true, - - // Enable to scale the image - scalable: true, - - // Enable to zoom the image - zoomable: true, - - // Enable to zoom the image by dragging touch - zoomOnTouch: true, - - // Enable to zoom the image by wheeling mouse - zoomOnWheel: true, - - // Define zoom ratio when zoom the image by wheeling mouse - wheelZoomRatio: 0.1, - - // Enable to move the crop box - cropBoxMovable: true, - - // Enable to resize the crop box - cropBoxResizable: true, - - // Toggle drag mode between "crop" and "move" when click twice on the cropper - toggleDragModeOnDblclick: true, - - // Size limitation - minCanvasWidth: 0, - minCanvasHeight: 0, - minCropBoxWidth: 0, - minCropBoxHeight: 0, - minContainerWidth: 200, - minContainerHeight: 100, - - // Shortcuts of events - build: null, - built: null, - cropstart: null, - cropmove: null, - cropend: null, - crop: null, - zoom: null - }; - - Cropper.setDefaults = function (options) { - $.extend(Cropper.DEFAULTS, options); - }; - - Cropper.TEMPLATE = ( - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '' + - '
' + - '
' - ); - - // Save the other cropper - Cropper.other = $.fn.cropper; - - // Register as jQuery plugin - $.fn.cropper = function (option) { - var args = toArray(arguments, 1); - var result; - - this.each(function () { - var $this = $(this); - var data = $this.data(NAMESPACE); - var options; - var fn; - - if (!data) { - if (/destroy/.test(option)) { - return; - } - - options = $.extend({}, $this.data(), $.isPlainObject(option) && option); - $this.data(NAMESPACE, (data = new Cropper(this, options))); - } - - if (typeof option === 'string' && $.isFunction(fn = data[option])) { - result = fn.apply(data, args); - } - }); - - return isUndefined(result) ? this : result; - }; - - $.fn.cropper.Constructor = Cropper; - $.fn.cropper.setDefaults = Cropper.setDefaults; - - // No conflict - $.fn.cropper.noConflict = function () { - $.fn.cropper = Cropper.other; - return this; - }; - -}); diff --git a/vendor/assets/stylesheets/cropper.css b/vendor/assets/stylesheets/cropper.css deleted file mode 100755 index 41ee4bd546..0000000000 --- a/vendor/assets/stylesheets/cropper.css +++ /dev/null @@ -1,379 +0,0 @@ -/*! - * Cropper v2.2.5 - * https://github.com/fengyuanchen/cropper - * - * Copyright (c) 2014-2016 Fengyuan Chen and contributors - * Released under the MIT license - * - * Date: 2016-01-18T05:42:29.639Z - */ -.cropper-container { - font-size: 0; - line-height: 0; - - position: relative; - - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - - direction: ltr !important; - -ms-touch-action: none; - touch-action: none; - -webkit-tap-highlight-color: transparent; - -webkit-touch-callout: none; -} - -.cropper-container img { - display: block; - - width: 100%; - min-width: 0 !important; - max-width: none !important; - height: 100%; - min-height: 0 !important; - max-height: none !important; - - image-orientation: 0deg !important; -} - -.cropper-wrap-box, -.cropper-canvas, -.cropper-drag-box, -.cropper-crop-box, -.cropper-modal { - position: absolute; - top: 0; - right: 0; - bottom: 0; - left: 0; -} - -.cropper-wrap-box { - overflow: hidden; -} - -.cropper-drag-box { - opacity: 0; - background-color: #fff; - - filter: alpha(opacity=0); -} - -.cropper-modal { - opacity: .5; - background-color: #000; - - filter: alpha(opacity=50); -} - -.cropper-view-box { - display: block; - overflow: hidden; - - width: 100%; - height: 100%; - - outline: 1px solid #39f; - outline-color: rgba(51, 153, 255, .75); -} - -.cropper-dashed { - position: absolute; - - display: block; - - opacity: .5; - border: 0 dashed #eee; - - filter: alpha(opacity=50); -} - -.cropper-dashed.dashed-h { - top: 33.33333%; - left: 0; - - width: 100%; - height: 33.33333%; - - border-top-width: 1px; - border-bottom-width: 1px; -} - -.cropper-dashed.dashed-v { - top: 0; - left: 33.33333%; - - width: 33.33333%; - height: 100%; - - border-right-width: 1px; - border-left-width: 1px; -} - -.cropper-center { - position: absolute; - top: 50%; - left: 50%; - - display: block; - - width: 0; - height: 0; - - opacity: .75; - - filter: alpha(opacity=75); -} - -.cropper-center:before, -.cropper-center:after { - position: absolute; - - display: block; - - content: ' '; - - background-color: #eee; -} - -.cropper-center:before { - top: 0; - left: -3px; - - width: 7px; - height: 1px; -} - -.cropper-center:after { - top: -3px; - left: 0; - - width: 1px; - height: 7px; -} - -.cropper-face, -.cropper-line, -.cropper-point { - position: absolute; - - display: block; - - width: 100%; - height: 100%; - - opacity: .1; - - filter: alpha(opacity=10); -} - -.cropper-face { - top: 0; - left: 0; - - background-color: #fff; -} - -.cropper-line { - background-color: #39f; -} - -.cropper-line.line-e { - top: 0; - right: -3px; - - width: 5px; - - cursor: e-resize; -} - -.cropper-line.line-n { - top: -3px; - left: 0; - - height: 5px; - - cursor: n-resize; -} - -.cropper-line.line-w { - top: 0; - left: -3px; - - width: 5px; - - cursor: w-resize; -} - -.cropper-line.line-s { - bottom: -3px; - left: 0; - - height: 5px; - - cursor: s-resize; -} - -.cropper-point { - width: 5px; - height: 5px; - - opacity: .75; - background-color: #39f; - - filter: alpha(opacity=75); -} - -.cropper-point.point-e { - top: 50%; - right: -3px; - - margin-top: -3px; - - cursor: e-resize; -} - -.cropper-point.point-n { - top: -3px; - left: 50%; - - margin-left: -3px; - - cursor: n-resize; -} - -.cropper-point.point-w { - top: 50%; - left: -3px; - - margin-top: -3px; - - cursor: w-resize; -} - -.cropper-point.point-s { - bottom: -3px; - left: 50%; - - margin-left: -3px; - - cursor: s-resize; -} - -.cropper-point.point-ne { - top: -3px; - right: -3px; - - cursor: ne-resize; -} - -.cropper-point.point-nw { - top: -3px; - left: -3px; - - cursor: nw-resize; -} - -.cropper-point.point-sw { - bottom: -3px; - left: -3px; - - cursor: sw-resize; -} - -.cropper-point.point-se { - right: -3px; - bottom: -3px; - - width: 20px; - height: 20px; - - cursor: se-resize; - - opacity: 1; - - filter: alpha(opacity=100); -} - -.cropper-point.point-se:before { - position: absolute; - right: -50%; - bottom: -50%; - - display: block; - - width: 200%; - height: 200%; - - content: ' '; - - opacity: 0; - background-color: #39f; - - filter: alpha(opacity=0); -} - -@media (min-width: 768px) { - .cropper-point.point-se { - width: 15px; - height: 15px; - } -} - -@media (min-width: 992px) { - .cropper-point.point-se { - width: 10px; - height: 10px; - } -} - -@media (min-width: 1200px) { - .cropper-point.point-se { - width: 5px; - height: 5px; - - opacity: .75; - - filter: alpha(opacity=75); - } -} - -.cropper-invisible { - opacity: 0; - - filter: alpha(opacity=0); -} - -.cropper-bg { - background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC'); -} - -.cropper-hide { - position: absolute; - - display: block; - - width: 0; - height: 0; -} - -.cropper-hidden { - display: none !important; -} - -.cropper-move { - cursor: move; -} - -.cropper-crop { - cursor: crosshair; -} - -.cropper-disabled .cropper-drag-box, -.cropper-disabled .cropper-face, -.cropper-disabled .cropper-line, -.cropper-disabled .cropper-point { - cursor: not-allowed; -} From cd5cbc0fd55a494247689cbfa5c9a3ef8bc8914e Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Tue, 15 Mar 2016 15:57:30 +0200 Subject: [PATCH 107/797] First draft on GitLab intro docs [ci skip] --- doc/intro/README.md | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 doc/intro/README.md diff --git a/doc/intro/README.md b/doc/intro/README.md new file mode 100644 index 0000000000..864d5812ed --- /dev/null +++ b/doc/intro/README.md @@ -0,0 +1,40 @@ +## Organize + +> Create projects and groups + +- [Create a new project](../gitlab-basics/create-project.md) +- [Create a new group](../gitlab-basics/create-group.md) + +## Prioritize + +> Create issues, labels, milestones, voting, and review issues + +- [Create a new issue](../gitlab-basics/create-issue.md) +- [Assign labels to issues](../workflow/labels.md) +- [Use milestones as an overview of your project's tracker](../workflow/milestones.md) +- Use voting to express your like/dislike to the issue in demand + +## Collaborate + +> Create merge requests, diffs, commits, and review code + +- [Create a new merge request](../gitlab-basics/add-merge-request.md) +- Review merge requests easily using inline comments + +## Deploy + +> Use built-in continuous integration in GitLab, or use your favorite CI tool. + +- [GitLab CI quick start guide](../ci/quick_start/README.md) + +## Install + +> Install and update your GitLab installation. + +- Install GitLab + - Omnibus + - [Source](../install/installation.md) +- Upgrade GitLab +- Learn about GitLab Omnibus architecture +- GitLab Omnibus configuration options +- Migrating from source installation to GitLab Omnibus From a7f74e033f89b3111c6f297f9880bb2b22a1a15a Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Tue, 15 Mar 2016 15:38:12 +0100 Subject: [PATCH 108/797] bundle:audit job only on master --- .gitlab-ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bd013d50fa..515eb85611 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -148,6 +148,8 @@ flay: bundler:audit: stage: test + only: + - master script: - "bundle exec bundle-audit update" - "bundle exec bundle-audit check" @@ -162,7 +164,7 @@ spec:feature:ruby22: stage: test image: ruby:2.2 only: - - master + - master script: - RAILS_ENV=test bundle exec rake assets:precompile 2>/dev/null - RAILS_ENV=test SIMPLECOV=true bundle exec rake spec:feature From 588002d8c70da8b4c52e409c62a95a63e24b391b Mon Sep 17 00:00:00 2001 From: Jason Roehm Date: Tue, 15 Mar 2016 11:03:00 -0400 Subject: [PATCH 109/797] fixed missing argument in list --- lib/ci/gitlab_ci_yaml_processor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index 610c56ddd9..abab91ddf5 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -284,7 +284,7 @@ module Ci end end - def match_ref?(pattern, ref, tag) + def match_ref?(pattern, ref, tag, trigger_request) pattern, path = pattern.split('@', 2) return false if path && path != self.path return true if tag && pattern == 'tags' From 89e50a0264c4651b9c2f4ac2981b9b1d95de2875 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Tue, 15 Mar 2016 16:08:32 +0100 Subject: [PATCH 110/797] Use klass instead of clazz --- app/helpers/ci_status_helper.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/helpers/ci_status_helper.rb b/app/helpers/ci_status_helper.rb index 391d74ebdb..8b1575d5e0 100644 --- a/app/helpers/ci_status_helper.rb +++ b/app/helpers/ci_status_helper.rb @@ -14,11 +14,11 @@ module CiStatusHelper def ci_status_with_icon(status, target = nil) content = ci_icon_for_status(status) + ' '.html_safe + ci_label_for_status(status) - clazz = "ci-status ci-#{status}" + klass = "ci-status ci-#{status}" if target - link_to content, target, class: clazz + link_to content, target, class: klass else - content_tag :span, content, class: clazz + content_tag :span, content, class: klass end end From c742760289e51117d3e76e27a626691bec631e1e Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Tue, 15 Mar 2016 16:46:17 +0100 Subject: [PATCH 111/797] Ignore eager loading in Project.search UNION The queries that are UNION'd together don't need any eager loading (since we really only use the resulting SQL instead of having ActiveRecord actually run the queries). By dropping any eager loaded associations queries such as the following work instead of producing a SQL error: Project.all.includes(:namespace).search('foo') --- app/models/project.rb | 7 +++++++ spec/models/project_spec.rb | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/app/models/project.rb b/app/models/project.rb index 79e0cc7b23..d246d9e3c7 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -286,7 +286,14 @@ class Project < ActiveRecord::Base or(ptable[:description].matches(pattern)) ) + # We explicitly remove any eager loading clauses as they're: + # + # 1. Not needed by this query + # 2. Combined with .joins(:namespace) lead to all columns from the + # projects & namespaces tables being selected, leading to a SQL error + # due to the columns of all UNION'd queries no longer being the same. namespaces = select(:id). + except(:includes). joins(:namespace). where(ntable[:name].matches(pattern)) diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 59c5ffa6b9..b8b9a455b8 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -634,6 +634,12 @@ describe Project, models: true do it 'returns projects with a matching namespace name regardless of the casing' do expect(described_class.search(project.namespace.name.upcase)).to eq([project]) end + + it 'returns projects when eager loading namespaces' do + relation = described_class.all.includes(:namespace) + + expect(relation.search(project.namespace.name)).to eq([project]) + end end describe '#rename_repo' do From 0444fa560acd07255960284f19b1de6499cd5910 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 12 Feb 2016 20:28:39 +0530 Subject: [PATCH 112/797] Original implementation to allow users to subscribe to labels 1. Allow subscribing (the current user) to a label - Refactor the `Subscription` coffeescript class - The main change is that it accepts a container, and conducts all DOM queries within its scope. We need this because the labels page has multiple instances of `Subscription` on the same page. 2. Creating an issue or MR with labels notifies users subscribed to those labels - Label `has_many` subscribers through subscriptions. 3. Adding a label to an issue or MR notifies users subscribed to those labels - This only applies to subscribers of the label that has just been added, not all labels for the issue. --- CHANGELOG | 2 + app/assets/javascripts/subscription.js.coffee | 32 +++---- app/assets/stylesheets/pages/labels.scss | 4 + app/controllers/projects/labels_controller.rb | 9 +- app/mailers/emails/issues.rb | 11 ++- app/mailers/emails/merge_requests.rb | 14 ++- app/models/concerns/issuable.rb | 30 ++----- app/models/concerns/subscribable.rb | 43 +++++++++ app/models/label.rb | 2 + app/services/issuable_base_service.rb | 2 +- app/services/issues/update_service.rb | 7 +- app/services/merge_requests/update_service.rb | 7 +- app/services/notification_service.rb | 44 ++++++++++ .../_relabeled_issuable_email.html.haml | 5 ++ .../notify/_relabeled_issuable_email.text.erb | 5 ++ .../notify/relabeled_issue_email.html.haml | 1 + .../notify/relabeled_issue_email.text.erb | 1 + .../relabeled_merge_request_email.html.haml | 1 + .../relabeled_merge_request_email.text.erb | 1 + app/views/projects/labels/_label.html.haml | 12 +++ app/views/shared/issuable/_sidebar.html.haml | 4 +- config/routes.rb | 4 + features/project/labels.feature | 16 ++++ features/steps/project/labels.rb | 34 ++++++++ spec/factories/labels.rb | 2 +- spec/models/concerns/subscribable_spec.rb | 16 ++++ spec/services/issues/update_service_spec.rb | 56 +++++++++++- .../merge_requests/update_service_spec.rb | 56 +++++++++++- spec/services/notification_service_spec.rb | 87 ++++++++++++++++--- spec/spec_helper.rb | 1 + spec/support/email_helpers.rb | 13 +++ 31 files changed, 458 insertions(+), 64 deletions(-) create mode 100644 app/models/concerns/subscribable.rb create mode 100644 app/views/notify/_relabeled_issuable_email.html.haml create mode 100644 app/views/notify/_relabeled_issuable_email.text.erb create mode 100644 app/views/notify/relabeled_issue_email.html.haml create mode 100644 app/views/notify/relabeled_issue_email.text.erb create mode 100644 app/views/notify/relabeled_merge_request_email.html.haml create mode 100644 app/views/notify/relabeled_merge_request_email.text.erb create mode 100644 features/project/labels.feature create mode 100644 features/steps/project/labels.rb create mode 100644 spec/models/concerns/subscribable_spec.rb create mode 100644 spec/support/email_helpers.rb diff --git a/CHANGELOG b/CHANGELOG index 015efa05c6..fb9500e68a 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -174,6 +174,8 @@ v 8.5.0 v 8.4.5 - No CE-specific changes + - Allow user subscription to a label; get notified for issues/merge requests related to that label. + - Allow user subscription to a label; get notified for issues/merge requests related to that label. (Timothy Andrew) v 8.4.4 - Update omniauth-saml gem to 1.4.2 diff --git a/app/assets/javascripts/subscription.js.coffee b/app/assets/javascripts/subscription.js.coffee index 7f41616d4e..afc17338b2 100644 --- a/app/assets/javascripts/subscription.js.coffee +++ b/app/assets/javascripts/subscription.js.coffee @@ -1,17 +1,19 @@ class @Subscription - constructor: (url) -> - $(".subscribe-button").unbind("click").click (event)=> - btn = $(event.currentTarget) - action = btn.find("span").text() - current_status = $(".subscription-status").attr("data-status") - btn.prop("disabled", true) - - $.post url, => - btn.prop("disabled", false) - status = if current_status == "subscribed" then "unsubscribed" else "subscribed" - $(".subscription-status").attr("data-status", status) - action = if status == "subscribed" then "Unsubscribe" else "Subscribe" - btn.find("span").text(action) - $(".subscription-status>div").toggleClass("hidden") + constructor: (@url, container) -> + @subscribe_button = $(container).find(".subscribe-button") + @subscription_status = $(container).find(".subscription-status") + @subscribe_button.unbind("click").click(@toggleSubscription) - + toggleSubscription: (event) => + btn = $(event.currentTarget) + action = btn.find("span").text() + current_status = @subscription_status.attr("data-status") + btn.prop("disabled", true) + + $.post @url, => + btn.prop("disabled", false) + status = if current_status == "subscribed" then "unsubscribed" else "subscribed" + @subscription_status.attr("data-status", status) + action = if status == "subscribed" then "Unsubscribe" else "Subscribe" + btn.find("span").text(action) + @subscription_status.find(">div").toggleClass("hidden") diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 5ec0966194..1791ba9fbd 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -41,3 +41,7 @@ .color-label { padding: 3px 4px; } + +.label-subscription { + display: inline-block; +} \ No newline at end of file diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index ecac3c395e..d0334c37b6 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -1,8 +1,8 @@ class Projects::LabelsController < Projects::ApplicationController before_action :module_enabled - before_action :label, only: [:edit, :update, :destroy] + before_action :label, only: [:edit, :update, :destroy, :toggle_subscription] before_action :authorize_read_label! - before_action :authorize_admin_labels!, except: [:index] + before_action :authorize_admin_labels!, except: [:index, :toggle_subscription] respond_to :js, :html @@ -60,6 +60,11 @@ class Projects::LabelsController < Projects::ApplicationController end end + def toggle_subscription + @label.toggle_subscription(current_user) + render nothing: true + end + protected def module_enabled diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 4a88cb6113..2838baa1b4 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -16,7 +16,14 @@ module Emails def closed_issue_email(recipient_id, issue_id, updated_by_user_id) setup_issue_mail(issue_id, recipient_id) - @updated_by = User.find updated_by_user_id + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) + end + + def relabeled_issue_email(recipient_id, issue_id, updated_by_user_id, label_names) + setup_issue_mail(issue_id, recipient_id) + @label_names = label_names + @updated_by = User.find(updated_by_user_id) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end @@ -24,7 +31,7 @@ module Emails setup_issue_mail(issue_id, recipient_id) @issue_status = status - @updated_by = User.find updated_by_user_id + @updated_by = User.find(updated_by_user_id) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 325996e2e1..680ce975c7 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -19,10 +19,20 @@ module Emails subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) end + def relabeled_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, label_names) + setup_merge_request_mail(merge_request_id, recipient_id) + @label_names = label_names + @updated_by = User.find(updated_by_user_id) + mail_answer_thread(@merge_request, + from: sender(@merge_request.author_id), + to: recipient(recipient_id), + subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + end + def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) - @updated_by = User.find updated_by_user_id + @updated_by = User.find(updated_by_user_id) mail_answer_thread(@merge_request, from: sender(updated_by_user_id), to: recipient(recipient_id), @@ -42,7 +52,7 @@ module Emails setup_merge_request_mail(merge_request_id, recipient_id) @mr_status = status - @updated_by = User.find updated_by_user_id + @updated_by = User.find(updated_by_user_id) mail_answer_thread(@merge_request, from: sender(updated_by_user_id), to: recipient(recipient_id), diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3c42f58293..affc4a842a 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -8,6 +8,7 @@ module Issuable extend ActiveSupport::Concern include Participable include Mentionable + include Subscribable include StripAttribute included do @@ -18,7 +19,6 @@ module Issuable has_many :notes, as: :noteable, dependent: :destroy has_many :label_links, as: :target, dependent: :destroy has_many :labels, through: :label_links - has_many :subscriptions, dependent: :destroy, as: :subscribable validates :author, presence: true validates :title, presence: true, length: { within: 0..255 } @@ -149,28 +149,6 @@ module Issuable notes.awards.where(note: "thumbsup").count end - def subscribed?(user) - subscription = subscriptions.find_by_user_id(user.id) - - if subscription - return subscription.subscribed - end - - participants(user).include?(user) - end - - def toggle_subscription(user) - subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: !subscribed?(user)) - end - - def unsubscribe(user) - subscriptions. - find_or_initialize_by(user_id: user.id). - update(subscribed: false) - end - def to_hook_data(user) hook_data = { object_kind: self.class.name.underscore, @@ -201,6 +179,12 @@ module Issuable end end + # Labels that are currently applied to this object + # that are not present in `old_labels` + def added_labels(old_labels) + self.labels - old_labels + end + # Convert this Issuable class name to a format usable by Ability definitions # # Examples: diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb new file mode 100644 index 0000000000..cab9241ac3 --- /dev/null +++ b/app/models/concerns/subscribable.rb @@ -0,0 +1,43 @@ +# == Subscribable concern +# +# Users can subscribe to these models. +# +# Used by Issue, MergeRequest, Label +# + +module Subscribable + extend ActiveSupport::Concern + + included do + has_many :subscriptions, dependent: :destroy, as: :subscribable + end + + def subscribed?(user) + subscription = subscriptions.find_by_user_id(user.id) + + if subscription + return subscription.subscribed + end + + # FIXME + # Issue/MergeRequest has participants, but Label doesn't. + # Ideally, subscriptions should be separate from participations, + # but that seems like a larger change with farther-reaching + # consequences, so this is a compromise for the time being. + if respond_to?(:participants) + participants(user).include?(user) + end + end + + def toggle_subscription(user) + subscriptions. + find_or_initialize_by(user_id: user.id). + update(subscribed: !subscribed?(user)) + end + + def unsubscribe(user) + subscriptions. + find_or_initialize_by(user_id: user.id). + update(subscribed: false) + end +end diff --git a/app/models/label.rb b/app/models/label.rb index 5ff644b842..f7ffc0b7f3 100644 --- a/app/models/label.rb +++ b/app/models/label.rb @@ -14,6 +14,8 @@ class Label < ActiveRecord::Base include Referable + include Subscribable + # Represents a "No Label" state used for filtering Issues and Merge # Requests that have no label assigned. LabelStruct = Struct.new(:title, :name) diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index ca87dca4a7..971c074882 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -95,7 +95,7 @@ class IssuableBaseService < BaseService old_labels = options[:old_labels] if old_labels && (issuable.labels != old_labels) - create_labels_note(issuable, issuable.labels - old_labels, old_labels - issuable.labels) + create_labels_note(issuable, issuable.added_labels(old_labels), old_labels - issuable.labels) end end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index 51ef9dfe61..b2e63a4e1a 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -4,7 +4,7 @@ module Issues update(issue) end - def handle_changes(issue, options = {}) + def handle_changes(issue, old_labels: [], new_labels: []) if has_changes?(issue, options) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -23,6 +23,11 @@ module Issues notification_service.reassigned_issue(issue, current_user) todo_service.reassigned_issue(issue, current_user) end + + new_labels = issue.added_labels(old_labels) + if new_labels.present? + notification_service.relabeled_issue(issue, new_labels, current_user) + end end def reopen_service diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 6319ad805b..6fd569dc30 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -14,7 +14,7 @@ module MergeRequests update(merge_request) end - def handle_changes(merge_request, options = {}) + def handle_changes(issue, old_labels: [], new_labels: []) if has_changes?(merge_request, options) todo_service.mark_pending_todos_as_done(merge_request, current_user) end @@ -44,6 +44,11 @@ module MergeRequests merge_request.previous_changes.include?('source_branch') merge_request.mark_as_unchecked end + + new_labels = merge_request.added_labels(old_labels) + if new_labels.present? + notification_service.relabeled_merge_request(merge_request, new_labels, current_user) + end end def reopen_service diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index ca8a41d93b..e9955cd3e2 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -52,6 +52,14 @@ class NotificationService reassign_resource_email(issue, issue.project, current_user, 'reassigned_issue_email') end + # When we change labels on an issue we should send emails. + # + # We pass in the labels, here, because we only want the labels that + # have been *added* during this relabel, not all of them. + def relabeled_issue(issue, labels, current_user) + relabel_resource_email(issue, issue.project, labels, current_user, 'relabeled_issue_email') + end + # When create a merge request we should send next emails: # @@ -70,6 +78,14 @@ class NotificationService reassign_resource_email(merge_request, merge_request.target_project, current_user, 'reassigned_merge_request_email') end + # When we change labels on a merge request we should send emails. + # + # We pass in the labels, here, because we only want the labels that + # have been *added* during this relabel, not all of them. + def relabeled_merge_request(merge_request, labels, current_user) + relabel_resource_email(merge_request, merge_request.project, labels, current_user, 'relabeled_merge_request_email') + end + def close_mr(merge_request, current_user) close_resource_email(merge_request, merge_request.target_project, current_user, 'closed_merge_request_email') end @@ -142,6 +158,7 @@ class NotificationService recipients = reject_muted_users(recipients, note.project) recipients = add_subscribed_users(recipients, note.noteable) + recipients = add_label_subscriptions(recipients, note.noteable) recipients = reject_unsubscribed_users(recipients, note.noteable) recipients.delete(note.author) @@ -359,6 +376,16 @@ class NotificationService end end + def add_label_subscriptions(recipients, target) + return recipients unless target.respond_to? :labels + + target.labels.each do |label| + recipients += label.subscriptions.where(subscribed: true).map(&:user) + end + + recipients + end + def new_resource_email(target, project, method) recipients = build_recipients(target, project, target.author) @@ -392,6 +419,15 @@ class NotificationService end end + def relabel_resource_email(target, project, labels, current_user, method) + recipients = build_relabel_recipients(target, project, labels, current_user) + label_names = labels.map(&:name) + + recipients.each do |recipient| + mailer.send(method, recipient.id, target.id, current_user.id, label_names).deliver_later + end + end + def reopen_resource_email(target, project, current_user, method, status) recipients = build_recipients(target, project, current_user) @@ -416,6 +452,7 @@ class NotificationService recipients = reject_muted_users(recipients, project) recipients = add_subscribed_users(recipients, target) + recipients = add_label_subscriptions(recipients, target) recipients = reject_unsubscribed_users(recipients, target) recipients.delete(current_user) @@ -423,6 +460,13 @@ class NotificationService recipients.uniq end + def build_relabel_recipients(target, project, labels, current_user) + recipients = add_label_subscriptions([], target) + recipients = reject_unsubscribed_users(recipients, target) + recipients.delete(current_user) + recipients.uniq + end + def mailer Notify end diff --git a/app/views/notify/_relabeled_issuable_email.html.haml b/app/views/notify/_relabeled_issuable_email.html.haml new file mode 100644 index 0000000000..a41ff07c30 --- /dev/null +++ b/app/views/notify/_relabeled_issuable_email.html.haml @@ -0,0 +1,5 @@ +%p + #{@updated_by.name} added the + %em= @label_names.to_sentence + #{"label".pluralize(@label_names.count)} to #{issuable.class.model_name.human} #{issuable.iid}. + diff --git a/app/views/notify/_relabeled_issuable_email.text.erb b/app/views/notify/_relabeled_issuable_email.text.erb new file mode 100644 index 0000000000..1a28d7fd35 --- /dev/null +++ b/app/views/notify/_relabeled_issuable_email.text.erb @@ -0,0 +1,5 @@ +<%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> was relabeled. + +Issue <%= issuable.iid %>: <%= url_for(namespace_project_issue_url(issuable.project.namespace, issuable.project, issuable)) %> +Author: <%= issuable.author_name %> +New Labels: <%= @label_names.to_sentence %> diff --git a/app/views/notify/relabeled_issue_email.html.haml b/app/views/notify/relabeled_issue_email.html.haml new file mode 100644 index 0000000000..a3e094e86e --- /dev/null +++ b/app/views/notify/relabeled_issue_email.html.haml @@ -0,0 +1 @@ += render "relabeled_issuable_email", issuable: @issue diff --git a/app/views/notify/relabeled_issue_email.text.erb b/app/views/notify/relabeled_issue_email.text.erb new file mode 100644 index 0000000000..de7f04a323 --- /dev/null +++ b/app/views/notify/relabeled_issue_email.text.erb @@ -0,0 +1 @@ +<%= render "relabeled_issuable_email", issuable: @issue %> diff --git a/app/views/notify/relabeled_merge_request_email.html.haml b/app/views/notify/relabeled_merge_request_email.html.haml new file mode 100644 index 0000000000..3937b449a3 --- /dev/null +++ b/app/views/notify/relabeled_merge_request_email.html.haml @@ -0,0 +1 @@ += render "relabeled_issuable_email", issuable: @merge_request diff --git a/app/views/notify/relabeled_merge_request_email.text.erb b/app/views/notify/relabeled_merge_request_email.text.erb new file mode 100644 index 0000000000..5c4d86e4dc --- /dev/null +++ b/app/views/notify/relabeled_merge_request_email.text.erb @@ -0,0 +1 @@ +<%= render "relabeled_issuable_email", issuable: @merge_request %> diff --git a/app/views/projects/labels/_label.html.haml b/app/views/projects/labels/_label.html.haml index f7ddd30c5a..3b14a925c4 100644 --- a/app/views/projects/labels/_label.html.haml +++ b/app/views/projects/labels/_label.html.haml @@ -10,6 +10,18 @@ = link_to_label(label) do = pluralize label.open_issues_count, 'open issue' + - if current_user + %div{class: "label-subscription", data: {id: label.id}} + - subscribed = label.subscribed?(current_user) + - subscription_status = subscribed ? 'subscribed' : 'unsubscribed' + .subscription-status{data: {status: subscription_status}} + %button.btn.btn-sm.btn-info.subscribe-button{:type => 'button'} + %span= subscribed ? 'Unsubscribe' : 'Subscribe' + - if can? current_user, :admin_label, @project = link_to 'Edit', edit_namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm' = link_to 'Delete', namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Remove this label? Are you sure?"} + +:javascript + new Subscription("#{toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)}", + ".label-subscription[data-id='#{label.id}']"); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 9020a1330a..b44086d420 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -98,7 +98,7 @@ %hr - if current_user - subscribed = issuable.subscribed?(current_user) - .block.light + .block.light.subscription .sidebar-collapsed-icon = icon('rss') .title.hide-collapsed @@ -124,5 +124,5 @@ = clipboard_button(clipboard_text: project_ref) :javascript - new Subscription("#{toggle_subscription_path(issuable)}"); + new Subscription("#{toggle_subscription_path(issuable)}", ".subscription"); new IssuableContext(); diff --git a/config/routes.rb b/config/routes.rb index 780ad757c5..2ae282f48a 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -675,6 +675,10 @@ Rails.application.routes.draw do collection do post :generate end + + member do + post :toggle_subscription + end end resources :issues, constraints: { id: /\d+/ }, except: [:destroy] do diff --git a/features/project/labels.feature b/features/project/labels.feature new file mode 100644 index 0000000000..cd3f3a789f --- /dev/null +++ b/features/project/labels.feature @@ -0,0 +1,16 @@ +@labels +Feature: Labels + Background: + Given I sign in as a user + And I own project "Shop" + And I visit project "Shop" issues page + And project "Shop" has labels: "bug", "feature", "enhancement" + + @javascript + Scenario: I can subscribe to a label + When I visit project "Shop" labels page + Then I should see that I am unsubscribed + When I click button "Subscribe" + Then I should see that I am subscribed + When I click button "Unsubscribe" + Then I should see that I am unsubscribed diff --git a/features/steps/project/labels.rb b/features/steps/project/labels.rb new file mode 100644 index 0000000000..3f800a1059 --- /dev/null +++ b/features/steps/project/labels.rb @@ -0,0 +1,34 @@ +class Spinach::Features::Labels < Spinach::FeatureSteps + include SharedAuthentication + include SharedIssuable + include SharedProject + include SharedNote + include SharedPaths + include SharedMarkdown + + step 'And I visit project "Shop" labels page' do + visit namespace_project_labels_path(project.namespace, project) + end + + step 'I should see that I am subscribed' do + expect(subscribe_button).to have_content 'Unsubscribe' + end + + step 'I should see that I am unsubscribed' do + expect(subscribe_button).to have_content 'Subscribe' + end + + step 'I click button "Unsubscribe"' do + subscribe_button.click + end + + step 'I click button "Subscribe"' do + subscribe_button.click + end + + private + + def subscribe_button + first('.subscribe-button span') + end +end diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb index 6e70af10af..91a7400afa 100644 --- a/spec/factories/labels.rb +++ b/spec/factories/labels.rb @@ -13,7 +13,7 @@ FactoryGirl.define do factory :label do - title "Bug" + title { FFaker::Color.name } color "#990000" project end diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb new file mode 100644 index 0000000000..9ee60426a5 --- /dev/null +++ b/spec/models/concerns/subscribable_spec.rb @@ -0,0 +1,16 @@ +require "spec_helper" + +describe Subscribable, "Subscribable" do + let(:resource) { create(:issue) } + let(:user) { create(:user) } + + describe "#subscribed?" do + it do + expect(resource.subscribed?(user)).to be_falsey + resource.toggle_subscription(user) + expect(resource.subscribed?(user)).to be_truthy + resource.toggle_subscription(user) + expect(resource.subscribed?(user)).to be_falsey + end + end +end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index e579e49dfa..dc9d832975 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -48,7 +48,7 @@ describe Issues::UpdateService, services: true do it { expect(@issue.assignee).to eq(user2) } it { expect(@issue).to be_closed } it { expect(@issue.labels.count).to eq(1) } - it { expect(@issue.labels.first.title).to eq('Bug') } + it { expect(@issue.labels.first.title).to eq(label.name) } it 'should send email to user2 about assign of new issue and email to user3 about issue unassignment' do deliveries = ActionMailer::Base.deliveries @@ -148,6 +148,60 @@ describe Issues::UpdateService, services: true do end end + context "when the issue is relabeled" do + it "sends notifications for subscribers of newly added labels" do + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [label.id] } + + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + @issue.reload + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "does send notifications for existing labels" do + second_label = create(:label) + issue.labels << label + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [label.id, second_label.id] } + + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + @issue.reload + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "does not send notifications for removed labels" do + second_label = create(:label) + issue.labels << label + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [second_label.id] } + + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + @issue.reload + should_not_email(subscriber) + should_not_email(non_subscriber) + end + end + context 'when Issue has tasks' do before { update_issue({ description: "- [ ] Task 1\n- [ ] Task 2" }) } diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 99703c7a8e..104e63ccfe 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -53,7 +53,7 @@ describe MergeRequests::UpdateService, services: true do it { expect(@merge_request.assignee).to eq(user2) } it { expect(@merge_request).to be_closed } it { expect(@merge_request.labels.count).to eq(1) } - it { expect(@merge_request.labels.first.title).to eq('Bug') } + it { expect(@merge_request.labels.first.title).to eq(label.name) } it { expect(@merge_request.target_branch).to eq('target') } it 'should execute hooks with update action' do @@ -176,6 +176,60 @@ describe MergeRequests::UpdateService, services: true do end end + context "when the merge request is relabeled" do + it "sends notifications for subscribers of newly added labels" do + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [label.id] } + + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + @merge_request.reload + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "does send notifications for existing labels" do + second_label = create(:label) + merge_request.labels << label + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [label.id, second_label.id] } + + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + @merge_request.reload + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "does not send notifications for removed labels" do + second_label = create(:label) + merge_request.labels << label + subscriber, non_subscriber = create_list(:user, 2) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + + opts = { label_ids: [second_label.id] } + + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + @merge_request.reload + should_not_email(subscriber) + should_not_email(non_subscriber) + end + end + context 'when MergeRequest has tasks' do before { update_merge_request({ description: "- [ ] Task 1\n- [ ] Task 2" }) } diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 2d0b5df422..35afa76805 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -224,6 +224,16 @@ describe NotificationService, services: true do should_not_email(issue.assignee) end + + it "should email subscribers of the issue's labels" do + subscriber, non_subscriber = create_list(:user, 2) + label = create(:label, issues: [issue]) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + notification.new_issue(issue, @u_disabled) + should_email(subscriber) + should_not_email(non_subscriber) + end end describe :reassigned_issue do @@ -326,6 +336,32 @@ describe NotificationService, services: true do should_not_email(@u_participating) end end + + describe :relabel_issue do + it "sends email to subscribers of the given labels" do + subscriber, non_subscriber = create_list(:user, 2) + label = create(:label, issues: [issue]) + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + notification.relabeled_issue(issue, [label], @u_disabled) + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "doesn't send email to anyone but subscribers of the given labels" do + label = create(:label, issues: [issue]) + notification.relabeled_issue(issue, [label], @u_disabled) + + should_not_email(issue.assignee) + should_not_email(issue.author) + should_not_email(@u_watcher) + should_not_email(@u_participant_mentioned) + should_not_email(@subscriber) + should_not_email(@watcher_and_subscriber) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + end + end end describe 'Merge Requests' do @@ -349,6 +385,17 @@ describe NotificationService, services: true do should_not_email(@u_participating) should_not_email(@u_disabled) end + + it "should email subscribers of the MR's labels" do + subscriber, non_subscriber = create_list(:user, 2) + label = create(:label) + merge_request.labels << label + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + notification.new_merge_request(merge_request, @u_disabled) + should_email(subscriber) + should_not_email(non_subscriber) + end end describe :reassigned_merge_request do @@ -410,6 +457,34 @@ describe NotificationService, services: true do should_not_email(@u_disabled) end end + + describe :relabel_merge_request do + it "sends email to subscribers of the given labels" do + subscriber, non_subscriber = create_list(:user, 2) + label = create(:label) + merge_request.labels << label + label.toggle_subscription(subscriber) + 2.times { label.toggle_subscription(non_subscriber) } + notification.relabeled_merge_request(merge_request, [label], @u_disabled) + should_email(subscriber) + should_not_email(non_subscriber) + end + + it "doesn't send email to anyone but subscribers of the given labels" do + label = create(:label) + merge_request.labels << label + notification.relabeled_merge_request(merge_request, [label], @u_disabled) + + should_not_email(merge_request.assignee) + should_not_email(merge_request.author) + should_not_email(@u_watcher) + should_not_email(@u_participant_mentioned) + should_not_email(@subscriber) + should_not_email(@watcher_and_subscriber) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + end + end end describe 'Projects' do @@ -467,16 +542,4 @@ describe NotificationService, services: true do # Make the watcher a subscriber to detect dupes issuable.subscriptions.create(user: @watcher_and_subscriber, subscribed: true) end - - def sent_to_user?(user) - ActionMailer::Base.deliveries.map(&:to).flatten.count(user.email) == 1 - end - - def should_email(user) - expect(sent_to_user?(user)).to be_truthy - end - - def should_not_email(user) - expect(sent_to_user?(user)).to be_falsey - end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 7d939ca750..596d607f2a 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -32,6 +32,7 @@ RSpec.configure do |config| config.include LoginHelpers, type: :feature config.include LoginHelpers, type: :request config.include StubConfiguration + config.include EmailHelpers config.include RelativeUrl, type: feature config.include TestEnv config.include ActiveJob::TestHelper diff --git a/spec/support/email_helpers.rb b/spec/support/email_helpers.rb new file mode 100644 index 0000000000..a85ab22ce3 --- /dev/null +++ b/spec/support/email_helpers.rb @@ -0,0 +1,13 @@ +module EmailHelpers + def sent_to_user?(user) + ActionMailer::Base.deliveries.map(&:to).flatten.count(user.email) == 1 + end + + def should_email(user) + expect(sent_to_user?(user)).to be_truthy + end + + def should_not_email(user) + expect(sent_to_user?(user)).to be_falsey + end +end From 0129f346ee7fe0d89c0b80b04afebe2c4faf4dc9 Mon Sep 17 00:00:00 2001 From: connorshea Date: Tue, 15 Mar 2016 10:42:16 -0600 Subject: [PATCH 113/797] Remove parentheses from if statement Otherwise Rubocop will give a warning. As mentioned in !3197. --- app/services/git_push_service.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index 4313de0cca..d840ab5e34 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -89,7 +89,7 @@ class GitPushService < BaseService project.change_head(branch_name) # Set protection on the default branch if configured - if (current_application_settings.default_branch_protection != PROTECTION_NONE) + if current_application_settings.default_branch_protection != PROTECTION_NONE developers_can_push = current_application_settings.default_branch_protection == PROTECTION_DEV_CAN_PUSH ? true : false @project.protected_branches.create({ name: @project.default_branch, developers_can_push: developers_can_push }) end From ac8c6f24e3070c28ab6f246599c95fd8f3b7e3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 15 Mar 2016 18:16:21 +0100 Subject: [PATCH 114/797] Add 8.5.6 CHANGELOG items [ci skip] --- CHANGELOG | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG b/CHANGELOG index 015efa05c6..ebf49cd2ac 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -39,6 +39,9 @@ v 8.6.0 (unreleased) - Move group activity to separate page - Continue parameters are checked to ensure redirection goes to the same instance +v 8.5.6 + - Obtain a lease before querying LDAP + v 8.5.5 - Ensure removing a project removes associated Todo entries - Prevent a 500 error in Todos when author was removed From 8acad49fe0a492c2f4e16756135679b3a190543b Mon Sep 17 00:00:00 2001 From: Hiroyuki Sato Date: Sun, 28 Feb 2016 21:11:43 +0900 Subject: [PATCH 115/797] Support Wiki with UTF-8 page name --- CHANGELOG | 1 + Gemfile | 4 +++- Gemfile.lock | 6 +++++- app/models/project_wiki.rb | 8 ++++---- app/models/wiki_page.rb | 2 +- config/initializers/gollum.rb | 13 +++++++++++++ 6 files changed, 27 insertions(+), 7 deletions(-) create mode 100644 config/initializers/gollum.rb diff --git a/CHANGELOG b/CHANGELOG index 015efa05c6..d19c694863 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -10,6 +10,7 @@ v 8.6.0 (unreleased) setup. A password can be provided during setup (see installation docs), or GitLab will ask the user to create a new one upon first visit. - Fix issue when pushing to projects ending in .wiki + - Add support for wiki with UTF-8 page names (Hiroyuki Sato) - Don't load all of GitLab in mail_room - Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set - Memoize @group in Admin::GroupsController (Yatish Mehta) diff --git a/Gemfile b/Gemfile index a0e8e79662..a849d7493a 100644 --- a/Gemfile +++ b/Gemfile @@ -58,7 +58,9 @@ gem "gitlab_git", '~> 9.0' gem 'gitlab_omniauth-ldap', '~> 1.2.1', require: "omniauth-ldap" # Git Wiki -gem 'gollum-lib', '~> 4.1.0' +# Required manually in config/initializers/gollum.rb to control load order +gem 'gollum-lib', '~> 4.1.0', require: false +gem 'gollum-rugged_adapter', '~> 0.4.2', require: false # Language detection gem "github-linguist", "~> 4.7.0", require: "linguist" diff --git a/Gemfile.lock b/Gemfile.lock index f4f5649eb7..669bfcf4d6 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -381,6 +381,9 @@ GEM rouge (~> 1.9) sanitize (~> 2.1.0) stringex (~> 2.5.1) + gollum-rugged_adapter (0.4.2) + mime-types (>= 1.15) + rugged (~> 0.24.0, >= 0.21.3) gon (6.0.1) actionpack (>= 3.0) json @@ -703,7 +706,7 @@ GEM rubyntlm (0.5.2) rubypants (0.2.0) rufus-scheduler (3.1.10) - rugged (0.24.0b13) + rugged (0.24.0) safe_yaml (1.0.4) sanitize (2.1.0) nokogiri (>= 1.4.4) @@ -941,6 +944,7 @@ DEPENDENCIES gitlab_meta (= 7.0) gitlab_omniauth-ldap (~> 1.2.1) gollum-lib (~> 4.1.0) + gollum-rugged_adapter (~> 0.4.2) gon (~> 6.0.1) grape (~> 0.13.0) grape-entity (~> 0.4.2) diff --git a/app/models/project_wiki.rb b/app/models/project_wiki.rb index c96e6f0b8e..59b1b86d1f 100644 --- a/app/models/project_wiki.rb +++ b/app/models/project_wiki.rb @@ -2,7 +2,7 @@ class ProjectWiki include Gitlab::ShellAdapter MARKUPS = { - 'Markdown' => :md, + 'Markdown' => :markdown, 'RDoc' => :rdoc, 'AsciiDoc' => :asciidoc } unless defined?(MARKUPS) @@ -47,7 +47,7 @@ class ProjectWiki def wiki @wiki ||= begin Gollum::Wiki.new(path_to_repo) - rescue Gollum::NoSuchPathError + rescue Rugged::OSError create_repo! end end @@ -90,7 +90,7 @@ class ProjectWiki def create_page(title, content, format = :markdown, message = nil) commit = commit_details(:created, message, title) - wiki.write_page(title, format, content, commit) + wiki.write_page(title, format.to_sym, content, commit) update_project_activity rescue Gollum::DuplicatePageError => e @@ -101,7 +101,7 @@ class ProjectWiki def update_page(page, content, format = :markdown, message = nil) commit = commit_details(:updated, message, page.title) - wiki.update_page(page, page.name, format, content, commit) + wiki.update_page(page, page.name, format.to_sym, content, commit) update_project_activity end diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index dbd70dc5a4..526760779a 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -62,7 +62,7 @@ class WikiPage # The raw content of this page. def content @attributes[:content] ||= if @page - @page.raw_data + @page.text_data end end diff --git a/config/initializers/gollum.rb b/config/initializers/gollum.rb new file mode 100644 index 0000000000..703f24f93b --- /dev/null +++ b/config/initializers/gollum.rb @@ -0,0 +1,13 @@ +module Gollum + GIT_ADAPTER = "rugged" +end +require "gollum-lib" + +module Gollum + class Committer + # Patch for UTF-8 path + def method_missing(name, *args) + index.send(name, *args) + end + end +end From 54ec7e959900493b6e9174bf4dfe09ed0afd1e46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 1 Mar 2016 17:33:13 +0100 Subject: [PATCH 116/797] Improving the original label-subscribing implementation 1. Make the "subscribed" text in Issuable sidebar reflect the labels subscription status 2. Current user mut be logged-in to toggle issue/MR/label subscription --- CHANGELOG | 3 +- app/assets/javascripts/subscription.js.coffee | 28 ++-- app/assets/stylesheets/pages/labels.scss | 4 +- app/controllers/projects/issues_controller.rb | 2 + app/controllers/projects/labels_controller.rb | 2 + .../projects/merge_requests_controller.rb | 2 + app/helpers/labels_helper.rb | 8 ++ app/mailers/emails/issues.rb | 25 ++-- app/mailers/emails/merge_requests.rb | 55 ++++---- app/models/concerns/issuable.rb | 10 +- app/models/concerns/subscribable.rb | 25 ++-- app/models/subscription.rb | 2 +- app/services/issuable_base_service.rb | 17 ++- app/services/issues/update_service.rb | 10 +- app/services/merge_requests/update_service.rb | 14 +- app/services/notification_service.rb | 72 +++++----- app/views/layouts/notify.html.haml | 13 +- .../_reassigned_issuable_email.text.erb | 2 +- .../_relabeled_issuable_email.html.haml | 4 +- .../notify/_relabeled_issuable_email.text.erb | 6 +- .../notify/relabeled_issue_email.html.haml | 2 +- .../notify/relabeled_issue_email.text.erb | 2 +- .../relabeled_merge_request_email.html.haml | 2 +- .../relabeled_merge_request_email.text.erb | 2 +- app/views/projects/labels/_label.html.haml | 16 +-- app/views/shared/issuable/_sidebar.html.haml | 4 +- features/project/labels.feature | 13 +- features/steps/project/labels.rb | 8 +- spec/factories/labels.rb | 2 +- spec/mailers/notify_spec.rb | 56 ++++++++ spec/mailers/shared/notify.rb | 6 +- spec/models/concerns/issuable_spec.rb | 42 ++++++ spec/models/concerns/subscribable_spec.rb | 51 ++++++- spec/services/issues/update_service_spec.rb | 55 +++----- .../merge_requests/update_service_spec.rb | 55 +++----- spec/services/notification_service_spec.rb | 129 +++++++++--------- 36 files changed, 439 insertions(+), 310 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fb9500e68a..e9e7c88d86 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -28,6 +28,7 @@ v 8.6.0 (unreleased) - Update documentation to reflect Guest role not being enforced on internal projects - Allow search for logged out users - Allow to define on which builds the current one depends on + - Allow user subscription to a label: get notified for issues/merge requests related to that label (Timothy Andrew) - Fix bug where Bitbucket `closed` issues were imported as `opened` (Iuri de Silvio) - Don't show Issues/MRs from archived projects in Groups view - Fix wrong "iid of max iid" in Issuable sidebar for some merged MRs @@ -174,8 +175,6 @@ v 8.5.0 v 8.4.5 - No CE-specific changes - - Allow user subscription to a label; get notified for issues/merge requests related to that label. - - Allow user subscription to a label; get notified for issues/merge requests related to that label. (Timothy Andrew) v 8.4.4 - Update omniauth-saml gem to 1.4.2 diff --git a/app/assets/javascripts/subscription.js.coffee b/app/assets/javascripts/subscription.js.coffee index afc17338b2..084f0e0dc6 100644 --- a/app/assets/javascripts/subscription.js.coffee +++ b/app/assets/javascripts/subscription.js.coffee @@ -1,19 +1,21 @@ class @Subscription - constructor: (@url, container) -> - @subscribe_button = $(container).find(".subscribe-button") - @subscription_status = $(container).find(".subscription-status") - @subscribe_button.unbind("click").click(@toggleSubscription) + constructor: (container) -> + $container = $(container) + @url = $container.attr('data-url') + @subscribe_button = $container.find('.subscribe-button') + @subscription_status = $container.find('.subscription-status') + @subscribe_button.unbind('click').click(@toggleSubscription) toggleSubscription: (event) => btn = $(event.currentTarget) - action = btn.find("span").text() - current_status = @subscription_status.attr("data-status") - btn.prop("disabled", true) + action = btn.find('span').text() + current_status = @subscription_status.attr('data-status') + btn.prop('disabled', true) $.post @url, => - btn.prop("disabled", false) - status = if current_status == "subscribed" then "unsubscribed" else "subscribed" - @subscription_status.attr("data-status", status) - action = if status == "subscribed" then "Unsubscribe" else "Subscribe" - btn.find("span").text(action) - @subscription_status.find(">div").toggleClass("hidden") + btn.prop('disabled', false) + status = if current_status == 'subscribed' then 'unsubscribed' else 'subscribed' + @subscription_status.attr('data-status', status) + action = if status == 'subscribed' then 'Unsubscribe' else 'Subscribe' + btn.find('span').text(action) + @subscription_status.find('>div').toggleClass('hidden') diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 1791ba9fbd..61ee34b695 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -43,5 +43,5 @@ } .label-subscription { - display: inline-block; -} \ No newline at end of file + display: inline-block; +} diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 67faa1e443..24a862814b 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -111,6 +111,8 @@ class Projects::IssuesController < Projects::ApplicationController end def toggle_subscription + return unless current_user + @issue.toggle_subscription(current_user) render nothing: true diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index d0334c37b6..e4dea6b065 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -61,6 +61,8 @@ class Projects::LabelsController < Projects::ApplicationController end def toggle_subscription + return unless current_user + @label.toggle_subscription(current_user) render nothing: true end diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 03ba289eb9..954ee55a21 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -234,6 +234,8 @@ class Projects::MergeRequestsController < Projects::ApplicationController end def toggle_subscription + return unless current_user + @merge_request.toggle_subscription(current_user) render nothing: true diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 89a054289e..4455dcd0e2 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -124,6 +124,14 @@ module LabelsHelper options_from_collection_for_select(grouped_labels, 'name', 'title', params[:label_name]) end + def label_subscription_status(label) + label.subscribed?(current_user) ? 'subscribed' : 'unsubscribed' + end + + def label_subscription_toggle_button_text(label) + label.subscribed?(current_user) ? 'Unsubscribe' : 'Subscribe' + end + # Required for Banzai::Filter::LabelReferenceFilter module_function :render_colored_label, :render_colored_cross_project_label, :text_color_for_bg, :escape_once diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 2838baa1b4..160b6df0b9 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -20,10 +20,11 @@ module Emails mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end - def relabeled_issue_email(recipient_id, issue_id, updated_by_user_id, label_names) - setup_issue_mail(issue_id, recipient_id) + def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id) + setup_issue_mail(issue_id, recipient_id, sent_notification: false) + @label_names = label_names - @updated_by = User.find(updated_by_user_id) + @labels_url = namespace_project_labels_url(@project.namespace, @project) mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end @@ -37,6 +38,16 @@ module Emails private + def setup_issue_mail(issue_id, recipient_id, sent_notification: true) + @issue = Issue.find(issue_id) + @project = @issue.project + @target_url = namespace_project_issue_url(@project.namespace, @project, @issue) + + if sent_notification + @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) + end + end + def issue_thread_options(sender_id, recipient_id) { from: sender(sender_id), @@ -44,13 +55,5 @@ module Emails subject: subject("#{@issue.title} (##{@issue.iid})") } end - - def setup_issue_mail(issue_id, recipient_id) - @issue = Issue.find(issue_id) - @project = @issue.project - @target_url = namespace_project_issue_url(@project.namespace, @project, @issue) - - @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) - end end end diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 680ce975c7..334bad4e2f 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -3,49 +3,35 @@ module Emails def new_merge_request_email(recipient_id, merge_request_id) setup_merge_request_mail(merge_request_id, recipient_id) - mail_new_thread(@merge_request, - from: sender(@merge_request.author_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_new_thread(@merge_request, merge_request_thread_options(@merge_request.author_id, recipient_id)) end def reassigned_merge_request_email(recipient_id, merge_request_id, previous_assignee_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) @previous_assignee = User.find_by(id: previous_assignee_id) if previous_assignee_id - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end - def relabeled_merge_request_email(recipient_id, merge_request_id, updated_by_user_id, label_names) - setup_merge_request_mail(merge_request_id, recipient_id) + def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id) + setup_merge_request_mail(merge_request_id, recipient_id, sent_notification: false) + @label_names = label_names - @updated_by = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, - from: sender(@merge_request.author_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + @labels_url = namespace_project_labels_url(@project.namespace, @project) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end def closed_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) @updated_by = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end def merged_merge_request_email(recipient_id, merge_request_id, updated_by_user_id) setup_merge_request_mail(merge_request_id, recipient_id) - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end def merge_request_status_email(recipient_id, merge_request_id, status, updated_by_user_id) @@ -53,22 +39,27 @@ module Emails @mr_status = status @updated_by = User.find(updated_by_user_id) - mail_answer_thread(@merge_request, - from: sender(updated_by_user_id), - to: recipient(recipient_id), - subject: subject("#{@merge_request.title} (##{@merge_request.iid})")) + mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, recipient_id)) end private - def setup_merge_request_mail(merge_request_id, recipient_id) + def setup_merge_request_mail(merge_request_id, recipient_id, sent_notification: true) @merge_request = MergeRequest.find(merge_request_id) @project = @merge_request.project - @target_url = namespace_project_merge_request_url(@project.namespace, - @project, - @merge_request) + @target_url = namespace_project_merge_request_url(@project.namespace, @project, @merge_request) - @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) + if sent_notification + @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) + end + end + + def merge_request_thread_options(sender_id, recipient_id) + { + from: sender(sender_id), + to: recipient(recipient_id), + subject: subject("#{@merge_request.title} (##{@merge_request.iid})") + } end end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index affc4a842a..86ab84615b 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -149,6 +149,10 @@ module Issuable notes.awards.where(note: "thumbsup").count end + def subscribed_without_subscriptions?(user) + participants(user).include?(user) + end + def to_hook_data(user) hook_data = { object_kind: self.class.name.underscore, @@ -179,12 +183,6 @@ module Issuable end end - # Labels that are currently applied to this object - # that are not present in `old_labels` - def added_labels(old_labels) - self.labels - old_labels - end - # Convert this Issuable class name to a format usable by Ability definitions # # Examples: diff --git a/app/models/concerns/subscribable.rb b/app/models/concerns/subscribable.rb index cab9241ac3..d5a881b244 100644 --- a/app/models/concerns/subscribable.rb +++ b/app/models/concerns/subscribable.rb @@ -13,20 +13,21 @@ module Subscribable end def subscribed?(user) - subscription = subscriptions.find_by_user_id(user.id) - - if subscription - return subscription.subscribed + if subscription = subscriptions.find_by_user_id(user.id) + subscription.subscribed + else + subscribed_without_subscriptions?(user) end + end - # FIXME - # Issue/MergeRequest has participants, but Label doesn't. - # Ideally, subscriptions should be separate from participations, - # but that seems like a larger change with farther-reaching - # consequences, so this is a compromise for the time being. - if respond_to?(:participants) - participants(user).include?(user) - end + # Override this method to define custom logic to consider a subscribable as + # subscribed without an explicit subscription record. + def subscribed_without_subscriptions?(user) + false + end + + def subscribers + subscriptions.where(subscribed: true).map(&:user) end def toggle_subscription(user) diff --git a/app/models/subscription.rb b/app/models/subscription.rb index dd75d3ab8b..dd800ce110 100644 --- a/app/models/subscription.rb +++ b/app/models/subscription.rb @@ -15,7 +15,7 @@ class Subscription < ActiveRecord::Base belongs_to :user belongs_to :subscribable, polymorphic: true - validates :user_id, + validates :user_id, uniqueness: { scope: [:subscribable_id, :subscribable_type] }, presence: true end diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb index 971c074882..18f76d3f65 100644 --- a/app/services/issuable_base_service.rb +++ b/app/services/issuable_base_service.rb @@ -11,7 +11,10 @@ class IssuableBaseService < BaseService issuable, issuable.project, current_user, issuable.milestone) end - def create_labels_note(issuable, added_labels, removed_labels) + def create_labels_note(issuable, old_labels) + added_labels = issuable.labels - old_labels + removed_labels = old_labels - issuable.labels + SystemNoteService.change_label( issuable, issuable.project, current_user, added_labels, removed_labels) end @@ -71,20 +74,19 @@ class IssuableBaseService < BaseService end end - def has_changes?(issuable, options = {}) + def has_changes?(issuable, old_labels: []) valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch] attrs_changed = valid_attrs.any? do |attr| issuable.previous_changes.include?(attr.to_s) end - old_labels = options[:old_labels] - labels_changed = old_labels && issuable.labels != old_labels + labels_changed = issuable.labels != old_labels attrs_changed || labels_changed end - def handle_common_system_notes(issuable, options = {}) + def handle_common_system_notes(issuable, old_labels: []) if issuable.previous_changes.include?('title') create_title_change_note(issuable, issuable.previous_changes['title'].first) end @@ -93,9 +95,6 @@ class IssuableBaseService < BaseService create_task_status_note(issuable) end - old_labels = options[:old_labels] - if old_labels && (issuable.labels != old_labels) - create_labels_note(issuable, issuable.added_labels(old_labels), old_labels - issuable.labels) - end + create_labels_note(issuable, old_labels) if issuable.labels != old_labels end end diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index b2e63a4e1a..3563cbaa99 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -4,8 +4,8 @@ module Issues update(issue) end - def handle_changes(issue, old_labels: [], new_labels: []) - if has_changes?(issue, options) + def handle_changes(issue, old_labels: []) + if has_changes?(issue, old_labels: old_labels) todo_service.mark_pending_todos_as_done(issue, current_user) end @@ -24,9 +24,9 @@ module Issues todo_service.reassigned_issue(issue, current_user) end - new_labels = issue.added_labels(old_labels) - if new_labels.present? - notification_service.relabeled_issue(issue, new_labels, current_user) + added_labels = issue.labels - old_labels + if added_labels.present? + notification_service.relabeled_issue(issue, added_labels, current_user) end end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 6fd569dc30..477c64e737 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -14,8 +14,8 @@ module MergeRequests update(merge_request) end - def handle_changes(issue, old_labels: [], new_labels: []) - if has_changes?(merge_request, options) + def handle_changes(merge_request, old_labels: []) + if has_changes?(merge_request, old_labels: old_labels) todo_service.mark_pending_todos_as_done(merge_request, current_user) end @@ -45,9 +45,13 @@ module MergeRequests merge_request.mark_as_unchecked end - new_labels = merge_request.added_labels(old_labels) - if new_labels.present? - notification_service.relabeled_merge_request(merge_request, new_labels, current_user) + added_labels = merge_request.labels - old_labels + if added_labels.present? + notification_service.relabeled_merge_request( + merge_request, + added_labels, + current_user + ) end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index e9955cd3e2..19a6779dea 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -24,16 +24,17 @@ class NotificationService end end - # When create an issue we should send next emails: + # When create an issue we should send an email to: # # * issue assignee if their notification level is not Disabled # * project team members with notification level higher then Participating + # * watchers of the issue's labels # def new_issue(issue, current_user) new_resource_email(issue, issue.project, 'new_issue_email') end - # When we close an issue we should send next emails: + # When we close an issue we should send an email to: # # * issue author if their notification level is not Disabled # * issue assignee if their notification level is not Disabled @@ -43,7 +44,7 @@ class NotificationService close_resource_email(issue, issue.project, current_user, 'closed_issue_email') end - # When we reassign an issue we should send next emails: + # When we reassign an issue we should send an email to: # # * issue old assignee if their notification level is not Disabled # * issue new assignee if their notification level is not Disabled @@ -52,24 +53,25 @@ class NotificationService reassign_resource_email(issue, issue.project, current_user, 'reassigned_issue_email') end - # When we change labels on an issue we should send emails. + # When we add labels to an issue we should send an email to: # - # We pass in the labels, here, because we only want the labels that - # have been *added* during this relabel, not all of them. - def relabeled_issue(issue, labels, current_user) - relabel_resource_email(issue, issue.project, labels, current_user, 'relabeled_issue_email') + # * watchers of the issue's labels + # + def relabeled_issue(issue, added_labels, current_user) + relabeled_resource_email(issue, added_labels, current_user, 'relabeled_issue_email') end - - # When create a merge request we should send next emails: + # When create a merge request we should send an email to: # # * mr assignee if their notification level is not Disabled + # * project team members with notification level higher then Participating + # * watchers of the mr's labels # def new_merge_request(merge_request, current_user) new_resource_email(merge_request, merge_request.target_project, 'new_merge_request_email') end - # When we reassign a merge_request we should send next emails: + # When we reassign a merge_request we should send an email to: # # * merge_request old assignee if their notification level is not Disabled # * merge_request assignee if their notification level is not Disabled @@ -78,12 +80,12 @@ class NotificationService reassign_resource_email(merge_request, merge_request.target_project, current_user, 'reassigned_merge_request_email') end - # When we change labels on a merge request we should send emails. + # When we add labels to a merge request we should send an email to: # - # We pass in the labels, here, because we only want the labels that - # have been *added* during this relabel, not all of them. - def relabeled_merge_request(merge_request, labels, current_user) - relabel_resource_email(merge_request, merge_request.project, labels, current_user, 'relabeled_merge_request_email') + # * watchers of the mr's labels + # + def relabeled_merge_request(merge_request, added_labels, current_user) + relabeled_resource_email(merge_request, added_labels, current_user, 'relabeled_merge_request_email') end def close_mr(merge_request, current_user) @@ -107,7 +109,8 @@ class NotificationService reopen_resource_email( merge_request, merge_request.target_project, - current_user, 'merge_request_status_email', + current_user, + 'merge_request_status_email', 'reopened' ) end @@ -158,7 +161,6 @@ class NotificationService recipients = reject_muted_users(recipients, note.project) recipients = add_subscribed_users(recipients, note.noteable) - recipients = add_label_subscriptions(recipients, note.noteable) recipients = reject_unsubscribed_users(recipients, note.noteable) recipients.delete(note.author) @@ -365,29 +367,23 @@ class NotificationService end def add_subscribed_users(recipients, target) - return recipients unless target.respond_to? :subscriptions + return recipients unless target.respond_to? :subscribers - subscriptions = target.subscriptions - - if subscriptions.any? - recipients + subscriptions.where(subscribed: true).map(&:user) - else - recipients - end + recipients + target.subscribers end - def add_label_subscriptions(recipients, target) + def add_labels_subscribers(recipients, target, labels: nil) return recipients unless target.respond_to? :labels - target.labels.each do |label| - recipients += label.subscriptions.where(subscribed: true).map(&:user) + (labels || target.labels).each do |label| + recipients += label.subscribers end recipients end def new_resource_email(target, project, method) - recipients = build_recipients(target, project, target.author) + recipients = build_recipients(target, project, target.author, action: :new) recipients.each do |recipient| mailer.send(method, recipient.id, target.id).deliver_later @@ -419,12 +415,12 @@ class NotificationService end end - def relabel_resource_email(target, project, labels, current_user, method) - recipients = build_relabel_recipients(target, project, labels, current_user) + def relabeled_resource_email(target, labels, current_user, method) + recipients = build_relabeled_recipients(target, current_user, labels: labels) label_names = labels.map(&:name) recipients.each do |recipient| - mailer.send(method, recipient.id, target.id, current_user.id, label_names).deliver_later + mailer.send(method, recipient.id, target.id, label_names, current_user.id).deliver_later end end @@ -452,7 +448,11 @@ class NotificationService recipients = reject_muted_users(recipients, project) recipients = add_subscribed_users(recipients, target) - recipients = add_label_subscriptions(recipients, target) + + if action == :new + recipients = add_labels_subscribers(recipients, target) + end + recipients = reject_unsubscribed_users(recipients, target) recipients.delete(current_user) @@ -460,8 +460,8 @@ class NotificationService recipients.uniq end - def build_relabel_recipients(target, project, labels, current_user) - recipients = add_label_subscriptions([], target) + def build_relabeled_recipients(target, current_user, labels:) + recipients = add_labels_subscribers([], target, labels: labels) recipients = reject_unsubscribed_users(recipients, target) recipients.delete(current_user) recipients.uniq diff --git a/app/views/layouts/notify.html.haml b/app/views/layouts/notify.html.haml index 325c68c69d..37b4d56296 100644 --- a/app/views/layouts/notify.html.haml +++ b/app/views/layouts/notify.html.haml @@ -42,12 +42,15 @@ - else #{link_to "View it on GitLab", @target_url}. %br - -# Don't link the host is the line below, one link in the email is easier to quickly click than two. + -# Don't link the host in the line below, one link in the email is easier to quickly click than two. You're receiving this email because of your account on #{Gitlab.config.gitlab.host}. If you'd like to receive fewer emails, you can - - if @sent_notification && @sent_notification.unsubscribable? - = link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification) - from this thread or - adjust your notification settings. + - if @labels_url + adjust your #{link_to 'label subscriptions', @labels_url}. + - else + - if @sent_notification && @sent_notification.unsubscribable? + = link_to "unsubscribe", unsubscribe_sent_notification_url(@sent_notification) + from this thread or + adjust your notification settings. = email_action @target_url diff --git a/app/views/notify/_reassigned_issuable_email.text.erb b/app/views/notify/_reassigned_issuable_email.text.erb index 855d37429d..daf20a226d 100644 --- a/app/views/notify/_reassigned_issuable_email.text.erb +++ b/app/views/notify/_reassigned_issuable_email.text.erb @@ -1,6 +1,6 @@ Reassigned <%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> -<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, {only_path: false}]) %> +<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> Assignee changed <%= "from #{@previous_assignee.name}" if @previous_assignee -%> to <%= "#{issuable.assignee_id ? issuable.assignee_name : 'Unassigned'}" %> diff --git a/app/views/notify/_relabeled_issuable_email.html.haml b/app/views/notify/_relabeled_issuable_email.html.haml index a41ff07c30..80a0de255b 100644 --- a/app/views/notify/_relabeled_issuable_email.html.haml +++ b/app/views/notify/_relabeled_issuable_email.html.haml @@ -1,5 +1,3 @@ %p - #{@updated_by.name} added the + #{'Label'.pluralize(@label_names.size)} added: %em= @label_names.to_sentence - #{"label".pluralize(@label_names.count)} to #{issuable.class.model_name.human} #{issuable.iid}. - diff --git a/app/views/notify/_relabeled_issuable_email.text.erb b/app/views/notify/_relabeled_issuable_email.text.erb index 1a28d7fd35..6a83d79fd6 100644 --- a/app/views/notify/_relabeled_issuable_email.text.erb +++ b/app/views/notify/_relabeled_issuable_email.text.erb @@ -1,5 +1,3 @@ -<%= issuable.class.model_name.human.titleize %> <%= issuable.iid %> was relabeled. +<%= 'Label'.pluralize(@label_names.size) %> added: <%= @label_names.to_sentence %> -Issue <%= issuable.iid %>: <%= url_for(namespace_project_issue_url(issuable.project.namespace, issuable.project, issuable)) %> -Author: <%= issuable.author_name %> -New Labels: <%= @label_names.to_sentence %> +<%= url_for([issuable.project.namespace.becomes(Namespace), issuable.project, issuable, { only_path: false }]) %> diff --git a/app/views/notify/relabeled_issue_email.html.haml b/app/views/notify/relabeled_issue_email.html.haml index a3e094e86e..b17b16e181 100644 --- a/app/views/notify/relabeled_issue_email.html.haml +++ b/app/views/notify/relabeled_issue_email.html.haml @@ -1 +1 @@ -= render "relabeled_issuable_email", issuable: @issue += render 'relabeled_issuable_email', issuable: @issue diff --git a/app/views/notify/relabeled_issue_email.text.erb b/app/views/notify/relabeled_issue_email.text.erb index de7f04a323..eeced97f60 100644 --- a/app/views/notify/relabeled_issue_email.text.erb +++ b/app/views/notify/relabeled_issue_email.text.erb @@ -1 +1 @@ -<%= render "relabeled_issuable_email", issuable: @issue %> +<%= render 'relabeled_issuable_email', issuable: @issue %> diff --git a/app/views/notify/relabeled_merge_request_email.html.haml b/app/views/notify/relabeled_merge_request_email.html.haml index 3937b449a3..9eaa9afa5b 100644 --- a/app/views/notify/relabeled_merge_request_email.html.haml +++ b/app/views/notify/relabeled_merge_request_email.html.haml @@ -1 +1 @@ -= render "relabeled_issuable_email", issuable: @merge_request += render 'relabeled_issuable_email', issuable: @merge_request diff --git a/app/views/notify/relabeled_merge_request_email.text.erb b/app/views/notify/relabeled_merge_request_email.text.erb index 5c4d86e4dc..87bc80ead3 100644 --- a/app/views/notify/relabeled_merge_request_email.text.erb +++ b/app/views/notify/relabeled_merge_request_email.text.erb @@ -1 +1 @@ -<%= render "relabeled_issuable_email", issuable: @merge_request %> +<%= render 'relabeled_issuable_email', issuable: @merge_request %> diff --git a/app/views/projects/labels/_label.html.haml b/app/views/projects/labels/_label.html.haml index 3b14a925c4..4927d239c1 100644 --- a/app/views/projects/labels/_label.html.haml +++ b/app/views/projects/labels/_label.html.haml @@ -11,17 +11,15 @@ = pluralize label.open_issues_count, 'open issue' - if current_user - %div{class: "label-subscription", data: {id: label.id}} - - subscribed = label.subscribed?(current_user) - - subscription_status = subscribed ? 'subscribed' : 'unsubscribed' - .subscription-status{data: {status: subscription_status}} - %button.btn.btn-sm.btn-info.subscribe-button{:type => 'button'} - %span= subscribed ? 'Unsubscribe' : 'Subscribe' + .label-subscription{data: {url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)}} + .subscription-status{data: {status: label_subscription_status(label)}} + %button.btn.btn-sm.btn-info.subscribe-button + %span= label_subscription_toggle_button_text(label) - if can? current_user, :admin_label, @project = link_to 'Edit', edit_namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm' = link_to 'Delete', namespace_project_label_path(@project.namespace, @project, label), class: 'btn btn-sm btn-remove remove-row', method: :delete, remote: true, data: {confirm: "Remove this label? Are you sure?"} -:javascript - new Subscription("#{toggle_subscription_namespace_project_label_path(@project.namespace, @project, label)}", - ".label-subscription[data-id='#{label.id}']"); +- if current_user + :javascript + new Subscription('##{dom_id(label)} .label-subscription'); diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index b44086d420..23b1ed1e51 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -98,7 +98,7 @@ %hr - if current_user - subscribed = issuable.subscribed?(current_user) - .block.light.subscription + .block.light.subscription{data: {url: toggle_subscription_path(issuable)}} .sidebar-collapsed-icon = icon('rss') .title.hide-collapsed @@ -124,5 +124,5 @@ = clipboard_button(clipboard_text: project_ref) :javascript - new Subscription("#{toggle_subscription_path(issuable)}", ".subscription"); + new Subscription('.subscription'); new IssuableContext(); diff --git a/features/project/labels.feature b/features/project/labels.feature index cd3f3a789f..955bc3d8b1 100644 --- a/features/project/labels.feature +++ b/features/project/labels.feature @@ -3,14 +3,13 @@ Feature: Labels Background: Given I sign in as a user And I own project "Shop" - And I visit project "Shop" issues page And project "Shop" has labels: "bug", "feature", "enhancement" + When I visit project "Shop" labels page @javascript Scenario: I can subscribe to a label - When I visit project "Shop" labels page - Then I should see that I am unsubscribed - When I click button "Subscribe" - Then I should see that I am subscribed - When I click button "Unsubscribe" - Then I should see that I am unsubscribed + Then I should see that I am not subscribed to the "bug" label + When I click button "Subscribe" for the "bug" label + Then I should see that I am subscribed to the "bug" label + When I click button "Unsubscribe" for the "bug" label + Then I should see that I am not subscribed to the "bug" label diff --git a/features/steps/project/labels.rb b/features/steps/project/labels.rb index 3f800a1059..17944527e3 100644 --- a/features/steps/project/labels.rb +++ b/features/steps/project/labels.rb @@ -10,19 +10,19 @@ class Spinach::Features::Labels < Spinach::FeatureSteps visit namespace_project_labels_path(project.namespace, project) end - step 'I should see that I am subscribed' do + step 'I should see that I am subscribed to the "bug" label' do expect(subscribe_button).to have_content 'Unsubscribe' end - step 'I should see that I am unsubscribed' do + step 'I should see that I am not subscribed to the "bug" label' do expect(subscribe_button).to have_content 'Subscribe' end - step 'I click button "Unsubscribe"' do + step 'I click button "Unsubscribe" for the "bug" label' do subscribe_button.click end - step 'I click button "Subscribe"' do + step 'I click button "Subscribe" for the "bug" label' do subscribe_button.click end diff --git a/spec/factories/labels.rb b/spec/factories/labels.rb index 91a7400afa..ea2be8928d 100644 --- a/spec/factories/labels.rb +++ b/spec/factories/labels.rb @@ -13,7 +13,7 @@ FactoryGirl.define do factory :label do - title { FFaker::Color.name } + sequence(:title) { |n| "label#{n}" } color "#990000" project end diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index 232a11245a..f910424d85 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -100,6 +100,34 @@ describe Notify do end end + describe 'that have been relabeled' do + subject { Notify.relabeled_issue_email(recipient.id, issue.id, %w[foo bar baz], current_user.id) } + + it_behaves_like 'a multiple recipients email' + it_behaves_like 'an answer to an existing thread', 'issue' + it_behaves_like 'it should show Gmail Actions View Issue link' + it_behaves_like 'a user cannot unsubscribe through footer link' + it_behaves_like 'an email with a labels subscriptions link in its footer' + + it 'is sent as the author' do + sender = subject.header[:from].addrs[0] + expect(sender.display_name).to eq(current_user.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject' do + is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/ + end + + it 'contains the names of the added labels' do + is_expected.to have_body_text /foo, bar, and baz/ + end + + it 'contains a link to the issue' do + is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + end + end + describe 'status changed' do let(:status) { 'closed' } subject { Notify.issue_status_changed_email(recipient.id, issue.id, status, current_user.id) } @@ -219,6 +247,34 @@ describe Notify do end end + describe 'that have been relabeled' do + subject { Notify.relabeled_merge_request_email(recipient.id, merge_request.id, %w[foo bar baz], current_user.id) } + + it_behaves_like 'a multiple recipients email' + it_behaves_like 'an answer to an existing thread', 'merge_request' + it_behaves_like 'it should show Gmail Actions View Merge request link' + it_behaves_like 'a user cannot unsubscribe through footer link' + it_behaves_like 'an email with a labels subscriptions link in its footer' + + it 'is sent as the author' do + sender = subject.header[:from].addrs[0] + expect(sender.display_name).to eq(current_user.name) + expect(sender.address).to eq(gitlab_sender) + end + + it 'has the correct subject' do + is_expected.to have_subject /#{merge_request.title} \(##{merge_request.iid}\)/ + end + + it 'contains the names of the added labels' do + is_expected.to have_body_text /foo, bar, and baz/ + end + + it 'contains a link to the merge request' do + is_expected.to have_body_text /#{namespace_project_merge_request_path project.namespace, project, merge_request}/ + end + end + describe 'status changed' do let(:status) { 'reopened' } subject { Notify.merge_request_status_email(recipient.id, merge_request.id, status, current_user.id) } diff --git a/spec/mailers/shared/notify.rb b/spec/mailers/shared/notify.rb index 48c851ebbd..6019af544d 100644 --- a/spec/mailers/shared/notify.rb +++ b/spec/mailers/shared/notify.rb @@ -112,6 +112,10 @@ shared_examples 'an unsubscribeable thread' do it { is_expected.to have_body_text /unsubscribe/ } end -shared_examples "a user cannot unsubscribe through footer link" do +shared_examples 'a user cannot unsubscribe through footer link' do it { is_expected.not_to have_body_text /unsubscribe/ } end + +shared_examples 'an email with a labels subscriptions link in its footer' do + it { is_expected.to have_body_text /label subscriptions/ } +end diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index aff384c294..be29b6d66f 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -113,6 +113,48 @@ describe Issue, "Issuable" do end end + describe '#subscribed?' do + context 'user is not a participant in the issue' do + before { allow(issue).to receive(:participants).with(user).and_return([]) } + + it 'returns false when no subcription exists' do + expect(issue.subscribed?(user)).to be_falsey + end + + it 'returns true when a subcription exists and subscribed is true' do + issue.subscriptions.create(user: user, subscribed: true) + + expect(issue.subscribed?(user)).to be_truthy + end + + it 'returns false when a subcription exists and subscribed is false' do + issue.subscriptions.create(user: user, subscribed: false) + + expect(issue.subscribed?(user)).to be_falsey + end + end + + context 'user is a participant in the issue' do + before { allow(issue).to receive(:participants).with(user).and_return([user]) } + + it 'returns false when no subcription exists' do + expect(issue.subscribed?(user)).to be_truthy + end + + it 'returns true when a subcription exists and subscribed is true' do + issue.subscriptions.create(user: user, subscribed: true) + + expect(issue.subscribed?(user)).to be_truthy + end + + it 'returns false when a subcription exists and subscribed is false' do + issue.subscriptions.create(user: user, subscribed: false) + + expect(issue.subscribed?(user)).to be_falsey + end + end + end + describe "#to_hook_data" do let(:data) { issue.to_hook_data(user) } let(:project) { issue.project } diff --git a/spec/models/concerns/subscribable_spec.rb b/spec/models/concerns/subscribable_spec.rb index 9ee60426a5..e31fdb0bff 100644 --- a/spec/models/concerns/subscribable_spec.rb +++ b/spec/models/concerns/subscribable_spec.rb @@ -1,15 +1,56 @@ -require "spec_helper" +require 'spec_helper' -describe Subscribable, "Subscribable" do +describe Subscribable, 'Subscribable' do let(:resource) { create(:issue) } let(:user) { create(:user) } - describe "#subscribed?" do - it do + describe '#subscribed?' do + it 'returns false when no subcription exists' do expect(resource.subscribed?(user)).to be_falsey - resource.toggle_subscription(user) + end + + it 'returns true when a subcription exists and subscribed is true' do + resource.subscriptions.create(user: user, subscribed: true) + expect(resource.subscribed?(user)).to be_truthy + end + + it 'returns false when a subcription exists and subscribed is false' do + resource.subscriptions.create(user: user, subscribed: false) + + expect(resource.subscribed?(user)).to be_falsey + end + end + describe '#subscribers' do + it 'returns [] when no subcribers exists' do + expect(resource.subscribers).to be_empty + end + + it 'returns the subscribed users' do + resource.subscriptions.create(user: user, subscribed: true) + resource.subscriptions.create(user: create(:user), subscribed: false) + + expect(resource.subscribers).to eq [user] + end + end + + describe '#toggle_subscription' do + it 'toggles the current subscription state for the given user' do + expect(resource.subscribed?(user)).to be_falsey + resource.toggle_subscription(user) + + expect(resource.subscribed?(user)).to be_truthy + end + end + + describe '#unsubscribe' do + it 'unsubscribes the given current user' do + resource.subscriptions.create(user: user, subscribed: true) + expect(resource.subscribed?(user)).to be_truthy + + resource.unsubscribe(user) + expect(resource.subscribed?(user)).to be_falsey end end diff --git a/spec/services/issues/update_service_spec.rb b/spec/services/issues/update_service_spec.rb index dc9d832975..4ffe753fef 100644 --- a/spec/services/issues/update_service_spec.rb +++ b/spec/services/issues/update_service_spec.rb @@ -6,6 +6,7 @@ describe Issues::UpdateService, services: true do let(:user3) { create(:user) } let(:issue) { create(:issue, title: 'Old title', assignee_id: user3.id) } let(:label) { create(:label) } + let(:label2) { create(:label) } let(:project) { issue.project } before do @@ -148,57 +149,45 @@ describe Issues::UpdateService, services: true do end end - context "when the issue is relabeled" do - it "sends notifications for subscribers of newly added labels" do - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + context 'when the issue is relabeled' do + let!(:non_subscriber) { create(:user) } + let!(:subscriber) { create(:user).tap { |u| label.toggle_subscription(u) } } + it 'sends notifications for subscribers of newly added labels' do opts = { label_ids: [label.id] } perform_enqueued_jobs do @issue = Issues::UpdateService.new(project, user, opts).execute(issue) end - @issue.reload should_email(subscriber) should_not_email(non_subscriber) end - it "does send notifications for existing labels" do - second_label = create(:label) - issue.labels << label - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + context 'when issue has the `label` label' do + before { issue.labels << label } - opts = { label_ids: [label.id, second_label.id] } + it 'does not send notifications for existing labels' do + opts = { label_ids: [label.id, label2.id] } - perform_enqueued_jobs do - @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end + + should_not_email(subscriber) + should_not_email(non_subscriber) end - @issue.reload - should_email(subscriber) - should_not_email(non_subscriber) - end + it 'does not send notifications for removed labels' do + opts = { label_ids: [label2.id] } - it "does not send notifications for removed labels" do - second_label = create(:label) - issue.labels << label - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + perform_enqueued_jobs do + @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + end - opts = { label_ids: [second_label.id] } - - perform_enqueued_jobs do - @issue = Issues::UpdateService.new(project, user, opts).execute(issue) + should_not_email(subscriber) + should_not_email(non_subscriber) end - - @issue.reload - should_not_email(subscriber) - should_not_email(non_subscriber) end end diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index 104e63ccfe..cb8cff2fa8 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -7,6 +7,7 @@ describe MergeRequests::UpdateService, services: true do let(:merge_request) { create(:merge_request, :simple, title: 'Old title', assignee_id: user3.id) } let(:project) { merge_request.project } let(:label) { create(:label) } + let(:label2) { create(:label) } before do project.team << [user, :master] @@ -176,57 +177,45 @@ describe MergeRequests::UpdateService, services: true do end end - context "when the merge request is relabeled" do - it "sends notifications for subscribers of newly added labels" do - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + context 'when the issue is relabeled' do + let!(:non_subscriber) { create(:user) } + let!(:subscriber) { create(:user).tap { |u| label.toggle_subscription(u) } } + it 'sends notifications for subscribers of newly added labels' do opts = { label_ids: [label.id] } perform_enqueued_jobs do @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) end - @merge_request.reload should_email(subscriber) should_not_email(non_subscriber) end - it "does send notifications for existing labels" do - second_label = create(:label) - merge_request.labels << label - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + context 'when issue has the `label` label' do + before { merge_request.labels << label } - opts = { label_ids: [label.id, second_label.id] } + it 'does not send notifications for existing labels' do + opts = { label_ids: [label.id, label2.id] } - perform_enqueued_jobs do - @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end + + should_not_email(subscriber) + should_not_email(non_subscriber) end - @merge_request.reload - should_email(subscriber) - should_not_email(non_subscriber) - end + it 'does not send notifications for removed labels' do + opts = { label_ids: [label2.id] } - it "does not send notifications for removed labels" do - second_label = create(:label) - merge_request.labels << label - subscriber, non_subscriber = create_list(:user, 2) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } + perform_enqueued_jobs do + @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + end - opts = { label_ids: [second_label.id] } - - perform_enqueued_jobs do - @merge_request = MergeRequests::UpdateService.new(project, user, opts).execute(merge_request) + should_not_email(subscriber) + should_not_email(non_subscriber) end - - @merge_request.reload - should_not_email(subscriber) - should_not_email(non_subscriber) end end diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 35afa76805..b5407397c1 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -225,14 +225,13 @@ describe NotificationService, services: true do should_not_email(issue.assignee) end - it "should email subscribers of the issue's labels" do - subscriber, non_subscriber = create_list(:user, 2) + it "emails subscribers of the issue's labels" do + subscriber = create(:user) label = create(:label, issues: [issue]) label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } notification.new_issue(issue, @u_disabled) + should_email(subscriber) - should_not_email(non_subscriber) end end @@ -306,6 +305,35 @@ describe NotificationService, services: true do end end + describe '#relabeled_issue' do + let(:label) { create(:label, issues: [issue]) } + let(:label2) { create(:label) } + let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } } + let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } } + + it "emails subscribers of the issue's added labels only" do + notification.relabeled_issue(issue, [label2], @u_disabled) + + should_not_email(subscriber_to_label) + should_email(subscriber_to_label2) + end + + it "doesn't send email to anyone but subscribers of the given labels" do + notification.relabeled_issue(issue, [label2], @u_disabled) + + should_not_email(issue.assignee) + should_not_email(issue.author) + should_not_email(@u_watcher) + should_not_email(@u_participant_mentioned) + should_not_email(@subscriber) + should_not_email(@watcher_and_subscriber) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + should_not_email(subscriber_to_label) + should_email(subscriber_to_label2) + end + end + describe :close_issue do it 'should sent email to issue assignee and issue author' do notification.close_issue(issue, @u_disabled) @@ -336,32 +364,6 @@ describe NotificationService, services: true do should_not_email(@u_participating) end end - - describe :relabel_issue do - it "sends email to subscribers of the given labels" do - subscriber, non_subscriber = create_list(:user, 2) - label = create(:label, issues: [issue]) - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } - notification.relabeled_issue(issue, [label], @u_disabled) - should_email(subscriber) - should_not_email(non_subscriber) - end - - it "doesn't send email to anyone but subscribers of the given labels" do - label = create(:label, issues: [issue]) - notification.relabeled_issue(issue, [label], @u_disabled) - - should_not_email(issue.assignee) - should_not_email(issue.author) - should_not_email(@u_watcher) - should_not_email(@u_participant_mentioned) - should_not_email(@subscriber) - should_not_email(@watcher_and_subscriber) - should_not_email(@unsubscriber) - should_not_email(@u_participating) - end - end end describe 'Merge Requests' do @@ -386,15 +388,13 @@ describe NotificationService, services: true do should_not_email(@u_disabled) end - it "should email subscribers of the MR's labels" do - subscriber, non_subscriber = create_list(:user, 2) - label = create(:label) - merge_request.labels << label + it "emails subscribers of the merge request's labels" do + subscriber = create(:user) + label = create(:label, merge_requests: [merge_request]) label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } notification.new_merge_request(merge_request, @u_disabled) + should_email(subscriber) - should_not_email(non_subscriber) end end @@ -413,6 +413,35 @@ describe NotificationService, services: true do end end + describe :relabel_merge_request do + let(:label) { create(:label, merge_requests: [merge_request]) } + let(:label2) { create(:label) } + let!(:subscriber_to_label) { create(:user).tap { |u| label.toggle_subscription(u) } } + let!(:subscriber_to_label2) { create(:user).tap { |u| label2.toggle_subscription(u) } } + + it "emails subscribers of the merge request's added labels only" do + notification.relabeled_merge_request(merge_request, [label2], @u_disabled) + + should_not_email(subscriber_to_label) + should_email(subscriber_to_label2) + end + + it "doesn't send email to anyone but subscribers of the given labels" do + notification.relabeled_merge_request(merge_request, [label2], @u_disabled) + + should_not_email(merge_request.assignee) + should_not_email(merge_request.author) + should_not_email(@u_watcher) + should_not_email(@u_participant_mentioned) + should_not_email(@subscriber) + should_not_email(@watcher_and_subscriber) + should_not_email(@unsubscriber) + should_not_email(@u_participating) + should_not_email(subscriber_to_label) + should_email(subscriber_to_label2) + end + end + describe :closed_merge_request do it do notification.close_mr(merge_request, @u_disabled) @@ -457,34 +486,6 @@ describe NotificationService, services: true do should_not_email(@u_disabled) end end - - describe :relabel_merge_request do - it "sends email to subscribers of the given labels" do - subscriber, non_subscriber = create_list(:user, 2) - label = create(:label) - merge_request.labels << label - label.toggle_subscription(subscriber) - 2.times { label.toggle_subscription(non_subscriber) } - notification.relabeled_merge_request(merge_request, [label], @u_disabled) - should_email(subscriber) - should_not_email(non_subscriber) - end - - it "doesn't send email to anyone but subscribers of the given labels" do - label = create(:label) - merge_request.labels << label - notification.relabeled_merge_request(merge_request, [label], @u_disabled) - - should_not_email(merge_request.assignee) - should_not_email(merge_request.author) - should_not_email(@u_watcher) - should_not_email(@u_participant_mentioned) - should_not_email(@subscriber) - should_not_email(@watcher_and_subscriber) - should_not_email(@unsubscriber) - should_not_email(@u_participating) - end - end end describe 'Projects' do From c98089b2b29bbcb44cdce7ce23bff3fa5d10d3f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 15 Mar 2016 17:18:47 +0100 Subject: [PATCH 117/797] Move the #toggle_subscription controller method to a concern --- app/controllers/projects/issues_controller.rb | 13 ++++--------- app/controllers/projects/labels_controller.rb | 18 ++++++++---------- .../projects/merge_requests_controller.rb | 12 +++--------- 3 files changed, 15 insertions(+), 28 deletions(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 24a862814b..b0a03ee45c 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -1,6 +1,8 @@ class Projects::IssuesController < Projects::ApplicationController + include ToggleSubscriptionAction + before_action :module_enabled - before_action :issue, only: [:edit, :update, :show, :toggle_subscription] + before_action :issue, only: [:edit, :update, :show] # Allow read any issue before_action :authorize_read_issue! @@ -110,14 +112,6 @@ class Projects::IssuesController < Projects::ApplicationController redirect_back_or_default(default: { action: 'index' }, options: { notice: "#{result[:count]} issues updated" }) end - def toggle_subscription - return unless current_user - - @issue.toggle_subscription(current_user) - - render nothing: true - end - def closed_by_merge_requests @closed_by_merge_requests ||= @issue.closed_by_merge_requests(current_user) end @@ -131,6 +125,7 @@ class Projects::IssuesController < Projects::ApplicationController redirect_old end end + alias_method :subscribable_resource, :issue def authorize_update_issue! return render_404 unless can?(current_user, :update_issue, @issue) diff --git a/app/controllers/projects/labels_controller.rb b/app/controllers/projects/labels_controller.rb index e4dea6b065..40d8098690 100644 --- a/app/controllers/projects/labels_controller.rb +++ b/app/controllers/projects/labels_controller.rb @@ -1,8 +1,12 @@ class Projects::LabelsController < Projects::ApplicationController + include ToggleSubscriptionAction + before_action :module_enabled - before_action :label, only: [:edit, :update, :destroy, :toggle_subscription] + before_action :label, only: [:edit, :update, :destroy] before_action :authorize_read_label! - before_action :authorize_admin_labels!, except: [:index, :toggle_subscription] + before_action :authorize_admin_labels!, only: [ + :new, :create, :edit, :update, :generate, :destroy + ] respond_to :js, :html @@ -60,13 +64,6 @@ class Projects::LabelsController < Projects::ApplicationController end end - def toggle_subscription - return unless current_user - - @label.toggle_subscription(current_user) - render nothing: true - end - protected def module_enabled @@ -80,8 +77,9 @@ class Projects::LabelsController < Projects::ApplicationController end def label - @label = @project.labels.find(params[:id]) + @label ||= @project.labels.find(params[:id]) end + alias_method :subscribable_resource, :label def authorize_admin_labels! return render_404 unless can?(current_user, :admin_label, @project) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 954ee55a21..61b82c9db4 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -1,10 +1,11 @@ class Projects::MergeRequestsController < Projects::ApplicationController + include ToggleSubscriptionAction include DiffHelper before_action :module_enabled before_action :merge_request, only: [ :edit, :update, :show, :diffs, :commits, :builds, :merge, :merge_check, - :ci_status, :toggle_subscription, :cancel_merge_when_build_succeeds + :ci_status, :cancel_merge_when_build_succeeds ] before_action :closes_issues, only: [:edit, :update, :show, :diffs, :commits, :builds] before_action :validates_merge_request, only: [:show, :diffs, :commits, :builds] @@ -233,14 +234,6 @@ class Projects::MergeRequestsController < Projects::ApplicationController render json: response end - def toggle_subscription - return unless current_user - - @merge_request.toggle_subscription(current_user) - - render nothing: true - end - protected def selected_target_project @@ -254,6 +247,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController def merge_request @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) end + alias_method :subscribable_resource, :merge_request def closes_issues @closes_issues ||= @merge_request.closes_issues From e90d6ec1d83ff86743e70931b49647eaf3e3e67a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 15 Mar 2016 17:24:55 +0100 Subject: [PATCH 118/797] Create a SentNotification record for #relabeled_issue_email / #relabeled_merge_request_email --- .../concerns/toggle_subscription_action.rb | 17 +++++++++++++++++ app/mailers/emails/issues.rb | 8 +++----- app/mailers/emails/merge_requests.rb | 8 +++----- 3 files changed, 23 insertions(+), 10 deletions(-) create mode 100644 app/controllers/concerns/toggle_subscription_action.rb diff --git a/app/controllers/concerns/toggle_subscription_action.rb b/app/controllers/concerns/toggle_subscription_action.rb new file mode 100644 index 0000000000..8a43c0b93c --- /dev/null +++ b/app/controllers/concerns/toggle_subscription_action.rb @@ -0,0 +1,17 @@ +module ToggleSubscriptionAction + extend ActiveSupport::Concern + + def toggle_subscription + return unless current_user + + subscribable_resource.toggle_subscription(current_user) + + render nothing: true + end + + private + + def subscribable_resource + raise NotImplementedError + end +end diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 160b6df0b9..5f9adb32e0 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -21,7 +21,7 @@ module Emails end def relabeled_issue_email(recipient_id, issue_id, label_names, updated_by_user_id) - setup_issue_mail(issue_id, recipient_id, sent_notification: false) + setup_issue_mail(issue_id, recipient_id) @label_names = label_names @labels_url = namespace_project_labels_url(@project.namespace, @project) @@ -38,14 +38,12 @@ module Emails private - def setup_issue_mail(issue_id, recipient_id, sent_notification: true) + def setup_issue_mail(issue_id, recipient_id) @issue = Issue.find(issue_id) @project = @issue.project @target_url = namespace_project_issue_url(@project.namespace, @project, @issue) - if sent_notification - @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) - end + @sent_notification = SentNotification.record(@issue, recipient_id, reply_key) end def issue_thread_options(sender_id, recipient_id) diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index 334bad4e2f..55bb4f6527 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -14,7 +14,7 @@ module Emails end def relabeled_merge_request_email(recipient_id, merge_request_id, label_names, updated_by_user_id) - setup_merge_request_mail(merge_request_id, recipient_id, sent_notification: false) + setup_merge_request_mail(merge_request_id, recipient_id) @label_names = label_names @labels_url = namespace_project_labels_url(@project.namespace, @project) @@ -44,14 +44,12 @@ module Emails private - def setup_merge_request_mail(merge_request_id, recipient_id, sent_notification: true) + def setup_merge_request_mail(merge_request_id, recipient_id) @merge_request = MergeRequest.find(merge_request_id) @project = @merge_request.project @target_url = namespace_project_merge_request_url(@project.namespace, @project, @merge_request) - if sent_notification - @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) - end + @sent_notification = SentNotification.record(@merge_request, recipient_id, reply_key) end def merge_request_thread_options(sender_id, recipient_id) From b24f9d66bc260dbfc04e63062b06bef390a4ba97 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 15 Mar 2016 17:38:45 +0000 Subject: [PATCH 119/797] Fixed overflow on users name in dropdowns Closes #14289 --- app/assets/stylesheets/framework/dropdowns.scss | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 5b647fc617..309da34da0 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -161,9 +161,8 @@ .dropdown-menu-user-full-name { display: block; - margin-bottom: 2px; font-weight: 600; - line-height: 1; + line-height: 16px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; @@ -171,7 +170,7 @@ .dropdown-menu-user-username { display: block; - line-height: 1; + line-height: 16px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; From aaf4434b0e24da916d4392aa9cd001cdb8e0c7dc Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Tue, 15 Mar 2016 00:11:20 +0100 Subject: [PATCH 120/797] Doc external users feature --- doc/README.md | 2 +- doc/api/users.md | 3 ++- doc/permissions/permissions.md | 17 +++++++++++++++++ lib/api/entities.rb | 1 + 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/doc/README.md b/doc/README.md index 0ca30e4e0f..db19c3de8d 100644 --- a/doc/README.md +++ b/doc/README.md @@ -8,7 +8,7 @@ - [Importing to GitLab](workflow/importing/README.md). - [Markdown](markdown/markdown.md) GitLab's advanced formatting system. - [Migrating from SVN](workflow/importing/migrating_from_svn.md) Convert a SVN repository to Git and GitLab -- [Permissions](permissions/permissions.md) Learn what each role in a project (guest/reporter/developer/master/owner) can do. +- [Permissions](permissions/permissions.md) Learn what each role in a project (external/guest/reporter/developer/master/owner) can do. - [Profile Settings](profile/README.md) - [Project Services](project_services/project_services.md) Integrate a project with external services, such as CI and chat. - [Public access](public_access/public_access.md) Learn how you can allow public and internal access to projects. diff --git a/doc/api/users.md b/doc/api/users.md index 82c57a2fd4..44a29da5ec 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -194,6 +194,7 @@ Parameters: - `admin` (optional) - User is admin - true or false (default) - `can_create_group` (optional) - User can create groups - true or false - `confirm` (optional) - Require confirmation - true (default) or false +- `external` (optional) - Flags the user as external - true or false(default) ## User modification @@ -560,7 +561,7 @@ Parameters: - `uid` (required) - id of specified user -Will return `200 OK` on success, `404 User Not Found` is user cannot be found or +Will return `200 OK` on success, `404 User Not Found` is user cannot be found or `403 Forbidden` when trying to block an already blocked user by LDAP synchronization. ## Unblock user diff --git a/doc/permissions/permissions.md b/doc/permissions/permissions.md index ac0fd3d175..2dfd08755b 100644 --- a/doc/permissions/permissions.md +++ b/doc/permissions/permissions.md @@ -71,3 +71,20 @@ Any user can remove themselves from a group, unless they are the last Owner of t | Create project in group | | | | ✓ | ✓ | | Manage group members | | | | | ✓ | | Remove group | | | | | ✓ | + +## External Users + +In cases where it is desired that a user has access to some internal or private projects, but others +should remain hidden from this user, there is the option of creating `External Users`. +An administrator can flag a user as external through the API or by checking the checkbox on the admin panel. + +In the case of a new user: navigate to the **Admin** area and click the **New User** button. If you would like to +edit a user, go to the user list on the **Admin** area and click the **Edit** button. + +External users can only access projects to which they are explicitly granted access, thus hiding all internal projects. +Access can be granted by adding the users as member to the project or by including this user in a group. External users will, like usual users, receive +a role in the project or group with all the abilities that are mentioned in the table above. + +External users cannot create groups or projects, and have the same access as logged out users in all other cases. This feature may be +useful when for example a contractor is working on a given project and should only access the given project and public +projects. diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 7204dca34b..4b3ad1443b 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -31,6 +31,7 @@ module API expose :can_create_group?, as: :can_create_group expose :can_create_project?, as: :can_create_project expose :two_factor_enabled + expose :external end class UserLogin < UserFull From 7c61bdcf6f7383ad26ea5b2c59bc8bc72096a8e6 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Tue, 15 Mar 2016 11:03:17 -0700 Subject: [PATCH 121/797] Fix Capybara 2.6.2 deprecation warnings --- features/support/capybara.rb | 2 +- spec/support/capybara.rb | 2 +- spec/support/wait_for_ajax.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/features/support/capybara.rb b/features/support/capybara.rb index f33379f76c..fe9e39cf50 100644 --- a/features/support/capybara.rb +++ b/features/support/capybara.rb @@ -9,7 +9,7 @@ Capybara.register_driver :poltergeist do |app| Capybara::Poltergeist::Driver.new(app, js_errors: true, timeout: timeout, window_size: [1366, 768]) end -Capybara.default_wait_time = timeout +Capybara.default_max_wait_time = timeout Capybara.ignore_hidden_elements = false unless ENV['CI'] || ENV['CI_SERVER'] diff --git a/spec/support/capybara.rb b/spec/support/capybara.rb index 65d59e6813..e1f90e17cc 100644 --- a/spec/support/capybara.rb +++ b/spec/support/capybara.rb @@ -10,7 +10,7 @@ Capybara.register_driver :poltergeist do |app| Capybara::Poltergeist::Driver.new(app, js_errors: true, timeout: timeout, window_size: [1366, 768]) end -Capybara.default_wait_time = timeout +Capybara.default_max_wait_time = timeout Capybara.ignore_hidden_elements = true unless ENV['CI'] || ENV['CI_SERVER'] diff --git a/spec/support/wait_for_ajax.rb b/spec/support/wait_for_ajax.rb index 692d219e9f..b90fc11267 100644 --- a/spec/support/wait_for_ajax.rb +++ b/spec/support/wait_for_ajax.rb @@ -1,6 +1,6 @@ module WaitForAjax def wait_for_ajax - Timeout.timeout(Capybara.default_wait_time) do + Timeout.timeout(Capybara.default_max_wait_time) do loop until finished_all_ajax_requests? end end From 4b3d344688954e9c515b9bb8f26239a781fcabfb Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Tue, 8 Mar 2016 02:56:43 -0500 Subject: [PATCH 122/797] Working version of autocomplete with categorized results --- app/assets/javascripts/dispatcher.js.coffee | 7 +- .../lib/category_autocomplete.js.coffee | 17 ++ .../javascripts/search_autocomplete.js.coffee | 169 +++++++++++++++++- app/helpers/search_helper.rb | 59 +++--- app/views/layouts/_search.html.haml | 13 +- app/views/shared/_location_badge.html.haml | 13 ++ 6 files changed, 229 insertions(+), 49 deletions(-) create mode 100644 app/assets/javascripts/lib/category_autocomplete.js.coffee create mode 100644 app/views/shared/_location_badge.html.haml diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 1be86e3b82..0aefea7d8d 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -151,9 +151,4 @@ class Dispatcher new Shortcuts() initSearch: -> - opts = $('.search-autocomplete-opts') - path = opts.data('autocomplete-path') - project_id = opts.data('autocomplete-project-id') - project_ref = opts.data('autocomplete-project-ref') - - new SearchAutocomplete(path, project_id, project_ref) + new SearchAutocomplete() diff --git a/app/assets/javascripts/lib/category_autocomplete.js.coffee b/app/assets/javascripts/lib/category_autocomplete.js.coffee new file mode 100644 index 0000000000..490032dc78 --- /dev/null +++ b/app/assets/javascripts/lib/category_autocomplete.js.coffee @@ -0,0 +1,17 @@ +$.widget( "custom.catcomplete", $.ui.autocomplete, + _create: -> + @_super(); + @widget().menu("option", "items", "> :not(.ui-autocomplete-category)") + + _renderMenu: (ul, items) -> + currentCategory = '' + $.each items, (index, item) => + if item.category isnt currentCategory + ul.append("
  • #{item.category}
  • ") + currentCategory = item.category + + li = @_renderItemData(ul, item) + + if item.category? + li.attr('aria-label', item.category + " : " + item.label) + ) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index c180136526..df31b07910 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -1,11 +1,164 @@ class @SearchAutocomplete - constructor: (search_autocomplete_path, project_id, project_ref) -> - project_id = '' unless project_id - project_ref = '' unless project_ref - query = "?project_id=" + project_id + "&project_ref=" + project_ref + constructor: (opts = {}) -> + { + @wrap = $('.search') + @optsEl = @wrap.find('.search-autocomplete-opts') + @autocompletePath = @optsEl.data('autocomplete-path') + @projectId = @optsEl.data('autocomplete-project-id') || '' + @projectRef = @optsEl.data('autocomplete-project-ref') || '' + } = opts - $("#search").autocomplete - source: search_autocomplete_path + query + @keyCode = + ESCAPE: 27 + BACKSPACE: 8 + TAB: 9 + ENTER: 13 + + @locationBadgeEl = @$('.search-location-badge') + @locationText = @$('.location-text') + @searchInput = @$('.search-input') + @projectInputEl = @$('#project_id') + @groupInputEl = @$('#group_id') + @searchCodeInputEl = @$('#search_code') + @repositoryInputEl = @$('#repository_ref') + @scopeInputEl = @$('#scope') + + @saveOriginalState() + @createAutocomplete() + @bindEvents() + + $: (selector) -> + @wrap.find(selector) + + saveOriginalState: -> + @originalState = @serializeState() + + restoreOriginalState: -> + inputs = Object.keys @originalState + + for input in inputs + @$("##{input}").val(@originalState[input]) + + + if @originalState._location is '' + @locationBadgeEl.html('') + else + @addLocationBadge( + value: @originalState._location + ) + + serializeState: -> + { + # Search Criteria + project_id: @projectInputEl.val() + group_id: @groupInputEl.val() + search_code: @searchCodeInputEl.val() + repository_ref: @repositoryInputEl.val() + + # Location badge + _location: $.trim(@locationText.text()) + } + + createAutocomplete: -> + @query = "?project_id=" + @projectId + "&project_ref=" + @projectRef + + @catComplete = @searchInput.catcomplete + appendTo: 'form.navbar-form' + source: @autocompletePath + @query minLength: 1 - select: (event, ui) -> - location.href = ui.item.url + close: (e) -> + e.preventDefault() + + select: (event, ui) => + # Pressing enter choses an alternative + if event.keyCode is @keyCode.ENTER + @goToResult(ui.item) + else + # Pressing tab sets the scope + if event.keyCode is @keyCode.TAB and ui.item.scope? + @setLocationBadge(ui.item) + @searchInput + .val('') # remove selected value from input + .focus() + else + # If option is not a scope go to page + @goToResult(ui.item) + + # Return false to avoid focus on the next element + return false + + + bindEvents: -> + @searchInput.on 'keydown', @onSearchKeyDown + @wrap.on 'click', '.remove-badge', @onRemoveLocationBadgeClick + + onRemoveLocationBadgeClick: (e) => + e.preventDefault() + @removeLocationBadge() + @searchInput.focus() + + onSearchKeyDown: (e) => + # Remove tag when pressing backspace and input search is empty + if e.keyCode is @keyCode.BACKSPACE and e.currentTarget.value is '' + @removeLocationBadge() + @destroyAutocomplete() + @searchInput.focus() + else if e.keyCode is @keyCode.ESCAPE + @restoreOriginalState() + else + # Create new autocomplete instance if it's not created + @createAutocomplete() unless @catcomplete? + + addLocationBadge: (item) -> + category = if item.category? then "#{item.category}: " else '' + value = if item.value? then item.value else '' + + html = " + #{category}#{value} + x + " + @locationBadgeEl.html(html) + + setLocationBadge: (item) -> + @addLocationBadge(item) + + # Reset input states + @resetSearchState() + + switch item.scope + when 'projects' + @projectInputEl.val(item.id) + # @searchCodeInputEl.val('true') # TODO: always true for projects? + # @repositoryInputEl.val('master') # TODO: always master? + + when 'groups' + @groupInputEl.val(item.id) + + removeLocationBadge: -> + @locationBadgeEl.empty() + + # Reset state + @resetSearchState() + + resetSearchState: -> + # Remove scope + @scopeInputEl.val('') + + # Remove group + @groupInputEl.val('') + + # Remove project id + @projectInputEl.val('') + + # Remove code search + @searchCodeInputEl.val('') + + # Remove repository ref + @repositoryInputEl.val('') + + goToResult: (result) -> + location.href = result.url + + destroyAutocomplete: -> + @catComplete.destroy() if @catcomplete? + @catComplete = null diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 494dad0b41..9102fd6d50 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -23,45 +23,45 @@ module SearchHelper # Autocomplete results for various settings pages def default_autocomplete [ - { label: "Profile settings", url: profile_path }, - { label: "SSH Keys", url: profile_keys_path }, - { label: "Dashboard", url: root_path }, - { label: "Admin Section", url: admin_root_path }, + { category: "Settings", label: "Profile settings", url: profile_path }, + { category: "Settings", label: "SSH Keys", url: profile_keys_path }, + { category: "Settings", label: "Dashboard", url: root_path }, + { category: "Settings", label: "Admin Section", url: admin_root_path }, ] end # Autocomplete results for internal help pages def help_autocomplete [ - { label: "help: API Help", url: help_page_path("api", "README") }, - { label: "help: Markdown Help", url: help_page_path("markdown", "markdown") }, - { label: "help: Permissions Help", url: help_page_path("permissions", "permissions") }, - { label: "help: Public Access Help", url: help_page_path("public_access", "public_access") }, - { label: "help: Rake Tasks Help", url: help_page_path("raketasks", "README") }, - { label: "help: SSH Keys Help", url: help_page_path("ssh", "README") }, - { label: "help: System Hooks Help", url: help_page_path("system_hooks", "system_hooks") }, - { label: "help: Webhooks Help", url: help_page_path("web_hooks", "web_hooks") }, - { label: "help: Workflow Help", url: help_page_path("workflow", "README") }, + { category: "Help", label: "API Help", url: help_page_path("api", "README") }, + { category: "Help", label: "Markdown Help", url: help_page_path("markdown", "markdown") }, + { category: "Help", label: "Permissions Help", url: help_page_path("permissions", "permissions") }, + { category: "Help", label: "Public Access Help", url: help_page_path("public_access", "public_access") }, + { category: "Help", label: "Rake Tasks Help", url: help_page_path("raketasks", "README") }, + { category: "Help", label: "SSH Keys Help", url: help_page_path("ssh", "README") }, + { category: "Help", label: "System Hooks Help", url: help_page_path("system_hooks", "system_hooks") }, + { category: "Help", label: "Webhooks Help", url: help_page_path("web_hooks", "web_hooks") }, + { category: "Help", label: "Workflow Help", url: help_page_path("workflow", "README") }, ] end # Autocomplete results for the current project, if it's defined def project_autocomplete if @project && @project.repository.exists? && @project.repository.root_ref - prefix = search_result_sanitize(@project.name_with_namespace) + prefix = "Project - " + search_result_sanitize(@project.name_with_namespace) ref = @ref || @project.repository.root_ref [ - { label: "#{prefix} - Files", url: namespace_project_tree_path(@project.namespace, @project, ref) }, - { label: "#{prefix} - Commits", url: namespace_project_commits_path(@project.namespace, @project, ref) }, - { label: "#{prefix} - Network", url: namespace_project_network_path(@project.namespace, @project, ref) }, - { label: "#{prefix} - Graph", url: namespace_project_graph_path(@project.namespace, @project, ref) }, - { label: "#{prefix} - Issues", url: namespace_project_issues_path(@project.namespace, @project) }, - { label: "#{prefix} - Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) }, - { label: "#{prefix} - Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, - { label: "#{prefix} - Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, - { label: "#{prefix} - Members", url: namespace_project_project_members_path(@project.namespace, @project) }, - { label: "#{prefix} - Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, + { category: prefix, label: "Files", url: namespace_project_tree_path(@project.namespace, @project, ref) }, + { category: prefix, label: "Commits", url: namespace_project_commits_path(@project.namespace, @project, ref) }, + { category: prefix, label: "Network", url: namespace_project_network_path(@project.namespace, @project, ref) }, + { category: prefix, label: "Graph", url: namespace_project_graph_path(@project.namespace, @project, ref) }, + { category: prefix, label: "Issues", url: namespace_project_issues_path(@project.namespace, @project) }, + { category: prefix, label: "Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) }, + { category: prefix, label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, + { category: prefix, label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, + { category: prefix, label: "Members", url: namespace_project_project_members_path(@project.namespace, @project) }, + { category: prefix, label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, ] else [] @@ -72,7 +72,10 @@ module SearchHelper def groups_autocomplete(term, limit = 5) current_user.authorized_groups.search(term).limit(limit).map do |group| { - label: "group: #{search_result_sanitize(group.name)}", + category: "Groups", + scope: "groups", + id: group.id, + label: "#{search_result_sanitize(group.name)}", url: group_path(group) } end @@ -83,7 +86,11 @@ module SearchHelper current_user.authorized_projects.search_by_title(term). sorted_by_stars.non_archived.limit(limit).map do |p| { - label: "project: #{search_result_sanitize(p.name_with_namespace)}", + category: "Projects", + scope: "projects", + id: p.id, + value: "#{search_result_sanitize(p.name)}", + label: "#{search_result_sanitize(p.name_with_namespace)}", url: namespace_project_path(p.namespace, p) } end diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 54af2c3063..c500289383 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -1,10 +1,12 @@ .search = form_tag search_path, method: :get, class: 'navbar-form pull-left' do |f| + = render 'shared/location_badge' = search_field_tag "search", nil, placeholder: 'Search', class: "search-input form-control", spellcheck: false, tabindex: "1" = hidden_field_tag :group_id, @group.try(:id) - - if @project && @project.persisted? - = hidden_field_tag :project_id, @project.id + = hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '' + + - if @project && @project.persisted? - if current_controller?(:issues) = hidden_field_tag :scope, 'issues' - elsif current_controller?(:merge_requests) @@ -21,10 +23,3 @@ = hidden_field_tag :repository_ref, @ref = button_tag 'Go' if ENV['RAILS_ENV'] == 'test' .search-autocomplete-opts.hide{:'data-autocomplete-path' => search_autocomplete_path, :'data-autocomplete-project-id' => @project.try(:id), :'data-autocomplete-project-ref' => @ref } - -:javascript - $('.search-input').on('keyup', function(e) { - if (e.keyCode == 27) { - $('.search-input').blur(); - } - }); diff --git a/app/views/shared/_location_badge.html.haml b/app/views/shared/_location_badge.html.haml new file mode 100644 index 0000000000..dfe8bc010d --- /dev/null +++ b/app/views/shared/_location_badge.html.haml @@ -0,0 +1,13 @@ +- if controller.controller_path =~ /^groups/ + - label = 'This group' +- if controller.controller_path =~ /^projects/ + - label = 'This project' + +.search-location-badge + - if label.present? + %span.label.label-primary + %i.location-text + = label + + %a.remove-badge{href: '#'} + x From 6f449c63ddab0027ef064b436d98c8e820cbe7b3 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Tue, 8 Mar 2016 19:39:14 -0500 Subject: [PATCH 123/797] Apply styling and tweaks to autocomplete dropdown --- .../lib/category_autocomplete.js.coffee | 32 +++++++++ .../javascripts/search_autocomplete.js.coffee | 35 ++++++++-- app/assets/stylesheets/framework/forms.scss | 34 --------- app/assets/stylesheets/framework/header.scss | 20 ------ app/assets/stylesheets/framework/jquery.scss | 36 ++++++++-- app/assets/stylesheets/pages/search.scss | 70 +++++++++++++++++++ app/helpers/search_helper.rb | 21 +++--- app/views/layouts/_search.html.haml | 13 ++-- app/views/shared/_location_badge.html.haml | 13 ++-- 9 files changed, 186 insertions(+), 88 deletions(-) diff --git a/app/assets/javascripts/lib/category_autocomplete.js.coffee b/app/assets/javascripts/lib/category_autocomplete.js.coffee index 490032dc78..c85fabbcd5 100644 --- a/app/assets/javascripts/lib/category_autocomplete.js.coffee +++ b/app/assets/javascripts/lib/category_autocomplete.js.coffee @@ -14,4 +14,36 @@ $.widget( "custom.catcomplete", $.ui.autocomplete, if item.category? li.attr('aria-label', item.category + " : " + item.label) + + _renderItem: (ul, item) -> + # Highlight occurrences + item.label = item.label.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + $.ui.autocomplete.escapeRegex(this.term) + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "$1"); + + return $( "
  • " ) + .data( "item.autocomplete", item ) + .append( "#{item.label}" ) + .appendTo( ul ); + + _resizeMenu: -> + if (isNaN(this.options.maxShowItems)) + return + + ul = this.menu.element.css(overflowX: '', overflowY: '', width: '', maxHeight: '') + + lis = ul.children('li').css('whiteSpace', 'nowrap'); + + if (lis.length > this.options.maxShowItems) + ulW = ul.prop('clientWidth') + + ul.css( + overflowX: 'hidden' + overflowY: 'auto' + maxHeight: lis.eq(0).outerHeight() * this.options.maxShowItems + 1 + ) + + barW = ulW - ul.prop('clientWidth'); + ul.width('+=' + barW); + + # Original code from jquery.ui.autocomplete.js _resizeMenu() + ul.outerWidth(Math.max(ul.outerWidth() + 1, this.element.outerWidth())); ) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index df31b07910..a6d5ab6523 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -24,7 +24,10 @@ class @SearchAutocomplete @scopeInputEl = @$('#scope') @saveOriginalState() - @createAutocomplete() + + if @locationBadgeEl.is(':empty') + @createAutocomplete() + @bindEvents() $: (selector) -> @@ -66,6 +69,12 @@ class @SearchAutocomplete appendTo: 'form.navbar-form' source: @autocompletePath + @query minLength: 1 + maxShowItems: 15 + position: + # { my: "left top", at: "left bottom", collision: "none" } + my: "left-10 top+9" + at: "left bottom" + collision: "none" close: (e) -> e.preventDefault() @@ -89,7 +98,9 @@ class @SearchAutocomplete bindEvents: -> - @searchInput.on 'keydown', @onSearchKeyDown + @searchInput.on 'keydown', @onSearchInputKeyDown + @searchInput.on 'focus', @onSearchInputFocus + @searchInput.on 'blur', @onSearchInputBlur @wrap.on 'click', '.remove-badge', @onRemoveLocationBadgeClick onRemoveLocationBadgeClick: (e) => @@ -97,7 +108,7 @@ class @SearchAutocomplete @removeLocationBadge() @searchInput.focus() - onSearchKeyDown: (e) => + onSearchInputKeyDown: (e) => # Remove tag when pressing backspace and input search is empty if e.keyCode is @keyCode.BACKSPACE and e.currentTarget.value is '' @removeLocationBadge() @@ -106,14 +117,24 @@ class @SearchAutocomplete else if e.keyCode is @keyCode.ESCAPE @restoreOriginalState() else - # Create new autocomplete instance if it's not created - @createAutocomplete() unless @catcomplete? + # Create new autocomplete if hasn't been created yet and there's no badge + if !@catComplete? and @locationBadgeEl.is(':empty') + @createAutocomplete() + + onSearchInputFocus: => + @wrap.addClass('search-active') + + onSearchInputBlur: => + @wrap.removeClass('search-active') + + # If input is blank then restore state + @restoreOriginalState() if @searchInput.val() is '' addLocationBadge: (item) -> category = if item.category? then "#{item.category}: " else '' value = if item.value? then item.value else '' - html = " + html = " #{category}#{value} x " @@ -160,5 +181,5 @@ class @SearchAutocomplete location.href = result.url destroyAutocomplete: -> - @catComplete.destroy() if @catcomplete? + @catComplete.destroy() if @catComplete? @catComplete = null diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 6c08005812..18136509da 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -6,40 +6,6 @@ input { border-radius: $border-radius-base; } -input[type='search'] { - background-color: white; - padding-left: 10px; -} - -input[type='search'].search-input { - background-repeat: no-repeat; - background-position: 10px; - background-size: 16px; - background-position-x: 30%; - padding-left: 10px; - background-color: $gray-light; - - &.search-input[value=""] { - background-image: url('data:image/png;base64,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'); - } - - &.search-input::-webkit-input-placeholder { - text-align: center; - } - - &.search-input:-moz-placeholder { /* Firefox 18- */ - text-align: center; - } - - &.search-input::-moz-placeholder { /* Firefox 19+ */ - text-align: center; - } - - &.search-input:-ms-input-placeholder { - text-align: center; - } -} - input[type='text'].danger { background: #F2DEDE!important; border-color: #D66; diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 4c4033e3ae..f72bd22348 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -112,26 +112,6 @@ header { } } - .search { - margin-right: 10px; - margin-left: 10px; - margin-top: ($header-height - 36) / 2; - - form { - margin: 0; - padding: 0; - } - - .search-input { - width: 220px; - - &:focus { - @include box-shadow(none); - outline: none; - } - } - } - .impersonation i { color: $red-normal; } diff --git a/app/assets/stylesheets/framework/jquery.scss b/app/assets/stylesheets/framework/jquery.scss index 0cdcd923b3..76b4cea477 100644 --- a/app/assets/stylesheets/framework/jquery.scss +++ b/app/assets/stylesheets/framework/jquery.scss @@ -19,13 +19,41 @@ } &.ui-autocomplete { - border-color: #DDD; - padding: 0; margin-top: 2px; z-index: 1001; + width: 240px; + margin-bottom: 0; + padding: 10px 10px; + font-size: 14px; + font-weight: normal; + background-color: $dropdown-bg; + border: 1px solid $dropdown-border-color; + border-radius: $border-radius-base; + box-shadow: 0 2px 4px $dropdown-shadow-color; - .ui-menu-item a { - padding: 4px 10px; + .ui-menu-item { + display: block; + position: relative; + padding: 0 10px; + color: $dropdown-link-color; + line-height: 34px; + text-overflow: ellipsis; + border-radius: 2px; + white-space: nowrap; + overflow: hidden; + border: none; + + &.ui-state-focus { + background-color: $dropdown-link-hover-bg; + text-decoration: none; + margin: 0; + } + } + + .ui-autocomplete-category { + text-transform: uppercase; + font-size: 11px; + color: #7f8fa4; } } diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 84234b15c6..3c3313c911 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -21,3 +21,73 @@ } } + +.search { + margin-right: 10px; + margin-left: 10px; + margin-top: ($header-height - 35) / 2; + + &.search-active { + form { + @extend .form-control:focus; + } + + .location-badge { + @include transition(all .15s); + background-color: $input-border-focus; + color: $white-light; + } + } + + form { + @extend .form-control; + margin: 0; + padding: 4px; + width: 350px; + line-height: 24px; + overflow: hidden; + } + + .location-text { + font-style: normal; + } + + .remove-badge { + display: none; + } + + .search-input { + border: none; + font-size: 14px; + outline: none; + padding: 0; + margin-left: 2px; + line-height: 25px; + width: 100%; + } + + .location-badge { + line-height: 25px; + padding: 0 5px; + border-radius: 2px; + font-size: 14px; + font-style: normal; + color: #AAAAAA; + display: inline-block; + background-color: #F5F5F5; + vertical-align: top; + } + + .search-input-container { + display: flex; + } + + .search-location-badge, .search-input-wrap { + // Fallback if flex is not supported + display: inline-block; + } + + .search-input-wrap { + width: 100%; + } +} diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index 9102fd6d50..cbead1b8b7 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -48,20 +48,19 @@ module SearchHelper # Autocomplete results for the current project, if it's defined def project_autocomplete if @project && @project.repository.exists? && @project.repository.root_ref - prefix = "Project - " + search_result_sanitize(@project.name_with_namespace) ref = @ref || @project.repository.root_ref [ - { category: prefix, label: "Files", url: namespace_project_tree_path(@project.namespace, @project, ref) }, - { category: prefix, label: "Commits", url: namespace_project_commits_path(@project.namespace, @project, ref) }, - { category: prefix, label: "Network", url: namespace_project_network_path(@project.namespace, @project, ref) }, - { category: prefix, label: "Graph", url: namespace_project_graph_path(@project.namespace, @project, ref) }, - { category: prefix, label: "Issues", url: namespace_project_issues_path(@project.namespace, @project) }, - { category: prefix, label: "Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) }, - { category: prefix, label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, - { category: prefix, label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, - { category: prefix, label: "Members", url: namespace_project_project_members_path(@project.namespace, @project) }, - { category: prefix, label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, + { category: "Current Project", label: "Files", url: namespace_project_tree_path(@project.namespace, @project, ref) }, + { category: "Current Project", label: "Commits", url: namespace_project_commits_path(@project.namespace, @project, ref) }, + { category: "Current Project", label: "Network", url: namespace_project_network_path(@project.namespace, @project, ref) }, + { category: "Current Project", label: "Graph", url: namespace_project_graph_path(@project.namespace, @project, ref) }, + { category: "Current Project", label: "Issues", url: namespace_project_issues_path(@project.namespace, @project) }, + { category: "Current Project", label: "Merge Requests", url: namespace_project_merge_requests_path(@project.namespace, @project) }, + { category: "Current Project", label: "Milestones", url: namespace_project_milestones_path(@project.namespace, @project) }, + { category: "Current Project", label: "Snippets", url: namespace_project_snippets_path(@project.namespace, @project) }, + { category: "Current Project", label: "Members", url: namespace_project_project_members_path(@project.namespace, @project) }, + { category: "Current Project", label: "Wiki", url: namespace_project_wikis_path(@project.namespace, @project) }, ] else [] diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index c500289383..843c833b4f 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -1,9 +1,12 @@ -.search - = form_tag search_path, method: :get, class: 'navbar-form pull-left' do |f| - = render 'shared/location_badge' - = search_field_tag "search", nil, placeholder: 'Search', class: "search-input form-control", spellcheck: false, tabindex: "1" - = hidden_field_tag :group_id, @group.try(:id) +.search.search-form + = form_tag search_path, method: :get, class: 'navbar-form' do |f| + .search-input-container + .search-location-badge + = render 'shared/location_badge' + .search-input-wrap + = search_field_tag "search", nil, placeholder: 'Search', class: "search-input", spellcheck: false, tabindex: "1", autocomplete: 'off' + = hidden_field_tag :group_id, @group.try(:id) = hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '' - if @project && @project.persisted? diff --git a/app/views/shared/_location_badge.html.haml b/app/views/shared/_location_badge.html.haml index dfe8bc010d..f1ecc060cf 100644 --- a/app/views/shared/_location_badge.html.haml +++ b/app/views/shared/_location_badge.html.haml @@ -3,11 +3,10 @@ - if controller.controller_path =~ /^projects/ - label = 'This project' -.search-location-badge - - if label.present? - %span.label.label-primary - %i.location-text - = label +- if label.present? + %span.location-badge + %i.location-text + = label - %a.remove-badge{href: '#'} - x + %a.remove-badge{href: '#'} + x From d6f822423d0f9c0d463cc25469833009815eae4a Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Tue, 8 Mar 2016 21:26:24 -0500 Subject: [PATCH 124/797] Tweak behaviours --- .../javascripts/search_autocomplete.js.coffee | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index a6d5ab6523..3cedf1c7b1 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -25,7 +25,8 @@ class @SearchAutocomplete @saveOriginalState() - if @locationBadgeEl.is(':empty') + # If there's no location badge + if !@locationBadgeEl.children().length @createAutocomplete() @bindEvents() @@ -65,7 +66,7 @@ class @SearchAutocomplete createAutocomplete: -> @query = "?project_id=" + @projectId + "&project_ref=" + @projectRef - @catComplete = @searchInput.catcomplete + @searchInput.catcomplete appendTo: 'form.navbar-form' source: @autocompletePath + @query minLength: 1 @@ -96,6 +97,7 @@ class @SearchAutocomplete # Return false to avoid focus on the next element return false + @autocomplete = @searchInput.data 'customCatcomplete' bindEvents: -> @searchInput.on 'keydown', @onSearchInputKeyDown @@ -112,14 +114,19 @@ class @SearchAutocomplete # Remove tag when pressing backspace and input search is empty if e.keyCode is @keyCode.BACKSPACE and e.currentTarget.value is '' @removeLocationBadge() - @destroyAutocomplete() + # @destroyAutocomplete() @searchInput.focus() else if e.keyCode is @keyCode.ESCAPE @restoreOriginalState() else # Create new autocomplete if hasn't been created yet and there's no badge - if !@catComplete? and @locationBadgeEl.is(':empty') - @createAutocomplete() + if @autocomplete is undefined + if !@locationBadgeEl.children().length + @createAutocomplete() + else + # There's a badge + if @locationBadgeEl.children().length + @destroyAutocomplete() onSearchInputFocus: => @wrap.addClass('search-active') @@ -181,5 +188,6 @@ class @SearchAutocomplete location.href = result.url destroyAutocomplete: -> - @catComplete.destroy() if @catComplete? - @catComplete = null + @autocomplete.destroy() if @autocomplete isnt undefined + @searchInput.attr('autocomplete', 'off') + @autocomplete = undefined From f825b60b918a115a5a4a8d66abbbf35a10653b1a Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Wed, 9 Mar 2016 12:45:43 -0500 Subject: [PATCH 125/797] Change hidden input id to avoid duplicated IDs The TODOs dashboard already had a #project_id input and it was causing a spec to fail --- app/assets/javascripts/search_autocomplete.js.coffee | 2 +- app/views/layouts/_search.html.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 3cedf1c7b1..0c4876358b 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -17,7 +17,7 @@ class @SearchAutocomplete @locationBadgeEl = @$('.search-location-badge') @locationText = @$('.location-text') @searchInput = @$('.search-input') - @projectInputEl = @$('#project_id') + @projectInputEl = @$('#search_project_id') @groupInputEl = @$('#group_id') @searchCodeInputEl = @$('#search_code') @repositoryInputEl = @$('#repository_ref') diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 843c833b4f..58a3cdf955 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -7,7 +7,7 @@ = search_field_tag "search", nil, placeholder: 'Search', class: "search-input", spellcheck: false, tabindex: "1", autocomplete: 'off' = hidden_field_tag :group_id, @group.try(:id) - = hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '' + = hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '', id: 'search_project_id' - if @project && @project.persisted? - if current_controller?(:issues) From 1879057ced32a33c5204f5903f0e7c931d942b58 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Wed, 9 Mar 2016 15:52:15 -0500 Subject: [PATCH 126/797] Add icons --- app/assets/images/spinner.svg | 1 + app/assets/stylesheets/pages/search.scss | 39 +++++++++++++++++++++++- app/views/layouts/_search.html.haml | 1 + 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 app/assets/images/spinner.svg diff --git a/app/assets/images/spinner.svg b/app/assets/images/spinner.svg new file mode 100644 index 0000000000..3dd110cfa0 --- /dev/null +++ b/app/assets/images/spinner.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 3c3313c911..90c9d4de59 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -37,6 +37,12 @@ background-color: $input-border-focus; color: $white-light; } + + .search-input-wrap { + i { + color: $input-border-focus; + } + } } form { @@ -61,7 +67,7 @@ font-size: 14px; outline: none; padding: 0; - margin-left: 2px; + margin-left: 5px; line-height: 25px; width: 100%; } @@ -89,5 +95,36 @@ .search-input-wrap { width: 100%; + position: relative; + + .search-icon { + @extend .fa-search; + @include transition(color .15s); + position: absolute; + right: 5px; + color: #E7E9ED; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + + &:before { + font-family: FontAwesome; + font-weight: normal; + font-style: normal; + } + } + + .ui-autocomplete-loading + .search-icon { + height: 25px; + width: 25px; + position: absolute; + right: 0; + background-image: image-url('spinner.svg'); + fill: red; + + &:before { + display: none; + } + } } } diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 58a3cdf955..a004908fb6 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -5,6 +5,7 @@ = render 'shared/location_badge' .search-input-wrap = search_field_tag "search", nil, placeholder: 'Search', class: "search-input", spellcheck: false, tabindex: "1", autocomplete: 'off' + %i.search-icon = hidden_field_tag :group_id, @group.try(:id) = hidden_field_tag :project_id, @project && @project.persisted? ? @project.id : '', id: 'search_project_id' From 651e893d63f50a457d20705401b80414a86d0918 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Wed, 9 Mar 2016 16:34:21 -0500 Subject: [PATCH 127/797] Better wording --- app/assets/javascripts/search_autocomplete.js.coffee | 8 ++++---- app/helpers/search_helper.rb | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index 0c4876358b..b867190086 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -84,14 +84,14 @@ class @SearchAutocomplete if event.keyCode is @keyCode.ENTER @goToResult(ui.item) else - # Pressing tab sets the scope - if event.keyCode is @keyCode.TAB and ui.item.scope? + # Pressing tab sets the location + if event.keyCode is @keyCode.TAB and ui.item.location? @setLocationBadge(ui.item) @searchInput .val('') # remove selected value from input .focus() else - # If option is not a scope go to page + # If option is not a location go to page @goToResult(ui.item) # Return false to avoid focus on the next element @@ -153,7 +153,7 @@ class @SearchAutocomplete # Reset input states @resetSearchState() - switch item.scope + switch item.location when 'projects' @projectInputEl.val(item.id) # @searchCodeInputEl.val('true') # TODO: always true for projects? diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index cbead1b8b7..de16454739 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -72,7 +72,7 @@ module SearchHelper current_user.authorized_groups.search(term).limit(limit).map do |group| { category: "Groups", - scope: "groups", + location: "groups", id: group.id, label: "#{search_result_sanitize(group.name)}", url: group_path(group) @@ -86,7 +86,7 @@ module SearchHelper sorted_by_stars.non_archived.limit(limit).map do |p| { category: "Projects", - scope: "projects", + location: "projects", id: p.id, value: "#{search_result_sanitize(p.name)}", label: "#{search_result_sanitize(p.name_with_namespace)}", From 8048d6114861988ea7d0325a58f26812dd48fd09 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Wed, 9 Mar 2016 22:16:30 -0500 Subject: [PATCH 128/797] Replace spinner icon for th FontAwesome one --- app/assets/images/spinner.svg | 1 - app/assets/stylesheets/pages/search.scss | 12 ++---------- 2 files changed, 2 insertions(+), 11 deletions(-) delete mode 100644 app/assets/images/spinner.svg diff --git a/app/assets/images/spinner.svg b/app/assets/images/spinner.svg deleted file mode 100644 index 3dd110cfa0..0000000000 --- a/app/assets/images/spinner.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 90c9d4de59..bc660985ec 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -115,16 +115,8 @@ } .ui-autocomplete-loading + .search-icon { - height: 25px; - width: 25px; - position: absolute; - right: 0; - background-image: image-url('spinner.svg'); - fill: red; - - &:before { - display: none; - } + @extend .fa-spinner; + @extend .fa-spin; } } } From 2f4bdefc725728473fa339a79c8813e6015a4667 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Fri, 11 Mar 2016 13:18:38 -0500 Subject: [PATCH 129/797] Allow to pass non-asynchronous data to GitLabDropdown --- app/assets/javascripts/gl_dropdown.js.coffee | 27 +++++++++++++------- 1 file changed, 18 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 4f03847775..e763ca5c78 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -83,15 +83,19 @@ class GitLabDropdown search_fields = if @options.search then @options.search.fields else []; if @options.data - # Remote data - @remote = new GitLabDropdownRemote @options.data, { - dataType: @options.dataType, - beforeSend: @toggleLoading.bind(@) - success: (data) => - @fullData = data + # If data is an array + if _.isArray @options.data + @parseData @options.data + else + # Remote data + @remote = new GitLabDropdownRemote @options.data, { + dataType: @options.dataType, + beforeSend: @toggleLoading.bind(@) + success: (data) => + @fullData = data - @parseData @fullData - } + @parseData @fullData + } # Init filiterable if @options.filterable @@ -204,7 +208,12 @@ class GitLabDropdown else selected = if @options.isSelected then @options.isSelected(data) else false url = if @options.url then @options.url(data) else "#" - text = if @options.text then @options.text(data) else "" + + if @options.text? + text = @options.text(data) + else + text = data.text if data.text? + cssClass = ""; if selected From 761a8d98e82fdce5b04d3e50e20a13e9d35a9919 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Fri, 11 Mar 2016 13:39:28 -0500 Subject: [PATCH 130/797] Allow data with desired format --- app/assets/javascripts/gl_dropdown.js.coffee | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index e763ca5c78..0b0620a71c 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -209,10 +209,17 @@ class GitLabDropdown selected = if @options.isSelected then @options.isSelected(data) else false url = if @options.url then @options.url(data) else "#" + # Set URL + if @options.url? + url = @options.url(data) + else + url = if data.url? then data.url else '' + + # Set Text if @options.text? text = @options.text(data) else - text = data.text if data.text? + text = if data.text? then data.text else '' cssClass = ""; From 238328f56e16fe53ef0014c249e932fdb6260568 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Fri, 11 Mar 2016 14:59:50 -0500 Subject: [PATCH 131/797] Allow to pass input filter param This allow us to set a different input to filter results --- app/assets/javascripts/gl_dropdown.js.coffee | 28 +++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 0b0620a71c..1d100c054d 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -2,7 +2,9 @@ class GitLabDropdownFilter BLUR_KEYCODES = [27, 40] constructor: (@dropdown, @options) -> - @input = @dropdown.find(".dropdown-input .dropdown-input-field") + { + @input + } = @options # Key events timeout = "" @@ -77,14 +79,30 @@ class GitLabDropdown PAGE_TWO_CLASS = "is-page-two" ACTIVE_CLASS = "is-active" + FILTER_INPUT = '.dropdown-input .dropdown-input-field' + constructor: (@el, @options) -> - self = @ @dropdown = $(@el).parent() + + # Set Defaults + { + # If no input is passed create a default one + @filterInput = @$(FILTER_INPUT) + } = @options + + self = @ + + # If selector was passed + if _.isString(@filterInput) + @filterInput = @$(@filterInput) + + search_fields = if @options.search then @options.search.fields else []; if @options.data # If data is an array if _.isArray @options.data + @fullData = @options.data @parseData @options.data else # Remote data @@ -100,6 +118,7 @@ class GitLabDropdown # Init filiterable if @options.filterable @filter = new GitLabDropdownFilter @dropdown, + input: @filterInput remote: @options.filterRemote query: @options.data keys: @options.search.fields @@ -133,6 +152,9 @@ class GitLabDropdown if self.options.clicked self.options.clicked() + $: (selector) -> + $(selector, @dropdown) + toggleLoading: -> $('.dropdown-menu', @dropdown).toggleClass LOADING_CLASS @@ -167,7 +189,7 @@ class GitLabDropdown @remote.execute() if @options.filterable - @dropdown.find(".dropdown-input-field").focus() + @filterInput.focus() hidden: => if @options.filterable From 03afe76614aebb0bc81c8ed42869b0887b6abe6c Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Fri, 11 Mar 2016 18:00:17 -0500 Subject: [PATCH 132/797] Allow to pass header items --- app/assets/javascripts/gl_dropdown.js.coffee | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 1d100c054d..042ae1d04b 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -222,8 +222,12 @@ class GitLabDropdown renderItem: (data) -> html = "" + # Separator return "
  • " if data is "divider" + # Header + return "" if data.header? + if @options.renderRow # Call the render function html = @options.renderRow(data) From 424927a7173f7038448827b862b400e2df50048a Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Fri, 11 Mar 2016 20:38:19 -0500 Subject: [PATCH 133/797] Allow to hightlight matches --- app/assets/javascripts/gl_dropdown.js.coffee | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 042ae1d04b..62199e5be0 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -252,6 +252,8 @@ class GitLabDropdown if selected cssClass = "is-active" + text = @highlightTextMatches(text, @filterInput.val()) + html = "
  • " html += "" html += text @@ -260,6 +262,15 @@ class GitLabDropdown return html + highlightTextMatches: (text, term) -> + occurrences = fuzzaldrinPlus.match(text, term) + textArr = text.split('') + textArr.forEach (character, i, textArr) -> + if i in occurrences + textArr[i] = "#{character}" + + textArr.join '' + noResults: -> html = "
  • " html += "" From dce5e9ce4824b62ef939aa635357a813a858322e Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Fri, 11 Mar 2016 20:47:01 -0500 Subject: [PATCH 134/797] Disable highlighting by default --- app/assets/javascripts/gl_dropdown.js.coffee | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 62199e5be0..79696cc679 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -88,6 +88,7 @@ class GitLabDropdown { # If no input is passed create a default one @filterInput = @$(FILTER_INPUT) + @highlight = false } = @options self = @ @@ -252,7 +253,8 @@ class GitLabDropdown if selected cssClass = "is-active" - text = @highlightTextMatches(text, @filterInput.val()) + if @highlight + text = @highlightTextMatches(text, @filterInput.val()) html = "
  • " html += "" From d38ef7b5b07890d02256bf05cf6b126fceee5770 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Mon, 14 Mar 2016 16:14:29 -0500 Subject: [PATCH 135/797] Use new dropdown class for search suggestions --- app/assets/javascripts/gl_dropdown.js.coffee | 5 +- .../javascripts/search_autocomplete.js.coffee | 266 +++++++++--------- app/assets/stylesheets/framework/jquery.scss | 6 - app/assets/stylesheets/pages/search.scss | 13 +- app/views/layouts/_search.html.haml | 6 +- 5 files changed, 154 insertions(+), 142 deletions(-) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 79696cc679..0684e7852f 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -4,6 +4,7 @@ class GitLabDropdownFilter constructor: (@dropdown, @options) -> { @input + @filterInputBlur = true } = @options # Key events @@ -19,7 +20,7 @@ class GitLabDropdownFilter blur_field = @shouldBlur e.keyCode search_text = @input.val() - if blur_field + if blur_field && @filterInputBlur @input.blur() if @options.remote @@ -89,6 +90,7 @@ class GitLabDropdown # If no input is passed create a default one @filterInput = @$(FILTER_INPUT) @highlight = false + @filterInputBlur = true } = @options self = @ @@ -119,6 +121,7 @@ class GitLabDropdown # Init filiterable if @options.filterable @filter = new GitLabDropdownFilter @dropdown, + filterInputBlur: @filterInputBlur input: @filterInput remote: @options.filterRemote query: @options.data diff --git a/app/assets/javascripts/search_autocomplete.js.coffee b/app/assets/javascripts/search_autocomplete.js.coffee index b867190086..e21a140b2a 100644 --- a/app/assets/javascripts/search_autocomplete.js.coffee +++ b/app/assets/javascripts/search_autocomplete.js.coffee @@ -1,21 +1,28 @@ class @SearchAutocomplete + + KEYCODE = + ESCAPE: 27 + BACKSPACE: 8 + TAB: 9 + ENTER: 13 + constructor: (opts = {}) -> { @wrap = $('.search') + @optsEl = @wrap.find('.search-autocomplete-opts') @autocompletePath = @optsEl.data('autocomplete-path') @projectId = @optsEl.data('autocomplete-project-id') || '' @projectRef = @optsEl.data('autocomplete-project-ref') || '' + } = opts - @keyCode = - ESCAPE: 27 - BACKSPACE: 8 - TAB: 9 - ENTER: 13 + # Dropdown Element + @dropdown = @wrap.find('.dropdown') @locationBadgeEl = @$('.search-location-badge') @locationText = @$('.location-text') + @scopeInputEl = @$('#scope') @searchInput = @$('.search-input') @projectInputEl = @$('#search_project_id') @groupInputEl = @$('#group_id') @@ -25,9 +32,7 @@ class @SearchAutocomplete @saveOriginalState() - # If there's no location badge - if !@locationBadgeEl.children().length - @createAutocomplete() + @searchInput.addClass('disabled') @bindEvents() @@ -37,6 +42,118 @@ class @SearchAutocomplete saveOriginalState: -> @originalState = @serializeState() + serializeState: -> + { + # Search Criteria + project_id: @projectInputEl.val() + group_id: @groupInputEl.val() + search_code: @searchCodeInputEl.val() + repository_ref: @repositoryInputEl.val() + + # Location badge + _location: $.trim(@locationText.text()) + } + + bindEvents: -> + @searchInput.on 'keydown', @onSearchInputKeyDown + @searchInput.on 'focus', @onSearchInputFocus + @searchInput.on 'blur', @onSearchInputBlur + + enableAutocomplete: -> + self = @ + @query = "?project_id=" + @projectId + "&project_ref=" + @projectRef + dropdownMenu = self.dropdown.find('.dropdown-menu') + + @searchInput.glDropdown( + filterInputBlur: false + filterable: true + filterRemote: true + highlight: true + filterInput: 'input#search' + search: + fields: ['text'] + data: (term, callback) -> + $.ajax + url: self.autocompletePath + self.query + data: + term: term + beforeSend: -> + # dropdownMenu.addClass 'is-loading' + success: (response) -> + data = [] + + # Save groups ordering according to server response + groupNames = _.unique(_.pluck(response, 'category')) + + # Group results by category name + groups = _.groupBy response, (item) -> + item.category + + # List results + for groupName in groupNames + + # Add group header before list each group + data.push + header: groupName + + # List group + for item in groups[groupName] + data.push + text: item.label + url: item.url + + callback(data) + complete: -> + # dropdownMenu.removeClass 'is-loading' + + ) + + @dropdown.addClass('open') + @searchInput.removeClass('disabled') + @autocomplete = true; + + onDropdownOpen: (e) => + @dropdown.dropdown('toggle') + + onSearchInputKeyDown: (e) => + # Remove tag when pressing backspace and input search is empty + if e.keyCode is KEYCODE.BACKSPACE and e.currentTarget.value is '' + @removeLocationBadge() + @searchInput.focus() + + else if e.keyCode is KEYCODE.ESCAPE + @searchInput.val('') + @restoreOriginalState() + else + # Create new autocomplete if it hasn't been created yet and there's no badge + if @autocomplete is undefined + if !@badgePresent() + @enableAutocomplete() + else + # There's a badge + if @badgePresent() + @disableAutocomplete() + + onSearchInputFocus: => + @wrap.addClass('search-active') + + onSearchInputBlur: => + @wrap.removeClass('search-active') + + # If input is blank then restore state + if @searchInput.val() is '' + @restoreOriginalState() + + addLocationBadge: (item) -> + category = if item.category? then "#{item.category}: " else '' + value = if item.value? then item.value else '' + + html = " + #{category}#{value} + x + " + @locationBadgeEl.html(html) + restoreOriginalState: -> inputs = Object.keys @originalState @@ -51,122 +168,14 @@ class @SearchAutocomplete value: @originalState._location ) - serializeState: -> - { - # Search Criteria - project_id: @projectInputEl.val() - group_id: @groupInputEl.val() - search_code: @searchCodeInputEl.val() - repository_ref: @repositoryInputEl.val() + @dropdown.removeClass 'open' - # Location badge - _location: $.trim(@locationText.text()) - } + # Only add class if there's a badge + if @badgePresent() + @searchInput.addClass 'disabled' - createAutocomplete: -> - @query = "?project_id=" + @projectId + "&project_ref=" + @projectRef - - @searchInput.catcomplete - appendTo: 'form.navbar-form' - source: @autocompletePath + @query - minLength: 1 - maxShowItems: 15 - position: - # { my: "left top", at: "left bottom", collision: "none" } - my: "left-10 top+9" - at: "left bottom" - collision: "none" - close: (e) -> - e.preventDefault() - - select: (event, ui) => - # Pressing enter choses an alternative - if event.keyCode is @keyCode.ENTER - @goToResult(ui.item) - else - # Pressing tab sets the location - if event.keyCode is @keyCode.TAB and ui.item.location? - @setLocationBadge(ui.item) - @searchInput - .val('') # remove selected value from input - .focus() - else - # If option is not a location go to page - @goToResult(ui.item) - - # Return false to avoid focus on the next element - return false - - @autocomplete = @searchInput.data 'customCatcomplete' - - bindEvents: -> - @searchInput.on 'keydown', @onSearchInputKeyDown - @searchInput.on 'focus', @onSearchInputFocus - @searchInput.on 'blur', @onSearchInputBlur - @wrap.on 'click', '.remove-badge', @onRemoveLocationBadgeClick - - onRemoveLocationBadgeClick: (e) => - e.preventDefault() - @removeLocationBadge() - @searchInput.focus() - - onSearchInputKeyDown: (e) => - # Remove tag when pressing backspace and input search is empty - if e.keyCode is @keyCode.BACKSPACE and e.currentTarget.value is '' - @removeLocationBadge() - # @destroyAutocomplete() - @searchInput.focus() - else if e.keyCode is @keyCode.ESCAPE - @restoreOriginalState() - else - # Create new autocomplete if hasn't been created yet and there's no badge - if @autocomplete is undefined - if !@locationBadgeEl.children().length - @createAutocomplete() - else - # There's a badge - if @locationBadgeEl.children().length - @destroyAutocomplete() - - onSearchInputFocus: => - @wrap.addClass('search-active') - - onSearchInputBlur: => - @wrap.removeClass('search-active') - - # If input is blank then restore state - @restoreOriginalState() if @searchInput.val() is '' - - addLocationBadge: (item) -> - category = if item.category? then "#{item.category}: " else '' - value = if item.value? then item.value else '' - - html = " - #{category}#{value} - x - " - @locationBadgeEl.html(html) - - setLocationBadge: (item) -> - @addLocationBadge(item) - - # Reset input states - @resetSearchState() - - switch item.location - when 'projects' - @projectInputEl.val(item.id) - # @searchCodeInputEl.val('true') # TODO: always true for projects? - # @repositoryInputEl.val('master') # TODO: always master? - - when 'groups' - @groupInputEl.val(item.id) - - removeLocationBadge: -> - @locationBadgeEl.empty() - - # Reset state - @resetSearchState() + badgePresent: -> + @locationBadgeEl.children().length resetSearchState: -> # Remove scope @@ -184,10 +193,13 @@ class @SearchAutocomplete # Remove repository ref @repositoryInputEl.val('') - goToResult: (result) -> - location.href = result.url + removeLocationBadge: -> + @locationBadgeEl.empty() - destroyAutocomplete: -> - @autocomplete.destroy() if @autocomplete isnt undefined - @searchInput.attr('autocomplete', 'off') + # Reset state + @resetSearchState() + + disableAutocomplete: -> + if @autocomplete isnt undefined + @searchInput.addClass('disabled') @autocomplete = undefined diff --git a/app/assets/stylesheets/framework/jquery.scss b/app/assets/stylesheets/framework/jquery.scss index 76b4cea477..85a6f4b8b5 100644 --- a/app/assets/stylesheets/framework/jquery.scss +++ b/app/assets/stylesheets/framework/jquery.scss @@ -49,12 +49,6 @@ margin: 0; } } - - .ui-autocomplete-category { - text-transform: uppercase; - font-size: 11px; - color: #7f8fa4; - } } .ui-state-default { diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index bc660985ec..ff32bca98d 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -21,7 +21,6 @@ } } - .search { margin-right: 10px; margin-left: 10px; @@ -51,7 +50,6 @@ padding: 4px; width: 350px; line-height: 24px; - overflow: hidden; } .location-text { @@ -69,7 +67,7 @@ padding: 0; margin-left: 5px; line-height: 25px; - width: 100%; + width: 98%; } .location-badge { @@ -89,7 +87,7 @@ } .search-location-badge, .search-input-wrap { - // Fallback if flex is not supported + // Fallback if flexbox is not supported display: inline-block; } @@ -103,6 +101,7 @@ position: absolute; right: 5px; color: #E7E9ED; + top: 0; -webkit-user-select: none; -moz-user-select: none; -ms-user-select: none; @@ -114,9 +113,9 @@ } } - .ui-autocomplete-loading + .search-icon { - @extend .fa-spinner; - @extend .fa-spin; + .dropdown-header { + text-transform: uppercase; + font-size: 11px; } } } diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index a004908fb6..f051e7a186 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -4,7 +4,11 @@ .search-location-badge = render 'shared/location_badge' .search-input-wrap - = search_field_tag "search", nil, placeholder: 'Search', class: "search-input", spellcheck: false, tabindex: "1", autocomplete: 'off' + .dropdown{ data: {url: search_autocomplete_path } } + = search_field_tag "search", nil, placeholder: 'Search', class: "search-input dropdown-menu-toggle", spellcheck: false, tabindex: "1", autocomplete: 'off', data: { toggle: 'dropdown' } + .dropdown-menu.dropdown-select + = dropdown_content + = dropdown_loading %i.search-icon = hidden_field_tag :group_id, @group.try(:id) From 2925fc96a2c8f2fa6fa8e8f09565be998ef305ae Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Mon, 14 Mar 2016 16:23:41 -0500 Subject: [PATCH 136/797] Delete unused file --- .../lib/category_autocomplete.js.coffee | 49 ------------------- 1 file changed, 49 deletions(-) delete mode 100644 app/assets/javascripts/lib/category_autocomplete.js.coffee diff --git a/app/assets/javascripts/lib/category_autocomplete.js.coffee b/app/assets/javascripts/lib/category_autocomplete.js.coffee deleted file mode 100644 index c85fabbcd5..0000000000 --- a/app/assets/javascripts/lib/category_autocomplete.js.coffee +++ /dev/null @@ -1,49 +0,0 @@ -$.widget( "custom.catcomplete", $.ui.autocomplete, - _create: -> - @_super(); - @widget().menu("option", "items", "> :not(.ui-autocomplete-category)") - - _renderMenu: (ul, items) -> - currentCategory = '' - $.each items, (index, item) => - if item.category isnt currentCategory - ul.append("
  • #{item.category}
  • ") - currentCategory = item.category - - li = @_renderItemData(ul, item) - - if item.category? - li.attr('aria-label', item.category + " : " + item.label) - - _renderItem: (ul, item) -> - # Highlight occurrences - item.label = item.label.replace(new RegExp("(?![^&;]+;)(?!<[^<>]*)(" + $.ui.autocomplete.escapeRegex(this.term) + ")(?![^<>]*>)(?![^&;]+;)", "gi"), "$1"); - - return $( "
  • " ) - .data( "item.autocomplete", item ) - .append( "#{item.label}" ) - .appendTo( ul ); - - _resizeMenu: -> - if (isNaN(this.options.maxShowItems)) - return - - ul = this.menu.element.css(overflowX: '', overflowY: '', width: '', maxHeight: '') - - lis = ul.children('li').css('whiteSpace', 'nowrap'); - - if (lis.length > this.options.maxShowItems) - ulW = ul.prop('clientWidth') - - ul.css( - overflowX: 'hidden' - overflowY: 'auto' - maxHeight: lis.eq(0).outerHeight() * this.options.maxShowItems + 1 - ) - - barW = ulW - ul.prop('clientWidth'); - ul.width('+=' + barW); - - # Original code from jquery.ui.autocomplete.js _resizeMenu() - ul.outerWidth(Math.max(ul.outerWidth() + 1, this.element.outerWidth())); - ) From 37440200df4f0618e9fd526bba7d3d1cddae1cab Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Mon, 14 Mar 2016 22:04:22 -0500 Subject: [PATCH 137/797] Fixes failing spec --- app/assets/javascripts/gl_dropdown.js.coffee | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/assets/javascripts/gl_dropdown.js.coffee b/app/assets/javascripts/gl_dropdown.js.coffee index 0684e7852f..c579657d4d 100644 --- a/app/assets/javascripts/gl_dropdown.js.coffee +++ b/app/assets/javascripts/gl_dropdown.js.coffee @@ -237,13 +237,12 @@ class GitLabDropdown html = @options.renderRow(data) else selected = if @options.isSelected then @options.isSelected(data) else false - url = if @options.url then @options.url(data) else "#" # Set URL if @options.url? url = @options.url(data) else - url = if data.url? then data.url else '' + url = if data.url? then data.url else '#' # Set Text if @options.text? From 228007dfbcd8f97c66c1802f3b69abd7d02c7d26 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Fri, 12 Feb 2016 19:42:25 +0100 Subject: [PATCH 138/797] new-branch-button --- CHANGELOG | 2 ++ app/assets/stylesheets/pages/issues.scss | 5 +++++ app/controllers/projects/branches_controller.rb | 16 +++++++++++++--- app/controllers/projects/issues_controller.rb | 1 + app/models/issue.rb | 14 ++++++++++++++ app/services/merge_requests/build_service.rb | 11 +++++++++++ .../projects/issues/_merge_requests.html.haml | 2 +- app/views/projects/issues/_new_branch.html.haml | 5 +++++ .../projects/issues/_related_branches.html.haml | 15 +++++++++++++++ app/views/projects/issues/show.html.haml | 2 ++ spec/models/issue_spec.rb | 12 ++++++++++++ 11 files changed, 81 insertions(+), 4 deletions(-) create mode 100644 app/views/projects/issues/_new_branch.html.haml create mode 100644 app/views/projects/issues/_related_branches.html.haml diff --git a/CHANGELOG b/CHANGELOG index 7f076f70c7..fe65e7b34f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -175,6 +175,8 @@ v 8.5.0 - Add label description (Nuttanart Pornprasitsakul) - Show label row when filtering issues or merge requests by label (Nuttanart Pornprasitsakul) - Add Todos + - User project limit is reached notice is hidden if the projects limit is zero + - New branch button appears on issues where applicable (Zeger-Jan van de Weg) v 8.4.5 - No CE-specific changes diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 1b686c58ea..5f1810cbaf 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -49,6 +49,11 @@ form.edit-issue { margin: 0; } +.related-branches-title { + font-size: 16px; + font-weight: 600; +} + .merge-requests-title { font-size: 16px; font-weight: 600; diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index 4db3b3bf23..cf68f50b1f 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -9,7 +9,7 @@ class Projects::BranchesController < Projects::ApplicationController @sort = params[:sort] || 'name' @branches = @repository.branches_sorted_by(@sort) @branches = Kaminari.paginate_array(@branches).page(params[:page]).per(PER_PAGE) - + @max_commits = @branches.reduce(0) do |memo, branch| diverging_commit_counts = repository.diverging_commit_counts(branch) [memo, diverging_commit_counts[:behind], diverging_commit_counts[:ahead]].max @@ -23,8 +23,7 @@ class Projects::BranchesController < Projects::ApplicationController def create branch_name = sanitize(strip_tags(params[:branch_name])) branch_name = Addressable::URI.unescape(branch_name) - ref = sanitize(strip_tags(params[:ref])) - ref = Addressable::URI.unescape(ref) + result = CreateBranchService.new(project, current_user). execute(branch_name, ref) @@ -49,4 +48,15 @@ class Projects::BranchesController < Projects::ApplicationController format.js { render status: status[:return_code] } end end + + private + + def ref + if params[:ref] + ref_escaped = sanitize(strip_tags(params[:ref])) + Addressable::URI.unescape(ref_escaped) + else + @project.default_branch + end + end end diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index b0a03ee45c..aa7a178dcf 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -65,6 +65,7 @@ class Projects::IssuesController < Projects::ApplicationController @notes = @issue.notes.nonawards.with_associations.fresh @noteable = @issue @merge_requests = @issue.referenced_merge_requests(current_user) + @related_branches = @issue.related_branches - @merge_requests.map(&:source_branch) respond_with(@issue) end diff --git a/app/models/issue.rb b/app/models/issue.rb index 5f58c0508f..243d9a5db6 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -94,6 +94,10 @@ class Issue < ActiveRecord::Base end.sort_by(&:iid) end + def related_branches + self.project.repository.branch_names.select { |branch| /\A#{iid}-/ =~ branch } + end + # Reset issue events cache # # Since we do cache @event we need to reset cache in special cases: @@ -120,4 +124,14 @@ class Issue < ActiveRecord::Base note.all_references(current_user).merge_requests end.uniq.select { |mr| mr.open? && mr.closes_issue?(self) } end + + def to_branch_name + "#{iid}-#{title.parameterize}"[0,25] + end + + def new_branch_button?(current_user) + !self.closed? && + referenced_merge_requests(current_user).empty? && + related_branches.empty? + end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 954746a39a..8f1b735b8d 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -47,6 +47,17 @@ module MergeRequests merge_request.title = merge_request.source_branch.titleize.humanize end + # When your branch name starts with an iid followed by a dash this pattern will + # be interpreted as the use wants to close that issue on this project + # Pattern example: 112-fix-mep-mep + # Will lead to appending `Closes #112` to the description + if merge_request.source_branch =~ /\A\d+-/ + closes_issue = "Closes ##{Regexp.last_match(0)[0...-1]}" + closes_issue.prepend("\n") if merge_request.description.present? + + merge_request.description << closes_issue + end + merge_request end diff --git a/app/views/projects/issues/_merge_requests.html.haml b/app/views/projects/issues/_merge_requests.html.haml index d9868ad1f0..d6b38b327f 100644 --- a/app/views/projects/issues/_merge_requests.html.haml +++ b/app/views/projects/issues/_merge_requests.html.haml @@ -1,4 +1,4 @@ --if @merge_requests.any? +- if @merge_requests.any? %h2.merge-requests-title = pluralize(@merge_requests.count, 'Related Merge Request') %ul.unstyled-list diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml new file mode 100644 index 0000000000..4d5fa61a91 --- /dev/null +++ b/app/views/projects/issues/_new_branch.html.haml @@ -0,0 +1,5 @@ +- if current_user && can?(current_user, :push_code, @project) && @issue.new_branch_button?(current_user) + .pull-right + = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name), method: :post, class: 'btn' do + = icon('code-fork') + New Branch diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml new file mode 100644 index 0000000000..5638766198 --- /dev/null +++ b/app/views/projects/issues/_related_branches.html.haml @@ -0,0 +1,15 @@ +- if @related_branches.any? + %h2.related-branches-title + = pluralize(@related_branches.count, 'Related Branch') + %ul.unstyled-list + - @related_branches.each do |branch| + %li + - sha = @project.repository.find_branch(branch).target + - ci_commit = @project.ci_commit(sha) if sha + - if ci_commit + %span.related-branch-ci-status + = render_ci_status(ci_commit) + %span.related-branch-info + %strong + = link_to namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch) do + = branch diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 0242276cd8..1e8308277c 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -70,8 +70,10 @@ .merge-requests = render 'merge_requests' + = render 'related_branches' .content-block.content-block-small + = render 'new_branch' = render 'votes/votes_block', votable: @issue .row diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 7f44ca2f7d..d572a71cf4 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -140,4 +140,16 @@ describe Issue, models: true do it_behaves_like 'a Taskable' do let(:subject) { create :issue } end + + describe "#to_branch_name" do + let(:issue) { build(:issue, title: 'a' * 30) } + + it "is expected not to exceed 25 chars" do + expect(issue.to_branch_name.length).to eq 25 + end + + it "starts with the issue iid" do + expect(issue.to_branch_name).to match /\A#{issue.iid}-a+\z/ + end + end end From ad97bebfed2e65951c7dc39ee80b32089a032804 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Wed, 17 Feb 2016 07:11:48 +0100 Subject: [PATCH 139/797] Enhance new branch button on an issue --- .../projects/branches_controller.rb | 6 ++ app/models/issue.rb | 11 ++- app/services/merge_requests/build_service.rb | 12 ++- app/services/system_note_service.rb | 9 ++ .../projects/issues/_new_branch.html.haml | 4 +- .../projects/branches_controller_spec.rb | 91 ++++++++++++------- spec/models/issue_spec.rb | 4 - spec/services/system_note_service_spec.rb | 12 +++ 8 files changed, 99 insertions(+), 50 deletions(-) diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index cf68f50b1f..bcbd3aa5d9 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -27,6 +27,12 @@ class Projects::BranchesController < Projects::ApplicationController result = CreateBranchService.new(project, current_user). execute(branch_name, ref) + if params[:issue_id] + issue = Issue.where(id: params[:issue_id], project: @project).limit(1).first + + SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) + end + if result[:status] == :success @branch = result[:branch] redirect_to namespace_project_tree_path(@project.namespace, @project, diff --git a/app/models/issue.rb b/app/models/issue.rb index 243d9a5db6..3b6bff6c57 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -95,7 +95,7 @@ class Issue < ActiveRecord::Base end def related_branches - self.project.repository.branch_names.select { |branch| /\A#{iid}-/ =~ branch } + self.project.repository.branch_names.select { |branch| branch.start_with? "#{iid}-" } end # Reset issue events cache @@ -126,12 +126,13 @@ class Issue < ActiveRecord::Base end def to_branch_name - "#{iid}-#{title.parameterize}"[0,25] + "#{iid}-#{title.parameterize}" end - def new_branch_button?(current_user) + def can_be_worked_on?(current_user) !self.closed? && - referenced_merge_requests(current_user).empty? && - related_branches.empty? + !self.project.forked? && + referenced_merge_requests(current_user).none? { |mr| mr.closes_issue?(self) } && + related_branches.empty? end end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index 8f1b735b8d..fa34753c4f 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -51,11 +51,15 @@ module MergeRequests # be interpreted as the use wants to close that issue on this project # Pattern example: 112-fix-mep-mep # Will lead to appending `Closes #112` to the description - if merge_request.source_branch =~ /\A\d+-/ - closes_issue = "Closes ##{Regexp.last_match(0)[0...-1]}" - closes_issue.prepend("\n") if merge_request.description.present? + if match = merge_request.source_branch.match(/\A(\d+)-/) + iid = match[1] + closes_issue = "Closes ##{iid}" - merge_request.description << closes_issue + if merge_request.description.present? + merge_request.description << closes_issue.prepend("\n") + else + merge_request.description = closes_issue + end end merge_request diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 58a861ee08..751815c5b1 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -207,6 +207,15 @@ class SystemNoteService create_note(noteable: noteable, project: project, author: author, note: body) end + # Called when a branch is created from the 'new branch' button on a issue + # Example note text: + # + # "Started branch `201-issue-branch-button`" + def self.new_issue_branch(issue, project, author, branch) + body = "Started branch `#{branch}`" + create_note(noteable: issue, project: project, author: author, note: body) + end + # Called when a Mentionable references a Noteable # # noteable - Noteable object being referenced diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 4d5fa61a91..a6b97b90e6 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,5 +1,5 @@ -- if current_user && can?(current_user, :push_code, @project) && @issue.new_branch_button?(current_user) +- if current_user && can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) .pull-right - = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name), method: :post, class: 'btn' do + = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_id: @issue.id), method: :post, class: 'btn', title: @issue.to_branch_name do = icon('code-fork') New Branch diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index 8e06d4bdc7..b509714e75 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -17,49 +17,70 @@ describe Projects::BranchesController do describe "POST create" do render_views - before do - post :create, - namespace_id: project.namespace.to_param, - project_id: project.to_param, - branch_name: branch, - ref: ref - end + context "on creation of a new branch" do + before do + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: branch, + ref: ref + end - context "valid branch name, valid source" do - let(:branch) { "merge_branch" } - let(:ref) { "master" } - it 'redirects' do - expect(subject). - to redirect_to("/#{project.path_with_namespace}/tree/merge_branch") + context "valid branch name, valid source" do + let(:branch) { "merge_branch" } + let(:ref) { "master" } + it 'redirects' do + expect(subject). + to redirect_to("/#{project.path_with_namespace}/tree/merge_branch") + end + end + + context "invalid branch name, valid ref" do + let(:branch) { "" } + let(:ref) { "master" } + it 'redirects' do + expect(subject). + to redirect_to("/#{project.path_with_namespace}/tree/alert('merge');") + end + end + + context "valid branch name, invalid ref" do + let(:branch) { "merge_branch" } + let(:ref) { "" } + it { is_expected.to render_template('new') } + end + + context "invalid branch name, invalid ref" do + let(:branch) { "" } + let(:ref) { "" } + it { is_expected.to render_template('new') } + end + + context "valid branch name with encoded slashes" do + let(:branch) { "feature%2Ftest" } + let(:ref) { "" } + it { is_expected.to render_template('new') } + it { project.repository.branch_names.include?('feature/test') } end end - context "invalid branch name, valid ref" do - let(:branch) { "" } - let(:ref) { "master" } + describe "created from the new branch button on issues" do + let(:branch) { "1-feature-branch" } + let!(:issue) { create(:issue) } + + before do + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: branch, + issue_id: issue.id + end + it 'redirects' do expect(subject). - to redirect_to("/#{project.path_with_namespace}/tree/alert('merge');") + to redirect_to("/#{project.path_with_namespace}/tree/1-feature-branch") end - end - context "valid branch name, invalid ref" do - let(:branch) { "merge_branch" } - let(:ref) { "" } - it { is_expected.to render_template('new') } - end - - context "invalid branch name, invalid ref" do - let(:branch) { "" } - let(:ref) { "" } - it { is_expected.to render_template('new') } - end - - context "valid branch name with encoded slashes" do - let(:branch) { "feature%2Ftest" } - let(:ref) { "" } - it { is_expected.to render_template('new') } - it { project.repository.branch_names.include?('feature/test')} end end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index d572a71cf4..97b2db2fba 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -144,10 +144,6 @@ describe Issue, models: true do describe "#to_branch_name" do let(:issue) { build(:issue, title: 'a' * 30) } - it "is expected not to exceed 25 chars" do - expect(issue.to_branch_name.length).to eq 25 - end - it "starts with the issue iid" do expect(issue.to_branch_name).to match /\A#{issue.iid}-a+\z/ end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5dcc39f5fd..2730b42c61 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -280,6 +280,18 @@ describe SystemNoteService, services: true do end end + describe '.new_issue_branch' do + subject { described_class.new_issue_branch(noteable, project, author, "1-mepmep") } + + it_behaves_like 'a system note' + + context 'when a branch is created from the new branch button' do + it 'sets the note text' do + expect(subject.note).to eq 'Started branch 1-mepmep' + end + end + end + describe '.cross_reference' do subject { described_class.cross_reference(noteable, mentioner, author) } From e831a3b869cbb82e9a4294a5f9309ba56df46589 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Fri, 19 Feb 2016 11:58:02 +0100 Subject: [PATCH 140/797] Link in the note when started a new branch from an issue --- app/services/system_note_service.rb | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 751815c5b1..b65ac47bce 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -212,7 +212,10 @@ class SystemNoteService # # "Started branch `201-issue-branch-button`" def self.new_issue_branch(issue, project, author, branch) - body = "Started branch `#{branch}`" + h = Gitlab::Application.routes.url_helpers + link = "#{Gitlab.config.gitlab.url}#{h.namespace_project_compare_path(project.namespace, project, from: project.default_branch, to: branch)}" + + body = "Started branch [#{branch}](#{link})" create_note(noteable: issue, project: project, author: author, note: body) end From 2b97c921196a7be904bfe4f0a31347c3583c9e88 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Mon, 22 Feb 2016 09:20:04 +0100 Subject: [PATCH 141/797] Incorporate review --- CHANGELOG | 3 +- app/assets/stylesheets/pages/issues.scss | 7 +-- .../projects/branches_controller.rb | 7 ++- app/models/issue.rb | 19 ++++--- app/services/system_note_service.rb | 2 +- .../projects/issues/_new_branch.html.haml | 2 +- .../issues/_related_branches.html.haml | 2 +- .../projects/branches_controller_spec.rb | 19 +++++-- .../features/issues/new_branch_button_spec.rb | 50 +++++++++++++++++++ spec/models/issue_spec.rb | 9 ++++ 10 files changed, 93 insertions(+), 27 deletions(-) create mode 100644 spec/features/issues/new_branch_button_spec.rb diff --git a/CHANGELOG b/CHANGELOG index fe65e7b34f..563a596640 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -59,6 +59,7 @@ v 8.5.3 - Show commit message in JIRA mention comment - Makes issue page and merge request page usable on mobile browsers. - Improved UI for profile settings + - New branch button appears on issues where applicable v 8.5.2 - Fix sidebar overlapping content when screen width was below 1200px @@ -175,8 +176,6 @@ v 8.5.0 - Add label description (Nuttanart Pornprasitsakul) - Show label row when filtering issues or merge requests by label (Nuttanart Pornprasitsakul) - Add Todos - - User project limit is reached notice is hidden if the projects limit is zero - - New branch button appears on issues where applicable (Zeger-Jan van de Weg) v 8.4.5 - No CE-specific changes diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 5f1810cbaf..0f2e14d1a2 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -49,12 +49,7 @@ form.edit-issue { margin: 0; } -.related-branches-title { - font-size: 16px; - font-weight: 600; -} - -.merge-requests-title { +.merge-requests-title, .related-branches-title { font-size: 16px; font-weight: 600; } diff --git a/app/controllers/projects/branches_controller.rb b/app/controllers/projects/branches_controller.rb index bcbd3aa5d9..43ea717cbd 100644 --- a/app/controllers/projects/branches_controller.rb +++ b/app/controllers/projects/branches_controller.rb @@ -27,10 +27,9 @@ class Projects::BranchesController < Projects::ApplicationController result = CreateBranchService.new(project, current_user). execute(branch_name, ref) - if params[:issue_id] - issue = Issue.where(id: params[:issue_id], project: @project).limit(1).first - - SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) + if params[:issue_iid] + issue = @project.issues.find_by(iid: params[:issue_iid]) + SystemNoteService.new_issue_branch(issue, @project, current_user, branch_name) if issue end if result[:status] == :success diff --git a/app/models/issue.rb b/app/models/issue.rb index 3b6bff6c57..ec275d5f5b 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -87,11 +87,16 @@ class Issue < ActiveRecord::Base end def referenced_merge_requests(current_user = nil) - Gitlab::ReferenceExtractor.lazily do - [self, *notes].flat_map do |note| - note.all_references(current_user).merge_requests - end - end.sort_by(&:iid) + if defined?(@referenced_merge_requests) + @referenced_merge_requests[current_user] ||= Gitlab::ReferenceExtractor.lazily do + [self, *notes].flat_map do |note| + note.all_references(current_user).merge_requests + end + end.sort_by(&:iid).uniq + else + @referenced_merge_requests = {} + referenced_merge_requests(current_user) + end end def related_branches @@ -132,7 +137,7 @@ class Issue < ActiveRecord::Base def can_be_worked_on?(current_user) !self.closed? && !self.project.forked? && - referenced_merge_requests(current_user).none? { |mr| mr.closes_issue?(self) } && - related_branches.empty? + self.related_branches.empty? && + self.referenced_merge_requests(current_user).empty? end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index b65ac47bce..5ea7d405e4 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -213,7 +213,7 @@ class SystemNoteService # "Started branch `201-issue-branch-button`" def self.new_issue_branch(issue, project, author, branch) h = Gitlab::Application.routes.url_helpers - link = "#{Gitlab.config.gitlab.url}#{h.namespace_project_compare_path(project.namespace, project, from: project.default_branch, to: branch)}" + link = "#{h.namespace_project_compare_url(project.namespace, project, from: project.default_branch, to: branch)}" body = "Started branch [#{branch}](#{link})" create_note(noteable: issue, project: project, author: author, note: body) diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index a6b97b90e6..e66e4669d4 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,5 +1,5 @@ - if current_user && can?(current_user, :push_code, @project) && @issue.can_be_worked_on?(current_user) .pull-right - = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_id: @issue.id), method: :post, class: 'btn', title: @issue.to_branch_name do + = link_to namespace_project_branches_path(@project.namespace, @project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid), method: :post, class: 'btn', title: @issue.to_branch_name do = icon('code-fork') New Branch diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 5638766198..b10cd03515 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -11,5 +11,5 @@ = render_ci_status(ci_commit) %span.related-branch-info %strong - = link_to namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch) do + = link_to namespace_project_compare_path(@project.namespace, @project, from: @project.default_branch, to: branch), class: "label-branch" do = branch diff --git a/spec/controllers/projects/branches_controller_spec.rb b/spec/controllers/projects/branches_controller_spec.rb index b509714e75..98ae424ed7 100644 --- a/spec/controllers/projects/branches_controller_spec.rb +++ b/spec/controllers/projects/branches_controller_spec.rb @@ -66,21 +66,30 @@ describe Projects::BranchesController do describe "created from the new branch button on issues" do let(:branch) { "1-feature-branch" } - let!(:issue) { create(:issue) } + let!(:issue) { create(:issue, project: project) } - before do + + it 'redirects' do post :create, namespace_id: project.namespace.to_param, project_id: project.to_param, branch_name: branch, - issue_id: issue.id - end + issue_iid: issue.iid - it 'redirects' do expect(subject). to redirect_to("/#{project.path_with_namespace}/tree/1-feature-branch") end + it 'posts a system note' do + expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, "1-feature-branch") + + post :create, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + branch_name: branch, + issue_iid: issue.iid + end + end end diff --git a/spec/features/issues/new_branch_button_spec.rb b/spec/features/issues/new_branch_button_spec.rb new file mode 100644 index 0000000000..f47d7a5471 --- /dev/null +++ b/spec/features/issues/new_branch_button_spec.rb @@ -0,0 +1,50 @@ +require 'rails_helper' + +feature 'Start new branch from an issue', feature: true do + let!(:project) { create(:project) } + let!(:issue) { create(:issue, project: project) } + let!(:user) { create(:user)} + + context "for team members" do + before do + project.team << [user, :master] + login_as(user) + end + + it 'shown the new branch button', js: false do + visit namespace_project_issue_path(project.namespace, project, issue) + + expect(page).to have_link "New Branch" + end + + context "when there is a referenced merge request" do + let(:note) do + create(:note, :on_issue, :system, project: project, + note: "mentioned in !#{referenced_mr.iid}") + end + let(:referenced_mr) do + create(:merge_request, source_project: project, + target_project: project, + description: "Fixes ##{issue.iid}") + end + + before do + issue.notes << note + + visit namespace_project_issue_path(project.namespace, project, issue) + end + + it "hides the new branch button", js: true do + expect(page).not_to have_link "New Branch" + expect(page).to have_content /1 Related Merge Request/ + end + end + end + + context "for visiters" do + it 'no button is shown', js: false do + visit namespace_project_issue_path(project.namespace, project, issue) + expect(page).not_to have_link "New Branch" + end + end +end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 97b2db2fba..2ccdec1eef 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -130,6 +130,15 @@ describe Issue, models: true do end end + describe '#related_branches' do + it "should " do + allow(subject.project.repository).to receive(:branch_names). + and_return(["mpempe", "#{subject.iid}mepmep", subject.to_branch_name]) + + expect(subject.related_branches).to eq [subject.to_branch_name] + end + end + it_behaves_like 'an editable mentionable' do subject { create(:issue) } From e03d0b28920bdc43ec321efa33f19777488a9187 Mon Sep 17 00:00:00 2001 From: connorshea Date: Tue, 15 Mar 2016 13:56:28 -0600 Subject: [PATCH 142/797] Follow the CSS Style Guide rules for HEX colors. Use lowercase letters and shorthand values for HEX colors where possible. Discussed in #14299. --- app/assets/stylesheets/framework/buttons.scss | 10 +- .../stylesheets/framework/calendar.scss | 8 +- app/assets/stylesheets/framework/callout.scss | 2 +- app/assets/stylesheets/framework/common.scss | 18 +-- .../stylesheets/framework/dropdowns.scss | 2 +- app/assets/stylesheets/framework/files.scss | 4 +- app/assets/stylesheets/framework/forms.scss | 4 +- .../stylesheets/framework/gitlab-theme.scss | 28 ++--- app/assets/stylesheets/framework/header.scss | 10 +- .../stylesheets/framework/issue_box.scss | 2 +- app/assets/stylesheets/framework/jquery.scss | 18 +-- .../stylesheets/framework/markdown_area.scss | 4 +- app/assets/stylesheets/framework/mixins.scss | 2 +- app/assets/stylesheets/framework/mobile.scss | 4 +- app/assets/stylesheets/framework/nav.scss | 4 +- app/assets/stylesheets/framework/selects.scss | 2 +- app/assets/stylesheets/framework/sidebar.scss | 4 +- .../stylesheets/framework/tw_bootstrap.scss | 2 +- .../framework/tw_bootstrap_variables.scss | 6 +- .../stylesheets/framework/typography.scss | 6 +- .../stylesheets/framework/variables.scss | 104 +++++++++--------- app/assets/stylesheets/highlight/dark.scss | 18 +-- app/assets/stylesheets/highlight/monokai.scss | 2 +- .../stylesheets/highlight/solarized_dark.scss | 2 +- .../highlight/solarized_light.scss | 2 +- app/assets/stylesheets/highlight/white.scss | 80 +++++++------- app/assets/stylesheets/pages/builds.scss | 2 +- app/assets/stylesheets/pages/commits.scss | 4 +- app/assets/stylesheets/pages/diff.scss | 6 +- app/assets/stylesheets/pages/editor.scss | 4 +- app/assets/stylesheets/pages/events.scss | 6 +- app/assets/stylesheets/pages/graph.scss | 4 +- app/assets/stylesheets/pages/issuable.scss | 4 +- app/assets/stylesheets/pages/issues.scss | 12 +- app/assets/stylesheets/pages/login.scss | 4 +- .../stylesheets/pages/merge_requests.scss | 4 +- app/assets/stylesheets/pages/note_form.scss | 2 +- app/assets/stylesheets/pages/notes.scss | 10 +- app/assets/stylesheets/pages/projects.scss | 12 +- app/assets/stylesheets/pages/runners.scss | 2 +- app/assets/stylesheets/pages/search.scss | 2 +- app/assets/stylesheets/pages/status.scss | 2 +- app/assets/stylesheets/pages/todos.scss | 2 +- app/assets/stylesheets/pages/ui_dev_kit.scss | 2 +- app/assets/stylesheets/pages/xterm.scss | 52 ++++----- 45 files changed, 242 insertions(+), 242 deletions(-) diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 8d475137b0..76611efb78 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -37,23 +37,23 @@ } @mixin btn-green { - @include btn-color($green-light, $border-green-light, $green-normal, $border-green-normal, $green-dark, $border-green-dark, #FFFFFF); + @include btn-color($green-light, $border-green-light, $green-normal, $border-green-normal, $green-dark, $border-green-dark, #fff); } @mixin btn-blue { - @include btn-color($blue-light, $border-blue-light, $blue-normal, $border-blue-normal, $blue-dark, $border-blue-dark, #FFFFFF); + @include btn-color($blue-light, $border-blue-light, $blue-normal, $border-blue-normal, $blue-dark, $border-blue-dark, #fff); } @mixin btn-blue-medium { - @include btn-color($blue-medium-light, $border-blue-light, $blue-medium, $border-blue-normal, $blue-medium-dark, $border-blue-dark, #FFFFFF); + @include btn-color($blue-medium-light, $border-blue-light, $blue-medium, $border-blue-normal, $blue-medium-dark, $border-blue-dark, #fff); } @mixin btn-orange { - @include btn-color($orange-light, $border-orange-light, $orange-normal, $border-orange-normal, $orange-dark, $border-orange-dark, #FFFFFF); + @include btn-color($orange-light, $border-orange-light, $orange-normal, $border-orange-normal, $orange-dark, $border-orange-dark, #fff); } @mixin btn-red { - @include btn-color($red-light, $border-red-light, $red-normal, $border-red-normal, $red-dark, $border-red-dark, #FFFFFF); + @include btn-color($red-light, $border-red-light, $red-normal, $border-red-normal, $red-dark, $border-red-dark, #fff); } @mixin btn-gray { diff --git a/app/assets/stylesheets/framework/calendar.scss b/app/assets/stylesheets/framework/calendar.scss index 580012abd7..e3192823a1 100644 --- a/app/assets/stylesheets/framework/calendar.scss +++ b/app/assets/stylesheets/framework/calendar.scss @@ -33,19 +33,19 @@ } .q2 { - fill: #ACD5F2 !important; + fill: #acd5f2 !important; } .q3 { - fill: #7FA8D1 !important; + fill: #7fa8d1 !important; } .q4 { - fill: #49729B !important; + fill: #49729b !important; } .q5 { - fill: #254E77 !important; + fill: #254e77 !important; } .domain-background { diff --git a/app/assets/stylesheets/framework/callout.scss b/app/assets/stylesheets/framework/callout.scss index 20a9bfb981..da7bab74a3 100644 --- a/app/assets/stylesheets/framework/callout.scss +++ b/app/assets/stylesheets/framework/callout.scss @@ -39,6 +39,6 @@ } .bs-callout-success { background-color: #dff0d8; - border-color: #5cA64d; + border-color: #5ca64d; color: #3c763d; } diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index ff551f151f..180926b3b9 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -1,6 +1,6 @@ /** COLORS **/ .cgray { color: $gl-gray; } -.clgray { color: #BBB } +.clgray { color: #bbb } .cred { color: $gl-text-red; } .cgreen { color: $gl-text-green; } .cdark { color: #444 } @@ -51,7 +51,7 @@ pre { } &.well-pre { - border: 1px solid #EEE; + border: 1px solid #eee; background: #f9f9f9; border-radius: 0; color: #555; @@ -103,7 +103,7 @@ span.update-author { } .user-mention { - color: #2FA0BB; + color: #2fa0bb; font-weight: bold; } @@ -187,9 +187,9 @@ li.note { .error-message { padding: 10px; - background: #C67; + background: #c67; margin: 0; - color: #FFF; + color: #fff; a { color: #fff; @@ -200,7 +200,7 @@ li.note { .browser-alert { padding: 10px; text-align: center; - background: #C67; + background: #c67; color: #fff; font-weight: bold; a { @@ -271,7 +271,7 @@ img.emoji { table { td.permission-x { - background: #D9EDF7 !important; + background: #d9edf7 !important; text-align: center; } } @@ -280,7 +280,7 @@ table { float: left; text-align: center; font-size: 32px; - color: #AAA; + color: #aaa; width: 60px; } @@ -347,7 +347,7 @@ table { .profiler-button, .profiler-controls { - border-color: #EEE !important; + border-color: #eee !important; } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 5b647fc617..ffa5887807 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -261,7 +261,7 @@ position: absolute; top: 10px; right: 10px; - color: #C7C7C7; + color: #c7c7c7; font-size: 12px; pointer-events: none; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index b034a4882c..116cc0df81 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -84,7 +84,7 @@ &.blob-no-preview { background: #eee; - text-shadow: 0 1px 2px #FFF; + text-shadow: 0 1px 2px #fff; padding: 100px 0; } @@ -124,7 +124,7 @@ } td.line-numbers { float: none; - border-left: 1px solid #DDD; + border-left: 1px solid #ddd; } td.lines { padding: 0; diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index 6c08005812..4cb4129b71 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -41,8 +41,8 @@ input[type='search'].search-input { } input[type='text'].danger { - background: #F2DEDE!important; - border-color: #D66; + background: #f2dede!important; + border-color: #d66; text-shadow: 0 1px 1px #fff } diff --git a/app/assets/stylesheets/framework/gitlab-theme.scss b/app/assets/stylesheets/framework/gitlab-theme.scss index 12cef6f8ea..2a4cf4fc33 100644 --- a/app/assets/stylesheets/framework/gitlab-theme.scss +++ b/app/assets/stylesheets/framework/gitlab-theme.scss @@ -23,13 +23,13 @@ &:hover { background-color: $color-darker; a { - color: #FFF; + color: #fff; } } } .collapse-nav a { - color: #FFF; + color: #fff; background: $color; } @@ -42,7 +42,7 @@ &:hover { background-color: $color-dark; - color: #FFF; + color: #fff; text-decoration: none; } } @@ -71,7 +71,7 @@ } &.active a { - color: #FFF; + color: #fff; background: $color-dark; &.no-highlight { @@ -79,42 +79,42 @@ } i { - color: #FFF + color: #fff } } } } } -$theme-blue: #2980B9; +$theme-blue: #2980b9; $theme-charcoal: #333c47; -$theme-graphite: #888888; +$theme-graphite: #888; $theme-gray: #373737; $theme-green: #019875; -$theme-violet: #554488; +$theme-violet: #548; body { &.ui_blue { - @include gitlab-theme(#BECDE9, $theme-blue, #1970A9, #096099); + @include gitlab-theme(#becde9, $theme-blue, #1970a9, #096099); } &.ui_charcoal { - @include gitlab-theme(#c5d0de, $theme-charcoal, #2b333d, #24272D); + @include gitlab-theme(#c5d0de, $theme-charcoal, #2b333d, #24272d); } &.ui_graphite { - @include gitlab-theme(#CCCCCC, $theme-graphite, #777777, #666666); + @include gitlab-theme(#ccc, $theme-graphite, #777, #666); } &.ui_gray { - @include gitlab-theme(#979797, $theme-gray, #272727, #222222); + @include gitlab-theme(#979797, $theme-gray, #272727, #222); } &.ui_green { - @include gitlab-theme(#AADDCC, $theme-green, #018865, #017855); + @include gitlab-theme(#adc, $theme-green, #018865, #017855); } &.ui_violet { - @include gitlab-theme(#9988CC, $theme-violet, #443366, #332255); + @include gitlab-theme(#98c, $theme-violet, #436, #325); } } \ No newline at end of file diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 4c4033e3ae..5f70cd399f 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -7,8 +7,8 @@ header { &.navbar-empty { height: 58px; - background: #FFF; - border-bottom: 1px solid #EEE; + background: #fff; + border-bottom: 1px solid #eee; .center-logo { margin: 11px 0; @@ -28,7 +28,7 @@ header { min-height: $header-height; background-color: #fff; border: none; - border-bottom: 1px solid #EEE; + border-bottom: 1px solid #eee; .container-fluid { width: 100% !important; @@ -47,7 +47,7 @@ header { text-align: center; &:hover, &:focus, &:active { - background-color: #FFF; + background-color: #fff; } } @@ -59,7 +59,7 @@ header { right: 2px; &:hover { - background-color: #EEE; + background-color: #eee; } &.active { color: #7f8fa4; diff --git a/app/assets/stylesheets/framework/issue_box.scss b/app/assets/stylesheets/framework/issue_box.scss index 77a00586b2..7f7b7c806e 100644 --- a/app/assets/stylesheets/framework/issue_box.scss +++ b/app/assets/stylesheets/framework/issue_box.scss @@ -20,7 +20,7 @@ display: block; float: left; margin-right: 10px; - color: #FFF; + color: #fff; font-size: $gl-font-size; line-height: 25px; diff --git a/app/assets/stylesheets/framework/jquery.scss b/app/assets/stylesheets/framework/jquery.scss index 0cdcd923b3..525ed81b05 100644 --- a/app/assets/stylesheets/framework/jquery.scss +++ b/app/assets/stylesheets/framework/jquery.scss @@ -3,13 +3,13 @@ font-size: $font-size-base; &.ui-datepicker-inline { - border: 1px solid #DDD; + border: 1px solid #ddd; padding: 10px; width: 270px; .ui-datepicker-header { - background: #FFF; - border-color: #DDD; + background: #fff; + border-color: #ddd; } .ui-datepicker-calendar td a { @@ -19,7 +19,7 @@ } &.ui-autocomplete { - border-color: #DDD; + border-color: #ddd; padding: 0; margin-top: 2px; z-index: 1001; @@ -30,20 +30,20 @@ } .ui-state-default { - border: 1px solid #FFF; - background: #FFF; + border: 1px solid #fff; + background: #fff; color: #777; } .ui-state-highlight { - border: 1px solid #EEE; - background: #EEE; + border: 1px solid #eee; + background: #eee; } .ui-state-active { border: 1px solid $gl-primary; background: $gl-primary; - color: #FFF; + color: #fff; } .ui-state-hover, diff --git a/app/assets/stylesheets/framework/markdown_area.scss b/app/assets/stylesheets/framework/markdown_area.scss index 1d8611b04d..8328aac4e7 100644 --- a/app/assets/stylesheets/framework/markdown_area.scss +++ b/app/assets/stylesheets/framework/markdown_area.scss @@ -71,7 +71,7 @@ } .md-preview-holder { - background: #FFF; + background: #fff; border: 1px solid #ddd; min-height: 169px; padding: 5px; @@ -80,7 +80,7 @@ .markdown-area { @include border-radius(0); - background: #FFF; + background: #fff; border: 1px solid #ddd; min-height: 140px; max-height: 500px; diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 1d5000fe38..4b59c758f1 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -75,7 +75,7 @@ @include clearfix; padding: 10px 0; - border-bottom: 1px solid #EEE; + border-bottom: 1px solid #eee; display: block; margin: 0px; diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 3bfac2ad9b..ec10a70cf0 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -131,9 +131,9 @@ right: 0px; top: 30%; padding: 5px 15px; - background: #EEE; + background: #eee; font-size: 20px; color: #777; z-index: 100; - @include box-shadow(0 1px 2px #DDD); + @include box-shadow(0 1px 2px #ddd); } diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index b2fbc95e04..b53872bf67 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -26,7 +26,7 @@ } &.active a { - color: #000000; + color: #000; border-bottom: 2px solid #4688f1; } @@ -41,7 +41,7 @@ .top-area { @include clearfix; - border-bottom: 1px solid #EEE; + border-bottom: 1px solid #eee; .nav-text { padding-top: 16px; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index 7bf04e4ad7..cbcfef8a69 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -229,7 +229,7 @@ .namespace-result { .namespace-kind { - color: #AAA; + color: #aaa; font-weight: normal; } .namespace-path { diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 26df9acd2a..29570f2ce3 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -47,7 +47,7 @@ width: 100%; .container-fluid { - background: #FFF; + background: #fff; padding: 0 $gl-padding; &.container-blank { @@ -103,7 +103,7 @@ } &:hover { - background-color: #EEE; + background-color: #eee; } } diff --git a/app/assets/stylesheets/framework/tw_bootstrap.scss b/app/assets/stylesheets/framework/tw_bootstrap.scss index ddf76704a5..9946dd3e9b 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap.scss @@ -95,7 +95,7 @@ } &.label-inverse { - background-color: #333333; + background-color: #333; } } diff --git a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss index b1b8295411..f63ac03323 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap_variables.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap_variables.scss @@ -57,7 +57,7 @@ $component-active-bg: $brand-info; $input-color: $text-color; $input-border: #e7e9ed; -$input-border-focus: #7F8FA4; +$input-border-focus: #7f8fa4; $legend-color: $text-color; @@ -125,8 +125,8 @@ $panel-inner-border: $border-color; // //## -$well-bg: #F9F9F9; -$well-border: #EEE; +$well-bg: #f9f9f9; +$well-border: #eee; //== Code // diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 9381cb3281..6ff8852c2f 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -27,13 +27,13 @@ line-height: 10px; color: #555; vertical-align: middle; - background-color: #FCFCFC; + background-color: #fcfcfc; border-width: 1px; border-style: solid; - border-color: #CCC #CCC #BBB; + border-color: #ccc #ccc #bbb; border-image: none; border-radius: 3px; - box-shadow: 0px -1px 0px #BBB inset; + box-shadow: 0px -1px 0px #bbb inset; } h1 { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index d491d01a3c..3447c558e8 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -1,8 +1,8 @@ $row-hover: #f4f8fe; -$gl-text-color: #54565B; -$gl-text-green: #4A2; -$gl-text-red: #D12F19; -$gl-text-orange: #D90; +$gl-text-color: #54565b; +$gl-text-green: #4a2; +$gl-text-red: #d12f19; +$gl-text-orange: #d90; $gl-header-color: #323232; $gl-link-color: #333c48; $md-text-color: #444; @@ -30,24 +30,24 @@ $gl-vert-padding: 6px; $gl-padding-top:10px; $gl-avatar-size: 40px; $secondary-text: #7f8fa4; -$error-exclamation-point: #E62958; +$error-exclamation-point: #e62958; $border-radius-default: 3px; -$list-title-color: #333333; -$list-text-color: #555555; +$list-title-color: #333; +$list-text-color: #555; -$btn-transparent-color: #8F8F8F; +$btn-transparent-color: #8f8f8f; -$ssh-key-icon-color: #8F8F8F; +$ssh-key-icon-color: #8f8f8f; $ssh-key-icon-size: 18px; -$provider-btn-group-border: #E5E5E5; -$provider-btn-not-active-color: #4688F1; +$provider-btn-group-border: #e5e5e5; +$provider-btn-not-active-color: #4688f1; /* * Color schema */ -$white-light: #FFFFFF; +$white-light: #fff; $white-normal: #ededed; $white-dark: #ededed; @@ -57,55 +57,55 @@ $gray-dark: #ededed; $gray-darkest: #c9c9c9; $green-light: #38ae67; -$green-normal: #2FAA60; -$green-dark: #2CA05B; +$green-normal: #2faa60; +$green-dark: #2ca05b; -$blue-light: #2EA8E5; -$blue-normal: #2D9FD8; -$blue-dark: #2897CE; +$blue-light: #2ea8e5; +$blue-normal: #2d9fd8; +$blue-dark: #2897ce; -$blue-medium-light: #3498CB; -$blue-medium: #2F8EBF; -$blue-medium-dark: #2D86B4; +$blue-medium-light: #3498cb; +$blue-medium: #2f8ebf; +$blue-medium-dark: #2d86b4; $orange-light: rgba(252, 109, 38, 0.80); -$orange-normal: #E75E40; -$orange-dark: #CE5237; +$orange-normal: #e75e40; +$orange-dark: #ce5237; -$red-light: #F06559; -$red-normal: #E52C5A; -$red-dark: #D22852; +$red-light: #f06559; +$red-normal: #e52c5a; +$red-dark: #d22852; -$border-white-light: #F1F2F4; -$border-white-normal: #D6DAE2; -$border-white-dark: #C6CACF; +$border-white-light: #f1f2f4; +$border-white-normal: #d6dae2; +$border-white-dark: #c6cacf; $border-gray-light: rgba(0, 0, 0, 0.06); $border-gray-normal: rgba(0, 0, 0, 0.10);; -$border-gray-dark: #C6CACF; +$border-gray-dark: #c6cacf; -$border-green-light: #2FAA60; -$border-green-normal: #2CA05B; +$border-green-light: #2faa60; +$border-green-normal: #2ca05b; $border-green-dark: #279654; -$border-blue-light: #2D9FD8; -$border-blue-normal: #2897CE; -$border-blue-dark: #258DC1; +$border-blue-light: #2d9fd8; +$border-blue-normal: #2897ce; +$border-blue-dark: #258dc1; $border-orange-light: #fc6d26; -$border-orange-normal: #CE5237; -$border-orange-dark: #C14E35; +$border-orange-normal: #ce5237; +$border-orange-dark: #c14e35; -$border-red-light: #F24F41; -$border-red-normal: #D22852; -$border-red-dark: #CA264F; +$border-red-light: #f24f41; +$border-red-normal: #d22852; +$border-red-dark: #ca264f; -$help-well-bg: #FAFAFA; -$help-well-border: #E5E5E5; +$help-well-bg: #fafafa; +$help-well-border: #e5e5e5; -$warning-message-bg: #FBF2D9; -$warning-message-color: #9E8E60; -$warning-message-border: #F0E2BB; +$warning-message-bg: #fbf2d9; +$warning-message-color: #9e8e60; +$warning-message-border: #f0e2bb; /* header */ $light-grey-header: #faf9f9; @@ -143,23 +143,23 @@ $dropdown-border-color: rgba(#000, .1); $dropdown-shadow-color: rgba(#000, .1); $dropdown-divider-color: rgba(#000, .1); $dropdown-header-color: #959494; -$dropdown-caret-color: #54565B; -$dropdown-title-btn-color: #BFBFBF; -$dropdown-input-color: #C7C7C7; +$dropdown-caret-color: #54565b; +$dropdown-title-btn-color: #bfbfbf; +$dropdown-input-color: #c7c7c7; $dropdown-input-focus-border: rgb(58, 171, 240); $dropdown-input-focus-shadow: rgba(#000, .2); $dropdown-loading-bg: rgba(#fff, .6); $dropdown-toggle-bg: #fff; $dropdown-toggle-color: #626262; -$dropdown-toggle-border-color: #EAEAEA; +$dropdown-toggle-border-color: #eaeaea; $dropdown-toggle-hover-border-color: darken($dropdown-toggle-border-color, 15%); -$dropdown-toggle-icon-color: #C4C4C4; +$dropdown-toggle-icon-color: #c4c4c4; $dropdown-toggle-hover-icon-color: $dropdown-toggle-hover-border-color; /* * Award emoji */ -$award-emoji-menu-bg: #FFF; -$award-emoji-menu-border: #F1F2F4; -$award-emoji-new-btn-icon-color: #DCDCDC; +$award-emoji-menu-bg: #fff; +$award-emoji-menu-border: #f1f2f4; +$award-emoji-new-btn-icon-color: #dcdcdc; diff --git a/app/assets/stylesheets/highlight/dark.scss b/app/assets/stylesheets/highlight/dark.scss index b794da2ce9..4767394489 100644 --- a/app/assets/stylesheets/highlight/dark.scss +++ b/app/assets/stylesheets/highlight/dark.scss @@ -43,12 +43,12 @@ // Search result highlight span.highlight_word { background-color: #ffe792 !important; - color: #000000 !important; + color: #000 !important; } .hll { background-color: #373b41 } .c { color: #969896 } /* Comment */ - .err { color: #cc6666 } /* Error */ + .err { color: #c66 } /* Error */ .k { color: #b294bb } /* Keyword */ .l { color: #de935f } /* Literal */ .n { color: #c5c8c6 } /* Name */ @@ -58,7 +58,7 @@ .cp { color: #969896 } /* Comment.Preproc */ .c1 { color: #969896 } /* Comment.Single */ .cs { color: #969896 } /* Comment.Special */ - .gd { color: #cc6666 } /* Generic.Deleted */ + .gd { color: #c66 } /* Generic.Deleted */ .ge { font-style: italic } /* Generic.Emph */ .gh { color: #c5c8c6; font-weight: bold } /* Generic.Heading */ .gi { color: #b5bd68 } /* Generic.Inserted */ @@ -77,17 +77,17 @@ .na { color: #81a2be } /* Name.Attribute */ .nb { color: #c5c8c6 } /* Name.Builtin */ .nc { color: #f0c674 } /* Name.Class */ - .no { color: #cc6666 } /* Name.Constant */ + .no { color: #c66 } /* Name.Constant */ .nd { color: #8abeb7 } /* Name.Decorator */ .ni { color: #c5c8c6 } /* Name.Entity */ - .ne { color: #cc6666 } /* Name.Exception */ + .ne { color: #c66 } /* Name.Exception */ .nf { color: #81a2be } /* Name.Function */ .nl { color: #c5c8c6 } /* Name.Label */ .nn { color: #f0c674 } /* Name.Namespace */ .nx { color: #81a2be } /* Name.Other */ .py { color: #c5c8c6 } /* Name.Property */ .nt { color: #8abeb7 } /* Name.Tag */ - .nv { color: #cc6666 } /* Name.Variable */ + .nv { color: #c66 } /* Name.Variable */ .ow { color: #8abeb7 } /* Operator.Word */ .w { color: #c5c8c6 } /* Text.Whitespace */ .mf { color: #de935f } /* Literal.Number.Float */ @@ -106,8 +106,8 @@ .s1 { color: #b5bd68 } /* Literal.String.Single */ .ss { color: #b5bd68 } /* Literal.String.Symbol */ .bp { color: #c5c8c6 } /* Name.Builtin.Pseudo */ - .vc { color: #cc6666 } /* Name.Variable.Class */ - .vg { color: #cc6666 } /* Name.Variable.Global */ - .vi { color: #cc6666 } /* Name.Variable.Instance */ + .vc { color: #c66 } /* Name.Variable.Class */ + .vg { color: #c66 } /* Name.Variable.Global */ + .vi { color: #c66 } /* Name.Variable.Instance */ .il { color: #de935f } /* Literal.Number.Integer.Long */ } diff --git a/app/assets/stylesheets/highlight/monokai.scss b/app/assets/stylesheets/highlight/monokai.scss index 9098e07adc..806401c21a 100644 --- a/app/assets/stylesheets/highlight/monokai.scss +++ b/app/assets/stylesheets/highlight/monokai.scss @@ -43,7 +43,7 @@ // Search result highlight span.highlight_word { background-color: #ffe792 !important; - color: #000000 !important; + color: #000 !important; } .hll { background-color: #49483e } diff --git a/app/assets/stylesheets/highlight/solarized_dark.scss b/app/assets/stylesheets/highlight/solarized_dark.scss index 8b1a2824f7..6a809d4dfd 100644 --- a/app/assets/stylesheets/highlight/solarized_dark.scss +++ b/app/assets/stylesheets/highlight/solarized_dark.scss @@ -96,7 +96,7 @@ .m { color: #2aa198 } /* Literal.Number */ .s { color: #2aa198 } /* Literal.String */ .na { color: #93a1a1 } /* Name.Attribute */ - .nb { color: #B58900 } /* Name.Builtin */ + .nb { color: #b58900 } /* Name.Builtin */ .nc { color: #268bd2 } /* Name.Class */ .no { color: #cb4b16 } /* Name.Constant */ .nd { color: #268bd2 } /* Name.Decorator */ diff --git a/app/assets/stylesheets/highlight/solarized_light.scss b/app/assets/stylesheets/highlight/solarized_light.scss index 7ad89dd2c7..b90c95c62d 100644 --- a/app/assets/stylesheets/highlight/solarized_light.scss +++ b/app/assets/stylesheets/highlight/solarized_light.scss @@ -96,7 +96,7 @@ .m { color: #2aa198 } /* Literal.Number */ .s { color: #2aa198 } /* Literal.String */ .na { color: #586e75 } /* Name.Attribute */ - .nb { color: #B58900 } /* Name.Builtin */ + .nb { color: #b58900 } /* Name.Builtin */ .nc { color: #268bd2 } /* Name.Class */ .no { color: #cb4b16 } /* Name.Constant */ .nd { color: #268bd2 } /* Name.Decorator */ diff --git a/app/assets/stylesheets/highlight/white.scss b/app/assets/stylesheets/highlight/white.scss index 8a091028a6..8c1b0cd84e 100644 --- a/app/assets/stylesheets/highlight/white.scss +++ b/app/assets/stylesheets/highlight/white.scss @@ -23,7 +23,7 @@ .line_holder { .diff-line-num { &.old { - background: #ffdddd; + background: #fdd; border-color: #f1c0c0; } @@ -68,66 +68,66 @@ } .hll { background-color: #f8f8f8 } - .c { color: #999988; font-style: italic; } + .c { color: #998; font-style: italic; } .err { color: #a61717; background-color: #e3d2d2; } .k { font-weight: bold; } .o { font-weight: bold; } - .cm { color: #999988; font-style: italic; } - .cp { color: #999999; font-weight: bold; } - .c1 { color: #999988; font-style: italic; } - .cs { color: #999999; font-weight: bold; font-style: italic; } - .gd { color: #000000; background-color: #ffdddd; } - .gd .x { color: #000000; background-color: #ffaaaa; } + .cm { color: #998; font-style: italic; } + .cp { color: #999; font-weight: bold; } + .c1 { color: #998; font-style: italic; } + .cs { color: #999; font-weight: bold; font-style: italic; } + .gd { color: #000; background-color: #fdd; } + .gd .x { color: #000; background-color: #faa; } .ge { font-style: italic; } - .gr { color: #aa0000; } - .gh { color: #999999; } - .gi { color: #000000; background-color: #ddffdd; } - .gi .x { color: #000000; background-color: #aaffaa; } - .go { color: #888888; } - .gp { color: #555555; } + .gr { color: #a00; } + .gh { color: #999; } + .gi { color: #000; background-color: #dfd; } + .gi .x { color: #000; background-color: #afa; } + .go { color: #888; } + .gp { color: #555; } .gs { font-weight: bold; } .gu { color: #800080; font-weight: bold; } - .gt { color: #aa0000; } + .gt { color: #a00; } .kc { font-weight: bold; } .kd { font-weight: bold; } .kn { font-weight: bold; } .kp { font-weight: bold; } .kr { font-weight: bold; } - .kt { color: #445588; font-weight: bold; } - .m { color: #009999; } - .s { color: #dd1144; } - .n { color: #333333; } + .kt { color: #458; font-weight: bold; } + .m { color: #099; } + .s { color: #d14; } + .n { color: #333; } .na { color: teal; } .nb { color: #0086b3; } - .nc { color: #445588; font-weight: bold; } + .nc { color: #458; font-weight: bold; } .no { color: teal; } .ni { color: purple; } - .ne { color: #990000; font-weight: bold; } - .nf { color: #990000; font-weight: bold; } - .nn { color: #555555; } + .ne { color: #900; font-weight: bold; } + .nf { color: #900; font-weight: bold; } + .nn { color: #555; } .nt { color: navy; } .nv { color: teal; } .ow { font-weight: bold; } - .w { color: #bbbbbb; } - .mf { color: #009999; } - .mh { color: #009999; } - .mi { color: #009999; } - .mo { color: #009999; } - .sb { color: #dd1144; } - .sc { color: #dd1144; } - .sd { color: #dd1144; } - .s2 { color: #dd1144; } - .se { color: #dd1144; } - .sh { color: #dd1144; } - .si { color: #dd1144; } - .sx { color: #dd1144; } + .w { color: #bbb; } + .mf { color: #099; } + .mh { color: #099; } + .mi { color: #099; } + .mo { color: #099; } + .sb { color: #d14; } + .sc { color: #d14; } + .sd { color: #d14; } + .s2 { color: #d14; } + .se { color: #d14; } + .sh { color: #d14; } + .si { color: #d14; } + .sx { color: #d14; } .sr { color: #009926; } - .s1 { color: #dd1144; } + .s1 { color: #d14; } .ss { color: #990073; } - .bp { color: #999999; } + .bp { color: #999; } .vc { color: teal; } .vg { color: teal; } .vi { color: teal; } - .il { color: #009999; } - .gc { color: #999; background-color: #EAF2F5; } + .il { color: #099; } + .gc { color: #999; background-color: #eaf2f5; } } diff --git a/app/assets/stylesheets/pages/builds.scss b/app/assets/stylesheets/pages/builds.scss index 75f298019e..201f3e5ca4 100644 --- a/app/assets/stylesheets/pages/builds.scss +++ b/app/assets/stylesheets/pages/builds.scss @@ -1,6 +1,6 @@ .build-page { pre.trace { - background: #111111; + background: #111; color: #fff; font-family: $monospace_font; white-space: pre; diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 818fd03e2a..2062cd2ebc 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -9,7 +9,7 @@ .lists-separator { margin: 10px 0; - border-color: #DDD; + border-color: #ddd; } .commits-row { @@ -76,7 +76,7 @@ li.commit { .commit-row-description { font-size: 14px; - border-left: 1px solid #EEE; + border-left: 1px solid #eee; padding: 10px 15px; margin: 5px 0 10px 5px; background: #f9f9f9; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index a7925e7954..4739aa0502 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -29,7 +29,7 @@ .diff-content { overflow: auto; overflow-y: hidden; - background: #FFF; + background: #fff; color: #333; .unfold { @@ -118,7 +118,7 @@ background-color: #fff; line-height: 0; img { - border: 1px solid #FFF; + border: 1px solid #fff; background: image-url('trans_bg.gif'); max-width: 100%; } @@ -265,7 +265,7 @@ .view-modes { padding: 10px; text-align: center; - background: #EEE; + background: #eee; ul, li { list-style: none; diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 39d916cd33..43be5e38ba 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -14,9 +14,9 @@ } .cancel-btn { - color: #B94A48; + color: #b94a48; &:hover { - color: #B94A48; + color: #b94a48; } } diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 35df9a61c8..1833e2136b 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -83,10 +83,10 @@ .event_icon { position: relative; float: right; - border: 1px solid #EEE; + border: 1px solid #eee; padding: 5px; @include border-radius(5px); - background: #F9F9F9; + background: #f9f9f9; margin-left: 10px; top: -6px; img { @@ -160,7 +160,7 @@ .event-body { margin: 0; - border-left: 2px solid #DDD; + border-left: 2px solid #ddd; padding-left: 10px; } diff --git a/app/assets/stylesheets/pages/graph.scss b/app/assets/stylesheets/pages/graph.scss index c3b10d144e..4e5c4ed84b 100644 --- a/app/assets/stylesheets/pages/graph.scss +++ b/app/assets/stylesheets/pages/graph.scss @@ -6,11 +6,11 @@ font-size: 14px; padding: 5px; border-bottom: 1px solid $border-color; - background: #EEE; + background: #eee; } .network-graph { - background: #FFF; + background: #fff; height: 500px; overflow-y: scroll; overflow-x: hidden; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 1310e6ad7c..faa2ebfda7 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -222,7 +222,7 @@ width: 100%; text-align: center; padding-bottom: 10px; - color: #999999; + color: #999; span { display: block; @@ -237,7 +237,7 @@ } i { - color: #999999; + color: #999; } } } diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 1b686c58ea..73718ff511 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -68,18 +68,18 @@ form.edit-issue { .merge-request, .issue { &.today { - background: #EFE; - border-color: #CEC; + background: #efe; + border-color: #cec; } &.closed { - background: #F9F9F9; - border-color: #E5E5E5; + background: #f9f9f9; + border-color: #e5e5e5; } &.merged { - background: #F9F9F9; - border-color: #E5E5E5; + background: #f9f9f9; + border-color: #e5e5e5; } } diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 61bec02f6c..6c35a74f08 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -39,7 +39,7 @@ .login-box{ background: #fafafa; border-radius: 10px; - box-shadow: 0 0px 2px #CCC; + box-shadow: 0 0px 2px #ccc; padding: 15px; .login-heading h3 { @@ -90,7 +90,7 @@ } &:active, &:focus { - background-color: #FFF; + background-color: #fff; } } diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 2772623f4b..cee5c47cfb 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -113,7 +113,7 @@ } .mr-widget-footer { - border-top: 1px solid #EEE; + border-top: 1px solid #eee; } .ci-coverage { @@ -222,7 +222,7 @@ margin-bottom: 20px; span { - color: #B2B2B2; + color: #b2b2b2; a { color: $md-link-color; diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 158c2a4786..61783ec46a 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -156,7 +156,7 @@ .comment-hints { color: #999; - background: #FFF; + background: #fff; padding: 7px; margin-top: -7px; border: 1px solid $border-color; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index d5f9852ebe..1f631ab03e 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -30,7 +30,7 @@ ul.notes { font-size: 14px; padding-top: 10px; padding-bottom: 10px; - background: #FDFDFD; + background: #fdfdfd; .timeline-icon { .avatar { @@ -129,7 +129,7 @@ ul.notes { hr { // Darken 'whitesmoke' a bit to make it more visible in note bodies - border-color: darken(#F5F5F5, 8%); + border-color: darken(#f5f5f5, 8%); margin: 10px 0; } } @@ -166,7 +166,7 @@ ul.notes { vertical-align: middle; text-align: center; padding: 10px 0; - background: #FFF; + background: #fff; color: $text-color; } &.notes_line2 { @@ -232,7 +232,7 @@ ul.notes { .add-diff-note { margin-top: -4px; @include border-radius(40px); - background: #FFF; + background: #fff; padding: 4px; font-size: 16px; color: $gl-link-color; @@ -249,7 +249,7 @@ ul.notes { &:hover { background: $gl-info; - color: #FFF; + color: #fff; @include show-add-diff-note; } } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index b1b76edfb3..a29b735eb0 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -140,7 +140,7 @@ left: 1px; margin-top: -9px; border-width: 10px 7px 10px 0; - border-right-color: #FFF; + border-right-color: #fff; } } .count { @@ -165,7 +165,7 @@ margin: 0 11px 0px 4px; &:hover { - background: #FFF; + background: #fff; } } } @@ -462,7 +462,7 @@ pre.light-well { .form-control { @extend .monospace; - background: #FFF; + background: #fff; font-size: 14px; margin-left: -1px; cursor: auto; @@ -472,16 +472,16 @@ pre.light-well { .cannot-be-merged, .cannot-be-merged:hover { - color: #E62958; + color: #e62958; margin-top: 2px; } .private-forks-notice .private-fork-icon { i:nth-child(1) { - color: #2AA056; + color: #2aa056; } i:nth-child(2) { - color: #FFFFFF; + color: #fff; } } diff --git a/app/assets/stylesheets/pages/runners.scss b/app/assets/stylesheets/pages/runners.scss index a9111a7388..eec22c5dc9 100644 --- a/app/assets/stylesheets/pages/runners.scss +++ b/app/assets/stylesheets/pages/runners.scss @@ -1,7 +1,7 @@ .runner-state { padding: 6px 12px; margin-right: 10px; - color: #FFF; + color: #fff; &.runner-state-shared { background: #32b186; diff --git a/app/assets/stylesheets/pages/search.scss b/app/assets/stylesheets/pages/search.scss index 84234b15c6..b6e4502464 100644 --- a/app/assets/stylesheets/pages/search.scss +++ b/app/assets/stylesheets/pages/search.scss @@ -16,7 +16,7 @@ margin-bottom: 20px; input { - border-color: #BBB; + border-color: #bbb; font-weight: bold; } } diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 4b6ef03567..6f777d1164 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -1,7 +1,7 @@ .ci-status { padding: 2px 7px; margin-right: 5px; - border: 1px solid #EEE; + border: 1px solid #eee; white-space: nowrap; @include border-radius(4px); diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 479c3c16d4..2ab40941c4 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -89,7 +89,7 @@ .todo-body { margin: 0; - border-left: 2px solid #DDD; + border-left: 2px solid #ddd; padding-left: 10px; } } diff --git a/app/assets/stylesheets/pages/ui_dev_kit.scss b/app/assets/stylesheets/pages/ui_dev_kit.scss index 05fa9312ef..587bd6a1e8 100644 --- a/app/assets/stylesheets/pages/ui_dev_kit.scss +++ b/app/assets/stylesheets/pages/ui_dev_kit.scss @@ -7,7 +7,7 @@ .example { &:before { content: "Example"; - color: #BBB; + color: #bbb; } padding: 15px; diff --git a/app/assets/stylesheets/pages/xterm.scss b/app/assets/stylesheets/pages/xterm.scss index 9a50096c0d..8886c1dff5 100644 --- a/app/assets/stylesheets/pages/xterm.scss +++ b/app/assets/stylesheets/pages/xterm.scss @@ -2,23 +2,23 @@ // color codes are based on http://en.wikipedia.org/wiki/File:Xterm_256color_chart.svg // see also: https://gist.github.com/jasonm23/2868981 - $black: #000000; + $black: #000; $red: #cd0000; $green: #00cd00; $yellow: #cdcd00; - $blue: #0000ee; // according to wikipedia, this is the xterm standard + $blue: #00e; // according to wikipedia, this is the xterm standard //$blue: #1e90ff; // this is used by all the terminals I tried (when configured with the xterm color profile) $magenta: #cd00cd; $cyan: #00cdcd; $white: #e5e5e5; $l-black: #7f7f7f; - $l-red: #ff0000; - $l-green: #00ff00; - $l-yellow: #ffff00; + $l-red: #f00; + $l-green: #0f0; + $l-yellow: #ff0; $l-blue: #5c5cff; - $l-magenta: #ff00ff; - $l-cyan: #00ffff; - $l-white: #ffffff; + $l-magenta: #f0f; + $l-cyan: #0ff; + $l-white: #fff; .term-bold { font-weight: bold; @@ -136,7 +136,7 @@ .xterm-fg-0 { - color: #000000; + color: #000; } .xterm-fg-1 { color: #800000; @@ -163,28 +163,28 @@ color: #808080; } .xterm-fg-9 { - color: #ff0000; + color: #f00; } .xterm-fg-10 { - color: #00ff00; + color: #0f0; } .xterm-fg-11 { - color: #ffff00; + color: #ff0; } .xterm-fg-12 { - color: #0000ff; + color: #00f; } .xterm-fg-13 { - color: #ff00ff; + color: #f0f; } .xterm-fg-14 { - color: #00ffff; + color: #0ff; } .xterm-fg-15 { - color: #ffffff; + color: #fff; } .xterm-fg-16 { - color: #000000; + color: #000; } .xterm-fg-17 { color: #00005f; @@ -199,7 +199,7 @@ color: #0000d7; } .xterm-fg-21 { - color: #0000ff; + color: #00f; } .xterm-fg-22 { color: #005f00; @@ -274,7 +274,7 @@ color: #00d7ff; } .xterm-fg-46 { - color: #00ff00; + color: #0f0; } .xterm-fg-47 { color: #00ff5f; @@ -289,7 +289,7 @@ color: #00ffd7; } .xterm-fg-51 { - color: #00ffff; + color: #0ff; } .xterm-fg-52 { color: #5f0000; @@ -724,7 +724,7 @@ color: #d7ffff; } .xterm-fg-196 { - color: #ff0000; + color: #f00; } .xterm-fg-197 { color: #ff005f; @@ -739,7 +739,7 @@ color: #ff00d7; } .xterm-fg-201 { - color: #ff00ff; + color: #f0f; } .xterm-fg-202 { color: #ff5f00; @@ -814,7 +814,7 @@ color: #ffd7ff; } .xterm-fg-226 { - color: #ffff00; + color: #ff0; } .xterm-fg-227 { color: #ffff5f; @@ -829,7 +829,7 @@ color: #ffffd7; } .xterm-fg-231 { - color: #ffffff; + color: #fff; } .xterm-fg-232 { color: #080808; @@ -850,7 +850,7 @@ color: #3a3a3a; } .xterm-fg-238 { - color: #444444; + color: #444; } .xterm-fg-239 { color: #4e4e4e; @@ -901,6 +901,6 @@ color: #e4e4e4; } .xterm-fg-255 { - color: #eeeeee; + color: #eee; } } From 4bcc0977500af7af070296e2faf817eed10aafb9 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Wed, 17 Feb 2016 14:41:33 +0100 Subject: [PATCH 143/797] A worker deletes a user, so the request doesn't time out Fixes #13261 --- CHANGELOG | 1 + app/controllers/admin/users_controller.rb | 2 +- app/workers/delete_user_worker.rb | 10 ++++++++++ spec/workers/delete_user_worker_spec.rb | 22 ++++++++++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 app/workers/delete_user_worker.rb create mode 100644 spec/workers/delete_user_worker_spec.rb diff --git a/CHANGELOG b/CHANGELOG index 7f076f70c7..6de17a4a09 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -178,6 +178,7 @@ v 8.5.0 v 8.4.5 - No CE-specific changes + - User deletion is now done in the background so the request can not time out v 8.4.4 - Update omniauth-saml gem to 1.4.2 diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 87f4fb455b..3fe33018d7 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -122,7 +122,7 @@ class Admin::UsersController < Admin::ApplicationController DeleteUserService.new(current_user).execute(user) respond_to do |format| - format.html { redirect_to admin_users_path } + format.html { redirect_to admin_users_path, notice: "The user is being deleted." } format.json { head :ok } end end diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb new file mode 100644 index 0000000000..bcbb6d58b4 --- /dev/null +++ b/app/workers/delete_user_worker.rb @@ -0,0 +1,10 @@ +class DeleteUserWorker + include Sidekiq::Worker + + def perform(current_user_id, delete_user_id) + delete_user = User.find(delete_user_id) + current_user = User.find(current_user_id) + + DeleteUserService.new(current_user).execute(delete_user) + end +end diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb new file mode 100644 index 0000000000..73005c507c --- /dev/null +++ b/spec/workers/delete_user_worker_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe DeleteUserWorker do + describe "Deletes a user and all their personal projects" do + let!(:user) { create(:user) } + let!(:current_user) { create(:user) } + let!(:namespace) { create(:namespace, owner: user) } + let!(:project) { create(:project, namespace: namespace) } + + before do + DeleteUserWorker.new.perform(current_user.id, user.id) + end + + it 'deletes all personal projects' do + expect { Project.find(project.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'deletes the user' do + expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end +end From b221d11a25218d3c2ad5304be91c849e1872be6c Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Thu, 25 Feb 2016 09:20:28 +0100 Subject: [PATCH 144/797] Add ability to delete a user with force --- CHANGELOG | 2 +- app/controllers/admin/users_controller.rb | 2 +- app/models/abuse_report.rb | 4 ++-- app/services/delete_user_service.rb | 26 +++++++++++++---------- app/services/destroy_group_service.rb | 4 ++-- app/workers/delete_user_worker.rb | 4 ++-- 6 files changed, 23 insertions(+), 19 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 6de17a4a09..bdeb9b68bd 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -59,6 +59,7 @@ v 8.5.3 - Show commit message in JIRA mention comment - Makes issue page and merge request page usable on mobile browsers. - Improved UI for profile settings + - User deletion is now done in the background so the request can not time out v 8.5.2 - Fix sidebar overlapping content when screen width was below 1200px @@ -178,7 +179,6 @@ v 8.5.0 v 8.4.5 - No CE-specific changes - - User deletion is now done in the background so the request can not time out v 8.4.4 - Update omniauth-saml gem to 1.4.2 diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 3fe33018d7..3063d299b1 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -119,7 +119,7 @@ class Admin::UsersController < Admin::ApplicationController end def destroy - DeleteUserService.new(current_user).execute(user) + DeleteUserWorker.perform_async(current_user.id, user.id) respond_to do |format| format.html { redirect_to admin_users_path, notice: "The user is being deleted." } diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index cc59aa4e91..155b5f327c 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -19,9 +19,9 @@ class AbuseReport < ActiveRecord::Base validates :message, presence: true validates :user_id, uniqueness: { message: 'has already been reported' } - def remove_user + def remove_user(current_user) user.block - user.destroy + DeleteUserWorker.perform_async(current_user.id, user.id, force: true) end def notify diff --git a/app/services/delete_user_service.rb b/app/services/delete_user_service.rb index 173e50c920..390960257c 100644 --- a/app/services/delete_user_service.rb +++ b/app/services/delete_user_service.rb @@ -5,18 +5,22 @@ class DeleteUserService @current_user = current_user end - def execute(user) - if user.solo_owned_groups.present? + def execute(user, options = {}) + if !options[:force] && user.solo_owned_groups.present? user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user' - user - else - user.personal_projects.each do |project| - # Skip repository removal because we remove directory with namespace - # that contain all this repositories - ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! - end - - user.destroy + return user end + + user.solo_owned_groups.each do |group| + DeleteGroupService.new(group, current_user).execute + end + + user.personal_projects.each do |project| + # Skip repository removal because we remove directory with namespace + # that contain all this repositories + ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! + end + + user.destroy end end diff --git a/app/services/destroy_group_service.rb b/app/services/destroy_group_service.rb index 9189de390a..3c42ac61be 100644 --- a/app/services/destroy_group_service.rb +++ b/app/services/destroy_group_service.rb @@ -6,12 +6,12 @@ class DestroyGroupService end def execute - @group.projects.each do |project| + group.projects.each do |project| # Skip repository removal because we remove directory with namespace # that contain all this repositories ::Projects::DestroyService.new(project, current_user, skip_repo: true).pending_delete! end - @group.destroy + group.destroy end end diff --git a/app/workers/delete_user_worker.rb b/app/workers/delete_user_worker.rb index bcbb6d58b4..6ff361e4d8 100644 --- a/app/workers/delete_user_worker.rb +++ b/app/workers/delete_user_worker.rb @@ -1,10 +1,10 @@ class DeleteUserWorker include Sidekiq::Worker - def perform(current_user_id, delete_user_id) + def perform(current_user_id, delete_user_id, options = {}) delete_user = User.find(delete_user_id) current_user = User.find(current_user_id) - DeleteUserService.new(current_user).execute(delete_user) + DeleteUserService.new(current_user).execute(delete_user, options.symbolize_keys) end end From d249da1c16bea8f9d340520168c64794c5f78928 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Fri, 4 Mar 2016 09:49:23 +0100 Subject: [PATCH 145/797] Specs on force deleting of a user --- app/services/delete_user_service.rb | 2 +- spec/workers/delete_user_worker_spec.rb | 48 +++++++++++++++++++++---- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/app/services/delete_user_service.rb b/app/services/delete_user_service.rb index 390960257c..5f2e4f75b7 100644 --- a/app/services/delete_user_service.rb +++ b/app/services/delete_user_service.rb @@ -12,7 +12,7 @@ class DeleteUserService end user.solo_owned_groups.each do |group| - DeleteGroupService.new(group, current_user).execute + DestroyGroupService.new(group, current_user).execute end user.personal_projects.each do |project| diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb index 73005c507c..bec9aecb48 100644 --- a/spec/workers/delete_user_worker_spec.rb +++ b/spec/workers/delete_user_worker_spec.rb @@ -7,16 +7,52 @@ describe DeleteUserWorker do let!(:namespace) { create(:namespace, owner: user) } let!(:project) { create(:project, namespace: namespace) } - before do - DeleteUserWorker.new.perform(current_user.id, user.id) + context 'no force flag given' do + before do + DeleteUserWorker.new.perform(current_user.id, user.id) + end + + it 'deletes all personal projects' do + expect { Project.find(project.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'deletes the user' do + expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end end - it 'deletes all personal projects' do - expect { Project.find(project.id) }.to raise_error(ActiveRecord::RecordNotFound) + context "solo owned groups present" do + let(:solo_owned) { create(:group) } + let(:member) { create(:group_member) } + let(:user) { user = member.user } + + before do + solo_owned.group_members = [member] + DeleteUserWorker.new.perform(current_user.id, user.id) + end + + it 'does not delete the user' do + expect(User.find(user.id)).to eq user + end end - it 'deletes the user' do - expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + context "deletions with force" do + let(:solo_owned) { create(:group) } + let(:member) { create(:group_member) } + let(:user) { user = member.user } + + before do + solo_owned.group_members = [member] + DeleteUserWorker.new.perform(current_user.id, user.id, "force" => true) + end + + it 'deletes solo owned groups' do + expect { Project.find(solo_owned.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'deletes the user' do + expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end end end end From 4231cfeba5f6de519a8609b8aa8a6880e38c2414 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Mon, 7 Mar 2016 12:40:21 +0100 Subject: [PATCH 146/797] Target right release, rebase got it wrong --- CHANGELOG | 1 + spec/workers/delete_user_worker_spec.rb | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index bdeb9b68bd..ee9d3cebf4 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -49,6 +49,7 @@ v 8.5.5 - Fix pagination for filtered dashboard and explore pages - Fix "Show all" link behavior - Add #upcoming filter to Milestone filter (Tiago Botelho) + - User deletion is now done in the background so the request can not time out v 8.5.4 - Do not cache requests for badges (including builds badge) diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb index bec9aecb48..a98ce6e599 100644 --- a/spec/workers/delete_user_worker_spec.rb +++ b/spec/workers/delete_user_worker_spec.rb @@ -24,7 +24,7 @@ describe DeleteUserWorker do context "solo owned groups present" do let(:solo_owned) { create(:group) } let(:member) { create(:group_member) } - let(:user) { user = member.user } + let(:user) { member.user } before do solo_owned.group_members = [member] @@ -39,7 +39,7 @@ describe DeleteUserWorker do context "deletions with force" do let(:solo_owned) { create(:group) } let(:member) { create(:group_member) } - let(:user) { user = member.user } + let(:user) { member.user } before do solo_owned.group_members = [member] From a09323c9f5df69a60d00278dec64775821751711 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Mon, 7 Mar 2016 13:46:37 +0100 Subject: [PATCH 147/797] Fix broken specs --- app/controllers/admin/abuse_reports_controller.rb | 2 +- spec/models/abuse_report_spec.rb | 15 ++++++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index 2463cfa87b..a3f78e3477 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -6,7 +6,7 @@ class Admin::AbuseReportsController < Admin::ApplicationController def destroy abuse_report = AbuseReport.find(params[:id]) - abuse_report.remove_user if params[:remove_user] + abuse_report.remove_user(current_user) if params[:remove_user] abuse_report.destroy render nothing: true diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index 4799bbaa57..8d16239bcb 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -13,7 +13,8 @@ require 'rails_helper' RSpec.describe AbuseReport, type: :model do - subject { create(:abuse_report) } + subject { create(:abuse_report) } + let(:user) { create(:user) } it { expect(subject).to be_valid } @@ -31,17 +32,13 @@ RSpec.describe AbuseReport, type: :model do describe '#remove_user' do it 'blocks the user' do - report = build(:abuse_report) - - allow(report.user).to receive(:destroy) - - expect { report.remove_user }.to change { report.user.blocked? }.to(true) + expect { subject.remove_user(user) }.to change { subject.user.blocked? }.to(true) end - it 'removes the user' do - report = build(:abuse_report) + it 'lets a worker delete the user' do + expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, force: true) - expect { report.remove_user }.to change { User.count }.by(-1) + subject.remove_user(user) end end From 8077a5f9589e365f58f22015819bff7debcd7299 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Sat, 12 Mar 2016 13:11:30 +0100 Subject: [PATCH 148/797] Clearify who deletes the user --- CHANGELOG | 2 +- app/controllers/admin/abuse_reports_controller.rb | 2 +- app/models/abuse_report.rb | 4 ++-- app/services/delete_user_service.rb | 2 +- spec/models/abuse_report_spec.rb | 7 ++++--- spec/workers/delete_user_worker_spec.rb | 2 +- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index ee9d3cebf4..fd7c7e7bc9 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -39,6 +39,7 @@ v 8.6.0 (unreleased) - Add ability to show archived projects on dashboard, explore and group pages - Move group activity to separate page - Continue parameters are checked to ensure redirection goes to the same instance + - User deletion is now done in the background so the request can not time out v 8.5.6 - Obtain a lease before querying LDAP @@ -60,7 +61,6 @@ v 8.5.3 - Show commit message in JIRA mention comment - Makes issue page and merge request page usable on mobile browsers. - Improved UI for profile settings - - User deletion is now done in the background so the request can not time out v 8.5.2 - Fix sidebar overlapping content when screen width was below 1200px diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index a3f78e3477..e9b0972bdd 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -6,7 +6,7 @@ class Admin::AbuseReportsController < Admin::ApplicationController def destroy abuse_report = AbuseReport.find(params[:id]) - abuse_report.remove_user(current_user) if params[:remove_user] + abuse_report.remove_user(deleted_by: current_user) if params[:remove_user] abuse_report.destroy render nothing: true diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 155b5f327c..b61f512312 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -19,9 +19,9 @@ class AbuseReport < ActiveRecord::Base validates :message, presence: true validates :user_id, uniqueness: { message: 'has already been reported' } - def remove_user(current_user) + def remove_user(deleted_by:) user.block - DeleteUserWorker.perform_async(current_user.id, user.id, force: true) + DeleteUserWorker.perform_async(deleted_by.id, user.id, delete_solo_owned_groups: true) end def notify diff --git a/app/services/delete_user_service.rb b/app/services/delete_user_service.rb index 5f2e4f75b7..ce79287e35 100644 --- a/app/services/delete_user_service.rb +++ b/app/services/delete_user_service.rb @@ -6,7 +6,7 @@ class DeleteUserService end def execute(user, options = {}) - if !options[:force] && user.solo_owned_groups.present? + if !options[:delete_solo_owned_groups] && user.solo_owned_groups.present? user.errors[:base] << 'You must transfer ownership or delete groups before you can remove user' return user end diff --git a/spec/models/abuse_report_spec.rb b/spec/models/abuse_report_spec.rb index 8d16239bcb..ac12ab6c75 100644 --- a/spec/models/abuse_report_spec.rb +++ b/spec/models/abuse_report_spec.rb @@ -32,13 +32,14 @@ RSpec.describe AbuseReport, type: :model do describe '#remove_user' do it 'blocks the user' do - expect { subject.remove_user(user) }.to change { subject.user.blocked? }.to(true) + expect { subject.remove_user(deleted_by: user) }.to change { subject.user.blocked? }.to(true) end it 'lets a worker delete the user' do - expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, force: true) + expect(DeleteUserWorker).to receive(:perform_async).with(user.id, subject.user.id, + delete_solo_owned_groups: true) - subject.remove_user(user) + subject.remove_user(deleted_by: user) end end diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb index a98ce6e599..975b0470cb 100644 --- a/spec/workers/delete_user_worker_spec.rb +++ b/spec/workers/delete_user_worker_spec.rb @@ -43,7 +43,7 @@ describe DeleteUserWorker do before do solo_owned.group_members = [member] - DeleteUserWorker.new.perform(current_user.id, user.id, "force" => true) + DeleteUserWorker.new.perform(current_user.id, user.id, "delete_solo_owned_groups" => true) end it 'deletes solo owned groups' do From aff7c92596df8a277bd59411fb77425c91a403ba Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Tue, 15 Mar 2016 15:09:55 -0500 Subject: [PATCH 149/797] Typo --- app/views/shared/issuable/_filter.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 42a3c2c3f0..3eb0db276b 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -16,7 +16,7 @@ - if params[:assignee_id] = hidden_field_tag(:assignee_id, params[:assignee_id]) = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee", - placeholder: "Search assignee", data: { any_user: "Any Author", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id" } }) + placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id" } }) .filter-item.inline.milestone-filter - if params[:milestone_title] From 48274581551b73575149463be0c050f6b5a564ee Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Tue, 15 Mar 2016 20:17:51 +0100 Subject: [PATCH 150/797] Incorporate the review and update spec The feature spec now also tests the absence of the new branch button --- CHANGELOG | 2 +- app/models/issue.rb | 22 +++++++++---------- app/services/system_note_service.rb | 4 ++-- .../features/issues/new_branch_button_spec.rb | 11 +++++----- spec/services/system_note_service_spec.rb | 2 +- 5 files changed, 20 insertions(+), 21 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 563a596640..b4a0bea736 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -4,6 +4,7 @@ v 8.6.0 (unreleased) - Bump gitlab_git to 9.0.3 (Stan Hu) - Support Golang subpackage fetching (Stan Hu) - Bump Capybara gem to 2.6.2 (Stan Hu) + - New branch button appears on issues where applicable - Contributions to forked projects are included in calendar - Improve the formatting for the user page bio (Connor Shea) - Removed the default password from the initial admin account created during @@ -59,7 +60,6 @@ v 8.5.3 - Show commit message in JIRA mention comment - Makes issue page and merge request page usable on mobile browsers. - Improved UI for profile settings - - New branch button appears on issues where applicable v 8.5.2 - Fix sidebar overlapping content when screen width was below 1200px diff --git a/app/models/issue.rb b/app/models/issue.rb index ec275d5f5b..781298a63b 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -87,20 +87,20 @@ class Issue < ActiveRecord::Base end def referenced_merge_requests(current_user = nil) - if defined?(@referenced_merge_requests) - @referenced_merge_requests[current_user] ||= Gitlab::ReferenceExtractor.lazily do - [self, *notes].flat_map do |note| - note.all_references(current_user).merge_requests - end - end.sort_by(&:iid).uniq - else - @referenced_merge_requests = {} - referenced_merge_requests(current_user) + @referenced_merge_requests ||= {} + @referenced_merge_requests[current_user] ||= begin + Gitlab::ReferenceExtractor.lazily do + [self, *notes].flat_map do |note| + note.all_references(current_user).merge_requests + end + end.sort_by(&:iid).uniq end end def related_branches - self.project.repository.branch_names.select { |branch| branch.start_with? "#{iid}-" } + self.project.repository.branch_names.select do |branch| + branch =~ /\A#{iid}-(?!\d+-stable)/i + end end # Reset issue events cache @@ -138,6 +138,6 @@ class Issue < ActiveRecord::Base !self.closed? && !self.project.forked? && self.related_branches.empty? && - self.referenced_merge_requests(current_user).empty? + self.closed_by_merge_requests(current_user).empty? end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 5ea7d405e4..f09b77c4a5 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -213,9 +213,9 @@ class SystemNoteService # "Started branch `201-issue-branch-button`" def self.new_issue_branch(issue, project, author, branch) h = Gitlab::Application.routes.url_helpers - link = "#{h.namespace_project_compare_url(project.namespace, project, from: project.default_branch, to: branch)}" + link = h.namespace_project_compare_url(project.namespace, project, from: project.default_branch, to: branch) - body = "Started branch [#{branch}](#{link})" + body = "Started branch [`#{branch}`](#{link})" create_note(noteable: issue, project: project, author: author, note: body) end diff --git a/spec/features/issues/new_branch_button_spec.rb b/spec/features/issues/new_branch_button_spec.rb index f47d7a5471..462cb1f8f6 100644 --- a/spec/features/issues/new_branch_button_spec.rb +++ b/spec/features/issues/new_branch_button_spec.rb @@ -18,14 +18,13 @@ feature 'Start new branch from an issue', feature: true do end context "when there is a referenced merge request" do - let(:note) do + let(:note) do create(:note, :on_issue, :system, project: project, - note: "mentioned in !#{referenced_mr.iid}") + note: "mentioned in !#{referenced_mr.iid}") end - let(:referenced_mr) do - create(:merge_request, source_project: project, - target_project: project, - description: "Fixes ##{issue.iid}") + let(:referenced_mr) do + create(:merge_request, :simple, source_project: project, target_project: project, + description: "Fixes ##{issue.iid}") end before do diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 2730b42c61..8e6292014d 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -287,7 +287,7 @@ describe SystemNoteService, services: true do context 'when a branch is created from the new branch button' do it 'sets the note text' do - expect(subject.note).to eq 'Started branch 1-mepmep' + expect(subject.note).to match /\AStarted branch [`1-mepmep`]/ end end end From 29a43373ce9b3676ae7baea61b0ecaf67db67445 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Mon, 14 Mar 2016 11:18:03 +0100 Subject: [PATCH 151/797] Specs for the DeleteUserService --- CHANGELOG | 1 - spec/services/delete_user_service_spec.rb | 58 ++++++++++++++++++++++ spec/workers/delete_user_worker_spec.rb | 60 +++++------------------ 3 files changed, 69 insertions(+), 50 deletions(-) create mode 100644 spec/services/delete_user_service_spec.rb diff --git a/CHANGELOG b/CHANGELOG index fd7c7e7bc9..2b5c03b875 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -50,7 +50,6 @@ v 8.5.5 - Fix pagination for filtered dashboard and explore pages - Fix "Show all" link behavior - Add #upcoming filter to Milestone filter (Tiago Botelho) - - User deletion is now done in the background so the request can not time out v 8.5.4 - Do not cache requests for badges (including builds badge) diff --git a/spec/services/delete_user_service_spec.rb b/spec/services/delete_user_service_spec.rb new file mode 100644 index 0000000000..a65938fa03 --- /dev/null +++ b/spec/services/delete_user_service_spec.rb @@ -0,0 +1,58 @@ +require 'spec_helper' + +describe DeleteUserService, services: true do + describe "Deletes a user and all their personal projects" do + let!(:user) { create(:user) } + let!(:current_user) { create(:user) } + let!(:namespace) { create(:namespace, owner: user) } + let!(:project) { create(:project, namespace: namespace) } + + context 'no options are given' do + it 'deletes the user' do + DeleteUserService.new(current_user).execute(user) + + expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'will delete the project in the near future' do + expect_any_instance_of(Projects::DestroyService).to receive(:pending_delete!).once + + DeleteUserService.new(current_user).execute(user) + end + end + + context "solo owned groups present" do + let(:solo_owned) { create(:group) } + let(:member) { create(:group_member) } + let(:user) { member.user } + + before do + solo_owned.group_members = [member] + DeleteUserService.new(current_user).execute(user) + end + + it 'does not delete the user' do + expect(User.find(user.id)).to eq user + end + end + + context "deletions with solo owned groups" do + let(:solo_owned) { create(:group) } + let(:member) { create(:group_member) } + let(:user) { member.user } + + before do + solo_owned.group_members = [member] + DeleteUserService.new(current_user).execute(user, delete_solo_owned_groups: true) + end + + it 'deletes solo owned groups' do + expect { Project.find(solo_owned.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + + it 'deletes the user' do + expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/workers/delete_user_worker_spec.rb b/spec/workers/delete_user_worker_spec.rb index 975b0470cb..14c5652128 100644 --- a/spec/workers/delete_user_worker_spec.rb +++ b/spec/workers/delete_user_worker_spec.rb @@ -1,58 +1,20 @@ require 'spec_helper' describe DeleteUserWorker do - describe "Deletes a user and all their personal projects" do - let!(:user) { create(:user) } - let!(:current_user) { create(:user) } - let!(:namespace) { create(:namespace, owner: user) } - let!(:project) { create(:project, namespace: namespace) } + let!(:user) { create(:user) } + let!(:current_user) { create(:user) } - context 'no force flag given' do - before do - DeleteUserWorker.new.perform(current_user.id, user.id) - end + it "calls the DeleteUserWorker with the params it was given" do + expect_any_instance_of(DeleteUserService).to receive(:execute). + with(user, {}) - it 'deletes all personal projects' do - expect { Project.find(project.id) }.to raise_error(ActiveRecord::RecordNotFound) - end + DeleteUserWorker.new.perform(current_user.id, user.id) + end - it 'deletes the user' do - expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) - end - end + it "uses symbolized keys" do + expect_any_instance_of(DeleteUserService).to receive(:execute). + with(user, test: "test") - context "solo owned groups present" do - let(:solo_owned) { create(:group) } - let(:member) { create(:group_member) } - let(:user) { member.user } - - before do - solo_owned.group_members = [member] - DeleteUserWorker.new.perform(current_user.id, user.id) - end - - it 'does not delete the user' do - expect(User.find(user.id)).to eq user - end - end - - context "deletions with force" do - let(:solo_owned) { create(:group) } - let(:member) { create(:group_member) } - let(:user) { member.user } - - before do - solo_owned.group_members = [member] - DeleteUserWorker.new.perform(current_user.id, user.id, "delete_solo_owned_groups" => true) - end - - it 'deletes solo owned groups' do - expect { Project.find(solo_owned.id) }.to raise_error(ActiveRecord::RecordNotFound) - end - - it 'deletes the user' do - expect { User.find(user.id) }.to raise_error(ActiveRecord::RecordNotFound) - end - end + DeleteUserWorker.new.perform(current_user.id, user.id, "test" => "test") end end From 7ae573c75ac51413c04249f77ed8ca4e144b7549 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 15 Mar 2016 23:22:05 +0100 Subject: [PATCH 152/797] Bump Git version requirement to 2.7.3 --- CHANGELOG | 3 +++ README.md | 2 +- doc/install/installation.md | 2 +- lib/tasks/gitlab/check.rake | 2 +- 4 files changed, 6 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index b120810ebd..6854e7cab8 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -32,6 +32,9 @@ v 8.6.0 (unreleased) - Move group activity to separate page - Continue parameters are checked to ensure redirection goes to the same instance +v 8.5.6 + - Bump Git version requirement to 2.7.3 + v 8.5.5 - Ensure removing a project removes associated Todo entries - Prevent a 500 error in Todos when author was removed diff --git a/README.md b/README.md index 3ec1d4a776..208427fcf8 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ GitLab is a Ruby on Rails application that runs on the following software: - Ubuntu/Debian/CentOS/RHEL - Ruby (MRI) 2.1 -- Git 1.7.10+ +- Git 2.7.3+ - Redis 2.8+ - MySQL or PostgreSQL diff --git a/doc/install/installation.md b/doc/install/installation.md index 4f01139726..362b27350c 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -76,7 +76,7 @@ Make sure you have the right version of Git installed # Install Git sudo apt-get install -y git-core - # Make sure Git is version 1.7.10 or higher, for example 1.7.12 or 2.0.0 + # Make sure Git is version 2.7.3 or higher git --version Is the system packaged Git too old? Remove it and compile from source. diff --git a/lib/tasks/gitlab/check.rake b/lib/tasks/gitlab/check.rake index 581ab26db7..27ed57efe5 100644 --- a/lib/tasks/gitlab/check.rake +++ b/lib/tasks/gitlab/check.rake @@ -913,7 +913,7 @@ namespace :gitlab do end def check_git_version - required_version = Gitlab::VersionInfo.new(1, 7, 10) + required_version = Gitlab::VersionInfo.new(2, 7, 3) current_version = Gitlab::VersionInfo.parse(run(%W(#{Gitlab.config.git.bin_path} --version))) puts "Your git bin path is \"#{Gitlab.config.git.bin_path}\"" From cd683714eb6240bb3d42dabb3f6dc470fc930fb4 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 15 Mar 2016 23:24:27 +0100 Subject: [PATCH 153/797] Fix changelog --- CHANGELOG | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index 12f44a4e68..b83940d2f1 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -40,8 +40,10 @@ v 8.6.0 (unreleased) - Move group activity to separate page - Continue parameters are checked to ensure redirection goes to the same instance -v 8.5.6 +v 8.5.7 - Bump Git version requirement to 2.7.3 + +v 8.5.6 - Obtain a lease before querying LDAP v 8.5.5 From 24ea8d9df43e28b57dfa52b8302c754f76a05e0a Mon Sep 17 00:00:00 2001 From: Chris Wilson Date: Tue, 15 Mar 2016 22:43:11 +0000 Subject: [PATCH 154/797] Use relative link for installation.md --- doc/install/requirements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install/requirements.md b/doc/install/requirements.md index a7dfa7ddf5..4c0f039f3e 100644 --- a/doc/install/requirements.md +++ b/doc/install/requirements.md @@ -22,7 +22,7 @@ For the installations options please see [the installation page on the GitLab we - FreeBSD On the above unsupported distributions is still possible to install GitLab yourself. -Please see the [installation from source guide](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/doc/install/installation.md) and the [installation guides](https://about.gitlab.com/installation/) for more information. +Please see the [installation from source guide](installation.md) and the [installation guides](https://about.gitlab.com/installation/) for more information. ### Non-Unix operating systems such as Windows From 7e03b40221388bd0567be6609f2ededa7f934179 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20D=C3=A1vila?= Date: Tue, 15 Mar 2016 19:51:27 -0500 Subject: [PATCH 155/797] Return an empty Array when there aren't lines to parse. --- lib/gitlab/diff/parser.rb | 2 ++ spec/lib/gitlab/diff/parser_spec.rb | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/lib/gitlab/diff/parser.rb b/lib/gitlab/diff/parser.rb index d0f6ba23ab..d0815fc7ee 100644 --- a/lib/gitlab/diff/parser.rb +++ b/lib/gitlab/diff/parser.rb @@ -4,6 +4,8 @@ module Gitlab include Enumerable def parse(lines) + return [] if lines.blank? + @lines = lines line_obj_index = 0 line_old = 1 diff --git a/spec/lib/gitlab/diff/parser_spec.rb b/spec/lib/gitlab/diff/parser_spec.rb index f576c39284..cdff063a9e 100644 --- a/spec/lib/gitlab/diff/parser_spec.rb +++ b/spec/lib/gitlab/diff/parser_spec.rb @@ -90,4 +90,9 @@ eos end end end + + context 'when lines is empty' do + it { expect(parser.parse([])).to eq([]) } + it { expect(parser.parse(nil)).to eq([]) } + end end From 5adaabdd30388993ce547a782146d166d30cfc9b Mon Sep 17 00:00:00 2001 From: Jason Roehm Date: Tue, 15 Mar 2016 21:28:06 -0400 Subject: [PATCH 156/797] make conditional a bit clearer --- lib/ci/gitlab_ci_yaml_processor.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/ci/gitlab_ci_yaml_processor.rb b/lib/ci/gitlab_ci_yaml_processor.rb index abab91ddf5..2228425076 100644 --- a/lib/ci/gitlab_ci_yaml_processor.rb +++ b/lib/ci/gitlab_ci_yaml_processor.rb @@ -289,7 +289,7 @@ module Ci return false if path && path != self.path return true if tag && pattern == 'tags' return true if !tag && pattern == 'branches' - return true if !trigger_request.nil? && pattern == 'triggers' + return true if trigger_request.present? && pattern == 'triggers' if pattern.first == "/" && pattern.last == "/" Regexp.new(pattern[1...-1]) =~ ref From dce43b4bea88c753c6aeb43d2d283bef4d11eff1 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 15 Mar 2016 17:17:05 +0000 Subject: [PATCH 157/797] Pre-selects values on issue and merge request dropdown Closes #14290 --- app/helpers/issuables_helper.rb | 28 +++++++++++++++++++++ app/views/shared/issuable/_filter.html.haml | 8 +++--- 2 files changed, 32 insertions(+), 4 deletions(-) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 2dfeddf736..29b78ac910 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -20,6 +20,34 @@ module IssuablesHelper base_issuable_scope(issuable).where('iid < ?', issuable.iid).first end + def user_dropdown_label(user_id, default_label) + return "Unassigned" if user_id == "0" + + user = @project.team.users.find_by(id: user_id) + + if user + user.name + else + default_label + end + end + + def labels_dropdown_label(label_name) + if !label_name + "Label" + else + label_name + end + end + + def milestone_dropdown_label(milestone_name) + if !milestone_name + "Milestone" + else + milestone_name + end + end + private def sidebar_gutter_collapsed? diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 3eb0db276b..74ccaa53da 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -9,19 +9,19 @@ .filter-item.inline - if params[:author_id] = hidden_field_tag(:author_id, params[:author_id]) - = dropdown_tag("Author", options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author", + = dropdown_tag(user_dropdown_label(params[:author_id], "Author"), options: { toggle_class: "js-user-search js-filter-submit js-author-search", title: "Filter by author", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-author", placeholder: "Search authors", data: { any_user: "Any Author", first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), selected: params[:author_id], field_name: "author_id" } }) .filter-item.inline - if params[:assignee_id] = hidden_field_tag(:assignee_id, params[:assignee_id]) - = dropdown_tag("Assignee", options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee", + = dropdown_tag(user_dropdown_label(params[:assignee_id], "Assignee"), options: { toggle_class: "js-user-search js-filter-submit js-assignee-search", title: "Filter by assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee", placeholder: "Search assignee", data: { any_user: "Any Assignee", first_user: (current_user.username if current_user), null_user: true, current_user: true, project_id: (@project.id if @project), selected: params[:assignee_id], field_name: "assignee_id" } }) .filter-item.inline.milestone-filter - if params[:milestone_title] = hidden_field_tag(:milestone_title, params[:milestone_title]) - = dropdown_tag("Milestone", options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable", + = dropdown_tag(milestone_dropdown_label(params[:milestone_title]), options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search milestones", footer_content: true, data: { show_no: true, show_any: true, field_name: "milestone_title", selected: params[:milestone_title], project_id: (@project.id if @project), milestones: (namespace_project_milestones_path(@project.namespace, @project, :js) if @project) } }) do - if @project %ul.dropdown-footer-list @@ -42,7 +42,7 @@ .dropdown %button.dropdown-menu-toggle.js-label-select.js-filter-submit{type: "button", data: {toggle: "dropdown", field_name: "label_name", show_no: "true", show_any: "true", selected: params[:label_name], project_id: (@project.id if @project), labels: (namespace_project_labels_path(@project.namespace, @project, :js) if @project)}} %span.dropdown-toggle-text - Label + = labels_dropdown_label(params[:label_name]) = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable .dropdown-page-one From 2e07ee7f01e9bd525c98ae3f84025dd8699fdf8a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Tue, 15 Mar 2016 18:28:25 +0000 Subject: [PATCH 158/797] Fixed failing tests --- app/helpers/issuables_helper.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 29b78ac910..b254c62da2 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -23,7 +23,8 @@ module IssuablesHelper def user_dropdown_label(user_id, default_label) return "Unassigned" if user_id == "0" - user = @project.team.users.find_by(id: user_id) + user = @project.team.users.find_by(id: user_id) if @project + user = User.find_by_id(user_id) if !@project if user user.name From 438d614b2780e280256af81f1e3f2f1847b3405e Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 16 Mar 2016 08:48:27 +0000 Subject: [PATCH 159/797] Removed label/milestone helper methods --- app/helpers/issuables_helper.rb | 26 +++++++-------------- app/views/shared/issuable/_filter.html.haml | 4 ++-- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index b254c62da2..23f389b8c5 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -23,8 +23,14 @@ module IssuablesHelper def user_dropdown_label(user_id, default_label) return "Unassigned" if user_id == "0" - user = @project.team.users.find_by(id: user_id) if @project - user = User.find_by_id(user_id) if !@project + if @project + member = @project.team.find_member(user_id) + if member + user = member.user + end + else + user = User.find(user_id) + end if user user.name @@ -33,22 +39,6 @@ module IssuablesHelper end end - def labels_dropdown_label(label_name) - if !label_name - "Label" - else - label_name - end - end - - def milestone_dropdown_label(milestone_name) - if !milestone_name - "Milestone" - else - milestone_name - end - end - private def sidebar_gutter_collapsed? diff --git a/app/views/shared/issuable/_filter.html.haml b/app/views/shared/issuable/_filter.html.haml index 74ccaa53da..dfdc84ba4c 100644 --- a/app/views/shared/issuable/_filter.html.haml +++ b/app/views/shared/issuable/_filter.html.haml @@ -21,7 +21,7 @@ .filter-item.inline.milestone-filter - if params[:milestone_title] = hidden_field_tag(:milestone_title, params[:milestone_title]) - = dropdown_tag(milestone_dropdown_label(params[:milestone_title]), options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable", + = dropdown_tag(h(params[:milestone_name] || "Milestone"), options: { title: "Filter by milestone", toggle_class: 'js-milestone-select js-filter-submit', filter: true, dropdown_class: "dropdown-menu-selectable", placeholder: "Search milestones", footer_content: true, data: { show_no: true, show_any: true, field_name: "milestone_title", selected: params[:milestone_title], project_id: (@project.id if @project), milestones: (namespace_project_milestones_path(@project.namespace, @project, :js) if @project) } }) do - if @project %ul.dropdown-footer-list @@ -42,7 +42,7 @@ .dropdown %button.dropdown-menu-toggle.js-label-select.js-filter-submit{type: "button", data: {toggle: "dropdown", field_name: "label_name", show_no: "true", show_any: "true", selected: params[:label_name], project_id: (@project.id if @project), labels: (namespace_project_labels_path(@project.namespace, @project, :js) if @project)}} %span.dropdown-toggle-text - = labels_dropdown_label(params[:label_name]) + = h(params[:label_name] || "Label") = icon('chevron-down') .dropdown-menu.dropdown-select.dropdown-menu-paging.dropdown-menu-labels.dropdown-menu-selectable .dropdown-page-one From 2941a76fe0fa5305bd6e31e6c47477732e3e7cec Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 16 Mar 2016 08:51:09 +0000 Subject: [PATCH 160/797] Added skip_js comment [ci skip] --- app/helpers/application_helper.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f07c79ec61..ffddd0400f 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -168,6 +168,7 @@ module ApplicationHelper # time - Time object # placement - Tooltip placement String (default: "top") # html_class - Custom class for `time` element (default: "time_ago") + # skip_js - When true, exclude the `script` tag (default: false) # # By default also includes a `script` element with Javascript necessary to # initialize the `timeago` jQuery extension. If this method is called many From d94dc565ba479e969ad548991b411e058330d8f9 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 16 Mar 2016 11:44:53 +0000 Subject: [PATCH 161/797] Fixed alignment of download dropdown With the new dropdown CSS - some dropdowns might look a bit odd --- app/assets/stylesheets/framework/dropdowns.scss | 6 ++++++ app/views/projects/repositories/_download_archive.html.haml | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 309da34da0..d71ca9842a 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -9,6 +9,12 @@ border-left: $caret-width-base solid transparent; } +.btn-group { + .caret { + margin-left: 0; + } +} + .dropdown { position: relative; } diff --git a/app/views/projects/repositories/_download_archive.html.haml b/app/views/projects/repositories/_download_archive.html.haml index b9486a9b49..2465831906 100644 --- a/app/views/projects/repositories/_download_archive.html.haml +++ b/app/views/projects/repositories/_download_archive.html.haml @@ -10,7 +10,7 @@ %span.caret %span.sr-only Select Archive Format - %ul.col-xs-10.dropdown-menu{ role: 'menu' } + %ul.col-xs-10.dropdown-menu.dropdown-menu-align-right{ role: 'menu' } %li = link_to archive_namespace_project_repository_path(@project.namespace, @project, ref: ref, format: 'zip'), rel: 'nofollow' do %i.fa.fa-download From d6caadd1d533eaacde8e7e315546e7c5425d60e6 Mon Sep 17 00:00:00 2001 From: Daniel Carraro Date: Mon, 29 Feb 2016 16:37:27 +1100 Subject: [PATCH 162/797] Added omniauth-auth0 Gem + Applicable Documentation. --- CHANGELOG | 1 + Gemfile | 1 + Gemfile.lock | 3 ++ config/gitlab.yml.example | 6 +++ doc/integration/auth0.md | 89 +++++++++++++++++++++++++++++++++++++ doc/integration/omniauth.md | 1 + 6 files changed, 101 insertions(+) create mode 100644 doc/integration/auth0.md diff --git a/CHANGELOG b/CHANGELOG index fcf659c07f..08018abd20 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -15,6 +15,7 @@ v 8.6.0 (unreleased) - Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set - Memoize @group in Admin::GroupsController (Yatish Mehta) - Indicate how much an MR diverged from the target branch (Pierre de La Morinerie) + - Added omniauth-auth0 Gem (Daniel Carraro) - Strip leading and trailing spaces in URL validator (evuez) - Add "last_sign_in_at" and "confirmed_at" to GET /users/* API endpoints for admins (evuez) - Return empty array instead of 404 when commit has no statuses in commit status API diff --git a/Gemfile b/Gemfile index a849d7493a..a3fb6779e9 100644 --- a/Gemfile +++ b/Gemfile @@ -22,6 +22,7 @@ gem 'devise', '~> 3.5.4' gem 'devise-async', '~> 0.9.0' gem 'doorkeeper', '~> 2.2.0' gem 'omniauth', '~> 1.3.1' +gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-azure-oauth2', '~> 0.0.6' gem 'omniauth-bitbucket', '~> 0.0.2' gem 'omniauth-cas3', '~> 1.1.2' diff --git a/Gemfile.lock b/Gemfile.lock index 669bfcf4d6..7b0dd83da5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -498,6 +498,8 @@ GEM omniauth (1.3.1) hashie (>= 1.2, < 4) rack (>= 1.0, < 3) + omniauth-auth0 (1.4.1) + omniauth-oauth2 (~> 1.1) omniauth-azure-oauth2 (0.0.6) jwt (~> 1.0) omniauth (~> 1.0) @@ -973,6 +975,7 @@ DEPENDENCIES oauth2 (~> 1.0.0) octokit (~> 3.8.0) omniauth (~> 1.3.1) + omniauth-auth0 (~> 1.4.1) omniauth-azure-oauth2 (~> 0.0.6) omniauth-bitbucket (~> 0.0.2) omniauth-cas3 (~> 1.1.2) diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 05f127d622..500b745f55 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -357,6 +357,12 @@ production: &base # crowd_server_url: 'CROWD SERVER URL', # application_name: 'YOUR_APP_NAME', # application_password: 'YOUR_APP_PASSWORD' } } + # + # - { name: 'auth0', + # args: { + # client_id: 'YOUR_AUTH0_CLIENT_ID', + # client_secret: 'YOUR_AUTH0_CLIENT_SECRET', + # namespace: 'YOUR_AUTH0_DOMAIN' } } # SSO maximum session duration in seconds. Defaults to CAS default of 8 hours. # cas3: diff --git a/doc/integration/auth0.md b/doc/integration/auth0.md new file mode 100644 index 0000000000..e5247082a8 --- /dev/null +++ b/doc/integration/auth0.md @@ -0,0 +1,89 @@ +# Auth0 OmniAuth Provider + +To enable the Auth0 OmniAuth provider, you must create an Auth0 account, and an +application. + +1. Sign in to the [Auth0 Console](https://manage.auth0.com). If you need to +create an account, you can do so at the same link. + +1. Select "New App/API". + +1. Provide the Application Name ('GitLab' works fine). + +1. Once created, you should see the Quick Start options. Disregard them and +select 'Settings' above the Quick Start options. + +1. At the top of the Settings screen, you should see your Domain, Client ID and +Client Secret. Take note of these as you'll need to put them in the +configuration file. For example: + - Domain: `test1234.auth0.com` + - Client ID: `t6X8L2465bNePWLOvt9yi41i` + - Client Secret: `KbveM3nqfjwCbrhaUy_gDu2dss8TIlHIdzlyf33pB7dEK5u_NyQdp65O_o02hXs2` + +1. Fill in the Allowed Callback URLs: + - http://`YOUR_GITLAB_URL`/users/auth/auth0/callback (or) + - https://`YOUR_GITLAB_URL`/users/auth/auth0/callback + +1. Fill in the Allowed Origins (CORS): + - http://`YOUR_GITLAB_URL` (or) + - https://`YOUR_GITLAB_URL` + +1. On your GitLab server, open the configuration file. + + For omnibus package: + + ```sh + sudo editor /etc/gitlab/gitlab.rb + ``` + + For installations from source: + + ```sh + cd /home/git/gitlab + sudo -u git -H editor config/gitlab.yml + ``` + +1. See [Initial OmniAuth Configuration](omniauth.md#initial-omniauth-configuration) +for initial settings. + +1. Add the provider configuration: + + For omnibus package: + + ```ruby + gitlab_rails['omniauth_providers'] = [ + { + "name" => "auth0", + "args" => { client_id: 'YOUR_AUTH0_CLIENT_ID'', + client_secret: 'YOUR_AUTH0_CLIENT_SECRET', + namespace: 'YOUR_AUTH0_DOMAIN' + } + } + ] + ``` + + For installations from source: + + ```yaml + - { name: 'auth0', + args: { + client_id: 'YOUR_AUTH0_CLIENT_ID', + client_secret: 'YOUR_AUTH0_CLIENT_SECRET', + namespace: 'YOUR_AUTH0_DOMAIN' + } + } + ``` + +1. Change `YOUR_AUTH0_CLIENT_ID` to the client ID from the Auth0 Console page +from step 5. + +1. Change `YOUR_AUTH0_CLIENT_SECRET` to the client secret from the Auth0 Console +page from step 5. + +1. Save the file and [reconfigure GitLab](../administration/restart_gitlab.md) +for the changes to take effect. + +On the sign in page there should now be an Auth0 icon below the regular sign in +form. Click the icon to begin the authentication process. Auth0 will ask the +user to sign in and authorize the GitLab application. If everything goes well +the user will be returned to GitLab and will be signed in. diff --git a/doc/integration/omniauth.md b/doc/integration/omniauth.md index ba47cb1626..25f3598830 100644 --- a/doc/integration/omniauth.md +++ b/doc/integration/omniauth.md @@ -28,6 +28,7 @@ contains some settings that are common for all providers. - [SAML](saml.md) - [Crowd](crowd.md) - [Azure](azure.md) +- [Auth0](auth0.md) ## Initial OmniAuth Configuration From af4d5458a9f2f45f781f5baab05eaa9a8113328d Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Wed, 16 Mar 2016 13:38:18 +0100 Subject: [PATCH 163/797] Added an index on ci_runners.token On PostgreSQL the index is created concurrently. Fixes gitlab-org/gitlab-ce#14325 --- db/migrate/20160316123110_ci_runners_token_index.rb | 13 +++++++++++++ db/schema.rb | 9 +++++---- 2 files changed, 18 insertions(+), 4 deletions(-) create mode 100644 db/migrate/20160316123110_ci_runners_token_index.rb diff --git a/db/migrate/20160316123110_ci_runners_token_index.rb b/db/migrate/20160316123110_ci_runners_token_index.rb new file mode 100644 index 0000000000..67bf5b4f97 --- /dev/null +++ b/db/migrate/20160316123110_ci_runners_token_index.rb @@ -0,0 +1,13 @@ +class CiRunnersTokenIndex < ActiveRecord::Migration + disable_ddl_transaction! + + def change + args = [:ci_runners, :token] + + if Gitlab::Database.postgresql? + args << { algorithm: :concurrently } + end + + add_index(*args) + end +end diff --git a/db/schema.rb b/db/schema.rb index 5027d2ba32..2c27b22886 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: 20160314143402) do +ActiveRecord::Schema.define(version: 20160316123110) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -260,6 +260,7 @@ ActiveRecord::Schema.define(version: 20160314143402) do end add_index "ci_runners", ["description"], name: "index_ci_runners_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} + add_index "ci_runners", ["token"], name: "index_ci_runners_on_token", using: :btree add_index "ci_runners", ["token"], name: "index_ci_runners_on_token_trigram", using: :gin, opclasses: {"token"=>"gin_trgm_ops"} create_table "ci_services", force: :cascade do |t| @@ -775,9 +776,9 @@ ActiveRecord::Schema.define(version: 20160314143402) do 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.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "active", null: false t.text "properties" t.boolean "template", default: false t.boolean "push_events", default: true From 25600f530f9eac4a7c5ab03dd6b0a51e84922eb7 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Wed, 16 Mar 2016 13:40:27 +0100 Subject: [PATCH 164/797] Update Git source installation steps to 2.7.3 --- doc/install/installation.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/install/installation.md b/doc/install/installation.md index 2dd5e495ed..1894075b72 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -89,8 +89,9 @@ Is the system packaged Git too old? Remove it and compile from source. # Download and compile from source cd /tmp - curl -L --progress https://www.kernel.org/pub/software/scm/git/git-2.4.3.tar.gz | tar xz - cd git-2.4.3/ + curl -O --progress https://www.kernel.org/pub/software/scm/git/git-2.7.3.tar.gz + echo '30d067499b61caddedaf1a407b4947244f14d10842d100f7c7c6ea1c288280cd git-2.7.3.tar.gz' | shasum -a256 -c - && tar -xzf git-2.7.3.tar.gz + cd git-2.7.3/ ./configure make prefix=/usr/local all From 96ac3021276babe14d7ec362fdd71f8d69238ffa Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Wed, 16 Mar 2016 13:41:07 +0100 Subject: [PATCH 165/797] Fix golang shasum invocation --- doc/install/installation.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/install/installation.md b/doc/install/installation.md index 1894075b72..aa989417c4 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -144,7 +144,7 @@ use 64-bit Linux. You can find downloads for other platforms at the [Go download page](https://golang.org/dl). curl -O --progress https://storage.googleapis.com/golang/go1.5.3.linux-amd64.tar.gz - echo '43afe0c5017e502630b1aea4d44b8a7f059bf60d7f29dfd58db454d4e4e0ae53 go1.5.3.linux-amd64.tar.gz' | shasum -c - && \ + echo '43afe0c5017e502630b1aea4d44b8a7f059bf60d7f29dfd58db454d4e4e0ae53 go1.5.3.linux-amd64.tar.gz' | shasum -a256 -c - && \ sudo tar -C /usr/local -xzf go1.5.3.linux-amd64.tar.gz sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/ rm go1.5.3.linux-amd64.tar.gz From 7cbf8fd0d089264f863dc01217514d7e2c40e056 Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Wed, 9 Mar 2016 07:51:52 +0100 Subject: [PATCH 166/797] Add tests for MergeRequest#source_sha --- spec/models/merge_request_spec.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 8bf68013fd..aceb2406eb 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -86,6 +86,24 @@ describe MergeRequest, models: true do end end + describe '#source_sha' do + let(:last_branch_commit) { subject.source_project.repository.commit(subject.source_branch) } + + context 'with diffs' do + subject { create(:merge_request, :with_diffs) } + it 'returns the sha of the source branch last commit' do + expect(subject.source_sha).to eq(last_branch_commit.sha) + end + end + + context 'when the merge request is being created' do + subject { build(:merge_request, source_branch: nil, compare_commits: []) } + it 'returns nil' do + expect(subject.source_sha).to be_nil + end + end + end + describe '#to_reference' do it 'returns a String reference to the object' do expect(subject.to_reference).to eq "!#{subject.iid}" From 18295585d9d2a7177f52fc451db7b0d542cc49b5 Mon Sep 17 00:00:00 2001 From: Pierre de La Morinerie Date: Wed, 9 Mar 2016 08:19:54 +0100 Subject: [PATCH 167/797] Fix MergeRequest#source_sha when there is no diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `MergeRequest#source_sha` is expected to return the sha of the source branch last commit. But when a open Merge Request has no diff (e.g. all commits have already been merged to the target branch), `merge_request.source_sha` incorrectly returns `nil`. This was un-noticed before – but now that !2217 has been merged, it makes `Gitlab::Git::Commit.between` raise an "Unexpected nil argument" exception. This fixes the crash, by making sure that `source_sha` returns a correct result even when there is no diff available. --- CHANGELOG | 1 + app/models/merge_request.rb | 6 +++++- spec/factories/merge_requests.rb | 5 +++++ spec/models/merge_request_spec.rb | 7 +++++++ 4 files changed, 18 insertions(+), 1 deletion(-) diff --git a/CHANGELOG b/CHANGELOG index fcf659c07f..ccc356a961 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -33,6 +33,7 @@ v 8.6.0 (unreleased) - Fix bug where Bitbucket `closed` issues were imported as `opened` (Iuri de Silvio) - Don't show Issues/MRs from archived projects in Groups view - Fix wrong "iid of max iid" in Issuable sidebar for some merged MRs + - Fix empty source_sha on Merge Request when there is no diff (Pierre de La Morinerie) - Increase the notes polling timeout over time (Roberto Dip) - Add shortcut to toggle markdown preview (Florent Baldino) - Show labels in dashboard and group milestone views diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 188325045e..30a7bd47be 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -520,7 +520,11 @@ class MergeRequest < ActiveRecord::Base end def source_sha - last_commit.try(:sha) + last_commit.try(:sha) || source_tip.try(:sha) + end + + def source_tip + source_branch && source_project.repository.commit(source_branch) end def fetch_ref diff --git a/spec/factories/merge_requests.rb b/spec/factories/merge_requests.rb index a9df5fa1d3..e281e2f227 100644 --- a/spec/factories/merge_requests.rb +++ b/spec/factories/merge_requests.rb @@ -51,6 +51,11 @@ FactoryGirl.define do trait :with_diffs do end + trait :without_diffs do + source_branch "improve/awesome" + target_branch "master" + end + trait :conflict do source_branch "feature_conflict" target_branch "feature" diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index aceb2406eb..619c01b552 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -96,6 +96,13 @@ describe MergeRequest, models: true do end end + context 'without diffs' do + subject { create(:merge_request, :without_diffs) } + it 'returns the sha of the source branch last commit' do + expect(subject.source_sha).to eq(last_branch_commit.sha) + end + end + context 'when the merge request is being created' do subject { build(:merge_request, source_branch: nil, compare_commits: []) } it 'returns nil' do From 315d87595404a8bde78566ec51e23b167f041048 Mon Sep 17 00:00:00 2001 From: Mark Pundsack Date: Wed, 16 Mar 2016 18:04:42 +0000 Subject: [PATCH 168/797] Fix typo in README.md. --- doc/ci/deployment/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/ci/deployment/README.md b/doc/ci/deployment/README.md index ffd841ca9e..7d91ce6710 100644 --- a/doc/ci/deployment/README.md +++ b/doc/ci/deployment/README.md @@ -89,7 +89,7 @@ We also use two secure variables: In GitLab CI 7.12 a new feature was introduced: Secure Variables. Secure Variables can added by going to `Project > Variables > Add Variable`. **This feature requires `gitlab-runner` with version equal or greater than 0.4.0.** -The variables that are defined in the project settings are send along with the build script to the runner. +The variables that are defined in the project settings are sent along with the build script to the runner. The secure variables are stored out of the repository. Never store secrets in your projects' .gitlab-ci.yml. It is also important that secret's value is hidden in the build log. From 6f9bda6b2d08e264414a24468ae9870e2ecb6572 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 16 Mar 2016 19:29:53 +0000 Subject: [PATCH 169/797] Fixes issue with dropdown caret being incorrect color --- app/assets/stylesheets/framework/buttons.scss | 1 - app/assets/stylesheets/framework/dropdowns.scss | 2 +- app/assets/stylesheets/framework/variables.scss | 1 - 3 files changed, 1 insertion(+), 3 deletions(-) diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 76611efb78..10fe42239b 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -139,7 +139,6 @@ .caret { margin-left: 5px; - color: $gray-darkest; } } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 968f9cb76c..3197ea8446 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -4,7 +4,7 @@ height: 0; margin-left: 2px; vertical-align: middle; - border-top: $caret-width-base dashed $dropdown-caret-color; + border-top: $caret-width-base dashed; border-right: $caret-width-base solid transparent; border-left: $caret-width-base solid transparent; } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 3447c558e8..5e3546bc6f 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -143,7 +143,6 @@ $dropdown-border-color: rgba(#000, .1); $dropdown-shadow-color: rgba(#000, .1); $dropdown-divider-color: rgba(#000, .1); $dropdown-header-color: #959494; -$dropdown-caret-color: #54565b; $dropdown-title-btn-color: #bfbfbf; $dropdown-input-color: #c7c7c7; $dropdown-input-focus-border: rgb(58, 171, 240); From 65565bb304238a2e7d260faccd1a033d6da343e9 Mon Sep 17 00:00:00 2001 From: Arinde Eniola Date: Wed, 16 Mar 2016 21:38:38 +0100 Subject: [PATCH 170/797] fix horizontal overflow that occurs in the code tag --- app/assets/stylesheets/pages/notes.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 1f631ab03e..f7eeadcb5b 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -108,6 +108,12 @@ ul.notes { word-wrap: break-word; @include md-typography; + pre { + code { + white-space: pre-wrap; + } + } + // Reset ul style types since we're nested inside a ul already & > ul { list-style-type: disc; From 49f2a4e3a6ebe2deb02abe48145e5ff33ccb7971 Mon Sep 17 00:00:00 2001 From: Arinde Eniola Date: Wed, 16 Mar 2016 23:30:01 +0100 Subject: [PATCH 171/797] fix bug causing dropdown to get cut off in project page --- app/assets/stylesheets/pages/projects.scss | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index a29b735eb0..aca4d8d1b7 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -33,6 +33,13 @@ .project-settings-dropdown { margin-left: 10px; display: inline-block; + + .dropdown-menu { + left: auto; + width: auto; + right: 0px; + max-width: 240px; + } } } From ec20fdf366843e60ed30abb5322c3c1b8f471b4a Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Wed, 16 Mar 2016 19:44:33 -0300 Subject: [PATCH 172/797] Code improvements and add Create group service --- app/assets/stylesheets/framework/common.scss | 50 ++++++++----------- app/assets/stylesheets/framework/mobile.scss | 2 +- app/assets/stylesheets/pages/projects.scss | 22 -------- app/controllers/groups_controller.rb | 4 +- app/controllers/users_controller.rb | 9 +--- app/finders/joined_groups_finder.rb | 2 +- app/models/ability.rb | 4 +- app/services/groups/base_service.rb | 4 ++ app/services/groups/create_service.rb | 17 +++++++ app/services/groups/update_service.rb | 13 ++--- app/services/projects/create_service.rb | 6 ++- app/views/groups/show.html.haml | 4 +- app/views/projects/_home_panel.html.haml | 2 +- ...roup_visibility_to_application_settings.rb | 20 +++++++- db/schema.rb | 2 +- .../security/group/internal_access_spec.rb | 2 - spec/services/groups/create_service_spec.rb | 22 ++++++++ 17 files changed, 99 insertions(+), 86 deletions(-) create mode 100644 app/services/groups/create_service.rb create mode 100644 spec/services/groups/create_service_spec.rb diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 18044b25b8..0931090b84 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -383,35 +383,25 @@ table { margin-right: -$gl-padding; border-top: 1px solid $border-color; } -.message { - border: 1px solid #ccc; - padding: 10px; - color: #333; -} -.message { - border: 1px solid #ccc; - padding: 10px; - color: #333; -} -.group-projects-show-title{ - h1 { - color: #313236; - margin: 0; - margin-bottom: 6px; - font-size: 23px; - font-weight: normal; - } - - .visibility-icon { - display: inline-block; - margin-left: 5px; - font-size: 18px; - color: $gray; - } - - p { - padding: 0 $gl-padding; - color: #5c5d5e; - } +.cover-title{ + h1 { + color: #313236; + margin: 0; + margin-bottom: 6px; + font-size: 23px; + font-weight: normal; } + + .visibility-icon { + display: inline-block; + margin-left: 5px; + font-size: 18px; + color: $gray; + } + + p { + padding: 0 $gl-padding; + color: #5c5d5e; + } +} diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index 3bfac2ad9b..d088228fe4 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -48,7 +48,7 @@ display: block; } - .project-home-desc { + #project-home-desc { font-size: 21px; } diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index b1b76edfb3..1a7b0c1e27 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -61,28 +61,6 @@ } } - .project-home-desc { - h1 { - color: #313236; - margin: 0; - margin-bottom: 6px; - font-size: 23px; - font-weight: normal; - } - - .visibility-icon { - display: inline-block; - margin-left: 5px; - font-size: 18px; - color: $gray; - } - - p { - padding: 0 $gl-padding; - color: #5c5d5e; - } - } - .project-repo-buttons { margin-top: 20px; margin-bottom: 0px; diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 54f14e62ea..5baeb3def0 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -29,10 +29,8 @@ class GroupsController < Groups::ApplicationController def create @group = Group.new(group_params) - @group.name = @group.path.dup unless @group.name - if @group.save - @group.add_owner(current_user) + if Groups::CreateService.new(@group, current_user, group_params).execute redirect_to @group, notice: "Group '#{@group.name}' was successfully created." else render action: "new" diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index 7b32572f82..481d00d6aa 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -3,13 +3,6 @@ class UsersController < ApplicationController before_action :set_user def show - @contributed_projects = contributed_projects.joined(@user).reject(&:forked?) - - @projects = PersonalProjectsFinder.new(@user).execute(current_user) - @projects = @projects.page(params[:page]).per(PER_PAGE) - - @groups = JoinedGroupsFinder.new(@user).execute(current_user) - respond_to do |format| format.html @@ -115,7 +108,7 @@ class UsersController < ApplicationController end def load_groups - @groups = @user.groups.order_id_desc + @groups = JoinedGroupsFinder.new(@user).execute(current_user) end def projects_for_current_user diff --git a/app/finders/joined_groups_finder.rb b/app/finders/joined_groups_finder.rb index 131b518563..fbdf492c96 100644 --- a/app/finders/joined_groups_finder.rb +++ b/app/finders/joined_groups_finder.rb @@ -1,6 +1,6 @@ #Shows only authorized groups of a user class JoinedGroupsFinder - def initialize(user = nil) + def initialize(user) @user = user end diff --git a/app/models/ability.rb b/app/models/ability.rb index fe460ccdac..bd001ef154 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -295,8 +295,8 @@ class Ability end def can_read_group?(user, group) - is_project_member = ProjectsFinder.new.execute(user, group: group).any? - user.admin? || group.public? || group.internal? || is_project_member || group.users.include?(user) + user.admin? || group.public? || group.internal? || group.users.include?(user) || + ProjectsFinder.new.execute(user, group: group).any? end def namespace_abilities(user, namespace) diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb index 5becd475d3..644ec7c013 100644 --- a/app/services/groups/base_service.rb +++ b/app/services/groups/base_service.rb @@ -5,5 +5,9 @@ module Groups def initialize(group, user, params = {}) @group, @current_user, @params = group, user, params.dup end + + def add_error_message(message) + group.errors.add(:visibility_level, message) + end end end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb new file mode 100644 index 0000000000..e2875aafb9 --- /dev/null +++ b/app/services/groups/create_service.rb @@ -0,0 +1,17 @@ +module Groups + class CreateService < Groups::BaseService + def execute + return false unless visibility_level_allowed?(params[:visibility_level]) + @group.name = @group.path.dup unless @group.name + @group.save(params) && @group.add_owner(current_user) + end + + private + + def visibility_level_allowed?(level) + allowed = Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level]) + add_error_message("Visibility level restricted by admin.") unless allowed + allowed + end + end +end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index acb6c529c1..a7382c1e07 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -5,7 +5,8 @@ module Groups class UpdateService < Groups::BaseService def execute - visibility_level_allowed?(params[:visibility_level]) ? group.update_attributes(params) : false + return false unless visibility_level_allowed?(params[:visibility_level]) + group.update_attributes(params) end private @@ -22,7 +23,7 @@ module Groups def visibility_by_project(level) projects_visibility = group.projects.pluck(:visibility_level) - allowed_by_projects = !projects_visibility.any?{|project_visibility| level.to_i < project_visibility } + allowed_by_projects = !projects_visibility.any?{ |project_visibility| level.to_i < project_visibility } add_error_message("Cannot be changed. There are projects with higher visibility permissions.") unless allowed_by_projects allowed_by_projects end @@ -32,13 +33,5 @@ module Groups add_error_message("You are not authorized to set this permission level.") unless allowed_by_user allowed_by_user end - - def add_error_message(message) - level_name = Gitlab::VisibilityLevel.level_name(params[:visibility_level]) - group.errors.add(:visibility_level, message) - end end end - - - diff --git a/app/services/projects/create_service.rb b/app/services/projects/create_service.rb index 522fae7950..4c121106bd 100644 --- a/app/services/projects/create_service.rb +++ b/app/services/projects/create_service.rb @@ -12,7 +12,7 @@ module Projects # Make sure that the user is allowed to use the specified visibility # level - unless Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level]) && @project.visibility_level_allowed?(@project.visibility_level) + unless visibility_level_allowed? deny_visibility_level(@project) return @project end @@ -100,5 +100,9 @@ module Projects @project.import_start if @project.import? end + + def visibility_level_allowed? + Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level]) && @project.visibility_level_allowed?(@project.visibility_level) + end end end diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 0a8c2da720..641191edf0 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -14,11 +14,11 @@ .avatar-holder = link_to group_icon(@group), target: '_blank' do = image_tag group_icon(@group), class: "avatar group-avatar s90" - .group-projects-show-title + .cover-title %h1 = @group.name - %span.visibility-icon.has_tooltip{data: { container: 'body', placement: 'left' }, title: "#{visibility_level_label(@group.visibility_level)} - #{project_visibility_level_description(@group.visibility_level)}"} + %span.visibility-icon.has_tooltip{data: { container: 'body', placement: 'left' }, title: "#{visibility_level_label(@group.visibility_level)} - #{group_visibility_description(@group)}"} = visibility_level_icon(@group.visibility_level, fw: false) .cover-desc.username diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index dd16f504c9..e8434b5292 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -2,7 +2,7 @@ .project-home-panel.cover-block.clearfix{:class => ("empty-project" if empty_repo)} .project-identicon-holder = project_icon(@project, alt: '', class: 'project-avatar avatar s90') - .group-projects-show-title + .cover-title#project-home-desc %h1 = @project.name %span.visibility-icon.has_tooltip{data: { container: 'body' }, diff --git a/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb b/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb index c2bdd7b31f..b71322376f 100644 --- a/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb +++ b/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb @@ -1,11 +1,27 @@ +#Create visibility level field on DB +#Sets default_visibility_level to value on settings if not restricted +#If value is restricted takes higher visibility level allowed + class AddDefaultGroupVisibilityToApplicationSettings < ActiveRecord::Migration def up add_column :application_settings, :default_group_visibility, :integer - visibility = Settings.gitlab.default_groups_features['visibility_level'] - execute("update application_settings set default_group_visibility = #{visibility}") + execute("update application_settings set default_group_visibility = #{allowed_visibility_level}") end def down remove_column :application_settings, :default_group_visibility end + + private + def allowed_visibility_level + default_visibility = Settings.gitlab.default_groups_features['visibility_level'] + restricted_levels = current_application_settings.restricted_visibility_levels + return default_visibility unless restricted_levels.present? + + if restricted_levels.include?(default_visibility) + Gitlab::VisibilityLevel.values.select{ |vis_level| vis_level unless restricted_levels.include?(vis_level) }.last + else + default_visibility + end + end end diff --git a/db/schema.rb b/db/schema.rb index 082d681a17..292a9100d9 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: 20160308212903) do +ActiveRecord::Schema.define(version: 20160309140734) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" diff --git a/spec/features/security/group/internal_access_spec.rb b/spec/features/security/group/internal_access_spec.rb index 69a0fbb446..4e781c23ee 100644 --- a/spec/features/security/group/internal_access_spec.rb +++ b/spec/features/security/group/internal_access_spec.rb @@ -4,8 +4,6 @@ describe 'Internal group access', feature: true do include AccessMatchers include GroupAccessHelper - - describe 'GET /groups/:path' do subject { group_path(group(Gitlab::VisibilityLevel::INTERNAL)) } diff --git a/spec/services/groups/create_service_spec.rb b/spec/services/groups/create_service_spec.rb new file mode 100644 index 0000000000..7dbc529797 --- /dev/null +++ b/spec/services/groups/create_service_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe Groups::CreateService, services: true do + let!(:user) { create(:user) } + let!(:private_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE) } + let!(:internal_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } + let!(:public_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + + describe "execute" do + let!(:service) { described_class.new(public_group, user, visibility_level: Gitlab::VisibilityLevel::PUBLIC ) } + subject { service.execute } + + context "create groups without restricted visibility level" do + it { is_expected.to be_truthy } + end + + context "cannot create group with restricted visibility level" do + before { allow(current_application_settings).to receive(:restricted_visibility_levels).and_return([Gitlab::VisibilityLevel::PUBLIC]) } + it { is_expected.to be_falsy } + end + end +end From 6c06b65d1e905775c2cd31e64c4bf2aa3f14ef5b Mon Sep 17 00:00:00 2001 From: Anatoly Borodin Date: Thu, 17 Mar 2016 00:35:53 +0100 Subject: [PATCH 173/797] Use relative links in `doc`, fix broken generated HTML links Links like (doc/web_hooks/web_hooks.md) work in the GitLab source code web interface, but the HTML generator produces broken links in the `doc` subdirectories: http://doc.gitlab.com/ce/hooks/doc/web_hooks/web_hooks.html instead of the right one http://doc.gitlab.com/ce/web_hooks/web_hooks.html in http://doc.gitlab.com/ce/hooks/custom_hooks.html. Fixes #14338 [ci skip] Signed-off-by: Anatoly Borodin --- doc/hooks/custom_hooks.md | 2 +- doc/release/security.md | 2 +- doc/security/two_factor_authentication.md | 2 +- doc/workflow/importing/import_projects_from_bitbucket.md | 2 +- doc/workflow/protected_branches.md | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/doc/hooks/custom_hooks.md b/doc/hooks/custom_hooks.md index 15051dd76f..dcdf49d337 100644 --- a/doc/hooks/custom_hooks.md +++ b/doc/hooks/custom_hooks.md @@ -2,7 +2,7 @@ **Note: Custom git hooks must be configured on the filesystem of the GitLab server. Only GitLab server administrators will be able to complete these tasks. -Please explore [webhooks](doc/web_hooks/web_hooks.md) as an option if you do not have filesystem access. For a user configurable Git Hooks interface, please see [GitLab Enterprise Edition Git Hooks](http://doc.gitlab.com/ee/git_hooks/git_hooks.html).** +Please explore [webhooks](../web_hooks/web_hooks.md) as an option if you do not have filesystem access. For a user configurable Git Hooks interface, please see [GitLab Enterprise Edition Git Hooks](http://doc.gitlab.com/ee/git_hooks/git_hooks.html).** Git natively supports hooks that are executed on different actions. Examples of server-side git hooks include pre-receive, post-receive, and update. diff --git a/doc/release/security.md b/doc/release/security.md index b1a62b333e..118c016ba4 100644 --- a/doc/release/security.md +++ b/doc/release/security.md @@ -15,7 +15,7 @@ Please report suspected security vulnerabilities in private to Date: Wed, 9 Mar 2016 13:50:34 +0900 Subject: [PATCH 174/797] Fix wiki search results point to raw source --- CHANGELOG | 1 + app/models/repository.rb | 4 ++++ app/views/search/results/_wiki_blob.html.haml | 4 ++-- features/steps/search.rb | 2 +- spec/models/repository_spec.rb | 18 +++++++++++++++++- 5 files changed, 25 insertions(+), 4 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index fcf659c07f..8a2359e396 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -11,6 +11,7 @@ v 8.6.0 (unreleased) GitLab will ask the user to create a new one upon first visit. - Fix issue when pushing to projects ending in .wiki - Add support for wiki with UTF-8 page names (Hiroyuki Sato) + - Fix wiki search results point to raw source (Hiroyuki Sato) - Don't load all of GitLab in mail_room - Update `omniauth-saml` to 1.5.0 to allow for custom response attributes to be set - Memoize @group in Admin::GroupsController (Yatish Mehta) diff --git a/app/models/repository.rb b/app/models/repository.rb index 6441cd87e8..e555e97689 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -758,12 +758,15 @@ class Repository def parse_search_result(result) ref = nil filename = nil + basename = nil startline = 0 result.each_line.each_with_index do |line, index| if line =~ /^.*:.*:\d+:/ ref, filename, startline = line.split(':') startline = startline.to_i - index + extname = File.extname(filename) + basename = filename.sub(/#{extname}$/, '') break end end @@ -776,6 +779,7 @@ class Repository OpenStruct.new( filename: filename, + basename: basename, ref: ref, startline: startline, data: data diff --git a/app/views/search/results/_wiki_blob.html.haml b/app/views/search/results/_wiki_blob.html.haml index f5859481d4..235106c4f7 100644 --- a/app/views/search/results/_wiki_blob.html.haml +++ b/app/views/search/results/_wiki_blob.html.haml @@ -2,9 +2,9 @@ .blob-result .file-holder .file-title - = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_blob.filename) do + = link_to namespace_project_wiki_path(@project.namespace, @project, wiki_blob.basename) do %i.fa.fa-file %strong - = wiki_blob.filename + = wiki_blob.basename .file-content.code.term = render 'shared/file_highlight', blob: wiki_blob, first_line_number: wiki_blob.startline diff --git a/features/steps/search.rb b/features/steps/search.rb index 48ea3fa387..0ad837ebe1 100644 --- a/features/steps/search.rb +++ b/features/steps/search.rb @@ -100,7 +100,7 @@ class Spinach::Features::Search < Spinach::FeatureSteps step 'I should see "test_wiki" link in the search results' do page.within('.results') do - find(:css, '.search-results').should have_link 'test_wiki.md' + expect(find(:css, '.search-results')).to have_link 'test_wiki' end end diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 34866be339..fc2ab2d993 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -101,13 +101,29 @@ describe Repository, models: true do end describe 'parsing result' do - subject { repository.parse_search_result(results.first) } + subject { repository.parse_search_result(search_result) } + let(:search_result) { results.first } it { is_expected.to be_an OpenStruct } it { expect(subject.filename).to eq('CHANGELOG') } + it { expect(subject.basename).to eq('CHANGELOG') } it { expect(subject.ref).to eq('master') } it { expect(subject.startline).to eq(186) } it { expect(subject.data.lines[2]).to eq(" - Feature: Replace teams with group membership\n") } + + context "when filename has extension" do + let(:search_result) { "master:CONTRIBUTE.md:5:- [Contribute to GitLab](#contribute-to-gitlab)\n" } + + it { expect(subject.filename).to eq('CONTRIBUTE.md') } + it { expect(subject.basename).to eq('CONTRIBUTE') } + end + + context "when file under directory" do + let(:search_result) { "master:a/b/c.md:5:a b c\n" } + + it { expect(subject.filename).to eq('a/b/c.md') } + it { expect(subject.basename).to eq('a/b/c') } + end end end From a18ac62756573a2da2c42ca50b6f30033be6fa63 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Wed, 16 Mar 2016 21:23:59 -0300 Subject: [PATCH 175/797] Block internal groups/projects visibility to external users --- app/finders/contributed_projects_finder.rb | 2 +- app/finders/joined_groups_finder.rb | 2 +- app/finders/personal_projects_finder.rb | 2 +- app/models/ability.rb | 7 +++++-- db/schema.rb | 6 +++--- 5 files changed, 11 insertions(+), 8 deletions(-) diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb index 0209649b01..4f7fe1c748 100644 --- a/app/finders/contributed_projects_finder.rb +++ b/app/finders/contributed_projects_finder.rb @@ -11,7 +11,7 @@ class ContributedProjectsFinder # # Returns an ActiveRecord::Relation. def execute(current_user = nil) - if current_user + if current_user && !current_user.external? relation = projects_visible_to_user(current_user) else relation = public_projects diff --git a/app/finders/joined_groups_finder.rb b/app/finders/joined_groups_finder.rb index fbdf492c96..ff744689e3 100644 --- a/app/finders/joined_groups_finder.rb +++ b/app/finders/joined_groups_finder.rb @@ -12,7 +12,7 @@ class JoinedGroupsFinder # # Returns an ActiveRecord::Relation. def execute(current_user = nil) - if current_user + if current_user && !current_user.external? relation = groups_visible_to_user(current_user) else relation = public_groups diff --git a/app/finders/personal_projects_finder.rb b/app/finders/personal_projects_finder.rb index a61ffa2299..0e2d915da5 100644 --- a/app/finders/personal_projects_finder.rb +++ b/app/finders/personal_projects_finder.rb @@ -11,7 +11,7 @@ class PersonalProjectsFinder # # Returns an ActiveRecord::Relation. def execute(current_user = nil) - if current_user + if current_user && !current_user.external? relation = projects_visible_to_user(current_user) else relation = public_projects diff --git a/app/models/ability.rb b/app/models/ability.rb index 455ea7bcc6..134ae440c9 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -295,8 +295,11 @@ class Ability end def can_read_group?(user, group) - user.admin? || group.public? || group.internal? || group.users.include?(user) || - ProjectsFinder.new.execute(user, group: group).any? + if user.external? + group.public? || ProjectsFinder.new.execute(user, group: group).any? + else + user.admin? || group.public? || group.internal? || group.users.include?(user) || ProjectsFinder.new.execute(user, group: group).any? + end end def namespace_abilities(user, namespace) diff --git a/db/schema.rb b/db/schema.rb index f5e3e5bc86..f1bccd6274 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -777,9 +777,9 @@ ActiveRecord::Schema.define(version: 20160314143402) do 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.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.boolean "active", null: false t.text "properties" t.boolean "template", default: false t.boolean "push_events", default: true From e106597e31490a0dcfa9ff0fe5f88b13c19fd766 Mon Sep 17 00:00:00 2001 From: connorshea Date: Wed, 16 Mar 2016 17:29:47 -0600 Subject: [PATCH 176/797] Follow the CSS Style Guide rules for unitless zero values. Updated Emoji Rake task to conform to style guide. Discussed in #14299. [ci skip] --- app/assets/stylesheets/framework/blocks.scss | 2 +- app/assets/stylesheets/framework/buttons.scss | 4 +- app/assets/stylesheets/framework/files.scss | 2 +- app/assets/stylesheets/framework/header.scss | 2 +- .../stylesheets/framework/highlight.scss | 4 +- app/assets/stylesheets/framework/mixins.scss | 6 +- app/assets/stylesheets/framework/mobile.scss | 2 +- app/assets/stylesheets/framework/nav.scss | 4 +- app/assets/stylesheets/framework/selects.scss | 2 +- app/assets/stylesheets/framework/sidebar.scss | 10 +- .../stylesheets/framework/tw_bootstrap.scss | 2 +- .../stylesheets/framework/typography.scss | 4 +- app/assets/stylesheets/framework/zen.scss | 2 +- app/assets/stylesheets/pages/commit.scss | 2 +- app/assets/stylesheets/pages/commits.scss | 2 +- app/assets/stylesheets/pages/diff.scss | 36 ++-- app/assets/stylesheets/pages/emojis.scss | 164 +++++++++--------- app/assets/stylesheets/pages/events.scss | 2 +- app/assets/stylesheets/pages/import.scss | 2 +- app/assets/stylesheets/pages/login.scss | 6 +- app/assets/stylesheets/pages/notes.scss | 4 +- app/assets/stylesheets/pages/profile.scss | 2 +- app/assets/stylesheets/pages/projects.scss | 6 +- app/assets/stylesheets/pages/sherlock.scss | 4 +- app/assets/stylesheets/pages/todos.scss | 2 +- lib/tasks/gemojione.rake | 1 + 26 files changed, 140 insertions(+), 139 deletions(-) diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index d20b77ffae..90c3ce0e84 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -23,7 +23,7 @@ margin-bottom: -$gl-padding; background-color: $background-color; padding: $gl-padding; - margin-bottom: 0px; + margin-bottom: 0; border-top: 1px solid $border-color; border-bottom: 1px solid $border-color; color: $gl-gray; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 10fe42239b..fa115a4bf5 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -127,7 +127,7 @@ margin-right: 7px; float: left; &:last-child { - margin-right: 0px; + margin-right: 0; } &.btn-xs { margin-right: 3px; @@ -169,7 +169,7 @@ margin-right: 7px; float: left; &:last-child { - margin-right: 0px; + margin-right: 0; } } } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 116cc0df81..646e261083 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -30,7 +30,7 @@ right: 15px; .btn { - padding: 0px 10px; + padding: 0 10px; font-size: 13px; line-height: 28px; } diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 5f70cd399f..71a7ecab8e 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -162,7 +162,7 @@ header { font-size: 18px; .navbar-nav { - margin: 0px; + margin: 0; float: none !important; .visible-xs, .visable-sm { diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index 12e2f00fe8..7cf4d4fba4 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -1,8 +1,8 @@ .file-content.code { border: none; box-shadow: none; - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; table-layout: fixed; pre { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index 4b59c758f1..377bfa174b 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -67,8 +67,8 @@ * Base mixin for lists in GitLab */ @mixin basic-list { - margin: 5px 0px; - padding: 0px; + margin: 5px 0; + padding: 0; list-style: none; > li { @@ -77,7 +77,7 @@ padding: 10px 0; border-bottom: 1px solid #eee; display: block; - margin: 0px; + margin: 0; &:last-child { border-bottom: none; diff --git a/app/assets/stylesheets/framework/mobile.scss b/app/assets/stylesheets/framework/mobile.scss index ec10a70cf0..5ea4f9a49d 100644 --- a/app/assets/stylesheets/framework/mobile.scss +++ b/app/assets/stylesheets/framework/mobile.scss @@ -128,7 +128,7 @@ .show-aside { display: none; position: fixed; - right: 0px; + right: 0; top: 30%; padding: 5px 15px; background: #eee; diff --git a/app/assets/stylesheets/framework/nav.scss b/app/assets/stylesheets/framework/nav.scss index b53872bf67..5f4ce87b08 100644 --- a/app/assets/stylesheets/framework/nav.scss +++ b/app/assets/stylesheets/framework/nav.scss @@ -59,7 +59,7 @@ .nav-links { display: inline-block; width: 50%; - margin-bottom: 0px; + margin-bottom: 0; border-bottom: none; /* Small devices (phones, tablets, 768px and lower) */ @@ -74,7 +74,7 @@ float: right; text-align: right; padding: 11px 0; - margin-bottom: 0px; + margin-bottom: 0; > .dropdown { margin-right: $gl-padding-top; diff --git a/app/assets/stylesheets/framework/selects.scss b/app/assets/stylesheets/framework/selects.scss index cbcfef8a69..b3371229d5 100644 --- a/app/assets/stylesheets/framework/selects.scss +++ b/app/assets/stylesheets/framework/selects.scss @@ -151,7 +151,7 @@ padding: 2px 25px 2px 5px; background: #fff image-url('select2.png'); background-repeat: no-repeat; - background-position: right 0px bottom 6px; + background-position: right 0 bottom 6px; border: 1px solid $input-border; @include border-radius($border-radius-default); @include transition(border-color ease-in-out .15s, box-shadow ease-in-out .15s); diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 29570f2ce3..be05db58c4 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -16,7 +16,7 @@ .gitlab-text-container-link { z-index: 1; position: absolute; - left: 0px; + left: 0; } #logo { @@ -143,7 +143,7 @@ overflow: hidden; &.navbar-collapse { - padding: 0px !important; + padding: 0 !important; } li { @@ -182,7 +182,7 @@ .count { float: right; background: #eee; - padding: 0px 8px; + padding: 0 8px; @include border-radius(6px); } @@ -194,8 +194,8 @@ } .sidebar-subnav { - margin-left: 0px; - padding-left: 0px; + margin-left: 0; + padding-left: 0; li { list-style: none; diff --git a/app/assets/stylesheets/framework/tw_bootstrap.scss b/app/assets/stylesheets/framework/tw_bootstrap.scss index 9946dd3e9b..dd42db1840 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap.scss @@ -138,7 +138,7 @@ } .btn-clipboard { - min-width: 0px; + min-width: 0; } } diff --git a/app/assets/stylesheets/framework/typography.scss b/app/assets/stylesheets/framework/typography.scss index 6ff8852c2f..949295a1d0 100644 --- a/app/assets/stylesheets/framework/typography.scss +++ b/app/assets/stylesheets/framework/typography.scss @@ -33,7 +33,7 @@ border-color: #ccc #ccc #bbb; border-image: none; border-radius: 3px; - box-shadow: 0px -1px 0px #bbb inset; + box-shadow: 0 -1px 0 #bbb inset; } h1 { @@ -187,7 +187,7 @@ body { } .page-title-empty { - margin-top: 0px; + margin-top: 0; line-height: 1.3; font-size: 1.25em; font-weight: 600; diff --git a/app/assets/stylesheets/framework/zen.scss b/app/assets/stylesheets/framework/zen.scss index c3f27333fa..02e24ec7c4 100644 --- a/app/assets/stylesheets/framework/zen.scss +++ b/app/assets/stylesheets/framework/zen.scss @@ -2,7 +2,7 @@ a.js-zen-enter { color: $gl-gray; position: absolute; - top: 0px; + top: 0; right: 4px; line-height: 56px; } diff --git a/app/assets/stylesheets/pages/commit.scss b/app/assets/stylesheets/pages/commit.scss index c0cc30d33a..971656feb4 100644 --- a/app/assets/stylesheets/pages/commit.scss +++ b/app/assets/stylesheets/pages/commit.scss @@ -55,7 +55,7 @@ padding: 10px 0; li { - padding: 3px 0px; + padding: 3px 0; line-height: 20px; } } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 2062cd2ebc..d57be1b2da 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -152,7 +152,7 @@ li.commit { .count { padding-top: 6px; - padding-bottom: 0px; + padding-bottom: 0; font-size: 12px; color: #333; display: block; diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index 4739aa0502..db06b8288c 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -57,8 +57,8 @@ font-family: $monospace_font; border: none; border-collapse: separate; - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; .line_holder td { line-height: $code_line_height; font-size: $code_font_size; @@ -76,10 +76,10 @@ } .old_line, .new_line { - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; border: none; - padding: 0px 5px; + padding: 0 5px; border-right: 1px solid; text-align: right; min-width: 35px; @@ -97,8 +97,8 @@ } .line_content { display: block; - margin: 0px; - padding: 0px 0.5em; + margin: 0; + padding: 0 0.5em; border: none; &.parallel { display: table-cell; @@ -183,7 +183,7 @@ height: 14px; width: 15px; position: absolute; - top: 0px; + top: 0; background: image-url('swipemode_sprites.gif') 0 3px no-repeat; } .bottom-handle { @@ -191,7 +191,7 @@ height: 14px; width: 15px; position: absolute; - bottom: 0px; + bottom: 0; background: image-url('swipemode_sprites.gif') 0 -11px no-repeat; } } @@ -206,8 +206,8 @@ .frame.added, .frame.deleted { position: absolute; display: block; - top: 0px; - left: 0px; + top: 0; + left: 0; } .controls { display: block; @@ -215,7 +215,7 @@ width: 300px; z-index: 100; position: absolute; - bottom: 0px; + bottom: 0; left: 50%; margin-left: -150px; @@ -231,11 +231,11 @@ .dragger { display: block; position: absolute; - left: 0px; - top: 0px; + left: 0; + top: 0; height: 14px; width: 14px; - background: image-url('onion_skin_sprites.gif') 0px -34px repeat-x; + background: image-url('onion_skin_sprites.gif') 0 -34px repeat-x; cursor: pointer; } @@ -243,17 +243,17 @@ display: block; position: absolute; top: 2px; - right: 0px; + right: 0; height: 10px; width: 10px; - background: image-url('onion_skin_sprites.gif') -2px 0px no-repeat; + background: image-url('onion_skin_sprites.gif') -2px 0 no-repeat; } .opaque { display: block; position: absolute; top: 2px; - left: 0px; + left: 0; height: 10px; width: 10px; background: image-url('onion_skin_sprites.gif') -2px -10px no-repeat; diff --git a/app/assets/stylesheets/pages/emojis.scss b/app/assets/stylesheets/pages/emojis.scss index 6c721b514f..b731abc745 100644 --- a/app/assets/stylesheets/pages/emojis.scss +++ b/app/assets/stylesheets/pages/emojis.scss @@ -1,60 +1,60 @@ -.emoji-0023-20E3 { background-position: 0px 0px; } -.emoji-002A-20E3 { background-position: -20px 0px; } -.emoji-0030-20E3 { background-position: 0px -20px; } +.emoji-0023-20E3 { background-position: 0 0; } +.emoji-002A-20E3 { background-position: -20px 0; } +.emoji-0030-20E3 { background-position: 0 -20px; } .emoji-0031-20E3 { background-position: -20px -20px; } -.emoji-0032-20E3 { background-position: -40px 0px; } +.emoji-0032-20E3 { background-position: -40px 0; } .emoji-0033-20E3 { background-position: -40px -20px; } -.emoji-0034-20E3 { background-position: 0px -40px; } +.emoji-0034-20E3 { background-position: 0 -40px; } .emoji-0035-20E3 { background-position: -20px -40px; } .emoji-0036-20E3 { background-position: -40px -40px; } -.emoji-0037-20E3 { background-position: -60px 0px; } +.emoji-0037-20E3 { background-position: -60px 0; } .emoji-0038-20E3 { background-position: -60px -20px; } .emoji-0039-20E3 { background-position: -60px -40px; } -.emoji-00A9 { background-position: 0px -60px; } +.emoji-00A9 { background-position: 0 -60px; } .emoji-00AE { background-position: -20px -60px; } .emoji-1F004 { background-position: -40px -60px; } .emoji-1F0CF { background-position: -60px -60px; } -.emoji-1F170 { background-position: -80px 0px; } +.emoji-1F170 { background-position: -80px 0; } .emoji-1F171 { background-position: -80px -20px; } .emoji-1F17E { background-position: -80px -40px; } .emoji-1F17F { background-position: -80px -60px; } -.emoji-1F18E { background-position: 0px -80px; } +.emoji-1F18E { background-position: 0 -80px; } .emoji-1F191 { background-position: -20px -80px; } .emoji-1F192 { background-position: -40px -80px; } .emoji-1F193 { background-position: -60px -80px; } .emoji-1F194 { background-position: -80px -80px; } -.emoji-1F195 { background-position: -100px 0px; } +.emoji-1F195 { background-position: -100px 0; } .emoji-1F196 { background-position: -100px -20px; } .emoji-1F197 { background-position: -100px -40px; } .emoji-1F198 { background-position: -100px -60px; } .emoji-1F199 { background-position: -100px -80px; } -.emoji-1F19A { background-position: 0px -100px; } +.emoji-1F19A { background-position: 0 -100px; } .emoji-1F1E6-1F1E8 { background-position: -20px -100px; } .emoji-1F1E6-1F1E9 { background-position: -40px -100px; } .emoji-1F1E6-1F1EA { background-position: -60px -100px; } .emoji-1F1E6-1F1EB { background-position: -80px -100px; } .emoji-1F1E6-1F1EC { background-position: -100px -100px; } -.emoji-1F1E6-1F1EE { background-position: -120px 0px; } +.emoji-1F1E6-1F1EE { background-position: -120px 0; } .emoji-1F1E6-1F1F1 { background-position: -120px -20px; } .emoji-1F1E6-1F1F2 { background-position: -120px -40px; } .emoji-1F1E6-1F1F4 { background-position: -120px -60px; } .emoji-1F1E6-1F1F6 { background-position: -120px -80px; } .emoji-1F1E6-1F1F7 { background-position: -120px -100px; } -.emoji-1F1E6-1F1F8 { background-position: 0px -120px; } +.emoji-1F1E6-1F1F8 { background-position: 0 -120px; } .emoji-1F1E6-1F1F9 { background-position: -20px -120px; } .emoji-1F1E6-1F1FA { background-position: -40px -120px; } .emoji-1F1E6-1F1FC { background-position: -60px -120px; } .emoji-1F1E6-1F1FD { background-position: -80px -120px; } .emoji-1F1E6-1F1FF { background-position: -100px -120px; } .emoji-1F1E7-1F1E6 { background-position: -120px -120px; } -.emoji-1F1E7-1F1E7 { background-position: -140px 0px; } +.emoji-1F1E7-1F1E7 { background-position: -140px 0; } .emoji-1F1E7-1F1E9 { background-position: -140px -20px; } .emoji-1F1E7-1F1EA { background-position: -140px -40px; } .emoji-1F1E7-1F1EB { background-position: -140px -60px; } .emoji-1F1E7-1F1EC { background-position: -140px -80px; } .emoji-1F1E7-1F1ED { background-position: -140px -100px; } .emoji-1F1E7-1F1EE { background-position: -140px -120px; } -.emoji-1F1E7-1F1EF { background-position: 0px -140px; } +.emoji-1F1E7-1F1EF { background-position: 0 -140px; } .emoji-1F1E7-1F1F1 { background-position: -20px -140px; } .emoji-1F1E7-1F1F2 { background-position: -40px -140px; } .emoji-1F1E7-1F1F3 { background-position: -60px -140px; } @@ -62,7 +62,7 @@ .emoji-1F1E7-1F1F6 { background-position: -100px -140px; } .emoji-1F1E7-1F1F7 { background-position: -120px -140px; } .emoji-1F1E7-1F1F8 { background-position: -140px -140px; } -.emoji-1F1E7-1F1F9 { background-position: -160px 0px; } +.emoji-1F1E7-1F1F9 { background-position: -160px 0; } .emoji-1F1E7-1F1FB { background-position: -160px -20px; } .emoji-1F1E7-1F1FC { background-position: -160px -40px; } .emoji-1F1E7-1F1FE { background-position: -160px -60px; } @@ -70,7 +70,7 @@ .emoji-1F1E8-1F1E6 { background-position: -160px -100px; } .emoji-1F1E8-1F1E8 { background-position: -160px -120px; } .emoji-1F1E8-1F1E9 { background-position: -160px -140px; } -.emoji-1F1E8-1F1EB { background-position: 0px -160px; } +.emoji-1F1E8-1F1EB { background-position: 0 -160px; } .emoji-1F1E8-1F1EC { background-position: -20px -160px; } .emoji-1F1E8-1F1ED { background-position: -40px -160px; } .emoji-1F1E8-1F1EE { background-position: -60px -160px; } @@ -79,7 +79,7 @@ .emoji-1F1E8-1F1F2 { background-position: -120px -160px; } .emoji-1F1E8-1F1F3 { background-position: -140px -160px; } .emoji-1F1E8-1F1F4 { background-position: -160px -160px; } -.emoji-1F1E8-1F1F5 { background-position: -180px 0px; } +.emoji-1F1E8-1F1F5 { background-position: -180px 0; } .emoji-1F1E8-1F1F7 { background-position: -180px -20px; } .emoji-1F1E8-1F1FA { background-position: -180px -40px; } .emoji-1F1E8-1F1FB { background-position: -180px -60px; } @@ -88,7 +88,7 @@ .emoji-1F1E8-1F1FE { background-position: -180px -120px; } .emoji-1F1E8-1F1FF { background-position: -180px -140px; } .emoji-1F1E9-1F1EA { background-position: -180px -160px; } -.emoji-1F1E9-1F1EC { background-position: 0px -180px; } +.emoji-1F1E9-1F1EC { background-position: 0 -180px; } .emoji-1F1E9-1F1EF { background-position: -20px -180px; } .emoji-1F1E9-1F1F0 { background-position: -40px -180px; } .emoji-1F1E9-1F1F2 { background-position: -60px -180px; } @@ -98,7 +98,7 @@ .emoji-1F1EA-1F1E8 { background-position: -140px -180px; } .emoji-1F1EA-1F1EA { background-position: -160px -180px; } .emoji-1F1EA-1F1EC { background-position: -180px -180px; } -.emoji-1F1EA-1F1ED { background-position: -200px 0px; } +.emoji-1F1EA-1F1ED { background-position: -200px 0; } .emoji-1F1EA-1F1F7 { background-position: -200px -20px; } .emoji-1F1EA-1F1F8 { background-position: -200px -40px; } .emoji-1F1EA-1F1F9 { background-position: -200px -60px; } @@ -108,7 +108,7 @@ .emoji-1F1EB-1F1F0 { background-position: -200px -140px; } .emoji-1F1EB-1F1F2 { background-position: -200px -160px; } .emoji-1F1EB-1F1F4 { background-position: -200px -180px; } -.emoji-1F1EB-1F1F7 { background-position: 0px -200px; } +.emoji-1F1EB-1F1F7 { background-position: 0 -200px; } .emoji-1F1EC-1F1E6 { background-position: -20px -200px; } .emoji-1F1EC-1F1E7 { background-position: -40px -200px; } .emoji-1F1EC-1F1E9 { background-position: -60px -200px; } @@ -119,7 +119,7 @@ .emoji-1F1EC-1F1EE { background-position: -160px -200px; } .emoji-1F1EC-1F1F1 { background-position: -180px -200px; } .emoji-1F1EC-1F1F2 { background-position: -200px -200px; } -.emoji-1F1EC-1F1F3 { background-position: -220px 0px; } +.emoji-1F1EC-1F1F3 { background-position: -220px 0; } .emoji-1F1EC-1F1F5 { background-position: -220px -20px; } .emoji-1F1EC-1F1F6 { background-position: -220px -40px; } .emoji-1F1EC-1F1F7 { background-position: -220px -60px; } @@ -130,7 +130,7 @@ .emoji-1F1EC-1F1FE { background-position: -220px -160px; } .emoji-1F1ED-1F1F0 { background-position: -220px -180px; } .emoji-1F1ED-1F1F2 { background-position: -220px -200px; } -.emoji-1F1ED-1F1F3 { background-position: 0px -220px; } +.emoji-1F1ED-1F1F3 { background-position: 0 -220px; } .emoji-1F1ED-1F1F7 { background-position: -20px -220px; } .emoji-1F1ED-1F1F9 { background-position: -40px -220px; } .emoji-1F1ED-1F1FA { background-position: -60px -220px; } @@ -142,7 +142,7 @@ .emoji-1F1EE-1F1F3 { background-position: -180px -220px; } .emoji-1F1EE-1F1F4 { background-position: -200px -220px; } .emoji-1F1EE-1F1F6 { background-position: -220px -220px; } -.emoji-1F1EE-1F1F7 { background-position: -240px 0px; } +.emoji-1F1EE-1F1F7 { background-position: -240px 0; } .emoji-1F1EE-1F1F8 { background-position: -240px -20px; } .emoji-1F1EE-1F1F9 { background-position: -240px -40px; } .emoji-1F1EF-1F1EA { background-position: -240px -60px; } @@ -154,7 +154,7 @@ .emoji-1F1F0-1F1ED { background-position: -240px -180px; } .emoji-1F1F0-1F1EE { background-position: -240px -200px; } .emoji-1F1F0-1F1F2 { background-position: -240px -220px; } -.emoji-1F1F0-1F1F3 { background-position: 0px -240px; } +.emoji-1F1F0-1F1F3 { background-position: 0 -240px; } .emoji-1F1F0-1F1F5 { background-position: -20px -240px; } .emoji-1F1F0-1F1F7 { background-position: -40px -240px; } .emoji-1F1F0-1F1FC { background-position: -60px -240px; } @@ -167,7 +167,7 @@ .emoji-1F1F1-1F1F0 { background-position: -200px -240px; } .emoji-1F1F1-1F1F7 { background-position: -220px -240px; } .emoji-1F1F1-1F1F8 { background-position: -240px -240px; } -.emoji-1F1F1-1F1F9 { background-position: -260px 0px; } +.emoji-1F1F1-1F1F9 { background-position: -260px 0; } .emoji-1F1F1-1F1FA { background-position: -260px -20px; } .emoji-1F1F1-1F1FB { background-position: -260px -40px; } .emoji-1F1F1-1F1FE { background-position: -260px -60px; } @@ -180,7 +180,7 @@ .emoji-1F1F2-1F1ED { background-position: -260px -200px; } .emoji-1F1F2-1F1F0 { background-position: -260px -220px; } .emoji-1F1F2-1F1F1 { background-position: -260px -240px; } -.emoji-1F1F2-1F1F2 { background-position: 0px -260px; } +.emoji-1F1F2-1F1F2 { background-position: 0 -260px; } .emoji-1F1F2-1F1F3 { background-position: -20px -260px; } .emoji-1F1F2-1F1F4 { background-position: -40px -260px; } .emoji-1F1F2-1F1F5 { background-position: -60px -260px; } @@ -194,7 +194,7 @@ .emoji-1F1F2-1F1FD { background-position: -220px -260px; } .emoji-1F1F2-1F1FE { background-position: -240px -260px; } .emoji-1F1F2-1F1FF { background-position: -260px -260px; } -.emoji-1F1F3-1F1E6 { background-position: -280px 0px; } +.emoji-1F1F3-1F1E6 { background-position: -280px 0; } .emoji-1F1F3-1F1E8 { background-position: -280px -20px; } .emoji-1F1F3-1F1EA { background-position: -280px -40px; } .emoji-1F1F3-1F1EB { background-position: -280px -60px; } @@ -208,7 +208,7 @@ .emoji-1F1F3-1F1FF { background-position: -280px -220px; } .emoji-1F1F4-1F1F2 { background-position: -280px -240px; } .emoji-1F1F5-1F1E6 { background-position: -280px -260px; } -.emoji-1F1F5-1F1EA { background-position: 0px -280px; } +.emoji-1F1F5-1F1EA { background-position: 0 -280px; } .emoji-1F1F5-1F1EB { background-position: -20px -280px; } .emoji-1F1F5-1F1EC { background-position: -40px -280px; } .emoji-1F1F5-1F1ED { background-position: -60px -280px; } @@ -223,7 +223,7 @@ .emoji-1F1F5-1F1FE { background-position: -240px -280px; } .emoji-1F1F6-1F1E6 { background-position: -260px -280px; } .emoji-1F1F7-1F1EA { background-position: -280px -280px; } -.emoji-1F1F7-1F1F4 { background-position: -300px 0px; } +.emoji-1F1F7-1F1F4 { background-position: -300px 0; } .emoji-1F1F7-1F1F8 { background-position: -300px -20px; } .emoji-1F1F7-1F1FA { background-position: -300px -40px; } .emoji-1F1F7-1F1FC { background-position: -300px -60px; } @@ -238,7 +238,7 @@ .emoji-1F1F8-1F1EF { background-position: -300px -240px; } .emoji-1F1F8-1F1F0 { background-position: -300px -260px; } .emoji-1F1F8-1F1F1 { background-position: -300px -280px; } -.emoji-1F1F8-1F1F2 { background-position: 0px -300px; } +.emoji-1F1F8-1F1F2 { background-position: 0 -300px; } .emoji-1F1F8-1F1F3 { background-position: -20px -300px; } .emoji-1F1F8-1F1F4 { background-position: -40px -300px; } .emoji-1F1F8-1F1F7 { background-position: -60px -300px; } @@ -254,7 +254,7 @@ .emoji-1F1F9-1F1EB { background-position: -260px -300px; } .emoji-1F1F9-1F1EC { background-position: -280px -300px; } .emoji-1F1F9-1F1ED { background-position: -300px -300px; } -.emoji-1F1F9-1F1EF { background-position: -320px 0px; } +.emoji-1F1F9-1F1EF { background-position: -320px 0; } .emoji-1F1F9-1F1F0 { background-position: -320px -20px; } .emoji-1F1F9-1F1F1 { background-position: -320px -40px; } .emoji-1F1F9-1F1F2 { background-position: -320px -60px; } @@ -270,7 +270,7 @@ .emoji-1F1FA-1F1F2 { background-position: -320px -260px; } .emoji-1F1FA-1F1F8 { background-position: -320px -280px; } .emoji-1F1FA-1F1FE { background-position: -320px -300px; } -.emoji-1F1FA-1F1FF { background-position: 0px -320px; } +.emoji-1F1FA-1F1FF { background-position: 0 -320px; } .emoji-1F1FB-1F1E6 { background-position: -20px -320px; } .emoji-1F1FB-1F1E8 { background-position: -40px -320px; } .emoji-1F1FB-1F1EA { background-position: -60px -320px; } @@ -287,7 +287,7 @@ .emoji-1F1FF-1F1F2 { background-position: -280px -320px; } .emoji-1F1FF-1F1FC { background-position: -300px -320px; } .emoji-1F201 { background-position: -320px -320px; } -.emoji-1F202 { background-position: -340px 0px; } +.emoji-1F202 { background-position: -340px 0; } .emoji-1F21A { background-position: -340px -20px; } .emoji-1F22F { background-position: -340px -40px; } .emoji-1F232 { background-position: -340px -60px; } @@ -304,7 +304,7 @@ .emoji-1F300 { background-position: -340px -280px; } .emoji-1F301 { background-position: -340px -300px; } .emoji-1F302 { background-position: -340px -320px; } -.emoji-1F303 { background-position: 0px -340px; } +.emoji-1F303 { background-position: 0 -340px; } .emoji-1F304 { background-position: -20px -340px; } .emoji-1F305 { background-position: -40px -340px; } .emoji-1F306 { background-position: -60px -340px; } @@ -322,7 +322,7 @@ .emoji-1F312 { background-position: -300px -340px; } .emoji-1F313 { background-position: -320px -340px; } .emoji-1F314 { background-position: -340px -340px; } -.emoji-1F315 { background-position: -360px 0px; } +.emoji-1F315 { background-position: -360px 0; } .emoji-1F316 { background-position: -360px -20px; } .emoji-1F317 { background-position: -360px -40px; } .emoji-1F318 { background-position: -360px -60px; } @@ -340,7 +340,7 @@ .emoji-1F326 { background-position: -360px -300px; } .emoji-1F327 { background-position: -360px -320px; } .emoji-1F328 { background-position: -360px -340px; } -.emoji-1F329 { background-position: 0px -360px; } +.emoji-1F329 { background-position: 0 -360px; } .emoji-1F32A { background-position: -20px -360px; } .emoji-1F32B { background-position: -40px -360px; } .emoji-1F32C { background-position: -60px -360px; } @@ -359,7 +359,7 @@ .emoji-1F339 { background-position: -320px -360px; } .emoji-1F33A { background-position: -340px -360px; } .emoji-1F33B { background-position: -360px -360px; } -.emoji-1F33C { background-position: -380px 0px; } +.emoji-1F33C { background-position: -380px 0; } .emoji-1F33D { background-position: -380px -20px; } .emoji-1F33E { background-position: -380px -40px; } .emoji-1F33F { background-position: -380px -60px; } @@ -378,7 +378,7 @@ .emoji-1F34C { background-position: -380px -320px; } .emoji-1F34D { background-position: -380px -340px; } .emoji-1F34E { background-position: -380px -360px; } -.emoji-1F34F { background-position: 0px -380px; } +.emoji-1F34F { background-position: 0 -380px; } .emoji-1F350 { background-position: -20px -380px; } .emoji-1F351 { background-position: -40px -380px; } .emoji-1F352 { background-position: -60px -380px; } @@ -398,7 +398,7 @@ .emoji-1F360 { background-position: -340px -380px; } .emoji-1F361 { background-position: -360px -380px; } .emoji-1F362 { background-position: -380px -380px; } -.emoji-1F363 { background-position: -400px 0px; } +.emoji-1F363 { background-position: -400px 0; } .emoji-1F364 { background-position: -400px -20px; } .emoji-1F365 { background-position: -400px -40px; } .emoji-1F366 { background-position: -400px -60px; } @@ -418,7 +418,7 @@ .emoji-1F374 { background-position: -400px -340px; } .emoji-1F375 { background-position: -400px -360px; } .emoji-1F376 { background-position: -400px -380px; } -.emoji-1F377 { background-position: 0px -400px; } +.emoji-1F377 { background-position: 0 -400px; } .emoji-1F378 { background-position: -20px -400px; } .emoji-1F379 { background-position: -40px -400px; } .emoji-1F37A { background-position: -60px -400px; } @@ -439,7 +439,7 @@ .emoji-1F385-1F3FE { background-position: -360px -400px; } .emoji-1F385-1F3FF { background-position: -380px -400px; } .emoji-1F386 { background-position: -400px -400px; } -.emoji-1F387 { background-position: -420px 0px; } +.emoji-1F387 { background-position: -420px 0; } .emoji-1F388 { background-position: -420px -20px; } .emoji-1F389 { background-position: -420px -40px; } .emoji-1F38A { background-position: -420px -60px; } @@ -460,7 +460,7 @@ .emoji-1F399 { background-position: -420px -360px; } .emoji-1F39A { background-position: -420px -380px; } .emoji-1F39B { background-position: -420px -400px; } -.emoji-1F39C { background-position: 0px -420px; } +.emoji-1F39C { background-position: 0 -420px; } .emoji-1F39D { background-position: -20px -420px; } .emoji-1F39E { background-position: -40px -420px; } .emoji-1F39F { background-position: -60px -420px; } @@ -482,7 +482,7 @@ .emoji-1F3AF { background-position: -380px -420px; } .emoji-1F3B0 { background-position: -400px -420px; } .emoji-1F3B1 { background-position: -420px -420px; } -.emoji-1F3B2 { background-position: -440px 0px; } +.emoji-1F3B2 { background-position: -440px 0; } .emoji-1F3B3 { background-position: -440px -20px; } .emoji-1F3B4 { background-position: -440px -40px; } .emoji-1F3B5 { background-position: -440px -60px; } @@ -504,7 +504,7 @@ .emoji-1F3C3-1F3FC { background-position: -440px -380px; } .emoji-1F3C3-1F3FD { background-position: -440px -400px; } .emoji-1F3C3-1F3FE { background-position: -440px -420px; } -.emoji-1F3C3-1F3FF { background-position: 0px -440px; } +.emoji-1F3C3-1F3FF { background-position: 0 -440px; } .emoji-1F3C4 { background-position: -20px -440px; } .emoji-1F3C4-1F3FB { background-position: -40px -440px; } .emoji-1F3C4-1F3FC { background-position: -60px -440px; } @@ -527,7 +527,7 @@ .emoji-1F3CA-1F3FD { background-position: -400px -440px; } .emoji-1F3CA-1F3FE { background-position: -420px -440px; } .emoji-1F3CA-1F3FF { background-position: -440px -440px; } -.emoji-1F3CB { background-position: -460px 0px; } +.emoji-1F3CB { background-position: -460px 0; } .emoji-1F3CB-1F3FB { background-position: -460px -20px; } .emoji-1F3CB-1F3FC { background-position: -460px -40px; } .emoji-1F3CB-1F3FD { background-position: -460px -60px; } @@ -550,7 +550,7 @@ .emoji-1F3DA { background-position: -460px -400px; } .emoji-1F3DB { background-position: -460px -420px; } .emoji-1F3DC { background-position: -460px -440px; } -.emoji-1F3DD { background-position: 0px -460px; } +.emoji-1F3DD { background-position: 0 -460px; } .emoji-1F3DE { background-position: -20px -460px; } .emoji-1F3DF { background-position: -40px -460px; } .emoji-1F3E0 { background-position: -60px -460px; } @@ -574,7 +574,7 @@ .emoji-1F3F2 { background-position: -420px -460px; } .emoji-1F3F3 { background-position: -440px -460px; } .emoji-1F3F4 { background-position: -460px -460px; } -.emoji-1F3F5 { background-position: -480px 0px; } +.emoji-1F3F5 { background-position: -480px 0; } .emoji-1F3F6 { background-position: -480px -20px; } .emoji-1F3F7 { background-position: -480px -40px; } .emoji-1F3F8 { background-position: -480px -60px; } @@ -598,7 +598,7 @@ .emoji-1F40A { background-position: -480px -420px; } .emoji-1F40B { background-position: -480px -440px; } .emoji-1F40C { background-position: -480px -460px; } -.emoji-1F40D { background-position: 0px -480px; } +.emoji-1F40D { background-position: 0 -480px; } .emoji-1F40E { background-position: -20px -480px; } .emoji-1F40F { background-position: -40px -480px; } .emoji-1F410 { background-position: -60px -480px; } @@ -623,7 +623,7 @@ .emoji-1F423 { background-position: -440px -480px; } .emoji-1F424 { background-position: -460px -480px; } .emoji-1F425 { background-position: -480px -480px; } -.emoji-1F426 { background-position: -500px 0px; } +.emoji-1F426 { background-position: -500px 0; } .emoji-1F427 { background-position: -500px -20px; } .emoji-1F428 { background-position: -500px -40px; } .emoji-1F429 { background-position: -500px -60px; } @@ -648,7 +648,7 @@ .emoji-1F43C { background-position: -500px -440px; } .emoji-1F43D { background-position: -500px -460px; } .emoji-1F43E { background-position: -500px -480px; } -.emoji-1F43F { background-position: 0px -500px; } +.emoji-1F43F { background-position: 0 -500px; } .emoji-1F440 { background-position: -20px -500px; } .emoji-1F441 { background-position: -40px -500px; } .emoji-1F441-1F5E8 { background-position: -60px -500px; } @@ -674,7 +674,7 @@ .emoji-1F446-1F3FF { background-position: -460px -500px; } .emoji-1F447 { background-position: -480px -500px; } .emoji-1F447-1F3FB { background-position: -500px -500px; } -.emoji-1F447-1F3FC { background-position: -520px 0px; } +.emoji-1F447-1F3FC { background-position: -520px 0; } .emoji-1F447-1F3FD { background-position: -520px -20px; } .emoji-1F447-1F3FE { background-position: -520px -40px; } .emoji-1F447-1F3FF { background-position: -520px -60px; } @@ -700,7 +700,7 @@ .emoji-1F44B-1F3FB { background-position: -520px -460px; } .emoji-1F44B-1F3FC { background-position: -520px -480px; } .emoji-1F44B-1F3FD { background-position: -520px -500px; } -.emoji-1F44B-1F3FE { background-position: 0px -520px; } +.emoji-1F44B-1F3FE { background-position: 0 -520px; } .emoji-1F44B-1F3FF { background-position: -20px -520px; } .emoji-1F44C { background-position: -40px -520px; } .emoji-1F44C-1F3FB { background-position: -60px -520px; } @@ -727,7 +727,7 @@ .emoji-1F44F-1F3FE { background-position: -480px -520px; } .emoji-1F44F-1F3FF { background-position: -500px -520px; } .emoji-1F450 { background-position: -520px -520px; } -.emoji-1F450-1F3FB { background-position: -540px 0px; } +.emoji-1F450-1F3FB { background-position: -540px 0; } .emoji-1F450-1F3FC { background-position: -540px -20px; } .emoji-1F450-1F3FD { background-position: -540px -40px; } .emoji-1F450-1F3FE { background-position: -540px -60px; } @@ -754,7 +754,7 @@ .emoji-1F464 { background-position: -540px -480px; } .emoji-1F465 { background-position: -540px -500px; } .emoji-1F466 { background-position: -540px -520px; } -.emoji-1F466-1F3FB { background-position: 0px -540px; } +.emoji-1F466-1F3FB { background-position: 0 -540px; } .emoji-1F466-1F3FC { background-position: -20px -540px; } .emoji-1F466-1F3FD { background-position: -40px -540px; } .emoji-1F466-1F3FE { background-position: -60px -540px; } @@ -782,7 +782,7 @@ .emoji-1F468-1F469-1F467-1F467 { background-position: -500px -540px; } .emoji-1F468-2764-1F468 { background-position: -520px -540px; } .emoji-1F468-2764-1F48B-1F468 { background-position: -540px -540px; } -.emoji-1F469 { background-position: -560px 0px; } +.emoji-1F469 { background-position: -560px 0; } .emoji-1F469-1F3FB { background-position: -560px -20px; } .emoji-1F469-1F3FC { background-position: -560px -40px; } .emoji-1F469-1F3FD { background-position: -560px -60px; } @@ -810,7 +810,7 @@ .emoji-1F470-1F3FB { background-position: -560px -500px; } .emoji-1F470-1F3FC { background-position: -560px -520px; } .emoji-1F470-1F3FD { background-position: -560px -540px; } -.emoji-1F470-1F3FE { background-position: 0px -560px; } +.emoji-1F470-1F3FE { background-position: 0 -560px; } .emoji-1F470-1F3FF { background-position: -20px -560px; } .emoji-1F471 { background-position: -40px -560px; } .emoji-1F471-1F3FB { background-position: -60px -560px; } @@ -839,7 +839,7 @@ .emoji-1F475 { background-position: -520px -560px; } .emoji-1F475-1F3FB { background-position: -540px -560px; } .emoji-1F475-1F3FC { background-position: -560px -560px; } -.emoji-1F475-1F3FD { background-position: -580px 0px; } +.emoji-1F475-1F3FD { background-position: -580px 0; } .emoji-1F475-1F3FE { background-position: -580px -20px; } .emoji-1F475-1F3FF { background-position: -580px -40px; } .emoji-1F476 { background-position: -580px -60px; } @@ -868,7 +868,7 @@ .emoji-1F47C-1F3FC { background-position: -580px -520px; } .emoji-1F47C-1F3FD { background-position: -580px -540px; } .emoji-1F47C-1F3FE { background-position: -580px -560px; } -.emoji-1F47C-1F3FF { background-position: 0px -580px; } +.emoji-1F47C-1F3FF { background-position: 0 -580px; } .emoji-1F47D { background-position: -20px -580px; } .emoji-1F47E { background-position: -40px -580px; } .emoji-1F47F { background-position: -60px -580px; } @@ -898,7 +898,7 @@ .emoji-1F485-1F3FD { background-position: -540px -580px; } .emoji-1F485-1F3FE { background-position: -560px -580px; } .emoji-1F485-1F3FF { background-position: -580px -580px; } -.emoji-1F486 { background-position: -600px 0px; } +.emoji-1F486 { background-position: -600px 0; } .emoji-1F486-1F3FB { background-position: -600px -20px; } .emoji-1F486-1F3FC { background-position: -600px -40px; } .emoji-1F486-1F3FD { background-position: -600px -60px; } @@ -928,7 +928,7 @@ .emoji-1F497 { background-position: -600px -540px; } .emoji-1F498 { background-position: -600px -560px; } .emoji-1F499 { background-position: -600px -580px; } -.emoji-1F49A { background-position: 0px -600px; } +.emoji-1F49A { background-position: 0 -600px; } .emoji-1F49B { background-position: -20px -600px; } .emoji-1F49C { background-position: -40px -600px; } .emoji-1F49D { background-position: -60px -600px; } @@ -959,7 +959,7 @@ .emoji-1F4B1 { background-position: -560px -600px; } .emoji-1F4B2 { background-position: -580px -600px; } .emoji-1F4B3 { background-position: -600px -600px; } -.emoji-1F4B4 { background-position: -620px 0px; } +.emoji-1F4B4 { background-position: -620px 0; } .emoji-1F4B5 { background-position: -620px -20px; } .emoji-1F4B6 { background-position: -620px -40px; } .emoji-1F4B7 { background-position: -620px -60px; } @@ -990,7 +990,7 @@ .emoji-1F4D0 { background-position: -620px -560px; } .emoji-1F4D1 { background-position: -620px -580px; } .emoji-1F4D2 { background-position: -620px -600px; } -.emoji-1F4D3 { background-position: 0px -620px; } +.emoji-1F4D3 { background-position: 0 -620px; } .emoji-1F4D4 { background-position: -20px -620px; } .emoji-1F4D5 { background-position: -40px -620px; } .emoji-1F4D6 { background-position: -60px -620px; } @@ -1022,7 +1022,7 @@ .emoji-1F4F0 { background-position: -580px -620px; } .emoji-1F4F1 { background-position: -600px -620px; } .emoji-1F4F2 { background-position: -620px -620px; } -.emoji-1F4F3 { background-position: -640px 0px; } +.emoji-1F4F3 { background-position: -640px 0; } .emoji-1F4F4 { background-position: -640px -20px; } .emoji-1F4F5 { background-position: -640px -40px; } .emoji-1F4F6 { background-position: -640px -60px; } @@ -1054,7 +1054,7 @@ .emoji-1F510 { background-position: -640px -580px; } .emoji-1F511 { background-position: -640px -600px; } .emoji-1F512 { background-position: -640px -620px; } -.emoji-1F513 { background-position: 0px -640px; } +.emoji-1F513 { background-position: 0 -640px; } .emoji-1F514 { background-position: -20px -640px; } .emoji-1F515 { background-position: -40px -640px; } .emoji-1F516 { background-position: -60px -640px; } @@ -1087,7 +1087,7 @@ .emoji-1F531 { background-position: -600px -640px; } .emoji-1F532 { background-position: -620px -640px; } .emoji-1F533 { background-position: -640px -640px; } -.emoji-1F534 { background-position: -660px 0px; } +.emoji-1F534 { background-position: -660px 0; } .emoji-1F535 { background-position: -660px -20px; } .emoji-1F536 { background-position: -660px -40px; } .emoji-1F537 { background-position: -660px -60px; } @@ -1120,7 +1120,7 @@ .emoji-1F55B { background-position: -660px -600px; } .emoji-1F55C { background-position: -660px -620px; } .emoji-1F55D { background-position: -660px -640px; } -.emoji-1F55E { background-position: 0px -660px; } +.emoji-1F55E { background-position: 0 -660px; } .emoji-1F55F { background-position: -20px -660px; } .emoji-1F560 { background-position: -40px -660px; } .emoji-1F561 { background-position: -60px -660px; } @@ -1154,7 +1154,7 @@ .emoji-1F578 { background-position: -620px -660px; } .emoji-1F579 { background-position: -640px -660px; } .emoji-1F57B { background-position: -660px -660px; } -.emoji-1F57E { background-position: -680px 0px; } +.emoji-1F57E { background-position: -680px 0; } .emoji-1F57F { background-position: -680px -20px; } .emoji-1F581 { background-position: -680px -40px; } .emoji-1F582 { background-position: -680px -60px; } @@ -1188,7 +1188,7 @@ .emoji-1F595-1F3FF { background-position: -680px -620px; } .emoji-1F596 { background-position: -680px -640px; } .emoji-1F596-1F3FB { background-position: -680px -660px; } -.emoji-1F596-1F3FC { background-position: 0px -680px; } +.emoji-1F596-1F3FC { background-position: 0 -680px; } .emoji-1F596-1F3FD { background-position: -20px -680px; } .emoji-1F596-1F3FE { background-position: -40px -680px; } .emoji-1F596-1F3FF { background-position: -60px -680px; } @@ -1223,7 +1223,7 @@ .emoji-1F5C4 { background-position: -640px -680px; } .emoji-1F5C6 { background-position: -660px -680px; } .emoji-1F5C7 { background-position: -680px -680px; } -.emoji-1F5C9 { background-position: -700px 0px; } +.emoji-1F5C9 { background-position: -700px 0; } .emoji-1F5CA { background-position: -700px -20px; } .emoji-1F5CE { background-position: -700px -40px; } .emoji-1F5CF { background-position: -700px -60px; } @@ -1258,7 +1258,7 @@ .emoji-1F5F8 { background-position: -700px -640px; } .emoji-1F5F9 { background-position: -700px -660px; } .emoji-1F5FA { background-position: -700px -680px; } -.emoji-1F5FB { background-position: 0px -700px; } +.emoji-1F5FB { background-position: 0 -700px; } .emoji-1F5FC { background-position: -20px -700px; } .emoji-1F5FD { background-position: -40px -700px; } .emoji-1F5FE { background-position: -60px -700px; } @@ -1294,7 +1294,7 @@ .emoji-1F61C { background-position: -660px -700px; } .emoji-1F61D { background-position: -680px -700px; } .emoji-1F61E { background-position: -700px -700px; } -.emoji-1F61F { background-position: -720px 0px; } +.emoji-1F61F { background-position: -720px 0; } .emoji-1F620 { background-position: -720px -20px; } .emoji-1F621 { background-position: -720px -40px; } .emoji-1F622 { background-position: -720px -60px; } @@ -1330,7 +1330,7 @@ .emoji-1F640 { background-position: -720px -660px; } .emoji-1F641 { background-position: -720px -680px; } .emoji-1F642 { background-position: -720px -700px; } -.emoji-1F643 { background-position: 0px -720px; } +.emoji-1F643 { background-position: 0 -720px; } .emoji-1F644 { background-position: -20px -720px; } .emoji-1F645 { background-position: -40px -720px; } .emoji-1F645-1F3FB { background-position: -60px -720px; } @@ -1367,7 +1367,7 @@ .emoji-1F64C-1F3FF { background-position: -680px -720px; } .emoji-1F64D { background-position: -700px -720px; } .emoji-1F64D-1F3FB { background-position: -720px -720px; } -.emoji-1F64D-1F3FC { background-position: -740px 0px; } +.emoji-1F64D-1F3FC { background-position: -740px 0; } .emoji-1F64D-1F3FD { background-position: -740px -20px; } .emoji-1F64D-1F3FE { background-position: -740px -40px; } .emoji-1F64D-1F3FF { background-position: -740px -60px; } @@ -1404,7 +1404,7 @@ .emoji-1F692 { background-position: -740px -680px; } .emoji-1F693 { background-position: -740px -700px; } .emoji-1F694 { background-position: -740px -720px; } -.emoji-1F695 { background-position: 0px -740px; } +.emoji-1F695 { background-position: 0 -740px; } .emoji-1F696 { background-position: -20px -740px; } .emoji-1F697 { background-position: -40px -740px; } .emoji-1F698 { background-position: -60px -740px; } @@ -1442,7 +1442,7 @@ .emoji-1F6B3 { background-position: -700px -740px; } .emoji-1F6B4 { background-position: -720px -740px; } .emoji-1F6B4-1F3FB { background-position: -740px -740px; } -.emoji-1F6B4-1F3FC { background-position: -760px 0px; } +.emoji-1F6B4-1F3FC { background-position: -760px 0; } .emoji-1F6B4-1F3FD { background-position: -760px -20px; } .emoji-1F6B4-1F3FE { background-position: -760px -40px; } .emoji-1F6B4-1F3FF { background-position: -760px -60px; } @@ -1480,7 +1480,7 @@ .emoji-1F6C5 { background-position: -760px -700px; } .emoji-1F6C6 { background-position: -760px -720px; } .emoji-1F6C7 { background-position: -760px -740px; } -.emoji-1F6C8 { background-position: 0px -760px; } +.emoji-1F6C8 { background-position: 0 -760px; } .emoji-1F6C9 { background-position: -20px -760px; } .emoji-1F6CA { background-position: -40px -760px; } .emoji-1F6CB { background-position: -60px -760px; } @@ -1519,7 +1519,7 @@ .emoji-1F918-1F3FC { background-position: -720px -760px; } .emoji-1F918-1F3FD { background-position: -740px -760px; } .emoji-1F918-1F3FE { background-position: -760px -760px; } -.emoji-1F918-1F3FF { background-position: -780px 0px; } +.emoji-1F918-1F3FF { background-position: -780px 0; } .emoji-1F980 { background-position: -780px -20px; } .emoji-1F981 { background-position: -780px -40px; } .emoji-1F982 { background-position: -780px -60px; } @@ -1558,7 +1558,7 @@ .emoji-24C2 { background-position: -780px -720px; } .emoji-25AA { background-position: -780px -740px; } .emoji-25AB { background-position: -780px -760px; } -.emoji-25B6 { background-position: 0px -780px; } +.emoji-25B6 { background-position: 0 -780px; } .emoji-25C0 { background-position: -20px -780px; } .emoji-25FB { background-position: -40px -780px; } .emoji-25FC { background-position: -60px -780px; } @@ -1598,7 +1598,7 @@ .emoji-264D { background-position: -740px -780px; } .emoji-264E { background-position: -760px -780px; } .emoji-264F { background-position: -780px -780px; } -.emoji-2650 { background-position: -800px 0px; } +.emoji-2650 { background-position: -800px 0; } .emoji-2651 { background-position: -800px -20px; } .emoji-2652 { background-position: -800px -40px; } .emoji-2653 { background-position: -800px -60px; } @@ -1638,7 +1638,7 @@ .emoji-26F0 { background-position: -800px -740px; } .emoji-26F1 { background-position: -800px -760px; } .emoji-26F2 { background-position: -800px -780px; } -.emoji-26F3 { background-position: 0px -800px; } +.emoji-26F3 { background-position: 0 -800px; } .emoji-26F4 { background-position: -20px -800px; } .emoji-26F5 { background-position: -40px -800px; } .emoji-26F7 { background-position: -60px -800px; } @@ -1679,7 +1679,7 @@ .emoji-270D-1F3FD { background-position: -760px -800px; } .emoji-270D-1F3FE { background-position: -780px -800px; } .emoji-270D-1F3FF { background-position: -800px -800px; } -.emoji-270F { background-position: -820px 0px; } +.emoji-270F { background-position: -820px 0; } .emoji-2712 { background-position: -820px -20px; } .emoji-2714 { background-position: -820px -40px; } .emoji-2716 { background-position: -820px -60px; } diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index 1833e2136b..e7da0a2f68 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -63,7 +63,7 @@ .note-image-attach { margin-top: 4px; - margin-left: 0px; + margin-left: 0; max-width: 200px; float: none; } diff --git a/app/assets/stylesheets/pages/import.scss b/app/assets/stylesheets/pages/import.scss index 3df4bb84bd..6a99cd9cb9 100644 --- a/app/assets/stylesheets/pages/import.scss +++ b/app/assets/stylesheets/pages/import.scss @@ -1,6 +1,6 @@ i.icon-gitorious { display: inline-block; - background-position: 0px 0px; + background-position: 0 0; background-size: contain; background-repeat: no-repeat; } diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index 6c35a74f08..d9c4788126 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -39,7 +39,7 @@ .login-box{ background: #fafafa; border-radius: 10px; - box-shadow: 0 0px 2px #ccc; + box-shadow: 0 0 2px #ccc; padding: 15px; .login-heading h3 { @@ -74,7 +74,7 @@ &.top { @include border-radius(5px 5px 0 0); - margin-bottom: 0px; + margin-bottom: 0; } &.bottom { @@ -85,7 +85,7 @@ &.middle { border-top: 0; - margin-bottom:0px; + margin-bottom:0; @include border-radius(0); } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 1f631ab03e..969c79a9be 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -11,8 +11,8 @@ ul.notes { display: block; list-style: none; - margin: 0px; - padding: 0px; + margin: 0; + padding: 0; .timeline-icon { float: left; diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index ecfe0e37c8..260179074c 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -30,7 +30,7 @@ .btn { line-height: 40px; height: 42px; - padding: 0px 12px; + padding: 0 12px; img { width: 32px; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index a29b735eb0..3fe2c9a334 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -85,7 +85,7 @@ .project-repo-buttons { margin-top: 20px; - margin-bottom: 0px; + margin-bottom: 0; .count-buttons { display: block; @@ -162,7 +162,7 @@ cursor: pointer; background-image: none; white-space: nowrap; - margin: 0 11px 0px 4px; + margin: 0 11px 0 4px; &:hover { background: #fff; @@ -210,7 +210,7 @@ } .project_member_row form { - margin: 0px; + margin: 0; } .transfer-project .select2-container { diff --git a/app/assets/stylesheets/pages/sherlock.scss b/app/assets/stylesheets/pages/sherlock.scss index 92d84d9640..bed6470dbd 100644 --- a/app/assets/stylesheets/pages/sherlock.scss +++ b/app/assets/stylesheets/pages/sherlock.scss @@ -13,13 +13,13 @@ table .sherlock-code { } .sherlock-line-samples-table { - margin-bottom: 0px !important; + margin-bottom: 0 !important; thead tr th, tbody tr td { font-size: 13px !important; text-align: right; - padding: 0px 10px !important; + padding: 0 10px !important; } } diff --git a/app/assets/stylesheets/pages/todos.scss b/app/assets/stylesheets/pages/todos.scss index 2ab40941c4..27970eba15 100644 --- a/app/assets/stylesheets/pages/todos.scss +++ b/app/assets/stylesheets/pages/todos.scss @@ -61,7 +61,7 @@ .note-image-attach { margin-top: 4px; - margin-left: 0px; + margin-left: 0; max-width: 200px; float: none; } diff --git a/lib/tasks/gemojione.rake b/lib/tasks/gemojione.rake index ebe301c1fc..cfaf4a129b 100644 --- a/lib/tasks/gemojione.rake +++ b/lib/tasks/gemojione.rake @@ -47,6 +47,7 @@ namespace :gemojione do # let's simplify it system(%Q(sed -i '' "s/width: #{SIZE}px; height: #{SIZE}px; background: image-url('emoji.png')/background-position:/" #{style_path})) system(%Q(sed -i '' "s/ no-repeat//" #{style_path})) + system(%Q(sed -i '' "s/ 0px/ 0/" #{style_path})) # Append a generic rule that applies to all Emojis File.open(style_path, 'a') do |f| From 5d3cd7fd3783d7e94894431ec13843a03feb77a5 Mon Sep 17 00:00:00 2001 From: connorshea Date: Wed, 16 Mar 2016 17:40:12 -0600 Subject: [PATCH 177/797] Follow the CSS Style Guide rules for including a space after the property colon. Fixes violations of SpaceAfterPropertyColon. Discussed in #14299. [ci skip] --- app/assets/stylesheets/framework/common.scss | 24 +++++++++---------- .../stylesheets/framework/variables.scss | 2 +- app/assets/stylesheets/pages/appearances.scss | 2 +- app/assets/stylesheets/pages/dashboard.scss | 6 ++--- app/assets/stylesheets/pages/events.scss | 4 ++-- app/assets/stylesheets/pages/issuable.scss | 2 +- app/assets/stylesheets/pages/login.scss | 4 ++-- app/assets/stylesheets/pages/notes.scss | 10 ++++---- app/assets/stylesheets/pages/projects.scss | 8 +++---- app/assets/stylesheets/pages/tree.scss | 2 +- 10 files changed, 32 insertions(+), 32 deletions(-) diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 180926b3b9..bc03c2180b 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -8,20 +8,20 @@ /** COMMON CLASSES **/ .prepend-top-0 { margin-top: 0; } .prepend-top-5 { margin-top: 5px; } -.prepend-top-10 { margin-top:10px } +.prepend-top-10 { margin-top: 10px } .prepend-top-default { margin-top: $gl-padding !important; } -.prepend-top-20 { margin-top:20px } -.prepend-left-10 { margin-left:10px } +.prepend-top-20 { margin-top: 20px } +.prepend-left-10 { margin-left: 10px } .prepend-left-default { margin-left: $gl-padding; } -.prepend-left-20 { margin-left:20px } +.prepend-left-20 { margin-left: 20px } .append-right-5 { margin-right: 5px } -.append-right-10 { margin-right:10px } +.append-right-10 { margin-right: 10px } .append-right-default { margin-right: $gl-padding; } -.append-right-20 { margin-right:20px } -.append-bottom-0 { margin-bottom:0 } -.append-bottom-10 { margin-bottom:10px } -.append-bottom-15 { margin-bottom:15px } -.append-bottom-20 { margin-bottom:20px } +.append-right-20 { margin-right: 20px } +.append-bottom-0 { margin-bottom: 0 } +.append-bottom-10 { margin-bottom: 10px } +.append-bottom-15 { margin-bottom: 15px } +.append-bottom-20 { margin-bottom: 20px } .append-bottom-default { margin-bottom: $gl-padding; } .inline { display: inline-block } .center { text-align: center } @@ -134,10 +134,10 @@ p.time { // Fix issue with notes & lists creating a bunch of bottom borders. li.note { - img { max-width:100% } + img { max-width: 100% } .note-title { li { - border-bottom:none !important; + border-bottom: none !important; } } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 5e3546bc6f..211ead7319 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -27,7 +27,7 @@ $gl-gray: #5a5a5a; $gl-padding: 16px; $gl-btn-padding: 10px; $gl-vert-padding: 6px; -$gl-padding-top:10px; +$gl-padding-top: 10px; $gl-avatar-size: 40px; $secondary-text: #7f8fa4; $error-exclamation-point: #e62958; diff --git a/app/assets/stylesheets/pages/appearances.scss b/app/assets/stylesheets/pages/appearances.scss index e2070f17c3..878f44116b 100644 --- a/app/assets/stylesheets/pages/appearances.scss +++ b/app/assets/stylesheets/pages/appearances.scss @@ -4,7 +4,7 @@ } .appearance-light-logo-preview { - background-color: $background-color; + background-color: $background-color; max-width: 72px; padding: 10px; margin-bottom: 10px; diff --git a/app/assets/stylesheets/pages/dashboard.scss b/app/assets/stylesheets/pages/dashboard.scss index 8863939914..cf7567513e 100644 --- a/app/assets/stylesheets/pages/dashboard.scss +++ b/app/assets/stylesheets/pages/dashboard.scss @@ -11,15 +11,15 @@ } .dashboard-search-filter { - padding:5px; + padding: 5px; .search-text-input { - float:left; + float: left; @extend .col-md-2; } .btn { margin-left: 5px; - float:left; + float: left; } } diff --git a/app/assets/stylesheets/pages/events.scss b/app/assets/stylesheets/pages/events.scss index e7da0a2f68..b39a9abf40 100644 --- a/app/assets/stylesheets/pages/events.scss +++ b/app/assets/stylesheets/pages/events.scss @@ -94,7 +94,7 @@ } } - &:last-child { border:none } + &:last-child { border: none } .event_commits { li { @@ -138,7 +138,7 @@ @include str-truncated(100%); padding: 5px 0; font-size: 13px; - float:left; + float: left; margin-right: -150px; padding-right: 150px; line-height: 20px; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index faa2ebfda7..c975ca0ce4 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -59,7 +59,7 @@ .issuable-sidebar { .block { @include clearfix; - padding: $gl-padding 0; + padding: $gl-padding 0; border-bottom: 1px solid $border-gray-light; // This prevents the mess when resizing the sidebar // of elements repositioning themselves.. diff --git a/app/assets/stylesheets/pages/login.scss b/app/assets/stylesheets/pages/login.scss index d9c4788126..bc41f7d306 100644 --- a/app/assets/stylesheets/pages/login.scss +++ b/app/assets/stylesheets/pages/login.scss @@ -28,7 +28,7 @@ img { max-width: 100%; - margin-bottom: 30px; + margin-bottom: 30px; } a { @@ -85,7 +85,7 @@ &.middle { border-top: 0; - margin-bottom:0; + margin-bottom: 0; @include border-radius(0); } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 969c79a9be..c40dc79537 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -3,9 +3,9 @@ */ @-webkit-keyframes targe3-note { - from { background:#fffff0; } - 50% { background:#ffffd3; } - to { background:#fffff0; } + from { background: #fffff0; } + 50% { background: #ffffd3; } + to { background: #fffff0; } } ul.notes { @@ -93,12 +93,12 @@ ul.notes { .discussion { overflow: hidden; display: block; - position:relative; + position: relative; } .note { display: block; - position:relative; + position: relative; .note-body { overflow: auto; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 3fe2c9a334..6c600c99d5 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -286,11 +286,11 @@ table.table.protected-branches-list tr.no-border { padding-bottom: 4px; ul.nav { - display:inline-block; + display: inline-block; } .nav li { - display:inline; + display: inline; } .nav > li > a { @@ -303,11 +303,11 @@ table.table.protected-branches-list tr.no-border { } li { - display:inline; + display: inline; } a { - float:left; + float: left; font-size: 17px; } diff --git a/app/assets/stylesheets/pages/tree.scss b/app/assets/stylesheets/pages/tree.scss index ef63b01060..73c7c9f687 100644 --- a/app/assets/stylesheets/pages/tree.scss +++ b/app/assets/stylesheets/pages/tree.scss @@ -46,7 +46,7 @@ img { position: relative; - top:-1px; + top: -1px; } } From 738812240c34143f9527afc2ea9eb83885c75a21 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 8 Mar 2016 11:50:51 +0100 Subject: [PATCH 178/797] Add label description in tooltip to labels in issue index and sidebar --- CHANGELOG | 1 + app/helpers/labels_helper.rb | 15 ++++++++------- app/views/admin/labels/_label.html.haml | 2 +- app/views/shared/_label_row.html.haml | 2 +- app/views/shared/milestones/_labels_tab.html.haml | 2 +- 5 files changed, 12 insertions(+), 10 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 638a4f1d3f..efb2709d83 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -17,6 +17,7 @@ v 8.6.0 (unreleased) - Memoize @group in Admin::GroupsController (Yatish Mehta) - Indicate how much an MR diverged from the target branch (Pierre de La Morinerie) - Added omniauth-auth0 Gem (Daniel Carraro) + - Add label description in tooltip to labels in issue index and sidebar - Strip leading and trailing spaces in URL validator (evuez) - Add "last_sign_in_at" and "confirmed_at" to GET /users/* API endpoints for admins (evuez) - Return empty array instead of 404 when commit has no statuses in commit status API diff --git a/app/helpers/labels_helper.rb b/app/helpers/labels_helper.rb index 4455dcd0e2..007cc951ed 100644 --- a/app/helpers/labels_helper.rb +++ b/app/helpers/labels_helper.rb @@ -32,7 +32,7 @@ module LabelsHelper # link_to_label(label) { "My Custom Label Text" } # # Returns a String - def link_to_label(label, project: nil, type: :issue, &block) + def link_to_label(label, project: nil, type: :issue, tooltip: true, &block) project ||= @project || label.project link = send("namespace_project_#{type.to_s.pluralize}_path", project.namespace, @@ -42,7 +42,7 @@ module LabelsHelper if block_given? link_to link, &block else - link_to render_colored_label(label), link + link_to render_colored_label(label, tooltip: tooltip), link end end @@ -50,23 +50,24 @@ module LabelsHelper @project.labels.pluck(:title) end - def render_colored_label(label, label_suffix = '') + def render_colored_label(label, label_suffix = '', tooltip: true) label_color = label.color || Label::DEFAULT_COLOR text_color = text_color_for_bg(label_color) # Intentionally not using content_tag here so that this method can be called # by LabelReferenceFilter - span = %() + + span = %() + %(#{escape_once(label.name)}#{label_suffix}) span.html_safe end - def render_colored_cross_project_label(label) + def render_colored_cross_project_label(label, tooltip: true) label_suffix = label.project.name_with_namespace label_suffix = " in #{escape_once(label_suffix)}" - render_colored_label(label, label_suffix) + render_colored_label(label, label_suffix, tooltip: tooltip) end def suggested_colors diff --git a/app/views/admin/labels/_label.html.haml b/app/views/admin/labels/_label.html.haml index 5736a30191..f417b2e44a 100644 --- a/app/views/admin/labels/_label.html.haml +++ b/app/views/admin/labels/_label.html.haml @@ -1,6 +1,6 @@ %li{id: dom_id(label)} .label-row - = render_colored_label(label) + = render_colored_label(label, tooltip: false) = markdown(label.description, pipeline: :single_line) .pull-right = link_to 'Edit', edit_admin_label_path(label), class: 'btn btn-sm' diff --git a/app/views/shared/_label_row.html.haml b/app/views/shared/_label_row.html.haml index 8134b15d24..4b47b0291b 100644 --- a/app/views/shared/_label_row.html.haml +++ b/app/views/shared/_label_row.html.haml @@ -1,4 +1,4 @@ %span.label-row - = link_to_label(label) + = link_to_label(label, tooltip: false) %span.prepend-left-10 = markdown(label.description, pipeline: :single_line) diff --git a/app/views/shared/milestones/_labels_tab.html.haml b/app/views/shared/milestones/_labels_tab.html.haml index ba27bafd1b..868b235700 100644 --- a/app/views/shared/milestones/_labels_tab.html.haml +++ b/app/views/shared/milestones/_labels_tab.html.haml @@ -5,7 +5,7 @@ %li %span.label-row = link_to milestones_label_path(options) do - - render_colored_label(label) + - render_colored_label(label, tooltip: false) %span.prepend-left-10 = markdown(label.description, pipeline: :single_line) From f54bf00309e310cabb2fec55d860f0670f3b79ac Mon Sep 17 00:00:00 2001 From: Gabriel Mazetto Date: Thu, 17 Mar 2016 00:24:12 -0300 Subject: [PATCH 179/797] =?UTF-8?q?Back-porting=20PostReceive=20refactor?= =?UTF-8?q?=20made=20for=20EE=20=F0=9F=8D=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/workers/post_receive.rb | 46 +++++++++++--------------- lib/gitlab/git_post_receive.rb | 60 ++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 27 deletions(-) create mode 100644 lib/gitlab/git_post_receive.rb diff --git a/app/workers/post_receive.rb b/app/workers/post_receive.rb index 14d7813412..3cc232ef1a 100644 --- a/app/workers/post_receive.rb +++ b/app/workers/post_receive.rb @@ -1,6 +1,5 @@ class PostReceive include Sidekiq::Worker - include Gitlab::Identifier sidekiq_options queue: :post_receive @@ -11,51 +10,44 @@ class PostReceive log("Check gitlab.yml config for correct gitlab_shell.repos_path variable. \"#{Gitlab.config.gitlab_shell.repos_path}\" does not match \"#{repo_path}\"") end - repo_path.gsub!(/\.git\z/, "") - repo_path.gsub!(/\A\//, "") + post_received = Gitlab::GitPostReceive.new(repo_path, identifier, changes) - project = Project.find_with_namespace(repo_path) - - if project.nil? + if post_received.project.nil? log("Triggered hook for non-existing project with full path \"#{repo_path} \"") return false end - changes = Base64.decode64(changes) unless changes.include?(" ") - changes = utf8_encode_changes(changes) - changes = changes.lines + if post_received.wiki? + # Nothing defined here yet. + elsif post_received.regular_project? + process_project_changes(post_received) + else + log("Triggered hook for unidentifiable repository type with full path \"#{repo_path} \"") + false + end + end - changes.each do |change| + def process_project_changes(post_received) + post_received.changes.each do |change| oldrev, newrev, ref = change.strip.split(' ') - @user ||= identify(identifier, project, newrev) + @user ||= post_received.identify(newrev) unless @user - log("Triggered hook for non-existing user \"#{identifier} \"") + log("Triggered hook for non-existing user \"#{post_received.identifier} \"") return false end if Gitlab::Git.tag_ref?(ref) - GitTagPushService.new.execute(project, @user, oldrev, newrev, ref) + GitTagPushService.new.execute(post_received.project, @user, oldrev, newrev, ref) else - GitPushService.new(project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute + GitPushService.new(post_received.project, @user, oldrev: oldrev, newrev: newrev, ref: ref).execute end end end - def utf8_encode_changes(changes) - changes = changes.dup - - changes.force_encoding("UTF-8") - return changes if changes.valid_encoding? - - # Convert non-UTF-8 branch/tag names to UTF-8 so they can be dumped as JSON. - detection = CharlockHolmes::EncodingDetector.detect(changes) - return changes unless detection && detection[:encoding] - - CharlockHolmes::Converter.convert(changes, detection[:encoding], 'UTF-8') - end - + private + def log(message) Gitlab::GitLogger.error("POST-RECEIVE: #{message}") end diff --git a/lib/gitlab/git_post_receive.rb b/lib/gitlab/git_post_receive.rb new file mode 100644 index 0000000000..a088e19d1e --- /dev/null +++ b/lib/gitlab/git_post_receive.rb @@ -0,0 +1,60 @@ +module Gitlab + class GitPostReceive + include Gitlab::Identifier + attr_reader :repo_path, :identifier, :changes, :project + + def initialize(repo_path, identifier, changes) + repo_path.gsub!(/\.git\z/, '') + repo_path.gsub!(/\A\//, '') + + @repo_path = repo_path + @identifier = identifier + @changes = deserialize_changes(changes) + + retrieve_project_and_type + end + + def wiki? + @type == :wiki + end + + def regular_project? + @type == :project + end + + def identify(revision) + super(identifier, project, revision) + end + + private + + def retrieve_project_and_type + @type = :project + @project = Project.find_with_namespace(@repo_path) + + if @repo_path.end_with?('.wiki') && !@project + @type = :wiki + @project = Project.find_with_namespace(@repo_path.gsub(/\.wiki\z/, '')) + end + end + + def deserialize_changes(changes) + changes = Base64.decode64(changes) unless changes.include?(' ') + changes = utf8_encode_changes(changes) + changes.lines + end + + def utf8_encode_changes(changes) + changes = changes.dup + + changes.force_encoding('UTF-8') + return changes if changes.valid_encoding? + + # Convert non-UTF-8 branch/tag names to UTF-8 so they can be dumped as JSON. + detection = CharlockHolmes::EncodingDetector.detect(changes) + return changes unless detection && detection[:encoding] + + CharlockHolmes::Converter.convert(changes, detection[:encoding], 'UTF-8') + end + end +end From b53d9f8a0ec6c044548fa21a04ba227a0c46d43a Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 15 Feb 2016 14:41:40 +0100 Subject: [PATCH 180/797] Add scaffold of service that moves issue to another project --- app/services/issues/move_service.rb | 41 +++++++++++++++++++++++ spec/services/issues/move_service_spec.rb | 22 ++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 app/services/issues/move_service.rb create mode 100644 spec/services/issues/move_service_spec.rb diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb new file mode 100644 index 0000000000..85c0d6192e --- /dev/null +++ b/app/services/issues/move_service.rb @@ -0,0 +1,41 @@ +module Issues + class MoveService < Issues::BaseService + def execute(issue_old, project_new) + @issue_old = issue_old + @issue_new = issue_old.dup + @project_new = project_new + + open_new_issue + rewrite_notes + close_old_issue + notify_participants + + @issue_new + end + + private + + def open_new_issue + @issue_new.project = @project_new + @issue_new.save! + + add_note_moved_from + end + + def rewrite_notes + end + + def close_old_issue + add_note_moved_to + end + + def notify_participants + end + + def add_note_moved_from + end + + def add_note_moved_to + end + end +end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb new file mode 100644 index 0000000000..27081e76d4 --- /dev/null +++ b/spec/services/issues/move_service_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe Issues::MoveService, services: true do + let(:user) { create(:user) } + let(:issue) { create(:issue, title: 'Some issue', description: 'Some issue description') } + let(:current_project) { issue.project } + let(:new_project) { create(:project) } + + before do + current_project.team << [user, :master] + end + + describe '#execute' do + let!(:new_issue) do + described_class.new(current_project, user).execute(issue, new_project) + end + + it 'should create a new issue in a new project' do + expect(new_issue.project).to eq new_project + end + end +end From 69b89d4ec8f9788dc11f30cdf1f782d316ab252c Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 15 Feb 2016 15:14:57 +0100 Subject: [PATCH 181/797] Add new system note used when issue has been moved --- app/services/issues/move_service.rb | 1 + app/services/system_note_service.rb | 17 +++++++++++++++++ spec/services/issues/move_service_spec.rb | 4 ++++ spec/services/system_note_service_spec.rb | 20 ++++++++++++++++++++ 4 files changed, 42 insertions(+) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 85c0d6192e..49ec9609e1 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -36,6 +36,7 @@ module Issues end def add_note_moved_to + SystemNoteService.issue_moved_to_another_project(@issue_old, @project, @project_new, @current_user) end end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 58a861ee08..95379d260f 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -387,4 +387,21 @@ class SystemNoteService body = "Marked the task **#{new_task.source}** as #{status_label}" create_note(noteable: noteable, project: project, author: author, note: body) end + + # Called when issue has been moved to another project + # + # issue - Issue that has been moved to another project + # project_from - Source project of the issue + # project_to - Destination project for the issue + # author - User performing the move + # + # Example Note text: + # + # "This issue has been moved to SomeNamespace / SomeProject" + # + # Returns the created Note object + def self.issue_moved_to_another_project(issue, project_from, project_to, author) + body = "This issue has been moved to #{project_to.to_reference} by #{author.to_reference}" + create_note(noteable: issue, project: project_from, author: author, note: body) + end end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 27081e76d4..25dacb2068 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -18,5 +18,9 @@ describe Issues::MoveService, services: true do it 'should create a new issue in a new project' do expect(new_issue.project).to eq new_project end + + it 'should add system note to old issue' do + expect(issue.notes.last.note).to match /This issue has been moved to/ + end end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 5dcc39f5fd..9074d3dbe1 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -441,6 +441,26 @@ describe SystemNoteService, services: true do end end + describe '.issue_moved_to_another_project' do + subject do + described_class.issue_moved_to_another_project(noteable, project, new_project, author) + end + + let(:new_project) { create(:project) } + + it 'should notify about issue being moved' do + expect(subject.note).to match /This issue has been moved to/ + end + + it 'should mention destination project' do + expect(subject.note).to include new_project.to_reference + end + + it 'should mention author of that change' do + expect(subject.note).to include author.to_reference + end + end + include JiraServiceHelper describe 'JIRA integration' do From 9882802a8b70e998ff6850a3d096b3b52730ab85 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 16 Feb 2016 11:47:00 +0100 Subject: [PATCH 182/797] Improve system notes that are added when issue is moved --- app/services/issues/move_service.rb | 4 +- app/services/system_note_service.rb | 25 +++++++---- spec/services/issues/move_service_spec.rb | 6 ++- spec/services/system_note_service_spec.rb | 55 ++++++++++++++++++----- 4 files changed, 68 insertions(+), 22 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 49ec9609e1..e2ab06ac33 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -4,6 +4,7 @@ module Issues @issue_old = issue_old @issue_new = issue_old.dup @project_new = project_new + @project_old = @project open_new_issue rewrite_notes @@ -33,10 +34,11 @@ module Issues end def add_note_moved_from + SystemNoteService.noteable_moved(:from, @issue_new, @project_new, @issue_old, @current_user) end def add_note_moved_to - SystemNoteService.issue_moved_to_another_project(@issue_old, @project, @project_new, @current_user) + SystemNoteService.noteable_moved(:to, @issue_old, @project_old, @issue_new, @current_user) end end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 95379d260f..a8d6555dec 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -3,6 +3,7 @@ # Used for creating system notes (e.g., when a user references a merge request # from an issue, an issue's assignee changes, an issue is closed, etc.) class SystemNoteService + extend GitlabMarkdownHelper # Called when commits are added to a Merge Request # # noteable - Noteable object @@ -388,20 +389,26 @@ class SystemNoteService create_note(noteable: noteable, project: project, author: author, note: body) end - # Called when issue has been moved to another project + # Called when noteable has been moved to another project # - # issue - Issue that has been moved to another project - # project_from - Source project of the issue - # project_to - Destination project for the issue - # author - User performing the move + # direction - symbol, :to or :from + # noteable - Noteable object + # noteable_ref - Referenced noteable + # author - User performing the move # # Example Note text: # - # "This issue has been moved to SomeNamespace / SomeProject" + # "Moved to project_new/#11" # # Returns the created Note object - def self.issue_moved_to_another_project(issue, project_from, project_to, author) - body = "This issue has been moved to #{project_to.to_reference} by #{author.to_reference}" - create_note(noteable: issue, project: project_from, author: author, note: body) + def self.noteable_moved(direction, noteable, project, noteable_ref, author) + unless [:to, :from].include?(direction) + raise StandardError, "Invalid direction `#{direction}`" + end + + cross_reference = cross_project_reference(noteable_ref.project, noteable_ref) + + body = "Moved #{direction} #{cross_reference}" + create_note(noteable: noteable, project: project, author: author, note: body) end end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 25dacb2068..569e155e61 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -20,7 +20,11 @@ describe Issues::MoveService, services: true do end it 'should add system note to old issue' do - expect(issue.notes.last.note).to match /This issue has been moved to/ + expect(issue.notes.last.note).to match /^Moved to/ + end + + it 'should add system note to new issue' do + expect(new_issue.notes.last.note).to match /^Moved from/ end end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 9074d3dbe1..e0ae49ab37 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -441,23 +441,56 @@ describe SystemNoteService, services: true do end end - describe '.issue_moved_to_another_project' do - subject do - described_class.issue_moved_to_another_project(noteable, project, new_project, author) - end - + describe '.noteable_moved' do let(:new_project) { create(:project) } + let(:new_noteable) { create(:issue, project: new_project) } - it 'should notify about issue being moved' do - expect(subject.note).to match /This issue has been moved to/ + subject do + described_class.noteable_moved(direction, noteable, project, new_noteable, author) end - it 'should mention destination project' do - expect(subject.note).to include new_project.to_reference + shared_examples 'cross project mentionable' do + include GitlabMarkdownHelper + + it 'should contain cross reference to new noteable' do + expect(subject.note).to include cross_project_reference(new_project, new_noteable) + end + + it 'should mention referenced noteable' do + expect(subject.note).to include new_noteable.to_reference + end + + it 'should mention referenced project' do + expect(subject.note).to include new_project.to_reference + end end - it 'should mention author of that change' do - expect(subject.note).to include author.to_reference + context 'moved to' do + let(:direction) { :to } + + it_behaves_like 'cross project mentionable' + + it 'should notify about noteable being moved to' do + expect(subject.note).to match /Moved to/ + end + end + + context 'moved from' do + let(:direction) { :from } + + it_behaves_like 'cross project mentionable' + + it 'should notify about noteable being moved from' do + expect(subject.note).to match /Moved from/ + end + end + + context 'invalid direction' do + let (:direction) { :invalid } + + it 'should raise error' do + expect { subject }.to raise_error StandardError, /Invalid direction/ + end end end From 5dea8fb9eda2490e5a05221afd15f1ddd6d79fe5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 16 Feb 2016 14:05:11 +0100 Subject: [PATCH 183/797] Add mock-up for interface being used to move issue --- app/views/shared/issuable/_form.html.haml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index d5a4aad05d..16412581cc 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -67,6 +67,14 @@ - if can? current_user, :admin_label, issuable.project = link_to 'Create new label', new_namespace_project_label_path(issuable.project.namespace, issuable.project), target: :blank +- if issuable.is_a?(Issue) + %hr + .form-group + = f.label :move_to_project_id, 'Move', class: 'control-label' + .col-sm-10 + = project_select_tag("#{issuable.class.model_name.param_key}[move_to_project_id]", + placeholder: 'Select project', class: 'custom-form-control') + - if issuable.is_a?(MergeRequest) %hr - if @merge_request.new_record? From 11f817b3fda3f77da5fdfda787816fd36d4d98a6 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 17 Feb 2016 14:45:32 +0100 Subject: [PATCH 184/797] Add ability to choose id attribute in project select helper --- app/assets/javascripts/project_select.js.coffee | 5 +++-- app/views/shared/issuable/_form.html.haml | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/project_select.js.coffee b/app/assets/javascripts/project_select.js.coffee index be8ab9b428..5b6a71714b 100644 --- a/app/assets/javascripts/project_select.js.coffee +++ b/app/assets/javascripts/project_select.js.coffee @@ -4,6 +4,7 @@ class @ProjectSelect @groupId = $(select).data('group-id') @includeGroups = $(select).data('include-groups') @orderBy = $(select).data('order-by') || 'id' + @selectId = $(select).data('select-id') || 'web_url' placeholder = "Search for project" placeholder += " or group" if @includeGroups @@ -31,8 +32,8 @@ class @ProjectSelect else Api.projects query.term, @orderBy, projectsCallback - id: (project) -> - project.web_url + id: (project) => + project[@selectId] text: (project) -> project.name_with_namespace || project.name diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 16412581cc..22540cbfe6 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -73,7 +73,7 @@ = f.label :move_to_project_id, 'Move', class: 'control-label' .col-sm-10 = project_select_tag("#{issuable.class.model_name.param_key}[move_to_project_id]", - placeholder: 'Select project', class: 'custom-form-control') + placeholder: 'Select project', class: 'custom-form-control', data: { 'select-id' => 'id' }) - if issuable.is_a?(MergeRequest) %hr From c8e7d1ed8e3eafcc8234a0e6f443bf85369c8de1 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 17 Feb 2016 15:59:25 +0100 Subject: [PATCH 185/797] Add issue move implementation to controller --- app/controllers/projects/issues_controller.rb | 5 +++ app/services/issues/move_service.rb | 29 ++++++++++++-- spec/services/issues/move_service_spec.rb | 40 ++++++++++++++----- spec/services/system_note_service_spec.rb | 2 +- 4 files changed, 60 insertions(+), 16 deletions(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index b0a03ee45c..e3486f576c 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -88,6 +88,11 @@ class Projects::IssuesController < Projects::ApplicationController def update @issue = Issues::UpdateService.new(project, current_user, issue_params).execute(issue) + move_service = Issues::MoveService.new(project, current_user, params.require(:issue).permit!, @issue) + + if move_service.move? + @issue = move_service.execute + end respond_to do |format| format.js diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index e2ab06ac33..935bd8a87f 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -1,11 +1,20 @@ module Issues class MoveService < Issues::BaseService - def execute(issue_old, project_new) - @issue_old = issue_old - @issue_new = issue_old.dup - @project_new = project_new + def initialize(project, current_user, params, issue) + super(project, current_user, params) + + @issue_old = issue + @issue_new = @issue_old.dup @project_old = @project + if params['move_to_project_id'] + @project_new = Project.find(params['move_to_project_id']) + end + end + + def execute + return unless move? + open_new_issue rewrite_notes close_old_issue @@ -14,8 +23,20 @@ module Issues @issue_new end + def move? + return false unless @project_new + return false unless @issue_new + return false unless can_move? + + true + end + private + def can_move? + true + end + def open_new_issue @issue_new.project = @project_new @issue_new.save! diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 569e155e61..412a817208 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -5,26 +5,44 @@ describe Issues::MoveService, services: true do let(:issue) { create(:issue, title: 'Some issue', description: 'Some issue description') } let(:current_project) { issue.project } let(:new_project) { create(:project) } + let(:move_params) { { 'move_to_project_id' => new_project.id } } + let(:move_service) { described_class.new(current_project, user, move_params, issue) } before do current_project.team << [user, :master] end - describe '#execute' do - let!(:new_issue) do - described_class.new(current_project, user).execute(issue, new_project) + context 'issue movable' do + describe '#move?' do + subject { move_service.move? } + it { is_expected.to be_truthy } end - it 'should create a new issue in a new project' do - expect(new_issue.project).to eq new_project - end + describe '#execute' do + let!(:new_issue) { move_service.execute } - it 'should add system note to old issue' do - expect(issue.notes.last.note).to match /^Moved to/ - end + it 'should create a new issue in a new project' do + expect(new_issue.project).to eq new_project + end - it 'should add system note to new issue' do - expect(new_issue.notes.last.note).to match /^Moved from/ + it 'should add system note to old issue' do + expect(issue.notes.last.note).to match /^Moved to/ + end + + it 'should add system note to new issue' do + expect(new_issue.notes.last.note).to match /^Moved from/ + end + end + end + + context 'issue not movable' do + context 'move not requested' do + let(:move_params) { {} } + + describe '#move?' do + subject { move_service.move? } + it { is_expected.to be_falsey } + end end end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index e0ae49ab37..7efb9f2ddb 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -486,7 +486,7 @@ describe SystemNoteService, services: true do end context 'invalid direction' do - let (:direction) { :invalid } + let(:direction) { :invalid } it 'should raise error' do expect { subject }.to raise_error StandardError, /Invalid direction/ From 14c983fb696b7b75065df2b5defd458e563444e3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 22 Feb 2016 11:00:40 +0100 Subject: [PATCH 186/797] Add implementation that rewrites issue notes when moving --- app/services/issues/move_service.rb | 23 ++++++-- spec/services/issues/move_service_spec.rb | 67 ++++++++++++++++++----- 2 files changed, 71 insertions(+), 19 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 935bd8a87f..2cba614742 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -15,9 +15,19 @@ module Issues def execute return unless move? + # New issue tasks + # open_new_issue rewrite_notes + add_moved_from_note + + # Old issue tasks + # close_old_issue + add_moved_to_note + + # Notifications + # notify_participants @issue_new @@ -40,25 +50,28 @@ module Issues def open_new_issue @issue_new.project = @project_new @issue_new.save! - - add_note_moved_from end def rewrite_notes + @issue_old.notes.find_each do |note| + note_new = note.dup + note_new.project = @project_new + note_new.noteable = @issue_new + note_new.save! + end end def close_old_issue - add_note_moved_to end def notify_participants end - def add_note_moved_from + def add_moved_from_note SystemNoteService.noteable_moved(:from, @issue_new, @project_new, @issue_old, @current_user) end - def add_note_moved_to + def add_moved_to_note SystemNoteService.noteable_moved(:to, @issue_old, @project_old, @issue_new, @current_user) end end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 412a817208..6667840ec6 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -2,15 +2,15 @@ require 'spec_helper' describe Issues::MoveService, services: true do let(:user) { create(:user) } - let(:issue) { create(:issue, title: 'Some issue', description: 'Some issue description') } - let(:current_project) { issue.project } + let(:title) { 'Some issue' } + let(:description) { 'Some issue description' } + let(:old_issue) { create(:issue, title: title, description: description) } + let(:current_project) { old_issue.project } let(:new_project) { create(:project) } let(:move_params) { { 'move_to_project_id' => new_project.id } } - let(:move_service) { described_class.new(current_project, user, move_params, issue) } + let(:move_service) { described_class.new(current_project, user, move_params, old_issue) } - before do - current_project.team << [user, :master] - end + before { current_project.team << [user, :master] } context 'issue movable' do describe '#move?' do @@ -19,18 +19,57 @@ describe Issues::MoveService, services: true do end describe '#execute' do - let!(:new_issue) { move_service.execute } - - it 'should create a new issue in a new project' do - expect(new_issue.project).to eq new_project + shared_context 'issue move executed' do + let!(:new_issue) { move_service.execute } end - it 'should add system note to old issue' do - expect(issue.notes.last.note).to match /^Moved to/ + context 'generic issue' do + include_context 'issue move executed' + + it 'creates a new issue in a new project' do + expect(new_issue.project).to eq new_project + end + + it 'rewrites issue title' do + expect(new_issue.title).to eq title + end + + it 'rewrites issue description' do + expect(new_issue.description).to include description + end + + it 'adds system note to old issue at the end' do + expect(old_issue.notes.last.note).to match /^Moved to/ + end + + it 'adds system note to new issue at the end' do + expect(new_issue.notes.last.note).to match /^Moved from/ + end end - it 'should add system note to new issue' do - expect(new_issue.notes.last.note).to match /^Moved from/ + context 'notes exist' do + let(:note_contents) do + ['Some system note 1', 'Some comment', 'Some system note 2'] + end + + before do + note_params = { noteable: old_issue, project: current_project, author: user} + create(:system_note, note_params.merge(note: note_contents.first)) + create(:note, note_params.merge(note: note_contents.second)) + create(:system_note, note_params.merge(note: note_contents.third)) + end + + include_context 'issue move executed' + + let(:new_notes) { new_issue.notes.order('id ASC').pluck(:note) } + + it 'rewrites existing system notes in valid order' do + expect(new_notes.first(3)).to eq note_contents + end + + it 'adds a system note about move after rewritten notes' do + expect(new_notes.last).to match /^Moved from/ + end end end end From 8d1b7f950708a0b759026816fcadb6324b29a208 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 22 Feb 2016 12:41:33 +0100 Subject: [PATCH 187/797] Add issue move ability and use it in move service --- app/models/ability.rb | 3 +- app/services/issues/move_service.rb | 9 +-- spec/services/issues/move_service_spec.rb | 70 +++++++++++++++++++---- 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/app/models/ability.rb b/app/models/ability.rb index fe9e0aab71..5dff01a4e5 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -222,7 +222,8 @@ class Ability :admin_wiki, :admin_project, :admin_commit_status, - :admin_build + :admin_build, + :move_issue ] end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 2cba614742..8a39e2d5f4 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -34,17 +34,14 @@ module Issues end def move? - return false unless @project_new - return false unless @issue_new - return false unless can_move? - - true + @project_new && can_move? end private def can_move? - true + can?(@current_user, :move_issue, @project_old) && + can?(@current_user, :move_issue, @project_new) end def open_new_issue diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 6667840ec6..d8357f8917 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -5,14 +5,25 @@ describe Issues::MoveService, services: true do let(:title) { 'Some issue' } let(:description) { 'Some issue description' } let(:old_issue) { create(:issue, title: title, description: description) } - let(:current_project) { old_issue.project } + let(:old_project) { old_issue.project } let(:new_project) { create(:project) } - let(:move_params) { { 'move_to_project_id' => new_project.id } } - let(:move_service) { described_class.new(current_project, user, move_params, old_issue) } + let(:move_service) { described_class.new(old_project, user, move_params, old_issue) } - before { current_project.team << [user, :master] } + shared_context 'issue move requested' do + let(:move_params) { { 'move_to_project_id' => new_project.id } } + end + shared_context 'user can move issue' do + before do + old_project.team << [user, :master] + new_project.team << [user, :master] + end + end + context 'issue movable' do + include_context 'issue move requested' + include_context 'user can move issue' + describe '#move?' do subject { move_service.move? } it { is_expected.to be_truthy } @@ -53,7 +64,7 @@ describe Issues::MoveService, services: true do end before do - note_params = { noteable: old_issue, project: current_project, author: user} + note_params = { noteable: old_issue, project: old_project, author: user} create(:system_note, note_params.merge(note: note_contents.first)) create(:note, note_params.merge(note: note_contents.second)) create(:system_note, note_params.merge(note: note_contents.third)) @@ -74,12 +85,51 @@ describe Issues::MoveService, services: true do end end - context 'issue not movable' do - context 'move not requested' do - let(:move_params) { {} } + context 'issue move not requested' do + let(:move_params) { {} } + + describe '#move?' do + subject { move_service.move? } + + context 'user do not have permissions to move issue' do + it { is_expected.to be_falsey } + end + + context 'user has permissions to move issue' do + include_context 'user can move issue' + it { is_expected.to be_falsey } + end + end + end + + + describe 'move permissions' do + include_context 'issue move requested' + + describe '#move?' do + subject { move_service.move? } + + context 'user is master in both projects' do + include_context 'user can move issue' + it { is_expected.to be_truthy } + end + + context 'user is master only in new project' do + before { new_project.team << [user, :master] } + it { is_expected.to be_falsey } + end + + context 'user is master only in old project' do + before { old_project.team << [user, :master] } + it { is_expected.to be_falsey } + end + + context 'user is master in one project and developer in another' do + before do + new_project.team << [user, :developer] + old_project.team << [user, :master] + end - describe '#move?' do - subject { move_service.move? } it { is_expected.to be_falsey } end end From 8a02449440b0491a056106d693d502252121740f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 22 Feb 2016 13:54:21 +0100 Subject: [PATCH 188/797] Do not show issue move form unless user can move --- app/views/shared/issuable/_form.html.haml | 2 +- spec/services/issues/move_service_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 22540cbfe6..bb0252ba3d 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -67,7 +67,7 @@ - if can? current_user, :admin_label, issuable.project = link_to 'Create new label', new_namespace_project_label_path(issuable.project.namespace, issuable.project), target: :blank -- if issuable.is_a?(Issue) +- if issuable.is_a?(Issue) && can?(current_user, :move_issue, issuable.project) %hr .form-group = f.label :move_to_project_id, 'Move', class: 'control-label' diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index d8357f8917..6fda8bc671 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -64,7 +64,7 @@ describe Issues::MoveService, services: true do end before do - note_params = { noteable: old_issue, project: old_project, author: user} + note_params = { noteable: old_issue, project: old_project, author: user } create(:system_note, note_params.merge(note: note_contents.first)) create(:note, note_params.merge(note: note_contents.second)) create(:system_note, note_params.merge(note: note_contents.third)) From 5ed8a1e107b9b6f91257e5afdd370f0552078786 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 22 Feb 2016 15:18:39 +0100 Subject: [PATCH 189/797] Silently close old issue when it has been moved --- app/services/issues/move_service.rb | 12 +++++------- spec/services/issues/move_service_spec.rb | 9 +++++++++ 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 8a39e2d5f4..d818ed8602 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -23,8 +23,8 @@ module Issues # Old issue tasks # - close_old_issue add_moved_to_note + close_old_issue # Notifications # @@ -45,20 +45,18 @@ module Issues end def open_new_issue - @issue_new.project = @project_new - @issue_new.save! + @issue_new.update(project: @project_new) end def rewrite_notes @issue_old.notes.find_each do |note| - note_new = note.dup - note_new.project = @project_new - note_new.noteable = @issue_new - note_new.save! + new_note = note.dup + new_note.update(project: @project_new, noteable: @issue_new) end end def close_old_issue + @issue_old.update(state: :closed) end def notify_participants diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 6fda8bc671..3b81cc90c6 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -56,6 +56,15 @@ describe Issues::MoveService, services: true do it 'adds system note to new issue at the end' do expect(new_issue.notes.last.note).to match /^Moved from/ end + + it 'closes old issue' do + expect(old_issue.closed?).to be true + end + + it 'persists changes to old and new issue' do + expect(new_issue.changed?).to be false + expect(old_issue.changed?).to be false + end end context 'notes exist' do From 4cbe87d50ecfad9b97ba76f05935124676c96052 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 23 Feb 2016 14:18:54 +0100 Subject: [PATCH 190/797] Rewrite references in issue description when moving it --- app/services/issues/move_service.rb | 17 ++++++++++++++- spec/services/issues/move_service_spec.rb | 25 +++++++++++++++++------ 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index d818ed8602..bba972382d 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -45,7 +45,8 @@ module Issues end def open_new_issue - @issue_new.update(project: @project_new) + new_description = rewrite_references(@issue_old, @issue_old.description) + @issue_new.update(project: @project_new, description: new_description) end def rewrite_notes @@ -69,5 +70,19 @@ module Issues def add_moved_to_note SystemNoteService.noteable_moved(:to, @issue_old, @project_old, @issue_new, @current_user) end + + def rewrite_references(mentionable, text) + new_content = text.dup + references = mentionable.all_references + + [:issues, :merge_requests, :milestones].each do |type| + references.public_send(type).each do |mentioned| + new_content.gsub!(mentioned.to_reference, + mentioned.to_reference(@project_new)) + end + end + + new_content + end end end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 3b81cc90c6..8d9cc09ffc 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -4,8 +4,8 @@ describe Issues::MoveService, services: true do let(:user) { create(:user) } let(:title) { 'Some issue' } let(:description) { 'Some issue description' } - let(:old_issue) { create(:issue, title: title, description: description) } - let(:old_project) { old_issue.project } + let(:old_project) { create(:project) } + let(:old_issue) { create(:issue, title: title, description: description, project: old_project) } let(:new_project) { create(:project) } let(:move_service) { described_class.new(old_project, user, move_params, old_issue) } @@ -61,13 +61,12 @@ describe Issues::MoveService, services: true do expect(old_issue.closed?).to be true end - it 'persists changes to old and new issue' do - expect(new_issue.changed?).to be false - expect(old_issue.changed?).to be false + it 'persists new issue' do + expect(new_issue.persisted?).to be true end end - context 'notes exist' do + context 'issue with notes' do let(:note_contents) do ['Some system note 1', 'Some comment', 'Some system note 2'] end @@ -91,6 +90,20 @@ describe Issues::MoveService, services: true do expect(new_notes.last).to match /^Moved from/ end end + + describe 'rewritting references' do + include_context 'issue move executed' + + context 'issue reference' do + let(:another_issue) { create(:issue, project: old_project) } + let(:description) { "Some description #{another_issue.to_reference}" } + + it 'rewrites referenced issues creating cross project reference' do + expect(new_issue.description) + .to eq "Some description #{old_project.to_reference}#{another_issue.to_reference}" + end + end + end end end From 57eb39548879109dff3813129fca7acbcca23f71 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 24 Feb 2016 11:52:02 +0100 Subject: [PATCH 191/797] Do not pass unsanitized params to issue move service --- app/controllers/projects/issues_controller.rb | 3 ++- app/services/issues/move_service.rb | 7 ++----- spec/services/issues/move_service_spec.rb | 10 +++++++--- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index e3486f576c..9ec342a7b2 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -88,7 +88,8 @@ class Projects::IssuesController < Projects::ApplicationController def update @issue = Issues::UpdateService.new(project, current_user, issue_params).execute(issue) - move_service = Issues::MoveService.new(project, current_user, params.require(:issue).permit!, @issue) + move_service = Issues::MoveService.new(project, current_user, issue_params, + @issue, params['move_to_project_id']) if move_service.move? @issue = move_service.execute diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index bba972382d..55239d566f 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -1,15 +1,12 @@ module Issues class MoveService < Issues::BaseService - def initialize(project, current_user, params, issue) + def initialize(project, current_user, params, issue, new_project_id) super(project, current_user, params) @issue_old = issue @issue_new = @issue_old.dup @project_old = @project - - if params['move_to_project_id'] - @project_new = Project.find(params['move_to_project_id']) - end + @project_new = Project.find(new_project_id) if new_project_id end def execute diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 8d9cc09ffc..931ba06f6a 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -7,10 +7,14 @@ describe Issues::MoveService, services: true do let(:old_project) { create(:project) } let(:old_issue) { create(:issue, title: title, description: description, project: old_project) } let(:new_project) { create(:project) } - let(:move_service) { described_class.new(old_project, user, move_params, old_issue) } + let(:issue_params) { old_issue.serializable_hash } + + let(:move_service) do + described_class.new(old_project, user, issue_params, old_issue, new_project_id) + end shared_context 'issue move requested' do - let(:move_params) { { 'move_to_project_id' => new_project.id } } + let(:new_project_id) { new_project.id } end shared_context 'user can move issue' do @@ -108,7 +112,7 @@ describe Issues::MoveService, services: true do end context 'issue move not requested' do - let(:move_params) { {} } + let(:new_project_id) { nil } describe '#move?' do subject { move_service.move? } From fbb5a8b089d00fb66d8915b0f546dd07e76877e4 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 24 Feb 2016 12:18:46 +0100 Subject: [PATCH 192/797] Use a new issue create service when moving an issue --- app/services/issues/move_service.rb | 29 +++++++++++++++++++---- spec/services/issues/move_service_spec.rb | 5 ++++ 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 55239d566f..510a04e98f 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -4,7 +4,7 @@ module Issues super(project, current_user, params) @issue_old = issue - @issue_new = @issue_old.dup + @issue_new = nil @project_old = @project @project_new = Project.find(new_project_id) if new_project_id end @@ -42,8 +42,18 @@ module Issues end def open_new_issue - new_description = rewrite_references(@issue_old, @issue_old.description) - @issue_new.update(project: @project_new, description: new_description) + create_service = CreateService.new(@project_new, current_user, new_issue_params) + @issue_new = create_service.execute + end + + def new_issue_params + new_params = { id: nil, iid: nil, milestone_id: nil, label_ids: [], + project_id: @project_new.id, + author_id: @issue_old.author_id, + description: rewrite_references(@issue_old), + updated_by_id: current_user.id } + + params.merge(new_params) end def rewrite_notes @@ -68,9 +78,9 @@ module Issues SystemNoteService.noteable_moved(:to, @issue_old, @project_old, @issue_new, @current_user) end - def rewrite_references(mentionable, text) - new_content = text.dup + def rewrite_references(mentionable) references = mentionable.all_references + new_content = mentionable_content(mentionable).dup [:issues, :merge_requests, :milestones].each do |type| references.public_send(type).each do |mentioned| @@ -81,5 +91,14 @@ module Issues new_content end + + def mentionable_content(mentionable) + case mentionable + when Issue then mentionable.description + when Note then mentionable.note + else + raise 'Unexpected mentionable while moving an issue' + end + end end end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 931ba06f6a..ec913cdde2 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -68,6 +68,11 @@ describe Issues::MoveService, services: true do it 'persists new issue' do expect(new_issue.persisted?).to be true end + + it 'persist all changes' do + expect(old_issue.changed?).to be false + expect(new_issue.changed?).to be false + end end context 'issue with notes' do From 8d3f072ec4f01017a4ec2e1d2082bafcf2b58188 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 24 Feb 2016 13:20:08 +0100 Subject: [PATCH 193/797] Take care about data being rewritten when moving issue --- app/services/issues/move_service.rb | 4 +--- spec/services/issues/move_service_spec.rb | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 510a04e98f..37aaeb2885 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -49,9 +49,7 @@ module Issues def new_issue_params new_params = { id: nil, iid: nil, milestone_id: nil, label_ids: [], project_id: @project_new.id, - author_id: @issue_old.author_id, - description: rewrite_references(@issue_old), - updated_by_id: current_user.id } + description: rewrite_references(@issue_old) } params.merge(new_params) end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index ec913cdde2..282dfd9187 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -2,13 +2,18 @@ require 'spec_helper' describe Issues::MoveService, services: true do let(:user) { create(:user) } + let(:author) { create(:user) } let(:title) { 'Some issue' } let(:description) { 'Some issue description' } let(:old_project) { create(:project) } - let(:old_issue) { create(:issue, title: title, description: description, project: old_project) } let(:new_project) { create(:project) } let(:issue_params) { old_issue.serializable_hash } + let(:old_issue) do + create(:issue, title: title, description: description, + project: old_project, author: author) + end + let(:move_service) do described_class.new(old_project, user, issue_params, old_issue, new_project_id) end @@ -73,6 +78,19 @@ describe Issues::MoveService, services: true do expect(old_issue.changed?).to be false expect(new_issue.changed?).to be false end + + it 'changes author' do + expect(new_issue.author).to eq user + end + + it 'removes data that is invalid in new context' do + expect(new_issue.milestone).to be_nil + expect(new_issue.labels).to be_empty + end + + it 'creates a new internal id for issue' do + expect(new_issue.iid).to be 1 + end end context 'issue with notes' do From c2d4060f26d06d1ce5450345a8be2bbf9186745f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 24 Feb 2016 15:38:52 +0100 Subject: [PATCH 194/797] Rewrite references in notes when moving issue --- app/services/issues/move_service.rb | 9 +++- spec/services/issues/move_service_spec.rb | 61 ++++++++++++++++------- 2 files changed, 51 insertions(+), 19 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 37aaeb2885..782b8b6a43 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -57,7 +57,10 @@ module Issues def rewrite_notes @issue_old.notes.find_each do |note| new_note = note.dup - new_note.update(project: @project_new, noteable: @issue_new) + new_params = { project: @project_new, noteable: @issue_new, + note: rewrite_references(new_note) } + + new_note.update(new_params) end end @@ -79,8 +82,10 @@ module Issues def rewrite_references(mentionable) references = mentionable.all_references new_content = mentionable_content(mentionable).dup + cross_project_refs = [:issues, :merge_requests, :milestones, + :snippets, :commits, :commit_ranges] - [:issues, :merge_requests, :milestones].each do |type| + cross_project_refs.each do |type| references.public_send(type).each do |mentioned| new_content.gsub!(mentioned.to_reference, mentioned.to_reference(@project_new)) diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 282dfd9187..596da74b57 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -94,27 +94,54 @@ describe Issues::MoveService, services: true do end context 'issue with notes' do - let(:note_contents) do - ['Some system note 1', 'Some comment', 'Some system note 2'] + context 'notes without references' do + let(:notes_params) do + [{ system: false, note: 'Some comment 1' }, + { system: true, note: 'Some system note' }, + { system: false, note: 'Some comment 2' }] + end + + before do + note_params = { noteable: old_issue, project: old_project, author: author } + notes_params.each do |note| + create(:note, note_params.merge(note)) + end + end + + include_context 'issue move executed' + + let(:all_notes) { new_issue.notes.order('id ASC') } + let(:system_notes) { all_notes.system } + let(:user_notes) { all_notes.user } + + it 'rewrites existing notes in valid order' do + expect(all_notes.pluck(:note).first(3)) + .to eq notes_params.map { |n| n[:note] } + end + + it 'adds a system note about move after rewritten notes' do + expect(system_notes.last.note).to match /^Moved from/ + end + + it 'preserves orignal author of comment' do + expect(user_notes.pluck(:author_id)).to all(eq(author.id)) + end end - before do - note_params = { noteable: old_issue, project: old_project, author: user } - create(:system_note, note_params.merge(note: note_contents.first)) - create(:note, note_params.merge(note: note_contents.second)) - create(:system_note, note_params.merge(note: note_contents.third)) - end + context 'notes with references' do + before do + create(:merge_request, source_project: old_project) + create(:note, noteable: old_issue, project: old_project, author: author, + note: 'Note with reference to merge request !1') + end - include_context 'issue move executed' + include_context 'issue move executed' + let(:new_note) { new_issue.notes.first } - let(:new_notes) { new_issue.notes.order('id ASC').pluck(:note) } - - it 'rewrites existing system notes in valid order' do - expect(new_notes.first(3)).to eq note_contents - end - - it 'adds a system note about move after rewritten notes' do - expect(new_notes.last).to match /^Moved from/ + it 'rewrites references using a cross reference to old project' do + expect(new_note.note) + .to eq "Note with reference to merge request #{old_project.to_reference}!1" + end end end From efd8251751c85285e6519fd3dd756f74579da15b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 24 Feb 2016 16:27:06 +0100 Subject: [PATCH 195/797] Minor refactoring of issue move service and specs --- app/services/issues/move_service.rb | 30 ++++++++++++----------- spec/services/issues/move_service_spec.rb | 5 ++-- 2 files changed, 19 insertions(+), 16 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 782b8b6a43..a404a64cfe 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -79,28 +79,30 @@ module Issues SystemNoteService.noteable_moved(:to, @issue_old, @project_old, @issue_new, @current_user) end - def rewrite_references(mentionable) - references = mentionable.all_references - new_content = mentionable_content(mentionable).dup - cross_project_refs = [:issues, :merge_requests, :milestones, - :snippets, :commits, :commit_ranges] + def rewrite_references(noteable) + references = noteable.all_references + new_content = noteable_content(noteable).dup + cross_project_mentionables = [:issues, :merge_requests, :milestones, + :snippets, :commits, :commit_ranges] - cross_project_refs.each do |type| - references.public_send(type).each do |mentioned| - new_content.gsub!(mentioned.to_reference, - mentioned.to_reference(@project_new)) + cross_project_mentionables.each do |type| + references.public_send(type).each do |mentionable| + new_content.gsub!(mentionable.to_reference, + mentionable.to_reference(@project_new)) end end new_content end - def mentionable_content(mentionable) - case mentionable - when Issue then mentionable.description - when Note then mentionable.note + def noteable_content(noteable) + case noteable + when Issue + noteable.description + when Note + noteable.note else - raise 'Unexpected mentionable while moving an issue' + raise 'Unexpected noteable while moving an issue' end end end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 596da74b57..cd05117561 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -101,6 +101,8 @@ describe Issues::MoveService, services: true do { system: false, note: 'Some comment 2' }] end + let(:notes_contents) { notes_params.map { |n| n[:note] } } + before do note_params = { noteable: old_issue, project: old_project, author: author } notes_params.each do |note| @@ -115,8 +117,7 @@ describe Issues::MoveService, services: true do let(:user_notes) { all_notes.user } it 'rewrites existing notes in valid order' do - expect(all_notes.pluck(:note).first(3)) - .to eq notes_params.map { |n| n[:note] } + expect(all_notes.pluck(:note).first(3)).to eq notes_contents end it 'adds a system note about move after rewritten notes' do From 3c493c24c70f7c8dc8e1f3bcf29e18d1ef0944a7 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 26 Feb 2016 12:04:29 +0100 Subject: [PATCH 196/797] Add reference unfold pipeline used when moving issue --- app/services/issues/move_service.rb | 11 ++-- lib/banzai/filter/reference_unfold_filter.rb | 46 +++++++++++++++ .../pipeline/reference_unfold_pipeline.rb | 9 +++ .../reference_unfold_pipeline_spec.rb | 59 +++++++++++++++++++ spec/services/issues/move_service_spec.rb | 4 +- 5 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 lib/banzai/filter/reference_unfold_filter.rb create mode 100644 lib/banzai/pipeline/reference_unfold_pipeline.rb create mode 100644 spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index a404a64cfe..cf935f30ce 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -86,10 +86,13 @@ module Issues :snippets, :commits, :commit_ranges] cross_project_mentionables.each do |type| - references.public_send(type).each do |mentionable| - new_content.gsub!(mentionable.to_reference, - mentionable.to_reference(@project_new)) - end + referables = references.public_send(type) + + context = { objects: referables, project: @project_new, + pipeline: :reference_unfold } + + new_content = Banzai.render_result(new_content, context) + new_content = new_content[:output].to_s end new_content diff --git a/lib/banzai/filter/reference_unfold_filter.rb b/lib/banzai/filter/reference_unfold_filter.rb new file mode 100644 index 0000000000..a614526165 --- /dev/null +++ b/lib/banzai/filter/reference_unfold_filter.rb @@ -0,0 +1,46 @@ +module Banzai + module Filter + ## + # Filter than unfolds local references. + # + # Replaces all local references with project cross reference version + # in all objects passed to this filter in context. + # + # Requires objects array with each element implementing `Referable`. + # + class ReferenceUnfoldFilter < ReferenceFilter + def initialize(*) + super + + @objects = context[:objects] + @project = context[:project] + + unless @objects.all? { |object| object.respond_to?(:to_reference) } + raise StandardError, "No `to_reference` method implemented in one of the objects !" + end + + unless @project.kind_of?(Project) + raise StandardError, 'No valid project passed in context!' + end + end + + def call + @objects.each do |object| + pattern = /#{Regexp.escape(object.to_reference)}/ + replace_text_nodes_matching(pattern) do |content| + content.gsub(pattern, object.to_reference(@project)) + end + end + + doc + end + + private + + def validate + needs :project + needs :objects + end + end + end +end diff --git a/lib/banzai/pipeline/reference_unfold_pipeline.rb b/lib/banzai/pipeline/reference_unfold_pipeline.rb new file mode 100644 index 0000000000..5b555d7c2d --- /dev/null +++ b/lib/banzai/pipeline/reference_unfold_pipeline.rb @@ -0,0 +1,9 @@ +module Banzai + module Pipeline + class ReferenceUnfoldPipeline < BasePipeline + def self.filters + [Filter::ReferenceUnfoldFilter] + end + end + end +end diff --git a/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb b/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb new file mode 100644 index 0000000000..af9d6f4ad0 --- /dev/null +++ b/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb @@ -0,0 +1,59 @@ +require 'spec_helper' + +describe Banzai::Pipeline::ReferenceUnfoldPipeline do + let(:text) { 'some text' } + let(:project) { create(:project) } + let(:objects) { [] } + + let(:result) do + described_class.to_html(text, project: project, objects: objects) + end + + context 'invalid initializers' do + subject { -> { result } } + + context 'project context is invalid' do + let(:project) { nil } + it { is_expected.to raise_error StandardError, /No valid project/ } + end + + context 'objects context is invalid' do + let(:objects) { ['issue'] } + it { is_expected.to raise_error StandardError, /No `to_reference` method/ } + end + end + + context 'multiple issues and merge requests referenced' do + subject { result } + + let(:main_project) { create(:project) } + + let(:issue_first) { create(:issue, project: main_project) } + let(:issue_second) { create(:issue, project: main_project) } + let(:merge_request) { create(:merge_request, source_project: main_project) } + + let(:objects) { [issue_first, issue_second, merge_request] } + + context 'plain text description' do + let(:text) { 'Description that references #1, #2 and !1' } + + it { is_expected.to include issue_first.to_reference(project) } + it { is_expected.to include issue_second.to_reference(project) } + it { is_expected.to include merge_request.to_reference(project) } + end + + context 'description with ignored elements' do + let(:text) do + <<-EOF + Hi. This references #1, but not `#2` +
    and not !1
    + EOF + end + + + it { is_expected.to include issue_first.to_reference(project) } + it { is_expected.to_not include issue_second.to_reference(project) } + it { is_expected.to_not include merge_request.to_reference(project) } + end + end +end diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index cd05117561..d516bd4830 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -11,7 +11,7 @@ describe Issues::MoveService, services: true do let(:old_issue) do create(:issue, title: title, description: description, - project: old_project, author: author) + project: old_project, author: author) end let(:move_service) do @@ -133,7 +133,7 @@ describe Issues::MoveService, services: true do before do create(:merge_request, source_project: old_project) create(:note, noteable: old_issue, project: old_project, author: author, - note: 'Note with reference to merge request !1') + note: 'Note with reference to merge request !1') end include_context 'issue move executed' From 9124ebce982981b74ae644793a680b2b6060ce21 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 29 Feb 2016 09:49:22 +0100 Subject: [PATCH 197/797] Use internal reference extractor in banzai unfold pipeline --- app/services/issues/move_service.rb | 26 +++------ lib/banzai/filter/reference_unfold_filter.rb | 34 +++++------ .../pipeline/reference_unfold_pipeline.rb | 14 ++++- .../reference_unfold_pipeline_spec.rb | 57 ++++++++++--------- 4 files changed, 66 insertions(+), 65 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index cf935f30ce..a9448af469 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -72,30 +72,22 @@ module Issues end def add_moved_from_note - SystemNoteService.noteable_moved(:from, @issue_new, @project_new, @issue_old, @current_user) + SystemNoteService.noteable_moved(:from, @issue_new, @project_new, + @issue_old, @current_user) end def add_moved_to_note - SystemNoteService.noteable_moved(:to, @issue_old, @project_old, @issue_new, @current_user) + SystemNoteService.noteable_moved(:to, @issue_old, @project_old, + @issue_new, @current_user) end def rewrite_references(noteable) - references = noteable.all_references - new_content = noteable_content(noteable).dup - cross_project_mentionables = [:issues, :merge_requests, :milestones, - :snippets, :commits, :commit_ranges] + content = noteable_content(noteable).dup + context = { pipeline: :reference_unfold, + project: @project_old, new_project: @project_new } - cross_project_mentionables.each do |type| - referables = references.public_send(type) - - context = { objects: referables, project: @project_new, - pipeline: :reference_unfold } - - new_content = Banzai.render_result(new_content, context) - new_content = new_content[:output].to_s - end - - new_content + new_content = Banzai.render_result(content, context) + new_content[:output].to_s end def noteable_content(noteable) diff --git a/lib/banzai/filter/reference_unfold_filter.rb b/lib/banzai/filter/reference_unfold_filter.rb index a614526165..d8ed44a0e4 100644 --- a/lib/banzai/filter/reference_unfold_filter.rb +++ b/lib/banzai/filter/reference_unfold_filter.rb @@ -1,45 +1,39 @@ +require 'html/pipeline/filter' + module Banzai module Filter ## # Filter than unfolds local references. # - # Replaces all local references with project cross reference version - # in all objects passed to this filter in context. # - # Requires objects array with each element implementing `Referable`. - # - class ReferenceUnfoldFilter < ReferenceFilter + class ReferenceUnfoldFilter < HTML::Pipeline::Filter def initialize(*) super - @objects = context[:objects] - @project = context[:project] - - unless @objects.all? { |object| object.respond_to?(:to_reference) } - raise StandardError, "No `to_reference` method implemented in one of the objects !" + unless result[:references].is_a?(Hash) + raise StandardError, 'References not processed!' end - unless @project.kind_of?(Project) - raise StandardError, 'No valid project passed in context!' - end + @text = context[:text].dup + @new_project = context[:new_project] + @referables = result[:references].values.flatten end def call - @objects.each do |object| - pattern = /#{Regexp.escape(object.to_reference)}/ - replace_text_nodes_matching(pattern) do |content| - content.gsub(pattern, object.to_reference(@project)) - end + @referables.each do |referable| + pattern = /#{Regexp.escape(referable.to_reference)}/ + @text.gsub!(pattern, referable.to_reference(@new_project)) end - doc + @text end private def validate needs :project - needs :objects + needs :new_project + needs :text end end end diff --git a/lib/banzai/pipeline/reference_unfold_pipeline.rb b/lib/banzai/pipeline/reference_unfold_pipeline.rb index 5b555d7c2d..597bf7befd 100644 --- a/lib/banzai/pipeline/reference_unfold_pipeline.rb +++ b/lib/banzai/pipeline/reference_unfold_pipeline.rb @@ -2,7 +2,19 @@ module Banzai module Pipeline class ReferenceUnfoldPipeline < BasePipeline def self.filters - [Filter::ReferenceUnfoldFilter] + FullPipeline.filters + + [Filter::ReferenceGathererFilter, + Filter::ReferenceUnfoldFilter] + end + + def self.call(text, context = {}) + context = context.merge(text: text) + super + end + + class << self + alias_method :to_document, :call + alias_method :to_html, :call end end end diff --git a/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb b/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb index af9d6f4ad0..c02dc5a2c2 100644 --- a/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb @@ -2,58 +2,61 @@ require 'spec_helper' describe Banzai::Pipeline::ReferenceUnfoldPipeline do let(:text) { 'some text' } - let(:project) { create(:project) } - let(:objects) { [] } + let(:old_project) { create(:project) } + let(:new_project) { create(:project) } + let(:pipeline_context) do + { project: old_project, new_project: new_project } + end let(:result) do - described_class.to_html(text, project: project, objects: objects) + described_class.to_document(text, pipeline_context) end context 'invalid initializers' do subject { -> { result } } - context 'project context is invalid' do - let(:project) { nil } - it { is_expected.to raise_error StandardError, /No valid project/ } + context 'project context is missing' do + let(:pipeline_context) { { new_project: new_project } } + it { is_expected.to raise_error ArgumentError, /Missing context keys/ } end - context 'objects context is invalid' do - let(:objects) { ['issue'] } - it { is_expected.to raise_error StandardError, /No `to_reference` method/ } + context 'new project context is missing' do + let(:pipeline_context) { { project: old_project } } + it { is_expected.to raise_error ArgumentError, /Missing context keys/ } end end context 'multiple issues and merge requests referenced' do - subject { result } + subject { result[:output] } - let(:main_project) { create(:project) } - - let(:issue_first) { create(:issue, project: main_project) } - let(:issue_second) { create(:issue, project: main_project) } - let(:merge_request) { create(:merge_request, source_project: main_project) } - - let(:objects) { [issue_first, issue_second, merge_request] } + let!(:issue_first) { create(:issue, project: old_project) } + let!(:issue_second) { create(:issue, project: old_project) } + let!(:merge_request) { create(:merge_request, source_project: old_project) } context 'plain text description' do let(:text) { 'Description that references #1, #2 and !1' } - it { is_expected.to include issue_first.to_reference(project) } - it { is_expected.to include issue_second.to_reference(project) } - it { is_expected.to include merge_request.to_reference(project) } + it { is_expected.to include issue_first.to_reference(new_project) } + it { is_expected.to include issue_second.to_reference(new_project) } + it { is_expected.to include merge_request.to_reference(new_project) } end context 'description with ignored elements' do let(:text) do - <<-EOF - Hi. This references #1, but not `#2` -
    and not !1
    - EOF + "Hi. This references #1, but not `#2`\n" + + '
    and not !1
    ' end + it { is_expected.to include issue_first.to_reference(new_project) } + it { is_expected.to_not include issue_second.to_reference(new_project) } + it { is_expected.to_not include merge_request.to_reference(new_project) } + end - it { is_expected.to include issue_first.to_reference(project) } - it { is_expected.to_not include issue_second.to_reference(project) } - it { is_expected.to_not include merge_request.to_reference(project) } + context 'description ambigous elements' do + let(:url) { 'http://gitlab.com/#1' } + let(:text) { "This references #1, but not #{url}" } + + it { is_expected.to include url } end end end From cd0f19450843b5b9245ae82302d3f9d9003cb899 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 1 Mar 2016 14:18:51 +0100 Subject: [PATCH 198/797] Do not unfold non-referables when moving an issue --- lib/banzai/filter/reference_unfold_filter.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/banzai/filter/reference_unfold_filter.rb b/lib/banzai/filter/reference_unfold_filter.rb index d8ed44a0e4..70a485ba2a 100644 --- a/lib/banzai/filter/reference_unfold_filter.rb +++ b/lib/banzai/filter/reference_unfold_filter.rb @@ -21,6 +21,8 @@ module Banzai def call @referables.each do |referable| + next unless referable.respond_to?(:to_reference) + pattern = /#{Regexp.escape(referable.to_reference)}/ @text.gsub!(pattern, referable.to_reference(@new_project)) end From fd8394faae25b54c4d9ac485a0ce746cffec3a0f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 7 Mar 2016 13:09:53 +0100 Subject: [PATCH 199/797] Move reference unfolder for GFM to separate class --- app/services/issues/move_service.rb | 7 +-- lib/banzai/filter/reference_unfold_filter.rb | 42 ------------- .../pipeline/reference_unfold_pipeline.rb | 21 ------- lib/gitlab/gfm/reference_unfolder.rb | 31 ++++++++++ lib/gitlab/reference_extractor.rb | 8 ++- .../reference_unfold_pipeline_spec.rb | 62 ------------------- .../lib/gitlab/gfm/reference_unfolder_spec.rb | 43 +++++++++++++ spec/lib/gitlab/reference_extractor_spec.rb | 12 ++++ 8 files changed, 95 insertions(+), 131 deletions(-) delete mode 100644 lib/banzai/filter/reference_unfold_filter.rb delete mode 100644 lib/banzai/pipeline/reference_unfold_pipeline.rb create mode 100644 lib/gitlab/gfm/reference_unfolder.rb delete mode 100644 spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb create mode 100644 spec/lib/gitlab/gfm/reference_unfolder_spec.rb diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index a9448af469..de12ea5c17 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -83,11 +83,8 @@ module Issues def rewrite_references(noteable) content = noteable_content(noteable).dup - context = { pipeline: :reference_unfold, - project: @project_old, new_project: @project_new } - - new_content = Banzai.render_result(content, context) - new_content[:output].to_s + unfolder = Gitlab::Gfm::ReferenceUnfolder.new(content, @project_old) + unfolder.unfold(@project_new) end def noteable_content(noteable) diff --git a/lib/banzai/filter/reference_unfold_filter.rb b/lib/banzai/filter/reference_unfold_filter.rb deleted file mode 100644 index 70a485ba2a..0000000000 --- a/lib/banzai/filter/reference_unfold_filter.rb +++ /dev/null @@ -1,42 +0,0 @@ -require 'html/pipeline/filter' - -module Banzai - module Filter - ## - # Filter than unfolds local references. - # - # - class ReferenceUnfoldFilter < HTML::Pipeline::Filter - def initialize(*) - super - - unless result[:references].is_a?(Hash) - raise StandardError, 'References not processed!' - end - - @text = context[:text].dup - @new_project = context[:new_project] - @referables = result[:references].values.flatten - end - - def call - @referables.each do |referable| - next unless referable.respond_to?(:to_reference) - - pattern = /#{Regexp.escape(referable.to_reference)}/ - @text.gsub!(pattern, referable.to_reference(@new_project)) - end - - @text - end - - private - - def validate - needs :project - needs :new_project - needs :text - end - end - end -end diff --git a/lib/banzai/pipeline/reference_unfold_pipeline.rb b/lib/banzai/pipeline/reference_unfold_pipeline.rb deleted file mode 100644 index 597bf7befd..0000000000 --- a/lib/banzai/pipeline/reference_unfold_pipeline.rb +++ /dev/null @@ -1,21 +0,0 @@ -module Banzai - module Pipeline - class ReferenceUnfoldPipeline < BasePipeline - def self.filters - FullPipeline.filters + - [Filter::ReferenceGathererFilter, - Filter::ReferenceUnfoldFilter] - end - - def self.call(text, context = {}) - context = context.merge(text: text) - super - end - - class << self - alias_method :to_document, :call - alias_method :to_html, :call - end - end - end -end diff --git a/lib/gitlab/gfm/reference_unfolder.rb b/lib/gitlab/gfm/reference_unfolder.rb new file mode 100644 index 0000000000..4969e8ebe8 --- /dev/null +++ b/lib/gitlab/gfm/reference_unfolder.rb @@ -0,0 +1,31 @@ +module Gitlab + module Gfm + ## + # Class than unfolds local references in text. + # + # + class ReferenceUnfolder + def initialize(text, project) + @text = text + @project = project + end + + def unfold(from_project) + referables.each_with_object(@text.dup) do |referable, text| + next unless referable.respond_to?(:to_reference) + + pattern = /#{Regexp.escape(referable.to_reference)}/ + text.gsub!(pattern, referable.to_reference(from_project)) + end + end + + private + + def referables + extractor = Gitlab::ReferenceExtractor.new(@project) + extractor.analyze(@text) + extractor.all + end + end + end +end diff --git a/lib/gitlab/reference_extractor.rb b/lib/gitlab/reference_extractor.rb index 4d830aa45e..8c698d43be 100644 --- a/lib/gitlab/reference_extractor.rb +++ b/lib/gitlab/reference_extractor.rb @@ -1,6 +1,7 @@ module Gitlab # Extract possible GFM references from an arbitrary String for further processing. class ReferenceExtractor < Banzai::ReferenceExtractor + REFERABLES = %i(user issue label milestone merge_request snippet commit commit_range) attr_accessor :project, :current_user, :author def initialize(project, current_user = nil, author = nil) @@ -17,7 +18,7 @@ module Gitlab super(text, context.merge(project: project)) end - %i(user label milestone merge_request snippet commit commit_range).each do |type| + REFERABLES.each do |type| define_method("#{type}s") do @references[type] ||= references(type, reference_context) end @@ -31,6 +32,11 @@ module Gitlab end end + def all + REFERABLES.each { |referable| send(referable.to_s.pluralize) } + @references.values.flatten + end + private def reference_context diff --git a/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb b/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb deleted file mode 100644 index c02dc5a2c2..0000000000 --- a/spec/lib/banzai/pipeline/reference_unfold_pipeline_spec.rb +++ /dev/null @@ -1,62 +0,0 @@ -require 'spec_helper' - -describe Banzai::Pipeline::ReferenceUnfoldPipeline do - let(:text) { 'some text' } - let(:old_project) { create(:project) } - let(:new_project) { create(:project) } - let(:pipeline_context) do - { project: old_project, new_project: new_project } - end - - let(:result) do - described_class.to_document(text, pipeline_context) - end - - context 'invalid initializers' do - subject { -> { result } } - - context 'project context is missing' do - let(:pipeline_context) { { new_project: new_project } } - it { is_expected.to raise_error ArgumentError, /Missing context keys/ } - end - - context 'new project context is missing' do - let(:pipeline_context) { { project: old_project } } - it { is_expected.to raise_error ArgumentError, /Missing context keys/ } - end - end - - context 'multiple issues and merge requests referenced' do - subject { result[:output] } - - let!(:issue_first) { create(:issue, project: old_project) } - let!(:issue_second) { create(:issue, project: old_project) } - let!(:merge_request) { create(:merge_request, source_project: old_project) } - - context 'plain text description' do - let(:text) { 'Description that references #1, #2 and !1' } - - it { is_expected.to include issue_first.to_reference(new_project) } - it { is_expected.to include issue_second.to_reference(new_project) } - it { is_expected.to include merge_request.to_reference(new_project) } - end - - context 'description with ignored elements' do - let(:text) do - "Hi. This references #1, but not `#2`\n" + - '
    and not !1
    ' - end - - it { is_expected.to include issue_first.to_reference(new_project) } - it { is_expected.to_not include issue_second.to_reference(new_project) } - it { is_expected.to_not include merge_request.to_reference(new_project) } - end - - context 'description ambigous elements' do - let(:url) { 'http://gitlab.com/#1' } - let(:text) { "This references #1, but not #{url}" } - - it { is_expected.to include url } - end - end -end diff --git a/spec/lib/gitlab/gfm/reference_unfolder_spec.rb b/spec/lib/gitlab/gfm/reference_unfolder_spec.rb new file mode 100644 index 0000000000..4f8b960350 --- /dev/null +++ b/spec/lib/gitlab/gfm/reference_unfolder_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +describe Gitlab::Gfm::ReferenceUnfolder do + let(:text) { 'some text' } + let(:old_project) { create(:project) } + let(:new_project) { create(:project) } + + describe '#unfold' do + subject { described_class.new(text, old_project).unfold(new_project) } + + context 'multiple issues and merge requests referenced' do + let!(:issue_first) { create(:issue, project: old_project) } + let!(:issue_second) { create(:issue, project: old_project) } + let!(:merge_request) { create(:merge_request, source_project: old_project) } + + context 'plain text description' do + let(:text) { 'Description that references #1, #2 and !1' } + + it { is_expected.to include issue_first.to_reference(new_project) } + it { is_expected.to include issue_second.to_reference(new_project) } + it { is_expected.to include merge_request.to_reference(new_project) } + end + + context 'description with ignored elements' do + let(:text) do + "Hi. This references #1, but not `#2`\n" + + '
    and not !1
    ' + end + + it { is_expected.to include issue_first.to_reference(new_project) } + it { is_expected.to_not include issue_second.to_reference(new_project) } + it { is_expected.to_not include merge_request.to_reference(new_project) } + end + + context 'description ambigous elements' do + let(:url) { 'http://gitlab.com/#1' } + let(:text) { "This references #1, but not #{url}" } + + it { is_expected.to include url } + end + end + end +end diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index 7d963795e1..78ab162e5d 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -122,4 +122,16 @@ describe Gitlab::ReferenceExtractor, lib: true do expect(extracted).to match_array([issue]) end end + + describe '#all' do + let(:issue) { create(:issue, project: project) } + let(:label) { create(:label, project: project) } + let(:text) { "Ref. #{issue.to_reference} and #{label.to_reference}" } + + before { subject.analyze(text) } + + it 'returns all referables' do + expect(subject.all).to match_array([issue, label]) + end + end end From 4354bfaba55c9238d5245fe2f5823665790c9817 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 11 Mar 2016 11:49:04 +0100 Subject: [PATCH 200/797] Add implementation of reference unfolder using banzai --- lib/banzai/filter/reference_filter.rb | 1 + lib/gitlab/gfm/reference_unfolder.rb | 53 +++++++++++++++++-- .../lib/gitlab/gfm/reference_unfolder_spec.rb | 32 +++++++++-- 3 files changed, 78 insertions(+), 8 deletions(-) diff --git a/lib/banzai/filter/reference_filter.rb b/lib/banzai/filter/reference_filter.rb index 3637b1bac9..132f0a4bd9 100644 --- a/lib/banzai/filter/reference_filter.rb +++ b/lib/banzai/filter/reference_filter.rb @@ -47,6 +47,7 @@ module Banzai # Returns a String def data_attribute(attributes = {}) attributes[:reference_filter] = self.class.name.demodulize + attributes.delete(:original) if context[:no_original_data] attributes.map { |key, value| %Q(data-#{key.to_s.dasherize}="#{escape_once(value)}") }.join(" ") end diff --git a/lib/gitlab/gfm/reference_unfolder.rb b/lib/gitlab/gfm/reference_unfolder.rb index 4969e8ebe8..57871c36eb 100644 --- a/lib/gitlab/gfm/reference_unfolder.rb +++ b/lib/gitlab/gfm/reference_unfolder.rb @@ -8,23 +8,66 @@ module Gitlab def initialize(text, project) @text = text @project = project + @original = markdown(text) end def unfold(from_project) - referables.each_with_object(@text.dup) do |referable, text| - next unless referable.respond_to?(:to_reference) + return @text unless @text =~ references_pattern - pattern = /#{Regexp.escape(referable.to_reference)}/ - text.gsub!(pattern, referable.to_reference(from_project)) + unfolded = @text.gsub(references_pattern) do |reference| + unfold_reference(reference, Regexp.last_match, from_project) end + + unless substitution_valid?(unfolded) + raise StandardError, 'Invalid references unfolding!' + end + + unfolded end private + def unfold_reference(reference, match, from_project) + before = @text[0...match.begin(0)] + after = @text[match.end(0)...@text.length] + referable = find_referable(reference) + + return reference unless referable + cross_reference = referable.to_reference(from_project) + new_text = before + cross_reference + after + + substitution_valid?(new_text) ? cross_reference : reference + end + + def references_pattern + return @pattern if @pattern + + patterns = Gitlab::ReferenceExtractor::REFERABLES.map do |ref| + ref.to_s.classify.constantize.try(:reference_pattern) + end + + @pattern = Regexp.union(patterns.compact) + end + def referables + return @referables if @referables + extractor = Gitlab::ReferenceExtractor.new(@project) extractor.analyze(@text) - extractor.all + @referables = extractor.all + end + + def find_referable(reference) + referables.find { |ref| ref.to_reference == reference } + end + + def substitution_valid?(substituted) + @original == markdown(substituted) + end + + def markdown(text) + helper = Class.new.extend(GitlabMarkdownHelper) + helper.markdown(text, project: @project, no_original_data: true) end end end diff --git a/spec/lib/gitlab/gfm/reference_unfolder_spec.rb b/spec/lib/gitlab/gfm/reference_unfolder_spec.rb index 4f8b960350..40cdb7e145 100644 --- a/spec/lib/gitlab/gfm/reference_unfolder_spec.rb +++ b/spec/lib/gitlab/gfm/reference_unfolder_spec.rb @@ -33,10 +33,36 @@ describe Gitlab::Gfm::ReferenceUnfolder do end context 'description ambigous elements' do - let(:url) { 'http://gitlab.com/#1' } - let(:text) { "This references #1, but not #{url}" } + context 'url' do + let(:url) { 'http://gitlab.com/#1' } + let(:text) { "This references #1, but not #{url}" } - it { is_expected.to include url } + it { is_expected.to include url } + end + + context 'code' do + let(:text) { "#1, but not `[#1]`" } + it { is_expected.to eq "#{issue_first.to_reference(new_project)}, but not `[#1]`" } + end + + context 'code reverse' do + let(:text) { "not `#1`, but #1" } + it { is_expected.to eq "not `#1`, but #{issue_first.to_reference(new_project)}" } + end + + context 'code in random order' do + let(:text) { "#1, `#1`, #1, `#1`" } + let(:ref) { issue_first.to_reference(new_project) } + + it { is_expected.to eq "#{ref}, `#1`, #{ref}, `#1`" } + end + end + + context 'reference contains milestone' do + let(:milestone) { create(:milestone) } + let(:text) { "milestone ref: #{milestone.to_reference}" } + + it { is_expected.to eq text } end end end From 310b417b0f2336e5e7a9b44da2fabf5d4abb0cb4 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 14 Mar 2016 08:25:37 +0100 Subject: [PATCH 201/797] Preserve original author when moving issue This also wrapps entire process into transation, as rewriting references may have large memory footprint. --- app/services/issues/move_service.rb | 38 +++++++++++------------ spec/services/issues/move_service_spec.rb | 8 ++--- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index de12ea5c17..d1bded5c99 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -4,7 +4,7 @@ module Issues super(project, current_user, params) @issue_old = issue - @issue_new = nil + @issue_new = issue.dup @project_old = @project @project_new = Project.find(new_project_id) if new_project_id end @@ -12,16 +12,18 @@ module Issues def execute return unless move? - # New issue tasks - # - open_new_issue - rewrite_notes - add_moved_from_note + ActiveRecord::Base.transaction do + # New issue tasks + # + open_new_issue + rewrite_notes + add_moved_from_note - # Old issue tasks - # - add_moved_to_note - close_old_issue + # Old issue tasks + # + add_moved_to_note + close_old_issue + end # Notifications # @@ -42,16 +44,12 @@ module Issues end def open_new_issue - create_service = CreateService.new(@project_new, current_user, new_issue_params) - @issue_new = create_service.execute - end - - def new_issue_params - new_params = { id: nil, iid: nil, milestone_id: nil, label_ids: [], - project_id: @project_new.id, - description: rewrite_references(@issue_old) } - - params.merge(new_params) + @issue_new.iid = nil + @issue_new.project = @project_new + @issue_new.labels = [] + @issue_new.milestone = nil + @issue_new.description = rewrite_references(@issue_old) + @issue_new.save! end def rewrite_notes diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index d516bd4830..4fcfceaddd 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -74,13 +74,13 @@ describe Issues::MoveService, services: true do expect(new_issue.persisted?).to be true end - it 'persist all changes' do + it 'persists all changes' do expect(old_issue.changed?).to be false - expect(new_issue.changed?).to be false + expect(new_issue.persisted?).to be true end - it 'changes author' do - expect(new_issue.author).to eq user + it 'preserves author' do + expect(new_issue.author).to eq author end it 'removes data that is invalid in new context' do From a91101b19a44d98005dd21b06d8509abcd82ddfc Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 14 Mar 2016 08:30:37 +0100 Subject: [PATCH 202/797] Make it possible to move issue if user is a reporter Discussed it here: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/2831#note_4190228 --- app/models/ability.rb | 1 - app/services/issues/move_service.rb | 4 ++-- app/views/shared/issuable/_form.html.haml | 2 +- spec/services/issues/move_service_spec.rb | 20 ++++++++++---------- 4 files changed, 13 insertions(+), 14 deletions(-) diff --git a/app/models/ability.rb b/app/models/ability.rb index 5dff01a4e5..ae1cf0c0f6 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -223,7 +223,6 @@ class Ability :admin_project, :admin_commit_status, :admin_build, - :move_issue ] end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index d1bded5c99..d9f4692d37 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -39,8 +39,8 @@ module Issues private def can_move? - can?(@current_user, :move_issue, @project_old) && - can?(@current_user, :move_issue, @project_new) + can?(@current_user, :admin_issue, @project_old) && + can?(@current_user, :admin_issue, @project_new) end def open_new_issue diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index bb0252ba3d..5b3c0ee273 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -67,7 +67,7 @@ - if can? current_user, :admin_label, issuable.project = link_to 'Create new label', new_namespace_project_label_path(issuable.project.namespace, issuable.project), target: :blank -- if issuable.is_a?(Issue) && can?(current_user, :move_issue, issuable.project) +- if issuable.is_a?(Issue) && can?(current_user, :admin_issue, issuable.project) %hr .form-group = f.label :move_to_project_id, 'Move', class: 'control-label' diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 4fcfceaddd..38fa5707cf 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -24,8 +24,8 @@ describe Issues::MoveService, services: true do shared_context 'user can move issue' do before do - old_project.team << [user, :master] - new_project.team << [user, :master] + old_project.team << [user, :reporter] + new_project.team << [user, :reporter] end end @@ -186,25 +186,25 @@ describe Issues::MoveService, services: true do describe '#move?' do subject { move_service.move? } - context 'user is master in both projects' do + context 'user is reporter in both projects' do include_context 'user can move issue' it { is_expected.to be_truthy } end - context 'user is master only in new project' do - before { new_project.team << [user, :master] } + context 'user is reporter only in new project' do + before { new_project.team << [user, :reporter] } it { is_expected.to be_falsey } end - context 'user is master only in old project' do - before { old_project.team << [user, :master] } + context 'user is reporter only in old project' do + before { old_project.team << [user, :reporter] } it { is_expected.to be_falsey } end - context 'user is master in one project and developer in another' do + context 'user is reporter in one project and guest in another' do before do - new_project.team << [user, :developer] - old_project.team << [user, :master] + new_project.team << [user, :guest] + old_project.team << [user, :reporter] end it { is_expected.to be_falsey } From bcd5806960f3f74e27bc8e7c8a6fab3a044c181a Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 14 Mar 2016 11:25:04 +0100 Subject: [PATCH 203/797] Add access-level filter support for projects select This also refactores ProjectSelect adding some decorator-like functions. --- .../javascripts/project_select.js.coffee | 56 +++++++++++++------ app/models/user.rb | 2 +- app/services/issues/move_service.rb | 4 +- app/views/shared/issuable/_form.html.haml | 3 +- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/app/assets/javascripts/project_select.js.coffee b/app/assets/javascripts/project_select.js.coffee index 5b6a71714b..c794d7ef39 100644 --- a/app/assets/javascripts/project_select.js.coffee +++ b/app/assets/javascripts/project_select.js.coffee @@ -1,10 +1,11 @@ class @ProjectSelect constructor: -> - $('.ajax-project-select').each (i, select) -> + $('.ajax-project-select').each (i, select) => @groupId = $(select).data('group-id') @includeGroups = $(select).data('include-groups') @orderBy = $(select).data('order-by') || 'id' @selectId = $(select).data('select-id') || 'web_url' + @accessLevel = $(select).data('access-level') placeholder = "Search for project" placeholder += " or group" if @includeGroups @@ -12,25 +13,11 @@ class @ProjectSelect $(select).select2 placeholder: placeholder minimumInputLength: 0 - query: (query) => - finalCallback = (projects) -> - data = { results: projects } - query.callback(data) - - if @includeGroups - projectsCallback = (projects) -> - groupsCallback = (groups) -> - data = groups.concat(projects) - finalCallback(data) - - Api.groups query.term, false, groupsCallback - else - projectsCallback = finalCallback - + query: (options) => if @groupId - Api.groupProjects @groupId, query.term, projectsCallback + Api.groupProjects @groupId, options.term, @createCallback(options) else - Api.projects query.term, @orderBy, projectsCallback + Api.projects options.term, @orderBy, @createCallback(options) id: (project) => project[@selectId] @@ -39,3 +26,36 @@ class @ProjectSelect project.name_with_namespace || project.name dropdownCssClass: "ajax-project-dropdown" + + createCallback: (options) => + finalCallback = (projects) -> + options.callback({ results: projects }) + + @accessLevelCallbackDecorator( + @groupsCallbackDecorator( + finalCallback + ) + ) + + groupsCallbackDecorator: (callback) => + return callback unless @includeGroups + + (projects) => + Api.groups options.term, false, (groups) => + data = groups.concat(projects) + callback(data) + + accessLevelCallbackDecorator: (callback) => + return callback unless @accessLevel + + ## + # Requires ECMAScript >= 5 + # + (projects) => + data = projects.filter (i) => + max = Math.max(i.permissions.group_access?.access_level ? 0, + i.permissions.project_access?.access_level ? 0) + + max >= @accessLevel + + callback(data) diff --git a/app/models/user.rb b/app/models/user.rb index 68b242888a..5ba72dc0ae 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -429,7 +429,7 @@ class User < ActiveRecord::Base Group.where("namespaces.id IN (#{union.to_sql})") end - # Returns the groups a user is authorized to access. + # Returns projects user is authorized to access. def authorized_projects Project.where("projects.id IN (#{projects_union.to_sql})") end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index d9f4692d37..25f970f080 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -15,7 +15,7 @@ module Issues ActiveRecord::Base.transaction do # New issue tasks # - open_new_issue + create_new_issue rewrite_notes add_moved_from_note @@ -43,7 +43,7 @@ module Issues can?(@current_user, :admin_issue, @project_new) end - def open_new_issue + def create_new_issue @issue_new.iid = nil @issue_new.project = @project_new @issue_new.labels = [] diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 5b3c0ee273..3d64738095 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -73,7 +73,8 @@ = f.label :move_to_project_id, 'Move', class: 'control-label' .col-sm-10 = project_select_tag("#{issuable.class.model_name.param_key}[move_to_project_id]", - placeholder: 'Select project', class: 'custom-form-control', data: { 'select-id' => 'id' }) + placeholder: 'Select project', class: 'custom-form-control', + data: { 'select-id' => 'id', 'access-level' => Gitlab::Access::REPORTER }) - if issuable.is_a?(MergeRequest) %hr From 58df72fac1252e09ca6043ac8d78548882c062be Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 14 Mar 2016 11:51:26 +0100 Subject: [PATCH 204/797] Make it possible to exclude project in projects select --- .../javascripts/project_select.js.coffee | 27 ++++++++++++++----- app/views/shared/issuable/_form.html.haml | 6 ++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/app/assets/javascripts/project_select.js.coffee b/app/assets/javascripts/project_select.js.coffee index c794d7ef39..4df61eb6ed 100644 --- a/app/assets/javascripts/project_select.js.coffee +++ b/app/assets/javascripts/project_select.js.coffee @@ -6,6 +6,7 @@ class @ProjectSelect @orderBy = $(select).data('order-by') || 'id' @selectId = $(select).data('select-id') || 'web_url' @accessLevel = $(select).data('access-level') + @withoutId = $(select).data('without-id') placeholder = "Search for project" placeholder += " or group" if @includeGroups @@ -31,9 +32,11 @@ class @ProjectSelect finalCallback = (projects) -> options.callback({ results: projects }) - @accessLevelCallbackDecorator( - @groupsCallbackDecorator( - finalCallback + @withoutIdCallbackDecorator( + @accessLevelCallbackDecorator( + @groupsCallbackDecorator( + finalCallback + ) ) ) @@ -52,10 +55,22 @@ class @ProjectSelect # Requires ECMAScript >= 5 # (projects) => - data = projects.filter (i) => - max = Math.max(i.permissions.group_access?.access_level ? 0, - i.permissions.project_access?.access_level ? 0) + data = projects.filter (p) => + max = Math.max(p.permissions.group_access?.access_level ? 0, + p.permissions.project_access?.access_level ? 0) max >= @accessLevel callback(data) + + withoutIdCallbackDecorator: (callback) => + return callback unless @withoutId + + ## + # Requires ECMAScript >= 5 + # + (projects) => + data = projects.filter (p) => + p.id != @withoutId + + callback(data) diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 3d64738095..f9160a4609 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -72,9 +72,9 @@ .form-group = f.label :move_to_project_id, 'Move', class: 'control-label' .col-sm-10 - = project_select_tag("#{issuable.class.model_name.param_key}[move_to_project_id]", - placeholder: 'Select project', class: 'custom-form-control', - data: { 'select-id' => 'id', 'access-level' => Gitlab::Access::REPORTER }) + = project_select_tag(:move_to_project_id, placeholder: 'Select project', + class: 'custom-form-control', data: { 'select-id' => 'id', + 'access-level' => Gitlab::Access::REPORTER, 'without-id' => issuable.project.id }) - if issuable.is_a?(MergeRequest) %hr From 93812f2594ab871d613e29ff33ef4eefe298aeaa Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Mon, 14 Mar 2016 13:24:56 +0100 Subject: [PATCH 205/797] Add initial notifications and hooks for issue move --- .../javascripts/project_select.js.coffee | 6 --- app/services/issues/move_service.rb | 37 +++++++++++++++---- app/services/notification_service.rb | 8 ++++ 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/app/assets/javascripts/project_select.js.coffee b/app/assets/javascripts/project_select.js.coffee index 4df61eb6ed..9738a66ecb 100644 --- a/app/assets/javascripts/project_select.js.coffee +++ b/app/assets/javascripts/project_select.js.coffee @@ -51,9 +51,6 @@ class @ProjectSelect accessLevelCallbackDecorator: (callback) => return callback unless @accessLevel - ## - # Requires ECMAScript >= 5 - # (projects) => data = projects.filter (p) => max = Math.max(p.permissions.group_access?.access_level ? 0, @@ -66,9 +63,6 @@ class @ProjectSelect withoutIdCallbackDecorator: (callback) => return callback unless @withoutId - ## - # Requires ECMAScript >= 5 - # (projects) => data = projects.filter (p) => p.id != @withoutId diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 25f970f080..646ff87621 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -6,7 +6,7 @@ module Issues @issue_old = issue @issue_new = issue.dup @project_old = @project - @project_new = Project.find(new_project_id) if new_project_id + @project_new = Project.find(new_project_id) end def execute @@ -25,9 +25,10 @@ module Issues close_old_issue end - # Notifications + # Notifications and hooks # - notify_participants + # notify_participants + # trigger_hooks_and_events @issue_new end @@ -44,10 +45,18 @@ module Issues end def create_new_issue - @issue_new.iid = nil @issue_new.project = @project_new + + # Reset internal ID, will be regenerated before save + # + @issue_new.iid = nil + + # Reset labels and milestones, as those are not valid in context + # of a new project + # @issue_new.labels = [] @issue_new.milestone = nil + @issue_new.description = rewrite_references(@issue_old) @issue_new.save! end @@ -66,9 +75,6 @@ module Issues @issue_old.update(state: :closed) end - def notify_participants - end - def add_moved_from_note SystemNoteService.noteable_moved(:from, @issue_new, @project_new, @issue_old, @current_user) @@ -95,5 +101,22 @@ module Issues raise 'Unexpected noteable while moving an issue' end end + + def trigger_hooks_and_events + event_service.close_issue(@issue_old, @current_user) + event_service.open_issue(@issue_new, @current_user) + + @issue_new.create_cross_references!(@current_user) + + execute_hooks(@issue_old, 'close') + execute_hooks(@issue_new, 'open') + end + + def notify_participants + todo_service.close_issue(@issue_old, @current_user) + todo_service.open_issue(@issue_new, @current_user) + + notification_service.issue_moved(@issue_old, @issue_new, @current_user) + end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 19a6779dea..d90e630ff5 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -236,6 +236,14 @@ class NotificationService end end + def issue_moved(issue, old_project, new_project, current_user) + recipients = build_recipients(issue, old_project, current_user) + + recipients.each do |recipient| + mailer.send('issue_moved', recipient.id, issue.id, current_user.id).deliver_later + end + end + protected # Get project users with WATCH notification level From a23f0e8c9ebd3a7922786d2fc4f3450c1fdecad6 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 15 Mar 2016 10:14:39 +0100 Subject: [PATCH 206/797] Reuse existing issue services when moving issue --- app/services/issues/close_service.rb | 6 +-- app/services/issues/create_service.rb | 4 +- app/services/issues/move_service.rb | 58 ++++++++--------------- app/services/notification_service.rb | 8 ---- spec/services/issues/move_service_spec.rb | 2 +- 5 files changed, 26 insertions(+), 52 deletions(-) diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 78254b49af..6866d3fcfb 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -1,6 +1,6 @@ module Issues class CloseService < Issues::BaseService - def execute(issue, commit = nil) + def execute(issue, commit = nil, notifications: true, system_note: true) if project.jira_tracker? && project.jira_service.active project.jira_service.execute(commit, issue) todo_service.close_issue(issue, current_user) @@ -9,8 +9,8 @@ module Issues if project.default_issues_tracker? && issue.close event_service.close_issue(issue, current_user) - create_note(issue, commit) - notification_service.close_issue(issue, current_user) + create_note(issue, commit) if system_note + notification_service.close_issue(issue, current_user) if notifications todo_service.close_issue(issue, current_user) execute_hooks(issue, 'close') end diff --git a/app/services/issues/create_service.rb b/app/services/issues/create_service.rb index 10787e8873..8af1a9b8d3 100644 --- a/app/services/issues/create_service.rb +++ b/app/services/issues/create_service.rb @@ -1,10 +1,10 @@ module Issues class CreateService < Issues::BaseService - def execute + def execute(set_author: true) filter_params label_params = params[:label_ids] issue = project.issues.new(params.except(:label_ids)) - issue.author = current_user + issue.author = current_user if set_author if issue.save issue.update_attributes(label_ids: label_params) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 646ff87621..e2089e22bc 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -4,14 +4,20 @@ module Issues super(project, current_user, params) @issue_old = issue - @issue_new = issue.dup + @issue_new = nil @project_old = @project - @project_new = Project.find(new_project_id) + + if new_project_id + @project_new = Project.find(new_project_id) + end end def execute return unless move? + # Using trasaction because of a high footprint on + # rewriting notes (unfolding references) + # ActiveRecord::Base.transaction do # New issue tasks # @@ -25,10 +31,7 @@ module Issues close_old_issue end - # Notifications and hooks - # - # notify_participants - # trigger_hooks_and_events + notify_participants @issue_new end @@ -45,20 +48,14 @@ module Issues end def create_new_issue - @issue_new.project = @project_new + new_params = { id: nil, iid: nil, milestone: nil, label_ids: [], + project: @project_new, author: @issue_old.author, + description: rewrite_references(@issue_old) } - # Reset internal ID, will be regenerated before save - # - @issue_new.iid = nil + create_service = CreateService.new(@project_new, @current_user, + params.merge(new_params)) - # Reset labels and milestones, as those are not valid in context - # of a new project - # - @issue_new.labels = [] - @issue_new.milestone = nil - - @issue_new.description = rewrite_references(@issue_old) - @issue_new.save! + @issue_new = create_service.execute(set_author: false) end def rewrite_notes @@ -72,7 +69,8 @@ module Issues end def close_old_issue - @issue_old.update(state: :closed) + close_service = CloseService.new(@project_new, @current_user) + close_service.execute(@issue_old, notifications: false, system_note: false) end def add_moved_from_note @@ -93,30 +91,14 @@ module Issues def noteable_content(noteable) case noteable - when Issue - noteable.description - when Note - noteable.note + when Issue then noteable.description + when Note then noteable.note else - raise 'Unexpected noteable while moving an issue' + raise 'Unexpected noteable while moving an issue!' end end - def trigger_hooks_and_events - event_service.close_issue(@issue_old, @current_user) - event_service.open_issue(@issue_new, @current_user) - - @issue_new.create_cross_references!(@current_user) - - execute_hooks(@issue_old, 'close') - execute_hooks(@issue_new, 'open') - end - def notify_participants - todo_service.close_issue(@issue_old, @current_user) - todo_service.open_issue(@issue_new, @current_user) - - notification_service.issue_moved(@issue_old, @issue_new, @current_user) end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index d90e630ff5..19a6779dea 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -236,14 +236,6 @@ class NotificationService end end - def issue_moved(issue, old_project, new_project, current_user) - recipients = build_recipients(issue, old_project, current_user) - - recipients.each do |recipient| - mailer.send('issue_moved', recipient.id, issue.id, current_user.id).deliver_later - end - end - protected # Get project users with WATCH notification level diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 38fa5707cf..69f4748ae6 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -76,7 +76,7 @@ describe Issues::MoveService, services: true do it 'persists all changes' do expect(old_issue.changed?).to be false - expect(new_issue.persisted?).to be true + expect(new_issue.changed?).to be false end it 'preserves author' do From 15d32b6a11380c257180dbc8e2691bc12e2792bc Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 15 Mar 2016 11:35:40 +0100 Subject: [PATCH 207/797] Add new notifications for issue move action [ci skip] --- app/mailers/emails/issues.rb | 8 ++++++ app/services/issues/move_service.rb | 9 +++++-- app/services/notification_service.rb | 10 ++++++++ app/views/notify/issue_moved_email.html.haml | 6 +++++ app/views/notify/issue_moved_email.text.erb | 4 +++ spec/mailers/notify_spec.rb | 27 ++++++++++++++++++++ spec/services/issues/move_service_spec.rb | 2 +- 7 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 app/views/notify/issue_moved_email.html.haml create mode 100644 app/views/notify/issue_moved_email.text.erb diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 5f9adb32e0..6f54c42146 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -36,6 +36,14 @@ module Emails mail_answer_thread(@issue, issue_thread_options(updated_by_user_id, recipient_id)) end + def issue_moved_email(recipient, issue, new_issue, updated_by_user) + setup_issue_mail(issue.id, recipient.id) + + @new_issue = new_issue + @new_project = new_issue.project + mail_answer_thread(issue, issue_thread_options(updated_by_user.id, recipient.id)) + end + private def setup_issue_mail(issue_id, recipient_id) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index e2089e22bc..4abcd20340 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -10,13 +10,17 @@ module Issues if new_project_id @project_new = Project.find(new_project_id) end + + if @project_new == @project_old + raise StandardError, 'Cannot move issue to project it originates from!' + end end def execute return unless move? - # Using trasaction because of a high footprint on - # rewriting notes (unfolding references) + # Using trasaction because of a high resources footprint + # on rewriting notes (unfolding references) # ActiveRecord::Base.transaction do # New issue tasks @@ -99,6 +103,7 @@ module Issues end def notify_participants + notification_service.issue_moved(@issue_old, @issue_new, @current_user) end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index 19a6779dea..3bdf00a829 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -236,6 +236,16 @@ class NotificationService end end + def issue_moved(issue, new_issue, current_user) + recipients = build_recipients(issue, issue.project, current_user) + + recipients.map do |recipient| + email = mailer.issue_moved_email(recipient, issue, new_issue, current_user) + email.deliver_later + email + end + end + protected # Get project users with WATCH notification level diff --git a/app/views/notify/issue_moved_email.html.haml b/app/views/notify/issue_moved_email.html.haml new file mode 100644 index 0000000000..40f7d61fe1 --- /dev/null +++ b/app/views/notify/issue_moved_email.html.haml @@ -0,0 +1,6 @@ +%p + Issue was moved to another project. +%p + New issue: + = link_to namespace_project_issue_url(@new_project.namespace, @new_project, @new_issue) do + = @new_issue.title diff --git a/app/views/notify/issue_moved_email.text.erb b/app/views/notify/issue_moved_email.text.erb new file mode 100644 index 0000000000..b3bd43c205 --- /dev/null +++ b/app/views/notify/issue_moved_email.text.erb @@ -0,0 +1,4 @@ +Issue was moved to another project. + +New issue location: +<%= namespace_project_issue_url(@new_project.namespace, @new_project, @new_issue) %> diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb index f910424d85..9b47acfe0c 100644 --- a/spec/mailers/notify_spec.rb +++ b/spec/mailers/notify_spec.rb @@ -158,6 +158,33 @@ describe Notify do is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ end end + + describe 'moved to another project' do + let(:new_issue) { create(:issue) } + subject { Notify.issue_moved_email(recipient, issue, new_issue, current_user) } + + it_behaves_like 'an answer to an existing thread', 'issue' + it_behaves_like 'it should show Gmail Actions View Issue link' + it_behaves_like 'an unsubscribeable thread' + + it 'contains description about action taken' do + is_expected.to have_body_text 'Issue was moved to another project' + end + + it 'has the correct subject' do + is_expected.to have_subject /#{issue.title} \(##{issue.iid}\)/i + end + + it 'contains link to new issue' do + new_issue_url = namespace_project_issue_path(new_issue.project.namespace, + new_issue.project, new_issue) + is_expected.to have_body_text new_issue_url + end + + it 'contains a link to the original issue' do + is_expected.to have_body_text /#{namespace_project_issue_path project.namespace, project, issue}/ + end + end end context 'for merge requests' do diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index 69f4748ae6..b71ce311af 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -55,7 +55,7 @@ describe Issues::MoveService, services: true do end it 'rewrites issue description' do - expect(new_issue.description).to include description + expect(new_issue.description).to eq description end it 'adds system note to old issue at the end' do From 414558939339bd636a3549866ce532fa25500bc5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 15 Mar 2016 14:59:38 +0100 Subject: [PATCH 208/797] Revert changes in js used to create project select This will preserve those changes in history, we may need them in future. Currently we will use a issues helpers to create options to choose project whem moving issue to it. --- .../javascripts/project_select.js.coffee | 74 ++++++------------- 1 file changed, 22 insertions(+), 52 deletions(-) diff --git a/app/assets/javascripts/project_select.js.coffee b/app/assets/javascripts/project_select.js.coffee index 9738a66ecb..be8ab9b428 100644 --- a/app/assets/javascripts/project_select.js.coffee +++ b/app/assets/javascripts/project_select.js.coffee @@ -1,12 +1,9 @@ class @ProjectSelect constructor: -> - $('.ajax-project-select').each (i, select) => + $('.ajax-project-select').each (i, select) -> @groupId = $(select).data('group-id') @includeGroups = $(select).data('include-groups') @orderBy = $(select).data('order-by') || 'id' - @selectId = $(select).data('select-id') || 'web_url' - @accessLevel = $(select).data('access-level') - @withoutId = $(select).data('without-id') placeholder = "Search for project" placeholder += " or group" if @includeGroups @@ -14,57 +11,30 @@ class @ProjectSelect $(select).select2 placeholder: placeholder minimumInputLength: 0 - query: (options) => - if @groupId - Api.groupProjects @groupId, options.term, @createCallback(options) - else - Api.projects options.term, @orderBy, @createCallback(options) + query: (query) => + finalCallback = (projects) -> + data = { results: projects } + query.callback(data) - id: (project) => - project[@selectId] + if @includeGroups + projectsCallback = (projects) -> + groupsCallback = (groups) -> + data = groups.concat(projects) + finalCallback(data) + + Api.groups query.term, false, groupsCallback + else + projectsCallback = finalCallback + + if @groupId + Api.groupProjects @groupId, query.term, projectsCallback + else + Api.projects query.term, @orderBy, projectsCallback + + id: (project) -> + project.web_url text: (project) -> project.name_with_namespace || project.name dropdownCssClass: "ajax-project-dropdown" - - createCallback: (options) => - finalCallback = (projects) -> - options.callback({ results: projects }) - - @withoutIdCallbackDecorator( - @accessLevelCallbackDecorator( - @groupsCallbackDecorator( - finalCallback - ) - ) - ) - - groupsCallbackDecorator: (callback) => - return callback unless @includeGroups - - (projects) => - Api.groups options.term, false, (groups) => - data = groups.concat(projects) - callback(data) - - accessLevelCallbackDecorator: (callback) => - return callback unless @accessLevel - - (projects) => - data = projects.filter (p) => - max = Math.max(p.permissions.group_access?.access_level ? 0, - p.permissions.project_access?.access_level ? 0) - - max >= @accessLevel - - callback(data) - - withoutIdCallbackDecorator: (callback) => - return callback unless @withoutId - - (projects) => - data = projects.filter (p) => - p.id != @withoutId - - callback(data) From 1dd279d83335de71c69d0acfdcdd7eb0ebe7f3dd Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Tue, 15 Mar 2016 15:01:26 +0100 Subject: [PATCH 209/797] Use helper to create list of projects issue can be moved to This also adds confirmation message if issue move has been requested. --- app/assets/javascripts/issuable_form.js.coffee | 11 ++++++++++- app/helpers/issues_helper.rb | 11 +++++++++++ app/views/shared/issuable/_form.html.haml | 6 +++--- spec/services/issues/move_service_spec.rb | 15 +++++++++++++-- 4 files changed, 37 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/issuable_form.js.coffee b/app/assets/javascripts/issuable_form.js.coffee index 48c249943f..9b939985b3 100644 --- a/app/assets/javascripts/issuable_form.js.coffee +++ b/app/assets/javascripts/issuable_form.js.coffee @@ -1,4 +1,6 @@ class @IssuableForm + ISSUE_MOVE_CONFIRM_MSG = 'Are you sure you want to move this issue to another project?' + constructor: (@form) -> GitLab.GfmAutoComplete.setup() new UsersSelect() @@ -6,12 +8,13 @@ class @IssuableForm @titleField = @form.find("input[name*='[title]']") @descriptionField = @form.find("textarea[name*='[description]']") + @issueMoveField = @form.find("#move_to_project_id") return unless @titleField.length && @descriptionField.length @initAutosave() - @form.on "submit", @resetAutosave + @form.on "submit", @handleSubmit @form.on "click", ".btn-cancel", @resetAutosave initAutosave: -> @@ -27,6 +30,12 @@ class @IssuableForm "description" ] + handleSubmit: (e) => + @resetAutosave + + if (parseInt(@issueMoveField?.val()) ? 0) > 0 + e.preventDefault() unless confirm(ISSUE_MOVE_CONFIRM_MSG) + resetAutosave: => @titleField.data("autosave").reset() @descriptionField.data("autosave").reset() diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index ae4ebc0854..62479b8d1c 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -57,6 +57,17 @@ module IssuesHelper options_from_collection_for_select(milestones, 'id', 'title', object.milestone_id) end + def project_options(issuable, current_user, ability: :read_project) + projects = current_user.authorized_projects + projects = projects.select do |project| + current_user.can?(ability, project) && project != issuable.project + end + + projects.unshift(OpenStruct.new(id: 0, name_with_namespace: 'No project')) + + options_from_collection_for_select(projects, :id, :name_with_namespace, 0) + end + def status_box_class(item) if item.respond_to?(:expired?) && item.expired? 'status-box-expired' diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index f9160a4609..11b8b9d44a 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -72,9 +72,9 @@ .form-group = f.label :move_to_project_id, 'Move', class: 'control-label' .col-sm-10 - = project_select_tag(:move_to_project_id, placeholder: 'Select project', - class: 'custom-form-control', data: { 'select-id' => 'id', - 'access-level' => Gitlab::Access::REPORTER, 'without-id' => issuable.project.id }) + - projects = project_options(issuable, current_user, ability: :admin_issue) + = select_tag(:move_to_project_id, projects, include_blank: true, + class: 'select2', data: { placeholder: 'Select project' }) - if issuable.is_a?(MergeRequest) %hr diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index b71ce311af..cb02b8721a 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -28,7 +28,7 @@ describe Issues::MoveService, services: true do new_project.team << [user, :reporter] end end - + context 'issue movable' do include_context 'issue move requested' include_context 'user can move issue' @@ -162,6 +162,18 @@ describe Issues::MoveService, services: true do end end + context 'moving to same project' do + let(:new_project) { old_project } + + include_context 'issue move requested' + include_context 'user can move issue' + + it 'raises error' do + expect { move_service } + .to raise_error(StandardError, /Cannot move issue/) + end + end + context 'issue move not requested' do let(:new_project_id) { nil } @@ -179,7 +191,6 @@ describe Issues::MoveService, services: true do end end - describe 'move permissions' do include_context 'issue move requested' From 5e3c9475a9332d7104a791f7bdfef30e069dd848 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 16 Mar 2016 10:24:44 +0100 Subject: [PATCH 210/797] Add minor improvements in code related to issue move --- .../javascripts/issuable_form.js.coffee | 8 ++--- app/helpers/issues_helper.rb | 8 +++-- app/services/issues/move_service.rb | 31 +++++++------------ app/services/system_note_service.rb | 10 +++--- app/views/shared/issuable/_form.html.haml | 2 +- lib/gitlab/gfm/reference_unfolder.rb | 27 ++++++++++++++-- spec/services/system_note_service_spec.rb | 2 +- 7 files changed, 50 insertions(+), 38 deletions(-) diff --git a/app/assets/javascripts/issuable_form.js.coffee b/app/assets/javascripts/issuable_form.js.coffee index 9b939985b3..d60832f90e 100644 --- a/app/assets/javascripts/issuable_form.js.coffee +++ b/app/assets/javascripts/issuable_form.js.coffee @@ -30,11 +30,11 @@ class @IssuableForm "description" ] - handleSubmit: (e) => - @resetAutosave - + handleSubmit: => if (parseInt(@issueMoveField?.val()) ? 0) > 0 - e.preventDefault() unless confirm(ISSUE_MOVE_CONFIRM_MSG) + return false unless confirm(ISSUE_MOVE_CONFIRM_MSG) + + @resetAutosave() resetAutosave: => @titleField.data("autosave").reset() diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index 62479b8d1c..b67057ebc7 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -60,12 +60,14 @@ module IssuesHelper def project_options(issuable, current_user, ability: :read_project) projects = current_user.authorized_projects projects = projects.select do |project| - current_user.can?(ability, project) && project != issuable.project + current_user.can?(ability, project) end - projects.unshift(OpenStruct.new(id: 0, name_with_namespace: 'No project')) + no_project = OpenStruct.new(id: 0, name_with_namespace: 'No project') + projects.unshift(no_project) + projects.delete(issuable.project) - options_from_collection_for_select(projects, :id, :name_with_namespace, 0) + options_from_collection_for_select(projects, :id, :name_with_namespace) end def status_box_class(item) diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index 4abcd20340..ce31830f2d 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -7,7 +7,7 @@ module Issues @issue_new = nil @project_old = @project - if new_project_id + if new_project_id.to_i > 0 @project_new = Project.find(new_project_id) end @@ -19,7 +19,7 @@ module Issues def execute return unless move? - # Using trasaction because of a high resources footprint + # Using transaction because of a high resources footprint # on rewriting notes (unfolding references) # ActiveRecord::Base.transaction do @@ -54,10 +54,11 @@ module Issues def create_new_issue new_params = { id: nil, iid: nil, milestone: nil, label_ids: [], project: @project_new, author: @issue_old.author, - description: rewrite_references(@issue_old) } + description: unfold_references(@issue_old.description) } + new_params = @issue_old.serializable_hash.merge(new_params) create_service = CreateService.new(@project_new, @current_user, - params.merge(new_params)) + new_params) @issue_new = create_service.execute(set_author: false) end @@ -66,7 +67,7 @@ module Issues @issue_old.notes.find_each do |note| new_note = note.dup new_params = { project: @project_new, noteable: @issue_new, - note: rewrite_references(new_note) } + note: unfold_references(new_note.note) } new_note.update(new_params) end @@ -78,30 +79,20 @@ module Issues end def add_moved_from_note - SystemNoteService.noteable_moved(:from, @issue_new, @project_new, - @issue_old, @current_user) + SystemNoteService.noteable_moved(@issue_new, @project_new, + @issue_old, @current_user, direction: :from) end def add_moved_to_note - SystemNoteService.noteable_moved(:to, @issue_old, @project_old, - @issue_new, @current_user) + SystemNoteService.noteable_moved(@issue_old, @project_old, + @issue_new, @current_user, direction: :to) end - def rewrite_references(noteable) - content = noteable_content(noteable).dup + def unfold_references(content) unfolder = Gitlab::Gfm::ReferenceUnfolder.new(content, @project_old) unfolder.unfold(@project_new) end - def noteable_content(noteable) - case noteable - when Issue then noteable.description - when Note then noteable.note - else - raise 'Unexpected noteable while moving an issue!' - end - end - def notify_participants notification_service.issue_moved(@issue_old, @issue_new, @current_user) end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index a8d6555dec..c679891b07 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -3,7 +3,6 @@ # Used for creating system notes (e.g., when a user references a merge request # from an issue, an issue's assignee changes, an issue is closed, etc.) class SystemNoteService - extend GitlabMarkdownHelper # Called when commits are added to a Merge Request # # noteable - Noteable object @@ -398,16 +397,15 @@ class SystemNoteService # # Example Note text: # - # "Moved to project_new/#11" + # "Moved to some_namespace/project_new#11" # # Returns the created Note object - def self.noteable_moved(direction, noteable, project, noteable_ref, author) + def self.noteable_moved(noteable, project, noteable_ref, author, direction:) unless [:to, :from].include?(direction) - raise StandardError, "Invalid direction `#{direction}`" + raise ArgumentError, "Invalid direction `#{direction}`" end - cross_reference = cross_project_reference(noteable_ref.project, noteable_ref) - + cross_reference = noteable_ref.to_reference(project) body = "Moved #{direction} #{cross_reference}" create_note(noteable: noteable, project: project, author: author, note: body) end diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index 11b8b9d44a..d3eabe9ea6 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -70,7 +70,7 @@ - if issuable.is_a?(Issue) && can?(current_user, :admin_issue, issuable.project) %hr .form-group - = f.label :move_to_project_id, 'Move', class: 'control-label' + = label_tag :move_to_project_id, 'Move', class: 'control-label' .col-sm-10 - projects = project_options(issuable, current_user, ability: :admin_issue) = select_tag(:move_to_project_id, projects, include_blank: true, diff --git a/lib/gitlab/gfm/reference_unfolder.rb b/lib/gitlab/gfm/reference_unfolder.rb index 57871c36eb..0a68d6f977 100644 --- a/lib/gitlab/gfm/reference_unfolder.rb +++ b/lib/gitlab/gfm/reference_unfolder.rb @@ -1,8 +1,30 @@ module Gitlab module Gfm ## - # Class than unfolds local references in text. + # Class that unfolds local references in text. # + # The initializer takes text in Markdown and project this text is valid + # in context of. + # + # `unfold` method tries to find all local references and unfold each of + # those local references to cross reference format. + # + # Examples: + # + # 'Hello, this issue is related to #123 and + # other issues labeled with ~"label"', will be converted to: + # + # 'Hello, this issue is related to gitlab-org/gitlab-ce#123 and + # other issue labeled with gitlab-org/gitlab-ce~"label"'. + # + # It does respect markdown lexical rules, so text in code block will not be + # replaced, see another example: + # + # 'Merge request for issue #1234, see also link: + # http://gitlab.com/some/link/#1234, and code `puts #1234`' => + # + # 'Merge request for issue gitlab-org/gitlab-ce#1234, se also link: + # http://gitlab.com/some/link/#1234, and code `puts #1234`' # class ReferenceUnfolder def initialize(text, project) @@ -66,8 +88,7 @@ module Gitlab end def markdown(text) - helper = Class.new.extend(GitlabMarkdownHelper) - helper.markdown(text, project: @project, no_original_data: true) + Banzai.render(text, project: @project, no_original_data: true) end end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 7efb9f2ddb..7c93ce304f 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -446,7 +446,7 @@ describe SystemNoteService, services: true do let(:new_noteable) { create(:issue, project: new_project) } subject do - described_class.noteable_moved(direction, noteable, project, new_noteable, author) + described_class.noteable_moved(noteable, project, new_noteable, author, direction: direction) end shared_examples 'cross project mentionable' do From e3d32ff036ab442b9613d4dbceb473f30da048af Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Wed, 16 Mar 2016 15:11:07 +0100 Subject: [PATCH 211/797] Add feature specs for issue move --- app/models/ability.rb | 2 +- spec/features/issues/move_spec.rb | 77 +++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 spec/features/issues/move_spec.rb diff --git a/app/models/ability.rb b/app/models/ability.rb index ae1cf0c0f6..fe9e0aab71 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -222,7 +222,7 @@ class Ability :admin_wiki, :admin_project, :admin_commit_status, - :admin_build, + :admin_build ] end diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb new file mode 100644 index 0000000000..6303b117c0 --- /dev/null +++ b/spec/features/issues/move_spec.rb @@ -0,0 +1,77 @@ +require 'rails_helper' + +feature 'issue move to another project' do + let(:user) { create(:user) } + let(:old_project) { create(:project) } + let(:text) { 'Some issue description' } + + let(:issue) do + create(:issue, description: text, project: old_project, author: user) + end + + background { login_as(user) } + + context 'user does not have permission to move issue' do + background do + old_project.team << [user, :guest] + + edit_issue(issue) + end + + scenario 'moving issue to another project not allowed' do + expect(page).to have_no_select('move_to_project_id') + end + end + + context 'user has permission to move issue' do + let!(:mr) { create(:merge_request, source_project: old_project) } + let(:new_project) { create(:project) } + let(:text) { 'Text with !1' } + let(:cross_reference) { old_project.to_reference } + + background do + old_project.team << [user, :reporter] + new_project.team << [user, :reporter] + + edit_issue(issue) + end + + scenario 'moving issue to another project', js: true do + find('#s2id_move_to_project_id').click + find('.select2-drop li', text: new_project.name_with_namespace).click + click_button('Save changes') + + expect(current_url).to include project_path(new_project) + + page.within('.issue') do + expect(page).to have_content("Text with #{cross_reference}!1") + expect(page).to have_content("Moved from #{cross_reference}#1") + expect(page).to have_content(issue.title) + end + end + + context 'projects user does not have permission to move issue to exist' do + let!(:private_project) { create(:project, :private) } + let(:another_project) { create(:project) } + background { another_project.team << [user, :guest] } + + scenario 'browsing projects in projects select' do + options = [ '', 'No project', new_project.name_with_namespace ] + expect(page).to have_select('move_to_project_id', options: options) + end + end + end + + def edit_issue(issue) + visit issue_path(issue) + page.within('.issuable-header') { click_link 'Edit' } + end + + def issue_path(issue) + namespace_project_issue_path(issue.project.namespace, issue.project, issue) + end + + def project_path(project) + namespace_project_path(new_project.namespace, new_project) + end +end From f108cedf4a46ee0f6114a5a8584a6080f4346e5b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 17 Mar 2016 07:56:23 +0100 Subject: [PATCH 212/797] Add description of technical debt label in docs [ci skip] --- CONTRIBUTING.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7540fa1afc..a946c2cd88 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,6 +16,7 @@ - [Issue tracker guidelines](#issue-tracker-guidelines) - [Issue weight](#issue-weight) - [Regression issues](#regression-issues) + - [Technical debt](#technical-debt) - [Merge requests](#merge-requests) - [Merge request guidelines](#merge-request-guidelines) - [Merge request description format](#merge-request-description-format) @@ -242,6 +243,25 @@ addressed. [8.3 Regressions]: https://gitlab.com/gitlab-org/gitlab-ce/issues/4127 [update the notes]: https://gitlab.com/gitlab-org/release-tools/blob/master/doc/pro-tips.md#update-the-regression-issue +### Technical debt + +In order to track things that can be improved in GitLab codebase, we created a +*technical debt* label in [issue tracker of CE][ce-tracker]. + +This label should be added to issues that describe things that can be improved, +shortcuts that has been taken, code that needs refactoring, features that need +additional attention, and all other things that have been left behind due to +high velocity of development. + +Everyone can create an issue (though you may need to ask for adding a specific +label, if you do not have permissions to do it by yourself), additional labels +can be combined with *technical debt* label, to make it easier to schedule the +improvements for a release. + +Issues with *technical debt* label have a same priority like issues that +describe a new features that can be introduced in GitLab, and should be +scheduled for a release by appropriate person. + ## Merge requests We welcome merge requests with fixes and improvements to GitLab code, tests, From cde7eee73e611ca7f775e179fb07b931c37f54ae Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Thu, 17 Mar 2016 09:24:06 +0200 Subject: [PATCH 213/797] Refactor external users docs [ci skip] --- doc/permissions/permissions.md | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/doc/permissions/permissions.md b/doc/permissions/permissions.md index 2dfd08755b..3d375e47c8 100644 --- a/doc/permissions/permissions.md +++ b/doc/permissions/permissions.md @@ -74,17 +74,21 @@ Any user can remove themselves from a group, unless they are the last Owner of t ## External Users -In cases where it is desired that a user has access to some internal or private projects, but others -should remain hidden from this user, there is the option of creating `External Users`. -An administrator can flag a user as external through the API or by checking the checkbox on the admin panel. +In cases where it is desired that a user has access only to some internal or +private projects, there is the option of creating **External Users**. This +feature may be useful when for example a contractor is working on a given +project and should only have access to that project. -In the case of a new user: navigate to the **Admin** area and click the **New User** button. If you would like to -edit a user, go to the user list on the **Admin** area and click the **Edit** button. +External users can only access projects to which they are explicitly granted +access, thus hiding all other internal or private ones from them. Access can be +granted by adding the user as member to the project or group. -External users can only access projects to which they are explicitly granted access, thus hiding all internal projects. -Access can be granted by adding the users as member to the project or by including this user in a group. External users will, like usual users, receive -a role in the project or group with all the abilities that are mentioned in the table above. +They will, like usual users, receive a role in the project or group with all +the abilities that are mentioned in the table above. They cannot however create +groups or projects, and they have the same access as logged out users in all +other cases. -External users cannot create groups or projects, and have the same access as logged out users in all other cases. This feature may be -useful when for example a contractor is working on a given project and should only access the given project and public -projects. +An administrator can flag a user as external [through the API](../api/users.md) +or by checking the checkbox on the admin panel. As an administrator, navigate +to **Admin > Users** to create a new user or edit an existing one. There, you +will find the option to flag the user as external. From 51300d9b2a65a43742985847376150229b91c4ca Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Thu, 17 Mar 2016 09:36:00 +0100 Subject: [PATCH 214/797] API support for setting External flag on existing users --- lib/api/users.rb | 3 ++- spec/requests/api/users_spec.rb | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/lib/api/users.rb b/lib/api/users.rb index c574f042a6..8849fff60a 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -108,12 +108,13 @@ module API # bio - Bio # admin - User is admin - true or false (default) # can_create_group - User can create groups - true or false + # external - Is user an external user - true or false(default) # Example Request: # PUT /users/:id put ":id" do authenticated_as_admin! - attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :website_url, :projects_limit, :username, :bio, :can_create_group, :admin] + attrs = attributes_for_keys [:email, :name, :password, :skype, :linkedin, :twitter, :website_url, :projects_limit, :username, :bio, :can_create_group, :admin, :external] user = User.find(params[:id]) not_found!('User') unless user diff --git a/spec/requests/api/users_spec.rb b/spec/requests/api/users_spec.rb index 5366a7bd06..679227bf88 100644 --- a/spec/requests/api/users_spec.rb +++ b/spec/requests/api/users_spec.rb @@ -282,6 +282,13 @@ describe API::API, api: true do expect(user.reload.admin).to eq(true) end + it "should update external status" do + put api("/users/#{user.id}", admin), { external: true } + expect(response.status).to eq 200 + expect(json_response['external']).to eq(true) + expect(user.reload.external?).to be_truthy + end + it "should not update admin status" do put api("/users/#{admin_user.id}", admin), { can_create_group: false } expect(response.status).to eq(200) From 5e405bfea9a2748af2652bf5dc51fce89a1794f3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 17 Mar 2016 09:54:16 +0100 Subject: [PATCH 215/797] Update methods that use issue close service `Issues::CloseService#execute` signature has changed, because of using keyword parameter for commmit. --- app/services/git_push_service.rb | 2 +- app/services/issues/close_service.rb | 2 +- app/services/merge_requests/post_merge_service.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index d840ab5e34..855d55b3e5 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -120,7 +120,7 @@ class GitPushService < BaseService closed_issues = commit.closes_issues(current_user) closed_issues.each do |issue| if can?(current_user, :update_issue, issue) - Issues::CloseService.new(project, authors[commit], {}).execute(issue, commit) + Issues::CloseService.new(project, authors[commit], {}).execute(issue, commit: commit) end end end diff --git a/app/services/issues/close_service.rb b/app/services/issues/close_service.rb index 6866d3fcfb..859c934ea3 100644 --- a/app/services/issues/close_service.rb +++ b/app/services/issues/close_service.rb @@ -1,6 +1,6 @@ module Issues class CloseService < Issues::BaseService - def execute(issue, commit = nil, notifications: true, system_note: true) + def execute(issue, commit: nil, notifications: true, system_note: true) if project.jira_tracker? && project.jira_service.active project.jira_service.execute(commit, issue) todo_service.close_issue(issue, current_user) diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index ebb67c7db6..064910f81f 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -22,7 +22,7 @@ module MergeRequests closed_issues = merge_request.closes_issues(current_user) closed_issues.each do |issue| if can?(current_user, :update_issue, issue) - Issues::CloseService.new(project, current_user, {}).execute(issue, merge_request) + Issues::CloseService.new(project, current_user, {}).execute(issue, commit: merge_request) end end end From 81439c9093707d8964258db9481715b3ca0e7a3a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 17 Mar 2016 09:13:20 +0000 Subject: [PATCH 216/797] Moved code to single line if --- app/helpers/issuables_helper.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index 23f389b8c5..81df209439 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -25,11 +25,9 @@ module IssuablesHelper if @project member = @project.team.find_member(user_id) - if member - user = member.user - end + user = member.user if member else - user = User.find(user_id) + user = User.find_by(id: user_id) end if user From dda7f9635fe96eba52110979914ff966188f6e8b Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 17 Mar 2016 10:31:17 +0100 Subject: [PATCH 217/797] Add field that references issue this issue has been moved to --- app/models/issue.rb | 3 +++ db/migrate/20160317092222_add_moved_to_to_issue.rb | 5 +++++ db/schema.rb | 4 +++- 3 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20160317092222_add_moved_to_to_issue.rb diff --git a/app/models/issue.rb b/app/models/issue.rb index 5f58c0508f..b506d17415 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -16,6 +16,7 @@ # state :string(255) # iid :integer # updated_by_id :integer +# moved_to_id :integer # require 'carrierwave/orm/activerecord' @@ -31,6 +32,8 @@ class Issue < ActiveRecord::Base ActsAsTaggableOn.strict_case_match = true belongs_to :project + belongs_to :moved_to, class_name: 'Issue' + validates :project, presence: true scope :of_group, diff --git a/db/migrate/20160317092222_add_moved_to_to_issue.rb b/db/migrate/20160317092222_add_moved_to_to_issue.rb new file mode 100644 index 0000000000..d262983b13 --- /dev/null +++ b/db/migrate/20160317092222_add_moved_to_to_issue.rb @@ -0,0 +1,5 @@ +class AddMovedToToIssue < ActiveRecord::Migration + def change + add_reference :issues, :moved_to, references: :issues, index: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 2c27b22886..aa0071094d 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: 20160316123110) do +ActiveRecord::Schema.define(version: 20160317092222) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -416,6 +416,7 @@ ActiveRecord::Schema.define(version: 20160316123110) do t.string "state" t.integer "iid" t.integer "updated_by_id" + t.integer "moved_to_id" end add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree @@ -424,6 +425,7 @@ ActiveRecord::Schema.define(version: 20160316123110) do add_index "issues", ["created_at"], name: "index_issues_on_created_at", using: :btree add_index "issues", ["description"], name: "index_issues_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} add_index "issues", ["milestone_id"], name: "index_issues_on_milestone_id", using: :btree + add_index "issues", ["moved_to_id"], name: "index_issues_on_moved_to_id", using: :btree add_index "issues", ["project_id", "iid"], name: "index_issues_on_project_id_and_iid", unique: true, using: :btree add_index "issues", ["project_id"], name: "index_issues_on_project_id", using: :btree add_index "issues", ["state"], name: "index_issues_on_state", using: :btree From 2057bc02a31a20765e165b503b877350e892908b Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Thu, 17 Mar 2016 11:02:11 +0100 Subject: [PATCH 218/797] Do not retry "git gc" --- app/services/projects/housekeeping_service.rb | 2 +- app/workers/gitlab_shell_one_shot_worker.rb | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) create mode 100644 app/workers/gitlab_shell_one_shot_worker.rb diff --git a/app/services/projects/housekeeping_service.rb b/app/services/projects/housekeeping_service.rb index bccd67d3db..a0973c5d26 100644 --- a/app/services/projects/housekeeping_service.rb +++ b/app/services/projects/housekeeping_service.rb @@ -24,7 +24,7 @@ module Projects def execute raise LeaseTaken if !try_obtain_lease - GitlabShellWorker.perform_async(:gc, @project.path_with_namespace) + GitlabShellOneShotWorker.perform_async(:gc, @project.path_with_namespace) ensure @project.update_column(:pushes_since_gc, 0) end diff --git a/app/workers/gitlab_shell_one_shot_worker.rb b/app/workers/gitlab_shell_one_shot_worker.rb new file mode 100644 index 0000000000..4ddbcf574d --- /dev/null +++ b/app/workers/gitlab_shell_one_shot_worker.rb @@ -0,0 +1,10 @@ +class GitlabShellOneShotWorker + include Sidekiq::Worker + include Gitlab::ShellAdapter + + sidekiq_options queue: :gitlab_shell, retry: false + + def perform(action, *arg) + gitlab_shell.send(action, *arg) + end +end From 956e914307029dbfbdb387fd6c0749dd50935fa4 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Thu, 17 Mar 2016 12:21:12 +0200 Subject: [PATCH 219/797] Add missing API docs on external user [ci skip] --- doc/api/users.md | 1 + lib/api/users.rb | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/api/users.md b/doc/api/users.md index 44a29da5ec..383e7c76ab 100644 --- a/doc/api/users.md +++ b/doc/api/users.md @@ -220,6 +220,7 @@ Parameters: - `bio` - User's biography - `admin` (optional) - User is admin - true or false (default) - `can_create_group` (optional) - User can create groups - true or false +- `external` (optional) - Flags the user as external - true or false(default) Note, at the moment this method does only return a 404 error, even in cases where a 409 (Conflict) would be more appropriate, diff --git a/lib/api/users.rb b/lib/api/users.rb index 8849fff60a..13ab17c690 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -61,7 +61,7 @@ module API # admin - User is admin - true or false (default) # can_create_group - User can create groups - true or false # confirm - Require user confirmation - true (default) or false - # external - Is user an external user - true or false(default) + # external - Flags the user as external - true or false(default) # Example Request: # POST /users post do @@ -108,7 +108,7 @@ module API # bio - Bio # admin - User is admin - true or false (default) # can_create_group - User can create groups - true or false - # external - Is user an external user - true or false(default) + # external - Flags the user as external - true or false(default) # Example Request: # PUT /users/:id put ":id" do From feb538dddd5e2e6b09860d34b4a17c3e46a006c8 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 17 Mar 2016 10:21:48 +0000 Subject: [PATCH 220/797] Fixed filter spacing Closes #14350 --- app/assets/stylesheets/framework/filters.scss | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index c431e2b0df..40a508c1eb 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -3,22 +3,11 @@ vertical-align: top; } -@media (min-width: 800px) { +@media (min-width: $screen-sm-min) { .issues-filters, .issues_bulk_update { - select, .select2-container { - width: 120px !important; - display: inline-block; - } - } -} - -@media (min-width: 1200px) { - .issues-filters, - .issues_bulk_update { - select, .select2-container { - width: 150px !important; - display: inline-block; + .dropdown-menu-toggle { + width: 132px; } } } From b9036ba61012e6723426f98171a2c111abb0bae5 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 17 Mar 2016 11:11:22 +0100 Subject: [PATCH 221/797] Prevent issue move if issue has been already moved --- app/models/issue.rb | 12 ++++++ app/services/issues/move_service.rb | 9 +++- app/views/shared/issuable/_form.html.haml | 2 +- spec/features/issues/move_spec.rb | 11 +++++ spec/models/issue_spec.rb | 50 +++++++++++++++++++++++ spec/services/issues/move_service_spec.rb | 18 ++++++++ 6 files changed, 99 insertions(+), 3 deletions(-) diff --git a/app/models/issue.rb b/app/models/issue.rb index b506d17415..6a016636e0 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -123,4 +123,16 @@ class Issue < ActiveRecord::Base note.all_references(current_user).merge_requests end.uniq.select { |mr| mr.open? && mr.closes_issue?(self) } end + + def moved? + !moved_to.nil? + end + + def can_move?(user, to_project = nil) + if to_project + return false unless user.can?(:admin_issue, to_project) + end + + !moved? && user.can?(:admin_issue, self.project) + end end diff --git a/app/services/issues/move_service.rb b/app/services/issues/move_service.rb index ce31830f2d..47b58d973f 100644 --- a/app/services/issues/move_service.rb +++ b/app/services/issues/move_service.rb @@ -33,6 +33,7 @@ module Issues # add_moved_to_note close_old_issue + mark_as_moved end notify_participants @@ -47,8 +48,8 @@ module Issues private def can_move? - can?(@current_user, :admin_issue, @project_old) && - can?(@current_user, :admin_issue, @project_new) + @issue_old.can_move?(@current_user) && + @issue_old.can_move?(@current_user, @project_new) end def create_new_issue @@ -96,5 +97,9 @@ module Issues def notify_participants notification_service.issue_moved(@issue_old, @issue_new, @current_user) end + + def mark_as_moved + @issue_old.update(moved_to: @issue_new) + end end end diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index d3eabe9ea6..5276afec1c 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -67,7 +67,7 @@ - if can? current_user, :admin_label, issuable.project = link_to 'Create new label', new_namespace_project_label_path(issuable.project.namespace, issuable.project), target: :blank -- if issuable.is_a?(Issue) && can?(current_user, :admin_issue, issuable.project) +- if issuable.is_a?(Issue) && issuable.can_move?(current_user) %hr .form-group = label_tag :move_to_project_id, 'Move', class: 'control-label' diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index 6303b117c0..db26b3a467 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -60,6 +60,17 @@ feature 'issue move to another project' do expect(page).to have_select('move_to_project_id', options: options) end end + + context 'issue has been already moved' do + let(:new_issue) { create(:issue, project: new_project) } + let(:issue) do + create(:issue, project: old_project, author: user, moved_to: new_issue) + end + + scenario 'user wants to move issue that has already been moved' do + expect(page).to have_no_select('move_to_project_id') + end + end end def edit_issue(issue) diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index 7f44ca2f7d..0d6e9cb3a4 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -130,6 +130,56 @@ describe Issue, models: true do end end + describe '#can_move?' do + let(:user) { create(:user) } + let(:issue) { create(:issue) } + subject { issue.can_move?(user) } + + context 'user is not a member of project issue belongs to' do + it { is_expected.to eq false} + end + + context 'user is reporter in project issue belongs to' do + let(:project) { create(:project) } + let(:issue) { create(:issue, project: project) } + + before { project.team << [user, :reporter] } + + it { is_expected.to eq true } + + context 'checking destination project also' do + subject { issue.can_move?(user, to_project) } + let(:to_project) { create(:project) } + + context 'destination project allowed' do + before { to_project.team << [user, :reporter] } + it { is_expected.to eq true } + end + + context 'destination project not allowed' do + before { to_project.team << [user, :guest] } + it { is_expected.to eq false } + end + end + end + end + + describe '#moved?' do + let(:issue) { create(:issue) } + subject { issue.moved? } + + context 'issue not moved' do + it { is_expected.to eq false } + end + + context 'issue already moved' do + let(:moved_to_issue) { create(:issue) } + let(:issue) { create(:issue, moved_to: moved_to_issue) } + + it { is_expected.to eq true } + end + end + it_behaves_like 'an editable mentionable' do subject { create(:issue) } diff --git a/spec/services/issues/move_service_spec.rb b/spec/services/issues/move_service_spec.rb index cb02b8721a..02ade91ac0 100644 --- a/spec/services/issues/move_service_spec.rb +++ b/spec/services/issues/move_service_spec.rb @@ -91,6 +91,11 @@ describe Issues::MoveService, services: true do it 'creates a new internal id for issue' do expect(new_issue.iid).to be 1 end + + it 'marks issue as moved' do + expect(old_issue.moved?).to eq true + expect(old_issue.moved_to).to eq new_issue + end end context 'issue with notes' do @@ -220,6 +225,19 @@ describe Issues::MoveService, services: true do it { is_expected.to be_falsey } end + + context 'issue has already been moved' do + include_context 'user can move issue' + + let(:moved_to_issue) { create(:issue) } + + let(:old_issue) do + create(:issue, project: old_project, author: author, + moved_to: moved_to_issue) + end + + it { is_expected.to be_falsey } + end end end end From 9337406671755ebd9175866cd86f1d6da4265d49 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Thu, 17 Mar 2016 11:48:07 +0100 Subject: [PATCH 222/797] Fix specs Spinach was right, I was a fool.. --- app/models/issue.rb | 1 + spec/features/issues/new_branch_button_spec.rb | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/issue.rb b/app/models/issue.rb index 781298a63b..2447f860c5 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -98,6 +98,7 @@ class Issue < ActiveRecord::Base end def related_branches + return [] if self.project.empty_repo? self.project.repository.branch_names.select do |branch| branch =~ /\A#{iid}-(?!\d+-stable)/i end diff --git a/spec/features/issues/new_branch_button_spec.rb b/spec/features/issues/new_branch_button_spec.rb index 462cb1f8f6..1f3bd915f4 100644 --- a/spec/features/issues/new_branch_button_spec.rb +++ b/spec/features/issues/new_branch_button_spec.rb @@ -24,7 +24,7 @@ feature 'Start new branch from an issue', feature: true do end let(:referenced_mr) do create(:merge_request, :simple, source_project: project, target_project: project, - description: "Fixes ##{issue.iid}") + description: "Fixes ##{issue.iid}") end before do From 019fed0f72116a73f0d410fe0c892fc9a19921e7 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 17 Mar 2016 12:16:34 +0100 Subject: [PATCH 223/797] Reuse `no_cache_headers` method in badges controller --- app/controllers/projects/badges_controller.rb | 13 +------------ features/steps/project/badges/build.rb | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/app/controllers/projects/badges_controller.rb b/app/controllers/projects/badges_controller.rb index dc9c96df00..6ff47c4033 100644 --- a/app/controllers/projects/badges_controller.rb +++ b/app/controllers/projects/badges_controller.rb @@ -1,5 +1,5 @@ class Projects::BadgesController < Projects::ApplicationController - before_action :set_no_cache + before_action :no_cache_headers def build respond_to do |format| @@ -10,15 +10,4 @@ class Projects::BadgesController < Projects::ApplicationController end end end - - private - - def set_no_cache - expires_now - - # Add some deprecated headers for older agents - # - response.headers['Pragma'] = 'no-cache' - response.headers['Expires'] = 'Fri, 01 Jan 1990 00:00:00 GMT' - end end diff --git a/features/steps/project/badges/build.rb b/features/steps/project/badges/build.rb index 47540f356e..66a48a176e 100644 --- a/features/steps/project/badges/build.rb +++ b/features/steps/project/badges/build.rb @@ -21,7 +21,7 @@ class Spinach::Features::ProjectBadgesBuild < Spinach::FeatureSteps end step 'I should see a badge that has not been cached' do - expect(page.response_headers).to include('Cache-Control' => 'no-cache') + expect(page.response_headers['Cache-Control']).to include 'no-cache' end def expect_badge(status) From fcf106897e2ff38e16e785ad9bcb18d117490fbf Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 17 Mar 2016 12:25:17 +0100 Subject: [PATCH 224/797] Do not use javascript in specs for issue move Feature specs that were using javascript took a long time to finish (about 10x longer) and where hitting Poltergeist timeouts. This modification skips using javascript with select2 selecbox, thus it is faster, but this also does not check some JavaScript related features. --- spec/features/issues/move_spec.rb | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/spec/features/issues/move_spec.rb b/spec/features/issues/move_spec.rb index db26b3a467..6fda0c3186 100644 --- a/spec/features/issues/move_spec.rb +++ b/spec/features/issues/move_spec.rb @@ -36,9 +36,8 @@ feature 'issue move to another project' do edit_issue(issue) end - scenario 'moving issue to another project', js: true do - find('#s2id_move_to_project_id').click - find('.select2-drop li', text: new_project.name_with_namespace).click + scenario 'moving issue to another project' do + select(new_project.name_with_namespace, from: 'move_to_project_id') click_button('Save changes') expect(current_url).to include project_path(new_project) From fa570525dbe35fb598b8a269198e1173da6d7ae3 Mon Sep 17 00:00:00 2001 From: Jacob Schatz Date: Tue, 23 Feb 2016 15:09:35 -0500 Subject: [PATCH 225/797] Adds small AJAX optimistic functionality to todos. Fixes #13656 A good first step and boring solution. --- app/assets/javascripts/dispatcher.js.coffee | 3 +- app/assets/javascripts/todos.js.coffee | 48 +++++++++++++++++++ app/assets/stylesheets/framework/flash.scss | 6 +++ app/controllers/dashboard/todos_controller.rb | 7 ++- app/views/dashboard/todos/_todo.html.haml | 2 +- app/views/dashboard/todos/index.html.haml | 6 ++- 6 files changed, 67 insertions(+), 5 deletions(-) create mode 100644 app/assets/javascripts/todos.js.coffee diff --git a/app/assets/javascripts/dispatcher.js.coffee b/app/assets/javascripts/dispatcher.js.coffee index 1be86e3b82..f5e1ca9860 100644 --- a/app/assets/javascripts/dispatcher.js.coffee +++ b/app/assets/javascripts/dispatcher.js.coffee @@ -14,7 +14,6 @@ class Dispatcher path = page.split(':') shortcut_handler = null - switch page when 'projects:issues:index' Issues.init() @@ -25,6 +24,8 @@ class Dispatcher new ZenMode() when 'projects:milestones:show', 'groups:milestones:show', 'dashboard:milestones:show' new Milestone() + when 'dashboard:todos:index' + new Todos() when 'projects:milestones:new', 'projects:milestones:edit' new ZenMode() new DropzoneInput($('.milestone-form')) diff --git a/app/assets/javascripts/todos.js.coffee b/app/assets/javascripts/todos.js.coffee new file mode 100644 index 0000000000..b68c143b4b --- /dev/null +++ b/app/assets/javascripts/todos.js.coffee @@ -0,0 +1,48 @@ +class @Todos + _this = null; + constructor: (@name) -> + _this = @ + @initBtnListeners() + + initBtnListeners: -> + $('.done-todo').on('click', @doneClicked) + + doneClicked: (e) -> + $this = $(this) + doneURL = $this.attr('href') + e.preventDefault() + e.stopImmediatePropagation() + $spinner = $('').addClass('fa fa-spinner fa-spin') + $this.addClass("disabled") + $this.append($spinner) + $.ajax + type: 'POST' + url: doneURL + dataType: 'json' + data: '_method': 'delete' + error: (data, textStatus, jqXHR) -> + new Flash('Unable to update your todos.', 'alert') + _this.clearDone($this.closest('li')) + return + + success: (data, textStatus, jqXHR) -> + new Flash(data.notice, 'success') + _this.clearDone($this.closest('li')) + return + + clearDone: ($row) -> + $ul = $row.closest('ul') + $row.remove() + if not $ul.find('li').length + Turbolinks.visit(location.href) + else + $pendingBadge = $('.todos-pending .badge') + $pendingBadge.text parseInt($pendingBadge.text()) - 1 + + $doneBadge = $('.todos-done .badge') + $doneBadge.text parseInt($doneBadge.text()) + 1 + + $mainTodosPendingBadge = $('.todos-pending-count') + $mainTodosPendingBadge.text parseInt($mainTodosPendingBadge.text()) - 1 + return + \ No newline at end of file diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 1bfd021399..244d0e2f0c 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -5,6 +5,12 @@ width: 100%; z-index: 100; + .flash-success { + @extend .alert; + @extend .alert-success; + margin: 0; + } + .flash-notice { @extend .alert; @extend .alert-info; diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 43cf8fa71a..54f718d011 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -8,9 +8,14 @@ class Dashboard::TodosController < Dashboard::ApplicationController def destroy todo.done! + todo_notice = 'Todo was successfully marked as done.' + respond_to do |format| - format.html { redirect_to dashboard_todos_path, notice: 'Todo was successfully marked as done.' } + format.html { redirect_to dashboard_todos_path, notice: todo_notice } format.js { render nothing: true } + format.json do + render json: { status: 'OK', notice: todo_notice } + end end end diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 45cfe3da18..b7deb9da54 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -16,7 +16,7 @@ - if todo.pending? .todo-actions.pull-right - = link_to 'Done', [:dashboard, todo], method: :delete, class: 'btn' + = link_to 'Done', [:dashboard, todo], method: :delete, class: 'btn done-todo' .todo-body .todo-note diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index 946d7df393..b5b8fb4d14 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -3,13 +3,15 @@ .top-area %ul.nav-links - %li{class: ('active' if params[:state].blank? || params[:state] == 'pending')} + - todo_pending_active = ('active' if params[:state].blank? || params[:state] == 'pending') + %li{class: "todos-pending #{todo_pending_active}"} = link_to todos_filter_path(state: 'pending') do %span To do %span{class: 'badge'} = todos_pending_count - %li{class: ('active' if params[:state] == 'done')} + - todo_done_active = ('active' if params[:state] == 'done') + %li{class: "todos-done #{todo_done_active}"} = link_to todos_filter_path(state: 'done') do %span Done From 8f8720209f1f325042cbe3f134797c85cc0c64c4 Mon Sep 17 00:00:00 2001 From: Jacob Schatz Date: Fri, 4 Mar 2016 09:15:48 -0500 Subject: [PATCH 226/797] Remove `Flash` from todos when finished --- app/assets/javascripts/todos.js.coffee | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/todos.js.coffee b/app/assets/javascripts/todos.js.coffee index b68c143b4b..811652c8d8 100644 --- a/app/assets/javascripts/todos.js.coffee +++ b/app/assets/javascripts/todos.js.coffee @@ -26,7 +26,6 @@ class @Todos return success: (data, textStatus, jqXHR) -> - new Flash(data.notice, 'success') _this.clearDone($this.closest('li')) return From 421215e3385860af42530895c22021c0704275e2 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 17 Mar 2016 12:54:02 +0000 Subject: [PATCH 227/797] Removed the flash success message Removes the group if empty --- app/assets/javascripts/todos.js.coffee | 52 +++++++------------ app/assets/stylesheets/framework/buttons.scss | 10 ++++ app/assets/stylesheets/framework/flash.scss | 6 --- app/controllers/dashboard/todos_controller.rb | 4 +- app/views/dashboard/todos/_todo.html.haml | 4 +- 5 files changed, 35 insertions(+), 41 deletions(-) diff --git a/app/assets/javascripts/todos.js.coffee b/app/assets/javascripts/todos.js.coffee index 811652c8d8..951b786b1f 100644 --- a/app/assets/javascripts/todos.js.coffee +++ b/app/assets/javascripts/todos.js.coffee @@ -1,47 +1,35 @@ class @Todos - _this = null; constructor: (@name) -> - _this = @ + @clearListeners() @initBtnListeners() - + + clearListeners: -> + $('.done-todo').off('click') + initBtnListeners: -> $('.done-todo').on('click', @doneClicked) - - doneClicked: (e) -> - $this = $(this) - doneURL = $this.attr('href') + + doneClicked: (e) => e.preventDefault() e.stopImmediatePropagation() - $spinner = $('').addClass('fa fa-spinner fa-spin') - $this.addClass("disabled") - $this.append($spinner) + + $this = $(e.currentTarget) + $this.disable() + $.ajax type: 'POST' - url: doneURL + url: $this.attr('href') dataType: 'json' data: '_method': 'delete' - error: (data, textStatus, jqXHR) -> - new Flash('Unable to update your todos.', 'alert') - _this.clearDone($this.closest('li')) - return + success: (data) => + @clearDone $this.closest('li'), data - success: (data, textStatus, jqXHR) -> - _this.clearDone($this.closest('li')) - return - - clearDone: ($row) -> + clearDone: ($row, data) -> $ul = $row.closest('ul') $row.remove() + + $('.todos-pending .badge, .todos-pending-count').text data.count + $('.todos-done .badge').text data.done_count + if not $ul.find('li').length - Turbolinks.visit(location.href) - else - $pendingBadge = $('.todos-pending .badge') - $pendingBadge.text parseInt($pendingBadge.text()) - 1 - - $doneBadge = $('.todos-done .badge') - $doneBadge.text parseInt($doneBadge.text()) + 1 - - $mainTodosPendingBadge = $('.todos-pending-count') - $mainTodosPendingBadge.text parseInt($mainTodosPendingBadge.text()) - 1 - return - \ No newline at end of file + $ul.parents('.panel').remove() diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index fa115a4bf5..657c5f033c 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -208,3 +208,13 @@ background-color: #e4e7ed !important; } } + +.btn-loading { + &:not(.disabled) .fa { + display: none; + } + + .fa { + margin-right: 5px; + } +} diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index 244d0e2f0c..1bfd021399 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -5,12 +5,6 @@ width: 100%; z-index: 100; - .flash-success { - @extend .alert; - @extend .alert-success; - margin: 0; - } - .flash-notice { @extend .alert; @extend .alert-info; diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 54f718d011..10120dfdf3 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -1,5 +1,5 @@ class Dashboard::TodosController < Dashboard::ApplicationController - before_action :find_todos, only: [:index, :destroy_all] + before_action :find_todos, only: [:index, :destroy, :destroy_all] def index @todos = @todos.page(params[:page]).per(PER_PAGE) @@ -14,7 +14,7 @@ class Dashboard::TodosController < Dashboard::ApplicationController format.html { redirect_to dashboard_todos_path, notice: todo_notice } format.js { render nothing: true } format.json do - render json: { status: 'OK', notice: todo_notice } + render json: { count: @todos.size, done_count: current_user.todos.done.count } end end end diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index b7deb9da54..42f3cf4de0 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -16,7 +16,9 @@ - if todo.pending? .todo-actions.pull-right - = link_to 'Done', [:dashboard, todo], method: :delete, class: 'btn done-todo' + = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do + = icon('spinner spin') + Done .todo-body .todo-note From 41c107beccda574aefd3e5d992345087b2e0d848 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 17 Mar 2016 13:08:59 +0000 Subject: [PATCH 228/797] Mark all as done through AJAX --- app/assets/javascripts/todos.js.coffee | 31 ++++++++++++++++--- app/controllers/dashboard/todos_controller.rb | 4 +++ app/views/dashboard/todos/_todo.html.haml | 2 +- app/views/dashboard/todos/index.html.haml | 6 ++-- 4 files changed, 35 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/todos.js.coffee b/app/assets/javascripts/todos.js.coffee index 951b786b1f..b6b4bd90e6 100644 --- a/app/assets/javascripts/todos.js.coffee +++ b/app/assets/javascripts/todos.js.coffee @@ -5,9 +5,11 @@ class @Todos clearListeners: -> $('.done-todo').off('click') + $('.js-todos-mark-all').off('click') initBtnListeners: -> $('.done-todo').on('click', @doneClicked) + $('.js-todos-mark-all').on('click', @allDoneClicked) doneClicked: (e) => e.preventDefault() @@ -22,14 +24,33 @@ class @Todos dataType: 'json' data: '_method': 'delete' success: (data) => - @clearDone $this.closest('li'), data + @clearDone $this.closest('li') + @updateBadges data - clearDone: ($row, data) -> + allDoneClicked: (e) => + e.preventDefault() + e.stopImmediatePropagation() + + $this = $(e.currentTarget) + $this.disable() + + $.ajax + type: 'POST' + url: $this.attr('href') + dataType: 'json' + data: '_method': 'delete' + success: (data) => + $this.remove() + $('.js-todos-list').remove() + @updateBadges data + + clearDone: ($row) -> $ul = $row.closest('ul') $row.remove() - $('.todos-pending .badge, .todos-pending-count').text data.count - $('.todos-done .badge').text data.done_count - if not $ul.find('li').length $ul.parents('.panel').remove() + + updateBadges: (data) -> + $('.todos-pending .badge, .todos-pending-count').text data.count + $('.todos-done .badge').text data.done_count diff --git a/app/controllers/dashboard/todos_controller.rb b/app/controllers/dashboard/todos_controller.rb index 10120dfdf3..7857af9c5d 100644 --- a/app/controllers/dashboard/todos_controller.rb +++ b/app/controllers/dashboard/todos_controller.rb @@ -25,6 +25,10 @@ class Dashboard::TodosController < Dashboard::ApplicationController respond_to do |format| format.html { redirect_to dashboard_todos_path, notice: 'All todos were marked as done.' } format.js { render nothing: true } + format.json do + find_todos + render json: { count: @todos.size, done_count: current_user.todos.done.count } + end end end diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 42f3cf4de0..4c848a5018 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -17,8 +17,8 @@ - if todo.pending? .todo-actions.pull-right = link_to [:dashboard, todo], method: :delete, class: 'btn btn-loading done-todo' do - = icon('spinner spin') Done + = icon('spinner spin') .todo-body .todo-note diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index b5b8fb4d14..623381375a 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -20,7 +20,9 @@ .nav-controls - if @todos.any?(&:pending?) - = link_to 'Mark all as done', destroy_all_dashboard_todos_path(todos_filter_params), class: 'btn', method: :delete + = link_to destroy_all_dashboard_todos_path(todos_filter_params), class: 'btn btn-loading js-todos-mark-all', method: :delete do + Mark all as done + = icon('spinner spin') .todos-filters .gray-content-block.second-block @@ -44,7 +46,7 @@ .prepend-top-default - if @todos.any? - @todos.group_by(&:project).each do |group| - .panel.panel-default.panel-small + .panel.panel-default.panel-small.js-todos-list - project = group[0] .panel-heading = link_to project.name_with_namespace, namespace_project_path(project.namespace, project) From 4b5e06a944c46043b735952bd02754993de84744 Mon Sep 17 00:00:00 2001 From: Jacob Schatz Date: Thu, 17 Mar 2016 09:18:16 -0400 Subject: [PATCH 229/797] Added comment to make builds run --- app/assets/stylesheets/pages/notes.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index f7eeadcb5b..4f9c18b85d 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -108,6 +108,7 @@ ul.notes { word-wrap: break-word; @include md-typography; + // On diffs code should wrap nicely and not overflow pre { code { white-space: pre-wrap; From 78530162261accaa01be0dc90bf8cd06acb210a6 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 01:26:48 -0500 Subject: [PATCH 230/797] Add cropper library --- app/assets/javascripts/lib/cropper.js | 2993 +++++++++++++++++++++++ app/assets/stylesheets/application.scss | 1 + app/assets/stylesheets/cropper.css | 379 +++ 3 files changed, 3373 insertions(+) create mode 100644 app/assets/javascripts/lib/cropper.js create mode 100644 app/assets/stylesheets/cropper.css diff --git a/app/assets/javascripts/lib/cropper.js b/app/assets/javascripts/lib/cropper.js new file mode 100644 index 0000000000..805485904a --- /dev/null +++ b/app/assets/javascripts/lib/cropper.js @@ -0,0 +1,2993 @@ +/*! + * Cropper v2.3.0 + * https://github.com/fengyuanchen/cropper + * + * Copyright (c) 2014-2016 Fengyuan Chen and contributors + * Released under the MIT license + * + * Date: 2016-02-22T02:13:13.332Z + */ + +(function (factory) { + if (typeof define === 'function' && define.amd) { + // AMD. Register as anonymous module. + define(['jquery'], factory); + } else if (typeof exports === 'object') { + // Node / CommonJS + factory(require('jquery')); + } else { + // Browser globals. + factory(jQuery); + } +})(function ($) { + + 'use strict'; + + // Globals + var $window = $(window); + var $document = $(document); + var location = window.location; + var navigator = window.navigator; + var ArrayBuffer = window.ArrayBuffer; + var Uint8Array = window.Uint8Array; + var DataView = window.DataView; + var btoa = window.btoa; + + // Constants + var NAMESPACE = 'cropper'; + + // Classes + var CLASS_MODAL = 'cropper-modal'; + var CLASS_HIDE = 'cropper-hide'; + var CLASS_HIDDEN = 'cropper-hidden'; + var CLASS_INVISIBLE = 'cropper-invisible'; + var CLASS_MOVE = 'cropper-move'; + var CLASS_CROP = 'cropper-crop'; + var CLASS_DISABLED = 'cropper-disabled'; + var CLASS_BG = 'cropper-bg'; + + // Events + var EVENT_MOUSE_DOWN = 'mousedown touchstart pointerdown MSPointerDown'; + var EVENT_MOUSE_MOVE = 'mousemove touchmove pointermove MSPointerMove'; + var EVENT_MOUSE_UP = 'mouseup touchend touchcancel pointerup pointercancel MSPointerUp MSPointerCancel'; + var EVENT_WHEEL = 'wheel mousewheel DOMMouseScroll'; + var EVENT_DBLCLICK = 'dblclick'; + var EVENT_LOAD = 'load.' + NAMESPACE; + var EVENT_ERROR = 'error.' + NAMESPACE; + var EVENT_RESIZE = 'resize.' + NAMESPACE; // Bind to window with namespace + var EVENT_BUILD = 'build.' + NAMESPACE; + var EVENT_BUILT = 'built.' + NAMESPACE; + var EVENT_CROP_START = 'cropstart.' + NAMESPACE; + var EVENT_CROP_MOVE = 'cropmove.' + NAMESPACE; + var EVENT_CROP_END = 'cropend.' + NAMESPACE; + var EVENT_CROP = 'crop.' + NAMESPACE; + var EVENT_ZOOM = 'zoom.' + NAMESPACE; + + // RegExps + var REGEXP_ACTIONS = /e|w|s|n|se|sw|ne|nw|all|crop|move|zoom/; + var REGEXP_DATA_URL = /^data\:/; + var REGEXP_DATA_URL_HEAD = /^data\:([^\;]+)\;base64,/; + var REGEXP_DATA_URL_JPEG = /^data\:image\/jpeg.*;base64,/; + + // Data keys + var DATA_PREVIEW = 'preview'; + var DATA_ACTION = 'action'; + + // Actions + var ACTION_EAST = 'e'; + var ACTION_WEST = 'w'; + var ACTION_SOUTH = 's'; + var ACTION_NORTH = 'n'; + var ACTION_SOUTH_EAST = 'se'; + var ACTION_SOUTH_WEST = 'sw'; + var ACTION_NORTH_EAST = 'ne'; + var ACTION_NORTH_WEST = 'nw'; + var ACTION_ALL = 'all'; + var ACTION_CROP = 'crop'; + var ACTION_MOVE = 'move'; + var ACTION_ZOOM = 'zoom'; + var ACTION_NONE = 'none'; + + // Supports + var SUPPORT_CANVAS = $.isFunction($('')[0].getContext); + var IS_SAFARI = navigator && /safari/i.test(navigator.userAgent) && /apple computer/i.test(navigator.vendor); + + // Maths + var num = Number; + var min = Math.min; + var max = Math.max; + var abs = Math.abs; + var sin = Math.sin; + var cos = Math.cos; + var sqrt = Math.sqrt; + var round = Math.round; + var floor = Math.floor; + + // Utilities + var fromCharCode = String.fromCharCode; + + function isNumber(n) { + return typeof n === 'number' && !isNaN(n); + } + + function isUndefined(n) { + return typeof n === 'undefined'; + } + + function toArray(obj, offset) { + var args = []; + + // This is necessary for IE8 + if (isNumber(offset)) { + args.push(offset); + } + + return args.slice.apply(obj, args); + } + + // Custom proxy to avoid jQuery's guid + function proxy(fn, context) { + var args = toArray(arguments, 2); + + return function () { + return fn.apply(context, args.concat(toArray(arguments))); + }; + } + + function isCrossOriginURL(url) { + var parts = url.match(/^(https?:)\/\/([^\:\/\?#]+):?(\d*)/i); + + return parts && ( + parts[1] !== location.protocol || + parts[2] !== location.hostname || + parts[3] !== location.port + ); + } + + function addTimestamp(url) { + var timestamp = 'timestamp=' + (new Date()).getTime(); + + return (url + (url.indexOf('?') === -1 ? '?' : '&') + timestamp); + } + + function getCrossOrigin(crossOrigin) { + return crossOrigin ? ' crossOrigin="' + crossOrigin + '"' : ''; + } + + function getImageSize(image, callback) { + var newImage; + + // Modern browsers (ignore Safari, #120 & #509) + if (image.naturalWidth && !IS_SAFARI) { + return callback(image.naturalWidth, image.naturalHeight); + } + + // IE8: Don't use `new Image()` here (#319) + newImage = document.createElement('img'); + + newImage.onload = function () { + callback(this.width, this.height); + }; + + newImage.src = image.src; + } + + function getTransform(options) { + var transforms = []; + var rotate = options.rotate; + var scaleX = options.scaleX; + var scaleY = options.scaleY; + + if (isNumber(rotate)) { + transforms.push('rotate(' + rotate + 'deg)'); + } + + if (isNumber(scaleX) && isNumber(scaleY)) { + transforms.push('scale(' + scaleX + ',' + scaleY + ')'); + } + + return transforms.length ? transforms.join(' ') : 'none'; + } + + function getRotatedSizes(data, isReversed) { + var deg = abs(data.degree) % 180; + var arc = (deg > 90 ? (180 - deg) : deg) * Math.PI / 180; + var sinArc = sin(arc); + var cosArc = cos(arc); + var width = data.width; + var height = data.height; + var aspectRatio = data.aspectRatio; + var newWidth; + var newHeight; + + if (!isReversed) { + newWidth = width * cosArc + height * sinArc; + newHeight = width * sinArc + height * cosArc; + } else { + newWidth = width / (cosArc + sinArc / aspectRatio); + newHeight = newWidth / aspectRatio; + } + + return { + width: newWidth, + height: newHeight + }; + } + + function getSourceCanvas(image, data) { + var canvas = $('')[0]; + var context = canvas.getContext('2d'); + var dstX = 0; + var dstY = 0; + var dstWidth = data.naturalWidth; + var dstHeight = data.naturalHeight; + var rotate = data.rotate; + var scaleX = data.scaleX; + var scaleY = data.scaleY; + var scalable = isNumber(scaleX) && isNumber(scaleY) && (scaleX !== 1 || scaleY !== 1); + var rotatable = isNumber(rotate) && rotate !== 0; + var advanced = rotatable || scalable; + var canvasWidth = dstWidth * abs(scaleX || 1); + var canvasHeight = dstHeight * abs(scaleY || 1); + var translateX; + var translateY; + var rotated; + + if (scalable) { + translateX = canvasWidth / 2; + translateY = canvasHeight / 2; + } + + if (rotatable) { + rotated = getRotatedSizes({ + width: canvasWidth, + height: canvasHeight, + degree: rotate + }); + + canvasWidth = rotated.width; + canvasHeight = rotated.height; + translateX = canvasWidth / 2; + translateY = canvasHeight / 2; + } + + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + if (advanced) { + dstX = -dstWidth / 2; + dstY = -dstHeight / 2; + + context.save(); + context.translate(translateX, translateY); + } + + if (rotatable) { + context.rotate(rotate * Math.PI / 180); + } + + // Should call `scale` after rotated + if (scalable) { + context.scale(scaleX, scaleY); + } + + context.drawImage(image, floor(dstX), floor(dstY), floor(dstWidth), floor(dstHeight)); + + if (advanced) { + context.restore(); + } + + return canvas; + } + + function getTouchesCenter(touches) { + var length = touches.length; + var pageX = 0; + var pageY = 0; + + if (length) { + $.each(touches, function (i, touch) { + pageX += touch.pageX; + pageY += touch.pageY; + }); + + pageX /= length; + pageY /= length; + } + + return { + pageX: pageX, + pageY: pageY + }; + } + + function getStringFromCharCode(dataView, start, length) { + var str = ''; + var i; + + for (i = start, length += start; i < length; i++) { + str += fromCharCode(dataView.getUint8(i)); + } + + return str; + } + + function getOrientation(arrayBuffer) { + var dataView = new DataView(arrayBuffer); + var length = dataView.byteLength; + var orientation; + var exifIDCode; + var tiffOffset; + var firstIFDOffset; + var littleEndian; + var endianness; + var app1Start; + var ifdStart; + var offset; + var i; + + // Only handle JPEG image (start by 0xFFD8) + if (dataView.getUint8(0) === 0xFF && dataView.getUint8(1) === 0xD8) { + offset = 2; + + while (offset < length) { + if (dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) { + app1Start = offset; + break; + } + + offset++; + } + } + + if (app1Start) { + exifIDCode = app1Start + 4; + tiffOffset = app1Start + 10; + + if (getStringFromCharCode(dataView, exifIDCode, 4) === 'Exif') { + endianness = dataView.getUint16(tiffOffset); + littleEndian = endianness === 0x4949; + + if (littleEndian || endianness === 0x4D4D /* bigEndian */) { + if (dataView.getUint16(tiffOffset + 2, littleEndian) === 0x002A) { + firstIFDOffset = dataView.getUint32(tiffOffset + 4, littleEndian); + + if (firstIFDOffset >= 0x00000008) { + ifdStart = tiffOffset + firstIFDOffset; + } + } + } + } + } + + if (ifdStart) { + length = dataView.getUint16(ifdStart, littleEndian); + + for (i = 0; i < length; i++) { + offset = ifdStart + i * 12 + 2; + + if (dataView.getUint16(offset, littleEndian) === 0x0112 /* Orientation */) { + + // 8 is the offset of the current tag's value + offset += 8; + + // Get the original orientation value + orientation = dataView.getUint16(offset, littleEndian); + + // Override the orientation with its default value for Safari (#120) + if (IS_SAFARI) { + dataView.setUint16(offset, 1, littleEndian); + } + + break; + } + } + } + + return orientation; + } + + function dataURLToArrayBuffer(dataURL) { + var base64 = dataURL.replace(REGEXP_DATA_URL_HEAD, ''); + var binary = atob(base64); + var length = binary.length; + var arrayBuffer = new ArrayBuffer(length); + var dataView = new Uint8Array(arrayBuffer); + var i; + + for (i = 0; i < length; i++) { + dataView[i] = binary.charCodeAt(i); + } + + return arrayBuffer; + } + + // Only available for JPEG image + function arrayBufferToDataURL(arrayBuffer) { + var dataView = new Uint8Array(arrayBuffer); + var length = dataView.length; + var base64 = ''; + var i; + + for (i = 0; i < length; i++) { + base64 += fromCharCode(dataView[i]); + } + + return 'data:image/jpeg;base64,' + btoa(base64); + } + + function Cropper(element, options) { + this.$element = $(element); + this.options = $.extend({}, Cropper.DEFAULTS, $.isPlainObject(options) && options); + this.isLoaded = false; + this.isBuilt = false; + this.isCompleted = false; + this.isRotated = false; + this.isCropped = false; + this.isDisabled = false; + this.isReplaced = false; + this.isLimited = false; + this.wheeling = false; + this.isImg = false; + this.originalUrl = ''; + this.canvas = null; + this.cropBox = null; + this.init(); + } + + Cropper.prototype = { + constructor: Cropper, + + init: function () { + var $this = this.$element; + var url; + + if ($this.is('img')) { + this.isImg = true; + + // Should use `$.fn.attr` here. e.g.: "img/picture.jpg" + this.originalUrl = url = $this.attr('src'); + + // Stop when it's a blank image + if (!url) { + return; + } + + // Should use `$.fn.prop` here. e.g.: "http://example.com/img/picture.jpg" + url = $this.prop('src'); + } else if ($this.is('canvas') && SUPPORT_CANVAS) { + url = $this[0].toDataURL(); + } + + this.load(url); + }, + + // A shortcut for triggering custom events + trigger: function (type, data) { + var e = $.Event(type, data); + + this.$element.trigger(e); + + return e; + }, + + load: function (url) { + var options = this.options; + var $this = this.$element; + var read; + var xhr; + + if (!url) { + return; + } + + // Trigger build event first + $this.one(EVENT_BUILD, options.build); + + if (this.trigger(EVENT_BUILD).isDefaultPrevented()) { + return; + } + + this.url = url; + this.image = {}; + + if (!options.checkOrientation || !ArrayBuffer) { + return this.clone(); + } + + read = $.proxy(this.read, this); + + // XMLHttpRequest disallows to open a Data URL in some browsers like IE11 and Safari + if (REGEXP_DATA_URL.test(url)) { + return REGEXP_DATA_URL_JPEG.test(url) ? + read(dataURLToArrayBuffer(url)) : + this.clone(); + } + + xhr = new XMLHttpRequest(); + + xhr.onerror = xhr.onabort = $.proxy(function () { + this.clone(); + }, this); + + xhr.onload = function () { + read(this.response); + }; + + xhr.open('get', url); + xhr.responseType = 'arraybuffer'; + xhr.send(); + }, + + read: function (arrayBuffer) { + var options = this.options; + var orientation = getOrientation(arrayBuffer); + var image = this.image; + var rotate; + var scaleX; + var scaleY; + + if (orientation > 1) { + this.url = arrayBufferToDataURL(arrayBuffer); + + switch (orientation) { + + // flip horizontal + case 2: + scaleX = -1; + break; + + // rotate left 180° + case 3: + rotate = -180; + break; + + // flip vertical + case 4: + scaleY = -1; + break; + + // flip vertical + rotate right 90° + case 5: + rotate = 90; + scaleY = -1; + break; + + // rotate right 90° + case 6: + rotate = 90; + break; + + // flip horizontal + rotate right 90° + case 7: + rotate = 90; + scaleX = -1; + break; + + // rotate left 90° + case 8: + rotate = -90; + break; + } + } + + if (options.rotatable) { + image.rotate = rotate; + } + + if (options.scalable) { + image.scaleX = scaleX; + image.scaleY = scaleY; + } + + this.clone(); + }, + + clone: function () { + var options = this.options; + var $this = this.$element; + var url = this.url; + var crossOrigin = ''; + var crossOriginUrl; + var $clone; + + if (options.checkCrossOrigin && isCrossOriginURL(url)) { + crossOrigin = $this.prop('crossOrigin'); + + if (crossOrigin) { + crossOriginUrl = url; + } else { + crossOrigin = 'anonymous'; + + // Bust cache (#148) when there is not a "crossOrigin" property + crossOriginUrl = addTimestamp(url); + } + } + + this.crossOrigin = crossOrigin; + this.crossOriginUrl = crossOriginUrl; + this.$clone = $clone = $(''); + + if (this.isImg) { + if ($this[0].complete) { + this.start(); + } else { + $this.one(EVENT_LOAD, $.proxy(this.start, this)); + } + } else { + $clone. + one(EVENT_LOAD, $.proxy(this.start, this)). + one(EVENT_ERROR, $.proxy(this.stop, this)). + addClass(CLASS_HIDE). + insertAfter($this); + } + }, + + start: function () { + var $image = this.$element; + var $clone = this.$clone; + + if (!this.isImg) { + $clone.off(EVENT_ERROR, this.stop); + $image = $clone; + } + + getImageSize($image[0], $.proxy(function (naturalWidth, naturalHeight) { + $.extend(this.image, { + naturalWidth: naturalWidth, + naturalHeight: naturalHeight, + aspectRatio: naturalWidth / naturalHeight + }); + + this.isLoaded = true; + this.build(); + }, this)); + }, + + stop: function () { + this.$clone.remove(); + this.$clone = null; + }, + + build: function () { + var options = this.options; + var $this = this.$element; + var $clone = this.$clone; + var $cropper; + var $cropBox; + var $face; + + if (!this.isLoaded) { + return; + } + + // Unbuild first when replace + if (this.isBuilt) { + this.unbuild(); + } + + // Create cropper elements + this.$container = $this.parent(); + this.$cropper = $cropper = $(Cropper.TEMPLATE); + this.$canvas = $cropper.find('.cropper-canvas').append($clone); + this.$dragBox = $cropper.find('.cropper-drag-box'); + this.$cropBox = $cropBox = $cropper.find('.cropper-crop-box'); + this.$viewBox = $cropper.find('.cropper-view-box'); + this.$face = $face = $cropBox.find('.cropper-face'); + + // Hide the original image + $this.addClass(CLASS_HIDDEN).after($cropper); + + // Show the clone image if is hidden + if (!this.isImg) { + $clone.removeClass(CLASS_HIDE); + } + + this.initPreview(); + this.bind(); + + options.aspectRatio = max(0, options.aspectRatio) || NaN; + options.viewMode = max(0, min(3, round(options.viewMode))) || 0; + + if (options.autoCrop) { + this.isCropped = true; + + if (options.modal) { + this.$dragBox.addClass(CLASS_MODAL); + } + } else { + $cropBox.addClass(CLASS_HIDDEN); + } + + if (!options.guides) { + $cropBox.find('.cropper-dashed').addClass(CLASS_HIDDEN); + } + + if (!options.center) { + $cropBox.find('.cropper-center').addClass(CLASS_HIDDEN); + } + + if (options.cropBoxMovable) { + $face.addClass(CLASS_MOVE).data(DATA_ACTION, ACTION_ALL); + } + + if (!options.highlight) { + $face.addClass(CLASS_INVISIBLE); + } + + if (options.background) { + $cropper.addClass(CLASS_BG); + } + + if (!options.cropBoxResizable) { + $cropBox.find('.cropper-line, .cropper-point').addClass(CLASS_HIDDEN); + } + + this.setDragMode(options.dragMode); + this.render(); + this.isBuilt = true; + this.setData(options.data); + $this.one(EVENT_BUILT, options.built); + + // Trigger the built event asynchronously to keep `data('cropper')` is defined + setTimeout($.proxy(function () { + this.trigger(EVENT_BUILT); + this.isCompleted = true; + }, this), 0); + }, + + unbuild: function () { + if (!this.isBuilt) { + return; + } + + this.isBuilt = false; + this.isCompleted = false; + this.initialImage = null; + + // Clear `initialCanvas` is necessary when replace + this.initialCanvas = null; + this.initialCropBox = null; + this.container = null; + this.canvas = null; + + // Clear `cropBox` is necessary when replace + this.cropBox = null; + this.unbind(); + + this.resetPreview(); + this.$preview = null; + + this.$viewBox = null; + this.$cropBox = null; + this.$dragBox = null; + this.$canvas = null; + this.$container = null; + + this.$cropper.remove(); + this.$cropper = null; + }, + + render: function () { + this.initContainer(); + this.initCanvas(); + this.initCropBox(); + + this.renderCanvas(); + + if (this.isCropped) { + this.renderCropBox(); + } + }, + + initContainer: function () { + var options = this.options; + var $this = this.$element; + var $container = this.$container; + var $cropper = this.$cropper; + + $cropper.addClass(CLASS_HIDDEN); + $this.removeClass(CLASS_HIDDEN); + + $cropper.css((this.container = { + width: max($container.width(), num(options.minContainerWidth) || 200), + height: max($container.height(), num(options.minContainerHeight) || 100) + })); + + $this.addClass(CLASS_HIDDEN); + $cropper.removeClass(CLASS_HIDDEN); + }, + + // Canvas (image wrapper) + initCanvas: function () { + var viewMode = this.options.viewMode; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var image = this.image; + var imageNaturalWidth = image.naturalWidth; + var imageNaturalHeight = image.naturalHeight; + var is90Degree = abs(image.rotate) === 90; + var naturalWidth = is90Degree ? imageNaturalHeight : imageNaturalWidth; + var naturalHeight = is90Degree ? imageNaturalWidth : imageNaturalHeight; + var aspectRatio = naturalWidth / naturalHeight; + var canvasWidth = containerWidth; + var canvasHeight = containerHeight; + var canvas; + + if (containerHeight * aspectRatio > containerWidth) { + if (viewMode === 3) { + canvasWidth = containerHeight * aspectRatio; + } else { + canvasHeight = containerWidth / aspectRatio; + } + } else { + if (viewMode === 3) { + canvasHeight = containerWidth / aspectRatio; + } else { + canvasWidth = containerHeight * aspectRatio; + } + } + + canvas = { + naturalWidth: naturalWidth, + naturalHeight: naturalHeight, + aspectRatio: aspectRatio, + width: canvasWidth, + height: canvasHeight + }; + + canvas.oldLeft = canvas.left = (containerWidth - canvasWidth) / 2; + canvas.oldTop = canvas.top = (containerHeight - canvasHeight) / 2; + + this.canvas = canvas; + this.isLimited = (viewMode === 1 || viewMode === 2); + this.limitCanvas(true, true); + this.initialImage = $.extend({}, image); + this.initialCanvas = $.extend({}, canvas); + }, + + limitCanvas: function (isSizeLimited, isPositionLimited) { + var options = this.options; + var viewMode = options.viewMode; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var canvas = this.canvas; + var aspectRatio = canvas.aspectRatio; + var cropBox = this.cropBox; + var isCropped = this.isCropped && cropBox; + var minCanvasWidth; + var minCanvasHeight; + var newCanvasLeft; + var newCanvasTop; + + if (isSizeLimited) { + minCanvasWidth = num(options.minCanvasWidth) || 0; + minCanvasHeight = num(options.minCanvasHeight) || 0; + + if (viewMode) { + if (viewMode > 1) { + minCanvasWidth = max(minCanvasWidth, containerWidth); + minCanvasHeight = max(minCanvasHeight, containerHeight); + + if (viewMode === 3) { + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } else { + minCanvasHeight = minCanvasWidth / aspectRatio; + } + } + } else { + if (minCanvasWidth) { + minCanvasWidth = max(minCanvasWidth, isCropped ? cropBox.width : 0); + } else if (minCanvasHeight) { + minCanvasHeight = max(minCanvasHeight, isCropped ? cropBox.height : 0); + } else if (isCropped) { + minCanvasWidth = cropBox.width; + minCanvasHeight = cropBox.height; + + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } else { + minCanvasHeight = minCanvasWidth / aspectRatio; + } + } + } + } + + if (minCanvasWidth && minCanvasHeight) { + if (minCanvasHeight * aspectRatio > minCanvasWidth) { + minCanvasHeight = minCanvasWidth / aspectRatio; + } else { + minCanvasWidth = minCanvasHeight * aspectRatio; + } + } else if (minCanvasWidth) { + minCanvasHeight = minCanvasWidth / aspectRatio; + } else if (minCanvasHeight) { + minCanvasWidth = minCanvasHeight * aspectRatio; + } + + canvas.minWidth = minCanvasWidth; + canvas.minHeight = minCanvasHeight; + canvas.maxWidth = Infinity; + canvas.maxHeight = Infinity; + } + + if (isPositionLimited) { + if (viewMode) { + newCanvasLeft = containerWidth - canvas.width; + newCanvasTop = containerHeight - canvas.height; + + canvas.minLeft = min(0, newCanvasLeft); + canvas.minTop = min(0, newCanvasTop); + canvas.maxLeft = max(0, newCanvasLeft); + canvas.maxTop = max(0, newCanvasTop); + + if (isCropped && this.isLimited) { + canvas.minLeft = min( + cropBox.left, + cropBox.left + cropBox.width - canvas.width + ); + canvas.minTop = min( + cropBox.top, + cropBox.top + cropBox.height - canvas.height + ); + canvas.maxLeft = cropBox.left; + canvas.maxTop = cropBox.top; + + if (viewMode === 2) { + if (canvas.width >= containerWidth) { + canvas.minLeft = min(0, newCanvasLeft); + canvas.maxLeft = max(0, newCanvasLeft); + } + + if (canvas.height >= containerHeight) { + canvas.minTop = min(0, newCanvasTop); + canvas.maxTop = max(0, newCanvasTop); + } + } + } + } else { + canvas.minLeft = -canvas.width; + canvas.minTop = -canvas.height; + canvas.maxLeft = containerWidth; + canvas.maxTop = containerHeight; + } + } + }, + + renderCanvas: function (isChanged) { + var canvas = this.canvas; + var image = this.image; + var rotate = image.rotate; + var naturalWidth = image.naturalWidth; + var naturalHeight = image.naturalHeight; + var aspectRatio; + var rotated; + + if (this.isRotated) { + this.isRotated = false; + + // Computes rotated sizes with image sizes + rotated = getRotatedSizes({ + width: image.width, + height: image.height, + degree: rotate + }); + + aspectRatio = rotated.width / rotated.height; + + if (aspectRatio !== canvas.aspectRatio) { + canvas.left -= (rotated.width - canvas.width) / 2; + canvas.top -= (rotated.height - canvas.height) / 2; + canvas.width = rotated.width; + canvas.height = rotated.height; + canvas.aspectRatio = aspectRatio; + canvas.naturalWidth = naturalWidth; + canvas.naturalHeight = naturalHeight; + + // Computes rotated sizes with natural image sizes + if (rotate % 180) { + rotated = getRotatedSizes({ + width: naturalWidth, + height: naturalHeight, + degree: rotate + }); + + canvas.naturalWidth = rotated.width; + canvas.naturalHeight = rotated.height; + } + + this.limitCanvas(true, false); + } + } + + if (canvas.width > canvas.maxWidth || canvas.width < canvas.minWidth) { + canvas.left = canvas.oldLeft; + } + + if (canvas.height > canvas.maxHeight || canvas.height < canvas.minHeight) { + canvas.top = canvas.oldTop; + } + + canvas.width = min(max(canvas.width, canvas.minWidth), canvas.maxWidth); + canvas.height = min(max(canvas.height, canvas.minHeight), canvas.maxHeight); + + this.limitCanvas(false, true); + + canvas.oldLeft = canvas.left = min(max(canvas.left, canvas.minLeft), canvas.maxLeft); + canvas.oldTop = canvas.top = min(max(canvas.top, canvas.minTop), canvas.maxTop); + + this.$canvas.css({ + width: canvas.width, + height: canvas.height, + left: canvas.left, + top: canvas.top + }); + + this.renderImage(); + + if (this.isCropped && this.isLimited) { + this.limitCropBox(true, true); + } + + if (isChanged) { + this.output(); + } + }, + + renderImage: function (isChanged) { + var canvas = this.canvas; + var image = this.image; + var reversed; + + if (image.rotate) { + reversed = getRotatedSizes({ + width: canvas.width, + height: canvas.height, + degree: image.rotate, + aspectRatio: image.aspectRatio + }, true); + } + + $.extend(image, reversed ? { + width: reversed.width, + height: reversed.height, + left: (canvas.width - reversed.width) / 2, + top: (canvas.height - reversed.height) / 2 + } : { + width: canvas.width, + height: canvas.height, + left: 0, + top: 0 + }); + + this.$clone.css({ + width: image.width, + height: image.height, + marginLeft: image.left, + marginTop: image.top, + transform: getTransform(image) + }); + + if (isChanged) { + this.output(); + } + }, + + initCropBox: function () { + var options = this.options; + var canvas = this.canvas; + var aspectRatio = options.aspectRatio; + var autoCropArea = num(options.autoCropArea) || 0.8; + var cropBox = { + width: canvas.width, + height: canvas.height + }; + + if (aspectRatio) { + if (canvas.height * aspectRatio > canvas.width) { + cropBox.height = cropBox.width / aspectRatio; + } else { + cropBox.width = cropBox.height * aspectRatio; + } + } + + this.cropBox = cropBox; + this.limitCropBox(true, true); + + // Initialize auto crop area + cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); + cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); + + // The width of auto crop area must large than "minWidth", and the height too. (#164) + cropBox.width = max(cropBox.minWidth, cropBox.width * autoCropArea); + cropBox.height = max(cropBox.minHeight, cropBox.height * autoCropArea); + cropBox.oldLeft = cropBox.left = canvas.left + (canvas.width - cropBox.width) / 2; + cropBox.oldTop = cropBox.top = canvas.top + (canvas.height - cropBox.height) / 2; + + this.initialCropBox = $.extend({}, cropBox); + }, + + limitCropBox: function (isSizeLimited, isPositionLimited) { + var options = this.options; + var aspectRatio = options.aspectRatio; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var canvas = this.canvas; + var cropBox = this.cropBox; + var isLimited = this.isLimited; + var minCropBoxWidth; + var minCropBoxHeight; + var maxCropBoxWidth; + var maxCropBoxHeight; + + if (isSizeLimited) { + minCropBoxWidth = num(options.minCropBoxWidth) || 0; + minCropBoxHeight = num(options.minCropBoxHeight) || 0; + + // The min/maxCropBoxWidth/Height must be less than containerWidth/Height + minCropBoxWidth = min(minCropBoxWidth, containerWidth); + minCropBoxHeight = min(minCropBoxHeight, containerHeight); + maxCropBoxWidth = min(containerWidth, isLimited ? canvas.width : containerWidth); + maxCropBoxHeight = min(containerHeight, isLimited ? canvas.height : containerHeight); + + if (aspectRatio) { + if (minCropBoxWidth && minCropBoxHeight) { + if (minCropBoxHeight * aspectRatio > minCropBoxWidth) { + minCropBoxHeight = minCropBoxWidth / aspectRatio; + } else { + minCropBoxWidth = minCropBoxHeight * aspectRatio; + } + } else if (minCropBoxWidth) { + minCropBoxHeight = minCropBoxWidth / aspectRatio; + } else if (minCropBoxHeight) { + minCropBoxWidth = minCropBoxHeight * aspectRatio; + } + + if (maxCropBoxHeight * aspectRatio > maxCropBoxWidth) { + maxCropBoxHeight = maxCropBoxWidth / aspectRatio; + } else { + maxCropBoxWidth = maxCropBoxHeight * aspectRatio; + } + } + + // The minWidth/Height must be less than maxWidth/Height + cropBox.minWidth = min(minCropBoxWidth, maxCropBoxWidth); + cropBox.minHeight = min(minCropBoxHeight, maxCropBoxHeight); + cropBox.maxWidth = maxCropBoxWidth; + cropBox.maxHeight = maxCropBoxHeight; + } + + if (isPositionLimited) { + if (isLimited) { + cropBox.minLeft = max(0, canvas.left); + cropBox.minTop = max(0, canvas.top); + cropBox.maxLeft = min(containerWidth, canvas.left + canvas.width) - cropBox.width; + cropBox.maxTop = min(containerHeight, canvas.top + canvas.height) - cropBox.height; + } else { + cropBox.minLeft = 0; + cropBox.minTop = 0; + cropBox.maxLeft = containerWidth - cropBox.width; + cropBox.maxTop = containerHeight - cropBox.height; + } + } + }, + + renderCropBox: function () { + var options = this.options; + var container = this.container; + var containerWidth = container.width; + var containerHeight = container.height; + var cropBox = this.cropBox; + + if (cropBox.width > cropBox.maxWidth || cropBox.width < cropBox.minWidth) { + cropBox.left = cropBox.oldLeft; + } + + if (cropBox.height > cropBox.maxHeight || cropBox.height < cropBox.minHeight) { + cropBox.top = cropBox.oldTop; + } + + cropBox.width = min(max(cropBox.width, cropBox.minWidth), cropBox.maxWidth); + cropBox.height = min(max(cropBox.height, cropBox.minHeight), cropBox.maxHeight); + + this.limitCropBox(false, true); + + cropBox.oldLeft = cropBox.left = min(max(cropBox.left, cropBox.minLeft), cropBox.maxLeft); + cropBox.oldTop = cropBox.top = min(max(cropBox.top, cropBox.minTop), cropBox.maxTop); + + if (options.movable && options.cropBoxMovable) { + + // Turn to move the canvas when the crop box is equal to the container + this.$face.data(DATA_ACTION, (cropBox.width === containerWidth && cropBox.height === containerHeight) ? ACTION_MOVE : ACTION_ALL); + } + + this.$cropBox.css({ + width: cropBox.width, + height: cropBox.height, + left: cropBox.left, + top: cropBox.top + }); + + if (this.isCropped && this.isLimited) { + this.limitCanvas(true, true); + } + + if (!this.isDisabled) { + this.output(); + } + }, + + output: function () { + this.preview(); + + if (this.isCompleted) { + this.trigger(EVENT_CROP, this.getData()); + } else if (!this.isBuilt) { + + // Only trigger one crop event before complete + this.$element.one(EVENT_BUILT, $.proxy(function () { + this.trigger(EVENT_CROP, this.getData()); + }, this)); + } + }, + + initPreview: function () { + var crossOrigin = getCrossOrigin(this.crossOrigin); + var url = crossOrigin ? this.crossOriginUrl : this.url; + var $clone2; + + this.$preview = $(this.options.preview); + this.$clone2 = $clone2 = $(''); + this.$viewBox.html($clone2); + this.$preview.each(function () { + var $this = $(this); + + // Save the original size for recover + $this.data(DATA_PREVIEW, { + width: $this.width(), + height: $this.height(), + html: $this.html() + }); + + /** + * Override img element styles + * Add `display:block` to avoid margin top issue + * (Occur only when margin-top <= -height) + */ + $this.html( + '' + ); + }); + }, + + resetPreview: function () { + this.$preview.each(function () { + var $this = $(this); + var data = $this.data(DATA_PREVIEW); + + $this.css({ + width: data.width, + height: data.height + }).html(data.html).removeData(DATA_PREVIEW); + }); + }, + + preview: function () { + var image = this.image; + var canvas = this.canvas; + var cropBox = this.cropBox; + var cropBoxWidth = cropBox.width; + var cropBoxHeight = cropBox.height; + var width = image.width; + var height = image.height; + var left = cropBox.left - canvas.left - image.left; + var top = cropBox.top - canvas.top - image.top; + + if (!this.isCropped || this.isDisabled) { + return; + } + + this.$clone2.css({ + width: width, + height: height, + marginLeft: -left, + marginTop: -top, + transform: getTransform(image) + }); + + this.$preview.each(function () { + var $this = $(this); + var data = $this.data(DATA_PREVIEW); + var originalWidth = data.width; + var originalHeight = data.height; + var newWidth = originalWidth; + var newHeight = originalHeight; + var ratio = 1; + + if (cropBoxWidth) { + ratio = originalWidth / cropBoxWidth; + newHeight = cropBoxHeight * ratio; + } + + if (cropBoxHeight && newHeight > originalHeight) { + ratio = originalHeight / cropBoxHeight; + newWidth = cropBoxWidth * ratio; + newHeight = originalHeight; + } + + $this.css({ + width: newWidth, + height: newHeight + }).find('img').css({ + width: width * ratio, + height: height * ratio, + marginLeft: -left * ratio, + marginTop: -top * ratio, + transform: getTransform(image) + }); + }); + }, + + bind: function () { + var options = this.options; + var $this = this.$element; + var $cropper = this.$cropper; + + if ($.isFunction(options.cropstart)) { + $this.on(EVENT_CROP_START, options.cropstart); + } + + if ($.isFunction(options.cropmove)) { + $this.on(EVENT_CROP_MOVE, options.cropmove); + } + + if ($.isFunction(options.cropend)) { + $this.on(EVENT_CROP_END, options.cropend); + } + + if ($.isFunction(options.crop)) { + $this.on(EVENT_CROP, options.crop); + } + + if ($.isFunction(options.zoom)) { + $this.on(EVENT_ZOOM, options.zoom); + } + + $cropper.on(EVENT_MOUSE_DOWN, $.proxy(this.cropStart, this)); + + if (options.zoomable && options.zoomOnWheel) { + $cropper.on(EVENT_WHEEL, $.proxy(this.wheel, this)); + } + + if (options.toggleDragModeOnDblclick) { + $cropper.on(EVENT_DBLCLICK, $.proxy(this.dblclick, this)); + } + + $document. + on(EVENT_MOUSE_MOVE, (this._cropMove = proxy(this.cropMove, this))). + on(EVENT_MOUSE_UP, (this._cropEnd = proxy(this.cropEnd, this))); + + if (options.responsive) { + $window.on(EVENT_RESIZE, (this._resize = proxy(this.resize, this))); + } + }, + + unbind: function () { + var options = this.options; + var $this = this.$element; + var $cropper = this.$cropper; + + if ($.isFunction(options.cropstart)) { + $this.off(EVENT_CROP_START, options.cropstart); + } + + if ($.isFunction(options.cropmove)) { + $this.off(EVENT_CROP_MOVE, options.cropmove); + } + + if ($.isFunction(options.cropend)) { + $this.off(EVENT_CROP_END, options.cropend); + } + + if ($.isFunction(options.crop)) { + $this.off(EVENT_CROP, options.crop); + } + + if ($.isFunction(options.zoom)) { + $this.off(EVENT_ZOOM, options.zoom); + } + + $cropper.off(EVENT_MOUSE_DOWN, this.cropStart); + + if (options.zoomable && options.zoomOnWheel) { + $cropper.off(EVENT_WHEEL, this.wheel); + } + + if (options.toggleDragModeOnDblclick) { + $cropper.off(EVENT_DBLCLICK, this.dblclick); + } + + $document. + off(EVENT_MOUSE_MOVE, this._cropMove). + off(EVENT_MOUSE_UP, this._cropEnd); + + if (options.responsive) { + $window.off(EVENT_RESIZE, this._resize); + } + }, + + resize: function () { + var restore = this.options.restore; + var $container = this.$container; + var container = this.container; + var canvasData; + var cropBoxData; + var ratio; + + // Check `container` is necessary for IE8 + if (this.isDisabled || !container) { + return; + } + + ratio = $container.width() / container.width; + + // Resize when width changed or height changed + if (ratio !== 1 || $container.height() !== container.height) { + if (restore) { + canvasData = this.getCanvasData(); + cropBoxData = this.getCropBoxData(); + } + + this.render(); + + if (restore) { + this.setCanvasData($.each(canvasData, function (i, n) { + canvasData[i] = n * ratio; + })); + this.setCropBoxData($.each(cropBoxData, function (i, n) { + cropBoxData[i] = n * ratio; + })); + } + } + }, + + dblclick: function () { + if (this.isDisabled) { + return; + } + + if (this.$dragBox.hasClass(CLASS_CROP)) { + this.setDragMode(ACTION_MOVE); + } else { + this.setDragMode(ACTION_CROP); + } + }, + + wheel: function (event) { + var e = event.originalEvent || event; + var ratio = num(this.options.wheelZoomRatio) || 0.1; + var delta = 1; + + if (this.isDisabled) { + return; + } + + event.preventDefault(); + + // Limit wheel speed to prevent zoom too fast + if (this.wheeling) { + return; + } + + this.wheeling = true; + + setTimeout($.proxy(function () { + this.wheeling = false; + }, this), 50); + + if (e.deltaY) { + delta = e.deltaY > 0 ? 1 : -1; + } else if (e.wheelDelta) { + delta = -e.wheelDelta / 120; + } else if (e.detail) { + delta = e.detail > 0 ? 1 : -1; + } + + this.zoom(-delta * ratio, event); + }, + + cropStart: function (event) { + var options = this.options; + var originalEvent = event.originalEvent; + var touches = originalEvent && originalEvent.touches; + var e = event; + var touchesLength; + var action; + + if (this.isDisabled) { + return; + } + + if (touches) { + touchesLength = touches.length; + + if (touchesLength > 1) { + if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { + e = touches[1]; + this.startX2 = e.pageX; + this.startY2 = e.pageY; + action = ACTION_ZOOM; + } else { + return; + } + } + + e = touches[0]; + } + + action = action || $(e.target).data(DATA_ACTION); + + if (REGEXP_ACTIONS.test(action)) { + if (this.trigger(EVENT_CROP_START, { + originalEvent: originalEvent, + action: action + }).isDefaultPrevented()) { + return; + } + + event.preventDefault(); + + this.action = action; + this.cropping = false; + + // IE8 has `event.pageX/Y`, but not `event.originalEvent.pageX/Y` + // IE10 has `event.originalEvent.pageX/Y`, but not `event.pageX/Y` + this.startX = e.pageX || originalEvent && originalEvent.pageX; + this.startY = e.pageY || originalEvent && originalEvent.pageY; + + if (action === ACTION_CROP) { + this.cropping = true; + this.$dragBox.addClass(CLASS_MODAL); + } + } + }, + + cropMove: function (event) { + var options = this.options; + var originalEvent = event.originalEvent; + var touches = originalEvent && originalEvent.touches; + var e = event; + var action = this.action; + var touchesLength; + + if (this.isDisabled) { + return; + } + + if (touches) { + touchesLength = touches.length; + + if (touchesLength > 1) { + if (options.zoomable && options.zoomOnTouch && touchesLength === 2) { + e = touches[1]; + this.endX2 = e.pageX; + this.endY2 = e.pageY; + } else { + return; + } + } + + e = touches[0]; + } + + if (action) { + if (this.trigger(EVENT_CROP_MOVE, { + originalEvent: originalEvent, + action: action + }).isDefaultPrevented()) { + return; + } + + event.preventDefault(); + + this.endX = e.pageX || originalEvent && originalEvent.pageX; + this.endY = e.pageY || originalEvent && originalEvent.pageY; + + this.change(e.shiftKey, action === ACTION_ZOOM ? event : null); + } + }, + + cropEnd: function (event) { + var originalEvent = event.originalEvent; + var action = this.action; + + if (this.isDisabled) { + return; + } + + if (action) { + event.preventDefault(); + + if (this.cropping) { + this.cropping = false; + this.$dragBox.toggleClass(CLASS_MODAL, this.isCropped && this.options.modal); + } + + this.action = ''; + + this.trigger(EVENT_CROP_END, { + originalEvent: originalEvent, + action: action + }); + } + }, + + change: function (shiftKey, event) { + var options = this.options; + var aspectRatio = options.aspectRatio; + var action = this.action; + var container = this.container; + var canvas = this.canvas; + var cropBox = this.cropBox; + var width = cropBox.width; + var height = cropBox.height; + var left = cropBox.left; + var top = cropBox.top; + var right = left + width; + var bottom = top + height; + var minLeft = 0; + var minTop = 0; + var maxWidth = container.width; + var maxHeight = container.height; + var renderable = true; + var offset; + var range; + + // Locking aspect ratio in "free mode" by holding shift key (#259) + if (!aspectRatio && shiftKey) { + aspectRatio = width && height ? width / height : 1; + } + + if (this.limited) { + minLeft = cropBox.minLeft; + minTop = cropBox.minTop; + maxWidth = minLeft + min(container.width, canvas.left + canvas.width); + maxHeight = minTop + min(container.height, canvas.top + canvas.height); + } + + range = { + x: this.endX - this.startX, + y: this.endY - this.startY + }; + + if (aspectRatio) { + range.X = range.y * aspectRatio; + range.Y = range.x / aspectRatio; + } + + switch (action) { + // Move crop box + case ACTION_ALL: + left += range.x; + top += range.y; + break; + + // Resize crop box + case ACTION_EAST: + if (range.x >= 0 && (right >= maxWidth || aspectRatio && + (top <= minTop || bottom >= maxHeight))) { + + renderable = false; + break; + } + + width += range.x; + + if (aspectRatio) { + height = width / aspectRatio; + top -= range.Y / 2; + } + + if (width < 0) { + action = ACTION_WEST; + width = 0; + } + + break; + + case ACTION_NORTH: + if (range.y <= 0 && (top <= minTop || aspectRatio && + (left <= minLeft || right >= maxWidth))) { + + renderable = false; + break; + } + + height -= range.y; + top += range.y; + + if (aspectRatio) { + width = height * aspectRatio; + left += range.X / 2; + } + + if (height < 0) { + action = ACTION_SOUTH; + height = 0; + } + + break; + + case ACTION_WEST: + if (range.x <= 0 && (left <= minLeft || aspectRatio && + (top <= minTop || bottom >= maxHeight))) { + + renderable = false; + break; + } + + width -= range.x; + left += range.x; + + if (aspectRatio) { + height = width / aspectRatio; + top += range.Y / 2; + } + + if (width < 0) { + action = ACTION_EAST; + width = 0; + } + + break; + + case ACTION_SOUTH: + if (range.y >= 0 && (bottom >= maxHeight || aspectRatio && + (left <= minLeft || right >= maxWidth))) { + + renderable = false; + break; + } + + height += range.y; + + if (aspectRatio) { + width = height * aspectRatio; + left -= range.X / 2; + } + + if (height < 0) { + action = ACTION_NORTH; + height = 0; + } + + break; + + case ACTION_NORTH_EAST: + if (aspectRatio) { + if (range.y <= 0 && (top <= minTop || right >= maxWidth)) { + renderable = false; + break; + } + + height -= range.y; + top += range.y; + width = height * aspectRatio; + } else { + if (range.x >= 0) { + if (right < maxWidth) { + width += range.x; + } else if (range.y <= 0 && top <= minTop) { + renderable = false; + } + } else { + width += range.x; + } + + if (range.y <= 0) { + if (top > minTop) { + height -= range.y; + top += range.y; + } + } else { + height -= range.y; + top += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_SOUTH_WEST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_NORTH_WEST; + width = 0; + } else if (height < 0) { + action = ACTION_SOUTH_EAST; + height = 0; + } + + break; + + case ACTION_NORTH_WEST: + if (aspectRatio) { + if (range.y <= 0 && (top <= minTop || left <= minLeft)) { + renderable = false; + break; + } + + height -= range.y; + top += range.y; + width = height * aspectRatio; + left += range.X; + } else { + if (range.x <= 0) { + if (left > minLeft) { + width -= range.x; + left += range.x; + } else if (range.y <= 0 && top <= minTop) { + renderable = false; + } + } else { + width -= range.x; + left += range.x; + } + + if (range.y <= 0) { + if (top > minTop) { + height -= range.y; + top += range.y; + } + } else { + height -= range.y; + top += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_SOUTH_EAST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_NORTH_EAST; + width = 0; + } else if (height < 0) { + action = ACTION_SOUTH_WEST; + height = 0; + } + + break; + + case ACTION_SOUTH_WEST: + if (aspectRatio) { + if (range.x <= 0 && (left <= minLeft || bottom >= maxHeight)) { + renderable = false; + break; + } + + width -= range.x; + left += range.x; + height = width / aspectRatio; + } else { + if (range.x <= 0) { + if (left > minLeft) { + width -= range.x; + left += range.x; + } else if (range.y >= 0 && bottom >= maxHeight) { + renderable = false; + } + } else { + width -= range.x; + left += range.x; + } + + if (range.y >= 0) { + if (bottom < maxHeight) { + height += range.y; + } + } else { + height += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_NORTH_EAST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_SOUTH_EAST; + width = 0; + } else if (height < 0) { + action = ACTION_NORTH_WEST; + height = 0; + } + + break; + + case ACTION_SOUTH_EAST: + if (aspectRatio) { + if (range.x >= 0 && (right >= maxWidth || bottom >= maxHeight)) { + renderable = false; + break; + } + + width += range.x; + height = width / aspectRatio; + } else { + if (range.x >= 0) { + if (right < maxWidth) { + width += range.x; + } else if (range.y >= 0 && bottom >= maxHeight) { + renderable = false; + } + } else { + width += range.x; + } + + if (range.y >= 0) { + if (bottom < maxHeight) { + height += range.y; + } + } else { + height += range.y; + } + } + + if (width < 0 && height < 0) { + action = ACTION_NORTH_WEST; + height = 0; + width = 0; + } else if (width < 0) { + action = ACTION_SOUTH_WEST; + width = 0; + } else if (height < 0) { + action = ACTION_NORTH_EAST; + height = 0; + } + + break; + + // Move canvas + case ACTION_MOVE: + this.move(range.x, range.y); + renderable = false; + break; + + // Zoom canvas + case ACTION_ZOOM: + this.zoom((function (x1, y1, x2, y2) { + var z1 = sqrt(x1 * x1 + y1 * y1); + var z2 = sqrt(x2 * x2 + y2 * y2); + + return (z2 - z1) / z1; + })( + abs(this.startX - this.startX2), + abs(this.startY - this.startY2), + abs(this.endX - this.endX2), + abs(this.endY - this.endY2) + ), event); + this.startX2 = this.endX2; + this.startY2 = this.endY2; + renderable = false; + break; + + // Create crop box + case ACTION_CROP: + if (!range.x || !range.y) { + renderable = false; + break; + } + + offset = this.$cropper.offset(); + left = this.startX - offset.left; + top = this.startY - offset.top; + width = cropBox.minWidth; + height = cropBox.minHeight; + + if (range.x > 0) { + action = range.y > 0 ? ACTION_SOUTH_EAST : ACTION_NORTH_EAST; + } else if (range.x < 0) { + left -= width; + action = range.y > 0 ? ACTION_SOUTH_WEST : ACTION_NORTH_WEST; + } + + if (range.y < 0) { + top -= height; + } + + // Show the crop box if is hidden + if (!this.isCropped) { + this.$cropBox.removeClass(CLASS_HIDDEN); + this.isCropped = true; + + if (this.limited) { + this.limitCropBox(true, true); + } + } + + break; + + // No default + } + + if (renderable) { + cropBox.width = width; + cropBox.height = height; + cropBox.left = left; + cropBox.top = top; + this.action = action; + + this.renderCropBox(); + } + + // Override + this.startX = this.endX; + this.startY = this.endY; + }, + + // Show the crop box manually + crop: function () { + if (!this.isBuilt || this.isDisabled) { + return; + } + + if (!this.isCropped) { + this.isCropped = true; + this.limitCropBox(true, true); + + if (this.options.modal) { + this.$dragBox.addClass(CLASS_MODAL); + } + + this.$cropBox.removeClass(CLASS_HIDDEN); + } + + this.setCropBoxData(this.initialCropBox); + }, + + // Reset the image and crop box to their initial states + reset: function () { + if (!this.isBuilt || this.isDisabled) { + return; + } + + this.image = $.extend({}, this.initialImage); + this.canvas = $.extend({}, this.initialCanvas); + this.cropBox = $.extend({}, this.initialCropBox); + + this.renderCanvas(); + + if (this.isCropped) { + this.renderCropBox(); + } + }, + + // Clear the crop box + clear: function () { + if (!this.isCropped || this.isDisabled) { + return; + } + + $.extend(this.cropBox, { + left: 0, + top: 0, + width: 0, + height: 0 + }); + + this.isCropped = false; + this.renderCropBox(); + + this.limitCanvas(true, true); + + // Render canvas after crop box rendered + this.renderCanvas(); + + this.$dragBox.removeClass(CLASS_MODAL); + this.$cropBox.addClass(CLASS_HIDDEN); + }, + + /** + * Replace the image's src and rebuild the cropper + * + * @param {String} url + * @param {Boolean} onlyColorChanged (optional) + */ + replace: function (url, onlyColorChanged) { + if (!this.isDisabled && url) { + if (this.isImg) { + this.$element.attr('src', url); + } + + if (onlyColorChanged) { + this.url = url; + this.$clone.attr('src', url); + + if (this.isBuilt) { + this.$preview.find('img').add(this.$clone2).attr('src', url); + } + } else { + if (this.isImg) { + this.isReplaced = true; + } + + // Clear previous data + this.options.data = null; + this.load(url); + } + } + }, + + // Enable (unfreeze) the cropper + enable: function () { + if (this.isBuilt) { + this.isDisabled = false; + this.$cropper.removeClass(CLASS_DISABLED); + } + }, + + // Disable (freeze) the cropper + disable: function () { + if (this.isBuilt) { + this.isDisabled = true; + this.$cropper.addClass(CLASS_DISABLED); + } + }, + + // Destroy the cropper and remove the instance from the image + destroy: function () { + var $this = this.$element; + + if (this.isLoaded) { + if (this.isImg && this.isReplaced) { + $this.attr('src', this.originalUrl); + } + + this.unbuild(); + $this.removeClass(CLASS_HIDDEN); + } else { + if (this.isImg) { + $this.off(EVENT_LOAD, this.start); + } else if (this.$clone) { + this.$clone.remove(); + } + } + + $this.removeData(NAMESPACE); + }, + + /** + * Move the canvas with relative offsets + * + * @param {Number} offsetX + * @param {Number} offsetY (optional) + */ + move: function (offsetX, offsetY) { + var canvas = this.canvas; + + this.moveTo( + isUndefined(offsetX) ? offsetX : canvas.left + num(offsetX), + isUndefined(offsetY) ? offsetY : canvas.top + num(offsetY) + ); + }, + + /** + * Move the canvas to an absolute point + * + * @param {Number} x + * @param {Number} y (optional) + */ + moveTo: function (x, y) { + var canvas = this.canvas; + var isChanged = false; + + // If "y" is not present, its default value is "x" + if (isUndefined(y)) { + y = x; + } + + x = num(x); + y = num(y); + + if (this.isBuilt && !this.isDisabled && this.options.movable) { + if (isNumber(x)) { + canvas.left = x; + isChanged = true; + } + + if (isNumber(y)) { + canvas.top = y; + isChanged = true; + } + + if (isChanged) { + this.renderCanvas(true); + } + } + }, + + /** + * Zoom the canvas with a relative ratio + * + * @param {Number} ratio + * @param {jQuery Event} _event (private) + */ + zoom: function (ratio, _event) { + var canvas = this.canvas; + + ratio = num(ratio); + + if (ratio < 0) { + ratio = 1 / (1 - ratio); + } else { + ratio = 1 + ratio; + } + + this.zoomTo(canvas.width * ratio / canvas.naturalWidth, _event); + }, + + /** + * Zoom the canvas to an absolute ratio + * + * @param {Number} ratio + * @param {jQuery Event} _event (private) + */ + zoomTo: function (ratio, _event) { + var options = this.options; + var canvas = this.canvas; + var width = canvas.width; + var height = canvas.height; + var naturalWidth = canvas.naturalWidth; + var naturalHeight = canvas.naturalHeight; + var originalEvent; + var newWidth; + var newHeight; + var offset; + var center; + + ratio = num(ratio); + + if (ratio >= 0 && this.isBuilt && !this.isDisabled && options.zoomable) { + newWidth = naturalWidth * ratio; + newHeight = naturalHeight * ratio; + + if (_event) { + originalEvent = _event.originalEvent; + } + + if (this.trigger(EVENT_ZOOM, { + originalEvent: originalEvent, + oldRatio: width / naturalWidth, + ratio: newWidth / naturalWidth + }).isDefaultPrevented()) { + return; + } + + if (originalEvent) { + offset = this.$cropper.offset(); + center = originalEvent.touches ? getTouchesCenter(originalEvent.touches) : { + pageX: _event.pageX || originalEvent.pageX || 0, + pageY: _event.pageY || originalEvent.pageY || 0 + }; + + // Zoom from the triggering point of the event + canvas.left -= (newWidth - width) * ( + ((center.pageX - offset.left) - canvas.left) / width + ); + canvas.top -= (newHeight - height) * ( + ((center.pageY - offset.top) - canvas.top) / height + ); + } else { + + // Zoom from the center of the canvas + canvas.left -= (newWidth - width) / 2; + canvas.top -= (newHeight - height) / 2; + } + + canvas.width = newWidth; + canvas.height = newHeight; + this.renderCanvas(true); + } + }, + + /** + * Rotate the canvas with a relative degree + * + * @param {Number} degree + */ + rotate: function (degree) { + this.rotateTo((this.image.rotate || 0) + num(degree)); + }, + + /** + * Rotate the canvas to an absolute degree + * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#rotate() + * + * @param {Number} degree + */ + rotateTo: function (degree) { + degree = num(degree); + + if (isNumber(degree) && this.isBuilt && !this.isDisabled && this.options.rotatable) { + this.image.rotate = degree % 360; + this.isRotated = true; + this.renderCanvas(true); + } + }, + + /** + * Scale the image + * https://developer.mozilla.org/en-US/docs/Web/CSS/transform-function#scale() + * + * @param {Number} scaleX + * @param {Number} scaleY (optional) + */ + scale: function (scaleX, scaleY) { + var image = this.image; + var isChanged = false; + + // If "scaleY" is not present, its default value is "scaleX" + if (isUndefined(scaleY)) { + scaleY = scaleX; + } + + scaleX = num(scaleX); + scaleY = num(scaleY); + + if (this.isBuilt && !this.isDisabled && this.options.scalable) { + if (isNumber(scaleX)) { + image.scaleX = scaleX; + isChanged = true; + } + + if (isNumber(scaleY)) { + image.scaleY = scaleY; + isChanged = true; + } + + if (isChanged) { + this.renderImage(true); + } + } + }, + + /** + * Scale the abscissa of the image + * + * @param {Number} scaleX + */ + scaleX: function (scaleX) { + var scaleY = this.image.scaleY; + + this.scale(scaleX, isNumber(scaleY) ? scaleY : 1); + }, + + /** + * Scale the ordinate of the image + * + * @param {Number} scaleY + */ + scaleY: function (scaleY) { + var scaleX = this.image.scaleX; + + this.scale(isNumber(scaleX) ? scaleX : 1, scaleY); + }, + + /** + * Get the cropped area position and size data (base on the original image) + * + * @param {Boolean} isRounded (optional) + * @return {Object} data + */ + getData: function (isRounded) { + var options = this.options; + var image = this.image; + var canvas = this.canvas; + var cropBox = this.cropBox; + var ratio; + var data; + + if (this.isBuilt && this.isCropped) { + data = { + x: cropBox.left - canvas.left, + y: cropBox.top - canvas.top, + width: cropBox.width, + height: cropBox.height + }; + + ratio = image.width / image.naturalWidth; + + $.each(data, function (i, n) { + n = n / ratio; + data[i] = isRounded ? round(n) : n; + }); + + } else { + data = { + x: 0, + y: 0, + width: 0, + height: 0 + }; + } + + if (options.rotatable) { + data.rotate = image.rotate || 0; + } + + if (options.scalable) { + data.scaleX = image.scaleX || 1; + data.scaleY = image.scaleY || 1; + } + + return data; + }, + + /** + * Set the cropped area position and size with new data + * + * @param {Object} data + */ + setData: function (data) { + var options = this.options; + var image = this.image; + var canvas = this.canvas; + var cropBoxData = {}; + var isRotated; + var isScaled; + var ratio; + + if ($.isFunction(data)) { + data = data.call(this.element); + } + + if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { + if (options.rotatable) { + if (isNumber(data.rotate) && data.rotate !== image.rotate) { + image.rotate = data.rotate; + this.isRotated = isRotated = true; + } + } + + if (options.scalable) { + if (isNumber(data.scaleX) && data.scaleX !== image.scaleX) { + image.scaleX = data.scaleX; + isScaled = true; + } + + if (isNumber(data.scaleY) && data.scaleY !== image.scaleY) { + image.scaleY = data.scaleY; + isScaled = true; + } + } + + if (isRotated) { + this.renderCanvas(); + } else if (isScaled) { + this.renderImage(); + } + + ratio = image.width / image.naturalWidth; + + if (isNumber(data.x)) { + cropBoxData.left = data.x * ratio + canvas.left; + } + + if (isNumber(data.y)) { + cropBoxData.top = data.y * ratio + canvas.top; + } + + if (isNumber(data.width)) { + cropBoxData.width = data.width * ratio; + } + + if (isNumber(data.height)) { + cropBoxData.height = data.height * ratio; + } + + this.setCropBoxData(cropBoxData); + } + }, + + /** + * Get the container size data + * + * @return {Object} data + */ + getContainerData: function () { + return this.isBuilt ? this.container : {}; + }, + + /** + * Get the image position and size data + * + * @return {Object} data + */ + getImageData: function () { + return this.isLoaded ? this.image : {}; + }, + + /** + * Get the canvas position and size data + * + * @return {Object} data + */ + getCanvasData: function () { + var canvas = this.canvas; + var data = {}; + + if (this.isBuilt) { + $.each([ + 'left', + 'top', + 'width', + 'height', + 'naturalWidth', + 'naturalHeight' + ], function (i, n) { + data[n] = canvas[n]; + }); + } + + return data; + }, + + /** + * Set the canvas position and size with new data + * + * @param {Object} data + */ + setCanvasData: function (data) { + var canvas = this.canvas; + var aspectRatio = canvas.aspectRatio; + + if ($.isFunction(data)) { + data = data.call(this.$element); + } + + if (this.isBuilt && !this.isDisabled && $.isPlainObject(data)) { + if (isNumber(data.left)) { + canvas.left = data.left; + } + + if (isNumber(data.top)) { + canvas.top = data.top; + } + + if (isNumber(data.width)) { + canvas.width = data.width; + canvas.height = data.width / aspectRatio; + } else if (isNumber(data.height)) { + canvas.height = data.height; + canvas.width = data.height * aspectRatio; + } + + this.renderCanvas(true); + } + }, + + /** + * Get the crop box position and size data + * + * @return {Object} data + */ + getCropBoxData: function () { + var cropBox = this.cropBox; + var data; + + if (this.isBuilt && this.isCropped) { + data = { + left: cropBox.left, + top: cropBox.top, + width: cropBox.width, + height: cropBox.height + }; + } + + return data || {}; + }, + + /** + * Set the crop box position and size with new data + * + * @param {Object} data + */ + setCropBoxData: function (data) { + var cropBox = this.cropBox; + var aspectRatio = this.options.aspectRatio; + var isWidthChanged; + var isHeightChanged; + + if ($.isFunction(data)) { + data = data.call(this.$element); + } + + if (this.isBuilt && this.isCropped && !this.isDisabled && $.isPlainObject(data)) { + + if (isNumber(data.left)) { + cropBox.left = data.left; + } + + if (isNumber(data.top)) { + cropBox.top = data.top; + } + + if (isNumber(data.width)) { + isWidthChanged = true; + cropBox.width = data.width; + } + + if (isNumber(data.height)) { + isHeightChanged = true; + cropBox.height = data.height; + } + + if (aspectRatio) { + if (isWidthChanged) { + cropBox.height = cropBox.width / aspectRatio; + } else if (isHeightChanged) { + cropBox.width = cropBox.height * aspectRatio; + } + } + + this.renderCropBox(); + } + }, + + /** + * Get a canvas drawn the cropped image + * + * @param {Object} options (optional) + * @return {HTMLCanvasElement} canvas + */ + getCroppedCanvas: function (options) { + var originalWidth; + var originalHeight; + var canvasWidth; + var canvasHeight; + var scaledWidth; + var scaledHeight; + var scaledRatio; + var aspectRatio; + var canvas; + var context; + var data; + + if (!this.isBuilt || !this.isCropped || !SUPPORT_CANVAS) { + return; + } + + if (!$.isPlainObject(options)) { + options = {}; + } + + data = this.getData(); + originalWidth = data.width; + originalHeight = data.height; + aspectRatio = originalWidth / originalHeight; + + if ($.isPlainObject(options)) { + scaledWidth = options.width; + scaledHeight = options.height; + + if (scaledWidth) { + scaledHeight = scaledWidth / aspectRatio; + scaledRatio = scaledWidth / originalWidth; + } else if (scaledHeight) { + scaledWidth = scaledHeight * aspectRatio; + scaledRatio = scaledHeight / originalHeight; + } + } + + // The canvas element will use `Math.floor` on a float number, so floor first + canvasWidth = floor(scaledWidth || originalWidth); + canvasHeight = floor(scaledHeight || originalHeight); + + canvas = $('')[0]; + canvas.width = canvasWidth; + canvas.height = canvasHeight; + context = canvas.getContext('2d'); + + if (options.fillColor) { + context.fillStyle = options.fillColor; + context.fillRect(0, 0, canvasWidth, canvasHeight); + } + + // https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D.drawImage + context.drawImage.apply(context, (function () { + var source = getSourceCanvas(this.$clone[0], this.image); + var sourceWidth = source.width; + var sourceHeight = source.height; + var canvas = this.canvas; + var params = [source]; + + // Source canvas + var srcX = data.x + canvas.naturalWidth * (abs(data.scaleX || 1) - 1) / 2; + var srcY = data.y + canvas.naturalHeight * (abs(data.scaleY || 1) - 1) / 2; + var srcWidth; + var srcHeight; + + // Destination canvas + var dstX; + var dstY; + var dstWidth; + var dstHeight; + + if (srcX <= -originalWidth || srcX > sourceWidth) { + srcX = srcWidth = dstX = dstWidth = 0; + } else if (srcX <= 0) { + dstX = -srcX; + srcX = 0; + srcWidth = dstWidth = min(sourceWidth, originalWidth + srcX); + } else if (srcX <= sourceWidth) { + dstX = 0; + srcWidth = dstWidth = min(originalWidth, sourceWidth - srcX); + } + + if (srcWidth <= 0 || srcY <= -originalHeight || srcY > sourceHeight) { + srcY = srcHeight = dstY = dstHeight = 0; + } else if (srcY <= 0) { + dstY = -srcY; + srcY = 0; + srcHeight = dstHeight = min(sourceHeight, originalHeight + srcY); + } else if (srcY <= sourceHeight) { + dstY = 0; + srcHeight = dstHeight = min(originalHeight, sourceHeight - srcY); + } + + // All the numerical parameters should be integer for `drawImage` (#476) + params.push(floor(srcX), floor(srcY), floor(srcWidth), floor(srcHeight)); + + // Scale destination sizes + if (scaledRatio) { + dstX *= scaledRatio; + dstY *= scaledRatio; + dstWidth *= scaledRatio; + dstHeight *= scaledRatio; + } + + // Avoid "IndexSizeError" in IE and Firefox + if (dstWidth > 0 && dstHeight > 0) { + params.push(floor(dstX), floor(dstY), floor(dstWidth), floor(dstHeight)); + } + + return params; + }).call(this)); + + return canvas; + }, + + /** + * Change the aspect ratio of the crop box + * + * @param {Number} aspectRatio + */ + setAspectRatio: function (aspectRatio) { + var options = this.options; + + if (!this.isDisabled && !isUndefined(aspectRatio)) { + + // 0 -> NaN + options.aspectRatio = max(0, aspectRatio) || NaN; + + if (this.isBuilt) { + this.initCropBox(); + + if (this.isCropped) { + this.renderCropBox(); + } + } + } + }, + + /** + * Change the drag mode + * + * @param {String} mode (optional) + */ + setDragMode: function (mode) { + var options = this.options; + var croppable; + var movable; + + if (this.isLoaded && !this.isDisabled) { + croppable = mode === ACTION_CROP; + movable = options.movable && mode === ACTION_MOVE; + mode = (croppable || movable) ? mode : ACTION_NONE; + + this.$dragBox. + data(DATA_ACTION, mode). + toggleClass(CLASS_CROP, croppable). + toggleClass(CLASS_MOVE, movable); + + if (!options.cropBoxMovable) { + + // Sync drag mode to crop box when it is not movable(#300) + this.$face. + data(DATA_ACTION, mode). + toggleClass(CLASS_CROP, croppable). + toggleClass(CLASS_MOVE, movable); + } + } + } + }; + + Cropper.DEFAULTS = { + + // Define the view mode of the cropper + viewMode: 0, // 0, 1, 2, 3 + + // Define the dragging mode of the cropper + dragMode: 'crop', // 'crop', 'move' or 'none' + + // Define the aspect ratio of the crop box + aspectRatio: NaN, + + // An object with the previous cropping result data + data: null, + + // A jQuery selector for adding extra containers to preview + preview: '', + + // Re-render the cropper when resize the window + responsive: true, + + // Restore the cropped area after resize the window + restore: true, + + // Check if the current image is a cross-origin image + checkCrossOrigin: true, + + // Check the current image's Exif Orientation information + checkOrientation: true, + + // Show the black modal + modal: true, + + // Show the dashed lines for guiding + guides: true, + + // Show the center indicator for guiding + center: true, + + // Show the white modal to highlight the crop box + highlight: true, + + // Show the grid background + background: true, + + // Enable to crop the image automatically when initialize + autoCrop: true, + + // Define the percentage of automatic cropping area when initializes + autoCropArea: 0.8, + + // Enable to move the image + movable: true, + + // Enable to rotate the image + rotatable: true, + + // Enable to scale the image + scalable: true, + + // Enable to zoom the image + zoomable: true, + + // Enable to zoom the image by dragging touch + zoomOnTouch: true, + + // Enable to zoom the image by wheeling mouse + zoomOnWheel: true, + + // Define zoom ratio when zoom the image by wheeling mouse + wheelZoomRatio: 0.1, + + // Enable to move the crop box + cropBoxMovable: true, + + // Enable to resize the crop box + cropBoxResizable: true, + + // Toggle drag mode between "crop" and "move" when click twice on the cropper + toggleDragModeOnDblclick: true, + + // Size limitation + minCanvasWidth: 0, + minCanvasHeight: 0, + minCropBoxWidth: 0, + minCropBoxHeight: 0, + minContainerWidth: 200, + minContainerHeight: 100, + + // Shortcuts of events + build: null, + built: null, + cropstart: null, + cropmove: null, + cropend: null, + crop: null, + zoom: null + }; + + Cropper.setDefaults = function (options) { + $.extend(Cropper.DEFAULTS, options); + }; + + Cropper.TEMPLATE = ( + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '
    ' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + + '
    ' + + '
    ' + ); + + // Save the other cropper + Cropper.other = $.fn.cropper; + + // Register as jQuery plugin + $.fn.cropper = function (option) { + var args = toArray(arguments, 1); + var result; + + this.each(function () { + var $this = $(this); + var data = $this.data(NAMESPACE); + var options; + var fn; + + if (!data) { + if (/destroy/.test(option)) { + return; + } + + options = $.extend({}, $this.data(), $.isPlainObject(option) && option); + $this.data(NAMESPACE, (data = new Cropper(this, options))); + } + + if (typeof option === 'string' && $.isFunction(fn = data[option])) { + result = fn.apply(data, args); + } + }); + + return isUndefined(result) ? this : result; + }; + + $.fn.cropper.Constructor = Cropper; + $.fn.cropper.setDefaults = Cropper.setDefaults; + + // No conflict + $.fn.cropper.noConflict = function () { + $.fn.cropper = Cropper.other; + return this; + }; + +}); diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 2d301d21ab..e2d590f4df 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -9,6 +9,7 @@ *= require_self *= require dropzone/basic *= require cal-heatmap + *= require cropper.css */ /* diff --git a/app/assets/stylesheets/cropper.css b/app/assets/stylesheets/cropper.css new file mode 100644 index 0000000000..8668c7c049 --- /dev/null +++ b/app/assets/stylesheets/cropper.css @@ -0,0 +1,379 @@ +/*! + * Cropper v2.3.0 + * https://github.com/fengyuanchen/cropper + * + * Copyright (c) 2014-2016 Fengyuan Chen and contributors + * Released under the MIT license + * + * Date: 2016-02-22T02:13:13.332Z + */ +.cropper-container { + font-size: 0; + line-height: 0; + + position: relative; + + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + + direction: ltr !important; + -ms-touch-action: none; + touch-action: none; + -webkit-tap-highlight-color: transparent; + -webkit-touch-callout: none; +} + +.cropper-container img { + display: block; + + width: 100%; + min-width: 0 !important; + max-width: none !important; + height: 100%; + min-height: 0 !important; + max-height: none !important; + + image-orientation: 0deg !important; +} + +.cropper-wrap-box, +.cropper-canvas, +.cropper-drag-box, +.cropper-crop-box, +.cropper-modal { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; +} + +.cropper-wrap-box { + overflow: hidden; +} + +.cropper-drag-box { + opacity: 0; + background-color: #fff; + + filter: alpha(opacity=0); +} + +.cropper-modal { + opacity: .5; + background-color: #000; + + filter: alpha(opacity=50); +} + +.cropper-view-box { + display: block; + overflow: hidden; + + width: 100%; + height: 100%; + + outline: 1px solid #39f; + outline-color: rgba(51, 153, 255, .75); +} + +.cropper-dashed { + position: absolute; + + display: block; + + opacity: .5; + border: 0 dashed #eee; + + filter: alpha(opacity=50); +} + +.cropper-dashed.dashed-h { + top: 33.33333%; + left: 0; + + width: 100%; + height: 33.33333%; + + border-top-width: 1px; + border-bottom-width: 1px; +} + +.cropper-dashed.dashed-v { + top: 0; + left: 33.33333%; + + width: 33.33333%; + height: 100%; + + border-right-width: 1px; + border-left-width: 1px; +} + +.cropper-center { + position: absolute; + top: 50%; + left: 50%; + + display: block; + + width: 0; + height: 0; + + opacity: .75; + + filter: alpha(opacity=75); +} + +.cropper-center:before, +.cropper-center:after { + position: absolute; + + display: block; + + content: ' '; + + background-color: #eee; +} + +.cropper-center:before { + top: 0; + left: -3px; + + width: 7px; + height: 1px; +} + +.cropper-center:after { + top: -3px; + left: 0; + + width: 1px; + height: 7px; +} + +.cropper-face, +.cropper-line, +.cropper-point { + position: absolute; + + display: block; + + width: 100%; + height: 100%; + + opacity: .1; + + filter: alpha(opacity=10); +} + +.cropper-face { + top: 0; + left: 0; + + background-color: #fff; +} + +.cropper-line { + background-color: #39f; +} + +.cropper-line.line-e { + top: 0; + right: -3px; + + width: 5px; + + cursor: e-resize; +} + +.cropper-line.line-n { + top: -3px; + left: 0; + + height: 5px; + + cursor: n-resize; +} + +.cropper-line.line-w { + top: 0; + left: -3px; + + width: 5px; + + cursor: w-resize; +} + +.cropper-line.line-s { + bottom: -3px; + left: 0; + + height: 5px; + + cursor: s-resize; +} + +.cropper-point { + width: 5px; + height: 5px; + + opacity: .75; + background-color: #39f; + + filter: alpha(opacity=75); +} + +.cropper-point.point-e { + top: 50%; + right: -3px; + + margin-top: -3px; + + cursor: e-resize; +} + +.cropper-point.point-n { + top: -3px; + left: 50%; + + margin-left: -3px; + + cursor: n-resize; +} + +.cropper-point.point-w { + top: 50%; + left: -3px; + + margin-top: -3px; + + cursor: w-resize; +} + +.cropper-point.point-s { + bottom: -3px; + left: 50%; + + margin-left: -3px; + + cursor: s-resize; +} + +.cropper-point.point-ne { + top: -3px; + right: -3px; + + cursor: ne-resize; +} + +.cropper-point.point-nw { + top: -3px; + left: -3px; + + cursor: nw-resize; +} + +.cropper-point.point-sw { + bottom: -3px; + left: -3px; + + cursor: sw-resize; +} + +.cropper-point.point-se { + right: -3px; + bottom: -3px; + + width: 20px; + height: 20px; + + cursor: se-resize; + + opacity: 1; + + filter: alpha(opacity=100); +} + +.cropper-point.point-se:before { + position: absolute; + right: -50%; + bottom: -50%; + + display: block; + + width: 200%; + height: 200%; + + content: ' '; + + opacity: 0; + background-color: #39f; + + filter: alpha(opacity=0); +} + +@media (min-width: 768px) { + .cropper-point.point-se { + width: 15px; + height: 15px; + } +} + +@media (min-width: 992px) { + .cropper-point.point-se { + width: 10px; + height: 10px; + } +} + +@media (min-width: 1200px) { + .cropper-point.point-se { + width: 5px; + height: 5px; + + opacity: .75; + + filter: alpha(opacity=75); + } +} + +.cropper-invisible { + opacity: 0; + + filter: alpha(opacity=0); +} + +.cropper-bg { + background-image: url('data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQAQMAAAAlPW0iAAAAA3NCSVQICAjb4U/gAAAABlBMVEXMzMz////TjRV2AAAACXBIWXMAAArrAAAK6wGCiw1aAAAAHHRFWHRTb2Z0d2FyZQBBZG9iZSBGaXJld29ya3MgQ1M26LyyjAAAABFJREFUCJlj+M/AgBVhF/0PAH6/D/HkDxOGAAAAAElFTkSuQmCC'); +} + +.cropper-hide { + position: absolute; + + display: block; + + width: 0; + height: 0; +} + +.cropper-hidden { + display: none !important; +} + +.cropper-move { + cursor: move; +} + +.cropper-crop { + cursor: crosshair; +} + +.cropper-disabled .cropper-drag-box, +.cropper-disabled .cropper-face, +.cropper-disabled .cropper-line, +.cropper-disabled .cropper-point { + cursor: not-allowed; +} From 768008f605e59601585cc32c8f6efbfedb659c30 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 09:41:38 -0500 Subject: [PATCH 231/797] Respond to json requests --- app/controllers/profiles_controller.rb | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 32fca6b838..30e2886cac 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -11,15 +11,16 @@ class ProfilesController < Profiles::ApplicationController def update user_params.except!(:email) if @user.ldap_user? - if @user.update_attributes(user_params) - flash[:notice] = "Profile was successfully updated" - else - messages = @user.errors.full_messages.uniq.join('. ') - flash[:alert] = "Failed to update profile. #{messages}" - end - respond_to do |format| - format.html { redirect_back_or_default(default: { action: 'show' }) } + if @user.update_attributes(user_params) + message = "Profile was successfully updated" + format.html { redirect_back_or_default(default: { action: 'show' }, options: { notice: message }) } + format.json { render json: { message: message } } + else + message = @user.errors.full_messages.uniq.join('. ') + format.html { redirect_back_or_default(default: { action: 'show' }, options: { alert: "Failed to update profile. #{message}" }) } + format.json { render json: { message: message }, status: :unprocessable_entity } + end end end From 8ffc04ebc9723bed25721cbb84ab01f0843c7a92 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 09:44:08 -0500 Subject: [PATCH 232/797] Add GitLabCrop class --- app/assets/javascripts/gl_crop.js.coffee | 118 +++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 app/assets/javascripts/gl_crop.js.coffee diff --git a/app/assets/javascripts/gl_crop.js.coffee b/app/assets/javascripts/gl_crop.js.coffee new file mode 100644 index 0000000000..5d5b2c406a --- /dev/null +++ b/app/assets/javascripts/gl_crop.js.coffee @@ -0,0 +1,118 @@ +class GitLabCrop + constructor: (el, opts = {}) -> + # Input file + @fileInput = $(el) + + # Set defaults + { + @filename + @form = @fileInput.parents('form') + @modalCrop = '.modal-profile-crop' + + # Button where user clicks to open file dialog + # If not passed as argument let's pick a default one + @pickImageEl = @fileInput.parent().find('.js-choose-user-avatar-button') + @uploadImageBtn = $('.js-upload-user-avatar') + } = opts + + # Ensure @modalCrop is a jQuery Object + @modalCrop = $(@modalCrop) + @modalCropImg = $('.modal-profile-crop-image') + @cropActionsBtn = @modalCrop.find('[data-method]') + + @bindEvents() + + bindEvents: -> + self = @ + @fileInput.on 'change', (e) -> + self.onFileInputChange(e, @) + + @pickImageEl.on 'click', @onPickImageClick + @modalCrop.on 'shown.bs.modal', @onModalShow + @modalCrop.on 'hidden.bs.modal', @onModalHide + @uploadImageBtn.on 'click', @onUploadImageBtnClick + @cropActionsBtn.on 'click', (e) -> + btn = @ + self.onActionBtnClick(btn) + @croppedImageBlob = null + + onPickImageClick: => + @fileInput.trigger('click') + + onModalShow: => + @modalCropImg.cropper( + viewMode: 1 + center: false + aspectRatio: 1 + modal: true + scalable: false + rotatable: false + zoomable: true + dragMode: 'move' + guides: false + zoomOnTouch: false + zoomOnWheel: false + cropBoxMovable: false + cropBoxResizable: false + toggleDragModeOnDblclick: false + built: -> + container = $(@).cropper 'getContainerData' + cropBoxWidth = 200; + cropBoxHeight = 200; + + $(@).cropper('setCropBoxData', + width: cropBoxWidth, + height: cropBoxHeight, + left: (container.width - cropBoxWidth) / 2, + top: (container.height - cropBoxHeight) / 2 + ) + ) + + + onModalHide: => + @modalCropImg + .attr('src', '') # Remove attached image + .cropper('destroy') # Destroy cropper instance + + onUploadImageBtnClick: (e) => + e.preventDefault() + @setBlob() + @modalCrop.modal('hide') + # @form.submit(); + + onActionBtnClick: (btn) -> + data = $(btn).data() + + if @modalCropImg.data('cropper') && data.method + data = $.extend {}, data + result = @modalCropImg.cropper data.method, data.option + + onFileInputChange: (e, input) -> + @readFile(input) + + readFile: (input) -> + self = @ + reader = new FileReader + reader.onload = -> + self.modalCropImg.attr('src', reader.result) + self.modalCrop.modal('show') + + reader.readAsDataURL(input.files[0]) + + dataURLtoBlob: (dataURL) -> + binary = atob(dataURL.split(',')[1]) + array = [] + for v, k in binary + array.push(binary.charCodeAt(k)) + new Blob([new Uint8Array(array)], type: 'image/png') + + setBlob: -> + dataURL = @modalCropImg.cropper('getCroppedCanvas').toDataURL('image/png') + @croppedImageBlob = @dataURLtoBlob(dataURL) + + getBlob: -> + @croppedImageBlob + +$.fn.glCrop = (opts) -> + return @.each -> + $(@).data('glcrop', new GitLabCrop(@, opts)) From 707be371eecda44b989f672fc12950a72bd2c502 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 09:46:50 -0500 Subject: [PATCH 233/797] Handle form submit via ajax and crop avatar on the client --- app/assets/javascripts/profile.js.coffee | 43 +++++++++++++++++++----- 1 file changed, 35 insertions(+), 8 deletions(-) diff --git a/app/assets/javascripts/profile.js.coffee b/app/assets/javascripts/profile.js.coffee index 20f8744055..f81d7dd6e6 100644 --- a/app/assets/javascripts/profile.js.coffee +++ b/app/assets/javascripts/profile.js.coffee @@ -1,5 +1,9 @@ class @Profile - constructor: -> + constructor: (opts = {}) -> + { + @form = $('.edit-user') + } = opts + # Automatically submit the Preferences form when any of its radio buttons change $('.js-preferences-form').on 'change.preference', 'input[type=radio]', -> $(this).parents('form').submit() @@ -17,14 +21,37 @@ class @Profile $('.update-notifications').on 'ajax:complete', -> $(this).find('.btn-save').enable() - $('.js-choose-user-avatar-button').bind "click", -> - form = $(this).closest("form") - form.find(".js-user-avatar-input").click() + @bindEvents() - $('.js-user-avatar-input').bind "change", -> - form = $(this).closest("form") - filename = $(this).val().replace(/^.*[\\\/]/, '') - form.find(".js-avatar-filename").text(filename) + @avatarGlCrop = $('.js-user-avatar-input').glCrop().data 'glcrop' + + bindEvents: -> + @form.on 'submit', @onSubmitForm + + onSubmitForm: (e) => + e.preventDefault() + @saveForm() + + saveForm: -> + self = @ + + formData = new FormData(@form[0]) + formData.append('user[avatar]', @avatarGlCrop.getBlob(), 'avatar.png') + + $.ajax + url: @form.attr('action') + type: @form.attr('method') + data: formData + dataType: "json" + processData: false + contentType: false + success: (response) -> + new Flash(response.message, 'notice') + error: (jqXHR) -> + new Flash(jqXHR.responseJSON.message, 'alert') + complete: -> + window.scrollTo 0, 0 + self.form.find(':input[disabled]').enable() $ -> # Extract the SSH Key title from its comment From 7216ce97f748988c644b1baff2ec42006e508fbc Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 09:48:47 -0500 Subject: [PATCH 234/797] Add modal dialog --- app/assets/stylesheets/pages/profile.scss | 17 ++++++++++++++++ app/views/profiles/show.html.haml | 24 ++++++++++++++++++++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 260179074c..0cd37e7014 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -197,3 +197,20 @@ width: 105px; } } + +.modal-profile-crop { + .modal-dialog { + width: 380px; + } + + .profile-crop-image-container { + width: 350px; + height: 350px; + margin: 0 auto; + } + + .crop-controls { + padding: 10px 0 0 0; + text-align: center; + } +} diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index cd582ba706..2d9129470f 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -26,7 +26,7 @@ %a.btn.js-choose-user-avatar-button Browse file... %span.avatar-file-name.prepend-left-default.js-avatar-filename No file chosen - = f.file_field :avatar, class: "js-user-avatar-input hidden" + = f.file_field :avatar_dialog_trigger, class: "js-user-avatar-input hidden", accept: "image/*" .help-block The maximum file size allowed is 200KB. - if @user.avatar? @@ -94,3 +94,25 @@ .prepend-top-default.append-bottom-default = f.submit 'Update profile settings', class: "btn btn-success" = link_to "Cancel", user_path(current_user), class: "btn btn-cancel" + +.modal.modal-profile-crop + .modal-dialog + .modal-content + .modal-header + %button.close{:type => "button", :'data-dismiss' => "modal"} + %span + × + %h4.modal-title + Position and size your new avatar + .modal-body + .profile-crop-image-container + %img.modal-profile-crop-image + .crop-controls + .btn-group + %button.btn.btn-primary{ data: { method: "zoom", option: "0.1" } } + %span.fa.fa-search-plus + %button.btn.btn-primary{ data: { method: "zoom", option: "-0.1" } } + %span.fa.fa-search-minus + .modal-footer + %button.btn.btn-primary.js-upload-user-avatar{:type => "button"} + Set new profile picture From 4117c815df73ba80258ca472470735826c68753a Mon Sep 17 00:00:00 2001 From: Geoffrey Lalonde Date: Thu, 17 Mar 2016 07:54:56 -0700 Subject: [PATCH 235/797] adjusted behavior so canceled builds tagged as allowed to fail do not fail build --- app/models/commit_status.rb | 2 +- spec/lib/ci/status_spec.rb | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 3b1aa0f5c8..3377a85a55 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -114,7 +114,7 @@ class CommitStatus < ActiveRecord::Base end def ignored? - failed? && allow_failure? + allow_failure? && (failed? || canceled?) end def duration diff --git a/spec/lib/ci/status_spec.rb b/spec/lib/ci/status_spec.rb index 1539720bb8..47f3df6e3c 100644 --- a/spec/lib/ci/status_spec.rb +++ b/spec/lib/ci/status_spec.rb @@ -48,6 +48,29 @@ describe Ci::Status do it { is_expected.to eq 'success' } end + context 'success and canceled' do + let(:statuses) do + [create(type, status: :success), create(type, status: :canceled)] + end + it { is_expected.to eq 'failed' } + end + + context 'all canceled' do + let(:statuses) do + [create(type, status: :canceled), create(type, status: :canceled)] + end + it { is_expected.to eq 'canceled' } + end + + context 'success and canceled but allowed to fail' do + let(:statuses) do + [create(type, status: :success), + create(type, status: :canceled, allow_failure: true)] + end + + it { is_expected.to eq 'success' } + end + context 'one finished and second running but allowed to fail' do let(:statuses) do [create(type, status: :success), From 573c8d561f369e8ceac21787a66df42d214bbecf Mon Sep 17 00:00:00 2001 From: Geoffrey Lalonde Date: Thu, 17 Mar 2016 07:46:00 -0700 Subject: [PATCH 236/797] added changelog entry --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index fcf659c07f..ff417e1a3f 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -41,6 +41,7 @@ v 8.6.0 (unreleased) - Move group activity to separate page - Continue parameters are checked to ensure redirection goes to the same instance - User deletion is now done in the background so the request can not time out + - Canceled builds are now ignored in compound build status if marked as `allowed to fail` v 8.5.7 - Bump Git version requirement to 2.7.3 From ceaa6943919041c204ec86ebea0347374dffdd44 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 17 Mar 2016 15:10:45 +0000 Subject: [PATCH 237/797] Fixed failing tests --- features/steps/dashboard/todos.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/features/steps/dashboard/todos.rb b/features/steps/dashboard/todos.rb index 9722a5a848..963e4f2136 100644 --- a/features/steps/dashboard/todos.rb +++ b/features/steps/dashboard/todos.rb @@ -41,7 +41,6 @@ class Spinach::Features::DashboardTodos < Spinach::FeatureSteps click_link 'Done' end - expect(page).to have_content 'Todo was successfully marked as done.' expect(page).to have_content 'To do 3' expect(page).to have_content 'Done 1' should_not_see_todo "John Doe assigned you merge request !#{merge_request.iid}" From 0ce1f9387a0bb92b6e6656fa90e7d456f82aded1 Mon Sep 17 00:00:00 2001 From: Pat Turner Date: Thu, 17 Mar 2016 15:28:55 +0000 Subject: [PATCH 238/797] Fix for https://gitlab.com/gitlab-org/gitlab-ce/issues/14366 --- doc/ci/yaml/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index a9b79bbdb1..4a932dfeaf 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -279,6 +279,8 @@ job_name: | Keyword | Required | Description | |---------------|----------|-------------| | script | yes | Defines a shell script which is executed by runner | +| image | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-.gitlab-ci.yml) | +| services | no | Use docker services, covered in [Use Docker - Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-.gitlab-ci.yml) | | stage | no | Defines a build stage (default: `test`) | | type | no | Alias for `stage` | | only | no | Defines a list of git refs for which build is created | From 21952cc7f13c6bf3eaf7c8515d59b79dd2fc6188 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Thu, 17 Mar 2016 17:29:09 +0100 Subject: [PATCH 239/797] Add side padding to content-list if inside panel Signed-off-by: Dmitriy Zaporozhets --- app/assets/stylesheets/framework/lists.scss | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/assets/stylesheets/framework/lists.scss b/app/assets/stylesheets/framework/lists.scss index bfec0911b3..2b4bb1eebf 100644 --- a/app/assets/stylesheets/framework/lists.scss +++ b/app/assets/stylesheets/framework/lists.scss @@ -141,6 +141,10 @@ ul.content-list { } } +.panel > .content-list > li { + padding: $gl-padding-top $gl-padding; +} + ul.controls { padding-top: 1px; float: right; From 95cc46a44a2d871fe700ed08850851f863ef2408 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 11:34:21 -0500 Subject: [PATCH 240/797] Allow to set crop box and export image dimensions --- app/assets/javascripts/gl_crop.js.coffee | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/app/assets/javascripts/gl_crop.js.coffee b/app/assets/javascripts/gl_crop.js.coffee index 5d5b2c406a..276e56b434 100644 --- a/app/assets/javascripts/gl_crop.js.coffee +++ b/app/assets/javascripts/gl_crop.js.coffee @@ -6,8 +6,13 @@ class GitLabCrop # Set defaults { @filename + @previewImage = $('.avatar-image .avatar') @form = @fileInput.parents('form') @modalCrop = '.modal-profile-crop' + @exportWidth = 200 + @exportHeight = 200 + @cropBoxWidth = 200 + @cropBoxHeight = 200 # Button where user clicks to open file dialog # If not passed as argument let's pick a default one @@ -40,6 +45,7 @@ class GitLabCrop @fileInput.trigger('click') onModalShow: => + self = @ @modalCropImg.cropper( viewMode: 1 center: false @@ -57,8 +63,8 @@ class GitLabCrop toggleDragModeOnDblclick: false built: -> container = $(@).cropper 'getContainerData' - cropBoxWidth = 200; - cropBoxHeight = 200; + cropBoxWidth = self.cropBoxWidth; + cropBoxHeight = self.cropBoxHeight; $(@).cropper('setCropBoxData', width: cropBoxWidth, @@ -77,8 +83,8 @@ class GitLabCrop onUploadImageBtnClick: (e) => e.preventDefault() @setBlob() + @setPreview() @modalCrop.modal('hide') - # @form.submit(); onActionBtnClick: (btn) -> data = $(btn).data() @@ -106,9 +112,15 @@ class GitLabCrop array.push(binary.charCodeAt(k)) new Blob([new Uint8Array(array)], type: 'image/png') + setPreview: -> + @previewImage.attr('src', @dataURL) + setBlob: -> - dataURL = @modalCropImg.cropper('getCroppedCanvas').toDataURL('image/png') - @croppedImageBlob = @dataURLtoBlob(dataURL) + @dataURL = @modalCropImg.cropper('getCroppedCanvas', + width: 200 + height: 200 + ).toDataURL('image/png') + @croppedImageBlob = @dataURLtoBlob(@dataURL) getBlob: -> @croppedImageBlob From 358545b8d2b41b24bdb8dc581f9541122212ae56 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 11:34:31 -0500 Subject: [PATCH 241/797] Make it responsive --- app/assets/stylesheets/pages/profile.scss | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 0cd37e7014..3831f88f01 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -201,11 +201,15 @@ .modal-profile-crop { .modal-dialog { width: 380px; + + @media (max-width: $screen-sm-min) { + width: auto; + } + } .profile-crop-image-container { - width: 350px; - height: 350px; + height: 300px; margin: 0 auto; } From 5ae95b1880f99bf0414dccacc51b624c00d6b305 Mon Sep 17 00:00:00 2001 From: Dmitriy Zaporozhets Date: Thu, 17 Mar 2016 17:59:58 +0100 Subject: [PATCH 242/797] Add space to separate different files in diff Signed-off-by: Dmitriy Zaporozhets --- app/assets/stylesheets/pages/diff.scss | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/diff.scss b/app/assets/stylesheets/pages/diff.scss index db06b8288c..d5862a11ac 100644 --- a/app/assets/stylesheets/pages/diff.scss +++ b/app/assets/stylesheets/pages/diff.scss @@ -1,7 +1,7 @@ // Common .diff-file { border: 1px solid $border-color; - border-top: none; + margin-bottom: $gl-padding; .diff-header { position: relative; @@ -361,3 +361,11 @@ border-color: $border; } } + +.files { + margin-top: -1px; + + .diff-file:last-child { + margin-bottom: 0; + } +} From 028c751040b6acc29f3127d5f2d119b899a85205 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 12:20:24 -0500 Subject: [PATCH 243/797] Revert link color from blue --- app/assets/stylesheets/framework/blocks.scss | 4 ---- 1 file changed, 4 deletions(-) diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index 90c3ce0e84..c36f29dda0 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -28,10 +28,6 @@ border-bottom: 1px solid $border-color; color: $gl-gray; - a { - color: $md-link-color; - } - &.oneline-block { line-height: 42px; } From 08ed12d91e589a1e55c6662ce06fe1478e8f9fd5 Mon Sep 17 00:00:00 2001 From: Pat Turner Date: Thu, 17 Mar 2016 17:27:13 +0000 Subject: [PATCH 244/797] removed anchor as unsupported in markdown --- doc/ci/yaml/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 4a932dfeaf..3572b496e1 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -279,8 +279,8 @@ job_name: | Keyword | Required | Description | |---------------|----------|-------------| | script | yes | Defines a shell script which is executed by runner | -| image | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-.gitlab-ci.yml) | -| services | no | Use docker services, covered in [Use Docker - Using Docker Images](../docker/using_docker_images.md#define-image-and-services-from-.gitlab-ci.yml) | +| image | no | Use docker image, covered in [Using Docker Images](../docker/using_docker_images.md) | +| services | no | Use docker services, covered in [Use Docker - Using Docker Images](../docker/using_docker_images.md) | | stage | no | Defines a build stage (default: `test`) | | type | no | Alias for `stage` | | only | no | Defines a list of git refs for which build is created | From 6048910aeef723faa535ce7ba6bb88a5e3360df9 Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Thu, 17 Mar 2016 13:38:35 -0400 Subject: [PATCH 245/797] Prevent a 500 when a repository's root_ref is nil Closes #14259 --- app/views/layouts/header/_default.html.haml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/header/_default.html.haml b/app/views/layouts/header/_default.html.haml index 77d01a7736..f3090b9670 100644 --- a/app/views/layouts/header/_default.html.haml +++ b/app/views/layouts/header/_default.html.haml @@ -46,6 +46,8 @@ %h1.title= title = render 'shared/outdated_browser' + - if @project && !@project.empty_repo? - :javascript - var findFileURL = "#{namespace_project_find_file_path(@project.namespace, @project, @ref || @project.repository.root_ref)}"; + - if ref = @ref || @project.repository.root_ref + :javascript + var findFileURL = "#{namespace_project_find_file_path(@project.namespace, @project, ref)}"; From cd05d3f78d2093baab39f6c3c114d1ab7138309f Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Thu, 17 Mar 2016 16:53:05 +0100 Subject: [PATCH 246/797] Cache project avatars stored in Git The avatar logic has been moved from Project to Repository as this makes caching easier. The logic itself in turn has been changed so that the logo file names are cached in Redis. This cache is flushed upon pushing a commit but _only_ if: 1. The commit was pushed to the default branch 2. The commit actually changes any of the logo files If no branch or commit is given the cache is flushed anyway, this ensures that calling Repository#expire_cache without any arguments still flushes the avatar cache (e.g. this is used when removing a project). Fixes gitlab-org/gitlab-ce#14363 --- app/models/project.rb | 5 +- app/models/repository.rb | 36 +++++++++++- app/services/git_push_service.rb | 2 +- spec/models/repository_spec.rb | 81 +++++++++++++++++++++++++- spec/services/git_push_service_spec.rb | 9 ++- 5 files changed, 120 insertions(+), 13 deletions(-) diff --git a/app/models/project.rb b/app/models/project.rb index ab4913e99a..412c6c6732 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -571,10 +571,7 @@ class Project < ActiveRecord::Base end def avatar_in_git - @avatar_file ||= 'logo.png' if repository.blob_at_branch('master', 'logo.png') - @avatar_file ||= 'logo.jpg' if repository.blob_at_branch('master', 'logo.jpg') - @avatar_file ||= 'logo.gif' if repository.blob_at_branch('master', 'logo.gif') - @avatar_file + repository.avatar end def avatar_url diff --git a/app/models/repository.rb b/app/models/repository.rb index e555e97689..036919c27b 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -3,6 +3,10 @@ require 'securerandom' class Repository class CommitError < StandardError; end + # Files to use as a project avatar in case no avatar was uploaded via the web + # UI. + AVATAR_FILES = %w{logo.png logo.jpg logo.gif} + include Gitlab::ShellAdapter attr_accessor :path_with_namespace, :project @@ -241,12 +245,13 @@ class Repository @branches = nil end - def expire_cache(branch_name = nil) + def expire_cache(branch_name = nil, revision = nil) cache_keys.each do |key| cache.expire(key) end expire_branch_cache(branch_name) + expire_avatar_cache(branch_name, revision) # This ensures this particular cache is flushed after the first commit to a # new repository. @@ -316,6 +321,23 @@ class Repository cache.expire(:branch_names) end + def expire_avatar_cache(branch_name = nil, revision = nil) + # Avatars are pulled from the default branch, thus if somebody pushes to a + # different branch there's no need to expire anything. + return if branch_name && branch_name != root_ref + + # We don't want to flush the cache if the commit didn't actually make any + # changes to any of the possible avatar files. + if revision && commit = self.commit(revision) + return unless commit.diffs. + any? { |diff| AVATAR_FILES.include?(diff.new_path) } + end + + cache.expire(:avatar) + + @avatar = nil + end + # Runs code just before a repository is deleted. def before_delete expire_cache if exists? @@ -350,8 +372,8 @@ class Repository end # Runs code after a new commit has been pushed. - def after_push_commit(branch_name) - expire_cache(branch_name) + def after_push_commit(branch_name, revision) + expire_cache(branch_name, revision) end # Runs code after a new branch has been created. @@ -857,6 +879,14 @@ class Repository end end + def avatar + @avatar ||= cache.fetch(:avatar) do + AVATAR_FILES.find do |file| + blob_at_branch('master', file) + end + end + end + private def cache diff --git a/app/services/git_push_service.rb b/app/services/git_push_service.rb index d840ab5e34..14e2a2c069 100644 --- a/app/services/git_push_service.rb +++ b/app/services/git_push_service.rb @@ -17,7 +17,7 @@ class GitPushService < BaseService # 6. Checks if the project's main language has changed # def execute - @project.repository.after_push_commit(branch_name) + @project.repository.after_push_commit(branch_name, params[:newrev]) if push_remove_branch? @project.repository.after_remove_branch diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index fc2ab2d993..536fe66b21 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -597,9 +597,9 @@ describe Repository, models: true do describe '#after_push_commit' do it 'flushes the cache' do - expect(repository).to receive(:expire_cache).with('master') + expect(repository).to receive(:expire_cache).with('master', '123') - repository.after_push_commit('master') + repository.after_push_commit('master', '123') end end @@ -703,4 +703,81 @@ describe Repository, models: true do repository.rm_tag('8.5') end end + + describe '#avatar' do + it 'returns the first avatar file found in the repository' do + expect(repository).to receive(:blob_at_branch). + with('master', 'logo.png'). + and_return(true) + + expect(repository.avatar).to eq('logo.png') + end + + it 'caches the output' do + allow(repository).to receive(:blob_at_branch). + with('master', 'logo.png'). + and_return(true) + + expect(repository.avatar).to eq('logo.png') + + expect(repository).to_not receive(:blob_at_branch) + expect(repository.avatar).to eq('logo.png') + end + end + + describe '#expire_avatar_cache' do + let(:cache) { repository.send(:cache) } + + before do + allow(repository).to receive(:cache).and_return(cache) + end + + context 'without a branch or revision' do + it 'flushes the cache' do + expect(cache).to receive(:expire).with(:avatar) + + repository.expire_avatar_cache + end + end + + context 'with a branch' do + it 'does not flush the cache if the branch is not the default branch' do + expect(cache).not_to receive(:expire) + + repository.expire_avatar_cache('cats') + end + + it 'flushes the cache if the branch equals the default branch' do + expect(cache).to receive(:expire).with(:avatar) + + repository.expire_avatar_cache(repository.root_ref) + end + end + + context 'with a branch and revision' do + let(:commit) { double(:commit) } + + before do + allow(repository).to receive(:commit).and_return(commit) + end + + it 'does not flush the cache if the commit does not change any logos' do + diff = double(:diff, new_path: 'test.txt') + + expect(commit).to receive(:diffs).and_return([diff]) + expect(cache).not_to receive(:expire) + + repository.expire_avatar_cache(repository.root_ref, '123') + end + + it 'flushes the cache if the commit changes any of the logos' do + diff = double(:diff, new_path: Repository::AVATAR_FILES[0]) + + expect(commit).to receive(:diffs).and_return([diff]) + expect(cache).to receive(:expire).with(:avatar) + + repository.expire_avatar_cache(repository.root_ref, '123') + end + end + end end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index 145bc93756..b49ca96e8e 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -29,7 +29,8 @@ describe GitPushService, services: true do it { is_expected.to be_truthy } it 'flushes general cached data' do - expect(project.repository).to receive(:expire_cache).with('master') + expect(project.repository).to receive(:expire_cache). + with('master', newrev) subject end @@ -46,7 +47,8 @@ describe GitPushService, services: true do it { is_expected.to be_truthy } it 'flushes general cached data' do - expect(project.repository).to receive(:expire_cache).with('master') + expect(project.repository).to receive(:expire_cache). + with('master', newrev) subject end @@ -65,7 +67,8 @@ describe GitPushService, services: true do end it 'flushes general cached data' do - expect(project.repository).to receive(:expire_cache).with('master') + expect(project.repository).to receive(:expire_cache). + with('master', newrev) subject end From 9ed1cbf2176bcab22d522373b2830338dc5c7c69 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 13:20:30 -0500 Subject: [PATCH 247/797] Show filename after setting a cropped image --- app/assets/javascripts/gl_crop.js.coffee | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/gl_crop.js.coffee b/app/assets/javascripts/gl_crop.js.coffee index 276e56b434..4d990a6416 100644 --- a/app/assets/javascripts/gl_crop.js.coffee +++ b/app/assets/javascripts/gl_crop.js.coffee @@ -5,9 +5,9 @@ class GitLabCrop # Set defaults { - @filename - @previewImage = $('.avatar-image .avatar') @form = @fileInput.parents('form') + @filename = '.js-avatar-filename' + @previewImage = $('.avatar-image .avatar') @modalCrop = '.modal-profile-crop' @exportWidth = 200 @exportHeight = 200 @@ -20,13 +20,20 @@ class GitLabCrop @uploadImageBtn = $('.js-upload-user-avatar') } = opts - # Ensure @modalCrop is a jQuery Object - @modalCrop = $(@modalCrop) + # Ensure needed elements are jquery objects + @filename = if _.isString(@filename) then @$(@filename) else @filename + + # Modal usually is outside the wrapper element + @modalCrop = if _.isString(@modalCrop) then $(@modalCrop) else @modalCrop + @modalCropImg = $('.modal-profile-crop-image') @cropActionsBtn = @modalCrop.find('[data-method]') @bindEvents() + $: (selector) -> + $(selector, @form) + bindEvents: -> self = @ @fileInput.on 'change', (e) -> @@ -114,6 +121,8 @@ class GitLabCrop setPreview: -> @previewImage.attr('src', @dataURL) + filename = @fileInput.val().replace(/^.*[\\\/]/, '') + @filename.text(filename) setBlob: -> @dataURL = @modalCropImg.cropper('getCroppedCanvas', From bc70597908c2aa144472b28c891306a6d9390c34 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 14:30:38 -0500 Subject: [PATCH 248/797] Fix failing spec --- app/assets/javascripts/gl_crop.js.coffee | 11 ++++++++--- app/views/profiles/show.html.haml | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/gl_crop.js.coffee b/app/assets/javascripts/gl_crop.js.coffee index 4d990a6416..6ab0afc184 100644 --- a/app/assets/javascripts/gl_crop.js.coffee +++ b/app/assets/javascripts/gl_crop.js.coffee @@ -1,7 +1,12 @@ class GitLabCrop - constructor: (el, opts = {}) -> - # Input file - @fileInput = $(el) + constructor: (input, opts = {}) -> + @fileInput = $(input) + + # We should rename to avoid spec to fail + # Form will submit the proper input filed with a file using FormData + @fileInput + .attr('name', "#{@fileInput.attr('name')}-trigger") + .attr('id', "#{@fileInput.attr('id')}-trigger") # Set defaults { diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 2d9129470f..dcb3be9585 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -26,7 +26,7 @@ %a.btn.js-choose-user-avatar-button Browse file... %span.avatar-file-name.prepend-left-default.js-avatar-filename No file chosen - = f.file_field :avatar_dialog_trigger, class: "js-user-avatar-input hidden", accept: "image/*" + = f.file_field :avatar, class: "js-user-avatar-input hidden", accept: "image/*" .help-block The maximum file size allowed is 200KB. - if @user.avatar? From 647f28bd1d92eabe239a7f0f4a65eb100c0cda73 Mon Sep 17 00:00:00 2001 From: Alfredo Sumaran Date: Thu, 17 Mar 2016 14:59:43 -0500 Subject: [PATCH 249/797] Make it generic --- app/assets/javascripts/gl_crop.js.coffee | 26 ++++++++++++++---------- app/assets/javascripts/profile.js.coffee | 10 ++++++++- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/app/assets/javascripts/gl_crop.js.coffee b/app/assets/javascripts/gl_crop.js.coffee index 6ab0afc184..137d025ed2 100644 --- a/app/assets/javascripts/gl_crop.js.coffee +++ b/app/assets/javascripts/gl_crop.js.coffee @@ -10,28 +10,32 @@ class GitLabCrop # Set defaults { - @form = @fileInput.parents('form') - @filename = '.js-avatar-filename' - @previewImage = $('.avatar-image .avatar') - @modalCrop = '.modal-profile-crop' @exportWidth = 200 @exportHeight = 200 @cropBoxWidth = 200 @cropBoxHeight = 200 + @form = @fileInput.parents('form') - # Button where user clicks to open file dialog - # If not passed as argument let's pick a default one - @pickImageEl = @fileInput.parent().find('.js-choose-user-avatar-button') - @uploadImageBtn = $('.js-upload-user-avatar') + # Required params + @filename + @previewImage + @modalCrop + @pickImageEl + @uploadImageBtn + @modalCropImg } = opts # Ensure needed elements are jquery objects - @filename = if _.isString(@filename) then @$(@filename) else @filename + # If selector is provided we will convert them to a jQuery Object + @filename = @$(@filename) + @previewImage = @$(@previewImage) + @pickImageEl = @$(@pickImageEl) - # Modal usually is outside the wrapper element + # Modal elements usually are outside the @form element @modalCrop = if _.isString(@modalCrop) then $(@modalCrop) else @modalCrop + @uploadImageBtn = if _.isString(@uploadImageBtn) then $(@uploadImageBtn) else @uploadImageBtn + @modalCropImg = if _.isString(@modalCropImg) then $(@modalCropImg) else @modalCropImg - @modalCropImg = $('.modal-profile-crop-image') @cropActionsBtn = @modalCrop.find('[data-method]') @bindEvents() diff --git a/app/assets/javascripts/profile.js.coffee b/app/assets/javascripts/profile.js.coffee index f81d7dd6e6..2fcc6dfd56 100644 --- a/app/assets/javascripts/profile.js.coffee +++ b/app/assets/javascripts/profile.js.coffee @@ -23,7 +23,15 @@ class @Profile @bindEvents() - @avatarGlCrop = $('.js-user-avatar-input').glCrop().data 'glcrop' + cropOpts = + filename: '.js-avatar-filename' + previewImage: '.avatar-image .avatar' + modalCrop: '.modal-profile-crop' + pickImageEl: '.js-choose-user-avatar-button' + uploadImageBtn: '.js-upload-user-avatar' + modalCropImg: '.modal-profile-crop-image' + + @avatarGlCrop = $('.js-user-avatar-input').glCrop(cropOpts).data 'glcrop' bindEvents: -> @form.on 'submit', @onSubmitForm From cfb10c103f1298b7c4ec94c76f66445a5969d13f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rub=C3=A9n=20D=C3=A1vila?= Date: Thu, 17 Mar 2016 15:16:34 -0500 Subject: [PATCH 250/797] Check push permissions only when pushing directly to target branch. --- app/services/commits/revert_service.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/services/commits/revert_service.rb b/app/services/commits/revert_service.rb index 9cb918d7a2..a3c950ede1 100644 --- a/app/services/commits/revert_service.rb +++ b/app/services/commits/revert_service.rb @@ -9,7 +9,8 @@ module Commits @commit = params[:commit] @create_merge_request = params[:create_merge_request].present? - validate and commit + check_push_permissions unless @create_merge_request + commit rescue Repository::CommitError, Gitlab::Git::Repository::InvalidBlobName, GitHooksService::PreReceiveError, ValidationError, ReversionError => ex error(ex.message) @@ -45,11 +46,11 @@ module Commits end end - def validate + def check_push_permissions allowed = ::Gitlab::GitAccess.new(current_user, project).can_push_to_branch?(@target_branch) unless allowed - raise_error('You are not allowed to push into this branch') + raise ValidationError.new('You are not allowed to push into this branch') end true From 81d191ede9eed2ef63d772a40038679944d8fd69 Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Thu, 17 Mar 2016 17:50:59 +0100 Subject: [PATCH 251/797] Removed Repository#rebuild_cache This method is not used or tested anywhere. --- app/models/repository.rb | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/app/models/repository.rb b/app/models/repository.rb index 036919c27b..53d3d90859 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -301,18 +301,6 @@ class Repository @tag_count = nil end - def rebuild_cache - cache_keys.each do |key| - cache.expire(key) - send(key) - end - - branches.each do |branch| - cache.expire(:"diverging_commit_counts_#{branch.name}") - diverging_commit_counts(branch) - end - end - def lookup_cache @lookup_cache ||= {} end From 3d7feeede32a81df06f80f6f3599bfe62aa6e13d Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Thu, 17 Mar 2016 17:52:19 +0100 Subject: [PATCH 252/797] Don't rebuild diverging commit count caches When calling Repository#build_cache we _don't_ want to build the diverging commit count cache as doing so can be _very_ slow for repositories with lots of branches. Because these caches are built whenever needed (= when actually viewing the list of branches in the web UI) we can safely remove this code from Repository#build_cache. --- app/models/repository.rb | 6 ------ 1 file changed, 6 deletions(-) diff --git a/app/models/repository.rb b/app/models/repository.rb index 53d3d90859..25d24493f6 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -227,12 +227,6 @@ class Repository send(key) end end - - branches.each do |branch| - unless cache.exist?(:"diverging_commit_counts_#{branch.name}") - send(:diverging_commit_counts, branch) - end - end end def expire_tags_cache From dd4b789765ca4219f89c03d14d0c2524b2374184 Mon Sep 17 00:00:00 2001 From: Yorick Peterse Date: Thu, 17 Mar 2016 18:15:09 +0100 Subject: [PATCH 253/797] Added tests for Repository#build_cache --- spec/models/repository_spec.rb | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 536fe66b21..a57229a4fd 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -780,4 +780,34 @@ describe Repository, models: true do end end end + + describe '#build_cache' do + let(:cache) { repository.send(:cache) } + + it 'builds the caches if they do not already exist' do + expect(cache).to receive(:exist?). + exactly(repository.cache_keys.length). + times. + and_return(false) + + repository.cache_keys.each do |key| + expect(repository).to receive(key) + end + + repository.build_cache + end + + it 'does not build any caches that already exist' do + expect(cache).to receive(:exist?). + exactly(repository.cache_keys.length). + times. + and_return(true) + + repository.cache_keys.each do |key| + expect(repository).to_not receive(key) + end + + repository.build_cache + end + end end From 515dd63d654dff964615cf0a2fb2c841b6302767 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 17 Mar 2016 22:21:10 +0100 Subject: [PATCH 254/797] Bump Git version requirement to 2.7.4 --- CHANGELOG | 3 +++ README.md | 2 +- doc/install/installation.md | 10 +++++----- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 75659d4766..6c48da07ad 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -47,6 +47,9 @@ v 8.6.0 (unreleased) - User deletion is now done in the background so the request can not time out - Canceled builds are now ignored in compound build status if marked as `allowed to fail` +v 8.5.8 + - Bump Git version requirement to 2.7.4 + v 8.5.7 - Bump Git version requirement to 2.7.3 diff --git a/README.md b/README.md index 208427fcf8..afa60116eb 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ GitLab is a Ruby on Rails application that runs on the following software: - Ubuntu/Debian/CentOS/RHEL - Ruby (MRI) 2.1 -- Git 2.7.3+ +- Git 2.7.4+ - Redis 2.8+ - MySQL or PostgreSQL diff --git a/doc/install/installation.md b/doc/install/installation.md index aa989417c4..c567846f62 100644 --- a/doc/install/installation.md +++ b/doc/install/installation.md @@ -76,7 +76,7 @@ Make sure you have the right version of Git installed # Install Git sudo apt-get install -y git-core - # Make sure Git is version 2.7.3 or higher + # Make sure Git is version 2.7.4 or higher git --version Is the system packaged Git too old? Remove it and compile from source. @@ -89,9 +89,9 @@ Is the system packaged Git too old? Remove it and compile from source. # Download and compile from source cd /tmp - curl -O --progress https://www.kernel.org/pub/software/scm/git/git-2.7.3.tar.gz - echo '30d067499b61caddedaf1a407b4947244f14d10842d100f7c7c6ea1c288280cd git-2.7.3.tar.gz' | shasum -a256 -c - && tar -xzf git-2.7.3.tar.gz - cd git-2.7.3/ + curl -O --progress https://www.kernel.org/pub/software/scm/git/git-2.7.4.tar.gz + echo '7104c4f5d948a75b499a954524cb281fe30c6649d8abe20982936f75ec1f275b git-2.7.4.tar.gz' | shasum -a256 -c - && tar -xzf git-2.7.4.tar.gz + cd git-2.7.4/ ./configure make prefix=/usr/local all @@ -161,7 +161,7 @@ We recommend using a PostgreSQL database. For MySQL check [MySQL setup guide](da # Install the database packages sudo apt-get install -y postgresql postgresql-client libpq-dev - + # Create a user for GitLab sudo -u postgres psql -d template1 -c "CREATE USER git CREATEDB;" From 613e0bc5411bd767eeca5439876d61d843957826 Mon Sep 17 00:00:00 2001 From: Arinde Eniola Date: Thu, 17 Mar 2016 22:34:42 +0100 Subject: [PATCH 255/797] Fix inconsistency in the header of issue and merge request pages --- app/views/projects/issues/show.html.haml | 15 +++++++------ .../merge_requests/show/_mr_title.html.haml | 21 +++++++++++-------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 1e8308277c..494f58c57c 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -25,17 +25,16 @@ %strong.identifier Issue ##{@issue.iid} %span.creator - by + opened .editor-details .editor-details - %strong - = link_to_member(@project, @issue.author, size: 24, mobile_classes: "hidden-xs") - %span.hidden-xs - = '@' + @issue.author.username - %strong - = link_to_member(@project, @issue.author, size: 24, mobile_classes: "hidden-sm hidden-md hidden-lg", - by_username: true, avatar: false) = time_ago_with_tooltip(@issue.created_at) + by + %strong + = link_to_member(@project, @issue.author, avatar: false, size: 24, mobile_classes: "hidden-xs") + %strong + = link_to_member(@project, @issue.author, avatar: false, size: 24, mobile_classes: "hidden-sm hidden-md hidden-lg", + by_username: true, avatar: false) .pull-right.issue-btn-group - if can?(current_user, :create_issue, @project) diff --git a/app/views/projects/merge_requests/show/_mr_title.html.haml b/app/views/projects/merge_requests/show/_mr_title.html.haml index a75c0d96c5..c6cbe8589e 100644 --- a/app/views/projects/merge_requests/show/_mr_title.html.haml +++ b/app/views/projects/merge_requests/show/_mr_title.html.haml @@ -8,18 +8,21 @@ = icon('angle-double-left') .issue-meta %strong.identifier - Merge Request ##{@merge_request.iid} + %span.hidden-sm.hidden-md.hidden-lg + MR + %span.hidden-xs + Merge Request + !#{@merge_request.iid} %span.creator - by + opened .editor-details - %strong - = link_to_member(@project, @merge_request.author, size: 24, mobile_classes: "hidden-xs") - %span.hidden-xs - = '@' + @merge_request.author.username - %strong - = link_to_member(@project, @merge_request.author, size: 24, mobile_classes: "hidden-sm hidden-md hidden-lg", - by_username: true, avatar: false) = time_ago_with_tooltip(@merge_request.created_at) + by + %strong + = link_to_member(@project, @merge_request.author, avatar: false, size: 24, mobile_classes: "hidden-xs") + %strong + = link_to_member(@project, @merge_request.author, avatar: false, size: 24, mobile_classes: "hidden-sm hidden-md hidden-lg", + by_username: true, avatar: false) .issue-btn-group.pull-right - if can?(current_user, :update_merge_request, @merge_request) From 0a7f7161198feaa9a4cae7c16669a0e6187aed33 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Thu, 17 Mar 2016 19:42:46 -0300 Subject: [PATCH 256/797] Code fixes --- app/assets/stylesheets/framework/blocks.scss | 18 +++++++++++ app/assets/stylesheets/framework/common.scss | 22 -------------- app/controllers/groups_controller.rb | 4 +-- app/finders/contributed_projects_finder.rb | 7 ++--- app/finders/groups_finder.rb | 10 ++++++- app/finders/joined_groups_finder.rb | 4 +-- app/finders/personal_projects_finder.rb | 17 +++++++---- app/models/ability.rb | 7 ++--- app/services/groups/base_service.rb | 16 ++++++++-- app/services/groups/create_service.rb | 19 ++++++------ app/services/groups/update_service.rb | 19 +----------- app/views/groups/show.html.haml | 10 +------ config/gitlab.yml.example | 4 --- ...roup_visibility_to_application_settings.rb | 12 ++------ lib/gitlab/current_settings.rb | 1 - lib/gitlab/visibility_level.rb | 13 ++++---- .../security/group/internal_access_spec.rb | 21 +++++++++---- .../security/group/private_access_spec.rb | 30 ++++++++++++------- .../security/group/public_access_spec.rb | 10 +++++++ spec/finders/groups_finder_spec.rb | 9 +++++- spec/finders/joined_groups_finder_spec.rb | 19 ++++++++++++ spec/finders/personal_projects_finder_spec.rb | 15 +++++++++- spec/services/groups/create_service_spec.rb | 12 ++++---- spec/support/group_access_helper.rb | 4 +++ 24 files changed, 178 insertions(+), 125 deletions(-) diff --git a/app/assets/stylesheets/framework/blocks.scss b/app/assets/stylesheets/framework/blocks.scss index d20b77ffae..3108487236 100644 --- a/app/assets/stylesheets/framework/blocks.scss +++ b/app/assets/stylesheets/framework/blocks.scss @@ -115,6 +115,24 @@ color: #4c4e54; font-size: 23px; line-height: 1.1; + + h1 { + color: #313236; + margin-bottom: 6px; + font-size: 23px; + } + + .visibility-icon { + display: inline-block; + margin-left: 5px; + font-size: 18px; + color: $gray; + } + + p { + padding: 0 $gl-padding; + color: #5c5d5e; + } } .cover-desc { diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index f4608cd80b..ff551f151f 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -385,25 +385,3 @@ table { margin-right: -$gl-padding; border-top: 1px solid $border-color; } - -.cover-title{ - h1 { - color: #313236; - margin: 0; - margin-bottom: 6px; - font-size: 23px; - font-weight: normal; - } - - .visibility-icon { - display: inline-block; - margin-left: 5px; - font-size: 18px; - color: $gray; - } - - p { - padding: 0 $gl-padding; - color: #5c5d5e; - } -} diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 8243946c85..ba2057eb2c 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -28,9 +28,9 @@ class GroupsController < Groups::ApplicationController end def create - @group = Group.new(group_params) + @group = Groups::CreateService.new(current_user, group_params).execute - if Groups::CreateService.new(@group, current_user, group_params).execute + if @group.persisted? redirect_to @group, notice: "Group '#{@group.name}' was successfully created." else render action: "new" diff --git a/app/finders/contributed_projects_finder.rb b/app/finders/contributed_projects_finder.rb index 4f7fe1c748..f8b04dfa2a 100644 --- a/app/finders/contributed_projects_finder.rb +++ b/app/finders/contributed_projects_finder.rb @@ -10,8 +10,9 @@ class ContributedProjectsFinder # visible by this user. # # Returns an ActiveRecord::Relation. + def execute(current_user = nil) - if current_user && !current_user.external? + if current_user relation = projects_visible_to_user(current_user) else relation = public_projects @@ -24,9 +25,7 @@ class ContributedProjectsFinder def projects_visible_to_user(current_user) authorized = @user.contributed_projects.visible_to_user(current_user) - - union = Gitlab::SQL::Union. - new([authorized.select(:id), public_projects.select(:id)]) + union = Gitlab::SQL::Union.new([authorized.select(:id), public_projects.select(:id)]) Project.where("projects.id IN (#{union.to_sql})") end diff --git a/app/finders/groups_finder.rb b/app/finders/groups_finder.rb index ce62f5e762..30698f8023 100644 --- a/app/finders/groups_finder.rb +++ b/app/finders/groups_finder.rb @@ -14,9 +14,17 @@ class GroupsFinder def all_groups(current_user) if current_user - [current_user.authorized_groups, Group.unscoped.public_and_internal_only] + user_groups(current_user) else [Group.unscoped.public_only] end end + + def user_groups(current_user) + if current_user.external? + [current_user.authorized_groups, Group.unscoped.public_only] + else + [current_user.authorized_groups, Group.unscoped.public_and_internal_only] + end + end end diff --git a/app/finders/joined_groups_finder.rb b/app/finders/joined_groups_finder.rb index ff744689e3..867eb66168 100644 --- a/app/finders/joined_groups_finder.rb +++ b/app/finders/joined_groups_finder.rb @@ -12,7 +12,7 @@ class JoinedGroupsFinder # # Returns an ActiveRecord::Relation. def execute(current_user = nil) - if current_user && !current_user.external? + if current_user relation = groups_visible_to_user(current_user) else relation = public_groups @@ -29,7 +29,7 @@ class JoinedGroupsFinder # "@user" that "current_user" also has access to. def groups_visible_to_user(current_user) base = @user.authorized_groups.visible_to_user(current_user) - extra = public_and_internal_groups + extra = current_user.external? ? public_groups : public_and_internal_groups union = Gitlab::SQL::Union.new([base.select(:id), extra.select(:id)]) Group.where("namespaces.id IN (#{union.to_sql})") diff --git a/app/finders/personal_projects_finder.rb b/app/finders/personal_projects_finder.rb index 0e2d915da5..34f33e2353 100644 --- a/app/finders/personal_projects_finder.rb +++ b/app/finders/personal_projects_finder.rb @@ -11,7 +11,7 @@ class PersonalProjectsFinder # # Returns an ActiveRecord::Relation. def execute(current_user = nil) - if current_user && !current_user.external? + if current_user relation = projects_visible_to_user(current_user) else relation = public_projects @@ -23,10 +23,7 @@ class PersonalProjectsFinder private def projects_visible_to_user(current_user) - authorized = @user.personal_projects.visible_to_user(current_user) - - union = Gitlab::SQL::Union. - new([authorized.select(:id), public_and_internal_projects.select(:id)]) + union = Gitlab::SQL::Union.new(projects_for_user_ids(current_user)) Project.where("projects.id IN (#{union.to_sql})") end @@ -38,4 +35,14 @@ class PersonalProjectsFinder def public_and_internal_projects @user.personal_projects.public_and_internal_only end + + def projects_for_user_ids(current_user) + authorized = @user.personal_projects.visible_to_user(current_user) + + if current_user.external? + [authorized.select(:id), public_projects.select(:id)] + else + [authorized.select(:id), public_and_internal_projects.select(:id)] + end + end end diff --git a/app/models/ability.rb b/app/models/ability.rb index 134ae440c9..ffcf05dcd3 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -295,11 +295,8 @@ class Ability end def can_read_group?(user, group) - if user.external? - group.public? || ProjectsFinder.new.execute(user, group: group).any? - else - user.admin? || group.public? || group.internal? || group.users.include?(user) || ProjectsFinder.new.execute(user, group: group).any? - end + user.admin? || group.public? || (group.internal? && !user.external?) || group.users.include?(user) || + ProjectsFinder.new.execute(user, group: group).any? end def namespace_abilities(user, namespace) diff --git a/app/services/groups/base_service.rb b/app/services/groups/base_service.rb index 644ec7c013..053b6a0528 100644 --- a/app/services/groups/base_service.rb +++ b/app/services/groups/base_service.rb @@ -6,8 +6,20 @@ module Groups @group, @current_user, @params = group, user, params.dup end - def add_error_message(message) - group.errors.add(:visibility_level, message) + private + + def visibility_allowed_for_user?(level) + allowed_by_user = Gitlab::VisibilityLevel.allowed_for?(current_user, level) + @group.errors.add(:visibility_level, "You are not authorized to set this permission level.") unless allowed_by_user + allowed_by_user + end + + def visibility_allowed_for_project?(level) + projects_visibility = group.projects.pluck(:visibility_level) + + allowed_by_projects = !projects_visibility.any? { |project_visibility| level.to_i < project_visibility } + @group.errors.add(:visibility_level, "Cannot be changed. There are projects with higher visibility permissions.") unless allowed_by_projects + allowed_by_projects end end end diff --git a/app/services/groups/create_service.rb b/app/services/groups/create_service.rb index e2875aafb9..38742369d8 100644 --- a/app/services/groups/create_service.rb +++ b/app/services/groups/create_service.rb @@ -1,17 +1,16 @@ module Groups class CreateService < Groups::BaseService - def execute - return false unless visibility_level_allowed?(params[:visibility_level]) - @group.name = @group.path.dup unless @group.name - @group.save(params) && @group.add_owner(current_user) + def initialize(user, params = {}) + @current_user, @params = user, params.dup + @group = Group.new(@params) end - private - - def visibility_level_allowed?(level) - allowed = Gitlab::VisibilityLevel.allowed_for?(current_user, params[:visibility_level]) - add_error_message("Visibility level restricted by admin.") unless allowed - allowed + def execute + return @group unless visibility_allowed_for_user?(@params[:visibility_level]) + @group.name = @group.path.dup unless @group.name + @group.save + @group.add_owner(@current_user) + @group end end end diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index a7382c1e07..b910e0fde9 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -14,24 +14,7 @@ module Groups def visibility_level_allowed?(level) return true unless level.present? - allowed_by_projects = visibility_by_project(level) - allowed_by_user = visibility_by_user(level) - - allowed_by_projects && allowed_by_user - end - - def visibility_by_project(level) - projects_visibility = group.projects.pluck(:visibility_level) - - allowed_by_projects = !projects_visibility.any?{ |project_visibility| level.to_i < project_visibility } - add_error_message("Cannot be changed. There are projects with higher visibility permissions.") unless allowed_by_projects - allowed_by_projects - end - - def visibility_by_user(level) - allowed_by_user = Gitlab::VisibilityLevel.allowed_for?(current_user, level) - add_error_message("You are not authorized to set this permission level.") unless allowed_by_user - allowed_by_user + visibility_allowed_for_project?(level) && visibility_allowed_for_user?(level) end end end diff --git a/app/views/groups/show.html.haml b/app/views/groups/show.html.haml index 2653a03059..4be117667d 100644 --- a/app/views/groups/show.html.haml +++ b/app/views/groups/show.html.haml @@ -18,7 +18,7 @@ %h1 = @group.name - %span.visibility-icon.has_tooltip{data: { container: 'body', placement: 'left' }, title: "#{visibility_level_label(@group.visibility_level)} - #{group_visibility_description(@group)}"} + %span.visibility-icon.has_tooltip{data: { container: 'body', placement: 'left' }, title: "#{group_visibility_description(@group)}"} = visibility_level_icon(@group.visibility_level, fw: false) .cover-desc.username @@ -28,14 +28,6 @@ .cover-desc.description = markdown(@group.description, pipeline: :description) - %ul.nav-links - %li.active - = link_to "#activity", 'data-toggle' => 'tab' do - Activity - %li - = link_to "#projects", 'data-toggle' => 'tab' do - Projects - - if can?(current_user, :read_group, @group) %div{ class: container_class } .top-area diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index fc808e228d..05f127d622 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -90,10 +90,6 @@ production: &base snippets: false builds: true - ## Default group features settings - default_groups_features: - visibility_level: 20 - ## Webhook settings # Number of seconds to wait for HTTP response after sending webhook HTTP POST request (default: 10) # webhook_timeout: 10 diff --git a/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb b/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb index b71322376f..d5c78db0aa 100644 --- a/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb +++ b/db/migrate/20160308212903_add_default_group_visibility_to_application_settings.rb @@ -13,15 +13,9 @@ class AddDefaultGroupVisibilityToApplicationSettings < ActiveRecord::Migration end private - def allowed_visibility_level - default_visibility = Settings.gitlab.default_groups_features['visibility_level'] - restricted_levels = current_application_settings.restricted_visibility_levels - return default_visibility unless restricted_levels.present? - if restricted_levels.include?(default_visibility) - Gitlab::VisibilityLevel.values.select{ |vis_level| vis_level unless restricted_levels.include?(vis_level) }.last - else - default_visibility - end + def allowed_visibility_level + allowed_levels = Gitlab::VisibilityLevel.values - current_application_settings.restricted_visibility_levels + allowed_levels.max end end diff --git a/lib/gitlab/current_settings.rb b/lib/gitlab/current_settings.rb index a50d8e3d5a..761b63e98f 100644 --- a/lib/gitlab/current_settings.rb +++ b/lib/gitlab/current_settings.rb @@ -29,7 +29,6 @@ module Gitlab session_expire_delay: Settings.gitlab['session_expire_delay'], default_project_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_snippet_visibility: Settings.gitlab.default_projects_features['visibility_level'], - default_group_visibility: Settings.gitlab.default_groups_features['visibility_level'], restricted_signup_domains: Settings.gitlab['restricted_signup_domains'], import_sources: ['github','bitbucket','gitlab','gitorious','google_code','fogbugz','git'], shared_runners_enabled: Settings.gitlab_ci['shared_runners_enabled'], diff --git a/lib/gitlab/visibility_level.rb b/lib/gitlab/visibility_level.rb index f6e0dd6afc..f193fb282a 100644 --- a/lib/gitlab/visibility_level.rb +++ b/lib/gitlab/visibility_level.rb @@ -6,19 +6,18 @@ module Gitlab module VisibilityLevel extend CurrentSettings + extend ActiveSupport::Concern + + included do + scope :public_only, -> { where(visibility_level: PUBLIC) } + scope :public_and_internal_only, -> { where(visibility_level: [PUBLIC, INTERNAL] ) } + end PRIVATE = 0 unless const_defined?(:PRIVATE) INTERNAL = 10 unless const_defined?(:INTERNAL) PUBLIC = 20 unless const_defined?(:PUBLIC) class << self - def included(base) - base.class_eval do - scope :public_only, -> { where(visibility_level: PUBLIC) } - scope :public_and_internal_only, -> { where(visibility_level: [PUBLIC, INTERNAL] ) } - end - end - def values options.values end diff --git a/spec/features/security/group/internal_access_spec.rb b/spec/features/security/group/internal_access_spec.rb index 4e781c23ee..e44d4c3292 100644 --- a/spec/features/security/group/internal_access_spec.rb +++ b/spec/features/security/group/internal_access_spec.rb @@ -12,9 +12,12 @@ describe 'Internal group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } + end context "when user in group project" do @@ -31,9 +34,11 @@ describe 'Internal group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } end context "when user in group project" do @@ -50,9 +55,11 @@ describe 'Internal group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } end context "when user in group project" do @@ -70,9 +77,11 @@ describe 'Internal group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } end context "when user in group project" do @@ -89,9 +98,11 @@ describe 'Internal group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } end context "when user in group project" do diff --git a/spec/features/security/group/private_access_spec.rb b/spec/features/security/group/private_access_spec.rb index 0d01310b44..8d8c61a618 100644 --- a/spec/features/security/group/private_access_spec.rb +++ b/spec/features/security/group/private_access_spec.rb @@ -14,9 +14,11 @@ describe 'Private group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } - it { is_expected.to_not be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } end context "when user in group project" do @@ -33,9 +35,11 @@ describe 'Private group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } - it { is_expected.to_not be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } end context "when user in group project" do @@ -52,9 +56,11 @@ describe 'Private group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } - it { is_expected.to_not be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } end context "when user in group project" do @@ -72,9 +78,11 @@ describe 'Private group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } - it { is_expected.to_not be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } end context "when user in group project" do @@ -91,9 +99,11 @@ describe 'Private group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } - it { is_expected.to_not be_allowed_for :user } - it { is_expected.to_not be_allowed_for :visitor } + it { is_expected.to be_denied_for :user } + it { is_expected.to be_denied_for :visitor } + it { is_expected.to be_denied_for :external } end context "when user in group project" do diff --git a/spec/features/security/group/public_access_spec.rb b/spec/features/security/group/public_access_spec.rb index 75d208f294..5ff982504c 100644 --- a/spec/features/security/group/public_access_spec.rb +++ b/spec/features/security/group/public_access_spec.rb @@ -14,9 +14,11 @@ describe 'Public group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } it { is_expected.to be_allowed_for :visitor } + it { is_expected.to be_allowed_for :external } end context "when user in group project" do @@ -33,9 +35,11 @@ describe 'Public group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } it { is_expected.to be_allowed_for :visitor } + it { is_expected.to be_allowed_for :external } end context "when user in group project" do @@ -52,9 +56,11 @@ describe 'Public group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } it { is_expected.to be_allowed_for :visitor } + it { is_expected.to be_allowed_for :external } end context "when user in group project" do @@ -72,9 +78,11 @@ describe 'Public group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } it { is_expected.to be_allowed_for :visitor } + it { is_expected.to be_allowed_for :external } end context "when user in group project" do @@ -91,9 +99,11 @@ describe 'Public group access', feature: true do it { is_expected.to be_allowed_for group_member(:master) } it { is_expected.to be_allowed_for group_member(:reporter) } it { is_expected.to be_allowed_for group_member(:guest) } + it { is_expected.to be_allowed_for external_guest } it { is_expected.to be_allowed_for :admin } it { is_expected.to be_allowed_for :user } it { is_expected.to be_allowed_for :visitor } + it { is_expected.to be_allowed_for :external } end context "when user in group project" do diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb index ed24954af7..d0fd7af8cc 100644 --- a/spec/finders/groups_finder_spec.rb +++ b/spec/finders/groups_finder_spec.rb @@ -18,7 +18,14 @@ describe GroupsFinder do describe 'with a user' do subject { finder.execute(user) } - it { is_expected.to eq([public_group, internal_group]) } + context 'normal user' do + it { is_expected.to eq([public_group, internal_group]) } + end + + context 'external user' do + before { user.update_attribute(external: true) } + it { is_expected.to eq([public_group]) } + end end end end diff --git a/spec/finders/joined_groups_finder_spec.rb b/spec/finders/joined_groups_finder_spec.rb index e2f6c59363..7b6fc837e5 100644 --- a/spec/finders/joined_groups_finder_spec.rb +++ b/spec/finders/joined_groups_finder_spec.rb @@ -46,6 +46,25 @@ describe JoinedGroupsFinder do it { is_expected.to eq([public_group, private_group]) } end + + context 'external users' do + before do + profile_visitor.update_attributes(external: true) + public_group.add_user(profile_owner, Gitlab::Access::MASTER) + internal_group.add_user(profile_owner, Gitlab::Access::MASTER) + end + + subject { finder.execute(profile_visitor) } + + it "doest not show internal groups if not member" do + expect(subject).to eq([public_group]) + end + + it "shows internal groups if authorized" do + internal_group.add_user(profile_visitor, Gitlab::Access::MASTER) + expect(subject).to eq([public_group, internal_group]) + end + end end end end diff --git a/spec/finders/personal_projects_finder_spec.rb b/spec/finders/personal_projects_finder_spec.rb index 38817add45..8758f61903 100644 --- a/spec/finders/personal_projects_finder_spec.rb +++ b/spec/finders/personal_projects_finder_spec.rb @@ -16,6 +16,11 @@ describe PersonalProjectsFinder do path: 'B') end + let!(:internal_project) do + create(:project, :internal, namespace: source_user.namespace, name: 'c', + path: 'C') + end + before do private_project.team << [current_user, Gitlab::Access::DEVELOPER] end @@ -29,6 +34,14 @@ describe PersonalProjectsFinder do describe 'with a current user' do subject { finder.execute(current_user) } - it { is_expected.to eq([private_project, public_project]) } + context 'normal user' do + it { is_expected.to eq([internal_project, private_project, public_project]) } + end + + context 'external' do + before { current_user.update_attributes(external: true) } + + it { is_expected.to eq([private_project, public_project]) } + end end end diff --git a/spec/services/groups/create_service_spec.rb b/spec/services/groups/create_service_spec.rb index 7dbc529797..b938a2f0c0 100644 --- a/spec/services/groups/create_service_spec.rb +++ b/spec/services/groups/create_service_spec.rb @@ -1,22 +1,20 @@ require 'spec_helper' describe Groups::CreateService, services: true do - let!(:user) { create(:user) } - let!(:private_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PRIVATE) } - let!(:internal_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } - let!(:public_group) { create(:group, visibility_level: Gitlab::VisibilityLevel::PUBLIC) } + let!(:user) { create(:user) } + let!(:group_params) { { path: "group_path", visibility_level: Gitlab::VisibilityLevel::PUBLIC } } describe "execute" do - let!(:service) { described_class.new(public_group, user, visibility_level: Gitlab::VisibilityLevel::PUBLIC ) } + let!(:service) { described_class.new(user, group_params ) } subject { service.execute } context "create groups without restricted visibility level" do - it { is_expected.to be_truthy } + it { is_expected.to be_persisted } end context "cannot create group with restricted visibility level" do before { allow(current_application_settings).to receive(:restricted_visibility_levels).and_return([Gitlab::VisibilityLevel::PUBLIC]) } - it { is_expected.to be_falsy } + it { is_expected.to_not be_persisted } end end end diff --git a/spec/support/group_access_helper.rb b/spec/support/group_access_helper.rb index a1a8fb2bd7..c8ed0e406a 100644 --- a/spec/support/group_access_helper.rb +++ b/spec/support/group_access_helper.rb @@ -14,4 +14,8 @@ module GroupAccessHelper create(:user).tap { |user| grp.add_user(user, level) } end + + def external_guest(grp=group()) + create(:user, external: true).tap { |user| grp.add_user(user, Gitlab::Access::GUEST) } + end end From abb77d0f6cec87455e7da13d6a977ac4b8f740f4 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 16:14:54 -0300 Subject: [PATCH 257/797] Add a confidential flag to issues --- db/migrate/20160223192159_add_confidential_to_issues.rb | 6 ++++++ db/schema.rb | 4 +++- 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 db/migrate/20160223192159_add_confidential_to_issues.rb diff --git a/db/migrate/20160223192159_add_confidential_to_issues.rb b/db/migrate/20160223192159_add_confidential_to_issues.rb new file mode 100644 index 0000000000..e9d47fd589 --- /dev/null +++ b/db/migrate/20160223192159_add_confidential_to_issues.rb @@ -0,0 +1,6 @@ +class AddConfidentialToIssues < ActiveRecord::Migration + def change + add_column :issues, :confidential, :boolean, default: false + add_index :issues, :confidential + end +end diff --git a/db/schema.rb b/db/schema.rb index 2f075677b3..7e6863ef47 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: 20160316123110) do +ActiveRecord::Schema.define(version: 20160316204731) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -416,10 +416,12 @@ ActiveRecord::Schema.define(version: 20160316123110) do t.string "state" t.integer "iid" t.integer "updated_by_id" + t.boolean "confidential", default: false end add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree add_index "issues", ["author_id"], name: "index_issues_on_author_id", using: :btree + add_index "issues", ["confidential"], name: "index_issues_on_confidential", using: :btree add_index "issues", ["created_at", "id"], name: "index_issues_on_created_at_and_id", using: :btree add_index "issues", ["created_at"], name: "index_issues_on_created_at", using: :btree add_index "issues", ["description"], name: "index_issues_on_description_trigram", using: :gin, opclasses: {"description"=>"gin_trgm_ops"} From 6b86d3fb800bb551af4a446b87dfd64c963733a3 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 16:16:48 -0300 Subject: [PATCH 258/797] Add an option to user make an issue confidential --- app/controllers/projects/issues_controller.rb | 2 +- app/views/shared/issuable/_form.html.haml | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index aa7a178dcf..0907733fe4 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -158,7 +158,7 @@ class Projects::IssuesController < Projects::ApplicationController def issue_params params.require(:issue).permit( - :title, :assignee_id, :position, :description, + :title, :assignee_id, :position, :description, :confidential, :milestone_id, :state_event, :task_num, label_ids: [] ) end diff --git a/app/views/shared/issuable/_form.html.haml b/app/views/shared/issuable/_form.html.haml index d5a4aad05d..9ef729e960 100644 --- a/app/views/shared/issuable/_form.html.haml +++ b/app/views/shared/issuable/_form.html.haml @@ -29,6 +29,15 @@ = render 'projects/notes/hints' .clearfix .error-alert + +- if issuable.is_a?(Issue) && !issuable.project.private? + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :confidential do + = f.check_box :confidential + This issue is confidential and should only be visible to team members + - if can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) %hr .form-group From 34ee75379cf8e6459b8926fbf956a8316f87eea7 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 16:38:51 -0300 Subject: [PATCH 259/797] Restrict access to confidential issues --- app/controllers/projects/issues_controller.rb | 6 +- app/finders/issues_finder.rb | 6 + app/models/ability.rb | 17 +- app/models/issue.rb | 7 + .../projects/issues_controller_spec.rb | 164 +++++++++++++++++- spec/factories/issues.rb | 4 + 6 files changed, 193 insertions(+), 11 deletions(-) diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 0907733fe4..6603f28a08 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -5,7 +5,7 @@ class Projects::IssuesController < Projects::ApplicationController before_action :issue, only: [:edit, :update, :show] # Allow read any issue - before_action :authorize_read_issue! + before_action :authorize_read_issue!, only: [:show] # Allow write(create) issue before_action :authorize_create_issue!, only: [:new, :create] @@ -128,6 +128,10 @@ class Projects::IssuesController < Projects::ApplicationController end alias_method :subscribable_resource, :issue + def authorize_read_issue! + return render_404 unless can?(current_user, :read_issue, @issue) + end + def authorize_update_issue! return render_404 unless can?(current_user, :update_issue, @issue) end diff --git a/app/finders/issues_finder.rb b/app/finders/issues_finder.rb index 20a2b0ce8f..c2befa5a5b 100644 --- a/app/finders/issues_finder.rb +++ b/app/finders/issues_finder.rb @@ -19,4 +19,10 @@ class IssuesFinder < IssuableFinder def klass Issue end + + private + + def init_collection + Issue.visible_to_user(current_user) + end end diff --git a/app/models/ability.rb b/app/models/ability.rb index ccac08b7d3..e22da4806e 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -49,7 +49,6 @@ class Ability rules = [ :read_project, :read_wiki, - :read_issue, :read_label, :read_milestone, :read_project_snippet, @@ -63,6 +62,9 @@ class Ability # Allow to read builds by anonymous user if guests are allowed rules << :read_build if project.public_builds? + # Allow to read issues by anonymous user if issue is not confidential + rules << :read_issue unless subject.is_a?(Issue) && subject.confidential? + rules - project_disabled_features_rules(project) else [] @@ -321,6 +323,7 @@ class Ability end rules += project_abilities(user, subject.project) + rules = filter_confidential_issues_abilities(user, subject, rules) if subject.is_a?(Issue) rules end end @@ -439,5 +442,17 @@ class Ability :"admin_#{name}" ] end + + 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) + rules.delete(:admin_issue) + rules.delete(:read_issue) + rules.delete(:update_issue) + end + + rules + end end end diff --git a/app/models/issue.rb b/app/models/issue.rb index 2447f860c5..053387cffd 100644 --- a/app/models/issue.rb +++ b/app/models/issue.rb @@ -58,6 +58,13 @@ class Issue < ActiveRecord::Base attributes end + def self.visible_to_user(user) + 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)) + end + def self.reference_prefix '#' end diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 76d56bc989..2cd8123114 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -1,16 +1,16 @@ require('spec_helper') describe Projects::IssuesController do - let(:project) { create(:project) } - let(:user) { create(:user) } - let(:issue) { create(:issue, project: project) } - - before do - sign_in(user) - project.team << [user, :developer] - end - describe "GET #index" do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:issue) { create(:issue, project: project) } + + before do + sign_in(user) + project.team << [user, :developer] + end + it "returns index" do get :index, namespace_id: project.namespace.path, project_id: project.path @@ -38,6 +38,152 @@ describe Projects::IssuesController do get :index, namespace_id: project.namespace.path, project_id: project.path expect(response.status).to eq(404) end + end + describe 'Confidential Issues' do + let(:project) { create(:empty_project, :public) } + let(:assignee) { create(:assignee) } + let(:author) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let!(:issue) { create(:issue, project: project) } + let!(:unescaped_parameter_value) { create(:issue, :confidential, project: project, author: author) } + let!(:request_forgery_timing_attack) { create(:issue, :confidential, project: project, assignee: assignee) } + + describe 'GET #index' do + it 'should not list confidential issues for guests' do + sign_out(:user) + get_issues + + expect(assigns(:issues)).to eq [issue] + end + + it 'should not list confidential issues for non project members' do + sign_in(non_member) + get_issues + + expect(assigns(:issues)).to eq [issue] + end + + it 'should list confidential issues for author' do + sign_in(author) + get_issues + + expect(assigns(:issues)).to include unescaped_parameter_value + expect(assigns(:issues)).not_to include request_forgery_timing_attack + end + + it 'should list confidential issues for assignee' do + sign_in(assignee) + get_issues + + expect(assigns(:issues)).not_to include unescaped_parameter_value + expect(assigns(:issues)).to include request_forgery_timing_attack + end + + it 'should list confidential issues for project members' do + sign_in(member) + project.team << [member, :developer] + + get_issues + + expect(assigns(:issues)).to include unescaped_parameter_value + expect(assigns(:issues)).to include request_forgery_timing_attack + end + + it 'should list confidential issues for admin' do + sign_in(admin) + get_issues + + expect(assigns(:issues)).to include unescaped_parameter_value + expect(assigns(:issues)).to include request_forgery_timing_attack + end + + def get_issues + get :index, + namespace_id: project.namespace.to_param, + project_id: project.to_param + end + end + + shared_examples_for 'restricted action' do |http_status| + it 'returns 404 for guests' do + sign_out :user + go(id: unescaped_parameter_value.to_param) + + expect(response).to have_http_status :not_found + end + + it 'returns 404 for non project members' do + sign_in(non_member) + 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) + + expect(response).to have_http_status http_status[:success] + end + + it "returns #{http_status[:success]} for assignee" do + sign_in(assignee) + go(id: request_forgery_timing_attack.to_param) + + expect(response).to have_http_status http_status[:success] + end + + it "returns #{http_status[:success]} for project members" do + sign_in(member) + project.team << [member, :developer] + go(id: unescaped_parameter_value.to_param) + + expect(response).to have_http_status http_status[:success] + end + + it "returns #{http_status[:success]} for admin" do + sign_in(admin) + go(id: unescaped_parameter_value.to_param) + + expect(response).to have_http_status http_status[:success] + end + end + + describe 'GET #show' do + it_behaves_like 'restricted action', success: 200 + + def go(id:) + get :show, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: id + end + end + + describe 'GET #edit' do + it_behaves_like 'restricted action', success: 200 + + def go(id:) + get :edit, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: id + end + end + + describe 'PUT #update' do + it_behaves_like 'restricted action', success: 302 + + def go(id:) + put :update, + namespace_id: project.namespace.to_param, + project_id: project.to_param, + id: id, + issue: { title: 'New title' } + end + end end end diff --git a/spec/factories/issues.rb b/spec/factories/issues.rb index 722095de59..e72aa9479b 100644 --- a/spec/factories/issues.rb +++ b/spec/factories/issues.rb @@ -4,6 +4,10 @@ FactoryGirl.define do author project + trait :confidential do + confidential true + end + trait :closed do state :closed end From 43d8bdb4f048cbeb5675ed9120cb1aeb415b9586 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 17:39:50 -0300 Subject: [PATCH 260/797] Restrict access to references for confidential issues --- lib/banzai/filter/issue_reference_filter.rb | 5 ++ .../features/issues/new_branch_button_spec.rb | 2 +- .../lib/banzai/filter/redactor_filter_spec.rb | 72 ++++++++++++++++++- .../gitlab/closing_issue_extractor_spec.rb | 1 + spec/lib/gitlab/reference_extractor_spec.rb | 2 + spec/models/commit_spec.rb | 13 +++- spec/models/concerns/mentionable_spec.rb | 5 +- spec/models/merge_request_spec.rb | 1 + spec/services/git_push_service_spec.rb | 4 ++ spec/support/mentionable_shared_examples.rb | 2 + 10 files changed, 102 insertions(+), 5 deletions(-) diff --git a/lib/banzai/filter/issue_reference_filter.rb b/lib/banzai/filter/issue_reference_filter.rb index 9f08aa36e8..2732e0b514 100644 --- a/lib/banzai/filter/issue_reference_filter.rb +++ b/lib/banzai/filter/issue_reference_filter.rb @@ -9,6 +9,11 @@ module Banzai Issue end + def self.user_can_see_reference?(user, node, context) + issue = Issue.find(node.attr('data-issue')) rescue nil + Ability.abilities.allowed?(user, :read_issue, issue) + end + def find_object(project, id) project.get_issue(id) end diff --git a/spec/features/issues/new_branch_button_spec.rb b/spec/features/issues/new_branch_button_spec.rb index 1f3bd915f4..9219b76754 100644 --- a/spec/features/issues/new_branch_button_spec.rb +++ b/spec/features/issues/new_branch_button_spec.rb @@ -24,7 +24,7 @@ feature 'Start new branch from an issue', feature: true do end let(:referenced_mr) do create(:merge_request, :simple, source_project: project, target_project: project, - description: "Fixes ##{issue.iid}") + description: "Fixes ##{issue.iid}", author: user) end before do diff --git a/spec/lib/banzai/filter/redactor_filter_spec.rb b/spec/lib/banzai/filter/redactor_filter_spec.rb index e9bb388e36..9acf6304bc 100644 --- a/spec/lib/banzai/filter/redactor_filter_spec.rb +++ b/spec/lib/banzai/filter/redactor_filter_spec.rb @@ -44,8 +44,78 @@ describe Banzai::Filter::RedactorFilter, lib: true do end end - context "for user references" do + context 'with data-issue' do + context 'for confidential issues' do + it 'removes references for non project members' do + non_member = create(:user) + project = create(:empty_project, :public) + issue = create(:issue, :confidential, project: project) + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: non_member) + + expect(doc.css('a').length).to eq 0 + end + + it 'allows references for author' do + author = create(:user) + project = create(:empty_project, :public) + issue = create(:issue, :confidential, project: project, author: author) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: author) + + expect(doc.css('a').length).to eq 1 + end + + it 'allows references for assignee' do + assignee = create(:user) + project = create(:empty_project, :public) + issue = create(:issue, :confidential, project: project, assignee: assignee) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: assignee) + + expect(doc.css('a').length).to eq 1 + end + + it 'allows references for project members' do + member = create(:user) + project = create(:empty_project, :public) + project.team << [member, :developer] + issue = create(:issue, :confidential, project: project) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: member) + + expect(doc.css('a').length).to eq 1 + end + + it 'allows references for admin' do + admin = create(:admin) + project = create(:empty_project, :public) + issue = create(:issue, :confidential, project: project) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: admin) + + expect(doc.css('a').length).to eq 1 + end + end + + it 'allows references for non confidential issues' do + user = create(:user) + project = create(:empty_project, :public) + issue = create(:issue, project: project) + + link = reference_link(project: project.id, issue: issue.id, reference_filter: 'IssueReferenceFilter') + doc = filter(link, current_user: user) + + expect(doc.css('a').length).to eq 1 + end + end + + context "for user references" do context 'with data-group' do it 'removes unpermitted Group references' do user = create(:user) diff --git a/spec/lib/gitlab/closing_issue_extractor_spec.rb b/spec/lib/gitlab/closing_issue_extractor_spec.rb index 04cf11fc6f..844fd79c99 100644 --- a/spec/lib/gitlab/closing_issue_extractor_spec.rb +++ b/spec/lib/gitlab/closing_issue_extractor_spec.rb @@ -11,6 +11,7 @@ describe Gitlab::ClosingIssueExtractor, lib: true do subject { described_class.new(project, project.creator) } before do + project.team << [project.creator, :developer] project2.team << [project.creator, :master] end diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index 7d963795e1..65af37e24f 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::ReferenceExtractor, lib: true do let(:project) { create(:project) } + subject { Gitlab::ReferenceExtractor.new(project, project.creator) } it 'accesses valid user objects' do @@ -41,6 +42,7 @@ describe Gitlab::ReferenceExtractor, lib: true do end it 'accesses valid issue objects' do + project.team << [project.creator, :developer] @i0 = create(:issue, project: project) @i1 = create(:issue, project: project) diff --git a/spec/models/commit_spec.rb b/spec/models/commit_spec.rb index 253902512c..0e9111c802 100644 --- a/spec/models/commit_spec.rb +++ b/spec/models/commit_spec.rb @@ -86,10 +86,21 @@ eos let(:issue) { create :issue, project: project } let(:other_project) { create :project, :public } let(:other_issue) { create :issue, project: other_project } + let(:commiter) { create :user } + + before do + project.team << [commiter, :developer] + other_project.team << [commiter, :developer] + end it 'detects issues that this commit is marked as closing' do ext_ref = "#{other_project.path_with_namespace}##{other_issue.iid}" - allow(commit).to receive(:safe_message).and_return("Fixes ##{issue.iid} and #{ext_ref}") + + allow(commit).to receive_messages( + safe_message: "Fixes ##{issue.iid} and #{ext_ref}", + committer_email: commiter.email + ) + expect(commit.closes_issues).to include(issue) expect(commit.closes_issues).to include(other_issue) end diff --git a/spec/models/concerns/mentionable_spec.rb b/spec/models/concerns/mentionable_spec.rb index 20f0c561e4..cb33edde82 100644 --- a/spec/models/concerns/mentionable_spec.rb +++ b/spec/models/concerns/mentionable_spec.rb @@ -48,7 +48,8 @@ describe Issue, "Mentionable" do describe '#create_new_cross_references!' do let(:project) { create(:project) } - let(:issues) { create_list(:issue, 2, project: project) } + let(:author) { create(:author) } + let(:issues) { create_list(:issue, 2, project: project, author: author) } context 'before changes are persisted' do it 'ignores pre-existing references' do @@ -91,7 +92,7 @@ describe Issue, "Mentionable" do end def create_issue(description:) - create(:issue, project: project, description: description) + create(:issue, project: project, description: description, author: author) end end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 8bf68013fd..633a16b59c 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -150,6 +150,7 @@ describe MergeRequest, models: true do let(:commit2) { double('commit2', safe_message: "Fixes #{issue1.to_reference}") } before do + subject.project.team << [subject.author, :developer] allow(subject).to receive(:commits).and_return([commit0, commit1, commit2]) end diff --git a/spec/services/git_push_service_spec.rb b/spec/services/git_push_service_spec.rb index b49ca96e8e..8490a729e5 100644 --- a/spec/services/git_push_service_spec.rb +++ b/spec/services/git_push_service_spec.rb @@ -215,12 +215,16 @@ describe GitPushService, services: true do let(:commit) { project.commit } before do + project.team << [commit_author, :developer] + project.team << [user, :developer] + allow(commit).to receive_messages( safe_message: "this commit \n mentions #{issue.to_reference}", references: [issue], author_name: commit_author.name, author_email: commit_author.email ) + allow(project.repository).to receive(:commits_between).and_return([commit]) end diff --git a/spec/support/mentionable_shared_examples.rb b/spec/support/mentionable_shared_examples.rb index fce91015fd..e876d44c16 100644 --- a/spec/support/mentionable_shared_examples.rb +++ b/spec/support/mentionable_shared_examples.rb @@ -52,6 +52,8 @@ shared_context 'mentionable context' do end set_mentionable_text.call(ref_string) + + project.team << [author, :developer] end end From 7ee528336ad86e478b6db6d0039aec607c8f0192 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 17:39:56 -0300 Subject: [PATCH 261/797] Restrict access for confidential issues on autocomplete --- app/controllers/projects_controller.rb | 2 +- app/services/projects/autocomplete_service.rb | 6 +- .../projects/autocomplete_service_spec.rb | 79 +++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 spec/services/projects/autocomplete_service_spec.rb diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 36f37221c5..c993048077 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -134,7 +134,7 @@ class ProjectsController < ApplicationController def autocomplete_sources note_type = params['type'] note_id = params['type_id'] - autocomplete = ::Projects::AutocompleteService.new(@project) + autocomplete = ::Projects::AutocompleteService.new(@project, current_user) participants = ::Projects::ParticipantsService.new(@project, current_user).execute(note_type, note_id) @suggestions = { diff --git a/app/services/projects/autocomplete_service.rb b/app/services/projects/autocomplete_service.rb index 7408e09ed1..ba50305dbd 100644 --- a/app/services/projects/autocomplete_service.rb +++ b/app/services/projects/autocomplete_service.rb @@ -1,11 +1,7 @@ module Projects class AutocompleteService < BaseService - def initialize(project) - @project = project - end - def issues - @project.issues.opened.select([:iid, :title]) + @project.issues.visible_to_user(current_user).opened.select([:iid, :title]) end def merge_requests diff --git a/spec/services/projects/autocomplete_service_spec.rb b/spec/services/projects/autocomplete_service_spec.rb new file mode 100644 index 0000000000..6108c26a78 --- /dev/null +++ b/spec/services/projects/autocomplete_service_spec.rb @@ -0,0 +1,79 @@ +require 'spec_helper' + +describe Projects::AutocompleteService, services: true do + describe '#issues' do + describe 'confidential issues' do + let(:author) { create(:user) } + let(:assignee) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let(:project) { create(:empty_project, :public) } + let!(:issue) { create(:issue, project: project, title: 'Issue 1') } + let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + + it 'should not list project confidential issues for guests' do + autocomplete = described_class.new(project, nil) + 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 not list project confidential issues for non project members' do + 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) + + expect(issues).to include issue.iid + expect(issues).to include security_issue_1.iid + expect(issues).not_to include security_issue_2.iid + expect(issues.count).to eq 2 + end + + it 'should list project confidential issues for assignee' do + autocomplete = described_class.new(project, assignee) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).not_to include security_issue_1.iid + expect(issues).to include security_issue_2.iid + expect(issues.count).to eq 2 + end + + it 'should list project confidential issues for project members' do + project.team << [member, :developer] + + autocomplete = described_class.new(project, member) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).to include security_issue_1.iid + expect(issues).to include security_issue_2.iid + expect(issues.count).to eq 3 + end + + it 'should list all project issues for admin' do + autocomplete = described_class.new(project, admin) + issues = autocomplete.issues.map(&:iid) + + expect(issues).to include issue.iid + expect(issues).to include security_issue_1.iid + expect(issues).to include security_issue_2.iid + expect(issues.count).to eq 3 + end + end + end +end From e4f1c001e6886d6001a258bf2fad75f8b424eff1 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 17:45:00 -0300 Subject: [PATCH 262/797] Restrict access to confidential issues through API --- lib/api/issues.rb | 3 +- spec/requests/api/issues_spec.rb | 112 ++++++++++++++++++++++++++++++- 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 252744515d..fda6f84143 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -82,7 +82,7 @@ module API # GET /projects/:id/issues?milestone=1.0.0&state=closed # GET /issues?iid=42 get ":id/issues" do - issues = user_project.issues + issues = user_project.issues.visible_to_user(current_user) issues = filter_issues_state(issues, params[:state]) unless params[:state].nil? issues = filter_issues_labels(issues, params[:labels]) unless params[:labels].nil? issues = filter_by_iid(issues, params[:iid]) unless params[:iid].nil? @@ -104,6 +104,7 @@ module API # GET /projects/:id/issues/:issue_id get ":id/issues/:issue_id" do @issue = user_project.issues.find(params[:issue_id]) + not_found! unless can?(current_user, :read_issue, @issue) present @issue, with: Entities::Issue end diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 571ea2dae4..bb2ab05800 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -3,7 +3,11 @@ require 'spec_helper' describe API::API, api: true do include ApiHelpers let(:user) { create(:user) } - let!(:project) { create(:project, namespace: user.namespace ) } + let(:non_member) { create(:user) } + let(:author) { create(:author) } + let(:assignee) { create(:assignee) } + let(:admin) { create(:admin) } + let!(:project) { create(:project, :public, namespace: user.namespace ) } let!(:closed_issue) do create :closed_issue, author: user, @@ -12,6 +16,13 @@ describe API::API, api: true do state: :closed, milestone: milestone end + let!(:confidential_issue) do + create :issue, + :confidential, + project: project, + author: author, + assignee: assignee + end let!(:issue) do create :issue, author: user, @@ -123,10 +134,43 @@ describe API::API, api: true do let(:base_url) { "/projects/#{project.id}" } let(:title) { milestone.title } - it "should return project issues" do + it 'should return project issues without confidential issues for non project members' do + get api("#{base_url}/issues", non_member) + 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) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'should return project confidential issues for assignee' do + get api("#{base_url}/issues", assignee) + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'should return project issues with confidential issues for project members' do get api("#{base_url}/issues", user) expect(response.status).to eq(200) expect(json_response).to be_an Array + expect(json_response.length).to eq(3) + expect(json_response.first['title']).to eq(issue.title) + end + + it 'should return project confidential issues for admin' do + get api("#{base_url}/issues", admin) + expect(response.status).to eq(200) + expect(json_response).to be_an Array + expect(json_response.length).to eq(3) expect(json_response.first['title']).to eq(issue.title) end @@ -206,6 +250,41 @@ describe API::API, api: true do get api("/projects/#{project.id}/issues/54321", user) expect(response.status).to eq(404) end + + context 'confidential issues' do + it "should return 404 for non project members" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member) + 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) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it "should return confidential issue for author" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", author) + expect(response.status).to eq(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it "should return confidential issue for assignee" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", assignee) + expect(response.status).to eq(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + + it "should return confidential issue for admin" do + get api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin) + expect(response.status).to eq(200) + expect(json_response['title']).to eq(confidential_issue.title) + expect(json_response['iid']).to eq(confidential_issue.iid) + end + end end describe "POST /projects/:id/issues" do @@ -294,6 +373,35 @@ describe API::API, api: true do expect(response.status).to eq(400) expect(json_response['message']['labels']['?']['title']).to eq(['is invalid']) end + + context 'confidential issues' do + it "should return 403 for non project members" do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", non_member), + 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' + expect(response.status).to eq(200) + expect(json_response['title']).to eq('updated title') + end + + it "should update a confidential issue for author" do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", author), + title: 'updated title' + expect(response.status).to eq(200) + expect(json_response['title']).to eq('updated title') + end + + it "should update a confidential issue for admin" do + put api("/projects/#{project.id}/issues/#{confidential_issue.id}", admin), + title: 'updated title' + expect(response.status).to eq(200) + expect(json_response['title']).to eq('updated title') + end + end end describe 'PUT /projects/:id/issues/:issue_id to update labels' do From f2ba4e3d364671cb100446b584502c5522a751df Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 17:48:19 -0300 Subject: [PATCH 263/797] Restrict access to confidential issues on search results --- app/services/search/global_service.rb | 2 +- app/services/search/project_service.rb | 3 +- lib/gitlab/project_search_results.rb | 3 +- lib/gitlab/search_results.rb | 7 +- .../lib/gitlab/project_search_results_spec.rb | 69 +++++++++++++- spec/lib/gitlab/search_results_spec.rb | 91 ++++++++++++++++++- 6 files changed, 166 insertions(+), 9 deletions(-) diff --git a/app/services/search/global_service.rb b/app/services/search/global_service.rb index e1e94c5cc3..aa9837038a 100644 --- a/app/services/search/global_service.rb +++ b/app/services/search/global_service.rb @@ -11,7 +11,7 @@ module Search projects = ProjectsFinder.new.execute(current_user) projects = projects.in_namespace(group.id) if group - Gitlab::SearchResults.new(projects, params[:search]) + Gitlab::SearchResults.new(current_user, projects, params[:search]) end end end diff --git a/app/services/search/project_service.rb b/app/services/search/project_service.rb index c08881dce4..4b500914cf 100644 --- a/app/services/search/project_service.rb +++ b/app/services/search/project_service.rb @@ -7,7 +7,8 @@ module Search end def execute - Gitlab::ProjectSearchResults.new(project, + Gitlab::ProjectSearchResults.new(current_user, + project, params[:search], params[:repository_ref]) end diff --git a/lib/gitlab/project_search_results.rb b/lib/gitlab/project_search_results.rb index 0607a8b959..71c5b6801f 100644 --- a/lib/gitlab/project_search_results.rb +++ b/lib/gitlab/project_search_results.rb @@ -2,7 +2,8 @@ module Gitlab class ProjectSearchResults < SearchResults attr_reader :project, :repository_ref - def initialize(project, query, repository_ref = nil) + def initialize(current_user, project, query, repository_ref = nil) + @current_user = current_user @project = project @repository_ref = if repository_ref.present? repository_ref diff --git a/lib/gitlab/search_results.rb b/lib/gitlab/search_results.rb index f13528a2ee..f8ab2b1f09 100644 --- a/lib/gitlab/search_results.rb +++ b/lib/gitlab/search_results.rb @@ -1,12 +1,13 @@ module Gitlab class SearchResults - attr_reader :query + attr_reader :current_user, :query # Limit search results by passed projects # It allows us to search only for projects user has access to attr_reader :limit_projects - def initialize(limit_projects, query) + def initialize(current_user, limit_projects, query) + @current_user = current_user @limit_projects = limit_projects || Project.all @query = Shellwords.shellescape(query) if query.present? end @@ -58,7 +59,7 @@ module Gitlab end def issues - issues = Issue.where(project_id: project_ids_relation) + issues = Issue.visible_to_user(current_user).where(project_id: project_ids_relation) if query =~ /#(\d+)\z/ issues = issues.where(iid: $1) diff --git a/spec/lib/gitlab/project_search_results_spec.rb b/spec/lib/gitlab/project_search_results_spec.rb index 09adbc07dc..db0ff95b4f 100644 --- a/spec/lib/gitlab/project_search_results_spec.rb +++ b/spec/lib/gitlab/project_search_results_spec.rb @@ -1,11 +1,12 @@ require 'spec_helper' describe Gitlab::ProjectSearchResults, lib: true do + let(:user) { create(:user) } let(:project) { create(:project) } let(:query) { 'hello world' } describe 'initialize with empty ref' do - let(:results) { Gitlab::ProjectSearchResults.new(project, query, '') } + let(:results) { Gitlab::ProjectSearchResults.new(user, project, query, '') } it { expect(results.project).to eq(project) } it { expect(results.repository_ref).to be_nil } @@ -14,10 +15,74 @@ describe Gitlab::ProjectSearchResults, lib: true do describe 'initialize with ref' do let(:ref) { 'refs/heads/test' } - let(:results) { Gitlab::ProjectSearchResults.new(project, query, ref) } + let(:results) { Gitlab::ProjectSearchResults.new(user, project, query, ref) } it { expect(results.project).to eq(project) } it { expect(results.repository_ref).to eq(ref) } it { expect(results.query).to eq('hello world') } end + + describe 'confidential issues' do + let(:query) { 'issue' } + let(:author) { create(:user) } + let(:assignee) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let!(:issue) { create(:issue, project: project, title: 'Issue 1') } + let!(:security_issue_1) { create(:issue, :confidential, project: project, title: 'Security issue 1', author: author) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project, assignee: assignee) } + + it 'should not list project confidential issues for non project members' do + results = described_class.new(non_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') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).not_to include security_issue_2 + expect(results.issues_count).to eq 2 + end + + it 'should list project confidential issues for assignee' do + results = described_class.new(assignee, project.id, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).not_to include security_issue_1 + expect(issues).to include security_issue_2 + expect(results.issues_count).to eq 2 + end + + it 'should list project confidential issues for project members' do + project.team << [member, :developer] + + results = described_class.new(member, project, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).to include security_issue_2 + expect(results.issues_count).to eq 3 + end + + it 'should list all project issues for admin' do + results = described_class.new(admin, project, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).to include security_issue_2 + expect(results.issues_count).to eq 3 + end + end end diff --git a/spec/lib/gitlab/search_results_spec.rb b/spec/lib/gitlab/search_results_spec.rb index bb18f41785..f4afe597e8 100644 --- a/spec/lib/gitlab/search_results_spec.rb +++ b/spec/lib/gitlab/search_results_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe Gitlab::SearchResults do + let(:user) { create(:user) } let!(:project) { create(:project, name: 'foo') } let!(:issue) { create(:issue, project: project, title: 'foo') } @@ -9,7 +10,7 @@ describe Gitlab::SearchResults do end let!(:milestone) { create(:milestone, project: project, title: 'foo') } - let(:results) { described_class.new(Project.all, 'foo') } + let(:results) { described_class.new(user, Project.all, 'foo') } describe '#total_count' do it 'returns the total amount of search hits' do @@ -52,4 +53,92 @@ describe Gitlab::SearchResults do expect(results.empty?).to eq(false) end end + + describe 'confidential issues' do + let(:project_1) { create(:empty_project) } + let(:project_2) { create(:empty_project) } + let(:project_3) { create(:empty_project) } + let(:project_4) { create(:empty_project) } + let(:query) { 'issue' } + let(:limit_projects) { Project.where(id: [project_1.id, project_2.id, project_3.id]) } + let(:author) { create(:user) } + let(:assignee) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let!(:issue) { create(:issue, project: project_1, title: 'Issue 1') } + let!(:security_issue_1) { create(:issue, :confidential, project: project_1, title: 'Security issue 1', author: author) } + let!(:security_issue_2) { create(:issue, :confidential, title: 'Security issue 2', project: project_1, assignee: assignee) } + let!(:security_issue_3) { create(:issue, :confidential, project: project_2, title: 'Security issue 3', author: author) } + let!(:security_issue_4) { create(:issue, :confidential, project: project_3, title: 'Security issue 4', assignee: assignee) } + let!(:security_issue_5) { create(:issue, :confidential, project: project_4, title: 'Security issue 5') } + + it 'should not list confidential issues for non project members' do + results = described_class.new(non_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') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).not_to include security_issue_2 + expect(issues).to include security_issue_3 + expect(issues).not_to include security_issue_4 + expect(issues).not_to include security_issue_5 + expect(results.issues_count).to eq 3 + end + + it 'should list confidential issues for assignee' do + results = described_class.new(assignee, limit_projects, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).not_to include security_issue_1 + expect(issues).to include security_issue_2 + expect(issues).not_to include security_issue_3 + expect(issues).to include security_issue_4 + expect(issues).not_to include security_issue_5 + expect(results.issues_count).to eq 3 + end + + it 'should list confidential issues for project members' do + project_1.team << [member, :developer] + project_2.team << [member, :developer] + + results = described_class.new(member, limit_projects, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).to include security_issue_2 + expect(issues).to include security_issue_3 + expect(issues).not_to include security_issue_4 + expect(issues).not_to include security_issue_5 + expect(results.issues_count).to eq 4 + end + + it 'should list all issues for admin' do + results = described_class.new(admin, limit_projects, query) + issues = results.objects('issues') + + expect(issues).to include issue + expect(issues).to include security_issue_1 + expect(issues).to include security_issue_2 + expect(issues).to include security_issue_3 + expect(issues).to include security_issue_4 + expect(issues).not_to include security_issue_5 + expect(results.issues_count).to eq 5 + end + end end From 1029f4101b1de98ec7ffa706530bf405ffa71060 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 17:53:21 -0300 Subject: [PATCH 264/797] Fix issues count on project view --- app/helpers/application_helper.rb | 2 +- app/views/layouts/nav/_project.html.haml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 883c287174..e6ceb21353 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -301,7 +301,7 @@ module ApplicationHelper if project.nil? nil elsif current_controller?(:issues) - project.issues.send(entity).count + project.issues.visible_to_user(current_user).send(entity).count elsif current_controller?(:merge_requests) project.merge_requests.send(entity).count end diff --git a/app/views/layouts/nav/_project.html.haml b/app/views/layouts/nav/_project.html.haml index 0ae83ee01e..86b46e8c75 100644 --- a/app/views/layouts/nav/_project.html.haml +++ b/app/views/layouts/nav/_project.html.haml @@ -67,7 +67,7 @@ %span Issues - if @project.default_issues_tracker? - %span.count.issue_counter= number_with_delimiter(@project.issues.opened.count) + %span.count.issue_counter= number_with_delimiter(@project.issues.visible_to_user(current_user).opened.count) - if project_nav_tab? :merge_requests = nav_link(controller: :merge_requests) do From 482bfd1a6d454652feb1f64c0c0e1c50ce701986 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 17:59:30 -0300 Subject: [PATCH 265/797] Restrict access for confidential issues on milestone view --- app/helpers/milestones_helper.rb | 2 +- app/models/concerns/milestoneish.rb | 20 ++-- app/models/milestone.rb | 4 +- app/views/projects/milestones/show.html.haml | 2 +- .../shared/milestones/_milestone.html.haml | 4 +- .../shared/milestones/_summary.html.haml | 8 +- app/views/shared/milestones/_tabs.html.haml | 4 +- app/views/shared/milestones/_top.html.haml | 4 +- spec/models/concerns/milestoneish_spec.rb | 104 ++++++++++++++++++ spec/models/milestone_spec.rb | 20 ++-- 10 files changed, 140 insertions(+), 32 deletions(-) create mode 100644 spec/models/concerns/milestoneish_spec.rb diff --git a/app/helpers/milestones_helper.rb b/app/helpers/milestones_helper.rb index e8ac8788d9..92ed0891e9 100644 --- a/app/helpers/milestones_helper.rb +++ b/app/helpers/milestones_helper.rb @@ -38,7 +38,7 @@ module MilestonesHelper def milestone_progress_bar(milestone) options = { class: 'progress-bar progress-bar-success', - style: "width: #{milestone.percent_complete}%;" + style: "width: #{milestone.percent_complete(current_user)}%;" } content_tag :div, class: 'progress' do diff --git a/app/models/concerns/milestoneish.rb b/app/models/concerns/milestoneish.rb index d67df7c1d9..5b8e3f654e 100644 --- a/app/models/concerns/milestoneish.rb +++ b/app/models/concerns/milestoneish.rb @@ -1,18 +1,18 @@ module Milestoneish - def closed_items_count - issues.closed.size + merge_requests.closed_and_merged.size + def closed_items_count(user = nil) + issues_visible_to_user(user).closed.size + merge_requests.closed_and_merged.size end - def total_items_count - issues.size + merge_requests.size + def total_items_count(user = nil) + issues_visible_to_user(user).size + merge_requests.size end - def complete? - total_items_count == closed_items_count + def complete?(user = nil) + total_items_count(user) == closed_items_count(user) end - def percent_complete - ((closed_items_count * 100) / total_items_count).abs + def percent_complete(user = nil) + ((closed_items_count(user) * 100) / total_items_count(user)).abs rescue ZeroDivisionError 0 end @@ -22,4 +22,8 @@ module Milestoneish (due_date - Date.today).to_i end + + def issues_visible_to_user(user = nil) + issues.visible_to_user(user) + end end diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 374590ba0c..de7183bf6b 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -121,8 +121,8 @@ class Milestone < ActiveRecord::Base active? && issues.opened.count.zero? end - def is_empty? - total_items_count.zero? + def is_empty?(user = nil) + total_items_count(user).zero? end def author_id diff --git a/app/views/projects/milestones/show.html.haml b/app/views/projects/milestones/show.html.haml index b4597043a2..be63875ab3 100644 --- a/app/views/projects/milestones/show.html.haml +++ b/app/views/projects/milestones/show.html.haml @@ -42,7 +42,7 @@ = preserve do = markdown @milestone.description -- if @milestone.complete? && @milestone.active? +- if @milestone.complete?(current_user) && @milestone.active? .alert.alert-success.prepend-top-default %span All issues for this milestone are closed. You may close milestone now. diff --git a/app/views/shared/milestones/_milestone.html.haml b/app/views/shared/milestones/_milestone.html.haml index f01138af3f..6b25745c55 100644 --- a/app/views/shared/milestones/_milestone.html.haml +++ b/app/views/shared/milestones/_milestone.html.haml @@ -6,10 +6,10 @@ .col-sm-6 %strong= link_to_gfm truncate(milestone.title, length: 100), milestone_path .col-sm-6 - .pull-right.light #{milestone.percent_complete}% complete + .pull-right.light #{milestone.percent_complete(current_user)}% complete .row .col-sm-6 - = link_to pluralize(milestone.issues.size, 'Issue'), issues_path + = link_to pluralize(milestone.issues_visible_to_user(current_user).size, 'Issue'), issues_path · = link_to pluralize(milestone.merge_requests.size, 'Merge Request'), merge_requests_path .col-sm-6= milestone_progress_bar(milestone) diff --git a/app/views/shared/milestones/_summary.html.haml b/app/views/shared/milestones/_summary.html.haml index 59d4ae29f7..385c659660 100644 --- a/app/views/shared/milestones/_summary.html.haml +++ b/app/views/shared/milestones/_summary.html.haml @@ -3,15 +3,15 @@ .context.prepend-top-default .milestone-summary %h4 Progress - %strong= milestone.issues.size + %strong= milestone.issues_visible_to_user(current_user).size issues: %span.milestone-stat - %strong= milestone.issues.opened.size + %strong= milestone.issues_visible_to_user(current_user).opened.size open and - %strong= milestone.issues.closed.size + %strong= milestone.issues_visible_to_user(current_user).closed.size closed %span.milestone-stat - %strong== #{milestone.percent_complete}% + %strong== #{milestone.percent_complete(current_user)}% complete %span.milestone-stat diff --git a/app/views/shared/milestones/_tabs.html.haml b/app/views/shared/milestones/_tabs.html.haml index 57d7ee85a3..2b6ce2d7e7 100644 --- a/app/views/shared/milestones/_tabs.html.haml +++ b/app/views/shared/milestones/_tabs.html.haml @@ -2,7 +2,7 @@ %li.active = link_to '#tab-issues', 'data-toggle' => 'tab', 'data-show' => '.tab-issues-buttons' do Issues - %span.badge= milestone.issues.size + %span.badge= milestone.issues_visible_to_user(current_user).size %li = link_to '#tab-merge-requests', 'data-toggle' => 'tab', 'data-show' => '.tab-merge-requests-buttons' do Merge Requests @@ -21,7 +21,7 @@ .tab-content.milestone-content .tab-pane.active#tab-issues - = render 'shared/milestones/issues_tab', issues: milestone.issues, show_project_name: show_project_name, show_full_project_name: show_full_project_name + = render 'shared/milestones/issues_tab', issues: milestone.issues_visible_to_user(current_user), show_project_name: show_project_name, show_full_project_name: show_full_project_name .tab-pane#tab-merge-requests = render 'shared/milestones/merge_requests_tab', merge_requests: milestone.merge_requests, show_project_name: show_project_name, show_full_project_name: show_full_project_name .tab-pane#tab-participants diff --git a/app/views/shared/milestones/_top.html.haml b/app/views/shared/milestones/_top.html.haml index 4cf1d948b5..cab8743a07 100644 --- a/app/views/shared/milestones/_top.html.haml +++ b/app/views/shared/milestones/_top.html.haml @@ -28,7 +28,7 @@ %h2.title = markdown escape_once(milestone.title), pipeline: :single_line -- if milestone.complete? && milestone.active? +- if milestone.complete?(current_user) && milestone.active? .alert.alert-success.prepend-top-default - close_msg = group ? 'You may close the milestone now.' : 'Navigate to the project to close the milestone.' %span All issues for this milestone are closed. #{close_msg} @@ -47,7 +47,7 @@ - project_name = group ? ms.project.name : ms.project.name_with_namespace = link_to project_name, namespace_project_milestone_path(ms.project.namespace, ms.project, ms) %td - = ms.issues.opened.count + = ms.issues_visible_to_user(current_user).opened.count %td - if ms.closed? Closed diff --git a/spec/models/concerns/milestoneish_spec.rb b/spec/models/concerns/milestoneish_spec.rb new file mode 100644 index 0000000000..47c3be673c --- /dev/null +++ b/spec/models/concerns/milestoneish_spec.rb @@ -0,0 +1,104 @@ +require 'spec_helper' + +describe Milestone, 'Milestoneish' do + let(:author) { create(:user) } + let(:assignee) { create(:user) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:admin) { create(:admin) } + let(:project) { create(:project, :public) } + let(:milestone) { create(:milestone, project: project) } + let!(:issue) { create(:issue, project: project, milestone: milestone) } + let!(:security_issue_1) { create(:issue, :confidential, project: project, author: author, milestone: milestone) } + let!(:security_issue_2) { create(:issue, :confidential, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_issue_1) { create(:issue, :closed, project: project, milestone: milestone) } + let!(:closed_issue_2) { create(:issue, :closed, project: project, milestone: milestone) } + let!(:closed_security_issue_1) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } + let!(:closed_security_issue_2) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:closed_security_issue_3) { create(:issue, :confidential, :closed, project: project, author: author, milestone: milestone) } + let!(:closed_security_issue_4) { create(:issue, :confidential, :closed, project: project, assignee: assignee, milestone: milestone) } + let!(:merge_request) { create(:merge_request, source_project: project, target_project: project, milestone: milestone) } + + before do + project.team << [member, :developer] + end + + describe '#closed_items_count' do + it 'should not count confidential issues for non project members' do + expect(milestone.closed_items_count(non_member)).to eq 2 + end + + it 'should count confidential issues for author' do + expect(milestone.closed_items_count(author)).to eq 4 + end + + it 'should count confidential issues for assignee' do + expect(milestone.closed_items_count(assignee)).to eq 4 + end + + it 'should count confidential issues for project members' do + expect(milestone.closed_items_count(member)).to eq 6 + end + + it 'should count all issues for admin' do + expect(milestone.closed_items_count(admin)).to eq 6 + end + end + + describe '#total_items_count' do + it 'should not count confidential issues for non project members' do + expect(milestone.total_items_count(non_member)).to eq 4 + end + + it 'should count confidential issues for author' do + expect(milestone.total_items_count(author)).to eq 7 + end + + it 'should count confidential issues for assignee' do + expect(milestone.total_items_count(assignee)).to eq 7 + end + + it 'should count confidential issues for project members' do + expect(milestone.total_items_count(member)).to eq 10 + end + + it 'should count all issues for admin' do + expect(milestone.total_items_count(admin)).to eq 10 + end + end + + describe '#complete?' do + it 'returns false when has items opened' do + expect(milestone.complete?(non_member)).to eq false + end + + it 'returns true when all items are closed' do + issue.close + merge_request.close + + expect(milestone.complete?(non_member)).to eq true + end + end + + describe '#percent_complete' do + it 'should not count confidential issues for non project members' do + expect(milestone.percent_complete(non_member)).to eq 50 + end + + it 'should count confidential issues for author' do + expect(milestone.percent_complete(author)).to eq 57 + end + + it 'should count confidential issues for assignee' do + expect(milestone.percent_complete(assignee)).to eq 57 + end + + it 'should count confidential issues for project members' do + expect(milestone.percent_complete(member)).to eq 60 + end + + it 'should count confidential issues for admin' do + expect(milestone.percent_complete(admin)).to eq 60 + end + end +end diff --git a/spec/models/milestone_spec.rb b/spec/models/milestone_spec.rb index de1757bf67..72a4ea7022 100644 --- a/spec/models/milestone_spec.rb +++ b/spec/models/milestone_spec.rb @@ -32,6 +32,7 @@ describe Milestone, models: true do let(:milestone) { create(:milestone) } let(:issue) { create(:issue) } + let(:user) { create(:user) } describe "unique milestone title per project" do it "shouldn't accept the same title in a project twice" do @@ -50,18 +51,17 @@ describe Milestone, models: true do describe "#percent_complete" do it "should not count open issues" do milestone.issues << issue - expect(milestone.percent_complete).to eq(0) + expect(milestone.percent_complete(user)).to eq(0) end it "should count closed issues" do issue.close milestone.issues << issue - expect(milestone.percent_complete).to eq(100) + expect(milestone.percent_complete(user)).to eq(100) end it "should recover from dividing by zero" do - expect(milestone.issues).to receive(:size).and_return(0) - expect(milestone.percent_complete).to eq(0) + expect(milestone.percent_complete(user)).to eq(0) end end @@ -103,7 +103,7 @@ describe Milestone, models: true do ) end - it { expect(milestone.percent_complete).to eq(75) } + it { expect(milestone.percent_complete(user)).to eq(75) } end describe :items_count do @@ -113,23 +113,23 @@ describe Milestone, models: true do milestone.merge_requests << create(:merge_request) end - it { expect(milestone.closed_items_count).to eq(1) } - it { expect(milestone.total_items_count).to eq(3) } - it { expect(milestone.is_empty?).to be_falsey } + it { expect(milestone.closed_items_count(user)).to eq(1) } + it { expect(milestone.total_items_count(user)).to eq(3) } + it { expect(milestone.is_empty?(user)).to be_falsey } end describe :can_be_closed? do it { expect(milestone.can_be_closed?).to be_truthy } end - describe :is_empty? do + describe :total_items_count do before do create :closed_issue, milestone: milestone create :merge_request, milestone: milestone end it 'Should return total count of issues and merge requests assigned to milestone' do - expect(milestone.total_items_count).to eq 2 + expect(milestone.total_items_count(user)).to eq 2 end end From 7d403ec46ffd5778a68eebb9117e08f605938b15 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 18:00:03 -0300 Subject: [PATCH 266/797] Add eye-slash icon to confidential issues --- app/helpers/issues_helper.rb | 4 ++++ app/views/projects/issues/_issue.html.haml | 1 + app/views/projects/issues/show.html.haml | 1 + app/views/search/results/_issue.html.haml | 1 + app/views/shared/milestones/_issuable.html.haml | 2 ++ 5 files changed, 9 insertions(+) diff --git a/app/helpers/issues_helper.rb b/app/helpers/issues_helper.rb index ae4ebc0854..e00d320402 100644 --- a/app/helpers/issues_helper.rb +++ b/app/helpers/issues_helper.rb @@ -98,6 +98,10 @@ module IssuesHelper end.sort.to_sentence(last_word_connector: ', or ') end + def confidential_icon(issue) + icon('eye-slash') if issue.confidential? + end + def emoji_icon(name, unicode = nil, aliases = []) unicode ||= Emoji.emoji_filename(name) rescue "" diff --git a/app/views/projects/issues/_issue.html.haml b/app/views/projects/issues/_issue.html.haml index a44f34c2a6..00e1a3d806 100644 --- a/app/views/projects/issues/_issue.html.haml +++ b/app/views/projects/issues/_issue.html.haml @@ -5,6 +5,7 @@ .issue-title %span.issue-title-text + = confidential_icon(issue) = link_to_gfm issue.title, issue_path(issue), class: "title" %ul.controls.light - if issue.closed? diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index c3ee5c80e5..ce5b84ee71 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -22,6 +22,7 @@ = icon('angle-double-left') .issue-meta + = confidential_icon(@issue) %strong.identifier Issue ##{@issue.iid} %span.creator diff --git a/app/views/search/results/_issue.html.haml b/app/views/search/results/_issue.html.haml index 45d700781f..710f5613c8 100644 --- a/app/views/search/results/_issue.html.haml +++ b/app/views/search/results/_issue.html.haml @@ -1,5 +1,6 @@ .search-result-row %h4 + = confidential_icon(issue) = link_to [issue.project.namespace.becomes(Namespace), issue.project, issue] do %span.term.str-truncated= issue.title .pull-right ##{issue.iid} diff --git a/app/views/shared/milestones/_issuable.html.haml b/app/views/shared/milestones/_issuable.html.haml index f7c6fc14ad..8588809672 100644 --- a/app/views/shared/milestones/_issuable.html.haml +++ b/app/views/shared/milestones/_issuable.html.haml @@ -10,6 +10,8 @@ %strong #{project.name} · - elsif show_full_project_name %strong #{project.name_with_namespace} · + - if issuable.is_a?(Issue) + = confidential_icon(issuable) = link_to_gfm issuable.title, [project.namespace.becomes(Namespace), project, issuable], title: issuable.title %div{class: 'issuable-detail'} = link_to [project.namespace.becomes(Namespace), project, issuable] do From 9222459ea36ce7bfafdf76742a5a44db7957db8d Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 18:03:10 -0300 Subject: [PATCH 267/797] Restrict access to confidential issues on activity feed --- app/helpers/events_helper.rb | 2 +- app/models/event.rb | 6 ++++-- app/views/events/_event.html.haml | 2 +- features/steps/groups.rb | 2 +- spec/models/event_spec.rb | 36 +++++++++++++++++++++++++++++++ 5 files changed, 43 insertions(+), 5 deletions(-) diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 37a888d9c6..a67a6b208e 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -194,7 +194,7 @@ module EventsHelper end def event_to_atom(xml, event) - if event.proper? + if event.proper?(current_user) xml.entry do event_link = event_feed_url(event) event_title = event_feed_title(event) diff --git a/app/models/event.rb b/app/models/event.rb index 9a0bbf50f8..a5cfeaf388 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -73,15 +73,17 @@ class Event < ActiveRecord::Base end end - def proper? + def proper?(user = nil) if push? true elsif membership_changed? true elsif created_project? true + elsif issue? + Ability.abilities.allowed?(user, :read_issue, issue) else - ((issue? || merge_request? || note?) && target) || milestone? + ((merge_request? || note?) && target) || milestone? end end diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index 36fb2d5162..2d9d9dd634 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -1,4 +1,4 @@ -- if event.proper? +- if event.proper?(current_user) .event-item{class: "#{event.body? ? "event-block" : "event-inline" }"} .event-item-timestamp #{time_ago_with_tooltip(event.created_at)} diff --git a/features/steps/groups.rb b/features/steps/groups.rb index 7a6ae15ffa..e5b7db4c5e 100644 --- a/features/steps/groups.rb +++ b/features/steps/groups.rb @@ -35,7 +35,7 @@ class Spinach::Features::Groups < Spinach::FeatureSteps end step 'I should see projects activity feed' do - expect(page).to have_content 'closed issue' + expect(page).to have_content 'joined project' end step 'I should see issues from group "Owned" assigned to me' do diff --git a/spec/models/event_spec.rb b/spec/models/event_spec.rb index ec2a923f91..5fe4424673 100644 --- a/spec/models/event_spec.rb +++ b/spec/models/event_spec.rb @@ -65,6 +65,42 @@ describe Event, models: true do it { expect(@event.author).to eq(@user) } end + describe '#proper?' do + context 'issue event' do + let(:project) { create(:empty_project, :public) } + let(:non_member) { create(:user) } + let(:member) { create(:user) } + let(:author) { create(:author) } + let(:assignee) { create(:user) } + let(:admin) { create(:admin) } + let(:event) { Event.new(project: project, action: Event::CREATED, target: issue, author_id: author.id) } + + before do + project.team << [member, :developer] + end + + context 'for non confidential issues' do + let(:issue) { create(:issue, project: project, author: author, assignee: assignee) } + + it { expect(event.proper?(non_member)).to eq true } + it { expect(event.proper?(author)).to eq true } + it { expect(event.proper?(assignee)).to eq true } + it { expect(event.proper?(member)).to eq true } + it { expect(event.proper?(admin)).to eq true } + end + + context 'for confidential issues' do + let(:issue) { create(:issue, :confidential, project: project, author: author, assignee: assignee) } + + it { expect(event.proper?(non_member)).to eq false } + it { expect(event.proper?(author)).to eq true } + it { expect(event.proper?(assignee)).to eq true } + it { expect(event.proper?(member)).to eq true } + it { expect(event.proper?(admin)).to eq true } + end + end + end + describe '.limit_recent' do let!(:event1) { create(:closed_issue_event) } let!(:event2) { create(:closed_issue_event) } From 5ed7e2cfd5de7c1db0374594428895e84e98321e Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Mar 2016 18:03:39 -0300 Subject: [PATCH 268/797] Update CHANGELOG --- CHANGELOG | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG b/CHANGELOG index 102908102e..d0b68ac07b 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.6.0 (unreleased) + - Add confidential issues - Bump gitlab_git to 9.0.3 (Stan Hu) - Support Golang subpackage fetching (Stan Hu) - Bump Capybara gem to 2.6.2 (Stan Hu) From 0901d5172429630c8d1ea6d8e91be71a2e690043 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Thu, 17 Mar 2016 16:25:21 -0700 Subject: [PATCH 269/797] Improve award emoji test reliability by checking that the emoji is displayed before the search field is focused --- features/project/issues/award_emoji.feature | 11 +++++++---- features/steps/project/issues/award_emoji.rb | 4 ++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/features/project/issues/award_emoji.feature b/features/project/issues/award_emoji.feature index 2945bb3753..f0fd414a9f 100644 --- a/features/project/issues/award_emoji.feature +++ b/features/project/issues/award_emoji.feature @@ -18,21 +18,24 @@ Feature: Award Emoji @javascript Scenario: I add and remove custom award in the issue Given I click to emoji-picker - Then The search field is focused - And I click to emoji in the picker + Then The emoji menu is visible + And The search field is focused + Then I click to emoji in the picker Then I have award added And I can remove it by clicking to icon @javascript Scenario: I can see the list of emoji categories Given I click to emoji-picker - Then The search field is focused + Then The emoji menu is visible + And The search field is focused Then I can see the activity and food categories @javascript Scenario: I can search emoji Given I click to emoji-picker - Then The search field is focused + Then The emoji menu is visible + And The search field is focused And I search "hand" Then I see search result for "hand" diff --git a/features/steps/project/issues/award_emoji.rb b/features/steps/project/issues/award_emoji.rb index ce2554bc80..c5d45709b4 100644 --- a/features/steps/project/issues/award_emoji.rb +++ b/features/steps/project/issues/award_emoji.rb @@ -92,6 +92,10 @@ class Spinach::Features::AwardEmoji < Spinach::FeatureSteps end end + step 'The emoji menu is visible' do + page.find(".emoji-menu.is-visible") + end + step 'The search field is focused' do expect(page).to have_selector('#emoji_search') expect(page.evaluate_script('document.activeElement.id')).to eq('emoji_search') From 423e0068407d0589e097ff76bb8301384b39229b Mon Sep 17 00:00:00 2001 From: Robert Speicher Date: Wed, 16 Mar 2016 15:45:51 -0400 Subject: [PATCH 270/797] Expand the issuable sidebar when coming back from the Changes tab Unless the user explicitly requested the collapsed sidebar. --- .../javascripts/merge_request_tabs.js.coffee | 25 ++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/app/assets/javascripts/merge_request_tabs.js.coffee b/app/assets/javascripts/merge_request_tabs.js.coffee index 8322b4c46a..839e6ec2c0 100644 --- a/app/assets/javascripts/merge_request_tabs.js.coffee +++ b/app/assets/javascripts/merge_request_tabs.js.coffee @@ -3,6 +3,8 @@ # Handles persisting and restoring the current tab selection and lazily-loading # content on the MergeRequests#show page. # +#= require jquery.cookie +# # ### Example Markup # #