From 6970c1f331e1f56424ff90fe43113d9f512bce95 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Wed, 2 Nov 2016 16:41:32 +0100 Subject: [PATCH 001/175] Allow to use Dockerfile templates --- app/assets/javascripts/api.js | 5 +++ .../javascripts/blob/blob_ci_yaml.js.es6 | 36 +++++++++++++++++++ app/assets/javascripts/blob_edit/edit_blob.js | 3 ++ app/assets/stylesheets/pages/editor.scss | 6 ++-- app/helpers/blob_helper.rb | 4 +++ app/views/projects/blob/_editor.html.haml | 2 ++ lib/api/templates.rb | 11 +++--- lib/gitlab/template/dockerfile_template.rb | 30 ++++++++++++++++ vendor/dockerfile/HTTPdDockerfile | 3 ++ 9 files changed, 93 insertions(+), 7 deletions(-) create mode 100644 lib/gitlab/template/dockerfile_template.rb create mode 100644 vendor/dockerfile/HTTPdDockerfile diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 1cab66e109e..291efd2e286 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -10,6 +10,7 @@ licensePath: "/api/:version/templates/licenses/:key", gitignorePath: "/api/:version/templates/gitignores/:key", gitlabCiYmlPath: "/api/:version/templates/gitlab_ci_ymls/:key", + dockerfilePath: "/api/:version/dockerfiles/:key", issuableTemplatePath: "/:namespace_path/:project_path/templates/:type/:key", group: function(group_id, callback) { var url = Api.buildUrl(Api.groupPath) @@ -119,6 +120,10 @@ return callback(file); }); }, + dockerfileYml: function(key, callback) { + var url = Api.buildUrl(Api.dockerfilePath).replace(':key', key); + $.get(url, callback); + }, issueTemplate: function(namespacePath, projectPath, key, type, callback) { var url = Api.buildUrl(Api.issuableTemplatePath) .replace(':key', key) diff --git a/app/assets/javascripts/blob/blob_ci_yaml.js.es6 b/app/assets/javascripts/blob/blob_ci_yaml.js.es6 index 37531aaec9b..62e686b8d9b 100644 --- a/app/assets/javascripts/blob/blob_ci_yaml.js.es6 +++ b/app/assets/javascripts/blob/blob_ci_yaml.js.es6 @@ -38,4 +38,40 @@ global.BlobCiYamlSelectors = BlobCiYamlSelectors; + class BlobDockerfileSelector extends gl.TemplateSelector { + requestFile(query) { + return Api.dockerfileYml(query.name, this.requestFileSuccess.bind(this)); + } + + requestFileSuccess(file) { + return super.requestFileSuccess(file); + } + } + + global.BlobDockerfileSelector = BlobDockerfileSelector; + + class BlobDockerfileSelectors { + constructor({ editor, $dropdowns } = {}) { + this.editor = editor; + this.$dropdowns = $dropdowns || $('.js-dockerfile-selector'); + this.initSelectors(); + } + + initSelectors() { + const editor = this.editor; + this.$dropdowns.each((i, dropdown) => { + const $dropdown = $(dropdown); + return new BlobDockerfileSelector({ + editor, + pattern: /(Dockerfile)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-dockerfile-selector-wrap'), + dropdown: $dropdown + }); + }); + } + } + + global.BlobDockerfileSelectors = BlobDockerfileSelectors; + })(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 60840560dd3..11dff7dfab4 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -33,6 +33,9 @@ new gl.BlobCiYamlSelectors({ editor: this.editor }); + new gl.BlobDockerfileSelectors({ + editor: this.editor + }); } EditBlob.prototype.initModePanesAndLinks = function() { diff --git a/app/assets/stylesheets/pages/editor.scss b/app/assets/stylesheets/pages/editor.scss index 778126bcfb7..ac968618c79 100644 --- a/app/assets/stylesheets/pages/editor.scss +++ b/app/assets/stylesheets/pages/editor.scss @@ -67,7 +67,8 @@ .soft-wrap-toggle, .license-selector, .gitignore-selector, - .gitlab-ci-yml-selector { + .gitlab-ci-yml-selector, + .dockerfile-selector { display: inline-block; vertical-align: top; font-family: $regular_font; @@ -97,7 +98,8 @@ .gitignore-selector, .license-selector, - .gitlab-ci-yml-selector { + .gitlab-ci-yml-selector, + .dockerfile-selector { .dropdown { line-height: 21px; } diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 07ff6fb9488..f31d4fb897d 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -191,6 +191,10 @@ module BlobHelper @gitlab_ci_ymls ||= Gitlab::Template::GitlabCiYmlTemplate.dropdown_names end + def dockerfile_names + @dockerfile_names ||= Gitlab::Template::DockerfileTemplate.dropdown_names + end + def blob_editor_paths { 'relative-url-root' => Rails.application.config.relative_url_root, diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 4a6aa92e3f3..34dc96bcb64 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -21,6 +21,8 @@ = dropdown_tag("Choose a .gitignore template", options: { toggle_class: 'btn js-gitignore-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } ) .gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.hidden = dropdown_tag("Choose a GitLab CI Yaml template", options: { toggle_class: 'btn js-gitlab-ci-yml-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls } } ) + .gitlab-ci-yml-selector.js-dockerfile-selector-wrap.hidden + = dropdown_tag("Choose a Dockerfile template", options: { toggle_class: 'js-dockerfile-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names } } ) = button_tag class: 'soft-wrap-toggle btn', type: 'button' do %span.no-wrap = custom_icon('icon_no_wrap') diff --git a/lib/api/templates.rb b/lib/api/templates.rb index 8a53d9c0095..442fef1a848 100644 --- a/lib/api/templates.rb +++ b/lib/api/templates.rb @@ -8,7 +8,8 @@ module API gitlab_ci_ymls: { klass: Gitlab::Template::GitlabCiYmlTemplate, gitlab_version: 8.9 - } + }, + dockerfiles: Gitlab::Template::DockerfileTemplate }.freeze PROJECT_TEMPLATE_REGEX = /[\<\{\[] @@ -51,7 +52,7 @@ module API end params do optional :popular, type: Boolean, desc: 'If passed, returns only popular licenses' - end + end get route do options = { featured: declared(params).popular.present? ? true : nil @@ -69,7 +70,7 @@ module API end params do requires :name, type: String, desc: 'The name of the template' - end + end get route, requirements: { name: /[\w\.-]+/ } do not_found!('License') unless Licensee::License.find(declared(params).name) @@ -78,7 +79,7 @@ module API present template, with: Entities::RepoLicense end end - + GLOBAL_TEMPLATE_TYPES.each do |template_type, properties| klass = properties[:klass] gitlab_version = properties[:gitlab_version] @@ -104,7 +105,7 @@ module API end params do requires :name, type: String, desc: 'The name of the template' - end + end get route do new_template = klass.find(declared(params).name) diff --git a/lib/gitlab/template/dockerfile_template.rb b/lib/gitlab/template/dockerfile_template.rb new file mode 100644 index 00000000000..d5d3e045a42 --- /dev/null +++ b/lib/gitlab/template/dockerfile_template.rb @@ -0,0 +1,30 @@ +module Gitlab + module Template + class DockerfileTemplate < BaseTemplate + def content + explanation = "# This file is a template, and might need editing before it works on your project." + [explanation, super].join("\n") + end + + class << self + def extension + 'Dockerfile' + end + + def categories + { + "General" => '' + } + end + + def base_dir + Rails.root.join('vendor/dockerfile') + end + + def finder(project = nil) + Gitlab::Template::Finders::GlobalTemplateFinder.new(self.base_dir, self.extension, self.categories) + end + end + end + end +end diff --git a/vendor/dockerfile/HTTPdDockerfile b/vendor/dockerfile/HTTPdDockerfile new file mode 100644 index 00000000000..2f05427323c --- /dev/null +++ b/vendor/dockerfile/HTTPdDockerfile @@ -0,0 +1,3 @@ +FROM httpd:alpine + +COPY ./ /usr/local/apache2/htdocs/ From dcd20236ec0f05b46702611f09e2af8094a383ae Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" Date: Tue, 8 Nov 2016 12:42:58 +0000 Subject: [PATCH 002/175] Refactored JS Added spec --- .../javascripts/blob/blob_ci_yaml.js.es6 | 36 ------------------- .../blob/blob_dockerfile_selector.js.es6 | 18 ++++++++++ .../blob/blob_dockerfile_selectors.js.es6 | 27 ++++++++++++++ app/assets/javascripts/blob_edit/edit_blob.js | 6 ++-- app/views/projects/blob/_editor.html.haml | 4 +-- lib/api/templates.rb | 5 ++- .../files/dockerfile_dropdown_spec.rb | 30 ++++++++++++++++ 7 files changed, 84 insertions(+), 42 deletions(-) create mode 100644 app/assets/javascripts/blob/blob_dockerfile_selector.js.es6 create mode 100644 app/assets/javascripts/blob/blob_dockerfile_selectors.js.es6 create mode 100644 spec/features/projects/files/dockerfile_dropdown_spec.rb diff --git a/app/assets/javascripts/blob/blob_ci_yaml.js.es6 b/app/assets/javascripts/blob/blob_ci_yaml.js.es6 index 62e686b8d9b..37531aaec9b 100644 --- a/app/assets/javascripts/blob/blob_ci_yaml.js.es6 +++ b/app/assets/javascripts/blob/blob_ci_yaml.js.es6 @@ -38,40 +38,4 @@ global.BlobCiYamlSelectors = BlobCiYamlSelectors; - class BlobDockerfileSelector extends gl.TemplateSelector { - requestFile(query) { - return Api.dockerfileYml(query.name, this.requestFileSuccess.bind(this)); - } - - requestFileSuccess(file) { - return super.requestFileSuccess(file); - } - } - - global.BlobDockerfileSelector = BlobDockerfileSelector; - - class BlobDockerfileSelectors { - constructor({ editor, $dropdowns } = {}) { - this.editor = editor; - this.$dropdowns = $dropdowns || $('.js-dockerfile-selector'); - this.initSelectors(); - } - - initSelectors() { - const editor = this.editor; - this.$dropdowns.each((i, dropdown) => { - const $dropdown = $(dropdown); - return new BlobDockerfileSelector({ - editor, - pattern: /(Dockerfile)/, - data: $dropdown.data('data'), - wrapper: $dropdown.closest('.js-dockerfile-selector-wrap'), - dropdown: $dropdown - }); - }); - } - } - - global.BlobDockerfileSelectors = BlobDockerfileSelectors; - })(window.gl || (window.gl = {})); diff --git a/app/assets/javascripts/blob/blob_dockerfile_selector.js.es6 b/app/assets/javascripts/blob/blob_dockerfile_selector.js.es6 new file mode 100644 index 00000000000..bdf95017613 --- /dev/null +++ b/app/assets/javascripts/blob/blob_dockerfile_selector.js.es6 @@ -0,0 +1,18 @@ +/* global Api */ +/*= require blob/template_selector */ + +(() => { + const global = window.gl || (window.gl = {}); + + class BlobDockerfileSelector extends gl.TemplateSelector { + requestFile(query) { + return Api.dockerfileYml(query.name, this.requestFileSuccess.bind(this)); + } + + requestFileSuccess(file) { + return super.requestFileSuccess(file); + } + } + + global.BlobDockerfileSelector = BlobDockerfileSelector; +})(); diff --git a/app/assets/javascripts/blob/blob_dockerfile_selectors.js.es6 b/app/assets/javascripts/blob/blob_dockerfile_selectors.js.es6 new file mode 100644 index 00000000000..9cee79fa5d5 --- /dev/null +++ b/app/assets/javascripts/blob/blob_dockerfile_selectors.js.es6 @@ -0,0 +1,27 @@ +(() => { + const global = window.gl || (window.gl = {}); + + class BlobDockerfileSelectors { + constructor({ editor, $dropdowns } = {}) { + this.editor = editor; + this.$dropdowns = $dropdowns || $('.js-dockerfile-selector'); + this.initSelectors(); + } + + initSelectors() { + const editor = this.editor; + this.$dropdowns.each((i, dropdown) => { + const $dropdown = $(dropdown); + return new gl.BlobDockerfileSelector({ + editor, + pattern: /(Dockerfile)/, + data: $dropdown.data('data'), + wrapper: $dropdown.closest('.js-dockerfile-selector-wrap'), + dropdown: $dropdown, + }); + }); + } + } + + global.BlobDockerfileSelectors = BlobDockerfileSelectors; +})(); diff --git a/app/assets/javascripts/blob_edit/edit_blob.js b/app/assets/javascripts/blob_edit/edit_blob.js index 11dff7dfab4..74cc0af2486 100644 --- a/app/assets/javascripts/blob_edit/edit_blob.js +++ b/app/assets/javascripts/blob_edit/edit_blob.js @@ -33,9 +33,9 @@ new gl.BlobCiYamlSelectors({ editor: this.editor }); - new gl.BlobDockerfileSelectors({ - editor: this.editor - }); + new gl.BlobDockerfileSelectors({ + editor: this.editor + }); } EditBlob.prototype.initModePanesAndLinks = function() { diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 34dc96bcb64..1d058daa094 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -21,8 +21,8 @@ = dropdown_tag("Choose a .gitignore template", options: { toggle_class: 'btn js-gitignore-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitignore_names } } ) .gitlab-ci-yml-selector.js-gitlab-ci-yml-selector-wrap.hidden = dropdown_tag("Choose a GitLab CI Yaml template", options: { toggle_class: 'btn js-gitlab-ci-yml-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: gitlab_ci_ymls } } ) - .gitlab-ci-yml-selector.js-dockerfile-selector-wrap.hidden - = dropdown_tag("Choose a Dockerfile template", options: { toggle_class: 'js-dockerfile-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names } } ) + .dockerfile-selector.js-dockerfile-selector-wrap.hidden + = dropdown_tag("Choose a Dockerfile template", options: { toggle_class: 'btn js-dockerfile-selector', title: "Choose a template", filter: true, placeholder: "Filter", data: { data: dockerfile_names } } ) = button_tag class: 'soft-wrap-toggle btn', type: 'button' do %span.no-wrap = custom_icon('icon_no_wrap') diff --git a/lib/api/templates.rb b/lib/api/templates.rb index 442fef1a848..2d887e15f28 100644 --- a/lib/api/templates.rb +++ b/lib/api/templates.rb @@ -9,7 +9,10 @@ module API klass: Gitlab::Template::GitlabCiYmlTemplate, gitlab_version: 8.9 }, - dockerfiles: Gitlab::Template::DockerfileTemplate + dockerfiles: { + klass: Gitlab::Template::DockerfileTemplate, + gitlab_version: 8.9 + } }.freeze PROJECT_TEMPLATE_REGEX = /[\<\{\[] diff --git a/spec/features/projects/files/dockerfile_dropdown_spec.rb b/spec/features/projects/files/dockerfile_dropdown_spec.rb new file mode 100644 index 00000000000..32f33a3ca97 --- /dev/null +++ b/spec/features/projects/files/dockerfile_dropdown_spec.rb @@ -0,0 +1,30 @@ +require 'spec_helper' + +feature 'User wants to add a Dockerfile file', feature: true do + include WaitForAjax + + before do + user = create(:user) + project = create(:project) + project.team << [user, :master] + login_as user + visit namespace_project_new_blob_path(project.namespace, project, 'master', file_name: 'Dockerfile') + end + + scenario 'user can see Dockerfile dropdown' do + expect(page).to have_css('.dockerfile-selector') + end + + scenario 'user can pick a Dockerfile file from the dropdown', js: true do + find('.js-dockerfile-selector').click + wait_for_ajax + within '.dockerfile-selector' do + find('.dropdown-input-field').set('HTTPd') + find('.dropdown-content li', text: 'HTTPd').click + end + wait_for_ajax + + expect(page).to have_css('.dockerfile-selector .dropdown-toggle-text', text: 'HTTPd') + expect(page).to have_content('COPY ./ /usr/local/apache2/htdocs/') + end +end From 69aaa972038c7b285c06804e04ff5932e468e523 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Fri, 19 Aug 2016 17:59:58 -0300 Subject: [PATCH 003/175] Remove omniauth-bitbucket gem --- Gemfile | 1 - Gemfile.lock | 5 ----- 2 files changed, 6 deletions(-) diff --git a/Gemfile b/Gemfile index 9e815925a1f..3576a670f93 100644 --- a/Gemfile +++ b/Gemfile @@ -22,7 +22,6 @@ gem 'doorkeeper', '~> 4.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' gem 'omniauth-facebook', '~> 4.0.0' gem 'omniauth-github', '~> 1.1.1' diff --git a/Gemfile.lock b/Gemfile.lock index bdc60552480..af1facf255a 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -438,10 +438,6 @@ GEM jwt (~> 1.0) omniauth (~> 1.0) omniauth-oauth2 (~> 1.1) - omniauth-bitbucket (0.0.2) - multi_json (~> 1.7) - omniauth (~> 1.1) - omniauth-oauth (~> 1.0) omniauth-cas3 (1.1.3) addressable (~> 2.3) nokogiri (~> 1.6.6) @@ -905,7 +901,6 @@ DEPENDENCIES 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) omniauth-facebook (~> 4.0.0) omniauth-github (~> 1.1.1) From a2ef52b32bd3d1ee70ea333566e330c44e6c9170 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 15:36:41 -0300 Subject: [PATCH 004/175] Add custom OmniAuth strategy for Bitbucket OAuth2 --- config/initializers/omniauth.rb | 6 ++++ lib/omniauth/strategies/bitbucket.rb | 45 ++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 lib/omniauth/strategies/bitbucket.rb diff --git a/config/initializers/omniauth.rb b/config/initializers/omniauth.rb index 26c30e523a7..ab5a0561b8c 100644 --- a/config/initializers/omniauth.rb +++ b/config/initializers/omniauth.rb @@ -26,3 +26,9 @@ if Gitlab.config.omniauth.enabled end end end + +module OmniAuth + module Strategies + autoload :Bitbucket, Rails.root.join('lib', 'omniauth', 'strategies', 'bitbucket') + end +end diff --git a/lib/omniauth/strategies/bitbucket.rb b/lib/omniauth/strategies/bitbucket.rb new file mode 100644 index 00000000000..2006e7582ce --- /dev/null +++ b/lib/omniauth/strategies/bitbucket.rb @@ -0,0 +1,45 @@ +require 'omniauth-oauth2' + +module OmniAuth + module Strategies + class Bitbucket < OmniAuth::Strategies::OAuth2 + option :name, 'bitbucket' + + option :client_options, { + site: 'https://bitbucket.org', + authorize_url: 'https://bitbucket.org/site/oauth2/authorize', + token_url: 'https://bitbucket.org/site/oauth2/access_token' + } + + def callback_url + full_host + script_name + callback_path + end + + uid do + raw['username'] + end + + info do + { + name: raw_info['display_name'], + avatar: raw_info['links']['avatar']['href'], + email: primary_email + } + end + + def raw_info + @raw_info ||= access_token.get('user').parsed + end + + def primary_email + primary = emails.find{ |i| i['is_primary'] && i['is_confirmed'] } + primary && primary['email'] || nil + end + + def emails + email_response = access_token.get('user/emails').parsed + @emails ||= email_response && email_response['values'] || nil + end + end + end +end From fc34c9560324b7db5bdaf205cbdf46a339102af2 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 15:42:26 -0300 Subject: [PATCH 005/175] Add a Bitbucket client for the OAuth2 API --- lib/bitbucket/client.rb | 11 ++++++ lib/bitbucket/connection.rb | 69 +++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+) create mode 100644 lib/bitbucket/client.rb create mode 100644 lib/bitbucket/connection.rb diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb new file mode 100644 index 00000000000..5f48e52da98 --- /dev/null +++ b/lib/bitbucket/client.rb @@ -0,0 +1,11 @@ +module Bitbucket + class Client + def initialize(options = {}) + @connection = options.fetch(:connection, Connection.new(options)) + end + + private + + attr_reader :connection + end +end diff --git a/lib/bitbucket/connection.rb b/lib/bitbucket/connection.rb new file mode 100644 index 00000000000..00f127f9507 --- /dev/null +++ b/lib/bitbucket/connection.rb @@ -0,0 +1,69 @@ +module Bitbucket + class Connection + DEFAULT_API_VERSION = '2.0' + DEFAULT_BASE_URI = 'https://api.bitbucket.org/' + DEFAULT_QUERY = {} + + def initialize(options = {}) + @api_version = options.fetch(:api_version, DEFAULT_API_VERSION) + @base_uri = options.fetch(:base_uri, DEFAULT_BASE_URI) + @query = options.fetch(:query, DEFAULT_QUERY) + + @token = options.fetch(:token) + @expires_at = options.fetch(:expires_at) + @expires_in = options.fetch(:expires_in) + @refresh_token = options.fetch(:refresh_token) + + @client = OAuth2::Client.new(provider.app_id, provider.app_secret, options) + @connection = OAuth2::AccessToken.new(@client, @token, refresh_token: @refresh_token, expires_at: @expires_at, expires_in: @expires_in) + end + + def query(params = {}) + @query.update(params) + end + + def get(path, query = {}) + refresh! if expired? + + response = connection.get(build_url(path), params: @query.merge(query)) + response.parsed + end + + def expired? + connection.expired? + end + + def refresh! + response = connection.refresh! + + @token = response.token + @expires_at = response.expires_at + @expires_in = response.expires_in + @refresh_token = response.refresh_token + + @connection = OAuth2::AccessToken.new(@client, @token, refresh_token: @refresh_token, expires_at: @expires_at, expires_in: @expires_in) + end + + private + + attr_reader :connection, :expires_at, :expires_in, :refresh_token, :token + + def build_url(path) + return path if path.starts_with?(root_url) + + "#{root_url}#{path}" + end + + def root_url + @root_url ||= "#{@base_uri}#{@api_version}" + end + + def provider + Gitlab.config.omniauth.providers.find { |provider| provider.name == 'bitbucket' } + end + + def options + OmniAuth::Strategies::Bitbucket.default_options[:client_options].deep_symbolize_keys + end + end +end From f1f5863bfc5727dba4767a54a299b0cf526b025a Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 15:45:29 -0300 Subject: [PATCH 006/175] Add an endpoint to get the Bitbucket user profile --- lib/bitbucket/client.rb | 5 +++++ lib/bitbucket/representation/base.rb | 13 +++++++++++++ lib/bitbucket/representation/user.rb | 9 +++++++++ 3 files changed, 27 insertions(+) create mode 100644 lib/bitbucket/representation/base.rb create mode 100644 lib/bitbucket/representation/user.rb diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 5f48e52da98..c05fc35f36e 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -4,6 +4,11 @@ module Bitbucket @connection = options.fetch(:connection, Connection.new(options)) end + def user + parsed_response = connection.get('/user') + Representation::User.new(parsed_response) + end + private attr_reader :connection diff --git a/lib/bitbucket/representation/base.rb b/lib/bitbucket/representation/base.rb new file mode 100644 index 00000000000..7b639492d38 --- /dev/null +++ b/lib/bitbucket/representation/base.rb @@ -0,0 +1,13 @@ +module Bitbucket + module Representation + class Base + def initialize(raw) + @raw = raw + end + + private + + attr_reader :raw + end + end +end diff --git a/lib/bitbucket/representation/user.rb b/lib/bitbucket/representation/user.rb new file mode 100644 index 00000000000..ba6b7667b49 --- /dev/null +++ b/lib/bitbucket/representation/user.rb @@ -0,0 +1,9 @@ +module Bitbucket + module Representation + class User < Representation::Base + def username + raw['username'] + end + end + end +end From e2f7f32a60a7663d12b5dae0320f640150f354e7 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 15:48:31 -0300 Subject: [PATCH 007/175] Add support for Bitbucket pagination when fetching collections --- lib/bitbucket/collection.rb | 21 ++++++++++++++++++++ lib/bitbucket/page.rb | 36 +++++++++++++++++++++++++++++++++++ lib/bitbucket/paginator.rb | 38 +++++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+) create mode 100644 lib/bitbucket/collection.rb create mode 100644 lib/bitbucket/page.rb create mode 100644 lib/bitbucket/paginator.rb diff --git a/lib/bitbucket/collection.rb b/lib/bitbucket/collection.rb new file mode 100644 index 00000000000..9cc8947417c --- /dev/null +++ b/lib/bitbucket/collection.rb @@ -0,0 +1,21 @@ +module Bitbucket + class Collection < Enumerator + def initialize(paginator) + super() do |yielder| + loop do + paginator.next.each { |item| yielder << item } + end + end + + lazy + end + + def method_missing(method, *args) + return super unless self.respond_to?(method) + + self.send(method, *args) do |item| + block_given? ? yield(item) : item + end + end + end +end diff --git a/lib/bitbucket/page.rb b/lib/bitbucket/page.rb new file mode 100644 index 00000000000..ad9a2baba36 --- /dev/null +++ b/lib/bitbucket/page.rb @@ -0,0 +1,36 @@ +module Bitbucket + class Page + attr_reader :attrs, :items + + def initialize(raw, type) + @attrs = parse_attrs(raw) + @items = parse_values(raw, representation_class(type)) + end + + def next? + attrs.fetch(:next, false) + end + + def next + attrs.fetch(:next) + end + + private + + def parse_attrs(raw) + attrs = %w(size page pagelen next previous) + attrs.map { |attr| { attr.to_sym => raw[attr] } }.reduce(&:merge) + end + + def parse_values(raw, representation_class) + return [] if raw['values'].nil? || !raw['values'].is_a?(Array) + + raw['values'].map { |hash| representation_class.new(hash) } + end + + def representation_class(type) + class_name = "Bitbucket::Representation::#{type.to_s.camelize}" + class_name.constantize + end + end +end diff --git a/lib/bitbucket/paginator.rb b/lib/bitbucket/paginator.rb new file mode 100644 index 00000000000..a1672d9eaa1 --- /dev/null +++ b/lib/bitbucket/paginator.rb @@ -0,0 +1,38 @@ +module Bitbucket + class Paginator + PAGE_LENGTH = 50 # The minimum length is 10 and the maximum is 100. + + def initialize(connection, url, type) + @connection = connection + @type = type + @url = url + @page = nil + + connection.query(pagelen: PAGE_LENGTH, sort: :created_on) + end + + def next + raise StopIteration unless has_next_page? + + @page = fetch_next_page + @page.items + end + + private + + attr_reader :connection, :page, :url, :type + + def has_next_page? + page.nil? || page.next? + end + + def page_url + page.nil? ? url : page.next + end + + def fetch_next_page + parsed_response = connection.get(page_url) + Page.new(parsed_response, type) + end + end +end From 6418c6f88efe9015c8bc2ebd4f7db1a7277a4dc9 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 15:53:46 -0300 Subject: [PATCH 008/175] Add an endpoint to get the user's repositories --- lib/bitbucket/client.rb | 14 ++++++- lib/bitbucket/representation/repo.rb | 57 ++++++++++++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 lib/bitbucket/representation/repo.rb diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index c05fc35f36e..39b52ae25a6 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -4,9 +4,19 @@ module Bitbucket @connection = options.fetch(:connection, Connection.new(options)) end + + def repos + relative_path = "/repositories/#{user.username}" + paginator = Paginator.new(connection, relative_path, :repo) + + Collection.new(paginator) + end + def user - parsed_response = connection.get('/user') - Representation::User.new(parsed_response) + @user ||= begin + parsed_response = connection.get('/user') + Representation::User.new(parsed_response) + end end private diff --git a/lib/bitbucket/representation/repo.rb b/lib/bitbucket/representation/repo.rb new file mode 100644 index 00000000000..fe5cda66ab9 --- /dev/null +++ b/lib/bitbucket/representation/repo.rb @@ -0,0 +1,57 @@ +module Bitbucket + module Representation + class Repo < Representation::Base + attr_reader :owner, :slug + + def initialize(raw) + super(raw) + + if full_name && full_name.split('/').size == 2 + @owner, @slug = full_name.split('/') + end + end + + def clone_url(token = nil) + url = raw['links']['clone'].find { |link| link['name'] == 'https' }.fetch('href') + + if token.present? + url.sub(/^[^\@]*/, "https://x-token-auth:#{token}") + else + url + end + end + + def description + raw['description'] + end + + def full_name + raw['full_name'] + end + + def has_issues? + raw['has_issues'] + end + + def name + raw['name'] + end + + def valid? + raw['scm'] == 'git' + end + + def visibility_level + if raw['is_private'] + Gitlab::VisibilityLevel::PRIVATE + else + Gitlab::VisibilityLevel::PUBLIC + end + end + + def to_s + full_name + end + end + end +end From 411d0a93723467eb355155fe52f1d159d4527556 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 15:55:08 -0300 Subject: [PATCH 009/175] Add an endpoint to get a single user repository --- lib/bitbucket/client.rb | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 39b52ae25a6..24984ca0793 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -5,6 +5,11 @@ module Bitbucket end + def repo(name) + parsed_response = connection.get("/repositories/#{name}") + Representation::Repo.new(parsed_response) + end + def repos relative_path = "/repositories/#{user.username}" paginator = Paginator.new(connection, relative_path, :repo) From 56cb4762d42f758ad6e4ec1874b7eed8e1c1f687 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 16:02:48 -0300 Subject: [PATCH 010/175] Refactoring Bitbucket import controller to use the new OAuth2 client --- .../import/bitbucket_controller.rb | 79 +++++++++++-------- app/views/import/bitbucket/status.html.haml | 35 ++++---- lib/bitbucket/error/unauthorized.rb | 6 ++ 3 files changed, 68 insertions(+), 52 deletions(-) create mode 100644 lib/bitbucket/error/unauthorized.rb diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 6ea54744da8..ee30a24ab77 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -3,49 +3,54 @@ class Import::BitbucketController < Import::BaseController before_action :bitbucket_auth, except: :callback rescue_from OAuth::Error, with: :bitbucket_unauthorized - rescue_from Gitlab::BitbucketImport::Client::Unauthorized, with: :bitbucket_unauthorized + rescue_from Bitbucket::Error::Unauthorized, with: :bitbucket_unauthorized def callback - request_token = session.delete(:oauth_request_token) - raise "Session expired!" if request_token.nil? + response = client.auth_code.get_token(params[:code], redirect_uri: callback_import_bitbucket_url) - request_token.symbolize_keys! - - access_token = client.get_token(request_token, params[:oauth_verifier], callback_import_bitbucket_url) - - session[:bitbucket_access_token] = access_token.token - session[:bitbucket_access_token_secret] = access_token.secret + session[:bitbucket_token] = response.token + session[:bitbucket_expires_at] = response.expires_at + session[:bitbucket_expires_in] = response.expires_in + session[:bitbucket_refresh_token] = response.refresh_token redirect_to status_import_bitbucket_url end def status - @repos = client.projects - @incompatible_repos = client.incompatible_projects + client = Bitbucket::Client.new(credentials) + repos = client.repos - @already_added_projects = current_user.created_projects.where(import_type: "bitbucket") + @repos = repos.select(&:valid?) + @incompatible_repos = repos.reject(&:valid?) + + @already_added_projects = current_user.created_projects.where(import_type: 'bitbucket') already_added_projects_names = @already_added_projects.pluck(:import_source) - @repos.to_a.reject!{ |repo| already_added_projects_names.include? "#{repo["owner"]}/#{repo["slug"]}" } + @repos.to_a.reject! { |repo| already_added_projects_names.include?(repo.full_name) } end def jobs - jobs = current_user.created_projects.where(import_type: "bitbucket").to_json(only: [:id, :import_status]) - render json: jobs + render json: current_user.created_projects + .where(import_type: 'bitbucket') + .to_json(only: [:id, :import_status]) end def create - @repo_id = params[:repo_id].to_s - repo = client.project(@repo_id.gsub('___', '/')) - @project_name = repo['slug'] - @target_namespace = find_or_create_namespace(repo['owner'], client.user['user']['username']) + client = Bitbucket::Client.new(credentials) - unless Gitlab::BitbucketImport::KeyAdder.new(repo, current_user, access_params).execute - render 'deploy_key' and return - end + @repo_id = params[:repo_id].to_s + name = @repo_id.to_s.gsub('___', '/') + repo = client.repo(name) + @project_name = repo.name + + repo_owner = repo.owner + repo_owner = current_user.username if repo_owner == client.user.username + @target_namespace = params[:new_namespace].presence || repo_owner + + namespace = find_or_create_namespace(target_namespace_name, repo_owner) if current_user.can?(:create_projects, @target_namespace) - @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @target_namespace, current_user, access_params).execute + @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, namespace, current_user, credentials).execute else render 'unauthorized' end @@ -54,8 +59,15 @@ class Import::BitbucketController < Import::BaseController private def client - @client ||= Gitlab::BitbucketImport::Client.new(session[:bitbucket_access_token], - session[:bitbucket_access_token_secret]) + @client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options) + end + + def provider + Gitlab.config.omniauth.providers.find { |provider| provider.name == 'bitbucket' } + end + + def options + OmniAuth::Strategies::Bitbucket.default_options[:client_options].deep_symbolize_keys end def verify_bitbucket_import_enabled @@ -63,26 +75,23 @@ class Import::BitbucketController < Import::BaseController end def bitbucket_auth - if session[:bitbucket_access_token].blank? - go_to_bitbucket_for_permissions - end + go_to_bitbucket_for_permissions if session[:bitbucket_token].blank? end def go_to_bitbucket_for_permissions - request_token = client.request_token(callback_import_bitbucket_url) - session[:oauth_request_token] = request_token - - redirect_to client.authorize_url(request_token, callback_import_bitbucket_url) + redirect_to client.auth_code.authorize_url(redirect_uri: callback_import_bitbucket_url) end def bitbucket_unauthorized go_to_bitbucket_for_permissions end - def access_params + def credentials { - bitbucket_access_token: session[:bitbucket_access_token], - bitbucket_access_token_secret: session[:bitbucket_access_token_secret] + token: session[:bitbucket_token], + expires_at: session[:bitbucket_expires_at], + expires_in: session[:bitbucket_expires_in], + refresh_token: session[:bitbucket_refresh_token] } end end diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index f8b4b107513..e62ff5f61f6 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -1,5 +1,6 @@ -- page_title "Bitbucket import" -- header_title "Projects", root_path +- page_title 'Bitbucket import' +- header_title 'Projects', root_path + %h3.page-title %i.fa.fa-bitbucket Import projects from Bitbucket @@ -10,13 +11,13 @@ %hr %p - if @incompatible_repos.any? - = button_tag class: "btn btn-import btn-success js-import-all" do + = button_tag class: 'btn btn-import btn-success js-import-all' do Import all compatible projects - = icon("spinner spin", class: "loading-icon") + = icon('spinner spin', class: 'loading-icon') - else - = button_tag class: "btn btn-success js-import-all" do + = button_tag class: 'btn btn-success js-import-all' do Import all projects - = icon("spinner spin", class: "loading-icon") + = icon('spinner spin', class: 'loading-icon') .table-responsive %table.table.import-jobs @@ -32,7 +33,7 @@ - @already_added_projects.each do |project| %tr{id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}"} %td - = link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: "_blank" + = link_to project.import_source, 'https://bitbucket.org/#{project.import_source}', target: '_blank' %td = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project] %td.job-status @@ -47,31 +48,31 @@ = project.human_import_status_name - @repos.each do |repo| - %tr{id: "repo_#{repo["owner"]}___#{repo["slug"]}"} + %tr{id: "repo_#{repo.owner}___#{repo.slug}"} %td - = link_to "#{repo["owner"]}/#{repo["slug"]}", "https://bitbucket.org/#{repo["owner"]}/#{repo["slug"]}", target: "_blank" + = link_to "#{repo.full_name}", "https://bitbucket.org/#{repo.full_name}", target: "_blank" %td.import-target - = import_project_target(repo['owner'], repo['slug']) + = "#{repo.full_name}" %td.import-actions.job-status - = button_tag class: "btn btn-import js-add-to-import" do + = button_tag class: 'btn btn-import js-add-to-import' do Import - = icon("spinner spin", class: "loading-icon") + = icon('spinner spin', class: 'loading-icon') - @incompatible_repos.each do |repo| - %tr{id: "repo_#{repo["owner"]}___#{repo["slug"]}"} + %tr{id: "repo_#{repo.owner}___#{repo.slug}"} %td - = link_to "#{repo["owner"]}/#{repo["slug"]}", "https://bitbucket.org/#{repo["owner"]}/#{repo["slug"]}", target: "_blank" + = link_to "#{repo.full_name}", "https://bitbucket.org/#{repo.full_name}", target: '_blank' %td.import-target %td.import-actions-job-status - = label_tag "Incompatible Project", nil, class: "label label-danger" + = label_tag 'Incompatible Project', nil, class: 'label label-danger' - if @incompatible_repos.any? %p One or more of your Bitbucket projects cannot be imported into GitLab directly because they use Subversion or Mercurial for version control, rather than Git. Please convert - = link_to "them to Git,", "https://www.atlassian.com/git/tutorials/migrating-overview" + = link_to 'them to Git,', 'https://www.atlassian.com/git/tutorials/migrating-overview' and go through the - = link_to "import flow", status_import_bitbucket_path, "data-no-turbolink" => "true" + = link_to 'import flow', status_import_bitbucket_path, 'data-no-turbolink' => 'true' again. .js-importer-status{ data: { jobs_import_path: "#{jobs_import_bitbucket_path}", import_path: "#{import_bitbucket_path}" } } diff --git a/lib/bitbucket/error/unauthorized.rb b/lib/bitbucket/error/unauthorized.rb new file mode 100644 index 00000000000..5e2eb57bb0e --- /dev/null +++ b/lib/bitbucket/error/unauthorized.rb @@ -0,0 +1,6 @@ +module Bitbucket + module Error + class Unauthorized < StandardError + end + end +end From ceac7878e9b5ba56f31f605c40095e8b83d83b6f Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 16:04:11 -0300 Subject: [PATCH 011/175] Clone Bitbucket repositories over HTTPS --- lib/gitlab/bitbucket_import/project_creator.rb | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/lib/gitlab/bitbucket_import/project_creator.rb b/lib/gitlab/bitbucket_import/project_creator.rb index b90ef0b0fba..9d80c5d4f2b 100644 --- a/lib/gitlab/bitbucket_import/project_creator.rb +++ b/lib/gitlab/bitbucket_import/project_creator.rb @@ -13,15 +13,15 @@ module Gitlab def execute ::Projects::CreateService.new( current_user, - name: repo["name"], - path: repo["slug"], - description: repo["description"], + name: repo.name, + path: repo.slug, + description: repo.description, namespace_id: namespace.id, - visibility_level: repo["is_private"] ? Gitlab::VisibilityLevel::PRIVATE : Gitlab::VisibilityLevel::PUBLIC, - import_type: "bitbucket", - import_source: "#{repo["owner"]}/#{repo["slug"]}", - import_url: "ssh://git@bitbucket.org/#{repo["owner"]}/#{repo["slug"]}.git", - import_data: { credentials: { bb_session: session_data } } + visibility_level: repo.visibility_level, + import_type: 'bitbucket', + import_source: repo.full_name, + import_url: repo.clone_url(@session_data[:token]), + import_data: { credentials: session_data } ).execute end end From 267e27b0cd543e8eeaa04686ad4678c4f553c479 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 16:05:44 -0300 Subject: [PATCH 012/175] Remove code to clone Bitbucket repositories using SSH --- app/controllers/application_controller.rb | 2 +- config/initializers/public_key.rb | 2 -- lib/gitlab/bitbucket_import/key_adder.rb | 24 ---------------------- lib/gitlab/bitbucket_import/key_deleter.rb | 23 --------------------- 4 files changed, 1 insertion(+), 50 deletions(-) delete mode 100644 config/initializers/public_key.rb delete mode 100644 lib/gitlab/bitbucket_import/key_adder.rb delete mode 100644 lib/gitlab/bitbucket_import/key_deleter.rb diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 517ad4f03f3..a6b0a4af503 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -254,7 +254,7 @@ class ApplicationController < ActionController::Base end def bitbucket_import_configured? - Gitlab::OAuth::Provider.enabled?(:bitbucket) && Gitlab::BitbucketImport.public_key.present? + Gitlab::OAuth::Provider.enabled?(:bitbucket) end def google_code_import_enabled? diff --git a/config/initializers/public_key.rb b/config/initializers/public_key.rb deleted file mode 100644 index e4f09a2d020..00000000000 --- a/config/initializers/public_key.rb +++ /dev/null @@ -1,2 +0,0 @@ -path = File.expand_path("~/.ssh/bitbucket_rsa.pub") -Gitlab::BitbucketImport.public_key = File.read(path) if File.exist?(path) diff --git a/lib/gitlab/bitbucket_import/key_adder.rb b/lib/gitlab/bitbucket_import/key_adder.rb deleted file mode 100644 index 0b63f025d0a..00000000000 --- a/lib/gitlab/bitbucket_import/key_adder.rb +++ /dev/null @@ -1,24 +0,0 @@ -module Gitlab - module BitbucketImport - class KeyAdder - attr_reader :repo, :current_user, :client - - def initialize(repo, current_user, access_params) - @repo, @current_user = repo, current_user - @client = Client.new(access_params[:bitbucket_access_token], - access_params[:bitbucket_access_token_secret]) - end - - def execute - return false unless BitbucketImport.public_key.present? - - project_identifier = "#{repo["owner"]}/#{repo["slug"]}" - client.add_deploy_key(project_identifier, BitbucketImport.public_key) - - true - rescue - false - end - end - end -end diff --git a/lib/gitlab/bitbucket_import/key_deleter.rb b/lib/gitlab/bitbucket_import/key_deleter.rb deleted file mode 100644 index e03c3155b3e..00000000000 --- a/lib/gitlab/bitbucket_import/key_deleter.rb +++ /dev/null @@ -1,23 +0,0 @@ -module Gitlab - module BitbucketImport - class KeyDeleter - attr_reader :project, :current_user, :client - - def initialize(project) - @project = project - @current_user = project.creator - @client = Client.from_project(@project) - end - - def execute - return false unless BitbucketImport.public_key.present? - - client.delete_deploy_key(project.import_source, BitbucketImport.public_key) - - true - rescue - false - end - end - end -end From 3dd15d3f753a5a71522275a37393bfa56d6e3517 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 16:09:25 -0300 Subject: [PATCH 013/175] Add an endpoint to get a list of issues for a repo --- lib/bitbucket/client.rb | 7 ++++ lib/bitbucket/representation/issue.rb | 49 +++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 lib/bitbucket/representation/issue.rb diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 24984ca0793..ac6e91bb526 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -4,6 +4,13 @@ module Bitbucket @connection = options.fetch(:connection, Connection.new(options)) end + def issues(repo) + relative_path = "/repositories/#{repo}/issues" + paginator = Paginator.new(connection, relative_path, :issue) + + Collection.new(paginator) + end + def repo(name) parsed_response = connection.get("/repositories/#{name}") diff --git a/lib/bitbucket/representation/issue.rb b/lib/bitbucket/representation/issue.rb new file mode 100644 index 00000000000..48647ad51f6 --- /dev/null +++ b/lib/bitbucket/representation/issue.rb @@ -0,0 +1,49 @@ +module Bitbucket + module Representation + class Issue < Representation::Base + CLOSED_STATUS = %w(resolved invalid duplicate wontfix closed).freeze + + def iid + raw['id'] + end + + def author + reporter.fetch('username', 'Anonymous') + end + + def description + raw.dig('content', 'raw') + end + + def state + closed? ? 'closed' : 'opened' + end + + def title + raw['title'] + end + + def created_at + raw['created_on'] + end + + def updated_at + raw['edited_on'] + end + + def to_s + iid + end + + private + + def closed? + CLOSED_STATUS.include?(raw['state']) + end + + def reporter + raw.fetch('reporter', {}) + end + end + end +end From 3f59d25d263d1ac9db76cd2d3d4d025fb6d6dff4 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 16:10:29 -0300 Subject: [PATCH 014/175] Add an endpoint to get a list of issue comments --- lib/bitbucket/client.rb | 10 +++++++++ lib/bitbucket/representation/comment.rb | 27 +++++++++++++++++++++++++ lib/bitbucket/representation/url.rb | 9 +++++++++ 3 files changed, 46 insertions(+) create mode 100644 lib/bitbucket/representation/comment.rb create mode 100644 lib/bitbucket/representation/url.rb diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index ac6e91bb526..3d22347603d 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -11,6 +11,16 @@ module Bitbucket Collection.new(paginator) end + def issue_comments(repo, number) + relative_path = "/repositories/#{repo}/issues/#{number}/comments" + paginator = Paginator.new(connection, relative_path, :url) + + Collection.new(paginator).map do |comment_url| + parsed_response = connection.get(comment_url.to_s) + Representation::Comment.new(parsed_response) + end + end + def repo(name) parsed_response = connection.get("/repositories/#{name}") diff --git a/lib/bitbucket/representation/comment.rb b/lib/bitbucket/representation/comment.rb new file mode 100644 index 00000000000..94bc18cbfab --- /dev/null +++ b/lib/bitbucket/representation/comment.rb @@ -0,0 +1,27 @@ +module Bitbucket + module Representation + class Comment < Representation::Base + def author + user.fetch('username', 'Anonymous') + end + + def note + raw.dig('content', 'raw') + end + + def created_at + raw['created_on'] + end + + def updated_at + raw['updated_on'] || raw['created_on'] + end + + private + + def user + raw.fetch('user', {}) + end + end + end +end diff --git a/lib/bitbucket/representation/url.rb b/lib/bitbucket/representation/url.rb new file mode 100644 index 00000000000..24ae1048013 --- /dev/null +++ b/lib/bitbucket/representation/url.rb @@ -0,0 +1,9 @@ +module Bitbucket + module Representation + class Url < Representation::Base + def to_s + raw.dig('links', 'self', 'href') + end + end + end +end From 3c756b83ef04dbbb2a82a53cf785a87da0772255 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 16:11:45 -0300 Subject: [PATCH 015/175] Remove client for the Bitbucket API version 1.0 --- lib/gitlab/bitbucket_import/client.rb | 142 -------------------------- 1 file changed, 142 deletions(-) delete mode 100644 lib/gitlab/bitbucket_import/client.rb diff --git a/lib/gitlab/bitbucket_import/client.rb b/lib/gitlab/bitbucket_import/client.rb deleted file mode 100644 index 8d1ad62fae0..00000000000 --- a/lib/gitlab/bitbucket_import/client.rb +++ /dev/null @@ -1,142 +0,0 @@ -module Gitlab - module BitbucketImport - class Client - class Unauthorized < StandardError; end - - attr_reader :consumer, :api - - def self.from_project(project) - import_data_credentials = project.import_data.credentials if project.import_data - if import_data_credentials && import_data_credentials[:bb_session] - token = import_data_credentials[:bb_session][:bitbucket_access_token] - token_secret = import_data_credentials[:bb_session][:bitbucket_access_token_secret] - new(token, token_secret) - else - raise Projects::ImportService::Error, "Unable to find project import data credentials for project ID: #{project.id}" - end - end - - def initialize(access_token = nil, access_token_secret = nil) - @consumer = ::OAuth::Consumer.new( - config.app_id, - config.app_secret, - bitbucket_options - ) - - if access_token && access_token_secret - @api = ::OAuth::AccessToken.new(@consumer, access_token, access_token_secret) - end - end - - def request_token(redirect_uri) - request_token = consumer.get_request_token(oauth_callback: redirect_uri) - - { - oauth_token: request_token.token, - oauth_token_secret: request_token.secret, - oauth_callback_confirmed: request_token.callback_confirmed?.to_s - } - end - - def authorize_url(request_token, redirect_uri) - request_token = ::OAuth::RequestToken.from_hash(consumer, request_token) if request_token.is_a?(Hash) - - if request_token.callback_confirmed? - request_token.authorize_url - else - request_token.authorize_url(oauth_callback: redirect_uri) - end - end - - def get_token(request_token, oauth_verifier, redirect_uri) - request_token = ::OAuth::RequestToken.from_hash(consumer, request_token) if request_token.is_a?(Hash) - - if request_token.callback_confirmed? - request_token.get_access_token(oauth_verifier: oauth_verifier) - else - request_token.get_access_token(oauth_callback: redirect_uri) - end - end - - def user - JSON.parse(get("/api/1.0/user").body) - end - - def issues(project_identifier) - all_issues = [] - offset = 0 - per_page = 50 # Maximum number allowed by Bitbucket - index = 0 - - begin - issues = JSON.parse(get(issue_api_endpoint(project_identifier, per_page, offset)).body) - # Find out how many total issues are present - total = issues["count"] if index == 0 - all_issues.concat(issues["issues"]) - offset += issues["issues"].count - index += 1 - end while all_issues.count < total - - all_issues - end - - def issue_comments(project_identifier, issue_id) - comments = JSON.parse(get("/api/1.0/repositories/#{project_identifier}/issues/#{issue_id}/comments").body) - comments.sort_by { |comment| comment["utc_created_on"] } - end - - def project(project_identifier) - JSON.parse(get("/api/1.0/repositories/#{project_identifier}").body) - end - - def find_deploy_key(project_identifier, key) - JSON.parse(get("/api/1.0/repositories/#{project_identifier}/deploy-keys").body).find do |deploy_key| - deploy_key["key"].chomp == key.chomp - end - end - - def add_deploy_key(project_identifier, key) - deploy_key = find_deploy_key(project_identifier, key) - return if deploy_key - - JSON.parse(api.post("/api/1.0/repositories/#{project_identifier}/deploy-keys", key: key, label: "GitLab import key").body) - end - - def delete_deploy_key(project_identifier, key) - deploy_key = find_deploy_key(project_identifier, key) - return unless deploy_key - - api.delete("/api/1.0/repositories/#{project_identifier}/deploy-keys/#{deploy_key["pk"]}").code == "204" - end - - def projects - JSON.parse(get("/api/1.0/user/repositories").body).select { |repo| repo["scm"] == "git" } - end - - def incompatible_projects - JSON.parse(get("/api/1.0/user/repositories").body).reject { |repo| repo["scm"] == "git" } - end - - private - - def get(url) - response = api.get(url) - raise Unauthorized if (400..499).cover?(response.code.to_i) - - response - end - - def issue_api_endpoint(project_identifier, per_page, offset) - "/api/1.0/repositories/#{project_identifier}/issues?sort=utc_created_on&limit=#{per_page}&start=#{offset}" - end - - def config - Gitlab.config.omniauth.providers.find { |provider| provider.name == "bitbucket" } - end - - def bitbucket_options - OmniAuth::Strategies::Bitbucket.default_options[:client_options].symbolize_keys - end - end - end -end From 317b020932736d2cd629542e3a8b3aef2219e033 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 16:13:56 -0300 Subject: [PATCH 016/175] Refactoring Bitbucket importer to use the new OAuth2 client --- lib/gitlab/bitbucket_import/importer.rb | 68 +++++++++++-------------- 1 file changed, 30 insertions(+), 38 deletions(-) diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index f4b5097adb1..67e906431f0 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -5,18 +5,14 @@ module Gitlab def initialize(project) @project = project - @client = Client.from_project(@project) + @client = Bitbucket::Client.new(project.import_data.credentials) @formatter = Gitlab::ImportFormatter.new end def execute - import_issues if has_issues? + import_issues true - rescue ActiveRecord::RecordInvalid => e - raise Projects::ImportService::Error.new, e.message - ensure - Gitlab::BitbucketImport::KeyDeleter.new(project).execute end private @@ -30,44 +26,40 @@ module Gitlab end end - def identifier - project.import_source - end - - def has_issues? - client.project(identifier)["has_issues"] + def repo + @repo ||= client.repo(project.import_source) end def import_issues - issues = client.issues(identifier) + return unless repo.has_issues? - issues.each do |issue| - body = '' - reporter = nil - author = 'Anonymous' + client.issues(repo).each do |issue| + description = @formatter.author_line(issue.author) + description += issue.description - if issue["reported_by"] && issue["reported_by"]["username"] - reporter = issue["reported_by"]["username"] - author = reporter - end + issue = project.issues.create( + iid: issue.iid, + title: issue.title, + description: description, + state: issue.state, + author_id: gl_user_id(project, issue.author), + created_at: issue.created_at, + updated_at: issue.updated_at + ) - body = @formatter.author_line(author) - body += issue["content"] + if issue.persisted? + client.issue_comments(repo, issue.iid).each do |comment| + note = @formatter.author_line(comment.author) + note += comment.note - comments = client.issue_comments(identifier, issue["local_id"]) - - if comments.any? - body += @formatter.comments_header - end - - comments.each do |comment| - author = 'Anonymous' - - if comment["author_info"] && comment["author_info"]["username"] - author = comment["author_info"]["username"] + issue.notes.create!( + project: project, + note: note, + author_id: gl_user_id(project, comment.author), + created_at: comment.created_at, + updated_at: comment.updated_at + ) end - - body += @formatter.comment(author, comment["utc_created_on"], comment["content"]) end project.issues.create!( @@ -77,8 +69,8 @@ module Gitlab author_id: gitlab_user_id(project, reporter) ) end - rescue ActiveRecord::RecordInvalid => e - raise Projects::ImportService::Error, e.message + rescue ActiveRecord::RecordInvalid + nil end end end From 64722a15e39436820a0636804179cf8c8957197e Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 16:15:15 -0300 Subject: [PATCH 017/175] Add an endpoint to get a list of pull requests for a repo --- lib/bitbucket/client.rb | 6 ++++++ lib/bitbucket/representation/pull_request.rb | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 lib/bitbucket/representation/pull_request.rb diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 3d22347603d..0368f388ea4 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -21,6 +21,12 @@ module Bitbucket end end + def pull_requests(repo) + relative_path = "/repositories/#{repo}/pullrequests" + paginator = Paginator.new(connection, relative_path, :pull_request) + + Collection.new(paginator) + end def repo(name) parsed_response = connection.get("/repositories/#{name}") diff --git a/lib/bitbucket/representation/pull_request.rb b/lib/bitbucket/representation/pull_request.rb new file mode 100644 index 00000000000..7cbad91e9c8 --- /dev/null +++ b/lib/bitbucket/representation/pull_request.rb @@ -0,0 +1,6 @@ +module Bitbucket + module Representation + class PullRequest < Representation::Base + end + end +end From 704115c726999d6f0467bbf70087db3ae690d3ab Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 22 Aug 2016 16:15:39 -0300 Subject: [PATCH 018/175] Import opened pull request from Bitbucket --- lib/bitbucket/representation/pull_request.rb | 59 ++++++++++++++++++++ lib/gitlab/bitbucket_import/importer.rb | 31 ++++++++++ 2 files changed, 90 insertions(+) diff --git a/lib/bitbucket/representation/pull_request.rb b/lib/bitbucket/representation/pull_request.rb index 7cbad91e9c8..e7b1f99e9a6 100644 --- a/lib/bitbucket/representation/pull_request.rb +++ b/lib/bitbucket/representation/pull_request.rb @@ -1,6 +1,65 @@ module Bitbucket module Representation class PullRequest < Representation::Base + def author + raw.fetch('author', {}).fetch('username', 'Anonymous') + end + + def description + raw['description'] + end + + def iid + raw['id'] + end + + def state + if raw['state'] == 'MERGED' + 'merged' + elsif raw['state'] == 'DECLINED' + 'closed' + else + 'opened' + end + end + + def created_at + raw['created_on'] + end + + def updated_at + raw['updated_on'] + end + + def title + raw['title'] + end + + def source_branch_name + source_branch.dig('branch', 'name') + end + + def source_branch_sha + source_branch.dig('commit', 'hash') + end + + def target_branch_name + target_branch.dig('branch', 'name') + end + + def target_branch_sha + target_branch.dig('commit', 'hash') + end + + private + + def source_branch + raw['source'] + end + + def target_branch + raw['destination'] + end end end end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 67e906431f0..0060e350249 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -11,6 +11,7 @@ module Gitlab def execute import_issues + import_pull_requests true end @@ -72,6 +73,36 @@ module Gitlab rescue ActiveRecord::RecordInvalid nil end + + def import_pull_requests + pull_requests = client.pull_requests(repo) + + pull_requests.each do |pull_request| + begin + description = @formatter.author_line(pull_request.author) + description += pull_request.description + + project.merge_requests.create( + iid: pull_request.iid, + title: pull_request.title, + description: description, + source_project: project, + source_branch: pull_request.source_branch_name, + source_branch_sha: pull_request.source_branch_sha, + target_project: project, + target_branch: pull_request.target_branch_name, + target_branch_sha: pull_request.target_branch_sha, + state: pull_request.state, + author_id: gl_user_id(project, pull_request.author), + assignee_id: nil, + created_at: pull_request.created_at, + updated_at: pull_request.updated_at + ) + rescue ActiveRecord::RecordInvalid + nil + end + end + end end end end From a0959430516f57ad27df21447777ebb226890647 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 11 Nov 2016 14:00:33 -0800 Subject: [PATCH 019/175] Fix rebase failures with Bitbucket changes --- app/controllers/import/bitbucket_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index ee30a24ab77..5326dce4ebb 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -47,9 +47,9 @@ class Import::BitbucketController < Import::BaseController repo_owner = current_user.username if repo_owner == client.user.username @target_namespace = params[:new_namespace].presence || repo_owner - namespace = find_or_create_namespace(target_namespace_name, repo_owner) + namespace = find_or_create_namespace(@target_namespace, repo_owner) - if current_user.can?(:create_projects, @target_namespace) + if current_user.can?(:create_projects, namespace) @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, namespace, current_user, credentials).execute else render 'unauthorized' From 478730bebd5c8a9505490d2b396ac3c866da1b09 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 11 Nov 2016 14:44:31 -0800 Subject: [PATCH 020/175] Support selection of different namespace and project destination --- app/controllers/import/bitbucket_controller.rb | 6 +++--- app/views/import/bitbucket/status.html.haml | 14 ++++++++++++-- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 5326dce4ebb..e7150cb8e95 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -39,9 +39,9 @@ class Import::BitbucketController < Import::BaseController client = Bitbucket::Client.new(credentials) @repo_id = params[:repo_id].to_s - name = @repo_id.to_s.gsub('___', '/') + name = @repo_id.gsub('___', '/') repo = client.repo(name) - @project_name = repo.name + @project_name = params[:new_name].presence || repo.name repo_owner = repo.owner repo_owner = current_user.username if repo_owner == client.user.username @@ -50,7 +50,7 @@ class Import::BitbucketController < Import::BaseController namespace = find_or_create_namespace(@target_namespace, repo_owner) if current_user.can?(:create_projects, namespace) - @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, namespace, current_user, credentials).execute + @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @project_name, namespace, current_user, credentials).execute else render 'unauthorized' end diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index e62ff5f61f6..cc262e97ceb 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -15,7 +15,7 @@ Import all compatible projects = icon('spinner spin', class: 'loading-icon') - else - = button_tag class: 'btn btn-success js-import-all' do + = button_tag class: 'btn btn-import btn-success js-import-all' do Import all projects = icon('spinner spin', class: 'loading-icon') @@ -52,7 +52,17 @@ %td = link_to "#{repo.full_name}", "https://bitbucket.org/#{repo.full_name}", target: "_blank" %td.import-target - = "#{repo.full_name}" + %fieldset.row + .input-group + .project-path.input-group-btn + - if current_user.can_select_namespace? + - selected = params[:namespace_id] || :current_user + - opts = current_user.can_create_group? ? { extra_group: Group.new(name: repo.owner, path: repo.owner) } : {} + = select_tag :namespace_id, namespaces_options(selected, opts.merge({ display_path: true })), { class: 'select2 js-select-namespace', tabindex: 1 } + - else + = text_field_tag :path, current_user.namespace_path, class: "input-large form-control", tabindex: 1, disabled: true + %span.input-group-addon / + = text_field_tag :path, repo.name, class: "input-mini form-control", tabindex: 2, autofocus: true, required: true %td.import-actions.job-status = button_tag class: 'btn btn-import js-add-to-import' do Import From e2688feeb3075265fb926bbd68560b2046afa0c5 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 11 Nov 2016 15:44:52 -0800 Subject: [PATCH 021/175] Address initial review comments --- app/controllers/import/bitbucket_controller.rb | 13 ++++++------- app/views/import/bitbucket/status.html.haml | 6 +++--- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index e7150cb8e95..72c90f9daf2 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -17,11 +17,10 @@ class Import::BitbucketController < Import::BaseController end def status - client = Bitbucket::Client.new(credentials) - repos = client.repos + bitbucket_client = Bitbucket::Client.new(credentials) + repos = bitbucket_client.repos - @repos = repos.select(&:valid?) - @incompatible_repos = repos.reject(&:valid?) + @repos, @incompatible_repos = repos.partition { |repo| repo.valid? } @already_added_projects = current_user.created_projects.where(import_type: 'bitbucket') already_added_projects_names = @already_added_projects.pluck(:import_source) @@ -36,15 +35,15 @@ class Import::BitbucketController < Import::BaseController end def create - client = Bitbucket::Client.new(credentials) + bitbucket_client = Bitbucket::Client.new(credentials) @repo_id = params[:repo_id].to_s name = @repo_id.gsub('___', '/') - repo = client.repo(name) + repo = bitbucket_client.repo(name) @project_name = params[:new_name].presence || repo.name repo_owner = repo.owner - repo_owner = current_user.username if repo_owner == client.user.username + repo_owner = current_user.username if repo_owner == bitbucket_client.user.username @target_namespace = params[:new_namespace].presence || repo_owner namespace = find_or_create_namespace(@target_namespace, repo_owner) diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index cc262e97ceb..ac09b71ae89 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -33,7 +33,7 @@ - @already_added_projects.each do |project| %tr{id: "project_#{project.id}", class: "#{project_status_css_class(project.import_status)}"} %td - = link_to project.import_source, 'https://bitbucket.org/#{project.import_source}', target: '_blank' + = link_to project.import_source, "https://bitbucket.org/#{project.import_source}", target: '_blank' %td = link_to project.path_with_namespace, [project.namespace.becomes(Namespace), project] %td.job-status @@ -50,7 +50,7 @@ - @repos.each do |repo| %tr{id: "repo_#{repo.owner}___#{repo.slug}"} %td - = link_to "#{repo.full_name}", "https://bitbucket.org/#{repo.full_name}", target: "_blank" + = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: "_blank" %td.import-target %fieldset.row .input-group @@ -70,7 +70,7 @@ - @incompatible_repos.each do |repo| %tr{id: "repo_#{repo.owner}___#{repo.slug}"} %td - = link_to "#{repo.full_name}", "https://bitbucket.org/#{repo.full_name}", target: '_blank' + = link_to repo.full_name, "https://bitbucket.org/#{repo.full_name}", target: '_blank' %td.import-target %td.import-actions-job-status = label_tag 'Incompatible Project', nil, class: 'label label-danger' From 9860488360681a3b10c3de04606ef931c3639601 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 11 Nov 2016 16:07:16 -0800 Subject: [PATCH 022/175] Fix missing Bitbucket ProjectController changes for specifying namespace/project --- lib/gitlab/bitbucket_import/project_creator.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/gitlab/bitbucket_import/project_creator.rb b/lib/gitlab/bitbucket_import/project_creator.rb index 9d80c5d4f2b..b34be272af3 100644 --- a/lib/gitlab/bitbucket_import/project_creator.rb +++ b/lib/gitlab/bitbucket_import/project_creator.rb @@ -1,10 +1,11 @@ module Gitlab module BitbucketImport class ProjectCreator - attr_reader :repo, :namespace, :current_user, :session_data + attr_reader :repo, :name, :namespace, :current_user, :session_data - def initialize(repo, namespace, current_user, session_data) + def initialize(repo, name, namespace, current_user, session_data) @repo = repo + @name = name @namespace = namespace @current_user = current_user @session_data = session_data @@ -13,8 +14,8 @@ module Gitlab def execute ::Projects::CreateService.new( current_user, - name: repo.name, - path: repo.slug, + name: name, + path: name, description: repo.description, namespace_id: namespace.id, visibility_level: repo.visibility_level, From b25ebfe67b0e5448e9625e7a5c469ab41a4b7059 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 11 Nov 2016 16:08:03 -0800 Subject: [PATCH 023/175] Lazily load Bitbucket connection --- lib/bitbucket/connection.rb | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/bitbucket/connection.rb b/lib/bitbucket/connection.rb index 00f127f9507..e9cbf36a44b 100644 --- a/lib/bitbucket/connection.rb +++ b/lib/bitbucket/connection.rb @@ -13,13 +13,18 @@ module Bitbucket @expires_at = options.fetch(:expires_at) @expires_in = options.fetch(:expires_in) @refresh_token = options.fetch(:refresh_token) + end - @client = OAuth2::Client.new(provider.app_id, provider.app_secret, options) - @connection = OAuth2::AccessToken.new(@client, @token, refresh_token: @refresh_token, expires_at: @expires_at, expires_in: @expires_in) + def client + @client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options) + end + + def connection + @connection ||= OAuth2::AccessToken.new(client, @token, refresh_token: @refresh_token, expires_at: @expires_at, expires_in: @expires_in) end def query(params = {}) - @query.update(params) + @query.merge!(params) end def get(path, query = {}) @@ -46,7 +51,7 @@ module Bitbucket private - attr_reader :connection, :expires_at, :expires_in, :refresh_token, :token + attr_reader :expires_at, :expires_in, :refresh_token, :token def build_url(path) return path if path.starts_with?(root_url) From 3b9d1c0f5d2996b946ab71704995b752c0ff8f60 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 11 Nov 2016 16:10:12 -0800 Subject: [PATCH 024/175] Fix refresh to lazily load connection --- lib/bitbucket/connection.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/bitbucket/connection.rb b/lib/bitbucket/connection.rb index e9cbf36a44b..201e7e3b808 100644 --- a/lib/bitbucket/connection.rb +++ b/lib/bitbucket/connection.rb @@ -45,8 +45,7 @@ module Bitbucket @expires_at = response.expires_at @expires_in = response.expires_in @refresh_token = response.refresh_token - - @connection = OAuth2::AccessToken.new(@client, @token, refresh_token: @refresh_token, expires_at: @expires_at, expires_in: @expires_in) + @connection = nil end private From 82d7a3a3dd61c70c87a8a4a116b2ce6c0612de59 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 11 Nov 2016 16:33:28 -0800 Subject: [PATCH 025/175] Fix typos in pull requests failing to import --- lib/gitlab/bitbucket_import/importer.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 0060e350249..15cc64dd7f4 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -43,7 +43,7 @@ module Gitlab title: issue.title, description: description, state: issue.state, - author_id: gl_user_id(project, issue.author), + author_id: gitlab_user_id(project, issue.author), created_at: issue.created_at, updated_at: issue.updated_at ) @@ -56,7 +56,7 @@ module Gitlab issue.notes.create!( project: project, note: note, - author_id: gl_user_id(project, comment.author), + author_id: gitlab_user_id(project, comment.author), created_at: comment.created_at, updated_at: comment.updated_at ) @@ -93,7 +93,7 @@ module Gitlab target_branch: pull_request.target_branch_name, target_branch_sha: pull_request.target_branch_sha, state: pull_request.state, - author_id: gl_user_id(project, pull_request.author), + author_id: gitlab_user_id(project, pull_request.author), assignee_id: nil, created_at: pull_request.created_at, updated_at: pull_request.updated_at From 489d241c8d68ed527fccb73a1f7e46e9a567c971 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 11 Nov 2016 16:48:04 -0800 Subject: [PATCH 026/175] Incorporate review comments --- lib/bitbucket/page.rb | 3 +-- lib/omniauth/strategies/bitbucket.rb | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/bitbucket/page.rb b/lib/bitbucket/page.rb index ad9a2baba36..0e8ce11aa1d 100644 --- a/lib/bitbucket/page.rb +++ b/lib/bitbucket/page.rb @@ -29,8 +29,7 @@ module Bitbucket end def representation_class(type) - class_name = "Bitbucket::Representation::#{type.to_s.camelize}" - class_name.constantize + class_name = Bitbucket::Representation.const_get(type.to_s.camelize) end end end diff --git a/lib/omniauth/strategies/bitbucket.rb b/lib/omniauth/strategies/bitbucket.rb index 2006e7582ce..c5484c59c47 100644 --- a/lib/omniauth/strategies/bitbucket.rb +++ b/lib/omniauth/strategies/bitbucket.rb @@ -32,7 +32,7 @@ module OmniAuth end def primary_email - primary = emails.find{ |i| i['is_primary'] && i['is_confirmed'] } + primary = emails.find { |i| i['is_primary'] && i['is_confirmed'] } primary && primary['email'] || nil end From ea393e6f308e5dcdd5c48433285594db0539b203 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 16 Nov 2016 00:13:17 -0800 Subject: [PATCH 027/175] Import pull request comments --- lib/bitbucket/client.rb | 7 ++ .../representation/pull_request_comment.rb | 39 +++++++++++ lib/gitlab/bitbucket_import/importer.rb | 70 ++++++++++++++++++- 3 files changed, 115 insertions(+), 1 deletion(-) create mode 100644 lib/bitbucket/representation/pull_request_comment.rb diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 0368f388ea4..fce1c898030 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -28,6 +28,13 @@ module Bitbucket Collection.new(paginator) end + def pull_request_comments(repo, pull_request) + relative_path = "/repositories/#{repo}/pullrequests/#{pull_request}/comments" + paginator = Paginator.new(connection, relative_path, :pull_request_comment) + + Collection.new(paginator) + end + def repo(name) parsed_response = connection.get("/repositories/#{name}") Representation::Repo.new(parsed_response) diff --git a/lib/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb new file mode 100644 index 00000000000..94719edbf38 --- /dev/null +++ b/lib/bitbucket/representation/pull_request_comment.rb @@ -0,0 +1,39 @@ +module Bitbucket + module Representation + class PullRequestComment < Comment + def iid + raw['id'] + end + + def file_path + inline.fetch('path', nil) + end + + def old_pos + inline.fetch('from', nil) || 1 + end + + def new_pos + inline.fetch('to', nil) || 1 + end + + def parent_id + raw.fetch('parent', {}).fetch('id', nil) + end + + def inline? + raw.has_key?('inline') + end + + def has_parent? + raw.has_key?('parent') + end + + private + + def inline + raw.fetch('inline', {}) + end + end + end +end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 15cc64dd7f4..94e8062e447 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -82,7 +82,7 @@ module Gitlab description = @formatter.author_line(pull_request.author) description += pull_request.description - project.merge_requests.create( + merge_request = project.merge_requests.create( iid: pull_request.iid, title: pull_request.title, description: description, @@ -98,11 +98,79 @@ module Gitlab created_at: pull_request.created_at, updated_at: pull_request.updated_at ) + + import_pull_request_comments(pull_request, merge_request) if merge_request.persisted? rescue ActiveRecord::RecordInvalid nil end end end + + def import_pull_request_comments(pull_request, merge_request) + comments = client.pull_request_comments(repo, pull_request.iid) + + inline_comments, pr_comments = comments.partition(&:inline?) + + import_inline_comments(inline_comments, pull_request, merge_request) + import_standalone_pr_comments(pr_comments, merge_request) + end + + def import_inline_comments(inline_comments, pull_request, merge_request) + line_code_map = {} + + children, parents = inline_comments.partition(&:has_parent?) + + # The Bitbucket API returns threaded replies as parent-child + # relationships. We assume that the child can appear in any order in + # the JSON. + parents.each do |comment| + line_code_map[comment.iid] = generate_line_code(comment) + end + + children.each do |comment| + line_code_map[comment.iid] = line_code_map.fetch(comment.parent_id, nil) + end + + inline_comments.each do |comment| + begin + attributes = pull_request_comment_attributes(comment) + attributes.merge!( + commit_id: pull_request.source_branch_sha, + line_code: line_code_map.fetch(comment.iid), + type: 'LegacyDiffNote') + + note = merge_request.notes.create!(attributes) + rescue ActiveRecord::RecordInvalid => e + Rails.log.error("Bitbucket importer ERROR: Invalid pull request comment #{e.message}") + nil + end + end + end + + def import_standalone_pr_comments(pr_comments, merge_request) + pr_comments.each do |comment| + begin + merge_request.notes.create!(pull_request_comment_attributes(comment)) + rescue ActiveRecord::RecordInvalid => e + Rails.log.error("Bitbucket importer ERROR: Invalid standalone pull request comment #{e.message}") + nil + end + end + end + + def generate_line_code(pr_comment) + Gitlab::Diff::LineCode.generate(pr_comment.file_path, pr_comment.new_pos, pr_comment.old_pos) + end + + def pull_request_comment_attributes(comment) + { + project: project, + note: comment.note, + author_id: gitlab_user_id(project, comment.author), + created_at: comment.created_at, + updated_at: comment.updated_at + } + end end end end From f25d64d41ae16b96c1381068112717be5b4a1552 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Wed, 16 Nov 2016 22:59:59 -0800 Subject: [PATCH 028/175] Remove no longer used BitbucketImport::Client spec --- .../gitlab/bitbucket_import/client_spec.rb | 67 ------------------- 1 file changed, 67 deletions(-) delete mode 100644 spec/lib/gitlab/bitbucket_import/client_spec.rb diff --git a/spec/lib/gitlab/bitbucket_import/client_spec.rb b/spec/lib/gitlab/bitbucket_import/client_spec.rb deleted file mode 100644 index 7543c29bcc4..00000000000 --- a/spec/lib/gitlab/bitbucket_import/client_spec.rb +++ /dev/null @@ -1,67 +0,0 @@ -require 'spec_helper' - -describe Gitlab::BitbucketImport::Client, lib: true do - include ImportSpecHelper - - let(:token) { '123456' } - let(:secret) { 'secret' } - let(:client) { Gitlab::BitbucketImport::Client.new(token, secret) } - - before do - stub_omniauth_provider('bitbucket') - end - - it 'all OAuth client options are symbols' do - client.consumer.options.keys.each do |key| - expect(key).to be_kind_of(Symbol) - end - end - - context 'issues' do - let(:per_page) { 50 } - let(:count) { 95 } - let(:sample_issues) do - issues = [] - - count.times do |i| - issues << { local_id: i } - end - - issues - end - let(:first_sample_data) { { count: count, issues: sample_issues[0..per_page - 1] } } - let(:second_sample_data) { { count: count, issues: sample_issues[per_page..count] } } - let(:project_id) { 'namespace/repo' } - - it 'retrieves issues over a number of pages' do - stub_request(:get, - "https://bitbucket.org/api/1.0/repositories/#{project_id}/issues?limit=50&sort=utc_created_on&start=0"). - to_return(status: 200, - body: first_sample_data.to_json, - headers: {}) - - stub_request(:get, - "https://bitbucket.org/api/1.0/repositories/#{project_id}/issues?limit=50&sort=utc_created_on&start=50"). - to_return(status: 200, - body: second_sample_data.to_json, - headers: {}) - - issues = client.issues(project_id) - expect(issues.count).to eq(95) - end - end - - context 'project import' do - it 'calls .from_project with no errors' do - project = create(:empty_project) - project.import_url = "ssh://git@bitbucket.org/test/test.git" - project.create_or_update_import_data(credentials: - { user: "git", - password: nil, - bb_session: { bitbucket_access_token: "test", - bitbucket_access_token_secret: "test" } }) - - expect { described_class.from_project(project) }.not_to raise_error - end - end -end From b8bf28348fb903c62e084353896873438f4f0845 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Thu, 17 Nov 2016 20:16:22 -0800 Subject: [PATCH 029/175] Rubocop fixes --- lib/bitbucket/page.rb | 2 +- lib/gitlab/bitbucket_import/importer.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bitbucket/page.rb b/lib/bitbucket/page.rb index 0e8ce11aa1d..bc51ce7dce2 100644 --- a/lib/bitbucket/page.rb +++ b/lib/bitbucket/page.rb @@ -29,7 +29,7 @@ module Bitbucket end def representation_class(type) - class_name = Bitbucket::Representation.const_get(type.to_s.camelize) + Bitbucket::Representation.const_get(type.to_s.camelize) end end end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 94e8062e447..1f7a691e6dd 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -139,7 +139,7 @@ module Gitlab line_code: line_code_map.fetch(comment.iid), type: 'LegacyDiffNote') - note = merge_request.notes.create!(attributes) + merge_request.notes.create!(attributes) rescue ActiveRecord::RecordInvalid => e Rails.log.error("Bitbucket importer ERROR: Invalid pull request comment #{e.message}") nil From 9e6b25d0bc5c2f88330bb074db242017ea45f90d Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Thu, 17 Nov 2016 21:30:35 -0800 Subject: [PATCH 030/175] Add support for extracting all pull requests and their raw diffs --- lib/bitbucket/client.rb | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index fce1c898030..0d4cfd600b8 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -22,7 +22,7 @@ module Bitbucket end def pull_requests(repo) - relative_path = "/repositories/#{repo}/pullrequests" + relative_path = "/repositories/#{repo}/pullrequests?state=ALL" paginator = Paginator.new(connection, relative_path, :pull_request) Collection.new(paginator) @@ -35,6 +35,12 @@ module Bitbucket Collection.new(paginator) end + def pull_request_diff(repo, pull_request) + relative_path = "/repositories/#{repo}/pullrequests/#{pull_request}/diff" + + connection.get(relative_path) + end + def repo(name) parsed_response = connection.get("/repositories/#{name}") Representation::Repo.new(parsed_response) From 4d7303a98e970c29079cc03a449c71f3cdaa1214 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 18 Nov 2016 21:35:03 -0800 Subject: [PATCH 031/175] Clean up owner and slug representation --- lib/bitbucket/representation/repo.rb | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/bitbucket/representation/repo.rb b/lib/bitbucket/representation/repo.rb index fe5cda66ab9..b291dfe0441 100644 --- a/lib/bitbucket/representation/repo.rb +++ b/lib/bitbucket/representation/repo.rb @@ -5,10 +5,18 @@ module Bitbucket def initialize(raw) super(raw) + end - if full_name && full_name.split('/').size == 2 - @owner, @slug = full_name.split('/') - end + def owner_and_slug + @owner_and_slug ||= full_name.split('/', 2) + end + + def owner + owner_and_slug.first + end + + def slug + owner_and_slug.last end def clone_url(token = nil) From c7c4d657b427c6fa146319ccc5aa17e87d3d0e0b Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sat, 19 Nov 2016 21:44:19 -0800 Subject: [PATCH 032/175] Clean up Bitbucket connection based on review comments --- lib/bitbucket/client.rb | 2 +- lib/bitbucket/connection.rb | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 0d4cfd600b8..33e977d655d 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -1,7 +1,7 @@ module Bitbucket class Client def initialize(options = {}) - @connection = options.fetch(:connection, Connection.new(options)) + @connection = Connection.new(options) end def issues(repo) diff --git a/lib/bitbucket/connection.rb b/lib/bitbucket/connection.rb index 201e7e3b808..c375fe16aee 100644 --- a/lib/bitbucket/connection.rb +++ b/lib/bitbucket/connection.rb @@ -7,12 +7,12 @@ module Bitbucket def initialize(options = {}) @api_version = options.fetch(:api_version, DEFAULT_API_VERSION) @base_uri = options.fetch(:base_uri, DEFAULT_BASE_URI) - @query = options.fetch(:query, DEFAULT_QUERY) + @default_query = options.fetch(:query, DEFAULT_QUERY) - @token = options.fetch(:token) - @expires_at = options.fetch(:expires_at) - @expires_in = options.fetch(:expires_in) - @refresh_token = options.fetch(:refresh_token) + @token = options[:token] + @expires_at = options[:expires_at] + @expires_in = options[:expires_in] + @refresh_token = options[:refresh_token] end def client @@ -24,13 +24,13 @@ module Bitbucket end def query(params = {}) - @query.merge!(params) + @default_query.merge!(params) end - def get(path, query = {}) + def get(path, extra_query = {}) refresh! if expired? - response = connection.get(build_url(path), params: @query.merge(query)) + response = connection.get(build_url(path), params: @default_query.merge(extra_query)) response.parsed end From 2747e515c6b06a905512c00af428b1a0aa018569 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sat, 19 Nov 2016 22:39:12 -0800 Subject: [PATCH 033/175] Fix BitbucketImport::ProjectCreator spec --- .../bitbucket_import/project_creator_spec.rb | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb index e1c60e07b4d..bb007949557 100644 --- a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb @@ -3,12 +3,14 @@ require 'spec_helper' describe Gitlab::BitbucketImport::ProjectCreator, lib: true do let(:user) { create(:user) } let(:repo) do - { - name: 'Vim', - slug: 'vim', - is_private: true, - owner: "asd" - }.with_indifferent_access + double(name: 'Vim', + slug: 'vim', + description: 'Test repo', + is_private: true, + owner: "asd", + full_name: 'Vim repo', + visibility_level: Gitlab::VisibilityLevel::PRIVATE, + clone_url: 'ssh://git@bitbucket.org/asd/vim.git') end let(:namespace){ create(:group, owner: user) } let(:token) { "asdasd12345" } @@ -22,7 +24,7 @@ describe Gitlab::BitbucketImport::ProjectCreator, lib: true do it 'creates project' do allow_any_instance_of(Project).to receive(:add_import_job) - project_creator = Gitlab::BitbucketImport::ProjectCreator.new(repo, namespace, user, access_params) + project_creator = Gitlab::BitbucketImport::ProjectCreator.new(repo, 'vim', namespace, user, access_params) project = project_creator.execute expect(project.import_url).to eq("ssh://git@bitbucket.org/asd/vim.git") From 7ba65d05af190a0aba05bd78463eb9e7d70ca6f7 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 20 Nov 2016 21:00:02 -0800 Subject: [PATCH 034/175] Fix Bitbucket callback spec --- .../import/bitbucket_controller_spec.rb | 20 +++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 1d3c9fbbe2f..11bb190af7e 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -6,11 +6,11 @@ describe Import::BitbucketController do let(:user) { create(:user) } let(:token) { "asdasd12345" } let(:secret) { "sekrettt" } + let(:refresh_token) { SecureRandom.hex(15) } let(:access_params) { { bitbucket_access_token: token, bitbucket_access_token_secret: secret } } def assign_session_tokens - session[:bitbucket_access_token] = token - session[:bitbucket_access_token_secret] = secret + session[:bitbucket_token] = token end before do @@ -24,15 +24,23 @@ describe Import::BitbucketController do end it "updates access token" do - access_token = double(token: token, secret: secret) - allow_any_instance_of(Gitlab::BitbucketImport::Client). + expires_at = Time.now + 1.day + expires_in = 1.day + access_token = double(token: token, + secret: secret, + expires_at: expires_at, + expires_in: expires_in, + refresh_token: refresh_token) + allow_any_instance_of(OAuth2::Client). to receive(:get_token).and_return(access_token) stub_omniauth_provider('bitbucket') get :callback - expect(session[:bitbucket_access_token]).to eq(token) - expect(session[:bitbucket_access_token_secret]).to eq(secret) + expect(session[:bitbucket_token]).to eq(token) + expect(session[:bitbucket_refresh_token]).to eq(refresh_token) + expect(session[:bitbucket_expires_at]).to eq(expires_at) + expect(session[:bitbucket_expires_in]).to eq(expires_in) expect(controller).to redirect_to(status_import_bitbucket_url) end end From af6926283b8c4d8cd9668a8df6a28f2ce35001f6 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 20 Nov 2016 21:33:06 -0800 Subject: [PATCH 035/175] Fix Bitbucket status controller spec --- spec/controllers/import/bitbucket_controller_spec.rb | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 11bb190af7e..2fd01c865fb 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -47,14 +47,13 @@ describe Import::BitbucketController do describe "GET status" do before do - @repo = OpenStruct.new(slug: 'vim', owner: 'asd') + @repo = double(slug: 'vim', owner: 'asd', full_name: 'asd/vim', "valid?" => true) assign_session_tokens end it "assigns variables" do @project = create(:project, import_type: 'bitbucket', creator_id: user.id) - client = stub_client(projects: [@repo]) - allow(client).to receive(:incompatible_projects).and_return([]) + allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo]) get :status @@ -65,7 +64,7 @@ describe Import::BitbucketController do it "does not show already added project" do @project = create(:project, import_type: 'bitbucket', creator_id: user.id, import_source: 'asd/vim') - stub_client(projects: [@repo]) + allow_any_instance_of(Bitbucket::Client).to receive(:repos).and_return([@repo]) get :status From 7953480646b5b129868e4323502a28ce27328d8c Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Sun, 20 Nov 2016 22:29:45 -0800 Subject: [PATCH 036/175] Fix remaining Bitbucket controller specs --- .../import/bitbucket_controller.rb | 2 +- .../import/bitbucket_controller_spec.rb | 25 ++++++++----------- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 72c90f9daf2..9c97a97a5dd 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -46,7 +46,7 @@ class Import::BitbucketController < Import::BaseController repo_owner = current_user.username if repo_owner == bitbucket_client.user.username @target_namespace = params[:new_namespace].presence || repo_owner - namespace = find_or_create_namespace(@target_namespace, repo_owner) + namespace = find_or_create_namespace(@target_namespace, current_user) if current_user.can?(:create_projects, namespace) @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @project_name, namespace, current_user, credentials).execute diff --git a/spec/controllers/import/bitbucket_controller_spec.rb b/spec/controllers/import/bitbucket_controller_spec.rb index 2fd01c865fb..ce7c0b334ee 100644 --- a/spec/controllers/import/bitbucket_controller_spec.rb +++ b/spec/controllers/import/bitbucket_controller_spec.rb @@ -7,7 +7,7 @@ describe Import::BitbucketController do let(:token) { "asdasd12345" } let(:secret) { "sekrettt" } let(:refresh_token) { SecureRandom.hex(15) } - let(:access_params) { { bitbucket_access_token: token, bitbucket_access_token_secret: secret } } + let(:access_params) { { token: token, expires_at: nil, expires_in: nil, refresh_token: nil } } def assign_session_tokens session[:bitbucket_token] = token @@ -77,19 +77,16 @@ describe Import::BitbucketController do let(:bitbucket_username) { user.username } let(:bitbucket_user) do - { user: { username: bitbucket_username } }.with_indifferent_access + double(username: bitbucket_username) end let(:bitbucket_repo) do - { slug: "vim", owner: bitbucket_username }.with_indifferent_access + double(slug: "vim", owner: bitbucket_username, name: 'vim') end before do - allow(Gitlab::BitbucketImport::KeyAdder). - to receive(:new).with(bitbucket_repo, user, access_params). - and_return(double(execute: true)) - - stub_client(user: bitbucket_user, project: bitbucket_repo) + allow_any_instance_of(Bitbucket::Client).to receive(:repo).and_return(bitbucket_repo) + allow_any_instance_of(Bitbucket::Client).to receive(:user).and_return(bitbucket_user) assign_session_tokens end @@ -97,7 +94,7 @@ describe Import::BitbucketController do context "when the Bitbucket user and GitLab user's usernames match" do it "takes the current user's namespace" do expect(Gitlab::BitbucketImport::ProjectCreator). - to receive(:new).with(bitbucket_repo, user.namespace, user, access_params). + to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params). and_return(double(execute: true)) post :create, format: :js @@ -109,7 +106,7 @@ describe Import::BitbucketController do it "takes the current user's namespace" do expect(Gitlab::BitbucketImport::ProjectCreator). - to receive(:new).with(bitbucket_repo, user.namespace, user, access_params). + to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params). and_return(double(execute: true)) post :create, format: :js @@ -121,7 +118,7 @@ describe Import::BitbucketController do let(:other_username) { "someone_else" } before do - bitbucket_repo["owner"] = other_username + allow(bitbucket_repo).to receive(:owner).and_return(other_username) end context "when a namespace with the Bitbucket user's username already exists" do @@ -130,7 +127,7 @@ describe Import::BitbucketController do context "when the namespace is owned by the GitLab user" do it "takes the existing namespace" do expect(Gitlab::BitbucketImport::ProjectCreator). - to receive(:new).with(bitbucket_repo, existing_namespace, user, access_params). + to receive(:new).with(bitbucket_repo, bitbucket_repo.name, existing_namespace, user, access_params). and_return(double(execute: true)) post :create, format: :js @@ -163,7 +160,7 @@ describe Import::BitbucketController do it "takes the new namespace" do expect(Gitlab::BitbucketImport::ProjectCreator). - to receive(:new).with(bitbucket_repo, an_instance_of(Group), user, access_params). + to receive(:new).with(bitbucket_repo, bitbucket_repo.name, an_instance_of(Group), user, access_params). and_return(double(execute: true)) post :create, format: :js @@ -184,7 +181,7 @@ describe Import::BitbucketController do it "takes the current user's namespace" do expect(Gitlab::BitbucketImport::ProjectCreator). - to receive(:new).with(bitbucket_repo, user.namespace, user, access_params). + to receive(:new).with(bitbucket_repo, bitbucket_repo.name, user.namespace, user, access_params). and_return(double(execute: true)) post :create, format: :js From 0b72994b63dbbbddaf0e77629249d92890a6e4a4 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Mon, 21 Nov 2016 08:25:47 -0800 Subject: [PATCH 037/175] Simplify Bitbucket::Page implementation --- lib/bitbucket/page.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bitbucket/page.rb b/lib/bitbucket/page.rb index bc51ce7dce2..b91a173b53c 100644 --- a/lib/bitbucket/page.rb +++ b/lib/bitbucket/page.rb @@ -23,7 +23,7 @@ module Bitbucket end def parse_values(raw, representation_class) - return [] if raw['values'].nil? || !raw['values'].is_a?(Array) + return [] unless raw['values'] && raw['values'].is_a?(Array) raw['values'].map { |hash| representation_class.new(hash) } end From 402cc95c1a1df8168467f74e21c6df7d48359714 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Mon, 21 Nov 2016 20:49:40 -0800 Subject: [PATCH 038/175] Fix Bitbucket importer spec to pass with 2.0 API --- lib/bitbucket/page.rb | 4 +- lib/gitlab/bitbucket_import/importer.rb | 7 ---- .../gitlab/bitbucket_import/importer_spec.rb | 42 +++++++++++++------ 3 files changed, 32 insertions(+), 21 deletions(-) diff --git a/lib/bitbucket/page.rb b/lib/bitbucket/page.rb index b91a173b53c..49d083cc66f 100644 --- a/lib/bitbucket/page.rb +++ b/lib/bitbucket/page.rb @@ -22,10 +22,10 @@ module Bitbucket attrs.map { |attr| { attr.to_sym => raw[attr] } }.reduce(&:merge) end - def parse_values(raw, representation_class) + def parse_values(raw, bitbucket_rep_class) return [] unless raw['values'] && raw['values'].is_a?(Array) - raw['values'].map { |hash| representation_class.new(hash) } + raw['values'].map { |hash| bitbucket_rep_class.new(hash) } end def representation_class(type) diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 1f7a691e6dd..729b465e861 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -62,13 +62,6 @@ module Gitlab ) end end - - project.issues.create!( - description: body, - title: issue["title"], - state: %w(resolved invalid duplicate wontfix closed).include?(issue["status"]) ? 'closed' : 'opened', - author_id: gitlab_user_id(project, reporter) - ) end rescue ActiveRecord::RecordInvalid nil diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index aa00f32becb..99a65f26f99 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -23,10 +23,14 @@ describe Gitlab::BitbucketImport::Importer, lib: true do statuses.map.with_index do |status, index| issues << { - local_id: index, - status: status, + id: index, + state: status, title: "Issue #{index}", - content: "Some content to issue #{index}" + content: { + raw: "Some content to issue #{index}", + markup: "markdown", + html: "Some content to issue #{index}" + } } end @@ -37,8 +41,8 @@ describe Gitlab::BitbucketImport::Importer, lib: true do let(:data) do { 'bb_session' => { - 'bitbucket_access_token' => "123456", - 'bitbucket_access_token_secret' => "secret" + 'bitbucket_token' => "123456", + 'bitbucket_refresh_token' => "secret" } } end @@ -53,7 +57,7 @@ describe Gitlab::BitbucketImport::Importer, lib: true do let(:issues_statuses_sample_data) do { count: sample_issues_statuses.count, - issues: sample_issues_statuses + values: sample_issues_statuses } end @@ -61,26 +65,40 @@ describe Gitlab::BitbucketImport::Importer, lib: true do before do stub_request( :get, - "https://bitbucket.org/api/1.0/repositories/#{project_identifier}" - ).to_return(status: 200, body: { has_issues: true }.to_json) + "https://api.bitbucket.org/2.0/repositories/#{project_identifier}" + ).to_return(status: 200, + headers: {"Content-Type" => "application/json"}, + body: { has_issues: true, full_name: project_identifier }.to_json) stub_request( :get, - "https://bitbucket.org/api/1.0/repositories/#{project_identifier}/issues?limit=50&sort=utc_created_on&start=0" - ).to_return(status: 200, body: issues_statuses_sample_data.to_json) + "https://api.bitbucket.org/2.0/repositories/#{project_identifier}/issues?pagelen=50&sort=created_on" + ).to_return(status: 200, + headers: {"Content-Type" => "application/json"}, + body: issues_statuses_sample_data.to_json) sample_issues_statuses.each_with_index do |issue, index| stub_request( :get, - "https://bitbucket.org/api/1.0/repositories/#{project_identifier}/issues/#{issue[:local_id]}/comments" + "https://api.bitbucket.org/2.0/repositories/#{project_identifier}/issues/#{issue[:id]}/comments?pagelen=50&sort=created_on" ).to_return( status: 200, - body: [{ author_info: { username: "username" }, utc_created_on: index }].to_json + headers: {"Content-Type" => "application/json"}, + body: { author_info: { username: "username" }, utc_created_on: index }.to_json ) end + + stub_request( + :get, + "https://api.bitbucket.org/2.0/repositories/#{project_identifier}/pullrequests?pagelen=50&sort=created_on&state=ALL" + ).to_return(status: 200, + headers: {"Content-Type" => "application/json"}, + body: {}.to_json) end it 'map statuses to open or closed' do + # HACK: Bitbucket::Representation.const_get('Issue') seems to return Issue without this + Bitbucket::Representation::Issue importer.execute expect(project.issues.where(state: "closed").size).to eq(5) From fc40c3f28a67ecafa58191c0cd6065960dd59c7d Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Mon, 21 Nov 2016 20:58:46 -0800 Subject: [PATCH 039/175] Fix Rubocop errors with Bitbucket Importer spec --- spec/lib/gitlab/bitbucket_import/importer_spec.rb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index 99a65f26f99..36893751ee0 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -67,14 +67,14 @@ describe Gitlab::BitbucketImport::Importer, lib: true do :get, "https://api.bitbucket.org/2.0/repositories/#{project_identifier}" ).to_return(status: 200, - headers: {"Content-Type" => "application/json"}, + headers: { "Content-Type" => "application/json" }, body: { has_issues: true, full_name: project_identifier }.to_json) stub_request( :get, "https://api.bitbucket.org/2.0/repositories/#{project_identifier}/issues?pagelen=50&sort=created_on" ).to_return(status: 200, - headers: {"Content-Type" => "application/json"}, + headers: { "Content-Type" => "application/json" }, body: issues_statuses_sample_data.to_json) sample_issues_statuses.each_with_index do |issue, index| @@ -83,22 +83,22 @@ describe Gitlab::BitbucketImport::Importer, lib: true do "https://api.bitbucket.org/2.0/repositories/#{project_identifier}/issues/#{issue[:id]}/comments?pagelen=50&sort=created_on" ).to_return( status: 200, - headers: {"Content-Type" => "application/json"}, + headers: { "Content-Type" => "application/json" }, body: { author_info: { username: "username" }, utc_created_on: index }.to_json ) end stub_request( - :get, - "https://api.bitbucket.org/2.0/repositories/#{project_identifier}/pullrequests?pagelen=50&sort=created_on&state=ALL" + :get, + "https://api.bitbucket.org/2.0/repositories/#{project_identifier}/pullrequests?pagelen=50&sort=created_on&state=ALL" ).to_return(status: 200, - headers: {"Content-Type" => "application/json"}, + headers: { "Content-Type" => "application/json" }, body: {}.to_json) end it 'map statuses to open or closed' do # HACK: Bitbucket::Representation.const_get('Issue') seems to return Issue without this - Bitbucket::Representation::Issue + Bitbucket::Representation::Issue.new importer.execute expect(project.issues.where(state: "closed").size).to eq(5) From 7a155137a4fd965cb8ff512a9548a7e685b330f5 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Mon, 21 Nov 2016 22:06:09 -0800 Subject: [PATCH 040/175] Fix spec for Bitbucket importer --- lib/gitlab/bitbucket_import/importer.rb | 6 +++--- spec/lib/gitlab/bitbucket_import/importer_spec.rb | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 729b465e861..08705afcabb 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -94,7 +94,7 @@ module Gitlab import_pull_request_comments(pull_request, merge_request) if merge_request.persisted? rescue ActiveRecord::RecordInvalid - nil + Rails.log.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid pull request #{e.message}") end end end @@ -134,7 +134,7 @@ module Gitlab merge_request.notes.create!(attributes) rescue ActiveRecord::RecordInvalid => e - Rails.log.error("Bitbucket importer ERROR: Invalid pull request comment #{e.message}") + Rails.log.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid pull request comment #{e.message}") nil end end @@ -145,7 +145,7 @@ module Gitlab begin merge_request.notes.create!(pull_request_comment_attributes(comment)) rescue ActiveRecord::RecordInvalid => e - Rails.log.error("Bitbucket importer ERROR: Invalid standalone pull request comment #{e.message}") + Rails.log.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid standalone pull request comment #{e.message}") nil end end diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index 36893751ee0..ef4fc9fd08e 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -98,7 +98,7 @@ describe Gitlab::BitbucketImport::Importer, lib: true do it 'map statuses to open or closed' do # HACK: Bitbucket::Representation.const_get('Issue') seems to return Issue without this - Bitbucket::Representation::Issue.new + Bitbucket::Representation::Issue.new({}) importer.execute expect(project.issues.where(state: "closed").size).to eq(5) From 1e62a13968cc4351684f919630cd426e20fc022a Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Mon, 28 Nov 2016 16:55:31 +0100 Subject: [PATCH 041/175] Improve pipeline fixtures --- db/fixtures/development/14_pipelines.rb | 63 +++++++++++++++++-------- 1 file changed, 44 insertions(+), 19 deletions(-) diff --git a/db/fixtures/development/14_pipelines.rb b/db/fixtures/development/14_pipelines.rb index 08ad3097d34..a019660e5f2 100644 --- a/db/fixtures/development/14_pipelines.rb +++ b/db/fixtures/development/14_pipelines.rb @@ -1,26 +1,50 @@ class Gitlab::Seeder::Pipelines STAGES = %w[build test deploy notify] BUILDS = [ - { name: 'build:linux', stage: 'build', status: :success }, - { name: 'build:osx', stage: 'build', status: :success }, - { name: 'rspec:linux 0 3', stage: 'test', status: :success }, - { name: 'rspec:linux 1 3', stage: 'test', status: :success }, - { name: 'rspec:linux 2 3', stage: 'test', status: :success }, - { name: 'rspec:windows 0 3', stage: 'test', status: :success }, - { name: 'rspec:windows 1 3', stage: 'test', status: :success }, - { name: 'rspec:windows 2 3', stage: 'test', status: :success }, - { name: 'rspec:windows 2 3', stage: 'test', status: :success }, - { name: 'rspec:osx', stage: 'test', status_event: :success }, - { name: 'spinach:linux', stage: 'test', status: :success }, - { name: 'spinach:osx', stage: 'test', status: :failed, allow_failure: true}, - { name: 'env:alpha', stage: 'deploy', environment: 'alpha', status: :pending }, - { name: 'env:beta', stage: 'deploy', environment: 'beta', status: :running }, - { name: 'env:gamma', stage: 'deploy', environment: 'gamma', status: :canceled }, - { name: 'staging', stage: 'deploy', environment: 'staging', status_event: :success, options: { environment: { on_stop: 'stop staging' } } }, - { name: 'stop staging', stage: 'deploy', environment: 'staging', when: 'manual', status: :skipped }, - { name: 'production', stage: 'deploy', environment: 'production', when: 'manual', status: :skipped }, + # build stage + { name: 'build:linux', stage: 'build', status: :success, + queued_at: 10.hour.ago, started_at: 9.hour.ago, finished_at: 8.hour.ago }, + { name: 'build:osx', stage: 'build', status: :success, + queued_at: 10.hour.ago, started_at: 10.hour.ago, finished_at: 9.hour.ago }, + + # test stage + { name: 'rspec:linux 0 3', stage: 'test', status: :success, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + { name: 'rspec:linux 1 3', stage: 'test', status: :success, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + { name: 'rspec:linux 2 3', stage: 'test', status: :success, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + { name: 'rspec:windows 0 3', stage: 'test', status: :success, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + { name: 'rspec:windows 1 3', stage: 'test', status: :success, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + { name: 'rspec:windows 2 3', stage: 'test', status: :success, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + { name: 'rspec:windows 2 3', stage: 'test', status: :success, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + { name: 'rspec:osx', stage: 'test', status_event: :success, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + { name: 'spinach:linux', stage: 'test', status: :success, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + { name: 'spinach:osx', stage: 'test', status: :failed, allow_failure: true, + queued_at: 8.hour.ago, started_at: 8.hour.ago, finished_at: 7.hour.ago }, + + # deploy stage + { name: 'staging', stage: 'deploy', environment: 'staging', status_event: :success, + options: { environment: { action: 'start', on_stop: 'stop staging' } }, + queued_at: 7.hour.ago, started_at: 6.hour.ago, finished_at: 4.hour.ago }, + { name: 'stop staging', stage: 'deploy', environment: 'staging', + when: 'manual', status: :skipped }, + { name: 'production', stage: 'deploy', environment: 'production', + when: 'manual', status: :skipped }, + + # notify stage { name: 'slack', stage: 'notify', when: 'manual', status: :created }, ] + EXTERNAL_JOBS = [ + { name: 'jenkins', stage: 'test', status: :success, + queued_at: 7.hour.ago, started_at: 6.hour.ago, finished_at: 4.hour.ago }, + ] def initialize(project) @project = project @@ -30,11 +54,12 @@ class Gitlab::Seeder::Pipelines pipelines.each do |pipeline| begin BUILDS.each { |opts| build_create!(pipeline, opts) } - commit_status_create!(pipeline, name: 'jenkins', stage: 'test', status: :success) + EXTERNAL_JOBS.each { |opts| commit_status_create!(pipeline, opts) } print '.' rescue ActiveRecord::RecordInvalid print 'F' ensure + pipeline.update_duration pipeline.update_status end end From 54221b5a3b9a2489f979944c77298c4adf004984 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Mon, 5 Dec 2016 20:34:11 +0200 Subject: [PATCH 042/175] Fix inline comment importing for 1:1 diff type --- .../representation/pull_request_comment.rb | 2 +- lib/gitlab/bitbucket_import/importer.rb | 26 ++++++++++++++----- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb index 94719edbf38..38090188919 100644 --- a/lib/bitbucket/representation/pull_request_comment.rb +++ b/lib/bitbucket/representation/pull_request_comment.rb @@ -14,7 +14,7 @@ module Bitbucket end def new_pos - inline.fetch('to', nil) || 1 + inline.fetch('to', nil) || old_pos || 1 end def parent_id diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 08705afcabb..6438c8a52e4 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -81,10 +81,10 @@ module Gitlab description: description, source_project: project, source_branch: pull_request.source_branch_name, - source_branch_sha: pull_request.source_branch_sha, + source_branch_sha: project.repository.rugged.lookup(pull_request.source_branch_sha).oid, target_project: project, target_branch: pull_request.target_branch_name, - target_branch_sha: pull_request.target_branch_sha, + target_branch_sha: project.repository.rugged.lookup(pull_request.target_branch_sha).oid, state: pull_request.state, author_id: gitlab_user_id(project, pull_request.author), assignee_id: nil, @@ -94,7 +94,7 @@ module Gitlab import_pull_request_comments(pull_request, merge_request) if merge_request.persisted? rescue ActiveRecord::RecordInvalid - Rails.log.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid pull request #{e.message}") + Rails.logger.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid pull request #{e.message}") end end end @@ -128,24 +128,36 @@ module Gitlab begin attributes = pull_request_comment_attributes(comment) attributes.merge!( - commit_id: pull_request.source_branch_sha, + position: build_position(merge_request, comment), line_code: line_code_map.fetch(comment.iid), - type: 'LegacyDiffNote') + type: 'DiffNote') merge_request.notes.create!(attributes) rescue ActiveRecord::RecordInvalid => e - Rails.log.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid pull request comment #{e.message}") + Rails.logger.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid pull request comment #{e.message}") nil end end end + def build_position(merge_request, pr_comment) + params = { + diff_refs: merge_request.diff_refs, + old_path: pr_comment.file_path, + new_path: pr_comment.file_path, + old_line: pr_comment.old_pos, + new_line: pr_comment.new_pos + } + + Gitlab::Diff::Position.new(params) + end + def import_standalone_pr_comments(pr_comments, merge_request) pr_comments.each do |comment| begin merge_request.notes.create!(pull_request_comment_attributes(comment)) rescue ActiveRecord::RecordInvalid => e - Rails.log.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid standalone pull request comment #{e.message}") + Rails.logger.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid standalone pull request comment #{e.message}") nil end end From 84f2c219aa33de4890c7681372dd03309f216795 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Tue, 6 Dec 2016 13:46:59 +0200 Subject: [PATCH 043/175] Fix importing inline comment for any diff type --- lib/bitbucket/representation/pull_request_comment.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb index 38090188919..c63d749cba7 100644 --- a/lib/bitbucket/representation/pull_request_comment.rb +++ b/lib/bitbucket/representation/pull_request_comment.rb @@ -10,11 +10,11 @@ module Bitbucket end def old_pos - inline.fetch('from', nil) || 1 + inline.fetch('from', nil) end def new_pos - inline.fetch('to', nil) || old_pos || 1 + inline.fetch('to', nil) end def parent_id From ee8433466ee77c1da026842e965b32ebefab6f13 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Tue, 6 Dec 2016 17:12:11 +0200 Subject: [PATCH 044/175] Fix importing PRs with not existing branches --- lib/bitbucket/connection.rb | 2 +- lib/gitlab/bitbucket_import/importer.rb | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/bitbucket/connection.rb b/lib/bitbucket/connection.rb index c375fe16aee..e28285f119c 100644 --- a/lib/bitbucket/connection.rb +++ b/lib/bitbucket/connection.rb @@ -45,7 +45,7 @@ module Bitbucket @expires_at = response.expires_at @expires_in = response.expires_in @refresh_token = response.refresh_token - @connection = nil + @connection = nil end private diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 6438c8a52e4..34d93542955 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -81,10 +81,10 @@ module Gitlab description: description, source_project: project, source_branch: pull_request.source_branch_name, - source_branch_sha: project.repository.rugged.lookup(pull_request.source_branch_sha).oid, + source_branch_sha: pull_request.source_branch_sha, target_project: project, target_branch: pull_request.target_branch_name, - target_branch_sha: project.repository.rugged.lookup(pull_request.target_branch_sha).oid, + target_branch_sha: pull_request.target_branch_sha, state: pull_request.state, author_id: gitlab_user_id(project, pull_request.author), assignee_id: nil, From 43b7b0ce23d4de7055dc1cdd660b92ff03f4eb1e Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Tue, 6 Dec 2016 18:26:24 +0200 Subject: [PATCH 045/175] Fix authorization with BitBucket --- lib/omniauth/strategies/bitbucket.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/omniauth/strategies/bitbucket.rb b/lib/omniauth/strategies/bitbucket.rb index c5484c59c47..475aad5970f 100644 --- a/lib/omniauth/strategies/bitbucket.rb +++ b/lib/omniauth/strategies/bitbucket.rb @@ -16,7 +16,7 @@ module OmniAuth end uid do - raw['username'] + raw_info['username'] end info do From 67b7637e5d7d3cf3e3f5cde6e7f984ece368c48c Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Wed, 7 Dec 2016 11:33:32 +0200 Subject: [PATCH 046/175] Apply review comments. Iteration 1 --- lib/bitbucket/client.rb | 29 +++++++++---------- lib/bitbucket/connection.rb | 8 ++--- lib/bitbucket/paginator.rb | 2 +- lib/bitbucket/representation/issue.rb | 6 +--- .../representation/pull_request_comment.rb | 2 +- lib/bitbucket/representation/repo.rb | 6 ++-- lib/gitlab/bitbucket_import/importer.rb | 2 +- 7 files changed, 26 insertions(+), 29 deletions(-) diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 33e977d655d..9fa44506374 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -5,40 +5,39 @@ module Bitbucket end def issues(repo) - relative_path = "/repositories/#{repo}/issues" - paginator = Paginator.new(connection, relative_path, :issue) + path = "/repositories/#{repo}/issues" + paginator = Paginator.new(connection, path, :issue) Collection.new(paginator) end - def issue_comments(repo, number) - relative_path = "/repositories/#{repo}/issues/#{number}/comments" - paginator = Paginator.new(connection, relative_path, :url) + def issue_comments(repo, issue_id) + path = "/repositories/#{repo}/issues/#{issue_id}/comments" + paginator = Paginator.new(connection, path, :url) Collection.new(paginator).map do |comment_url| - parsed_response = connection.get(comment_url.to_s) - Representation::Comment.new(parsed_response) + Representation::Comment.new(connection.get(comment_url.to_s)) end end def pull_requests(repo) - relative_path = "/repositories/#{repo}/pullrequests?state=ALL" - paginator = Paginator.new(connection, relative_path, :pull_request) + path = "/repositories/#{repo}/pullrequests?state=ALL" + paginator = Paginator.new(connection, path, :pull_request) Collection.new(paginator) end def pull_request_comments(repo, pull_request) - relative_path = "/repositories/#{repo}/pullrequests/#{pull_request}/comments" - paginator = Paginator.new(connection, relative_path, :pull_request_comment) + path = "/repositories/#{repo}/pullrequests/#{pull_request}/comments" + paginator = Paginator.new(connection, path, :pull_request_comment) Collection.new(paginator) end def pull_request_diff(repo, pull_request) - relative_path = "/repositories/#{repo}/pullrequests/#{pull_request}/diff" + path = "/repositories/#{repo}/pullrequests/#{pull_request}/diff" - connection.get(relative_path) + connection.get(path) end def repo(name) @@ -47,8 +46,8 @@ module Bitbucket end def repos - relative_path = "/repositories/#{user.username}" - paginator = Paginator.new(connection, relative_path, :repo) + path = "/repositories/#{user.username}" + paginator = Paginator.new(connection, path, :repo) Collection.new(paginator) end diff --git a/lib/bitbucket/connection.rb b/lib/bitbucket/connection.rb index e28285f119c..692a596c057 100644 --- a/lib/bitbucket/connection.rb +++ b/lib/bitbucket/connection.rb @@ -5,8 +5,8 @@ module Bitbucket DEFAULT_QUERY = {} def initialize(options = {}) - @api_version = options.fetch(:api_version, DEFAULT_API_VERSION) - @base_uri = options.fetch(:base_uri, DEFAULT_BASE_URI) + @api_version = options.fetch(:api_version, DEFAULT_API_VERSION) + @base_uri = options.fetch(:base_uri, DEFAULT_BASE_URI) @default_query = options.fetch(:query, DEFAULT_QUERY) @token = options[:token] @@ -23,7 +23,7 @@ module Bitbucket @connection ||= OAuth2::AccessToken.new(client, @token, refresh_token: @refresh_token, expires_at: @expires_at, expires_in: @expires_in) end - def query(params = {}) + def set_default_query_parameters(params = {}) @default_query.merge!(params) end @@ -63,7 +63,7 @@ module Bitbucket end def provider - Gitlab.config.omniauth.providers.find { |provider| provider.name == 'bitbucket' } + Gitlab::OAuth::Provider.config_for('bitbucket') end def options diff --git a/lib/bitbucket/paginator.rb b/lib/bitbucket/paginator.rb index a1672d9eaa1..d0e23007ff8 100644 --- a/lib/bitbucket/paginator.rb +++ b/lib/bitbucket/paginator.rb @@ -8,7 +8,7 @@ module Bitbucket @url = url @page = nil - connection.query(pagelen: PAGE_LENGTH, sort: :created_on) + connection.set_default_query_parameters(pagelen: PAGE_LENGTH, sort: :created_on) end def next diff --git a/lib/bitbucket/representation/issue.rb b/lib/bitbucket/representation/issue.rb index 48647ad51f6..dc034c19750 100644 --- a/lib/bitbucket/representation/issue.rb +++ b/lib/bitbucket/representation/issue.rb @@ -8,7 +8,7 @@ module Bitbucket end def author - reporter.fetch('username', 'Anonymous') + raw.dig('reporter', 'username') || 'Anonymous' end def description @@ -40,10 +40,6 @@ module Bitbucket def closed? CLOSED_STATUS.include?(raw['state']) end - - def reporter - raw.fetch('reporter', {}) - end end end end diff --git a/lib/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb index c63d749cba7..ae2b069d6a2 100644 --- a/lib/bitbucket/representation/pull_request_comment.rb +++ b/lib/bitbucket/representation/pull_request_comment.rb @@ -18,7 +18,7 @@ module Bitbucket end def parent_id - raw.fetch('parent', {}).fetch('id', nil) + raw.dig('parent', 'id') end def inline? diff --git a/lib/bitbucket/representation/repo.rb b/lib/bitbucket/representation/repo.rb index b291dfe0441..8969ecd1c19 100644 --- a/lib/bitbucket/representation/repo.rb +++ b/lib/bitbucket/representation/repo.rb @@ -23,7 +23,9 @@ module Bitbucket url = raw['links']['clone'].find { |link| link['name'] == 'https' }.fetch('href') if token.present? - url.sub(/^[^\@]*/, "https://x-token-auth:#{token}") + clone_url = URI::parse(url) + clone_url.user = "x-token-auth:#{token}" + clone_url.to_s else url end @@ -37,7 +39,7 @@ module Bitbucket raw['full_name'] end - def has_issues? + def issues_enabled? raw['has_issues'] end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 34d93542955..0f583b07e93 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -32,7 +32,7 @@ module Gitlab end def import_issues - return unless repo.has_issues? + return unless repo.issues_enabled? client.issues(repo).each do |issue| description = @formatter.author_line(issue.author) From b12d6541835024eb74384551b84bf0e74747d0c3 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Wed, 7 Dec 2016 14:00:06 +0200 Subject: [PATCH 047/175] BitBuckpet importer. Refactoring. Iteration 2 --- app/controllers/import/bitbucket_controller.rb | 2 +- lib/bitbucket/client.rb | 6 ++---- lib/bitbucket/page.rb | 6 ++---- lib/bitbucket/paginator.rb | 4 ++-- lib/bitbucket/representation/base.rb | 4 ++++ lib/bitbucket/representation/url.rb | 9 --------- lib/gitlab/bitbucket_import/importer.rb | 7 +++++++ 7 files changed, 18 insertions(+), 20 deletions(-) delete mode 100644 lib/bitbucket/representation/url.rb diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 9c97a97a5dd..12716d60e7d 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -62,7 +62,7 @@ class Import::BitbucketController < Import::BaseController end def provider - Gitlab.config.omniauth.providers.find { |provider| provider.name == 'bitbucket' } + Gitlab::OAuth::Provider.config_for('bitbucket') end def options diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 9fa44506374..3457c2c6454 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -13,11 +13,9 @@ module Bitbucket def issue_comments(repo, issue_id) path = "/repositories/#{repo}/issues/#{issue_id}/comments" - paginator = Paginator.new(connection, path, :url) + paginator = Paginator.new(connection, path, :comment) - Collection.new(paginator).map do |comment_url| - Representation::Comment.new(connection.get(comment_url.to_s)) - end + Collection.new(paginator) end def pull_requests(repo) diff --git a/lib/bitbucket/page.rb b/lib/bitbucket/page.rb index 49d083cc66f..8f50f67f84d 100644 --- a/lib/bitbucket/page.rb +++ b/lib/bitbucket/page.rb @@ -18,14 +18,12 @@ module Bitbucket private def parse_attrs(raw) - attrs = %w(size page pagelen next previous) - attrs.map { |attr| { attr.to_sym => raw[attr] } }.reduce(&:merge) + raw.slice(*%w(size page pagelen next previous)).symbolize_keys end def parse_values(raw, bitbucket_rep_class) return [] unless raw['values'] && raw['values'].is_a?(Array) - - raw['values'].map { |hash| bitbucket_rep_class.new(hash) } + bitbucket_rep_class.decorate(raw['values']) end def representation_class(type) diff --git a/lib/bitbucket/paginator.rb b/lib/bitbucket/paginator.rb index d0e23007ff8..37f12328447 100644 --- a/lib/bitbucket/paginator.rb +++ b/lib/bitbucket/paginator.rb @@ -26,12 +26,12 @@ module Bitbucket page.nil? || page.next? end - def page_url + def next_url page.nil? ? url : page.next end def fetch_next_page - parsed_response = connection.get(page_url) + parsed_response = connection.get(next_url) Page.new(parsed_response, type) end end diff --git a/lib/bitbucket/representation/base.rb b/lib/bitbucket/representation/base.rb index 7b639492d38..94adaacc9b5 100644 --- a/lib/bitbucket/representation/base.rb +++ b/lib/bitbucket/representation/base.rb @@ -5,6 +5,10 @@ module Bitbucket @raw = raw end + def self.decorate(entries) + entries.map { |entry| new(entry)} + end + private attr_reader :raw diff --git a/lib/bitbucket/representation/url.rb b/lib/bitbucket/representation/url.rb deleted file mode 100644 index 24ae1048013..00000000000 --- a/lib/bitbucket/representation/url.rb +++ /dev/null @@ -1,9 +0,0 @@ -module Bitbucket - module Representation - class Url < Representation::Base - def to_s - raw.dig('links', 'self', 'href') - end - end - end -end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 0f583b07e93..825d43e6589 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -50,6 +50,13 @@ module Gitlab if issue.persisted? client.issue_comments(repo, issue.iid).each do |comment| + # The note can be blank for issue service messages like "Chenged title: ..." + # We would like to import those comments as well but there is no any + # specific parameter that would allow to process them, it's just an empty comment. + # To prevent our importer from just crashing or from creating useless empty comments + # we do this check. + next unless comment.note.present? + note = @formatter.author_line(comment.author) note += comment.note From 98c0eb0f75692b1281adda9bfb75e1fcc12cec6d Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Wed, 7 Dec 2016 15:54:32 +0200 Subject: [PATCH 048/175] BitBucket refactoring. Iteration 3 --- lib/bitbucket/client.rb | 26 ++++++++++--------------- lib/bitbucket/collection.rb | 2 +- lib/bitbucket/paginator.rb | 2 +- lib/gitlab/bitbucket_import/importer.rb | 6 +++--- 4 files changed, 15 insertions(+), 21 deletions(-) diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 3457c2c6454..e23da4556aa 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -6,35 +6,26 @@ module Bitbucket def issues(repo) path = "/repositories/#{repo}/issues" - paginator = Paginator.new(connection, path, :issue) - - Collection.new(paginator) + get_collection(path, :issue) end def issue_comments(repo, issue_id) path = "/repositories/#{repo}/issues/#{issue_id}/comments" - paginator = Paginator.new(connection, path, :comment) - - Collection.new(paginator) + get_collection(path, :comment) end def pull_requests(repo) path = "/repositories/#{repo}/pullrequests?state=ALL" - paginator = Paginator.new(connection, path, :pull_request) - - Collection.new(paginator) + get_collection(path, :pull_request) end def pull_request_comments(repo, pull_request) path = "/repositories/#{repo}/pullrequests/#{pull_request}/comments" - paginator = Paginator.new(connection, path, :pull_request_comment) - - Collection.new(paginator) + get_collection(path, :pull_request_comment) end def pull_request_diff(repo, pull_request) path = "/repositories/#{repo}/pullrequests/#{pull_request}/diff" - connection.get(path) end @@ -45,9 +36,7 @@ module Bitbucket def repos path = "/repositories/#{user.username}" - paginator = Paginator.new(connection, path, :repo) - - Collection.new(paginator) + get_collection(path, :repo) end def user @@ -60,5 +49,10 @@ module Bitbucket private attr_reader :connection + + def get_collection(path, type) + paginator = Paginator.new(connection, path, type) + Collection.new(paginator) + end end end diff --git a/lib/bitbucket/collection.rb b/lib/bitbucket/collection.rb index 9cc8947417c..3a9379ff680 100644 --- a/lib/bitbucket/collection.rb +++ b/lib/bitbucket/collection.rb @@ -3,7 +3,7 @@ module Bitbucket def initialize(paginator) super() do |yielder| loop do - paginator.next.each { |item| yielder << item } + paginator.items.each { |item| yielder << item } end end diff --git a/lib/bitbucket/paginator.rb b/lib/bitbucket/paginator.rb index 37f12328447..641a6ed79d6 100644 --- a/lib/bitbucket/paginator.rb +++ b/lib/bitbucket/paginator.rb @@ -11,7 +11,7 @@ module Bitbucket connection.set_default_query_parameters(pagelen: PAGE_LENGTH, sort: :created_on) end - def next + def items raise StopIteration unless has_next_page? @page = fetch_next_page diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 825d43e6589..fba382e6fea 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -50,7 +50,7 @@ module Gitlab if issue.persisted? client.issue_comments(repo, issue.iid).each do |comment| - # The note can be blank for issue service messages like "Chenged title: ..." + # The note can be blank for issue service messages like "Changed title: ..." # We would like to import those comments as well but there is no any # specific parameter that would allow to process them, it's just an empty comment. # To prevent our importer from just crashing or from creating useless empty comments @@ -70,8 +70,8 @@ module Gitlab end end end - rescue ActiveRecord::RecordInvalid - nil + rescue ActiveRecord::RecordInvalid => e + Rails.logger.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Couldn't import record properly #{e.message}") end def import_pull_requests From bd3bd9bcea11244c56a0f7b63a6afa6fe439bf01 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Wed, 7 Dec 2016 16:16:18 +0200 Subject: [PATCH 049/175] Remove outdated bitbucket_import.rb --- lib/gitlab/bitbucket_import.rb | 6 ------ 1 file changed, 6 deletions(-) delete mode 100644 lib/gitlab/bitbucket_import.rb diff --git a/lib/gitlab/bitbucket_import.rb b/lib/gitlab/bitbucket_import.rb deleted file mode 100644 index 7298152e7e9..00000000000 --- a/lib/gitlab/bitbucket_import.rb +++ /dev/null @@ -1,6 +0,0 @@ -module Gitlab - module BitbucketImport - mattr_accessor :public_key - @public_key = nil - end -end From 00cd864237d6c7ec57ecb49d304ca8dfa9e41d31 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Wed, 7 Dec 2016 18:04:02 +0200 Subject: [PATCH 050/175] BitBucket importer: import issues with labels --- lib/bitbucket/representation/issue.rb | 4 ++++ lib/gitlab/bitbucket_import/importer.rb | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/lib/bitbucket/representation/issue.rb b/lib/bitbucket/representation/issue.rb index dc034c19750..6c8e9a4c244 100644 --- a/lib/bitbucket/representation/issue.rb +++ b/lib/bitbucket/representation/issue.rb @@ -7,6 +7,10 @@ module Bitbucket raw['id'] end + def kind + raw['kind'] + end + def author raw.dig('reporter', 'username') || 'Anonymous' end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index fba382e6fea..8852f5b0f3f 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -1,12 +1,18 @@ module Gitlab module BitbucketImport class Importer + LABELS = [{ title: 'bug', color: '#FF0000'}, + { title: 'enhancement', color: '#428BCA'}, + { title: 'proposal', color: '#69D100'}, + { title: 'task', color: '#7F8C8D'}].freeze + attr_reader :project, :client def initialize(project) @project = project @client = Bitbucket::Client.new(project.import_data.credentials) @formatter = Gitlab::ImportFormatter.new + @labels = {} end def execute @@ -34,10 +40,14 @@ module Gitlab def import_issues return unless repo.issues_enabled? + create_labels + client.issues(repo).each do |issue| description = @formatter.author_line(issue.author) description += issue.description + label_name = issue.kind + issue = project.issues.create( iid: issue.iid, title: issue.title, @@ -48,6 +58,8 @@ module Gitlab updated_at: issue.updated_at ) + assign_label(issue, label_name) + if issue.persisted? client.issue_comments(repo, issue.iid).each do |comment| # The note can be blank for issue service messages like "Changed title: ..." @@ -74,6 +86,16 @@ module Gitlab Rails.logger.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Couldn't import record properly #{e.message}") end + def create_labels + LABELS.each do |label| + @labels[label[:title]] = project.labels.create!(label) + end + end + + def assign_label(issue, label_name) + issue.labels << @labels[label_name] + end + def import_pull_requests pull_requests = client.pull_requests(repo) From 89cc2064a2ebfe947d257c9c15c63825edc73bee Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Thu, 8 Dec 2016 14:41:10 +0200 Subject: [PATCH 051/175] Update documentation for BitBucket --- doc/integration/bitbucket.md | 99 ++---------------- .../img/bitbucket_oauth_settings_page.png | Bin 30081 -> 30275 bytes 2 files changed, 7 insertions(+), 92 deletions(-) diff --git a/doc/integration/bitbucket.md b/doc/integration/bitbucket.md index 9122dc62e39..9cdb101f457 100644 --- a/doc/integration/bitbucket.md +++ b/doc/integration/bitbucket.md @@ -44,14 +44,12 @@ you to use. And grant at least the following permissions: ``` - Account: Email - Repositories: Read, Admin + Account: Email, Read + Repositories: Read + Pull Requests: Read + Issues: Read ``` - >**Note:** - It may seem a little odd to giving GitLab admin permissions to repositories, - but this is needed in order for GitLab to be able to clone the repositories. - ![Bitbucket OAuth settings page](img/bitbucket_oauth_settings_page.png) 1. Select **Save**. @@ -93,7 +91,8 @@ you to use. ```yaml - { name: 'bitbucket', app_id: 'BITBUCKET_APP_KEY', - app_secret: 'BITBUCKET_APP_SECRET' } + app_secret: 'BITBUCKET_APP_SECRET', + url: 'https://bitbucket.org/' } ``` --- @@ -112,91 +111,7 @@ well, the user will be returned to GitLab and will be signed in. ## Bitbucket project import -To allow projects to be imported directly into GitLab, Bitbucket requires two -extra setup steps compared to [GitHub](github.md) and [GitLab.com](gitlab.md). - -Bitbucket doesn't allow OAuth applications to clone repositories over HTTPS, and -instead requires GitLab to use SSH and identify itself using your GitLab -server's SSH key. - -To be able to access repositories on Bitbucket, GitLab will automatically -register your public key with Bitbucket as a deploy key for the repositories to -be imported. Your public key needs to be at `~/.ssh/bitbucket_rsa` which -translates to `/var/opt/gitlab/.ssh/bitbucket_rsa` for Omnibus packages and to -`/home/git/.ssh/bitbucket_rsa` for installations from source. - ---- - -Below are the steps that will allow GitLab to be able to import your projects -from Bitbucket. - -1. Make sure you [have enabled the Bitbucket OAuth support](#bitbucket-omniauth-provider). -1. Create a new SSH key with an **empty passphrase**: - - ```sh - sudo -u git -H ssh-keygen - ``` - - When asked to 'Enter file in which to save the key' enter: - `/var/opt/gitlab/.ssh/bitbucket_rsa` for Omnibus packages or - `/home/git/.ssh/bitbucket_rsa` for installations from source. The name is - important so make sure to get it right. - - > **Warning:** - This key must NOT be associated with ANY existing Bitbucket accounts. If it - is, the import will fail with an `Access denied! Please verify you can add - deploy keys to this repository.` error. - -1. Next, you need to to configure the SSH client to use your new key. Open the - SSH configuration file of the `git` user: - - ``` - # For Omnibus packages - sudo editor /var/opt/gitlab/.ssh/config - - # For installations from source - sudo editor /home/git/.ssh/config - ``` - -1. Add a host configuration for `bitbucket.org`: - - ```sh - Host bitbucket.org - IdentityFile ~/.ssh/bitbucket_rsa - User git - ``` - -1. Save the file and exit. -1. Manually connect to `bitbucket.org` over SSH, while logged in as the `git` - user that GitLab will use: - - ```sh - sudo -u git -H ssh bitbucket.org - ``` - - That step is performed because GitLab needs to connect to Bitbucket over SSH, - in order to add `bitbucket.org` to your GitLab server's known SSH hosts. - -1. Verify the RSA key fingerprint you'll see in the response matches the one - in the [Bitbucket documentation][bitbucket-docs] (the specific IP address - doesn't matter): - - ```sh - The authenticity of host 'bitbucket.org (104.192.143.1)' can't be established. - RSA key fingerprint is SHA256:zzXQOXSRBEiUtuE8AikJYKwbHaxvSc0ojez9YXaGp1A. - Are you sure you want to continue connecting (yes/no)? - ``` - -1. If the fingerprint matches, type `yes` to continue connecting and have - `bitbucket.org` be added to your known SSH hosts. After confirming you should - see a permission denied message. If you see an authentication successful - message you have done something wrong. The key you are using has already been - added to a Bitbucket account and will cause the import script to fail. Ensure - the key you are using CANNOT authenticate with Bitbucket. -1. Restart GitLab to allow it to find the new public key. - -Your GitLab server is now able to connect to Bitbucket over SSH. You should be -able to see the "Import projects from Bitbucket" option on the New Project page +You should be able to see the "Import projects from Bitbucket" option on the New Project page enabled. ## Acknowledgements diff --git a/doc/integration/img/bitbucket_oauth_settings_page.png b/doc/integration/img/bitbucket_oauth_settings_page.png index 8dbee9762d7510f9e553643c61dd52a353ebbc39..24acc6e1f5acee975aba3fcff8df9a4bbfb8f5dd 100644 GIT binary patch literal 30275 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 From 858ec6048223f2eec8c1386d33e3a6c5827233cb Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 9 Dec 2016 16:59:23 +0200 Subject: [PATCH 052/175] Handling OAuth2 errors --- app/controllers/import/bitbucket_controller.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index 12716d60e7d..b9cc6556140 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -2,7 +2,7 @@ class Import::BitbucketController < Import::BaseController before_action :verify_bitbucket_import_enabled before_action :bitbucket_auth, except: :callback - rescue_from OAuth::Error, with: :bitbucket_unauthorized + rescue_from OAuth2::Error, with: :bitbucket_unauthorized rescue_from Bitbucket::Error::Unauthorized, with: :bitbucket_unauthorized def callback From cc30a9f7ed436fd906c1e24a195414f2f84ee61c Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 9 Dec 2016 17:25:42 +0200 Subject: [PATCH 053/175] Fix rubocop[ci skip] --- lib/gitlab/bitbucket_import/importer.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 8852f5b0f3f..e00a90da980 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -1,10 +1,10 @@ module Gitlab module BitbucketImport class Importer - LABELS = [{ title: 'bug', color: '#FF0000'}, - { title: 'enhancement', color: '#428BCA'}, - { title: 'proposal', color: '#69D100'}, - { title: 'task', color: '#7F8C8D'}].freeze + LABELS = [{ title: 'bug', color: '#FF0000' }, + { title: 'enhancement', color: '#428BCA' }, + { title: 'proposal', color: '#69D100' }, + { title: 'task', color: '#7F8C8D' }].freeze attr_reader :project, :client From ff2193a3db558214fab90bb644be6967a03176a0 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 9 Dec 2016 19:40:22 +0200 Subject: [PATCH 054/175] Fix specs --- lib/gitlab/bitbucket_import/importer.rb | 6 +----- spec/lib/gitlab/bitbucket_import/importer_spec.rb | 14 +++++++++++++- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index e00a90da980..a0a17333185 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -58,7 +58,7 @@ module Gitlab updated_at: issue.updated_at ) - assign_label(issue, label_name) + issue.labels << @labels[label_name] if issue.persisted? client.issue_comments(repo, issue.iid).each do |comment| @@ -92,10 +92,6 @@ module Gitlab end end - def assign_label(issue, label_name) - issue.labels << @labels[label_name] - end - def import_pull_requests pull_requests = client.pull_requests(repo) diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index ef4fc9fd08e..353312675d6 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -18,6 +18,7 @@ describe Gitlab::BitbucketImport::Importer, lib: true do "closed" # undocumented status ] end + let(:sample_issues_statuses) do issues = [] @@ -26,6 +27,7 @@ describe Gitlab::BitbucketImport::Importer, lib: true do id: index, state: status, title: "Issue #{index}", + kind: 'bug', content: { raw: "Some content to issue #{index}", markup: "markdown", @@ -38,6 +40,7 @@ describe Gitlab::BitbucketImport::Importer, lib: true do end let(:project_identifier) { 'namespace/repo' } + let(:data) do { 'bb_session' => { @@ -46,6 +49,7 @@ describe Gitlab::BitbucketImport::Importer, lib: true do } } end + let(:project) do create( :project, @@ -53,7 +57,9 @@ describe Gitlab::BitbucketImport::Importer, lib: true do import_data: ProjectImportData.new(credentials: data) ) end + let(:importer) { Gitlab::BitbucketImport::Importer.new(project) } + let(:issues_statuses_sample_data) do { count: sample_issues_statuses.count, @@ -77,6 +83,12 @@ describe Gitlab::BitbucketImport::Importer, lib: true do headers: { "Content-Type" => "application/json" }, body: issues_statuses_sample_data.to_json) + stub_request(:get, "https://api.bitbucket.org/2.0/repositories/namespace/repo?pagelen=50&sort=created_on"). + with(:headers => {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization'=>'Bearer', 'User-Agent'=>'Faraday v0.9.2'}). + to_return(:status => 200, + :body => "", + :headers => {}) + sample_issues_statuses.each_with_index do |issue, index| stub_request( :get, @@ -97,7 +109,7 @@ describe Gitlab::BitbucketImport::Importer, lib: true do end it 'map statuses to open or closed' do - # HACK: Bitbucket::Representation.const_get('Issue') seems to return Issue without this + # HACK: Bitbucket::Representation.const_get('Issue') seems to return ::Issue without this Bitbucket::Representation::Issue.new({}) importer.execute From 091970208e0c8e7aefb6e7dcfafb4c81188c27cf Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 9 Dec 2016 15:17:13 -0800 Subject: [PATCH 055/175] Return repositories to which user is a member, not just owner --- lib/bitbucket/client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index e23da4556aa..a9f405e659b 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -35,7 +35,7 @@ module Bitbucket end def repos - path = "/repositories/#{user.username}" + path = "/repositories/#{user.username}?role=member" get_collection(path, :repo) end From 1d7f85aeef624a83f0b225217a23c8f5189cde54 Mon Sep 17 00:00:00 2001 From: Stan Hu Date: Fri, 9 Dec 2016 15:28:49 -0800 Subject: [PATCH 056/175] Fix query for importing all projects for member --- lib/bitbucket/client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index a9f405e659b..5c2ef2a4509 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -35,7 +35,7 @@ module Bitbucket end def repos - path = "/repositories/#{user.username}?role=member" + path = "/repositories?role=member" get_collection(path, :repo) end From eb09395b2b5527e271c8e155ff6403953f72fef6 Mon Sep 17 00:00:00 2001 From: Nick Thomas Date: Mon, 12 Dec 2016 12:58:42 +0000 Subject: [PATCH 057/175] Upgrade NGINX configuration files to add websocket support --- lib/support/nginx/gitlab | 7 +++++++ lib/support/nginx/gitlab-ssl | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/lib/support/nginx/gitlab b/lib/support/nginx/gitlab index d521de28e8a..2f7c34a3f31 100644 --- a/lib/support/nginx/gitlab +++ b/lib/support/nginx/gitlab @@ -20,6 +20,11 @@ upstream gitlab-workhorse { server unix:/home/git/gitlab/tmp/sockets/gitlab-workhorse.socket fail_timeout=0; } +map $http_upgrade $connection_upgrade_gitlab { + default upgrade; + '' close; +} + ## Normal HTTP host server { ## Either remove "default_server" from the listen line below, @@ -53,6 +58,8 @@ server { proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade_gitlab; proxy_pass http://gitlab-workhorse; } diff --git a/lib/support/nginx/gitlab-ssl b/lib/support/nginx/gitlab-ssl index bf014b56cf6..5661394058d 100644 --- a/lib/support/nginx/gitlab-ssl +++ b/lib/support/nginx/gitlab-ssl @@ -24,6 +24,11 @@ upstream gitlab-workhorse { server unix:/home/git/gitlab/tmp/sockets/gitlab-workhorse.socket fail_timeout=0; } +map $http_upgrade $connection_upgrade_gitlab_ssl { + default upgrade; + '' close; +} + ## Redirects all HTTP traffic to the HTTPS host server { ## Either remove "default_server" from the listen line below, @@ -98,6 +103,9 @@ server { proxy_set_header X-Forwarded-Ssl on; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade_gitlab_ssl; + proxy_pass http://gitlab-workhorse; } From 314c4746bc24a31efe88b142cd83ab36c3473cc9 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Mon, 12 Dec 2016 16:16:51 +0200 Subject: [PATCH 058/175] Specs for Bitbucket::Connections and Bitbucket::Collections --- lib/bitbucket/connection.rb | 20 ++++++-------- lib/bitbucket/paginator.rb | 4 +-- spec/lib/bitbucket/collection_spec.rb | 23 ++++++++++++++++ spec/lib/bitbucket/connection_spec.rb | 26 +++++++++++++++++++ .../bitbucket_import/project_creator_spec.rb | 2 ++ 5 files changed, 60 insertions(+), 15 deletions(-) create mode 100644 spec/lib/bitbucket/collection_spec.rb create mode 100644 spec/lib/bitbucket/connection_spec.rb diff --git a/lib/bitbucket/connection.rb b/lib/bitbucket/connection.rb index 692a596c057..c150a20761e 100644 --- a/lib/bitbucket/connection.rb +++ b/lib/bitbucket/connection.rb @@ -15,18 +15,6 @@ module Bitbucket @refresh_token = options[:refresh_token] end - def client - @client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options) - end - - def connection - @connection ||= OAuth2::AccessToken.new(client, @token, refresh_token: @refresh_token, expires_at: @expires_at, expires_in: @expires_in) - end - - def set_default_query_parameters(params = {}) - @default_query.merge!(params) - end - def get(path, extra_query = {}) refresh! if expired? @@ -52,6 +40,14 @@ module Bitbucket attr_reader :expires_at, :expires_in, :refresh_token, :token + def client + @client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options) + end + + def connection + @connection ||= OAuth2::AccessToken.new(client, @token, refresh_token: @refresh_token, expires_at: @expires_at, expires_in: @expires_in) + end + def build_url(path) return path if path.starts_with?(root_url) diff --git a/lib/bitbucket/paginator.rb b/lib/bitbucket/paginator.rb index 641a6ed79d6..b38cd99855c 100644 --- a/lib/bitbucket/paginator.rb +++ b/lib/bitbucket/paginator.rb @@ -7,8 +7,6 @@ module Bitbucket @type = type @url = url @page = nil - - connection.set_default_query_parameters(pagelen: PAGE_LENGTH, sort: :created_on) end def items @@ -31,7 +29,7 @@ module Bitbucket end def fetch_next_page - parsed_response = connection.get(next_url) + parsed_response = connection.get(next_url, { pagelen: PAGE_LENGTH, sort: :created_on }) Page.new(parsed_response, type) end end diff --git a/spec/lib/bitbucket/collection_spec.rb b/spec/lib/bitbucket/collection_spec.rb new file mode 100644 index 00000000000..eeed61b0488 --- /dev/null +++ b/spec/lib/bitbucket/collection_spec.rb @@ -0,0 +1,23 @@ +require 'spec_helper' + +# Emulates paginator. It returns 2 pages with results +class TestPaginator + def initialize + @current_page = 0 + end + + def items + @current_page += 1 + + raise StopIteration if @current_page > 2 + + ["result_1_page_#{@current_page}", "result_2_page_#{@current_page}"] + end +end + +describe Bitbucket::Collection do + it "iterates paginator" do + collection = described_class.new(TestPaginator.new) + expect(collection.to_a).to match(["result_1_page_1", "result_2_page_1", "result_1_page_2", "result_2_page_2"]) + end +end diff --git a/spec/lib/bitbucket/connection_spec.rb b/spec/lib/bitbucket/connection_spec.rb new file mode 100644 index 00000000000..5242c6fac34 --- /dev/null +++ b/spec/lib/bitbucket/connection_spec.rb @@ -0,0 +1,26 @@ +require 'spec_helper' + +describe Bitbucket::Connection do + describe '#get' do + it 'calls OAuth2::AccessToken::get' do + expect_any_instance_of(OAuth2::AccessToken).to receive(:get).and_return(double(parsed: true)) + connection = described_class.new({}) + connection.get('/users') + end + end + + describe '#expired?' do + it 'calls connection.expired?' do + expect_any_instance_of(OAuth2::AccessToken).to receive(:expired?).and_return(true) + expect(described_class.new({}).expired?).to be_truthy + end + end + + describe '#refresh!' do + it 'calls connection.refresh!' do + response = double(token: nil, expires_at: nil, expires_in: nil, refresh_token: nil) + expect_any_instance_of(OAuth2::AccessToken).to receive(:refresh!).and_return(response) + described_class.new({}).refresh! + end + end +end diff --git a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb index bb007949557..b6d052a4612 100644 --- a/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/project_creator_spec.rb @@ -2,6 +2,7 @@ require 'spec_helper' describe Gitlab::BitbucketImport::ProjectCreator, lib: true do let(:user) { create(:user) } + let(:repo) do double(name: 'Vim', slug: 'vim', @@ -12,6 +13,7 @@ describe Gitlab::BitbucketImport::ProjectCreator, lib: true do visibility_level: Gitlab::VisibilityLevel::PRIVATE, clone_url: 'ssh://git@bitbucket.org/asd/vim.git') end + let(:namespace){ create(:group, owner: user) } let(:token) { "asdasd12345" } let(:secret) { "sekrettt" } From 3a0fecb4924f1a6dbcc3e61041e0cac95ec3b21b Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Mon, 12 Dec 2016 17:29:25 +0200 Subject: [PATCH 059/175] Spec for bitbucket page --- lib/bitbucket/page.rb | 1 + spec/lib/bitbucket/page_spec.rb | 50 +++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 spec/lib/bitbucket/page_spec.rb diff --git a/lib/bitbucket/page.rb b/lib/bitbucket/page.rb index 8f50f67f84d..2b0a3fe7b1a 100644 --- a/lib/bitbucket/page.rb +++ b/lib/bitbucket/page.rb @@ -23,6 +23,7 @@ module Bitbucket def parse_values(raw, bitbucket_rep_class) return [] unless raw['values'] && raw['values'].is_a?(Array) + bitbucket_rep_class.decorate(raw['values']) end diff --git a/spec/lib/bitbucket/page_spec.rb b/spec/lib/bitbucket/page_spec.rb new file mode 100644 index 00000000000..04d5a0470b1 --- /dev/null +++ b/spec/lib/bitbucket/page_spec.rb @@ -0,0 +1,50 @@ +require 'spec_helper' + +describe Bitbucket::Page do + let(:response) { { 'values' => [{ 'username' => 'Ben' }], 'pagelen' => 2, 'next' => '' } } + + before do + # Autoloading hack + Bitbucket::Representation::User.new({}) + end + + describe '#items' do + it 'returns collection of needed objects' do + page = described_class.new(response, :user) + + expect(page.items.first).to be_a(Bitbucket::Representation::User) + expect(page.items.count).to eq(1) + end + end + + describe '#attrs' do + it 'returns attributes' do + page = described_class.new(response, :user) + + expect(page.attrs.keys).to include(:pagelen, :next) + end + end + + describe '#next?' do + it 'returns true' do + page = described_class.new(response, :user) + + expect(page.next?).to be_truthy + end + + it 'returns false' do + response['next'] = nil + page = described_class.new(response, :user) + + expect(page.next?).to be_falsey + end + end + + describe '#next' do + it 'returns next attribute' do + page = described_class.new(response, :user) + + expect(page.next).to eq('') + end + end +end From c45484ba193baa811e50aaa106a2c0ed3721d6e8 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Mon, 12 Dec 2016 18:20:23 +0200 Subject: [PATCH 060/175] Spec for Bitbucket::Paginator --- spec/lib/bitbucket/paginator_spec.rb | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 spec/lib/bitbucket/paginator_spec.rb diff --git a/spec/lib/bitbucket/paginator_spec.rb b/spec/lib/bitbucket/paginator_spec.rb new file mode 100644 index 00000000000..2c972da682e --- /dev/null +++ b/spec/lib/bitbucket/paginator_spec.rb @@ -0,0 +1,21 @@ +require 'spec_helper' + +describe Bitbucket::Paginator do + let(:last_page) { double(:page, next?: false, items: ['item_2']) } + let(:first_page) { double(:page, next?: true, next: last_page, items: ['item_1']) } + + describe 'items' do + it 'return items and raises StopIteration in the end' do + paginator = described_class.new(nil, nil, nil) + + allow(paginator).to receive(:fetch_next_page).and_return(first_page) + expect(paginator.items).to match(['item_1']) + + allow(paginator).to receive(:fetch_next_page).and_return(last_page) + expect(paginator.items).to match(['item_2']) + + allow(paginator).to receive(:fetch_next_page).and_return(nil) + expect{ paginator.items }.to raise_error(StopIteration) + end + end +end From a2be395401f6320d2722bbd98de0c046d05f0480 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Mon, 12 Dec 2016 20:41:55 +0200 Subject: [PATCH 061/175] specs for bitbucket representers --- .../representation/pull_request_comment.rb | 6 +-- .../bitbucket/representation/comment_spec.rb | 22 +++++++++ .../bitbucket/representation/issue_spec.rb | 42 +++++++++++++++++ .../pull_request_comment_spec.rb | 35 ++++++++++++++ .../representation/pull_request_spec.rb | 47 +++++++++++++++++++ .../lib/bitbucket/representation/user_spec.rb | 11 +++++ 6 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 spec/lib/bitbucket/representation/comment_spec.rb create mode 100644 spec/lib/bitbucket/representation/issue_spec.rb create mode 100644 spec/lib/bitbucket/representation/pull_request_comment_spec.rb create mode 100644 spec/lib/bitbucket/representation/pull_request_spec.rb create mode 100644 spec/lib/bitbucket/representation/user_spec.rb diff --git a/lib/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb index ae2b069d6a2..4f3809fbcea 100644 --- a/lib/bitbucket/representation/pull_request_comment.rb +++ b/lib/bitbucket/representation/pull_request_comment.rb @@ -6,15 +6,15 @@ module Bitbucket end def file_path - inline.fetch('path', nil) + inline.fetch('path') end def old_pos - inline.fetch('from', nil) + inline.fetch('from') end def new_pos - inline.fetch('to', nil) + inline.fetch('to') end def parent_id diff --git a/spec/lib/bitbucket/representation/comment_spec.rb b/spec/lib/bitbucket/representation/comment_spec.rb new file mode 100644 index 00000000000..5864193cbfc --- /dev/null +++ b/spec/lib/bitbucket/representation/comment_spec.rb @@ -0,0 +1,22 @@ +require 'spec_helper' + +describe Bitbucket::Representation::Comment do + describe '#author' do + it { expect(described_class.new('user' => { 'username' => 'Ben' }).author).to eq('Ben') } + it { expect(described_class.new({}).author).to eq('Anonymous') } + end + + describe '#note' do + it { expect(described_class.new('content' => { 'raw' => 'Text' }).note).to eq('Text') } + it { expect(described_class.new({}).note).to be_nil } + end + + describe '#created_at' do + it { expect(described_class.new('created_on' => Date.today).created_at).to eq(Date.today) } + end + + describe '#updated_at' do + it { expect(described_class.new('updated_on' => Date.today).updated_at).to eq(Date.today) } + it { expect(described_class.new('created_on' => Date.today).updated_at).to eq(Date.today) } + end +end diff --git a/spec/lib/bitbucket/representation/issue_spec.rb b/spec/lib/bitbucket/representation/issue_spec.rb new file mode 100644 index 00000000000..56deae63bbc --- /dev/null +++ b/spec/lib/bitbucket/representation/issue_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Bitbucket::Representation::Issue do + describe '#iid' do + it { expect(described_class.new('id' => 1).iid).to eq(1) } + end + + describe '#kind' do + it { expect(described_class.new('kind' => 'bug').kind).to eq('bug') } + end + + describe '#author' do + it { expect(described_class.new({ 'reporter' => { 'username' => 'Ben' }}).author).to eq('Ben') } + it { expect(described_class.new({}).author).to eq('Anonymous') } + end + + describe '#description' do + it { expect(described_class.new({ 'content' => { 'raw' => 'Text' }}).description).to eq('Text') } + it { expect(described_class.new({}).description).to be_nil } + end + + describe '#state' do + it { expect(described_class.new({ 'state' => 'invalid' }).state).to eq('closed') } + it { expect(described_class.new({ 'state' => 'wontfix' }).state).to eq('closed') } + it { expect(described_class.new({ 'state' => 'resolved' }).state).to eq('closed') } + it { expect(described_class.new({ 'state' => 'duplicate' }).state).to eq('closed') } + it { expect(described_class.new({ 'state' => 'closed' }).state).to eq('closed') } + it { expect(described_class.new({ 'state' => 'opened' }).state).to eq('opened') } + end + + describe '#title' do + it { expect(described_class.new('title' => 'Issue').title).to eq('Issue') } + end + + describe '#created_at' do + it { expect(described_class.new('created_on' => Date.today).created_at).to eq(Date.today) } + end + + describe '#updated_at' do + it { expect(described_class.new('edited_on' => Date.today).updated_at).to eq(Date.today) } + end +end diff --git a/spec/lib/bitbucket/representation/pull_request_comment_spec.rb b/spec/lib/bitbucket/representation/pull_request_comment_spec.rb new file mode 100644 index 00000000000..8377f0540cd --- /dev/null +++ b/spec/lib/bitbucket/representation/pull_request_comment_spec.rb @@ -0,0 +1,35 @@ +require 'spec_helper' + +describe Bitbucket::Representation::PullRequestComment do + describe '#iid' do + it { expect(described_class.new('id' => 1).iid).to eq(1) } + end + + describe '#file_path' do + it { expect(described_class.new('inline' => { 'path' => '/path' }).file_path).to eq('/path') } + end + + describe '#old_pos' do + it { expect(described_class.new('inline' => { 'from' => 3 }).old_pos).to eq(3) } + end + + describe '#new_pos' do + it { expect(described_class.new('inline' => { 'to' => 3 }).new_pos).to eq(3) } + end + + + describe '#parent_id' do + it { expect(described_class.new({ 'parent' => { 'id' => 2 }}).parent_id).to eq(2) } + it { expect(described_class.new({}).parent_id).to be_nil } + end + + describe '#inline?' do + it { expect(described_class.new('inline' => {}).inline?).to be_truthy } + it { expect(described_class.new({}).inline?).to be_falsey } + end + + describe '#has_parent?' do + it { expect(described_class.new('parent' => {}).has_parent?).to be_truthy } + it { expect(described_class.new({}).has_parent?).to be_falsey } + end +end diff --git a/spec/lib/bitbucket/representation/pull_request_spec.rb b/spec/lib/bitbucket/representation/pull_request_spec.rb new file mode 100644 index 00000000000..661422efddf --- /dev/null +++ b/spec/lib/bitbucket/representation/pull_request_spec.rb @@ -0,0 +1,47 @@ +require 'spec_helper' + +describe Bitbucket::Representation::PullRequest do + describe '#iid' do + it { expect(described_class.new('id' => 1).iid).to eq(1) } + end + + describe '#author' do + it { expect(described_class.new({ 'author' => { 'username' => 'Ben' }}).author).to eq('Ben') } + it { expect(described_class.new({}).author).to eq('Anonymous') } + end + + describe '#description' do + it { expect(described_class.new({ 'description' => 'Text' }).description).to eq('Text') } + it { expect(described_class.new({}).description).to be_nil } + end + + describe '#state' do + it { expect(described_class.new({ 'state' => 'MERGED' }).state).to eq('merged') } + it { expect(described_class.new({ 'state' => 'DECLINED' }).state).to eq('closed') } + it { expect(described_class.new({}).state).to eq('opened') } + end + + describe '#title' do + it { expect(described_class.new('title' => 'Issue').title).to eq('Issue') } + end + + describe '#source_branch_name' do + it { expect(described_class.new({ source: { branch: { name: 'feature' } } }.with_indifferent_access).source_branch_name).to eq('feature') } + it { expect(described_class.new({ source: {} }.with_indifferent_access).source_branch_name).to be_nil } + end + + describe '#source_branch_sha' do + it { expect(described_class.new({ source: { commit: { hash: 'abcd123' } } }.with_indifferent_access).source_branch_sha).to eq('abcd123') } + it { expect(described_class.new({ source: {} }.with_indifferent_access).source_branch_sha).to be_nil } + end + + describe '#target_branch_name' do + it { expect(described_class.new({ destination: { branch: { name: 'master' } } }.with_indifferent_access).target_branch_name).to eq('master') } + it { expect(described_class.new({ destination: {} }.with_indifferent_access).target_branch_name).to be_nil } + end + + describe '#target_branch_sha' do + it { expect(described_class.new({ destination: { commit: { hash: 'abcd123' } } }.with_indifferent_access).target_branch_sha).to eq('abcd123') } + it { expect(described_class.new({ destination: {} }.with_indifferent_access).target_branch_sha).to be_nil } + end +end diff --git a/spec/lib/bitbucket/representation/user_spec.rb b/spec/lib/bitbucket/representation/user_spec.rb new file mode 100644 index 00000000000..f79ff4edb7b --- /dev/null +++ b/spec/lib/bitbucket/representation/user_spec.rb @@ -0,0 +1,11 @@ +require 'spec_helper' + +describe Bitbucket::Representation::User do + describe '#username' do + it 'returns correct value' do + user = described_class.new('username' => 'Ben') + + expect(user.username).to eq('Ben') + end + end +end From 0057ed1e69bc203d82fd3e8dfa6db7ea6a9b1de7 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Tue, 13 Dec 2016 17:59:21 +0200 Subject: [PATCH 062/175] BB importer: Fixed after code review[ci skip] --- lib/bitbucket/paginator.rb | 2 +- spec/lib/bitbucket/collection_spec.rb | 1 + spec/lib/bitbucket/connection_spec.rb | 5 +++++ 3 files changed, 7 insertions(+), 1 deletion(-) diff --git a/lib/bitbucket/paginator.rb b/lib/bitbucket/paginator.rb index b38cd99855c..135d0d55674 100644 --- a/lib/bitbucket/paginator.rb +++ b/lib/bitbucket/paginator.rb @@ -29,7 +29,7 @@ module Bitbucket end def fetch_next_page - parsed_response = connection.get(next_url, { pagelen: PAGE_LENGTH, sort: :created_on }) + parsed_response = connection.get(next_url, pagelen: PAGE_LENGTH, sort: :created_on) Page.new(parsed_response, type) end end diff --git a/spec/lib/bitbucket/collection_spec.rb b/spec/lib/bitbucket/collection_spec.rb index eeed61b0488..015a7f80e03 100644 --- a/spec/lib/bitbucket/collection_spec.rb +++ b/spec/lib/bitbucket/collection_spec.rb @@ -18,6 +18,7 @@ end describe Bitbucket::Collection do it "iterates paginator" do collection = described_class.new(TestPaginator.new) + expect(collection.to_a).to match(["result_1_page_1", "result_2_page_1", "result_1_page_2", "result_2_page_2"]) end end diff --git a/spec/lib/bitbucket/connection_spec.rb b/spec/lib/bitbucket/connection_spec.rb index 5242c6fac34..6be681a8b47 100644 --- a/spec/lib/bitbucket/connection_spec.rb +++ b/spec/lib/bitbucket/connection_spec.rb @@ -4,7 +4,9 @@ describe Bitbucket::Connection do describe '#get' do it 'calls OAuth2::AccessToken::get' do expect_any_instance_of(OAuth2::AccessToken).to receive(:get).and_return(double(parsed: true)) + connection = described_class.new({}) + connection.get('/users') end end @@ -12,6 +14,7 @@ describe Bitbucket::Connection do describe '#expired?' do it 'calls connection.expired?' do expect_any_instance_of(OAuth2::AccessToken).to receive(:expired?).and_return(true) + expect(described_class.new({}).expired?).to be_truthy end end @@ -19,7 +22,9 @@ describe Bitbucket::Connection do describe '#refresh!' do it 'calls connection.refresh!' do response = double(token: nil, expires_at: nil, expires_in: nil, refresh_token: nil) + expect_any_instance_of(OAuth2::AccessToken).to receive(:refresh!).and_return(response) + described_class.new({}).refresh! end end From e39f024029b46322c1bf24409fd5ce7bfcef2da5 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Tue, 13 Dec 2016 21:28:04 +0200 Subject: [PATCH 063/175] BB importer: Adding created_by only when used is not found[ci skip] --- lib/bitbucket/representation/base.rb | 4 ++++ lib/bitbucket/representation/comment.rb | 2 +- lib/bitbucket/representation/issue.rb | 2 +- lib/bitbucket/representation/pull_request.rb | 2 +- lib/bitbucket/representation/user.rb | 6 ++++- lib/gitlab/bitbucket_import/importer.rb | 23 +++++++++++++++----- 6 files changed, 29 insertions(+), 10 deletions(-) diff --git a/lib/bitbucket/representation/base.rb b/lib/bitbucket/representation/base.rb index 94adaacc9b5..fd622d333da 100644 --- a/lib/bitbucket/representation/base.rb +++ b/lib/bitbucket/representation/base.rb @@ -5,6 +5,10 @@ module Bitbucket @raw = raw end + def user_representation(raw) + User.new(raw) + end + def self.decorate(entries) entries.map { |entry| new(entry)} end diff --git a/lib/bitbucket/representation/comment.rb b/lib/bitbucket/representation/comment.rb index 94bc18cbfab..bc40f891cd3 100644 --- a/lib/bitbucket/representation/comment.rb +++ b/lib/bitbucket/representation/comment.rb @@ -2,7 +2,7 @@ module Bitbucket module Representation class Comment < Representation::Base def author - user.fetch('username', 'Anonymous') + user_representation(user) end def note diff --git a/lib/bitbucket/representation/issue.rb b/lib/bitbucket/representation/issue.rb index 6c8e9a4c244..90adfa3331a 100644 --- a/lib/bitbucket/representation/issue.rb +++ b/lib/bitbucket/representation/issue.rb @@ -12,7 +12,7 @@ module Bitbucket end def author - raw.dig('reporter', 'username') || 'Anonymous' + user_representation(raw.fetch('reporter', {})) end def description diff --git a/lib/bitbucket/representation/pull_request.rb b/lib/bitbucket/representation/pull_request.rb index e7b1f99e9a6..96992003d24 100644 --- a/lib/bitbucket/representation/pull_request.rb +++ b/lib/bitbucket/representation/pull_request.rb @@ -2,7 +2,7 @@ module Bitbucket module Representation class PullRequest < Representation::Base def author - raw.fetch('author', {}).fetch('username', 'Anonymous') + user_representation(raw.fetch('author', {})) end def description diff --git a/lib/bitbucket/representation/user.rb b/lib/bitbucket/representation/user.rb index ba6b7667b49..6025a9f0653 100644 --- a/lib/bitbucket/representation/user.rb +++ b/lib/bitbucket/representation/user.rb @@ -2,7 +2,11 @@ module Bitbucket module Representation class User < Representation::Base def username - raw['username'] + raw['username'] || 'Anonymous' + end + + def uuid + raw['uuid'] end end end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index a0a17333185..519a109c0c8 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -24,15 +24,23 @@ module Gitlab private - def gitlab_user_id(project, bitbucket_id) - if bitbucket_id - user = User.joins(:identities).find_by("identities.extern_uid = ? AND identities.provider = 'bitbucket'", bitbucket_id.to_s) + def gitlab_user_id(project, user) + if user.uuid + user = find_user_by_uuid(user.uuid) (user && user.id) || project.creator_id else project.creator_id end end + def find_user_by_uuid(uuid) + User.joins(:identities).find_by("identities.extern_uid = ? AND identities.provider = 'bitbucket'", uuid) + end + + def existing_gitlab_user?(user) + user.uuid && find_user_by_uuid(user.uuid) + end + def repo @repo ||= client.repo(project.import_source) end @@ -43,7 +51,8 @@ module Gitlab create_labels client.issues(repo).each do |issue| - description = @formatter.author_line(issue.author) + description = '' + description += @formatter.author_line(issue.author.username) unless existing_gitlab_user?(issue.author) description += issue.description label_name = issue.kind @@ -69,7 +78,8 @@ module Gitlab # we do this check. next unless comment.note.present? - note = @formatter.author_line(comment.author) + note = '' + note += @formatter.author_line(comment.author.username) unless existing_gitlab_user?(comment.author) note += comment.note issue.notes.create!( @@ -97,7 +107,8 @@ module Gitlab pull_requests.each do |pull_request| begin - description = @formatter.author_line(pull_request.author) + description = '' + description += @formatter.author_line(pull_request.author.username) unless existing_gitlab_user?(pull_request.author) description += pull_request.description merge_request = project.merge_requests.create( From f20ea1f5cb354db0afe18e4021e1d2fb439c2e06 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Wed, 14 Dec 2016 11:53:29 +0200 Subject: [PATCH 064/175] Fix BB authentication[ci skip] --- lib/omniauth/strategies/bitbucket.rb | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/lib/omniauth/strategies/bitbucket.rb b/lib/omniauth/strategies/bitbucket.rb index 475aad5970f..5a7d67c2390 100644 --- a/lib/omniauth/strategies/bitbucket.rb +++ b/lib/omniauth/strategies/bitbucket.rb @@ -11,10 +11,6 @@ module OmniAuth token_url: 'https://bitbucket.org/site/oauth2/access_token' } - def callback_url - full_host + script_name + callback_path - end - uid do raw_info['username'] end @@ -28,7 +24,7 @@ module OmniAuth end def raw_info - @raw_info ||= access_token.get('user').parsed + @raw_info ||= access_token.get('api/2.0/user').parsed end def primary_email @@ -37,7 +33,7 @@ module OmniAuth end def emails - email_response = access_token.get('user/emails').parsed + email_response = access_token.get('api/2.0/user/emails').parsed @emails ||= email_response && email_response['values'] || nil end end From 8f0cef0b6e5e950efdf3ebfe8f9f846095fff9d9 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Wed, 14 Dec 2016 12:35:10 +0200 Subject: [PATCH 065/175] BB importer: Refactoring user importing logic[ci skip] --- lib/bitbucket/representation/base.rb | 4 ---- lib/bitbucket/representation/comment.rb | 2 +- lib/bitbucket/representation/issue.rb | 2 +- lib/bitbucket/representation/pull_request.rb | 2 +- lib/bitbucket/representation/user.rb | 6 +----- lib/gitlab/bitbucket_import/importer.rb | 20 ++++++++++---------- 6 files changed, 14 insertions(+), 22 deletions(-) diff --git a/lib/bitbucket/representation/base.rb b/lib/bitbucket/representation/base.rb index fd622d333da..94adaacc9b5 100644 --- a/lib/bitbucket/representation/base.rb +++ b/lib/bitbucket/representation/base.rb @@ -5,10 +5,6 @@ module Bitbucket @raw = raw end - def user_representation(raw) - User.new(raw) - end - def self.decorate(entries) entries.map { |entry| new(entry)} end diff --git a/lib/bitbucket/representation/comment.rb b/lib/bitbucket/representation/comment.rb index bc40f891cd3..3c75e9368fa 100644 --- a/lib/bitbucket/representation/comment.rb +++ b/lib/bitbucket/representation/comment.rb @@ -2,7 +2,7 @@ module Bitbucket module Representation class Comment < Representation::Base def author - user_representation(user) + user['username'] end def note diff --git a/lib/bitbucket/representation/issue.rb b/lib/bitbucket/representation/issue.rb index 90adfa3331a..ffe8a65d839 100644 --- a/lib/bitbucket/representation/issue.rb +++ b/lib/bitbucket/representation/issue.rb @@ -12,7 +12,7 @@ module Bitbucket end def author - user_representation(raw.fetch('reporter', {})) + raw.dig('reporter', 'username') end def description diff --git a/lib/bitbucket/representation/pull_request.rb b/lib/bitbucket/representation/pull_request.rb index 96992003d24..e37c9a62c0e 100644 --- a/lib/bitbucket/representation/pull_request.rb +++ b/lib/bitbucket/representation/pull_request.rb @@ -2,7 +2,7 @@ module Bitbucket module Representation class PullRequest < Representation::Base def author - user_representation(raw.fetch('author', {})) + raw.dig('author', 'username') end def description diff --git a/lib/bitbucket/representation/user.rb b/lib/bitbucket/representation/user.rb index 6025a9f0653..ba6b7667b49 100644 --- a/lib/bitbucket/representation/user.rb +++ b/lib/bitbucket/representation/user.rb @@ -2,11 +2,7 @@ module Bitbucket module Representation class User < Representation::Base def username - raw['username'] || 'Anonymous' - end - - def uuid - raw['uuid'] + raw['username'] end end end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 519a109c0c8..b6a0b122cdb 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -24,21 +24,21 @@ module Gitlab private - def gitlab_user_id(project, user) - if user.uuid - user = find_user_by_uuid(user.uuid) + def gitlab_user_id(project, username) + if username + user = find_user(username) (user && user.id) || project.creator_id else project.creator_id end end - def find_user_by_uuid(uuid) - User.joins(:identities).find_by("identities.extern_uid = ? AND identities.provider = 'bitbucket'", uuid) + def find_user(username) + User.joins(:identities).find_by("identities.extern_uid = ? AND identities.provider = 'bitbucket'", username) end - def existing_gitlab_user?(user) - user.uuid && find_user_by_uuid(user.uuid) + def existing_gitlab_user?(username) + username && find_user(username) end def repo @@ -52,7 +52,7 @@ module Gitlab client.issues(repo).each do |issue| description = '' - description += @formatter.author_line(issue.author.username) unless existing_gitlab_user?(issue.author) + description += @formatter.author_line(issue.author) unless existing_gitlab_user?(issue.author) description += issue.description label_name = issue.kind @@ -79,7 +79,7 @@ module Gitlab next unless comment.note.present? note = '' - note += @formatter.author_line(comment.author.username) unless existing_gitlab_user?(comment.author) + note += @formatter.author_line(comment.author) unless existing_gitlab_user?(comment.author) note += comment.note issue.notes.create!( @@ -108,7 +108,7 @@ module Gitlab pull_requests.each do |pull_request| begin description = '' - description += @formatter.author_line(pull_request.author.username) unless existing_gitlab_user?(pull_request.author) + description += @formatter.author_line(pull_request.author) unless existing_gitlab_user?(pull_request.author) description += pull_request.description merge_request = project.merge_requests.create( From 6bbe2f118ee17ac8b1d43a77f4020c048c427b77 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Wed, 14 Dec 2016 15:18:30 +0200 Subject: [PATCH 066/175] BB importer: More advanced error handling --- lib/gitlab/bitbucket_import/importer.rb | 76 +++++++++++++++---------- 1 file changed, 45 insertions(+), 31 deletions(-) diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index b6a0b122cdb..567f2b314aa 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -6,24 +6,34 @@ module Gitlab { title: 'proposal', color: '#69D100' }, { title: 'task', color: '#7F8C8D' }].freeze - attr_reader :project, :client + attr_reader :project, :client, :errors def initialize(project) @project = project @client = Bitbucket::Client.new(project.import_data.credentials) @formatter = Gitlab::ImportFormatter.new @labels = {} + @errors = [] end def execute import_issues import_pull_requests + handle_errors true end private + def handle_errors + return unless errors.any? + project.update_column(:import_error, { + message: 'The remote data could not be fully imported.', + errors: errors + }.to_json) + end + def gitlab_user_id(project, username) if username user = find_user(username) @@ -51,21 +61,25 @@ module Gitlab create_labels client.issues(repo).each do |issue| - description = '' - description += @formatter.author_line(issue.author) unless existing_gitlab_user?(issue.author) - description += issue.description + begin + description = '' + description += @formatter.author_line(issue.author) unless existing_gitlab_user?(issue.author) + description += issue.description - label_name = issue.kind + label_name = issue.kind - issue = project.issues.create( - iid: issue.iid, - title: issue.title, - description: description, - state: issue.state, - author_id: gitlab_user_id(project, issue.author), - created_at: issue.created_at, - updated_at: issue.updated_at - ) + issue = project.issues.create!( + iid: issue.iid, + title: issue.title, + description: description, + state: issue.state, + author_id: gitlab_user_id(project, issue.author), + created_at: issue.created_at, + updated_at: issue.updated_at + ) + rescue StandardError => e + errors << { type: :issue, iid: issue.iid, errors: e.message } + end issue.labels << @labels[label_name] @@ -82,18 +96,20 @@ module Gitlab note += @formatter.author_line(comment.author) unless existing_gitlab_user?(comment.author) note += comment.note - issue.notes.create!( - project: project, - note: note, - author_id: gitlab_user_id(project, comment.author), - created_at: comment.created_at, - updated_at: comment.updated_at - ) + begin + issue.notes.create!( + project: project, + note: note, + author_id: gitlab_user_id(project, comment.author), + created_at: comment.created_at, + updated_at: comment.updated_at + ) + rescue StandardError => e + errors << { type: :issue_comment, iid: issue.iid, errors: e.message } + end end end end - rescue ActiveRecord::RecordInvalid => e - Rails.logger.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Couldn't import record properly #{e.message}") end def create_labels @@ -129,8 +145,8 @@ module Gitlab ) import_pull_request_comments(pull_request, merge_request) if merge_request.persisted? - rescue ActiveRecord::RecordInvalid - Rails.logger.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid pull request #{e.message}") + rescue StandardError => e + errors << { type: :pull_request, iid: pull_request.iid, errors: e.message } end end end @@ -169,9 +185,8 @@ module Gitlab type: 'DiffNote') merge_request.notes.create!(attributes) - rescue ActiveRecord::RecordInvalid => e - Rails.logger.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid pull request comment #{e.message}") - nil + rescue StandardError => e + errors << { type: :pull_request, iid: comment.iid, errors: e.message } end end end @@ -192,9 +207,8 @@ module Gitlab pr_comments.each do |comment| begin merge_request.notes.create!(pull_request_comment_attributes(comment)) - rescue ActiveRecord::RecordInvalid => e - Rails.logger.error("Bitbucket importer ERROR in #{project.path_with_namespace}: Invalid standalone pull request comment #{e.message}") - nil + rescue StandardError => e + errors << { type: :pull_request, iid: comment.iid, errors: e.message } end end end From c756e62b08f0a639f5550d17339b2938c9c9e096 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Wed, 14 Dec 2016 20:19:26 +0200 Subject: [PATCH 067/175] BB importer: fix specs --- spec/lib/bitbucket/connection_spec.rb | 4 ++++ spec/lib/bitbucket/representation/comment_spec.rb | 2 +- spec/lib/bitbucket/representation/issue_spec.rb | 6 +++--- .../bitbucket/representation/pull_request_comment_spec.rb | 3 +-- spec/lib/bitbucket/representation/pull_request_spec.rb | 4 ++-- spec/lib/gitlab/bitbucket_import/importer_spec.rb | 8 ++++---- 6 files changed, 15 insertions(+), 12 deletions(-) diff --git a/spec/lib/bitbucket/connection_spec.rb b/spec/lib/bitbucket/connection_spec.rb index 6be681a8b47..14faeb231a9 100644 --- a/spec/lib/bitbucket/connection_spec.rb +++ b/spec/lib/bitbucket/connection_spec.rb @@ -1,6 +1,10 @@ require 'spec_helper' describe Bitbucket::Connection do + before do + allow_any_instance_of(described_class).to receive(:provider).and_return(double(app_id: '', app_secret: '')) + end + describe '#get' do it 'calls OAuth2::AccessToken::get' do expect_any_instance_of(OAuth2::AccessToken).to receive(:get).and_return(double(parsed: true)) diff --git a/spec/lib/bitbucket/representation/comment_spec.rb b/spec/lib/bitbucket/representation/comment_spec.rb index 5864193cbfc..fec243a9f96 100644 --- a/spec/lib/bitbucket/representation/comment_spec.rb +++ b/spec/lib/bitbucket/representation/comment_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Bitbucket::Representation::Comment do describe '#author' do it { expect(described_class.new('user' => { 'username' => 'Ben' }).author).to eq('Ben') } - it { expect(described_class.new({}).author).to eq('Anonymous') } + it { expect(described_class.new({}).author).to be_nil } end describe '#note' do diff --git a/spec/lib/bitbucket/representation/issue_spec.rb b/spec/lib/bitbucket/representation/issue_spec.rb index 56deae63bbc..e1f3419c77e 100644 --- a/spec/lib/bitbucket/representation/issue_spec.rb +++ b/spec/lib/bitbucket/representation/issue_spec.rb @@ -10,12 +10,12 @@ describe Bitbucket::Representation::Issue do end describe '#author' do - it { expect(described_class.new({ 'reporter' => { 'username' => 'Ben' }}).author).to eq('Ben') } - it { expect(described_class.new({}).author).to eq('Anonymous') } + it { expect(described_class.new({ 'reporter' => { 'username' => 'Ben' } }).author).to eq('Ben') } + it { expect(described_class.new({}).author).to be_nil } end describe '#description' do - it { expect(described_class.new({ 'content' => { 'raw' => 'Text' }}).description).to eq('Text') } + it { expect(described_class.new({ 'content' => { 'raw' => 'Text' } }).description).to eq('Text') } it { expect(described_class.new({}).description).to be_nil } end diff --git a/spec/lib/bitbucket/representation/pull_request_comment_spec.rb b/spec/lib/bitbucket/representation/pull_request_comment_spec.rb index 8377f0540cd..673dcf22ce8 100644 --- a/spec/lib/bitbucket/representation/pull_request_comment_spec.rb +++ b/spec/lib/bitbucket/representation/pull_request_comment_spec.rb @@ -17,9 +17,8 @@ describe Bitbucket::Representation::PullRequestComment do it { expect(described_class.new('inline' => { 'to' => 3 }).new_pos).to eq(3) } end - describe '#parent_id' do - it { expect(described_class.new({ 'parent' => { 'id' => 2 }}).parent_id).to eq(2) } + it { expect(described_class.new({ 'parent' => { 'id' => 2 } }).parent_id).to eq(2) } it { expect(described_class.new({}).parent_id).to be_nil } end diff --git a/spec/lib/bitbucket/representation/pull_request_spec.rb b/spec/lib/bitbucket/representation/pull_request_spec.rb index 661422efddf..30453528be4 100644 --- a/spec/lib/bitbucket/representation/pull_request_spec.rb +++ b/spec/lib/bitbucket/representation/pull_request_spec.rb @@ -6,8 +6,8 @@ describe Bitbucket::Representation::PullRequest do end describe '#author' do - it { expect(described_class.new({ 'author' => { 'username' => 'Ben' }}).author).to eq('Ben') } - it { expect(described_class.new({}).author).to eq('Anonymous') } + it { expect(described_class.new({ 'author' => { 'username' => 'Ben' } }).author).to eq('Ben') } + it { expect(described_class.new({}).author).to be_nil } end describe '#description' do diff --git a/spec/lib/gitlab/bitbucket_import/importer_spec.rb b/spec/lib/gitlab/bitbucket_import/importer_spec.rb index 353312675d6..53f3c73ade4 100644 --- a/spec/lib/gitlab/bitbucket_import/importer_spec.rb +++ b/spec/lib/gitlab/bitbucket_import/importer_spec.rb @@ -84,10 +84,10 @@ describe Gitlab::BitbucketImport::Importer, lib: true do body: issues_statuses_sample_data.to_json) stub_request(:get, "https://api.bitbucket.org/2.0/repositories/namespace/repo?pagelen=50&sort=created_on"). - with(:headers => {'Accept'=>'*/*', 'Accept-Encoding'=>'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization'=>'Bearer', 'User-Agent'=>'Faraday v0.9.2'}). - to_return(:status => 200, - :body => "", - :headers => {}) + with(headers: { 'Accept' => '*/*', 'Accept-Encoding' => 'gzip;q=1.0,deflate;q=0.6,identity;q=0.3', 'Authorization' => 'Bearer', 'User-Agent' => 'Faraday v0.9.2' }). + to_return(status: 200, + body: "", + headers: {}) sample_issues_statuses.each_with_index do |issue, index| stub_request( From 0ddc5f667e60db6f7f6021dec55a1c9feab852ab Mon Sep 17 00:00:00 2001 From: James Lopez Date: Thu, 15 Dec 2016 12:40:33 +0100 Subject: [PATCH 068/175] add new runner script attempts docs and update .gitlab-ci.yml --- .gitlab-ci.yml | 1 + doc/ci/variables/README.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index e522d47d19d..b256e8a2a5f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -15,6 +15,7 @@ variables: USE_BUNDLE_INSTALL: "true" GIT_DEPTH: "20" PHANTOMJS_VERSION: "2.1.1" + GET_SOURCES_ATTEMPTS: "3" before_script: - source ./scripts/prepare_build.sh diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index e0ff9756868..a3ce44d72c6 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -58,6 +58,9 @@ version of Runner required. | **CI_RUNNER_DESCRIPTION** | 8.10 | 0.5 | The description of the runner as saved in GitLab | | **CI_RUNNER_TAGS** | 8.10 | 0.5 | The defined runner tags | | **CI_DEBUG_TRACE** | all | 1.7 | Whether [debug tracing](#debug-tracing) is enabled | +| **GET_SOURCES_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to fetch sources running a build | +| **ARTIFACT_DOWNLOAD_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to download artifacts running a build | +| **RESTORE_CACHE_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to restore the cache running a build | | **GITLAB_USER_ID** | 8.12 | all | The id of the user who started the build | | **GITLAB_USER_EMAIL** | 8.12 | all | The email of the user who started the build | From 26628fb91a89bbe4998633eec00d2bd76cfb95c0 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Thu, 15 Dec 2016 14:19:28 +0200 Subject: [PATCH 069/175] BB importer: Fixed bug with putting expired token to a project.clone_url --- app/controllers/import/bitbucket_controller.rb | 3 +++ lib/bitbucket/client.rb | 4 ++-- lib/bitbucket/connection.rb | 4 ++-- lib/gitlab/bitbucket_import/project_creator.rb | 2 +- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/app/controllers/import/bitbucket_controller.rb b/app/controllers/import/bitbucket_controller.rb index b9cc6556140..8e42cdf415f 100644 --- a/app/controllers/import/bitbucket_controller.rb +++ b/app/controllers/import/bitbucket_controller.rb @@ -49,6 +49,9 @@ class Import::BitbucketController < Import::BaseController namespace = find_or_create_namespace(@target_namespace, current_user) if current_user.can?(:create_projects, namespace) + # The token in a session can be expired, we need to get most recent one because + # Bitbucket::Connection class refreshes it. + session[:bitbucket_token] = bitbucket_client.connection.token @project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @project_name, namespace, current_user, credentials).execute else render 'unauthorized' diff --git a/lib/bitbucket/client.rb b/lib/bitbucket/client.rb index 5c2ef2a4509..f8ee7e0f9ae 100644 --- a/lib/bitbucket/client.rb +++ b/lib/bitbucket/client.rb @@ -1,5 +1,7 @@ module Bitbucket class Client + attr_reader :connection + def initialize(options = {}) @connection = Connection.new(options) end @@ -48,8 +50,6 @@ module Bitbucket private - attr_reader :connection - def get_collection(path, type) paginator = Paginator.new(connection, path, type) Collection.new(paginator) diff --git a/lib/bitbucket/connection.rb b/lib/bitbucket/connection.rb index c150a20761e..7e55cf4deab 100644 --- a/lib/bitbucket/connection.rb +++ b/lib/bitbucket/connection.rb @@ -4,6 +4,8 @@ module Bitbucket DEFAULT_BASE_URI = 'https://api.bitbucket.org/' DEFAULT_QUERY = {} + attr_reader :expires_at, :expires_in, :refresh_token, :token + def initialize(options = {}) @api_version = options.fetch(:api_version, DEFAULT_API_VERSION) @base_uri = options.fetch(:base_uri, DEFAULT_BASE_URI) @@ -38,8 +40,6 @@ module Bitbucket private - attr_reader :expires_at, :expires_in, :refresh_token, :token - def client @client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options) end diff --git a/lib/gitlab/bitbucket_import/project_creator.rb b/lib/gitlab/bitbucket_import/project_creator.rb index b34be272af3..eb03882ab26 100644 --- a/lib/gitlab/bitbucket_import/project_creator.rb +++ b/lib/gitlab/bitbucket_import/project_creator.rb @@ -21,7 +21,7 @@ module Gitlab visibility_level: repo.visibility_level, import_type: 'bitbucket', import_source: repo.full_name, - import_url: repo.clone_url(@session_data[:token]), + import_url: repo.clone_url(session_data[:token]), import_data: { credentials: session_data } ).execute end From 1356e40f22c555f676777ed9385a12b09c19fdce Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Thu, 13 Oct 2016 04:33:09 +0100 Subject: [PATCH 070/175] Changed autocomplete_sources into an action that returns a single 'at' type of sources at a time Finished up autocomplete_sources action and added frontend to fetch data only when its needed Added wait_for_ajax to specs Fixed builds and improved the setup/destroy lifecycle Changed global namespace and DRYed up loading logic Added safety for accidentally loading data twice Removed destroy as its not necessary and is messing with click events from a blur race condition Created AutocompleteSourcesController and updated routes Fixed @undefined from tabbing before load ends Disable tabSelectsMatch until we have loaded data Review changes --- .../javascripts/gfm_auto_complete.js.es6 | 233 +++++++++--------- app/assets/javascripts/gl_form.js | 2 +- app/assets/javascripts/issuable_form.js | 2 +- .../autocomplete_sources_controller.rb | 48 ++++ app/controllers/projects_controller.rb | 33 --- .../layouts/_init_auto_complete.html.haml | 14 +- .../18435-autocomplete-is-not-performant.yml | 4 + config/routes/project.rb | 13 +- .../participants_autocomplete_spec.rb | 7 +- .../projects/gfm_autocomplete_load_spec.rb | 4 +- spec/routing/project_routing_spec.rb | 19 +- 11 files changed, 210 insertions(+), 169 deletions(-) create mode 100644 app/controllers/projects/autocomplete_sources_controller.rb create mode 100644 changelogs/unreleased/18435-autocomplete-is-not-performant.yml diff --git a/app/assets/javascripts/gfm_auto_complete.js.es6 b/app/assets/javascripts/gfm_auto_complete.js.es6 index 245383438d1..dda061a556b 100644 --- a/app/assets/javascripts/gfm_auto_complete.js.es6 +++ b/app/assets/javascripts/gfm_auto_complete.js.es6 @@ -2,19 +2,28 @@ // Creates the variables for setting up GFM auto-completion (function() { - if (window.GitLab == null) { - window.GitLab = {}; + if (window.gl == null) { + window.gl = {}; } function sanitize(str) { return str.replace(/<(?:.|\n)*?>/gm, ''); } - window.GitLab.GfmAutoComplete = { - dataLoading: false, - dataLoaded: false, + window.gl.GfmAutoComplete = { + dataSources: {}, + defaultLoadingData: ['loading'], cachedData: {}, - dataSource: '', + isLoadingData: {}, + atTypeMap: { + ':': 'emojis', + '@': 'members', + '#': 'issues', + '!': 'mergeRequests', + '~': 'labels', + '%': 'milestones', + '/': 'commands' + }, // Emoji Emoji: { template: '
  • ${name} ${name}
  • ' @@ -35,33 +44,31 @@ template: '
  • ${title}
  • ' }, Loading: { - template: '
  • Loading...
  • ' + template: '
  • Loading...
  • ' }, DefaultOptions: { sorter: function(query, items, searchKey) { - // Highlight first item only if at least one char was typed - this.setting.highlightFirst = this.setting.alwaysHighlightFirst || query.length > 0; - if ((items[0].name != null) && items[0].name === 'loading') { + if (gl.GfmAutoComplete.isLoading(items)) { return items; } return $.fn.atwho["default"].callbacks.sorter(query, items, searchKey); }, filter: function(query, data, searchKey) { - if (data[0] === 'loading') { + if (gl.GfmAutoComplete.isLoading(data)) { + gl.GfmAutoComplete.togglePreventSelection.call(this, true); + gl.GfmAutoComplete.fetchData(this.$inputor, this.at); return data; + } else { + gl.GfmAutoComplete.togglePreventSelection.call(this, false); + return $.fn.atwho["default"].callbacks.filter(query, data, searchKey); } - return $.fn.atwho["default"].callbacks.filter(query, data, searchKey); }, beforeInsert: function(value) { if (value && !this.setting.skipSpecialCharacterTest) { var withoutAt = value.substring(1); if (withoutAt && /[^\w\d]/.test(withoutAt)) value = value.charAt() + '"' + withoutAt + '"'; } - if (!window.GitLab.GfmAutoComplete.dataLoaded) { - return this.at; - } else { - return value; - } + return value; }, matcher: function (flag, subtext) { // The below is taken from At.js source @@ -85,69 +92,46 @@ } } }, - setup: _.debounce(function(input) { + setup: function(input) { // Add GFM auto-completion to all input fields, that accept GFM input. this.input = input || $('.js-gfm-input'); - // destroy previous instances - this.destroyAtWho(); - // set up instances - this.setupAtWho(); - - if (this.dataSource && !this.dataLoading && !this.cachedData) { - this.dataLoading = true; - return this.fetchData(this.dataSource) - .done((data) => { - this.dataLoading = false; - this.loadData(data); - }); - }; - - if (this.cachedData != null) { - return this.loadData(this.cachedData); - } - }, 1000), - setupAtWho: function() { + this.setupLifecycle(); + }, + setupLifecycle() { + this.input.each((i, input) => { + const $input = $(input); + $input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input)); + }); + }, + setupAtWho: function($input) { // Emoji - this.input.atwho({ + $input.atwho({ at: ':', - displayTpl: (function(_this) { - return function(value) { - if (value.path != null) { - return _this.Emoji.template; - } else { - return _this.Loading.template; - } - }; - })(this), + displayTpl: function(value) { + return value.path != null ? this.Emoji.template : this.Loading.template; + }.bind(this), insertTpl: ':${name}:', - data: ['loading'], startWithSpace: false, skipSpecialCharacterTest: true, + data: this.defaultLoadingData, callbacks: { sorter: this.DefaultOptions.sorter, - filter: this.DefaultOptions.filter, beforeInsert: this.DefaultOptions.beforeInsert, - matcher: this.DefaultOptions.matcher + filter: this.DefaultOptions.filter } }); // Team Members - this.input.atwho({ + $input.atwho({ at: '@', - displayTpl: (function(_this) { - return function(value) { - if (value.username != null) { - return _this.Members.template; - } else { - return _this.Loading.template; - } - }; - })(this), + displayTpl: function(value) { + return value.username != null ? this.Members.template : this.Loading.template; + }.bind(this), insertTpl: '${atwho-at}${username}', searchKey: 'search', - data: ['loading'], startWithSpace: false, alwaysHighlightFirst: true, skipSpecialCharacterTest: true, + data: this.defaultLoadingData, callbacks: { sorter: this.DefaultOptions.sorter, filter: this.DefaultOptions.filter, @@ -178,20 +162,14 @@ } } }); - this.input.atwho({ + $input.atwho({ at: '#', alias: 'issues', searchKey: 'search', - displayTpl: (function(_this) { - return function(value) { - if (value.title != null) { - return _this.Issues.template; - } else { - return _this.Loading.template; - } - }; - })(this), - data: ['loading'], + displayTpl: function(value) { + return value.title != null ? this.Issues.template : this.Loading.template; + }.bind(this), + data: this.defaultLoadingData, insertTpl: '${atwho-at}${id}', startWithSpace: false, callbacks: { @@ -213,26 +191,21 @@ } } }); - this.input.atwho({ + $input.atwho({ at: '%', alias: 'milestones', searchKey: 'search', - displayTpl: (function(_this) { - return function(value) { - if (value.title != null) { - return _this.Milestones.template; - } else { - return _this.Loading.template; - } - }; - })(this), insertTpl: '${atwho-at}${title}', - data: ['loading'], + displayTpl: function(value) { + return value.title != null ? this.Milestones.template : this.Loading.template; + }.bind(this), startWithSpace: false, + data: this.defaultLoadingData, callbacks: { matcher: this.DefaultOptions.matcher, sorter: this.DefaultOptions.sorter, beforeInsert: this.DefaultOptions.beforeInsert, + filter: this.DefaultOptions.filter, beforeSave: function(milestones) { return $.map(milestones, function(m) { if (m.title == null) { @@ -247,21 +220,15 @@ } } }); - this.input.atwho({ + $input.atwho({ at: '!', alias: 'mergerequests', searchKey: 'search', - displayTpl: (function(_this) { - return function(value) { - if (value.title != null) { - return _this.Issues.template; - } else { - return _this.Loading.template; - } - }; - })(this), - data: ['loading'], startWithSpace: false, + displayTpl: function(value) { + return value.title != null ? this.Issues.template : this.Loading.template; + }.bind(this), + data: this.defaultLoadingData, insertTpl: '${atwho-at}${id}', callbacks: { sorter: this.DefaultOptions.sorter, @@ -282,18 +249,31 @@ } } }); - this.input.atwho({ + $input.atwho({ at: '~', alias: 'labels', searchKey: 'search', - displayTpl: this.Labels.template, + data: this.defaultLoadingData, + displayTpl: function(value) { + return this.isLoading(value) ? this.Loading.template : this.Labels.template; + }.bind(this), insertTpl: '${atwho-at}${title}', startWithSpace: false, callbacks: { matcher: this.DefaultOptions.matcher, sorter: this.DefaultOptions.sorter, beforeInsert: this.DefaultOptions.beforeInsert, + filter: this.DefaultOptions.filter, beforeSave: function(merges) { + if (gl.GfmAutoComplete.isLoading(merges)) return merges; + var sanitizeLabelTitle; + sanitizeLabelTitle = function(title) { + if (/[\w\?&]+\s+[\w\?&]+/g.test(title)) { + return "\"" + (sanitize(title)) + "\""; + } else { + return sanitize(title); + } + }; return $.map(merges, function(m) { return { title: sanitize(m.title), @@ -305,12 +285,14 @@ } }); // We don't instantiate the slash commands autocomplete for note and issue/MR edit forms - this.input.filter('[data-supports-slash-commands="true"]').atwho({ + $input.filter('[data-supports-slash-commands="true"]').atwho({ at: '/', alias: 'commands', searchKey: 'search', skipSpecialCharacterTest: true, + data: this.defaultLoadingData, displayTpl: function(value) { + if (this.isLoading(value)) return this.Loading.template; var tpl = '
  • /${name}'; if (value.aliases.length > 0) { tpl += ' (or /<%- aliases.join(", /") %>)'; @@ -323,7 +305,7 @@ } tpl += '
  • '; return _.template(tpl)(value); - }, + }.bind(this), insertTpl: function(value) { var tpl = "/${name} "; var reference_prefix = null; @@ -341,6 +323,7 @@ filter: this.DefaultOptions.filter, beforeInsert: this.DefaultOptions.beforeInsert, beforeSave: function(commands) { + if (gl.GfmAutoComplete.isLoading(commands)) return commands; return $.map(commands, function(c) { var search = c.name; if (c.aliases.length > 0) { @@ -368,32 +351,40 @@ }); return; }, - destroyAtWho: function() { - return this.input.atwho('destroy'); + fetchData: function($input, at) { + if (this.isLoadingData[at]) return; + this.isLoadingData[at] = true; + if (this.cachedData[at]) { + this.loadData($input, at, this.cachedData[at]); + } else { + $.getJSON(this.dataSources[this.atTypeMap[at]], (data) => { + this.loadData($input, at, data); + }).fail(() => { this.isLoadingData[at] = false; }); + } }, - fetchData: function(dataSource) { - return $.getJSON(dataSource); - }, - loadData: function(data) { - this.cachedData = data; - this.dataLoaded = true; - // load members - this.input.atwho('load', '@', data.members); - // load issues - this.input.atwho('load', 'issues', data.issues); - // load milestones - this.input.atwho('load', 'milestones', data.milestones); - // load merge requests - this.input.atwho('load', 'mergerequests', data.mergerequests); - // load emojis - this.input.atwho('load', ':', data.emojis); - // load labels - this.input.atwho('load', '~', data.labels); - // load commands - this.input.atwho('load', '/', data.commands); + loadData: function($input, at, data) { + this.isLoadingData[at] = false; + this.cachedData[at] = data; + $input.atwho('load', at, data); // This trigger at.js again // otherwise we would be stuck with loading until the user types - return $(':focus').trigger('keyup'); + return $input.trigger('keyup'); + }, + isLoading(data) { + if (!data) return false; + if (Array.isArray(data)) data = data[0]; + return data === this.defaultLoadingData[0] || data.name === this.defaultLoadingData[0]; + }, + togglePreventSelection(isPrevented = !!this.setting.tabSelectsMatch) { + this.setting.tabSelectsMatch = !isPrevented; + this.setting.spaceSelectsMatch = !isPrevented; + const eventListenerAction = `${isPrevented ? 'add' : 'remove'}EventListener`; + this.$inputor[0][eventListenerAction]('keydown', gl.GfmAutoComplete.preventSpaceTabEnter); + }, + preventSpaceTabEnter(e) { + const key = e.which || e.keyCode; + const preventables = [9, 13, 32]; + if (preventables.indexOf(key) > -1) e.preventDefault(); } }; diff --git a/app/assets/javascripts/gl_form.js b/app/assets/javascripts/gl_form.js index 56a33eeaad5..7dc2d13e5d8 100644 --- a/app/assets/javascripts/gl_form.js +++ b/app/assets/javascripts/gl_form.js @@ -30,7 +30,7 @@ this.form.addClass('gfm-form'); // remove notify commit author checkbox for non-commit notes gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); - GitLab.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); + gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); new DropzoneInput(this.form); autosize(this.textarea); // form and textarea event listeners diff --git a/app/assets/javascripts/issuable_form.js b/app/assets/javascripts/issuable_form.js index 2f3cad13cc0..1c4086517fe 100644 --- a/app/assets/javascripts/issuable_form.js +++ b/app/assets/javascripts/issuable_form.js @@ -19,7 +19,7 @@ this.renderWipExplanation = bind(this.renderWipExplanation, this); this.resetAutosave = bind(this.resetAutosave, this); this.handleSubmit = bind(this.handleSubmit, this); - GitLab.GfmAutoComplete.setup(); + gl.GfmAutoComplete.setup(); new UsersSelect(); new ZenMode(); this.titleField = this.form.find("input[name*='[title]']"); diff --git a/app/controllers/projects/autocomplete_sources_controller.rb b/app/controllers/projects/autocomplete_sources_controller.rb new file mode 100644 index 00000000000..d9dfa534669 --- /dev/null +++ b/app/controllers/projects/autocomplete_sources_controller.rb @@ -0,0 +1,48 @@ +class Projects::AutocompleteSourcesController < Projects::ApplicationController + before_action :load_autocomplete_service, except: [:emojis, :members] + + def emojis + render json: Gitlab::AwardEmoji.urls + end + + def members + render json: ::Projects::ParticipantsService.new(@project, current_user).execute(noteable) + end + + def issues + render json: @autocomplete_service.issues + end + + def merge_requests + render json: @autocomplete_service.merge_requests + end + + def labels + render json: @autocomplete_service.labels + end + + def milestones + render json: @autocomplete_service.milestones + end + + def commands + render json: @autocomplete_service.commands(noteable, params[:type]) + end + + private + + def load_autocomplete_service + @autocomplete_service = ::Projects::AutocompleteService.new(@project, current_user) + end + + def noteable + case params[:type] + when 'Issue' + IssuesFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id]) + when 'MergeRequest' + MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id]) + when 'Commit' + @project.commit(params[:type_id]) + end + end +end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index a8a18b4fa16..d5ee503c44c 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -127,39 +127,6 @@ class ProjectsController < Projects::ApplicationController redirect_to edit_project_path(@project), alert: ex.message end - def autocomplete_sources - noteable = - case params[:type] - when 'Issue' - IssuesFinder.new(current_user, project_id: @project.id). - execute.find_by(iid: params[:type_id]) - when 'MergeRequest' - MergeRequestsFinder.new(current_user, project_id: @project.id). - execute.find_by(iid: params[:type_id]) - when 'Commit' - @project.commit(params[:type_id]) - else - nil - end - - autocomplete = ::Projects::AutocompleteService.new(@project, current_user) - participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable) - - @suggestions = { - emojis: Gitlab::AwardEmoji.urls, - issues: autocomplete.issues, - milestones: autocomplete.milestones, - mergerequests: autocomplete.merge_requests, - labels: autocomplete.labels, - members: participants, - commands: autocomplete.commands(noteable, params[:type]) - } - - respond_to do |format| - format.json { render json: @suggestions } - end - end - def new_issue_address return render_404 unless Gitlab::IncomingEmail.supports_issue_creation? diff --git a/app/views/layouts/_init_auto_complete.html.haml b/app/views/layouts/_init_auto_complete.html.haml index e138ebab018..3daa1e90a8c 100644 --- a/app/views/layouts/_init_auto_complete.html.haml +++ b/app/views/layouts/_init_auto_complete.html.haml @@ -3,6 +3,14 @@ - if project :javascript - GitLab.GfmAutoComplete.dataSource = "#{autocomplete_sources_namespace_project_path(project.namespace, project, type: noteable_type, type_id: params[:id])}" - GitLab.GfmAutoComplete.cachedData = undefined; - GitLab.GfmAutoComplete.setup(); + gl.GfmAutoComplete.dataSources = { + emojis: "#{emojis_namespace_project_autocomplete_sources_path(project.namespace, project)}", + members: "#{members_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}", + issues: "#{issues_namespace_project_autocomplete_sources_path(project.namespace, project)}", + mergeRequests: "#{merge_requests_namespace_project_autocomplete_sources_path(project.namespace, project)}", + labels: "#{labels_namespace_project_autocomplete_sources_path(project.namespace, project)}", + milestones: "#{milestones_namespace_project_autocomplete_sources_path(project.namespace, project)}", + commands: "#{commands_namespace_project_autocomplete_sources_path(project.namespace, project, type: noteable_type, type_id: params[:id])}" + }; + + gl.GfmAutoComplete.setup(); diff --git a/changelogs/unreleased/18435-autocomplete-is-not-performant.yml b/changelogs/unreleased/18435-autocomplete-is-not-performant.yml new file mode 100644 index 00000000000..019c55e27dc --- /dev/null +++ b/changelogs/unreleased/18435-autocomplete-is-not-performant.yml @@ -0,0 +1,4 @@ +--- +title: Made comment autocomplete more performant and removed some loading bugs +merge_request: 6856 +author: diff --git a/config/routes/project.rb b/config/routes/project.rb index 0754f0ec3b0..e17d6bae10c 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -11,6 +11,18 @@ constraints(ProjectUrlConstrainer.new) do module: :projects, as: :project) do + resources :autocomplete_sources, only: [] do + collection do + get 'emojis' + get 'members' + get 'issues' + get 'merge_requests' + get 'labels' + get 'milestones' + get 'commands' + end + end + # # Templates # @@ -316,7 +328,6 @@ constraints(ProjectUrlConstrainer.new) do post :remove_export post :generate_new_export get :download_export - get :autocomplete_sources get :activity get :refs put :new_issue_address diff --git a/spec/features/participants_autocomplete_spec.rb b/spec/features/participants_autocomplete_spec.rb index a78a1c9c890..c2545b0c259 100644 --- a/spec/features/participants_autocomplete_spec.rb +++ b/spec/features/participants_autocomplete_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' feature 'Member autocomplete', feature: true do + include WaitForAjax + let(:project) { create(:project, :public) } let(:user) { create(:user) } let(:participant) { create(:user) } @@ -79,11 +81,10 @@ feature 'Member autocomplete', feature: true do end def open_member_suggestions - sleep 1 page.within('.new-note') do - sleep 1 - find('#note_note').native.send_keys('@') + find('#note_note').send_keys('@') end + wait_for_ajax end def visit_issue(project, issue) diff --git a/spec/features/projects/gfm_autocomplete_load_spec.rb b/spec/features/projects/gfm_autocomplete_load_spec.rb index 1921ea6d8ae..dd9622f16a0 100644 --- a/spec/features/projects/gfm_autocomplete_load_spec.rb +++ b/spec/features/projects/gfm_autocomplete_load_spec.rb @@ -10,12 +10,12 @@ describe 'GFM autocomplete loading', feature: true, js: true do end it 'does not load on project#show' do - expect(evaluate_script('GitLab.GfmAutoComplete.dataSource')).to eq('') + expect(evaluate_script('gl.GfmAutoComplete.dataSources')).to eq({}) end it 'loads on new issue page' do visit new_namespace_project_issue_path(project.namespace, project) - expect(evaluate_script('GitLab.GfmAutoComplete.dataSource')).not_to eq('') + expect(evaluate_script('gl.GfmAutoComplete.dataSources')).not_to eq({}) end end diff --git a/spec/routing/project_routing_spec.rb b/spec/routing/project_routing_spec.rb index b6e7da841b1..77549db2927 100644 --- a/spec/routing/project_routing_spec.rb +++ b/spec/routing/project_routing_spec.rb @@ -80,10 +80,6 @@ describe 'project routing' do expect(get('/gitlab/gitlabhq/edit')).to route_to('projects#edit', namespace_id: 'gitlab', id: 'gitlabhq') end - it 'to #autocomplete_sources' do - expect(get('/gitlab/gitlabhq/autocomplete_sources')).to route_to('projects#autocomplete_sources', namespace_id: 'gitlab', id: 'gitlabhq') - end - describe 'to #show' do context 'regular name' do it { expect(get('/gitlab/gitlabhq')).to route_to('projects#show', namespace_id: 'gitlab', id: 'gitlabhq') } @@ -117,6 +113,21 @@ describe 'project routing' do end end + # emojis_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/emojis(.:format) projects/autocomplete_sources#emojis + # members_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/members(.:format) projects/autocomplete_sources#members + # issues_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/issues(.:format) projects/autocomplete_sources#issues + # merge_requests_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/merge_requests(.:format) projects/autocomplete_sources#merge_requests + # labels_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/labels(.:format) projects/autocomplete_sources#labels + # milestones_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/milestones(.:format) projects/autocomplete_sources#milestones + # commands_namespace_project_autocomplete_sources_path GET /:project_id/autocomplete_sources/commands(.:format) projects/autocomplete_sources#commands + describe Projects::AutocompleteSourcesController, 'routing' do + [:emojis, :members, :issues, :merge_requests, :labels, :milestones, :commands].each do |action| + it "to ##{action}" do + expect(get("/gitlab/gitlabhq/autocomplete_sources/#{action}")).to route_to("projects/autocomplete_sources##{action}", namespace_id: 'gitlab', project_id: 'gitlabhq') + end + end + end + # pages_project_wikis GET /:project_id/wikis/pages(.:format) projects/wikis#pages # history_project_wiki GET /:project_id/wikis/:id/history(.:format) projects/wikis#history # project_wikis POST /:project_id/wikis(.:format) projects/wikis#create From 9a2eaecc8b720367a7d079019b22f40627a871b1 Mon Sep 17 00:00:00 2001 From: "Luke \"Jared\" Bennett" Date: Wed, 14 Dec 2016 13:30:08 +0000 Subject: [PATCH 071/175] Correct slack slash commands pretty path --- .../services/mattermost_slash_commands/_help.html.haml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/app/views/projects/services/mattermost_slash_commands/_help.html.haml b/app/views/projects/services/mattermost_slash_commands/_help.html.haml index a676c0290a0..01a77a952d1 100644 --- a/app/views/projects/services/mattermost_slash_commands/_help.html.haml +++ b/app/views/projects/services/mattermost_slash_commands/_help.html.haml @@ -1,5 +1,4 @@ -- pretty_path_with_namespace = "#{@project ? @project.namespace.name : 'namespace'} / #{@project ? @project.name : 'name'}" -- run_actions_text = "Perform common operations on this project: #{pretty_path_with_namespace}" +- run_actions_text = "Perform common operations on this project: #{@project.name_with_namespace}" .well This service allows GitLab users to perform common operations on this @@ -27,7 +26,7 @@ .form-group = label_tag :display_name, 'Display name', class: 'col-sm-2 col-xs-12 control-label' .col-sm-10.col-xs-12.input-group - = text_field_tag :display_name, "GitLab / #{pretty_path_with_namespace}", class: 'form-control input-sm', readonly: 'readonly' + = text_field_tag :display_name, "GitLab / #{@project.name_with_namespace}", class: 'form-control input-sm', readonly: 'readonly' .input-group-btn = clipboard_button(clipboard_target: '#display_name') From ffa35233573acd31725677547555598fc36072e0 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 16 Dec 2016 10:38:23 +0200 Subject: [PATCH 072/175] BB importer: Added note about linking BB users and GitLab users[ci skip] --- doc/workflow/importing/import_projects_from_bitbucket.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/doc/workflow/importing/import_projects_from_bitbucket.md b/doc/workflow/importing/import_projects_from_bitbucket.md index 520c4216295..935d6288f3b 100644 --- a/doc/workflow/importing/import_projects_from_bitbucket.md +++ b/doc/workflow/importing/import_projects_from_bitbucket.md @@ -20,7 +20,8 @@ It takes just a few steps to import your existing Bitbucket projects to GitLab. ![Import projects](bitbucket_importer/bitbucket_import_select_project.png) -A new GitLab project will be created with your imported data. +A new GitLab project will be created with your imported data. Keep in mind that if you want to Bitbucket users +to be linked to GitLab user you have to have all of them in GitLab in advance. They will be matched by their BitBucket username. ### Note Milestones and wiki pages are not imported from Bitbucket. From e52e3ab5082599fd5a895de961b07584421a5cd2 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Wed, 23 Nov 2016 21:03:53 +1000 Subject: [PATCH 073/175] Remove whole description from #merge_commit_message and add add closed issues --- app/models/merge_request.rb | 3 ++- spec/models/merge_request_spec.rb | 14 ++++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index b73d7acefea..62dd02936e2 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -615,7 +615,8 @@ class MergeRequest < ActiveRecord::Base def merge_commit_message message = "Merge branch '#{source_branch}' into '#{target_branch}'\n\n" message << "#{title}\n\n" - message << "#{description}\n\n" if description.present? + mr_closes_issues = closes_issues + message << "Closed Issues: #{mr_closes_issues.map { |issue| issue.to_reference(target_project) }.join(", ")}\n\n" if mr_closes_issues.present? message << "See merge request #{to_reference}" message diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 1b71d00eb8f..e1f9d66714d 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -410,11 +410,17 @@ describe MergeRequest, models: true do .to match("Remove all technical debt\n\n") end - it 'includes its description in the body' do - request = build(:merge_request, description: 'By removing all code') + it 'includes its closed issues in the body' do + issue = create(:issue, project: subject.project) - expect(request.merge_commit_message) - .to match("By removing all code\n\n") + subject.project.team << [subject.author, :developer] + subject.description = "Closes #{issue.to_reference}" + + allow(subject.project).to receive(:default_branch). + and_return(subject.target_branch) + + expect(subject.merge_commit_message) + .to match("Closed Issues: #{issue.to_reference}") end it 'includes its reference in the body' do From 1a8f43ff3e5481d65f547ac01c03e2d64e14fff0 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Thu, 24 Nov 2016 07:50:49 +1000 Subject: [PATCH 074/175] introduce MergeRequest#issues_mentioned_but_not_closing --- app/models/merge_request.rb | 16 +++++++++ spec/models/merge_request_spec.rb | 57 +++++++++++++++++++------------ 2 files changed, 52 insertions(+), 21 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 62dd02936e2..6f660bf0e77 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -568,6 +568,22 @@ class MergeRequest < ActiveRecord::Base end end + def issues_mentioned_but_not_closing(current_user = self.author) + issues = [] + + if target_branch == project.default_branch + messages = [description] + messages.concat(commits.map(&:safe_message)) if merge_request_diff + + ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze(messages.join("\n")) + + issues = ext.issues + end + + issues - closes_issues + end + def target_project_path if target_project target_project.path_with_namespace diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index e1f9d66714d..688c78fb404 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -252,7 +252,7 @@ describe MergeRequest, models: true do end end - describe 'detection of issues to be closed' do + describe 'detection of issues' do let(:issue0) { create :issue, project: subject.project } let(:issue1) { create :issue, project: subject.project } @@ -265,29 +265,44 @@ describe MergeRequest, models: true do allow(subject).to receive(:commits).and_return([commit0, commit1, commit2]) end - it 'accesses the set of issues that will be closed on acceptance' do + describe 'detection of issues to be closed' do + it 'accesses the set of issues that will be closed on acceptance' do + allow(subject.project).to receive(:default_branch). + and_return(subject.target_branch) + + closed = subject.closes_issues + + expect(closed).to include(issue0, issue1) + end + + it 'only lists issues as to be closed if it targets the default branch' do + allow(subject.project).to receive(:default_branch).and_return('master') + subject.target_branch = 'something-else' + + expect(subject.closes_issues).to be_empty + end + + it 'detects issues mentioned in the description' do + issue2 = create(:issue, project: subject.project) + + subject.description = "Closes #{issue2.to_reference}" + + allow(subject.project).to receive(:default_branch). + and_return(subject.target_branch) + + expect(subject.closes_issues).to include(issue2) + end + end + + it 'detects issues mentioned but not closed' do + mentioned_issue = create(:issue, project: subject.project) + + subject.description = "Is related to #{mentioned_issue.to_reference}" + allow(subject.project).to receive(:default_branch). and_return(subject.target_branch) - closed = subject.closes_issues - - expect(closed).to include(issue0, issue1) - end - - it 'only lists issues as to be closed if it targets the default branch' do - allow(subject.project).to receive(:default_branch).and_return('master') - subject.target_branch = 'something-else' - - expect(subject.closes_issues).to be_empty - end - - it 'detects issues mentioned in the description' do - issue2 = create(:issue, project: subject.project) - subject.description = "Closes #{issue2.to_reference}" - allow(subject.project).to receive(:default_branch). - and_return(subject.target_branch) - - expect(subject.closes_issues).to include(issue2) + expect(subject.issues_mentioned_but_not_closing).to match_array([mentioned_issue]) end end From 80915c35f48463c3a983129961f3130bd9703754 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Thu, 24 Nov 2016 19:21:27 +1000 Subject: [PATCH 075/175] diplays mentioned but not merged message on MR show page --- app/helpers/merge_requests_helper.rb | 11 +++++++++++ .../projects/merge_requests/widget/_open.html.haml | 9 +++++++++ 2 files changed, 20 insertions(+) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index a6659ea2fd1..dcd35dc6282 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -59,6 +59,17 @@ module MergeRequestsHelper @mr_closes_issues ||= @merge_request.closes_issues end + def mr_issues_mentioned_but_not_closing + @mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing + end + + def mr_issues_mentioned_but_not_closing_message + verb = mr_issues_mentioned_but_not_closing.size > 1 ? 'are' : 'is' + issue_text = 'issue'.pluralize(mr_issues_mentioned_but_not_closing.size) + + "The following #{issue_text} #{verb} mentioned but not being closed:" + end + def mr_change_branches_path(merge_request) new_namespace_project_merge_request_path( @project.namespace, @project, diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index eee711dc5af..1f794cad663 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -30,6 +30,15 @@ - elsif @merge_request.can_be_merged? || resolved_conflicts = render 'projects/merge_requests/widget/open/accept' + - if mr_issues_mentioned_but_not_closing.present? + .mr-widget-footer + %span + %i.fa.fa-warning + = mr_issues_mentioned_but_not_closing_message + = succeed '.' do + != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author + = mr_assign_issues_link + - if mr_closes_issues.present? .mr-widget-footer %span From c1515cd865ec11110f9b34c41a41747dcbc3b402 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Fri, 25 Nov 2016 06:46:16 +1000 Subject: [PATCH 076/175] better mentioned but not closing message and icon --- app/helpers/merge_requests_helper.rb | 7 ------- app/views/projects/merge_requests/widget/_open.html.haml | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index dcd35dc6282..20218775659 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -63,13 +63,6 @@ module MergeRequestsHelper @mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing end - def mr_issues_mentioned_but_not_closing_message - verb = mr_issues_mentioned_but_not_closing.size > 1 ? 'are' : 'is' - issue_text = 'issue'.pluralize(mr_issues_mentioned_but_not_closing.size) - - "The following #{issue_text} #{verb} mentioned but not being closed:" - end - def mr_change_branches_path(merge_request) new_namespace_project_merge_request_path( @project.namespace, @project, diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index 1f794cad663..ebea48a4321 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -33,8 +33,8 @@ - if mr_issues_mentioned_but_not_closing.present? .mr-widget-footer %span - %i.fa.fa-warning - = mr_issues_mentioned_but_not_closing_message + %i.fa.fa-info-circle + #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)} mentioned but not being closed: = succeed '.' do != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author = mr_assign_issues_link From 0e76daf3da35920d10053dc4d8707e1b6aa7c913 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Fri, 25 Nov 2016 06:49:56 +1000 Subject: [PATCH 077/175] only look for issues mentioned on description on MergeRequest#issues_mentioned_but_not_closing --- app/models/merge_request.rb | 6 ++++-- spec/models/merge_request_spec.rb | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 6f660bf0e77..b8c139d01e2 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -570,18 +570,20 @@ class MergeRequest < ActiveRecord::Base def issues_mentioned_but_not_closing(current_user = self.author) issues = [] + closing_issues = [] if target_branch == project.default_branch messages = [description] - messages.concat(commits.map(&:safe_message)) if merge_request_diff ext = Gitlab::ReferenceExtractor.new(project, current_user) ext.analyze(messages.join("\n")) issues = ext.issues + closing_issues = Gitlab::ClosingIssueExtractor.new(project, current_user). + closed_by_message(messages.join("\n")) end - issues - closes_issues + issues - closing_issues end def target_project_path diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 688c78fb404..b2c26874552 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -294,7 +294,7 @@ describe MergeRequest, models: true do end end - it 'detects issues mentioned but not closed' do + it 'detects issues mentioned in description but not closed' do mentioned_issue = create(:issue, project: subject.project) subject.description = "Is related to #{mentioned_issue.to_reference}" From b4764a8dd22c29b7edc3065af9a99713aa5708d3 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Fri, 25 Nov 2016 07:01:05 +1000 Subject: [PATCH 078/175] shorter lines on MergeRequest#merge_commit_message --- app/models/merge_request.rb | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index b8c139d01e2..5a5b8bd6010 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -631,10 +631,17 @@ class MergeRequest < ActiveRecord::Base end def merge_commit_message + closes_issues_references = closes_issues.map do |issue| + issue.to_reference(target_project) + end + message = "Merge branch '#{source_branch}' into '#{target_branch}'\n\n" message << "#{title}\n\n" - mr_closes_issues = closes_issues - message << "Closed Issues: #{mr_closes_issues.map { |issue| issue.to_reference(target_project) }.join(", ")}\n\n" if mr_closes_issues.present? + + if closes_issues_references.present? + message << "Closed Issues: #{closes_issues_references.join(", ")}\n\n" + end + message << "See merge request #{to_reference}" message From 00a842eacc4b2ff1514b258fbf08e4017f3be447 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Fri, 25 Nov 2016 19:49:56 +1000 Subject: [PATCH 079/175] Add toggle links for using default message and description on change merge commit message container --- .../widget/open/_accept.html.haml | 1 + .../_commit_message_container.html.haml | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml index 435fe835fae..66096ff7476 100644 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ b/app/views/projects/merge_requests/widget/open/_accept.html.haml @@ -41,6 +41,7 @@ Modify commit message .js-toggle-content.hide.prepend-top-default = render 'shared/commit_message_container', params: params, + description: @merge_request.description, text: @merge_request.merge_commit_message, rows: 14, hint: true diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index 0a38327baa2..adee374413f 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -14,3 +14,31 @@ %p.hint Try to keep the first line under 52 characters and the others under 72. + - if local_assigns[:description] + %p.hint.use-description-hint + = link_to "#", class: "use-description-link" do + Use Merge Request description as merge commit message + %p.hint.use-default-message-hint.hide + = link_to "#", class: "use-default-message-link" do + Use default Gitlab merge commit message + + + :javascript + $('.use-description-link').on('click', function(e) { + e.preventDefault(); + + $('.use-description-hint').hide(); + $('.use-default-message-hint').show(); + $('.js-commit-message').val("#{escape_javascript local_assigns[:description]}"); + }); + + $('.use-default-message-link').on('click', function(e) { + e.preventDefault(); + + var defaultMessage = "#{escape_javascript (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder])}"; + + $('.use-description-hint').show(); + $('.use-default-message-hint').hide(); + $('.js-commit-message').val(defaultMessage); + }); + From 1a72dc2486601eadec03122f8124d3b553df3571 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Sun, 27 Nov 2016 20:40:56 +1000 Subject: [PATCH 080/175] keep branch being merged, MR title and MR reference in merge commit message when using description --- app/views/shared/_commit_message_container.html.haml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index adee374413f..706eef5a331 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -27,15 +27,21 @@ $('.use-description-link').on('click', function(e) { e.preventDefault(); + var message = "Merge branch '#{j @merge_request.source_branch}' into '#{j @merge_request.target_branch}'\n\n" + message = message + "#{j @merge_request.title}\n\n" + message = message + "#{j local_assigns[:description]}\n\n"; + message = message + "See merge request #{j @merge_request.to_reference}" + + $('.use-description-hint').hide(); $('.use-default-message-hint').show(); - $('.js-commit-message').val("#{escape_javascript local_assigns[:description]}"); + $('.js-commit-message').val(message) }); $('.use-default-message-link').on('click', function(e) { e.preventDefault(); - var defaultMessage = "#{escape_javascript (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder])}"; + var defaultMessage = "#{j (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder])}"; $('.use-description-hint').show(); $('.use-default-message-hint').hide(); From d1980ef9c8c059fb9d4be1a8339dea05e9a442f1 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 28 Nov 2016 07:37:57 +1000 Subject: [PATCH 081/175] only render MR description toggle javascript if description is available --- .../_commit_message_container.html.haml | 39 +++++++++---------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index 706eef5a331..a151731ba0a 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -14,7 +14,7 @@ %p.hint Try to keep the first line under 52 characters and the others under 72. - - if local_assigns[:description] + - if local_assigns[:description] %p.hint.use-description-hint = link_to "#", class: "use-description-link" do Use Merge Request description as merge commit message @@ -22,29 +22,28 @@ = link_to "#", class: "use-default-message-link" do Use default Gitlab merge commit message + :javascript + $('.use-description-link').on('click', function(e) { + e.preventDefault(); - :javascript - $('.use-description-link').on('click', function(e) { - e.preventDefault(); - - var message = "Merge branch '#{j @merge_request.source_branch}' into '#{j @merge_request.target_branch}'\n\n" - message = message + "#{j @merge_request.title}\n\n" - message = message + "#{j local_assigns[:description]}\n\n"; - message = message + "See merge request #{j @merge_request.to_reference}" + var message = "Merge branch '#{j @merge_request.source_branch}' into '#{j @merge_request.target_branch}'\n\n" + message = message + "#{j @merge_request.title}\n\n" + message = message + "#{j local_assigns[:description]}\n\n"; + message = message + "See merge request #{j @merge_request.to_reference}" - $('.use-description-hint').hide(); - $('.use-default-message-hint').show(); - $('.js-commit-message').val(message) - }); + $('.use-description-hint').hide(); + $('.use-default-message-hint').show(); + $('.js-commit-message').val(message) + }); - $('.use-default-message-link').on('click', function(e) { - e.preventDefault(); + $('.use-default-message-link').on('click', function(e) { + e.preventDefault(); - var defaultMessage = "#{j (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder])}"; + var defaultMessage = "#{j (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder])}"; - $('.use-description-hint').show(); - $('.use-default-message-hint').hide(); - $('.js-commit-message').val(defaultMessage); - }); + $('.use-description-hint').show(); + $('.use-default-message-hint').hide(); + $('.js-commit-message').val(defaultMessage); + }); From 78f221d12e28b6ea10f8fbc7f83fa39caaad05d0 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 28 Nov 2016 19:02:37 +1000 Subject: [PATCH 082/175] describe #closes_issues and describe # #issues_mentioned_but_not_closing on merge_request_spec.rb --- spec/models/merge_request_spec.rb | 42 ++++++++++++------------------- 1 file changed, 16 insertions(+), 26 deletions(-) diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index b2c26874552..9ca60e27900 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -252,7 +252,7 @@ describe MergeRequest, models: true do end end - describe 'detection of issues' do + describe '#closes_issues' do let(:issue0) { create :issue, project: subject.project } let(:issue1) { create :issue, project: subject.project } @@ -265,38 +265,28 @@ describe MergeRequest, models: true do allow(subject).to receive(:commits).and_return([commit0, commit1, commit2]) end - describe 'detection of issues to be closed' do - it 'accesses the set of issues that will be closed on acceptance' do - allow(subject.project).to receive(:default_branch). - and_return(subject.target_branch) + it 'accesses the set of issues that will be closed on acceptance' do + allow(subject.project).to receive(:default_branch). + and_return(subject.target_branch) - closed = subject.closes_issues + closed = subject.closes_issues - expect(closed).to include(issue0, issue1) - end - - it 'only lists issues as to be closed if it targets the default branch' do - allow(subject.project).to receive(:default_branch).and_return('master') - subject.target_branch = 'something-else' - - expect(subject.closes_issues).to be_empty - end - - it 'detects issues mentioned in the description' do - issue2 = create(:issue, project: subject.project) - - subject.description = "Closes #{issue2.to_reference}" - - allow(subject.project).to receive(:default_branch). - and_return(subject.target_branch) - - expect(subject.closes_issues).to include(issue2) - end + expect(closed).to include(issue0, issue1) end + it 'only lists issues as to be closed if it targets the default branch' do + allow(subject.project).to receive(:default_branch).and_return('master') + subject.target_branch = 'something-else' + + expect(subject.closes_issues).to be_empty + end + end + + describe '#issues_mentioned_but_not_closing' do it 'detects issues mentioned in description but not closed' do mentioned_issue = create(:issue, project: subject.project) + subject.project.team << [subject.author, :developer] subject.description = "Is related to #{mentioned_issue.to_reference}" allow(subject.project).to receive(:default_branch). From 512c870ed46b5e441fd0b8daa8bd9cab449f7ac0 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 28 Nov 2016 19:06:18 +1000 Subject: [PATCH 083/175] Remove unnecessary code from MergeRequest#issues_mentioned_but_not_closing --- app/models/merge_request.rb | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 5a5b8bd6010..dba0c463fd6 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -573,14 +573,12 @@ class MergeRequest < ActiveRecord::Base closing_issues = [] if target_branch == project.default_branch - messages = [description] - ext = Gitlab::ReferenceExtractor.new(project, current_user) - ext.analyze(messages.join("\n")) + ext.analyze(description) issues = ext.issues closing_issues = Gitlab::ClosingIssueExtractor.new(project, current_user). - closed_by_message(messages.join("\n")) + closed_by_message(description) end issues - closing_issues From 58609f842e1344579ed14745bb6bcb365059166f Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 28 Nov 2016 19:48:55 +1000 Subject: [PATCH 084/175] backend completely drives creation of merge commit message --- app/models/merge_request.rb | 3 +- .../merge_requests/widget/_open.html.haml | 3 +- .../widget/open/_accept.html.haml | 3 +- .../_commit_message_container.html.haml | 40 +++++++++---------- spec/models/merge_request_spec.rb | 14 +++++++ 5 files changed, 37 insertions(+), 26 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index dba0c463fd6..2d7be2c2c7e 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -628,7 +628,7 @@ class MergeRequest < ActiveRecord::Base self.target_project.repository.branch_names.include?(self.target_branch) end - def merge_commit_message + def merge_commit_message(include_description: false) closes_issues_references = closes_issues.map do |issue| issue.to_reference(target_project) end @@ -640,6 +640,7 @@ class MergeRequest < ActiveRecord::Base message << "Closed Issues: #{closes_issues_references.join(", ")}\n\n" end + message << "#{description}\n\n" if include_description && description.present? message << "See merge request #{to_reference}" message diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index ebea48a4321..bf1e49c98ce 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -37,12 +37,11 @@ #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)} mentioned but not being closed: = succeed '.' do != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author - = mr_assign_issues_link - if mr_closes_issues.present? .mr-widget-footer %span - %i.fa.fa-check + = icon('check') Accepting this merge request will close #{"issue".pluralize(mr_closes_issues.size)} = succeed '.' do != markdown issues_sentence(mr_closes_issues), pipeline: :gfm, author: @merge_request.author diff --git a/app/views/projects/merge_requests/widget/open/_accept.html.haml b/app/views/projects/merge_requests/widget/open/_accept.html.haml index 66096ff7476..d6f7f23533c 100644 --- a/app/views/projects/merge_requests/widget/open/_accept.html.haml +++ b/app/views/projects/merge_requests/widget/open/_accept.html.haml @@ -41,7 +41,8 @@ Modify commit message .js-toggle-content.hide.prepend-top-default = render 'shared/commit_message_container', params: params, - description: @merge_request.description, + message_with_description: @merge_request.merge_commit_message(include_description: true), + message_without_description: @merge_request.merge_commit_message, text: @merge_request.merge_commit_message, rows: 14, hint: true diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index a151731ba0a..3e0186983e4 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -8,42 +8,38 @@ = text_area_tag 'commit_message', (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder]), class: 'form-control js-commit-message', placeholder: local_assigns[:placeholder], + data: local_assigns.slice(:message_with_description, :message_without_description), required: true, rows: (local_assigns[:rows] || 3), id: "commit_message-#{nonce}" - if local_assigns[:hint] %p.hint Try to keep the first line under 52 characters and the others under 72. - - if local_assigns[:description] - %p.hint.use-description-hint - = link_to "#", class: "use-description-link" do - Use Merge Request description as merge commit message - %p.hint.use-default-message-hint.hide - = link_to "#", class: "use-default-message-link" do - Use default Gitlab merge commit message + -if local_assigns.slice(:message_with_description, :message_without_description).present? + %p.hint.with-description-hint + = link_to "#", class: "with-description-link" do + Include description in commit message + %p.hint.without-description-hint.hide + = link_to "#", class: "without-description-link" do + Don't include description in commit message :javascript - $('.use-description-link').on('click', function(e) { + $('.with-description-link').on('click', function(e) { e.preventDefault(); - var message = "Merge branch '#{j @merge_request.source_branch}' into '#{j @merge_request.target_branch}'\n\n" - message = message + "#{j @merge_request.title}\n\n" - message = message + "#{j local_assigns[:description]}\n\n"; - message = message + "See merge request #{j @merge_request.to_reference}" + var textarea = $('.js-commit-message') - - $('.use-description-hint').hide(); - $('.use-default-message-hint').show(); - $('.js-commit-message').val(message) + textarea.val(textarea.data('messageWithDescription')) + $('.with-description-hint').hide(); + $('.without-description-hint').show(); }); - $('.use-default-message-link').on('click', function(e) { + $('.without-description-link').on('click', function(e) { e.preventDefault(); - var defaultMessage = "#{j (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder])}"; + var textarea = $('.js-commit-message') - $('.use-description-hint').show(); - $('.use-default-message-hint').hide(); - $('.js-commit-message').val(defaultMessage); + textarea.val(textarea.data('messageWithoutDescription')) + $('.with-description-hint').show(); + $('.without-description-hint').hide(); }); - diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 9ca60e27900..f74c89bba4d 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -440,6 +440,20 @@ describe MergeRequest, models: true do expect(request.merge_commit_message).not_to match("Title\n\n\n\n") end + + it 'includes its description in the body' do + request = build(:merge_request, description: 'By removing all code') + + expect(request.merge_commit_message(include_description: true)) + .to match("By removing all code\n\n") + end + + it 'does not includes its description in the body' do + request = build(:merge_request, description: 'By removing all code') + + expect(request.merge_commit_message) + .not_to match("By removing all code\n\n") + end end describe "#reset_merge_when_build_succeeds" do From e97c7100aed6fb4ca072c80a78b95d5f51805197 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 28 Nov 2016 21:30:25 +1000 Subject: [PATCH 085/175] move javascript code from _commit_message_container view to javascripts/merge_request.js --- app/assets/javascripts/merge_request.js | 26 +++++++++++++++++++ .../_commit_message_container.html.haml | 21 --------------- 2 files changed, 26 insertions(+), 21 deletions(-) diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 70f9a8d1955..309724071d2 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -27,6 +27,8 @@ // Prevent duplicate event bindings this.disableTaskList(); this.initMRBtnListeners(); + this.initMessageWithDescriptionListener(); + this.initMessageWithoutDescriptionListener(); if ($("a.btn-close").length) { this.initTaskList(); } @@ -108,6 +110,30 @@ // note so that we can re-use its form here }; + MergeRequest.prototype.initMessageWithDescriptionListener = function() { + return $('a.with-description-link').on('click', function(e) { + e.preventDefault(); + + var textarea = $('textarea.js-commit-message'); + + textarea.val(textarea.data('messageWithDescription')); + $('p.with-description-hint').hide(); + $('p.without-description-hint').show(); + }); + }; + + MergeRequest.prototype.initMessageWithoutDescriptionListener = function() { + return $('a.without-description-link').on('click', function(e) { + e.preventDefault(); + + var textarea = $('textarea.js-commit-message'); + + textarea.val(textarea.data('messageWithoutDescription')); + $('p.with-description-hint').show(); + $('p.without-description-hint').hide(); + }); + }; + return MergeRequest; })(); diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index 3e0186983e4..803cbb47e55 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -22,24 +22,3 @@ %p.hint.without-description-hint.hide = link_to "#", class: "without-description-link" do Don't include description in commit message - - :javascript - $('.with-description-link').on('click', function(e) { - e.preventDefault(); - - var textarea = $('.js-commit-message') - - textarea.val(textarea.data('messageWithDescription')) - $('.with-description-hint').hide(); - $('.without-description-hint').show(); - }); - - $('.without-description-link').on('click', function(e) { - e.preventDefault(); - - var textarea = $('.js-commit-message') - - textarea.val(textarea.data('messageWithoutDescription')) - $('.with-description-hint').show(); - $('.without-description-hint').hide(); - }); From 4181528569a81004d4e64f3e9726fc653a322cc7 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 28 Nov 2016 22:11:45 +1000 Subject: [PATCH 086/175] Better `Closes issues` text for MergeRequest#merge_commit_message --- app/models/merge_request.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2d7be2c2c7e..48c30b08502 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -637,7 +637,8 @@ class MergeRequest < ActiveRecord::Base message << "#{title}\n\n" if closes_issues_references.present? - message << "Closed Issues: #{closes_issues_references.join(", ")}\n\n" + issue_text = 'issue'.pluralize(closes_issues_references.size) + message << "Closes #{issue_text} #{closes_issues_references.to_sentence}\n\n" end message << "#{description}\n\n" if include_description && description.present? From 9e321043c72322ae12aba230b49f9da326e66e56 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Tue, 29 Nov 2016 07:10:59 +1000 Subject: [PATCH 087/175] extract duplicate logic into a variable on _commit_message_container --- app/views/shared/_commit_message_container.html.haml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index 803cbb47e55..2b2da446d09 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -1,5 +1,6 @@ .form-group.commit_message-group - nonce = SecureRandom.hex + - descriptions = local_assigns.slice(:message_with_description, :message_without_description) = label_tag "commit_message-#{nonce}", class: 'control-label' do Commit message .col-sm-10 @@ -8,14 +9,14 @@ = text_area_tag 'commit_message', (params[:commit_message] || local_assigns[:text] || local_assigns[:placeholder]), class: 'form-control js-commit-message', placeholder: local_assigns[:placeholder], - data: local_assigns.slice(:message_with_description, :message_without_description), + data: descriptions, required: true, rows: (local_assigns[:rows] || 3), id: "commit_message-#{nonce}" - if local_assigns[:hint] %p.hint Try to keep the first line under 52 characters and the others under 72. - -if local_assigns.slice(:message_with_description, :message_without_description).present? + - if descriptions.present? %p.hint.with-description-hint = link_to "#", class: "with-description-link" do Include description in commit message From fedba9fc2568ec784eddbe6aff0fc9ca5f95b116 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Tue, 29 Nov 2016 07:29:15 +1000 Subject: [PATCH 088/175] add mentioned but not closed message to the same line as closes issueswq --- .../merge_requests/widget/_open.html.haml | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index bf1e49c98ce..4aae3ba63a9 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -30,19 +30,20 @@ - elsif @merge_request.can_be_merged? || resolved_conflicts = render 'projects/merge_requests/widget/open/accept' - - if mr_issues_mentioned_but_not_closing.present? - .mr-widget-footer - %span - %i.fa.fa-info-circle - #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)} mentioned but not being closed: - = succeed '.' do - != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author - - - if mr_closes_issues.present? + - if mr_closes_issues.present? || mr_issues_mentioned_but_not_closing .mr-widget-footer %span = icon('check') - Accepting this merge request will close #{"issue".pluralize(mr_closes_issues.size)} - = succeed '.' do - != markdown issues_sentence(mr_closes_issues), pipeline: :gfm, author: @merge_request.author - = mr_assign_issues_link + - if mr_closes_issues.present? + Accepting this merge request will close #{"issue".pluralize(mr_closes_issues.size)} + = succeed '.' do + != markdown issues_sentence(mr_closes_issues), pipeline: :gfm, author: @merge_request.author + = mr_assign_issues_link + - if mr_issues_mentioned_but_not_closing.present? + #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)} + = succeed '' do + != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author + = succeed '' do + mentioned but not closed. + + From 943ef94912410dd028626711c97cd1ae881a5e4c Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Tue, 29 Nov 2016 07:30:55 +1000 Subject: [PATCH 089/175] better text for mentioned but not closed --- app/views/projects/merge_requests/widget/_open.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index 4aae3ba63a9..695359a48a3 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -44,6 +44,6 @@ = succeed '' do != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author = succeed '' do - mentioned but not closed. + mentioned but will not closed. From 99dd58ec557779eadd83aa597d8c16996be60df1 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 5 Dec 2016 20:46:43 +1000 Subject: [PATCH 090/175] Unify commit message listeners in one function --- app/assets/javascripts/merge_request.js | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 309724071d2..194a27ae22b 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -27,8 +27,7 @@ // Prevent duplicate event bindings this.disableTaskList(); this.initMRBtnListeners(); - this.initMessageWithDescriptionListener(); - this.initMessageWithoutDescriptionListener(); + this.initCommitMessageListeners(); if ($("a.btn-close").length) { this.initTaskList(); } @@ -110,24 +109,20 @@ // note so that we can re-use its form here }; - MergeRequest.prototype.initMessageWithDescriptionListener = function() { - return $('a.with-description-link').on('click', function(e) { - e.preventDefault(); + MergeRequest.prototype.initCommitMessageListeners = function() { + var textarea = $('textarea.js-commit-message'); - var textarea = $('textarea.js-commit-message'); + $('a.with-description-link').on('click', function(e) { + e.preventDefault(); textarea.val(textarea.data('messageWithDescription')); $('p.with-description-hint').hide(); $('p.without-description-hint').show(); }); - }; - MergeRequest.prototype.initMessageWithoutDescriptionListener = function() { - return $('a.without-description-link').on('click', function(e) { + $('a.without-description-link').on('click', function(e) { e.preventDefault(); - var textarea = $('textarea.js-commit-message'); - textarea.val(textarea.data('messageWithoutDescription')); $('p.with-description-hint').show(); $('p.without-description-hint').hide(); From 603ef5d49ed453cbb47b68d3af078529c6b834a1 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 5 Dec 2016 21:29:17 +1000 Subject: [PATCH 091/175] Show either description or closes issues references on MergeRequest#merge_commit_message so closes issues references are not duplicated --- app/models/merge_request.rb | 14 ++++++++------ .../projects/merge_requests/widget/_open.html.haml | 8 +++----- spec/models/merge_request_spec.rb | 2 +- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 48c30b08502..da293c3738f 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -633,18 +633,20 @@ class MergeRequest < ActiveRecord::Base issue.to_reference(target_project) end - message = "Merge branch '#{source_branch}' into '#{target_branch}'\n\n" - message << "#{title}\n\n" + message = [ + "Merge branch '#{source_branch}' into '#{target_branch}'", + title + ] - if closes_issues_references.present? + if !include_description && closes_issues_references.present? issue_text = 'issue'.pluralize(closes_issues_references.size) - message << "Closes #{issue_text} #{closes_issues_references.to_sentence}\n\n" + message << "Closes #{issue_text} #{closes_issues_references.to_sentence}" end - message << "#{description}\n\n" if include_description && description.present? + message << "#{description}" if include_description && description.present? message << "See merge request #{to_reference}" - message + message.join("\n\n") end def reset_merge_when_build_succeeds diff --git a/app/views/projects/merge_requests/widget/_open.html.haml b/app/views/projects/merge_requests/widget/_open.html.haml index 695359a48a3..f4aa1609a1b 100644 --- a/app/views/projects/merge_requests/widget/_open.html.haml +++ b/app/views/projects/merge_requests/widget/_open.html.haml @@ -30,7 +30,7 @@ - elsif @merge_request.can_be_merged? || resolved_conflicts = render 'projects/merge_requests/widget/open/accept' - - if mr_closes_issues.present? || mr_issues_mentioned_but_not_closing + - if mr_closes_issues.present? || mr_issues_mentioned_but_not_closing.present? .mr-widget-footer %span = icon('check') @@ -41,9 +41,7 @@ = mr_assign_issues_link - if mr_issues_mentioned_but_not_closing.present? #{"Issue".pluralize(mr_issues_mentioned_but_not_closing.size)} - = succeed '' do - != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author - = succeed '' do - mentioned but will not closed. + != markdown issues_sentence(mr_issues_mentioned_but_not_closing), pipeline: :gfm, author: @merge_request.author + #{mr_issues_mentioned_but_not_closing.size > 1 ? 'are' : 'is'} mentioned but will not closed. diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index f74c89bba4d..1e9790cf644 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -425,7 +425,7 @@ describe MergeRequest, models: true do and_return(subject.target_branch) expect(subject.merge_commit_message) - .to match("Closed Issues: #{issue.to_reference}") + .to match("Closes issue #{issue.to_reference}") end it 'includes its reference in the body' do From 5d478e5ceec3db9c38ef2ab1fafd7235fe3bb244 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 5 Dec 2016 21:43:40 +1000 Subject: [PATCH 092/175] add js prefix to classes used to toggle description on commit message in merge request --- app/assets/javascripts/merge_request.js | 12 ++++++------ app/views/shared/_commit_message_container.html.haml | 8 ++++---- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 194a27ae22b..462dbc44e9f 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -112,20 +112,20 @@ MergeRequest.prototype.initCommitMessageListeners = function() { var textarea = $('textarea.js-commit-message'); - $('a.with-description-link').on('click', function(e) { + $('a.js-with-description-link').on('click', function(e) { e.preventDefault(); textarea.val(textarea.data('messageWithDescription')); - $('p.with-description-hint').hide(); - $('p.without-description-hint').show(); + $('p.js-with-description-hint').hide(); + $('p.js-without-description-hint').show(); }); - $('a.without-description-link').on('click', function(e) { + $('a.js-without-description-link').on('click', function(e) { e.preventDefault(); textarea.val(textarea.data('messageWithoutDescription')); - $('p.with-description-hint').show(); - $('p.without-description-hint').hide(); + $('p.js-with-description-hint').show(); + $('p.js-without-description-hint').hide(); }); }; diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index 2b2da446d09..c196bc06b17 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -17,9 +17,9 @@ Try to keep the first line under 52 characters and the others under 72. - if descriptions.present? - %p.hint.with-description-hint - = link_to "#", class: "with-description-link" do + %p.hint.js-with-description-hint + = link_to "#", class: "js-with-description-link" do Include description in commit message - %p.hint.without-description-hint.hide - = link_to "#", class: "without-description-link" do + %p.hint.js-without-description-hint.hide + = link_to "#", class: "js-without-description-link" do Don't include description in commit message From 7d7ae494d476f2e0588740612846da4284b3da0c Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 5 Dec 2016 21:44:29 +1000 Subject: [PATCH 093/175] add guard clause to MergeRequest#issues_mentioned_but_not_closing --- app/models/merge_request.rb | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index da293c3738f..acaf14a12e9 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -569,17 +569,14 @@ class MergeRequest < ActiveRecord::Base end def issues_mentioned_but_not_closing(current_user = self.author) - issues = [] - closing_issues = [] + return [] unless target_branch == project.default_branch - if target_branch == project.default_branch - ext = Gitlab::ReferenceExtractor.new(project, current_user) - ext.analyze(description) + ext = Gitlab::ReferenceExtractor.new(project, current_user) + ext.analyze(description) - issues = ext.issues - closing_issues = Gitlab::ClosingIssueExtractor.new(project, current_user). - closed_by_message(description) - end + issues = ext.issues + closing_issues = Gitlab::ClosingIssueExtractor.new(project, current_user). + closed_by_message(description) issues - closing_issues end From 5311d7f0aec15768e78924b8fb7cb17cf487baa5 Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Mon, 5 Dec 2016 22:52:47 +1000 Subject: [PATCH 094/175] Add feature spec to verify all 4 different states of closing issues message on Merge Request show page. --- .../merge_requests/closes_issues_spec.rb | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 spec/features/merge_requests/closes_issues_spec.rb diff --git a/spec/features/merge_requests/closes_issues_spec.rb b/spec/features/merge_requests/closes_issues_spec.rb new file mode 100644 index 00000000000..cfa94e13df2 --- /dev/null +++ b/spec/features/merge_requests/closes_issues_spec.rb @@ -0,0 +1,57 @@ +require 'spec_helper' + +feature 'Merge Commit Description', feature: true do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:issue_1) { create(:issue, project: project)} + let(:issue_2) { create(:issue, project: project)} + let(:merge_request) do + create( + :merge_request, + :simple, + source_project: project, + description: merge_request_description + ) + end + let(:merge_request_description) { 'Merge Request Description' } + + before do + project.team << [user, :master] + + login_as user + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + + click_link 'Modify commit message' + end + + context 'not closing or mentioning any issue' do + it 'does not display closing issue message' do + expect(page).not_to have_css('.mr-widget-footer') + end + end + + context 'closing issues but not mentioning any other issue' do + let(:merge_request_description) { "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}" } + + it 'does not display closing issue message' do + expect(page).to have_content("Accepting this merge request will close issues #{issue_1.to_reference} and #{issue_2.to_reference}") + end + end + + context 'mentioning issues but not closing them' do + let(:merge_request_description) { "Description\n\nRefers to #{issue_1.to_reference} and #{issue_2.to_reference}" } + + it 'does not display closing issue message' do + expect(page).to have_content("Issues #{issue_1.to_reference} and #{issue_2.to_reference} are mentioned but will not closed.") + end + end + + context 'closing some issues and mentioning, but not closing, others' do + let(:merge_request_description) { "Description\n\ncloses #{issue_1.to_reference}\n\n refers to #{issue_2.to_reference}" } + + it 'does not display closing issue message' do + expect(page).to have_content("Accepting this merge request will close issue #{issue_1.to_reference}. Issue #{issue_2.to_reference} is mentioned but will not closed.") + end + end +end From 4525cdaec3f9af4a38711137b5fcdcff68cd7d1a Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Thu, 8 Dec 2016 21:39:16 +1000 Subject: [PATCH 095/175] add eslint disable prefix for prefer-arrow-callback rule on header of merge_request.js file --- app/assets/javascripts/merge_request.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 462dbc44e9f..244c2f6746c 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -1,4 +1,4 @@ -/* eslint-disable func-names, space-before-function-paren, no-var, space-before-blocks, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, padded-blocks, max-len */ +/* eslint-disable func-names, space-before-function-paren, no-var, space-before-blocks, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, padded-blocks, max-len, prefer-arrow-callback */ /* global MergeRequestTabs */ /*= require jquery.waitforimages */ From f3378630c15d43080d2bda03f5165653092a660b Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Sat, 10 Dec 2016 21:58:15 +1000 Subject: [PATCH 096/175] add feature specs to test toggling of merge commit message between message with description and without --- .../merge_requests/closes_issues_spec.rb | 4 +- .../merge_commit_message_toggle_spec.rb | 74 +++++++++++++++++++ 2 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 spec/features/merge_requests/merge_commit_message_toggle_spec.rb diff --git a/spec/features/merge_requests/closes_issues_spec.rb b/spec/features/merge_requests/closes_issues_spec.rb index cfa94e13df2..dc32c8f7373 100644 --- a/spec/features/merge_requests/closes_issues_spec.rb +++ b/spec/features/merge_requests/closes_issues_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature 'Merge Commit Description', feature: true do +feature 'Merge Request closing issues message', feature: true do let(:user) { create(:user) } let(:project) { create(:project, :public) } let(:issue_1) { create(:issue, project: project)} @@ -21,8 +21,6 @@ feature 'Merge Commit Description', feature: true do login_as user visit namespace_project_merge_request_path(project.namespace, project, merge_request) - - click_link 'Modify commit message' end context 'not closing or mentioning any issue' do diff --git a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb new file mode 100644 index 00000000000..2c78234bd0f --- /dev/null +++ b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb @@ -0,0 +1,74 @@ +require 'spec_helper' + +feature 'Clicking toggle commit message link', feature: true, js: true do + let(:user) { create(:user) } + let(:project) { create(:project, :public) } + let(:issue_1) { create(:issue, project: project)} + let(:issue_2) { create(:issue, project: project)} + let(:merge_request) do + create( + :merge_request, + :simple, + source_project: project, + description: "Description\n\nclosing #{issue_1.to_reference}, #{issue_2.to_reference}" + ) + end + let(:textbox) { page.find(:css, '.js-commit-message', visible: false) } + let(:include_link) { page.find(:css, '.js-with-description-link', visible: false) } + let(:do_not_include_link) { page.find(:css, '.js-without-description-link', visible: false) } + let(:default_message) do + [ + "Merge branch 'feature' into 'master'", + merge_request.title, + "Closes issues #{issue_1.to_reference} and #{issue_2.to_reference}", + "See merge request #{merge_request.to_reference}" + ].join("\n\n") + end + let(:message_with_description) do + [ + "Merge branch 'feature' into 'master'", + merge_request.title, + merge_request.description, + "See merge request #{merge_request.to_reference}" + ].join("\n\n") + end + + before do + project.team << [user, :master] + + login_as user + + visit namespace_project_merge_request_path(project.namespace, project, merge_request) + + expect(textbox).not_to be_visible + click_link "Modify commit message" + expect(textbox).to be_visible + end + + it "toggles commit message between message with description and without description " do + expect(textbox.value).to eq(default_message) + + click_link "Include description in commit message" + + expect(textbox.value).to eq(message_with_description) + + click_link "Don't include description in commit message" + + expect(textbox.value).to eq(default_message) + end + + it "toggles link between 'Include description' and 'Don't include description'" do + expect(include_link).to be_visible + expect(do_not_include_link).not_to be_visible + + click_link "Include description in commit message" + + expect(include_link).not_to be_visible + expect(do_not_include_link).to be_visible + + click_link "Don't include description in commit message" + + expect(include_link).to be_visible + expect(do_not_include_link).not_to be_visible + end +end From 3e3d6b53dcdc50bbe45c4b3ff43faf3d2728f72c Mon Sep 17 00:00:00 2001 From: Gabriel Gizotti Date: Tue, 13 Dec 2016 23:00:00 +1000 Subject: [PATCH 097/175] Change closes issues reference text on MergeRequest#merge_commit_message to match existing text generated by the system --- app/models/merge_request.rb | 3 +-- .../merge_requests/merge_commit_message_toggle_spec.rb | 2 +- spec/models/merge_request_spec.rb | 6 +++--- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index acaf14a12e9..b7c775777c7 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -636,8 +636,7 @@ class MergeRequest < ActiveRecord::Base ] if !include_description && closes_issues_references.present? - issue_text = 'issue'.pluralize(closes_issues_references.size) - message << "Closes #{issue_text} #{closes_issues_references.to_sentence}" + message << "Closes #{closes_issues_references.to_sentence}" end message << "#{description}" if include_description && description.present? diff --git a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb index 2c78234bd0f..3dbe26cddb0 100644 --- a/spec/features/merge_requests/merge_commit_message_toggle_spec.rb +++ b/spec/features/merge_requests/merge_commit_message_toggle_spec.rb @@ -20,7 +20,7 @@ feature 'Clicking toggle commit message link', feature: true, js: true do [ "Merge branch 'feature' into 'master'", merge_request.title, - "Closes issues #{issue_1.to_reference} and #{issue_2.to_reference}", + "Closes #{issue_1.to_reference} and #{issue_2.to_reference}", "See merge request #{merge_request.to_reference}" ].join("\n\n") end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 1e9790cf644..5da00a8636a 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -416,16 +416,16 @@ describe MergeRequest, models: true do end it 'includes its closed issues in the body' do - issue = create(:issue, project: subject.project) + issue = create(:issue, project: subject.project) subject.project.team << [subject.author, :developer] - subject.description = "Closes #{issue.to_reference}" + subject.description = "This issue Closes #{issue.to_reference}" allow(subject.project).to receive(:default_branch). and_return(subject.target_branch) expect(subject.merge_commit_message) - .to match("Closes issue #{issue.to_reference}") + .to match("Closes #{issue.to_reference}") end it 'includes its reference in the body' do From 8b26ff58e16e55077ddd704add5997924bcd94b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Trzci=C5=84ski?= Date: Fri, 16 Dec 2016 09:17:15 +0000 Subject: [PATCH 098/175] Update templates.rb --- lib/api/templates.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/api/templates.rb b/lib/api/templates.rb index 2d887e15f28..e23f99256a5 100644 --- a/lib/api/templates.rb +++ b/lib/api/templates.rb @@ -11,7 +11,7 @@ module API }, dockerfiles: { klass: Gitlab::Template::DockerfileTemplate, - gitlab_version: 8.9 + gitlab_version: 8.15 } }.freeze PROJECT_TEMPLATE_REGEX = From 1d0ccec6dd8375b751846f69bb170ebd11e9a391 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 22 Nov 2016 14:23:53 +0530 Subject: [PATCH 099/175] Add a `scopes` column to the `personal_access_tokens` table --- app/models/personal_access_token.rb | 2 + ...column_scopes_to_personal_access_tokens.rb | 36 +++++++++++++++++ ...cess_tokens_default_back_to_empty_array.rb | 39 +++++++++++++++++++ db/schema.rb | 1 + spec/factories/personal_access_tokens.rb | 1 + 5 files changed, 79 insertions(+) create mode 100644 db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb create mode 100644 db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index c4b095e0c04..10a34c42fd8 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -2,6 +2,8 @@ class PersonalAccessToken < ActiveRecord::Base include TokenAuthenticatable add_authentication_token_field :token + serialize :scopes, Array + belongs_to :user scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") } diff --git a/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb b/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb new file mode 100644 index 00000000000..ab7f0365603 --- /dev/null +++ b/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb @@ -0,0 +1,36 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddColumnScopesToPersonalAccessTokens < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + disable_ddl_transaction! + + def up + # The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. + # It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to + # `[]`. + add_column_with_default :personal_access_tokens, :scopes, :string, default: ['api'].to_yaml + end + + def down + remove_column :personal_access_tokens, :scopes + end +end diff --git a/db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb b/db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb new file mode 100644 index 00000000000..018cc3d4747 --- /dev/null +++ b/db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb @@ -0,0 +1,39 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class ChangePersonalAccessTokensDefaultBackToEmptyArray < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + # When a migration requires downtime you **must** uncomment the following + # constant and define a short and easy to understand explanation as to why the + # migration requires downtime. + # DOWNTIME_REASON = '' + + # When using the methods "add_concurrent_index" or "add_column_with_default" + # you must disable the use of transactions as these methods can not run in an + # existing transaction. When using "add_concurrent_index" make sure that this + # method is the _only_ method called in the migration, any other changes + # should go in a separate migration. This ensures that upon failure _only_ the + # index creation fails and can be retried or reverted easily. + # + # To disable transactions uncomment the following line and remove these + # comments: + # disable_ddl_transaction! + + def up + # The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. + # It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to + # `[]`. + change_column_default :personal_access_tokens, :scopes, [].to_yaml + end + + def down + # The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. + # It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to + # `[]`. + change_column_default :personal_access_tokens, :scopes, ['api'].to_yaml + end +end diff --git a/db/schema.rb b/db/schema.rb index 67ff83d96d9..a1a22df0d53 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -854,6 +854,7 @@ ActiveRecord::Schema.define(version: 20161212142807) do t.datetime "expires_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.string "scopes", default: "--- []\n", null: false end add_index "personal_access_tokens", ["token"], name: "index_personal_access_tokens_on_token", unique: true, using: :btree diff --git a/spec/factories/personal_access_tokens.rb b/spec/factories/personal_access_tokens.rb index da4c72bcb5b..811eab7e15b 100644 --- a/spec/factories/personal_access_tokens.rb +++ b/spec/factories/personal_access_tokens.rb @@ -5,5 +5,6 @@ FactoryGirl.define do name { FFaker::Product.brand } revoked false expires_at { 5.days.from_now } + scopes ['api'] end end From 6c809dfae84e702f7a49d3fac5725745264e0ff9 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 22 Nov 2016 14:27:31 +0530 Subject: [PATCH 100/175] Allow creating personal access tokens / OAuth applications with scopes. --- app/assets/stylesheets/pages/profile.scss | 10 +++++++ .../admin/applications_controller.rb | 6 ++++- .../concerns/oauth_applications.rb | 14 ++++++++++ .../oauth/applications_controller.rb | 6 +++++ .../personal_access_tokens_controller.rb | 12 ++++----- app/views/admin/applications/_form.html.haml | 10 +++++++ app/views/admin/applications/show.html.haml | 15 +++++++++-- .../doorkeeper/applications/_form.html.haml | 9 +++++++ .../doorkeeper/applications/show.html.haml | 15 ++++++++++- .../personal_access_tokens/_form.html.haml | 22 ++++++++++++++++ .../personal_access_tokens/index.html.haml | 17 +++--------- .../profiles/personal_access_tokens_spec.rb | 26 +++++++++++++++++++ 12 files changed, 138 insertions(+), 24 deletions(-) create mode 100644 app/controllers/concerns/oauth_applications.rb create mode 100644 app/views/profiles/personal_access_tokens/_form.html.haml diff --git a/app/assets/stylesheets/pages/profile.scss b/app/assets/stylesheets/pages/profile.scss index 8a5b0e20a86..8b1976bd925 100644 --- a/app/assets/stylesheets/pages/profile.scss +++ b/app/assets/stylesheets/pages/profile.scss @@ -262,3 +262,13 @@ table.u2f-registrations { border-right: solid 1px transparent; } } + +.oauth-application-show { + .scope-name { + font-weight: 600; + } + + .scopes-list { + padding-left: 18px; + } +} \ No newline at end of file diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 471d24934a0..759044910bb 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -1,4 +1,6 @@ class Admin::ApplicationsController < Admin::ApplicationController + include OauthApplications + before_action :set_application, only: [:show, :edit, :update, :destroy] def index @@ -10,9 +12,11 @@ class Admin::ApplicationsController < Admin::ApplicationController def new @application = Doorkeeper::Application.new + @scopes = Doorkeeper.configuration.scopes end def edit + @scopes = Doorkeeper.configuration.scopes end def create @@ -47,6 +51,6 @@ class Admin::ApplicationsController < Admin::ApplicationController # Only allow a trusted parameter "white list" through. def application_params - params[:doorkeeper_application].permit(:name, :redirect_uri) + params[:doorkeeper_application].permit(:name, :redirect_uri, :scopes) end end diff --git a/app/controllers/concerns/oauth_applications.rb b/app/controllers/concerns/oauth_applications.rb new file mode 100644 index 00000000000..34ad43ededd --- /dev/null +++ b/app/controllers/concerns/oauth_applications.rb @@ -0,0 +1,14 @@ +module OauthApplications + extend ActiveSupport::Concern + + included do + before_action :prepare_scopes, only: [:create, :update] + end + + def prepare_scopes + scopes = params.dig(:doorkeeper_application, :scopes) + if scopes + params[:doorkeeper_application][:scopes] = scopes.join(' ') + end + end +end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index 0f54dfa4efc..b5449a6c30e 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -2,6 +2,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController include Gitlab::CurrentSettings include Gitlab::GonHelper include PageLayoutHelper + include OauthApplications before_action :verify_user_oauth_applications_enabled before_action :authenticate_user! @@ -13,6 +14,10 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController set_index_vars end + def edit + @scopes = Doorkeeper.configuration.scopes + end + def create @application = Doorkeeper::Application.new(application_params) @@ -40,6 +45,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController @authorized_tokens = current_user.oauth_authorized_tokens @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) @authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?) + @scopes = Doorkeeper.configuration.scopes # Don't overwrite a value possibly set by `create` @application ||= Doorkeeper::Application.new diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 508b82a9a6c..6e007f17913 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -1,8 +1,6 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController - before_action :load_personal_access_tokens, only: :index - def index - @personal_access_token = current_user.personal_access_tokens.build + set_index_vars end def create @@ -12,7 +10,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController flash[:personal_access_token] = @personal_access_token.token redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created." else - load_personal_access_tokens + set_index_vars render :index end end @@ -32,10 +30,12 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController private def personal_access_token_params - params.require(:personal_access_token).permit(:name, :expires_at) + params.require(:personal_access_token).permit(:name, :expires_at, scopes: []) end - def load_personal_access_tokens + def set_index_vars + @personal_access_token ||= current_user.personal_access_tokens.build + @scopes = Gitlab::Auth::SCOPES @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive end diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 4aacbb8cd77..36d2f415a05 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -18,6 +18,16 @@ Use %code= Doorkeeper.configuration.native_redirect_uri for local tests + + .form-group + = f.label :scopes, class: 'col-sm-2 control-label' + .col-sm-10 + - @scopes.each do |scope| + %fieldset + = check_box_tag 'doorkeeper_application[scopes][]', scope, application.scopes.include?(scope), id: "doorkeeper_application_scopes_#{scope}" + = label_tag "doorkeeper_application_scopes_#{scope}", scope + %span= "(#{t(scope, scope: [:doorkeeper, :scopes])})" + .form-actions = f.submit 'Submit', class: "btn btn-save wide" = link_to "Cancel", admin_applications_path, class: "btn btn-default" diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 3eb9d61972b..3418dc96496 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -2,8 +2,7 @@ %h3.page-title Application: #{@application.name} - -.table-holder +.table-holder.oauth-application-show %table.table %tr %td @@ -23,6 +22,18 @@ - @application.redirect_uri.split.each do |uri| %div %span.monospace= uri + + - if @application.scopes.present? + %tr + %td + Scopes + %td + %ul.scopes-list.append-bottom-0 + - @application.scopes.each do |scope| + %li + %span.scope-name= scope + = "(#{t(scope, scope: [:doorkeeper, :scopes])})" + .form-actions = link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide pull-left' = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10' diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 5c98265727a..6fdb04077b6 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -17,5 +17,14 @@ %code= Doorkeeper.configuration.native_redirect_uri for local tests + .form-group + = f.label :scopes, class: 'label-light' + - @scopes.each do |scope| + %fieldset + = check_box_tag 'doorkeeper_application[scopes][]', scope, application.scopes.include?(scope), id: "doorkeeper_application_scopes_#{scope}" + = label_tag "doorkeeper_application_scopes_#{scope}", scope + %span= "(#{t(scope, scope: [:doorkeeper, :scopes])})" + + .prepend-top-default = f.submit 'Save application', class: "btn btn-create" diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index 47442b78d48..a18e133c8de 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -2,7 +2,7 @@ %h3.page-title Application: #{@application.name} -.table-holder +.table-holder.oauth-application-show %table.table %tr %td @@ -22,6 +22,19 @@ - @application.redirect_uri.split.each do |uri| %div %span.monospace= uri + + - if @application.scopes.present? + %tr + %td + Scopes + %td + %ul.scopes-list.append-bottom-0 + - @application.scopes.each do |scope| + %li + %span.scope-name= scope + = "(#{t(scope, scope: [:doorkeeper, :scopes])})" + + .form-actions = link_to 'Edit', edit_oauth_application_path(@application), class: 'btn btn-primary wide pull-left' = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10' diff --git a/app/views/profiles/personal_access_tokens/_form.html.haml b/app/views/profiles/personal_access_tokens/_form.html.haml new file mode 100644 index 00000000000..6083fdaa31d --- /dev/null +++ b/app/views/profiles/personal_access_tokens/_form.html.haml @@ -0,0 +1,22 @@ += form_for [:profile, @personal_access_token], method: :post, html: { class: 'js-requires-input' } do |f| + + = form_errors(@personal_access_token) + + .form-group + = f.label :name, class: 'label-light' + = f.text_field :name, class: "form-control", required: true + + .form-group + = f.label :expires_at, class: 'label-light' + = f.text_field :expires_at, class: "datepicker form-control", required: false + + .form-group + = f.label :scopes, class: 'label-light' + - @scopes.each do |scope| + %fieldset + = check_box_tag 'personal_access_token[scopes][]', scope, @personal_access_token.scopes.include?(scope), id: "personal_access_token_scopes_#{scope}" + = label_tag "personal_access_token_scopes_#{scope}", scope + %span= "(#{t(scope, scope: [:doorkeeper, :scopes])})" + + .prepend-top-default + = f.submit 'Create Personal Access Token', class: "btn btn-create" diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 05a2ea67aa2..39eef0f6baf 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -28,21 +28,8 @@ Add a Personal Access Token %p.profile-settings-content Pick a name for the application, and we'll give you a unique token. - = form_for [:profile, @personal_access_token], - method: :post, html: { class: 'js-requires-input' } do |f| - = form_errors(@personal_access_token) - - .form-group - = f.label :name, class: 'label-light' - = f.text_field :name, class: "form-control", required: true - - .form-group - = f.label :expires_at, class: 'label-light' - = f.text_field :expires_at, class: "datepicker form-control", required: false - - .prepend-top-default - = f.submit 'Create Personal Access Token', class: "btn btn-create" + = render "form" %hr @@ -56,6 +43,7 @@ %th Name %th Created %th Expires + %th Scopes %th %tbody - @active_personal_access_tokens.each do |token| @@ -67,6 +55,7 @@ = token.expires_at.to_date.to_s(:medium) - else %span.personal-access-tokens-never-expires-label Never + %td= token.scopes.present? ? token.scopes.join(", ") : "" %td= link_to "Revoke", revoke_profile_personal_access_token_path(token), method: :put, class: "btn btn-danger pull-right", data: { confirm: "Are you sure you want to revoke this token? This action cannot be undone." } - else diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index a85930c7543..0ffeeff0921 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -51,6 +51,32 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do expect(active_personal_access_tokens).to have_text(Date.today.next_month.at_beginning_of_month.to_s(:medium)) end + context "scopes" do + it "allows creation of a token with scopes" do + visit profile_personal_access_tokens_path + fill_in "Name", with: FFaker::Product.brand + + check "api" + check "read_user" + + expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) + expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) + expect(PersonalAccessToken.last.scopes).to match_array(['api', 'read_user']) + expect(active_personal_access_tokens).to have_text('api') + expect(active_personal_access_tokens).to have_text('read_user') + end + + it "allows creation of a token with no scopes" do + visit profile_personal_access_tokens_path + fill_in "Name", with: FFaker::Product.brand + + expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) + expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) + expect(PersonalAccessToken.last.scopes).to eq([]) + expect(active_personal_access_tokens).to have_text('no scopes') + end + end + context "when creation fails" do it "displays an error message" do disallow_personal_access_token_saves! From e0ef9dc83ebfe102aaf980495b14fd6a06a24fd1 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 16 Dec 2016 12:12:53 +0200 Subject: [PATCH 101/175] BB importer: Milestone importer --- lib/bitbucket/representation/issue.rb | 4 ++++ lib/gitlab/bitbucket_import/importer.rb | 2 ++ spec/lib/bitbucket/representation/issue_spec.rb | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/lib/bitbucket/representation/issue.rb b/lib/bitbucket/representation/issue.rb index ffe8a65d839..3af731753d1 100644 --- a/lib/bitbucket/representation/issue.rb +++ b/lib/bitbucket/representation/issue.rb @@ -27,6 +27,10 @@ module Bitbucket raw['title'] end + def milestone + raw.dig('milestone', 'name') + end + def created_at raw['created_on'] end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 567f2b314aa..53c95ea4079 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -67,6 +67,7 @@ module Gitlab description += issue.description label_name = issue.kind + milestone = issue.milestone ? project.milestones.find_or_create_by(title: issue.milestone) : nil issue = project.issues.create!( iid: issue.iid, @@ -74,6 +75,7 @@ module Gitlab description: description, state: issue.state, author_id: gitlab_user_id(project, issue.author), + milestone: milestone, created_at: issue.created_at, updated_at: issue.updated_at ) diff --git a/spec/lib/bitbucket/representation/issue_spec.rb b/spec/lib/bitbucket/representation/issue_spec.rb index e1f3419c77e..9a195bebd31 100644 --- a/spec/lib/bitbucket/representation/issue_spec.rb +++ b/spec/lib/bitbucket/representation/issue_spec.rb @@ -9,6 +9,12 @@ describe Bitbucket::Representation::Issue do it { expect(described_class.new('kind' => 'bug').kind).to eq('bug') } end + describe '#milestone' do + it { expect(described_class.new({ 'milestone' => { 'name' => '1.0' } }).milestone).to eq('1.0') } + it { expect(described_class.new({}).milestone).to be_nil } + end + + describe '#author' do it { expect(described_class.new({ 'reporter' => { 'username' => 'Ben' } }).author).to eq('Ben') } it { expect(described_class.new({}).author).to be_nil } From 2490f804cd4c12533fd6c70fc8014a06f2d19d47 Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Fri, 16 Dec 2016 11:59:10 +0100 Subject: [PATCH 102/175] Additional rounded label fixes --- app/assets/stylesheets/framework/variables.scss | 2 +- app/assets/stylesheets/pages/labels.scss | 4 +++- changelogs/unreleased/rounded-labels-fixes.yml | 4 ++++ 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/rounded-labels-fixes.yml diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 936aaf38254..d201a538195 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -436,7 +436,7 @@ $jq-ui-default-color: #777; $label-gray-bg: #f8fafc; $label-inverse-bg: #333; $label-remove-border: rgba(0, 0, 0, .1); -$label-border-radius: 14px; +$label-border-radius: 100px; /* * Lint diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 25c91203ff4..703a429d63c 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -98,7 +98,7 @@ } .label { - padding: 9px; + padding: 8px 9px 9px 9px; font-size: 14px; } } @@ -201,6 +201,8 @@ .label-remove { border-left: 1px solid $label-remove-border; z-index: 3; + border-radius: $label-border-radius; + padding: 6px 10px 6px 9px; } .btn { diff --git a/changelogs/unreleased/rounded-labels-fixes.yml b/changelogs/unreleased/rounded-labels-fixes.yml new file mode 100644 index 00000000000..e0fbc6e3b5a --- /dev/null +++ b/changelogs/unreleased/rounded-labels-fixes.yml @@ -0,0 +1,4 @@ +--- +title: Additional rounded label fixes +merge_request: +author: From 7fa06ed55d18af4d055041eb27d38fecf9b5548f Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 22 Nov 2016 14:34:23 +0530 Subject: [PATCH 103/175] Calls to the API are checked for scope. - Move the `Oauth2::AccessTokenValidationService` class to `AccessTokenValidationService`, since it is now being used for personal access token validation as well. - Each API endpoint declares the scopes it accepts (if any). Currently, the top level API module declares the `api` scope, and the `Users` API module declares the `read_user` scope (for GET requests). - Move the `find_user_by_private_token` from the API `Helpers` module to the `APIGuard` module, to avoid littering `Helpers` with more auth-related methods to support `find_user_by_private_token` --- .../access_token_validation_service.rb | 34 ++++++++++ .../oauth2/access_token_validation_service.rb | 42 ------------ config/initializers/doorkeeper.rb | 4 +- config/locales/doorkeeper.en.yml | 1 + lib/api/api.rb | 2 + lib/api/api_guard.rb | 66 +++++++++++++------ lib/api/helpers.rb | 15 +---- lib/api/users.rb | 5 +- lib/gitlab/auth.rb | 4 ++ spec/requests/api/doorkeeper_access_spec.rb | 2 +- spec/requests/api/helpers_spec.rb | 43 +++++++----- .../access_token_validation_service_spec.rb | 42 ++++++++++++ 12 files changed, 166 insertions(+), 94 deletions(-) create mode 100644 app/services/access_token_validation_service.rb delete mode 100644 app/services/oauth2/access_token_validation_service.rb create mode 100644 spec/services/access_token_validation_service_spec.rb diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb new file mode 100644 index 00000000000..69449f3a445 --- /dev/null +++ b/app/services/access_token_validation_service.rb @@ -0,0 +1,34 @@ +module AccessTokenValidationService + # Results: + VALID = :valid + EXPIRED = :expired + REVOKED = :revoked + INSUFFICIENT_SCOPE = :insufficient_scope + + class << self + def validate(token, scopes: []) + if token.expired? + return EXPIRED + + elsif token.revoked? + return REVOKED + + elsif !self.sufficient_scope?(token, scopes) + return INSUFFICIENT_SCOPE + + else + return VALID + end + end + + # True if the token's scope contains any of the required scopes. + def sufficient_scope?(token, required_scopes) + if required_scopes.blank? + true + else + # Check whether the token is allowed access to any of the required scopes. + Set.new(required_scopes).intersection(Set.new(token.scopes)).present? + end + end + end +end diff --git a/app/services/oauth2/access_token_validation_service.rb b/app/services/oauth2/access_token_validation_service.rb deleted file mode 100644 index 264fdccde8f..00000000000 --- a/app/services/oauth2/access_token_validation_service.rb +++ /dev/null @@ -1,42 +0,0 @@ -module Oauth2::AccessTokenValidationService - # Results: - VALID = :valid - EXPIRED = :expired - REVOKED = :revoked - INSUFFICIENT_SCOPE = :insufficient_scope - - class << self - def validate(token, scopes: []) - if token.expired? - return EXPIRED - - elsif token.revoked? - return REVOKED - - elsif !self.sufficient_scope?(token, scopes) - return INSUFFICIENT_SCOPE - - else - return VALID - end - end - - protected - - # True if the token's scope is a superset of required scopes, - # or the required scopes is empty. - def sufficient_scope?(token, scopes) - if scopes.blank? - # if no any scopes required, the scopes of token is sufficient. - return true - else - # If there are scopes required, then check whether - # the set of authorized scopes is a superset of the set of required scopes - required_scopes = Set.new(scopes) - authorized_scopes = Set.new(token.scopes) - - return authorized_scopes >= required_scopes - end - end - end -end diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index fc4b0a72add..88cd0f5f652 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -52,8 +52,8 @@ Doorkeeper.configure do # Define access token scopes for your provider # For more information go to # https://github.com/doorkeeper-gem/doorkeeper/wiki/Using-Scopes - default_scopes :api - # optional_scopes :write, :update + default_scopes(*Gitlab::Auth::DEFAULT_SCOPES) + optional_scopes(*Gitlab::Auth::OPTIONAL_SCOPES) # Change the way client credentials are retrieved from the request object. # By default it retrieves first from the `HTTP_AUTHORIZATION` header, then diff --git a/config/locales/doorkeeper.en.yml b/config/locales/doorkeeper.en.yml index a4032a21420..1d728282d90 100644 --- a/config/locales/doorkeeper.en.yml +++ b/config/locales/doorkeeper.en.yml @@ -59,6 +59,7 @@ en: unknown: "The access token is invalid" scopes: api: Access your API + read_user: Read user information flash: applications: diff --git a/lib/api/api.rb b/lib/api/api.rb index cec2702e44d..9d5adffd8f4 100644 --- a/lib/api/api.rb +++ b/lib/api/api.rb @@ -3,6 +3,8 @@ module API include APIGuard version 'v3', using: :path + before { allow_access_with_scope :api } + rescue_from Gitlab::Access::AccessDeniedError do rack_response({ 'message' => '403 Forbidden' }.to_json, 403) end diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index 8cc7a26f1fa..cd266669b1e 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -6,6 +6,9 @@ module API module APIGuard extend ActiveSupport::Concern + PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN" + PRIVATE_TOKEN_PARAM = :private_token + included do |base| # OAuth2 Resource Server Authentication use Rack::OAuth2::Server::Resource::Bearer, 'The API' do |request| @@ -41,30 +44,59 @@ module API # Defaults to empty array. # def doorkeeper_guard(scopes: []) - access_token = find_access_token - return nil unless access_token - - case validate_access_token(access_token, scopes) - when Oauth2::AccessTokenValidationService::INSUFFICIENT_SCOPE - raise InsufficientScopeError.new(scopes) - - when Oauth2::AccessTokenValidationService::EXPIRED - raise ExpiredError - - when Oauth2::AccessTokenValidationService::REVOKED - raise RevokedError - - when Oauth2::AccessTokenValidationService::VALID - @current_user = User.find(access_token.resource_owner_id) + if access_token = find_access_token + case AccessTokenValidationService.validate(access_token, scopes: scopes) + when AccessTokenValidationService::INSUFFICIENT_SCOPE + raise InsufficientScopeError.new(scopes) + when AccessTokenValidationService::EXPIRED + raise ExpiredError + when AccessTokenValidationService::REVOKED + raise RevokedError + when AccessTokenValidationService::VALID + @current_user = User.find(access_token.resource_owner_id) + end end end + def find_user_by_private_token(scopes: []) + token_string = (params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER]).to_s + + return nil unless token_string.present? + + find_user_by_authentication_token(token_string) || find_user_by_personal_access_token(token_string, scopes) + end + def current_user @current_user end + # Set the authorization scope(s) allowed for the current request. + # + # Note: A call to this method adds to any previous scopes in place. This is done because + # `Grape` callbacks run from the outside-in: the top-level callback (API::API) runs first, then + # the next-level callback (API::API::Users, for example) runs. All these scopes are valid for the + # given endpoint (GET `/api/users` is accessible by the `api` and `read_user` scopes), and so they + # need to be stored. + def allow_access_with_scope(*scopes) + @scopes ||= [] + @scopes.concat(scopes.map(&:to_s)) + end + private + def find_user_by_authentication_token(token_string) + User.find_by_authentication_token(token_string) + end + + def find_user_by_personal_access_token(token_string, scopes) + access_token = PersonalAccessToken.active.find_by_token(token_string) + return unless access_token + + if AccessTokenValidationService.sufficient_scope?(access_token, scopes) + User.find(access_token.user_id) + end + end + def find_access_token @access_token ||= Doorkeeper.authenticate(doorkeeper_request, Doorkeeper.configuration.access_token_methods) end @@ -72,10 +104,6 @@ module API def doorkeeper_request @doorkeeper_request ||= ActionDispatch::Request.new(env) end - - def validate_access_token(access_token, scopes) - Oauth2::AccessTokenValidationService.validate(access_token, scopes: scopes) - end end module ClassMethods diff --git a/lib/api/helpers.rb b/lib/api/helpers.rb index 746849ef4c0..4be659fc20b 100644 --- a/lib/api/helpers.rb +++ b/lib/api/helpers.rb @@ -2,8 +2,6 @@ module API module Helpers include Gitlab::Utils - PRIVATE_TOKEN_HEADER = "HTTP_PRIVATE_TOKEN" - PRIVATE_TOKEN_PARAM = :private_token SUDO_HEADER = "HTTP_SUDO" SUDO_PARAM = :sudo @@ -308,7 +306,7 @@ module API private def private_token - params[PRIVATE_TOKEN_PARAM] || env[PRIVATE_TOKEN_HEADER] + params[APIGuard::PRIVATE_TOKEN_PARAM] || env[APIGuard::PRIVATE_TOKEN_HEADER] end def warden @@ -323,18 +321,11 @@ module API warden.try(:authenticate) if %w[GET HEAD].include?(env['REQUEST_METHOD']) end - def find_user_by_private_token - token = private_token - return nil unless token.present? - - User.find_by_authentication_token(token) || User.find_by_personal_access_token(token) - end - def initial_current_user return @initial_current_user if defined?(@initial_current_user) - @initial_current_user ||= find_user_by_private_token - @initial_current_user ||= doorkeeper_guard + @initial_current_user ||= find_user_by_private_token(scopes: @scopes) + @initial_current_user ||= doorkeeper_guard(scopes: @scopes) @initial_current_user ||= find_user_from_warden unless @initial_current_user && Gitlab::UserAccess.new(@initial_current_user).allowed? diff --git a/lib/api/users.rb b/lib/api/users.rb index c7db2d71017..0842c3874c5 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -2,7 +2,10 @@ module API class Users < Grape::API include PaginationParams - before { authenticate! } + before do + allow_access_with_scope :read_user if request.get? + authenticate! + end resource :users, requirements: { uid: /[0-9]*/, id: /[0-9]*/ } do helpers do diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index aca5d0020cf..c3c464248ef 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -2,6 +2,10 @@ module Gitlab module Auth class MissingPersonalTokenError < StandardError; end + SCOPES = [:api, :read_user] + DEFAULT_SCOPES = [:api] + OPTIONAL_SCOPES = SCOPES - DEFAULT_SCOPES + class << self def find_for_git_client(login, password, project:, ip:) raise "Must provide an IP for rate limiting" if ip.nil? diff --git a/spec/requests/api/doorkeeper_access_spec.rb b/spec/requests/api/doorkeeper_access_spec.rb index 5262a623761..bd9ecaf2685 100644 --- a/spec/requests/api/doorkeeper_access_spec.rb +++ b/spec/requests/api/doorkeeper_access_spec.rb @@ -5,7 +5,7 @@ describe API::API, api: true do let!(:user) { create(:user) } let!(:application) { Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) } - let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id } + let!(:token) { Doorkeeper::AccessToken.create! application_id: application.id, resource_owner_id: user.id, scopes: "api" } describe "when unauthenticated" do it "returns authentication success" do diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index 4035fd97af5..15b93118ee4 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -1,6 +1,7 @@ require 'spec_helper' describe API::Helpers, api: true do + include API::APIGuard::HelperMethods include API::Helpers include SentryHelper @@ -15,24 +16,24 @@ describe API::Helpers, api: true do def set_env(user_or_token, identifier) clear_env clear_param - env[API::Helpers::PRIVATE_TOKEN_HEADER] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token env[API::Helpers::SUDO_HEADER] = identifier.to_s end def set_param(user_or_token, identifier) clear_env clear_param - params[API::Helpers::PRIVATE_TOKEN_PARAM] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token + params[API::APIGuard::PRIVATE_TOKEN_PARAM] = user_or_token.respond_to?(:private_token) ? user_or_token.private_token : user_or_token params[API::Helpers::SUDO_PARAM] = identifier.to_s end def clear_env - env.delete(API::Helpers::PRIVATE_TOKEN_HEADER) + env.delete(API::APIGuard::PRIVATE_TOKEN_HEADER) env.delete(API::Helpers::SUDO_HEADER) end def clear_param - params.delete(API::Helpers::PRIVATE_TOKEN_PARAM) + params.delete(API::APIGuard::PRIVATE_TOKEN_PARAM) params.delete(API::Helpers::SUDO_PARAM) end @@ -94,22 +95,22 @@ describe API::Helpers, api: true do describe "when authenticating using a user's private token" do it "returns nil for an invalid token" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = 'invalid token' + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } expect(current_user).to be_nil end it "returns nil for a user without access" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) expect(current_user).to be_nil end it "leaves user as is when sudo not specified" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = user.private_token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token expect(current_user).to eq(user) clear_env - params[API::Helpers::PRIVATE_TOKEN_PARAM] = user.private_token + params[API::APIGuard::PRIVATE_TOKEN_PARAM] = user.private_token expect(current_user).to eq(user) end end @@ -117,37 +118,45 @@ describe API::Helpers, api: true do describe "when authenticating using a user's personal access tokens" do let(:personal_access_token) { create(:personal_access_token, user: user) } + before do + allow_any_instance_of(self.class).to receive(:doorkeeper_guard) { false } + end + it "returns nil for an invalid token" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = 'invalid token' - allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' expect(current_user).to be_nil end it "returns nil for a user without access" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) expect(current_user).to be_nil end + it "returns nil for a token without the appropriate scope" do + personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token + allow_access_with_scope('write_user') + expect(current_user).to be_nil + end + it "leaves user as is when sudo not specified" do - env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(current_user).to eq(user) clear_env - params[API::Helpers::PRIVATE_TOKEN_PARAM] = personal_access_token.token + params[API::APIGuard::PRIVATE_TOKEN_PARAM] = personal_access_token.token expect(current_user).to eq(user) end it 'does not allow revoked tokens' do personal_access_token.revoke! - env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token - allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(current_user).to be_nil end it 'does not allow expired tokens' do personal_access_token.update_attributes!(expires_at: 1.day.ago) - env[API::Helpers::PRIVATE_TOKEN_HEADER] = personal_access_token.token - allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token expect(current_user).to be_nil end end diff --git a/spec/services/access_token_validation_service_spec.rb b/spec/services/access_token_validation_service_spec.rb new file mode 100644 index 00000000000..8808934fa24 --- /dev/null +++ b/spec/services/access_token_validation_service_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe AccessTokenValidationService, services: true do + + describe ".sufficient_scope?" do + it "returns true if the required scope is present in the token's scopes" do + token = double("token", scopes: [:api, :read_user]) + + expect(described_class.sufficient_scope?(token, [:api])).to be(true) + end + + it "returns true if more than one of the required scopes is present in the token's scopes" do + token = double("token", scopes: [:api, :read_user, :other_scope]) + + expect(described_class.sufficient_scope?(token, [:api, :other_scope])).to be(true) + end + + it "returns true if the list of required scopes is an exact match for the token's scopes" do + token = double("token", scopes: [:api, :read_user, :other_scope]) + + expect(described_class.sufficient_scope?(token, [:api, :read_user, :other_scope])).to be(true) + end + + it "returns true if the list of required scopes contains all of the token's scopes, in addition to others" do + token = double("token", scopes: [:api, :read_user]) + + expect(described_class.sufficient_scope?(token, [:api, :read_user, :other_scope])).to be(true) + end + + it 'returns true if the list of required scopes is blank' do + token = double("token", scopes: []) + + expect(described_class.sufficient_scope?(token, [])).to be(true) + end + + it "returns false if there are no scopes in common between the required scopes and the token scopes" do + token = double("token", scopes: [:api, :read_user]) + + expect(described_class.sufficient_scope?(token, [:other_scope])).to be(false) + end + end +end From 36b3210b9ec4fffd9fa5a73626907e8a6a59f435 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 22 Nov 2016 14:43:37 +0530 Subject: [PATCH 104/175] Validate access token scopes in `Gitlab::Auth` - This module is used for git-over-http, as well as JWT. - The only valid scope here is `api`, currently. --- lib/gitlab/auth.rb | 14 ++++++++--- spec/lib/gitlab/auth_spec.rb | 46 ++++++++++++++++++++++++++++------ spec/requests/git_http_spec.rb | 2 +- 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index c3c464248ef..c6a23aa2bdf 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -92,7 +92,7 @@ module Gitlab def oauth_access_token_check(login, password) if login == "oauth2" && password.present? token = Doorkeeper::AccessToken.by_token(password) - if token && token.accessible? + if token && token.accessible? && token_has_scope?(token) user = User.find_by(id: token.resource_owner_id) Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities) end @@ -101,12 +101,20 @@ module Gitlab def personal_access_token_check(login, password) if login && password - user = User.find_by_personal_access_token(password) + token = PersonalAccessToken.active.find_by_token(password) validation = User.by_login(login) - Gitlab::Auth::Result.new(user, nil, :personal_token, full_authentication_abilities) if user.present? && user == validation + + if token && token.user == validation && token_has_scope?(token) + Gitlab::Auth::Result.new(validation, nil, :personal_token, full_authentication_abilities) + end + end end + def token_has_scope?(token) + AccessTokenValidationService.sufficient_scope?(token, ['api']) + end + def lfs_token_check(login, password) deploy_key_matches = login.match(/\Alfs\+deploy-key-(\d+)\z/) diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index c9d64e99f88..b64413cda12 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -79,14 +79,46 @@ describe Gitlab::Auth, lib: true do expect(gl_auth.find_for_git_client("lfs+deploy-key-#{key.id}", token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(key, nil, :lfs_deploy_token, read_authentication_abilities)) end - it 'recognizes OAuth tokens' do - user = create(:user) - application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) - token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id) - ip = 'ip' + context "while using OAuth tokens as passwords" do + it 'succeeds for OAuth tokens with the `api` scope' do + user = create(:user) + application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) + token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api") + ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'oauth2') - expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'oauth2') + expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities)) + end + + it 'fails for OAuth tokens with other scopes' do + user = create(:user) + application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) + token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "read_user") + ip = 'ip' + + expect(gl_auth).to receive(:rate_limit!).with(ip, success: false, login: 'oauth2') + expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, nil)) + end + end + + context "while using personal access tokens as passwords" do + it 'succeeds for personal access tokens with the `api` scope' do + user = create(:user) + personal_access_token = create(:personal_access_token, user: user, scopes: ['api']) + ip = 'ip' + + expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: user.email) + expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :personal_token, full_authentication_abilities)) + end + + it 'fails for personal access tokens with other scopes' do + user = create(:user) + personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) + ip = 'ip' + + expect(gl_auth).to receive(:rate_limit!).with(ip, success: false, login: user.email) + expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, nil)) + end end it 'returns double nil for invalid credentials' do diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index f1728d61def..d71bb08c218 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -230,7 +230,7 @@ describe 'Git HTTP requests', lib: true do context "when an oauth token is provided" do before do application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) - @token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id) + @token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api") end it "downloads get status 200" do From ac9835c602f1c9b5a35ef40df079faf1d4b91f7b Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 22 Nov 2016 16:20:21 +0530 Subject: [PATCH 105/175] Update CHANGELOG --- changelogs/unreleased/20492-access-token-scopes.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/20492-access-token-scopes.yml diff --git a/changelogs/unreleased/20492-access-token-scopes.yml b/changelogs/unreleased/20492-access-token-scopes.yml new file mode 100644 index 00000000000..a9424ded662 --- /dev/null +++ b/changelogs/unreleased/20492-access-token-scopes.yml @@ -0,0 +1,4 @@ +--- +title: Add scopes for personal access tokens and OAuth tokens +merge_request: 5951 +author: From 4d6da770de94f4bf140507cdf43461b67269ce28 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 24 Nov 2016 13:07:22 +0530 Subject: [PATCH 106/175] Implement minor changes from @dbalexandre's review. - Mainly whitespace changes. - Require the migration adding the `scope` column to the `personal_access_tokens` table to have downtime, since API calls will fail if the new code is in place, but the migration hasn't run. - Minor refactoring - load `@scopes` in a `before_action`, since we're doing it in three different places. --- .../admin/applications_controller.rb | 3 +- .../concerns/oauth_applications.rb | 5 ++++ .../oauth/applications_controller.rb | 6 +--- .../doorkeeper/applications/_form.html.haml | 1 - .../doorkeeper/applications/show.html.haml | 1 - ...column_scopes_to_personal_access_tokens.rb | 23 ++------------- ...cess_tokens_default_back_to_empty_array.rb | 28 ++----------------- lib/api/api_guard.rb | 26 +++++++++-------- lib/gitlab/auth.rb | 1 - .../access_token_validation_service_spec.rb | 1 - 10 files changed, 28 insertions(+), 67 deletions(-) diff --git a/app/controllers/admin/applications_controller.rb b/app/controllers/admin/applications_controller.rb index 759044910bb..62f62e99a97 100644 --- a/app/controllers/admin/applications_controller.rb +++ b/app/controllers/admin/applications_controller.rb @@ -2,6 +2,7 @@ class Admin::ApplicationsController < Admin::ApplicationController include OauthApplications before_action :set_application, only: [:show, :edit, :update, :destroy] + before_action :load_scopes, only: [:new, :edit] def index @applications = Doorkeeper::Application.where("owner_id IS NULL") @@ -12,11 +13,9 @@ class Admin::ApplicationsController < Admin::ApplicationController def new @application = Doorkeeper::Application.new - @scopes = Doorkeeper.configuration.scopes end def edit - @scopes = Doorkeeper.configuration.scopes end def create diff --git a/app/controllers/concerns/oauth_applications.rb b/app/controllers/concerns/oauth_applications.rb index 34ad43ededd..7210ed3eb32 100644 --- a/app/controllers/concerns/oauth_applications.rb +++ b/app/controllers/concerns/oauth_applications.rb @@ -7,8 +7,13 @@ module OauthApplications def prepare_scopes scopes = params.dig(:doorkeeper_application, :scopes) + if scopes params[:doorkeeper_application][:scopes] = scopes.join(' ') end end + + def load_scopes + @scopes = Doorkeeper.configuration.scopes + end end diff --git a/app/controllers/oauth/applications_controller.rb b/app/controllers/oauth/applications_controller.rb index b5449a6c30e..2ae4785b12c 100644 --- a/app/controllers/oauth/applications_controller.rb +++ b/app/controllers/oauth/applications_controller.rb @@ -7,6 +7,7 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController before_action :verify_user_oauth_applications_enabled before_action :authenticate_user! before_action :add_gon_variables + before_action :load_scopes, only: [:index, :create, :edit] layout 'profile' @@ -14,10 +15,6 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController set_index_vars end - def edit - @scopes = Doorkeeper.configuration.scopes - end - def create @application = Doorkeeper::Application.new(application_params) @@ -45,7 +42,6 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController @authorized_tokens = current_user.oauth_authorized_tokens @authorized_anonymous_tokens = @authorized_tokens.reject(&:application) @authorized_apps = @authorized_tokens.map(&:application).uniq.reject(&:nil?) - @scopes = Doorkeeper.configuration.scopes # Don't overwrite a value possibly set by `create` @application ||= Doorkeeper::Application.new diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 6fdb04077b6..96677dc1a4d 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -25,6 +25,5 @@ = label_tag "doorkeeper_application_scopes_#{scope}", scope %span= "(#{t(scope, scope: [:doorkeeper, :scopes])})" - .prepend-top-default = f.submit 'Save application', class: "btn btn-create" diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index a18e133c8de..5473a8e0ddc 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -34,7 +34,6 @@ %span.scope-name= scope = "(#{t(scope, scope: [:doorkeeper, :scopes])})" - .form-actions = link_to 'Edit', edit_oauth_application_path(@application), class: 'btn btn-primary wide pull-left' = render 'delete_form', application: @application, submit_btn_css: 'btn btn-danger prepend-left-10' diff --git a/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb b/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb index ab7f0365603..91479de840b 100644 --- a/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb +++ b/db/migrate/20160823083941_add_column_scopes_to_personal_access_tokens.rb @@ -1,32 +1,15 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. +# The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. +# It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to +# `[]`. class AddColumnScopesToPersonalAccessTokens < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers - # Set this constant to true if this migration requires downtime. DOWNTIME = false - # When a migration requires downtime you **must** uncomment the following - # constant and define a short and easy to understand explanation as to why the - # migration requires downtime. - # DOWNTIME_REASON = '' - - # When using the methods "add_concurrent_index" or "add_column_with_default" - # you must disable the use of transactions as these methods can not run in an - # existing transaction. When using "add_concurrent_index" make sure that this - # method is the _only_ method called in the migration, any other changes - # should go in a separate migration. This ensures that upon failure _only_ the - # index creation fails and can be retried or reverted easily. - # - # To disable transactions uncomment the following line and remove these - # comments: disable_ddl_transaction! def up - # The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. - # It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to - # `[]`. add_column_with_default :personal_access_tokens, :scopes, :string, default: ['api'].to_yaml end diff --git a/db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb b/db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb index 018cc3d4747..c8ceb116b8a 100644 --- a/db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb +++ b/db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb @@ -1,39 +1,17 @@ -# See http://doc.gitlab.com/ce/development/migration_style_guide.html -# for more information on how to write migrations for GitLab. +# The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. +# It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to +# `[]`. class ChangePersonalAccessTokensDefaultBackToEmptyArray < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers - # Set this constant to true if this migration requires downtime. DOWNTIME = false - # When a migration requires downtime you **must** uncomment the following - # constant and define a short and easy to understand explanation as to why the - # migration requires downtime. - # DOWNTIME_REASON = '' - - # When using the methods "add_concurrent_index" or "add_column_with_default" - # you must disable the use of transactions as these methods can not run in an - # existing transaction. When using "add_concurrent_index" make sure that this - # method is the _only_ method called in the migration, any other changes - # should go in a separate migration. This ensures that upon failure _only_ the - # index creation fails and can be retried or reverted easily. - # - # To disable transactions uncomment the following line and remove these - # comments: - # disable_ddl_transaction! - def up - # The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. - # It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to - # `[]`. change_column_default :personal_access_tokens, :scopes, [].to_yaml end def down - # The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. - # It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to - # `[]`. change_column_default :personal_access_tokens, :scopes, ['api'].to_yaml end end diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index cd266669b1e..563224a580f 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -44,17 +44,21 @@ module API # Defaults to empty array. # def doorkeeper_guard(scopes: []) - if access_token = find_access_token - case AccessTokenValidationService.validate(access_token, scopes: scopes) - when AccessTokenValidationService::INSUFFICIENT_SCOPE - raise InsufficientScopeError.new(scopes) - when AccessTokenValidationService::EXPIRED - raise ExpiredError - when AccessTokenValidationService::REVOKED - raise RevokedError - when AccessTokenValidationService::VALID - @current_user = User.find(access_token.resource_owner_id) - end + access_token = find_access_token + return nil unless access_token + + case AccessTokenValidationService.validate(access_token, scopes: scopes) + when AccessTokenValidationService::INSUFFICIENT_SCOPE + raise InsufficientScopeError.new(scopes) + + when AccessTokenValidationService::EXPIRED + raise ExpiredError + + when AccessTokenValidationService::REVOKED + raise RevokedError + + when AccessTokenValidationService::VALID + @current_user = User.find(access_token.resource_owner_id) end end diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index c6a23aa2bdf..c425702fd75 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -107,7 +107,6 @@ module Gitlab if token && token.user == validation && token_has_scope?(token) Gitlab::Auth::Result.new(validation, nil, :personal_token, full_authentication_abilities) end - end end diff --git a/spec/services/access_token_validation_service_spec.rb b/spec/services/access_token_validation_service_spec.rb index 8808934fa24..332e745aa36 100644 --- a/spec/services/access_token_validation_service_spec.rb +++ b/spec/services/access_token_validation_service_spec.rb @@ -1,7 +1,6 @@ require 'spec_helper' describe AccessTokenValidationService, services: true do - describe ".sufficient_scope?" do it "returns true if the required scope is present in the token's scopes" do token = double("token", scopes: [:api, :read_user]) From 990ae6b8e5f2797a6c168f9c16a725a159570058 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 24 Nov 2016 14:22:03 +0530 Subject: [PATCH 107/175] Move the scopes form/list view into a partial. - The list of scopes that's displayed while creating a personal access token is identical to the list that's displayed while creating an OAuth application. Extract these into a partial. - The list of scopes that's displayed while in the show page for an OAuth token in the profile settings and admin settings are identical. Extract these into a partial. --- app/views/admin/applications/show.html.haml | 11 +---------- app/views/doorkeeper/applications/_form.html.haml | 6 +----- app/views/doorkeeper/applications/show.html.haml | 11 +---------- .../profiles/personal_access_tokens/_form.html.haml | 6 +----- app/views/shared/tokens/_scopes_form.html.haml | 5 +++++ app/views/shared/tokens/_scopes_list.html.haml | 10 ++++++++++ 6 files changed, 19 insertions(+), 30 deletions(-) create mode 100644 app/views/shared/tokens/_scopes_form.html.haml create mode 100644 app/views/shared/tokens/_scopes_list.html.haml diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 3418dc96496..6e7b7003ac5 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -23,16 +23,7 @@ %div %span.monospace= uri - - if @application.scopes.present? - %tr - %td - Scopes - %td - %ul.scopes-list.append-bottom-0 - - @application.scopes.each do |scope| - %li - %span.scope-name= scope - = "(#{t(scope, scope: [:doorkeeper, :scopes])})" + = render partial: "shared/tokens/scopes_list" .form-actions = link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide pull-left' diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index 96677dc1a4d..a6ad0bb8d1b 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -19,11 +19,7 @@ .form-group = f.label :scopes, class: 'label-light' - - @scopes.each do |scope| - %fieldset - = check_box_tag 'doorkeeper_application[scopes][]', scope, application.scopes.include?(scope), id: "doorkeeper_application_scopes_#{scope}" - = label_tag "doorkeeper_application_scopes_#{scope}", scope - %span= "(#{t(scope, scope: [:doorkeeper, :scopes])})" + = render partial: 'shared/tokens/scopes_form', locals: { prefix: 'doorkeeper_application', token: application } .prepend-top-default = f.submit 'Save application', class: "btn btn-create" diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index 5473a8e0ddc..e528cb825f5 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -23,16 +23,7 @@ %div %span.monospace= uri - - if @application.scopes.present? - %tr - %td - Scopes - %td - %ul.scopes-list.append-bottom-0 - - @application.scopes.each do |scope| - %li - %span.scope-name= scope - = "(#{t(scope, scope: [:doorkeeper, :scopes])})" + = render partial: "shared/tokens/scopes_list" .form-actions = link_to 'Edit', edit_oauth_application_path(@application), class: 'btn btn-primary wide pull-left' diff --git a/app/views/profiles/personal_access_tokens/_form.html.haml b/app/views/profiles/personal_access_tokens/_form.html.haml index 6083fdaa31d..5651b242129 100644 --- a/app/views/profiles/personal_access_tokens/_form.html.haml +++ b/app/views/profiles/personal_access_tokens/_form.html.haml @@ -12,11 +12,7 @@ .form-group = f.label :scopes, class: 'label-light' - - @scopes.each do |scope| - %fieldset - = check_box_tag 'personal_access_token[scopes][]', scope, @personal_access_token.scopes.include?(scope), id: "personal_access_token_scopes_#{scope}" - = label_tag "personal_access_token_scopes_#{scope}", scope - %span= "(#{t(scope, scope: [:doorkeeper, :scopes])})" + = render partial: 'shared/tokens/scopes_form', locals: { prefix: 'personal_access_token', token: @personal_access_token } .prepend-top-default = f.submit 'Create Personal Access Token', class: "btn btn-create" diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml new file mode 100644 index 00000000000..5dbbd9e4808 --- /dev/null +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -0,0 +1,5 @@ +- @scopes.each do |scope| + %fieldset + = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}" + = label_tag "#{prefix}_scopes_#{scope}", scope + %span= "(#{t(scope, scope: [:doorkeeper, :scopes])})" diff --git a/app/views/shared/tokens/_scopes_list.html.haml b/app/views/shared/tokens/_scopes_list.html.haml new file mode 100644 index 00000000000..9e3b562f0f5 --- /dev/null +++ b/app/views/shared/tokens/_scopes_list.html.haml @@ -0,0 +1,10 @@ +- if @application.scopes.present? + %tr + %td + Scopes + %td + %ul.scopes-list.append-bottom-0 + - @application.scopes.each do |scope| + %li + %span.scope-name= scope + = "(#{t(scope, scope: [:doorkeeper, :scopes])})" From dc95bcbb165289d9754e6bf66288c8d4350f6e57 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 24 Nov 2016 14:39:12 +0530 Subject: [PATCH 108/175] Refactor access token validation in `Gitlab::Auth` - Based on @dbalexandre's review - Extract token validity conditions into two separate methods, for personal access tokens and OAuth tokens. --- lib/gitlab/auth.rb | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index c425702fd75..c21afaa1551 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -92,7 +92,7 @@ module Gitlab def oauth_access_token_check(login, password) if login == "oauth2" && password.present? token = Doorkeeper::AccessToken.by_token(password) - if token && token.accessible? && token_has_scope?(token) + if valid_oauth_token?(token) user = User.find_by(id: token.resource_owner_id) Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities) end @@ -104,12 +104,20 @@ module Gitlab token = PersonalAccessToken.active.find_by_token(password) validation = User.by_login(login) - if token && token.user == validation && token_has_scope?(token) + if valid_personal_access_token?(token, validation) Gitlab::Auth::Result.new(validation, nil, :personal_token, full_authentication_abilities) end end end + def valid_oauth_token?(token) + token && token.accessible? && token_has_scope?(token) + end + + def valid_personal_access_token?(token, user) + token && token.user == user && token_has_scope?(token) + end + def token_has_scope?(token) AccessTokenValidationService.sufficient_scope?(token, ['api']) end From fc7a5a3806c7c7317731ea305715fe0573b90d88 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 28 Nov 2016 12:30:41 +0530 Subject: [PATCH 109/175] Modify `ApiHelpers` spec to adhere to the Four-Phase test style. - Use whitespace to separate the setup, expectation and teardown phases. --- spec/requests/api/helpers_spec.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/spec/requests/api/helpers_spec.rb b/spec/requests/api/helpers_spec.rb index 15b93118ee4..c3d7ac3eef8 100644 --- a/spec/requests/api/helpers_spec.rb +++ b/spec/requests/api/helpers_spec.rb @@ -97,20 +97,26 @@ describe API::Helpers, api: true do it "returns nil for an invalid token" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' allow_any_instance_of(self.class).to receive(:doorkeeper_guard){ false } + expect(current_user).to be_nil end it "returns nil for a user without access" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) + expect(current_user).to be_nil end it "leaves user as is when sudo not specified" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = user.private_token + expect(current_user).to eq(user) + clear_env + params[API::APIGuard::PRIVATE_TOKEN_PARAM] = user.private_token + expect(current_user).to eq(user) end end @@ -124,12 +130,14 @@ describe API::Helpers, api: true do it "returns nil for an invalid token" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = 'invalid token' + expect(current_user).to be_nil end it "returns nil for a user without access" do env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_any_instance_of(Gitlab::UserAccess).to receive(:allowed?).and_return(false) + expect(current_user).to be_nil end @@ -137,6 +145,7 @@ describe API::Helpers, api: true do personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token allow_access_with_scope('write_user') + expect(current_user).to be_nil end @@ -145,18 +154,21 @@ describe API::Helpers, api: true do expect(current_user).to eq(user) clear_env params[API::APIGuard::PRIVATE_TOKEN_PARAM] = personal_access_token.token + expect(current_user).to eq(user) end it 'does not allow revoked tokens' do personal_access_token.revoke! env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token + expect(current_user).to be_nil end it 'does not allow expired tokens' do personal_access_token.update_attributes!(expires_at: 1.day.ago) env[API::APIGuard::PRIVATE_TOKEN_HEADER] = personal_access_token.token + expect(current_user).to be_nil end end From f14d423dc7c9ec2d97c83f0c8893661922df4360 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 28 Nov 2016 13:13:53 +0530 Subject: [PATCH 110/175] Add a controller spec for personal access tokens. Split the existing feature spec into both feature and controller specs. Feature specs assert on browser DOM, and controller specs assert on database state. --- .../profiles/personal_access_tokens_spec.rb | 49 ++++++++++++++++++ .../profiles/personal_access_tokens_spec.rb | 51 ++++--------------- 2 files changed, 60 insertions(+), 40 deletions(-) create mode 100644 spec/controllers/profiles/personal_access_tokens_spec.rb diff --git a/spec/controllers/profiles/personal_access_tokens_spec.rb b/spec/controllers/profiles/personal_access_tokens_spec.rb new file mode 100644 index 00000000000..45534a3a587 --- /dev/null +++ b/spec/controllers/profiles/personal_access_tokens_spec.rb @@ -0,0 +1,49 @@ +require 'spec_helper' + +describe Profiles::PersonalAccessTokensController do + let(:user) { create(:user) } + + describe '#create' do + def created_token + PersonalAccessToken.order(:created_at).last + end + + before { sign_in(user) } + + it "allows creation of a token" do + name = FFaker::Product.brand + + post :create, personal_access_token: { name: name } + + expect(created_token).not_to be_nil + expect(created_token.name).to eq(name) + expect(created_token.expires_at).to be_nil + expect(PersonalAccessToken.active).to include(created_token) + end + + it "allows creation of a token with an expiry date" do + expires_at = 5.days.from_now + + post :create, personal_access_token: { name: FFaker::Product.brand, expires_at: expires_at } + + expect(created_token).not_to be_nil + expect(created_token.expires_at.to_i).to eq(expires_at.to_i) + end + + context "scopes" do + it "allows creation of a token with scopes" do + post :create, personal_access_token: { name: FFaker::Product.brand, scopes: ['api', 'read_user'] } + + expect(created_token).not_to be_nil + expect(created_token.scopes).to eq(['api', 'read_user']) + end + + it "allows creation of a token with no scopes" do + post :create, personal_access_token: { name: FFaker::Product.brand, scopes: [] } + + expect(created_token).not_to be_nil + expect(created_token.scopes).to eq([]) + end + end + end +end diff --git a/spec/features/profiles/personal_access_tokens_spec.rb b/spec/features/profiles/personal_access_tokens_spec.rb index 0ffeeff0921..55a01057c83 100644 --- a/spec/features/profiles/personal_access_tokens_spec.rb +++ b/spec/features/profiles/personal_access_tokens_spec.rb @@ -27,54 +27,25 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do describe "token creation" do it "allows creation of a token" do - visit profile_personal_access_tokens_path - fill_in "Name", with: FFaker::Product.brand + name = FFaker::Product.brand - expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) - expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.name) - expect(active_personal_access_tokens).to have_text("Never") - end - - it "allows creation of a token with an expiry date" do visit profile_personal_access_tokens_path - fill_in "Name", with: FFaker::Product.brand + fill_in "Name", with: name # Set date to 1st of next month find_field("Expires at").trigger('focus') find("a[title='Next']").click click_on "1" - expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) - expect(active_personal_access_tokens).to have_text(PersonalAccessToken.last.name) + # Scopes + check "api" + check "read_user" + + click_on "Create Personal Access Token" + expect(active_personal_access_tokens).to have_text(name) expect(active_personal_access_tokens).to have_text(Date.today.next_month.at_beginning_of_month.to_s(:medium)) - end - - context "scopes" do - it "allows creation of a token with scopes" do - visit profile_personal_access_tokens_path - fill_in "Name", with: FFaker::Product.brand - - check "api" - check "read_user" - - expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) - expect(PersonalAccessToken.last.scopes).to match_array(['api', 'read_user']) - expect(active_personal_access_tokens).to have_text('api') - expect(active_personal_access_tokens).to have_text('read_user') - end - - it "allows creation of a token with no scopes" do - visit profile_personal_access_tokens_path - fill_in "Name", with: FFaker::Product.brand - - expect {click_on "Create Personal Access Token"}.to change { PersonalAccessToken.count }.by(1) - expect(created_personal_access_token).to eq(PersonalAccessToken.last.token) - expect(PersonalAccessToken.last.scopes).to eq([]) - expect(active_personal_access_tokens).to have_text('no scopes') - end + expect(active_personal_access_tokens).to have_text('api') + expect(active_personal_access_tokens).to have_text('read_user') end context "when creation fails" do @@ -111,7 +82,7 @@ describe 'Profile > Personal Access Tokens', feature: true, js: true do disallow_personal_access_token_saves! visit profile_personal_access_tokens_path - expect { click_on "Revoke" }.not_to change { PersonalAccessToken.inactive.count } + click_on "Revoke" expect(active_personal_access_tokens).to have_text(personal_access_token.name) expect(page).to have_content("Could not revoke") end From f706a973c26f9de9a1f1599d532b33e9e66a80bb Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 5 Dec 2016 09:49:51 +0530 Subject: [PATCH 111/175] View-related (and other minor) changes to !5951 based on @rymai's review. - The `scopes_form` partial can be used in the `admin/applications` view as well - Don't allow partials to access instance variables directly. Instead, pass in the instance variables as local variables, and use `local_assigns.fetch` to assert that the variables are passed in as expected. - Change a few instances of `render :partial` to `render` - Remove an instance of `required: false` in a view, since this is the default - Inline many instances of a local variable (`ip = 'ip'`) in `auth_spec` --- app/views/admin/applications/_form.html.haml | 6 +-- app/views/admin/applications/show.html.haml | 2 +- .../doorkeeper/applications/_form.html.haml | 2 +- .../doorkeeper/applications/show.html.haml | 2 +- .../personal_access_tokens/_form.html.haml | 11 +++-- .../personal_access_tokens/index.html.haml | 2 +- .../shared/tokens/_scopes_form.html.haml | 6 ++- .../shared/tokens/_scopes_list.html.haml | 23 ++++++---- spec/lib/gitlab/auth_spec.rb | 46 ++++++++----------- 9 files changed, 48 insertions(+), 52 deletions(-) diff --git a/app/views/admin/applications/_form.html.haml b/app/views/admin/applications/_form.html.haml index 36d2f415a05..c689b26d6e6 100644 --- a/app/views/admin/applications/_form.html.haml +++ b/app/views/admin/applications/_form.html.haml @@ -22,11 +22,7 @@ .form-group = f.label :scopes, class: 'col-sm-2 control-label' .col-sm-10 - - @scopes.each do |scope| - %fieldset - = check_box_tag 'doorkeeper_application[scopes][]', scope, application.scopes.include?(scope), id: "doorkeeper_application_scopes_#{scope}" - = label_tag "doorkeeper_application_scopes_#{scope}", scope - %span= "(#{t(scope, scope: [:doorkeeper, :scopes])})" + = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes .form-actions = f.submit 'Submit', class: "btn btn-save wide" diff --git a/app/views/admin/applications/show.html.haml b/app/views/admin/applications/show.html.haml index 6e7b7003ac5..14683cc66e9 100644 --- a/app/views/admin/applications/show.html.haml +++ b/app/views/admin/applications/show.html.haml @@ -23,7 +23,7 @@ %div %span.monospace= uri - = render partial: "shared/tokens/scopes_list" + = render "shared/tokens/scopes_list", token: @application .form-actions = link_to 'Edit', edit_admin_application_path(@application), class: 'btn btn-primary wide pull-left' diff --git a/app/views/doorkeeper/applications/_form.html.haml b/app/views/doorkeeper/applications/_form.html.haml index a6ad0bb8d1b..b3313c7c985 100644 --- a/app/views/doorkeeper/applications/_form.html.haml +++ b/app/views/doorkeeper/applications/_form.html.haml @@ -19,7 +19,7 @@ .form-group = f.label :scopes, class: 'label-light' - = render partial: 'shared/tokens/scopes_form', locals: { prefix: 'doorkeeper_application', token: application } + = render 'shared/tokens/scopes_form', prefix: 'doorkeeper_application', token: application, scopes: @scopes .prepend-top-default = f.submit 'Save application', class: "btn btn-create" diff --git a/app/views/doorkeeper/applications/show.html.haml b/app/views/doorkeeper/applications/show.html.haml index e528cb825f5..559de63d96d 100644 --- a/app/views/doorkeeper/applications/show.html.haml +++ b/app/views/doorkeeper/applications/show.html.haml @@ -23,7 +23,7 @@ %div %span.monospace= uri - = render partial: "shared/tokens/scopes_list" + = render "shared/tokens/scopes_list", token: @application .form-actions = link_to 'Edit', edit_oauth_application_path(@application), class: 'btn btn-primary wide pull-left' diff --git a/app/views/profiles/personal_access_tokens/_form.html.haml b/app/views/profiles/personal_access_tokens/_form.html.haml index 5651b242129..3f6efa33953 100644 --- a/app/views/profiles/personal_access_tokens/_form.html.haml +++ b/app/views/profiles/personal_access_tokens/_form.html.haml @@ -1,6 +1,9 @@ -= form_for [:profile, @personal_access_token], method: :post, html: { class: 'js-requires-input' } do |f| +- personal_access_token = local_assigns.fetch(:personal_access_token) +- scopes = local_assigns.fetch(:scopes) - = form_errors(@personal_access_token) += form_for [:profile, personal_access_token], method: :post, html: { class: 'js-requires-input' } do |f| + + = form_errors(personal_access_token) .form-group = f.label :name, class: 'label-light' @@ -8,11 +11,11 @@ .form-group = f.label :expires_at, class: 'label-light' - = f.text_field :expires_at, class: "datepicker form-control", required: false + = f.text_field :expires_at, class: "datepicker form-control" .form-group = f.label :scopes, class: 'label-light' - = render partial: 'shared/tokens/scopes_form', locals: { prefix: 'personal_access_token', token: @personal_access_token } + = render 'shared/tokens/scopes_form', prefix: 'personal_access_token', token: personal_access_token, scopes: scopes .prepend-top-default = f.submit 'Create Personal Access Token', class: "btn btn-create" diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 39eef0f6baf..bb4effeeeb1 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -29,7 +29,7 @@ %p.profile-settings-content Pick a name for the application, and we'll give you a unique token. - = render "form" + = render "form", personal_access_token: @personal_access_token, scopes: @scopes %hr diff --git a/app/views/shared/tokens/_scopes_form.html.haml b/app/views/shared/tokens/_scopes_form.html.haml index 5dbbd9e4808..5074afb63a1 100644 --- a/app/views/shared/tokens/_scopes_form.html.haml +++ b/app/views/shared/tokens/_scopes_form.html.haml @@ -1,4 +1,8 @@ -- @scopes.each do |scope| +- scopes = local_assigns.fetch(:scopes) +- prefix = local_assigns.fetch(:prefix) +- token = local_assigns.fetch(:token) + +- scopes.each do |scope| %fieldset = check_box_tag "#{prefix}[scopes][]", scope, token.scopes.include?(scope), id: "#{prefix}_scopes_#{scope}" = label_tag "#{prefix}_scopes_#{scope}", scope diff --git a/app/views/shared/tokens/_scopes_list.html.haml b/app/views/shared/tokens/_scopes_list.html.haml index 9e3b562f0f5..f99e905e95c 100644 --- a/app/views/shared/tokens/_scopes_list.html.haml +++ b/app/views/shared/tokens/_scopes_list.html.haml @@ -1,10 +1,13 @@ -- if @application.scopes.present? - %tr - %td - Scopes - %td - %ul.scopes-list.append-bottom-0 - - @application.scopes.each do |scope| - %li - %span.scope-name= scope - = "(#{t(scope, scope: [:doorkeeper, :scopes])})" +- token = local_assigns.fetch(:token) + +- return unless token.scopes.present? + +%tr + %td + Scopes + %td + %ul.scopes-list.append-bottom-0 + - token.scopes.each do |scope| + %li + %span.scope-name= scope + = "(#{t(scope, scope: [:doorkeeper, :scopes])})" diff --git a/spec/lib/gitlab/auth_spec.rb b/spec/lib/gitlab/auth_spec.rb index b64413cda12..f251c0dd25a 100644 --- a/spec/lib/gitlab/auth_spec.rb +++ b/spec/lib/gitlab/auth_spec.rb @@ -47,36 +47,31 @@ describe Gitlab::Auth, lib: true do project.create_drone_ci_service(active: true) project.drone_ci_service.update(token: 'token') - ip = 'ip' - - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'drone-ci-token') - expect(gl_auth.find_for_git_client('drone-ci-token', 'token', project: project, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, project, :ci, build_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: 'drone-ci-token') + expect(gl_auth.find_for_git_client('drone-ci-token', 'token', project: project, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, project, :ci, build_authentication_abilities)) end it 'recognizes master passwords' do user = create(:user, password: 'password') - ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: user.username) - expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: user.username) + expect(gl_auth.find_for_git_client(user.username, 'password', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :gitlab_or_ldap, full_authentication_abilities)) end it 'recognizes user lfs tokens' do user = create(:user) - ip = 'ip' token = Gitlab::LfsToken.new(user).token - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: user.username) - expect(gl_auth.find_for_git_client(user.username, token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :lfs_token, full_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: user.username) + expect(gl_auth.find_for_git_client(user.username, token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :lfs_token, full_authentication_abilities)) end it 'recognizes deploy key lfs tokens' do key = create(:deploy_key) - ip = 'ip' token = Gitlab::LfsToken.new(key).token - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: "lfs+deploy-key-#{key.id}") - expect(gl_auth.find_for_git_client("lfs+deploy-key-#{key.id}", token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(key, nil, :lfs_deploy_token, read_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: "lfs+deploy-key-#{key.id}") + expect(gl_auth.find_for_git_client("lfs+deploy-key-#{key.id}", token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(key, nil, :lfs_deploy_token, read_authentication_abilities)) end context "while using OAuth tokens as passwords" do @@ -84,20 +79,18 @@ describe Gitlab::Auth, lib: true do user = create(:user) application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "api") - ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: 'oauth2') - expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: 'oauth2') + expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :oauth, read_authentication_abilities)) end it 'fails for OAuth tokens with other scopes' do user = create(:user) application = Doorkeeper::Application.create!(name: "MyApp", redirect_uri: "https://app.com", owner: user) token = Doorkeeper::AccessToken.create!(application_id: application.id, resource_owner_id: user.id, scopes: "read_user") - ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: false, login: 'oauth2') - expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, nil)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: 'oauth2') + expect(gl_auth.find_for_git_client("oauth2", token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) end end @@ -105,28 +98,25 @@ describe Gitlab::Auth, lib: true do it 'succeeds for personal access tokens with the `api` scope' do user = create(:user) personal_access_token = create(:personal_access_token, user: user, scopes: ['api']) - ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: true, login: user.email) - expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(user, nil, :personal_token, full_authentication_abilities)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: true, login: user.email) + expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(user, nil, :personal_token, full_authentication_abilities)) end it 'fails for personal access tokens with other scopes' do user = create(:user) personal_access_token = create(:personal_access_token, user: user, scopes: ['read_user']) - ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: false, login: user.email) - expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new(nil, nil)) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: user.email) + expect(gl_auth.find_for_git_client(user.email, personal_access_token.token, project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new(nil, nil)) end end it 'returns double nil for invalid credentials' do login = 'foo' - ip = 'ip' - expect(gl_auth).to receive(:rate_limit!).with(ip, success: false, login: login) - expect(gl_auth.find_for_git_client(login, 'bar', project: nil, ip: ip)).to eq(Gitlab::Auth::Result.new) + expect(gl_auth).to receive(:rate_limit!).with('ip', success: false, login: login) + expect(gl_auth.find_for_git_client(login, 'bar', project: nil, ip: 'ip')).to eq(Gitlab::Auth::Result.new) end end From b303948ff549ce57d3b6985c2c366dfcdc5a2ca3 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 5 Dec 2016 22:55:53 +0530 Subject: [PATCH 112/175] Convert AccessTokenValidationService into a class. - Previously, AccessTokenValidationService was a module, and all its public methods accepted a token. It makes sense to convert it to a class which accepts a token during initialization. - Also rename the `sufficient_scope?` method to `include_any_scope?` - Based on feedback from @rymai --- .../access_token_validation_service.rb | 38 +++++++++---------- lib/api/api_guard.rb | 4 +- lib/gitlab/auth.rb | 2 +- .../access_token_validation_service_spec.rb | 14 +++---- 4 files changed, 28 insertions(+), 30 deletions(-) diff --git a/app/services/access_token_validation_service.rb b/app/services/access_token_validation_service.rb index 69449f3a445..ddaaed90e5b 100644 --- a/app/services/access_token_validation_service.rb +++ b/app/services/access_token_validation_service.rb @@ -1,34 +1,32 @@ -module AccessTokenValidationService +AccessTokenValidationService = Struct.new(:token) do # Results: VALID = :valid EXPIRED = :expired REVOKED = :revoked INSUFFICIENT_SCOPE = :insufficient_scope - class << self - def validate(token, scopes: []) - if token.expired? - return EXPIRED + def validate(scopes: []) + if token.expired? + return EXPIRED - elsif token.revoked? - return REVOKED + elsif token.revoked? + return REVOKED - elsif !self.sufficient_scope?(token, scopes) - return INSUFFICIENT_SCOPE + elsif !self.include_any_scope?(scopes) + return INSUFFICIENT_SCOPE - else - return VALID - end + else + return VALID end + end - # True if the token's scope contains any of the required scopes. - def sufficient_scope?(token, required_scopes) - if required_scopes.blank? - true - else - # Check whether the token is allowed access to any of the required scopes. - Set.new(required_scopes).intersection(Set.new(token.scopes)).present? - end + # True if the token's scope contains any of the passed scopes. + def include_any_scope?(scopes) + if scopes.blank? + true + else + # Check whether the token is allowed access to any of the required scopes. + Set.new(scopes).intersection(Set.new(token.scopes)).present? end end end diff --git a/lib/api/api_guard.rb b/lib/api/api_guard.rb index 563224a580f..df6db140d0e 100644 --- a/lib/api/api_guard.rb +++ b/lib/api/api_guard.rb @@ -47,7 +47,7 @@ module API access_token = find_access_token return nil unless access_token - case AccessTokenValidationService.validate(access_token, scopes: scopes) + case AccessTokenValidationService.new(access_token).validate(scopes: scopes) when AccessTokenValidationService::INSUFFICIENT_SCOPE raise InsufficientScopeError.new(scopes) @@ -96,7 +96,7 @@ module API access_token = PersonalAccessToken.active.find_by_token(token_string) return unless access_token - if AccessTokenValidationService.sufficient_scope?(access_token, scopes) + if AccessTokenValidationService.new(access_token).include_any_scope?(scopes) User.find(access_token.user_id) end end diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index c21afaa1551..2879a4d2f5d 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -119,7 +119,7 @@ module Gitlab end def token_has_scope?(token) - AccessTokenValidationService.sufficient_scope?(token, ['api']) + AccessTokenValidationService.new(token).include_any_scope?(['api']) end def lfs_token_check(login, password) diff --git a/spec/services/access_token_validation_service_spec.rb b/spec/services/access_token_validation_service_spec.rb index 332e745aa36..87f093ee8ce 100644 --- a/spec/services/access_token_validation_service_spec.rb +++ b/spec/services/access_token_validation_service_spec.rb @@ -1,41 +1,41 @@ require 'spec_helper' describe AccessTokenValidationService, services: true do - describe ".sufficient_scope?" do + describe ".include_any_scope?" do it "returns true if the required scope is present in the token's scopes" do token = double("token", scopes: [:api, :read_user]) - expect(described_class.sufficient_scope?(token, [:api])).to be(true) + expect(described_class.new(token).include_any_scope?([:api])).to be(true) end it "returns true if more than one of the required scopes is present in the token's scopes" do token = double("token", scopes: [:api, :read_user, :other_scope]) - expect(described_class.sufficient_scope?(token, [:api, :other_scope])).to be(true) + expect(described_class.new(token).include_any_scope?([:api, :other_scope])).to be(true) end it "returns true if the list of required scopes is an exact match for the token's scopes" do token = double("token", scopes: [:api, :read_user, :other_scope]) - expect(described_class.sufficient_scope?(token, [:api, :read_user, :other_scope])).to be(true) + expect(described_class.new(token).include_any_scope?([:api, :read_user, :other_scope])).to be(true) end it "returns true if the list of required scopes contains all of the token's scopes, in addition to others" do token = double("token", scopes: [:api, :read_user]) - expect(described_class.sufficient_scope?(token, [:api, :read_user, :other_scope])).to be(true) + expect(described_class.new(token).include_any_scope?([:api, :read_user, :other_scope])).to be(true) end it 'returns true if the list of required scopes is blank' do token = double("token", scopes: []) - expect(described_class.sufficient_scope?(token, [])).to be(true) + expect(described_class.new(token).include_any_scope?([])).to be(true) end it "returns false if there are no scopes in common between the required scopes and the token scopes" do token = double("token", scopes: [:api, :read_user]) - expect(described_class.sufficient_scope?(token, [:other_scope])).to be(false) + expect(described_class.new(token).include_any_scope?([:other_scope])).to be(false) end end end From 5becbe2495850923604c71b4c807666ea94819b3 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Mon, 5 Dec 2016 22:58:19 +0530 Subject: [PATCH 113/175] Rename the `token_has_scope?` method. `valid_api_token?` is a better name. Scopes are just (potentially) one facet of a "valid" token. --- lib/gitlab/auth.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/gitlab/auth.rb b/lib/gitlab/auth.rb index 2879a4d2f5d..8dda65c71ef 100644 --- a/lib/gitlab/auth.rb +++ b/lib/gitlab/auth.rb @@ -111,14 +111,14 @@ module Gitlab end def valid_oauth_token?(token) - token && token.accessible? && token_has_scope?(token) + token && token.accessible? && valid_api_token?(token) end def valid_personal_access_token?(token, user) - token && token.user == user && token_has_scope?(token) + token && token.user == user && valid_api_token?(token) end - def token_has_scope?(token) + def valid_api_token?(token) AccessTokenValidationService.new(token).include_any_scope?(['api']) end From eb434b15ebbc7d0b7ed79bb2daa45601e3c918ca Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 16 Dec 2016 14:57:09 +0530 Subject: [PATCH 114/175] Make `ChangePersonalAccessTokensDefaultBackToEmptyArray` a "post" migration. If we leave this as a regular migration, we could have the following flow: 1. Application knows nothing about scopes. 2. First migration runs, all existing personal access tokens have `api` scope 3. Application still knows nothing about scopes. 4. Second migration runs, all tokens created after this point have no scope 5. Application still knows nothing about scopes. 6. Tokens created at this time _should have the API scope, but instead have no scope_ 7. Application code is reloaded, application knows about scopes 8. Tokens created after this point only have no scope if the user deliberately chooses to have no scopes. Point #6 is the problem here. To avoid this, we move the second migration to a "post" migration, which runs after the application code is deployed/reloaded. --- ...ge_personal_access_tokens_default_back_to_empty_array.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) rename db/{migrate => post_migrate}/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb (72%) diff --git a/db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb b/db/post_migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb similarity index 72% rename from db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb rename to db/post_migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb index c8ceb116b8a..7df561d82dd 100644 --- a/db/migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb +++ b/db/post_migrate/20160824121037_change_personal_access_tokens_default_back_to_empty_array.rb @@ -1,6 +1,8 @@ # The default needs to be `[]`, but all existing access tokens need to have `scopes` set to `['api']`. -# It's easier to achieve this by adding the column with the `['api']` default, and then changing the default to -# `[]`. +# It's easier to achieve this by adding the column with the `['api']` default (regular migration), and +# then changing the default to `[]` (in this post-migration). +# +# Details: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/5951#note_19721973 class ChangePersonalAccessTokensDefaultBackToEmptyArray < ActiveRecord::Migration include Gitlab::Database::MigrationHelpers From 3b4e81eed50dac796de5720b9975125dc8de609b Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 16 Dec 2016 12:12:53 +0200 Subject: [PATCH 115/175] BB importer: Milestone importer --- lib/bitbucket/representation/issue.rb | 4 ++++ lib/gitlab/bitbucket_import/importer.rb | 2 ++ spec/lib/bitbucket/representation/issue_spec.rb | 6 ++++++ 3 files changed, 12 insertions(+) diff --git a/lib/bitbucket/representation/issue.rb b/lib/bitbucket/representation/issue.rb index ffe8a65d839..3af731753d1 100644 --- a/lib/bitbucket/representation/issue.rb +++ b/lib/bitbucket/representation/issue.rb @@ -27,6 +27,10 @@ module Bitbucket raw['title'] end + def milestone + raw.dig('milestone', 'name') + end + def created_at raw['created_on'] end diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 567f2b314aa..53c95ea4079 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -67,6 +67,7 @@ module Gitlab description += issue.description label_name = issue.kind + milestone = issue.milestone ? project.milestones.find_or_create_by(title: issue.milestone) : nil issue = project.issues.create!( iid: issue.iid, @@ -74,6 +75,7 @@ module Gitlab description: description, state: issue.state, author_id: gitlab_user_id(project, issue.author), + milestone: milestone, created_at: issue.created_at, updated_at: issue.updated_at ) diff --git a/spec/lib/bitbucket/representation/issue_spec.rb b/spec/lib/bitbucket/representation/issue_spec.rb index e1f3419c77e..9a195bebd31 100644 --- a/spec/lib/bitbucket/representation/issue_spec.rb +++ b/spec/lib/bitbucket/representation/issue_spec.rb @@ -9,6 +9,12 @@ describe Bitbucket::Representation::Issue do it { expect(described_class.new('kind' => 'bug').kind).to eq('bug') } end + describe '#milestone' do + it { expect(described_class.new({ 'milestone' => { 'name' => '1.0' } }).milestone).to eq('1.0') } + it { expect(described_class.new({}).milestone).to be_nil } + end + + describe '#author' do it { expect(described_class.new({ 'reporter' => { 'username' => 'Ben' } }).author).to eq('Ben') } it { expect(described_class.new({}).author).to be_nil } From 595da33d738041cf0b2c28a87989aa271f5019cc Mon Sep 17 00:00:00 2001 From: Dimitrie Hoekstra Date: Fri, 16 Dec 2016 13:05:44 +0100 Subject: [PATCH 116/175] fixed scss linting issue --- app/assets/stylesheets/pages/labels.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/pages/labels.scss b/app/assets/stylesheets/pages/labels.scss index 703a429d63c..d129eb12a45 100644 --- a/app/assets/stylesheets/pages/labels.scss +++ b/app/assets/stylesheets/pages/labels.scss @@ -98,7 +98,7 @@ } .label { - padding: 8px 9px 9px 9px; + padding: 8px 9px 9px; font-size: 14px; } } From c945a0a7141ddf80e58e821178195cc48b8143f0 Mon Sep 17 00:00:00 2001 From: Adam Niedzielski Date: Fri, 16 Dec 2016 13:24:03 +0100 Subject: [PATCH 117/175] Pass variables from deployment project services to CI runner This commit introduces the concept of deployment variables - variables that are collected from deployment services and passed to CI runner during a deployment build. Deployment services specify the variables by overriding "predefined_variables" method. This commit also configures variables for KubernetesService --- app/models/ci/build.rb | 3 +- app/models/project.rb | 6 ++++ .../project_services/deployment_service.rb | 4 +++ .../project_services/kubernetes_service.rb | 10 ++++++ .../expose-deployment-variables.yml | 4 +++ doc/ci/variables/README.md | 15 +++++++++ doc/project_services/kubernetes.md | 11 +++++++ spec/models/build_spec.rb | 11 +++++++ .../kubernetes_service_spec.rb | 33 +++++++++++++++++++ spec/models/project_spec.rb | 20 +++++++++++ 10 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/expose-deployment-variables.yml diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index fdbf28a1d68..591aba6bdc9 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -155,7 +155,7 @@ module Ci end def has_environment? - self.environment.present? + environment.present? end def starts_environment? @@ -221,6 +221,7 @@ module Ci variables += pipeline.predefined_variables variables += runner.predefined_variables if runner variables += project.container_registry_variables + variables += project.deployment_variables if has_environment? variables += yaml_variables variables += user_variables variables += project.secret_variables diff --git a/app/models/project.rb b/app/models/project.rb index 2c726cfc5df..5f8058dac60 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1229,6 +1229,12 @@ class Project < ActiveRecord::Base end end + def deployment_variables + return [] unless deployment_service + + deployment_service.predefined_variables + end + def append_or_update_attribute(name, value) old_values = public_send(name.to_s) diff --git a/app/models/project_services/deployment_service.rb b/app/models/project_services/deployment_service.rb index 55e98c31251..da6be9dd7b7 100644 --- a/app/models/project_services/deployment_service.rb +++ b/app/models/project_services/deployment_service.rb @@ -8,4 +8,8 @@ class DeploymentService < Service def supported_events [] end + + def predefined_variables + [] + end end diff --git a/app/models/project_services/kubernetes_service.rb b/app/models/project_services/kubernetes_service.rb index 80ae1191108..f5fbf8b353b 100644 --- a/app/models/project_services/kubernetes_service.rb +++ b/app/models/project_services/kubernetes_service.rb @@ -83,6 +83,16 @@ class KubernetesService < DeploymentService { success: false, result: err } end + def predefined_variables + variables = [ + { key: 'KUBE_URL', value: api_url, public: true }, + { key: 'KUBE_TOKEN', value: token, public: false }, + { key: 'KUBE_NAMESPACE', value: namespace, public: true } + ] + variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } if ca_pem.present? + variables + end + private def build_kubeclient(api_path = '/api', api_version = 'v1') diff --git a/changelogs/unreleased/expose-deployment-variables.yml b/changelogs/unreleased/expose-deployment-variables.yml new file mode 100644 index 00000000000..7663d5b6ae5 --- /dev/null +++ b/changelogs/unreleased/expose-deployment-variables.yml @@ -0,0 +1,4 @@ +--- +title: Pass variables from deployment project services to CI runner +merge_request: 8107 +author: diff --git a/doc/ci/variables/README.md b/doc/ci/variables/README.md index eb540a50606..baa5fc67816 100644 --- a/doc/ci/variables/README.md +++ b/doc/ci/variables/README.md @@ -13,6 +13,7 @@ this order: 1. [Secret variables](#secret-variables) 1. YAML-defined [job-level variables](../yaml/README.md#job-variables) 1. YAML-defined [global variables](../yaml/README.md#variables) +1. [Deployment variables](#deployment-variables) 1. [Predefined variables](#predefined-variables-environment-variables) (are the lowest in the chain) @@ -148,6 +149,20 @@ Secret variables can be added by going to your project's Once you set them, they will be available for all subsequent builds. +## Deployment variables + +>**Note:** +This feature requires GitLab CI 8.15 or higher. + +[Project services](../../project_services/project_services.md) that are +responsible for deployment configuration may define their own variables that +are set in the build environment. These variables are only defined for +[deployment builds](../environments.md). Please consult the documentation of +the project services that you are using to learn which variables they define. + +An example project service that defines deployment variables is +[Kubernetes Service](../../project_services/kubernetes.md). + ## Debug tracing > Introduced in GitLab Runner 1.7. diff --git a/doc/project_services/kubernetes.md b/doc/project_services/kubernetes.md index cb577b608b4..fda364b864e 100644 --- a/doc/project_services/kubernetes.md +++ b/doc/project_services/kubernetes.md @@ -36,3 +36,14 @@ to create one. You can also view or create service tokens in the Fill in the service token and namespace according to the values you just got. If the API is using a self-signed TLS certificate, you'll also need to include the `ca.crt` contents as the `Custom CA bundle`. + +## Deployment variables + +The Kubernetes service exposes following +[deployment variables](../ci/variables/README.md#deployment-variables) in the +GitLab CI build environment: + +- `KUBE_URL` - equal to the API URL +- `KUBE_TOKEN` +- `KUBE_NAMESPACE` +- `KUBE_CA_PEM` - only if a custom CA bundle was specified diff --git a/spec/models/build_spec.rb b/spec/models/build_spec.rb index d5f2ffcff59..6f1c2ae0fd8 100644 --- a/spec/models/build_spec.rb +++ b/spec/models/build_spec.rb @@ -506,6 +506,17 @@ describe Ci::Build, models: true do it { is_expected.to include({ key: 'CI_RUNNER_TAGS', value: 'docker, linux', public: true }) } end + context 'when build is for a deployment' do + let(:deployment_variable) { { key: 'KUBERNETES_TOKEN', value: 'TOKEN', public: false } } + + before do + build.environment = 'production' + allow(project).to receive(:deployment_variables).and_return([deployment_variable]) + end + + it { is_expected.to include(deployment_variable) } + end + context 'returns variables in valid order' do before do allow(build).to receive(:predefined_variables) { ['predefined'] } diff --git a/spec/models/project_services/kubernetes_service_spec.rb b/spec/models/project_services/kubernetes_service_spec.rb index ffb92012b89..3603602e41d 100644 --- a/spec/models/project_services/kubernetes_service_spec.rb +++ b/spec/models/project_services/kubernetes_service_spec.rb @@ -123,4 +123,37 @@ describe KubernetesService, models: true do end end end + + describe '#predefined_variables' do + before do + subject.api_url = 'https://kube.domain.com' + subject.token = 'token' + subject.namespace = 'my-project' + subject.ca_pem = 'CA PEM DATA' + end + + it 'sets KUBE_URL' do + expect(subject.predefined_variables).to include( + { key: 'KUBE_URL', value: 'https://kube.domain.com', public: true } + ) + end + + it 'sets KUBE_TOKEN' do + expect(subject.predefined_variables).to include( + { key: 'KUBE_TOKEN', value: 'token', public: false } + ) + end + + it 'sets KUBE_NAMESPACE' do + expect(subject.predefined_variables).to include( + { key: 'KUBE_NAMESPACE', value: 'my-project', public: true } + ) + end + + it 'sets KUBE_CA_PEM' do + expect(subject.predefined_variables).to include( + { key: 'KUBE_CA_PEM', value: 'CA PEM DATA', public: true } + ) + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 21ff238841e..c7d914a81f9 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -1696,6 +1696,26 @@ describe Project, models: true do end end + describe '#deployment_variables' do + context 'when project has no deployment service' do + let(:project) { create(:empty_project) } + + it 'returns an empty array' do + expect(project.deployment_variables).to eq [] + end + end + + context 'when project has a deployment service' do + let(:project) { create(:kubernetes_project) } + + it 'returns variables from this service' do + expect(project.deployment_variables).to include( + { key: 'KUBE_TOKEN', value: project.kubernetes_service.token, public: false } + ) + end + end + end + def enable_lfs allow(Gitlab.config.lfs).to receive(:enabled).and_return(true) end From df6c1b84a842d6dc54b27e396b60ffd4d7723c4a Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Fri, 16 Dec 2016 12:29:58 +0000 Subject: [PATCH 118/175] Prevent enviroment table to overflow when name has underscores Fix error in linter Add changelog entry --- .../components/environment_item.js.es6 | 2 +- app/assets/stylesheets/pages/environments.scss | 14 ++++++++++---- .../unreleased/25207-text-overflow-env-table.yml | 4 ++++ 3 files changed, 15 insertions(+), 5 deletions(-) create mode 100644 changelogs/unreleased/25207-text-overflow-env-table.yml diff --git a/app/assets/javascripts/environments/components/environment_item.js.es6 b/app/assets/javascripts/environments/components/environment_item.js.es6 index 2e046a60146..4674d5202e6 100644 --- a/app/assets/javascripts/environments/components/environment_item.js.es6 +++ b/app/assets/javascripts/environments/components/environment_item.js.es6 @@ -449,7 +449,7 @@ - +
    diff --git a/app/assets/stylesheets/pages/environments.scss b/app/assets/stylesheets/pages/environments.scss index 92dd9885ab8..3d60426de01 100644 --- a/app/assets/stylesheets/pages/environments.scss +++ b/app/assets/stylesheets/pages/environments.scss @@ -30,19 +30,25 @@ display: table-cell; } + .environments-name, .environments-commit, .environments-actions { width: 20%; } - .environments-deploy, - .environments-build, .environments-date { width: 10%; } - .environments-name { - width: 30%; + .environments-deploy, + .environments-build { + width: 15%; + } + + .environment-name, + .environments-build-cell, + .deployment-column { + word-break: break-all; } .deployment-column { diff --git a/changelogs/unreleased/25207-text-overflow-env-table.yml b/changelogs/unreleased/25207-text-overflow-env-table.yml new file mode 100644 index 00000000000..69348281a50 --- /dev/null +++ b/changelogs/unreleased/25207-text-overflow-env-table.yml @@ -0,0 +1,4 @@ +--- +title: Prevent enviroment table to overflow when name has underscores +merge_request: 8142 +author: From b0501c34c478a528f2aa7633dfa6d13e9c61af64 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 16 Dec 2016 15:40:38 +0200 Subject: [PATCH 119/175] BB importer: address review comment --- .../importing/import_projects_from_bitbucket.md | 2 +- lib/gitlab/bitbucket_import/importer.rb | 10 ++++------ 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/doc/workflow/importing/import_projects_from_bitbucket.md b/doc/workflow/importing/import_projects_from_bitbucket.md index 935d6288f3b..9e1e3c7ba08 100644 --- a/doc/workflow/importing/import_projects_from_bitbucket.md +++ b/doc/workflow/importing/import_projects_from_bitbucket.md @@ -20,7 +20,7 @@ It takes just a few steps to import your existing Bitbucket projects to GitLab. ![Import projects](bitbucket_importer/bitbucket_import_select_project.png) -A new GitLab project will be created with your imported data. Keep in mind that if you want to Bitbucket users +A new GitLab project will be created with your imported data. Keep in mind that if you want Bitbucket users to be linked to GitLab user you have to have all of them in GitLab in advance. They will be matched by their BitBucket username. ### Note diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 53c95ea4079..63a4407cb78 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -28,6 +28,7 @@ module Gitlab def handle_errors return unless errors.any? + project.update_column(:import_error, { message: 'The remote data could not be fully imported.', errors: errors @@ -35,15 +36,12 @@ module Gitlab end def gitlab_user_id(project, username) - if username - user = find_user(username) - (user && user.id) || project.creator_id - else - project.creator_id - end + user = find_user(username) + user.try(:id) || project.creator_id end def find_user(username) + return nil unless username User.joins(:identities).find_by("identities.extern_uid = ? AND identities.provider = 'bitbucket'", username) end From 27f271ee1ed977f8070f528f1d5e21ad577f5409 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Fri, 16 Dec 2016 14:54:23 +0100 Subject: [PATCH 120/175] Refactor Bitbucket import docs --- doc/README.md | 2 +- doc/integration/bitbucket.md | 18 ++--- .../img/bitbucket_oauth_settings_page.png | Bin 30275 -> 5607 bytes .../bitbucket_import_grant_access.png | Bin 30083 -> 0 bytes .../bitbucket_import_new_project.png | Bin 16502 -> 0 bytes .../bitbucket_import_select_bitbucket.png | Bin 46606 -> 0 bytes .../bitbucket_import_select_project.png | Bin 15288 -> 0 bytes .../img/bitbucket_import_grant_access.png | Bin 0 -> 7248 bytes .../img/bitbucket_import_new_project.png | Bin 0 -> 1316 bytes .../img/bitbucket_import_select_project.png | Bin 0 -> 8688 bytes ...rojects_from_github_select_auth_method.png | Bin 17613 -> 17612 bytes .../import_projects_from_bitbucket.md | 76 +++++++++++++----- 12 files changed, 65 insertions(+), 31 deletions(-) delete mode 100644 doc/workflow/importing/bitbucket_importer/bitbucket_import_grant_access.png delete mode 100644 doc/workflow/importing/bitbucket_importer/bitbucket_import_new_project.png delete mode 100644 doc/workflow/importing/bitbucket_importer/bitbucket_import_select_bitbucket.png delete mode 100644 doc/workflow/importing/bitbucket_importer/bitbucket_import_select_project.png create mode 100644 doc/workflow/importing/img/bitbucket_import_grant_access.png create mode 100644 doc/workflow/importing/img/bitbucket_import_new_project.png create mode 100644 doc/workflow/importing/img/bitbucket_import_select_project.png diff --git a/doc/README.md b/doc/README.md index eba1e9845b1..a60a5359540 100644 --- a/doc/README.md +++ b/doc/README.md @@ -8,7 +8,7 @@ - [GitLab as OAuth2 authentication service provider](integration/oauth_provider.md). It allows you to login to other applications from GitLab. - [Container Registry](user/project/container_registry.md) Learn how to use GitLab Container Registry. - [GitLab basics](gitlab-basics/README.md) Find step by step how to start working on your commandline and on GitLab. -- [Importing to GitLab](workflow/importing/README.md). +- [Importing to GitLab](workflow/importing/README.md) Import your projects from GitHub, Bitbucket, GitLab.com, FogBugz and SVN into GitLab. - [Importing and exporting projects between instances](user/project/settings/import_export.md). - [Markdown](user/markdown.md) GitLab's advanced formatting system. - [Migrating from SVN](workflow/importing/migrating_from_svn.md) Convert a SVN repository to Git and GitLab. diff --git a/doc/integration/bitbucket.md b/doc/integration/bitbucket.md index 9cdb101f457..5df6e103f42 100644 --- a/doc/integration/bitbucket.md +++ b/doc/integration/bitbucket.md @@ -18,8 +18,10 @@ Bitbucket.org. ## Bitbucket OmniAuth provider > **Note:** -Make sure to first follow the [Initial OmniAuth configuration][init-oauth] -before proceeding with setting up the Bitbucket integration. +GitLab 8.15 significantly simplified the way to integrate Bitbucket.org with +GitLab. You are encouraged to upgrade your GitLab instance if you haven't done +already. If you're using GitLab 8.14 and below, [use the previous integration +docs][bb-old]. To enable the Bitbucket OmniAuth provider you must register your application with Bitbucket.org. Bitbucket will generate an application ID and secret key for @@ -111,16 +113,12 @@ well, the user will be returned to GitLab and will be signed in. ## Bitbucket project import -You should be able to see the "Import projects from Bitbucket" option on the New Project page -enabled. - -## Acknowledgements - -Special thanks to the writer behind the following article: - -- http://stratus3d.com/blog/2015/09/06/migrating-from-bitbucket-to-local-gitlab-server/ +Once the above configuration is set up, you can use Bitbucket to sign into +GitLab and [start importing your projects][bb-import]. [init-oauth]: omniauth.md#initial-omniauth-configuration +[bb-import]: ../workflow/importing/import_projects_from_bitbucket.md +[bb-old]: https://gitlab.com/gitlab-org/gitlab-ce/blob/8-14-stable/doc/integration/bitbucket.md [bitbucket-docs]: https://confluence.atlassian.com/bitbucket/use-the-ssh-protocol-with-bitbucket-cloud-221449711.html#UsetheSSHprotocolwithBitbucketCloud-KnownhostorBitbucket%27spublickeyfingerprints [reconfigure]: ../administration/restart_gitlab.md#omnibus-gitlab-reconfigure [restart GitLab]: ../administration/restart_gitlab.md#installations-from-source diff --git a/doc/integration/img/bitbucket_oauth_settings_page.png b/doc/integration/img/bitbucket_oauth_settings_page.png index 24acc6e1f5acee975aba3fcff8df9a4bbfb8f5dd..21ce82a6074e2a5b1d52331a5567634a8c5c7914 100644 GIT binary patch literal 5607 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 literal 30275 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 E0sRnX_5c6? diff --git a/doc/workflow/importing/bitbucket_importer/bitbucket_import_select_project.png b/doc/workflow/importing/bitbucket_importer/bitbucket_import_select_project.png deleted file mode 100644 index 1a5661de75d556dea016fe86a67ca043a7c2cd93..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 15288 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ⅈsCu>v!UGJEX5#;mOeh$3qFC_3x%oU$!|p+ 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 aEn<9j5s)&ia`UGUM_xunx>V9M=)V90=XCM_ literal 0 HcmV?d00001 diff --git a/doc/workflow/importing/img/bitbucket_import_new_project.png b/doc/workflow/importing/img/bitbucket_import_new_project.png new file mode 100644 index 0000000000000000000000000000000000000000..8ed528c2f09bb5d89d7f9c8e0dc34999e25f6906 GIT binary patch literal 1316 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 Z#Q*Fcy(J-O&-vQ}WhKE9MPi2E{tsBHW1Rp1 literal 0 HcmV?d00001 diff --git a/doc/workflow/importing/img/import_projects_from_github_select_auth_method.png b/doc/workflow/importing/img/import_projects_from_github_select_auth_method.png index f50d92669916e0a250083ee0bf792fa16fd9fe50..1ccb38a815e486a44a06d249b0f332982068fc3a 100644 GIT binary patch delta 16807 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 delta 16730 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 diff --git a/doc/workflow/importing/import_projects_from_bitbucket.md b/doc/workflow/importing/import_projects_from_bitbucket.md index 935d6288f3b..fbbbc7f4a72 100644 --- a/doc/workflow/importing/import_projects_from_bitbucket.md +++ b/doc/workflow/importing/import_projects_from_bitbucket.md @@ -1,27 +1,63 @@ # Import your project from Bitbucket to GitLab -It takes just a few steps to import your existing Bitbucket projects to GitLab. But keep in mind that it is possible only if Bitbucket support is enabled on your GitLab instance. You can read more about Bitbucket support [here](../../integration/bitbucket.md). +Import your projects from Bitbucket to GitLab with minimal effort. -* Sign in to GitLab.com and go to your dashboard +## Overview -* Click on "New project" +>**Note:** +The [Bitbucket integration][bb-import] must be first enabled in order to be +able to import your projects from Bitbucket. Ask your GitLab administrator +to enable this if not already. -![New project in GitLab](bitbucket_importer/bitbucket_import_new_project.png) +- At its current state, the Bitbucket importer can import: + - the repository description (GitLab 7.7+) + - the Git repository data (GitLab 7.7+) + - the issues (GitLab 7.7+) + - the pull requests (GitLab 8.4+) + - the wiki pages (GitLab 8.4+) + - the milestones (GitLab 8.7+) + - the labels (GitLab 8.7+) + - the release note descriptions (GitLab 8.12+) +- References to pull requests and issues are preserved (GitLab 8.7+) +- Repository public access is retained. If a repository is private in Bitbucket + it will be created as private in GitLab as well. -* Click on the "Bitbucket" button - -![Bitbucket](bitbucket_importer/bitbucket_import_select_bitbucket.png) - -* Grant GitLab access to your Bitbucket account - -![Grant access](bitbucket_importer/bitbucket_import_grant_access.png) - -* Click on the projects that you'd like to import or "Import all projects" - -![Import projects](bitbucket_importer/bitbucket_import_select_project.png) - -A new GitLab project will be created with your imported data. Keep in mind that if you want to Bitbucket users -to be linked to GitLab user you have to have all of them in GitLab in advance. They will be matched by their BitBucket username. - -### Note Milestones and wiki pages are not imported from Bitbucket. + +## How it works + +When issues/pull requests are being imported, the Bitbucket importer tries to find +the Bitbucket author/assignee in GitLab's database using the Bitbucket ID. For this +to work, the Bitbucket author/assignee should have signed in beforehand in GitLab +and [**associated their Bitbucket account**][social sign-in]. If the user is not +found in GitLab's database, the project creator (most of the times the current +user that started the import process) is set as the author, but a reference on +the issue about the original Bitbucket author is kept. + +The importer will create any new namespaces (groups) if they don't exist or in +the case the namespace is taken, the repository will be imported under the user's +namespace that started the import process. + +## Importing your Bitbucket repositories + +1. Sign in to GitLab and go to your dashboard. +1. Click on **New project**. + + ![New project in GitLab](img/bitbucket_import_new_project.png) + +1. Click on the "Bitbucket" button + + ![Bitbucket](img/import_projects_from_github_new_project_page.png) + +1. Grant GitLab access to your Bitbucket account + + ![Grant access](img/bitbucket_import_grant_access.png) + +1. Click on the projects that you'd like to import or **Import all projects**. + You can also select the namespace under which each project will be + imported. + + ![Import projects](img/bitbucket_import_select_project.png) + +[bb-import]: ../../integration/bitbucket.md +[social sign-in]: ../../user/profile/account/social_sign_in.md From 445e83ebee1a224bd576db738f4bf597b37a5f6c Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Fri, 16 Dec 2016 16:21:33 +0100 Subject: [PATCH 121/175] Use new generic image for project import --- ...rt_projects_from_github_new_project_page.png | Bin 11047 -> 0 bytes .../import_projects_from_new_project_page.png | Bin 0 -> 36821 bytes .../importing/import_projects_from_bitbucket.md | 2 +- .../importing/import_projects_from_github.md | 2 +- 4 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 doc/workflow/importing/img/import_projects_from_github_new_project_page.png create mode 100644 doc/workflow/importing/img/import_projects_from_new_project_page.png diff --git a/doc/workflow/importing/img/import_projects_from_github_new_project_page.png b/doc/workflow/importing/img/import_projects_from_github_new_project_page.png deleted file mode 100644 index b23ade4480c4ee2ddd1a9f81cd03a6ba735a8e53..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11047 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 diff --git a/doc/workflow/importing/img/import_projects_from_new_project_page.png b/doc/workflow/importing/img/import_projects_from_new_project_page.png new file mode 100644 index 0000000000000000000000000000000000000000..97ca30b20874d405b64fb181a54c5395da203542 GIT binary patch literal 36821 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 Date: Fri, 16 Dec 2016 16:34:58 +0100 Subject: [PATCH 122/175] Add detailed docs in yaml README [ci skip] --- doc/ci/yaml/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index 5e8d888e555..430952705e0 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1036,6 +1036,31 @@ variables: GIT_STRATEGY: none ``` +## Build stages attempts + +> Introduced in GitLab, it requires GitLab Runner v1.9+. + +You can set the number for attempts the running build will try to execute each +of the following stages: + +| Variable | Description | +|-------------------------|-------------| +| **GET_SOURCES_ATTEMPTS** | Number of attempts to fetch sources running a build | +| **ARTIFACT_DOWNLOAD_ATTEMPTS** | Number of attempts to download artifacts running a build | +| **RESTORE_CACHE_ATTEMPTS** | Number of attempts to restore the cache running a build | + +The default is one single attempt. + +Example: + +``` +variables: + GET_SOURCES_ATTEMPTS: "3" +``` + +You can set the them in the global [`variables`](#variables) section or the [`variables`](#job-variables) +section for individual jobs. + ## Shallow cloning > Introduced in GitLab 8.9 as an experimental feature. May change in future From 7985b52286237b3801fc112b8bf3841599931c23 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 16 Dec 2016 17:35:31 +0200 Subject: [PATCH 123/175] BB importer: Adressed more review comments --- changelogs/unreleased/bitbucket-oauth2.yml | 4 +++ lib/gitlab/bitbucket_import/importer.rb | 34 ++++++++++++---------- 2 files changed, 23 insertions(+), 15 deletions(-) create mode 100644 changelogs/unreleased/bitbucket-oauth2.yml diff --git a/changelogs/unreleased/bitbucket-oauth2.yml b/changelogs/unreleased/bitbucket-oauth2.yml new file mode 100644 index 00000000000..97d82518b7b --- /dev/null +++ b/changelogs/unreleased/bitbucket-oauth2.yml @@ -0,0 +1,4 @@ +--- +title: Refactor Bitbucket importer to use BitBucket API Version 2 +merge_request: +author: diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index 63a4407cb78..f3760640655 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -6,7 +6,7 @@ module Gitlab { title: 'proposal', color: '#69D100' }, { title: 'task', color: '#7F8C8D' }].freeze - attr_reader :project, :client, :errors + attr_reader :project, :client, :errors, :users def initialize(project) @project = project @@ -14,6 +14,7 @@ module Gitlab @formatter = Gitlab::ImportFormatter.new @labels = {} @errors = [] + @users = {} end def execute @@ -36,17 +37,18 @@ module Gitlab end def gitlab_user_id(project, username) - user = find_user(username) - user.try(:id) || project.creator_id + find_user_id(username) || project.creator_id end - def find_user(username) + def find_user_id(username) return nil unless username - User.joins(:identities).find_by("identities.extern_uid = ? AND identities.provider = 'bitbucket'", username) - end - def existing_gitlab_user?(username) - username && find_user(username) + return users[username] if users.key?(username) + + users[username] = User.select(:id) + .joins(:identities) + .find_by("identities.extern_uid = ? AND identities.provider = 'bitbucket'", username) + .try(:id) end def repo @@ -58,16 +60,18 @@ module Gitlab create_labels + gitlab_issue = nil + client.issues(repo).each do |issue| begin description = '' - description += @formatter.author_line(issue.author) unless existing_gitlab_user?(issue.author) + description += @formatter.author_line(issue.author) unless find_user_id(issue.author) description += issue.description label_name = issue.kind milestone = issue.milestone ? project.milestones.find_or_create_by(title: issue.milestone) : nil - issue = project.issues.create!( + gitlab_issue = project.issues.create!( iid: issue.iid, title: issue.title, description: description, @@ -81,9 +85,9 @@ module Gitlab errors << { type: :issue, iid: issue.iid, errors: e.message } end - issue.labels << @labels[label_name] + gitlab_issue.labels << @labels[label_name] - if issue.persisted? + if gitlab_issue.persisted? client.issue_comments(repo, issue.iid).each do |comment| # The note can be blank for issue service messages like "Changed title: ..." # We would like to import those comments as well but there is no any @@ -93,11 +97,11 @@ module Gitlab next unless comment.note.present? note = '' - note += @formatter.author_line(comment.author) unless existing_gitlab_user?(comment.author) + note += @formatter.author_line(comment.author) unless find_user_id(comment.author) note += comment.note begin - issue.notes.create!( + gitlab_issue.notes.create!( project: project, note: note, author_id: gitlab_user_id(project, comment.author), @@ -124,7 +128,7 @@ module Gitlab pull_requests.each do |pull_request| begin description = '' - description += @formatter.author_line(pull_request.author) unless existing_gitlab_user?(pull_request.author) + description += @formatter.author_line(pull_request.author) unless find_user_id(pull_request.author) description += pull_request.description merge_request = project.merge_requests.create( From 20e472d946d7cc4a2b9dd91264458b1c4ceb5ab6 Mon Sep 17 00:00:00 2001 From: Valery Sizov Date: Fri, 16 Dec 2016 17:40:29 +0200 Subject: [PATCH 124/175] BB importer: Fix documantation --- doc/workflow/importing/import_projects_from_bitbucket.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/doc/workflow/importing/import_projects_from_bitbucket.md b/doc/workflow/importing/import_projects_from_bitbucket.md index f0b73ccbcd2..b6d47e5afa2 100644 --- a/doc/workflow/importing/import_projects_from_bitbucket.md +++ b/doc/workflow/importing/import_projects_from_bitbucket.md @@ -13,16 +13,14 @@ to enable this if not already. - the repository description (GitLab 7.7+) - the Git repository data (GitLab 7.7+) - the issues (GitLab 7.7+) + - the issue comments (GitLab 8.15+) - the pull requests (GitLab 8.4+) - - the wiki pages (GitLab 8.4+) - - the milestones (GitLab 8.7+) - - the labels (GitLab 8.7+) - - the release note descriptions (GitLab 8.12+) + - the pull request comments (GitLab 8.15+) + - the milestones (GitLab 8.15+) - References to pull requests and issues are preserved (GitLab 8.7+) - Repository public access is retained. If a repository is private in Bitbucket it will be created as private in GitLab as well. -Milestones and wiki pages are not imported from Bitbucket. ## How it works From f82d549d26af89cba00005e1a1c9b721c076f7a0 Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Wed, 7 Dec 2016 13:25:49 +0530 Subject: [PATCH 125/175] Accept environment variables from the `pre-receive` script. 1. Starting version 2.11, git changed the way the pre-receive flow works. - Previously, the new potential objects would be added to the main repo. If the pre-receive passes, the new objects stay in the repo but are linked up. If the pre-receive fails, the new objects stay orphaned in the repo, and are cleaned up during the next `git gc`. - In 2.11, the new potential objects are added to a temporary "alternate object directory", that git creates for this purpose. If the pre-receive passes, the objects from the alternate object directory are migrated to the main repo. If the pre-receive fails the alternate object directory is simply deleted. 2. In our workflow, the pre-recieve script (in `gitlab-shell) calls the `/allowed` endpoint, which calls out directly to git to perform various checks. These direct calls to git do _not_ have the necessary environment variables set which allow access to the "alternate object directory" (explained above). Therefore these calls to git are not able to access any of the new potential objects to be added during this push. 3. We fix this by accepting the relevant environment variables (GIT_ALTERNATE_OBJECT_DIRECTORIES, GIT_OBJECT_DIRECTORY) on the `/allowed` endpoint, and then include these environment variables while calling out to git. 4. This commit includes (whitelisted) these environment variables while making the "force push" check. A `Gitlab::Git::RevList` module is extracted to prevent `ForcePush` from being littered with these checks. --- lib/api/helpers/internal_helpers.rb | 8 ++++++++ lib/api/internal.rb | 6 +++++- lib/gitlab/checks/change_access.rb | 5 +++-- lib/gitlab/checks/force_push.rb | 4 ++-- lib/gitlab/git/rev_list.rb | 28 ++++++++++++++++++++++++++++ lib/gitlab/git_access.rb | 5 +++-- lib/gitlab/popen.rb | 4 ++-- 7 files changed, 51 insertions(+), 9 deletions(-) create mode 100644 lib/gitlab/git/rev_list.rb diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index eb223c1101d..e8975eb57e0 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -52,6 +52,14 @@ module API :push_code ] end + + def parse_allowed_environment_variables + return if params[:env].blank? + + JSON.parse(params[:env]) + + rescue JSON::ParserError + end end end end diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 7087ce11401..db2d18f935d 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -32,7 +32,11 @@ module API if wiki? Gitlab::GitAccessWiki.new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities) else - Gitlab::GitAccess.new(actor, project, protocol, authentication_abilities: ssh_authentication_abilities) + Gitlab::GitAccess.new(actor, + project, + protocol, + authentication_abilities: ssh_authentication_abilities, + env: parse_allowed_environment_variables) end access_status = access.check(params[:action], params[:changes]) diff --git a/lib/gitlab/checks/change_access.rb b/lib/gitlab/checks/change_access.rb index cb1065223d4..3d203017d9f 100644 --- a/lib/gitlab/checks/change_access.rb +++ b/lib/gitlab/checks/change_access.rb @@ -3,11 +3,12 @@ module Gitlab class ChangeAccess attr_reader :user_access, :project - def initialize(change, user_access:, project:) + def initialize(change, user_access:, project:, env: {}) @oldrev, @newrev, @ref = change.values_at(:oldrev, :newrev, :ref) @branch_name = Gitlab::Git.branch_name(@ref) @user_access = user_access @project = project + @env = env end def exec @@ -68,7 +69,7 @@ module Gitlab end def forced_push? - Gitlab::Checks::ForcePush.force_push?(@project, @oldrev, @newrev) + Gitlab::Checks::ForcePush.force_push?(@project, @oldrev, @newrev, env: @env) end def matching_merge_request? diff --git a/lib/gitlab/checks/force_push.rb b/lib/gitlab/checks/force_push.rb index 5fe86553bd0..589525e40ad 100644 --- a/lib/gitlab/checks/force_push.rb +++ b/lib/gitlab/checks/force_push.rb @@ -1,14 +1,14 @@ module Gitlab module Checks class ForcePush - def self.force_push?(project, oldrev, newrev) + def self.force_push?(project, oldrev, newrev, env: {}) return false if project.empty_repo? # Created or deleted branch if Gitlab::Git.blank_ref?(oldrev) || Gitlab::Git.blank_ref?(newrev) false else - missed_ref, _ = Gitlab::Popen.popen(%W(#{Gitlab.config.git.bin_path} --git-dir=#{project.repository.path_to_repo} rev-list --max-count=1 #{oldrev} ^#{newrev})) + missed_ref, _ = Gitlab::Git::RevList.new(oldrev, newrev, project: project, env: env).execute missed_ref.present? end end diff --git a/lib/gitlab/git/rev_list.rb b/lib/gitlab/git/rev_list.rb new file mode 100644 index 00000000000..ecdb7f07744 --- /dev/null +++ b/lib/gitlab/git/rev_list.rb @@ -0,0 +1,28 @@ +# Call out to the `git rev-list` command + +module Gitlab + module Git + class RevList + def initialize(oldrev, newrev, project:, env: nil) + @args = [Gitlab.config.git.bin_path, + "--git-dir=#{project.repository.path_to_repo}", + "rev-list", + "--max-count=1", + oldrev, + "^#{newrev}"] + + @env = env.slice(*allowed_environment_variables) + end + + def execute + Gitlab::Popen.popen(@args, nil, @env.slice(*allowed_environment_variables)) + end + + private + + def allowed_environment_variables + %w(GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_OBJECT_DIRECTORY) + end + end + end +end diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index db07b7c5fcc..c6b6efda360 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -17,12 +17,13 @@ module Gitlab attr_reader :actor, :project, :protocol, :user_access, :authentication_abilities - def initialize(actor, project, protocol, authentication_abilities:) + def initialize(actor, project, protocol, authentication_abilities:, env: {}) @actor = actor @project = project @protocol = protocol @authentication_abilities = authentication_abilities @user_access = UserAccess.new(user, project: project) + @env = env end def check(cmd, changes) @@ -103,7 +104,7 @@ module Gitlab end def change_access_check(change) - Checks::ChangeAccess.new(change, user_access: user_access, project: project).exec + Checks::ChangeAccess.new(change, user_access: user_access, project: project, env: @env).exec end def protocol_allowed? diff --git a/lib/gitlab/popen.rb b/lib/gitlab/popen.rb index cc74bb29087..4bc5cda8cb5 100644 --- a/lib/gitlab/popen.rb +++ b/lib/gitlab/popen.rb @@ -5,13 +5,13 @@ module Gitlab module Popen extend self - def popen(cmd, path = nil) + def popen(cmd, path = nil, vars = {}) unless cmd.is_a?(Array) raise "System commands must be given as an array of strings" end path ||= Dir.pwd - vars = { "PWD" => path } + vars['PWD'] = path options = { chdir: path } unless File.directory?(path) From a2b39feb1a3ae6fe2615418bb759bf39125e5d0e Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 9 Dec 2016 15:15:55 +0530 Subject: [PATCH 126/175] Validate environment variables in `Gitlab::Git::RevList` The list of environment variables in `Gitlab::Git::RevList` need to be validate to make sure that they don't reference any other project on disk. This commit mixes in `ActiveModel::Validations` into `Gitlab::Git::RevList`, and validates that the environment variables are on the level (using a custom validator class). If the validations fail, the force push is still executed without any environment variables set. Add specs for the validation using shared examples. --- .../git_environment_variables_validator.rb | 13 ++++ lib/gitlab/git/rev_list.rb | 16 ++++- spec/lib/gitlab/git/rev_list_spec.rb | 36 +++++++++++ ...it_environment_variables_validator_spec.rb | 64 +++++++++++++++++++ 4 files changed, 126 insertions(+), 3 deletions(-) create mode 100644 app/validators/git_environment_variables_validator.rb create mode 100644 spec/lib/gitlab/git/rev_list_spec.rb create mode 100644 spec/validators/git_environment_variables_validator_spec.rb diff --git a/app/validators/git_environment_variables_validator.rb b/app/validators/git_environment_variables_validator.rb new file mode 100644 index 00000000000..92041e0a773 --- /dev/null +++ b/app/validators/git_environment_variables_validator.rb @@ -0,0 +1,13 @@ +class GitEnvironmentVariablesValidator < ActiveModel::EachValidator + def validate_each(record, attribute, env) + variables_to_validate = %w(GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES) + + variables_to_validate.each do |variable_name| + variable_value = env[variable_name] + + if variable_value.present? && !(variable_value =~ /^#{record.project.repository.path_to_repo}/) + record.errors.add(attribute, "The #{variable_name} variable must start with the project repo path") + end + end + end +end diff --git a/lib/gitlab/git/rev_list.rb b/lib/gitlab/git/rev_list.rb index ecdb7f07744..d8c78d806ea 100644 --- a/lib/gitlab/git/rev_list.rb +++ b/lib/gitlab/git/rev_list.rb @@ -3,19 +3,29 @@ module Gitlab module Git class RevList + include ActiveModel::Validations + + validates :env, git_environment_variables: true + + attr_reader :project, :env + def initialize(oldrev, newrev, project:, env: nil) + @project = project + @env = env.presence || {} @args = [Gitlab.config.git.bin_path, "--git-dir=#{project.repository.path_to_repo}", "rev-list", "--max-count=1", oldrev, "^#{newrev}"] - - @env = env.slice(*allowed_environment_variables) end def execute - Gitlab::Popen.popen(@args, nil, @env.slice(*allowed_environment_variables)) + if self.valid? + Gitlab::Popen.popen(@args, nil, @env.slice(*allowed_environment_variables)) + else + Gitlab::Popen.popen(@args) + end end private diff --git a/spec/lib/gitlab/git/rev_list_spec.rb b/spec/lib/gitlab/git/rev_list_spec.rb new file mode 100644 index 00000000000..cdfbff5658c --- /dev/null +++ b/spec/lib/gitlab/git/rev_list_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' +require 'validators/git_environment_variables_validator_spec' + +describe Gitlab::Git::RevList, lib: true do + let(:project) { create(:project) } + + context "validations" do + it_behaves_like( + "validated git environment variables", + ->(env, project) { Gitlab::Git::RevList.new('oldrev', 'newrev', project: project, env: env) } + ) + end + + context "#execute" do + let(:env) { { "GIT_OBJECT_DIRECTORY" => project.repository.path_to_repo } } + let(:rev_list) { Gitlab::Git::RevList.new('oldrev', 'newrev', project: project, env: env) } + + it "calls out to `popen` without environment variables if the record is invalid" do + allow(rev_list).to receive(:valid?).and_return(false) + allow(Open3).to receive(:popen3) + + rev_list.execute + + expect(Open3).to have_received(:popen3).with(hash_excluding(env), any_args) + end + + it "calls out to `popen` with environment variables if the record is valid" do + allow(rev_list).to receive(:valid?).and_return(true) + allow(Open3).to receive(:popen3) + + rev_list.execute + + expect(Open3).to have_received(:popen3).with(hash_including(env), any_args) + end + end +end diff --git a/spec/validators/git_environment_variables_validator_spec.rb b/spec/validators/git_environment_variables_validator_spec.rb new file mode 100644 index 00000000000..81b028b6572 --- /dev/null +++ b/spec/validators/git_environment_variables_validator_spec.rb @@ -0,0 +1,64 @@ +require 'spec_helper' + +shared_examples_for "validated git environment variables" do |record_fn| + subject { GitEnvironmentVariablesValidator.new(attributes: ['env']) } + let(:project) { create(:project) } + + context "GIT_OBJECT_DIRECTORY" do + it "accepts values starting with the project repo path" do + env = { "GIT_OBJECT_DIRECTORY" => "#{project.repository.path_to_repo}/objects" } + record = record_fn[env, project] + + subject.validate_each(record, 'env', env) + + expect(record).to be_valid, "expected #{project.repository.path_to_repo}" + end + + it "rejects values starting not with the project repo path" do + env = { "GIT_OBJECT_DIRECTORY" => "/some/other/path" } + record = record_fn[env, project] + + subject.validate_each(record, 'env', env) + + expect(record).to be_invalid + end + + it "rejects values containing the project repo path but not starting with it" do + env = { "GIT_OBJECT_DIRECTORY" => "/some/other/path/#{project.repository.path_to_repo}" } + record = record_fn[env, project] + + subject.validate_each(record, 'env', env) + + expect(record).to be_invalid + end + end + + context "GIT_ALTERNATE_OBJECT_DIRECTORIES" do + it "accepts values starting with the project repo path" do + env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => project.repository.path_to_repo } + record = record_fn[env, project] + + subject.validate_each(record, 'env', env) + + expect(record).to be_valid, "expected #{project.repository.path_to_repo}" + end + + it "rejects values starting not with the project repo path" do + env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => "/some/other/path" } + record = record_fn[env, project] + + subject.validate_each(record, 'env', env) + + expect(record).to be_invalid + end + + it "rejects values containing the project repo path but not starting with it" do + env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => "/some/other/path/#{project.repository.path_to_repo}" } + record = record_fn[env, project] + + subject.validate_each(record, 'env', env) + + expect(record).to be_invalid + end + end +end From c937aec1f7ba1f102995fefaef2141e7ed90f5fd Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 9 Dec 2016 15:52:20 +0530 Subject: [PATCH 127/175] Check the exit code while invoking git in the force push check. Previously, we were calling out to `popen` without asserting on the returned exit-code. Now we raise a `RuntimeError` if the exit code is non-zero. --- lib/gitlab/checks/force_push.rb | 9 +++++++-- spec/lib/gitlab/checks/force_push_spec.rb | 19 +++++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 spec/lib/gitlab/checks/force_push_spec.rb diff --git a/lib/gitlab/checks/force_push.rb b/lib/gitlab/checks/force_push.rb index 589525e40ad..e1c967a1f89 100644 --- a/lib/gitlab/checks/force_push.rb +++ b/lib/gitlab/checks/force_push.rb @@ -8,8 +8,13 @@ module Gitlab if Gitlab::Git.blank_ref?(oldrev) || Gitlab::Git.blank_ref?(newrev) false else - missed_ref, _ = Gitlab::Git::RevList.new(oldrev, newrev, project: project, env: env).execute - missed_ref.present? + missed_ref, exit_status = Gitlab::Git::RevList.new(oldrev, newrev, project: project, env: env).execute + + if exit_status == 0 + missed_ref.present? + else + raise RuntimeError, "Got a non-zero exit code while calling out to `git rev-list` in the force-push check." + end end end end diff --git a/spec/lib/gitlab/checks/force_push_spec.rb b/spec/lib/gitlab/checks/force_push_spec.rb new file mode 100644 index 00000000000..f6288011494 --- /dev/null +++ b/spec/lib/gitlab/checks/force_push_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Gitlab::Checks::ChangeAccess, lib: true do + let(:project) { create(:project) } + + context "exit code checking" do + it "does not raise a runtime error if the `popen` call to git returns a zero exit code" do + allow(Gitlab::Popen).to receive(:popen).and_return(['normal output', 0]) + + expect { Gitlab::Checks::ForcePush.force_push?(project, 'oldrev', 'newrev') }.not_to raise_error + end + + it "raises a runtime error if the `popen` call to git returns a non-zero exit code" do + allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1]) + + expect { Gitlab::Checks::ForcePush.force_push?(project, 'oldrev', 'newrev') }.to raise_error(RuntimeError) + end + end +end From a80ccec70687de2d7c53026fa25d7b85098cdb9a Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Tue, 13 Dec 2016 18:49:16 +0530 Subject: [PATCH 128/175] Add CHANGELOG entry. --- changelogs/unreleased/25301-git-2-11-force-push-bug.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/25301-git-2-11-force-push-bug.yml diff --git a/changelogs/unreleased/25301-git-2-11-force-push-bug.yml b/changelogs/unreleased/25301-git-2-11-force-push-bug.yml new file mode 100644 index 00000000000..afe57729c48 --- /dev/null +++ b/changelogs/unreleased/25301-git-2-11-force-push-bug.yml @@ -0,0 +1,4 @@ +--- +title: Accept environment variables from the `pre-receive` script +merge_request: 7967 +author: From 3e1442766f3e2327e1e620b3b11623b09c35142b Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Thu, 15 Dec 2016 07:53:46 +0530 Subject: [PATCH 129/175] Implement review comments from @dbalexandre. - Don't define "allowed environment variables" in two places. - Dispatch to different arities of `Popen.open` without an if/else block. - Use `described_class` instead of explicitly stating the class name within a - spec. - Remove `git_environment_variables_validator_spec` and keep the validation inline. --- .../git_environment_variables_validator.rb | 13 ---- lib/gitlab/git/rev_list.rb | 22 ++++--- spec/lib/gitlab/git/rev_list_spec.rb | 50 +++++++++++++-- ...it_environment_variables_validator_spec.rb | 64 ------------------- 4 files changed, 57 insertions(+), 92 deletions(-) delete mode 100644 app/validators/git_environment_variables_validator.rb delete mode 100644 spec/validators/git_environment_variables_validator_spec.rb diff --git a/app/validators/git_environment_variables_validator.rb b/app/validators/git_environment_variables_validator.rb deleted file mode 100644 index 92041e0a773..00000000000 --- a/app/validators/git_environment_variables_validator.rb +++ /dev/null @@ -1,13 +0,0 @@ -class GitEnvironmentVariablesValidator < ActiveModel::EachValidator - def validate_each(record, attribute, env) - variables_to_validate = %w(GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES) - - variables_to_validate.each do |variable_name| - variable_value = env[variable_name] - - if variable_value.present? && !(variable_value =~ /^#{record.project.repository.path_to_repo}/) - record.errors.add(attribute, "The #{variable_name} variable must start with the project repo path") - end - end - end -end diff --git a/lib/gitlab/git/rev_list.rb b/lib/gitlab/git/rev_list.rb index d8c78d806ea..ecd038e04df 100644 --- a/lib/gitlab/git/rev_list.rb +++ b/lib/gitlab/git/rev_list.rb @@ -3,12 +3,10 @@ module Gitlab module Git class RevList - include ActiveModel::Validations - - validates :env, git_environment_variables: true - attr_reader :project, :env + ALLOWED_VARIABLES = %w(GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES).freeze + def initialize(oldrev, newrev, project:, env: nil) @project = project @env = env.presence || {} @@ -21,17 +19,21 @@ module Gitlab end def execute - if self.valid? - Gitlab::Popen.popen(@args, nil, @env.slice(*allowed_environment_variables)) - else - Gitlab::Popen.popen(@args) + Gitlab::Popen.popen(@args, nil, parse_environment_variables) + end + + def valid? + env.slice(*ALLOWED_VARIABLES).all? do |(name, value)| + value =~ /^#{project.repository.path_to_repo}/ end end private - def allowed_environment_variables - %w(GIT_ALTERNATE_OBJECT_DIRECTORIES GIT_OBJECT_DIRECTORY) + def parse_environment_variables + return {} unless valid? + + env.slice(*ALLOWED_VARIABLES) end end end diff --git a/spec/lib/gitlab/git/rev_list_spec.rb b/spec/lib/gitlab/git/rev_list_spec.rb index cdfbff5658c..f76aca29107 100644 --- a/spec/lib/gitlab/git/rev_list_spec.rb +++ b/spec/lib/gitlab/git/rev_list_spec.rb @@ -1,14 +1,54 @@ require 'spec_helper' -require 'validators/git_environment_variables_validator_spec' describe Gitlab::Git::RevList, lib: true do let(:project) { create(:project) } context "validations" do - it_behaves_like( - "validated git environment variables", - ->(env, project) { Gitlab::Git::RevList.new('oldrev', 'newrev', project: project, env: env) } - ) + context "GIT_OBJECT_DIRECTORY" do + it "accepts values starting with the project repo path" do + env = { "GIT_OBJECT_DIRECTORY" => "#{project.repository.path_to_repo}/objects" } + rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) + + expect(rev_list).to be_valid + end + + it "rejects values starting not with the project repo path" do + env = { "GIT_OBJECT_DIRECTORY" => "/some/other/path" } + rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) + + expect(rev_list).not_to be_valid + end + + it "rejects values containing the project repo path but not starting with it" do + env = { "GIT_OBJECT_DIRECTORY" => "/some/other/path/#{project.repository.path_to_repo}" } + rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) + + expect(rev_list).not_to be_valid + end + end + + context "GIT_ALTERNATE_OBJECT_DIRECTORIES" do + it "accepts values starting with the project repo path" do + env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => project.repository.path_to_repo } + rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) + + expect(rev_list).to be_valid + end + + it "rejects values starting not with the project repo path" do + env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => "/some/other/path" } + rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) + + expect(rev_list).not_to be_valid + end + + it "rejects values containing the project repo path but not starting with it" do + env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => "/some/other/path/#{project.repository.path_to_repo}" } + rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) + + expect(rev_list).not_to be_valid + end + end end context "#execute" do diff --git a/spec/validators/git_environment_variables_validator_spec.rb b/spec/validators/git_environment_variables_validator_spec.rb deleted file mode 100644 index 81b028b6572..00000000000 --- a/spec/validators/git_environment_variables_validator_spec.rb +++ /dev/null @@ -1,64 +0,0 @@ -require 'spec_helper' - -shared_examples_for "validated git environment variables" do |record_fn| - subject { GitEnvironmentVariablesValidator.new(attributes: ['env']) } - let(:project) { create(:project) } - - context "GIT_OBJECT_DIRECTORY" do - it "accepts values starting with the project repo path" do - env = { "GIT_OBJECT_DIRECTORY" => "#{project.repository.path_to_repo}/objects" } - record = record_fn[env, project] - - subject.validate_each(record, 'env', env) - - expect(record).to be_valid, "expected #{project.repository.path_to_repo}" - end - - it "rejects values starting not with the project repo path" do - env = { "GIT_OBJECT_DIRECTORY" => "/some/other/path" } - record = record_fn[env, project] - - subject.validate_each(record, 'env', env) - - expect(record).to be_invalid - end - - it "rejects values containing the project repo path but not starting with it" do - env = { "GIT_OBJECT_DIRECTORY" => "/some/other/path/#{project.repository.path_to_repo}" } - record = record_fn[env, project] - - subject.validate_each(record, 'env', env) - - expect(record).to be_invalid - end - end - - context "GIT_ALTERNATE_OBJECT_DIRECTORIES" do - it "accepts values starting with the project repo path" do - env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => project.repository.path_to_repo } - record = record_fn[env, project] - - subject.validate_each(record, 'env', env) - - expect(record).to be_valid, "expected #{project.repository.path_to_repo}" - end - - it "rejects values starting not with the project repo path" do - env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => "/some/other/path" } - record = record_fn[env, project] - - subject.validate_each(record, 'env', env) - - expect(record).to be_invalid - end - - it "rejects values containing the project repo path but not starting with it" do - env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => "/some/other/path/#{project.repository.path_to_repo}" } - record = record_fn[env, project] - - subject.validate_each(record, 'env', env) - - expect(record).to be_invalid - end - end -end From e394d2872aa3a95d0e5cd13afe8e0de1ab01213a Mon Sep 17 00:00:00 2001 From: Timothy Andrew Date: Fri, 16 Dec 2016 22:39:52 +0530 Subject: [PATCH 130/175] Implement final review comments from @rymai. - `raise "string"` raises a `RuntimeError` - no need to be explicit - Remove top-level comment in the `RevList` class - Use `%w()` instead of `%w[]` - Extract an `environment_variables` method to cache `env.slice(*ALLOWED_VARIABLES)` - Use `start_with?` for env variable validation instead of regex match - Validation specs for each allowed environment variable were identical. Build them dynamically. - Minor change to `popen3` expectation. --- lib/gitlab/checks/force_push.rb | 2 +- lib/gitlab/git/rev_list.rb | 14 +++--- spec/lib/gitlab/git/rev_list_spec.rb | 65 +++++++++------------------- 3 files changed, 30 insertions(+), 51 deletions(-) diff --git a/lib/gitlab/checks/force_push.rb b/lib/gitlab/checks/force_push.rb index e1c967a1f89..de0c9049ebf 100644 --- a/lib/gitlab/checks/force_push.rb +++ b/lib/gitlab/checks/force_push.rb @@ -13,7 +13,7 @@ module Gitlab if exit_status == 0 missed_ref.present? else - raise RuntimeError, "Got a non-zero exit code while calling out to `git rev-list` in the force-push check." + raise "Got a non-zero exit code while calling out to `git rev-list` in the force-push check." end end end diff --git a/lib/gitlab/git/rev_list.rb b/lib/gitlab/git/rev_list.rb index ecd038e04df..25e9d619697 100644 --- a/lib/gitlab/git/rev_list.rb +++ b/lib/gitlab/git/rev_list.rb @@ -1,11 +1,9 @@ -# Call out to the `git rev-list` command - module Gitlab module Git class RevList attr_reader :project, :env - ALLOWED_VARIABLES = %w(GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES).freeze + ALLOWED_VARIABLES = %w[GIT_OBJECT_DIRECTORY GIT_ALTERNATE_OBJECT_DIRECTORIES].freeze def initialize(oldrev, newrev, project:, env: nil) @project = project @@ -23,8 +21,8 @@ module Gitlab end def valid? - env.slice(*ALLOWED_VARIABLES).all? do |(name, value)| - value =~ /^#{project.repository.path_to_repo}/ + environment_variables.all? do |(name, value)| + value.start_with?(project.repository.path_to_repo) end end @@ -33,7 +31,11 @@ module Gitlab def parse_environment_variables return {} unless valid? - env.slice(*ALLOWED_VARIABLES) + environment_variables + end + + def environment_variables + @environment_variables ||= env.slice(*ALLOWED_VARIABLES) end end end diff --git a/spec/lib/gitlab/git/rev_list_spec.rb b/spec/lib/gitlab/git/rev_list_spec.rb index f76aca29107..444639acbaa 100644 --- a/spec/lib/gitlab/git/rev_list_spec.rb +++ b/spec/lib/gitlab/git/rev_list_spec.rb @@ -4,49 +4,28 @@ describe Gitlab::Git::RevList, lib: true do let(:project) { create(:project) } context "validations" do - context "GIT_OBJECT_DIRECTORY" do - it "accepts values starting with the project repo path" do - env = { "GIT_OBJECT_DIRECTORY" => "#{project.repository.path_to_repo}/objects" } - rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) + described_class::ALLOWED_VARIABLES.each do |var| + context var do + it "accepts values starting with the project repo path" do + env = { var => "#{project.repository.path_to_repo}/objects" } + rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) - expect(rev_list).to be_valid - end + expect(rev_list).to be_valid + end - it "rejects values starting not with the project repo path" do - env = { "GIT_OBJECT_DIRECTORY" => "/some/other/path" } - rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) + it "rejects values starting not with the project repo path" do + env = { var => "/some/other/path" } + rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) - expect(rev_list).not_to be_valid - end + expect(rev_list).not_to be_valid + end - it "rejects values containing the project repo path but not starting with it" do - env = { "GIT_OBJECT_DIRECTORY" => "/some/other/path/#{project.repository.path_to_repo}" } - rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) + it "rejects values containing the project repo path but not starting with it" do + env = { var => "/some/other/path/#{project.repository.path_to_repo}" } + rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) - expect(rev_list).not_to be_valid - end - end - - context "GIT_ALTERNATE_OBJECT_DIRECTORIES" do - it "accepts values starting with the project repo path" do - env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => project.repository.path_to_repo } - rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) - - expect(rev_list).to be_valid - end - - it "rejects values starting not with the project repo path" do - env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => "/some/other/path" } - rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) - - expect(rev_list).not_to be_valid - end - - it "rejects values containing the project repo path but not starting with it" do - env = { "GIT_ALTERNATE_OBJECT_DIRECTORIES" => "/some/other/path/#{project.repository.path_to_repo}" } - rev_list = described_class.new('oldrev', 'newrev', project: project, env: env) - - expect(rev_list).not_to be_valid + expect(rev_list).not_to be_valid + end end end end @@ -57,20 +36,18 @@ describe Gitlab::Git::RevList, lib: true do it "calls out to `popen` without environment variables if the record is invalid" do allow(rev_list).to receive(:valid?).and_return(false) - allow(Open3).to receive(:popen3) + + expect(Open3).to receive(:popen3).with(hash_excluding(env), any_args) rev_list.execute - - expect(Open3).to have_received(:popen3).with(hash_excluding(env), any_args) end it "calls out to `popen` with environment variables if the record is valid" do allow(rev_list).to receive(:valid?).and_return(true) - allow(Open3).to receive(:popen3) + + expect(Open3).to receive(:popen3).with(hash_including(env), any_args) rev_list.execute - - expect(Open3).to have_received(:popen3).with(hash_including(env), any_args) end end end From 170efaaba273792ddffc2806ef1501f33d87a5a2 Mon Sep 17 00:00:00 2001 From: Rydkin Maxim Date: Fri, 16 Dec 2016 01:14:20 +0300 Subject: [PATCH 131/175] Enable Style/MultilineOperationIndentation in Rubocop, fixes #25741 --- .rubocop.yml | 3 ++- app/controllers/jwt_controller.rb | 2 +- app/controllers/sessions_controller.rb | 2 +- app/helpers/form_helper.rb | 12 ++++++------ app/helpers/nav_helper.rb | 18 +++++++++--------- app/helpers/tab_helper.rb | 6 +++--- app/models/member.rb | 4 ++-- app/models/network/graph.rb | 4 ++-- .../project_services/issue_tracker_service.rb | 4 ++-- app/models/user.rb | 2 +- app/policies/note_policy.rb | 2 +- app/policies/project_policy.rb | 12 ++++++------ app/services/groups/update_service.rb | 2 +- app/services/issues/update_service.rb | 2 +- app/services/merge_requests/build_service.rb | 2 +- app/services/merge_requests/update_service.rb | 2 +- app/services/projects/update_service.rb | 2 +- config/initializers/sidekiq.rb | 2 +- lib/gitlab/email/reply_parser.rb | 2 +- spec/lib/gitlab/gfm/reference_rewriter_spec.rb | 2 +- spec/requests/api/projects_spec.rb | 2 +- 21 files changed, 45 insertions(+), 44 deletions(-) diff --git a/.rubocop.yml b/.rubocop.yml index 13df3f99613..80eb4a5c19e 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -292,7 +292,8 @@ Style/MultilineMethodDefinitionBraceLayout: # Checks indentation of binary operations that span more than one line. Style/MultilineOperationIndentation: - Enabled: false + Enabled: true + EnforcedStyle: indented # Avoid multi-line `? :` (the ternary operator), use if/unless instead. Style/MultilineTernaryOperator: diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index c736200a104..c2e4d62b50b 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -26,7 +26,7 @@ class JwtController < ApplicationController @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip) render_unauthorized unless @authentication_result.success? && - (@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User)) + (@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User)) end rescue Gitlab::Auth::MissingPersonalTokenError render_missing_personal_token diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb index 8c698695202..93a180b9036 100644 --- a/app/controllers/sessions_controller.rb +++ b/app/controllers/sessions_controller.rb @@ -114,7 +114,7 @@ class SessionsController < Devise::SessionsController def valid_otp_attempt?(user) user.validate_and_consume_otp!(user_params[:otp_attempt]) || - user.invalidate_otp_backup_code!(user_params[:otp_attempt]) + user.invalidate_otp_backup_code!(user_params[:otp_attempt]) end def log_audit_event(user, options = {}) diff --git a/app/helpers/form_helper.rb b/app/helpers/form_helper.rb index 6a43be2cf3e..1182939f656 100644 --- a/app/helpers/form_helper.rb +++ b/app/helpers/form_helper.rb @@ -7,12 +7,12 @@ module FormHelper content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do content_tag(:h4, headline) << - content_tag(:ul) do - model.errors.full_messages. - map { |msg| content_tag(:li, msg) }. - join. - html_safe - end + content_tag(:ul) do + model.errors.full_messages. + map { |msg| content_tag(:li, msg) }. + join. + html_safe + end end end end diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index a3331dc80cb..e21178c7377 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -7,12 +7,12 @@ module NavHelper def page_gutter_class if current_path?('merge_requests#show') || - current_path?('merge_requests#diffs') || - current_path?('merge_requests#commits') || - current_path?('merge_requests#builds') || - current_path?('merge_requests#conflicts') || - current_path?('merge_requests#pipelines') || - current_path?('issues#show') + current_path?('merge_requests#diffs') || + current_path?('merge_requests#commits') || + current_path?('merge_requests#builds') || + current_path?('merge_requests#conflicts') || + current_path?('merge_requests#pipelines') || + current_path?('issues#show') if cookies[:collapsed_gutter] == 'true' "page-gutter right-sidebar-collapsed" else @@ -21,9 +21,9 @@ module NavHelper elsif current_path?('builds#show') "page-gutter build-sidebar right-sidebar-expanded" elsif current_path?('wikis#show') || - current_path?('wikis#edit') || - current_path?('wikis#history') || - current_path?('wikis#git_access') + current_path?('wikis#edit') || + current_path?('wikis#history') || + current_path?('wikis#git_access') "page-gutter wiki-sidebar right-sidebar-expanded" end end diff --git a/app/helpers/tab_helper.rb b/app/helpers/tab_helper.rb index 563ddd2a511..547f6258909 100644 --- a/app/helpers/tab_helper.rb +++ b/app/helpers/tab_helper.rb @@ -106,9 +106,9 @@ module TabHelper def branches_tab_class if current_controller?(:protected_branches) || - current_controller?(:branches) || - current_page?(namespace_project_repository_path(@project.namespace, - @project)) + current_controller?(:branches) || + current_page?(namespace_project_repository_path(@project.namespace, + @project)) 'active' end end diff --git a/app/models/member.rb b/app/models/member.rb index 3b65587c66b..d29b3c68cf6 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -89,8 +89,8 @@ class Member < ActiveRecord::Base member = if user.is_a?(User) source.members.find_by(user_id: user.id) || - source.requesters.find_by(user_id: user.id) || - source.members.build(user_id: user.id) + source.requesters.find_by(user_id: user.id) || + source.members.build(user_id: user.id) else source.members.build(invite_email: user) end diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 345041a6ad1..b524ca50ee8 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -161,8 +161,8 @@ module Network def is_overlap?(range, overlap_space) range.each do |i| if i != range.first && - i != range.last && - @commits[i].spaces.include?(overlap_space) + i != range.last && + @commits[i].spaces.include?(overlap_space) return true end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 207bb816ad1..bce2cdd5516 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -85,8 +85,8 @@ class IssueTrackerService < Service def enabled_in_gitlab_config Gitlab.config.issues_tracker && - Gitlab.config.issues_tracker.values.any? && - issues_tracker + Gitlab.config.issues_tracker.values.any? && + issues_tracker end def issues_tracker diff --git a/app/models/user.rb b/app/models/user.rb index 1bd28203523..3f8bbbc425d 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -390,7 +390,7 @@ class User < ActiveRecord::Base def namespace_uniq # Return early if username already failed the first uniqueness validation return if errors.key?(:username) && - errors[:username].include?('has already been taken') + errors[:username].include?('has already been taken') existing_namespace = Namespace.by_path(username) if existing_namespace && existing_namespace != namespace diff --git a/app/policies/note_policy.rb b/app/policies/note_policy.rb index 83847466ee2..5326061bd07 100644 --- a/app/policies/note_policy.rb +++ b/app/policies/note_policy.rb @@ -12,7 +12,7 @@ class NotePolicy < BasePolicy end if @subject.for_merge_request? && - @subject.noteable.author == @user + @subject.noteable.author == @user can! :resolve_note end end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index d5aadfce76a..b5db9c12622 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -3,7 +3,7 @@ class ProjectPolicy < BasePolicy team_access!(user) owner = project.owner == user || - (project.group && project.group.has_owner?(user)) + (project.group && project.group.has_owner?(user)) owner_access! if user.admin? || owner team_member_owner_access! if owner @@ -13,7 +13,7 @@ class ProjectPolicy < BasePolicy public_access! if project.request_access_enabled && - !(owner || user.admin? || project.team.member?(user) || project_group_member?(user)) + !(owner || user.admin? || project.team.member?(user) || project_group_member?(user)) can! :request_access end end @@ -244,10 +244,10 @@ class ProjectPolicy < BasePolicy def project_group_member?(user) project.group && - ( - project.group.members.exists?(user_id: user.id) || - project.group.requesters.exists?(user_id: user.id) - ) + ( + project.group.members.exists?(user_id: user.id) || + project.group.requesters.exists?(user_id: user.id) + ) end def named_abilities(name) diff --git a/app/services/groups/update_service.rb b/app/services/groups/update_service.rb index 99ad12b1003..fff2273f402 100644 --- a/app/services/groups/update_service.rb +++ b/app/services/groups/update_service.rb @@ -5,7 +5,7 @@ module Groups new_visibility = params[:visibility_level] if new_visibility && new_visibility.to_i != group.visibility_level unless can?(current_user, :change_visibility_level, group) && - Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) + Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) deny_visibility_level(group, new_visibility) return group diff --git a/app/services/issues/update_service.rb b/app/services/issues/update_service.rb index a2111b3806b..78cbf94ec69 100644 --- a/app/services/issues/update_service.rb +++ b/app/services/issues/update_service.rb @@ -10,7 +10,7 @@ module Issues end if issue.previous_changes.include?('title') || - issue.previous_changes.include?('description') + issue.previous_changes.include?('description') todo_service.update_issue(issue, current_user) end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index bebfca7537b..6a7393a9921 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -42,7 +42,7 @@ module MergeRequests end if merge_request.source_project == merge_request.target_project && - merge_request.target_branch == merge_request.source_branch + merge_request.target_branch == merge_request.source_branch messages << 'You must select different branches' end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index fda0da19d87..ad16ef8c70f 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -25,7 +25,7 @@ module MergeRequests end if merge_request.previous_changes.include?('title') || - merge_request.previous_changes.include?('description') + merge_request.previous_changes.include?('description') todo_service.update_merge_request(merge_request, current_user) end diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 921ca6748d3..8a6af8d8ada 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -6,7 +6,7 @@ module Projects if new_visibility && new_visibility.to_i != project.visibility_level unless can?(current_user, :change_visibility_level, project) && - Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) + Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) deny_visibility_level(project, new_visibility) return project diff --git a/config/initializers/sidekiq.rb b/config/initializers/sidekiq.rb index 1d7a3f03ace..5a7365bb0f6 100644 --- a/config/initializers/sidekiq.rb +++ b/config/initializers/sidekiq.rb @@ -34,7 +34,7 @@ Sidekiq.configure_server do |config| # Database pool should be at least `sidekiq_concurrency` + 2 # For more info, see: https://github.com/mperham/sidekiq/blob/master/4.0-Upgrade.md config = ActiveRecord::Base.configurations[Rails.env] || - Rails.application.config.database_configuration[Rails.env] + Rails.application.config.database_configuration[Rails.env] config['pool'] = Sidekiq.options[:concurrency] + 2 ActiveRecord::Base.establish_connection(config) Rails.logger.debug("Connection Pool size for Sidekiq Server is now: #{ActiveRecord::Base.connection.pool.instance_variable_get('@size')}") diff --git a/lib/gitlab/email/reply_parser.rb b/lib/gitlab/email/reply_parser.rb index 85402c2a278..f586c5ab062 100644 --- a/lib/gitlab/email/reply_parser.rb +++ b/lib/gitlab/email/reply_parser.rb @@ -69,7 +69,7 @@ module Gitlab # This one might be controversial but so many reply lines have years, times and end with a colon. # Let's try it and see how well it works. break if (l =~ /\d{4}/ && l =~ /\d:\d\d/ && l =~ /\:$/) || - (l =~ /On \w+ \d+,? \d+,?.*wrote:/) + (l =~ /On \w+ \d+,? \d+,?.*wrote:/) # Headers on subsequent lines break if (0..2).all? { |off| lines[idx + off] =~ REPLYING_HEADER_REGEX } diff --git a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb index d619e401897..f4703dc704f 100644 --- a/spec/lib/gitlab/gfm/reference_rewriter_spec.rb +++ b/spec/lib/gitlab/gfm/reference_rewriter_spec.rb @@ -29,7 +29,7 @@ describe Gitlab::Gfm::ReferenceRewriter do context 'description with ignored elements' do let(:text) do "Hi. This references #1, but not `#2`\n" + - '
    and not !1
    ' + '
    and not !1
    ' end it { is_expected.to include issue_first.to_reference(new_project) } diff --git a/spec/requests/api/projects_spec.rb b/spec/requests/api/projects_spec.rb index c5d67a90abc..8304c408064 100644 --- a/spec/requests/api/projects_spec.rb +++ b/spec/requests/api/projects_spec.rb @@ -167,7 +167,7 @@ describe API::Projects, api: true do expect(json_response).to satisfy do |response| response.one? do |entry| entry.has_key?('permissions') && - entry['name'] == project.name && + entry['name'] == project.name && entry['owner']['username'] == user.username end end From 88e364f0f6f8d21b73f9eea786c7f8326dff61fe Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray Date: Fri, 16 Dec 2016 12:56:38 -0600 Subject: [PATCH 132/175] Put back bootstrap wells --- app/assets/stylesheets/framework/tw_bootstrap.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/framework/tw_bootstrap.scss b/app/assets/stylesheets/framework/tw_bootstrap.scss index d998d654aa4..718dbbfea27 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap.scss @@ -35,7 +35,7 @@ @import "bootstrap/alerts"; // @import "bootstrap/progress-bars"; @import "bootstrap/list-group"; -// @import "bootstrap/wells"; +@import "bootstrap/wells"; @import "bootstrap/close"; @import "bootstrap/panels"; From 1b313e8db8513f41808923d9d429305a68bcee3a Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 15 Dec 2016 12:55:20 +0100 Subject: [PATCH 133/175] Make CI/CD detailed status group concept explicit --- app/views/ci/status/_badge.html.haml | 5 +++-- lib/gitlab/ci/status/build/play.rb | 4 ++++ lib/gitlab/ci/status/build/stop.rb | 4 ++++ lib/gitlab/ci/status/core.rb | 9 +-------- spec/lib/gitlab/ci/status/build/cancelable_spec.rb | 8 ++++++++ spec/lib/gitlab/ci/status/build/play_spec.rb | 4 ++++ spec/lib/gitlab/ci/status/build/retryable_spec.rb | 8 ++++++++ spec/lib/gitlab/ci/status/build/stop_spec.rb | 4 ++++ spec/lib/gitlab/ci/status/canceled_spec.rb | 4 ++++ spec/lib/gitlab/ci/status/created_spec.rb | 4 ++++ spec/lib/gitlab/ci/status/failed_spec.rb | 4 ++++ spec/lib/gitlab/ci/status/pending_spec.rb | 4 ++++ spec/lib/gitlab/ci/status/running_spec.rb | 4 ++++ spec/lib/gitlab/ci/status/skipped_spec.rb | 4 ++++ spec/lib/gitlab/ci/status/success_spec.rb | 4 ++++ 15 files changed, 64 insertions(+), 10 deletions(-) diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml index f2135af2686..601fb7f0f3f 100644 --- a/app/views/ci/status/_badge.html.haml +++ b/app/views/ci/status/_badge.html.haml @@ -1,10 +1,11 @@ - status = local_assigns.fetch(:status) +- css_classes = "ci-status ci-#{status.group}" - if status.has_details? - = link_to status.details_path, class: "ci-status ci-#{status}" do + = link_to status.details_path, class: css_classes do = custom_icon(status.icon) = status.text - else - %span{ class: "ci-status ci-#{status}" } + %span{ class: css_classes } = custom_icon(status.icon) = status.text diff --git a/lib/gitlab/ci/status/build/play.rb b/lib/gitlab/ci/status/build/play.rb index 5c506e6d59f..1bf949c96dd 100644 --- a/lib/gitlab/ci/status/build/play.rb +++ b/lib/gitlab/ci/status/build/play.rb @@ -17,6 +17,10 @@ module Gitlab 'icon_status_manual' end + def group + 'manual' + end + def has_action? can?(user, :update_build, subject) end diff --git a/lib/gitlab/ci/status/build/stop.rb b/lib/gitlab/ci/status/build/stop.rb index f8ffa95cde4..e1dfdb76d41 100644 --- a/lib/gitlab/ci/status/build/stop.rb +++ b/lib/gitlab/ci/status/build/stop.rb @@ -17,6 +17,10 @@ module Gitlab 'icon_status_manual' end + def group + 'manual' + end + def has_action? can?(user, :update_build, subject) end diff --git a/lib/gitlab/ci/status/core.rb b/lib/gitlab/ci/status/core.rb index 46fef8262c1..43d2b1b40d4 100644 --- a/lib/gitlab/ci/status/core.rb +++ b/lib/gitlab/ci/status/core.rb @@ -22,14 +22,7 @@ module Gitlab raise NotImplementedError end - # Deprecation warning: this method is here because we need to maintain - # backwards compatibility with legacy statuses. We often do something - # like "ci-status ci-status-#{status}" to set CSS class. - # - # `to_s` method should be renamed to `group` at some point, after - # phasing legacy satuses out. - # - def to_s + def group self.class.name.demodulize.downcase.underscore end diff --git a/spec/lib/gitlab/ci/status/build/cancelable_spec.rb b/spec/lib/gitlab/ci/status/build/cancelable_spec.rb index 9376bce17a1..b3c07347de1 100644 --- a/spec/lib/gitlab/ci/status/build/cancelable_spec.rb +++ b/spec/lib/gitlab/ci/status/build/cancelable_spec.rb @@ -32,6 +32,14 @@ describe Gitlab::Ci::Status::Build::Cancelable do end end + describe '#group' do + it 'does not override status group' do + expect(status).to receive(:group) + + subject.group + end + end + describe 'action details' do let(:user) { create(:user) } let(:build) { create(:ci_build) } diff --git a/spec/lib/gitlab/ci/status/build/play_spec.rb b/spec/lib/gitlab/ci/status/build/play_spec.rb index 4ddf04a8e11..f1b50a59ce6 100644 --- a/spec/lib/gitlab/ci/status/build/play_spec.rb +++ b/spec/lib/gitlab/ci/status/build/play_spec.rb @@ -18,6 +18,10 @@ describe Gitlab::Ci::Status::Build::Play do it { expect(subject.icon).to eq 'icon_status_manual' } end + describe '#group' do + it { expect(subject.group).to eq 'manual' } + end + describe 'action details' do let(:user) { create(:user) } let(:build) { create(:ci_build) } diff --git a/spec/lib/gitlab/ci/status/build/retryable_spec.rb b/spec/lib/gitlab/ci/status/build/retryable_spec.rb index d61e5bbaa6b..62036f8ec5d 100644 --- a/spec/lib/gitlab/ci/status/build/retryable_spec.rb +++ b/spec/lib/gitlab/ci/status/build/retryable_spec.rb @@ -32,6 +32,14 @@ describe Gitlab::Ci::Status::Build::Retryable do end end + describe '#group' do + it 'does not override status group' do + expect(status).to receive(:group) + + subject.group + end + end + describe 'action details' do let(:user) { create(:user) } let(:build) { create(:ci_build) } diff --git a/spec/lib/gitlab/ci/status/build/stop_spec.rb b/spec/lib/gitlab/ci/status/build/stop_spec.rb index 59a85b55f90..597e02e86e4 100644 --- a/spec/lib/gitlab/ci/status/build/stop_spec.rb +++ b/spec/lib/gitlab/ci/status/build/stop_spec.rb @@ -20,6 +20,10 @@ describe Gitlab::Ci::Status::Build::Stop do it { expect(subject.icon).to eq 'icon_status_manual' } end + describe '#group' do + it { expect(subject.group).to eq 'manual' } + end + describe 'action details' do let(:user) { create(:user) } let(:build) { create(:ci_build) } diff --git a/spec/lib/gitlab/ci/status/canceled_spec.rb b/spec/lib/gitlab/ci/status/canceled_spec.rb index 4639278ad45..38412fe2e4f 100644 --- a/spec/lib/gitlab/ci/status/canceled_spec.rb +++ b/spec/lib/gitlab/ci/status/canceled_spec.rb @@ -16,4 +16,8 @@ describe Gitlab::Ci::Status::Canceled do describe '#icon' do it { expect(subject.icon).to eq 'icon_status_canceled' } end + + describe '#group' do + it { expect(subject.group).to eq 'canceled' } + end end diff --git a/spec/lib/gitlab/ci/status/created_spec.rb b/spec/lib/gitlab/ci/status/created_spec.rb index 2ce176a29d6..6d847484693 100644 --- a/spec/lib/gitlab/ci/status/created_spec.rb +++ b/spec/lib/gitlab/ci/status/created_spec.rb @@ -16,4 +16,8 @@ describe Gitlab::Ci::Status::Created do describe '#icon' do it { expect(subject.icon).to eq 'icon_status_created' } end + + describe '#group' do + it { expect(subject.group).to eq 'created' } + end end diff --git a/spec/lib/gitlab/ci/status/failed_spec.rb b/spec/lib/gitlab/ci/status/failed_spec.rb index 9d527e6a7ef..990d686d22c 100644 --- a/spec/lib/gitlab/ci/status/failed_spec.rb +++ b/spec/lib/gitlab/ci/status/failed_spec.rb @@ -16,4 +16,8 @@ describe Gitlab::Ci::Status::Failed do describe '#icon' do it { expect(subject.icon).to eq 'icon_status_failed' } end + + describe '#group' do + it { expect(subject.group).to eq 'failed' } + end end diff --git a/spec/lib/gitlab/ci/status/pending_spec.rb b/spec/lib/gitlab/ci/status/pending_spec.rb index d03f595d3c7..7bb6579c317 100644 --- a/spec/lib/gitlab/ci/status/pending_spec.rb +++ b/spec/lib/gitlab/ci/status/pending_spec.rb @@ -16,4 +16,8 @@ describe Gitlab::Ci::Status::Pending do describe '#icon' do it { expect(subject.icon).to eq 'icon_status_pending' } end + + describe '#group' do + it { expect(subject.group).to eq 'pending' } + end end diff --git a/spec/lib/gitlab/ci/status/running_spec.rb b/spec/lib/gitlab/ci/status/running_spec.rb index 9f47090d396..852d6c06baf 100644 --- a/spec/lib/gitlab/ci/status/running_spec.rb +++ b/spec/lib/gitlab/ci/status/running_spec.rb @@ -16,4 +16,8 @@ describe Gitlab::Ci::Status::Running do describe '#icon' do it { expect(subject.icon).to eq 'icon_status_running' } end + + describe '#group' do + it { expect(subject.group).to eq 'running' } + end end diff --git a/spec/lib/gitlab/ci/status/skipped_spec.rb b/spec/lib/gitlab/ci/status/skipped_spec.rb index 94601648a8d..e00b356a24b 100644 --- a/spec/lib/gitlab/ci/status/skipped_spec.rb +++ b/spec/lib/gitlab/ci/status/skipped_spec.rb @@ -16,4 +16,8 @@ describe Gitlab::Ci::Status::Skipped do describe '#icon' do it { expect(subject.icon).to eq 'icon_status_skipped' } end + + describe '#group' do + it { expect(subject.group).to eq 'skipped' } + end end diff --git a/spec/lib/gitlab/ci/status/success_spec.rb b/spec/lib/gitlab/ci/status/success_spec.rb index 90f9f615e0d..4a89e1faf40 100644 --- a/spec/lib/gitlab/ci/status/success_spec.rb +++ b/spec/lib/gitlab/ci/status/success_spec.rb @@ -16,4 +16,8 @@ describe Gitlab::Ci::Status::Success do describe '#icon' do it { expect(subject.icon).to eq 'icon_status_success' } end + + describe '#group' do + it { expect(subject.group).to eq 'success' } + end end From cce41cb2a6574de609211e4e2284f684e2d236d8 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Thu, 15 Dec 2016 18:08:11 +0000 Subject: [PATCH 134/175] Adds CSS --- app/assets/stylesheets/framework/icons.scss | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 226bd2ead31..b37847e3d96 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -49,3 +49,11 @@ fill: $gray-darkest; } } + +.ci-status-icon-manual { + color: $gl-text-color; + + svg { + fill: $gray-darkest; + } +} From b18897ac40ffa9f68cf189f2a011ea6eca5ac74f Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 16 Dec 2016 15:28:24 +0100 Subject: [PATCH 135/175] Update status group name for success with warnings --- lib/gitlab/ci/status/pipeline/success_with_warnings.rb | 2 +- .../gitlab/ci/status/pipeline/success_with_warnings_spec.rb | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/lib/gitlab/ci/status/pipeline/success_with_warnings.rb b/lib/gitlab/ci/status/pipeline/success_with_warnings.rb index a7c98f9e909..24bf8b869e0 100644 --- a/lib/gitlab/ci/status/pipeline/success_with_warnings.rb +++ b/lib/gitlab/ci/status/pipeline/success_with_warnings.rb @@ -17,7 +17,7 @@ module Gitlab 'icon_status_warning' end - def to_s + def group 'success_with_warnings' end diff --git a/spec/lib/gitlab/ci/status/pipeline/success_with_warnings_spec.rb b/spec/lib/gitlab/ci/status/pipeline/success_with_warnings_spec.rb index 7e3383c307f..979160eb9c4 100644 --- a/spec/lib/gitlab/ci/status/pipeline/success_with_warnings_spec.rb +++ b/spec/lib/gitlab/ci/status/pipeline/success_with_warnings_spec.rb @@ -17,6 +17,10 @@ describe Gitlab::Ci::Status::Pipeline::SuccessWithWarnings do it { expect(subject.icon).to eq 'icon_status_warning' } end + describe '#group' do + it { expect(subject.group).to eq 'success_with_warnings' } + end + describe '.matches?' do context 'when pipeline is successful' do let(:pipeline) do From 1476bb2c54d8195ea673777c2d5c873c2a3becf7 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Fri, 16 Dec 2016 15:32:54 +0100 Subject: [PATCH 136/175] Improve how we calculate detailed status group name --- lib/gitlab/ci/status/core.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/gitlab/ci/status/core.rb b/lib/gitlab/ci/status/core.rb index 43d2b1b40d4..73b6ab5a635 100644 --- a/lib/gitlab/ci/status/core.rb +++ b/lib/gitlab/ci/status/core.rb @@ -23,7 +23,7 @@ module Gitlab end def group - self.class.name.demodulize.downcase.underscore + self.class.name.demodulize.underscore end def has_details? From dbe2ac8ccc07f53669214eb954489a6cb233d4e9 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Fri, 16 Dec 2016 17:11:04 -0200 Subject: [PATCH 137/175] Fix rubucop offenses --- lib/gitlab/bitbucket_import/importer.rb | 50 +++++++++---------- .../bitbucket/representation/issue_spec.rb | 1 - 2 files changed, 25 insertions(+), 26 deletions(-) diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index f3760640655..d5287c69e6e 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -60,8 +60,6 @@ module Gitlab create_labels - gitlab_issue = nil - client.issues(repo).each do |issue| begin description = '' @@ -87,31 +85,33 @@ module Gitlab gitlab_issue.labels << @labels[label_name] - if gitlab_issue.persisted? - client.issue_comments(repo, issue.iid).each do |comment| - # The note can be blank for issue service messages like "Changed title: ..." - # We would like to import those comments as well but there is no any - # specific parameter that would allow to process them, it's just an empty comment. - # To prevent our importer from just crashing or from creating useless empty comments - # we do this check. - next unless comment.note.present? + import_issue_comments(issue, gitlab_issue) if gitlab_issue.persisted? + end + end - note = '' - note += @formatter.author_line(comment.author) unless find_user_id(comment.author) - note += comment.note + def import_issue_comments(issue, gitlab_issue) + client.issue_comments(repo, issue.iid).each do |comment| + # The note can be blank for issue service messages like "Changed title: ..." + # We would like to import those comments as well but there is no any + # specific parameter that would allow to process them, it's just an empty comment. + # To prevent our importer from just crashing or from creating useless empty comments + # we do this check. + next unless comment.note.present? - begin - gitlab_issue.notes.create!( - project: project, - note: note, - author_id: gitlab_user_id(project, comment.author), - created_at: comment.created_at, - updated_at: comment.updated_at - ) - rescue StandardError => e - errors << { type: :issue_comment, iid: issue.iid, errors: e.message } - end - end + note = '' + note += @formatter.author_line(comment.author) unless find_user_id(comment.author) + note += comment.note + + begin + gitlab_issue.notes.create!( + project: project, + note: note, + author_id: gitlab_user_id(project, comment.author), + created_at: comment.created_at, + updated_at: comment.updated_at + ) + rescue StandardError => e + errors << { type: :issue_comment, iid: issue.iid, errors: e.message } end end end diff --git a/spec/lib/bitbucket/representation/issue_spec.rb b/spec/lib/bitbucket/representation/issue_spec.rb index 9a195bebd31..20f47224aa8 100644 --- a/spec/lib/bitbucket/representation/issue_spec.rb +++ b/spec/lib/bitbucket/representation/issue_spec.rb @@ -14,7 +14,6 @@ describe Bitbucket::Representation::Issue do it { expect(described_class.new({}).milestone).to be_nil } end - describe '#author' do it { expect(described_class.new({ 'reporter' => { 'username' => 'Ben' } }).author).to eq('Ben') } it { expect(described_class.new({}).author).to be_nil } From d58fffb27766f49b82866c5460fe218854f9596e Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Fri, 16 Dec 2016 14:30:28 -0600 Subject: [PATCH 138/175] fix margin on alert stripes within ":flash_message" block --- app/assets/stylesheets/framework/layout.scss | 8 ++++++++ app/views/layouts/_page.html.haml | 7 ++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 66711aa1804..bef24162924 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -32,6 +32,14 @@ body { } } +.alert-wrapper { + margin-bottom: $gl-padding; + + .alert { + margin-bottom: 0; + } +} + /* The following prevents side effects related to iOS Safari's implementation of -webkit-overflow-scrolling: touch, which is applied to the body by jquery.nicescroling plugin to force hardware acceleration for momentum scrolling. Side diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index a9a0b149049..54d02ee8e4b 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -22,9 +22,10 @@ = render "layouts/nav/#{nav}" .content-wrapper{ class: "#{layout_nav_class}" } = yield :sub_nav - = render "layouts/broadcast" - = render "layouts/flash" - = yield :flash_message + .alert-wrapper + = render "layouts/broadcast" + = render "layouts/flash" + = yield :flash_message %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } .content{ id: "content-body" } = yield From dc9b0913670dc8de5e01a40fd28fa44f48defc9e Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Fri, 16 Dec 2016 14:38:01 -0600 Subject: [PATCH 139/175] stripe colors for successive alert-warning blocks --- app/assets/stylesheets/framework/layout.scss | 27 +++++++++++++++++++ .../stylesheets/framework/variables.scss | 1 - app/assets/stylesheets/pages/projects.scss | 6 ----- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index bef24162924..59fae61a44f 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -38,6 +38,33 @@ body { .alert { margin-bottom: 0; } + + /* Stripe the background colors so that adjacent alert-warnings are distinct from one another */ + .alert-warning { + transition: background-color 0.15s, border-color 0.15s; + background-color: lighten($gl-warning, 4%); + border-color: lighten($gl-warning, 4%); + } + + .alert-warning + .alert-warning { + background-color: $gl-warning; + border-color: $gl-warning; + } + + .alert-warning + .alert-warning + .alert-warning { + background-color: darken($gl-warning, 4%); + border-color: darken($gl-warning, 4%); + } + + .alert-warning + .alert-warning + .alert-warning + .alert-warning { + background-color: darken($gl-warning, 8%); + border-color: darken($gl-warning, 8%); + } + + .alert-warning:only-of-type { + background-color: $gl-warning; + border-color: $gl-warning; + } } diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 267fcd77b38..0b156d61a23 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -474,7 +474,6 @@ $project-option-descr-color: #54565b; $project-breadcrumb-color: #999; $project-private-forks-notice-odd: #2aa056; $project-network-controls-color: #888; -$project-limit-message-bg: #f28d35; /* * Runners diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index 9c3dbc58ae0..3b1b375133d 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -6,12 +6,6 @@ } } -.no-ssh-key-message, -.project-limit-message { - background-color: $project-limit-message-bg; - margin-bottom: 0; -} - .new_project, .edit-project { From ac07ce64a8a62ed4901787063001c7175a5e2cd5 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Fri, 16 Dec 2016 14:41:32 -0600 Subject: [PATCH 140/175] add CHANGELOG.md entry for !8151 --- ...lean-up-css-for-project-alerts-and-flash-notifications.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/25743-clean-up-css-for-project-alerts-and-flash-notifications.yml diff --git a/changelogs/unreleased/25743-clean-up-css-for-project-alerts-and-flash-notifications.yml b/changelogs/unreleased/25743-clean-up-css-for-project-alerts-and-flash-notifications.yml new file mode 100644 index 00000000000..0a81124de0d --- /dev/null +++ b/changelogs/unreleased/25743-clean-up-css-for-project-alerts-and-flash-notifications.yml @@ -0,0 +1,4 @@ +--- +title: fix colors and margins for adjacent alert banners +merge_request: 8151 +author: From fe9a372c0b64b47117fc0a64dbdfb514f757ee6e Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Fri, 16 Dec 2016 19:11:48 -0200 Subject: [PATCH 141/175] Fix import issues method --- lib/gitlab/bitbucket_import/importer.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/gitlab/bitbucket_import/importer.rb b/lib/gitlab/bitbucket_import/importer.rb index d5287c69e6e..7d2f92d577a 100644 --- a/lib/gitlab/bitbucket_import/importer.rb +++ b/lib/gitlab/bitbucket_import/importer.rb @@ -79,13 +79,13 @@ module Gitlab created_at: issue.created_at, updated_at: issue.updated_at ) + + gitlab_issue.labels << @labels[label_name] + + import_issue_comments(issue, gitlab_issue) if gitlab_issue.persisted? rescue StandardError => e errors << { type: :issue, iid: issue.iid, errors: e.message } end - - gitlab_issue.labels << @labels[label_name] - - import_issue_comments(issue, gitlab_issue) if gitlab_issue.persisted? end end From a3be4aeb7a71cc940394a5f13d09e79fcafdb1d5 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Fri, 16 Dec 2016 19:51:40 -0200 Subject: [PATCH 142/175] Avoid use of Hash#dig to keep compatibility with Ruby 2.1 --- lib/bitbucket/representation/comment.rb | 2 +- lib/bitbucket/representation/issue.rb | 6 +++--- lib/bitbucket/representation/pull_request.rb | 10 +++++----- lib/bitbucket/representation/pull_request_comment.rb | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/lib/bitbucket/representation/comment.rb b/lib/bitbucket/representation/comment.rb index 3c75e9368fa..4937aa9728f 100644 --- a/lib/bitbucket/representation/comment.rb +++ b/lib/bitbucket/representation/comment.rb @@ -6,7 +6,7 @@ module Bitbucket end def note - raw.dig('content', 'raw') + raw.fetch('content', {}).fetch('raw', nil) end def created_at diff --git a/lib/bitbucket/representation/issue.rb b/lib/bitbucket/representation/issue.rb index 3af731753d1..054064395c3 100644 --- a/lib/bitbucket/representation/issue.rb +++ b/lib/bitbucket/representation/issue.rb @@ -12,11 +12,11 @@ module Bitbucket end def author - raw.dig('reporter', 'username') + raw.fetch('reporter', {}).fetch('username', nil) end def description - raw.dig('content', 'raw') + raw.fetch('content', {}).fetch('raw', nil) end def state @@ -28,7 +28,7 @@ module Bitbucket end def milestone - raw.dig('milestone', 'name') + raw['milestone']['name'] if raw['milestone'].present? end def created_at diff --git a/lib/bitbucket/representation/pull_request.rb b/lib/bitbucket/representation/pull_request.rb index e37c9a62c0e..eebf8093380 100644 --- a/lib/bitbucket/representation/pull_request.rb +++ b/lib/bitbucket/representation/pull_request.rb @@ -2,7 +2,7 @@ module Bitbucket module Representation class PullRequest < Representation::Base def author - raw.dig('author', 'username') + raw.fetch('author', {}).fetch('username', nil) end def description @@ -36,19 +36,19 @@ module Bitbucket end def source_branch_name - source_branch.dig('branch', 'name') + source_branch.fetch('branch', {}).fetch('name', nil) end def source_branch_sha - source_branch.dig('commit', 'hash') + source_branch.fetch('commit', {}).fetch('hash', nil) end def target_branch_name - target_branch.dig('branch', 'name') + target_branch.fetch('branch', {}).fetch('name', nil) end def target_branch_sha - target_branch.dig('commit', 'hash') + target_branch.fetch('commit', {}).fetch('hash', nil) end private diff --git a/lib/bitbucket/representation/pull_request_comment.rb b/lib/bitbucket/representation/pull_request_comment.rb index 4f3809fbcea..4f8efe03bae 100644 --- a/lib/bitbucket/representation/pull_request_comment.rb +++ b/lib/bitbucket/representation/pull_request_comment.rb @@ -18,7 +18,7 @@ module Bitbucket end def parent_id - raw.dig('parent', 'id') + raw.fetch('parent', {}).fetch('id', nil) end def inline? From 34ed74fab90904eb77c8b07779401650684af5b1 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Fri, 16 Dec 2016 19:59:53 -0200 Subject: [PATCH 143/175] Avoid use of Hash#dig to keep compatibility with Ruby 2.1 --- app/controllers/concerns/oauth_applications.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/controllers/concerns/oauth_applications.rb b/app/controllers/concerns/oauth_applications.rb index 7210ed3eb32..9849aa93fa6 100644 --- a/app/controllers/concerns/oauth_applications.rb +++ b/app/controllers/concerns/oauth_applications.rb @@ -6,7 +6,7 @@ module OauthApplications end def prepare_scopes - scopes = params.dig(:doorkeeper_application, :scopes) + scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil) if scopes params[:doorkeeper_application][:scopes] = scopes.join(' ') From 09388b2021034173156ba8958fa290b01e3a447d Mon Sep 17 00:00:00 2001 From: Nur Rony Date: Tue, 18 Oct 2016 18:22:18 +0600 Subject: [PATCH 144/175] Adds sort dropdown for group members --- app/assets/stylesheets/pages/members.scss | 56 ++++++++++++++----- .../groups/group_members_controller.rb | 10 +++- .../projects/project_members_controller.rb | 5 +- app/helpers/members_helper.rb | 30 +++++++--- app/helpers/sorting_helper.rb | 41 +++++++++++++- .../groups/group_members/index.html.haml | 1 + .../projects/project_members/index.html.haml | 1 + .../shared/members/_sort_dropdown.html.haml | 11 ++++ 8 files changed, 128 insertions(+), 27 deletions(-) create mode 100644 app/views/shared/members/_sort_dropdown.html.haml diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index 5f3bbb40ba0..b7521133ce5 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -78,6 +78,20 @@ float: right; } + .dropdown { + width: 100%; + margin-top: 5px; + + .dropdown-menu-toggle { + width: 100%; + } + + @media (min-width: $screen-sm-min) { + top: 2.4px; + width: 155px; + } + } + .form-control { width: 100%; padding-right: 35px; @@ -85,18 +99,34 @@ @media (min-width: $screen-sm-min) { width: 350px; } - } -} -.member-search-btn { - position: absolute; - right: 0; - top: 0; - height: 35px; - padding-left: 10px; - padding-right: 10px; - color: $gray-darkest; - background: transparent; - border: 0; - outline: 0; + &.input-short { + @media (min-width: $screen-md-min) { + width: 170px; + } + + @media (min-width: $screen-lg-min) { + width: 210px; + } + } + } + + .member-search-btn { + position: absolute; + right: 4px; + top: 0; + height: 35px; + padding-left: 10px; + padding-right: 10px; + color: $gray-darkest; + background: transparent; + border: 0; + outline: 0; + + @media (min-width: $screen-sm-min) { + right: 160px; + top: 8px; + } + } + } diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 940a3ad20ba..af8abb96d4a 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -10,11 +10,15 @@ class Groups::GroupMembersController < Groups::ApplicationController @members = @members.non_invite unless can?(current_user, :admin_group, @group) if params[:search].present? - users = @group.users.search(params[:search]).to_a - @members = @members.where(user_id: users) + @members = @members.joins(:user).merge(User.search(params[:search])) end - @members = @members.order('access_level DESC').page(params[:page]).per(50) + if params[:sort].present? + @members = @members.joins(:user).merge(User.sort(@sort = params[:sort])) + end + + + @members = @members.page(params[:page]).per(50) @requesters = AccessRequestsFinder.new(@group).execute(current_user) @group_member = @group.group_members.new diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index 53308948f62..e4aba4b700e 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -1,10 +1,12 @@ class Projects::ProjectMembersController < Projects::ApplicationController include MembershipActions + include SortingHelper # Authorize before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] def index + @sort = params[:sort].presence || sort_value_name @group_links = @project.project_group_links @project_members = @project.project_members @@ -40,7 +42,8 @@ class Projects::ProjectMembersController < Projects::ApplicationController @project_members = Member. where(wheres.join(' OR ')). - order(access_level: :desc).page(params[:page]) + sort(@sort). + page(params[:page]) @requesters = AccessRequestsFinder.new(@project).execute(current_user) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index 877c77050be..f1b8962eb36 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -11,17 +11,17 @@ module MembersHelper text = 'Are you sure you want to ' action = - if member.request? - if member.user == user - 'withdraw your access request for' + if member.request? + if member.user == user + 'withdraw your access request for' + else + "deny #{member.user.name}'s request to join" + end + elsif member.invite? + "revoke the invitation for #{member.invite_email} to join" else - "deny #{member.user.name}'s request to join" + "remove #{member.user.name} from" end - elsif member.invite? - "revoke the invitation for #{member.invite_email} to join" - else - "remove #{member.user.name} from" - end text << action << " the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?" end @@ -36,4 +36,16 @@ module MembersHelper "Are you sure you want to leave the " \ "\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?" end + + def filter_group_project_member_path(options = {}) + exist_opts = { + search: params[:search], + sort: params[:sort] + } + + options = exist_opts.merge(options) + path = request.path + path << "?#{options.to_param}" + path + end end diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 8b138a8e69f..9c6f9f741ed 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -25,7 +25,7 @@ module SortingHelper sort_value_recently_updated => sort_title_recently_updated, sort_value_oldest_updated => sort_title_oldest_updated, sort_value_recently_created => sort_title_recently_created, - sort_value_oldest_created => sort_title_oldest_created, + sort_value_oldest_created => sort_title_oldest_created } if current_controller?('admin/projects') @@ -35,6 +35,17 @@ module SortingHelper options end + def member_sort_options_hash + { + sort_value_last_joined => sort_title_last_joined, + sort_value_oldest_joined => sort_title_oldest_joined, + sort_value_name => sort_title_name_asc, + sort_value_name_desc => sort_title_name_desc, + sort_value_recently_signin => sort_title_recently_signin, + sort_value_oldest_signin => sort_title_oldest_signin + } + end + def sort_title_priority 'Priority' end @@ -95,6 +106,34 @@ module SortingHelper 'Most popular' end + def sort_title_last_joined + 'Last joined' + end + + def sort_title_oldest_joined + 'Oldest joined' + end + + def sort_title_name_asc + 'Name, ascending' + end + + def sort_title_name_desc + 'Name, descending' + end + + def sort_value_last_joined + 'last_joined' + end + + def sort_value_oldest_joined + 'oldest_joined' + end + + def sort_value_name_desc + 'name_desc' + end + def sort_value_priority 'priority' end diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index ebf9aca7700..bc5d3c797ac 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -21,6 +21,7 @@ = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } = icon("search") + = render 'shared/members/sort_dropdown' .panel.panel-default .panel-heading Users with access to diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index bdeb704b6da..4f1cec20f85 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -21,6 +21,7 @@ = search_field_tag :search, params[:search], { placeholder: 'Find existing members by name', class: 'form-control', spellcheck: false } %button.member-search-btn{ type: "submit", "aria-label" => "Submit search" } = icon("search") + = render 'shared/members/sort_dropdown' - if @group_links.any? = render 'groups', group_links: @group_links diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml new file mode 100644 index 00000000000..42c09636ba3 --- /dev/null +++ b/app/views/shared/members/_sort_dropdown.html.haml @@ -0,0 +1,11 @@ +- @sort ||= sort_value_last_joined + +.dropdown.inline + = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' }, { id: 'sort-members-dropdown' }) + %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable + %li.dropdown-header + Sort by + - member_sort_options_hash.each do |value, title| + %li + = link_to filter_group_project_member_path(sort: value), class: ("is-active" if @sort == value) do + = title From f438a2aabe7f75c0d6ed3a6c1ce2ab5a62e33b31 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Wed, 16 Nov 2016 18:42:39 -0200 Subject: [PATCH 145/175] Add CHANGELOG entry --- .../23573-sort-functionality-for-project-member.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/23573-sort-functionality-for-project-member.yml diff --git a/changelogs/unreleased/23573-sort-functionality-for-project-member.yml b/changelogs/unreleased/23573-sort-functionality-for-project-member.yml new file mode 100644 index 00000000000..73de0a6351b --- /dev/null +++ b/changelogs/unreleased/23573-sort-functionality-for-project-member.yml @@ -0,0 +1,4 @@ +--- +title: Add sorting functionality for group/project members +merge_request: 7032 +author: From f54ddbf1ecb91ea96b52e18f87ff5ed10ad45747 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Wed, 16 Nov 2016 19:35:33 -0200 Subject: [PATCH 146/175] Fix MembersHelper --- app/helpers/members_helper.rb | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index f1b8962eb36..cf8fdfca1bd 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -11,17 +11,17 @@ module MembersHelper text = 'Are you sure you want to ' action = - if member.request? - if member.user == user - 'withdraw your access request for' - else - "deny #{member.user.name}'s request to join" - end - elsif member.invite? - "revoke the invitation for #{member.invite_email} to join" + if member.request? + if member.user == user + 'withdraw your access request for' else - "remove #{member.user.name} from" + "deny #{member.user.name}'s request to join" end + elsif member.invite? + "revoke the invitation for #{member.invite_email} to join" + else + "remove #{member.user.name} from" + end text << action << " the #{member.source.human_name} #{member.real_source_type.humanize(capitalize: false)}?" end @@ -39,8 +39,8 @@ module MembersHelper def filter_group_project_member_path(options = {}) exist_opts = { - search: params[:search], - sort: params[:sort] + search: params[:search], + sort: params[:sort] } options = exist_opts.merge(options) From 59d43bea80b56faff54630934694b317cda9f899 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Wed, 16 Nov 2016 19:37:51 -0200 Subject: [PATCH 147/175] Fix sort functionality for group/project members --- .../groups/group_members_controller.rb | 14 ++++--------- app/models/member.rb | 20 +++++++++++++++++++ app/models/user.rb | 6 ++++-- 3 files changed, 28 insertions(+), 12 deletions(-) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index af8abb96d4a..812aaa4c191 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -6,19 +6,13 @@ class Groups::GroupMembersController < Groups::ApplicationController def index @project = @group.projects.find(params[:project_id]) if params[:project_id] + @members = @group.group_members @members = @members.non_invite unless can?(current_user, :admin_group, @group) - - if params[:search].present? - @members = @members.joins(:user).merge(User.search(params[:search])) - end - - if params[:sort].present? - @members = @members.joins(:user).merge(User.sort(@sort = params[:sort])) - end - - + @members = @members.search(params[:search]) if params[:search].present? + @members = @members.sort(@sort = params[:sort]) if params[:sort].present? @members = @members.page(params[:page]).per(50) + @requesters = AccessRequestsFinder.new(@group).execute(current_user) @group_member = @group.group_members.new diff --git a/app/models/member.rb b/app/models/member.rb index 3b65587c66b..b82b16e6f33 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -57,6 +57,11 @@ class Member < ActiveRecord::Base scope :owners, -> { active.where(access_level: OWNER) } scope :owners_and_masters, -> { active.where(access_level: [OWNER, MASTER]) } + scope :order_name_asc, -> { joins(:user).merge(User.order_name_asc) } + scope :order_name_desc, -> { joins(:user).merge(User.order_name_desc) } + scope :order_recent_sign_in, -> { joins(:user).merge(User.order_recent_sign_in) } + scope :order_oldest_sign_in, -> { joins(:user).merge(User.order_oldest_sign_in) } + before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } after_create :send_invite, if: :invite?, unless: :importing? @@ -72,6 +77,21 @@ class Member < ActiveRecord::Base default_value_for :notification_level, NotificationSetting.levels[:global] class << self + def search(query) + joins(:user).merge(User.search(query)) + end + + def sort(method) + case method.to_s + when 'recent_sign_in' then order_recent_sign_in + when 'oldest_sign_in' then order_oldest_sign_in + when 'last_joined' then order_created_desc + when 'oldest_joined' then order_created_asc + else + order_by(method) + end + end + def access_for_user_ids(user_ids) where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h end diff --git a/app/models/user.rb b/app/models/user.rb index 1bd28203523..a2812d68384 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -178,6 +178,8 @@ class User < ActiveRecord::Base 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 WHERE user_id IS NOT NULL AND requested_at IS NULL)') } scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) } + scope :order_recent_sign_in, -> { reorder(last_sign_in_at: :desc) } + scope :order_oldest_sign_in, -> { reorder(last_sign_in_at: :asc) } def self.with_two_factor joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id"). @@ -205,8 +207,8 @@ class User < ActiveRecord::Base def sort(method) case method.to_s - when 'recent_sign_in' then reorder(last_sign_in_at: :desc) - when 'oldest_sign_in' then reorder(last_sign_in_at: :asc) + when 'recent_sign_in' then order_recent_sign_in + when 'oldest_sign_in' then order_oldest_sign_in else order_by(method) end From 7783267d6cc41b6a5ced907316aefbc71f2a8e7e Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Wed, 16 Nov 2016 19:45:35 -0200 Subject: [PATCH 148/175] Add option to sort group/project members by access level --- app/helpers/sorting_helper.rb | 18 ++++++++++++++++++ app/models/member.rb | 2 ++ 2 files changed, 20 insertions(+) diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 9c6f9f741ed..f03c4627050 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -37,6 +37,8 @@ module SortingHelper def member_sort_options_hash { + sort_value_access_level_asc => sort_title_access_level_asc, + sort_value_access_level_desc => sort_title_access_level_desc, sort_value_last_joined => sort_title_last_joined, sort_value_oldest_joined => sort_title_oldest_joined, sort_value_name => sort_title_name_asc, @@ -114,6 +116,14 @@ module SortingHelper 'Oldest joined' end + def sort_title_access_level_asc + 'Access level, ascending' + end + + def sort_title_access_level_desc + 'Access level, descending' + end + def sort_title_name_asc 'Name, ascending' end @@ -130,6 +140,14 @@ module SortingHelper 'oldest_joined' end + def sort_value_access_level_asc + 'access_level_asc' + end + + def sort_value_access_level_desc + 'access_level_desc' + end + def sort_value_name_desc 'name_desc' end diff --git a/app/models/member.rb b/app/models/member.rb index b82b16e6f33..8c36a631ac4 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -83,6 +83,8 @@ class Member < ActiveRecord::Base def sort(method) case method.to_s + when 'access_level_asc' then reorder(access_level: :asc) + when 'access_level_desc' then reorder(access_level: :desc) when 'recent_sign_in' then order_recent_sign_in when 'oldest_sign_in' then order_oldest_sign_in when 'last_joined' then order_created_desc From 4b7a3d0c38126489c42a207411b510b5a7b3264b Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Wed, 16 Nov 2016 20:24:43 -0200 Subject: [PATCH 149/175] Add feature spec for sort functionality on group/project members list --- spec/features/groups/members/sorting_spec.rb | 82 +++++++++++++++++++ .../features/projects/members/sorting_spec.rb | 82 +++++++++++++++++++ 2 files changed, 164 insertions(+) create mode 100644 spec/features/groups/members/sorting_spec.rb create mode 100644 spec/features/projects/members/sorting_spec.rb diff --git a/spec/features/groups/members/sorting_spec.rb b/spec/features/groups/members/sorting_spec.rb new file mode 100644 index 00000000000..5990e43c261 --- /dev/null +++ b/spec/features/groups/members/sorting_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +feature 'Groups > Members > Sorting', feature: true do + let(:owner) { create(:user, name: 'John Doe') } + let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) } + let(:group) { create(:group) } + + background do + group.add_owner(owner) + group.add_developer(developer) + + login_as(owner) + end + + scenario 'sorts by access level ascending' do + visit_members_list(sort: :access_level_asc) + + expect(first_member).to include(developer.name) + expect(second_member).to include(owner.name) + end + + scenario 'sorts by access level descending' do + visit_members_list(sort: :access_level_desc) + + expect(first_member).to include(owner.name) + expect(second_member).to include(developer.name) + end + + scenario 'sorts by last joined' do + visit_members_list(sort: :last_joined) + + expect(first_member).to include(developer.name) + expect(second_member).to include(owner.name) + end + + scenario 'sorts by oldest joined' do + visit_members_list(sort: :oldest_joined) + + expect(first_member).to include(owner.name) + expect(second_member).to include(developer.name) + end + + scenario 'sorts by name ascending' do + visit_members_list(sort: :name_asc) + + expect(first_member).to include(owner.name) + expect(second_member).to include(developer.name) + end + + scenario 'sorts by name descending' do + visit_members_list(sort: :name_desc) + + expect(first_member).to include(developer.name) + expect(second_member).to include(owner.name) + end + + scenario 'sorts by recent sign in' do + visit_members_list(sort: :recent_sign_in) + + expect(first_member).to include(owner.name) + expect(second_member).to include(developer.name) + end + + scenario 'sorts by oldest sign in' do + visit_members_list(sort: :oldest_sign_in) + + expect(first_member).to include(developer.name) + expect(second_member).to include(owner.name) + end + + def visit_members_list(sort:) + visit group_group_members_path(group.to_param, sort: sort) + end + + def first_member + page.all('ul.content-list > li').first.text + end + + def second_member + page.all('ul.content-list > li').last.text + end +end diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb new file mode 100644 index 00000000000..597e72a5599 --- /dev/null +++ b/spec/features/projects/members/sorting_spec.rb @@ -0,0 +1,82 @@ +require 'spec_helper' + +feature 'Projects > Members > Sorting', feature: true do + let(:master) { create(:user, name: 'John Doe') } + let(:developer) { create(:user, name: 'Mary Jane', last_sign_in_at: 5.days.ago) } + let(:project) { create(:empty_project) } + + background do + project.team << [master, :master] + project.team << [developer, :developer] + + login_as(master) + end + + scenario 'sorts by access level ascending' do + visit_members_list(sort: :access_level_asc) + + expect(first_member).to include(developer.name) + expect(second_member).to include(master.name) + end + + scenario 'sorts by access level descending' do + visit_members_list(sort: :access_level_desc) + + expect(first_member).to include(master.name) + expect(second_member).to include(developer.name) + end + + scenario 'sorts by last joined' do + visit_members_list(sort: :last_joined) + + expect(first_member).to include(developer.name) + expect(second_member).to include(master.name) + end + + scenario 'sorts by oldest joined' do + visit_members_list(sort: :oldest_joined) + + expect(first_member).to include(master.name) + expect(second_member).to include(developer.name) + end + + scenario 'sorts by name ascending' do + visit_members_list(sort: :name_asc) + + expect(first_member).to include(master.name) + expect(second_member).to include(developer.name) + end + + scenario 'sorts by name descending' do + visit_members_list(sort: :name_desc) + + expect(first_member).to include(developer.name) + expect(second_member).to include(master.name) + end + + scenario 'sorts by recent sign in' do + visit_members_list(sort: :recent_sign_in) + + expect(first_member).to include(master.name) + expect(second_member).to include(developer.name) + end + + scenario 'sorts by oldest sign in' do + visit_members_list(sort: :oldest_sign_in) + + expect(first_member).to include(developer.name) + expect(second_member).to include(master.name) + end + + def visit_members_list(sort:) + visit namespace_project_project_members_path(project.namespace.to_param, project.to_param, sort: sort) + end + + def first_member + page.all('ul.content-list > li').first.text + end + + def second_member + page.all('ul.content-list > li').last.text + end +end From 3a2905f5072f451b4b1f284c4383b4054a8892af Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Wed, 16 Nov 2016 20:37:50 -0200 Subject: [PATCH 150/175] Sort group/project members alphabetically by default --- app/controllers/groups/group_members_controller.rb | 4 +++- app/views/shared/members/_sort_dropdown.html.haml | 2 -- spec/features/groups/members/sorting_spec.rb | 7 +++++++ spec/features/projects/members/sorting_spec.rb | 7 +++++++ 4 files changed, 17 insertions(+), 3 deletions(-) diff --git a/app/controllers/groups/group_members_controller.rb b/app/controllers/groups/group_members_controller.rb index 812aaa4c191..4f273a8d4f0 100644 --- a/app/controllers/groups/group_members_controller.rb +++ b/app/controllers/groups/group_members_controller.rb @@ -1,16 +1,18 @@ class Groups::GroupMembersController < Groups::ApplicationController include MembershipActions + include SortingHelper # Authorize before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] def index + @sort = params[:sort].presence || sort_value_name @project = @group.projects.find(params[:project_id]) if params[:project_id] @members = @group.group_members @members = @members.non_invite unless can?(current_user, :admin_group, @group) @members = @members.search(params[:search]) if params[:search].present? - @members = @members.sort(@sort = params[:sort]) if params[:sort].present? + @members = @members.sort(@sort) @members = @members.page(params[:page]).per(50) @requesters = AccessRequestsFinder.new(@group).execute(current_user) diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml index 42c09636ba3..a2b47b605b7 100644 --- a/app/views/shared/members/_sort_dropdown.html.haml +++ b/app/views/shared/members/_sort_dropdown.html.haml @@ -1,5 +1,3 @@ -- @sort ||= sort_value_last_joined - .dropdown.inline = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' }, { id: 'sort-members-dropdown' }) %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable diff --git a/spec/features/groups/members/sorting_spec.rb b/spec/features/groups/members/sorting_spec.rb index 5990e43c261..5904025d978 100644 --- a/spec/features/groups/members/sorting_spec.rb +++ b/spec/features/groups/members/sorting_spec.rb @@ -12,6 +12,13 @@ feature 'Groups > Members > Sorting', feature: true do login_as(owner) end + scenario 'sorts alphabetically by default' do + visit_members_list(sort: nil) + + expect(first_member).to include(owner.name) + expect(second_member).to include(developer.name) + end + scenario 'sorts by access level ascending' do visit_members_list(sort: :access_level_asc) diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index 597e72a5599..61d3a112b93 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -12,6 +12,13 @@ feature 'Projects > Members > Sorting', feature: true do login_as(master) end + scenario 'sorts alphabetically by default' do + visit_members_list(sort: nil) + + expect(first_member).to include(master.name) + expect(second_member).to include(developer.name) + end + scenario 'sorts by access level ascending' do visit_members_list(sort: :access_level_asc) From c3af880aeea12565bc33a29284a9836e3b800019 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Nov 2016 15:51:34 -0200 Subject: [PATCH 151/175] Remove unnecessary curly braces from sort dropdown partial --- app/views/shared/members/_sort_dropdown.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml index a2b47b605b7..8f28324c5f6 100644 --- a/app/views/shared/members/_sort_dropdown.html.haml +++ b/app/views/shared/members/_sort_dropdown.html.haml @@ -1,5 +1,5 @@ .dropdown.inline - = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' }, { id: 'sort-members-dropdown' }) + = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' }, id: 'sort-members-dropdown') %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable %li.dropdown-header Sort by From 06f696dd0a6326ca521d81203901618afa0f9a9a Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 17 Nov 2016 15:52:15 -0200 Subject: [PATCH 152/175] Refactor MembersHelper#filter_group_project_member_path --- app/helpers/members_helper.rb | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index cf8fdfca1bd..41d471cc92f 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -38,12 +38,8 @@ module MembersHelper end def filter_group_project_member_path(options = {}) - exist_opts = { - search: params[:search], - sort: params[:sort] - } + options = params.slice(:search, :sort).merge(options) - options = exist_opts.merge(options) path = request.path path << "?#{options.to_param}" path From 621b08d8b5cd5ffe22f5bc7fddecc16821c9bd56 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Fri, 18 Nov 2016 15:50:29 -0200 Subject: [PATCH 153/175] Fix sort functionality on project/group members to return invited users --- app/models/member.rb | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/app/models/member.rb b/app/models/member.rb index 8c36a631ac4..0312aef32fa 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -57,10 +57,10 @@ class Member < ActiveRecord::Base scope :owners, -> { active.where(access_level: OWNER) } scope :owners_and_masters, -> { active.where(access_level: [OWNER, MASTER]) } - scope :order_name_asc, -> { joins(:user).merge(User.order_name_asc) } - scope :order_name_desc, -> { joins(:user).merge(User.order_name_desc) } - scope :order_recent_sign_in, -> { joins(:user).merge(User.order_recent_sign_in) } - scope :order_oldest_sign_in, -> { joins(:user).merge(User.order_oldest_sign_in) } + scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) } + scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) } + scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) } + scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } @@ -94,6 +94,17 @@ class Member < ActiveRecord::Base end end + def left_join_users + users = User.arel_table + members = Member.arel_table + + member_users = members.join(users, Arel::Nodes::OuterJoin). + on(members[:user_id].eq(users[:id])). + join_sources + + joins(member_users) + end + def access_for_user_ids(user_ids) where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h end From b667d834bc77d29b982c3858d6f788b2a4a34c0d Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Mon, 21 Nov 2016 16:05:11 -0200 Subject: [PATCH 154/175] Remove unused id from shared members sort dropdown --- app/views/shared/members/_sort_dropdown.html.haml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml index 8f28324c5f6..3fad8406374 100644 --- a/app/views/shared/members/_sort_dropdown.html.haml +++ b/app/views/shared/members/_sort_dropdown.html.haml @@ -1,5 +1,5 @@ .dropdown.inline - = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' }, id: 'sort-members-dropdown') + = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable %li.dropdown-header Sort by From 0ef2c8dfbe923fc5e90d2bae306b9ef2aac85112 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Tue, 22 Nov 2016 19:10:31 -0200 Subject: [PATCH 155/175] Use factories to create project/group membership on specs --- spec/features/groups/members/sorting_spec.rb | 4 ++-- spec/features/projects/members/sorting_spec.rb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/spec/features/groups/members/sorting_spec.rb b/spec/features/groups/members/sorting_spec.rb index 5904025d978..42ba8b614a3 100644 --- a/spec/features/groups/members/sorting_spec.rb +++ b/spec/features/groups/members/sorting_spec.rb @@ -6,8 +6,8 @@ feature 'Groups > Members > Sorting', feature: true do let(:group) { create(:group) } background do - group.add_owner(owner) - group.add_developer(developer) + create(:group_member, :owner, user: owner, group: group, created_at: 5.days.ago) + create(:group_member, :developer, user: developer, group: group, created_at: 3.days.ago) login_as(owner) end diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index 61d3a112b93..3754dfa658d 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -6,8 +6,8 @@ feature 'Projects > Members > Sorting', feature: true do let(:project) { create(:empty_project) } background do - project.team << [master, :master] - project.team << [developer, :developer] + create(:project_member, :master, user: master, project: project, created_at: 5.days.ago) + create(:project_member, :developer, user: developer, project: project, created_at: 3.days.ago) login_as(master) end From 5479fc9107507fd441de0661dd2c4c0826fb40f0 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Thu, 24 Nov 2016 11:17:51 -0200 Subject: [PATCH 156/175] Undo changes on members search button stylesheet --- app/assets/stylesheets/pages/members.scss | 37 +++++++++++------------ 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index b7521133ce5..f2417efeebb 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -110,23 +110,22 @@ } } } - - .member-search-btn { - position: absolute; - right: 4px; - top: 0; - height: 35px; - padding-left: 10px; - padding-right: 10px; - color: $gray-darkest; - background: transparent; - border: 0; - outline: 0; - - @media (min-width: $screen-sm-min) { - right: 160px; - top: 8px; - } - } - +} + +.member-search-btn { + position: absolute; + right: 4px; + top: 0; + height: 35px; + padding-left: 10px; + padding-right: 10px; + color: $gray-darkest; + background: transparent; + border: 0; + outline: 0; + + @media (min-width: $screen-sm-min) { + right: 160px; + top: 8px; + } } From eac34fd9a3347b873fc963856b2f0e2104fe7a9b Mon Sep 17 00:00:00 2001 From: Nur Rony Date: Fri, 2 Dec 2016 16:24:28 +0600 Subject: [PATCH 157/175] Fix sort dropdown alignment --- app/assets/stylesheets/pages/members.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/pages/members.scss b/app/assets/stylesheets/pages/members.scss index f2417efeebb..36ee5d17211 100644 --- a/app/assets/stylesheets/pages/members.scss +++ b/app/assets/stylesheets/pages/members.scss @@ -83,11 +83,12 @@ margin-top: 5px; .dropdown-menu-toggle { + vertical-align: middle; width: 100%; } @media (min-width: $screen-sm-min) { - top: 2.4px; + margin-top: 0; width: 155px; } } @@ -126,6 +127,5 @@ @media (min-width: $screen-sm-min) { right: 160px; - top: 8px; } } From ecea127cd1e2ac382a71e03ebc16de44f762b2dd Mon Sep 17 00:00:00 2001 From: Nur Rony Date: Fri, 2 Dec 2016 17:52:10 +0600 Subject: [PATCH 158/175] Improve test for sort dropdown on members page --- app/views/shared/members/_sort_dropdown.html.haml | 2 +- spec/features/groups/members/sorting_spec.rb | 9 +++++++++ spec/features/projects/members/sorting_spec.rb | 9 +++++++++ 3 files changed, 19 insertions(+), 1 deletion(-) diff --git a/app/views/shared/members/_sort_dropdown.html.haml b/app/views/shared/members/_sort_dropdown.html.haml index 3fad8406374..bad0891f9f2 100644 --- a/app/views/shared/members/_sort_dropdown.html.haml +++ b/app/views/shared/members/_sort_dropdown.html.haml @@ -1,4 +1,4 @@ -.dropdown.inline +.dropdown.inline.member-sort-dropdown = dropdown_toggle(member_sort_options_hash[@sort], { toggle: 'dropdown' }) %ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-selectable %li.dropdown-header diff --git a/spec/features/groups/members/sorting_spec.rb b/spec/features/groups/members/sorting_spec.rb index 42ba8b614a3..608aedd3471 100644 --- a/spec/features/groups/members/sorting_spec.rb +++ b/spec/features/groups/members/sorting_spec.rb @@ -17,6 +17,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(first_member).to include(owner.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending') end scenario 'sorts by access level ascending' do @@ -24,6 +25,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(first_member).to include(developer.name) expect(second_member).to include(owner.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending') end scenario 'sorts by access level descending' do @@ -31,6 +33,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(first_member).to include(owner.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending') end scenario 'sorts by last joined' do @@ -38,6 +41,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(first_member).to include(developer.name) expect(second_member).to include(owner.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined') end scenario 'sorts by oldest joined' do @@ -45,6 +49,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(first_member).to include(owner.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined') end scenario 'sorts by name ascending' do @@ -52,6 +57,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(first_member).to include(owner.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending') end scenario 'sorts by name descending' do @@ -59,6 +65,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(first_member).to include(developer.name) expect(second_member).to include(owner.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending') end scenario 'sorts by recent sign in' do @@ -66,6 +73,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(first_member).to include(owner.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in') end scenario 'sorts by oldest sign in' do @@ -73,6 +81,7 @@ feature 'Groups > Members > Sorting', feature: true do expect(first_member).to include(developer.name) expect(second_member).to include(owner.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in') end def visit_members_list(sort:) diff --git a/spec/features/projects/members/sorting_spec.rb b/spec/features/projects/members/sorting_spec.rb index 3754dfa658d..d6ebb523f95 100644 --- a/spec/features/projects/members/sorting_spec.rb +++ b/spec/features/projects/members/sorting_spec.rb @@ -17,6 +17,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(first_member).to include(master.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending') end scenario 'sorts by access level ascending' do @@ -24,6 +25,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(first_member).to include(developer.name) expect(second_member).to include(master.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, ascending') end scenario 'sorts by access level descending' do @@ -31,6 +33,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(first_member).to include(master.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Access level, descending') end scenario 'sorts by last joined' do @@ -38,6 +41,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(first_member).to include(developer.name) expect(second_member).to include(master.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Last joined') end scenario 'sorts by oldest joined' do @@ -45,6 +49,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(first_member).to include(master.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest joined') end scenario 'sorts by name ascending' do @@ -52,6 +57,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(first_member).to include(master.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, ascending') end scenario 'sorts by name descending' do @@ -59,6 +65,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(first_member).to include(developer.name) expect(second_member).to include(master.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Name, descending') end scenario 'sorts by recent sign in' do @@ -66,6 +73,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(first_member).to include(master.name) expect(second_member).to include(developer.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Recent sign in') end scenario 'sorts by oldest sign in' do @@ -73,6 +81,7 @@ feature 'Projects > Members > Sorting', feature: true do expect(first_member).to include(developer.name) expect(second_member).to include(master.name) + expect(page).to have_css('.member-sort-dropdown .dropdown-toggle-text', text: 'Oldest sign in') end def visit_members_list(sort:) From e644b8d683d7b9fa2c411fe65e1c828ba9908b57 Mon Sep 17 00:00:00 2001 From: Douglas Barbosa Alexandre Date: Fri, 9 Dec 2016 17:19:47 -0200 Subject: [PATCH 159/175] Fix query in Projects::ProjectMembersController to fetch members --- app/controllers/projects/project_members_controller.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/controllers/projects/project_members_controller.rb b/app/controllers/projects/project_members_controller.rb index e4aba4b700e..3aec6f18e27 100644 --- a/app/controllers/projects/project_members_controller.rb +++ b/app/controllers/projects/project_members_controller.rb @@ -37,8 +37,8 @@ class Projects::ProjectMembersController < Projects::ApplicationController @group_links = @project.project_group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id)) end - wheres = ["id IN (#{@project_members.select(:id).to_sql})"] - wheres << "id IN (#{group_members.select(:id).to_sql})" if group_members + wheres = ["members.id IN (#{@project_members.select(:id).to_sql})"] + wheres << "members.id IN (#{group_members.select(:id).to_sql})" if group_members @project_members = Member. where(wheres.join(' OR ')). From bea9795531534a2ce060d14aef4f99577c17f2c3 Mon Sep 17 00:00:00 2001 From: James Edwards-Jones Date: Sat, 17 Dec 2016 06:19:12 +0000 Subject: [PATCH 160/175] Fix link from doc/development/performance.md to 'Performance Monitoring' --- doc/development/performance.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/development/performance.md b/doc/development/performance.md index 5c43ae7b79a..f936a49a2aa 100644 --- a/doc/development/performance.md +++ b/doc/development/performance.md @@ -37,7 +37,7 @@ graphs/dashboards. GitLab provides built-in tools to aid the process of improving performance: * [Sherlock](profiling.md#sherlock) -* [GitLab Performance Monitoring](../administration/monitoring/performance/monitoring.md) +* [GitLab Performance Monitoring](../administration/monitoring/performance/introduction.md) * [Request Profiling](../administration/monitoring/performance/request_profiling.md) GitLab employees can use GitLab.com's performance monitoring systems located at From f11caaf4692afdde0a2c458b3682aef3f9658b6a Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Mon, 12 Dec 2016 09:31:48 +0100 Subject: [PATCH 161/175] Setup mattermost session --- lib/mattermost/mattermost.rb | 102 +++++++++++++++++++++++++ spec/lib/mattermost/mattermost_spec.rb | 42 ++++++++++ 2 files changed, 144 insertions(+) create mode 100644 lib/mattermost/mattermost.rb create mode 100644 spec/lib/mattermost/mattermost_spec.rb diff --git a/lib/mattermost/mattermost.rb b/lib/mattermost/mattermost.rb new file mode 100644 index 00000000000..84d016bb197 --- /dev/null +++ b/lib/mattermost/mattermost.rb @@ -0,0 +1,102 @@ +module Mattermost + class NoSessionError < StandardError; end + # This class' prime objective is to obtain a session token on a Mattermost + # instance with SSO configured where this GitLab instance is the provider. + # + # The process depends on OAuth, but skips a step in the authentication cycle. + # For example, usually a user would click the 'login in GitLab' button on + # Mattermost, which would yield a 302 status code and redirects you to GitLab + # to approve the use of your account on Mattermost. Which would trigger a + # callback so Mattermost knows this request is approved and gets the required + # data to create the user account etc. + # + # This class however skips the button click, and also the approval phase to + # speed up the process and keep it without manual action and get a session + # going. + class Mattermost + include Doorkeeper::Helpers::Controller + include HTTParty + + attr_accessor :current_resource_owner + + def initialize(uri, current_user) + self.class.base_uri(uri) + + @current_resource_owner = current_user + end + + def with_session + raise NoSessionError unless create + yield + destroy + end + + # Next methods are needed for Doorkeeper + def pre_auth + @pre_auth ||= Doorkeeper::OAuth::PreAuthorization.new( + Doorkeeper.configuration, server.client_via_uid, params) + end + + def authorization + @authorization ||= strategy.request + end + + def strategy + @strategy ||= server.authorization_request(pre_auth.response_type) + end + + def request + @request ||= OpenStruct.new(parameters: params) + end + + def params + Rack::Utils.parse_query(@oauth_uri.query).symbolize_keys + end + + private + + def create + return unless oauth_uri + return unless token_uri + + self.class.headers("Cookie" => "MMAUTHTOKEN=#{request_token}") + + request_token + end + + def destroy + post('/api/v3/users/logout') + end + + def oauth_uri + response = get("/api/v3/oauth/gitlab/login", follow_redirects: false) + return unless 300 <= response.code && response.code < 400 + + redirect_uri = response.headers['location'] + return unless redirect_uri + + @oauth_uri ||= URI.parse(redirect_uri) + end + + def token_uri + @token_uri ||= if @oauth_uri + authorization.authorize.redirect_uri if pre_auth.authorizable? + end + end + + def request_token + @request_token ||= if @token_uri + response = get(@token_uri, follow_redirects: false) + response.headers['token'] if 200 <= response.code && response.code < 400 + end + end + + def get(path, options = {}) + self.class.get(path, options) + end + + def post(path, options = {}) + self.class.post(path, options) + end + end +end diff --git a/spec/lib/mattermost/mattermost_spec.rb b/spec/lib/mattermost/mattermost_spec.rb new file mode 100644 index 00000000000..7c99b4df9f3 --- /dev/null +++ b/spec/lib/mattermost/mattermost_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe Mattermost::Mattermost do + let(:user) { create(:user) } + + subject { described_class.new('http://localhost:8065', user) } + + # Needed for doorman to function + it { is_expected.to respond_to(:current_resource_owner) } + it { is_expected.to respond_to(:request) } + it { is_expected.to respond_to(:authorization) } + it { is_expected.to respond_to(:strategy) } + + describe '#with session' do + let!(:stub) do + WebMock.stub_request(:get, 'http://localhost:8065/api/v3/oauth/gitlab/login'). + to_return(headers: { 'location' => 'http://mylocation.com' }, status: 307) + end + + context 'without oauth uri' do + it 'makes a request to the oauth uri' do + expect { subject.with_session }.to raise_error(Mattermost::NoSessionError) + end + + context 'with oauth_uri' do + let!(:doorkeeper) do + Doorkeeper::Application.create(name: "GitLab Mattermost", + redirect_uri: "http://localhost:8065/signup/gitlab/complete\nhttp://localhost:8065/login/gitlab/complete", + scopes: "") + end + + context 'without token_uri' do + it 'can not create a session' do + expect do + subject.with_session + end.to raise_error(Mattermost::NoSessionError) + end + end + end + end + end +end From a31cdb29e49b62f0227963cbc54b6564a3ee9da8 Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Thu, 15 Dec 2016 14:32:50 +0100 Subject: [PATCH 162/175] Improve session tests --- lib/mattermost/{mattermost.rb => session.rb} | 9 ++- spec/lib/mattermost/mattermost_spec.rb | 42 ------------ spec/lib/mattermost/session_spec.rb | 68 ++++++++++++++++++++ 3 files changed, 74 insertions(+), 45 deletions(-) rename lib/mattermost/{mattermost.rb => session.rb} (95%) delete mode 100644 spec/lib/mattermost/mattermost_spec.rb create mode 100644 spec/lib/mattermost/session_spec.rb diff --git a/lib/mattermost/mattermost.rb b/lib/mattermost/session.rb similarity index 95% rename from lib/mattermost/mattermost.rb rename to lib/mattermost/session.rb index 84d016bb197..d14121c91a0 100644 --- a/lib/mattermost/mattermost.rb +++ b/lib/mattermost/session.rb @@ -13,13 +13,14 @@ module Mattermost # This class however skips the button click, and also the approval phase to # speed up the process and keep it without manual action and get a session # going. - class Mattermost + class Session include Doorkeeper::Helpers::Controller include HTTParty attr_accessor :current_resource_owner def initialize(uri, current_user) + # Sets the base uri for HTTParty, so we can use paths self.class.base_uri(uri) @current_resource_owner = current_user @@ -27,8 +28,10 @@ module Mattermost def with_session raise NoSessionError unless create - yield + result = yield destroy + + result end # Next methods are needed for Doorkeeper @@ -85,7 +88,7 @@ module Mattermost end def request_token - @request_token ||= if @token_uri + @request_token ||= begin response = get(@token_uri, follow_redirects: false) response.headers['token'] if 200 <= response.code && response.code < 400 end diff --git a/spec/lib/mattermost/mattermost_spec.rb b/spec/lib/mattermost/mattermost_spec.rb deleted file mode 100644 index 7c99b4df9f3..00000000000 --- a/spec/lib/mattermost/mattermost_spec.rb +++ /dev/null @@ -1,42 +0,0 @@ -require 'spec_helper' - -describe Mattermost::Mattermost do - let(:user) { create(:user) } - - subject { described_class.new('http://localhost:8065', user) } - - # Needed for doorman to function - it { is_expected.to respond_to(:current_resource_owner) } - it { is_expected.to respond_to(:request) } - it { is_expected.to respond_to(:authorization) } - it { is_expected.to respond_to(:strategy) } - - describe '#with session' do - let!(:stub) do - WebMock.stub_request(:get, 'http://localhost:8065/api/v3/oauth/gitlab/login'). - to_return(headers: { 'location' => 'http://mylocation.com' }, status: 307) - end - - context 'without oauth uri' do - it 'makes a request to the oauth uri' do - expect { subject.with_session }.to raise_error(Mattermost::NoSessionError) - end - - context 'with oauth_uri' do - let!(:doorkeeper) do - Doorkeeper::Application.create(name: "GitLab Mattermost", - redirect_uri: "http://localhost:8065/signup/gitlab/complete\nhttp://localhost:8065/login/gitlab/complete", - scopes: "") - end - - context 'without token_uri' do - it 'can not create a session' do - expect do - subject.with_session - end.to raise_error(Mattermost::NoSessionError) - end - end - end - end - end -end diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb new file mode 100644 index 00000000000..a93bab877da --- /dev/null +++ b/spec/lib/mattermost/session_spec.rb @@ -0,0 +1,68 @@ +require 'spec_helper' + +describe Mattermost::Session do + let(:user) { create(:user) } + + subject { described_class.new('http://localhost:8065', user) } + + # Needed for doorkeeper to function + it { is_expected.to respond_to(:current_resource_owner) } + it { is_expected.to respond_to(:request) } + it { is_expected.to respond_to(:authorization) } + it { is_expected.to respond_to(:strategy) } + + describe '#with session' do + let(:location) { 'http://location.tld' } + let!(:stub) do + WebMock.stub_request(:get, 'http://localhost:8065/api/v3/oauth/gitlab/login'). + to_return(headers: { 'location' => location }, status: 307) + end + + context 'without oauth uri' do + it 'makes a request to the oauth uri' do + expect { subject.with_session }.to raise_error(Mattermost::NoSessionError) + end + end + + context 'with oauth_uri' do + let!(:doorkeeper) do + Doorkeeper::Application.create(name: "GitLab Mattermost", + redirect_uri: "http://localhost:8065/signup/gitlab/complete\nhttp://localhost:8065/login/gitlab/complete", + scopes: "") + end + + context 'without token_uri' do + it 'can not create a session' do + expect do + subject.with_session + end.to raise_error(Mattermost::NoSessionError) + end + end + + context 'with token_uri' do + let(:state) { "eyJhY3Rpb24iOiJsb2dpbiIsImhhc2giOiIkMmEkMTAkVC9wYVlEaTdIUS8vcWdKRmdOOUllZUptaUNJWUlvNVNtNEcwU2NBMXFqelNOVmVPZ1cxWUsifQ%3D%3D" } + let(:location) { "http://locahost:8065/oauth/authorize?response_type=code&client_id=#{doorkeeper.uid}&redirect_uri=http%3A%2F%2Flocalhost:8065%2Fsignup%2Fgitlab%2Fcomplete&state=#{state}" } + + before do + WebMock.stub_request(:get, /http:\/\/localhost:8065\/signup\/gitlab\/complete*/). + to_return(headers: { 'token' => 'thisworksnow' }, status: 202) + end + + it 'can setup a session' do + expect(subject).to receive(:destroy) + + subject.with_session { 1 + 1 } + end + + it 'returns the value of the block' do + WebMock.stub_request(:post, "http://localhost:8065/api/v3/users/logout"). + to_return(headers: { 'token' => 'thisworksnow' }, status: 200) + + value = subject.with_session { 1 + 1 } + + expect(value).to be(2) + end + end + end + end +end From 9bcc4d4de5510a14ae891105645b4d59891ba78d Mon Sep 17 00:00:00 2001 From: "Z.J. van de Weg" Date: Thu, 15 Dec 2016 21:06:17 +0100 Subject: [PATCH 163/175] Ensure the session is destroyed --- lib/mattermost/session.rb | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lib/mattermost/session.rb b/lib/mattermost/session.rb index d14121c91a0..f4629585da7 100644 --- a/lib/mattermost/session.rb +++ b/lib/mattermost/session.rb @@ -28,10 +28,12 @@ module Mattermost def with_session raise NoSessionError unless create - result = yield - destroy - result + begin + yield + ensure + destroy + end end # Next methods are needed for Doorkeeper From 48ebfaa49146b8f6fcb24b063f22d553b2f20395 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 16 Dec 2016 11:31:26 +0100 Subject: [PATCH 164/175] Improve Mattermost Session specs --- lib/mattermost/session.rb | 23 +++++++------ spec/lib/mattermost/session_spec.rb | 53 +++++++++++++++++++++-------- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/lib/mattermost/session.rb b/lib/mattermost/session.rb index f4629585da7..7d0290be5a1 100644 --- a/lib/mattermost/session.rb +++ b/lib/mattermost/session.rb @@ -17,7 +17,7 @@ module Mattermost include Doorkeeper::Helpers::Controller include HTTParty - attr_accessor :current_resource_owner + attr_accessor :current_resource_owner, :token def initialize(uri, current_user) # Sets the base uri for HTTParty, so we can use paths @@ -64,9 +64,9 @@ module Mattermost return unless oauth_uri return unless token_uri - self.class.headers("Cookie" => "MMAUTHTOKEN=#{request_token}") - - request_token + self.token = request_token + self.class.headers("Cookie" => "MMAUTHTOKEN=#{self.token}") + self.token end def destroy @@ -84,16 +84,17 @@ module Mattermost end def token_uri - @token_uri ||= if @oauth_uri - authorization.authorize.redirect_uri if pre_auth.authorizable? - end + @token_uri ||= + if @oauth_uri + authorization.authorize.redirect_uri if pre_auth.authorizable? + end end def request_token - @request_token ||= begin - response = get(@token_uri, follow_redirects: false) - response.headers['token'] if 200 <= response.code && response.code < 400 - end + response = get(@token_uri, follow_redirects: false) + if 200 <= response.code && response.code < 400 + response.headers['token'] + end end def get(path, options = {}) diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb index a93bab877da..69d677930bc 100644 --- a/spec/lib/mattermost/session_spec.rb +++ b/spec/lib/mattermost/session_spec.rb @@ -1,9 +1,12 @@ require 'spec_helper' -describe Mattermost::Session do +describe Mattermost::Session, type: :request do let(:user) { create(:user) } - subject { described_class.new('http://localhost:8065', user) } + let(:gitlab_url) { "http://gitlab.com" } + let(:mattermost_url) { "http://mattermost.com" } + + subject { described_class.new(mattermost_url, user) } # Needed for doorkeeper to function it { is_expected.to respond_to(:current_resource_owner) } @@ -14,7 +17,7 @@ describe Mattermost::Session do describe '#with session' do let(:location) { 'http://location.tld' } let!(:stub) do - WebMock.stub_request(:get, 'http://localhost:8065/api/v3/oauth/gitlab/login'). + WebMock.stub_request(:get, "#{mattermost_url}/api/v3/oauth/gitlab/login"). to_return(headers: { 'location' => location }, status: 307) end @@ -26,9 +29,10 @@ describe Mattermost::Session do context 'with oauth_uri' do let!(:doorkeeper) do - Doorkeeper::Application.create(name: "GitLab Mattermost", - redirect_uri: "http://localhost:8065/signup/gitlab/complete\nhttp://localhost:8065/login/gitlab/complete", - scopes: "") + Doorkeeper::Application.create( + name: "GitLab Mattermost", + redirect_uri: "#{mattermost_url}/signup/gitlab/complete\n#{mattermost_url}/login/gitlab/complete", + scopes: "") end context 'without token_uri' do @@ -40,24 +44,43 @@ describe Mattermost::Session do end context 'with token_uri' do - let(:state) { "eyJhY3Rpb24iOiJsb2dpbiIsImhhc2giOiIkMmEkMTAkVC9wYVlEaTdIUS8vcWdKRmdOOUllZUptaUNJWUlvNVNtNEcwU2NBMXFqelNOVmVPZ1cxWUsifQ%3D%3D" } - let(:location) { "http://locahost:8065/oauth/authorize?response_type=code&client_id=#{doorkeeper.uid}&redirect_uri=http%3A%2F%2Flocalhost:8065%2Fsignup%2Fgitlab%2Fcomplete&state=#{state}" } + let(:state) { "state" } + let(:params) do + { response_type: "code", + client_id: doorkeeper.uid, + redirect_uri: "#{mattermost_url}/signup/gitlab/complete", + state: state } + end + let(:location) do + "#{gitlab_url}/oauth/authorize?#{URI.encode_www_form(params)}" + end before do - WebMock.stub_request(:get, /http:\/\/localhost:8065\/signup\/gitlab\/complete*/). - to_return(headers: { 'token' => 'thisworksnow' }, status: 202) + WebMock.stub_request(:get, "#{mattermost_url}/signup/gitlab/complete"). + with(query: hash_including({ 'state' => state })). + to_return do |request| + post "/oauth/token", + client_id: doorkeeper.uid, + client_secret: doorkeeper.secret, + redirect_uri: params[:redirect_uri], + grant_type: 'authorization_code', + code: request.uri.query_values['code'] + + if response.status == 200 + { headers: { 'token' => 'thisworksnow' }, status: 202 } + end + end + + WebMock.stub_request(:post, "#{mattermost_url}/api/v3/users/logout"). + to_return(headers: { Cookie: 'MMAUTHTOKEN=thisworksnow' }, status: 200) end it 'can setup a session' do - expect(subject).to receive(:destroy) - subject.with_session { 1 + 1 } + expect(subject.token).not_to be_nil end it 'returns the value of the block' do - WebMock.stub_request(:post, "http://localhost:8065/api/v3/users/logout"). - to_return(headers: { 'token' => 'thisworksnow' }, status: 200) - value = subject.with_session { 1 + 1 } expect(value).to be(2) From e663725961de66ac838d0a5a85978656938e74f4 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 16 Dec 2016 12:20:42 +0100 Subject: [PATCH 165/175] Store mattermost_url in settings --- config/gitlab.yml.example | 6 ++++++ config/initializers/1_settings.rb | 7 +++++++ lib/mattermost/session.rb | 17 +++++++++-------- spec/lib/mattermost/session_spec.rb | 18 +++++++++++++----- 4 files changed, 35 insertions(+), 13 deletions(-) diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 327e4a7937c..b8b41a0d86c 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -153,6 +153,12 @@ production: &base # The location where LFS objects are stored (default: shared/lfs-objects). # storage_path: shared/lfs-objects + ## Mattermost + ## For enabling Add to Mattermost button + mattermost: + enabled: false + host: 'https://mattermost.example.com' + ## Gravatar ## For Libravatar see: http://doc.gitlab.com/ce/customization/libravatar.html gravatar: diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 0ee1b1ec634..45404e579ae 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -261,6 +261,13 @@ Settings['lfs'] ||= Settingslogic.new({}) Settings.lfs['enabled'] = true if Settings.lfs['enabled'].nil? Settings.lfs['storage_path'] = File.expand_path(Settings.lfs['storage_path'] || File.join(Settings.shared['path'], "lfs-objects"), Rails.root) +# +# Mattermost +# +Settings['mattermost'] ||= Settingslogic.new({}) +Settings.mattermost['enabled'] = false if Settings.mattermost['enabled'].nil? +Settings.mattermost['host'] = nil unless Settings.mattermost.enabled + # # Gravatar # diff --git a/lib/mattermost/session.rb b/lib/mattermost/session.rb index 7d0290be5a1..a3715bed482 100644 --- a/lib/mattermost/session.rb +++ b/lib/mattermost/session.rb @@ -17,12 +17,11 @@ module Mattermost include Doorkeeper::Helpers::Controller include HTTParty + base_uri Settings.mattermost.host + attr_accessor :current_resource_owner, :token - def initialize(uri, current_user) - # Sets the base uri for HTTParty, so we can use paths - self.class.base_uri(uri) - + def initialize(current_user) @current_resource_owner = current_user end @@ -30,7 +29,7 @@ module Mattermost raise NoSessionError unless create begin - yield + yield self ensure destroy end @@ -65,7 +64,9 @@ module Mattermost return unless token_uri self.token = request_token - self.class.headers("Cookie" => "MMAUTHTOKEN=#{self.token}") + @headers = { + "Authorization": "Bearer #{self.token}" + } self.token end @@ -98,11 +99,11 @@ module Mattermost end def get(path, options = {}) - self.class.get(path, options) + self.class.get(path, options.merge(headers: @headers)) end def post(path, options = {}) - self.class.post(path, options) + self.class.post(path, options.merge(headers: @headers)) end end end diff --git a/spec/lib/mattermost/session_spec.rb b/spec/lib/mattermost/session_spec.rb index 69d677930bc..3c2eddbd221 100644 --- a/spec/lib/mattermost/session_spec.rb +++ b/spec/lib/mattermost/session_spec.rb @@ -6,7 +6,7 @@ describe Mattermost::Session, type: :request do let(:gitlab_url) { "http://gitlab.com" } let(:mattermost_url) { "http://mattermost.com" } - subject { described_class.new(mattermost_url, user) } + subject { described_class.new(user) } # Needed for doorkeeper to function it { is_expected.to respond_to(:current_resource_owner) } @@ -14,6 +14,10 @@ describe Mattermost::Session, type: :request do it { is_expected.to respond_to(:authorization) } it { is_expected.to respond_to(:strategy) } + before do + described_class.base_uri(mattermost_url) + end + describe '#with session' do let(:location) { 'http://location.tld' } let!(:stub) do @@ -72,18 +76,22 @@ describe Mattermost::Session, type: :request do end WebMock.stub_request(:post, "#{mattermost_url}/api/v3/users/logout"). - to_return(headers: { Cookie: 'MMAUTHTOKEN=thisworksnow' }, status: 200) + to_return(headers: { Authorization: 'token thisworksnow' }, status: 200) end it 'can setup a session' do - subject.with_session { 1 + 1 } + subject.with_session do |session| + end + expect(subject.token).not_to be_nil end it 'returns the value of the block' do - value = subject.with_session { 1 + 1 } + result = subject.with_session do |session| + "value" + end - expect(value).to be(2) + expect(result).to eq("value") end end end From c9610e0a052526adb3138dccf6114d710979a0b7 Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Fri, 16 Dec 2016 13:43:01 +0100 Subject: [PATCH 166/175] Fix rubocop failures --- config/initializers/1_settings.rb | 4 +- lib/mattermost/session.rb | 90 ++++++++++++++++--------------- 2 files changed, 50 insertions(+), 44 deletions(-) diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index 45404e579ae..ddea325c6ca 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -265,8 +265,8 @@ Settings.lfs['storage_path'] = File.expand_path(Settings.lfs['storage_path'] || # Mattermost # Settings['mattermost'] ||= Settingslogic.new({}) -Settings.mattermost['enabled'] = false if Settings.mattermost['enabled'].nil? -Settings.mattermost['host'] = nil unless Settings.mattermost.enabled +Settings.mattermost['enabled'] = false if Settings.mattermost['enabled'].nil? +Settings.mattermost['host'] = nil unless Settings.mattermost.enabled # # Gravatar diff --git a/lib/mattermost/session.rb b/lib/mattermost/session.rb index a3715bed482..fb8d7d97f8a 100644 --- a/lib/mattermost/session.rb +++ b/lib/mattermost/session.rb @@ -54,48 +54,7 @@ module Mattermost end def params - Rack::Utils.parse_query(@oauth_uri.query).symbolize_keys - end - - private - - def create - return unless oauth_uri - return unless token_uri - - self.token = request_token - @headers = { - "Authorization": "Bearer #{self.token}" - } - self.token - end - - def destroy - post('/api/v3/users/logout') - end - - def oauth_uri - response = get("/api/v3/oauth/gitlab/login", follow_redirects: false) - return unless 300 <= response.code && response.code < 400 - - redirect_uri = response.headers['location'] - return unless redirect_uri - - @oauth_uri ||= URI.parse(redirect_uri) - end - - def token_uri - @token_uri ||= - if @oauth_uri - authorization.authorize.redirect_uri if pre_auth.authorizable? - end - end - - def request_token - response = get(@token_uri, follow_redirects: false) - if 200 <= response.code && response.code < 400 - response.headers['token'] - end + Rack::Utils.parse_query(oauth_uri.query).symbolize_keys end def get(path, options = {}) @@ -105,5 +64,52 @@ module Mattermost def post(path, options = {}) self.class.post(path, options.merge(headers: @headers)) end + + private + + def create + return unless oauth_uri + return unless token_uri + + @token = request_token + @headers = { + Authorization: "Bearer #{@token}" + } + + @token + end + + def destroy + post('/api/v3/users/logout') + end + + def oauth_uri + return @oauth_uri if defined?(@oauth_uri) + + @oauth_uri = nil + + response = get("/api/v3/oauth/gitlab/login", follow_redirects: false) + return unless 300 <= response.code && response.code < 400 + + redirect_uri = response.headers['location'] + return unless redirect_uri + + @oauth_uri = URI.parse(redirect_uri) + end + + def token_uri + @token_uri ||= + if oauth_uri + authorization.authorize.redirect_uri if pre_auth.authorizable? + end + end + + def request_token + response = get(token_uri, follow_redirects: false) + + if 200 <= response.code && response.code < 400 + response.headers['token'] + end + end end end From dc68a91c444d3da85bbcb87e4ef4e841be9f3887 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Sat, 17 Dec 2016 13:34:35 +0100 Subject: [PATCH 167/175] Fix CI/CD statuses actions' CSS on pipeline graphs --- app/views/ci/status/_graph_badge.html.haml | 2 +- spec/features/projects/pipelines/pipeline_spec.rb | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/ci/status/_graph_badge.html.haml b/app/views/ci/status/_graph_badge.html.haml index c7d04ab61e9..9f3a9c0c6b2 100644 --- a/app/views/ci/status/_graph_badge.html.haml +++ b/app/views/ci/status/_graph_badge.html.haml @@ -2,7 +2,7 @@ - subject = local_assigns.fetch(:subject) - status = subject.detailed_status(current_user) -- klass = "ci-status-icon ci-status-icon-#{status}" +- klass = "ci-status-icon ci-status-icon-#{status.group}" - if status.has_details? = link_to status.details_path, data: { toggle: 'tooltip', title: "#{subject.name} - #{status.label}" } do diff --git a/spec/features/projects/pipelines/pipeline_spec.rb b/spec/features/projects/pipelines/pipeline_spec.rb index 0a77eaa123c..57f1e75ea2c 100644 --- a/spec/features/projects/pipelines/pipeline_spec.rb +++ b/spec/features/projects/pipelines/pipeline_spec.rb @@ -99,7 +99,7 @@ describe "Pipelines", feature: true, js: true do context 'when pipeline has manual builds' do it 'shows the skipped icon and a play action for the manual build' do page.within('a[data-title="manual build - manual play action"]') do - expect(page).to have_selector('.ci-status-icon-skipped') + expect(page).to have_selector('.ci-status-icon-manual') expect(page).to have_content('manual') end From a871746e8a3f45e0c732e56ea475de1d8050c93d Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Fri, 16 Dec 2016 17:30:17 +0500 Subject: [PATCH 168/175] Move admin deploy keys spinach test to rspec https://gitlab.com/gitlab-org/gitlab-ce/issues/23036 --- features/admin/deploy_keys.feature | 16 ------- features/steps/admin/deploy_keys.rb | 46 ------------------- spec/features/admin/admin_deploy_keys_spec.rb | 29 ++++++++++++ 3 files changed, 29 insertions(+), 62 deletions(-) delete mode 100644 features/admin/deploy_keys.feature delete mode 100644 features/steps/admin/deploy_keys.rb create mode 100644 spec/features/admin/admin_deploy_keys_spec.rb diff --git a/features/admin/deploy_keys.feature b/features/admin/deploy_keys.feature deleted file mode 100644 index 33439cd1e85..00000000000 --- a/features/admin/deploy_keys.feature +++ /dev/null @@ -1,16 +0,0 @@ -@admin -Feature: Admin Deploy Keys - Background: - Given I sign in as an admin - And there are public deploy keys in system - - Scenario: Deploy Keys list - When I visit admin deploy keys page - Then I should see all public deploy keys - - Scenario: Deploy Keys new - When I visit admin deploy keys page - And I click 'New Deploy Key' - And I submit new deploy key - Then I should be on admin deploy keys page - And I should see newly created deploy key diff --git a/features/steps/admin/deploy_keys.rb b/features/steps/admin/deploy_keys.rb deleted file mode 100644 index 56787eeb6b3..00000000000 --- a/features/steps/admin/deploy_keys.rb +++ /dev/null @@ -1,46 +0,0 @@ -class Spinach::Features::AdminDeployKeys < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - include SharedAdmin - - step 'there are public deploy keys in system' do - create(:deploy_key, public: true) - create(:another_deploy_key, public: true) - end - - step 'I should see all public deploy keys' do - DeployKey.are_public.each do |p| - expect(page).to have_content p.title - end - end - - step 'I visit admin deploy key page' do - visit admin_deploy_key_path(deploy_key) - end - - step 'I visit admin deploy keys page' do - visit admin_deploy_keys_path - end - - step 'I click \'New Deploy Key\'' do - click_link 'New Deploy Key' - end - - step 'I submit new deploy key' do - fill_in "deploy_key_title", with: "laptop" - fill_in "deploy_key_key", with: "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAzrEJUIR6Y03TCE9rIJ+GqTBvgb8t1jI9h5UBzCLuK4VawOmkLornPqLDrGbm6tcwM/wBrrLvVOqi2HwmkKEIecVO0a64A4rIYScVsXIniHRS6w5twyn1MD3sIbN+socBDcaldECQa2u1dI3tnNVcs8wi77fiRe7RSxePsJceGoheRQgC8AZ510UdIlO+9rjIHUdVN7LLyz512auAfYsgx1OfablkQ/XJcdEwDNgi9imI6nAXhmoKUm1IPLT2yKajTIC64AjLOnE0YyCh6+7RFMpiMyu1qiOCpdjYwTgBRiciNRZCH8xIedyCoAmiUgkUT40XYHwLuwiPJICpkAzp7Q== user@laptop" - click_button "Create" - end - - step 'I should be on admin deploy keys page' do - expect(current_path).to eq admin_deploy_keys_path - end - - step 'I should see newly created deploy key' do - expect(page).to have_content(deploy_key.title) - end - - def deploy_key - @deploy_key ||= DeployKey.are_public.first - end -end diff --git a/spec/features/admin/admin_deploy_keys_spec.rb b/spec/features/admin/admin_deploy_keys_spec.rb new file mode 100644 index 00000000000..8bf68480785 --- /dev/null +++ b/spec/features/admin/admin_deploy_keys_spec.rb @@ -0,0 +1,29 @@ +require 'spec_helper' + +RSpec.describe 'admin deploy keys', type: :feature do + let!(:deploy_key) { create(:deploy_key, public: true) } + let!(:another_deploy_key) { create(:another_deploy_key, public: true) } + + before do + login_as(:admin) + end + + it 'show all public deploy keys' do + visit admin_deploy_keys_path + + expect(page).to have_content(deploy_key.title) + expect(page).to have_content(another_deploy_key.title) + end + + it 'creates new deploy key' do + visit admin_deploy_keys_path + + click_link 'New Deploy Key' + fill_in 'deploy_key_title', with: 'laptop' + fill_in 'deploy_key_key', with: 'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAzrEJUIR6Y03TCE9rIJ+GqTBvgb8t1jI9h5UBzCLuK4VawOmkLornPqLDrGbm6tcwM/wBrrLvVOqi2HwmkKEIecVO0a64A4rIYScVsXIniHRS6w5twyn1MD3sIbN+socBDcaldECQa2u1dI3tnNVcs8wi77fiRe7RSxePsJceGoheRQgC8AZ510UdIlO+9rjIHUdVN7LLyz512auAfYsgx1OfablkQ/XJcdEwDNgi9imI6nAXhmoKUm1IPLT2yKajTIC64AjLOnE0YyCh6+7RFMpiMyu1qiOCpdjYwTgBRiciNRZCH8xIedyCoAmiUgkUT40XYHwLuwiPJICpkAzp7Q== user@laptop' + click_button 'Create' + + expect(current_path).to eq admin_deploy_keys_path + expect(page).to have_content('laptop') + end +end From 5a6252ff4ab4fd24d4c3f1b14d4551061e7acb65 Mon Sep 17 00:00:00 2001 From: Semyon Pupkov Date: Fri, 16 Dec 2016 16:45:52 +0500 Subject: [PATCH 169/175] Move admin application spinach test to rspec https://gitlab.com/gitlab-org/gitlab-ce/issues/23036 --- features/admin/applications.feature | 18 ------ features/steps/admin/applications.rb | 55 ------------------- features/steps/shared/paths.rb | 4 -- .../admin/admin_manage_applications_spec.rb | 36 ++++++++++++ 4 files changed, 36 insertions(+), 77 deletions(-) delete mode 100644 features/admin/applications.feature delete mode 100644 features/steps/admin/applications.rb create mode 100644 spec/features/admin/admin_manage_applications_spec.rb diff --git a/features/admin/applications.feature b/features/admin/applications.feature deleted file mode 100644 index 2a00e1666c0..00000000000 --- a/features/admin/applications.feature +++ /dev/null @@ -1,18 +0,0 @@ -@admin -Feature: Admin Applications - Background: - Given I sign in as an admin - And I visit applications page - - Scenario: I can manage application - Then I click on new application button - And I should see application form - Then I fill application form out and submit - And I see application - Then I click edit - And I see edit application form - Then I change name of application and submit - And I see that application was changed - Then I visit applications page - And I click to remove application - Then I see that application is removed \ No newline at end of file diff --git a/features/steps/admin/applications.rb b/features/steps/admin/applications.rb deleted file mode 100644 index 7c12cb96921..00000000000 --- a/features/steps/admin/applications.rb +++ /dev/null @@ -1,55 +0,0 @@ -class Spinach::Features::AdminApplications < Spinach::FeatureSteps - include SharedAuthentication - include SharedPaths - include SharedAdmin - - 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" - 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" - end - - step 'I see application' do - expect(page).to have_content "Application: test" - expect(page).to have_content "Application Id" - expect(page).to have_content "Secret" - end - - step 'I click edit' do - click_on "Edit" - end - - step 'I see edit application form' do - expect(page).to have_content "Edit application" - end - - 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" - end - - step 'I see that application was changed' do - expect(page).to have_content "test_changed" - expect(page).to have_content "Application Id" - expect(page).to have_content "Secret" - end - - step 'I click to remove application' do - page.within '.oauth-applications' do - click_on "Destroy" - end - end - - step "I see that application is removed" do - expect(page.find(".oauth-applications")).not_to have_content "test_changed" - end -end diff --git a/features/steps/shared/paths.rb b/features/steps/shared/paths.rb index 82c07d4f536..a78d0a775ba 100644 --- a/features/steps/shared/paths.rb +++ b/features/steps/shared/paths.rb @@ -207,10 +207,6 @@ module SharedPaths visit admin_spam_logs_path end - step 'I visit applications page' do - visit admin_applications_path - end - # ---------------------------------------- # Generic Project # ---------------------------------------- diff --git a/spec/features/admin/admin_manage_applications_spec.rb b/spec/features/admin/admin_manage_applications_spec.rb new file mode 100644 index 00000000000..c2c618b5659 --- /dev/null +++ b/spec/features/admin/admin_manage_applications_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +RSpec.describe 'admin manage applications', feature: true do + before do + login_as :admin + end + + it do + visit admin_applications_path + + click_on 'New Application' + expect(page).to have_content('New application') + + fill_in :doorkeeper_application_name, with: 'test' + fill_in :doorkeeper_application_redirect_uri, with: 'https://test.com' + click_on 'Submit' + expect(page).to have_content('Application: test') + expect(page).to have_content('Application Id') + expect(page).to have_content('Secret') + + click_on 'Edit' + expect(page).to have_content('Edit application') + + fill_in :doorkeeper_application_name, with: 'test_changed' + click_on 'Submit' + expect(page).to have_content('test_changed') + expect(page).to have_content('Application Id') + expect(page).to have_content('Secret') + + visit admin_applications_path + page.within '.oauth-applications' do + click_on 'Destroy' + end + expect(page.find('.oauth-applications')).not_to have_content('test_changed') + end +end From 27fd32613dac9c093d538e576131e7fda3f7d8e3 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Sat, 17 Dec 2016 13:46:16 +0100 Subject: [PATCH 170/175] Add `ci-manual` status CSS with darkest gray color --- app/assets/stylesheets/pages/status.scss | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index f3b0608e545..637df7e349e 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -113,6 +113,19 @@ fill: $gl-gray-light; } } + + &.ci-manual { + color: $gl-gray-dark; + border-color: $gl-gray-dark; + + &:not(span):hover { + background-color: rgba( $gl-gray-dark, .07); + } + + svg { + fill: $gl-gray-dark; + } + } } } From 3932c20ee61d404196022258d7b7e83c137e4519 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Sat, 17 Dec 2016 13:38:18 +0000 Subject: [PATCH 171/175] Improve spacing and fixes manual status color --- app/assets/stylesheets/framework/icons.scss | 2 +- app/assets/stylesheets/pages/status.scss | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index b37847e3d96..8624a25c052 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -54,6 +54,6 @@ color: $gl-text-color; svg { - fill: $gray-darkest; + fill: $gl-text-color; } } diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 637df7e349e..7ce8f7757f3 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -1,5 +1,6 @@ .container-fluid { .ci-status { + display: inline-block; padding: 2px 7px; margin-right: 10px; border: 1px solid $gray-darker; @@ -15,8 +16,7 @@ height: 13px; width: 13px; position: relative; - top: 1px; - margin-right: 3px; + top: 2px; overflow: visible; } @@ -115,15 +115,15 @@ } &.ci-manual { - color: $gl-gray-dark; - border-color: $gl-gray-dark; + color: $gl-text-color; + border-color: $gl-text-color; &:not(span):hover { - background-color: rgba( $gl-gray-dark, .07); + background-color: rgba( $gl-text-color, .07); } svg { - fill: $gl-gray-dark; + fill: $gl-text-color; } } } From 25b84b2f849d10005d214cc537ed833503d8dc89 Mon Sep 17 00:00:00 2001 From: Filipa Lacerda Date: Sat, 17 Dec 2016 14:09:38 +0000 Subject: [PATCH 172/175] Fix extra spacing in all rgba methods in status file --- app/assets/stylesheets/pages/status.scss | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/app/assets/stylesheets/pages/status.scss b/app/assets/stylesheets/pages/status.scss index 7ce8f7757f3..055dacd81f4 100644 --- a/app/assets/stylesheets/pages/status.scss +++ b/app/assets/stylesheets/pages/status.scss @@ -25,7 +25,7 @@ border-color: $gl-danger; &:not(span):hover { - background-color: rgba( $gl-danger, .07); + background-color: rgba($gl-danger, .07); } svg { @@ -39,7 +39,7 @@ border-color: $gl-success; &:not(span):hover { - background-color: rgba( $gl-success, .07); + background-color: rgba($gl-success, .07); } svg { @@ -52,7 +52,7 @@ border-color: $gl-info; &:not(span):hover { - background-color: rgba( $gl-info, .07); + background-color: rgba($gl-info, .07); } svg { @@ -66,7 +66,7 @@ border-color: $gl-gray; &:not(span):hover { - background-color: rgba( $gl-gray, .07); + background-color: rgba($gl-gray, .07); } svg { @@ -79,7 +79,7 @@ border-color: $gl-warning; &:not(span):hover { - background-color: rgba( $gl-warning, .07); + background-color: rgba($gl-warning, .07); } svg { @@ -92,7 +92,7 @@ border-color: $blue-normal; &:not(span):hover { - background-color: rgba( $blue-normal, .07); + background-color: rgba($blue-normal, .07); } svg { @@ -106,7 +106,7 @@ border-color: $gl-gray-light; &:not(span):hover { - background-color: rgba( $gl-gray-light, .07); + background-color: rgba($gl-gray-light, .07); } svg { @@ -119,7 +119,7 @@ border-color: $gl-text-color; &:not(span):hover { - background-color: rgba( $gl-text-color, .07); + background-color: rgba($gl-text-color, .07); } svg { From 9fd775def2d89500cf291fe675458b68ead7cd2c Mon Sep 17 00:00:00 2001 From: Kamil Trzcinski Date: Sun, 18 Dec 2016 23:39:45 +0100 Subject: [PATCH 173/175] Add CHANGELOG --- changelogs/unreleased/dockerfile-templates.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelogs/unreleased/dockerfile-templates.yml diff --git a/changelogs/unreleased/dockerfile-templates.yml b/changelogs/unreleased/dockerfile-templates.yml new file mode 100644 index 00000000000..e4db46cdf9a --- /dev/null +++ b/changelogs/unreleased/dockerfile-templates.yml @@ -0,0 +1,4 @@ +--- +title: Add support for Dockerfile templates +merge_request: 7247 +author: From f8dde43d418d0a9f5a3ead75481cf9bfe8c2e7c6 Mon Sep 17 00:00:00 2001 From: Ruben Davila Date: Sun, 18 Dec 2016 20:35:07 -0500 Subject: [PATCH 174/175] Always use `fixture_file_upload` helper to upload files in tests. * Also is not a good idea to use File.open without closing the file handler. We should use it with a block or close it explicitly. --- spec/helpers/groups_helper_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/helpers/groups_helper_spec.rb b/spec/helpers/groups_helper_spec.rb index 233d00534e5..c8b0d86425f 100644 --- a/spec/helpers/groups_helper_spec.rb +++ b/spec/helpers/groups_helper_spec.rb @@ -6,7 +6,7 @@ describe GroupsHelper do it 'returns an url for the avatar' do group = create(:group) - group.avatar = File.open(avatar_file_path) + group.avatar = fixture_file_upload(avatar_file_path) group.save! expect(group_icon(group.path).to_s). to match("/uploads/group/avatar/#{group.id}/banana_sample.gif") From cfd1c6a54e36a25282afb61dea5f1be37e9b3500 Mon Sep 17 00:00:00 2001 From: James Lopez Date: Mon, 19 Dec 2016 07:58:10 +0000 Subject: [PATCH 175/175] Fix typo --- 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 dd8e1078c60..7158b2e7895 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1056,7 +1056,7 @@ variables: GET_SOURCES_ATTEMPTS: "3" ``` -You can set the them in the global [`variables`](#variables) section or the [`variables`](#job-variables) +You can set them in the global [`variables`](#variables) section or the [`variables`](#job-variables) section for individual jobs. ## Shallow cloning