diff options
111 files changed, 2132 insertions, 294 deletions
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index a3ce1de50c2..8466edb1981 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -362,6 +362,7 @@ db:migrate:reset-mysql: - git fetch origin v8.14.10 - git checkout -f FETCH_HEAD - bundle install $BUNDLE_INSTALL_FLAGS + - cp config/gitlab.yml.example config/gitlab.yml - bundle exec rake db:drop db:create db:schema:load db:seed_fu - git checkout $CI_COMMIT_SHA - bundle install $BUNDLE_INSTALL_FLAGS diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index a5510516948..04a373efe6b 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.15.0 +0.16.0 @@ -386,7 +386,7 @@ gem 'vmstat', '~> 2.3.0' gem 'sys-filesystem', '~> 1.1.6' # Gitaly GRPC client -gem 'gitaly', '~> 0.13.0' +gem 'gitaly', '~> 0.14.0' gem 'toml-rb', '~> 0.3.15', require: false diff --git a/Gemfile.lock b/Gemfile.lock index ba580bd1309..f356024506c 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -278,7 +278,7 @@ GEM po_to_json (>= 1.0.0) rails (>= 3.2.0) gherkin-ruby (0.3.2) - gitaly (0.13.0) + gitaly (0.14.0) google-protobuf (~> 3.1) grpc (~> 1.0) github-linguist (4.7.6) @@ -980,7 +980,7 @@ DEPENDENCIES gettext (~> 3.2.2) gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails_js (~> 1.2.0) - gitaly (~> 0.13.0) + gitaly (~> 0.14.0) github-linguist (~> 4.7.0) gitlab-flowdock-git-hook (~> 1.0.1) gitlab-markup (~> 1.5.1) diff --git a/app/assets/javascripts/close_reopen_report_toggle.js b/app/assets/javascripts/close_reopen_report_toggle.js new file mode 100644 index 00000000000..882d20671cc --- /dev/null +++ b/app/assets/javascripts/close_reopen_report_toggle.js @@ -0,0 +1,97 @@ +import DropLab from './droplab/drop_lab'; +import ISetter from './droplab/plugins/input_setter'; + +// Todo: Remove this when fixing issue in input_setter plugin +const InputSetter = Object.assign({}, ISetter); + +class CloseReopenReportToggle { + constructor(opts = {}) { + this.dropdownTrigger = opts.dropdownTrigger; + this.dropdownList = opts.dropdownList; + this.button = opts.button; + } + + initDroplab() { + this.reopenItem = this.dropdownList.querySelector('.reopen-item'); + this.closeItem = this.dropdownList.querySelector('.close-item'); + + this.droplab = new DropLab(); + + const config = this.setConfig(); + + this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config); + } + + updateButton(isClosed) { + this.toggleButtonType(isClosed); + + this.button.blur(); + } + + toggleButtonType(isClosed) { + const [showItem, hideItem] = this.getButtonTypes(isClosed); + + showItem.classList.remove('hidden'); + showItem.classList.add('droplab-item-selected'); + + hideItem.classList.add('hidden'); + hideItem.classList.remove('droplab-item-selected'); + + showItem.click(); + } + + getButtonTypes(isClosed) { + return isClosed ? [this.reopenItem, this.closeItem] : [this.closeItem, this.reopenItem]; + } + + setDisable(shouldDisable = true) { + if (shouldDisable) { + this.button.setAttribute('disabled', 'true'); + this.dropdownTrigger.setAttribute('disabled', 'true'); + } else { + this.button.removeAttribute('disabled'); + this.dropdownTrigger.removeAttribute('disabled'); + } + } + + setConfig() { + const config = { + InputSetter: [ + { + input: this.button, + valueAttribute: 'data-text', + inputAttribute: 'data-value', + }, + { + input: this.button, + valueAttribute: 'data-text', + inputAttribute: 'title', + }, + { + input: this.button, + valueAttribute: 'data-button-class', + inputAttribute: 'class', + }, + { + input: this.dropdownTrigger, + valueAttribute: 'data-toggle-class', + inputAttribute: 'class', + }, + { + input: this.button, + valueAttribute: 'data-url', + inputAttribute: 'href', + }, + { + input: this.button, + valueAttribute: 'data-method', + inputAttribute: 'data-method', + }, + ], + }; + + return config; + } +} + +export default CloseReopenReportToggle; diff --git a/app/assets/javascripts/comment_type_toggle.js b/app/assets/javascripts/comment_type_toggle.js index df0ba86198c..c74184949df 100644 --- a/app/assets/javascripts/comment_type_toggle.js +++ b/app/assets/javascripts/comment_type_toggle.js @@ -1,5 +1,8 @@ import DropLab from './droplab/drop_lab'; -import InputSetter from './droplab/plugins/input_setter'; +import ISetter from './droplab/plugins/input_setter'; + +// Todo: Remove this when fixing issue in input_setter plugin +const InputSetter = Object.assign({}, ISetter); class CommentTypeToggle { constructor(opts = {}) { diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 2c56b718212..6cb9cfe1382 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -30,6 +30,7 @@ class GfmAutoComplete { this.input.each((i, input) => { const $input = $(input); $input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input)); + $input.on('change.atwho', () => input.dispatchEvent(new Event('input'))); // This triggers at.js again // Needed for quick actions with suffixes (ex: /label ~) $input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup')); diff --git a/app/assets/javascripts/helpers/issuables_helper.js b/app/assets/javascripts/helpers/issuables_helper.js new file mode 100644 index 00000000000..52d0f7e43fc --- /dev/null +++ b/app/assets/javascripts/helpers/issuables_helper.js @@ -0,0 +1,27 @@ +import CloseReopenReportToggle from '../close_reopen_report_toggle'; + +function initCloseReopenReport() { + const container = document.querySelector('.js-issuable-close-dropdown'); + + if (!container) return undefined; + + const dropdownTrigger = container.querySelector('.js-issuable-close-toggle'); + const dropdownList = container.querySelector('.js-issuable-close-menu'); + const button = container.querySelector('.js-issuable-close-button'); + + const closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + closeReopenReportToggle.initDroplab(); + + return closeReopenReportToggle; +} + +const IssuablesHelper = { + initCloseReopenReport, +}; + +export default IssuablesHelper; diff --git a/app/assets/javascripts/issue.js b/app/assets/javascripts/issue.js index 7490cd31f58..2bee4fb045a 100644 --- a/app/assets/javascripts/issue.js +++ b/app/assets/javascripts/issue.js @@ -6,6 +6,7 @@ import '~/lib/utils/text_utility'; import './flash'; import TaskList from './task_list'; import CreateMergeRequestDropdown from './create_merge_request_dropdown'; +import IssuablesHelper from './helpers/issuables_helper'; class Issue { constructor() { @@ -28,6 +29,11 @@ class Issue { Issue.initMergeRequests(); Issue.initRelatedBranches(); + this.closeButtons = $('a.btn-close'); + this.reopenButtons = $('a.btn-reopen'); + + this.initCloseReopenReport(); + if (Issue.createMrDropdownWrap) { this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap); } @@ -35,13 +41,8 @@ class Issue { initIssueBtnEventListeners() { const issueFailMessage = 'Unable to update this issue at this time.'; - const closeButtons = $('a.btn-close'); - const isClosedBadge = $('div.status-box-closed'); - const isOpenBadge = $('div.status-box-open'); - const projectIssuesCounter = $('.issue_counter'); - const reopenButtons = $('a.btn-reopen'); - return closeButtons.add(reopenButtons).on('click', (e) => { + return $(document).on('click', 'a.btn-close, a.btn-reopen', (e) => { var $button, shouldSubmit, url; e.preventDefault(); e.stopImmediatePropagation(); @@ -50,7 +51,9 @@ class Issue { if (shouldSubmit) { Issue.submitNoteForm($button.closest('form')); } - $button.prop('disabled', true); + + this.disableCloseReopenButton($button); + url = $button.attr('href'); return $.ajax({ type: 'PUT', @@ -58,15 +61,19 @@ class Issue { }) .fail(() => new Flash(issueFailMessage)) .done((data) => { + const isClosedBadge = $('div.status-box-closed'); + const isOpenBadge = $('div.status-box-open'); + const projectIssuesCounter = $('.issue_counter'); + if ('id' in data) { $(document).trigger('issuable:change'); const isClosed = $button.hasClass('btn-close'); - closeButtons.toggleClass('hidden', isClosed); - reopenButtons.toggleClass('hidden', !isClosed); isClosedBadge.toggleClass('hidden', !isClosed); isOpenBadge.toggleClass('hidden', isClosed); + this.toggleCloseReopenButton(isClosed); + let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, '')); numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1; projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues)); @@ -83,12 +90,34 @@ class Issue { } else { new Flash(issueFailMessage); } - - $button.prop('disabled', false); + }) + .then(() => { + this.disableCloseReopenButton($button, false); }); }); } + initCloseReopenReport() { + this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); + + if (this.closeButtons) this.closeButtons = this.closeButtons.not('.issuable-close-button'); + if (this.reopenButtons) this.reopenButtons = this.reopenButtons.not('.issuable-close-button'); + } + + disableCloseReopenButton($button, shouldDisable) { + if (this.closeReopenReportToggle) { + this.closeReopenReportToggle.setDisable(shouldDisable); + } else { + $button.prop('disabled', shouldDisable); + } + } + + toggleCloseReopenButton(isClosed) { + if (this.closeReopenReportToggle) this.closeReopenReportToggle.updateButton(isClosed); + this.closeButtons.toggleClass('hidden', isClosed); + this.reopenButtons.toggleClass('hidden', !isClosed); + } + static submitNoteForm(form) { var noteText; noteText = form.find("textarea.js-note-text").val(); diff --git a/app/assets/javascripts/merge_request.js b/app/assets/javascripts/merge_request.js index 7ba9efc5387..0db2abe507d 100644 --- a/app/assets/javascripts/merge_request.js +++ b/app/assets/javascripts/merge_request.js @@ -4,6 +4,7 @@ import 'vendor/jquery.waitforimages'; import TaskList from './task_list'; import './merge_request_tabs'; +import IssuablesHelper from './helpers/issuables_helper'; (function() { this.MergeRequest = (function() { @@ -21,9 +22,12 @@ import './merge_request_tabs'; return _this.showAllCommits(); }; })(this)); + this.initTabs(); this.initMRBtnListeners(); this.initCommitMessageListeners(); + this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport(); + if ($("a.btn-close").length) { this.taskList = new TaskList({ dataType: 'merge_request', @@ -64,11 +68,15 @@ import './merge_request_tabs'; if (shouldSubmit && $this.data('submitted')) { return; } + + if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable(); + if (shouldSubmit) { if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) { e.preventDefault(); e.stopImmediatePropagation(); - return _this.submitNoteForm($this.closest('form'), $this); + + _this.submitNoteForm($this.closest('form'), $this); } } }); diff --git a/app/assets/javascripts/shortcuts.js b/app/assets/javascripts/shortcuts.js index a4a7f3fa944..49d980212d6 100644 --- a/app/assets/javascripts/shortcuts.js +++ b/app/assets/javascripts/shortcuts.js @@ -62,7 +62,7 @@ import findAndFollowLink from './shortcuts_dashboard_navigation'; if (Cookies.get(performanceBarCookieName) === 'true') { Cookies.remove(performanceBarCookieName, { path: '/' }); } else { - Cookies.set(performanceBarCookieName, true, { path: '/' }); + Cookies.set(performanceBarCookieName, 'true', { path: '/' }); } gl.utils.refreshCurrentPage(); }; diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 4369ae78bde..6eabdc63d9e 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -20,17 +20,29 @@ color: $text; border-color: $border; + > .icon { + color: $text; + } + &:hover, &:focus { background-color: $hover-background; border-color: $hover-border; color: $hover-text; + + > .icon { + color: $hover-text; + } } &:active { background-color: $active-background; border-color: $active-border; color: $hover-text; + + > .icon { + color: $hover-text; + } } } @@ -163,7 +175,8 @@ @include btn-orange; } - &.btn-close { + &.btn-close, + &.btn-close-color { @include btn-outline($white-light, $orange-600, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700); } @@ -181,7 +194,8 @@ float: right; } - &.btn-reopen { + &.btn-reopen, + .btn-reopen-color { /* should be same as parent class for now */ } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 4f54ca24940..dc4ed42544f 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -295,9 +295,74 @@ } } -.filtered-search-box-input-container .dropdown-menu, -.filtered-search-box-input-container .dropdown-menu-nav, -.comment-type-dropdown .dropdown-menu { +.droplab-dropdown { + .description { + display: inline-block; + white-space: normal; + margin-left: 5px; + } + + .dropdown-toggle > i { + pointer-events: none; + } + + li { + padding: $gl-btn-padding $gl-btn-padding 2px; + cursor: pointer; + + > a, + > button { + display: flex; + margin: 0; + padding: 0; + border-radius: 0; + text-overflow: inherit; + background-color: inherit; + color: inherit; + border: inherit; + text-align: left; + + &:hover, + &:focus { + background-color: inherit; + color: inherit; + } + + &.btn .fa:not(:last-child) { + margin-left: 5px; + } + } + + &:hover, + &:focus { + background-color: $dropdown-hover-color; + color: $white-light; + } + + &.droplab-item-selected i { + visibility: visible; + } + + .icon { + visibility: hidden; + } + } + + .icon { + display: inline-block; + vertical-align: top; + padding-top: 2px; + } + + .divider { + margin: 0 8px; + padding: 0; + border-top: $gray-darkest; + } +} + +.droplab-dropdown .dropdown-menu, +.droplab-dropdown .dropdown-menu-nav { display: none; opacity: 1; visibility: visible; diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index 767cf5ffea5..f05348ee4e3 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -70,6 +70,13 @@ .input-token { max-width: 200px; + padding: 0; + + &:hover, + &:focus { + background-color: inherit; + color: inherit; + } } .input-token:only-child, @@ -156,6 +163,16 @@ } } +.droplab-dropdown li.filtered-search-token { + padding: 0; + + &:hover, + &:focus { + background-color: inherit; + color: inherit; + } +} + .filtered-search-term { .name { background-color: inherit; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 8b4ea0fbff2..23555e8e3e6 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -265,7 +265,7 @@ $diff-view-modes-border: #c1c1c1; /* * Fonts */ -$monospace_font: 'Menlo', 'Liberation Mono', 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; +$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace; $regular_font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol"; /* diff --git a/app/assets/stylesheets/new_sidebar.scss b/app/assets/stylesheets/new_sidebar.scss index 17f23f7fce3..f9ceaec9418 100644 --- a/app/assets/stylesheets/new_sidebar.scss +++ b/app/assets/stylesheets/new_sidebar.scss @@ -31,6 +31,12 @@ $new-sidebar-width: 220px; &:hover { background-color: $border-color; } + + .project-title, + .group-title { + overflow: hidden; + text-overflow: ellipsis; + } } .settings-avatar { diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 47f50083726..56a4b53ed61 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -799,3 +799,28 @@ } } } + +.issuable-close-button, +.issuable-close-toggle { + @include transition(border-color, color); +} + +.issuable-close-dropdown { + .dropdown-menu { + min-width: 270px; + left: auto; + right: 0; + } + + .description { + margin-bottom: 10px; + + .text { + margin: 0; + } + } + + .dropdown-toggle > .icon { + margin: 0 3px; + } +} diff --git a/app/assets/stylesheets/pages/note_form.scss b/app/assets/stylesheets/pages/note_form.scss index 9877ed2cfd6..cdb1e65e4be 100644 --- a/app/assets/stylesheets/pages/note_form.scss +++ b/app/assets/stylesheets/pages/note_form.scss @@ -356,7 +356,6 @@ color: $white-light; padding-right: 2px; margin-top: 2px; - pointer-events: none; } } @@ -366,56 +365,6 @@ width: 298px; } - .description { - display: inline-block; - white-space: normal; - margin-left: 8px; - padding-right: 33px; - } - - li { - padding-top: 6px; - - & > a { - margin: 0; - padding: 0; - color: inherit; - border-radius: 0; - text-overflow: inherit; - - &:hover, - &:focus { - background-color: inherit; - color: inherit; - } - } - - &:hover, - &:focus { - background-color: $dropdown-hover-color; - color: $white-light; - } - - &.droplab-item-selected i { - visibility: visible; - } - - i { - visibility: hidden; - } - } - - i { - display: inline-block; - vertical-align: top; - padding-top: 2px; - } - - .divider { - margin: 0 8px; - padding: 0; - border-top: $gray-darkest; - } @media (max-width: $screen-xs-max) { display: flex; diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index f978ce478c7..1cc060e4de8 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -126,6 +126,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController :metrics_port, :metrics_sample_interval, :metrics_timeout, + :performance_bar_allowed_group_id, + :performance_bar_enabled, :recaptcha_enabled, :recaptcha_private_key, :recaptcha_site_key, diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index b4c0cd0487f..db7edbd619b 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -9,7 +9,7 @@ class ApplicationController < ActionController::Base include SentryHelper include WorkhorseHelper include EnforcesTwoFactorAuthentication - include Peek::Rblineprof::CustomControllerHelpers + include WithPerformanceBar before_action :authenticate_user_from_private_token! before_action :authenticate_user_from_rss_token! @@ -68,21 +68,6 @@ class ApplicationController < ActionController::Base end end - def peek_enabled? - return false unless Gitlab::PerformanceBar.enabled? - return false unless current_user - - if RequestStore.active? - if RequestStore.store.key?(:peek_enabled) - RequestStore.store[:peek_enabled] - else - RequestStore.store[:peek_enabled] = cookies[:perf_bar_enabled].present? - end - else - cookies[:perf_bar_enabled].present? - end - end - protected # This filter handles both private tokens and personal access tokens diff --git a/app/controllers/concerns/with_performance_bar.rb b/app/controllers/concerns/with_performance_bar.rb new file mode 100644 index 00000000000..ed253042701 --- /dev/null +++ b/app/controllers/concerns/with_performance_bar.rb @@ -0,0 +1,17 @@ +module WithPerformanceBar + extend ActiveSupport::Concern + + included do + include Peek::Rblineprof::CustomControllerHelpers + end + + def peek_enabled? + return false unless Gitlab::PerformanceBar.enabled?(current_user) + + if RequestStore.active? + RequestStore.fetch(:peek_enabled) { cookies[:perf_bar_enabled].present? } + else + cookies[:perf_bar_enabled].present? + end + end +end diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 5de0f828010..6602b204fcb 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -17,8 +17,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont end def merge_request_params - params.require(:merge_request) - .permit(merge_request_params_attributes) + params.require(:merge_request).permit(merge_request_params_attributes) end def merge_request_params_attributes diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index b5366519ed9..d0c518f81f7 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -245,6 +245,53 @@ module IssuablesHelper @counts[cache_key][state] end + def close_issuable_url(issuable) + issuable_url(issuable, close_reopen_params(issuable, :close)) + end + + def reopen_issuable_url(issuable) + issuable_url(issuable, close_reopen_params(issuable, :reopen)) + end + + def close_reopen_issuable_url(issuable, should_inverse = false) + issuable.closed? ^ should_inverse ? reopen_issuable_url(issuable) : close_issuable_url(issuable) + end + + def issuable_url(issuable, *options) + case issuable + when Issue + issue_url(issuable, *options) + when MergeRequest + merge_request_url(issuable, *options) + end + end + + def issuable_button_visibility(issuable, closed) + case issuable + when Issue + issue_button_visibility(issuable, closed) + when MergeRequest + merge_request_button_visibility(issuable, closed) + end + end + + def issuable_close_reopen_button_method(issuable) + case issuable + when Issue + '' + when MergeRequest + 'put' + end + end + + def issuable_author_is_current_user(issuable) + issuable.author == current_user + end + + def issuable_display_type(issuable) + issuable.model_name.human.downcase + end + private def sidebar_gutter_collapsed? @@ -270,8 +317,6 @@ module IssuablesHelper issue_template_names when MergeRequest merge_request_template_names - else - raise 'Unknown issuable type!' end end @@ -301,4 +346,12 @@ module IssuablesHelper container: (is_collapsed ? 'body' : nil) } end + + def close_reopen_params(issuable, action) + { + issuable.model_name.to_s.underscore => { state_event: action } + }.tap do |params| + params[:format] = :json if issuable.is_a?(Issue) + end + end end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index b0d7f7ef5f5..14516091495 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -234,6 +234,7 @@ class ApplicationSetting < ActiveRecord::Base koding_url: nil, max_artifacts_size: Settings.artifacts['max_size'], max_attachment_size: Settings.gitlab['max_attachment_size'], + performance_bar_allowed_group_id: nil, plantuml_enabled: false, plantuml_url: nil, recaptcha_enabled: false, @@ -336,6 +337,48 @@ class ApplicationSetting < ActiveRecord::Base super(levels.map { |level| Gitlab::VisibilityLevel.level_value(level) }) end + def performance_bar_allowed_group_id=(group_full_path) + group_full_path = nil if group_full_path.blank? + + if group_full_path.nil? + if group_full_path != performance_bar_allowed_group_id + super(group_full_path) + Gitlab::PerformanceBar.expire_allowed_user_ids_cache + end + return + end + + group = Group.find_by_full_path(group_full_path) + + if group + if group.id != performance_bar_allowed_group_id + super(group.id) + Gitlab::PerformanceBar.expire_allowed_user_ids_cache + end + else + super(nil) + Gitlab::PerformanceBar.expire_allowed_user_ids_cache + end + end + + def performance_bar_allowed_group + Group.find_by_id(performance_bar_allowed_group_id) + end + + # Return true if the Performance Bar is enabled for a given group + def performance_bar_enabled + performance_bar_allowed_group_id.present? + end + + # - If `enable` is true, we early return since the actual attribute that holds + # the enabling/disabling is `performance_bar_allowed_group_id` + # - If `enable` is false, we set `performance_bar_allowed_group_id` to `nil` + def performance_bar_enabled=(enable) + return if enable + + self.performance_bar_allowed_group_id = nil + end + # Choose one of the available repository storage options. Currently all have # equal weighting. def pick_repository_storage diff --git a/app/models/concerns/each_batch.rb b/app/models/concerns/each_batch.rb new file mode 100644 index 00000000000..6ddbb8da1a9 --- /dev/null +++ b/app/models/concerns/each_batch.rb @@ -0,0 +1,81 @@ +module EachBatch + extend ActiveSupport::Concern + + module ClassMethods + # Iterates over the rows in a relation in batches, similar to Rails' + # `in_batches` but in a more efficient way. + # + # Unlike `in_batches` provided by Rails this method does not support a + # custom start/end range, nor does it provide support for the `load:` + # keyword argument. + # + # This method will yield an ActiveRecord::Relation to the supplied block, or + # return an Enumerator if no block is given. + # + # Example: + # + # User.each_batch do |relation| + # relation.update_all(updated_at: Time.now) + # end + # + # The supplied block is also passed an optional batch index: + # + # User.each_batch do |relation, index| + # puts index # => 1, 2, 3, ... + # end + # + # You can also specify an alternative column to use for ordering the rows: + # + # User.each_batch(column: :created_at) do |relation| + # ... + # end + # + # This will produce SQL queries along the lines of: + # + # User Load (0.7ms) SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 41654) ORDER BY "users"."id" ASC LIMIT 1 OFFSET 1000 + # (0.7ms) SELECT COUNT(*) FROM "users" WHERE ("users"."id" >= 41654) AND ("users"."id" < 42687) + # + # of - The number of rows to retrieve per batch. + # column - The column to use for ordering the batches. + def each_batch(of: 1000, column: primary_key) + unless column + raise ArgumentError, + 'the column: argument must be set to a column name to use for ordering rows' + end + + start = except(:select) + .select(column) + .reorder(column => :asc) + .take + + return unless start + + start_id = start[column] + arel_table = self.arel_table + + 1.step do |index| + stop = except(:select) + .select(column) + .where(arel_table[column].gteq(start_id)) + .reorder(column => :asc) + .offset(of) + .limit(1) + .take + + relation = where(arel_table[column].gteq(start_id)) + + if stop + stop_id = stop[column] + start_id = stop_id + relation = relation.where(arel_table[column].lt(stop_id)) + end + + # Any ORDER BYs are useless for this relation and can lead to less + # efficient UPDATE queries, hence we get rid of it. + yield relation.except(:order), index + + break unless stop + end + end + end +end diff --git a/app/models/project_services/gitlab_issue_tracker_service.rb b/app/models/project_services/gitlab_issue_tracker_service.rb index 5e31f393bbe..420102875a5 100644 --- a/app/models/project_services/gitlab_issue_tracker_service.rb +++ b/app/models/project_services/gitlab_issue_tracker_service.rb @@ -1,5 +1,5 @@ class GitlabIssueTrackerService < IssueTrackerService - include Gitlab::Routing.url_helpers + include Gitlab::Routing validates :project_url, :issues_url, :new_issue_url, presence: true, url: true, if: :activated? diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index 8af642b44aa..5498a2e17b2 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -1,5 +1,5 @@ class JiraService < IssueTrackerService - include Gitlab::Routing.url_helpers + include Gitlab::Routing validates :url, url: true, presence: true, if: :activated? validates :api_url, url: true, allow_blank: true diff --git a/app/services/chat_names/authorize_user_service.rb b/app/services/chat_names/authorize_user_service.rb index 321bf3a9205..7256466c9e8 100644 --- a/app/services/chat_names/authorize_user_service.rb +++ b/app/services/chat_names/authorize_user_service.rb @@ -1,6 +1,6 @@ module ChatNames class AuthorizeUserService - include Gitlab::Routing.url_helpers + include Gitlab::Routing def initialize(service, params) @service = service diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index c5f959a1874..bc4a13cf4bc 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -35,11 +35,12 @@ module MergeRequests # target branch manually def close_merge_requests commit_ids = @commits.map(&:id) - merge_requests = @project.merge_requests.opened.where(target_branch: @branch_name).to_a + merge_requests = @project.merge_requests.preload(:merge_request_diff).opened.where(target_branch: @branch_name).to_a merge_requests = merge_requests.select(&:diff_head_commit) merge_requests = merge_requests.select do |merge_request| - commit_ids.include?(merge_request.diff_head_sha) + commit_ids.include?(merge_request.diff_head_sha) && + merge_request.merge_request_diff.state != 'empty' end filter_merge_requests(merge_requests).each do |merge_request| diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 5f5eeb8b9a9..7f1e13c7989 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -333,6 +333,22 @@ Environment variable `prometheus_multiproc_dir` does not exist or is not pointing to a valid directory. %fieldset + %legend Profiling - Performance Bar + %p + Enable the Performance Bar for a given group. + = link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar') + .form-group + .col-sm-offset-2.col-sm-10 + .checkbox + = f.label :performance_bar_enabled do + = f.check_box :performance_bar_enabled + Enable the Performance Bar + .form-group + = f.label :performance_bar_allowed_group_id, 'Allowed group', class: 'control-label col-sm-2' + .col-sm-10 + = f.text_field :performance_bar_allowed_group_id, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path + + %fieldset %legend Background Jobs %p These settings require a restart to take effect. diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index 1a9f5401a78..cc9219cb6fe 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -12,10 +12,12 @@ .content-wrapper{ class: "#{(layout_nav_class unless show_new_nav?)}" } .alert-wrapper = render "layouts/broadcast" + - if show_new_nav? + - if content_for?(:new_global_flash) + = yield :new_global_flash + = render "layouts/nav/breadcrumbs" = render "layouts/flash" = yield :flash_message - - if show_new_nav? - = render "layouts/nav/breadcrumbs" %div{ class: "#{(container_class unless @no_container)} #{@content_class}" } .content{ id: "content-body" } = yield diff --git a/app/views/layouts/nav/_new_admin_sidebar.html.haml b/app/views/layouts/nav/_new_admin_sidebar.html.haml index 40c1ca7b53e..d7a9e530983 100644 --- a/app/views/layouts/nav/_new_admin_sidebar.html.haml +++ b/app/views/layouts/nav/_new_admin_sidebar.html.haml @@ -4,7 +4,7 @@ = icon('wrench') .project-title Admin Area %ul.sidebar-top-level-items - = nav_link(controller: %w(dashboard admin projects users groups builds runners cohorts), html_options: {class: 'home'}) do + = nav_link(controller: %w(dashboard admin projects users groups jobs runners cohorts), html_options: {class: 'home'}) do = link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do %span Overview @@ -26,7 +26,7 @@ = link_to admin_groups_path, title: 'Groups' do %span Groups - = nav_link path: 'builds#index' do + = nav_link path: 'jobs#index' do = link_to admin_jobs_path, title: 'Jobs' do %span Jobs diff --git a/app/views/layouts/nav/_new_group_sidebar.html.haml b/app/views/layouts/nav/_new_group_sidebar.html.haml index b7ac04cc3e5..7b68d11041e 100644 --- a/app/views/layouts/nav/_new_group_sidebar.html.haml +++ b/app/views/layouts/nav/_new_group_sidebar.html.haml @@ -1,5 +1,5 @@ .nav-sidebar - = link_to group_path(@group), title: 'Group', class: 'context-header' do + = link_to group_path(@group), title: @group.name, class: 'context-header' do .avatar-container.s40.group-avatar = image_tag group_icon(@group), class: "avatar s40 avatar-tile" .group-title diff --git a/app/views/layouts/nav/_new_project_sidebar.html.haml b/app/views/layouts/nav/_new_project_sidebar.html.haml index 6e483353a2d..cc731db3cc1 100644 --- a/app/views/layouts/nav/_new_project_sidebar.html.haml +++ b/app/views/layouts/nav/_new_project_sidebar.html.haml @@ -1,6 +1,6 @@ .nav-sidebar - can_edit = can?(current_user, :admin_project, @project) - = link_to project_path(@project), title: 'Project', class: 'context-header' do + = link_to project_path(@project), title: @project.name, class: 'context-header' do .avatar-container.s40.project-avatar = project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile') .project-title diff --git a/app/views/projects/empty.html.haml b/app/views/projects/empty.html.haml index 50e0bad3ccf..0f132a68ce1 100644 --- a/app/views/projects/empty.html.haml +++ b/app/views/projects/empty.html.haml @@ -1,6 +1,7 @@ - @no_container = true +- flash_message_container = show_new_nav? ? :new_global_flash : :flash_message -= content_for :flash_message do += content_for flash_message_container do - if current_user && can?(current_user, :download_code, @project) = render 'shared/no_ssh' = render 'shared/no_password' diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index cf8493faba8..a57844f974e 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -30,24 +30,23 @@ .dropdown-menu.dropdown-menu-align-right.hidden-lg %ul - if can_update_issue - %li - = link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'issuable-edit' - %li - = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - %li - = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + %li= link_to 'Edit', edit_project_issue_path(@project, @issue) + - unless current_user == @issue.author + %li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue)) + - if can_update_issue + %li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' + %li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' - if can_report_spam - %li - = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' + %li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam' - if can_update_issue || can_report_spam %li.divider - %li - = link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link' + %li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link' - if can_update_issue = link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit' - = link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue' - = link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue' + + = render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue + - if can_report_spam = link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam' = link_to new_project_issue_path(@project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do diff --git a/app/views/projects/merge_requests/_mr_title.html.haml b/app/views/projects/merge_requests/_mr_title.html.haml index 3182aecd0a8..a2e819fb3a7 100644 --- a/app/views/projects/merge_requests/_mr_title.html.haml +++ b/app/views/projects/merge_requests/_mr_title.html.haml @@ -1,3 +1,5 @@ +- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request) + - if @merge_request.closed_without_fork? .alert.alert-danger %p The source project of this merge request has been removed. @@ -15,21 +17,24 @@ .issuable-meta = issuable_meta(@merge_request, @project, "Merge request") - - if can?(current_user, :update_merge_request, @merge_request) - .issuable-actions - .clearfix.issue-btn-group.dropdown - %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } - Options - = icon('caret-down') - .dropdown-menu.dropdown-menu-align-right.hidden-lg - %ul + .issuable-actions + .clearfix.issue-btn-group.dropdown + %button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } } + Options + = icon('caret-down') + .dropdown-menu.dropdown-menu-align-right.hidden-lg + %ul + - if can_update_merge_request + %li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'issuable-edit' + - unless current_user == @merge_request.author + %li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request)) + - if can_update_merge_request %li{ class: merge_request_button_visibility(@merge_request, true) } = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request' %li{ class: merge_request_button_visibility(@merge_request, false) } = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request' - %li - = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: 'issuable-edit' - = link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{merge_request_button_visibility(@merge_request, true)}", title: 'Close merge request' - = link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen reopen-mr-link #{merge_request_button_visibility(@merge_request, false)}", title: 'Reopen merge request' - = link_to edit_project_merge_request_path(@project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped issuable-edit" do - Edit + + - if can_update_merge_request + = link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped issuable-edit" + + = render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request diff --git a/app/views/projects/show.html.haml b/app/views/projects/show.html.haml index ea780b1cb83..d413c4619be 100644 --- a/app/views/projects/show.html.haml +++ b/app/views/projects/show.html.haml @@ -1,9 +1,10 @@ - @no_container = true +- flash_message_container = show_new_nav? ? :new_global_flash : :flash_message = content_for :meta_tags do = auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity") -= content_for :flash_message do += content_for flash_message_container do - if current_user && can?(current_user, :download_code, @project) = render 'shared/no_ssh' = render 'shared/no_password' diff --git a/app/views/shared/issuable/_close_reopen_button.html.haml b/app/views/shared/issuable/_close_reopen_button.html.haml new file mode 100644 index 00000000000..8a1268a1c6d --- /dev/null +++ b/app/views/shared/issuable/_close_reopen_button.html.haml @@ -0,0 +1,14 @@ +- is_current_user = issuable_author_is_current_user(issuable) +- display_issuable_type = issuable_display_type(issuable) +- button_method = issuable_close_reopen_button_method(issuable) + +- if can_update && is_current_user + = link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method, + class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}" + = link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method, + class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}" +- elsif can_update && !is_current_user + = render 'shared/issuable/close_reopen_report_toggle', issuable: issuable +- else + = link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), + class: 'hidden-xs hidden-sm btn btn-grouped btn-close-color', title: 'Report abuse' diff --git a/app/views/shared/issuable/_close_reopen_report_toggle.html.haml b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml new file mode 100644 index 00000000000..6756a7f17fd --- /dev/null +++ b/app/views/shared/issuable/_close_reopen_report_toggle.html.haml @@ -0,0 +1,49 @@ +- display_issuable_type = issuable_display_type(issuable) +- button_action = issuable.closed? ? 'reopen' : 'close' +- display_button_action = button_action.capitalize +- button_responsive_class = 'hidden-xs hidden-sm' +- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button issuable-close-button" +- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle" +- button_method = issuable_close_reopen_button_method(issuable) + +.pull-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown + = link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_url(issuable), + method: button_method, class: "#{button_class} btn-#{button_action}", title: "#{display_button_action} #{display_issuable_type}" + + = button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color", + data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => 'Toggle dropdown' do + = icon('caret-down', class: 'toggle-icon icon') + + %ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ class: button_responsive_class, data: { dropdown: true } } + %li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}", + data: { text: "Close #{display_issuable_type}", url: close_issuable_url(issuable), + button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color", method: button_method } } + %button.btn.btn-transparent + = icon('check', class: 'icon') + .description + %strong.title + Close + = display_issuable_type + + %li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}", + data: { text: "Reopen #{display_issuable_type}", url: reopen_issuable_url(issuable), + button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color", method: button_method } } + %button.btn.btn-transparent + = icon('check', class: 'icon') + .description + %strong.title + Reopen + = display_issuable_type + + %li.divider.droplab-item-ignore + + %li.report-item{ data: { text: 'Report abuse', url: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)), + button_class: "#{button_class} btn-close-color", toggle_class: "#{toggle_class} btn-close-color", method: '' } } + %button.btn.btn-transparent + = icon('check', class: 'icon') + .description + %strong.title Report abuse + %p.text + Report + = display_issuable_type.pluralize + that are abusive, inappropriate or spam. diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index ae890567225..bdb573cb8fd 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -19,7 +19,7 @@ content_class: "filtered-search-history-dropdown-content", title: "Recent searches" }) do .js-filtered-search-history-dropdown{ data: { project_full_path: @project.full_path } } - .filtered-search-box-input-container + .filtered-search-box-input-container.droplab-dropdown .scroll-container %ul.tokens-container.list-unstyled %li.input-token diff --git a/app/views/shared/notes/_comment_button.html.haml b/app/views/shared/notes/_comment_button.html.haml index 29cf5825292..1dfe380db16 100644 --- a/app/views/shared/notes/_comment_button.html.haml +++ b/app/views/shared/notes/_comment_button.html.haml @@ -1,6 +1,6 @@ - noteable_name = @note.noteable.human_class_name -.pull-left.btn-group.append-right-10.comment-type-dropdown.js-comment-type-dropdown +.pull-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown %input.btn.btn-nr.btn-create.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' } - if @note.can_be_discussion_note? @@ -9,8 +9,8 @@ %ul#resolvable-comment-menu.dropdown-menu{ data: { dropdown: true } } %li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => 'Comment', 'close-text' => "Comment & close #{noteable_name}", 'reopen-text' => "Comment & reopen #{noteable_name}" } } - %a{ href: '#' } - = icon('check') + %button.btn.btn-transparent + = icon('check', class: 'icon') .description %strong Comment %p @@ -19,8 +19,8 @@ %li.divider.droplab-item-ignore %li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } } - %a{ href: '#' } - = icon('check') + %button.btn.btn-transparent + = icon('check', class: 'icon') .description %strong Start discussion %p diff --git a/changelogs/unreleased/20628-add-oauth-implicit-grant.yml b/changelogs/unreleased/20628-add-oauth-implicit-grant.yml new file mode 100644 index 00000000000..58a28142feb --- /dev/null +++ b/changelogs/unreleased/20628-add-oauth-implicit-grant.yml @@ -0,0 +1,4 @@ +--- +title: "#20628 Enable implicit grant in GitLab as OAuth Provider" +merge_request: 12384 +author: Mateusz Pytel diff --git a/changelogs/unreleased/31982-liberation-mono-linux.yml b/changelogs/unreleased/31982-liberation-mono-linux.yml new file mode 100644 index 00000000000..c0f29cf4c47 --- /dev/null +++ b/changelogs/unreleased/31982-liberation-mono-linux.yml @@ -0,0 +1,4 @@ +--- +title: Change order of monospace fonts to fix bug on some linux distros +merge_request: +author: diff --git a/changelogs/unreleased/33929-allow-to-enable-perf-bar-for-a-group.yml b/changelogs/unreleased/33929-allow-to-enable-perf-bar-for-a-group.yml new file mode 100644 index 00000000000..810cc8489b5 --- /dev/null +++ b/changelogs/unreleased/33929-allow-to-enable-perf-bar-for-a-group.yml @@ -0,0 +1,4 @@ +--- +title: Allow to enable the performance bar per user or Feature group +merge_request: 12362 +author: diff --git a/changelogs/unreleased/feature-user-agent-details-api.yml b/changelogs/unreleased/feature-user-agent-details-api.yml new file mode 100644 index 00000000000..839ec7d21cd --- /dev/null +++ b/changelogs/unreleased/feature-user-agent-details-api.yml @@ -0,0 +1,4 @@ +--- +title: Allow admins to retrieve user agent details for an issue or snippet +merge_request: 12655 +author: diff --git a/changelogs/unreleased/fix-mrs-merged-immediately.yml b/changelogs/unreleased/fix-mrs-merged-immediately.yml new file mode 100644 index 00000000000..41c06614e6d --- /dev/null +++ b/changelogs/unreleased/fix-mrs-merged-immediately.yml @@ -0,0 +1,4 @@ +--- +title: Don't mark empty MRs as merged on push to the target branch +merge_request: +author: diff --git a/changelogs/unreleased/issue-description-gfm.yml b/changelogs/unreleased/issue-description-gfm.yml new file mode 100644 index 00000000000..4d421bff677 --- /dev/null +++ b/changelogs/unreleased/issue-description-gfm.yml @@ -0,0 +1,4 @@ +--- +title: Fixed GFM references not being included when updating issues inline +merge_request: +author: diff --git a/config/application.rb b/config/application.rb index 3e6d72810cd..d88740ef8d7 100644 --- a/config/application.rb +++ b/config/application.rb @@ -166,9 +166,10 @@ module Gitlab config.after_initialize do Rails.application.reload_routes! - named_routes_set = Gitlab::Application.routes.named_routes project_url_helpers = Module.new do - named_routes_set.helper_names.each do |name| + extend ActiveSupport::Concern + + Gitlab::Application.routes.named_routes.helper_names.each do |name| next unless name.include?('namespace_project') define_method(name.sub('namespace_project', 'project')) do |project, *args| @@ -177,14 +178,7 @@ module Gitlab end end - named_routes_set.url_helpers_module.include project_url_helpers - named_routes_set.url_helpers_module.extend project_url_helpers - - Gitlab::Routing.url_helpers.include project_url_helpers - Gitlab::Routing.url_helpers.extend project_url_helpers - - GitlabRoutingHelper.include project_url_helpers - GitlabRoutingHelper.extend project_url_helpers + Gitlab::Routing.add_helpers(project_url_helpers) end end end diff --git a/config/gitlab.yml.example b/config/gitlab.yml.example index 75d03de18a1..221e3d6e03b 100644 --- a/config/gitlab.yml.example +++ b/config/gitlab.yml.example @@ -657,7 +657,10 @@ test: client_id: 'YOUR_AUTH0_CLIENT_ID', client_secret: 'YOUR_AUTH0_CLIENT_SECRET', namespace: 'YOUR_AUTH0_DOMAIN' } } - + - { name: 'authentiq', + app_id: 'YOUR_CLIENT_ID', + app_secret: 'YOUR_CLIENT_SECRET', + args: { scope: 'aq:name email~rs address aq:push' } } ldap: enabled: false servers: diff --git a/config/initializers/doorkeeper.rb b/config/initializers/doorkeeper.rb index a5636765774..8e2e639fc41 100644 --- a/config/initializers/doorkeeper.rb +++ b/config/initializers/doorkeeper.rb @@ -87,9 +87,7 @@ Doorkeeper.configure do # "password" => Resource Owner Password Credentials Grant Flow # "client_credentials" => Client Credentials Grant Flow # - # If not specified, Doorkeeper enables all the four grant flows. - # - grant_flows %w(authorization_code password client_credentials) + grant_flows %w(authorization_code implicit password client_credentials) # Under some circumstances you might want to have applications auto-approved, # so that the user skips the authorization step. diff --git a/db/migrate/20170706151212_add_performance_bar_allowed_group_id_to_application_settings.rb b/db/migrate/20170706151212_add_performance_bar_allowed_group_id_to_application_settings.rb new file mode 100644 index 00000000000..fe9970ddc71 --- /dev/null +++ b/db/migrate/20170706151212_add_performance_bar_allowed_group_id_to_application_settings.rb @@ -0,0 +1,9 @@ +class AddPerformanceBarAllowedGroupIdToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def change + add_column :application_settings, :performance_bar_allowed_group_id, :integer + end +end diff --git a/db/schema.rb b/db/schema.rb index 386f3041135..023783c2b3b 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -126,6 +126,7 @@ ActiveRecord::Schema.define(version: 20170724184243) do t.boolean "prometheus_metrics_enabled", default: false, null: false t.boolean "help_page_hide_commercial_content", default: false t.string "help_page_support_url" + t.integer "performance_bar_allowed_group_id" end create_table "audit_events", force: :cascade do |t| diff --git a/doc/README.md b/doc/README.md index ebf1a0415d2..9b81c409570 100644 --- a/doc/README.md +++ b/doc/README.md @@ -167,6 +167,7 @@ have access to GitLab administration tools and settings. - [Operations](administration/operations.md): Keeping GitLab up and running. - [Polling](administration/polling.md): Configure how often the GitLab UI polls for updates. - [Request Profiling](administration/monitoring/performance/request_profiling.md): Get a detailed profile on slow requests. +- [Performance Bar](administration/monitoring/performance/performance_bar.md): Get performance information for the current page. ### Customization diff --git a/doc/administration/monitoring/performance/img/performance_bar.png b/doc/administration/monitoring/performance/img/performance_bar.png Binary files differnew file mode 100644 index 00000000000..d38293d2ed6 --- /dev/null +++ b/doc/administration/monitoring/performance/img/performance_bar.png diff --git a/doc/administration/monitoring/performance/img/performance_bar_configuration_settings.png b/doc/administration/monitoring/performance/img/performance_bar_configuration_settings.png Binary files differnew file mode 100644 index 00000000000..2d64ef8c5fc --- /dev/null +++ b/doc/administration/monitoring/performance/img/performance_bar_configuration_settings.png diff --git a/doc/administration/monitoring/performance/img/performance_bar_line_profiling.png b/doc/administration/monitoring/performance/img/performance_bar_line_profiling.png Binary files differnew file mode 100644 index 00000000000..7868e2c46d1 --- /dev/null +++ b/doc/administration/monitoring/performance/img/performance_bar_line_profiling.png diff --git a/doc/administration/monitoring/performance/img/performance_bar_sql_queries.png b/doc/administration/monitoring/performance/img/performance_bar_sql_queries.png Binary files differnew file mode 100644 index 00000000000..372ae021f6b --- /dev/null +++ b/doc/administration/monitoring/performance/img/performance_bar_sql_queries.png diff --git a/doc/administration/monitoring/performance/performance_bar.md b/doc/administration/monitoring/performance/performance_bar.md new file mode 100644 index 00000000000..ee680c7b258 --- /dev/null +++ b/doc/administration/monitoring/performance/performance_bar.md @@ -0,0 +1,35 @@ +# Performance Bar + +A Performance Bar can be displayed, to dig into the performance of a page. When +activated, it looks as follows: + +![Performance Bar](img/performance_bar.png) + +It allows you to: + +- see the current host serving the page +- see the timing of the page (backend, frontend) +- the number of DB queries, the time it took, and the detail of these queries +![SQL profiling using the Performance Bar](img/performance_bar_sql_queries.png) +- the number of calls to Redis, and the time it took +- the number of background jobs created by Sidekiq, and the time it took +- the number of Ruby GC calls, and the time it took +- profile the code used to generate the page, line by line +![Line profiling using the Performance Bar](img/performance_bar_line_profiling.png) + +## Enable the Performance Bar via the Admin panel + +GitLab Performance Bar is disabled by default. To enable it for a given group, +navigate to the Admin area in **Settings > Profiling - Performance Bar** +(`/admin/application_settings`). + +The only required setting you need to set is the full path of the group that +will be allowed to display the Performance Bar. +Make sure _Enable the Performance Bar_ is checked and hit +**Save** to save the changes. + +--- + +![GitLab Performance Bar Admin Settings](img/performance_bar_configuration_settings.png) + +--- diff --git a/doc/api/README.md b/doc/api/README.md index b7f6ee69193..95e7a457848 100644 --- a/doc/api/README.md +++ b/doc/api/README.md @@ -17,6 +17,7 @@ following locations: - [Deploy Keys](deploy_keys.md) - [Environments](environments.md) - [Events](events.md) +- [Feature flags](features.md) - [Gitignores templates](templates/gitignores.md) - [GitLab CI Config templates](templates/gitlab_ci_ymls.md) - [Groups](groups.md) diff --git a/doc/api/features.md b/doc/api/features.md index 558869255cc..6861dbf00a2 100644 --- a/doc/api/features.md +++ b/doc/api/features.md @@ -1,4 +1,4 @@ -# Features API +# Features flags API All methods require administrator authorization. @@ -61,7 +61,8 @@ POST /features/:name | `feature_group` | string | no | A Feature group name | | `user` | string | no | A GitLab username | -Note that `feature_group` and `user` are mutually exclusive. +Note that you can enable or disable a feature for both a `feature_group` and a +`user` with a single API call. ```bash curl --data "value=30" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features/new_library diff --git a/doc/api/issues.md b/doc/api/issues.md index df5666bb7b6..a00a63bad4b 100644 --- a/doc/api/issues.md +++ b/doc/api/issues.md @@ -964,3 +964,30 @@ Example response: ## Comments on issues Comments are done via the [notes](notes.md) resource. + +## Get user agent details + +Available only for admins. + +``` +GET /projects/:id/issues/:issue_iid/user_agent_detail +``` + +| Attribute | Type | Required | Description | +|-------------|---------|----------|--------------------------------------| +| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user | +| `issue_iid` | integer | yes | The internal ID of a project's issue | + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/user_agent_detail +``` + +Example response: + +```json +{ + "user_agent": "AppleWebKit/537.36", + "ip_address": "127.0.0.1", + "akismet_submitted": false +} +``` diff --git a/doc/api/oauth2.md b/doc/api/oauth2.md index 07cb64cb373..77bb7a55d8c 100644 --- a/doc/api/oauth2.md +++ b/doc/api/oauth2.md @@ -1,59 +1,55 @@ # GitLab as an OAuth2 provider -This document covers using the OAuth2 protocol to access GitLab. +This document covers using the [OAuth2](https://oauth.net/2/) protocol to allow other services access Gitlab resources on user's behalf. -If you want GitLab to be an OAuth authentication service provider to sign into other services please see the [Oauth2 provider documentation](../integration/oauth_provider.md). +If you want GitLab to be an OAuth authentication service provider to sign into other services please see the [OAuth2 provider](../integration/oauth_provider.md) +documentation. -OAuth2 is a protocol that enables us to authenticate a user without requiring them to give their password to a third-party. +This functionality is based on [doorkeeper gem](https://github.com/doorkeeper-gem/doorkeeper). -This functionality is based on [doorkeeper gem](https://github.com/doorkeeper-gem/doorkeeper) +## Supported OAuth2 Flows -## Web Application Flow +Gitlab currently supports following authorization flows: -This is the most common type of flow and is used by server-side clients that wish to access GitLab on a user's behalf. +* *Web Application Flow* - Most secure and common type of flow, designed for the applications with secure server-side. +* *Implicit Flow* - This flow is designed for user-agent only apps (e.g. single page web application running on GitLab Pages). +* *Resource Owner Password Credentials Flow* - To be used **only** for securely hosted, first-party services. ->**Note:** -This flow **should not** be used for client-side clients as you would leak your `client_secret`. Client-side clients should use the Implicit Grant (which is currently unsupported). +Please refer to [OAuth RFC](https://tools.ietf.org/html/rfc6749) to find out in details how all those flows work and pick the right one for your use case. -For more detailed information, check out the [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.1) +Both *web application* and *implicit* flows require `application` to be registered first via `/profile/applications` page +in your user's account. During registration, by enabling proper scopes you can limit the range of resources which the `application` can access. Upon creation +you'll obtain `application` credentials: _Application ID_ and _Client Secret_ - **keep them secure**. -In the following sections you will be introduced to the three steps needed for this flow. +>**Important:** OAuth specification advises sending `state` parameter with each request to `/oauth/authorize`. We highly recommended to send a unique +value with each request and validate it against the one in redirect request. This is important to prevent [CSRF attacks]. The `state` param really should +have been a requirement in the standard! -### 1. Registering the client +In the following sections you will find detailed instructions on how to obtain authorization with each flow. -First, you should create an application (`/profile/applications`) in your user's account. -Each application gets a unique App ID and App Secret parameters. +### Web Application Flow ->**Note:** -**You should not share/leak your App ID or App Secret.** +Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.1) for a detailed flow description -### 2. Requesting authorization +#### 1. Requesting authorization code -To request the authorization code, you should redirect the user to the `/oauth/authorize` endpoint: +To request the authorization code, you should redirect the user to the `/oauth/authorize` endpoint with following GET parameters: ``` -https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=your_unique_state_hash +https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=YOUR_UNIQUE_STATE_HASH ``` -This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided. +This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided. The redirect will +include the GET `code` parameter, for example: -The redirect will include the GET `code` parameter, for example: - -``` -http://myapp.com/oauth/redirect?code=1234567890&state=your_unique_state_hash -``` +`http://myapp.com/oauth/redirect?code=1234567890&state=YOUR_UNIQUE_STATE_HASH` You should then use the `code` to request an access token. ->**Important:** -It is highly recommended that you send a `state` value with the request to `/oauth/authorize` and -validate that value is returned and matches in the redirect request. -This is important to prevent [CSRF attacks](http://www.oauthsecurity.com/#user-content-authorization-code-flow), -`state` really should have been a requirement in the standard! - -### 3. Requesting the access token +#### 2. Requesting access token -Once you have the authorization code you can request an `access_token` using the code, to do that you can use any HTTP client. In the following example, we are using Ruby's `rest-client`: +Once you have the authorization code you can request an `access_token` using the code, to do that you can use any HTTP client. In the following example, +we are using Ruby's `rest-client`: ``` parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=authorization_code&redirect_uri=REDIRECT_URI' @@ -72,28 +68,40 @@ The `redirect_uri` must match the `redirect_uri` used in the original authorizat You can now make requests to the API with the access token returned. -### Use the access token to access the API -The access token allows you to make requests to the API on a behalf of a user. +### Implicit Grant + +Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.2) for a detailed flow description. + +Unlike the web flow, the client receives an `access token` immediately as a result of the authorization request. The flow does not use client secret +or authorization code because all of the application code and storage is easily accessible, therefore __secrets__ can leak easily. + +>**Important:** Avoid using this flow for applications that store data outside of the Gitlab instance. If you do, make sure to verify `application id` +associated with access token before granting access to the data +(see [/oauth/token/info](https://github.com/doorkeeper-gem/doorkeeper/wiki/API-endpoint-descriptions-and-examples#get----oauthtokeninfo)). + + +#### 1. Requesting access token + +To request the access token, you should redirect the user to the `/oauth/authorize` endpoint using `token` response type: ``` -GET https://gitlab.example.com/api/v4/user?access_token=OAUTH-TOKEN +https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=token&state=YOUR_UNIQUE_STATE_HASH ``` -Or you can put the token to the Authorization header: +This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided. The redirect +will include a fragment with `access_token` as well as token details in GET parameters, for example: ``` -curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v4/user +http://myapp.com/oauth/redirect#access_token=ABCDExyz123&state=YOUR_UNIQUE_STATE_HASH&token_type=bearer&expires_in=3600 ``` -## Resource Owner Password Credentials +### Resource Owner Password Credentials -## Deprecation Notice +Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.3) for a detailed flow description. -1. Starting in GitLab 8.11, the Resource Owner Password Credentials has been *disabled* for users with two-factor authentication turned on. -2. These users can access the API using [personal access tokens] instead. - ---- +> **Deprecation notice:** Starting in GitLab 8.11, the Resource Owner Password Credentials has been *disabled* for users with two-factor authentication +turned on. These users can access the API using [personal access tokens] instead. In this flow, a token is requested in exchange for the resource owner credentials (username and password). The credentials should only be used when there is a high degree of trust between the resource owner and the client (e.g. the @@ -101,12 +109,16 @@ client is part of the device operating system or a highly privileged application available (such as an authorization code). >**Important:** -Never store the users credentials and only use this grant type when your client is deployed to a trusted environment, in 99% of cases [personal access tokens] are a better choice. +Never store the users credentials and only use this grant type when your client is deployed to a trusted environment, in 99% of cases [personal access tokens] +are a better choice. Even though this grant type requires direct client access to the resource owner credentials, the resource owner credentials are used for a single request and are exchanged for an access token. This grant type can eliminate the need for the client to store the resource owner credentials for future use, by exchanging the credentials with a long-lived access token or refresh token. -You can do POST request to `/oauth/token` with parameters: + +#### 1. Requesting access token + +POST request to `/oauth/token` with parameters: ``` { @@ -134,4 +146,18 @@ access_token = client.password.get_token('user@example.com', 'secret') puts access_token.token ``` +## Access Gitlab API with `access token` + +The `access token` allows you to make requests to the API on a behalf of a user. You can pass the token either as GET parameter +``` +GET https://gitlab.example.com/api/v4/user?access_token=OAUTH-TOKEN +``` + +or you can put the token to the Authorization header: + +``` +curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v4/user +``` + [personal access tokens]: ../user/profile/personal_access_tokens.md +[CSRF attacks]: http://www.oauthsecurity.com/#user-content-authorization-code-flow
\ No newline at end of file diff --git a/doc/api/project_snippets.md b/doc/api/project_snippets.md index 92491de4daa..d74398c6e65 100644 --- a/doc/api/project_snippets.md +++ b/doc/api/project_snippets.md @@ -119,3 +119,35 @@ Parameters: - `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user - `snippet_id` (required) - The ID of a project's snippet + +## Get user agent details + +> **Notes:** +> [Introduced][ce-29508] in GitLab 9.4. + + +Available only for admins. + +``` +GET /projects/:id/snippets/:snippet_id/user_agent_detail +``` + +| Attribute | Type | Required | Description | +|-------------|---------|----------|--------------------------------------| +| `id` | Integer | yes | The ID of a snippet | + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/snippets/1/user_agent_detail +``` + +Example response: + +```json +{ + "user_agent": "AppleWebKit/537.36", + "ip_address": "127.0.0.1", + "akismet_submitted": false +} +``` + +[ce-[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508 diff --git a/doc/api/snippets.md b/doc/api/snippets.md index efaab712367..fdafbfb5b9e 100644 --- a/doc/api/snippets.md +++ b/doc/api/snippets.md @@ -234,3 +234,35 @@ Example response: } ] ``` + +## Get user agent details + +> **Notes:** +> [Introduced][ce-29508] in GitLab 9.4. + + +Available only for admins. + +``` +GET /snippets/:id/user_agent_detail +``` + +| Attribute | Type | Required | Description | +|-------------|---------|----------|--------------------------------------| +| `id` | Integer | yes | The ID of a snippet | + +```bash +curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets/1/user_agent_detail +``` + +Example response: + +```json +{ + "user_agent": "AppleWebKit/537.36", + "ip_address": "127.0.0.1", + "akismet_submitted": false +} +``` + +[ce-[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508 diff --git a/doc/development/README.md b/doc/development/README.md index a2a07c37ced..58993c52dcd 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -55,6 +55,7 @@ - [Single Table Inheritance](single_table_inheritance.md) - [Background Migrations](background_migrations.md) - [Storing SHA1 Hashes As Binary](sha1_as_binary.md) +- [Iterating Tables In Batches](iterating_tables_in_batches.md) ## i18n diff --git a/doc/development/feature_flags.md b/doc/development/feature_flags.md index 5c6316b9ac6..59e8a087e02 100644 --- a/doc/development/feature_flags.md +++ b/doc/development/feature_flags.md @@ -3,5 +3,19 @@ Starting from GitLab 9.3 we support feature flags via [Flipper](https://github.com/jnunemaker/flipper/). You should use the `Feature` class (defined in `lib/feature.rb`) in your code to get, set and list feature -flags. During runtime you can set the values for the gates via the -[admin API](../api/features.md). +flags. + +During runtime you can set the values for the gates via the +[features API](../api/features.md) (accessible to admins only). + +## Feature groups + +Starting from GitLab 9.4 we support feature groups via +[Flipper groups](https://github.com/jnunemaker/flipper/blob/v0.10.2/docs/Gates.md#2-group). + +Feature groups must be defined statically in `lib/feature.rb` (in the +`.register_feature_groups` method), but their implementation can obviously be +dynamic (querying the DB etc.). + +Once defined in `lib/feature.rb`, you will be able to activate a +feature for a given feature group via the [`feature_group` param of the features API](../api/features.md#set-or-create-a-feature) diff --git a/doc/development/iterating_tables_in_batches.md b/doc/development/iterating_tables_in_batches.md new file mode 100644 index 00000000000..590c8cbba2d --- /dev/null +++ b/doc/development/iterating_tables_in_batches.md @@ -0,0 +1,37 @@ +# Iterating Tables In Batches + +Rails provides a method called `in_batches` that can be used to iterate over +rows in batches. For example: + +```ruby +User.in_batches(of: 10) do |relation| + relation.update_all(updated_at: Time.now) +end +``` + +Unfortunately this method is implemented in a way that is not very efficient, +both query and memory usage wise. + +To work around this you can include the `EachBatch` module into your models, +then use the `each_batch` class method. For example: + +```ruby +class User < ActiveRecord::Base + include EachBatch +end + +User.each_batch(of: 10) do |relation| + relation.update_all(updated_at: Time.now) +end +``` + +This will end up producing queries such as: + +``` +User Load (0.7ms) SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 41654) ORDER BY "users"."id" ASC LIMIT 1 OFFSET 1000 + (0.7ms) SELECT COUNT(*) FROM "users" WHERE ("users"."id" >= 41654) AND ("users"."id" < 42687) +``` + +The API of this method is similar to `in_batches`, though it doesn't support +all of the arguments that `in_batches` supports. You should always use +`each_batch` _unless_ you have a specific need for `in_batches`. diff --git a/doc/install/database_mysql.md b/doc/install/database_mysql.md index 37e9b3101ca..bc75dc1447e 100644 --- a/doc/install/database_mysql.md +++ b/doc/install/database_mysql.md @@ -39,6 +39,9 @@ mysql> SET storage_engine=INNODB; # If you have MySQL < 5.7.7 and want to enable utf8mb4 character set support with your GitLab install, you must set the following NOW: mysql> SET GLOBAL innodb_file_per_table=1, innodb_file_format=Barracuda, innodb_large_prefix=1; +# If you use MySQL with replication, or just have MySQL configured with binary logging, you need to run the following to allow the use of `TRIGGER`: +mysql> SET GLOBAL log_bin_trust_function_creators = 1; + # Create the GitLab production database mysql> CREATE DATABASE IF NOT EXISTS `gitlabhq_production` DEFAULT CHARACTER SET `utf8` COLLATE `utf8_general_ci`; @@ -60,7 +63,15 @@ mysql> \q ``` You are done installing the database for now and can go back to the rest of the installation. -Please proceed to the rest of the installation before running through the utf8mb4 support section. +Please proceed to the rest of the installation **before** running through the steps below. + +### `log_bin_trust_function_creators` + +If you use MySQL with replication, or just have MySQL configured with binary logging, all of your MySQL servers will need to have `log_bin_trust_function_creators` enabled to allow the use of `TRIGGER` in migrations. You have already set this global variable in the steps above, but to make it persistent, add the following to your `my.cnf` file: + +``` +log_bin_trust_function_creators=1 +``` ### MySQL utf8mb4 support diff --git a/doc/update/9.2-to-9.3.md b/doc/update/9.2-to-9.3.md index 097b996ec31..910539acc70 100644 --- a/doc/update/9.2-to-9.3.md +++ b/doc/update/9.2-to-9.3.md @@ -164,6 +164,19 @@ permissions on the database: ```bash mysql -u root -p -e "GRANT TRIGGER ON \`gitlabhq_production\`.* TO 'git'@'localhost';" ``` + +If you use MySQL with replication, or just have MySQL configured with binary logging, +you will need to also run the following on all of your MySQL servers: + +```bash +mysql -u root -p -e "SET GLOBAL log_bin_trust_function_creators = 1;" +``` + +You can make this setting permanent by adding it to your `my.cnf`: + +``` +log_bin_trust_function_creators=1 +``` ### 11. Update configuration files diff --git a/lib/api/entities.rb b/lib/api/entities.rb index fdc0c562248..f4796f311a5 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -888,5 +888,11 @@ module API expose :dependencies, using: Dependency end end + + class UserAgentDetail < Grape::Entity + expose :user_agent + expose :ip_address + expose :submitted, as: :akismet_submitted + end end end diff --git a/lib/api/features.rb b/lib/api/features.rb index 21745916463..9385c6ca174 100644 --- a/lib/api/features.rb +++ b/lib/api/features.rb @@ -14,14 +14,12 @@ module API end end - def gate_target(params) - if params[:feature_group] - Feature.group(params[:feature_group]) - elsif params[:user] - User.find_by_username(params[:user]) - else - gate_value(params) - end + def gate_targets(params) + targets = [] + targets << Feature.group(params[:feature_group]) if params[:feature_group] + targets << User.find_by_username(params[:user]) if params[:user] + + targets end end @@ -42,18 +40,25 @@ module API requires :value, type: String, desc: '`true` or `false` to enable/disable, an integer for percentage of time' optional :feature_group, type: String, desc: 'A Feature group name' optional :user, type: String, desc: 'A GitLab username' - mutually_exclusive :feature_group, :user end post ':name' do feature = Feature.get(params[:name]) - target = gate_target(params) + targets = gate_targets(params) value = gate_value(params) case value when true - feature.enable(target) + if targets.present? + targets.each { |target| feature.enable(target) } + else + feature.enable + end when false - feature.disable(target) + if targets.present? + targets.each { |target| feature.disable(target) } + else + feature.disable + end else feature.enable_percentage_of_time(value) end diff --git a/lib/api/issues.rb b/lib/api/issues.rb index 09dca0dff8b..64be08094ed 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -241,6 +241,22 @@ module API present paginate(merge_requests), with: Entities::MergeRequestBasic, current_user: current_user, project: user_project end + + desc 'Get the user agent details for an issue' do + success Entities::UserAgentDetail + end + params do + requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue' + end + get ":id/issues/:issue_iid/user_agent_detail" do + authenticated_as_admin! + + issue = find_project_issue(params[:issue_iid]) + + return not_found!('UserAgentDetail') unless issue.user_agent_detail + + present issue.user_agent_detail, with: Entities::UserAgentDetail + end end end end diff --git a/lib/api/project_snippets.rb b/lib/api/project_snippets.rb index 64efe82a937..3320eadff0d 100644 --- a/lib/api/project_snippets.rb +++ b/lib/api/project_snippets.rb @@ -131,6 +131,22 @@ module API content_type 'text/plain' present snippet.content end + + desc 'Get the user agent details for a project snippet' do + success Entities::UserAgentDetail + end + params do + requires :snippet_id, type: Integer, desc: 'The ID of a project snippet' + end + get ":id/snippets/:snippet_id/user_agent_detail" do + authenticated_as_admin! + + snippet = Snippet.find_by!(id: params[:id]) + + return not_found!('UserAgentDetail') unless snippet.user_agent_detail + + present snippet.user_agent_detail, with: Entities::UserAgentDetail + end end end end diff --git a/lib/api/snippets.rb b/lib/api/snippets.rb index c630c24c339..fd634037a77 100644 --- a/lib/api/snippets.rb +++ b/lib/api/snippets.rb @@ -140,6 +140,22 @@ module API content_type 'text/plain' present snippet.content end + + desc 'Get the user agent details for a snippet' do + success Entities::UserAgentDetail + end + params do + requires :id, type: Integer, desc: 'The ID of a snippet' + end + get ":id/user_agent_detail" do + authenticated_as_admin! + + snippet = Snippet.find_by!(id: params[:id]) + + return not_found!('UserAgentDetail') unless snippet.user_agent_detail + + present snippet.user_agent_detail, with: Entities::UserAgentDetail + end end end end diff --git a/lib/gitlab/badge/metadata.rb b/lib/gitlab/badge/metadata.rb index 86c193650fb..8ad6f3cb986 100644 --- a/lib/gitlab/badge/metadata.rb +++ b/lib/gitlab/badge/metadata.rb @@ -4,7 +4,7 @@ module Gitlab # Abstract class for badge metadata # class Metadata - include Gitlab::Routing.url_helpers + include Gitlab::Routing include ActionView::Helpers::AssetTagHelper include ActionView::Helpers::UrlHelper diff --git a/lib/gitlab/conflict/file.rb b/lib/gitlab/conflict/file.rb index d2b4e6e209e..98dfe900044 100644 --- a/lib/gitlab/conflict/file.rb +++ b/lib/gitlab/conflict/file.rb @@ -1,7 +1,7 @@ module Gitlab module Conflict class File - include Gitlab::Routing.url_helpers + include Gitlab::Routing include IconsHelper MissingResolution = Class.new(ResolutionError) diff --git a/lib/gitlab/email/message/repository_push.rb b/lib/gitlab/email/message/repository_push.rb index 42fc2a4ea19..dd1d9dcd555 100644 --- a/lib/gitlab/email/message/repository_push.rb +++ b/lib/gitlab/email/message/repository_push.rb @@ -4,7 +4,7 @@ module Gitlab class RepositoryPush attr_reader :author_id, :ref, :action - include Gitlab::Routing.url_helpers + include Gitlab::Routing include DiffHelper delegate :namespace, :name_with_namespace, to: :project, prefix: :project diff --git a/lib/gitlab/git/blob.rb b/lib/gitlab/git/blob.rb index ffe4f3ca95f..ea386c2ddcb 100644 --- a/lib/gitlab/git/blob.rb +++ b/lib/gitlab/git/blob.rb @@ -41,10 +41,6 @@ module Gitlab commit_id: sha ) when :BLOB - # EncodingDetector checks the first 1024 * 1024 bytes for NUL byte, libgit2 checks - # only the first 8000 (https://github.com/libgit2/libgit2/blob/2ed855a9e8f9af211e7274021c2264e600c0f86b/src/filter.h#L15), - # which is what we use below to keep a consistent behavior. - detect = CharlockHolmes::EncodingDetector.new(8000).detect(entry.data) new( id: entry.oid, name: name, @@ -53,7 +49,7 @@ module Gitlab mode: entry.mode.to_s(8), path: path, commit_id: sha, - binary: detect && detect[:type] == :binary + binary: binary?(entry.data) ) end end @@ -87,14 +83,28 @@ module Gitlab end def raw(repository, sha) - blob = repository.lookup(sha) + Gitlab::GitalyClient.migrate(:git_blob_raw) do |is_enabled| + if is_enabled + Gitlab::GitalyClient::Blob.new(repository).get_blob(oid: sha, limit: MAX_DATA_DISPLAY_SIZE) + else + blob = repository.lookup(sha) + + new( + id: blob.oid, + size: blob.size, + data: blob.content(MAX_DATA_DISPLAY_SIZE), + binary: blob.binary? + ) + end + end + end - new( - id: blob.oid, - size: blob.size, - data: blob.content(MAX_DATA_DISPLAY_SIZE), - binary: blob.binary? - ) + def binary?(data) + # EncodingDetector checks the first 1024 * 1024 bytes for NUL byte, libgit2 checks + # only the first 8000 (https://github.com/libgit2/libgit2/blob/2ed855a9e8f9af211e7274021c2264e600c0f86b/src/filter.h#L15), + # which is what we use below to keep a consistent behavior. + detect = CharlockHolmes::EncodingDetector.new(8000).detect(data) + detect && detect[:type] == :binary end # Recursive search of blob id by path @@ -165,8 +175,17 @@ module Gitlab return if @data == '' # don't mess with submodule blobs return @data if @loaded_all_data + Gitlab::GitalyClient.migrate(:git_blob_load_all_data) do |is_enabled| + @data = begin + if is_enabled + Gitlab::GitalyClient::Blob.new(repository).get_blob(oid: id, limit: -1).data + else + repository.lookup(id).content + end + end + end + @loaded_all_data = true - @data = repository.lookup(id).content @loaded_size = @data.bytesize @binary = nil end diff --git a/lib/gitlab/gitaly_client/blob.rb b/lib/gitlab/gitaly_client/blob.rb new file mode 100644 index 00000000000..0c398b46a08 --- /dev/null +++ b/lib/gitlab/gitaly_client/blob.rb @@ -0,0 +1,30 @@ +module Gitlab + module GitalyClient + class Blob + def initialize(repository) + @gitaly_repo = repository.gitaly_repository + end + + def get_blob(oid:, limit:) + request = Gitaly::GetBlobRequest.new( + repository: @gitaly_repo, + oid: oid, + limit: limit + ) + response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_blob, request) + + blob = response.first + return unless blob.oid.present? + + data = response.reduce(blob.data.dup) { |memo, msg| memo << msg.data.dup } + + Gitlab::Git::Blob.new( + id: blob.oid, + size: blob.size, + data: data, + binary: Gitlab::Git::Blob.binary?(data) + ) + end + end + end +end diff --git a/lib/gitlab/otp_key_rotator.rb b/lib/gitlab/otp_key_rotator.rb index 0d541935bc6..22332474945 100644 --- a/lib/gitlab/otp_key_rotator.rb +++ b/lib/gitlab/otp_key_rotator.rb @@ -34,7 +34,7 @@ module Gitlab write_csv do |csv| ActiveRecord::Base.transaction do - User.with_two_factor.in_batches do |relation| + User.with_two_factor.in_batches do |relation| # rubocop: disable Cop/InBatches rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt) rows.each do |row| user = %i[id ciphertext iv salt].zip(row).to_h diff --git a/lib/gitlab/performance_bar.rb b/lib/gitlab/performance_bar.rb index a4c8a77129e..2da2ce45ebc 100644 --- a/lib/gitlab/performance_bar.rb +++ b/lib/gitlab/performance_bar.rb @@ -1,7 +1,33 @@ module Gitlab module PerformanceBar - def self.enabled? - Rails.env.development? || Feature.enabled?('gitlab_performance_bar') + include Gitlab::CurrentSettings + + ALLOWED_USER_IDS_KEY = 'performance_bar_allowed_user_ids'.freeze + + def self.enabled?(user = nil) + return false unless user && allowed_group_id + + allowed_user_ids.include?(user.id) + end + + def self.allowed_group_id + current_application_settings.performance_bar_allowed_group_id + end + + def self.allowed_user_ids + Rails.cache.fetch(ALLOWED_USER_IDS_KEY) do + group = Group.find_by_id(allowed_group_id) + + if group + GroupMembersFinder.new(group).execute.pluck(:user_id) + else + [] + end + end + end + + def self.expire_allowed_user_ids_cache + Rails.cache.delete(ALLOWED_USER_IDS_KEY) end end end diff --git a/lib/gitlab/routing.rb b/lib/gitlab/routing.rb index 632e2d87500..ac1e864433b 100644 --- a/lib/gitlab/routing.rb +++ b/lib/gitlab/routing.rb @@ -2,10 +2,30 @@ module Gitlab module Routing extend ActiveSupport::Concern + mattr_accessor :_includers + self._includers = [] + included do + Gitlab::Routing._includers << self include Gitlab::Routing.url_helpers end + def self.add_helpers(mod) + url_helpers.include mod + url_helpers.extend mod + + app_url_helpers = Gitlab::Application.routes.named_routes.url_helpers_module + app_url_helpers.include mod + app_url_helpers.extend mod + + GitlabRoutingHelper.include mod + GitlabRoutingHelper.extend mod + + _includers.each do |klass| + klass.include mod + end + end + # Returns the URL helpers Module. # # This method caches the output as Rails' "url_helpers" method creates an diff --git a/lib/gitlab/slash_commands/presenters/base.rb b/lib/gitlab/slash_commands/presenters/base.rb index 27696436574..e13808a2720 100644 --- a/lib/gitlab/slash_commands/presenters/base.rb +++ b/lib/gitlab/slash_commands/presenters/base.rb @@ -2,7 +2,7 @@ module Gitlab module SlashCommands module Presenters class Base - include Gitlab::Routing.url_helpers + include Gitlab::Routing def initialize(resource = nil) @resource = resource diff --git a/lib/gitlab/url_builder.rb b/lib/gitlab/url_builder.rb index 073af685a09..35792d2d67f 100644 --- a/lib/gitlab/url_builder.rb +++ b/lib/gitlab/url_builder.rb @@ -1,6 +1,6 @@ module Gitlab class UrlBuilder - include Gitlab::Routing.url_helpers + include Gitlab::Routing include GitlabRoutingHelper include ActionView::RecordIdentifier diff --git a/rubocop/cop/in_batches.rb b/rubocop/cop/in_batches.rb new file mode 100644 index 00000000000..c0240187e66 --- /dev/null +++ b/rubocop/cop/in_batches.rb @@ -0,0 +1,16 @@ +require_relative '../model_helpers' + +module RuboCop + module Cop + # Cop that prevents the use of `in_batches` + class InBatches < RuboCop::Cop::Cop + MSG = 'Do not use `in_batches`, use `each_batch` from the EachBatch module instead'.freeze + + def on_send(node) + return unless node.children[1] == :in_batches + + add_offense(node, :selector) + end + end + end +end diff --git a/rubocop/rubocop.rb b/rubocop/rubocop.rb index 1e70314598a..f76144275c9 100644 --- a/rubocop/rubocop.rb +++ b/rubocop/rubocop.rb @@ -5,6 +5,7 @@ require_relative 'cop/redirect_with_status' require_relative 'cop/polymorphic_associations' require_relative 'cop/project_path_helper' require_relative 'cop/active_record_dependent' +require_relative 'cop/in_batches' require_relative 'cop/migration/add_column' require_relative 'cop/migration/add_column_with_default_to_large_table' require_relative 'cop/migration/add_concurrent_foreign_key' diff --git a/spec/features/issuables/close_reopen_report_toggle_spec.rb b/spec/features/issuables/close_reopen_report_toggle_spec.rb new file mode 100644 index 00000000000..9a99bb705b7 --- /dev/null +++ b/spec/features/issuables/close_reopen_report_toggle_spec.rb @@ -0,0 +1,116 @@ +require 'spec_helper' + +describe 'Issuables Close/Reopen/Report toggle', :feature do + let(:user) { create(:user) } + + shared_examples 'an issuable close/reopen/report toggle' do + let(:container) { find('.issuable-close-dropdown') } + let(:human_model_name) { issuable.model_name.human.downcase } + + it 'shows toggle' do + expect(page).to have_link("Close #{human_model_name}") + expect(page).to have_selector('.issuable-close-dropdown') + end + + it 'opens a dropdown when toggle is clicked' do + container.find('.dropdown-toggle').click + + expect(container).to have_selector('.dropdown-menu') + expect(container).to have_content("Close #{human_model_name}") + expect(container).to have_content('Report abuse') + expect(container).to have_content("Report #{human_model_name.pluralize} that are abusive, inappropriate or spam.") + expect(container).to have_selector('.close-item.droplab-item-selected') + expect(container).to have_selector('.report-item') + expect(container).not_to have_selector('.report-item.droplab-item-selected') + expect(container).not_to have_selector('.reopen-item') + end + + it 'changes the button when an item is selected' do + button = container.find('.issuable-close-button') + + container.find('.dropdown-toggle').click + container.find('.report-item').click + + expect(container).not_to have_selector('.dropdown-menu') + expect(button).to have_content('Report abuse') + + container.find('.dropdown-toggle').click + container.find('.close-item').click + + expect(button).to have_content("Close #{human_model_name}") + end + end + + context 'on an issue' do + let(:project) { create(:empty_project) } + let(:issuable) { create(:issue, project: project) } + + before do + project.add_master(user) + login_as user + end + + context 'when user has permission to update', :js do + before do + visit project_issue_path(project, issuable) + end + + it_behaves_like 'an issuable close/reopen/report toggle' + end + + context 'when user doesnt have permission to update' do + let(:cant_project) { create(:empty_project) } + let(:cant_issuable) { create(:issue, project: cant_project) } + + before do + cant_project.add_guest(user) + + visit project_issue_path(cant_project, cant_issuable) + end + + it 'only shows the `Report abuse` and `New issue` buttons' do + expect(page).to have_link('Report abuse') + expect(page).to have_link('New issue') + expect(page).not_to have_link('Close issue') + expect(page).not_to have_link('Reopen issue') + expect(page).not_to have_link('Edit') + end + end + end + + context 'on a merge request' do + let(:project) { create(:project) } + let(:issuable) { create(:merge_request, source_project: project) } + + before do + project.add_master(user) + login_as user + end + + context 'when user has permission to update', :js do + before do + visit project_merge_request_path(project, issuable) + end + + it_behaves_like 'an issuable close/reopen/report toggle' + end + + context 'when user doesnt have permission to update' do + let(:cant_project) { create(:project) } + let(:cant_issuable) { create(:merge_request, source_project: cant_project) } + + before do + cant_project.add_reporter(user) + + visit project_merge_request_path(cant_project, cant_issuable) + end + + it 'only shows a `Report abuse` button' do + expect(page).to have_link('Report abuse') + expect(page).not_to have_link('Close merge request') + expect(page).not_to have_link('Reopen merge request') + expect(page).not_to have_link('Edit') + end + end + end +end diff --git a/spec/features/issues/filtered_search/visual_tokens_spec.rb b/spec/features/issues/filtered_search/visual_tokens_spec.rb index 0412c186a90..a15c3d1d447 100644 --- a/spec/features/issues/filtered_search/visual_tokens_spec.rb +++ b/spec/features/issues/filtered_search/visual_tokens_spec.rb @@ -133,7 +133,7 @@ describe 'Visual tokens', js: true, feature: true do describe 'editing milestone token' do before do input_filtered_search('milestone:%10.0 author:none', submit: false) - first('.tokens-container .filtered-search-token').double_click + first('.tokens-container .filtered-search-token').click first('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item') end diff --git a/spec/features/issues/gfm_autocomplete_spec.rb b/spec/features/issues/gfm_autocomplete_spec.rb index 2193c5fd69c..9b4cc653af5 100644 --- a/spec/features/issues/gfm_autocomplete_spec.rb +++ b/spec/features/issues/gfm_autocomplete_spec.rb @@ -14,6 +14,18 @@ feature 'GFM autocomplete', feature: true, js: true do wait_for_requests end + it 'updates issue descripton with GFM reference' do + find('.issuable-edit').click + + find('#issue-description').native.send_keys("@#{user.name[0...3]}") + + find('.atwho-view .cur').trigger('click') + + click_button 'Save changes' + + expect(find('.description')).to have_content(user.to_reference) + end + it 'opens autocomplete menu when field starts with text' do page.within '.timeline-content-form' do find('#note_note').native.send_keys('') diff --git a/spec/features/oauth_login_spec.rb b/spec/features/oauth_login_spec.rb index 1b6d1f3415f..42764e808e6 100644 --- a/spec/features/oauth_login_spec.rb +++ b/spec/features/oauth_login_spec.rb @@ -13,7 +13,7 @@ feature 'OAuth Login', js: true do end providers = [:github, :twitter, :bitbucket, :gitlab, :google_oauth2, - :facebook, :cas3, :auth0] + :facebook, :cas3, :auth0, :authentiq] before(:all) do # The OmniAuth `full_host` parameter doesn't get set correctly (it gets set to something like `http://localhost` diff --git a/spec/features/user_can_display_performance_bar_spec.rb b/spec/features/user_can_display_performance_bar_spec.rb index 8464bf50cac..670e8dda916 100644 --- a/spec/features/user_can_display_performance_bar_spec.rb +++ b/spec/features/user_can_display_performance_bar_spec.rb @@ -33,22 +33,24 @@ describe 'User can display performance bar', :js do end end + let(:group) { create(:group) } + context 'when user is logged-out' do before do visit root_path end - context 'when the gitlab_performance_bar feature is disabled' do + context 'when the performance_bar feature is disabled' do before do - Feature.disable('gitlab_performance_bar') + stub_application_setting(performance_bar_allowed_group_id: nil) end it_behaves_like 'performance bar is disabled' end - context 'when the gitlab_performance_bar feature is enabled' do + context 'when the performance_bar feature is enabled' do before do - Feature.enable('gitlab_performance_bar') + stub_application_setting(performance_bar_allowed_group_id: group.id) end it_behaves_like 'performance bar is disabled' @@ -57,22 +59,25 @@ describe 'User can display performance bar', :js do context 'when user is logged-in' do before do - sign_in(create(:user)) + user = create(:user) + + sign_in(user) + group.add_guest(user) visit root_path end - context 'when the gitlab_performance_bar feature is disabled' do + context 'when the performance_bar feature is disabled' do before do - Feature.disable('gitlab_performance_bar') + stub_application_setting(performance_bar_allowed_group_id: nil) end it_behaves_like 'performance bar is disabled' end - context 'when the gitlab_performance_bar feature is enabled' do + context 'when the performance_bar feature is enabled' do before do - Feature.enable('gitlab_performance_bar') + stub_application_setting(performance_bar_allowed_group_id: group.id) end it_behaves_like 'performance bar is enabled' diff --git a/spec/javascripts/close_reopen_report_toggle_spec.js b/spec/javascripts/close_reopen_report_toggle_spec.js new file mode 100644 index 00000000000..925e959c85a --- /dev/null +++ b/spec/javascripts/close_reopen_report_toggle_spec.js @@ -0,0 +1,270 @@ +import CloseReopenReportToggle from '~/close_reopen_report_toggle'; +import DropLab from '~/droplab/drop_lab'; + +describe('CloseReopenReportToggle', () => { + describe('class constructor', () => { + const dropdownTrigger = {}; + const dropdownList = {}; + const button = {}; + let commentTypeToggle; + + beforeEach(function () { + commentTypeToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + }); + + it('sets .dropdownTrigger', function () { + expect(commentTypeToggle.dropdownTrigger).toBe(dropdownTrigger); + }); + + it('sets .dropdownList', function () { + expect(commentTypeToggle.dropdownList).toBe(dropdownList); + }); + + it('sets .button', function () { + expect(commentTypeToggle.button).toBe(button); + }); + }); + + describe('initDroplab', () => { + let closeReopenReportToggle; + const dropdownList = jasmine.createSpyObj('dropdownList', ['querySelector']); + const dropdownTrigger = {}; + const button = {}; + const reopenItem = {}; + const closeItem = {}; + const config = {}; + + beforeEach(() => { + spyOn(DropLab.prototype, 'init'); + dropdownList.querySelector.and.returnValues(reopenItem, closeItem); + + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + spyOn(closeReopenReportToggle, 'setConfig').and.returnValue(config); + + closeReopenReportToggle.initDroplab(); + }); + + it('sets .reopenItem and .closeItem', () => { + expect(dropdownList.querySelector).toHaveBeenCalledWith('.reopen-item'); + expect(dropdownList.querySelector).toHaveBeenCalledWith('.close-item'); + expect(closeReopenReportToggle.reopenItem).toBe(reopenItem); + expect(closeReopenReportToggle.closeItem).toBe(closeItem); + }); + + it('sets .droplab', () => { + expect(closeReopenReportToggle.droplab).toEqual(jasmine.any(Object)); + }); + + it('calls .setConfig', () => { + expect(closeReopenReportToggle.setConfig).toHaveBeenCalled(); + }); + + it('calls droplab.init', () => { + expect(DropLab.prototype.init).toHaveBeenCalledWith( + dropdownTrigger, + dropdownList, + jasmine.any(Array), + config, + ); + }); + }); + + describe('updateButton', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = {}; + const button = jasmine.createSpyObj('button', ['blur']); + const isClosed = true; + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + spyOn(closeReopenReportToggle, 'toggleButtonType'); + + closeReopenReportToggle.updateButton(isClosed); + }); + + it('calls .toggleButtonType', () => { + expect(closeReopenReportToggle.toggleButtonType).toHaveBeenCalledWith(isClosed); + }); + + it('calls .button.blur', () => { + expect(closeReopenReportToggle.button.blur).toHaveBeenCalled(); + }); + }); + + describe('toggleButtonType', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = {}; + const button = {}; + const isClosed = true; + const showItem = jasmine.createSpyObj('showItem', ['click']); + const hideItem = {}; + showItem.classList = jasmine.createSpyObj('classList', ['add', 'remove']); + hideItem.classList = jasmine.createSpyObj('classList', ['add', 'remove']); + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + spyOn(closeReopenReportToggle, 'getButtonTypes').and.returnValue([showItem, hideItem]); + + closeReopenReportToggle.toggleButtonType(isClosed); + }); + + it('calls .getButtonTypes', () => { + expect(closeReopenReportToggle.getButtonTypes).toHaveBeenCalledWith(isClosed); + }); + + it('removes hide class and add selected class to showItem, opposite for hideItem', () => { + expect(showItem.classList.remove).toHaveBeenCalledWith('hidden'); + expect(showItem.classList.add).toHaveBeenCalledWith('droplab-item-selected'); + expect(hideItem.classList.add).toHaveBeenCalledWith('hidden'); + expect(hideItem.classList.remove).toHaveBeenCalledWith('droplab-item-selected'); + }); + + it('clicks the showItem', () => { + expect(showItem.click).toHaveBeenCalled(); + }); + }); + + describe('getButtonTypes', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = {}; + const button = {}; + const reopenItem = {}; + const closeItem = {}; + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + closeReopenReportToggle.reopenItem = reopenItem; + closeReopenReportToggle.closeItem = closeItem; + }); + + it('returns reopenItem, closeItem if isClosed is true', () => { + const buttonTypes = closeReopenReportToggle.getButtonTypes(true); + + expect(buttonTypes).toEqual([reopenItem, closeItem]); + }); + + it('returns closeItem, reopenItem if isClosed is false', () => { + const buttonTypes = closeReopenReportToggle.getButtonTypes(false); + + expect(buttonTypes).toEqual([closeItem, reopenItem]); + }); + }); + + describe('setDisable', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = jasmine.createSpyObj('button', ['setAttribute', 'removeAttribute']); + const button = jasmine.createSpyObj('button', ['setAttribute', 'removeAttribute']); + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + }); + + it('disable .button and .dropdownTrigger if shouldDisable is true', () => { + closeReopenReportToggle.setDisable(true); + + expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true'); + expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true'); + }); + + it('disable .button and .dropdownTrigger if shouldDisable is undefined', () => { + closeReopenReportToggle.setDisable(); + + expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true'); + expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true'); + }); + + it('enable .button and .dropdownTrigger if shouldDisable is false', () => { + closeReopenReportToggle.setDisable(false); + + expect(button.removeAttribute).toHaveBeenCalledWith('disabled'); + expect(dropdownTrigger.removeAttribute).toHaveBeenCalledWith('disabled'); + }); + }); + + describe('setConfig', () => { + let closeReopenReportToggle; + const dropdownList = {}; + const dropdownTrigger = {}; + const button = {}; + let config; + + beforeEach(() => { + closeReopenReportToggle = new CloseReopenReportToggle({ + dropdownTrigger, + dropdownList, + button, + }); + + config = closeReopenReportToggle.setConfig(); + }); + + it('returns a config object', () => { + expect(config).toEqual({ + InputSetter: [ + { + input: button, + valueAttribute: 'data-text', + inputAttribute: 'data-value', + }, + { + input: button, + valueAttribute: 'data-text', + inputAttribute: 'title', + }, + { + input: button, + valueAttribute: 'data-button-class', + inputAttribute: 'class', + }, + { + input: dropdownTrigger, + valueAttribute: 'data-toggle-class', + inputAttribute: 'class', + }, + { + input: button, + valueAttribute: 'data-url', + inputAttribute: 'href', + }, + { + input: button, + valueAttribute: 'data-method', + inputAttribute: 'data-method', + }, + ], + }); + }); + }); +}); diff --git a/spec/javascripts/issue_spec.js b/spec/javascripts/issue_spec.js index df97a100b0d..0c8c4d2cea6 100644 --- a/spec/javascripts/issue_spec.js +++ b/spec/javascripts/issue_spec.js @@ -1,10 +1,10 @@ /* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */ import Issue from '~/issue'; - +import CloseReopenReportToggle from '~/close_reopen_report_toggle'; import '~/lib/utils/text_utility'; describe('Issue', function() { - let $boxClosed, $boxOpen, $btnClose, $btnReopen; + let $boxClosed, $boxOpen, $btn; preloadFixtures('issues/closed-issue.html.raw'); preloadFixtures('issues/issue-with-task-list.html.raw'); @@ -20,9 +20,7 @@ describe('Issue', function() { function expectIssueState(isIssueOpen) { expectVisibility($boxClosed, !isIssueOpen); expectVisibility($boxOpen, isIssueOpen); - - expectVisibility($btnClose, isIssueOpen); - expectVisibility($btnReopen, !isIssueOpen); + expect($btn).toHaveText(isIssueOpen ? 'Close issue' : 'Reopen issue'); } function expectNewBranchButtonState(isPending, canCreate) { @@ -57,7 +55,7 @@ describe('Issue', function() { } } - function findElements() { + function findElements(isIssueInitiallyOpen) { $boxClosed = $('div.status-box-closed'); expect($boxClosed).toExist(); expect($boxClosed).toHaveText('Closed'); @@ -66,13 +64,9 @@ describe('Issue', function() { expect($boxOpen).toExist(); expect($boxOpen).toHaveText('Open'); - $btnClose = $('.btn-close.btn-grouped'); - expect($btnClose).toExist(); - expect($btnClose).toHaveText('Close issue'); - - $btnReopen = $('.btn-reopen.btn-grouped'); - expect($btnReopen).toExist(); - expect($btnReopen).toHaveText('Reopen issue'); + $btn = $('.js-issuable-close-button'); + expect($btn).toExist(); + expect($btn).toHaveText(isIssueInitiallyOpen ? 'Close issue' : 'Reopen issue'); } describe('task lists', function() { @@ -99,7 +93,6 @@ describe('Issue', function() { function ajaxSpy(req) { if (req.url === this.$triggeredButton.attr('href')) { expect(req.type).toBe('PUT'); - expect(this.$triggeredButton).toHaveProp('disabled', true); expectNewBranchButtonState(true, false); return this.issueStateDeferred; } else if (req.url === Issue.createMrDropdownWrap.dataset.canCreatePath) { @@ -119,10 +112,11 @@ describe('Issue', function() { loadFixtures('issues/closed-issue.html.raw'); } - findElements(); + findElements(isIssueInitiallyOpen); this.issue = new Issue(); expectIssueState(isIssueInitiallyOpen); - this.$triggeredButton = isIssueInitiallyOpen ? $btnClose : $btnReopen; + + this.$triggeredButton = $btn; this.$projectIssuesCounter = $('.issue_counter'); this.$projectIssuesCounter.text('1,001'); @@ -143,7 +137,7 @@ describe('Issue', function() { }); expectIssueState(!isIssueInitiallyOpen); - expect(this.$triggeredButton).toHaveProp('disabled', false); + expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); expect(this.$projectIssuesCounter.text()).toBe(isIssueInitiallyOpen ? '1,000' : '1,002'); expectNewBranchButtonState(false, !isIssueInitiallyOpen); }); @@ -158,7 +152,7 @@ describe('Issue', function() { }); expectIssueState(isIssueInitiallyOpen); - expect(this.$triggeredButton).toHaveProp('disabled', false); + expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); expectErrorMessage(); expect(this.$projectIssuesCounter.text()).toBe('1,001'); expectNewBranchButtonState(false, isIssueInitiallyOpen); @@ -172,7 +166,7 @@ describe('Issue', function() { }); expectIssueState(isIssueInitiallyOpen); - expect(this.$triggeredButton).toHaveProp('disabled', true); + expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull(); expectErrorMessage(); expect(this.$projectIssuesCounter.text()).toBe('1,001'); expectNewBranchButtonState(false, isIssueInitiallyOpen); @@ -195,4 +189,37 @@ describe('Issue', function() { }); }); }); + + describe('units', () => { + describe('class constructor', () => { + it('calls .initCloseReopenReport', () => { + spyOn(Issue.prototype, 'initCloseReopenReport'); + + new Issue(); // eslint-disable-line no-new + + expect(Issue.prototype.initCloseReopenReport).toHaveBeenCalled(); + }); + }); + + describe('initCloseReopenReport', () => { + it('calls .initDroplab', () => { + const container = jasmine.createSpyObj('container', ['querySelector']); + const dropdownTrigger = {}; + const dropdownList = {}; + const button = {}; + + spyOn(document, 'querySelector').and.returnValue(container); + spyOn(CloseReopenReportToggle.prototype, 'initDroplab'); + container.querySelector.and.returnValues(dropdownTrigger, dropdownList, button); + + Issue.prototype.initCloseReopenReport(); + + expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown'); + expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle'); + expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu'); + expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button'); + expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/javascripts/merge_request_spec.js b/spec/javascripts/merge_request_spec.js index f444bcaf847..6ff42e2378d 100644 --- a/spec/javascripts/merge_request_spec.js +++ b/spec/javascripts/merge_request_spec.js @@ -2,10 +2,12 @@ /* global MergeRequest */ import '~/merge_request'; +import CloseReopenReportToggle from '~/close_reopen_report_toggle'; +import IssuablesHelper from '~/helpers/issuables_helper'; (function() { describe('MergeRequest', function() { - return describe('task lists', function() { + describe('task lists', function() { preloadFixtures('merge_requests/merge_request_with_task_list.html.raw'); beforeEach(function() { loadFixtures('merge_requests/merge_request_with_task_list.html.raw'); @@ -27,5 +29,34 @@ import '~/merge_request'; return $('.js-task-list-field').trigger('tasklist:changed'); }); }); + + describe('class constructor', () => { + it('calls .initCloseReopenReport', () => { + spyOn(IssuablesHelper, 'initCloseReopenReport'); + + new MergeRequest(); // eslint-disable-line no-new + + expect(IssuablesHelper.initCloseReopenReport).toHaveBeenCalled(); + }); + + it('calls .initDroplab', () => { + const container = jasmine.createSpyObj('container', ['querySelector']); + const dropdownTrigger = {}; + const dropdownList = {}; + const button = {}; + + spyOn(CloseReopenReportToggle.prototype, 'initDroplab'); + spyOn(document, 'querySelector').and.returnValue(container); + container.querySelector.and.returnValues(dropdownTrigger, dropdownList, button); + + new MergeRequest(); // eslint-disable-line no-new + + expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown'); + expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle'); + expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu'); + expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button'); + expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled(); + }); + }); }); }).call(window); diff --git a/spec/lib/extracts_path_spec.rb b/spec/lib/extracts_path_spec.rb index f2132d485ab..dfffef8b9b7 100644 --- a/spec/lib/extracts_path_spec.rb +++ b/spec/lib/extracts_path_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe ExtractsPath, lib: true do include ExtractsPath include RepoHelpers - include Gitlab::Routing.url_helpers + include Gitlab::Routing let(:project) { double('project') } let(:request) { double('request') } diff --git a/spec/lib/gitlab/git/blob_spec.rb b/spec/lib/gitlab/git/blob_spec.rb index 58d3ee6b488..3c784eda4f8 100644 --- a/spec/lib/gitlab/git/blob_spec.rb +++ b/spec/lib/gitlab/git/blob_spec.rb @@ -111,7 +111,7 @@ describe Gitlab::Git::Blob, seed_helper: true do end end - describe '.raw' do + shared_examples 'finding blobs by ID' do let(:raw_blob) { Gitlab::Git::Blob.raw(repository, SeedRepo::RubyBlob::ID) } it { expect(raw_blob.id).to eq(SeedRepo::RubyBlob::ID) } it { expect(raw_blob.data[0..10]).to eq("require \'fi") } @@ -136,6 +136,16 @@ describe Gitlab::Git::Blob, seed_helper: true do end end + describe '.raw' do + context 'when the blob_raw Gitaly feature is enabled' do + it_behaves_like 'finding blobs by ID' + end + + context 'when the blob_raw Gitaly feature is disabled', skip_gitaly_mock: true do + it_behaves_like 'finding blobs by ID' + end + end + describe 'encoding' do context 'file with russian text' do let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "encoding/russian.rb") } diff --git a/spec/lib/gitlab/performance_bar_spec.rb b/spec/lib/gitlab/performance_bar_spec.rb new file mode 100644 index 00000000000..8a586bdbf63 --- /dev/null +++ b/spec/lib/gitlab/performance_bar_spec.rb @@ -0,0 +1,92 @@ +require 'spec_helper' + +describe Gitlab::PerformanceBar do + shared_examples 'allowed user IDs are cached' do + before do + # Warm the Redis cache + described_class.enabled?(user) + end + + it 'caches the allowed user IDs in cache', :caching do + expect do + expect(described_class.enabled?(user)).to be_truthy + end.not_to exceed_query_limit(0) + end + end + + describe '.enabled?' do + let(:user) { create(:user) } + + before do + stub_application_setting(performance_bar_allowed_group_id: -1) + end + + it 'returns false when given user is nil' do + expect(described_class.enabled?(nil)).to be_falsy + end + + it 'returns false when allowed_group_id is nil' do + expect(described_class).to receive(:allowed_group_id).and_return(nil) + + expect(described_class.enabled?(user)).to be_falsy + end + + context 'when allowed group ID does not exist' do + it 'returns false' do + expect(described_class.enabled?(user)).to be_falsy + end + end + + context 'when allowed group exists' do + let!(:my_group) { create(:group, path: 'my-group') } + + before do + stub_application_setting(performance_bar_allowed_group_id: my_group.id) + end + + context 'when user is not a member of the allowed group' do + it 'returns false' do + expect(described_class.enabled?(user)).to be_falsy + end + + it_behaves_like 'allowed user IDs are cached' + end + + context 'when user is a member of the allowed group' do + before do + my_group.add_developer(user) + end + + it 'returns true' do + expect(described_class.enabled?(user)).to be_truthy + end + + it_behaves_like 'allowed user IDs are cached' + end + end + + context 'when allowed group is nested', :nested_groups do + let!(:nested_my_group) { create(:group, parent: create(:group, path: 'my-org'), path: 'my-group') } + + before do + create(:group, path: 'my-group') + nested_my_group.add_developer(user) + stub_application_setting(performance_bar_allowed_group_id: nested_my_group.id) + end + + it 'returns the nested group' do + expect(described_class.enabled?(user)).to be_truthy + end + end + + context 'when a nested group has the same path', :nested_groups do + before do + create(:group, :nested, path: 'my-group').add_developer(user) + end + + it 'returns false' do + expect(described_class.enabled?(user)).to be_falsy + end + end + end +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 166a4474abf..fb485d0b2c6 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -214,6 +214,160 @@ describe ApplicationSetting, models: true do end end + describe 'performance bar settings' do + describe 'performance_bar_allowed_group_id=' do + context 'with a blank path' do + before do + setting.performance_bar_allowed_group_id = create(:group).full_path + end + + it 'persists nil for a "" path and clears allowed user IDs cache' do + expect(Gitlab::PerformanceBar).to receive(:expire_allowed_user_ids_cache) + + setting.performance_bar_allowed_group_id = '' + + expect(setting.performance_bar_allowed_group_id).to be_nil + end + end + + context 'with an invalid path' do + it 'does not persist an invalid group path' do + setting.performance_bar_allowed_group_id = 'foo' + + expect(setting.performance_bar_allowed_group_id).to be_nil + end + end + + context 'with a path to an existing group' do + let(:group) { create(:group) } + + it 'persists a valid group path and clears allowed user IDs cache' do + expect(Gitlab::PerformanceBar).to receive(:expire_allowed_user_ids_cache) + + setting.performance_bar_allowed_group_id = group.full_path + + expect(setting.performance_bar_allowed_group_id).to eq(group.id) + end + + context 'when the given path is the same' do + context 'with a blank path' do + before do + setting.performance_bar_allowed_group_id = nil + end + + it 'clears the cached allowed user IDs' do + expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache) + + setting.performance_bar_allowed_group_id = '' + end + end + + context 'with a valid path' do + before do + setting.performance_bar_allowed_group_id = group.full_path + end + + it 'clears the cached allowed user IDs' do + expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache) + + setting.performance_bar_allowed_group_id = group.full_path + end + end + end + end + end + + describe 'performance_bar_allowed_group' do + context 'with no performance_bar_allowed_group_id saved' do + it 'returns nil' do + expect(setting.performance_bar_allowed_group).to be_nil + end + end + + context 'with a performance_bar_allowed_group_id saved' do + let(:group) { create(:group) } + + before do + setting.performance_bar_allowed_group_id = group.full_path + end + + it 'returns the group' do + expect(setting.performance_bar_allowed_group).to eq(group) + end + end + end + + describe 'performance_bar_enabled' do + context 'with the Performance Bar is enabled' do + let(:group) { create(:group) } + + before do + setting.performance_bar_allowed_group_id = group.full_path + end + + it 'returns true' do + expect(setting.performance_bar_enabled).to be_truthy + end + end + end + + describe 'performance_bar_enabled=' do + context 'when the performance bar is enabled' do + let(:group) { create(:group) } + + before do + setting.performance_bar_allowed_group_id = group.full_path + end + + context 'when passing true' do + it 'does not clear allowed user IDs cache' do + expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache) + + setting.performance_bar_enabled = true + + expect(setting.performance_bar_allowed_group_id).to eq(group.id) + expect(setting.performance_bar_enabled).to be_truthy + end + end + + context 'when passing false' do + it 'disables the performance bar and clears allowed user IDs cache' do + expect(Gitlab::PerformanceBar).to receive(:expire_allowed_user_ids_cache) + + setting.performance_bar_enabled = false + + expect(setting.performance_bar_allowed_group_id).to be_nil + expect(setting.performance_bar_enabled).to be_falsey + end + end + end + + context 'when the performance bar is disabled' do + context 'when passing true' do + it 'does nothing and does not clear allowed user IDs cache' do + expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache) + + setting.performance_bar_enabled = true + + expect(setting.performance_bar_allowed_group_id).to be_nil + expect(setting.performance_bar_enabled).to be_falsey + end + end + + context 'when passing false' do + it 'does nothing and does not clear allowed user IDs cache' do + expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache) + + setting.performance_bar_enabled = false + + expect(setting.performance_bar_allowed_group_id).to be_nil + expect(setting.performance_bar_enabled).to be_falsey + end + end + end + end + end + describe 'usage ping settings' do context 'when the usage ping is disabled in gitlab.yml' do before do diff --git a/spec/models/concerns/each_batch_spec.rb b/spec/models/concerns/each_batch_spec.rb new file mode 100644 index 00000000000..951690a217b --- /dev/null +++ b/spec/models/concerns/each_batch_spec.rb @@ -0,0 +1,53 @@ +require 'spec_helper' + +describe EachBatch do + describe '.each_batch' do + let(:model) do + Class.new(ActiveRecord::Base) do + include EachBatch + + self.table_name = 'users' + end + end + + before do + 5.times { create(:user, updated_at: 1.day.ago) } + end + + it 'yields an ActiveRecord::Relation when a block is given' do + model.each_batch do |relation| + expect(relation).to be_a_kind_of(ActiveRecord::Relation) + end + end + + it 'yields a batch index as the second argument' do + model.each_batch do |_, index| + expect(index).to eq(1) + end + end + + it 'accepts a custom batch size' do + amount = 0 + + model.each_batch(of: 1) { amount += 1 } + + expect(amount).to eq(5) + end + + it 'does not include ORDER BYs in the yielded relations' do + model.each_batch do |relation| + expect(relation.to_sql).not_to include('ORDER BY') + end + end + + it 'allows updating of the yielded relations' do + time = Time.now + + model.each_batch do |relation| + relation.update_all(updated_at: time) + end + + expect(model.where(updated_at: time).count).to eq(5) + end + end +end diff --git a/spec/models/concerns/sha_attribute_spec.rb b/spec/models/concerns/sha_attribute_spec.rb index 610793ee557..21893e0cbaa 100644 --- a/spec/models/concerns/sha_attribute_spec.rb +++ b/spec/models/concerns/sha_attribute_spec.rb @@ -13,7 +13,7 @@ describe ShaAttribute do end describe '#sha_attribute' do - context' when the table exists' do + context 'when the table exists' do before do allow(model).to receive(:table_exists?).and_return(true) end @@ -30,7 +30,7 @@ describe ShaAttribute do end end - context' when the table does not exist' do + context 'when the table does not exist' do before do allow(model).to receive(:table_exists?).and_return(false) end diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index 399020953e8..066d7b9307f 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -466,13 +466,13 @@ describe Group, models: true do it_behaves_like 'ref is protected' end - context 'when group has children' do - let!(:group_child) { create(:group, parent: group) } - let!(:variable_child) { create(:ci_group_variable, group: group_child) } - let!(:group_child_3) { create(:group, parent: group_child_2) } - let!(:variable_child_3) { create(:ci_group_variable, group: group_child_3) } - let!(:group_child_2) { create(:group, parent: group_child) } - let!(:variable_child_2) { create(:ci_group_variable, group: group_child_2) } + context 'when group has children', :postgresql do + let(:group_child) { create(:group, parent: group) } + let(:group_child_2) { create(:group, parent: group_child) } + let(:group_child_3) { create(:group, parent: group_child_2) } + let(:variable_child) { create(:ci_group_variable, group: group_child) } + let(:variable_child_2) { create(:ci_group_variable, group: group_child_2) } + let(:variable_child_3) { create(:ci_group_variable, group: group_child_3) } it 'returns all variables belong to the group and parent groups' do expected_array1 = [protected_variable, secret_variable] diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 4a1de76f099..105afed1337 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe JiraService, models: true do - include Gitlab::Routing.url_helpers + include Gitlab::Routing describe "Associations" do it { is_expected.to belong_to :project } diff --git a/spec/requests/api/features_spec.rb b/spec/requests/api/features_spec.rb index 1d8aaeea8f2..7e21006b254 100644 --- a/spec/requests/api/features_spec.rb +++ b/spec/requests/api/features_spec.rb @@ -113,6 +113,20 @@ describe API::Features do { 'key' => 'actors', 'value' => ["User:#{user.id}"] } ]) end + + it 'creates an enabled feature for the given user and feature group when passed user=username and feature_group=perf_team' do + post api("/features/#{feature_name}", admin), value: 'true', user: user.username, feature_group: 'perf_team' + + expect(response).to have_http_status(201) + expect(json_response).to eq( + 'name' => 'my_feature', + 'state' => 'conditional', + 'gates' => [ + { 'key' => 'boolean', 'value' => false }, + { 'key' => 'groups', 'value' => ['perf_team'] }, + { 'key' => 'actors', 'value' => ["User:#{user.id}"] } + ]) + end end it 'creates a feature with the given percentage if passed an integer' do diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 9b53164b4a2..9837fedb522 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -1462,6 +1462,25 @@ describe API::Issues do end end + describe "GET /projects/:id/issues/:issue_iid/user_agent_detail" do + let!(:user_agent_detail) { create(:user_agent_detail, subject: issue) } + + it 'exposes known attributes' do + get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", admin) + + expect(response).to have_http_status(200) + expect(json_response['user_agent']).to eq(user_agent_detail.user_agent) + expect(json_response['ip_address']).to eq(user_agent_detail.ip_address) + expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted) + end + + it "returns unautorized for non-admin users" do + get api("/projects/#{project.id}/issues/#{issue.iid}/user_agent_detail", user) + + expect(response).to have_http_status(403) + end + end + def expect_paginated_array_response(size: nil) expect(response).to have_http_status(200) expect(response).to include_pagination_headers diff --git a/spec/requests/api/project_snippets_spec.rb b/spec/requests/api/project_snippets_spec.rb index 518639f45a2..f220972bae3 100644 --- a/spec/requests/api/project_snippets_spec.rb +++ b/spec/requests/api/project_snippets_spec.rb @@ -5,6 +5,26 @@ describe API::ProjectSnippets do let(:user) { create(:user) } let(:admin) { create(:admin) } + describe "GET /projects/:project_id/snippets/:id/user_agent_detail" do + let(:snippet) { create(:project_snippet, :public, project: project) } + let!(:user_agent_detail) { create(:user_agent_detail, subject: snippet) } + + it 'exposes known attributes' do + get api("/projects/#{project.id}/snippets/#{snippet.id}/user_agent_detail", admin) + + expect(response).to have_http_status(200) + expect(json_response['user_agent']).to eq(user_agent_detail.user_agent) + expect(json_response['ip_address']).to eq(user_agent_detail.ip_address) + expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted) + end + + it "returns unautorized for non-admin users" do + get api("/projects/#{snippet.project.id}/snippets/#{snippet.id}/user_agent_detail", user) + + expect(response).to have_http_status(403) + end + end + describe 'GET /projects/:project_id/snippets/' do let(:user) { create(:user) } @@ -20,7 +40,7 @@ describe API::ProjectSnippets do expect(response).to include_pagination_headers expect(json_response).to be_an Array expect(json_response.size).to eq(3) - expect(json_response.map{ |snippet| snippet['id']} ).to include(public_snippet.id, internal_snippet.id, private_snippet.id) + expect(json_response.map { |snippet| snippet['id'] }).to include(public_snippet.id, internal_snippet.id, private_snippet.id) expect(json_response.last).to have_key('web_url') end @@ -38,7 +58,7 @@ describe API::ProjectSnippets do describe 'GET /projects/:project_id/snippets/:id' do let(:user) { create(:user) } - let(:snippet) { create(:project_snippet, :public, project: project) } + let(:snippet) { create(:project_snippet, :public, project: project) } it 'returns snippet json' do get api("/projects/#{project.id}/snippets/#{snippet.id}", user) diff --git a/spec/requests/api/snippets_spec.rb b/spec/requests/api/snippets_spec.rb index b20a187acfe..373fab4d98a 100644 --- a/spec/requests/api/snippets_spec.rb +++ b/spec/requests/api/snippets_spec.rb @@ -271,4 +271,25 @@ describe API::Snippets do expect(json_response['message']).to eq('404 Snippet Not Found') end end + + describe "GET /snippets/:id/user_agent_detail" do + let(:admin) { create(:admin) } + let(:snippet) { create(:personal_snippet, :public, author: user) } + let!(:user_agent_detail) { create(:user_agent_detail, subject: snippet) } + + it 'exposes known attributes' do + get api("/snippets/#{snippet.id}/user_agent_detail", admin) + + expect(response).to have_http_status(200) + expect(json_response['user_agent']).to eq(user_agent_detail.user_agent) + expect(json_response['ip_address']).to eq(user_agent_detail.ip_address) + expect(json_response['akismet_submitted']).to eq(user_agent_detail.submitted) + end + + it "returns unautorized for non-admin users" do + get api("/snippets/#{snippet.id}/user_agent_detail", user) + + expect(response).to have_http_status(403) + end + end end diff --git a/spec/rubocop/cop/in_batches_spec.rb b/spec/rubocop/cop/in_batches_spec.rb new file mode 100644 index 00000000000..072481984c6 --- /dev/null +++ b/spec/rubocop/cop/in_batches_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' +require 'rubocop' +require 'rubocop/rspec/support' +require_relative '../../../rubocop/cop/in_batches' + +describe RuboCop::Cop::InBatches do + include CopHelper + + subject(:cop) { described_class.new } + + it 'registers an offense when in_batches is used' do + inspect_source(cop, 'foo.in_batches do; end') + + aggregate_failures do + expect(cop.offenses.size).to eq(1) + expect(cop.offenses.map(&:line)).to eq([1]) + end + end +end diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 671a932441e..74dcf152cb8 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -98,18 +98,52 @@ describe MergeRequests::RefreshService, services: true do end context 'push to origin repo target branch' do - before do - service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature') - reload_mrs + context 'when all MRs to the target branch had diffs' do + before do + service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature') + reload_mrs + end + + it 'updates the merge state' do + expect(@merge_request.notes.last.note).to include('merged') + expect(@merge_request).to be_merged + expect(@fork_merge_request).to be_merged + expect(@fork_merge_request.notes.last.note).to include('merged') + expect(@build_failed_todo).to be_done + expect(@fork_build_failed_todo).to be_done + end end - it 'updates the merge state' do - expect(@merge_request.notes.last.note).to include('merged') - expect(@merge_request).to be_merged - expect(@fork_merge_request).to be_merged - expect(@fork_merge_request.notes.last.note).to include('merged') - expect(@build_failed_todo).to be_done - expect(@fork_build_failed_todo).to be_done + context 'when an MR to be closed was empty already' do + let!(:empty_fork_merge_request) do + create(:merge_request, + source_project: @fork_project, + source_branch: 'master', + target_branch: 'master', + target_project: @project) + end + + before do + # This spec already has a fake push, so pretend that we were targeting + # feature all along. + empty_fork_merge_request.update_columns(target_branch: 'feature') + + service.new(@project, @user).execute(@oldrev, @newrev, 'refs/heads/feature') + reload_mrs + empty_fork_merge_request.reload + end + + it 'only updates the non-empty MRs' do + expect(@merge_request).to be_merged + expect(@merge_request.notes.last.note).to include('merged') + + expect(@fork_merge_request).to be_merged + expect(@fork_merge_request.notes.last.note).to include('merged') + + expect(empty_fork_merge_request).to be_open + expect(empty_fork_merge_request.merge_request_diff.state).to eq('empty') + expect(empty_fork_merge_request.notes).to be_empty + end end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index e35e4c1d631..60477b8e9ba 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -1,7 +1,7 @@ require 'spec_helper' describe SystemNoteService, services: true do - include Gitlab::Routing.url_helpers + include Gitlab::Routing let(:project) { create(:empty_project) } let(:author) { create(:user) } diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 3e90a642d56..a497b8613bb 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -57,7 +57,7 @@ RSpec.configure do |config| config.include StubGitlabCalls config.include StubGitlabData config.include ApiHelpers, :api - config.include Gitlab::Routing.url_helpers, type: :routing + config.include Gitlab::Routing, type: :routing config.include MigrationsHelpers, :migration config.infer_spec_type_from_file_location! |