From f63ab77009d61857249bf087e284e493ae08a30f Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 12 Oct 2017 15:38:10 -0500 Subject: [PATCH 001/112] remove commits.js import form main.js (related to !14735) --- app/assets/javascripts/main.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index dcc0fa63b63..23ecce78bb9 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -40,7 +40,6 @@ import './admin'; import './aside'; import loadAwardsHandler from './awards_handler'; import bp from './breakpoints'; -import './commits'; import './compare'; import './compare_autocomplete'; import './confirm_danger_modal'; From 0ebf577ac90f1d1c16db6097259cc083a4b246b9 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 12 Oct 2017 15:42:16 -0500 Subject: [PATCH 002/112] refactor Compare class to ES class syntax --- app/assets/javascripts/compare.js | 34 +++++++++++++++---------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/app/assets/javascripts/compare.js b/app/assets/javascripts/compare.js index 9e5dbd64a7e..1f17af3b6fc 100644 --- a/app/assets/javascripts/compare.js +++ b/app/assets/javascripts/compare.js @@ -1,7 +1,7 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, object-shorthand, consistent-return, no-unused-vars, comma-dangle, vars-on-top, prefer-template, max-len */ -window.Compare = (function() { - function Compare(opts) { +window.Compare = class Compare { + constructor(opts) { this.opts = opts; this.source_loading = $(".js-source-loading"); this.target_loading = $(".js-target-loading"); @@ -34,12 +34,12 @@ window.Compare = (function() { this.initialState(); } - Compare.prototype.initialState = function() { + initialState() { this.getSourceHtml(); - return this.getTargetHtml(); - }; + this.getTargetHtml(); + } - Compare.prototype.getTargetProject = function() { + getTargetProject() { return $.ajax({ url: this.opts.targetProjectUrl, data: { @@ -52,22 +52,22 @@ window.Compare = (function() { return $('.js-target-branch-dropdown .dropdown-content').html(html); } }); - }; + } - Compare.prototype.getSourceHtml = function() { - return this.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', { + getSourceHtml() { + return this.constructor.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', { ref: $("input[name='merge_request[source_branch]']").val() }); - }; + } - Compare.prototype.getTargetHtml = function() { - return this.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', { + getTargetHtml() { + return this.constructor.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', { target_project_id: $("input[name='merge_request[target_project_id]']").val(), ref: $("input[name='merge_request[target_branch]']").val() }); - }; + } - Compare.prototype.sendAjax = function(url, loading, target, data) { + static sendAjax(url, loading, target, data) { var $target; $target = $(target); return $.ajax({ @@ -84,7 +84,5 @@ window.Compare = (function() { gl.utils.localTimeAgo($('.js-timeago', className)); } }); - }; - - return Compare; -})(); + } +}; From 9f51a70e176d881d93474201c1444a4be1f2e7e0 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 12 Oct 2017 15:47:15 -0500 Subject: [PATCH 003/112] convert Compare class into ES module import/export syntax --- app/assets/javascripts/compare.js | 4 ++-- app/assets/javascripts/dispatcher.js | 2 +- app/assets/javascripts/main.js | 1 - 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/assets/javascripts/compare.js b/app/assets/javascripts/compare.js index 1f17af3b6fc..0ce467a3bd4 100644 --- a/app/assets/javascripts/compare.js +++ b/app/assets/javascripts/compare.js @@ -1,6 +1,6 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, object-shorthand, consistent-return, no-unused-vars, comma-dangle, vars-on-top, prefer-template, max-len */ -window.Compare = class Compare { +export default class Compare { constructor(opts) { this.opts = opts; this.source_loading = $(".js-source-loading"); @@ -85,4 +85,4 @@ window.Compare = class Compare { } }); } -}; +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index a21c92f24d6..c01d7dd4114 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -22,7 +22,7 @@ import NewCommitForm from './new_commit_form'; import Project from './project'; import projectAvatar from './project_avatar'; /* global MergeRequest */ -/* global Compare */ +import Compare from './compare'; /* global CompareAutocomplete */ /* global ProjectFindFile */ import ProjectNew from './project_new'; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 23ecce78bb9..ad86b6929d4 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -40,7 +40,6 @@ import './admin'; import './aside'; import loadAwardsHandler from './awards_handler'; import bp from './breakpoints'; -import './compare'; import './compare_autocomplete'; import './confirm_danger_modal'; import Flash, { removeFlashClickListener } from './flash'; From 8c64e8b4b6f34418bb3a41c1561bf1d73a8b19d8 Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 12 Oct 2017 15:57:29 -0500 Subject: [PATCH 004/112] convert CompareAutocomplete from class definition into simple function call, remove global export --- .../javascripts/compare_autocomplete.js | 116 ++++++++---------- app/assets/javascripts/dispatcher.js | 4 +- app/assets/javascripts/main.js | 1 - 3 files changed, 56 insertions(+), 65 deletions(-) diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 72c0d98d47c..079246b33f0 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -1,68 +1,60 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, object-shorthand, comma-dangle, prefer-arrow-callback, no-else-return, newline-per-chained-call, wrap-iife, max-len */ -window.CompareAutocomplete = (function() { - function CompareAutocomplete() { - this.initDropdown(); - } - - CompareAutocomplete.prototype.initDropdown = function() { - return $('.js-compare-dropdown').each(function() { - var $dropdown, selected; - $dropdown = $(this); - selected = $dropdown.data('selected'); - const $dropdownContainer = $dropdown.closest('.dropdown'); - const $fieldInput = $(`input[name="${$dropdown.data('field-name')}"]`, $dropdownContainer); - const $filterInput = $('input[type="search"]', $dropdownContainer); - $dropdown.glDropdown({ - data: function(term, callback) { - return $.ajax({ - url: $dropdown.data('refs-url'), - data: { - ref: $dropdown.data('ref'), - search: term, - } - }).done(function(refs) { - return callback(refs); - }); - }, - selectable: true, - filterable: true, - filterRemote: true, - fieldName: $dropdown.data('field-name'), - filterInput: 'input[type="search"]', - renderRow: function(ref) { - var link; - if (ref.header != null) { - return $('
  • ').addClass('dropdown-header').text(ref.header); - } else { - link = $('').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref)); - return $('
  • ').append(link); +export default function initCompareAutocomplete() { + $('.js-compare-dropdown').each(() => { + var $dropdown, selected; + $dropdown = $(this); + selected = $dropdown.data('selected'); + const $dropdownContainer = $dropdown.closest('.dropdown'); + const $fieldInput = $(`input[name="${$dropdown.data('field-name')}"]`, $dropdownContainer); + const $filterInput = $('input[type="search"]', $dropdownContainer); + $dropdown.glDropdown({ + data: function(term, callback) { + return $.ajax({ + url: $dropdown.data('refs-url'), + data: { + ref: $dropdown.data('ref'), + search: term, } - }, - id: function(obj, $el) { - return $el.attr('data-ref'); - }, - toggleLabel: function(obj, $el) { - return $el.text().trim(); + }).done(function(refs) { + return callback(refs); + }); + }, + selectable: true, + filterable: true, + filterRemote: true, + fieldName: $dropdown.data('field-name'), + filterInput: 'input[type="search"]', + renderRow: function(ref) { + var link; + if (ref.header != null) { + return $('
  • ').addClass('dropdown-header').text(ref.header); + } else { + link = $('').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref)); + return $('
  • ').append(link); } - }); - $filterInput.on('keyup', (e) => { - const keyCode = e.keyCode || e.which; - if (keyCode !== 13) return; - const text = $filterInput.val(); - $fieldInput.val(text); - $('.dropdown-toggle-text', $dropdown).text(text); - $dropdownContainer.removeClass('open'); - }); - - $dropdownContainer.on('click', '.dropdown-content a', (e) => { - $dropdown.prop('title', e.target.text.replace(/_+?/g, '-')); - if ($dropdown.hasClass('has-tooltip')) { - $dropdown.tooltip('fixTitle'); - } - }); + }, + id: function(obj, $el) { + return $el.attr('data-ref'); + }, + toggleLabel: function(obj, $el) { + return $el.text().trim(); + } + }); + $filterInput.on('keyup', (e) => { + const keyCode = e.keyCode || e.which; + if (keyCode !== 13) return; + const text = $filterInput.val(); + $fieldInput.val(text); + $('.dropdown-toggle-text', $dropdown).text(text); + $dropdownContainer.removeClass('open'); }); - }; - return CompareAutocomplete; -})(); + $dropdownContainer.on('click', '.dropdown-content a', (e) => { + $dropdown.prop('title', e.target.text.replace(/_+?/g, '-')); + if ($dropdown.hasClass('has-tooltip')) { + $dropdown.tooltip('fixTitle'); + } + }); + }); +} diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index c01d7dd4114..acc907085a3 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -23,7 +23,7 @@ import Project from './project'; import projectAvatar from './project_avatar'; /* global MergeRequest */ import Compare from './compare'; -/* global CompareAutocomplete */ +import initCompareAutocomplete from './compare_autocomplete'; /* global ProjectFindFile */ import ProjectNew from './project_new'; import projectImport from './project_import'; @@ -617,7 +617,7 @@ import ProjectVariables from './project_variables'; projectAvatar(); switch (path[1]) { case 'compare': - new CompareAutocomplete(); + initCompareAutocomplete(); break; case 'edit': shortcut_handler = new ShortcutsNavigation(); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index ad86b6929d4..93d85d6dc5c 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -40,7 +40,6 @@ import './admin'; import './aside'; import loadAwardsHandler from './awards_handler'; import bp from './breakpoints'; -import './compare_autocomplete'; import './confirm_danger_modal'; import Flash, { removeFlashClickListener } from './flash'; import './gl_dropdown'; From 0885126a3bff39251b529a3662e37f970d3a901a Mon Sep 17 00:00:00 2001 From: Mike Greiling Date: Thu, 12 Oct 2017 22:51:09 -0500 Subject: [PATCH 005/112] revert arrow function change --- app/assets/javascripts/compare_autocomplete.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/javascripts/compare_autocomplete.js b/app/assets/javascripts/compare_autocomplete.js index 079246b33f0..e633ef8a29e 100644 --- a/app/assets/javascripts/compare_autocomplete.js +++ b/app/assets/javascripts/compare_autocomplete.js @@ -1,7 +1,7 @@ /* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, object-shorthand, comma-dangle, prefer-arrow-callback, no-else-return, newline-per-chained-call, wrap-iife, max-len */ export default function initCompareAutocomplete() { - $('.js-compare-dropdown').each(() => { + $('.js-compare-dropdown').each(function() { var $dropdown, selected; $dropdown = $(this); selected = $dropdown.data('selected'); From 89d3fcd19346b9884140652a5ef13dd8744c3c43 Mon Sep 17 00:00:00 2001 From: bikebilly Date: Mon, 4 Dec 2017 12:27:21 +0100 Subject: [PATCH 006/112] Remove Auto DevOps checkboxes - backend --- .../projects/pipelines_settings_controller.rb | 1 - app/helpers/auto_devops_helper.rb | 16 --- app/services/projects/update_service.rb | 10 +- .../pipelines_settings_controller_spec.rb | 15 +-- spec/helpers/auto_devops_helper_spec.rb | 100 ------------------ spec/services/projects/update_service_spec.rb | 45 ++++++-- 6 files changed, 50 insertions(+), 137 deletions(-) diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index b890818c475..06ce7328fb5 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -29,7 +29,6 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController :runners_token, :builds_enabled, :build_allow_git_fetch, :build_timeout_in_minutes, :build_coverage_regex, :public_builds, :auto_cancel_pending_pipelines, :ci_config_path, - :run_auto_devops_pipeline_implicit, :run_auto_devops_pipeline_explicit, auto_devops_attributes: [:id, :domain, :enabled] ) end diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index ec6194d204f..f4310ca2f06 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -8,22 +8,6 @@ module AutoDevopsHelper !project.ci_service end - def show_run_auto_devops_pipeline_checkbox_for_instance_setting?(project) - return false if project.repository.gitlab_ci_yml - - if project&.auto_devops&.enabled.present? - !project.auto_devops.enabled && current_application_settings.auto_devops_enabled? - else - current_application_settings.auto_devops_enabled? - end - end - - def show_run_auto_devops_pipeline_checkbox_for_explicit_setting?(project) - return false if project.repository.gitlab_ci_yml - - !project.auto_devops_enabled? - end - def auto_devops_warning_message(project) missing_domain = !project.auto_devops&.has_domain? missing_service = !project.deployment_platform&.active? diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 72eecc61c96..ff4c73c886e 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -15,7 +15,7 @@ module Projects return error("Could not set the default branch") unless project.change_head(params[:default_branch]) end - if project.update_attributes(update_params) + if project.update_attributes(params.except(:default_branch)) if project.previous_changes.include?('path') project.rename_repo else @@ -32,15 +32,13 @@ module Projects end def run_auto_devops_pipeline? - params.dig(:run_auto_devops_pipeline_explicit) == 'true' || params.dig(:run_auto_devops_pipeline_implicit) == 'true' + return false if project.repository.gitlab_ci_yml || !project.auto_devops.previous_changes.include?('enabled') + + project.auto_devops.enabled? || (project.auto_devops.enabled.nil? && current_application_settings.auto_devops_enabled?) end private - def update_params - params.except(:default_branch, :run_auto_devops_pipeline_explicit, :run_auto_devops_pipeline_implicit) - end - def renaming_project_with_container_registry_tags? new_path = params[:path] diff --git a/spec/controllers/projects/pipelines_settings_controller_spec.rb b/spec/controllers/projects/pipelines_settings_controller_spec.rb index b2d83a02290..1cc488bef32 100644 --- a/spec/controllers/projects/pipelines_settings_controller_spec.rb +++ b/spec/controllers/projects/pipelines_settings_controller_spec.rb @@ -16,14 +16,13 @@ describe Projects::PipelinesSettingsController do patch :update, namespace_id: project.namespace.to_param, project_id: project, - project: { auto_devops_attributes: params, - run_auto_devops_pipeline_implicit: 'false', - run_auto_devops_pipeline_explicit: auto_devops_pipeline } + project: { + auto_devops_attributes: params + } end context 'when updating the auto_devops settings' do let(:params) { { enabled: '', domain: 'mepmep.md' } } - let(:auto_devops_pipeline) { 'false' } it 'redirects to the settings page' do subject @@ -44,7 +43,9 @@ describe Projects::PipelinesSettingsController do end context 'when run_auto_devops_pipeline is true' do - let(:auto_devops_pipeline) { 'true' } + before do + expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(true) + end it 'queues a CreatePipelineWorker' do expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) @@ -54,7 +55,9 @@ describe Projects::PipelinesSettingsController do end context 'when run_auto_devops_pipeline is not true' do - let(:auto_devops_pipeline) { 'false' } + before do + expect_any_instance_of(Projects::UpdateService).to receive(:run_auto_devops_pipeline?).and_return(false) + end it 'does not queue a CreatePipelineWorker' do expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, :web, any_args) diff --git a/spec/helpers/auto_devops_helper_spec.rb b/spec/helpers/auto_devops_helper_spec.rb index 7266e1b84d1..5e272af6073 100644 --- a/spec/helpers/auto_devops_helper_spec.rb +++ b/spec/helpers/auto_devops_helper_spec.rb @@ -82,104 +82,4 @@ describe AutoDevopsHelper do it { is_expected.to eq(false) } end end - - describe '.show_run_auto_devops_pipeline_checkbox_for_instance_setting?' do - subject { helper.show_run_auto_devops_pipeline_checkbox_for_instance_setting?(project) } - - context 'when master contains a .gitlab-ci.yml file' do - before do - allow(project.repository).to receive(:gitlab_ci_yml).and_return("script: ['test']") - end - - it { is_expected.to eq(false) } - end - - context 'when auto devops is explicitly enabled' do - before do - project.create_auto_devops!(enabled: true) - end - - it { is_expected.to eq(false) } - end - - context 'when auto devops is explicitly disabled' do - before do - project.create_auto_devops!(enabled: false) - end - - context 'when auto devops is enabled system-wide' do - before do - stub_application_setting(auto_devops_enabled: true) - end - - it { is_expected.to eq(true) } - end - - context 'when auto devops is disabled system-wide' do - before do - stub_application_setting(auto_devops_enabled: false) - end - - it { is_expected.to eq(false) } - end - end - - context 'when auto devops is set to instance setting' do - before do - project.create_auto_devops!(enabled: nil) - end - - it { is_expected.to eq(false) } - end - end - - describe '.show_run_auto_devops_pipeline_checkbox_for_explicit_setting?' do - subject { helper.show_run_auto_devops_pipeline_checkbox_for_explicit_setting?(project) } - - context 'when master contains a .gitlab-ci.yml file' do - before do - allow(project.repository).to receive(:gitlab_ci_yml).and_return("script: ['test']") - end - - it { is_expected.to eq(false) } - end - - context 'when auto devops is explicitly enabled' do - before do - project.create_auto_devops!(enabled: true) - end - - it { is_expected.to eq(false) } - end - - context 'when auto devops is explicitly disabled' do - before do - project.create_auto_devops!(enabled: false) - end - - it { is_expected.to eq(true) } - end - - context 'when auto devops is set to instance setting' do - before do - project.create_auto_devops!(enabled: nil) - end - - context 'when auto devops is enabled system-wide' do - before do - stub_application_setting(auto_devops_enabled: true) - end - - it { is_expected.to eq(false) } - end - - context 'when auto devops is disabled system-wide' do - before do - stub_application_setting(auto_devops_enabled: false) - end - - it { is_expected.to eq(true) } - end - end - end end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index fcd71857af3..d887f70efae 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -199,24 +199,53 @@ describe Projects::UpdateService do end describe '#run_auto_devops_pipeline?' do - subject { described_class.new(project, user, params).run_auto_devops_pipeline? } + subject { described_class.new(project, user).run_auto_devops_pipeline? } - context 'when neither pipeline setting is true' do - let(:params) { {} } + context 'when master contains a .gitlab-ci.yml file' do + before do + allow(project.repository).to receive(:gitlab_ci_yml).and_return("script: ['test']") + end it { is_expected.to eq(false) } end - context 'when run_auto_devops_pipeline_explicit is true' do - let(:params) { { run_auto_devops_pipeline_explicit: 'true' } } + context 'when auto devops is explicitly enabled' do + before do + project.create_auto_devops!(enabled: true) + end it { is_expected.to eq(true) } end - context 'when run_auto_devops_pipeline_implicit is true' do - let(:params) { { run_auto_devops_pipeline_implicit: 'true' } } + context 'when auto devops is explicitly disabled' do + before do + project.create_auto_devops!(enabled: false) + end - it { is_expected.to eq(true) } + it { is_expected.to eq(false) } + end + + context 'when auto devops is set to instance setting' do + before do + project.create_auto_devops!(enabled: nil) + allow(project.auto_devops).to receive(:previous_changes).and_return('enabled' => true) + end + + context 'when auto devops is enabled system-wide' do + before do + stub_application_setting(auto_devops_enabled: true) + end + + it { is_expected.to eq(true) } + end + + context 'when auto devops is disabled system-wide' do + before do + stub_application_setting(auto_devops_enabled: false) + end + + it { is_expected.to eq(false) } + end end end From 5a2a0b284cf721e6cba63fc69fb7550f5039da8a Mon Sep 17 00:00:00 2001 From: bikebilly Date: Mon, 4 Dec 2017 10:21:19 +0100 Subject: [PATCH 007/112] Remove Auto DevOps checkboxes - frontend --- app/assets/javascripts/dispatcher.js | 7 -- .../projects/ci_cd_settings_bundle.js | 19 ---- .../pipelines_settings/_show.html.haml | 23 ++-- .../autodevops/img/auto_devops_settings.png | Bin 67845 -> 95233 bytes doc/topics/autodevops/index.md | 2 - .../settings/pipelines_settings_spec.rb | 101 ------------------ 6 files changed, 6 insertions(+), 146 deletions(-) delete mode 100644 app/assets/javascripts/projects/ci_cd_settings_bundle.js diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index a21c92f24d6..7df1cbe854d 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -522,13 +522,6 @@ import ProjectVariables from './project_variables'; case 'projects:settings:ci_cd:show': // Initialize expandable settings panels initSettingsPanels(); - - import(/* webpackChunkName: "ci-cd-settings" */ './projects/ci_cd_settings_bundle') - .then(ciCdSettings => ciCdSettings.default()) - .catch((err) => { - Flash(s__('ProjectSettings|Problem setting up the CI/CD settings JavaScript')); - throw err; - }); case 'groups:settings:ci_cd:show': new ProjectVariables(); break; diff --git a/app/assets/javascripts/projects/ci_cd_settings_bundle.js b/app/assets/javascripts/projects/ci_cd_settings_bundle.js deleted file mode 100644 index 90e418f6771..00000000000 --- a/app/assets/javascripts/projects/ci_cd_settings_bundle.js +++ /dev/null @@ -1,19 +0,0 @@ -function updateAutoDevopsRadios(radioWrappers) { - radioWrappers.forEach((radioWrapper) => { - const radio = radioWrapper.querySelector('.js-auto-devops-enable-radio'); - const runPipelineCheckboxWrapper = radioWrapper.querySelector('.js-run-auto-devops-pipeline-checkbox-wrapper'); - const runPipelineCheckbox = radioWrapper.querySelector('.js-run-auto-devops-pipeline-checkbox'); - - if (runPipelineCheckbox) { - runPipelineCheckbox.checked = radio.checked; - runPipelineCheckboxWrapper.classList.toggle('hide', !radio.checked); - } - }); -} - -export default function initCiCdSettings() { - const radioWrappers = document.querySelectorAll('.js-auto-devops-enable-radio-wrapper'); - radioWrappers.forEach(radioWrapper => - radioWrapper.addEventListener('change', () => updateAutoDevopsRadios(radioWrappers)), - ); -} diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index ee4fa663b9f..c63e716180c 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -6,46 +6,35 @@ %h5 Auto DevOps (Beta) %p Auto DevOps will automatically build, test, and deploy your application based on a predefined Continuous Integration and Delivery configuration. - This will happen starting with the next event (e.g.: push) that occurs to the project. = link_to 'Learn more about Auto DevOps', help_page_path('topics/autodevops/index.md') - message = auto_devops_warning_message(@project) - if message %p.settings-message.text-center = message.html_safe = f.fields_for :auto_devops_attributes, @auto_devops do |form| - .radio.js-auto-devops-enable-radio-wrapper + .radio = form.label :enabled_true do - = form.radio_button :enabled, 'true', class: 'js-auto-devops-enable-radio' + = form.radio_button :enabled, 'true' %strong Enable Auto DevOps %br %span.descr The Auto DevOps pipeline configuration will be used when there is no .gitlab-ci.yml in the project. - - if show_run_auto_devops_pipeline_checkbox_for_explicit_setting?(@project) - .checkbox.hide.js-run-auto-devops-pipeline-checkbox-wrapper - = label_tag 'project[run_auto_devops_pipeline_explicit]' do - = check_box_tag 'project[run_auto_devops_pipeline_explicit]', true, false, class: 'js-run-auto-devops-pipeline-checkbox' - = s_('ProjectSettings|Immediately run a pipeline on the default branch') - .radio.js-auto-devops-enable-radio-wrapper + .radio = form.label :enabled_false do - = form.radio_button :enabled, 'false', class: 'js-auto-devops-enable-radio' + = form.radio_button :enabled, 'false' %strong Disable Auto DevOps %br %span.descr An explicit .gitlab-ci.yml needs to be specified before you can begin using Continuous Integration and Delivery. - .radio.js-auto-devops-enable-radio-wrapper + .radio = form.label :enabled_ do - = form.radio_button :enabled, '', class: 'js-auto-devops-enable-radio' + = form.radio_button :enabled, '' %strong Instance default (#{current_application_settings.auto_devops_enabled? ? 'enabled' : 'disabled'}) %br %span.descr Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific .gitlab-ci.yml. - - if show_run_auto_devops_pipeline_checkbox_for_instance_setting?(@project) - .checkbox.hide.js-run-auto-devops-pipeline-checkbox-wrapper - = label_tag 'project[run_auto_devops_pipeline_implicit]' do - = check_box_tag 'project[run_auto_devops_pipeline_implicit]', true, false, class: 'js-run-auto-devops-pipeline-checkbox' - = s_('ProjectSettings|Immediately run a pipeline on the default branch') %p You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages. = form.text_field :domain, class: 'form-control', placeholder: 'domain.com' diff --git a/doc/topics/autodevops/img/auto_devops_settings.png b/doc/topics/autodevops/img/auto_devops_settings.png index b572cc5b85504984a5e968789249f40508385762..067c9da3fdc772a38eb28f449ad9db4d1ba5e682 100644 GIT binary patch literal 95233 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

    NXKQw77RXmpoT@^`4UYg{wi$T;PV}b#Kffa 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

    }rvB=CEdc^&sQ#!osZG8Y8_K{!( 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

    This is a test
    ', short_id: '12345678') - escaped = '* 12345678 - <pre>This is a test</pre>' + escaped = '<pre>This is a test</pre>' - expect(described_class.new_commit_summary([commit])).to eq([escaped]) + expect(described_class.new_commit_summary(merge_request, [commit])).to all(match(%r[- #{escaped}])) + end + + it 'contains the MR diffs commit url' do + commit = merge_request.commits.last + url = %r[/merge_requests/#{merge_request.iid}/diffs\?commit_id=#{commit.id}] + + expect(described_class.new_commit_summary(merge_request, [commit])).to all(match(url)) end end From 3d8fbd12b8f234aa62f4b5ceed21076a7afbcd23 Mon Sep 17 00:00:00 2001 From: "micael.bergeron" Date: Mon, 20 Nov 2017 09:02:01 -0500 Subject: [PATCH 033/112] add support for commit (in mr) to reference filter --- app/controllers/concerns/renders_notes.rb | 11 ++++++++++- app/models/merge_request.rb | 9 +++++++++ app/models/note.rb | 4 ++++ app/views/projects/commits/_commit.html.haml | 2 +- lib/banzai/filter/commit_reference_filter.rb | 14 ++++++++++++-- lib/banzai/object_renderer.rb | 16 ++++++++-------- 6 files changed, 44 insertions(+), 12 deletions(-) diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb index 824ad06465c..a35313c917c 100644 --- a/app/controllers/concerns/renders_notes.rb +++ b/app/controllers/concerns/renders_notes.rb @@ -3,7 +3,7 @@ module RendersNotes preload_noteable_for_regular_notes(notes) preload_max_access_for_authors(notes, @project) preload_first_time_contribution_for_authors(noteable, notes) - Notes::RenderService.new(current_user).execute(notes, @project) + Notes::RenderService.new(current_user).execute(notes, @project, noteable_context(noteable)) notes end @@ -26,4 +26,13 @@ module RendersNotes notes.each {|n| n.specialize_for_first_contribution!(noteable)} end + + def noteable_context(noteable) + case noteable + when MergeRequest + { merge_request: noteable } + else + {} + end + end end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 949d42f865c..d60b9fb6b2d 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1021,4 +1021,13 @@ class MergeRequest < ActiveRecord::Base project.merge_requests.merged.where(author_id: author_id).empty? end + + def banzai_render_context(field) + # this will be used to reference these commit in the context of the MR + # the URL are built differently + { + merge_request: self, + mr_commit_shas: all_commit_shas + } + end end diff --git a/app/models/note.rb b/app/models/note.rb index 1357e75d907..fb4a52f8c6e 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -405,6 +405,10 @@ class Note < ActiveRecord::Base noteable_object&.touch end + def banzai_render_context(field) + super.merge(noteable: noteable) + end + private def keep_around_commit diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 45b4ef12ec9..8c28becf471 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -4,7 +4,7 @@ - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } - link = commit_path(project, commit, merge_request: merge_request) -- cache_key = [project.full_path, commit.id, current_application_settings, @path.presence, current_controller?(:commits), merge_request.iid, view_details, I18n.locale] +- cache_key = [project.full_path, commit.id, current_application_settings, @path.presence, current_controller?(:commits), merge_request&.iid, view_details, I18n.locale] - cache_key.push(commit.status(ref)) if commit.status(ref) = cache(cache_key, expires_in: 1.day) do diff --git a/lib/banzai/filter/commit_reference_filter.rb b/lib/banzai/filter/commit_reference_filter.rb index 714e0319025..f4e0c3111f5 100644 --- a/lib/banzai/filter/commit_reference_filter.rb +++ b/lib/banzai/filter/commit_reference_filter.rb @@ -24,8 +24,18 @@ module Banzai def url_for_object(commit, project) h = Gitlab::Routing.url_helpers - h.project_commit_url(project, commit, - only_path: context[:only_path]) + noteable = context[:merge_request] || context[:noteable] + + if noteable.is_a?(MergeRequest) && + noteable.all_commit_shas.include?(commit.id) + + # the internal shas are in the context? + # why not preload in the object?, just make sure we have the same ref + # in all the rendering + h.diffs_project_merge_request_url(project, noteable, commit_id: commit.id) + else + h.project_commit_url(project, commit, only_path: context[:only_path]) + end end def object_link_text_extras(object, matches) diff --git a/lib/banzai/object_renderer.rb b/lib/banzai/object_renderer.rb index ecb3affbba5..29c4e60f70c 100644 --- a/lib/banzai/object_renderer.rb +++ b/lib/banzai/object_renderer.rb @@ -18,10 +18,10 @@ module Banzai # project - A Project to use for redacting Markdown. # user - The user viewing the Markdown/HTML documents, if any. # context - A Hash containing extra attributes to use during redaction - def initialize(project, user = nil, redaction_context = {}) + def initialize(project, user = nil, context = {}) @project = project @user = user - @redaction_context = redaction_context + @context = base_context.merge(context) end # Renders and redacts an Array of objects. @@ -48,7 +48,8 @@ module Banzai pipeline = HTML::Pipeline.new([]) objects.map do |object| - pipeline.to_document(Banzai.render_field(object, attribute)) + context = context_for(object, attribute) + pipeline.to_document(Banzai.render_field(object, attribute, context)) end end @@ -73,20 +74,19 @@ module Banzai # Returns a Banzai context for the given object and attribute. def context_for(object, attribute) - base_context.merge(object.banzai_render_context(attribute)) + @context.merge(object.banzai_render_context(attribute)) end def base_context - @base_context ||= @redaction_context.merge( + { current_user: user, project: project, skip_redaction: true - ) + } end def save_options - return {} unless base_context[:xhtml] - + return {} unless @context[:xhtml] { save_with: Nokogiri::XML::Node::SaveOptions::AS_XHTML } end end From 716f9cbb415cd425644b1aeae19844b26cc7d6b7 Mon Sep 17 00:00:00 2001 From: "micael.bergeron" Date: Tue, 21 Nov 2017 13:06:38 -0500 Subject: [PATCH 034/112] tidying up the changes --- .../projects/merge_requests_controller.rb | 3 --- app/models/merge_request.rb | 9 --------- app/views/projects/commits/_commit.html.haml | 19 +++++++++++++------ 3 files changed, 13 insertions(+), 18 deletions(-) diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 37acd1c9787..e7b3b73024b 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -7,11 +7,8 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include IssuableCollections skip_before_action :merge_request, only: [:index, :bulk_update] - before_action :authorize_update_issuable!, only: [:close, :edit, :update, :remove_wip, :sort] - before_action :set_issuables_index, only: [:index] - before_action :authenticate_user!, only: [:assign_related_issues] def index diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index d60b9fb6b2d..949d42f865c 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -1021,13 +1021,4 @@ class MergeRequest < ActiveRecord::Base project.merge_requests.merged.where(author_id: author_id).empty? end - - def banzai_render_context(field) - # this will be used to reference these commit in the context of the MR - # the URL are built differently - { - merge_request: self, - mr_commit_shas: all_commit_shas - } - end end diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 8c28becf471..618a6355d23 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -1,11 +1,18 @@ -- view_details = local_assigns.fetch(:view_details, false) +- view_details = local_assigns.fetch(:view_details, false) - merge_request = local_assigns.fetch(:merge_request, nil) -- project = local_assigns.fetch(:project) { merge_request&.project } -- ref = local_assigns.fetch(:ref) { merge_request&.source_branch } -- link = commit_path(project, commit, merge_request: merge_request) +- project = local_assigns.fetch(:project) { merge_request&.project } +- ref = local_assigns.fetch(:ref) { merge_request&.source_branch } -- cache_key = [project.full_path, commit.id, current_application_settings, @path.presence, current_controller?(:commits), merge_request&.iid, view_details, I18n.locale] -- cache_key.push(commit.status(ref)) if commit.status(ref) +- link = commit_path(project, commit, merge_request: merge_request) +- cache_key = [project.full_path, + commit.id, + current_application_settings, + @path.presence, + current_controller?(:commits), + merge_request&.iid, + view_details, + commit.status(ref), + I18n.locale].compact = cache(cache_key, expires_in: 1.day) do %li.commit.flex-row.js-toggle-container{ id: "commit-#{commit.short_id}" } From cb6f51ec9b2006f1040cca94119135c92e9a4cd1 Mon Sep 17 00:00:00 2001 From: "micael.bergeron" Date: Wed, 22 Nov 2017 09:48:09 -0500 Subject: [PATCH 035/112] add support for the commit reference filter --- .../diff_notes/diff_notes_bundle.js | 2 +- app/controllers/concerns/renders_notes.rb | 11 +----- .../merge_requests/diffs_controller.rb | 5 +-- app/models/commit.rb | 3 +- app/models/merge_request.rb | 21 ++++++---- app/services/system_note_service.rb | 14 ++----- .../_not_all_comments_displayed.html.haml | 8 ++-- lib/banzai/filter/commit_reference_filter.rb | 38 ++++++++++++++----- lib/banzai/object_renderer.rb | 13 +++---- lib/gitlab/diff/diff_refs.rb | 22 ++--------- lib/gitlab/git.rb | 12 ++++++ lib/gitlab/git/commit.rb | 1 + .../projects/commit_controller_spec.rb | 2 +- spec/lib/gitlab/git_spec.rb | 25 ++++++++++++ spec/models/merge_request_spec.rb | 2 +- spec/services/system_note_service_spec.rb | 11 +----- 16 files changed, 105 insertions(+), 85 deletions(-) diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index 2f22361d6d2..e0422057090 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -16,7 +16,7 @@ import './components/diff_note_avatars'; import './components/new_issue_for_discussion'; $(() => { - const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box') + const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box'); const projectPath = projectPathHolder.dataset.projectPath; const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn'; diff --git a/app/controllers/concerns/renders_notes.rb b/app/controllers/concerns/renders_notes.rb index a35313c917c..824ad06465c 100644 --- a/app/controllers/concerns/renders_notes.rb +++ b/app/controllers/concerns/renders_notes.rb @@ -3,7 +3,7 @@ module RendersNotes preload_noteable_for_regular_notes(notes) preload_max_access_for_authors(notes, @project) preload_first_time_contribution_for_authors(noteable, notes) - Notes::RenderService.new(current_user).execute(notes, @project, noteable_context(noteable)) + Notes::RenderService.new(current_user).execute(notes, @project) notes end @@ -26,13 +26,4 @@ module RendersNotes notes.each {|n| n.specialize_for_first_contribution!(noteable)} end - - def noteable_context(noteable) - case noteable - when MergeRequest - { merge_request: noteable } - else - {} - end - end end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 07bf9db5a34..fe8525a488c 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -21,8 +21,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic private def define_diff_vars - @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff.order_id_desc - + @merge_request_diffs = @merge_request.merge_request_diffs.viewable.order_id_desc @compare = commit || find_merge_request_diff_compare return render_404 unless @compare @@ -31,7 +30,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic def commit return nil unless commit_id = params[:commit_id].presence - return nil unless @merge_request.all_commit_shas.include?(commit_id) + return nil unless @merge_request.all_commits.exists?(sha: commit_id) @commit ||= @project.commit(commit_id) end diff --git a/app/models/commit.rb b/app/models/commit.rb index 6b28d290f99..307e4fcedfe 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -1,3 +1,4 @@ +# coding: utf-8 class Commit extend ActiveModel::Naming extend Gitlab::Cache::RequestCache @@ -25,7 +26,7 @@ class Commit DIFF_HARD_LIMIT_FILES = 1000 DIFF_HARD_LIMIT_LINES = 50000 - MIN_SHA_LENGTH = 7 + MIN_SHA_LENGTH = Gitlab::Git::Commit::MIN_SHA_LENGTH COMMIT_SHA_PATTERN = /\h{#{MIN_SHA_LENGTH},40}/.freeze def banzai_render_context(field) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 949d42f865c..22a79da9879 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -649,6 +649,7 @@ class MergeRequest < ActiveRecord::Base .to_sql Note.from("(#{union}) #{Note.table_name}") + .includes(:noteable) end alias_method :discussion_notes, :related_notes @@ -920,16 +921,13 @@ class MergeRequest < ActiveRecord::Base def all_pipelines return Ci::Pipeline.none unless source_project + commit_shas = all_commits.unscope(:limit).select(:sha) @all_pipelines ||= source_project.pipelines - .where(sha: all_commit_shas, ref: source_branch) + .where(sha: commit_shas, ref: source_branch) .order(id: :desc) end - # Note that this could also return SHA from now dangling commits - # - def all_commit_shas - return commit_shas unless persisted? - + def all_commits diffs_relation = merge_request_diffs # MySQL doesn't support LIMIT in a subquery. @@ -938,8 +936,15 @@ class MergeRequest < ActiveRecord::Base MergeRequestDiffCommit .where(merge_request_diff: diffs_relation) .limit(10_000) - .pluck('sha') - .uniq + end + + # Note that this could also return SHA from now dangling commits + # + def all_commit_shas + @all_commit_shas ||= begin + return commit_shas unless persisted? + all_commits.pluck(:sha).uniq + end end def merge_commit diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 5f8a1bf07e2..30a5aab13bf 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -23,7 +23,7 @@ module SystemNoteService body = "added #{commits_text}\n\n" body << existing_commit_summary(noteable, existing_commits, oldrev) - body << new_commit_summary(noteable, new_commits).join("\n") + body << new_commit_summary(new_commits).join("\n") body << "\n\n[Compare with previous version](#{diff_comparison_url(noteable, project, oldrev)})" create_note(NoteSummary.new(noteable, project, author, body, action: 'commit', commit_count: total_count)) @@ -486,9 +486,9 @@ module SystemNoteService # new_commits - Array of new Commit objects # # Returns an Array of Strings - def new_commit_summary(merge_request, new_commits) + def new_commit_summary(new_commits) new_commits.collect do |commit| - "* [#{commit.short_id}](#{merge_request_commit_url(merge_request, commit)}) - #{escape_html(commit.title)}" + "* #{commit.short_id} - #{escape_html(commit.title)}" end end @@ -668,12 +668,4 @@ module SystemNoteService start_sha: oldrev ) end - - def merge_request_commit_url(merge_request, commit) - url_helpers.diffs_project_merge_request_url( - merge_request.target_project, - merge_request, - commit_id: commit - ) - end end diff --git a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml index e4a1dc786b9..faabba5fc35 100644 --- a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml +++ b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml @@ -11,7 +11,7 @@ comparing two versions of the diff - else viewing an old version of the diff - .pull-right - = link_to diffs_project_merge_request_path(@project, @merge_request), class: 'btn btn-sm' do - Show latest version - = "of the diff" if @commit + .pull-right + = link_to diffs_project_merge_request_path(@project, @merge_request), class: 'btn btn-sm' do + Show latest version + = "of the diff" if @commit diff --git a/lib/banzai/filter/commit_reference_filter.rb b/lib/banzai/filter/commit_reference_filter.rb index f4e0c3111f5..c202d71072e 100644 --- a/lib/banzai/filter/commit_reference_filter.rb +++ b/lib/banzai/filter/commit_reference_filter.rb @@ -22,19 +22,29 @@ module Banzai end end + def referenced_merge_request_commit_shas + return [] unless noteable.is_a?(MergeRequest) + + @referenced_merge_request_commit_shas ||= begin + referenced_shas = references_per_project.values.reduce(:|).to_a + noteable.all_commit_shas.select do |sha| + referenced_shas.any? { |ref| Gitlab::Git.shas_eql?(sha, ref) } + end + end + end + def url_for_object(commit, project) h = Gitlab::Routing.url_helpers - noteable = context[:merge_request] || context[:noteable] - if noteable.is_a?(MergeRequest) && - noteable.all_commit_shas.include?(commit.id) - - # the internal shas are in the context? - # why not preload in the object?, just make sure we have the same ref - # in all the rendering - h.diffs_project_merge_request_url(project, noteable, commit_id: commit.id) + if referenced_merge_request_commit_shas.include?(commit.id) + h.diffs_project_merge_request_url(project, + noteable, + commit_id: commit.id, + only_path: only_path?) else - h.project_commit_url(project, commit, only_path: context[:only_path]) + h.project_commit_url(project, + commit, + only_path: only_path?) end end @@ -48,6 +58,16 @@ module Banzai extras end + + private + + def noteable + context[:noteable] + end + + def only_path? + context[:only_path] + end end end end diff --git a/lib/banzai/object_renderer.rb b/lib/banzai/object_renderer.rb index 29c4e60f70c..0bf9a8d66bc 100644 --- a/lib/banzai/object_renderer.rb +++ b/lib/banzai/object_renderer.rb @@ -17,11 +17,11 @@ module Banzai # project - A Project to use for redacting Markdown. # user - The user viewing the Markdown/HTML documents, if any. - # context - A Hash containing extra attributes to use during redaction - def initialize(project, user = nil, context = {}) + # redaction_context - A Hash containing extra attributes to use during redaction + def initialize(project, user = nil, redaction_context = {}) @project = project @user = user - @context = base_context.merge(context) + @redaction_context = base_context.merge(redaction_context) end # Renders and redacts an Array of objects. @@ -48,8 +48,7 @@ module Banzai pipeline = HTML::Pipeline.new([]) objects.map do |object| - context = context_for(object, attribute) - pipeline.to_document(Banzai.render_field(object, attribute, context)) + pipeline.to_document(Banzai.render_field(object, attribute)) end end @@ -74,7 +73,7 @@ module Banzai # Returns a Banzai context for the given object and attribute. def context_for(object, attribute) - @context.merge(object.banzai_render_context(attribute)) + @redaction_context.merge(object.banzai_render_context(attribute)) end def base_context @@ -86,7 +85,7 @@ module Banzai end def save_options - return {} unless @context[:xhtml] + return {} unless @redaction_context[:xhtml] { save_with: Nokogiri::XML::Node::SaveOptions::AS_XHTML } end end diff --git a/lib/gitlab/diff/diff_refs.rb b/lib/gitlab/diff/diff_refs.rb index c98eefbce25..88e0db830f6 100644 --- a/lib/gitlab/diff/diff_refs.rb +++ b/lib/gitlab/diff/diff_refs.rb @@ -13,9 +13,9 @@ module Gitlab def ==(other) other.is_a?(self.class) && - shas_equal?(base_sha, other.base_sha) && - shas_equal?(start_sha, other.start_sha) && - shas_equal?(head_sha, other.head_sha) + Git.shas_eql?(base_sha, other.base_sha) && + Git.shas_eql?(start_sha, other.start_sha) && + Git.shas_eql?(head_sha, other.head_sha) end alias_method :eql?, :== @@ -47,22 +47,6 @@ module Gitlab CompareService.new(project, head_sha).execute(project, start_sha, straight: straight) end end - - private - - def shas_equal?(sha1, sha2) - return true if sha1 == sha2 - return false if sha1.nil? || sha2.nil? - return false unless sha1.class == sha2.class - - length = [sha1.length, sha2.length].min - - # If either of the shas is below the minimum length, we cannot be sure - # that they actually refer to the same commit because of hash collision. - return false if length < Commit::MIN_SHA_LENGTH - - sha1[0, length] == sha2[0, length] - end end end end diff --git a/lib/gitlab/git.rb b/lib/gitlab/git.rb index 1f31cdbc96d..1f7c35cafaa 100644 --- a/lib/gitlab/git.rb +++ b/lib/gitlab/git.rb @@ -70,6 +70,18 @@ module Gitlab def diff_line_code(file_path, new_line_position, old_line_position) "#{Digest::SHA1.hexdigest(file_path)}_#{old_line_position}_#{new_line_position}" end + + def shas_eql?(sha1, sha2) + return false if sha1.nil? || sha2.nil? + return false unless sha1.class == sha2.class + + # If either of the shas is below the minimum length, we cannot be sure + # that they actually refer to the same commit because of hash collision. + length = [sha1.length, sha2.length].min + return false if length < Gitlab::Git::Commit::MIN_SHA_LENGTH + + sha1[0, length] == sha2[0, length] + end end end end diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index 8900e2d7afe..e90b158fb34 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -6,6 +6,7 @@ module Gitlab attr_accessor :raw_commit, :head + MIN_SHA_LENGTH = 7 SERIALIZE_KEYS = [ :id, :message, :parent_ids, :authored_date, :author_name, :author_email, diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index a5b603d6bff..fa8533bc3d9 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -338,7 +338,7 @@ describe Projects::CommitController do context 'when the commit does not exist' do before do - diff_for_path(id: commit.id.succ, old_path: existing_path, new_path: existing_path) + diff_for_path(id: commit.id.reverse, old_path: existing_path, new_path: existing_path) end it 'returns a 404' do diff --git a/spec/lib/gitlab/git_spec.rb b/spec/lib/gitlab/git_spec.rb index 494dfe0e595..ce15057dd7d 100644 --- a/spec/lib/gitlab/git_spec.rb +++ b/spec/lib/gitlab/git_spec.rb @@ -38,4 +38,29 @@ describe Gitlab::Git do expect(described_class.ref_name(utf8_invalid_ref)).to eq("an_invalid_ref_å") end end + + describe '.shas_eql?' do + using RSpec::Parameterized::TableSyntax + + where(:sha1, :sha2, :result) do + sha = RepoHelpers.sample_commit.id + short_sha = sha[0, Gitlab::Git::Commit::MIN_SHA_LENGTH] + too_short_sha = sha[0, Gitlab::Git::Commit::MIN_SHA_LENGTH - 1] + + [ + [sha, sha, true], + [sha, short_sha, true], + [sha, sha.reverse, false], + [sha, too_short_sha, false], + [sha, nil, false] + ] + end + + with_them do + it { expect(described_class.shas_eql?(sha1, sha2)).to eq(result) } + it 'is commutative' do + expect(described_class.shas_eql?(sha2, sha1)).to eq(result) + end + end + end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 71fbb82184c..30a5a3bbff7 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -967,7 +967,7 @@ describe MergeRequest do end shared_examples 'returning all SHA' do - it 'returns all SHA from all merge_request_diffs' do + it 'returns all SHAs from all merge_request_diffs' do expect(subject.merge_request_diffs.size).to eq(2) expect(subject.all_commit_shas).to match_array(all_commit_shas) end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 148f81b6a58..47412110b4b 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -690,20 +690,11 @@ describe SystemNoteService do end describe '.new_commit_summary' do - let(:merge_request) { create(:merge_request, :simple, target_project: project, source_project: project) } - it 'escapes HTML titles' do commit = double(title: '
    This is a test
    ', short_id: '12345678') escaped = '<pre>This is a test</pre>' - expect(described_class.new_commit_summary(merge_request, [commit])).to all(match(%r[- #{escaped}])) - end - - it 'contains the MR diffs commit url' do - commit = merge_request.commits.last - url = %r[/merge_requests/#{merge_request.iid}/diffs\?commit_id=#{commit.id}] - - expect(described_class.new_commit_summary(merge_request, [commit])).to all(match(url)) + expect(described_class.new_commit_summary([commit])).to all(match(%r[- #{escaped}])) end end From 16c8b91092fa0fe0b5822648c22ee545e11cb4bc Mon Sep 17 00:00:00 2001 From: "micael.bergeron" Date: Wed, 6 Dec 2017 08:32:12 -0500 Subject: [PATCH 036/112] revert the `#all_pipelines` method to use the pluck the `ci_pipelines.sha` column is not the same type than the `merge_request_diff_commits.sha` column (varchar, bytea) --- app/models/merge_request.rb | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 22a79da9879..2dad036639a 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -921,9 +921,8 @@ class MergeRequest < ActiveRecord::Base def all_pipelines return Ci::Pipeline.none unless source_project - commit_shas = all_commits.unscope(:limit).select(:sha) @all_pipelines ||= source_project.pipelines - .where(sha: commit_shas, ref: source_branch) + .where(sha: all_commit_shas, ref: source_branch) .order(id: :desc) end From 17075a0bdc0c271e9f7a4f25829c0517656d5871 Mon Sep 17 00:00:00 2001 From: "micael.bergeron" Date: Wed, 6 Dec 2017 10:57:10 -0500 Subject: [PATCH 037/112] cache the Note#commit method --- app/models/note.rb | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/app/models/note.rb b/app/models/note.rb index fb4a52f8c6e..c4c2ab8e67d 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -231,19 +231,17 @@ class Note < ActiveRecord::Base end def commit - project.commit(commit_id) if commit_id.present? + @commit ||= project.commit(commit_id) if commit_id.present? end # override to return commits, which are not active record def noteable - if for_commit? - @commit ||= commit - else - super - end - # Temp fix to prevent app crash - # if note commit id doesn't exist + return commit if for_commit? + + super rescue + # Temp fix to prevent app crash + # if note commit id doesn't exist nil end From 360b94ceba146935a40b02f39ed3d833eaea134a Mon Sep 17 00:00:00 2001 From: "micael.bergeron" Date: Fri, 1 Dec 2017 14:08:30 -0500 Subject: [PATCH 038/112] adding view and feature specs --- app/helpers/merge_requests_helper.rb | 30 ++--- app/models/merge_request.rb | 9 +- .../_not_all_comments_displayed.html.haml | 2 +- .../projects/merge_requests/show.html.haml | 8 +- lib/banzai/object_renderer.rb | 1 + .../projects/commit_controller_spec.rb | 3 +- .../merge_requests/diffs_controller_spec.rb | 3 +- spec/factories/notes.rb | 10 +- spec/features/merge_requests/versions_spec.rb | 105 +++++++++++------- spec/helpers/merge_requests_helper_spec.rb | 17 +++ .../filter/commit_reference_filter_spec.rb | 12 ++ spec/models/diff_note_spec.rb | 29 ++--- .../projects/commit/show.html.haml_spec.rb | 22 +++- .../merge_requests/_commits.html.haml_spec.rb | 4 +- .../diffs/_diffs.html.haml_spec.rb | 36 ++++++ 15 files changed, 200 insertions(+), 91 deletions(-) create mode 100644 spec/views/projects/merge_requests/diffs/_diffs.html.haml_spec.rb diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 004aaeb2c56..ce57422f45d 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -101,28 +101,28 @@ module MergeRequestsHelper }.merge(merge_params_ee(merge_request)) end - def tab_link_for(tab, options={}, &block) + def tab_link_for(merge_request, tab, options = {}, &block) data_attrs = { action: tab.to_s, - target: "##{tab.to_s}", + target: "##{tab}", toggle: options.fetch(:force_link, false) ? '' : 'tab' } url = case tab - when :show - data_attrs.merge!(target: '#notes') - project_merge_request_path(@project, @merge_request) - when :commits - commits_project_merge_request_path(@project, @merge_request) - when :pipelines - pipelines_project_merge_request_path(@project, @merge_request) - when :diffs - diffs_project_merge_request_path(@project, @merge_request) - else - raise "Cannot create tab #{tab}." - end + when :show + data_attrs[:target] = '#notes' + method(:project_merge_request_path) + when :commits + method(:commits_project_merge_request_path) + when :pipelines + method(:pipelines_project_merge_request_path) + when :diffs + method(:diffs_project_merge_request_path) + else + raise "Cannot create tab #{tab}." + end - link_to(url, data: data_attrs, &block) + link_to(url[merge_request.project, merge_request], data: data_attrs, &block) end def merge_params_ee(merge_request) diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2dad036639a..422f138c4ea 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -927,10 +927,12 @@ class MergeRequest < ActiveRecord::Base end def all_commits - diffs_relation = merge_request_diffs - # MySQL doesn't support LIMIT in a subquery. - diffs_relation = diffs_relation.recent if Gitlab::Database.postgresql? + diffs_relation = if Gitlab::Database.postgresql? + merge_request_diffs.recent + else + merge_request_diffs + end MergeRequestDiffCommit .where(merge_request_diff: diffs_relation) @@ -942,6 +944,7 @@ class MergeRequest < ActiveRecord::Base def all_commit_shas @all_commit_shas ||= begin return commit_shas unless persisted? + all_commits.pluck(:sha).uniq end end diff --git a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml index faabba5fc35..529fbb8547a 100644 --- a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml +++ b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml @@ -12,6 +12,6 @@ - else viewing an old version of the diff .pull-right - = link_to diffs_project_merge_request_path(@project, @merge_request), class: 'btn btn-sm' do + = link_to diffs_project_merge_request_path(@merge_request.project, @merge_request), class: 'btn btn-sm' do Show latest version = "of the diff" if @commit diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 0d7abe8137f..abff702fd9d 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -38,21 +38,21 @@ .nav-links.scrolling-tabs %ul.merge-request-tabs %li.notes-tab - = tab_link_for :show, force_link: @commit.present? do + = tab_link_for @merge_request, :show, force_link: @commit.present? do Discussion %span.badge= @merge_request.related_notes.user.count - if @merge_request.source_project %li.commits-tab - = tab_link_for :commits do + = tab_link_for @merge_request, :commits do Commits %span.badge= @commits_count - if @pipelines.any? %li.pipelines-tab - = tab_link_for :pipelines do + = tab_link_for @merge_request, :pipelines do Pipelines %span.badge.js-pipelines-mr-count= @pipelines.size %li.diffs-tab - = tab_link_for :diffs do + = tab_link_for @merge_request, :diffs do Changes %span.badge= @merge_request.diff_size #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } diff --git a/lib/banzai/object_renderer.rb b/lib/banzai/object_renderer.rb index 0bf9a8d66bc..2691be81623 100644 --- a/lib/banzai/object_renderer.rb +++ b/lib/banzai/object_renderer.rb @@ -86,6 +86,7 @@ module Banzai def save_options return {} unless @redaction_context[:xhtml] + { save_with: Nokogiri::XML::Node::SaveOptions::AS_XHTML } end end diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index fa8533bc3d9..694c64ae1ad 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -140,7 +140,8 @@ describe Projects::CommitController do it 'prepare diff notes in the context of the merge request' do go(id: commit.id, merge_request_iid: merge_request.iid) - expect(assigns(:new_diff_note_attrs)).to eq({ noteable_type: 'MergeRequest', + expect(assigns(:new_diff_note_attrs)).to eq({ + noteable_type: 'MergeRequest', noteable_id: merge_request.id, commit_id: commit.id }) diff --git a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb index 18a70bec103..ba97ccfbbd4 100644 --- a/spec/controllers/projects/merge_requests/diffs_controller_spec.rb +++ b/spec/controllers/projects/merge_requests/diffs_controller_spec.rb @@ -100,7 +100,8 @@ describe Projects::MergeRequests::DiffsController do expect(assigns(:diff_notes_disabled)).to be_falsey expect(assigns(:new_diff_note_attrs)).to eq(noteable_type: 'MergeRequest', - noteable_id: merge_request.id) + noteable_id: merge_request.id, + commit_id: nil) end it 'only renders the diffs for the path given' do diff --git a/spec/factories/notes.rb b/spec/factories/notes.rb index ab4ae123429..471bfb3213a 100644 --- a/spec/factories/notes.rb +++ b/spec/factories/notes.rb @@ -63,13 +63,19 @@ FactoryGirl.define do factory :diff_note_on_commit, traits: [:on_commit], class: DiffNote do association :project, :repository + + transient do + line_number 14 + diff_refs { project.commit(commit_id).try(:diff_refs) } + end + position do Gitlab::Diff::Position.new( old_path: "files/ruby/popen.rb", new_path: "files/ruby/popen.rb", old_line: nil, - new_line: 14, - diff_refs: project.commit(commit_id).try(:diff_refs) + new_line: line_number, + diff_refs: diff_refs ) end end diff --git a/spec/features/merge_requests/versions_spec.rb b/spec/features/merge_requests/versions_spec.rb index 29f95039af8..482f2e51c8b 100644 --- a/spec/features/merge_requests/versions_spec.rb +++ b/spec/features/merge_requests/versions_spec.rb @@ -6,18 +6,47 @@ feature 'Merge Request versions', :js do let!(:merge_request_diff1) { merge_request.merge_request_diffs.create(head_commit_sha: '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } let!(:merge_request_diff2) { merge_request.merge_request_diffs.create(head_commit_sha: nil) } let!(:merge_request_diff3) { merge_request.merge_request_diffs.create(head_commit_sha: '5937ac0a7beb003549fc5fd26fc247adbce4a52e') } + let!(:params) { Hash.new } before do sign_in(create(:admin)) - visit diffs_project_merge_request_path(project, merge_request) + visit diffs_project_merge_request_path(project, merge_request, params) end - it 'show the latest version of the diff' do - page.within '.mr-version-dropdown' do - expect(page).to have_content 'latest version' + shared_examples 'allows commenting' do |file_id:, line_code:, comment:| + it do + diff_file_selector = ".diff-file[id='#{file_id}']" + line_code = "#{file_id}_#{line_code}" + + page.within(diff_file_selector) do + find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").hover + find(".line_holder[id='#{line_code}'] button").click + + page.within("form[data-line-code='#{line_code}']") do + fill_in "note[note]", with: comment + find(".js-comment-button").click + end + + wait_for_requests + + expect(page).to have_content(comment) + end + end + end + + describe 'compare with the latest version' do + it 'show the latest version of the diff' do + page.within '.mr-version-dropdown' do + expect(page).to have_content 'latest version' + end + + expect(page).to have_content '8 changed files' end - expect(page).to have_content '8 changed files' + it_behaves_like 'allows commenting', + file_id: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44', + line_code: '1_1', + comment: 'Typo, please fix.' end describe 'switch between versions' do @@ -62,24 +91,10 @@ feature 'Merge Request versions', :js do expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']") end - it 'allows commenting' do - diff_file_selector = ".diff-file[id='7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44']" - line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_2_2' - - page.within(diff_file_selector) do - find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").hover - find(".line_holder[id='#{line_code}'] button").click - - page.within("form[data-line-code='#{line_code}']") do - fill_in "note[note]", with: "Typo, please fix" - find(".js-comment-button").click - end - - wait_for_requests - - expect(page).to have_content("Typo, please fix") - end - end + it_behaves_like 'allows commenting', + file_id: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44', + line_code: '2_2', + comment: 'Typo, please fix.' end describe 'compare with older version' do @@ -132,25 +147,6 @@ feature 'Merge Request versions', :js do expect(page).to have_css(".diffs .notes[data-discussion-id='#{outdated_diff_note.discussion_id}']") end - it 'allows commenting' do - diff_file_selector = ".diff-file[id='7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44']" - line_code = '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44_4_4' - - page.within(diff_file_selector) do - find(".line_holder[id='#{line_code}'] td:nth-of-type(1)").hover - find(".line_holder[id='#{line_code}'] button").click - - page.within("form[data-line-code='#{line_code}']") do - fill_in "note[note]", with: "Typo, please fix" - find(".js-comment-button").click - end - - wait_for_requests - - expect(page).to have_content("Typo, please fix") - end - end - it 'show diff between new and old version' do expect(page).to have_content '4 changed files with 15 additions and 6 deletions' end @@ -162,6 +158,11 @@ feature 'Merge Request versions', :js do end expect(page).to have_content '8 changed files' end + + it_behaves_like 'allows commenting', + file_id: '7445606fbf8f3683cd42bdc54b05d7a0bc2dfc44', + line_code: '4_4', + comment: 'Typo, please fix.' end describe 'compare with same version' do @@ -210,4 +211,24 @@ feature 'Merge Request versions', :js do expect(page).to have_content '0 changed files' end end + + describe 'scoped in a commit' do + let(:params) { { commit_id: '570e7b2abdd848b95f2f578043fc23bd6f6fd24d' } } + + before do + wait_for_requests + end + + it 'should only show diffs from the commit' do + diff_commit_ids = find_all('.diff-file [data-commit-id]').map {|diff| diff['data-commit-id']} + + expect(diff_commit_ids).not_to be_empty + expect(diff_commit_ids).to all(eq(params[:commit_id])) + end + + it_behaves_like 'allows commenting', + file_id: '2f6fcd96b88b36ce98c38da085c795a27d92a3dd', + line_code: '6_6', + comment: 'Typo, please fix.' + end end diff --git a/spec/helpers/merge_requests_helper_spec.rb b/spec/helpers/merge_requests_helper_spec.rb index fd7900c32f4..3008528e60c 100644 --- a/spec/helpers/merge_requests_helper_spec.rb +++ b/spec/helpers/merge_requests_helper_spec.rb @@ -1,7 +1,9 @@ require 'spec_helper' describe MergeRequestsHelper do + include ActionView::Helpers::UrlHelper include ProjectForksHelper + describe 'ci_build_details_path' do let(:project) { create(:project) } let(:merge_request) { MergeRequest.new } @@ -41,4 +43,19 @@ describe MergeRequestsHelper do it { is_expected.to eq([source_title, target_title]) } end end + + describe '#tab_link_for' do + let(:merge_request) { create(:merge_request, :simple) } + let(:options) { Hash.new } + + subject { tab_link_for(merge_request, :show, options) { 'Discussion' } } + + describe 'supports the :force_link option' do + let(:options) { { force_link: true } } + + it 'removes the data-toggle attributes' do + is_expected.not_to match(/data-toggle="tab"/) + end + end + end end diff --git a/spec/lib/banzai/filter/commit_reference_filter_spec.rb b/spec/lib/banzai/filter/commit_reference_filter_spec.rb index 702fcac0c6f..080a5f57da9 100644 --- a/spec/lib/banzai/filter/commit_reference_filter_spec.rb +++ b/spec/lib/banzai/filter/commit_reference_filter_spec.rb @@ -92,6 +92,18 @@ describe Banzai::Filter::CommitReferenceFilter do expect(link).not_to match %r(https?://) expect(link).to eq urls.project_commit_url(project, reference, only_path: true) end + + context "in merge request context" do + let(:noteable) { create(:merge_request, target_project: project, source_project: project) } + let(:commit) { noteable.commits.first } + + it 'handles merge request contextual commit references' do + url = urls.diffs_project_merge_request_url(project, noteable, commit_id: commit.id) + doc = reference_filter("See #{reference}", noteable: noteable) + + expect(doc.css('a').first[:href]).to eq(url) + end + end end context 'cross-project / cross-namespace complete reference' do diff --git a/spec/models/diff_note_spec.rb b/spec/models/diff_note_spec.rb index 8389d5c5430..4d0b3245a13 100644 --- a/spec/models/diff_note_spec.rb +++ b/spec/models/diff_note_spec.rb @@ -9,13 +9,14 @@ describe DiffNote do let(:path) { "files/ruby/popen.rb" } + let(:diff_refs) { merge_request.diff_refs } let!(:position) do Gitlab::Diff::Position.new( old_path: path, new_path: path, old_line: nil, new_line: 14, - diff_refs: merge_request.diff_refs + diff_refs: diff_refs ) end @@ -25,7 +26,7 @@ describe DiffNote do new_path: path, old_line: 16, new_line: 22, - diff_refs: merge_request.diff_refs + diff_refs: diff_refs ) end @@ -158,25 +159,21 @@ describe DiffNote do describe "creation" do describe "updating of position" do context "when noteable is a commit" do - let(:diff_note) { create(:diff_note_on_commit, project: project, position: position) } + let(:diff_refs) { commit.diff_refs } + + subject { create(:diff_note_on_commit, project: project, position: position, commit_id: commit.id) } it "doesn't update the position" do - diff_note - - expect(diff_note.original_position).to eq(position) - expect(diff_note.position).to eq(position) + is_expected.to have_attributes(original_position: position, + position: position) end end context "when noteable is a merge request" do - let(:diff_note) { create(:diff_note_on_merge_request, project: project, position: position, noteable: merge_request) } - context "when the note is active" do it "doesn't update the position" do - diff_note - - expect(diff_note.original_position).to eq(position) - expect(diff_note.position).to eq(position) + expect(subject.original_position).to eq(position) + expect(subject.position).to eq(position) end end @@ -186,10 +183,8 @@ describe DiffNote do end it "updates the position" do - diff_note - - expect(diff_note.original_position).to eq(position) - expect(diff_note.position).not_to eq(position) + expect(subject.original_position).to eq(position) + expect(subject.position).not_to eq(position) end end end diff --git a/spec/views/projects/commit/show.html.haml_spec.rb b/spec/views/projects/commit/show.html.haml_spec.rb index 32c95c6bb0d..a9c32122600 100644 --- a/spec/views/projects/commit/show.html.haml_spec.rb +++ b/spec/views/projects/commit/show.html.haml_spec.rb @@ -2,14 +2,15 @@ require 'spec_helper' describe 'projects/commit/show.html.haml' do let(:project) { create(:project, :repository) } + let(:commit) { project.commit } before do assign(:project, project) assign(:repository, project.repository) - assign(:commit, project.commit) - assign(:noteable, project.commit) + assign(:commit, commit) + assign(:noteable, commit) assign(:notes, []) - assign(:diffs, project.commit.diffs) + assign(:diffs, commit.diffs) allow(view).to receive(:current_user).and_return(nil) allow(view).to receive(:can?).and_return(false) @@ -43,4 +44,19 @@ describe 'projects/commit/show.html.haml' do expect(rendered).not_to have_selector('.limit-container-width') end end + + context 'in the context of a merge request' do + let(:merge_request) { create(:merge_request, source_project: project, target_project: project) } + + before do + assign(:merge_request, merge_request) + render + end + + it 'shows that it is in the context of a merge request' do + merge_request_url = diffs_project_merge_request_url(project, merge_request, commit_id: commit.id) + expect(rendered).to have_content("This commit is part of merge request") + expect(rendered).to have_link(merge_request.to_reference, merge_request_url) + end + end end diff --git a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb index efed2e02a1b..3ca67114558 100644 --- a/spec/views/projects/merge_requests/_commits.html.haml_spec.rb +++ b/spec/views/projects/merge_requests/_commits.html.haml_spec.rb @@ -25,8 +25,8 @@ describe 'projects/merge_requests/_commits.html.haml' do it 'shows commits from source project' do render - commit = source_project.commit(merge_request.source_branch) - href = project_commit_path(source_project, commit) + commit = merge_request.commits.first # HEAD + href = diffs_project_merge_request_path(target_project, merge_request, commit_id: commit) expect(rendered).to have_link(Commit.truncate_sha(commit.sha), href: href) end diff --git a/spec/views/projects/merge_requests/diffs/_diffs.html.haml_spec.rb b/spec/views/projects/merge_requests/diffs/_diffs.html.haml_spec.rb new file mode 100644 index 00000000000..e7c40421f1f --- /dev/null +++ b/spec/views/projects/merge_requests/diffs/_diffs.html.haml_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe 'projects/merge_requests/diffs/_diffs.html.haml' do + include Devise::Test::ControllerHelpers + + let(:user) { create(:user) } + let(:project) { create(:project, :public, :repository) } + let(:merge_request) { create(:merge_request_with_diffs, target_project: project, source_project: project, author: user) } + + before do + allow(view).to receive(:url_for).and_return(controller.request.fullpath) + + assign(:merge_request, merge_request) + assign(:environment, merge_request.environments_for(user).last) + assign(:diffs, merge_request.diffs) + assign(:merge_request_diffs, merge_request.diffs) + assign(:diff_notes_disabled, true) # disable note creation + assign(:use_legacy_diff_notes, false) + assign(:grouped_diff_discussions, {}) + assign(:notes, []) + end + + context 'for a commit' do + let(:commit) { merge_request.commits.last } + + before do + assign(:commit, commit) + end + + it "shows the commit scope" do + render + + expect(rendered).to have_content "Only comments from the following commit are shown below" + end + end +end From 693a6c1c9aa49aa70e41b26cb9a1511bc5928663 Mon Sep 17 00:00:00 2001 From: Markus Koller Date: Thu, 7 Dec 2017 13:54:49 +0100 Subject: [PATCH 039/112] Use Markdown styling for new project guidelines --- app/views/projects/new.html.haml | 3 ++- changelogs/unreleased/fix-new-project-guidelines-styling.yml | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/fix-new-project-guidelines-styling.yml diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index cad7c2e83db..4bb97ecdd16 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -18,7 +18,8 @@ A project is where you house your files (repository), plan your work (issues), and publish your documentation (wiki), #{link_to 'among other things', help_page_path("user/project/index.md", anchor: "projects-features"), target: '_blank'}. %p All features are enabled when you create a project, but you can disable the ones you don’t need in the project settings. - = brand_new_project_guidelines + .md + = brand_new_project_guidelines .col-lg-9.js-toggle-container %ul.nav-links.gitlab-tabs{ role: 'tablist' } %li.active{ role: 'presentation' } diff --git a/changelogs/unreleased/fix-new-project-guidelines-styling.yml b/changelogs/unreleased/fix-new-project-guidelines-styling.yml new file mode 100644 index 00000000000..a97f5c485d4 --- /dev/null +++ b/changelogs/unreleased/fix-new-project-guidelines-styling.yml @@ -0,0 +1,5 @@ +--- +title: Use Markdown styling for new project guidelines +merge_request: 15785 +author: Markus Koller +type: fixed From c2f68b7ae319cb732eeaf53ddd020702644dc70d Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 7 Dec 2017 23:16:57 +0900 Subject: [PATCH 040/112] Update document to use ci_validates_dependencies --- doc/ci/yaml/README.md | 14 ++++---------- doc/user/project/pipelines/job_artifacts.md | 8 ++++++++ 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index ea151853f50..ac5d99c71fc 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1153,22 +1153,16 @@ deploy: script: make deploy ``` -### Validations for `dependencies` keyword - +>**Note:** > Introduced in GitLab 10.3 +> This is the user documentation. For the administration guide see + [administration/job_artifacts](../../../administration/job_artifacts.md#validation_for_dependency). -`dependencies` keyword doesn't check the depended `artifacts` strictly. Therefore -they do not fail even though it falls into the following conditions. +If a depended job doesn't have artifacts by the following reason, the depending job will fail. 1. A depended `artifacts` has been [erased](https://docs.gitlab.com/ee/api/jobs.html#erase-a-job). 1. A depended `artifacts` has been [expired](https://docs.gitlab.com/ee/ci/yaml/#artifacts-expire_in). -To validate those conditions, you can flip the feature flag from a rails console: - -``` -Feature.enable('ci_validates_dependencies') -``` - ### before_script and after_script It's possible to overwrite the globally defined `before_script` and `after_script`: diff --git a/doc/user/project/pipelines/job_artifacts.md b/doc/user/project/pipelines/job_artifacts.md index f9a268fb789..f8675f77856 100644 --- a/doc/user/project/pipelines/job_artifacts.md +++ b/doc/user/project/pipelines/job_artifacts.md @@ -163,6 +163,14 @@ information in the UI. ![Latest artifacts button](img/job_latest_artifacts_browser.png) +## Validation for `dependency` keyword + +To disable [the validation for dependency], you can flip the feature flag from a rails console: + +``` +Feature.enable('ci_disable_validates_dependencies') +``` [expiry date]: ../../../ci/yaml/README.md#artifacts-expire_in +[the validation for dependency]: ../../../ci/yaml/README.md#dependencies [ce-14399]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14399 From 85151ff6f65fcb3076b554c563ccc2fa458a9ccb Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Thu, 7 Dec 2017 23:17:46 +0900 Subject: [PATCH 041/112] Change feature flag to ci_disable_validates_dependencies to enable it as default --- app/models/ci/build.rb | 2 +- spec/models/ci/build_spec.rb | 2 +- spec/services/ci/register_job_service_spec.rb | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index fbda0962a91..85960f1b6bb 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -143,7 +143,7 @@ module Ci end before_transition any => [:running] do |build| - build.validates_dependencies! if Feature.enabled?('ci_validates_dependencies') + build.validates_dependencies! unless Feature.enabled?('ci_disable_validates_dependencies') end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 96d9fba2a2d..3440ce7f1e8 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1870,7 +1870,7 @@ describe Ci::Build do describe 'state transition: any => [:running]' do before do - stub_feature_flags(ci_validates_dependencies: true) + stub_feature_flags(ci_disable_validates_dependencies: true) end let(:build) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 1, options: options) } diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 19e78faa591..16218b78fb8 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -278,7 +278,7 @@ module Ci context 'when "dependencies" keyword is specified' do before do - stub_feature_flags(ci_validates_dependencies: true) + stub_feature_flags(ci_disable_validates_dependencies: false) end let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: job_name, stage_idx: 0) } From de4f2a6e826ead22e776cca939d558cbe3bb3abf Mon Sep 17 00:00:00 2001 From: Marcia Ramos Date: Thu, 7 Dec 2017 15:09:22 +0000 Subject: [PATCH 042/112] Docs: admin index --- doc/README.md | 87 +++++--------------------- doc/administration/index.md | 121 ++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 73 deletions(-) create mode 100644 doc/administration/index.md diff --git a/doc/README.md b/doc/README.md index 3f9176103ea..6a426d33300 100644 --- a/doc/README.md +++ b/doc/README.md @@ -33,7 +33,7 @@ Shortcuts to GitLab's most visited docs: | [Using Docker images](ci/docker/using_docker_images.md) | [GitLab Pages](user/project/pages/index.md) | - [User documentation](user/index.md) -- [Administrator documentation](#administrator-documentation) +- [Administrator documentation](administration/index.md) - [Contributor documentation](#contributor-documentation) ## Getting started with GitLab @@ -134,83 +134,24 @@ Manage your [repositories](user/project/repository/index.md) from the UI (user i ## Administrator documentation -Learn how to administer your GitLab instance. Regular users don't -have access to GitLab administration tools and settings. +[Administration documentation](administration/index.md) applies to admin users of GitLab +self-hosted instances: -### Install, update, upgrade, migrate +- GitLab Community Edition +- GitLab [Enterprise Editions](https://about.gitlab.com/gitlab-ee/) + - Enterprise Edition Starter (EES) + - Enterprise Edition Premium (EEP) + - Enterprise Edition Ultimate (EEU) -- [Install](install/README.md): Requirements, directory structures and installation from source. -- [Mattermost](https://docs.gitlab.com/omnibus/gitlab-mattermost/): Integrate [Mattermost](https://about.mattermost.com/) with your GitLab installation. -- [Migrate GitLab CI to CE/EE](migrate_ci_to_ce/README.md): If you have an old GitLab installation (older than 8.0), follow this guide to migrate your existing GitLab CI data to GitLab CE/EE. -- [Restart GitLab](administration/restart_gitlab.md): Learn how to restart GitLab and its components. -- [Update](update/README.md): Update guides to upgrade your installation. - -### User permissions - -- [Access restrictions](user/admin_area/settings/visibility_and_access_controls.md#enabled-git-access-protocols): Define which Git access protocols can be used to talk to GitLab -- [Authentication/Authorization](topics/authentication/index.md#gitlab-administrators): Enforce 2FA, configure external authentication with LDAP, SAML, CAS and additional Omniauth providers. - -### Features - -- [Container Registry](administration/container_registry.md): Configure Docker Registry with GitLab. -- [Custom Git hooks](administration/custom_hooks.md): Custom Git hooks (on the filesystem) for when webhooks aren't enough. -- [Git LFS configuration](workflow/lfs/lfs_administration.md): Learn how to use LFS under GitLab. -- [GitLab Pages configuration](administration/pages/index.md): Configure GitLab Pages. -- [High Availability](administration/high_availability/README.md): Configure multiple servers for scaling or high availability. -- [User cohorts](user/admin_area/user_cohorts.md): View user activity over time. -- [Web terminals](administration/integration/terminal.md): Provide terminal access to environments from within GitLab. -- GitLab CI - - [CI admin settings](user/admin_area/settings/continuous_integration.md): Define max artifacts size and expiration time. - -### Integrations - -- [Integrations](integration/README.md): How to integrate with systems such as JIRA, Redmine, Twitter. -- [Mattermost](user/project/integrations/mattermost.md): Set up GitLab with Mattermost. - -### Monitoring - -- [GitLab performance monitoring with InfluxDB](administration/monitoring/performance/introduction.md): Configure GitLab and InfluxDB for measuring performance metrics. -- [GitLab performance monitoring with Prometheus](administration/monitoring/prometheus/index.md): Configure GitLab and Prometheus for measuring performance metrics. -- [Monitoring uptime](user/admin_area/monitoring/health_check.md): Check the server status using the health check endpoint. -- [Monitoring GitHub imports](administration/monitoring/github_imports.md) - -### Performance - -- [Housekeeping](administration/housekeeping.md): Keep your Git repository tidy and fast. -- [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 - -- [Adjust your instance's timezone](workflow/timezone.md): Customize the default time zone of GitLab. -- [Environment variables](administration/environment_variables.md): Supported environment variables that can be used to override their defaults values in order to configure GitLab. -- [Header logo](customization/branded_page_and_email_header.md): Change the logo on the overall page and email header. -- [Issue closing pattern](administration/issue_closing_pattern.md): Customize how to close an issue from commit messages. -- [Libravatar](customization/libravatar.md): Use Libravatar instead of Gravatar for user avatars. -- [Welcome message](customization/welcome_message.md): Add a custom welcome message to the sign-in page. -- [New project page](customization/new_project_page.md): Customize the new project page. - -### Admin tools - -- [Gitaly](administration/gitaly/index.md): Configuring Gitaly, GitLab's Git repository storage service -- [Raketasks](raketasks/README.md): Backups, maintenance, automatic webhook setup and the importing of projects. - - [Backup and restore](raketasks/backup_restore.md): Backup and restore your GitLab instance. -- [Reply by email](administration/reply_by_email.md): Allow users to comment on issues and merge requests by replying to notification emails. -- [Repository checks](administration/repository_checks.md): Periodic Git repository checks. -- [Repository storage paths](administration/repository_storage_paths.md): Manage the paths used to store repositories. -- [Security](security/README.md): Learn what you can do to further secure your GitLab instance. -- [System hooks](system_hooks/system_hooks.md): Notifications when users, projects and keys are changed. - -### Troubleshooting - -- [Debugging tips](administration/troubleshooting/debug.md): Tips to debug problems when things go wrong -- [Log system](administration/logs.md): Where to look for logs. -- [Sidekiq Troubleshooting](administration/troubleshooting/sidekiq.md): Debug when Sidekiq appears hung and is not processing jobs. +Learn how to install, configure, update, upgrade, integrate, and maintain your own instance. +Regular users don't have access to GitLab administration tools and settings. ## Contributor documentation +GitLab Community Edition is [opensource](https://gitlab.com/gitlab-org/gitlab-ce/) +and Enterprise Editions are [opencore](https://gitlab.com/gitlab-org/gitlab-ee/). +Learn how to contribute to GitLab: + - [Development](development/README.md): All styleguides and explanations how to contribute. - [Legal](legal/README.md): Contributor license agreements. - [Writing documentation](development/writing_documentation.md): Contributing to GitLab Docs. diff --git a/doc/administration/index.md b/doc/administration/index.md new file mode 100644 index 00000000000..c8d28d8485a --- /dev/null +++ b/doc/administration/index.md @@ -0,0 +1,121 @@ +# Administrator documentation + +Learn how to administer your GitLab instance (Community Edition and +[Enterprise Editions](https://about.gitlab.com/gitlab-ee/)). +Regular users don't have access to GitLab administration tools and settings. + +GitLab.com is administered by GitLab, Inc., therefore, only GitLab team members have +access to its admin configurations. If you're a GitLab.com user, please check the +[user documentation](../user/index.html). + +## Installing and maintaining GitLab + +Learn how to install, configure, update, and maintain your GitLab instance. + +### Installing GitLab + +- [Install](../install/README.md): Requirements, directory structures, and installation methods. +- [High Availability](high_availability/README.md): Configure multiple servers for scaling or high availability. + +### Configuring GitLab + +- [Adjust your instance's timezone](../workflow/timezone.md): Customize the default time zone of GitLab. +- [Header logo](../customization/branded_page_and_email_header.md): Change the logo on all pages and email headers. +- [Welcome message](../customization/welcome_message.md): Add a custom welcome message to the sign-in page. +- [System hooks](../system_hooks/system_hooks.md): Notifications when users, projects and keys are changed. +- [Security](../security/README.md): Learn what you can do to further secure your GitLab instance. +- [Usage statistics, version check, and usage ping](../user/admin_area/settings/usage_statistics.md): Enable or disable information about your instance to be sent to GitLab, Inc. +- [Polling](polling.md): Configure how often the GitLab UI polls for updates. +- [GitLab Pages configuration](pages/index.md): Enable and configure GitLab Pages. +- [GitLab Pages configuration for installations from the source](pages/source.md): Enable and configure GitLab Pages on +[source installations](../install/installation.md#installation-from-source). +- [Environment variables](environment_variables.md): Supported environment variables that can be used to override their defaults values in order to configure GitLab. + +### Maintaining GitLab + +- [Raketasks](../raketasks/README.md): Perform various tasks for maintenance, backups, automatic webhooks setup, etc. + - [Backup and restore](../raketasks/backup_restore.md): Backup and restore your GitLab instance. +- [Operations](operations.md): Keeping GitLab up and running (clean up Redis sessions, moving repositories, Sidekiq Job throttling, Sidekiq MemoryKiller, Unicorn). +- [Restart GitLab](restart_gitlab.md): Learn how to restart GitLab and its components. + +#### Updating GitLab + +- [GitLab versions and maintenance policy](../policy/maintenance.md): Understand GitLab versions and releases (Major, Minor, Patch, Security), as well as update recommendations. +- [Update GitLab](../update/README.md): Update guides to upgrade your installation to a new version. +- [Downtimeless updates](../update/README.md#upgrading-without-downtime): Upgrade to a newer major, minor, or patch version of GitLab without taking your GitLab instance offline. +- [Migrate your GitLab CI/CD data to another version of GitLab](../migrate_ci_to_ce/README.md): If you have an old GitLab installation (older than 8.0), follow this guide to migrate your existing GitLab CI/CD data to another version of GitLab. + +### Upgrading or downgrading GitLab + +- [Upgrade from GitLab CE to GitLab EE](../update/README.md#upgrading-between-editions): learn how to upgrade GitLab Community Edition to GitLab Enterprise Editions. +- [Downgrade from GitLab EE to GitLab CE](../downgrade_ee_to_ce/README.md): Learn how to downgrade GitLab Enterprise Editions to Community Edition. + +### GitLab platform integrations + +- [Mattermost](https://docs.gitlab.com/omnibus/gitlab-mattermost/): Integrate with [Mattermost](https://about.mattermost.com/), an open source, private cloud workplace for web messaging. +- [PlantUML](integration/plantuml.md): Create simple diagrams in AsciiDoc and Markdown documents +created in snippets, wikis, and repos. +- [Web terminals](integration/terminal.md): Provide terminal access to your applications deployed to Kubernetes from within GitLab's CI/CD [environments](../ci/environments.md#web-terminals). + +## User settings and permissions + +- [Libravatar](../customization/libravatar.md): Use Libravatar instead of Gravatar for user avatars. +- [Sign-up restrictions](../user/admin_area/settings/sign_up_restrictions.md): block email addresses of specific domains, or whitelist only specific domains. +- [Access restrictions](../user/admin_area/settings/visibility_and_access_controls.md#enabled-git-access-protocols): Define which Git access protocols can be used to talk to GitLab (SSH, HTTP, HTTPS). +- [Authentication/Authorization](../topics/authentication/index.md#gitlab-administrators): Enforce 2FA, configure external authentication with LDAP, SAML, CAS and additional Omniauth providers. +- [Reply by email](reply_by_email.md): Allow users to comment on issues and merge requests by replying to notification emails. + - [Postfix for Reply by email](reply_by_email_postfix_setup.md): Set up a basic Postfix mail +server with IMAP authentication on Ubuntu, to be used with Reply by email. +- [User Cohorts](../user/admin_area/user_cohorts.md): Display the monthly cohorts of new users and their activities over time. + +## Project settings + +- [Container Registry](container_registry.md): Configure Container Registry with GitLab. +- [Issue closing pattern](issue_closing_pattern.md): Customize how to close an issue from commit messages. +- [Gitaly](gitaly/index.md): Configuring Gitaly, GitLab's Git repository storage service. +- [Default labels](../user/admin_area/labels.html): Create labels that will be automatically added to every new project. + +### Repository settings + +- [Repository checks](repository_checks.md): Periodic Git repository checks. +- [Repository storage paths](repository_storage_paths.md): Manage the paths used to store repositories. +- [Repository storage rake tasks](raketasks/storage.md): A collection of rake tasks to list and migrate existing projects and attachments associated with it from Legacy storage to Hashed storage. + +## Continuous Integration settings + +- [Enable/disable GitLab CI/CD](../ci/enable_or_disable_ci.md#site-wide-admin-setting): Enable or disable GitLab CI/CD for your instance. +- [GitLab CI/CD admin settings](../user/admin_area/settings/continuous_integration.md): Define max artifacts size and expiration time. +- [Job artifacts](job_artifacts.md): Enable, disable, and configure job artifacts (a set of files and directories which are outputted by a job when it completes successfully). +- [Artifacts size and expiration](../user/admin_area/settings/continuous_integration.md#maximum-artifacts-size): Define maximum artifacts limits and expiration date. +- [Register Shared and specific Runners](../ci/runners/README.md#registering-a-shared-runner): Learn how to register and configure Shared and specific Runners to your own instance. +- [Shared Runners pipelines quota](../user/admin_area/settings/continuous_integration.md#shared-runners-pipeline-minutes-quota): Limit the usage of pipeline minutes for Shared Runners. +- [Enable/disable Auto DevOps](../topics/autodevops/index.md#enabling-auto-devops): Enable or disable Auto DevOps for your instance. + +## Git configuration options + +- [Custom Git hooks](custom_hooks.md): Custom Git hooks (on the filesystem) for when webhooks aren't enough. +- [Git LFS configuration](../workflow/lfs/lfs_administration.md): Learn how to configure LFS for GitLab. +- [Housekeeping](housekeeping.md): Keep your Git repositories tidy and fast. + +## Monitoring GitLab + +- [Monitoring uptime](../user/admin_area/monitoring/health_check.md): Check the server status using the health check endpoint. + - [IP whitelist](monitoring/ip_whitelist.md): Monitor endpoints that provide health check information when probed. +- [Monitoring GitHub imports](monitoring/github_imports.md): GitLab's GitHub Importer displays Prometheus metrics to monitor the health and progress of the importer. +- [Conversational Development (ConvDev) Index](../user/admin_area/monitoring/convdev.md): Provides an overview of your entire instance's feature usage. + +### Performance Monitoring + +- [GitLab Performance Monitoring](monitoring/performance/gitlab_configuration.md): Enable GitLab Performance Monitoring. +- [GitLab performance monitoring with InfluxDB](monitoring/performance/introduction.md): Configure GitLab and InfluxDB for measuring performance metrics. + - [InfluxDB Schema](monitoring/performance/influxdb_schema.md): Measurements stored in InfluxDB. +- [GitLab performance monitoring with Prometheus](monitoring/prometheus/index.md): Configure GitLab and Prometheus for measuring performance metrics. +- [GitLab performance monitoring with Grafana](monitoring/prometheus/index.md): Configure GitLab to visualize time series metrics through graphs and dashboards. +- [Request Profiling](monitoring/performance/request_profiling.md): Get a detailed profile on slow requests. +- [Performance Bar](monitoring/performance/performance_bar.md): Get performance information for the current page. + +## Troubleshooting + +- [Debugging tips](troubleshooting/debug.md): Tips to debug problems when things go wrong +- [Log system](logs.md): Where to look for logs. +- [Sidekiq Troubleshooting](troubleshooting/sidekiq.md): Debug when Sidekiq appears hung and is not processing jobs. From d5646318aadca88a757783e177c5dc901e9bc19c Mon Sep 17 00:00:00 2001 From: Marcia Ramos Date: Thu, 7 Dec 2017 15:28:00 +0000 Subject: [PATCH 043/112] Docs: refactor Pages index --- doc/user/project/pages/index.md | 105 ++++++++++++++++++++------------ 1 file changed, 67 insertions(+), 38 deletions(-) diff --git a/doc/user/project/pages/index.md b/doc/user/project/pages/index.md index abe6b4cbd8e..8404d789de6 100644 --- a/doc/user/project/pages/index.md +++ b/doc/user/project/pages/index.md @@ -1,49 +1,78 @@ -# GitLab Pages documentation +# GitLab Pages -With GitLab Pages you can create static websites for your GitLab projects, -groups, or user accounts. You can use any static website generator: Jekyll, -Middleman, Hexo, Hugo, Pelican, you name it! Connect as many customs domains -as you like and bring your own TLS certificate to secure them. +With GitLab Pages you can host your website at no cost. -Here's some info we've gathered to get you started. +Your files live in a GitLab project's [repository](../repository/index.md), +from which you can deploy [static websites](#explore-gitlab-pages). +GitLab Pages supports all static site generators (SSGs). -## General info +## Getting Started -- [Product webpage](https://pages.gitlab.io) -- ["We're bringing GitLab Pages to CE"](https://about.gitlab.com/2016/12/24/were-bringing-gitlab-pages-to-community-edition/) -- [Pages group - templates](https://gitlab.com/pages) -- [General user documentation](introduction.md) -- [Admin documentation - Set GitLab Pages on your own GitLab instance](../../../administration/pages/index.md) -- ["We are changing the IP of GitLab Pages on GitLab.com"](https://about.gitlab.com/2017/03/06/we-are-changing-the-ip-of-gitlab-pages-on-gitlab-com/) +Follow the steps below to get your website live. They shouldn't take more than +5 minutes to complete: -## Getting started +- 1. [Fork](../../../gitlab-basics/fork-project.md#how-to-fork-a-project) an [example project](https://gitlab.com/pages) +- 2. Change a file to trigger a GitLab CI/CD pipeline +- 3. Visit your project's **Settings > Pages** to see your **website link**, and click on it. Bam! Your website is live. -- **GitLab Pages from A to Z** - - [Part 1: Static sites and GitLab Pages domains](getting_started_part_one.md) - - [Part 2: Quick start guide - Setting up GitLab Pages](getting_started_part_two.md) - - [Part 3: Setting Up Custom Domains - DNS Records and SSL/TLS Certificates](getting_started_part_three.md) - - [Part 4: Creating and tweaking `.gitlab-ci.yml` for GitLab Pages](getting_started_part_four.md) -- **Static Site Generators - Blog posts series** - - [SSGs part 1: Static vs dynamic websites](https://about.gitlab.com/2016/06/03/ssg-overview-gitlab-pages-part-1-dynamic-x-static/) - - [SSGs part 2: Modern static site generators](https://about.gitlab.com/2016/06/10/ssg-overview-gitlab-pages-part-2/) - - [SSGs part 3: Build any SSG site with GitLab Pages](https://about.gitlab.com/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/) -- **Secure GitLab Pages custom domain with SSL/TLS certificates** - - [Let's Encrypt](https://about.gitlab.com/2016/04/11/tutorial-securing-your-gitlab-pages-with-tls-and-letsencrypt/) - - [CloudFlare](https://about.gitlab.com/2017/02/07/setting-up-gitlab-pages-with-cloudflare-certificates/) - - [StartSSL](https://about.gitlab.com/2016/06/24/secure-gitlab-pages-with-startssl/) -- **General** - - [Hosting on GitLab.com with GitLab Pages](https://about.gitlab.com/2016/04/07/gitlab-pages-setup/) a comprehensive step-by-step guide - - [Posting to your GitLab Pages blog from iOS](https://about.gitlab.com/2016/08/19/posting-to-your-gitlab-pages-blog-from-ios/) +_Further steps (optional):_ -## Video tutorials +- 4. Remove the [fork relationship](getting_started_part_two.md#fork-a-project-to-get-started-from) (_You don't need the relationship unless you intent to contribute back to the example project you forked from_). +- 5. Make it a [user/group website](getting_started_part_one.md#user-and-group-websites) -- [How to publish a website with GitLab Pages on GitLab.com: from a forked project](https://youtu.be/TWqh9MtT4Bg) -- [How to Enable GitLab Pages for GitLab CE and EE (for Admins only)](https://youtu.be/dD8c7WNcc6s) +**Watch a video with the steps above: https://www.youtube.com/watch?v=TWqh9MtT4Bg** + +_Advanced options:_ + +- [Use a custom domain](getting_started_part_three.md#adding-your-custom-domain-to-gitlab-pages) +- Apply [SSL/TLS certification](getting_started_part_three.md#ssl-tls-certificates) to your custom domain + +## Explore GitLab Pages + +With GitLab Pages you can create [static websites](getting_started_part_one.md#what-you-need-to-know-before-getting-started) +for your GitLab projects, groups, or user accounts. You can use any static +website generator: Jekyll, Middleman, Hexo, Hugo, Pelican, you name it! +Connect as many custom domains as you like and bring your own TLS certificate +to secure them. + +Read the following tutorials to know more about: + +- [Static websites and GitLab Pages domains](getting_started_part_one.md) +- [Forking projects and creating new ones from scratch, URLs and baseurls](getting_started_part_two.md) +- [Custom domains and subdomains, DNS records, SSL/TLS certificates](getting_started_part_three.md) +- [How to create your own `.gitlab-ci.yml` for your site](getting_started_part_four.md) +- [Technical aspects, custom 404 pages, limitations](introduction.md) +- [Hosting on GitLab.com with GitLab Pages](https://about.gitlab.com/2016/04/07/gitlab-pages-setup/) (outdated) + +_Blog posts series about Static Site Generators (SSGs):_ + +- [SSGs part 1: Static vs dynamic websites](https://about.gitlab.com/2016/06/03/ssg-overview-gitlab-pages-part-1-dynamic-x-static/) +- [SSGs part 2: Modern static site generators](https://about.gitlab.com/2016/06/10/ssg-overview-gitlab-pages-part-2/) +- [SSGs part 3: Build any SSG site with GitLab Pages](https://about.gitlab.com/2016/06/17/ssg-overview-gitlab-pages-part-3-examples-ci/) + +_Blog posts for securing GitLab Pages custom domains with SSL/TLS certificates:_ + +- [CloudFlare](https://about.gitlab.com/2017/02/07/setting-up-gitlab-pages-with-cloudflare-certificates/) +- [Let's Encrypt](https://about.gitlab.com/2016/04/11/tutorial-securing-your-gitlab-pages-with-tls-and-letsencrypt/) (outdated) +- [StartSSL](https://about.gitlab.com/2016/06/24/secure-gitlab-pages-with-startssl/) (deprecated) ## Advanced use -- **Blog Posts** - - [GitLab CI: Run jobs sequentially, in parallel, or build a custom pipeline](https://about.gitlab.com/2016/07/29/the-basics-of-gitlab-ci/) - - [GitLab CI: Deployment & environments](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/) - - [Building a new GitLab docs site with Nanoc, GitLab CI, and GitLab Pages](https://about.gitlab.com/2016/12/07/building-a-new-gitlab-docs-site-with-nanoc-gitlab-ci-and-gitlab-pages/) - - [Publish code coverage reports with GitLab Pages](https://about.gitlab.com/2016/11/03/publish-code-coverage-report-with-gitlab-pages/) +- [Posting to your GitLab Pages blog from iOS](https://about.gitlab.com/2016/08/19/posting-to-your-gitlab-pages-blog-from-ios/) +- [GitLab CI: Run jobs sequentially, in parallel, or build a custom pipeline](https://about.gitlab.com/2016/07/29/the-basics-of-gitlab-ci/) +- [GitLab CI: Deployment & environments](https://about.gitlab.com/2016/08/26/ci-deployment-and-environments/) +- [Building a new GitLab docs site with Nanoc, GitLab CI, and GitLab Pages](https://about.gitlab.com/2016/12/07/building-a-new-gitlab-docs-site-with-nanoc-gitlab-ci-and-gitlab-pages/) +- [Publish code coverage reports with GitLab Pages](https://about.gitlab.com/2016/11/03/publish-code-coverage-report-with-gitlab-pages/) + +## Admin GitLab Pages for CE and EE + +Enable and configure GitLab Pages on your own instance (GitLab Community Edition and Enterprise Editions) with +the [admin guide](../../../administration/pages/index.md). + +**Watch the video: https://www.youtube.com/watch?v=dD8c7WNcc6s** + +## More information about GitLab Pages + +- For an overview, visit the [feature webpage](https://about.gitlab.com/features/pages/) +- Announcement (2016-12-24): ["We're bringing GitLab Pages to CE"](https://about.gitlab.com/2016/12/24/were-bringing-gitlab-pages-to-community-edition/) +- Announcement (2017-03-06): ["We are changing the IP of GitLab Pages on GitLab.com"](https://about.gitlab.com/2017/03/06/we-are-changing-the-ip-of-gitlab-pages-on-gitlab-com/) From 03ac8d5d0b8331a2ecfae05137e02c78428fa899 Mon Sep 17 00:00:00 2001 From: Zeger-Jan van de Weg Date: Thu, 7 Dec 2017 15:33:30 +0000 Subject: [PATCH 044/112] Remove Rugged::Repository#empty? --- app/models/project.rb | 5 +- app/models/repository.rb | 17 ++- lib/backup/repository.rb | 9 +- lib/gitlab/git/operation_service.rb | 2 +- lib/gitlab/git/remote_repository.rb | 6 +- lib/gitlab/git/repository.rb | 32 ++--- lib/gitlab/gitaly_client.rb | 2 +- spec/lib/{gitlab => }/backup/manager_spec.rb | 0 spec/lib/backup/repository_spec.rb | 69 +++++++++++ spec/lib/gitlab/backup/repository_spec.rb | 117 ------------------ spec/lib/gitlab/git/remote_repository_spec.rb | 4 +- spec/lib/gitlab/git/repository_spec.rb | 2 +- spec/models/project_spec.rb | 19 ++- spec/models/repository_spec.rb | 12 +- spec/tasks/gitlab/backup_rake_spec.rb | 2 +- 15 files changed, 127 insertions(+), 171 deletions(-) rename spec/lib/{gitlab => }/backup/manager_spec.rb (100%) create mode 100644 spec/lib/backup/repository_spec.rb delete mode 100644 spec/lib/gitlab/backup/repository_spec.rb diff --git a/app/models/project.rb b/app/models/project.rb index 41657c171e2..6ae15a0a50f 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -227,7 +227,6 @@ class Project < ActiveRecord::Base delegate :members, to: :team, prefix: true delegate :add_user, :add_users, to: :team delegate :add_guest, :add_reporter, :add_developer, :add_master, to: :team - delegate :empty_repo?, to: :repository # Validations validates :creator, presence: true, on: :create @@ -499,6 +498,10 @@ class Project < ActiveRecord::Base auto_devops&.enabled.nil? && !current_application_settings.auto_devops_enabled? end + def empty_repo? + repository.empty? + end + def repository_storage_path Gitlab.config.repositories.storages[repository_storage].try(:[], 'path') end diff --git a/app/models/repository.rb b/app/models/repository.rb index 82af299ec5e..751306188a0 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -37,7 +37,7 @@ class Repository issue_template_names merge_request_template_names).freeze # Methods that use cache_method but only memoize the value - MEMOIZED_CACHED_METHODS = %i(license empty_repo?).freeze + MEMOIZED_CACHED_METHODS = %i(license).freeze # Certain method caches should be refreshed when certain types of files are # changed. This Hash maps file types (as returned by Gitlab::FileDetector) to @@ -497,7 +497,11 @@ class Repository end cache_method :exists? - delegate :empty?, to: :raw_repository + def empty? + return true unless exists? + + !has_visible_content? + end cache_method :empty? # The size of this repository in megabytes. @@ -944,13 +948,8 @@ class Repository end end - def empty_repo? - !exists? || !has_visible_content? - end - cache_method :empty_repo?, memoize_only: true - def search_files_by_content(query, ref) - return [] if empty_repo? || query.blank? + return [] if empty? || query.blank? offset = 2 args = %W(grep -i -I -n --before-context #{offset} --after-context #{offset} -E -e #{Regexp.escape(query)} #{ref || root_ref}) @@ -959,7 +958,7 @@ class Repository end def search_files_by_name(query, ref) - return [] if empty_repo? || query.blank? + return [] if empty? || query.blank? args = %W(ls-tree --full-tree -r #{ref || root_ref} --name-status | #{Regexp.escape(query)}) diff --git a/lib/backup/repository.rb b/lib/backup/repository.rb index b6d273b98c2..2a04c03919d 100644 --- a/lib/backup/repository.rb +++ b/lib/backup/repository.rb @@ -193,12 +193,9 @@ module Backup end def empty_repo?(project_or_wiki) - project_or_wiki.repository.expire_exists_cache # protect backups from stale cache - project_or_wiki.repository.empty_repo? - rescue => e - progress.puts "Ignoring repository error and continuing backing up project: #{display_repo_path(project_or_wiki)} - #{e.message}".color(:orange) - - false + # Protect against stale caches + project_or_wiki.repository.expire_emptiness_caches + project_or_wiki.repository.empty? end def repository_storage_paths_args diff --git a/lib/gitlab/git/operation_service.rb b/lib/gitlab/git/operation_service.rb index e36d5410431..7e8fe173056 100644 --- a/lib/gitlab/git/operation_service.rb +++ b/lib/gitlab/git/operation_service.rb @@ -83,7 +83,7 @@ module Gitlab Gitlab::Git.check_namespace!(start_repository) start_repository = RemoteRepository.new(start_repository) unless start_repository.is_a?(RemoteRepository) - start_branch_name = nil if start_repository.empty_repo? + start_branch_name = nil if start_repository.empty? if start_branch_name && !start_repository.branch_exists?(start_branch_name) raise ArgumentError, "Cannot find branch #{start_branch_name} in #{start_repository.relative_path}" diff --git a/lib/gitlab/git/remote_repository.rb b/lib/gitlab/git/remote_repository.rb index 3685aa20669..6bd6e58feeb 100644 --- a/lib/gitlab/git/remote_repository.rb +++ b/lib/gitlab/git/remote_repository.rb @@ -24,10 +24,12 @@ module Gitlab @path = repository.path end - def empty_repo? + def empty? # We will override this implementation in gitaly-ruby because we cannot # use '@repository' there. - @repository.empty_repo? + # + # Caches and memoization used on the Rails side + !@repository.exists? || @repository.empty? end def commit_id(revision) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 1468069a991..91dd2fbbdbc 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -75,9 +75,6 @@ module Gitlab @attributes = Gitlab::Git::Attributes.new(path) end - delegate :empty?, - to: :rugged - def ==(other) path == other.path end @@ -206,6 +203,13 @@ module Gitlab end end + # Git repository can contains some hidden refs like: + # /refs/notes/* + # /refs/git-as-svn/* + # /refs/pulls/* + # This refs by default not visible in project page and not cloned to client side. + alias_method :has_visible_content?, :has_local_branches? + def has_local_branches_rugged? rugged.branches.each(:local).any? do |ref| begin @@ -1004,7 +1008,7 @@ module Gitlab Gitlab::Git.check_namespace!(start_repository) start_repository = RemoteRepository.new(start_repository) unless start_repository.is_a?(RemoteRepository) - return yield nil if start_repository.empty_repo? + return yield nil if start_repository.empty? if start_repository.same_repository?(self) yield commit(start_branch_name) @@ -1120,24 +1124,8 @@ module Gitlab Gitlab::Git::Commit.find(self, ref) end - # Refactoring aid; allows us to copy code from app/models/repository.rb - def empty_repo? - !exists? || !has_visible_content? - end - - # - # Git repository can contains some hidden refs like: - # /refs/notes/* - # /refs/git-as-svn/* - # /refs/pulls/* - # This refs by default not visible in project page and not cloned to client side. - # - # This method return true if repository contains some content visible in project page. - # - def has_visible_content? - return @has_visible_content if defined?(@has_visible_content) - - @has_visible_content = has_local_branches? + def empty? + !has_visible_content? end # Like all public `Gitlab::Git::Repository` methods, this method is part diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index 1fe938a39a8..b753ac46291 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -27,7 +27,7 @@ module Gitlab end SERVER_VERSION_FILE = 'GITALY_SERVER_VERSION'.freeze - MAXIMUM_GITALY_CALLS = 30 + MAXIMUM_GITALY_CALLS = 35 CLIENT_NAME = (Sidekiq.server? ? 'gitlab-sidekiq' : 'gitlab-web').freeze MUTEX = Mutex.new diff --git a/spec/lib/gitlab/backup/manager_spec.rb b/spec/lib/backup/manager_spec.rb similarity index 100% rename from spec/lib/gitlab/backup/manager_spec.rb rename to spec/lib/backup/manager_spec.rb diff --git a/spec/lib/backup/repository_spec.rb b/spec/lib/backup/repository_spec.rb new file mode 100644 index 00000000000..6ee3d531d6e --- /dev/null +++ b/spec/lib/backup/repository_spec.rb @@ -0,0 +1,69 @@ +require 'spec_helper' + +describe Backup::Repository do + let(:progress) { StringIO.new } + let!(:project) { create(:project) } + + before do + allow(progress).to receive(:puts) + allow(progress).to receive(:print) + + allow_any_instance_of(String).to receive(:color) do |string, _color| + string + end + + allow_any_instance_of(described_class).to receive(:progress).and_return(progress) + end + + describe '#dump' do + describe 'repo failure' do + before do + allow(Gitlab::Popen).to receive(:popen).and_return(['normal output', 0]) + end + + it 'does not raise error' do + expect { described_class.new.dump }.not_to raise_error + end + end + end + + describe '#restore' do + describe 'command failure' do + before do + allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1]) + end + + it 'shows the appropriate error' do + described_class.new.restore + + expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error") + end + end + end + + describe '#empty_repo?' do + context 'for a wiki' do + let(:wiki) { create(:project_wiki) } + + it 'invalidates the emptiness cache' do + expect(wiki.repository).to receive(:expire_emptiness_caches).once + + wiki.empty? + end + + context 'wiki repo has content' do + let!(:wiki_page) { create(:wiki_page, wiki: wiki) } + + it 'returns true, regardless of bad cache value' do + expect(described_class.new.send(:empty_repo?, wiki)).to be(false) + end + end + + context 'wiki repo does not have content' do + it 'returns true, regardless of bad cache value' do + expect(described_class.new.send(:empty_repo?, wiki)).to be_truthy + end + end + end + end +end diff --git a/spec/lib/gitlab/backup/repository_spec.rb b/spec/lib/gitlab/backup/repository_spec.rb deleted file mode 100644 index 535cce12780..00000000000 --- a/spec/lib/gitlab/backup/repository_spec.rb +++ /dev/null @@ -1,117 +0,0 @@ -require 'spec_helper' - -describe Backup::Repository do - let(:progress) { StringIO.new } - let!(:project) { create(:project) } - - before do - allow(progress).to receive(:puts) - allow(progress).to receive(:print) - - allow_any_instance_of(String).to receive(:color) do |string, _color| - string - end - - allow_any_instance_of(described_class).to receive(:progress).and_return(progress) - end - - describe '#dump' do - describe 'repo failure' do - before do - allow_any_instance_of(Repository).to receive(:empty_repo?).and_raise(Rugged::OdbError) - allow(Gitlab::Popen).to receive(:popen).and_return(['normal output', 0]) - end - - it 'does not raise error' do - expect { described_class.new.dump }.not_to raise_error - end - - it 'shows the appropriate error' do - described_class.new.dump - - expect(progress).to have_received(:puts).with("Ignoring repository error and continuing backing up project: #{project.full_path} - Rugged::OdbError") - end - end - - describe 'command failure' do - before do - allow_any_instance_of(Repository).to receive(:empty_repo?).and_return(false) - allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1]) - end - - it 'shows the appropriate error' do - described_class.new.dump - - expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error") - end - end - end - - describe '#restore' do - describe 'command failure' do - before do - allow(Gitlab::Popen).to receive(:popen).and_return(['error', 1]) - end - - it 'shows the appropriate error' do - described_class.new.restore - - expect(progress).to have_received(:puts).with("Ignoring error on #{project.full_path} - error") - end - end - end - - describe '#empty_repo?' do - context 'for a wiki' do - let(:wiki) { create(:project_wiki) } - - context 'wiki repo has content' do - let!(:wiki_page) { create(:wiki_page, wiki: wiki) } - - before do - wiki.repository.exists? # initial cache - end - - context '`repository.exists?` is incorrectly cached as false' do - before do - repo = wiki.repository - repo.send(:cache).expire(:exists?) - repo.send(:cache).fetch(:exists?) { false } - repo.send(:instance_variable_set, :@exists, false) - end - - it 'returns false, regardless of bad cache value' do - expect(described_class.new.send(:empty_repo?, wiki)).to be_falsey - end - end - - context '`repository.exists?` is correctly cached as true' do - it 'returns false' do - expect(described_class.new.send(:empty_repo?, wiki)).to be_falsey - end - end - end - - context 'wiki repo does not have content' do - context '`repository.exists?` is incorrectly cached as true' do - before do - repo = wiki.repository - repo.send(:cache).expire(:exists?) - repo.send(:cache).fetch(:exists?) { true } - repo.send(:instance_variable_set, :@exists, true) - end - - it 'returns true, regardless of bad cache value' do - expect(described_class.new.send(:empty_repo?, wiki)).to be_truthy - end - end - - context '`repository.exists?` is correctly cached as false' do - it 'returns true' do - expect(described_class.new.send(:empty_repo?, wiki)).to be_truthy - end - end - end - end - end -end diff --git a/spec/lib/gitlab/git/remote_repository_spec.rb b/spec/lib/gitlab/git/remote_repository_spec.rb index 0506210887c..eb148cc3804 100644 --- a/spec/lib/gitlab/git/remote_repository_spec.rb +++ b/spec/lib/gitlab/git/remote_repository_spec.rb @@ -4,7 +4,7 @@ describe Gitlab::Git::RemoteRepository, seed_helper: true do let(:repository) { Gitlab::Git::Repository.new('default', TEST_REPO_PATH, '') } subject { described_class.new(repository) } - describe '#empty_repo?' do + describe '#empty?' do using RSpec::Parameterized::TableSyntax where(:repository, :result) do @@ -13,7 +13,7 @@ describe Gitlab::Git::RemoteRepository, seed_helper: true do end with_them do - it { expect(subject.empty_repo?).to eq(result) } + it { expect(subject.empty?).to eq(result) } end end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 08dd6ea80ff..f19b65a5f71 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -257,7 +257,7 @@ describe Gitlab::Git::Repository, seed_helper: true do end describe '#empty?' do - it { expect(repository.empty?).to be_falsey } + it { expect(repository).not_to be_empty } end describe '#ref_names' do diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index bda1d1cb612..f4699fd243d 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -313,7 +313,6 @@ describe Project do it { is_expected.to delegate_method(method).to(:team) } end - it { is_expected.to delegate_method(:empty_repo?).to(:repository) } it { is_expected.to delegate_method(:members).to(:team).with_prefix(true) } it { is_expected.to delegate_method(:name).to(:owner).with_prefix(true).with_arguments(allow_nil: true) } end @@ -656,6 +655,24 @@ describe Project do end end + describe '#empty_repo?' do + context 'when the repo does not exist' do + let(:project) { build_stubbed(:project) } + + it 'returns true' do + expect(project.empty_repo?).to be(true) + end + end + + context 'when the repo exists' do + let(:project) { create(:project, :repository) } + let(:empty_project) { create(:project, :empty_repo) } + + it { expect(empty_project.empty_repo?).to be(true) } + it { expect(project.empty_repo?).to be(false) } + end + end + describe '#external_issue_tracker' do let(:project) { create(:project) } let(:ext_project) { create(:redmine_project) } diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index d37e3d2c527..82ed1ecee33 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -583,7 +583,7 @@ describe Repository do end it 'properly handles query when repo is empty' do - repository = create(:project).repository + repository = create(:project, :empty_repo).repository results = repository.search_files_by_content('test', 'master') expect(results).to match_array([]) @@ -619,7 +619,7 @@ describe Repository do end it 'properly handles query when repo is empty' do - repository = create(:project).repository + repository = create(:project, :empty_repo).repository results = repository.search_files_by_name('test', 'master') @@ -1204,17 +1204,15 @@ describe Repository do let(:empty_repository) { create(:project_empty_repo).repository } it 'returns true for an empty repository' do - expect(empty_repository.empty?).to eq(true) + expect(empty_repository).to be_empty end it 'returns false for a non-empty repository' do - expect(repository.empty?).to eq(false) + expect(repository).not_to be_empty end it 'caches the output' do - expect(repository.raw_repository).to receive(:empty?) - .once - .and_return(false) + expect(repository.raw_repository).to receive(:has_visible_content?).once repository.empty? repository.empty? diff --git a/spec/tasks/gitlab/backup_rake_spec.rb b/spec/tasks/gitlab/backup_rake_spec.rb index bf2e11bc360..b41c3b3958a 100644 --- a/spec/tasks/gitlab/backup_rake_spec.rb +++ b/spec/tasks/gitlab/backup_rake_spec.rb @@ -212,7 +212,7 @@ describe 'gitlab:app namespace rake task' do # Avoid asking gitaly about the root ref (which will fail beacuse of the # mocked storages) - allow_any_instance_of(Repository).to receive(:empty_repo?).and_return(false) + allow_any_instance_of(Repository).to receive(:empty?).and_return(false) end after do From daf9357aa92283e6cdd0d1e0cade65c8e2294540 Mon Sep 17 00:00:00 2001 From: "micael.bergeron" Date: Thu, 7 Dec 2017 10:35:56 -0500 Subject: [PATCH 045/112] fix the missing reference to #references_per_project --- lib/banzai/filter/commit_reference_filter.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/banzai/filter/commit_reference_filter.rb b/lib/banzai/filter/commit_reference_filter.rb index c202d71072e..eedb95197aa 100644 --- a/lib/banzai/filter/commit_reference_filter.rb +++ b/lib/banzai/filter/commit_reference_filter.rb @@ -26,7 +26,7 @@ module Banzai return [] unless noteable.is_a?(MergeRequest) @referenced_merge_request_commit_shas ||= begin - referenced_shas = references_per_project.values.reduce(:|).to_a + referenced_shas = references_per_parent.values.reduce(:|).to_a noteable.all_commit_shas.select do |sha| referenced_shas.any? { |ref| Gitlab::Git.shas_eql?(sha, ref) } end From 7af56500a1e1b6437a2fa27988a5f11a4eb54391 Mon Sep 17 00:00:00 2001 From: James Lopez Date: Thu, 7 Dec 2017 17:13:40 +0100 Subject: [PATCH 046/112] refactor code to match EE changes --- lib/gitlab/git_access.rb | 6 +++++- lib/gitlab/git_access_wiki.rb | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 8998c4b1a83..9d7d921bb9c 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -166,7 +166,7 @@ module Gitlab end if Gitlab::Database.read_only? - raise UnauthorizedError, ERROR_MESSAGES[:cannot_push_to_read_only] + raise UnauthorizedError, push_to_read_only_message end if deploy_key @@ -280,5 +280,9 @@ module Gitlab UserAccess.new(user, project: project) end end + + def push_to_read_only_message + ERROR_MESSAGES[:cannot_push_to_read_only] + end end end diff --git a/lib/gitlab/git_access_wiki.rb b/lib/gitlab/git_access_wiki.rb index 98f1f45b338..1c9477e84b2 100644 --- a/lib/gitlab/git_access_wiki.rb +++ b/lib/gitlab/git_access_wiki.rb @@ -19,10 +19,14 @@ module Gitlab end if Gitlab::Database.read_only? - raise UnauthorizedError, ERROR_MESSAGES[:read_only] + raise UnauthorizedError, push_to_read_only_message end true end + + def push_to_read_only_message + ERROR_MESSAGES[:read_only] + end end end From ee22a47d629fb13a52100921761f833acd80dbd9 Mon Sep 17 00:00:00 2001 From: Pawel Chojnacki Date: Thu, 7 Dec 2017 17:47:23 +0100 Subject: [PATCH 047/112] Update prometheus-client-mmap gem to highly optimized version + change string concatenation to help with GC pressure. + fix metric producing incompatible label sets --- Gemfile.lock | 4 +--- app/services/metrics_service.rb | 2 +- config/initializers/7_prometheus_metrics.rb | 9 +-------- lib/gitlab/metrics/samplers/ruby_sampler.rb | 6 +++--- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index 379f2a4be53..6213167ae0b 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -486,7 +486,6 @@ GEM mini_mime (0.1.4) mini_portile2 (2.3.0) minitest (5.7.0) - mmap2 (2.2.9) mousetrap-rails (1.4.6) multi_json (1.12.2) multi_xml (0.6.0) @@ -623,8 +622,7 @@ GEM parser unparser procto (0.0.3) - prometheus-client-mmap (0.7.0.beta39) - mmap2 (~> 2.2, >= 2.2.9) + prometheus-client-mmap (0.7.0.beta43) pry (0.10.4) coderay (~> 1.1.0) method_source (~> 0.8.1) diff --git a/app/services/metrics_service.rb b/app/services/metrics_service.rb index 6b3939aeba5..236e9fe8c44 100644 --- a/app/services/metrics_service.rb +++ b/app/services/metrics_service.rb @@ -20,7 +20,7 @@ class MetricsService end def metrics_text - "#{health_metrics_text}#{prometheus_metrics_text}" + prometheus_metrics_text.concat(health_metrics_text) end private diff --git a/config/initializers/7_prometheus_metrics.rb b/config/initializers/7_prometheus_metrics.rb index 43b1e943897..eb7959e4da6 100644 --- a/config/initializers/7_prometheus_metrics.rb +++ b/config/initializers/7_prometheus_metrics.rb @@ -11,14 +11,7 @@ Prometheus::Client.configure do |config| config.multiprocess_files_dir ||= Rails.root.join('tmp/prometheus_multiproc_dir') end - config.pid_provider = -> do - worker_id = Prometheus::Client::Support::Unicorn.worker_id - if worker_id.nil? - "process_pid_#{Process.pid}" - else - "worker_id_#{worker_id}" - end - end + config.pid_provider = Prometheus::Client::Support::Unicorn.method(:worker_pid_provider) end Gitlab::Application.configure do |config| diff --git a/lib/gitlab/metrics/samplers/ruby_sampler.rb b/lib/gitlab/metrics/samplers/ruby_sampler.rb index 436a9e9550d..f4901be9581 100644 --- a/lib/gitlab/metrics/samplers/ruby_sampler.rb +++ b/lib/gitlab/metrics/samplers/ruby_sampler.rb @@ -32,7 +32,7 @@ module Gitlab def init_metrics metrics = {} - metrics[:sampler_duration] = Metrics.histogram(with_prefix(:sampler_duration, :seconds), 'Sampler time', {}) + metrics[:sampler_duration] = Metrics.histogram(with_prefix(:sampler_duration, :seconds), 'Sampler time', { worker: nil }) metrics[:total_time] = Metrics.gauge(with_prefix(:gc, :time_total), 'Total GC time', labels, :livesum) GC.stat.keys.each do |key| metrics[key] = Metrics.gauge(with_prefix(:gc, key), to_doc_string(key), labels, :livesum) @@ -100,9 +100,9 @@ module Gitlab worker_no = ::Prometheus::Client::Support::Unicorn.worker_id if worker_no - { unicorn: worker_no } + { worker: worker_no } else - { unicorn: 'master' } + { worker: 'master' } end end end From 95ed3c41ad1b616b45914e774874276ba06ef624 Mon Sep 17 00:00:00 2001 From: Jacob Vosmaer Date: Thu, 7 Dec 2017 18:02:46 +0100 Subject: [PATCH 048/112] Use Gitaly 0.59.0 --- GITALY_SERVER_VERSION | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 46448c71b9d..cb6b534abe1 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.57.0 +0.59.0 From 37f0ee0466c1f9f705584895ec441a744433b847 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray Date: Thu, 7 Dec 2017 10:16:38 -0700 Subject: [PATCH 049/112] Animate contextual sidebar; change class names for easier readability --- app/assets/javascripts/contextual_sidebar.js | 8 ++-- app/assets/javascripts/fly_out_nav.js | 2 +- .../framework/contextual-sidebar.scss | 38 +++++++++++++------ .../stylesheets/framework/variables.scss | 3 +- app/assets/stylesheets/pages/boards.scss | 6 +-- app/assets/stylesheets/pages/issuable.scss | 2 +- .../layouts/nav/sidebar/_admin.html.haml | 2 +- .../layouts/nav/sidebar/_group.html.haml | 2 +- .../layouts/nav/sidebar/_profile.html.haml | 2 +- .../layouts/nav/sidebar/_project.html.haml | 2 +- .../shared/_sidebar_toggle_button.html.haml | 15 ++++---- spec/javascripts/fly_out_nav_spec.js | 4 +- 12 files changed, 51 insertions(+), 35 deletions(-) diff --git a/app/assets/javascripts/contextual_sidebar.js b/app/assets/javascripts/contextual_sidebar.js index 46b68ebe158..cd20dde2951 100644 --- a/app/assets/javascripts/contextual_sidebar.js +++ b/app/assets/javascripts/contextual_sidebar.js @@ -28,7 +28,7 @@ export default class ContextualSidebar { this.$closeSidebar.on('click', () => this.toggleSidebarNav(false)); this.$overlay.on('click', () => this.toggleSidebarNav(false)); this.$sidebarToggle.on('click', () => { - const value = !this.$sidebar.hasClass('sidebar-icons-only'); + const value = !this.$sidebar.hasClass('sidebar-collapsed-desktop'); this.toggleCollapsedSidebar(value); }); @@ -43,16 +43,16 @@ export default class ContextualSidebar { } toggleSidebarNav(show) { - this.$sidebar.toggleClass('nav-sidebar-expanded', show); + this.$sidebar.toggleClass('sidebar-expanded-mobile', show); this.$overlay.toggleClass('mobile-nav-open', show); - this.$sidebar.removeClass('sidebar-icons-only'); + this.$sidebar.removeClass('sidebar-collapsed-desktop'); } toggleCollapsedSidebar(collapsed) { const breakpoint = bp.getBreakpointSize(); if (this.$sidebar.length) { - this.$sidebar.toggleClass('sidebar-icons-only', collapsed); + this.$sidebar.toggleClass('sidebar-collapsed-desktop', collapsed); this.$page.toggleClass('page-with-icon-sidebar', breakpoint === 'sm' ? true : collapsed); } ContextualSidebar.setCollapsedCookie(collapsed); diff --git a/app/assets/javascripts/fly_out_nav.js b/app/assets/javascripts/fly_out_nav.js index 98837c3b2a0..6110d961609 100644 --- a/app/assets/javascripts/fly_out_nav.js +++ b/app/assets/javascripts/fly_out_nav.js @@ -21,7 +21,7 @@ let headerHeight = 50; export const getHeaderHeight = () => headerHeight; -export const isSidebarCollapsed = () => sidebar && sidebar.classList.contains('sidebar-icons-only'); +export const isSidebarCollapsed = () => sidebar && sidebar.classList.contains('sidebar-collapsed-desktop'); export const canShowActiveSubItems = (el) => { if (el.classList.contains('active') && !isSidebarCollapsed()) { diff --git a/app/assets/stylesheets/framework/contextual-sidebar.scss b/app/assets/stylesheets/framework/contextual-sidebar.scss index b73932eb7e1..6b02b83c85e 100644 --- a/app/assets/stylesheets/framework/contextual-sidebar.scss +++ b/app/assets/stylesheets/framework/contextual-sidebar.scss @@ -1,4 +1,6 @@ .page-with-contextual-sidebar { + transition: padding-left, $sidebar-transition-duration; + @media (min-width: $screen-md-min) { padding-left: $contextual-sidebar-collapsed-width; } @@ -27,8 +29,10 @@ .context-header { position: relative; margin-right: 2px; + width: $contextual-sidebar-width; a { + transition: padding, $sidebar-transition-duration; font-weight: $gl-font-weight-bold; display: flex; align-items: center; @@ -63,10 +67,10 @@ } .nav-sidebar { + transition: width, $sidebar-transition-duration; position: fixed; z-index: 400; width: $contextual-sidebar-width; - transition: left $sidebar-transition-duration; top: $header-height; bottom: 0; left: 0; @@ -74,16 +78,15 @@ box-shadow: inset -2px 0 0 $border-color; transform: translate3d(0, 0, 0); - &:not(.sidebar-icons-only) { + &:not(.sidebar-collapsed-desktop) { @media (min-width: $screen-sm-min) and (max-width: $screen-md-max) { box-shadow: inset -2px 0 0 $border-color, 2px 1px 3px $dropdown-shadow-color; } } - &.sidebar-icons-only { - width: auto; - min-width: $contextual-sidebar-collapsed-width; + &.sidebar-collapsed-desktop { + width: $contextual-sidebar-collapsed-width; .nav-sidebar-inner-scroll { overflow-x: hidden; @@ -108,12 +111,11 @@ } } - &.nav-sidebar-expanded { + &.sidebar-expanded-mobile { left: 0; } a { - transition: none; text-decoration: none; } @@ -126,6 +128,7 @@ white-space: nowrap; a { + transition: padding, $sidebar-transition-duration; display: flex; align-items: center; padding: 12px 16px; @@ -310,11 +313,16 @@ // Collapsed nav -.toggle-sidebar-button, -.close-nav-button { +.toggle-button-container { width: $contextual-sidebar-width - 2px; + transition: width, $sidebar-transition-duration; position: fixed; bottom: 0; + left: 0; +} + +.toggle-sidebar-button, +.close-nav-button { padding: 16px; background-color: $gray-light; border: 0; @@ -343,10 +351,15 @@ } } +.collapse-text { + white-space: nowrap; + overflow: hidden; +} -.sidebar-icons-only { +.sidebar-collapsed-desktop { .context-header { height: 61px; + width: $contextual-sidebar-collapsed-width; a { padding: 10px 4px; @@ -373,8 +386,11 @@ margin-right: 0; } - .toggle-sidebar-button { + .toggle-button-container { width: $contextual-sidebar-collapsed-width - 2px; + } + + .toggle-sidebar-button { padding: 16px; .collapse-text, diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index 4f99c27eff1..a6fa96d834c 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -5,10 +5,9 @@ $grid-size: 8px; $gutter_collapsed_width: 62px; $gutter_width: 290px; $gutter_inner_width: 250px; -$sidebar-transition-duration: .15s; +$sidebar-transition-duration: .3s; $sidebar-breakpoint: 1024px; $default-transition-duration: .15s; -$right-sidebar-transition-duration: .3s; $contextual-sidebar-width: 220px; $contextual-sidebar-collapsed-width: 50px; diff --git a/app/assets/stylesheets/pages/boards.scss b/app/assets/stylesheets/pages/boards.scss index 3683afa07de..862ea379cbc 100644 --- a/app/assets/stylesheets/pages/boards.scss +++ b/app/assets/stylesheets/pages/boards.scss @@ -57,7 +57,7 @@ position: relative; @media (min-width: $screen-sm-min) { - transition: width $right-sidebar-transition-duration; + transition: width $sidebar-transition-duration; width: 100%; &.is-compact { @@ -453,8 +453,8 @@ .right-sidebar.right-sidebar-expanded { &.boards-sidebar-slide-enter-active, &.boards-sidebar-slide-leave-active { - transition: width $right-sidebar-transition-duration, - padding $right-sidebar-transition-duration; + transition: width $sidebar-transition-duration, + padding $sidebar-transition-duration; } &.boards-sidebar-slide-enter, diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 11ee1232bfe..32f2fa88236 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -126,7 +126,7 @@ top: $header-height; bottom: 0; right: 0; - transition: width $right-sidebar-transition-duration; + transition: width $sidebar-transition-duration; background: $gray-light; z-index: 200; overflow: hidden; diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml index 0ec07605631..cb8db306b56 100644 --- a/app/views/layouts/nav/sidebar/_admin.html.haml +++ b/app/views/layouts/nav/sidebar/_admin.html.haml @@ -1,4 +1,4 @@ -.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) } +.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll .context-header = link_to admin_root_path, title: 'Admin Overview' do diff --git a/app/views/layouts/nav/sidebar/_group.html.haml b/app/views/layouts/nav/sidebar/_group.html.haml index 0bf318b0b66..0c27b09f7b1 100644 --- a/app/views/layouts/nav/sidebar/_group.html.haml +++ b/app/views/layouts/nav/sidebar/_group.html.haml @@ -1,7 +1,7 @@ - issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute - merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute -.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) } +.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll .context-header = link_to group_path(@group), title: @group.name do diff --git a/app/views/layouts/nav/sidebar/_profile.html.haml b/app/views/layouts/nav/sidebar/_profile.html.haml index 7e23f9c1f05..a5a62a0695f 100644 --- a/app/views/layouts/nav/sidebar/_profile.html.haml +++ b/app/views/layouts/nav/sidebar/_profile.html.haml @@ -1,4 +1,4 @@ -.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) } +.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll .context-header = link_to profile_path, title: 'Profile Settings' do diff --git a/app/views/layouts/nav/sidebar/_project.html.haml b/app/views/layouts/nav/sidebar/_project.html.haml index 53a9162b703..be39f577ba7 100644 --- a/app/views/layouts/nav/sidebar/_project.html.haml +++ b/app/views/layouts/nav/sidebar/_project.html.haml @@ -1,4 +1,4 @@ -.nav-sidebar{ class: ("sidebar-icons-only" if collapsed_sidebar?) } +.nav-sidebar{ class: ("sidebar-collapsed-desktop" if collapsed_sidebar?) } .nav-sidebar-inner-scroll - can_edit = can?(current_user, :admin_project, @project) .context-header diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index 2530db986e0..81b2209111f 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -1,8 +1,9 @@ -%a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } - = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left') - = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right') - %span.collapse-text Collapse sidebar +.toggle-button-container + %a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } + = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left') + = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right') + .collapse-text Collapse sidebar -= button_tag class: 'close-nav-button', type: 'button' do - = sprite_icon('close', size: 16) - %span.collapse-text Close sidebar + = button_tag class: 'close-nav-button', type: 'button' do + = sprite_icon('close', size: 16) + .collapse-text Close sidebar diff --git a/spec/javascripts/fly_out_nav_spec.js b/spec/javascripts/fly_out_nav_spec.js index 4f20e31f511..a3fa07d5bc2 100644 --- a/spec/javascripts/fly_out_nav_spec.js +++ b/spec/javascripts/fly_out_nav_spec.js @@ -253,7 +253,7 @@ describe('Fly out sidebar navigation', () => { it('shows collapsed only sub-items if icon only sidebar', () => { const subItems = el.querySelector('.sidebar-sub-level-items'); const sidebar = document.createElement('div'); - sidebar.classList.add('sidebar-icons-only'); + sidebar.classList.add('sidebar-collapsed-desktop'); subItems.classList.add('is-fly-out-only'); setSidebar(sidebar); @@ -343,7 +343,7 @@ describe('Fly out sidebar navigation', () => { it('returns true when active & collapsed sidebar', () => { const sidebar = document.createElement('div'); - sidebar.classList.add('sidebar-icons-only'); + sidebar.classList.add('sidebar-collapsed-desktop'); el.classList.add('active'); setSidebar(sidebar); From e6ac6734c2f636d3d063718a95ba1169e299b51f Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 Dec 2017 20:18:45 -0600 Subject: [PATCH 050/112] Use relative _path helper URLs in the GitLab UI Fix https://gitlab.com/gitlab-org/gitlab-ce/issues/40825 --- app/assets/javascripts/job.js | 8 ++++---- app/assets/javascripts/notes/index.js | 10 +++++++++- app/helpers/builds_helper.rb | 3 +-- app/views/layouts/_search.html.haml | 9 ++++++++- app/views/projects/ci/builds/_build.html.haml | 2 +- app/views/projects/commits/show.html.haml | 2 +- app/views/projects/issues/_discussion.html.haml | 2 +- app/views/projects/new.html.haml | 2 +- app/views/shared/_outdated_browser.html.haml | 2 +- lib/api/entities.rb | 3 +++ spec/javascripts/job_spec.js | 2 +- 11 files changed, 31 insertions(+), 14 deletions(-) diff --git a/app/assets/javascripts/job.js b/app/assets/javascripts/job.js index cf8fda9a4fa..85ea6330ee9 100644 --- a/app/assets/javascripts/job.js +++ b/app/assets/javascripts/job.js @@ -9,7 +9,7 @@ export default class Job { this.state = null; this.options = options || $('.js-build-options').data(); - this.pageUrl = this.options.pageUrl; + this.pagePath = this.options.pagePath; this.buildStatus = this.options.buildStatus; this.state = this.options.logState; this.buildStage = this.options.buildStage; @@ -167,11 +167,11 @@ export default class Job { getBuildTrace() { return $.ajax({ - url: `${this.pageUrl}/trace.json`, + url: `${this.pagePath}/trace.json`, data: { state: this.state }, }) .done((log) => { - setCiStatusFavicon(`${this.pageUrl}/status.json`); + setCiStatusFavicon(`${this.pagePath}/status.json`); if (log.state) { this.state = log.state; @@ -209,7 +209,7 @@ export default class Job { } if (log.status !== this.buildStatus) { - gl.utils.visitUrl(this.pageUrl); + gl.utils.visitUrl(this.pagePath); } }) .fail(() => { diff --git a/app/assets/javascripts/notes/index.js b/app/assets/javascripts/notes/index.js index 8d74c5de5cf..a94163a5f87 100644 --- a/app/assets/javascripts/notes/index.js +++ b/app/assets/javascripts/notes/index.js @@ -8,10 +8,18 @@ document.addEventListener('DOMContentLoaded', () => new Vue({ }, data() { const notesDataset = document.getElementById('js-vue-notes').dataset; + const parsedUserData = JSON.parse(notesDataset.currentUserData); + const currentUserData = parsedUserData ? { + id: parsedUserData.id, + name: parsedUserData.name, + username: parsedUserData.username, + avatar_url: parsedUserData.avatar_path || parsedUserData.avatar_url, + path: parsedUserData.path, + } : {}; return { noteableData: JSON.parse(notesDataset.noteableData), - currentUserData: JSON.parse(notesDataset.currentUserData), + currentUserData, notesData: { lastFetchedAt: notesDataset.lastFetchedAt, discussionsPath: notesDataset.discussionsPath, diff --git a/app/helpers/builds_helper.rb b/app/helpers/builds_helper.rb index aa3a9a055a0..4ec63fdaffc 100644 --- a/app/helpers/builds_helper.rb +++ b/app/helpers/builds_helper.rb @@ -20,8 +20,7 @@ module BuildsHelper def javascript_build_options { - page_url: project_job_url(@project, @build), - build_url: project_job_url(@project, @build, :json), + page_path: project_job_path(@project, @build), build_status: @build.status, build_stage: @build.stage, log_state: '' diff --git a/app/views/layouts/_search.html.haml b/app/views/layouts/_search.html.haml index 30ae385f62f..52587760ba4 100644 --- a/app/views/layouts/_search.html.haml +++ b/app/views/layouts/_search.html.haml @@ -13,7 +13,14 @@ .location-badge= label .search-input-wrap .dropdown{ data: { url: search_autocomplete_path } } - = search_field_tag 'search', nil, placeholder: 'Search', class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', spellcheck: false, tabindex: '1', autocomplete: 'off', data: { issues_path: issues_dashboard_url, mr_path: merge_requests_dashboard_url }, aria: { label: 'Search' } + = search_field_tag 'search', nil, placeholder: 'Search', + class: 'search-input dropdown-menu-toggle no-outline js-search-dashboard-options', + spellcheck: false, + tabindex: '1', + autocomplete: 'off', + data: { issues_path: issues_dashboard_path, + mr_path: merge_requests_dashboard_path }, + aria: { label: 'Search' } %button.hidden.js-dropdown-search-toggle{ type: 'button', data: { toggle: 'dropdown' } } .dropdown-menu.dropdown-select = dropdown_content do diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index c1842527480..86510b8ab93 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -14,7 +14,7 @@ %td.branch-commit - if can?(current_user, :read_build, job) - = link_to project_job_url(job.project, job) do + = link_to project_job_path(job.project, job) do %span.build-link ##{job.id} - else %span.build-link ##{job.id} diff --git a/app/views/projects/commits/show.html.haml b/app/views/projects/commits/show.html.haml index ef305120525..ab371521840 100644 --- a/app/views/projects/commits/show.html.haml +++ b/app/views/projects/commits/show.html.haml @@ -3,7 +3,7 @@ - page_title _("Commits"), @ref = content_for :meta_tags do - = auto_discovery_link_tag(:atom, project_commits_url(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") + = auto_discovery_link_tag(:atom, project_commits_path(@project, @ref, rss_url_options), title: "#{@project.name}:#{@ref} commits") .js-project-commits-show{ 'data-commits-limit' => @limit } %div{ class: container_class } diff --git a/app/views/projects/issues/_discussion.html.haml b/app/views/projects/issues/_discussion.html.haml index 1eccc0509bd..9779c1985d5 100644 --- a/app/views/projects/issues/_discussion.html.haml +++ b/app/views/projects/issues/_discussion.html.haml @@ -14,4 +14,4 @@ notes_path: notes_url, last_fetched_at: Time.now.to_i, noteable_data: serialize_issuable(@issue), - current_user_data: UserSerializer.new.represent(current_user).to_json } } + current_user_data: UserSerializer.new.represent(current_user, only_path: true).to_json } } diff --git a/app/views/projects/new.html.haml b/app/views/projects/new.html.haml index 4bb97ecdd16..2f56630c22e 100644 --- a/app/views/projects/new.html.haml +++ b/app/views/projects/new.html.haml @@ -86,7 +86,7 @@ = icon('bug', text: 'Fogbugz') %div - if gitea_import_enabled? - = link_to new_import_gitea_url, class: 'btn import_gitea' do + = link_to new_import_gitea_path, class: 'btn import_gitea' do = custom_icon('go_logo') Gitea %div diff --git a/app/views/shared/_outdated_browser.html.haml b/app/views/shared/_outdated_browser.html.haml index a638b0a805e..8ddb1b2bc99 100644 --- a/app/views/shared/_outdated_browser.html.haml +++ b/app/views/shared/_outdated_browser.html.haml @@ -4,5 +4,5 @@ GitLab may not work properly because you are using an outdated web browser. %br Please install a - = link_to 'supported web browser', help_page_url('install/requirements', anchor: 'supported-web-browsers') + = link_to 'supported web browser', help_page_path('install/requirements', anchor: 'supported-web-browsers') for a better experience. diff --git a/lib/api/entities.rb b/lib/api/entities.rb index 62ee20bf7de..d96e7f2770f 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -16,10 +16,13 @@ module API class UserBasic < UserSafe expose :state + expose :avatar_url do |user, options| user.avatar_url(only_path: false) end + expose :avatar_path, if: ->(user, options) { options.fetch(:only_path, false) && user.avatar_path } + expose :web_url do |user, options| Gitlab::Routing.url_helpers.user_url(user) end diff --git a/spec/javascripts/job_spec.js b/spec/javascripts/job_spec.js index 5e67911d338..20c4caa865d 100644 --- a/spec/javascripts/job_spec.js +++ b/spec/javascripts/job_spec.js @@ -28,7 +28,7 @@ describe('Job', () => { }); it('copies build options', function () { - expect(this.job.pageUrl).toBe(JOB_URL); + expect(this.job.pagePath).toBe(JOB_URL); expect(this.job.buildStatus).toBe('success'); expect(this.job.buildStage).toBe('test'); expect(this.job.state).toBe(''); From a1d5d49a01520dc1d05c9d6ba65ed515adc7b8af Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray Date: Thu, 7 Dec 2017 10:20:07 -0700 Subject: [PATCH 051/112] Add changelog --- changelogs/unreleased/35724-animate-sidebar.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/35724-animate-sidebar.yml diff --git a/changelogs/unreleased/35724-animate-sidebar.yml b/changelogs/unreleased/35724-animate-sidebar.yml new file mode 100644 index 00000000000..5d0b46a23c8 --- /dev/null +++ b/changelogs/unreleased/35724-animate-sidebar.yml @@ -0,0 +1,5 @@ +--- +title: Animate contextual sidebar on collapse/expand +merge_request: +author: +type: changed From 30bc983c340de3641b952606c154c7a413368ab6 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Fri, 8 Dec 2017 02:21:16 +0900 Subject: [PATCH 052/112] Test for both ci_disable_validates_dependencies true/false --- spec/models/ci/build_spec.rb | 124 ++++++++++-------- spec/services/ci/register_job_service_spec.rb | 109 +++++++++------ 2 files changed, 142 insertions(+), 91 deletions(-) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 3440ce7f1e8..a6258676767 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1869,70 +1869,90 @@ describe Ci::Build do end describe 'state transition: any => [:running]' do - before do - stub_feature_flags(ci_disable_validates_dependencies: true) + shared_examples 'validation is active' do + context 'when depended job has not been completed yet' do + let!(:pre_stage_job) { create(:ci_build, :running, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect { job.run! }.to raise_error(Ci::Build::MissingDependenciesError) } + end + + context 'when artifacts of depended job has been expired' do + let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect { job.run! }.to raise_error(Ci::Build::MissingDependenciesError) } + end + + context 'when artifacts of depended job has been erased' do + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } + + before do + pre_stage_job.erase + end + + it { expect { job.run! }.to raise_error(Ci::Build::MissingDependenciesError) } + end end - let(:build) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 1, options: options) } + shared_examples 'validation is not active' do + context 'when depended job has not been completed yet' do + let!(:pre_stage_job) { create(:ci_build, :running, pipeline: pipeline, name: 'test', stage_idx: 0) } - context 'when "dependencies" keyword is not defined' do - let(:options) { {} } + it { expect { job.run! }.not_to raise_error } + end - it { expect { build.run! }.not_to raise_error } + context 'when artifacts of depended job has been expired' do + let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect { job.run! }.not_to raise_error } + end + + context 'when artifacts of depended job has been erased' do + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } + + before do + pre_stage_job.erase + end + + it { expect { job.run! }.not_to raise_error } + end end - context 'when "dependencies" keyword is empty' do - let(:options) { { dependencies: [] } } + let!(:job) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 1, options: options) } - it { expect { build.run! }.not_to raise_error } + context 'when validates for dependencies is enabled' do + before do + stub_feature_flags(ci_disable_validates_dependencies: false) + end + + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) } + + context 'when "dependencies" keyword is not defined' do + let(:options) { {} } + + it { expect { job.run! }.not_to raise_error } + end + + context 'when "dependencies" keyword is empty' do + let(:options) { { dependencies: [] } } + + it { expect { job.run! }.not_to raise_error } + end + + context 'when "dependencies" keyword is specified' do + let(:options) { { dependencies: ['test'] } } + + it_behaves_like 'validation is active' + end end - context 'when "dependencies" keyword is specified' do + context 'when validates for dependencies is disabled' do let(:options) { { dependencies: ['test'] } } - context 'when a depended job exists' do - context 'when depended job has artifacts' do - let!(:pre_stage_job) do - create(:ci_build, - :success, - :artifacts, - pipeline: pipeline, - name: 'test', - stage_idx: 0, - options: { artifacts: { paths: ['binaries/'] } } ) - end - - it { expect { build.run! }.not_to raise_error } - end - - context 'when depended job does not have artifacts' do - let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) } - - it { expect { build.run! }.not_to raise_error } - end - - context 'when depended job has not been completed yet' do - let!(:pre_stage_job) { create(:ci_build, :running, pipeline: pipeline, name: 'test', stage_idx: 0) } - - it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } - end - - context 'when artifacts of depended job has been expired' do - let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } - - it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } - end - - context 'when artifacts of depended job has been erased' do - let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } - - before do - pre_stage_job.erase - end - - it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } - end + before do + stub_feature_flags(ci_disable_validates_dependencies: true) end + + it_behaves_like 'validation is not active' end end diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index 16218b78fb8..3ee59014b5b 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -277,54 +277,85 @@ module Ci end context 'when "dependencies" keyword is specified' do + shared_examples 'not pick' do + it 'does not pick the build and drops the build' do + expect(subject).to be_nil + expect(pending_job.reload).to be_failed + expect(pending_job).to be_missing_dependency_failure + end + end + + shared_examples 'validation is active' do + context 'when depended job has not been completed yet' do + let!(:pre_stage_job) { create(:ci_build, :running, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it_behaves_like 'not pick' + end + + context 'when artifacts of depended job has been expired' do + let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it_behaves_like 'not pick' + end + + context 'when artifacts of depended job has been erased' do + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } + + before do + pre_stage_job.erase + end + + it_behaves_like 'not pick' + end + end + + shared_examples 'validation is not active' do + context 'when depended job has not been completed yet' do + let!(:pre_stage_job) { create(:ci_build, :running, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect(subject).to eq(pending_job) } + end + + context 'when artifacts of depended job has been expired' do + let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect(subject).to eq(pending_job) } + end + + context 'when artifacts of depended job has been erased' do + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } + + before do + pre_stage_job.erase + end + + it { expect(subject).to eq(pending_job) } + end + end + before do stub_feature_flags(ci_disable_validates_dependencies: false) end - let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: job_name, stage_idx: 0) } + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) } + let!(:pending_job) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 1, options: { dependencies: ['test'] } ) } - let!(:pending_job) do - create(:ci_build, :pending, pipeline: pipeline, stage_idx: 1, options: { dependencies: ['spec'] } ) + subject { execute(specific_runner) } + + context 'when validates for dependencies is enabled' do + before do + stub_feature_flags(ci_disable_validates_dependencies: false) + end + + it_behaves_like 'validation is active' end - let(:picked_job) { execute(specific_runner) } - - context 'when a depended job exists' do - let(:job_name) { 'spec' } - - it "picks the build" do - expect(picked_job).to eq(pending_job) + context 'when validates for dependencies is disabled' do + before do + stub_feature_flags(ci_disable_validates_dependencies: true) end - context 'when "artifacts" keyword is specified on depended job' do - let!(:pre_stage_job) do - create(:ci_build, - :success, - :artifacts, - pipeline: pipeline, - name: job_name, - stage_idx: 0, - options: { artifacts: { paths: ['binaries/'] } } ) - end - - context 'when artifacts of depended job has existsed' do - it "picks the build" do - expect(picked_job).to eq(pending_job) - end - end - - context 'when artifacts of depended job has not existsed' do - before do - pre_stage_job.erase - end - - it 'does not pick the build and drops the build' do - expect(picked_job).to be_nil - expect(pending_job.reload).to be_failed - expect(pending_job).to be_missing_dependency_failure - end - end - end + it_behaves_like 'validation is not active' end end From ee85ca735a3ceb94d53e1a4ced38a46621194639 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray Date: Thu, 7 Dec 2017 10:24:05 -0700 Subject: [PATCH 053/112] Fix mobile styles --- .../stylesheets/framework/contextual-sidebar.scss | 13 +++---------- app/views/shared/_sidebar_toggle_button.html.haml | 15 +++++++-------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/app/assets/stylesheets/framework/contextual-sidebar.scss b/app/assets/stylesheets/framework/contextual-sidebar.scss index 6b02b83c85e..3c60b4c8b2e 100644 --- a/app/assets/stylesheets/framework/contextual-sidebar.scss +++ b/app/assets/stylesheets/framework/contextual-sidebar.scss @@ -313,16 +313,12 @@ // Collapsed nav -.toggle-button-container { +.toggle-sidebar-button, +.close-nav-button { width: $contextual-sidebar-width - 2px; transition: width, $sidebar-transition-duration; position: fixed; bottom: 0; - left: 0; -} - -.toggle-sidebar-button, -.close-nav-button { padding: 16px; background-color: $gray-light; border: 0; @@ -386,12 +382,9 @@ margin-right: 0; } - .toggle-button-container { - width: $contextual-sidebar-collapsed-width - 2px; - } - .toggle-sidebar-button { padding: 16px; + width: $contextual-sidebar-collapsed-width - 2px; .collapse-text, .icon-angle-double-left { diff --git a/app/views/shared/_sidebar_toggle_button.html.haml b/app/views/shared/_sidebar_toggle_button.html.haml index 81b2209111f..2530db986e0 100644 --- a/app/views/shared/_sidebar_toggle_button.html.haml +++ b/app/views/shared/_sidebar_toggle_button.html.haml @@ -1,9 +1,8 @@ -.toggle-button-container - %a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } - = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left') - = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right') - .collapse-text Collapse sidebar +%a.toggle-sidebar-button.js-toggle-sidebar{ role: "button", type: "button", title: "Toggle sidebar" } + = sprite_icon('angle-double-left', css_class: 'icon-angle-double-left') + = sprite_icon('angle-double-right', css_class: 'icon-angle-double-right') + %span.collapse-text Collapse sidebar - = button_tag class: 'close-nav-button', type: 'button' do - = sprite_icon('close', size: 16) - .collapse-text Close sidebar += button_tag class: 'close-nav-button', type: 'button' do + = sprite_icon('close', size: 16) + %span.collapse-text Close sidebar From d30202f5d22c9a225f6eeb015eb84d28d0a0ef4a Mon Sep 17 00:00:00 2001 From: Marcia Ramos Date: Thu, 7 Dec 2017 15:33:51 -0200 Subject: [PATCH 054/112] fix typo --- doc/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/README.md b/doc/README.md index 6a426d33300..11d52001440 100644 --- a/doc/README.md +++ b/doc/README.md @@ -18,7 +18,7 @@ self-hosted, fully featured solution of GitLab, available under distinct [subscr > **GitLab EE** contains all features available in **GitLab CE**, plus premium features available in each version: **Enterprise Edition Starter** -(**EES**), **Enterprise Edition Premium** (**EEP**), and **Enterprise Edition Premium** +(**EES**), **Enterprise Edition Premium** (**EEP**), and **Enterprise Edition Ultimate** (**EEU**). Everything available in **EES** is also available in **EEP**. Every feature available in **EEP** is also available in **EEU**. From 1c42ea529b48219481a40f523ebc0da7af644a46 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Thu, 7 Dec 2017 17:52:32 +0000 Subject: [PATCH 055/112] Resolve "updateEndpoint undefined on Issue page" --- app/assets/javascripts/issue_show/index.js | 27 ++----------------- ...updateendpoint-undefined-on-issue-page.yml | 5 ++++ 2 files changed, 7 insertions(+), 25 deletions(-) create mode 100644 changelogs/unreleased/40715-updateendpoint-undefined-on-issue-page.yml diff --git a/app/assets/javascripts/issue_show/index.js b/app/assets/javascripts/issue_show/index.js index aca9dec2a96..a21ce41e65e 100644 --- a/app/assets/javascripts/issue_show/index.js +++ b/app/assets/javascripts/issue_show/index.js @@ -5,7 +5,7 @@ import '../vue_shared/vue_resource_interceptor'; document.addEventListener('DOMContentLoaded', () => { const initialDataEl = document.getElementById('js-issuable-app-initial-data'); - const initialData = JSON.parse(initialDataEl.innerHTML.replace(/"/g, '"')); + const props = JSON.parse(initialDataEl.innerHTML.replace(/"/g, '"')); $('.issuable-edit').on('click', (e) => { e.preventDefault(); @@ -18,32 +18,9 @@ document.addEventListener('DOMContentLoaded', () => { components: { issuableApp, }, - data() { - return { - ...initialData, - }; - }, render(createElement) { return createElement('issuable-app', { - props: { - canUpdate: this.canUpdate, - canDestroy: this.canDestroy, - endpoint: this.endpoint, - issuableRef: this.issuableRef, - initialTitleHtml: this.initialTitleHtml, - initialTitleText: this.initialTitleText, - initialDescriptionHtml: this.initialDescriptionHtml, - initialDescriptionText: this.initialDescriptionText, - issuableTemplates: this.issuableTemplates, - markdownPreviewPath: this.markdownPreviewPath, - markdownDocsPath: this.markdownDocsPath, - projectPath: this.projectPath, - projectNamespace: this.projectNamespace, - updatedAt: this.updatedAt, - updatedByName: this.updatedByName, - updatedByPath: this.updatedByPath, - initialTaskStatus: this.initialTaskStatus, - }, + props, }); }, }); diff --git a/changelogs/unreleased/40715-updateendpoint-undefined-on-issue-page.yml b/changelogs/unreleased/40715-updateendpoint-undefined-on-issue-page.yml new file mode 100644 index 00000000000..0328a693354 --- /dev/null +++ b/changelogs/unreleased/40715-updateendpoint-undefined-on-issue-page.yml @@ -0,0 +1,5 @@ +--- +title: Fix updateEndpoint undefined error for issue_show app root +merge_request: 15698 +author: +type: fixed From 2ac6d806900f3aea708b3fcdc32463235f83eb73 Mon Sep 17 00:00:00 2001 From: Achilleas Pipinellis Date: Thu, 7 Dec 2017 19:46:27 +0100 Subject: [PATCH 056/112] Clean up docs for dependencies validation --- doc/administration/job_artifacts.md | 39 +++++++++++++++++++++ doc/ci/yaml/README.md | 18 ++++++---- doc/user/project/pipelines/job_artifacts.md | 22 +++++++----- 3 files changed, 63 insertions(+), 16 deletions(-) diff --git a/doc/administration/job_artifacts.md b/doc/administration/job_artifacts.md index 86b436d89dd..33f8a69c249 100644 --- a/doc/administration/job_artifacts.md +++ b/doc/administration/job_artifacts.md @@ -128,6 +128,45 @@ steps below. 1. Save the file and [restart GitLab][] for the changes to take effect. +## Validation for dependencies + +> Introduced in GitLab 10.3. + +To disable [the dependencies validation](../ci/yaml/README.md#when-a-dependent-job-will-fail), +you can flip the feature flag from a Rails console. + +--- + +**In Omnibus installations:** + +1. Enter the Rails console: + + ```sh + sudo gitlab-rails console + ``` + +1. Flip the switch and disable it: + + ```ruby + Feature.enable('ci_disable_validates_dependencies') + ``` +--- + +**In installations from source:** + +1. Enter the Rails console: + + ```sh + cd /home/git/gitlab + RAILS_ENV=production sudo -u git -H bundle exec rails console + ``` + +1. Flip the switch and disable it: + + ```ruby + Feature.enable('ci_disable_validates_dependencies') + ``` + ## Set the maximum file size of the artifacts Provided the artifacts are enabled, you can change the maximum file size of the diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index ac5d99c71fc..32464cbb259 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1153,15 +1153,19 @@ deploy: script: make deploy ``` ->**Note:** -> Introduced in GitLab 10.3 -> This is the user documentation. For the administration guide see - [administration/job_artifacts](../../../administration/job_artifacts.md#validation_for_dependency). +#### When a dependent job will fail -If a depended job doesn't have artifacts by the following reason, the depending job will fail. +> Introduced in GitLab 10.3. -1. A depended `artifacts` has been [erased](https://docs.gitlab.com/ee/api/jobs.html#erase-a-job). -1. A depended `artifacts` has been [expired](https://docs.gitlab.com/ee/ci/yaml/#artifacts-expire_in). +If the artifacts of the job that is set as a dependency have been +[expired](#artifacts-expire_in) or +[erased](../../user/project/pipelines/job_artifacts.md#erasing-artifacts), then +the dependent job will fail. + +NOTE: **Note:** +You can ask your administrator to +[flip this switch](../../administration/job_artifacts.md#validation-for-dependencies) +and bring back the old behavior. ### before_script and after_script diff --git a/doc/user/project/pipelines/job_artifacts.md b/doc/user/project/pipelines/job_artifacts.md index f8675f77856..402989f4508 100644 --- a/doc/user/project/pipelines/job_artifacts.md +++ b/doc/user/project/pipelines/job_artifacts.md @@ -44,7 +44,7 @@ the artifacts will be kept forever. For more examples on artifacts, follow the [artifacts reference in `.gitlab-ci.yml`](../../../ci/yaml/README.md#artifacts). -## Browsing job artifacts +## Browsing artifacts >**Note:** With GitLab 9.2, PDFs, images, videos and other formats can be previewed @@ -77,7 +77,7 @@ one HTML file that you can view directly online when --- -## Downloading job artifacts +## Downloading artifacts If you need to download the whole archive, there are buttons in various places inside GitLab that make that possible. @@ -102,7 +102,7 @@ inside GitLab that make that possible. ![Job artifacts browser](img/job_artifacts_browser.png) -## Downloading the latest job artifacts +## Downloading the latest artifacts It is possible to download the latest artifacts of a job via a well known URL so you can use it for scripting purposes. @@ -163,14 +163,18 @@ information in the UI. ![Latest artifacts button](img/job_latest_artifacts_browser.png) -## Validation for `dependency` keyword +## Erasing artifacts -To disable [the validation for dependency], you can flip the feature flag from a rails console: +DANGER: **Warning:** +This is a destructive action that leads to data loss. Use with caution. -``` -Feature.enable('ci_disable_validates_dependencies') -``` +If you have at least Developer [permissions](../../permissions.md#gitlab-ci-cd-permissions) +on the project, you can erase a single job via the UI which will also remove the +artifacts and the job's trace. + +1. Navigate to a job's page. +1. Click the trash icon at the top right of the job's trace. +1. Confirm the deletion. [expiry date]: ../../../ci/yaml/README.md#artifacts-expire_in -[the validation for dependency]: ../../../ci/yaml/README.md#dependencies [ce-14399]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/14399 From d377aa5475ad130f9856fe811b00571235e84e22 Mon Sep 17 00:00:00 2001 From: Jose Ivan Vargas Date: Mon, 4 Dec 2017 15:39:07 -0600 Subject: [PATCH 057/112] Changed the deploy markers on the prometheus dashboard to be more verbose --- .../monitoring/components/dashboard.vue | 4 + .../monitoring/components/graph.vue | 24 +++-- .../components/graph/deployment.vue | 93 ++++++++++++++----- .../monitoring/mixins/monitoring_mixins.js | 2 + .../monitoring/utils/date_time_formatters.js | 1 + .../vue_shared/components/icon.vue | 30 +++++- .../stylesheets/pages/environments.scss | 19 +++- .../projects/environments/metrics.html.haml | 2 + ...-deploy-markers-should-be-more-verbose.yml | 5 + .../monitoring/graph/deployment_spec.js | 29 +++++- spec/javascripts/monitoring/graph_spec.js | 14 +++ spec/javascripts/monitoring/mock_data.js | 6 ++ 12 files changed, 191 insertions(+), 38 deletions(-) create mode 100644 changelogs/unreleased/38032-deploy-markers-should-be-more-verbose.yml diff --git a/app/assets/javascripts/monitoring/components/dashboard.vue b/app/assets/javascripts/monitoring/components/dashboard.vue index cbe24c0915b..8da723ced03 100644 --- a/app/assets/javascripts/monitoring/components/dashboard.vue +++ b/app/assets/javascripts/monitoring/components/dashboard.vue @@ -21,6 +21,8 @@ hasMetrics: convertPermissionToBoolean(metricsData.hasMetrics), documentationPath: metricsData.documentationPath, settingsPath: metricsData.settingsPath, + tagsPath: metricsData.tagsPath, + projectPath: metricsData.projectPath, metricsEndpoint: metricsData.additionalMetrics, deploymentEndpoint: metricsData.deploymentEndpoint, emptyGettingStartedSvgPath: metricsData.emptyGettingStartedSvgPath, @@ -112,6 +114,8 @@ :hover-data="hoverData" :update-aspect-ratio="updateAspectRatio" :deployment-data="store.deploymentData" + :project-path="projectPath" + :tags-path="tagsPath" /> diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index f8782fde927..cdae287658b 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -30,6 +30,14 @@ required: false, default: () => ({}), }, + projectPath: { + type: String, + required: true, + }, + tagsPath: { + type: String, + required: true, + }, }, mixins: [MonitoringMixin], @@ -251,6 +259,14 @@ :line-color="path.lineColor" :area-color="path.areaColor" /> + + - - diff --git a/app/assets/javascripts/monitoring/components/graph/deployment.vue b/app/assets/javascripts/monitoring/components/graph/deployment.vue index e3b8be0c7fb..32ca08fdbc0 100644 --- a/app/assets/javascripts/monitoring/components/graph/deployment.vue +++ b/app/assets/javascripts/monitoring/components/graph/deployment.vue @@ -1,5 +1,6 @@ @@ -97,14 +105,14 @@ :x="positionFlag(deployment)" y="0" width="134" - height="80"> + :height="svgContainerHeight(deployment.tag)"> + :height="svgContainerHeight(deployment.tag) - 2"> + :y="3">
    Date: Thu, 7 Dec 2017 20:46:05 +0000 Subject: [PATCH 061/112] Add documentation about using US-English --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4930b541ba2..01d4a546b97 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -598,6 +598,7 @@ merge request: present time and never use past tense (has been/was). For example instead of _prohibited this user from being saved due to the following errors:_ the text should be _sorry, we could not create your account because:_ +1. Code should be written in [US English][us-english] This is also the style used by linting tools such as [RuboCop](https://github.com/bbatsov/rubocop), @@ -663,6 +664,7 @@ available at [http://contributor-covenant.org/version/1/1/0/](http://contributor [GitLab Inc engineering workflow]: https://about.gitlab.com/handbook/engineering/workflow/#labelling-issues [polling-etag]: https://docs.gitlab.com/ce/development/polling.html [testing]: doc/development/testing_guide/index.md +[us-english]: https://en.wikipedia.org/wiki/American_English [^1]: Please note that specs other than JavaScript specs are considered backend code. From 116d8cfcfbe26f0bb5c6ac49a841f9555b44cf07 Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Sun, 3 Dec 2017 22:01:18 -0600 Subject: [PATCH 062/112] Fix new personal access token showing up in a flash message --- .../personal_access_tokens_controller.rb | 4 ++- app/models/personal_access_token.rb | 21 ++++++++++++++++ .../personal_access_tokens/index.html.haml | 7 +++--- spec/models/personal_access_token_spec.rb | 25 +++++++++++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/app/controllers/profiles/personal_access_tokens_controller.rb b/app/controllers/profiles/personal_access_tokens_controller.rb index 6d9873e38df..346eab4ba19 100644 --- a/app/controllers/profiles/personal_access_tokens_controller.rb +++ b/app/controllers/profiles/personal_access_tokens_controller.rb @@ -8,7 +8,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController @personal_access_token = finder.build(personal_access_token_params) if @personal_access_token.save - flash[:personal_access_token] = @personal_access_token.token + PersonalAccessToken.redis_store!(current_user.id, @personal_access_token.token) redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created." else set_index_vars @@ -43,5 +43,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController @inactive_personal_access_tokens = finder(state: 'inactive').execute @active_personal_access_tokens = finder(state: 'active').execute.order(:expires_at) + + @new_personal_access_token = PersonalAccessToken.redis_getdel(current_user.id) end end diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index cfcb03138b7..063dc521324 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -3,6 +3,8 @@ class PersonalAccessToken < ActiveRecord::Base include TokenAuthenticatable add_authentication_token_field :token + REDIS_EXPIRY_TIME = 3.minutes + serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize belongs_to :user @@ -27,6 +29,21 @@ class PersonalAccessToken < ActiveRecord::Base !revoked? && !expired? end + def self.redis_getdel(user_id) + Gitlab::Redis::SharedState.with do |redis| + token = redis.get(redis_shared_state_key(user_id)) + redis.del(redis_shared_state_key(user_id)) + token + end + end + + def self.redis_store!(user_id, token) + Gitlab::Redis::SharedState.with do |redis| + redis.set(redis_shared_state_key(user_id), token, ex: REDIS_EXPIRY_TIME) + token + end + end + protected def validate_scopes @@ -38,4 +55,8 @@ class PersonalAccessToken < ActiveRecord::Base def set_default_scopes self.scopes = Gitlab::Auth::DEFAULT_SCOPES if self.scopes.empty? end + + def self.redis_shared_state_key(user_id) + "gitlab:personal_access_token:#{user_id}" + end end diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index 26c2e4c5936..f445e5a2417 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -15,14 +15,13 @@ They are the only accepted password when you have Two-Factor Authentication (2FA) enabled. .col-lg-8 - - - if flash[:personal_access_token] + - if @new_personal_access_token .created-personal-access-token-container %h5.prepend-top-0 Your New Personal Access Token .form-group - = text_field_tag 'created-personal-access-token', flash[:personal_access_token], readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-personal-access-token-help-block" - = clipboard_button(text: flash[:personal_access_token], title: "Copy personal access token to clipboard", placement: "left") + = text_field_tag 'created-personal-access-token', @new_personal_access_token, readonly: true, class: "form-control js-select-on-focus", 'aria-describedby' => "created-personal-access-token-help-block" + = clipboard_button(text: @new_personal_access_token, title: "Copy personal access token to clipboard", placement: "left") %span#created-personal-access-token-help-block.help-block.text-danger Make sure you save it - you won't be able to access it again. %hr diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index 01440b15674..2bb1c49b740 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -1,6 +1,8 @@ require 'spec_helper' describe PersonalAccessToken do + subject { described_class } + describe '.build' do let(:personal_access_token) { build(:personal_access_token) } let(:invalid_personal_access_token) { build(:personal_access_token, :invalid) } @@ -45,6 +47,29 @@ describe PersonalAccessToken do end end + describe 'Redis storage' do + let(:user_id) { 123 } + let(:token) { 'abc000foo' } + + before do + subject.redis_store!(user_id, token) + end + + it 'returns stored data' do + expect(subject.redis_getdel(user_id)).to eq(token) + end + + context 'after deletion' do + before do + expect(subject.redis_getdel(user_id)).to eq(token) + end + + it 'token is removed' do + expect(subject.redis_getdel(user_id)).to be_nil + end + end + end + context "validations" do let(:personal_access_token) { build(:personal_access_token) } From e0172564c219b6c0d858d4c803d792b99c7f86a0 Mon Sep 17 00:00:00 2001 From: Annabel Dunstone Gray Date: Thu, 7 Dec 2017 16:41:58 -0700 Subject: [PATCH 063/112] Fix transitions --- .../framework/contextual-sidebar.scss | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/app/assets/stylesheets/framework/contextual-sidebar.scss b/app/assets/stylesheets/framework/contextual-sidebar.scss index 3c60b4c8b2e..26a2db99e0a 100644 --- a/app/assets/stylesheets/framework/contextual-sidebar.scss +++ b/app/assets/stylesheets/framework/contextual-sidebar.scss @@ -1,5 +1,5 @@ .page-with-contextual-sidebar { - transition: padding-left, $sidebar-transition-duration; + transition: padding-left $sidebar-transition-duration; @media (min-width: $screen-md-min) { padding-left: $contextual-sidebar-collapsed-width; @@ -32,7 +32,7 @@ width: $contextual-sidebar-width; a { - transition: padding, $sidebar-transition-duration; + transition: padding $sidebar-transition-duration; font-weight: $gl-font-weight-bold; display: flex; align-items: center; @@ -67,7 +67,7 @@ } .nav-sidebar { - transition: width, $sidebar-transition-duration; + transition: width $sidebar-transition-duration, left $sidebar-transition-duration; position: fixed; z-index: 400; width: $contextual-sidebar-width; @@ -128,10 +128,10 @@ white-space: nowrap; a { - transition: padding, $sidebar-transition-duration; + transition: padding $sidebar-transition-duration; display: flex; align-items: center; - padding: 12px 16px; + padding: 12px 15px; color: $gl-text-color-secondary; } @@ -291,7 +291,8 @@ > a { margin-left: 4px; - padding-left: 12px; + // Subtract width of left border on active element + padding-left: 11px; } .badge { @@ -316,7 +317,7 @@ .toggle-sidebar-button, .close-nav-button { width: $contextual-sidebar-width - 2px; - transition: width, $sidebar-transition-duration; + transition: width $sidebar-transition-duration; position: fixed; bottom: 0; padding: 16px; @@ -354,7 +355,7 @@ .sidebar-collapsed-desktop { .context-header { - height: 61px; + height: 60px; width: $contextual-sidebar-collapsed-width; a { @@ -362,10 +363,6 @@ } } - li a { - padding: 12px 15px; - } - .sidebar-top-level-items > li { &.active a { padding-left: 12px; From 41b0c0e9be7a34d1255326904a234ae142d6c205 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kim=20Carlb=C3=A4cker?= Date: Fri, 8 Dec 2017 00:27:11 +0000 Subject: [PATCH 064/112] Migrate Git::Repository#fsck to Gitaly --- lib/gitlab/git/repository.rb | 18 ++++++++++++++++-- lib/gitlab/gitaly_client/repository_service.rb | 11 +++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 91dd2fbbdbc..73889328f36 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -1160,9 +1160,15 @@ module Gitlab end def fsck - output, status = run_git(%W[--git-dir=#{path} fsck], nice: true) + gitaly_migrate(:git_fsck) do |is_enabled| + msg, status = if is_enabled + gitaly_fsck + else + shell_fsck + end - raise GitError.new("Could not fsck repository:\n#{output}") unless status.zero? + raise GitError.new("Could not fsck repository: #{msg}") unless status.zero? + end end def rebase(user, rebase_id, branch:, branch_sha:, remote_repository:, remote_branch:) @@ -1310,6 +1316,14 @@ module Gitlab File.write(File.join(worktree_info_path, 'sparse-checkout'), files) end + def gitaly_fsck + gitaly_repository_client.fsck + end + + def shell_fsck + run_git(%W[--git-dir=#{path} fsck], nice: true) + end + def rugged_fetch_source_branch(source_repository, source_branch, local_ref) with_repo_branch_commit(source_repository, source_branch) do |commit| if commit diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb index b9e606592d7..a477d618f63 100644 --- a/lib/gitlab/gitaly_client/repository_service.rb +++ b/lib/gitlab/gitaly_client/repository_service.rb @@ -87,6 +87,17 @@ module Gitlab response.result end + + def fsck + request = Gitaly::FsckRequest.new(repository: @gitaly_repo) + response = GitalyClient.call(@storage, :repository_service, :fsck, request) + + if response.error.empty? + return "", 0 + else + return response.error.b, 1 + end + end end end end From f1ae1e39ce6b7578c5697c977bc3b52b119301ab Mon Sep 17 00:00:00 2001 From: Bob Van Landuyt Date: Mon, 13 Nov 2017 16:52:07 +0100 Subject: [PATCH 065/112] Move the circuitbreaker check out in a separate process Moving the check out of the general requests, makes sure we don't have any slowdown in the regular requests. To keep the process performing this checks small, the check is still performed inside a unicorn. But that is called from a process running on the same server. Because the checks are now done outside normal request, we can have a simpler failure strategy: The check is now performed in the background every `circuitbreaker_check_interval`. Failures are logged in redis. The failures are reset when the check succeeds. Per check we will try `circuitbreaker_access_retries` times within `circuitbreaker_storage_timeout` seconds. When the number of failures exceeds `circuitbreaker_failure_count_threshold`, we will block access to the storage. After `failure_reset_time` of no checks, we will clear the stored failures. This could happen when the process that performs the checks is not running. --- .../admin/health_check_controller.rb | 2 +- app/controllers/health_controller.rb | 11 +- app/helpers/application_settings_helper.rb | 19 +- app/helpers/storage_health_helper.rb | 6 +- app/models/application_setting.rb | 12 +- .../application_settings/_form.html.haml | 18 +- bin/storage_check | 11 ++ .../unreleased/bvl-circuitbreaker-process.yml | 5 + config/routes.rb | 1 + ..._check_interval_to_application_settings.rb | 20 +++ ...23101020_update_circuitbreaker_defaults.rb | 34 ++++ ...101046_remove_old_circuitbreaker_config.rb | 26 +++ db/schema.rb | 7 +- doc/api/settings.md | 3 +- lib/api/circuit_breakers.rb | 2 +- lib/gitlab/git/storage/checker.rb | 98 +++++++++++ lib/gitlab/git/storage/circuit_breaker.rb | 106 +----------- .../git/storage/circuit_breaker_settings.rb | 12 +- lib/gitlab/git/storage/failure_info.rb | 39 +++++ .../git/storage/null_circuit_breaker.rb | 22 ++- lib/gitlab/storage_check.rb | 11 ++ lib/gitlab/storage_check/cli.rb | 69 ++++++++ lib/gitlab/storage_check/gitlab_caller.rb | 39 +++++ lib/gitlab/storage_check/option_parser.rb | 39 +++++ lib/gitlab/storage_check/response.rb | 77 +++++++++ spec/bin/storage_check_spec.rb | 13 ++ .../admin/health_check_controller_spec.rb | 4 +- spec/controllers/health_controller_spec.rb | 42 +++++ .../features/admin/admin_health_check_spec.rb | 12 +- spec/lib/gitlab/git/storage/checker_spec.rb | 132 ++++++++++++++ .../git/storage/circuit_breaker_spec.rb | 163 ++---------------- .../gitlab/git/storage/failure_info_spec.rb | 70 ++++++++ spec/lib/gitlab/git/storage/health_spec.rb | 2 +- .../git/storage/null_circuit_breaker_spec.rb | 4 +- spec/lib/gitlab/storage_check/cli_spec.rb | 19 ++ .../storage_check/gitlab_caller_spec.rb | 46 +++++ .../storage_check/option_parser_spec.rb | 31 ++++ .../lib/gitlab/storage_check/response_spec.rb | 54 ++++++ spec/models/application_setting_spec.rb | 15 +- spec/models/repository_spec.rb | 8 +- spec/requests/api/circuit_breakers_spec.rb | 2 +- spec/requests/api/settings_spec.rb | 4 +- spec/spec_helper.rb | 12 -- spec/support/stored_repositories.rb | 21 ++- spec/support/stub_configuration.rb | 2 + 45 files changed, 983 insertions(+), 362 deletions(-) create mode 100755 bin/storage_check create mode 100644 changelogs/unreleased/bvl-circuitbreaker-process.yml create mode 100644 db/migrate/20171123094802_add_circuitbreaker_check_interval_to_application_settings.rb create mode 100644 db/post_migrate/20171123101020_update_circuitbreaker_defaults.rb create mode 100644 db/post_migrate/20171123101046_remove_old_circuitbreaker_config.rb create mode 100644 lib/gitlab/git/storage/checker.rb create mode 100644 lib/gitlab/git/storage/failure_info.rb create mode 100644 lib/gitlab/storage_check.rb create mode 100644 lib/gitlab/storage_check/cli.rb create mode 100644 lib/gitlab/storage_check/gitlab_caller.rb create mode 100644 lib/gitlab/storage_check/option_parser.rb create mode 100644 lib/gitlab/storage_check/response.rb create mode 100644 spec/bin/storage_check_spec.rb create mode 100644 spec/lib/gitlab/git/storage/checker_spec.rb create mode 100644 spec/lib/gitlab/git/storage/failure_info_spec.rb create mode 100644 spec/lib/gitlab/storage_check/cli_spec.rb create mode 100644 spec/lib/gitlab/storage_check/gitlab_caller_spec.rb create mode 100644 spec/lib/gitlab/storage_check/option_parser_spec.rb create mode 100644 spec/lib/gitlab/storage_check/response_spec.rb diff --git a/app/controllers/admin/health_check_controller.rb b/app/controllers/admin/health_check_controller.rb index 65a17828feb..61247b280b3 100644 --- a/app/controllers/admin/health_check_controller.rb +++ b/app/controllers/admin/health_check_controller.rb @@ -5,7 +5,7 @@ class Admin::HealthCheckController < Admin::ApplicationController end def reset_storage_health - Gitlab::Git::Storage::CircuitBreaker.reset_all! + Gitlab::Git::Storage::FailureInfo.reset_all! redirect_to admin_health_check_path, notice: _('Git storage health information has been reset') end diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 98c2aaa3526..a931b456a93 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -1,5 +1,5 @@ class HealthController < ActionController::Base - protect_from_forgery with: :exception + protect_from_forgery with: :exception, except: :storage_check include RequiresWhitelistedMonitoringClient CHECKS = [ @@ -23,6 +23,15 @@ class HealthController < ActionController::Base render_check_results(results) end + def storage_check + results = Gitlab::Git::Storage::Checker.check_all + + render json: { + check_interval: Gitlab::CurrentSettings.current_application_settings.circuitbreaker_check_interval, + results: results + } + end + private def render_check_results(results) diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index dccde46fa33..b12ea760668 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -124,17 +124,6 @@ module ApplicationSettingsHelper _('The number of attempts GitLab will make to access a storage.') end - def circuitbreaker_backoff_threshold_help_text - _("The number of failures after which GitLab will start temporarily "\ - "disabling access to a storage shard on a host") - end - - def circuitbreaker_failure_wait_time_help_text - _("When access to a storage fails. GitLab will prevent access to the "\ - "storage for the time specified here. This allows the filesystem to "\ - "recover. Repositories on failing shards are temporarly unavailable") - end - def circuitbreaker_failure_reset_time_help_text _("The time in seconds GitLab will keep failure information. When no "\ "failures occur during this time, information about the mount is reset.") @@ -145,6 +134,11 @@ module ApplicationSettingsHelper "timeout error will be raised.") end + def circuitbreaker_check_interval_help_text + _("The time in seconds between storage checks. When a previous check did "\ + "complete yet, GitLab will skip a check.") + end + def visible_attributes [ :admin_notification_email, @@ -154,10 +148,9 @@ module ApplicationSettingsHelper :akismet_enabled, :auto_devops_enabled, :circuitbreaker_access_retries, - :circuitbreaker_backoff_threshold, + :circuitbreaker_check_interval, :circuitbreaker_failure_count_threshold, :circuitbreaker_failure_reset_time, - :circuitbreaker_failure_wait_time, :circuitbreaker_storage_timeout, :clientside_sentry_dsn, :clientside_sentry_enabled, diff --git a/app/helpers/storage_health_helper.rb b/app/helpers/storage_health_helper.rb index 4d2180f7eee..b76c1228220 100644 --- a/app/helpers/storage_health_helper.rb +++ b/app/helpers/storage_health_helper.rb @@ -18,16 +18,12 @@ module StorageHealthHelper current_failures = circuit_breaker.failure_count translation_params = { number_of_failures: current_failures, - maximum_failures: maximum_failures, - number_of_seconds: circuit_breaker.failure_wait_time } + maximum_failures: maximum_failures } if circuit_breaker.circuit_broken? s_("%{number_of_failures} of %{maximum_failures} failures. GitLab will not "\ "retry automatically. Reset storage information when the problem is "\ "resolved.") % translation_params - elsif circuit_breaker.backing_off? - _("%{number_of_failures} of %{maximum_failures} failures. GitLab will "\ - "block access for %{number_of_seconds} seconds.") % translation_params else _("%{number_of_failures} of %{maximum_failures} failures. GitLab will "\ "allow access on the next attempt.") % translation_params diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 3117c98c846..253e213af81 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -153,11 +153,10 @@ class ApplicationSetting < ActiveRecord::Base presence: true, numericality: { greater_than_or_equal_to: 0 } - validates :circuitbreaker_backoff_threshold, - :circuitbreaker_failure_count_threshold, - :circuitbreaker_failure_wait_time, + validates :circuitbreaker_failure_count_threshold, :circuitbreaker_failure_reset_time, :circuitbreaker_storage_timeout, + :circuitbreaker_check_interval, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } @@ -165,13 +164,6 @@ class ApplicationSetting < ActiveRecord::Base presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 1 } - validates_each :circuitbreaker_backoff_threshold do |record, attr, value| - if value.to_i >= record.circuitbreaker_failure_count_threshold - record.errors.add(attr, _("The circuitbreaker backoff threshold should be "\ - "lower than the failure count threshold")) - end - end - validates :gitaly_timeout_default, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } diff --git a/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index a9d0503bc73..3e2dbb07a6c 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -545,6 +545,12 @@ %fieldset %legend Git Storage Circuitbreaker settings + .form-group + = f.label :circuitbreaker_check_interval, _('Check interval'), class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :circuitbreaker_check_interval, class: 'form-control' + .help-block + = circuitbreaker_check_interval_help_text .form-group = f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'control-label col-sm-2' .col-sm-10 @@ -557,18 +563,6 @@ = f.number_field :circuitbreaker_storage_timeout, class: 'form-control' .help-block = circuitbreaker_storage_timeout_help_text - .form-group - = f.label :circuitbreaker_backoff_threshold, _('Number of failures before backing off'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_backoff_threshold, class: 'form-control' - .help-block - = circuitbreaker_backoff_threshold_help_text - .form-group - = f.label :circuitbreaker_failure_wait_time, _('Seconds to wait after a storage failure'), class: 'control-label col-sm-2' - .col-sm-10 - = f.number_field :circuitbreaker_failure_wait_time, class: 'form-control' - .help-block - = circuitbreaker_failure_wait_time_help_text .form-group = f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2' .col-sm-10 diff --git a/bin/storage_check b/bin/storage_check new file mode 100755 index 00000000000..5a818732bd1 --- /dev/null +++ b/bin/storage_check @@ -0,0 +1,11 @@ +#!/usr/bin/env ruby + +require 'optparse' +require 'net/http' +require 'json' +require 'socket' +require 'logger' + +require_relative '../lib/gitlab/storage_check' + +Gitlab::StorageCheck::CLI.start!(ARGV) diff --git a/changelogs/unreleased/bvl-circuitbreaker-process.yml b/changelogs/unreleased/bvl-circuitbreaker-process.yml new file mode 100644 index 00000000000..595dd13f724 --- /dev/null +++ b/changelogs/unreleased/bvl-circuitbreaker-process.yml @@ -0,0 +1,5 @@ +--- +title: Monitor NFS shards for circuitbreaker in a separate process +merge_request: 15426 +author: +type: changed diff --git a/config/routes.rb b/config/routes.rb index 4f27fea0e92..016140e0ede 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -42,6 +42,7 @@ Rails.application.routes.draw do scope path: '-' do get 'liveness' => 'health#liveness' get 'readiness' => 'health#readiness' + post 'storage_check' => 'health#storage_check' resources :metrics, only: [:index] mount Peek::Railtie => '/peek' diff --git a/db/migrate/20171123094802_add_circuitbreaker_check_interval_to_application_settings.rb b/db/migrate/20171123094802_add_circuitbreaker_check_interval_to_application_settings.rb new file mode 100644 index 00000000000..213d46018fc --- /dev/null +++ b/db/migrate/20171123094802_add_circuitbreaker_check_interval_to_application_settings.rb @@ -0,0 +1,20 @@ +class AddCircuitbreakerCheckIntervalToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :application_settings, + :circuitbreaker_check_interval, + :integer, + default: 1 + end + + def down + remove_column :application_settings, + :circuitbreaker_check_interval + end +end diff --git a/db/post_migrate/20171123101020_update_circuitbreaker_defaults.rb b/db/post_migrate/20171123101020_update_circuitbreaker_defaults.rb new file mode 100644 index 00000000000..8e1c9e6d6bb --- /dev/null +++ b/db/post_migrate/20171123101020_update_circuitbreaker_defaults.rb @@ -0,0 +1,34 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class UpdateCircuitbreakerDefaults < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + class ApplicationSetting < ActiveRecord::Base; end + + def up + change_column_default :application_settings, + :circuitbreaker_failure_count_threshold, + 3 + change_column_default :application_settings, + :circuitbreaker_storage_timeout, + 15 + + ApplicationSetting.update_all(circuitbreaker_failure_count_threshold: 3, + circuitbreaker_storage_timeout: 15) + end + + def down + change_column_default :application_settings, + :circuitbreaker_failure_count_threshold, + 160 + change_column_default :application_settings, + :circuitbreaker_storage_timeout, + 30 + + ApplicationSetting.update_all(circuitbreaker_failure_count_threshold: 160, + circuitbreaker_storage_timeout: 30) + end +end diff --git a/db/post_migrate/20171123101046_remove_old_circuitbreaker_config.rb b/db/post_migrate/20171123101046_remove_old_circuitbreaker_config.rb new file mode 100644 index 00000000000..e646d4d3224 --- /dev/null +++ b/db/post_migrate/20171123101046_remove_old_circuitbreaker_config.rb @@ -0,0 +1,26 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class RemoveOldCircuitbreakerConfig < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + def up + remove_column :application_settings, + :circuitbreaker_backoff_threshold + remove_column :application_settings, + :circuitbreaker_failure_wait_time + end + + def down + add_column :application_settings, + :circuitbreaker_backoff_threshold, + :integer, + default: 80 + add_column :application_settings, + :circuitbreaker_failure_wait_time, + :integer, + default: 30 + end +end diff --git a/db/schema.rb b/db/schema.rb index 6ea3ab54742..4c697a4a384 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -135,12 +135,10 @@ ActiveRecord::Schema.define(version: 20171205190711) do t.boolean "hashed_storage_enabled", default: false, null: false t.boolean "project_export_enabled", default: true, null: false t.boolean "auto_devops_enabled", default: false, null: false - t.integer "circuitbreaker_failure_count_threshold", default: 160 - t.integer "circuitbreaker_failure_wait_time", default: 30 + t.integer "circuitbreaker_failure_count_threshold", default: 3 t.integer "circuitbreaker_failure_reset_time", default: 1800 - t.integer "circuitbreaker_storage_timeout", default: 30 + t.integer "circuitbreaker_storage_timeout", default: 15 t.integer "circuitbreaker_access_retries", default: 3 - t.integer "circuitbreaker_backoff_threshold", default: 80 t.boolean "throttle_unauthenticated_enabled", default: false, null: false t.integer "throttle_unauthenticated_requests_per_period", default: 3600, null: false t.integer "throttle_unauthenticated_period_in_seconds", default: 3600, null: false @@ -150,6 +148,7 @@ ActiveRecord::Schema.define(version: 20171205190711) do t.boolean "throttle_authenticated_web_enabled", default: false, null: false t.integer "throttle_authenticated_web_requests_per_period", default: 7200, null: false t.integer "throttle_authenticated_web_period_in_seconds", default: 3600, null: false + t.integer "circuitbreaker_check_interval", default: 1, null: false t.boolean "password_authentication_enabled_for_web" t.boolean "password_authentication_enabled_for_git", default: true t.integer "gitaly_timeout_default", default: 55, null: false diff --git a/doc/api/settings.md b/doc/api/settings.md index 22fb2baa8ec..0e4758cda2d 100644 --- a/doc/api/settings.md +++ b/doc/api/settings.md @@ -70,10 +70,9 @@ PUT /application/settings | `akismet_api_key` | string | no | API key for akismet spam protection | | `akismet_enabled` | boolean | no | Enable or disable akismet spam protection | | `circuitbreaker_access_retries | integer | no | The number of attempts GitLab will make to access a storage. | -| `circuitbreaker_backoff_threshold | integer | no | The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host. | +| `circuitbreaker_check_interval` | integer | no | Number of seconds in between storage checks. | | `circuitbreaker_failure_count_threshold` | integer | no | The number of failures of after which GitLab will completely prevent access to the storage. | | `circuitbreaker_failure_reset_time` | integer | no | Time in seconds GitLab will keep storage failure information. When no failures occur during this time, the failure information is reset. | -| `circuitbreaker_failure_wait_time` | integer | no | Time in seconds GitLab will block access to a failing storage to allow it to recover. | | `circuitbreaker_storage_timeout` | integer | no | Seconds to wait for a storage access attempt | | `clientside_sentry_dsn` | string | no | Required if `clientside_sentry_dsn` is enabled | | `clientside_sentry_enabled` | boolean | no | Enable Sentry error reporting for the client side | diff --git a/lib/api/circuit_breakers.rb b/lib/api/circuit_breakers.rb index 118883f5ea5..598c76f6168 100644 --- a/lib/api/circuit_breakers.rb +++ b/lib/api/circuit_breakers.rb @@ -41,7 +41,7 @@ module API detail 'This feature was introduced in GitLab 9.5' end delete do - Gitlab::Git::Storage::CircuitBreaker.reset_all! + Gitlab::Git::Storage::FailureInfo.reset_all! end end end diff --git a/lib/gitlab/git/storage/checker.rb b/lib/gitlab/git/storage/checker.rb new file mode 100644 index 00000000000..de63cb4b40c --- /dev/null +++ b/lib/gitlab/git/storage/checker.rb @@ -0,0 +1,98 @@ +module Gitlab + module Git + module Storage + class Checker + include CircuitBreakerSettings + + attr_reader :storage_path, :storage, :hostname, :logger + + def self.check_all(logger = Rails.logger) + threads = Gitlab.config.repositories.storages.keys.map do |storage_name| + Thread.new do + Thread.current[:result] = new(storage_name, logger).check_with_lease + end + end + + threads.map do |thread| + thread.join + thread[:result] + end + end + + def initialize(storage, logger = Rails.logger) + @storage = storage + config = Gitlab.config.repositories.storages[@storage] + @storage_path = config['path'] + @logger = logger + + @hostname = Gitlab::Environment.hostname + end + + def check_with_lease + lease_key = "storage_check:#{cache_key}" + lease = Gitlab::ExclusiveLease.new(lease_key, timeout: storage_timeout) + result = { storage: storage, success: nil } + + if uuid = lease.try_obtain + result[:success] = check + + Gitlab::ExclusiveLease.cancel(lease_key, uuid) + else + logger.warn("#{hostname}: #{storage}: Skipping check, previous check still running") + end + + result + end + + def check + if Gitlab::Git::Storage::ForkedStorageCheck.storage_available?(storage_path, storage_timeout, access_retries) + track_storage_accessible + true + else + track_storage_inaccessible + logger.error("#{hostname}: #{storage}: Not accessible.") + false + end + end + + private + + def track_storage_inaccessible + first_failure = current_failure_info.first_failure || Time.now + last_failure = Time.now + + Gitlab::Git::Storage.redis.with do |redis| + redis.pipelined do + redis.hset(cache_key, :first_failure, first_failure.to_i) + redis.hset(cache_key, :last_failure, last_failure.to_i) + redis.hincrby(cache_key, :failure_count, 1) + redis.expire(cache_key, failure_reset_time) + maintain_known_keys(redis) + end + end + end + + def track_storage_accessible + Gitlab::Git::Storage.redis.with do |redis| + redis.pipelined do + redis.hset(cache_key, :first_failure, nil) + redis.hset(cache_key, :last_failure, nil) + redis.hset(cache_key, :failure_count, 0) + maintain_known_keys(redis) + end + end + end + + def maintain_known_keys(redis) + expire_time = Time.now.to_i + failure_reset_time + redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, expire_time, cache_key) + redis.zremrangebyscore(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, '-inf', Time.now.to_i) + end + + def current_failure_info + FailureInfo.load(cache_key) + end + end + end + end +end diff --git a/lib/gitlab/git/storage/circuit_breaker.rb b/lib/gitlab/git/storage/circuit_breaker.rb index 4328c0ea29b..898bb1b65be 100644 --- a/lib/gitlab/git/storage/circuit_breaker.rb +++ b/lib/gitlab/git/storage/circuit_breaker.rb @@ -4,22 +4,11 @@ module Gitlab class CircuitBreaker include CircuitBreakerSettings - FailureInfo = Struct.new(:last_failure, :failure_count) - attr_reader :storage, - :hostname, - :storage_path + :hostname - delegate :last_failure, :failure_count, to: :failure_info - - def self.reset_all! - Gitlab::Git::Storage.redis.with do |redis| - all_storage_keys = redis.zrange(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, -1) - redis.del(*all_storage_keys) unless all_storage_keys.empty? - end - - RequestStore.delete(:circuitbreaker_cache) - end + delegate :last_failure, :failure_count, :no_failures?, + to: :failure_info def self.for_storage(storage) cached_circuitbreakers = RequestStore.fetch(:circuitbreaker_cache) do @@ -46,9 +35,6 @@ module Gitlab def initialize(storage, hostname) @storage = storage @hostname = hostname - - config = Gitlab.config.repositories.storages[@storage] - @storage_path = config['path'] end def perform @@ -65,15 +51,6 @@ module Gitlab failure_count > failure_count_threshold end - def backing_off? - return false if no_failures? - - recent_failure = last_failure > failure_wait_time.seconds.ago - too_many_failures = failure_count > backoff_threshold - - recent_failure && too_many_failures - end - private # The circuitbreaker can be enabled for the entire fleet using a Feature @@ -86,88 +63,13 @@ module Gitlab end def failure_info - @failure_info ||= get_failure_info - end - - # Memoizing the `storage_available` call means we only do it once per - # request when the storage is available. - # - # When the storage appears not available, and the memoized value is `false` - # we might want to try again. - def storage_available? - return @storage_available if @storage_available - - if @storage_available = Gitlab::Git::Storage::ForkedStorageCheck - .storage_available?(storage_path, storage_timeout, access_retries) - track_storage_accessible - else - track_storage_inaccessible - end - - @storage_available + @failure_info ||= FailureInfo.load(cache_key) end def check_storage_accessible! if circuit_broken? raise Gitlab::Git::Storage::CircuitOpen.new("Circuit for #{storage} is broken", failure_reset_time) end - - if backing_off? - raise Gitlab::Git::Storage::Failing.new("Backing off access to #{storage}", failure_wait_time) - end - - unless storage_available? - raise Gitlab::Git::Storage::Inaccessible.new("#{storage} not accessible", failure_wait_time) - end - end - - def no_failures? - last_failure.blank? && failure_count == 0 - end - - def track_storage_inaccessible - @failure_info = FailureInfo.new(Time.now, failure_count + 1) - - Gitlab::Git::Storage.redis.with do |redis| - redis.pipelined do - redis.hset(cache_key, :last_failure, last_failure.to_i) - redis.hincrby(cache_key, :failure_count, 1) - redis.expire(cache_key, failure_reset_time) - maintain_known_keys(redis) - end - end - end - - def track_storage_accessible - @failure_info = FailureInfo.new(nil, 0) - - Gitlab::Git::Storage.redis.with do |redis| - redis.pipelined do - redis.hset(cache_key, :last_failure, nil) - redis.hset(cache_key, :failure_count, 0) - maintain_known_keys(redis) - end - end - end - - def maintain_known_keys(redis) - expire_time = Time.now.to_i + failure_reset_time - redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, expire_time, cache_key) - redis.zremrangebyscore(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, '-inf', Time.now.to_i) - end - - def get_failure_info - last_failure, failure_count = Gitlab::Git::Storage.redis.with do |redis| - redis.hmget(cache_key, :last_failure, :failure_count) - end - - last_failure = Time.at(last_failure.to_i) if last_failure.present? - - FailureInfo.new(last_failure, failure_count.to_i) - end - - def cache_key - @cache_key ||= "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}#{storage}:#{hostname}" end end end diff --git a/lib/gitlab/git/storage/circuit_breaker_settings.rb b/lib/gitlab/git/storage/circuit_breaker_settings.rb index 257fe8cd8f0..c9e225f187d 100644 --- a/lib/gitlab/git/storage/circuit_breaker_settings.rb +++ b/lib/gitlab/git/storage/circuit_breaker_settings.rb @@ -6,10 +6,6 @@ module Gitlab application_settings.circuitbreaker_failure_count_threshold end - def failure_wait_time - application_settings.circuitbreaker_failure_wait_time - end - def failure_reset_time application_settings.circuitbreaker_failure_reset_time end @@ -22,8 +18,12 @@ module Gitlab application_settings.circuitbreaker_access_retries end - def backoff_threshold - application_settings.circuitbreaker_backoff_threshold + def check_interval + application_settings.circuitbreaker_check_interval + end + + def cache_key + @cache_key ||= "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}#{storage}:#{hostname}" end private diff --git a/lib/gitlab/git/storage/failure_info.rb b/lib/gitlab/git/storage/failure_info.rb new file mode 100644 index 00000000000..387279c110d --- /dev/null +++ b/lib/gitlab/git/storage/failure_info.rb @@ -0,0 +1,39 @@ +module Gitlab + module Git + module Storage + class FailureInfo + attr_accessor :first_failure, :last_failure, :failure_count + + def self.reset_all! + Gitlab::Git::Storage.redis.with do |redis| + all_storage_keys = redis.zrange(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, -1) + redis.del(*all_storage_keys) unless all_storage_keys.empty? + end + + RequestStore.delete(:circuitbreaker_cache) + end + + def self.load(cache_key) + first_failure, last_failure, failure_count = Gitlab::Git::Storage.redis.with do |redis| + redis.hmget(cache_key, :first_failure, :last_failure, :failure_count) + end + + last_failure = Time.at(last_failure.to_i) if last_failure.present? + first_failure = Time.at(first_failure.to_i) if first_failure.present? + + new(first_failure, last_failure, failure_count.to_i) + end + + def initialize(first_failure, last_failure, failure_count) + @first_failure = first_failure + @last_failure = last_failure + @failure_count = failure_count + end + + def no_failures? + first_failure.blank? && last_failure.blank? && failure_count == 0 + end + end + end + end +end diff --git a/lib/gitlab/git/storage/null_circuit_breaker.rb b/lib/gitlab/git/storage/null_circuit_breaker.rb index a12d52d295f..261c936c689 100644 --- a/lib/gitlab/git/storage/null_circuit_breaker.rb +++ b/lib/gitlab/git/storage/null_circuit_breaker.rb @@ -11,6 +11,9 @@ module Gitlab # These will always have nil values attr_reader :storage_path + delegate :last_failure, :failure_count, :no_failures?, + to: :failure_info + def initialize(storage, hostname, error: nil) @storage = storage @hostname = hostname @@ -29,16 +32,17 @@ module Gitlab false end - def last_failure - circuit_broken? ? Time.now : nil - end - - def failure_count - circuit_broken? ? failure_count_threshold : 0 - end - def failure_info - Gitlab::Git::Storage::CircuitBreaker::FailureInfo.new(last_failure, failure_count) + @failure_info ||= + if circuit_broken? + Gitlab::Git::Storage::FailureInfo.new(Time.now, + Time.now, + failure_count_threshold) + else + Gitlab::Git::Storage::FailureInfo.new(nil, + nil, + 0) + end end end end diff --git a/lib/gitlab/storage_check.rb b/lib/gitlab/storage_check.rb new file mode 100644 index 00000000000..fe81513c9ec --- /dev/null +++ b/lib/gitlab/storage_check.rb @@ -0,0 +1,11 @@ +require_relative 'storage_check/cli' +require_relative 'storage_check/gitlab_caller' +require_relative 'storage_check/option_parser' +require_relative 'storage_check/response' + +module Gitlab + module StorageCheck + ENDPOINT = '/-/storage_check'.freeze + Options = Struct.new(:target, :token, :interval, :dryrun) + end +end diff --git a/lib/gitlab/storage_check/cli.rb b/lib/gitlab/storage_check/cli.rb new file mode 100644 index 00000000000..04bf1bf1d26 --- /dev/null +++ b/lib/gitlab/storage_check/cli.rb @@ -0,0 +1,69 @@ +module Gitlab + module StorageCheck + class CLI + def self.start!(args) + runner = new(Gitlab::StorageCheck::OptionParser.parse!(args)) + runner.start_loop + end + + attr_reader :logger, :options + + def initialize(options) + @options = options + @logger = Logger.new(STDOUT) + end + + def start_loop + logger.info "Checking #{options.target} every #{options.interval} seconds" + + if options.dryrun + logger.info "Dryrun, exiting..." + return + end + + begin + loop do + response = GitlabCaller.new(options).call! + log_response(response) + update_settings(response) + + sleep options.interval + end + rescue Interrupt + logger.info "Ending storage-check" + end + end + + def update_settings(response) + previous_interval = options.interval + + if response.valid? + options.interval = response.check_interval || previous_interval + end + + if previous_interval != options.interval + logger.info "Interval changed: #{options.interval} seconds" + end + end + + def log_response(response) + unless response.valid? + return logger.error("Invalid response checking nfs storage: #{response.http_response.inspect}") + end + + if response.responsive_shards.any? + logger.debug("Responsive shards: #{response.responsive_shards.join(', ')}") + end + + warnings = [] + if response.skipped_shards.any? + warnings << "Skipped shards: #{response.skipped_shards.join(', ')}" + end + if response.failing_shards.any? + warnings << "Failing shards: #{response.failing_shards.join(', ')}" + end + logger.warn(warnings.join(' - ')) if warnings.any? + end + end + end +end diff --git a/lib/gitlab/storage_check/gitlab_caller.rb b/lib/gitlab/storage_check/gitlab_caller.rb new file mode 100644 index 00000000000..44952b68844 --- /dev/null +++ b/lib/gitlab/storage_check/gitlab_caller.rb @@ -0,0 +1,39 @@ +require 'excon' + +module Gitlab + module StorageCheck + class GitlabCaller + def initialize(options) + @options = options + end + + def call! + Gitlab::StorageCheck::Response.new(get_response) + rescue Errno::ECONNREFUSED, Excon::Error + # Server not ready, treated as invalid response. + Gitlab::StorageCheck::Response.new(nil) + end + + def get_response + scheme, *other_parts = URI.split(@options.target) + socket_path = if scheme == 'unix' + other_parts.compact.join + end + + connection = Excon.new(@options.target, socket: socket_path) + connection.post(path: Gitlab::StorageCheck::ENDPOINT, + headers: headers) + end + + def headers + @headers ||= begin + headers = {} + headers['Content-Type'] = headers['Accept'] = 'application/json' + headers['TOKEN'] = @options.token if @options.token + + headers + end + end + end + end +end diff --git a/lib/gitlab/storage_check/option_parser.rb b/lib/gitlab/storage_check/option_parser.rb new file mode 100644 index 00000000000..66ed7906f97 --- /dev/null +++ b/lib/gitlab/storage_check/option_parser.rb @@ -0,0 +1,39 @@ +module Gitlab + module StorageCheck + class OptionParser + def self.parse!(args) + # Start out with some defaults + options = Gitlab::StorageCheck::Options.new(nil, nil, 1, false) + + parser = ::OptionParser.new do |opts| + opts.banner = "Usage: bin/storage_check [options]" + + opts.on('-t=string', '--target string', 'URL or socket to trigger storage check') do |value| + options.target = value + end + + opts.on('-T=string', '--token string', 'Health token to use') { |value| options.token = value } + + opts.on('-i=n', '--interval n', ::OptionParser::DecimalInteger, 'Seconds between checks') do |value| + options.interval = value + end + + opts.on('-d', '--dryrun', "Output what will be performed, but don't start the process") do |value| + options.dryrun = value + end + end + parser.parse!(args) + + unless options.target + raise ::OptionParser::InvalidArgument.new('Provide a URI to provide checks') + end + + if URI.parse(options.target).scheme.nil? + raise ::OptionParser::InvalidArgument.new('Add the scheme to the target, `unix://`, `https://` or `http://` are supported') + end + + options + end + end + end +end diff --git a/lib/gitlab/storage_check/response.rb b/lib/gitlab/storage_check/response.rb new file mode 100644 index 00000000000..326ab236e3e --- /dev/null +++ b/lib/gitlab/storage_check/response.rb @@ -0,0 +1,77 @@ +require 'json' + +module Gitlab + module StorageCheck + class Response + attr_reader :http_response + + def initialize(http_response) + @http_response = http_response + end + + def valid? + @http_response && (200...299).cover?(@http_response.status) && + @http_response.headers['Content-Type'].include?('application/json') && + parsed_response + end + + def check_interval + return nil unless parsed_response + + parsed_response['check_interval'] + end + + def responsive_shards + divided_results[:responsive_shards] + end + + def skipped_shards + divided_results[:skipped_shards] + end + + def failing_shards + divided_results[:failing_shards] + end + + private + + def results + return [] unless parsed_response + + parsed_response['results'] + end + + def divided_results + return @divided_results if @divided_results + + @divided_results = {} + @divided_results[:responsive_shards] = [] + @divided_results[:skipped_shards] = [] + @divided_results[:failing_shards] = [] + + results.each do |info| + name = info['storage'] + + case info['success'] + when true + @divided_results[:responsive_shards] << name + when false + @divided_results[:failing_shards] << name + else + @divided_results[:skipped_shards] << name + end + end + + @divided_results + end + + def parsed_response + return @parsed_response if defined?(@parsed_response) + + @parsed_response = JSON.parse(@http_response.body) + rescue JSON::JSONError + @parsed_response = nil + end + end + end +end diff --git a/spec/bin/storage_check_spec.rb b/spec/bin/storage_check_spec.rb new file mode 100644 index 00000000000..02f6fcb6e3a --- /dev/null +++ b/spec/bin/storage_check_spec.rb @@ -0,0 +1,13 @@ +require 'spec_helper' + +describe 'bin/storage_check' do + it 'is executable' do + command = %w[bin/storage_check -t unix://the/path/to/a/unix-socket.sock -i 10 -d] + expected_output = 'Checking unix://the/path/to/a/unix-socket.sock every 10 seconds' + + output, status = Gitlab::Popen.popen(command, Rails.root.to_s) + + expect(status).to eq(0) + expect(output).to include(expected_output) + end +end diff --git a/spec/controllers/admin/health_check_controller_spec.rb b/spec/controllers/admin/health_check_controller_spec.rb index 0b8e0c8a065..d15ee0021d9 100644 --- a/spec/controllers/admin/health_check_controller_spec.rb +++ b/spec/controllers/admin/health_check_controller_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Admin::HealthCheckController, broken_storage: true do +describe Admin::HealthCheckController do let(:admin) { create(:admin) } before do @@ -17,7 +17,7 @@ describe Admin::HealthCheckController, broken_storage: true do describe 'POST reset_storage_health' do it 'resets all storage health information' do - expect(Gitlab::Git::Storage::CircuitBreaker).to receive(:reset_all!) + expect(Gitlab::Git::Storage::FailureInfo).to receive(:reset_all!) post :reset_storage_health end diff --git a/spec/controllers/health_controller_spec.rb b/spec/controllers/health_controller_spec.rb index 9e9cf4f2c1f..95946def5f9 100644 --- a/spec/controllers/health_controller_spec.rb +++ b/spec/controllers/health_controller_spec.rb @@ -14,6 +14,48 @@ describe HealthController do stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false') end + describe '#storage_check' do + before do + allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip) + end + + subject { post :storage_check } + + it 'checks all the configured storages' do + expect(Gitlab::Git::Storage::Checker).to receive(:check_all).and_call_original + + subject + end + + it 'returns the check interval' do + stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'true') + stub_application_setting(circuitbreaker_check_interval: 10) + + subject + + expect(json_response['check_interval']).to eq(10) + end + + context 'with failing storages', :broken_storage do + before do + stub_storage_settings( + broken: { path: 'tmp/tests/non-existent-repositories' } + ) + end + + it 'includes the failure information' do + subject + + expected_results = [ + { 'storage' => 'broken', 'success' => false }, + { 'storage' => 'default', 'success' => true } + ] + + expect(json_response['results']).to eq(expected_results) + end + end + end + describe '#readiness' do shared_context 'endpoint responding with readiness data' do let(:request_params) { {} } diff --git a/spec/features/admin/admin_health_check_spec.rb b/spec/features/admin/admin_health_check_spec.rb index 4430fc15501..ac3392b49f9 100644 --- a/spec/features/admin/admin_health_check_spec.rb +++ b/spec/features/admin/admin_health_check_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -feature "Admin Health Check", :feature, :broken_storage do +feature "Admin Health Check", :feature do include StubENV before do @@ -36,6 +36,7 @@ feature "Admin Health Check", :feature, :broken_storage do context 'when services are up' do before do + stub_storage_settings({}) # Hide the broken storage visit admin_health_check_path end @@ -56,10 +57,8 @@ feature "Admin Health Check", :feature, :broken_storage do end end - context 'with repository storage failures' do + context 'with repository storage failures', :broken_storage do before do - # Track a failure - Gitlab::Git::Storage::CircuitBreaker.for_storage('broken').perform { nil } rescue nil visit admin_health_check_path end @@ -67,9 +66,10 @@ feature "Admin Health Check", :feature, :broken_storage do hostname = Gitlab::Environment.hostname maximum_failures = Gitlab::CurrentSettings.current_application_settings .circuitbreaker_failure_count_threshold + number_of_failures = maximum_failures + 1 - expect(page).to have_content('broken: failed storage access attempt on host:') - expect(page).to have_content("#{hostname}: 1 of #{maximum_failures} failures.") + expect(page).to have_content("broken: #{number_of_failures} failed storage access attempts:") + expect(page).to have_content("#{hostname}: #{number_of_failures} of #{maximum_failures} failures.") end it 'allows resetting storage failures' do diff --git a/spec/lib/gitlab/git/storage/checker_spec.rb b/spec/lib/gitlab/git/storage/checker_spec.rb new file mode 100644 index 00000000000..d74c3bcb04c --- /dev/null +++ b/spec/lib/gitlab/git/storage/checker_spec.rb @@ -0,0 +1,132 @@ +require 'spec_helper' + +describe Gitlab::Git::Storage::Checker, :clean_gitlab_redis_shared_state do + let(:storage_name) { 'default' } + let(:hostname) { Gitlab::Environment.hostname } + let(:cache_key) { "storage_accessible:#{storage_name}:#{hostname}" } + + subject(:checker) { described_class.new(storage_name) } + + def value_from_redis(name) + Gitlab::Git::Storage.redis.with do |redis| + redis.hmget(cache_key, name) + end.first + end + + def set_in_redis(name, value) + Gitlab::Git::Storage.redis.with do |redis| + redis.hmset(cache_key, name, value) + end.first + end + + describe '.check_all' do + it 'calls a check for each storage' do + fake_checker_default = double + fake_checker_broken = double + fake_logger = fake_logger + + expect(described_class).to receive(:new).with('default', fake_logger) { fake_checker_default } + expect(described_class).to receive(:new).with('broken', fake_logger) { fake_checker_broken } + expect(fake_checker_default).to receive(:check_with_lease) + expect(fake_checker_broken).to receive(:check_with_lease) + + described_class.check_all(fake_logger) + end + + context 'with broken storage', :broken_storage do + it 'returns the results' do + expected_result = [ + { storage: 'default', success: true }, + { storage: 'broken', success: false } + ] + + expect(described_class.check_all).to eq(expected_result) + end + end + end + + describe '#initialize' do + it 'assigns the settings' do + expect(checker.hostname).to eq(hostname) + expect(checker.storage).to eq('default') + expect(checker.storage_path).to eq(TestEnv.repos_path) + end + end + + describe '#check_with_lease' do + it 'only allows one check at a time' do + expect(checker).to receive(:check).once { sleep 1 } + + thread = Thread.new { checker.check_with_lease } + checker.check_with_lease + thread.join + end + + it 'returns a result hash' do + expect(checker.check_with_lease).to eq(storage: 'default', success: true) + end + end + + describe '#check' do + it 'tracks that the storage was accessible' do + set_in_redis(:failure_count, 10) + set_in_redis(:last_failure, Time.now.to_f) + + checker.check + + expect(value_from_redis(:failure_count).to_i).to eq(0) + expect(value_from_redis(:last_failure)).to be_empty + expect(value_from_redis(:first_failure)).to be_empty + end + + it 'calls the check with the correct arguments' do + stub_application_setting(circuitbreaker_storage_timeout: 30, + circuitbreaker_access_retries: 3) + + expect(Gitlab::Git::Storage::ForkedStorageCheck) + .to receive(:storage_available?).with(TestEnv.repos_path, 30, 3) + .and_call_original + + checker.check + end + + it 'returns `true`' do + expect(checker.check).to eq(true) + end + + it 'maintains known storage keys' do + Timecop.freeze do + # Insert an old key to expire + old_entry = Time.now.to_i - 3.days.to_i + Gitlab::Git::Storage.redis.with do |redis| + redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, old_entry, 'to_be_removed') + end + + checker.check + + known_keys = Gitlab::Git::Storage.redis.with do |redis| + redis.zrange(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, -1) + end + + expect(known_keys).to contain_exactly(cache_key) + end + end + + context 'the storage is not available', :broken_storage do + let(:storage_name) { 'broken' } + + it 'tracks that the storage was inaccessible' do + Timecop.freeze do + expect { checker.check }.to change { value_from_redis(:failure_count).to_i }.by(1) + + expect(value_from_redis(:last_failure)).not_to be_empty + expect(value_from_redis(:first_failure)).not_to be_empty + end + end + + it 'returns `false`' do + expect(checker.check).to eq(false) + end + end + end +end diff --git a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb index f34c9f09057..210b90bfba9 100644 --- a/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb +++ b/spec/lib/gitlab/git/storage/circuit_breaker_spec.rb @@ -1,11 +1,18 @@ require 'spec_helper' -describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: true, broken_storage: true do +describe Gitlab::Git::Storage::CircuitBreaker, :broken_storage do let(:storage_name) { 'default' } let(:circuit_breaker) { described_class.new(storage_name, hostname) } let(:hostname) { Gitlab::Environment.hostname } let(:cache_key) { "storage_accessible:#{storage_name}:#{hostname}" } + def set_in_redis(name, value) + Gitlab::Git::Storage.redis.with do |redis| + redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, cache_key) + redis.hmset(cache_key, name, value) + end.first + end + before do # Override test-settings for the circuitbreaker with something more realistic # for these specs. @@ -19,36 +26,7 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: ) end - def value_from_redis(name) - Gitlab::Git::Storage.redis.with do |redis| - redis.hmget(cache_key, name) - end.first - end - - def set_in_redis(name, value) - Gitlab::Git::Storage.redis.with do |redis| - redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, cache_key) - redis.hmset(cache_key, name, value) - end.first - end - - describe '.reset_all!' do - it 'clears all entries form redis' do - set_in_redis(:failure_count, 10) - - described_class.reset_all! - - key_exists = Gitlab::Git::Storage.redis.with { |redis| redis.exists(cache_key) } - - expect(key_exists).to be_falsey - end - - it 'does not break when there are no keys in redis' do - expect { described_class.reset_all! }.not_to raise_error - end - end - - describe '.for_storage' do + describe '.for_storage', :request_store do it 'only builds a single circuitbreaker per storage' do expect(described_class).to receive(:new).once.and_call_original @@ -71,7 +49,6 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: it 'assigns the settings' do expect(circuit_breaker.hostname).to eq(hostname) expect(circuit_breaker.storage).to eq('default') - expect(circuit_breaker.storage_path).to eq(TestEnv.repos_path) end end @@ -91,9 +68,9 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: end end - describe '#failure_wait_time' do + describe '#check_interval' do it 'reads the value from settings' do - expect(circuit_breaker.failure_wait_time).to eq(1) + expect(circuit_breaker.check_interval).to eq(1) end end @@ -114,12 +91,6 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: expect(circuit_breaker.access_retries).to eq(4) end end - - describe '#backoff_threshold' do - it 'reads the value from settings' do - expect(circuit_breaker.backoff_threshold).to eq(5) - end - end end describe '#perform' do @@ -134,19 +105,6 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: end end - it 'raises the correct exception when backing off' do - Timecop.freeze do - set_in_redis(:last_failure, 1.second.ago.to_f) - set_in_redis(:failure_count, 90) - - expect { |b| circuit_breaker.perform(&b) } - .to raise_error do |exception| - expect(exception).to be_kind_of(Gitlab::Git::Storage::Failing) - expect(exception.retry_after).to eq(30) - end - end - end - it 'yields the block' do expect { |b| circuit_breaker.perform(&b) } .to yield_control @@ -170,54 +128,6 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: .to raise_error(Rugged::OSError) end - it 'tracks that the storage was accessible' do - set_in_redis(:failure_count, 10) - set_in_redis(:last_failure, Time.now.to_f) - - circuit_breaker.perform { '' } - - expect(value_from_redis(:failure_count).to_i).to eq(0) - expect(value_from_redis(:last_failure)).to be_empty - expect(circuit_breaker.failure_count).to eq(0) - expect(circuit_breaker.last_failure).to be_nil - end - - it 'maintains known storage keys' do - Timecop.freeze do - # Insert an old key to expire - old_entry = Time.now.to_i - 3.days.to_i - Gitlab::Git::Storage.redis.with do |redis| - redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, old_entry, 'to_be_removed') - end - - circuit_breaker.perform { '' } - - known_keys = Gitlab::Git::Storage.redis.with do |redis| - redis.zrange(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, -1) - end - - expect(known_keys).to contain_exactly(cache_key) - end - end - - it 'only performs the accessibility check once' do - expect(Gitlab::Git::Storage::ForkedStorageCheck) - .to receive(:storage_available?).once.and_call_original - - 2.times { circuit_breaker.perform { '' } } - end - - it 'calls the check with the correct arguments' do - stub_application_setting(circuitbreaker_storage_timeout: 30, - circuitbreaker_access_retries: 3) - - expect(Gitlab::Git::Storage::ForkedStorageCheck) - .to receive(:storage_available?).with(TestEnv.repos_path, 30, 3) - .and_call_original - - circuit_breaker.perform { '' } - end - context 'with the feature disabled' do before do stub_feature_flags(git_storage_circuit_breaker: false) @@ -240,31 +150,6 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: expect(result).to eq('hello') end end - - context 'the storage is not available' do - let(:storage_name) { 'broken' } - - it 'raises the correct exception' do - expect(circuit_breaker).to receive(:track_storage_inaccessible) - - expect { circuit_breaker.perform { '' } } - .to raise_error do |exception| - expect(exception).to be_kind_of(Gitlab::Git::Storage::Inaccessible) - expect(exception.retry_after).to eq(30) - end - end - - it 'tracks that the storage was inaccessible' do - Timecop.freeze do - expect { circuit_breaker.perform { '' } }.to raise_error(Gitlab::Git::Storage::Inaccessible) - - expect(value_from_redis(:failure_count).to_i).to eq(1) - expect(value_from_redis(:last_failure)).not_to be_empty - expect(circuit_breaker.failure_count).to eq(1) - expect(circuit_breaker.last_failure).to be_within(1.second).of(Time.now) - end - end - end end describe '#circuit_broken?' do @@ -283,32 +168,6 @@ describe Gitlab::Git::Storage::CircuitBreaker, clean_gitlab_redis_shared_state: end end - describe '#backing_off?' do - it 'is true when there was a recent failure' do - Timecop.freeze do - set_in_redis(:last_failure, 1.second.ago.to_f) - set_in_redis(:failure_count, 90) - - expect(circuit_breaker.backing_off?).to be_truthy - end - end - - context 'the `failure_wait_time` is set to 0' do - before do - stub_application_setting(circuitbreaker_failure_wait_time: 0) - end - - it 'is working even when there are failures' do - Timecop.freeze do - set_in_redis(:last_failure, 0.seconds.ago.to_f) - set_in_redis(:failure_count, 90) - - expect(circuit_breaker.backing_off?).to be_falsey - end - end - end - end - describe '#last_failure' do it 'returns the last failure time' do time = Time.parse("2017-05-26 17:52:30") diff --git a/spec/lib/gitlab/git/storage/failure_info_spec.rb b/spec/lib/gitlab/git/storage/failure_info_spec.rb new file mode 100644 index 00000000000..bae88fdda86 --- /dev/null +++ b/spec/lib/gitlab/git/storage/failure_info_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe Gitlab::Git::Storage::FailureInfo, :broken_storage do + let(:storage_name) { 'default' } + let(:hostname) { Gitlab::Environment.hostname } + let(:cache_key) { "storage_accessible:#{storage_name}:#{hostname}" } + + def value_from_redis(name) + Gitlab::Git::Storage.redis.with do |redis| + redis.hmget(cache_key, name) + end.first + end + + def set_in_redis(name, value) + Gitlab::Git::Storage.redis.with do |redis| + redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, cache_key) + redis.hmset(cache_key, name, value) + end.first + end + + describe '.reset_all!' do + it 'clears all entries form redis' do + set_in_redis(:failure_count, 10) + + described_class.reset_all! + + key_exists = Gitlab::Git::Storage.redis.with { |redis| redis.exists(cache_key) } + + expect(key_exists).to be_falsey + end + + it 'does not break when there are no keys in redis' do + expect { described_class.reset_all! }.not_to raise_error + end + end + + describe '.load' do + it 'loads failure information for a storage on a host' do + first_failure = Time.parse("2017-11-14 17:52:30") + last_failure = Time.parse("2017-11-14 18:54:37") + failure_count = 11 + + set_in_redis(:first_failure, first_failure.to_i) + set_in_redis(:last_failure, last_failure.to_i) + set_in_redis(:failure_count, failure_count.to_i) + + info = described_class.load(cache_key) + + expect(info.first_failure).to eq(first_failure) + expect(info.last_failure).to eq(last_failure) + expect(info.failure_count).to eq(failure_count) + end + end + + describe '#no_failures?' do + it 'is true when there are no failures' do + info = described_class.new(nil, nil, 0) + + expect(info.no_failures?).to be_truthy + end + + it 'is false when there are failures' do + info = described_class.new(Time.parse("2017-11-14 17:52:30"), + Time.parse("2017-11-14 18:54:37"), + 20) + + expect(info.no_failures?).to be_falsy + end + end +end diff --git a/spec/lib/gitlab/git/storage/health_spec.rb b/spec/lib/gitlab/git/storage/health_spec.rb index d7a52a04fbb..bb670fc5d94 100644 --- a/spec/lib/gitlab/git/storage/health_spec.rb +++ b/spec/lib/gitlab/git/storage/health_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' -describe Gitlab::Git::Storage::Health, clean_gitlab_redis_shared_state: true, broken_storage: true do +describe Gitlab::Git::Storage::Health, broken_storage: true do let(:host1_key) { 'storage_accessible:broken:web01' } let(:host2_key) { 'storage_accessible:default:kiq01' } diff --git a/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb b/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb index 5db37f55e03..93ad20011de 100644 --- a/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb +++ b/spec/lib/gitlab/git/storage/null_circuit_breaker_spec.rb @@ -27,7 +27,7 @@ describe Gitlab::Git::Storage::NullCircuitBreaker do end describe '#failure_info' do - it { Timecop.freeze { expect(breaker.failure_info).to eq(Gitlab::Git::Storage::CircuitBreaker::FailureInfo.new(Time.now, breaker.failure_count_threshold)) } } + it { expect(breaker.failure_info.no_failures?).to be_falsy } end end @@ -49,7 +49,7 @@ describe Gitlab::Git::Storage::NullCircuitBreaker do end describe '#failure_info' do - it { expect(breaker.failure_info).to eq(Gitlab::Git::Storage::CircuitBreaker::FailureInfo.new(nil, 0)) } + it { expect(breaker.failure_info.no_failures?).to be_truthy } end end diff --git a/spec/lib/gitlab/storage_check/cli_spec.rb b/spec/lib/gitlab/storage_check/cli_spec.rb new file mode 100644 index 00000000000..6db0925899c --- /dev/null +++ b/spec/lib/gitlab/storage_check/cli_spec.rb @@ -0,0 +1,19 @@ +require 'spec_helper' + +describe Gitlab::StorageCheck::CLI do + let(:options) { Gitlab::StorageCheck::Options.new('unix://tmp/socket.sock', nil, 1, false) } + subject(:runner) { described_class.new(options) } + + describe '#update_settings' do + it 'updates the interval when changed in a valid response and logs the change' do + fake_response = double + expect(fake_response).to receive(:valid?).and_return(true) + expect(fake_response).to receive(:check_interval).and_return(42) + expect(runner.logger).to receive(:info) + + runner.update_settings(fake_response) + + expect(options.interval).to eq(42) + end + end +end diff --git a/spec/lib/gitlab/storage_check/gitlab_caller_spec.rb b/spec/lib/gitlab/storage_check/gitlab_caller_spec.rb new file mode 100644 index 00000000000..d869022fd31 --- /dev/null +++ b/spec/lib/gitlab/storage_check/gitlab_caller_spec.rb @@ -0,0 +1,46 @@ +require 'spec_helper' + +describe Gitlab::StorageCheck::GitlabCaller do + let(:options) { Gitlab::StorageCheck::Options.new('unix://tmp/socket.sock', nil, nil, false) } + subject(:gitlab_caller) { described_class.new(options) } + + describe '#call!' do + context 'when a socket is given' do + it 'calls a socket' do + fake_connection = double + expect(fake_connection).to receive(:post) + expect(Excon).to receive(:new).with('unix://tmp/socket.sock', socket: "tmp/socket.sock") { fake_connection } + + gitlab_caller.call! + end + end + + context 'when a host is given' do + let(:options) { Gitlab::StorageCheck::Options.new('http://localhost:8080', nil, nil, false) } + + it 'it calls a http response' do + fake_connection = double + expect(Excon).to receive(:new).with('http://localhost:8080', socket: nil) { fake_connection } + expect(fake_connection).to receive(:post) + + gitlab_caller.call! + end + end + end + + describe '#headers' do + it 'Adds the JSON header' do + headers = gitlab_caller.headers + + expect(headers['Content-Type']).to eq('application/json') + end + + context 'when a token was provided' do + let(:options) { Gitlab::StorageCheck::Options.new('unix://tmp/socket.sock', 'atoken', nil, false) } + + it 'adds it to the headers' do + expect(gitlab_caller.headers['TOKEN']).to eq('atoken') + end + end + end +end diff --git a/spec/lib/gitlab/storage_check/option_parser_spec.rb b/spec/lib/gitlab/storage_check/option_parser_spec.rb new file mode 100644 index 00000000000..cad4dfbefcf --- /dev/null +++ b/spec/lib/gitlab/storage_check/option_parser_spec.rb @@ -0,0 +1,31 @@ +require 'spec_helper' + +describe Gitlab::StorageCheck::OptionParser do + describe '.parse!' do + it 'assigns all options' do + args = %w(--target unix://tmp/hello/world.sock --token thetoken --interval 42) + + options = described_class.parse!(args) + + expect(options.token).to eq('thetoken') + expect(options.interval).to eq(42) + expect(options.target).to eq('unix://tmp/hello/world.sock') + end + + it 'requires the interval to be a number' do + args = %w(--target unix://tmp/hello/world.sock --interval fortytwo) + + expect { described_class.parse!(args) }.to raise_error(OptionParser::InvalidArgument) + end + + it 'raises an error if the scheme is not included' do + args = %w(--target tmp/hello/world.sock) + + expect { described_class.parse!(args) }.to raise_error(OptionParser::InvalidArgument) + end + + it 'raises an error if both socket and host are missing' do + expect { described_class.parse!([]) }.to raise_error(OptionParser::InvalidArgument) + end + end +end diff --git a/spec/lib/gitlab/storage_check/response_spec.rb b/spec/lib/gitlab/storage_check/response_spec.rb new file mode 100644 index 00000000000..0ff2963e443 --- /dev/null +++ b/spec/lib/gitlab/storage_check/response_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +describe Gitlab::StorageCheck::Response do + let(:fake_json) do + { + check_interval: 42, + results: [ + { storage: 'working', success: true }, + { storage: 'skipped', success: nil }, + { storage: 'failing', success: false } + ] + }.to_json + end + + let(:fake_http_response) do + fake_response = instance_double("Excon::Response - Status check") + allow(fake_response).to receive(:status).and_return(200) + allow(fake_response).to receive(:body).and_return(fake_json) + allow(fake_response).to receive(:headers).and_return('Content-Type' => 'application/json') + + fake_response + end + let(:response) { described_class.new(fake_http_response) } + + describe '#valid?' do + it 'is valid for a success response with parseable JSON' do + expect(response).to be_valid + end + end + + describe '#check_interval' do + it 'returns the result from the JSON' do + expect(response.check_interval).to eq(42) + end + end + + describe '#responsive_shards' do + it 'contains the names of working shards' do + expect(response.responsive_shards).to contain_exactly('working') + end + end + + describe '#skipped_shards' do + it 'contains the names of skipped shards' do + expect(response.skipped_shards).to contain_exactly('skipped') + end + end + + describe '#failing_shards' do + it 'contains the name of failing shards' do + expect(response.failing_shards).to contain_exactly('failing') + end + end +end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 0b7e16cc33c..ef480e7a80a 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -115,9 +115,8 @@ describe ApplicationSetting do end context 'circuitbreaker settings' do - [:circuitbreaker_backoff_threshold, - :circuitbreaker_failure_count_threshold, - :circuitbreaker_failure_wait_time, + [:circuitbreaker_failure_count_threshold, + :circuitbreaker_check_interval, :circuitbreaker_failure_reset_time, :circuitbreaker_storage_timeout].each do |field| it "Validates #{field} as number" do @@ -126,16 +125,6 @@ describe ApplicationSetting do .is_greater_than_or_equal_to(0) end end - - it 'requires the `backoff_threshold` to be lower than the `failure_count_threshold`' do - setting.circuitbreaker_failure_count_threshold = 10 - setting.circuitbreaker_backoff_threshold = 15 - failure_message = "The circuitbreaker backoff threshold should be lower "\ - "than the failure count threshold" - - expect(setting).not_to be_valid - expect(setting.errors[:circuitbreaker_backoff_threshold]).to include(failure_message) - end end context 'repository storages' do diff --git a/spec/models/repository_spec.rb b/spec/models/repository_spec.rb index 82ed1ecee33..358bc3dfb94 100644 --- a/spec/models/repository_spec.rb +++ b/spec/models/repository_spec.rb @@ -29,7 +29,9 @@ describe Repository do def expect_to_raise_storage_error expect { yield }.to raise_error do |exception| storage_exceptions = [Gitlab::Git::Storage::Inaccessible, Gitlab::Git::CommandError, GRPC::Unavailable] - expect(exception.class).to be_in(storage_exceptions) + known_exception = storage_exceptions.select { |e| exception.is_a?(e) } + + expect(known_exception).not_to be_nil end end @@ -634,9 +636,7 @@ describe Repository do end describe '#fetch_ref' do - # Setting the var here, sidesteps the stub that makes gitaly raise an error - # before the actual test call - set(:broken_repository) { create(:project, :broken_storage).repository } + let(:broken_repository) { create(:project, :broken_storage).repository } describe 'when storage is broken', :broken_storage do it 'should raise a storage error' do diff --git a/spec/requests/api/circuit_breakers_spec.rb b/spec/requests/api/circuit_breakers_spec.rb index 3b858c40fd6..fe76f057115 100644 --- a/spec/requests/api/circuit_breakers_spec.rb +++ b/spec/requests/api/circuit_breakers_spec.rb @@ -47,7 +47,7 @@ describe API::CircuitBreakers do describe 'DELETE circuit_breakers/repository_storage' do it 'clears all circuit_breakers' do - expect(Gitlab::Git::Storage::CircuitBreaker).to receive(:reset_all!) + expect(Gitlab::Git::Storage::FailureInfo).to receive(:reset_all!) delete api('/circuit_breakers/repository_storage', admin) diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 63175c40a18..015d4b9a491 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -54,7 +54,7 @@ describe API::Settings, 'Settings' do dsa_key_restriction: 2048, ecdsa_key_restriction: 384, ed25519_key_restriction: 256, - circuitbreaker_failure_wait_time: 2 + circuitbreaker_check_interval: 2 expect(response).to have_gitlab_http_status(200) expect(json_response['default_projects_limit']).to eq(3) @@ -75,7 +75,7 @@ describe API::Settings, 'Settings' do expect(json_response['dsa_key_restriction']).to eq(2048) expect(json_response['ecdsa_key_restriction']).to eq(384) expect(json_response['ed25519_key_restriction']).to eq(256) - expect(json_response['circuitbreaker_failure_wait_time']).to eq(2) + expect(json_response['circuitbreaker_check_interval']).to eq(2) end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 242a2230b67..f94fb8733d5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -121,18 +121,6 @@ RSpec.configure do |config| reset_delivered_emails! end - # Stub the `ForkedStorageCheck.storage_available?` method unless - # `:broken_storage` metadata is defined - # - # This check can be slow and is unnecessary in a test environment where we - # know the storage is available, because we create it at runtime - config.before(:example) do |example| - unless example.metadata[:broken_storage] - allow(Gitlab::Git::Storage::ForkedStorageCheck) - .to receive(:storage_available?).and_return(true) - end - end - config.around(:each, :use_clean_rails_memory_store_caching) do |example| caching_store = Rails.cache Rails.cache = ActiveSupport::Cache::MemoryStore.new diff --git a/spec/support/stored_repositories.rb b/spec/support/stored_repositories.rb index f3deae0f455..f9121cce985 100644 --- a/spec/support/stored_repositories.rb +++ b/spec/support/stored_repositories.rb @@ -12,6 +12,25 @@ RSpec.configure do |config| raise GRPC::Unavailable.new('Gitaly broken in this spec') end - Gitlab::Git::Storage::CircuitBreaker.reset_all! + # Track the maximum number of failures + first_failure = Time.parse("2017-11-14 17:52:30") + last_failure = Time.parse("2017-11-14 18:54:37") + failure_count = Gitlab::CurrentSettings + .current_application_settings + .circuitbreaker_failure_count_threshold + 1 + cache_key = "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}broken:#{Gitlab::Environment.hostname}" + + Gitlab::Git::Storage.redis.with do |redis| + redis.pipelined do + redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, cache_key) + redis.hset(cache_key, :first_failure, first_failure.to_i) + redis.hset(cache_key, :last_failure, last_failure.to_i) + redis.hset(cache_key, :failure_count, failure_count.to_i) + end + end + end + + config.after(:each, :broken_storage) do + Gitlab::Git::Storage.redis.with(&:flushall) end end diff --git a/spec/support/stub_configuration.rb b/spec/support/stub_configuration.rb index 4ead78529c3..b36cf3c544c 100644 --- a/spec/support/stub_configuration.rb +++ b/spec/support/stub_configuration.rb @@ -43,6 +43,8 @@ module StubConfiguration end def stub_storage_settings(messages) + messages.deep_stringify_keys! + # Default storage is always required messages['default'] ||= Gitlab.config.repositories.storages.default messages.each do |storage_name, storage_settings| From 13df7a85cb6d934a5b0fdfc63810879647e9a28c Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 7 Dec 2017 11:30:47 +0000 Subject: [PATCH 066/112] Moved URL utility methods into es modules --- .../javascripts/lib/utils/url_utility.js | 127 ++++++++---------- 1 file changed, 58 insertions(+), 69 deletions(-) diff --git a/app/assets/javascripts/lib/utils/url_utility.js b/app/assets/javascripts/lib/utils/url_utility.js index 17236c91490..f03848822a4 100644 --- a/app/assets/javascripts/lib/utils/url_utility.js +++ b/app/assets/javascripts/lib/utils/url_utility.js @@ -1,93 +1,72 @@ -/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, one-var, one-var-declaration-per-line, no-void, guard-for-in, no-restricted-syntax, prefer-template, quotes, max-len */ - -var base; -var w = window; -if (w.gl == null) { - w.gl = {}; -} -if ((base = w.gl).utils == null) { - base.utils = {}; -} // Returns an array containing the value(s) of the // of the key passed as an argument -w.gl.utils.getParameterValues = function(sParam) { - var i, sPageURL, sParameterName, sURLVariables, values; - sPageURL = decodeURIComponent(window.location.search.substring(1)); - sURLVariables = sPageURL.split('&'); - sParameterName = void 0; - values = []; - i = 0; - while (i < sURLVariables.length) { - sParameterName = sURLVariables[i].split('='); +export function getParameterValues(sParam) { + const sPageURL = decodeURIComponent(window.location.search.substring(1)); + + return sPageURL.split('&').reduce((acc, urlParam) => { + const sParameterName = urlParam.split('='); + if (sParameterName[0] === sParam) { - values.push(sParameterName[1].replace(/\+/g, ' ')); + acc.push(sParameterName[1].replace(/\+/g, ' ')); } - i += 1; - } - return values; -}; + + return acc; + }, []); +} + // @param {Object} params - url keys and value to merge // @param {String} url -w.gl.utils.mergeUrlParams = function(params, url) { - var lastChar, newUrl, paramName, paramValue, pattern; - newUrl = decodeURIComponent(url); - for (paramName in params) { - paramValue = params[paramName]; - pattern = new RegExp("\\b(" + paramName + "=).*?(&|$)"); - if (paramValue == null) { - newUrl = newUrl.replace(pattern, ''); +export function mergeUrlParams(params, url) { + let newUrl = Object.keys(params).reduce((accParam, paramName) => { + const paramValue = params[paramName]; + const pattern = new RegExp(`\\b(${paramName}=).*?(&|$)`); + let acc = accParam; + + if (paramValue === null) { + acc = acc.replace(pattern, ''); } else if (url.search(pattern) !== -1) { - newUrl = newUrl.replace(pattern, "$1" + paramValue + "$2"); + acc = acc.replace(pattern, `$1${paramValue}$2`); } else { - newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue; + acc = `${accParam}${accParam.indexOf('?') > 0 ? '&' : '?'}${paramName}=${paramValue}`; } - } + + return acc; + }, decodeURIComponent(url)); + // Remove a trailing ampersand - lastChar = newUrl[newUrl.length - 1]; + const lastChar = newUrl[newUrl.length - 1]; + if (lastChar === '&') { newUrl = newUrl.slice(0, -1); } + return newUrl; -}; -// removes parameter query string from url. returns the modified url -w.gl.utils.removeParamQueryString = function(url, param) { - var urlVariables, variables; - url = decodeURIComponent(url); - urlVariables = url.split('&'); - return ((function() { - var j, len, results; - results = []; - for (j = 0, len = urlVariables.length; j < len; j += 1) { - variables = urlVariables[j]; - if (variables.indexOf(param) === -1) { - results.push(variables); - } - } - return results; - })()).join('&'); -}; -w.gl.utils.removeParams = (params) => { +} + +export function removeParamQueryString(url, param) { + const decodedUrl = decodeURIComponent(url); + const urlVariables = decodedUrl.split('&'); + + return urlVariables.filter(variable => variable.indexOf(param) === -1).join('&'); +} + +export function removeParams(params) { const url = document.createElement('a'); url.href = window.location.href; + params.forEach((param) => { - url.search = w.gl.utils.removeParamQueryString(url.search, param); + url.search = removeParamQueryString(url.search, param); }); + return url.href; -}; -w.gl.utils.getLocationHash = function(url) { - var hashIndex; - if (typeof url === 'undefined') { - // Note: We can't use window.location.hash here because it's - // not consistent across browsers - Firefox will pre-decode it - url = window.location.href; - } - hashIndex = url.indexOf('#'); +} + +export function getLocationHash(url) { + const hashIndex = window.location.href.indexOf('#'); + return hashIndex === -1 ? null : url.substring(hashIndex + 1); -}; +} -w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(window.location.href); - -// eslint-disable-next-line import/prefer-default-export export function visitUrl(url, external = false) { if (external) { // Simulate `target="blank" rel="noopener noreferrer"` @@ -100,6 +79,10 @@ export function visitUrl(url, external = false) { } } +export function refreshCurrentPage() { + visitUrl(window.location.href); +} + export function redirectTo(url) { return window.location.assign(url); } @@ -107,5 +90,11 @@ export function redirectTo(url) { window.gl = window.gl || {}; window.gl.utils = { ...(window.gl.utils || {}), + mergeUrlParams, + getLocationHash, + getParameterValues, + redirectTo, + refreshCurrentPage, + removeParams, visitUrl, }; From e0bbadc2d2c50fce75ef1166d0991a5d04ef5e0a Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 7 Dec 2017 12:30:53 +0000 Subject: [PATCH 067/112] use exported methods instead of gl.utils --- app/assets/javascripts/admin.js | 5 ++-- .../javascripts/behaviors/toggler_behavior.js | 3 ++- .../javascripts/blob/blob_file_dropzone.js | 4 ++-- .../blob/blob_line_permalink_updater.js | 4 +++- app/assets/javascripts/diff.js | 8 +++---- .../filtered_search_manager.js | 3 ++- app/assets/javascripts/gl_dropdown.js | 3 ++- .../javascripts/groups/components/app.vue | 4 ++-- .../groups/components/group_item.vue | 3 ++- .../javascripts/groups/new_group_child.js | 5 ++-- .../javascripts/issue_show/components/app.vue | 6 ++--- app/assets/javascripts/job.js | 3 ++- .../javascripts/lib/utils/common_utils.js | 3 ++- .../javascripts/lib/utils/url_utility.js | 23 ++++--------------- app/assets/javascripts/main.js | 6 ++--- app/assets/javascripts/merge_request_tabs.js | 3 ++- app/assets/javascripts/namespace_select.js | 4 ++-- app/assets/javascripts/notes.js | 3 ++- .../notes/components/issue_notes_app.vue | 3 ++- app/assets/javascripts/pager.js | 4 ++-- app/assets/javascripts/performance_bar.js | 3 ++- app/assets/javascripts/project.js | 3 ++- .../projects/project_import_gitlab_project.js | 4 ++-- app/assets/javascripts/repo/stores/actions.js | 3 ++- .../javascripts/repo/stores/actions/tree.js | 3 ++- app/assets/javascripts/shortcuts.js | 5 ++-- app/assets/javascripts/shortcuts_blob.js | 6 ++--- .../javascripts/sidebar/sidebar_mediator.js | 3 ++- app/assets/javascripts/todos.js | 4 ++-- app/assets/javascripts/tree.js | 5 ++-- .../components/mr_widget_deployment.js | 3 ++- .../filtered_search_manager_spec.js | 10 ++++---- spec/javascripts/gl_dropdown_spec.js | 6 ++--- .../javascripts/groups/components/app_spec.js | 6 ++--- .../groups/components/group_item_spec.js | 6 ++--- .../issue_show/components/app_spec.js | 15 ++++++------ spec/javascripts/job_spec.js | 18 +++++++-------- spec/javascripts/merge_request_tabs_spec.js | 13 ++++++----- spec/javascripts/notes_spec.js | 10 ++++---- spec/javascripts/pager_spec.js | 7 +++--- .../components/repo_commit_section_spec.js | 5 ++-- .../repo/stores/actions/tree_spec.js | 5 ++-- spec/javascripts/repo/stores/actions_spec.js | 5 ++-- spec/javascripts/search_autocomplete_spec.js | 3 ++- .../sidebar/sidebar_mediator_spec.js | 5 ++-- spec/javascripts/todos_spec.js | 5 ++-- .../components/mr_widget_deployment_spec.js | 5 ++-- 47 files changed, 140 insertions(+), 126 deletions(-) diff --git a/app/assets/javascripts/admin.js b/app/assets/javascripts/admin.js index 34669dd13d6..b0b72c40f25 100644 --- a/app/assets/javascripts/admin.js +++ b/app/assets/javascripts/admin.js @@ -1,4 +1,5 @@ /* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, no-unused-vars, no-else-return, prefer-arrow-callback, camelcase, quotes, comma-dangle, max-len */ +import { refreshCurrentPage } from './lib/utils/url_utility'; window.Admin = (function() { function Admin() { @@ -40,10 +41,10 @@ window.Admin = (function() { return $('.change-owner-link').show(); }); $('li.project_member').bind('ajax:success', function() { - return gl.utils.refreshCurrentPage(); + return refreshCurrentPage(); }); $('li.group_member').bind('ajax:success', function() { - return gl.utils.refreshCurrentPage(); + return refreshCurrentPage(); }); showBlacklistType = function() { if ($("input[name='blacklist_type']:checked").val() === 'file') { diff --git a/app/assets/javascripts/behaviors/toggler_behavior.js b/app/assets/javascripts/behaviors/toggler_behavior.js index b70b0a9bbf8..417ac31fc86 100644 --- a/app/assets/javascripts/behaviors/toggler_behavior.js +++ b/app/assets/javascripts/behaviors/toggler_behavior.js @@ -5,6 +5,7 @@ // %button.js-toggle-button // %div.js-toggle-content // +import { getLocationHash } from '../lib/utils/url_utility'; $(() => { function toggleContainer(container, toggleState) { @@ -32,7 +33,7 @@ $(() => { // If we're accessing a permalink, ensure it is not inside a // closed js-toggle-container! - const hash = window.gl.utils.getLocationHash(); + const hash = getLocationHash(); const anchor = hash && document.getElementById(hash); const container = anchor && $(anchor).closest('.js-toggle-container'); diff --git a/app/assets/javascripts/blob/blob_file_dropzone.js b/app/assets/javascripts/blob/blob_file_dropzone.js index 0d590a9dbc4..f7ae6f1cd12 100644 --- a/app/assets/javascripts/blob/blob_file_dropzone.js +++ b/app/assets/javascripts/blob/blob_file_dropzone.js @@ -1,6 +1,6 @@ /* eslint-disable func-names, object-shorthand, prefer-arrow-callback */ import Dropzone from 'dropzone'; -import '../lib/utils/url_utility'; +import { visitUrl } from '../lib/utils/url_utility'; import { HIDDEN_CLASS } from '../lib/utils/constants'; import csrf from '../lib/utils/csrf'; @@ -49,7 +49,7 @@ export default class BlobFileDropzone { }); this.on('success', function (header, response) { $('#modal-upload-blob').modal('hide'); - window.gl.utils.visitUrl(response.filePath); + visitUrl(response.filePath); }); this.on('maxfilesexceeded', function (file) { dropzoneMessage.addClass(HIDDEN_CLASS); diff --git a/app/assets/javascripts/blob/blob_line_permalink_updater.js b/app/assets/javascripts/blob/blob_line_permalink_updater.js index c8f68860fbd..d36d9f0de2d 100644 --- a/app/assets/javascripts/blob/blob_line_permalink_updater.js +++ b/app/assets/javascripts/blob/blob_line_permalink_updater.js @@ -1,7 +1,9 @@ +import { getLocationHash } from '../lib/utils/url_utility'; + const lineNumberRe = /^L[0-9]+/; const updateLineNumbersOnBlobPermalinks = (linksToUpdate) => { - const hash = gl.utils.getLocationHash(); + const hash = getLocationHash(); if (hash && lineNumberRe.test(hash)) { const hashUrlString = `#${hash}`; diff --git a/app/assets/javascripts/diff.js b/app/assets/javascripts/diff.js index c8874e48c09..a162424b3cf 100644 --- a/app/assets/javascripts/diff.js +++ b/app/assets/javascripts/diff.js @@ -1,4 +1,4 @@ -import './lib/utils/url_utility'; +import { getLocationHash } from './lib/utils/url_utility'; import FilesCommentButton from './files_comment_button'; import SingleFileDiff from './single_file_diff'; import imageDiffHelper from './image_diff/helpers/index'; @@ -31,7 +31,7 @@ export default class Diff { isBound = true; } - if (gl.utils.getLocationHash()) { + if (getLocationHash()) { this.highlightSelectedLine(); } @@ -73,7 +73,7 @@ export default class Diff { } openAnchoredDiff(cb) { - const locationHash = gl.utils.getLocationHash(); + const locationHash = getLocationHash(); const anchoredDiff = locationHash && locationHash.split('_')[0]; if (!anchoredDiff) return; @@ -128,7 +128,7 @@ export default class Diff { } // eslint-disable-next-line class-methods-use-this highlightSelectedLine() { - const hash = gl.utils.getLocationHash(); + const hash = getLocationHash(); const $diffFiles = $('.diff-file'); $diffFiles.find('.hll').removeClass('hll'); diff --git a/app/assets/javascripts/filtered_search/filtered_search_manager.js b/app/assets/javascripts/filtered_search/filtered_search_manager.js index 69c57f923b6..b10ca38be21 100644 --- a/app/assets/javascripts/filtered_search/filtered_search_manager.js +++ b/app/assets/javascripts/filtered_search/filtered_search_manager.js @@ -1,3 +1,4 @@ +import * as urlUtils from '../lib/utils/url_utility'; import Flash from '../flash'; import FilteredSearchContainer from './container'; import RecentSearchesRoot from './recent_searches_root'; @@ -566,7 +567,7 @@ class FilteredSearchManager { if (this.updateObject) { this.updateObject(parameterizedUrl); } else { - gl.utils.visitUrl(parameterizedUrl); + urlUtils.visitUrl(parameterizedUrl); } } diff --git a/app/assets/javascripts/gl_dropdown.js b/app/assets/javascripts/gl_dropdown.js index 7ca783d3af6..381353c4031 100644 --- a/app/assets/javascripts/gl_dropdown.js +++ b/app/assets/javascripts/gl_dropdown.js @@ -2,6 +2,7 @@ /* global fuzzaldrinPlus */ import _ from 'underscore'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import * as urlUtils from './lib/utils/url_utility'; import { isObject } from './lib/utils/type_utility'; var GitLabDropdown, GitLabDropdownFilter, GitLabDropdownRemote, GitLabDropdownInput; @@ -852,7 +853,7 @@ GitLabDropdown = (function() { if ($el.length) { var href = $el.attr('href'); if (href && href !== '#') { - gl.utils.visitUrl(href); + urlUtils.visitUrl(href); } else { $el.trigger('click'); } diff --git a/app/assets/javascripts/groups/components/app.vue b/app/assets/javascripts/groups/components/app.vue index 2c0b6ab4ea8..17bcced944d 100644 --- a/app/assets/javascripts/groups/components/app.vue +++ b/app/assets/javascripts/groups/components/app.vue @@ -5,7 +5,7 @@ import eventHub from '../event_hub'; import { getParameterByName } from '../../lib/utils/common_utils'; import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import { COMMON_STR } from '../constants'; - +import * as utils from '../../lib/utils/url_utility'; import groupsComponent from './groups.vue'; export default { @@ -93,7 +93,7 @@ export default { this.isLoading = false; $.scrollTo(0); - const currentPath = gl.utils.mergeUrlParams({ page }, window.location.href); + const currentPath = utils.mergeUrlParams({ page }, window.location.href); window.history.replaceState({ page: currentPath, }, document.title, currentPath); diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue index c76ce762b54..7a8d5b2c483 100644 --- a/app/assets/javascripts/groups/components/group_item.vue +++ b/app/assets/javascripts/groups/components/group_item.vue @@ -1,4 +1,5 @@ diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index b7559ced946..feb73481422 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -1,9 +1,14 @@ + + diff --git a/app/assets/javascripts/vue_shared/mixins/recaptcha_dialog_implementor.js b/app/assets/javascripts/vue_shared/mixins/recaptcha_dialog_implementor.js new file mode 100644 index 00000000000..ef70f9432e3 --- /dev/null +++ b/app/assets/javascripts/vue_shared/mixins/recaptcha_dialog_implementor.js @@ -0,0 +1,36 @@ +import RecaptchaDialog from '../components/recaptcha_dialog.vue'; + +export default { + data() { + return { + showRecaptcha: false, + recaptchaHTML: '', + }; + }, + + components: { + RecaptchaDialog, + }, + + methods: { + openRecaptcha() { + this.showRecaptcha = true; + }, + + closeRecaptcha() { + this.showRecaptcha = false; + }, + + checkForSpam(data) { + if (!data.recaptcha_html) return data; + + this.recaptchaHTML = data.recaptcha_html; + + const spamError = new Error(data.error_message); + spamError.name = 'SpamError'; + spamError.message = 'SpamError'; + + throw spamError; + }, + }, +}; diff --git a/app/assets/stylesheets/framework/modal.scss b/app/assets/stylesheets/framework/modal.scss index 5c9838c1029..ce551e6b7ce 100644 --- a/app/assets/stylesheets/framework/modal.scss +++ b/app/assets/stylesheets/framework/modal.scss @@ -48,3 +48,10 @@ body.modal-open { display: block; } +.recaptcha-dialog .recaptcha-form { + display: inline-block; + + .recaptcha { + margin: 0; + } +} diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 744e448e8df..ecac9be0360 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -25,7 +25,7 @@ module IssuableActions end format.json do - render_entity_json + recaptcha_check_with_fallback(false) { render_entity_json } end end diff --git a/app/controllers/concerns/spammable_actions.rb b/app/controllers/concerns/spammable_actions.rb index ada0dde87fb..03d8e188093 100644 --- a/app/controllers/concerns/spammable_actions.rb +++ b/app/controllers/concerns/spammable_actions.rb @@ -23,8 +23,8 @@ module SpammableActions @spam_config_loaded = Gitlab::Recaptcha.load_configurations! end - def recaptcha_check_with_fallback(&fallback) - if spammable.valid? + def recaptcha_check_with_fallback(should_redirect = true, &fallback) + if should_redirect && spammable.valid? redirect_to spammable_path elsif render_recaptcha? ensure_spam_config_loaded! @@ -33,7 +33,18 @@ module SpammableActions flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.' end - render :verify + respond_to do |format| + format.html do + render :verify + end + + format.json do + locals = { spammable: spammable, script: false, has_submit: false } + recaptcha_html = render_to_string(partial: 'shared/recaptcha_form', formats: :html, locals: locals) + + render json: { recaptcha_html: recaptcha_html } + end + end else yield end diff --git a/app/views/layouts/_recaptcha_verification.html.haml b/app/views/layouts/_recaptcha_verification.html.haml index 77c77dc6754..e6f87ddd383 100644 --- a/app/views/layouts/_recaptcha_verification.html.haml +++ b/app/views/layouts/_recaptcha_verification.html.haml @@ -1,5 +1,4 @@ - humanized_resource_name = spammable.class.model_name.human.downcase -- resource_name = spammable.class.model_name.singular %h3.page-title Anti-spam verification @@ -8,16 +7,4 @@ %p #{"We detected potential spam in the #{humanized_resource_name}. Please solve the reCAPTCHA to proceed."} -= form_for form do |f| - .recaptcha - - params[resource_name].each do |field, value| - = hidden_field(resource_name, field, value: value) - = hidden_field_tag(:spam_log_id, spammable.spam_log.id) - = hidden_field_tag(:recaptcha_verification, true) - = recaptcha_tags - - -# Yields a block with given extra params. - = yield - - .row-content-block.footer-block - = f.submit "Submit #{humanized_resource_name}", class: 'btn btn-create' += render 'shared/recaptcha_form', spammable: spammable diff --git a/app/views/shared/_recaptcha_form.html.haml b/app/views/shared/_recaptcha_form.html.haml new file mode 100644 index 00000000000..0e816870f15 --- /dev/null +++ b/app/views/shared/_recaptcha_form.html.haml @@ -0,0 +1,19 @@ +- resource_name = spammable.class.model_name.singular +- humanized_resource_name = spammable.class.model_name.human.downcase +- script = local_assigns.fetch(:script, true) +- has_submit = local_assigns.fetch(:has_submit, true) + += form_for resource_name, method: :post, html: { class: 'recaptcha-form js-recaptcha-form' } do |f| + .recaptcha + - params[resource_name].each do |field, value| + = hidden_field(resource_name, field, value: value) + = hidden_field_tag(:spam_log_id, spammable.spam_log.id) + = hidden_field_tag(:recaptcha_verification, true) + = recaptcha_tags script: script, callback: 'recaptchaDialogCallback' + + -# Yields a block with given extra params. + = yield + + - if has_submit + .row-content-block.footer-block + = f.submit "Submit #{humanized_resource_name}", class: 'btn btn-create' diff --git a/changelogs/unreleased/29483-no-feedback-when-checking-on-checklist-if-potential-spam-was-detected.yml b/changelogs/unreleased/29483-no-feedback-when-checking-on-checklist-if-potential-spam-was-detected.yml new file mode 100644 index 00000000000..6bfcc5e70de --- /dev/null +++ b/changelogs/unreleased/29483-no-feedback-when-checking-on-checklist-if-potential-spam-was-detected.yml @@ -0,0 +1,5 @@ +--- +title: Add recaptcha modal to issue updates detected as spam +merge_request: 15408 +author: +type: fixed diff --git a/spec/controllers/projects/issues_controller_spec.rb b/spec/controllers/projects/issues_controller_spec.rb index 4dbbaecdd6d..c5d08cb0b9d 100644 --- a/spec/controllers/projects/issues_controller_spec.rb +++ b/spec/controllers/projects/issues_controller_spec.rb @@ -272,6 +272,20 @@ describe Projects::IssuesController do expect(response).to have_http_status(:ok) expect(issue.reload.title).to eq('New title') end + + context 'when Akismet is enabled and the issue is identified as spam' do + before do + stub_application_setting(recaptcha_enabled: true) + allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true) + allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true) + end + + it 'renders json with recaptcha_html' do + subject + + expect(JSON.parse(response.body)).to have_key('recaptcha_html') + end + end end context 'when user does not have access to update issue' do @@ -504,17 +518,16 @@ describe Projects::IssuesController do expect(spam_logs.first.recaptcha_verified).to be_falsey end - it 'renders json errors' do + it 'renders recaptcha_html json response' do update_issue - expect(json_response) - .to eql("errors" => ["Your issue has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."]) + expect(json_response).to have_key('recaptcha_html') end - it 'returns 422 status' do + it 'returns 200 status' do update_issue - expect(response).to have_gitlab_http_status(422) + expect(response).to have_gitlab_http_status(200) end end diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index b47a8bf705f..53b8a368d28 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -4,6 +4,7 @@ import '~/render_gfm'; import issuableApp from '~/issue_show/components/app.vue'; import eventHub from '~/issue_show/event_hub'; import issueShowData from '../mock_data'; +import setTimeoutPromise from '../../helpers/set_timeout_promise_helper'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); @@ -55,6 +56,8 @@ describe('Issuable output', () => { Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor); vm.poll.stop(); + + vm.$destroy(); }); it('should render a title/description/edited and update title/description/edited on update', (done) => { @@ -268,6 +271,52 @@ describe('Issuable output', () => { }); }); + it('opens recaptcha dialog if update rejected as spam', (done) => { + function mockScriptSrc() { + const recaptchaChild = vm.$children + .find(child => child.$options._componentTag === 'recaptcha-dialog'); // eslint-disable-line no-underscore-dangle + + recaptchaChild.scriptSrc = '//scriptsrc'; + } + + let modal; + const promise = new Promise((resolve) => { + resolve({ + json() { + return { + recaptcha_html: '
    recaptcha_html
    ', + }; + }, + }); + }); + + spyOn(vm.service, 'updateIssuable').and.returnValue(promise); + + vm.canUpdate = true; + vm.showForm = true; + + vm.$nextTick() + .then(() => mockScriptSrc()) + .then(() => vm.updateIssuable()) + .then(promise) + .then(() => setTimeoutPromise()) + .then(() => { + modal = vm.$el.querySelector('.js-recaptcha-dialog'); + + expect(modal.style.display).not.toEqual('none'); + expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html'); + expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc'); + }) + .then(() => modal.querySelector('.close').click()) + .then(() => vm.$nextTick()) + .then(() => { + expect(modal.style.display).toEqual('none'); + expect(document.body.querySelector('.js-recaptcha-script')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + describe('deleteIssuable', () => { it('changes URL when deleted', (done) => { spyOn(gl.utils, 'visitUrl'); diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js index 163e5cdd062..2e000a1063f 100644 --- a/spec/javascripts/issue_show/components/description_spec.js +++ b/spec/javascripts/issue_show/components/description_spec.js @@ -51,6 +51,35 @@ describe('Description component', () => { }); }); + it('opens recaptcha dialog if update rejected as spam', (done) => { + let modal; + const recaptchaChild = vm.$children + .find(child => child.$options._componentTag === 'recaptcha-dialog'); // eslint-disable-line no-underscore-dangle + + recaptchaChild.scriptSrc = '//scriptsrc'; + + vm.taskListUpdateSuccess({ + recaptcha_html: '
    recaptcha_html
    ', + }); + + vm.$nextTick() + .then(() => { + modal = vm.$el.querySelector('.js-recaptcha-dialog'); + + expect(modal.style.display).not.toEqual('none'); + expect(modal.querySelector('.g-recaptcha').textContent).toEqual('recaptcha_html'); + expect(document.body.querySelector('.js-recaptcha-script').src).toMatch('//scriptsrc'); + }) + .then(() => modal.querySelector('.close').click()) + .then(() => vm.$nextTick()) + .then(() => { + expect(modal.style.display).toEqual('none'); + expect(document.body.querySelector('.js-recaptcha-script')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + describe('TaskList', () => { beforeEach(() => { vm = mountComponent(DescriptionComponent, Object.assign({}, props, { @@ -86,6 +115,7 @@ describe('Description component', () => { dataType: 'issuableType', fieldName: 'description', selector: '.detail-page-description', + onSuccess: jasmine.any(Function), }); done(); }); diff --git a/spec/javascripts/vue_shared/components/popup_dialog_spec.js b/spec/javascripts/vue_shared/components/popup_dialog_spec.js new file mode 100644 index 00000000000..5c1d2a196f4 --- /dev/null +++ b/spec/javascripts/vue_shared/components/popup_dialog_spec.js @@ -0,0 +1,12 @@ +import Vue from 'vue'; +import PopupDialog from '~/vue_shared/components/popup_dialog.vue'; +import mountComponent from '../../helpers/vue_mount_component_helper'; + +describe('PopupDialog', () => { + it('does not render a primary button if no primaryButtonLabel', () => { + const popupDialog = Vue.extend(PopupDialog); + const vm = mountComponent(popupDialog); + + expect(vm.$el.querySelector('.js-primary-button')).toBeNull(); + }); +}); From 3e83d9f73a2dbec010026dbcd24effe89d4dc16f Mon Sep 17 00:00:00 2001 From: Jan Provaznik Date: Fri, 8 Dec 2017 10:50:22 +0100 Subject: [PATCH 079/112] Use prefix for TableOfContents filter hrefs TableOfContents filter generates hrefs for each header in markdown, if the header text consists from digits and redacted symbols only, e.g. "123" or "1.0 then the auto-generated href has the same format as issue references. If the generated id contains only digits, then 'anchor-' prefix is prepended to the id. Closes #38473 --- changelogs/unreleased/anchor-issue-references.yml | 6 ++++++ lib/banzai/filter/table_of_contents_filter.rb | 1 + spec/lib/banzai/filter/table_of_contents_filter_spec.rb | 7 +++++++ spec/lib/gitlab/reference_extractor_spec.rb | 9 +++++++++ 4 files changed, 23 insertions(+) create mode 100644 changelogs/unreleased/anchor-issue-references.yml diff --git a/changelogs/unreleased/anchor-issue-references.yml b/changelogs/unreleased/anchor-issue-references.yml new file mode 100644 index 00000000000..78896427417 --- /dev/null +++ b/changelogs/unreleased/anchor-issue-references.yml @@ -0,0 +1,6 @@ +--- +title: Fix false positive issue references in merge requests caused by header anchor + links. +merge_request: +author: +type: fixed diff --git a/lib/banzai/filter/table_of_contents_filter.rb b/lib/banzai/filter/table_of_contents_filter.rb index 47151626208..97244159985 100644 --- a/lib/banzai/filter/table_of_contents_filter.rb +++ b/lib/banzai/filter/table_of_contents_filter.rb @@ -32,6 +32,7 @@ module Banzai .gsub(PUNCTUATION_REGEXP, '') # remove punctuation .tr(' ', '-') # replace spaces with dash .squeeze('-') # replace multiple dashes with one + .gsub(/\A(\d+)\z/, 'anchor-\1') # digits-only hrefs conflict with issue refs uniq = headers[id] > 0 ? "-#{headers[id]}" : '' headers[id] += 1 diff --git a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb index 85eddde732e..0cfef4ff5bf 100644 --- a/spec/lib/banzai/filter/table_of_contents_filter_spec.rb +++ b/spec/lib/banzai/filter/table_of_contents_filter_spec.rb @@ -65,6 +65,13 @@ describe Banzai::Filter::TableOfContentsFilter do expect(doc.css('h2 a').first.attr('href')).to eq '#one-1' end + it 'prepends a prefix to digits-only ids' do + doc = filter(header(1, "123") + header(2, "1.0")) + + expect(doc.css('h1 a').first.attr('href')).to eq '#anchor-123' + expect(doc.css('h2 a').first.attr('href')).to eq '#anchor-10' + end + it 'supports Unicode' do doc = filter(header(1, '한글')) expect(doc.css('h1 a').first.attr('id')).to eq 'user-content-한글' diff --git a/spec/lib/gitlab/reference_extractor_spec.rb b/spec/lib/gitlab/reference_extractor_spec.rb index 476a3f1998d..bce0fa2ea8f 100644 --- a/spec/lib/gitlab/reference_extractor_spec.rb +++ b/spec/lib/gitlab/reference_extractor_spec.rb @@ -115,6 +115,15 @@ describe Gitlab::ReferenceExtractor do end end + it 'does not include anchors from table of contents in issue references' do + issue1 = create(:issue, project: project) + issue2 = create(:issue, project: project) + + subject.analyze("not real issue

    #{issue1.iid}

    , real issue #{issue2.to_reference}") + + expect(subject.issues).to match_array([issue2]) + end + it 'accesses valid issue objects' do @i0 = create(:issue, project: project) @i1 = create(:issue, project: project) From b2a1919c3e9dab3d3757d021216c83a324277db3 Mon Sep 17 00:00:00 2001 From: Brett Walker Date: Thu, 7 Dec 2017 17:29:43 +0100 Subject: [PATCH 080/112] expire todo count calculations to be consistent with assigned_open_merge_requests_count and assigned_open_issues_count, which are used in the top header stats. Also important for a Geo secondary, so that the pending todo stat gets updated on the same frequency as the users open issues/merge requests. --- app/models/user.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/models/user.rb b/app/models/user.rb index af1c36d9c93..093ff808626 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1054,13 +1054,13 @@ class User < ActiveRecord::Base end def todos_done_count(force: false) - Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do + Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: 20.minutes) do TodosFinder.new(self, state: :done).execute.count end end def todos_pending_count(force: false) - Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force) do + Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: 20.minutes) do TodosFinder.new(self, state: :pending).execute.count end end From 5ac008c1e5b41a8d0b92be8fa13688f3ad196848 Mon Sep 17 00:00:00 2001 From: Ian Baum Date: Fri, 8 Dec 2017 17:04:48 +0000 Subject: [PATCH 081/112] Resolve "Include asset_sync gem" --- Gemfile | 3 ++ Gemfile.lock | 6 ++++ .../40031-include-assset_sync-gem.yml | 5 +++ config/initializers/asset_sync.rb | 31 +++++++++++++++++++ 4 files changed, 45 insertions(+) create mode 100644 changelogs/unreleased/40031-include-assset_sync-gem.yml create mode 100644 config/initializers/asset_sync.rb diff --git a/Gemfile b/Gemfile index 3187b0e5ae9..93003ed96c4 100644 --- a/Gemfile +++ b/Gemfile @@ -411,3 +411,6 @@ gem 'flipper-active_record', '~> 0.10.2' # Structured logging gem 'lograge', '~> 0.5' gem 'grape_logging', '~> 1.7' + +# Asset synchronization +gem 'asset_sync', '~> 2.2.0' diff --git a/Gemfile.lock b/Gemfile.lock index 6213167ae0b..b5ca351fea8 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -58,6 +58,11 @@ GEM asciidoctor (1.5.3) asciidoctor-plantuml (0.0.7) asciidoctor (~> 1.5) + asset_sync (2.2.0) + activemodel (>= 4.1.0) + fog-core + mime-types (>= 2.99) + unf ast (2.3.0) atomic (1.1.99) attr_encrypted (3.0.3) @@ -975,6 +980,7 @@ DEPENDENCIES asana (~> 0.6.0) asciidoctor (~> 1.5.2) asciidoctor-plantuml (= 0.0.7) + asset_sync (~> 2.2.0) attr_encrypted (~> 3.0.0) awesome_print (~> 1.2.0) babosa (~> 1.0.2) diff --git a/changelogs/unreleased/40031-include-assset_sync-gem.yml b/changelogs/unreleased/40031-include-assset_sync-gem.yml new file mode 100644 index 00000000000..93ce565b32c --- /dev/null +++ b/changelogs/unreleased/40031-include-assset_sync-gem.yml @@ -0,0 +1,5 @@ +--- +title: Add assets_sync gem to Gemfile +merge_request: 15734 +author: +type: added diff --git a/config/initializers/asset_sync.rb b/config/initializers/asset_sync.rb new file mode 100644 index 00000000000..db8500f6231 --- /dev/null +++ b/config/initializers/asset_sync.rb @@ -0,0 +1,31 @@ +AssetSync.configure do |config| + # Disable the asset_sync gem by default. If it is enabled, but not configured, + # asset_sync will cause the build to fail. + config.enabled = if ENV.has_key?('ASSET_SYNC_ENABLED') + ENV['ASSET_SYNC_ENABLED'] == 'true' + else + false + end + + # Pulled from https://github.com/AssetSync/asset_sync/blob/v2.2.0/lib/asset_sync/engine.rb#L15-L40 + # This allows us to disable asset_sync by default and configure through environment variables + # Updates to asset_sync gem should be checked + config.fog_provider = ENV['FOG_PROVIDER'] if ENV.has_key?('FOG_PROVIDER') + config.fog_directory = ENV['FOG_DIRECTORY'] if ENV.has_key?('FOG_DIRECTORY') + config.fog_region = ENV['FOG_REGION'] if ENV.has_key?('FOG_REGION') + + config.aws_access_key_id = ENV['AWS_ACCESS_KEY_ID'] if ENV.has_key?('AWS_ACCESS_KEY_ID') + config.aws_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY'] if ENV.has_key?('AWS_SECRET_ACCESS_KEY') + config.aws_reduced_redundancy = ENV['AWS_REDUCED_REDUNDANCY'] == true if ENV.has_key?('AWS_REDUCED_REDUNDANCY') + + config.rackspace_username = ENV['RACKSPACE_USERNAME'] if ENV.has_key?('RACKSPACE_USERNAME') + config.rackspace_api_key = ENV['RACKSPACE_API_KEY'] if ENV.has_key?('RACKSPACE_API_KEY') + + config.google_storage_access_key_id = ENV['GOOGLE_STORAGE_ACCESS_KEY_ID'] if ENV.has_key?('GOOGLE_STORAGE_ACCESS_KEY_ID') + config.google_storage_secret_access_key = ENV['GOOGLE_STORAGE_SECRET_ACCESS_KEY'] if ENV.has_key?('GOOGLE_STORAGE_SECRET_ACCESS_KEY') + + config.existing_remote_files = ENV['ASSET_SYNC_EXISTING_REMOTE_FILES'] || "keep" + + config.gzip_compression = (ENV['ASSET_SYNC_GZIP_COMPRESSION'] == 'true') if ENV.has_key?('ASSET_SYNC_GZIP_COMPRESSION') + config.manifest = (ENV['ASSET_SYNC_MANIFEST'] == 'true') if ENV.has_key?('ASSET_SYNC_MANIFEST') +end From 562fb460b83502c060cd84b1627dc33098e01c31 Mon Sep 17 00:00:00 2001 From: Mayra Cabrera Date: Fri, 8 Dec 2017 17:42:43 +0000 Subject: [PATCH 082/112] Allow git pull/push on project redirects --- GITLAB_SHELL_VERSION | 2 +- app/models/namespace.rb | 11 ++ app/models/redirect_route.rb | 28 +++++ app/models/route.rb | 26 ++++- ...low-git-pull-push-on-project-redirects.yml | 5 + ...4204233_add_permanent_to_redirect_route.rb | 18 +++ ...9_add_permanent_index_to_redirect_route.rb | 19 ++++ db/schema.rb | 4 +- lib/api/internal.rb | 13 ++- lib/gitlab/checks/project_moved.rb | 65 +++++++++++ lib/gitlab/git_access.rb | 17 ++- lib/gitlab/identifier.rb | 5 +- spec/lib/gitlab/checks/project_moved_spec.rb | 81 ++++++++++++++ spec/lib/gitlab/git_access_spec.rb | 60 ++++++++-- spec/lib/gitlab/identifier_spec.rb | 4 + spec/models/namespace_spec.rb | 30 +++++ spec/models/route_spec.rb | 104 ++++++++++++++++++ spec/models/user_spec.rb | 24 ++++ spec/requests/api/internal_spec.rb | 38 ++++--- spec/requests/git_http_spec.rb | 8 +- 20 files changed, 510 insertions(+), 52 deletions(-) create mode 100644 changelogs/unreleased/35385-allow-git-pull-push-on-project-redirects.yml create mode 100644 db/migrate/20171204204233_add_permanent_to_redirect_route.rb create mode 100644 db/migrate/20171206221519_add_permanent_index_to_redirect_route.rb create mode 100644 lib/gitlab/checks/project_moved.rb create mode 100644 spec/lib/gitlab/checks/project_moved_spec.rb diff --git a/GITLAB_SHELL_VERSION b/GITLAB_SHELL_VERSION index 4e32c7b1caf..269fb5dfe2c 100644 --- a/GITLAB_SHELL_VERSION +++ b/GITLAB_SHELL_VERSION @@ -1 +1 @@ -5.10.1 +5.10.2 diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 901dbf2ba69..0ff169d4531 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -40,6 +40,7 @@ class Namespace < ActiveRecord::Base namespace_path: true validate :nesting_level_allowed + validate :allowed_path_by_redirects delegate :name, to: :owner, allow_nil: true, prefix: true @@ -257,4 +258,14 @@ class Namespace < ActiveRecord::Base Namespace.where(id: descendants.select(:id)) .update_all(share_with_group_lock: true) end + + def allowed_path_by_redirects + return if path.nil? + + errors.add(:path, "#{path} has been taken before. Please use another one") if namespace_previously_created_with_same_path? + end + + def namespace_previously_created_with_same_path? + RedirectRoute.permanent.exists?(path: path) + end end diff --git a/app/models/redirect_route.rb b/app/models/redirect_route.rb index 31de204d824..20532527346 100644 --- a/app/models/redirect_route.rb +++ b/app/models/redirect_route.rb @@ -17,4 +17,32 @@ class RedirectRoute < ActiveRecord::Base where(wheres, path, "#{sanitize_sql_like(path)}/%") end + + scope :permanent, -> do + if column_permanent_exists? + where(permanent: true) + else + none + end + end + + scope :temporary, -> do + if column_permanent_exists? + where(permanent: [false, nil]) + else + all + end + end + + default_value_for :permanent, false + + def permanent=(value) + if self.class.column_permanent_exists? + super + end + end + + def self.column_permanent_exists? + ActiveRecord::Base.connection.column_exists?(:redirect_routes, :permanent) + end end diff --git a/app/models/route.rb b/app/models/route.rb index 97e8a6ad9e9..7ba3ec06041 100644 --- a/app/models/route.rb +++ b/app/models/route.rb @@ -8,6 +8,8 @@ class Route < ActiveRecord::Base presence: true, uniqueness: { case_sensitive: false } + validate :ensure_permanent_paths + after_create :delete_conflicting_redirects after_update :delete_conflicting_redirects, if: :path_changed? after_update :create_redirect_for_old_path @@ -40,7 +42,7 @@ class Route < ActiveRecord::Base # We are not calling route.delete_conflicting_redirects here, in hopes # of avoiding deadlocks. The parent (self, in this method) already # called it, which deletes conflicts for all descendants. - route.create_redirect(old_path) if attributes[:path] + route.create_redirect(old_path, permanent: permanent_redirect?) if attributes[:path] end end end @@ -50,16 +52,30 @@ class Route < ActiveRecord::Base end def conflicting_redirects - RedirectRoute.matching_path_and_descendants(path) + RedirectRoute.temporary.matching_path_and_descendants(path) end - def create_redirect(path) - RedirectRoute.create(source: source, path: path) + def create_redirect(path, permanent: false) + RedirectRoute.create(source: source, path: path, permanent: permanent) end private def create_redirect_for_old_path - create_redirect(path_was) if path_changed? + create_redirect(path_was, permanent: permanent_redirect?) if path_changed? + end + + def permanent_redirect? + source_type != "Project" + end + + def ensure_permanent_paths + return if path.nil? + + errors.add(:path, "#{path} has been taken before. Please use another one") if conflicting_redirect_exists? + end + + def conflicting_redirect_exists? + RedirectRoute.permanent.matching_path_and_descendants(path).exists? end end diff --git a/changelogs/unreleased/35385-allow-git-pull-push-on-project-redirects.yml b/changelogs/unreleased/35385-allow-git-pull-push-on-project-redirects.yml new file mode 100644 index 00000000000..31450287caf --- /dev/null +++ b/changelogs/unreleased/35385-allow-git-pull-push-on-project-redirects.yml @@ -0,0 +1,5 @@ +--- +title: Allow git pull/push on group/user/project redirects +merge_request: 15670 +author: +type: added diff --git a/db/migrate/20171204204233_add_permanent_to_redirect_route.rb b/db/migrate/20171204204233_add_permanent_to_redirect_route.rb new file mode 100644 index 00000000000..f3ae471201e --- /dev/null +++ b/db/migrate/20171204204233_add_permanent_to_redirect_route.rb @@ -0,0 +1,18 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddPermanentToRedirectRoute < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + disable_ddl_transaction! + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + def up + add_column(:redirect_routes, :permanent, :boolean) + end + + def down + remove_column(:redirect_routes, :permanent) + end +end diff --git a/db/migrate/20171206221519_add_permanent_index_to_redirect_route.rb b/db/migrate/20171206221519_add_permanent_index_to_redirect_route.rb new file mode 100644 index 00000000000..33ce7e1aa68 --- /dev/null +++ b/db/migrate/20171206221519_add_permanent_index_to_redirect_route.rb @@ -0,0 +1,19 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddPermanentIndexToRedirectRoute < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + # Set this constant to true if this migration requires downtime. + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_concurrent_index(:redirect_routes, :permanent) + end + + def down + remove_concurrent_index(:redirect_routes, :permanent) if index_exists?(:redirect_routes, :permanent) + end +end diff --git a/db/schema.rb b/db/schema.rb index 4c697a4a384..c0a141885ad 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -11,7 +11,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 20171205190711) do +ActiveRecord::Schema.define(version: 20171206221519) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -1526,10 +1526,12 @@ ActiveRecord::Schema.define(version: 20171205190711) do t.string "path", null: false t.datetime "created_at", null: false t.datetime "updated_at", null: false + t.boolean "permanent" end add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path", unique: true, using: :btree add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path_text_pattern_ops", using: :btree, opclasses: {"path"=>"varchar_pattern_ops"} + add_index "redirect_routes", ["permanent"], name: "index_redirect_routes_on_permanent", using: :btree add_index "redirect_routes", ["source_type", "source_id"], name: "index_redirect_routes_on_source_type_and_source_id", using: :btree create_table "releases", force: :cascade do |t| diff --git a/lib/api/internal.rb b/lib/api/internal.rb index 451121a4cea..ccaaeca10d4 100644 --- a/lib/api/internal.rb +++ b/lib/api/internal.rb @@ -4,6 +4,7 @@ module API before { authenticate_by_gitlab_shell_token! } helpers ::API::Helpers::InternalHelpers + helpers ::Gitlab::Identifier namespace 'internal' do # Check if git command is allowed to project @@ -176,17 +177,25 @@ module API post '/post_receive' do status 200 - PostReceive.perform_async(params[:gl_repository], params[:identifier], params[:changes]) broadcast_message = BroadcastMessage.current&.last&.message reference_counter_decreased = Gitlab::ReferenceCounter.new(params[:gl_repository]).decrease - { + output = { merge_request_urls: merge_request_urls, broadcast_message: broadcast_message, reference_counter_decreased: reference_counter_decreased } + + project = Gitlab::GlRepository.parse(params[:gl_repository]).first + user = identify(params[:identifier]) + redirect_message = Gitlab::Checks::ProjectMoved.fetch_redirect_message(user.id, project.id) + if redirect_message + output[:redirected_message] = redirect_message + end + + output end end end diff --git a/lib/gitlab/checks/project_moved.rb b/lib/gitlab/checks/project_moved.rb new file mode 100644 index 00000000000..3a1c0a3455e --- /dev/null +++ b/lib/gitlab/checks/project_moved.rb @@ -0,0 +1,65 @@ +module Gitlab + module Checks + class ProjectMoved + REDIRECT_NAMESPACE = "redirect_namespace".freeze + + def initialize(project, user, redirected_path, protocol) + @project = project + @user = user + @redirected_path = redirected_path + @protocol = protocol + end + + def self.fetch_redirect_message(user_id, project_id) + redirect_key = redirect_message_key(user_id, project_id) + + Gitlab::Redis::SharedState.with do |redis| + message = redis.get(redirect_key) + redis.del(redirect_key) + message + end + end + + def add_redirect_message + Gitlab::Redis::SharedState.with do |redis| + key = self.class.redirect_message_key(user.id, project.id) + redis.setex(key, 5.minutes, redirect_message) + end + end + + def redirect_message(rejected: false) + <<~MESSAGE.strip_heredoc + Project '#{redirected_path}' was moved to '#{project.full_path}'. + + Please update your Git remote: + + #{remote_url_message(rejected)} + MESSAGE + end + + def permanent_redirect? + RedirectRoute.permanent.exists?(path: redirected_path) + end + + private + + attr_reader :project, :redirected_path, :protocol, :user + + def self.redirect_message_key(user_id, project_id) + "#{REDIRECT_NAMESPACE}:#{user_id}:#{project_id}" + end + + def remote_url_message(rejected) + if rejected + "git remote set-url origin #{url} and try again." + else + "git remote set-url origin #{url}" + end + end + + def url + protocol == 'ssh' ? project.ssh_url_to_repo : project.http_url_to_repo + end + end + end +end diff --git a/lib/gitlab/git_access.rb b/lib/gitlab/git_access.rb index 9d7d921bb9c..56f6febe86d 100644 --- a/lib/gitlab/git_access.rb +++ b/lib/gitlab/git_access.rb @@ -102,18 +102,15 @@ module Gitlab end def check_project_moved! - return unless redirected_path + return if redirected_path.nil? - url = protocol == 'ssh' ? project.ssh_url_to_repo : project.http_url_to_repo - message = <<-MESSAGE.strip_heredoc - Project '#{redirected_path}' was moved to '#{project.full_path}'. + project_moved = Checks::ProjectMoved.new(project, user, redirected_path, protocol) - Please update your Git remote and try again: - - git remote set-url origin #{url} - MESSAGE - - raise ProjectMovedError, message + if project_moved.permanent_redirect? + project_moved.add_redirect_message + else + raise ProjectMovedError, project_moved.redirect_message(rejected: true) + end end def check_command_disabled!(cmd) diff --git a/lib/gitlab/identifier.rb b/lib/gitlab/identifier.rb index 94678b6ec40..3f3f10596c5 100644 --- a/lib/gitlab/identifier.rb +++ b/lib/gitlab/identifier.rb @@ -2,9 +2,8 @@ # key-13 or user-36 or last commit module Gitlab module Identifier - def identify(identifier, project, newrev) + def identify(identifier, project = nil, newrev = nil) if identifier.blank? - # Local push from gitlab identify_using_commit(project, newrev) elsif identifier =~ /\Auser-\d+\Z/ # git push over http @@ -17,6 +16,8 @@ module Gitlab # Tries to identify a user based on a commit SHA. def identify_using_commit(project, ref) + return if project.nil? && ref.nil? + commit = project.commit(ref) return if !commit || !commit.author_email diff --git a/spec/lib/gitlab/checks/project_moved_spec.rb b/spec/lib/gitlab/checks/project_moved_spec.rb new file mode 100644 index 00000000000..fa1575e2177 --- /dev/null +++ b/spec/lib/gitlab/checks/project_moved_spec.rb @@ -0,0 +1,81 @@ +require 'rails_helper' + +describe Gitlab::Checks::ProjectMoved, :clean_gitlab_redis_shared_state do + let(:user) { create(:user) } + let(:project) { create(:project) } + + describe '.fetch_redirct_message' do + context 'with a redirect message queue' do + it 'should return the redirect message' do + project_moved = described_class.new(project, user, 'foo/bar', 'http') + project_moved.add_redirect_message + + expect(described_class.fetch_redirect_message(user.id, project.id)).to eq(project_moved.redirect_message) + end + + it 'should delete the redirect message from redis' do + project_moved = described_class.new(project, user, 'foo/bar', 'http') + project_moved.add_redirect_message + + expect(Gitlab::Redis::SharedState.with { |redis| redis.get("redirect_namespace:#{user.id}:#{project.id}") }).not_to be_nil + described_class.fetch_redirect_message(user.id, project.id) + expect(Gitlab::Redis::SharedState.with { |redis| redis.get("redirect_namespace:#{user.id}:#{project.id}") }).to be_nil + end + end + + context 'with no redirect message queue' do + it 'should return nil' do + expect(described_class.fetch_redirect_message(1, 2)).to be_nil + end + end + end + + describe '#add_redirect_message' do + it 'should queue a redirect message' do + project_moved = described_class.new(project, user, 'foo/bar', 'http') + expect(project_moved.add_redirect_message).to eq("OK") + end + end + + describe '#redirect_message' do + context 'when the push is rejected' do + it 'should return a redirect message telling the user to try again' do + project_moved = described_class.new(project, user, 'foo/bar', 'http') + message = "Project 'foo/bar' was moved to '#{project.full_path}'." + + "\n\nPlease update your Git remote:" + + "\n\n git remote set-url origin #{project.http_url_to_repo} and try again.\n" + + expect(project_moved.redirect_message(rejected: true)).to eq(message) + end + end + + context 'when the push is not rejected' do + it 'should return a redirect message' do + project_moved = described_class.new(project, user, 'foo/bar', 'http') + message = "Project 'foo/bar' was moved to '#{project.full_path}'." + + "\n\nPlease update your Git remote:" + + "\n\n git remote set-url origin #{project.http_url_to_repo}\n" + + expect(project_moved.redirect_message).to eq(message) + end + end + end + + describe '#permanent_redirect?' do + context 'with a permanent RedirectRoute' do + it 'should return true' do + project.route.create_redirect('foo/bar', permanent: true) + project_moved = described_class.new(project, user, 'foo/bar', 'http') + expect(project_moved.permanent_redirect?).to be_truthy + end + end + + context 'without a permanent RedirectRoute' do + it 'should return false' do + project.route.create_redirect('foo/bar') + project_moved = described_class.new(project, user, 'foo/bar', 'http') + expect(project_moved.permanent_redirect?).to be_falsy + end + end + end +end diff --git a/spec/lib/gitlab/git_access_spec.rb b/spec/lib/gitlab/git_access_spec.rb index c9643c5da47..2db560c2cec 100644 --- a/spec/lib/gitlab/git_access_spec.rb +++ b/spec/lib/gitlab/git_access_spec.rb @@ -193,7 +193,15 @@ describe Gitlab::GitAccess do let(:actor) { build(:rsa_deploy_key_2048, user: user) } end - describe '#check_project_moved!' do + shared_examples 'check_project_moved' do + it 'enqueues a redirected message' do + push_access_check + + expect(Gitlab::Checks::ProjectMoved.fetch_redirect_message(user.id, project.id)).not_to be_nil + end + end + + describe '#check_project_moved!', :clean_gitlab_redis_shared_state do before do project.add_master(user) end @@ -207,7 +215,40 @@ describe Gitlab::GitAccess do end end - context 'when a redirect was followed to find the project' do + context 'when a permanent redirect and ssh protocol' do + let(:redirected_path) { 'some/other-path' } + + before do + allow_any_instance_of(Gitlab::Checks::ProjectMoved).to receive(:permanent_redirect?).and_return(true) + end + + it 'allows push and pull access' do + aggregate_failures do + expect { push_access_check }.not_to raise_error + end + end + + it_behaves_like 'check_project_moved' + end + + context 'with a permanent redirect and http protocol' do + let(:redirected_path) { 'some/other-path' } + let(:protocol) { 'http' } + + before do + allow_any_instance_of(Gitlab::Checks::ProjectMoved).to receive(:permanent_redirect?).and_return(true) + end + + it 'allows_push and pull access' do + aggregate_failures do + expect { push_access_check }.not_to raise_error + end + end + + it_behaves_like 'check_project_moved' + end + + context 'with a temporal redirect and ssh protocol' do let(:redirected_path) { 'some/other-path' } it 'blocks push and pull access' do @@ -219,16 +260,15 @@ describe Gitlab::GitAccess do expect { pull_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.ssh_url_to_repo}/) end end + end - context 'http protocol' do - let(:protocol) { 'http' } + context 'with a temporal redirect and http protocol' do + let(:redirected_path) { 'some/other-path' } + let(:protocol) { 'http' } - it 'includes the path to the project using HTTP' do - aggregate_failures do - expect { push_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.http_url_to_repo}/) - expect { pull_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.http_url_to_repo}/) - end - end + it 'does not allow to push and pull access' do + expect { push_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.http_url_to_repo}/) + expect { pull_access_check }.to raise_error(described_class::ProjectMovedError, /git remote set-url origin #{project.http_url_to_repo}/) end end end diff --git a/spec/lib/gitlab/identifier_spec.rb b/spec/lib/gitlab/identifier_spec.rb index cfaeb1f0d4f..0385dd762c2 100644 --- a/spec/lib/gitlab/identifier_spec.rb +++ b/spec/lib/gitlab/identifier_spec.rb @@ -70,6 +70,10 @@ describe Gitlab::Identifier do expect(identifier.identify_using_commit(project, '123')).to eq(user) end end + + it 'returns nil if the project & ref are not present' do + expect(identifier.identify_using_commit(nil, nil)).to be_nil + end end describe '#identify_using_user' do diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 3817f20bfe7..b7c6286fd83 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -559,4 +559,34 @@ describe Namespace do end end end + + describe "#allowed_path_by_redirects" do + let(:namespace1) { create(:namespace, path: 'foo') } + + context "when the path has been taken before" do + before do + namespace1.path = 'bar' + namespace1.save! + end + + it 'should be invalid' do + namespace2 = build(:group, path: 'foo') + expect(namespace2).to be_invalid + end + + it 'should return an error on path' do + namespace2 = build(:group, path: 'foo') + namespace2.valid? + expect(namespace2.errors.messages[:path].first).to eq('foo has been taken before. Please use another one') + end + end + + context "when the path has not been taken before" do + it 'should be valid' do + expect(RedirectRoute.count).to eq(0) + namespace = build(:namespace) + expect(namespace).to be_valid + end + end + end end diff --git a/spec/models/route_spec.rb b/spec/models/route_spec.rb index fece370c03f..ddad6862a63 100644 --- a/spec/models/route_spec.rb +++ b/spec/models/route_spec.rb @@ -87,6 +87,7 @@ describe Route do end context 'when conflicting redirects exist' do + let(:route) { create(:project).route } let!(:conflicting_redirect1) { route.create_redirect('bar/test') } let!(:conflicting_redirect2) { route.create_redirect('bar/test/foo') } let!(:conflicting_redirect3) { route.create_redirect('gitlab-org') } @@ -141,11 +142,50 @@ describe Route do expect(redirect_route.source).to eq(route.source) expect(redirect_route.path).to eq('foo') end + + context 'when the source is a Project' do + it 'creates a temporal RedirectRoute' do + project = create(:project) + route = project.route + redirect_route = route.create_redirect('foo') + expect(redirect_route.permanent?).to be_falsy + end + end + + context 'when the source is not a project' do + it 'creates a permanent RedirectRoute' do + redirect_route = route.create_redirect('foo', permanent: true) + expect(redirect_route.permanent?).to be_truthy + end + end end describe '#delete_conflicting_redirects' do + context 'with permanent redirect' do + it 'does not delete the redirect' do + route.create_redirect("#{route.path}/foo", permanent: true) + + expect do + route.delete_conflicting_redirects + end.not_to change { RedirectRoute.count } + end + end + + context 'with temporal redirect' do + let(:route) { create(:project).route } + + it 'deletes the redirect' do + route.create_redirect("#{route.path}/foo") + + expect do + route.delete_conflicting_redirects + end.to change { RedirectRoute.count }.by(-1) + end + end + context 'when a redirect route with the same path exists' do context 'when the redirect route has matching case' do + let(:route) { create(:project).route } let!(:redirect1) { route.create_redirect(route.path) } it 'deletes the redirect' do @@ -169,6 +209,7 @@ describe Route do end context 'when the redirect route is differently cased' do + let(:route) { create(:project).route } let!(:redirect1) { route.create_redirect(route.path.upcase) } it 'deletes the redirect' do @@ -185,7 +226,32 @@ describe Route do expect(route.conflicting_redirects).to be_an(ActiveRecord::Relation) end + context 'with permanent redirects' do + it 'does not return anything' do + route.create_redirect("#{route.path}/foo", permanent: true) + route.create_redirect("#{route.path}/foo/bar", permanent: true) + route.create_redirect("#{route.path}/baz/quz", permanent: true) + + expect(route.conflicting_redirects).to be_empty + end + end + + context 'with temporal redirects' do + let(:route) { create(:project).route } + + it 'returns the redirect routes' do + route = create(:project).route + redirect1 = route.create_redirect("#{route.path}/foo") + redirect2 = route.create_redirect("#{route.path}/foo/bar") + redirect3 = route.create_redirect("#{route.path}/baz/quz") + + expect(route.conflicting_redirects).to match_array([redirect1, redirect2, redirect3]) + end + end + context 'when a redirect route with the same path exists' do + let(:route) { create(:project).route } + context 'when the redirect route has matching case' do let!(:redirect1) { route.create_redirect(route.path) } @@ -214,4 +280,42 @@ describe Route do end end end + + describe "#conflicting_redirect_exists?" do + context 'when a conflicting redirect exists' do + let(:group1) { create(:group, path: 'foo') } + let(:group2) { create(:group, path: 'baz') } + + it 'should not be saved' do + group1.path = 'bar' + group1.save + + group2.path = 'foo' + + expect(group2.save).to be_falsy + end + + it 'should return an error on path' do + group1.path = 'bar' + group1.save + + group2.path = 'foo' + group2.valid? + expect(group2.errors["route.path"].first).to eq('foo has been taken before. Please use another one') + end + end + + context 'when a conflicting redirect does not exist' do + let(:project1) { create(:project, path: 'foo') } + let(:project2) { create(:project, path: 'baz') } + + it 'should be saved' do + project1.path = 'bar' + project1.save + + project2.path = 'foo' + expect(project2.save).to be_truthy + end + end + end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 03c96a8f5aa..cdabd35b6ba 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -2592,4 +2592,28 @@ describe User do include_examples 'max member access for groups' end end + + describe "#username_previously_taken?" do + let(:user1) { create(:user, username: 'foo') } + + context 'when the username has been taken before' do + before do + user1.username = 'bar' + user1.save! + end + + it 'should raise an ActiveRecord::RecordInvalid exception' do + user2 = build(:user, username: 'foo') + expect { user2.save! }.to raise_error(ActiveRecord::RecordInvalid, /Path foo has been taken before/) + end + end + + context 'when the username has not been taken before' do + it 'should be valid' do + expect(RedirectRoute.count).to eq(0) + user2 = build(:user, username: 'baz') + expect(user2).to be_valid + end + end + end end diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 67e1539cbc3..3c31980b273 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -537,16 +537,7 @@ describe API::Internal do context 'the project path was changed' do let!(:old_path_to_repo) { project.repository.path_to_repo } - let!(:old_full_path) { project.full_path } - let(:project_moved_message) do - <<-MSG.strip_heredoc - Project '#{old_full_path}' was moved to '#{project.full_path}'. - - Please update your Git remote and try again: - - git remote set-url origin #{project.ssh_url_to_repo} - MSG - end + let!(:repository) { project.repository } before do project.team << [user, :developer] @@ -555,19 +546,17 @@ describe API::Internal do end it 'rejects the push' do - push_with_path(key, old_path_to_repo) + push(key, project) expect(response).to have_gitlab_http_status(200) - expect(json_response['status']).to be_falsey - expect(json_response['message']).to eq(project_moved_message) + expect(json_response['status']).to be_falsy end it 'rejects the SSH pull' do - pull_with_path(key, old_path_to_repo) + pull(key, project) expect(response).to have_gitlab_http_status(200) - expect(json_response['status']).to be_falsey - expect(json_response['message']).to eq(project_moved_message) + expect(json_response['status']).to be_falsy end end end @@ -695,7 +684,7 @@ describe API::Internal do # end # end - describe 'POST /internal/post_receive' do + describe 'POST /internal/post_receive', :clean_gitlab_redis_shared_state do let(:identifier) { 'key-123' } let(:valid_params) do @@ -713,6 +702,8 @@ describe API::Internal do before do project.team << [user, :developer] + allow(described_class).to receive(:identify).and_return(user) + allow_any_instance_of(Gitlab::Identifier).to receive(:identify).and_return(user) end it 'enqueues a PostReceive worker job' do @@ -780,6 +771,19 @@ describe API::Internal do expect(json_response['broadcast_message']).to eq(nil) end end + + context 'with a redirected data' do + it 'returns redirected message on the response' do + project_moved = Gitlab::Checks::ProjectMoved.new(project, user, 'foo/baz', 'http') + project_moved.add_redirect_message + + post api("/internal/post_receive"), valid_params + + expect(response).to have_gitlab_http_status(200) + expect(json_response["redirected_message"]).to be_present + expect(json_response["redirected_message"]).to eq(project_moved.redirect_message) + end + end end describe 'POST /internal/pre_receive' do diff --git a/spec/requests/git_http_spec.rb b/spec/requests/git_http_spec.rb index a16f98bec36..fa02fffc82a 100644 --- a/spec/requests/git_http_spec.rb +++ b/spec/requests/git_http_spec.rb @@ -324,9 +324,9 @@ describe 'Git HTTP requests' do <<-MSG.strip_heredoc Project '#{redirect.path}' was moved to '#{project.full_path}'. - Please update your Git remote and try again: + Please update your Git remote: - git remote set-url origin #{project.http_url_to_repo} + git remote set-url origin #{project.http_url_to_repo} and try again. MSG end @@ -533,9 +533,9 @@ describe 'Git HTTP requests' do <<-MSG.strip_heredoc Project '#{redirect.path}' was moved to '#{project.full_path}'. - Please update your Git remote and try again: + Please update your Git remote: - git remote set-url origin #{project.http_url_to_repo} + git remote set-url origin #{project.http_url_to_repo} and try again. MSG end From 0e935c76061e9e5b2ef0a196637602f3720b23d7 Mon Sep 17 00:00:00 2001 From: Luke Bennett Date: Fri, 8 Dec 2017 18:19:51 +0000 Subject: [PATCH 083/112] Add recaptcha_check_if_spammable for issualbes than arent spammables --- app/controllers/concerns/issuable_actions.rb | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index ecac9be0360..281756af57a 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -21,11 +21,11 @@ module IssuableActions respond_to do |format| format.html do - recaptcha_check_with_fallback { render :edit } + recaptcha_check_if_spammable { render :edit } end format.json do - recaptcha_check_with_fallback(false) { render_entity_json } + recaptcha_check_if_spammable(false) { render_entity_json } end end @@ -80,6 +80,12 @@ module IssuableActions private + def recaptcha_check_if_spammable(should_redirect = true, &block) + return yield unless @issuable.is_a? Spammable + + recaptcha_check_with_fallback(should_redirect, &block) + end + def render_conflict_response respond_to do |format| format.html do From d332c8c78a77ee400e01f91fd2c573f12caef21d Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 21 Nov 2017 17:58:42 +0000 Subject: [PATCH 084/112] Merge branch '36679-non-authorized-user-may-see-wikis-or-pipeline-page' into 'security-10-2' Fixes project visibility guidelines See merge request gitlab/gitlabhq!2226 (cherry picked from commit 877c42c0aaf3298d6001614c9706bc366ae4014c) e4fd1c26 Ensure project wiki visibility guidelines are met --- app/controllers/projects_controller.rb | 2 +- app/helpers/preferences_helper.rb | 2 +- spec/factories/users.rb | 4 ++ spec/helpers/preferences_helper_spec.rb | 74 ++++++++++++++++++++++--- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 3882fa4791d..8e9d6766d80 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -272,7 +272,7 @@ class ProjectsController < Projects::ApplicationController render 'projects/empty' if @project.empty_repo? else - if @project.wiki_enabled? + if can?(current_user, :read_wiki, @project) @project_wiki = @project.wiki @wiki_home = @project_wiki.find_page('home', params[:version_id]) elsif @project.feature_available?(:issues, current_user) diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 8e822ed0ea2..aaee6eaeedd 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -58,7 +58,7 @@ module PreferencesHelper user_view elsif user_view == "activity" "activity" - elsif @project.wiki_enabled? + elsif can?(current_user, :read_wiki, @project) "wiki" elsif @project.feature_available?(:issues, current_user) "projects/issues/issues" diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 4000cd085b7..8ace424f8af 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -58,6 +58,10 @@ FactoryGirl.define do end end + trait :readme do + project_view :readme + end + factory :omniauth_user do transient do extern_uid '123456' diff --git a/spec/helpers/preferences_helper_spec.rb b/spec/helpers/preferences_helper_spec.rb index 8b8080563d3..749aa25e632 100644 --- a/spec/helpers/preferences_helper_spec.rb +++ b/spec/helpers/preferences_helper_spec.rb @@ -77,15 +77,6 @@ describe PreferencesHelper do end end - def stub_user(messages = {}) - if messages.empty? - allow(helper).to receive(:current_user).and_return(nil) - else - allow(helper).to receive(:current_user) - .and_return(double('user', messages)) - end - end - describe '#default_project_view' do context 'user not signed in' do before do @@ -125,5 +116,70 @@ describe PreferencesHelper do end end end + + context 'user signed in' do + let(:user) { create(:user, :readme) } + let(:project) { create(:project, :public, :repository) } + + before do + helper.instance_variable_set(:@project, project) + allow(helper).to receive(:current_user).and_return(user) + end + + context 'when the user is allowed to see the code' do + it 'returns the project view' do + allow(helper).to receive(:can?).with(user, :download_code, project).and_return(true) + + expect(helper.default_project_view).to eq('readme') + end + end + + context 'with wikis enabled and the right policy for the user' do + before do + project.project_feature.update_attribute(:issues_access_level, 0) + allow(helper).to receive(:can?).with(user, :download_code, project).and_return(false) + end + + it 'returns wiki if the user has the right policy' do + allow(helper).to receive(:can?).with(user, :read_wiki, project).and_return(true) + + expect(helper.default_project_view).to eq('wiki') + end + + it 'returns customize_workflow if the user does not have the right policy' do + allow(helper).to receive(:can?).with(user, :read_wiki, project).and_return(false) + + expect(helper.default_project_view).to eq('customize_workflow') + end + end + + context 'with issues as a feature available' do + it 'return issues' do + allow(helper).to receive(:can?).with(user, :download_code, project).and_return(false) + allow(helper).to receive(:can?).with(user, :read_wiki, project).and_return(false) + + expect(helper.default_project_view).to eq('projects/issues/issues') + end + end + + context 'with no activity, no wikies and no issues' do + it 'returns customize_workflow as default' do + project.project_feature.update_attribute(:issues_access_level, 0) + allow(helper).to receive(:can?).with(user, :download_code, project).and_return(false) + allow(helper).to receive(:can?).with(user, :read_wiki, project).and_return(false) + + expect(helper.default_project_view).to eq('customize_workflow') + end + end + end + end + + def stub_user(messages = {}) + if messages.empty? + allow(helper).to receive(:current_user).and_return(nil) + else + allow(helper).to receive(:current_user) + .and_return(double('user', messages)) + end end end From 8c0aa7d4a791cd05eddd9163fdc8270b933ffc6b Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Fri, 24 Nov 2017 09:42:12 +0000 Subject: [PATCH 085/112] Merge branch 'bvl-10-2-email-disclosure' into 'security-10-2' (10.2) Avoid partial partial email adresses for matching See merge request gitlab/gitlabhq!2232 (cherry picked from commit 081aa1e91a777c9acb31be4a1e76b3dd7032fa9a) There are unresolved conflicts in app/models/user.rb. fa85a3fd Don't allow searching for partial user emails --- app/models/user.rb | 27 ++++++++++++++ .../unreleased/bvl-email-disclosure.yml | 5 +++ .../features/groups/members/manage_members.rb | 21 +++++++++++ spec/models/user_spec.rb | 35 ++++--------------- 4 files changed, 60 insertions(+), 28 deletions(-) create mode 100644 changelogs/unreleased/bvl-email-disclosure.yml diff --git a/app/models/user.rb b/app/models/user.rb index af1c36d9c93..7dc18c351e6 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -315,6 +315,13 @@ class User < ActiveRecord::Base # # Returns an ActiveRecord::Relation. def search(query) +<<<<<<< HEAD +======= + table = arel_table + query = query.downcase + pattern = User.to_pattern(query) + +>>>>>>> f45fc58d84... Merge branch 'bvl-10-2-email-disclosure' into 'security-10-2' order = <<~SQL CASE WHEN users.name = %{query} THEN 0 @@ -324,8 +331,16 @@ class User < ActiveRecord::Base END SQL +<<<<<<< HEAD fuzzy_search(query, [:name, :email, :username]) .reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name) +======= + where( + table[:name].matches(pattern) + .or(table[:email].eq(query)) + .or(table[:username].matches(pattern)) + ).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name) +>>>>>>> f45fc58d84... Merge branch 'bvl-10-2-email-disclosure' into 'security-10-2' end # searches user by given pattern @@ -334,6 +349,7 @@ class User < ActiveRecord::Base def search_with_secondary_emails(query) email_table = Email.arel_table +<<<<<<< HEAD matched_by_emails_user_ids = email_table .project(email_table[:user_id]) .where(Email.fuzzy_arel_match(:email, query)) @@ -343,6 +359,17 @@ class User < ActiveRecord::Base .or(fuzzy_arel_match(:email, query)) .or(fuzzy_arel_match(:username, query)) .or(arel_table[:id].in(matched_by_emails_user_ids)) +======= + query = query.downcase + pattern = User.to_pattern(query) + matched_by_emails_user_ids = email_table.project(email_table[:user_id]).where(email_table[:email].eq(query)) + + where( + table[:name].matches(pattern) + .or(table[:email].eq(query)) + .or(table[:username].matches(pattern)) + .or(table[:id].in(matched_by_emails_user_ids)) +>>>>>>> f45fc58d84... Merge branch 'bvl-10-2-email-disclosure' into 'security-10-2' ) end diff --git a/changelogs/unreleased/bvl-email-disclosure.yml b/changelogs/unreleased/bvl-email-disclosure.yml new file mode 100644 index 00000000000..d6cd8709d9f --- /dev/null +++ b/changelogs/unreleased/bvl-email-disclosure.yml @@ -0,0 +1,5 @@ +--- +title: Don't match partial email adresses +merge_request: 2227 +author: +type: security diff --git a/spec/features/groups/members/manage_members.rb b/spec/features/groups/members/manage_members.rb index da1e17225db..21f7b4999ad 100644 --- a/spec/features/groups/members/manage_members.rb +++ b/spec/features/groups/members/manage_members.rb @@ -38,6 +38,27 @@ feature 'Groups > Members > Manage members' do end end + scenario 'do not disclose email addresses', :js do + group.add_owner(user1) + create(:user, email: 'undisclosed_email@gitlab.com', name: "Jane 'invisible' Doe") + + visit group_group_members_path(group) + + find('.select2-container').click + select_input = find('.select2-input') + + select_input.send_keys('@gitlab.com') + wait_for_requests + + expect(page).to have_content('No matches found') + + select_input.native.clear + select_input.send_keys('undisclosed_email@gitlab.com') + wait_for_requests + + expect(page).to have_content("Jane 'invisible' Doe") + end + scenario 'remove user from group', :js do group.add_owner(user1) group.add_developer(user2) diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index cdabd35b6ba..4687d9dfa00 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -913,11 +913,11 @@ describe User do describe 'email matching' do it 'returns users with a matching Email' do - expect(described_class.search(user.email)).to eq([user, user2]) + expect(described_class.search(user.email)).to eq([user]) end - it 'returns users with a partially matching Email' do - expect(described_class.search(user.email[0..2])).to eq([user, user2]) + it 'does not return users with a partially matching Email' do + expect(described_class.search(user.email[0..2])).not_to include(user, user2) end it 'returns users with a matching Email regardless of the casing' do @@ -973,8 +973,8 @@ describe User do expect(search_with_secondary_emails(user.email)).to eq([user]) end - it 'returns users with a partially matching email' do - expect(search_with_secondary_emails(user.email[0..2])).to eq([user]) + it 'does not return users with a partially matching email' do + expect(search_with_secondary_emails(user.email[0..2])).not_to include([user]) end it 'returns users with a matching email regardless of the casing' do @@ -997,29 +997,8 @@ describe User do expect(search_with_secondary_emails(email.email)).to eq([email.user]) end - it 'returns users with a matching part of secondary email' do - expect(search_with_secondary_emails(email.email[1..4])).to eq([email.user]) - end - - it 'return users with a matching part of secondary email regardless of case' do - expect(search_with_secondary_emails(email.email[1..4].upcase)).to eq([email.user]) - expect(search_with_secondary_emails(email.email[1..4].downcase)).to eq([email.user]) - expect(search_with_secondary_emails(email.email[1..4].capitalize)).to eq([email.user]) - end - - it 'returns multiple users with matching secondary emails' do - email1 = create(:email, email: '1_testemail@example.com') - email2 = create(:email, email: '2_testemail@example.com') - email3 = create(:email, email: 'other@email.com') - email3.user.update_attributes!(email: 'another@mail.com') - - expect( - search_with_secondary_emails('testemail@example.com').map(&:id) - ).to include(email1.user.id, email2.user.id) - - expect( - search_with_secondary_emails('testemail@example.com').map(&:id) - ).not_to include(email3.user.id) + it 'does not return users with a matching part of secondary email' do + expect(search_with_secondary_emails(email.email[1..4])).not_to include([email.user]) end end From 8f29d2640ffb29c7ca8c0ab1136aa1959582db3a Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Tue, 28 Nov 2017 08:28:35 +0000 Subject: [PATCH 086/112] Merge branch 'rs-security-group-api' into 'security-10-2' [10.2] Ensure we expose group projects using GroupProjectsFinder See merge request gitlab/gitlabhq!2234 (cherry picked from commit 072f8f2fd6ec794645375a16ca4ddc1cbeb76d7a) a2240338 Ensure we expose group projects using GroupProjectsFinder --- .../unreleased/rs-security-group-api.yml | 5 ++ lib/api/entities.rb | 17 ++++- spec/requests/api/groups_spec.rb | 62 +++++++++++++++++++ 3 files changed, 82 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/rs-security-group-api.yml diff --git a/changelogs/unreleased/rs-security-group-api.yml b/changelogs/unreleased/rs-security-group-api.yml new file mode 100644 index 00000000000..34a39ddd6dc --- /dev/null +++ b/changelogs/unreleased/rs-security-group-api.yml @@ -0,0 +1,5 @@ +--- +title: Prevent an information disclosure in the Groups API +merge_request: +author: +type: security diff --git a/lib/api/entities.rb b/lib/api/entities.rb index d96e7f2770f..928706dfda7 100644 --- a/lib/api/entities.rb +++ b/lib/api/entities.rb @@ -248,8 +248,21 @@ module API end class GroupDetail < Group - expose :projects, using: Entities::Project - expose :shared_projects, using: Entities::Project + expose :projects, using: Entities::Project do |group, options| + GroupProjectsFinder.new( + group: group, + current_user: options[:current_user], + options: { only_owned: true } + ).execute + end + + expose :shared_projects, using: Entities::Project do |group, options| + GroupProjectsFinder.new( + group: group, + current_user: options[:current_user], + options: { only_shared: true } + ).execute + end end class Commit < Grape::Entity diff --git a/spec/requests/api/groups_spec.rb b/spec/requests/api/groups_spec.rb index 554723d6b1e..6330c140246 100644 --- a/spec/requests/api/groups_spec.rb +++ b/spec/requests/api/groups_spec.rb @@ -173,6 +173,28 @@ describe API::Groups do end describe "GET /groups/:id" do + # Given a group, create one project for each visibility level + # + # group - Group to add projects to + # share_with - If provided, each project will be shared with this Group + # + # Returns a Hash of visibility_level => Project pairs + def add_projects_to_group(group, share_with: nil) + projects = { + public: create(:project, :public, namespace: group), + internal: create(:project, :internal, namespace: group), + private: create(:project, :private, namespace: group) + } + + if share_with + create(:project_group_link, project: projects[:public], group: share_with) + create(:project_group_link, project: projects[:internal], group: share_with) + create(:project_group_link, project: projects[:private], group: share_with) + end + + projects + end + context 'when unauthenticated' do it 'returns 404 for a private group' do get api("/groups/#{group2.id}") @@ -183,6 +205,26 @@ describe API::Groups do get api("/groups/#{group1.id}") expect(response).to have_gitlab_http_status(200) end + + it 'returns only public projects in the group' do + public_group = create(:group, :public) + projects = add_projects_to_group(public_group) + + get api("/groups/#{public_group.id}") + + expect(json_response['projects'].map { |p| p['id'].to_i }) + .to contain_exactly(projects[:public].id) + end + + it 'returns only public projects shared with the group' do + public_group = create(:group, :public) + projects = add_projects_to_group(public_group, share_with: group1) + + get api("/groups/#{group1.id}") + + expect(json_response['shared_projects'].map { |p| p['id'].to_i }) + .to contain_exactly(projects[:public].id) + end end context "when authenticated as user" do @@ -222,6 +264,26 @@ describe API::Groups do expect(response).to have_gitlab_http_status(404) end + + it 'returns only public and internal projects in the group' do + public_group = create(:group, :public) + projects = add_projects_to_group(public_group) + + get api("/groups/#{public_group.id}", user2) + + expect(json_response['projects'].map { |p| p['id'].to_i }) + .to contain_exactly(projects[:public].id, projects[:internal].id) + end + + it 'returns only public and internal projects shared with the group' do + public_group = create(:group, :public) + projects = add_projects_to_group(public_group, share_with: group1) + + get api("/groups/#{group1.id}", user2) + + expect(json_response['shared_projects'].map { |p| p['id'].to_i }) + .to contain_exactly(projects[:public].id, projects[:internal].id) + end end context "when authenticated as admin" do From c59ae5470546d1169ee3ab89486140e815400f31 Mon Sep 17 00:00:00 2001 From: Sean McGivern Date: Wed, 22 Nov 2017 17:24:11 +0000 Subject: [PATCH 087/112] Merge branch 'issue_30663' into 'security-10-2' Prevent creating issues through API without having permissions See merge request gitlab/gitlabhq!2225 (cherry picked from commit c298bbaa88883343dc9cbbb6abec0808fb3b546c) 915b97c5 Prevent creating issues through API without having permissions --- changelogs/unreleased/issue_30663.yml | 5 +++++ lib/api/issues.rb | 2 ++ spec/requests/api/issues_spec.rb | 14 ++++++++++++++ 3 files changed, 21 insertions(+) create mode 100644 changelogs/unreleased/issue_30663.yml diff --git a/changelogs/unreleased/issue_30663.yml b/changelogs/unreleased/issue_30663.yml new file mode 100644 index 00000000000..b20ed6a82e7 --- /dev/null +++ b/changelogs/unreleased/issue_30663.yml @@ -0,0 +1,5 @@ +--- +title: Prevent creating issues through API when user does not have permissions +merge_request: +author: +type: security diff --git a/lib/api/issues.rb b/lib/api/issues.rb index e60e00d7956..5f943ba27d1 100644 --- a/lib/api/issues.rb +++ b/lib/api/issues.rb @@ -161,6 +161,8 @@ module API use :issue_params end post ':id/issues' do + authorize! :create_issue, user_project + # Setting created_at time only allowed for admins and project owners unless current_user.admin? || user_project.owner == current_user params.delete(:created_at) diff --git a/spec/requests/api/issues_spec.rb b/spec/requests/api/issues_spec.rb index 99525cd0a6a..3f5070a1fd2 100644 --- a/spec/requests/api/issues_spec.rb +++ b/spec/requests/api/issues_spec.rb @@ -860,6 +860,20 @@ describe API::Issues, :mailer do end end + context 'user does not have permissions to create issue' do + let(:not_member) { create(:user) } + + before do + project.project_feature.update(issues_access_level: ProjectFeature::PRIVATE) + end + + it 'renders 403' do + post api("/projects/#{project.id}/issues", not_member), title: 'new issue' + + expect(response).to have_gitlab_http_status(403) + end + end + it 'creates a new project issue' do post api("/projects/#{project.id}/issues", user), title: 'new issue', labels: 'label, label2', weight: 3, From f4fbe61a9e073d8e49b0e8104961b2556ce3ac05 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Wed, 6 Dec 2017 20:10:32 +0000 Subject: [PATCH 088/112] Merge branch 'note-preview' into 'security-10-2' prevent potential XSS when editing comment See merge request gitlab/gitlabhq!2238 (cherry picked from commit 80ed6d25a46c0f70ec8baea78b5777118d63876c) 7480e462 prevent potential XSS when editing comment --- .../javascripts/notes/components/issue_note.vue | 3 ++- .../notes/components/issue_note_spec.js | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/notes/components/issue_note.vue b/app/assets/javascripts/notes/components/issue_note.vue index 8c81c5d6df3..3ceb961f58e 100644 --- a/app/assets/javascripts/notes/components/issue_note.vue +++ b/app/assets/javascripts/notes/components/issue_note.vue @@ -1,5 +1,6 @@

    3b!J{SVhSP%2F5)Pq!k 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%0AX9X!ltM=ii??1O8V!!CX3<4E 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 S@;T=nKzvVEKbLh*2~7Z*qdRH< diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 914217772b8..d100b431721 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -129,8 +129,6 @@ full use of Auto DevOps. If this is your fist time, we recommend you follow the 1. Go to your project's **Settings > CI/CD > General pipelines settings** and find the Auto DevOps section 1. Select "Enable Auto DevOps" -1. After selecting an option to enable Auto DevOps, a checkbox will appear below - so you can immediately run a pipeline on the default branch 1. Optionally, but recommended, add in the [base domain](#auto-devops-base-domain) that will be used by Kubernetes to deploy your application 1. Hit **Save changes** for the changes to take effect diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb index eb8e7265dd3..561f08cba00 100644 --- a/spec/features/projects/settings/pipelines_settings_spec.rb +++ b/spec/features/projects/settings/pipelines_settings_spec.rb @@ -59,107 +59,6 @@ feature "Pipelines settings" do expect(project.auto_devops).to be_present expect(project.auto_devops).not_to be_enabled end - - describe 'Immediately run pipeline checkbox option', :js do - context 'when auto devops is set to instance default (enabled)' do - before do - stub_application_setting(auto_devops_enabled: true) - project.create_auto_devops!(enabled: nil) - visit project_settings_ci_cd_path(project) - end - - it 'does not show checkboxes on page-load' do - expect(page).to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper.hide', count: 1, visible: false) - end - - it 'selecting explicit disabled hides all checkboxes' do - page.choose('project_auto_devops_attributes_enabled_false') - - expect(page).to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper.hide', count: 1, visible: false) - end - - it 'selecting explicit enabled hides all checkboxes because we are already enabled' do - page.choose('project_auto_devops_attributes_enabled_true') - - expect(page).to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper.hide', count: 1, visible: false) - end - end - - context 'when auto devops is set to instance default (disabled)' do - before do - stub_application_setting(auto_devops_enabled: false) - project.create_auto_devops!(enabled: nil) - visit project_settings_ci_cd_path(project) - end - - it 'does not show checkboxes on page-load' do - expect(page).to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper.hide', count: 1, visible: false) - end - - it 'selecting explicit disabled hides all checkboxes' do - page.choose('project_auto_devops_attributes_enabled_false') - - expect(page).to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper.hide', count: 1, visible: false) - end - - it 'selecting explicit enabled shows a checkbox' do - page.choose('project_auto_devops_attributes_enabled_true') - - expect(page).to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper:not(.hide)', count: 1) - end - end - - context 'when auto devops is set to explicit disabled' do - before do - stub_application_setting(auto_devops_enabled: true) - project.create_auto_devops!(enabled: false) - visit project_settings_ci_cd_path(project) - end - - it 'does not show checkboxes on page-load' do - expect(page).to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper.hide', count: 2, visible: false) - end - - it 'selecting explicit enabled shows a checkbox' do - page.choose('project_auto_devops_attributes_enabled_true') - - expect(page).to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper:not(.hide)', count: 1) - end - - it 'selecting instance default (enabled) shows a checkbox' do - page.choose('project_auto_devops_attributes_enabled_') - - expect(page).to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper:not(.hide)', count: 1) - end - end - - context 'when auto devops is set to explicit enabled' do - before do - stub_application_setting(auto_devops_enabled: false) - project.create_auto_devops!(enabled: true) - visit project_settings_ci_cd_path(project) - end - - it 'does not have any checkboxes' do - expect(page).not_to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper', visible: false) - end - end - - context 'when master contains a .gitlab-ci.yml file' do - let(:project) { create(:project, :repository) } - - before do - project.repository.create_file(user, '.gitlab-ci.yml', "script: ['test']", message: 'test', branch_name: project.default_branch) - stub_application_setting(auto_devops_enabled: true) - project.create_auto_devops!(enabled: false) - visit project_settings_ci_cd_path(project) - end - - it 'does not have any checkboxes' do - expect(page).not_to have_selector('.js-run-auto-devops-pipeline-checkbox-wrapper', visible: false) - end - end - end end end end From de3eabc5bf530dd95dafa77efc006c163aa6b1f0 Mon Sep 17 00:00:00 2001 From: Fatih Acet Date: Fri, 1 Dec 2017 16:18:59 +0100 Subject: [PATCH 008/112] Fix loading branches list on cherry pick modal after merge. --- .../vue_merge_request_widget/mr_widget_options.js | 2 ++ spec/features/merge_requests/widget_spec.rb | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js index 1274db2c4c8..9cb3edead86 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.js @@ -1,3 +1,4 @@ +import Project from '~/project'; import SmartInterval from '~/smart_interval'; import Flash from '../flash'; import { @@ -140,6 +141,7 @@ export default { const el = document.createElement('div'); el.innerHTML = res.body; document.body.appendChild(el); + Project.initRefSwitcher(); } }) .catch(() => { diff --git a/spec/features/merge_requests/widget_spec.rb b/spec/features/merge_requests/widget_spec.rb index 2bad3b02250..3ee094c216e 100644 --- a/spec/features/merge_requests/widget_spec.rb +++ b/spec/features/merge_requests/widget_spec.rb @@ -63,6 +63,18 @@ describe 'Merge request', :js do expect(page).to have_selector('.accept-merge-request') expect(find('.accept-merge-request')['disabled']).not_to be(true) end + + it 'allows me to merge, see cherry-pick modal and load branches list' do + wait_for_requests + click_button 'Merge' + + wait_for_requests + click_link 'Cherry-pick' + page.find('.js-project-refs-dropdown').click + wait_for_requests + + expect(page.all('.js-cherry-pick-form .dropdown-content li').size).to be > 1 + end end context 'view merge request with external CI service' do From 88f268b5e5d4327529f74484f82ae860eb13978b Mon Sep 17 00:00:00 2001 From: Eric Eastwood Date: Mon, 4 Dec 2017 17:19:20 -0600 Subject: [PATCH 009/112] Replace absolute URLs on related branches/MRs with relative url to avoid hostname --- app/views/projects/issues/show.html.haml | 4 ++-- ...bsolute-urls-with-related-branches-to-avoid-hostname.yml | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/40555-replace-absolute-urls-with-related-branches-to-avoid-hostname.yml diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 48410ffee21..3e03ae1e117 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -74,10 +74,10 @@ = edited_time_ago_with_tooltip(@issue, placement: 'bottom', html_class: 'issue-edited-ago js-issue-edited-ago') - #merge-requests{ data: { url: referenced_merge_requests_project_issue_url(@project, @issue) } } + #merge-requests{ data: { url: referenced_merge_requests_project_issue_path(@project, @issue) } } // This element is filled in using JavaScript. - #related-branches{ data: { url: related_branches_project_issue_url(@project, @issue) } } + #related-branches{ data: { url: related_branches_project_issue_path(@project, @issue) } } // This element is filled in using JavaScript. .content-block.emoji-block diff --git a/changelogs/unreleased/40555-replace-absolute-urls-with-related-branches-to-avoid-hostname.yml b/changelogs/unreleased/40555-replace-absolute-urls-with-related-branches-to-avoid-hostname.yml new file mode 100644 index 00000000000..4f0eaf8472f --- /dev/null +++ b/changelogs/unreleased/40555-replace-absolute-urls-with-related-branches-to-avoid-hostname.yml @@ -0,0 +1,6 @@ +--- +title: Fix related branches/Merge requests failing to load when the hostname setting + is changed +merge_request: +author: +type: fixed From aaf16997303a470809c8be442a537983b49f0f7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Tue, 5 Dec 2017 17:49:49 +0100 Subject: [PATCH 010/112] Move the "Limit conflicts with EE" doc to "Automatic CE-> EE merge" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- PROCESS.md | 6 +- doc/development/README.md | 2 +- doc/development/automatic_ce_ee_merge.md | 91 ++++++ doc/development/ee_features.md | 6 + doc/development/limit_ee_conflicts.md | 347 ----------------------- lib/gitlab/ee_compat_check.rb | 6 +- 6 files changed, 105 insertions(+), 353 deletions(-) create mode 100644 doc/development/automatic_ce_ee_merge.md delete mode 100644 doc/development/limit_ee_conflicts.md diff --git a/PROCESS.md b/PROCESS.md index 7c8db689256..758e773ae4d 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -130,7 +130,8 @@ freeze date (the 7th) should have a corresponding Enterprise Edition merge request, even if there are no conflicts. This is to reduce the size of the subsequent EE merge, as we often merge a lot to CE on the release date. For more information, see -[limit conflicts with EE when developing on CE][limit_ee_conflicts]. +[Automatic CE->EE merge][automatic_ce_ee_merge] and +[Guidelines for implementing Enterprise Edition feature][ee_features]. ### After the 7th @@ -281,4 +282,5 @@ still an issue I encourage you to open it on the [GitLab.com issue tracker](http ["Implement design & UI elements" guidelines]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#implement-design-ui-elements [Thoughtbot code review guide]: https://github.com/thoughtbot/guides/tree/master/code-review [done]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done -[limit_ee_conflicts]: https://docs.gitlab.com/ce/development/limit_ee_conflicts.html +[automatic_ce_ee_merge]: https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html +[ee_features]: https://docs.gitlab.com/ce/development/ee_features.html diff --git a/doc/development/README.md b/doc/development/README.md index 6892838be7f..944880d8ac7 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -16,7 +16,7 @@ comments: false - [GitLab core team & GitLab Inc. contribution process](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/PROCESS.md) - [Generate a changelog entry with `bin/changelog`](changelog.md) - [Code review guidelines](code_review.md) for reviewing code and having code reviewed. -- [Limit conflicts with EE when developing on CE](limit_ee_conflicts.md) +- [Automatic CE->EE merge](automatic_ce_ee_merge.md) ## UX and frontend guides diff --git a/doc/development/automatic_ce_ee_merge.md b/doc/development/automatic_ce_ee_merge.md new file mode 100644 index 00000000000..1a2a968ef54 --- /dev/null +++ b/doc/development/automatic_ce_ee_merge.md @@ -0,0 +1,91 @@ +# Automatic CE->EE merge + +GitLab Community Edition is merged automatically every 3 hours into the +Enterprise Edition (look for the [`CE Upstream` merge requests]). + +This merge is done automatically in a +[scheduled pipeline](https://gitlab.com/gitlab-org/release-tools/-/jobs/43201679). + +If a merge is already in progress, the job [doesn't create a new one](https://gitlab.com/gitlab-org/release-tools/-/jobs/43157687). + +**If you are pinged in a `CE Upstream` merge request to resolve a conflict, +please resolve the conflict as soon as possible or ask someone else to do it!** + +>**Note:** +It's ok to resolve more conflicts than the one that you are asked to resolve. In +that case, it's a good habit to ask for a double-check on your resolution by +someone who is familiar with the code you touched. + +### Always merge EE merge request before their CE counterpart + +**In order to avoid conflicts in the CE->EE merge, you should always merge the +EE version of your CE merge request (if present).** + +The rationale for this is that as CE->EE merges are done automatically every few +hours, it can happen that: + +1. A CE merge request that needs EE-specific changes is merged +1. The automatic CE->EE merge happens +1. Conflicts due to the CE merge request occur since its EE merge request isn't + merged yet +1. The automatic merge bot will ping someone to resolve the conflict **that are + already resolved in the EE merge request that isn't merged yet** +1. That's a waste of time, and that's why you should merge EE merge request + before their CE counterpart + +## Avoiding CE->EE merge conflicts beforehand + +To avoid the conflicts beforehand, check out the +[Guidelines for implementing Enterprise Edition feature](ee_features.md). + +In any case, the CI `ee_compat_check` job will tell you if you need to open an +EE version of your CE merge request. + +### Conflicts detection in CE merge requests + +For each commit (except on `master`), the `rake ee_compat_check` CI job tries to +detect if the current branch's changes will conflict during the CE->EE merge. + +The job reports what files are conflicting and how to setup a merge request +against EE. + +#### How the job works? + +1. Generates the diff between your branch and current CE `master` +1. Tries to apply it to current EE `master` +1. If it applies cleanly, the job succeeds, otherwise... +1. Detects a branch with the `ee-` prefix or `-ee` suffix in EE +1. If it exists, generate the diff between this branch and current EE `master` +1. Tries to apply it to current EE `master` +1. If it applies cleanly, the job succeeds + +In the case where the job fails, it means you should create a `ee-` +or `-ee` branch, push it to EE and open a merge request against EE +`master`. +At this point if you retry the failing job in your CE merge request, it should +now pass. + +Notes: + +- This task is not a silver-bullet, its current goal is to bring awareness to + developers that their work needs to be ported to EE. +- Community contributors shouldn't submit merge requests against EE, but + reviewers should take actions by either creating such EE merge request or + asking a GitLab developer to do it **before the merge request is merged**. +- If you branch is too far behind `master`, the job will fail. In that case you + should rebase your branch upon latest `master`. +- Code reviews for merge requests often consist of multiple iterations of + feedback and fixes. There is no need to update your EE MR after each + iteration. Instead, create an EE MR as soon as you see the + `ee_compat_check` job failing. After you receive the final approval + from a Maintainer (but **before the CE MR is merged**) update the EE MR. + This helps to identify significant conflicts sooner, but also reduces the + number of times you have to resolve conflicts. +- Please remember to + [always have you EE merge request merged before the CE one](#always-merge-ee-merge-request-before-their-ce-counterpart). +- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html) + to avoid resolving the same conflicts multiple times. + +--- + +[Return to Development documentation](README.md) diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index 932a44f65e4..eb33d17443a 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -380,3 +380,9 @@ to avoid conflicts during CE to EE merge. } } ``` + +## gitlab-svgs + +Conflicts in `app/assets/images/icons.json` or `app/assets/images/icons.svg` can +be resolved simply by regenerating those assets with +[`yarn run svg`](https://gitlab.com/gitlab-org/gitlab-svgs). diff --git a/doc/development/limit_ee_conflicts.md b/doc/development/limit_ee_conflicts.md deleted file mode 100644 index ba82babb38a..00000000000 --- a/doc/development/limit_ee_conflicts.md +++ /dev/null @@ -1,347 +0,0 @@ -# Limit conflicts with EE when developing on CE - -This guide contains best-practices for avoiding conflicts between CE and EE. - -## Daily CE Upstream merge - -GitLab Community Edition is merged daily into the Enterprise Edition (look for -the [`CE Upstream` merge requests]). The daily merge is currently done manually -by four individuals. - -**If a developer pings you in a `CE Upstream` merge request for help with -resolving conflicts, please help them because it means that you didn't do your -job to reduce the conflicts nor to ease their resolution in the first place!** - -To avoid the conflicts beforehand when working on CE, there are a few tools and -techniques that can help you: - -- know what are the usual types of conflicts and how to prevent them -- the CI `rake ee_compat_check` job tells you if you need to open an EE-version - of your CE merge request - -[`CE Upstream` merge requests]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests?label_name%5B%5D=CE+upstream - -## Check the status of the CI `rake ee_compat_check` job - -For each commit (except on `master`), the `rake ee_compat_check` CI job tries to -detect if the current branch's changes will conflict during the CE->EE merge. - -The job reports what files are conflicting and how to setup a merge request -against EE. Here is roughly how it works: - -1. Generates the diff between your branch and current CE `master` -1. Tries to apply it to current EE `master` -1. If it applies cleanly, the job succeeds, otherwise... -1. Detects a branch with the `-ee` suffix in EE -1. If it exists, generate the diff between this branch and current EE `master` -1. Tries to apply it to current EE `master` -1. If it applies cleanly, the job succeeds - -In the case where the job fails, it means you should create a `-ee` -branch, push it to EE and open a merge request against EE `master`. At this -point if you retry the failing job in your CE merge request, it should now pass. - -Notes: - -- This task is not a silver-bullet, its current goal is to bring awareness to - developers that their work needs to be ported to EE. -- Community contributors shouldn't submit merge requests against EE, but - reviewers should take actions by either creating such EE merge request or - asking a GitLab developer to do it once the merge request is merged. -- If you branch is more than 500 commits behind `master`, the job will fail and - you should rebase your branch upon latest `master`. -- Code reviews for merge requests often consist of multiple iterations of - feedback and fixes. There is no need to update your EE MR after each - iteration. Instead, create an EE MR as soon as you see the - `rake ee_compat_check` job failing. After you receive the final acceptance - from a Maintainer (but before the CE MR is merged) update the EE MR. - This helps to identify significant conflicts sooner, but also reduces the - number of times you have to resolve conflicts. -- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html) - to avoid resolving the same conflicts multiple times. - -## Possible type of conflicts - -### Controllers - -#### List or arrays are augmented in EE - -In controllers, the most common type of conflict is with `before_action` that -has a list of actions in CE but EE adds some actions to that list. - -The same problem often occurs for `params.require` / `params.permit` calls. - -##### Mitigations - -Separate CE and EE actions/keywords. For instance for `params.require` in -`ProjectsController`: - -```ruby -def project_params - params.require(:project).permit(project_params_ce) - # On EE, this is always: - # params.require(:project).permit(project_params_ce << project_params_ee) -end - -# Always returns an array of symbols, created however best fits the use case. -# It _should_ be sorted alphabetically. -def project_params_ce - %i[ - description - name - path - ] -end - -# (On EE) -def project_params_ee - %i[ - approvals_before_merge - approver_group_ids - approver_ids - ... - ] -end -``` - -#### Additional condition(s) in EE - -For instance for LDAP: - -```diff - def destroy - @key = current_user.keys.find(params[:id]) - - @key.destroy - + @key.destroy unless @key.is_a? LDAPKey - - respond_to do |format| -``` - -Or for Geo: - -```diff -def after_sign_out_path_for(resource) -- current_application_settings.after_sign_out_path.presence || new_user_session_path -+ if Gitlab::Geo.secondary? -+ Gitlab::Geo.primary_node.oauth_logout_url(@geo_logout_state) -+ else -+ current_application_settings.after_sign_out_path.presence || new_user_session_path -+ end -end -``` - -Or even for audit log: - -```diff -def approve_access_request -- Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute -+ member = Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute -+ -+ log_audit_event(member, action: :create) - - redirect_to polymorphic_url([membershipable, :members]) -end -``` - -### Views - -#### Additional view code in EE - -A block of code added in CE conflicts because there is already another block -at the same place in EE - -##### Mitigations - -Blocks of code that are EE-specific should be moved to partials as much as -possible to avoid conflicts with big chunks of HAML code that that are not fun -to resolve when you add the indentation to the equation. - -For instance this kind of thing: - -```haml -.form-group.detail-page-description - = form.label :description, 'Description', class: 'control-label' - .col-sm-10 - = render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do - = render 'projects/zen', f: form, attr: :description, - classes: 'note-textarea', - placeholder: "Write a comment or drag your files here...", - supports_quick_actions: !issuable.persisted? - = render 'projects/notes/hints', supports_quick_actions: !issuable.persisted? - .clearfix - .error-alert -- if issuable.is_a?(Issue) - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = form.label :confidential do - = form.check_box :confidential - This issue is confidential and should only be visible to team members with at least Reporter access. -- if can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) - - has_due_date = issuable.has_attribute?(:due_date) - %hr - .row - %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } - .form-group.issue-assignee - = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - - if issuable.assignee_id - = form.hidden_field :assignee_id - = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - .form-group.issue-milestone - = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" - .form-group - - has_labels = @labels && @labels.any? - = form.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}" - = form.hidden_field :label_ids, multiple: true, value: '' - .col-sm-10{ class: "#{"col-lg-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" } - .issuable-form-select-holder - = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label" - - if issuable.respond_to?(:weight) - - weight_options = Issue.weight_options - - weight_options.delete(Issue::WEIGHT_ALL) - - weight_options.delete(Issue::WEIGHT_ANY) - .form-group - = form.label :label_ids, class: "control-label #{"col-lg-4" if has_due_date}" do - Weight - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - - if issuable.weight - = form.hidden_field :weight - = dropdown_tag(issuable.weight || "Weight", options: { title: "Select weight", toggle_class: 'js-weight-select js-issuable-form-weight', dropdown_class: "dropdown-menu-selectable dropdown-menu-weight", - placeholder: "Search weight", data: { field_name: "#{issuable.class.model_name.param_key}[weight]" , default_label: "Weight" } }) do - %ul - - weight_options.each do |weight| - %li - %a{href: "#", data: { id: weight, none: weight === Issue::WEIGHT_NONE }, class: ("is-active" if issuable.weight == weight)} - = weight - - if has_due_date - .col-lg-6 - .form-group - = form.label :due_date, "Due date", class: "control-label" - .col-sm-10 - .issuable-form-select-holder - = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date" -``` - -could be simplified by using partials: - -```haml -= render 'shared/issuable/form/description', issuable: issuable, form: form - -- if issuable.respond_to?(:confidential) - .form-group - .col-sm-offset-2.col-sm-10 - .checkbox - = form.label :confidential do - = form.check_box :confidential - This issue is confidential and should only be visible to team members with at least Reporter access. - -= render 'shared/issuable/form/metadata', issuable: issuable, form: form -``` - -and then the `app/views/shared/issuable/form/_metadata.html.haml` could be as follows: - -```haml -- issuable = local_assigns.fetch(:issuable) - -- return unless can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project) - -- has_due_date = issuable.has_attribute?(:due_date) -- has_labels = @labels && @labels.any? -- form = local_assigns.fetch(:form) - -%hr -.row - %div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") } - .form-group.issue-assignee - = form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - - if issuable.assignee_id - = form.hidden_field :assignee_id - = dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit", - placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} }) - .form-group.issue-milestone - = form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}" - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - = render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone" - .form-group - - has_labels = @labels && @labels.any? - = form.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}" - = form.hidden_field :label_ids, multiple: true, value: '' - .col-sm-10{ class: "#{"col-lg-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" } - .issuable-form-select-holder - = render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label" - - = render "shared/issuable/form/weight", issuable: issuable, form: form - - - if has_due_date - .col-lg-6 - .form-group - = form.label :due_date, "Due date", class: "control-label" - .col-sm-10 - .issuable-form-select-holder - = form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date" -``` - -and then the `app/views/shared/issuable/form/_weight.html.haml` could be as follows: - -```haml -- issuable = local_assigns.fetch(:issuable) - -- return unless issuable.respond_to?(:weight) - -- has_due_date = issuable.has_attribute?(:due_date) -- form = local_assigns.fetch(:form) - -.form-group - = form.label :label_ids, class: "control-label #{"col-lg-4" if has_due_date}" do - Weight - .col-sm-10{ class: ("col-lg-8" if has_due_date) } - .issuable-form-select-holder - - if issuable.weight - = form.hidden_field :weight - - = weight_dropdown_tag(issuable, toggle_class: 'js-issuable-form-weight') do - %ul - - Issue.weight_options.each do |weight| - %li - %a{ href: '#', data: { id: weight, none: weight === Issue::WEIGHT_NONE }, class: ("is-active" if issuable.weight == weight) } - = weight -``` - -Note: - -- The safeguards at the top allow to get rid of an unneccessary indentation level -- Here we only moved the 'Weight' code to a partial since this is the only - EE-specific code in that view, so it's the most likely to conflict, but you - are encouraged to use partials even for code that's in CE to logically split - big views into several smaller files. - -#### Indentation issue - -Sometimes a code block is indented more or less in EE because there's an -additional condition. - -##### Mitigations - -Blocks of code that are EE-specific should be moved to partials as much as -possible to avoid conflicts with big chunks of HAML code that that are not fun -to resolve when you add the indentation in the equation. - -### Assets - -#### gitlab-svgs - -Conflicts in `app/assets/images/icons.json` or `app/assets/images/icons.svg` can be resolved simply by regenerating those assets with [`yarn run svg`](https://gitlab.com/gitlab-org/gitlab-svgs). - ---- - -[Return to Development documentation](README.md) diff --git a/lib/gitlab/ee_compat_check.rb b/lib/gitlab/ee_compat_check.rb index 4a9d3e52fae..37face8e7d0 100644 --- a/lib/gitlab/ee_compat_check.rb +++ b/lib/gitlab/ee_compat_check.rb @@ -280,7 +280,7 @@ module Gitlab The `#{branch}` branch applies cleanly to EE/master! Much ❤️! For more information, see - https://docs.gitlab.com/ce/development/limit_ee_conflicts.html#check-the-rake-ee_compat_check-in-your-merge-requests + https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html #{THANKS_FOR_READING_BANNER} } end @@ -357,7 +357,7 @@ module Gitlab Once this is done, you can retry this failed build, and it should pass. Stay 💪 ! For more information, see - https://docs.gitlab.com/ce/development/limit_ee_conflicts.html#check-the-rake-ee_compat_check-in-your-merge-requests + https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html #{THANKS_FOR_READING_BANNER} } end @@ -378,7 +378,7 @@ module Gitlab retry this build. Stay 💪 ! For more information, see - https://docs.gitlab.com/ce/development/limit_ee_conflicts.html#check-the-rake-ee_compat_check-in-your-merge-requests + https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html #{THANKS_FOR_READING_BANNER} } end From c4c06a33f91ca56b2c30b7521708c34089ed43f7 Mon Sep 17 00:00:00 2001 From: Clement Ho Date: Tue, 5 Dec 2017 22:40:42 +0000 Subject: [PATCH 011/112] Fix transient error in pikadayToString --- spec/javascripts/lib/utils/datefix_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/javascripts/lib/utils/datefix_spec.js b/spec/javascripts/lib/utils/datefix_spec.js index e58ac4300ba..a9f3abcf2a4 100644 --- a/spec/javascripts/lib/utils/datefix_spec.js +++ b/spec/javascripts/lib/utils/datefix_spec.js @@ -21,7 +21,7 @@ describe('datefix', () => { describe('pikadayToString', () => { it('should format a UTC date into yyyy-mm-dd format', () => { - expect(pikadayToString(new Date('2020-01-29'))).toEqual('2020-01-29'); + expect(pikadayToString(new Date('2020-01-29:00:00'))).toEqual('2020-01-29'); }); }); }); From 67c7e0fc5d50e2c8fc7aa773b98d32922132be47 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Sun, 3 Sep 2017 23:35:37 +0900 Subject: [PATCH 012/112] Fail jobs if its dependency is missing --- app/models/ci/build.rb | 6 ++++ app/models/commit_status.rb | 3 +- app/services/ci/register_job_service.rb | 3 ++ lib/gitlab/ci/error/missing_dependencies.rb | 7 +++++ .../ci/error/missing_dependencies_spec.rb | 5 ++++ spec/models/ci/build_spec.rb | 30 +++++++++++++++++++ spec/services/ci/register_job_service_spec.rb | 28 +++++++++++++++++ 7 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 lib/gitlab/ci/error/missing_dependencies.rb create mode 100644 spec/lib/gitlab/ci/error/missing_dependencies_spec.rb diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index d2402b55184..119c6fd7b45 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -139,6 +139,12 @@ module Ci Ci::Build.retry(build, build.user) end end + + before_transition any => [:running] do |build| + if !build.empty_dependencies? && build.dependencies.empty? + raise Gitlab::Ci::Error::MissingDependencies + end + end end def detailed_status(current_user) diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index ee21ed8e420..c0263c0b4e2 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -43,7 +43,8 @@ class CommitStatus < ActiveRecord::Base script_failure: 1, api_failure: 2, stuck_or_timeout_failure: 3, - runner_system_failure: 4 + runner_system_failure: 4, + missing_dependency_failure: 5 } ## diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index 2ef76e03031..f73902935e6 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -54,6 +54,9 @@ module Ci # we still have to return 409 in the end, # to make sure that this is properly handled by runner. valid = false + rescue Gitlab::Ci::Error::MissingDependencies + build.drop!(:missing_dependency_failure) + valid = false end end diff --git a/lib/gitlab/ci/error/missing_dependencies.rb b/lib/gitlab/ci/error/missing_dependencies.rb new file mode 100644 index 00000000000..f4b1940d84f --- /dev/null +++ b/lib/gitlab/ci/error/missing_dependencies.rb @@ -0,0 +1,7 @@ +module Gitlab + module Ci + module Error + class MissingDependencies < StandardError; end + end + end +end diff --git a/spec/lib/gitlab/ci/error/missing_dependencies_spec.rb b/spec/lib/gitlab/ci/error/missing_dependencies_spec.rb new file mode 100644 index 00000000000..039a4776dc3 --- /dev/null +++ b/spec/lib/gitlab/ci/error/missing_dependencies_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe Gitlab::Ci::Error::MissingDependencies do + it { expect(described_class).to be < StandardError } +end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 26d33663dad..61ac2dd78d1 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1868,6 +1868,36 @@ describe Ci::Build do end end + describe 'state transition: any => [:running]' do + let(:build) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 1, options: options) } + + context 'when "dependencies" keyword is not defined' do + let(:options) { {} } + + it { expect { build.run! }.not_to raise_error } + end + + context 'when "dependencies" keyword is empty' do + let(:options) { { dependencies: [] } } + + it { expect { build.run! }.not_to raise_error } + end + + context 'when "dependencies" keyword is specified' do + let(:options) { { dependencies: ['test'] } } + + context 'when a depended job exists' do + let!(:pre_build) { create(:ci_build, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect { build.run! }.not_to raise_error } + end + + context 'when depended jobs do not exist' do + it { expect { build.run! }.to raise_error(Gitlab::Ci::Error::MissingDependencies) } + end + end + end + describe 'state transition when build fails' do let(:service) { MergeRequests::AddTodoWhenBuildFailsService.new(project, user) } diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index decdd577226..e779d02cc52 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -276,6 +276,34 @@ module Ci end end + context 'when "dependencies" keyword is specified' do + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: job_name, stage_idx: 0) } + + let!(:pending_job) do + create(:ci_build, :pending, pipeline: pipeline, stage_idx: 1, options: { dependencies: ['spec'] } ) + end + + let(:picked_job) { execute(specific_runner) } + + context 'when a depended job exists' do + let(:job_name) { 'spec' } + + it "picks the build" do + expect(picked_job).to eq(pending_job) + end + end + + context 'when depended jobs do not exist' do + let(:job_name) { 'robocop' } + + it 'does not pick the build and drops the build' do + expect(picked_job).to be_nil + expect(pending_job.reload).to be_failed + expect(pending_job).to be_missing_dependency_failure + end + end + end + def execute(runner) described_class.new(runner).execute.build end From 8917726bb5e6746750129c0d9322c2daa7f88172 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Sun, 3 Sep 2017 23:45:44 +0900 Subject: [PATCH 013/112] Add changelog. Fix doc --- ...feature-sm-34834-missing-dependency-should-fail-job-2.yml | 5 +++++ doc/ci/yaml/README.md | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) create mode 100644 changelogs/unreleased/feature-sm-34834-missing-dependency-should-fail-job-2.yml diff --git a/changelogs/unreleased/feature-sm-34834-missing-dependency-should-fail-job-2.yml b/changelogs/unreleased/feature-sm-34834-missing-dependency-should-fail-job-2.yml new file mode 100644 index 00000000000..ab85b8ee515 --- /dev/null +++ b/changelogs/unreleased/feature-sm-34834-missing-dependency-should-fail-job-2.yml @@ -0,0 +1,5 @@ +--- +title: Fail jobs if its dependency is missing +merge_request: 14009 +author: +type: fixed diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index f40d2c5e347..ef32e7658ee 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1106,7 +1106,8 @@ Note that `artifacts` from all previous [stages](#stages) are passed by default. To use this feature, define `dependencies` in context of the job and pass a list of all previous jobs from which the artifacts should be downloaded. You can only define jobs from stages that are executed before the current one. -An error will be shown if you define jobs from the current stage or next ones. +An error will be shown if you define jobs from the current stage or next ones, +or there are no depended jobs in previous stages. Defining an empty array will skip downloading any artifacts for that job. The status of the previous job is not considered when using `dependencies`, so if it failed or it is a manual job that was not run, no error occurs. From 6e343b27bfb993b2c19dd4b4fd8d2b48747fbac3 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Mon, 4 Sep 2017 17:56:49 +0900 Subject: [PATCH 014/112] Use Class.new(StandardError) instead of custom extended error class. Bring back specified_dependencies?. --- app/models/ci/build.rb | 10 ++++++++-- app/services/ci/register_job_service.rb | 2 +- lib/gitlab/ci/error/missing_dependencies.rb | 7 ------- spec/lib/gitlab/ci/error/missing_dependencies_spec.rb | 5 ----- spec/models/ci/build_spec.rb | 2 +- 5 files changed, 10 insertions(+), 16 deletions(-) delete mode 100644 lib/gitlab/ci/error/missing_dependencies.rb delete mode 100644 spec/lib/gitlab/ci/error/missing_dependencies_spec.rb diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 119c6fd7b45..965ba35c8b0 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -6,6 +6,8 @@ module Ci include Presentable include Importable + MissingDependenciesError = Class.new(StandardError) + belongs_to :runner belongs_to :trigger_request belongs_to :erased_by, class_name: 'User' @@ -141,8 +143,8 @@ module Ci end before_transition any => [:running] do |build| - if !build.empty_dependencies? && build.dependencies.empty? - raise Gitlab::Ci::Error::MissingDependencies + if build.specified_dependencies? && build.dependencies.empty? + raise MissingDependenciesError end end end @@ -484,6 +486,10 @@ module Ci options[:dependencies]&.empty? end + def specified_dependencies? + options.has_key?(:dependencies) && options[:dependencies].any? + end + def hide_secrets(trace) return unless trace diff --git a/app/services/ci/register_job_service.rb b/app/services/ci/register_job_service.rb index f73902935e6..c8b6450c9b5 100644 --- a/app/services/ci/register_job_service.rb +++ b/app/services/ci/register_job_service.rb @@ -54,7 +54,7 @@ module Ci # we still have to return 409 in the end, # to make sure that this is properly handled by runner. valid = false - rescue Gitlab::Ci::Error::MissingDependencies + rescue Ci::Build::MissingDependenciesError build.drop!(:missing_dependency_failure) valid = false end diff --git a/lib/gitlab/ci/error/missing_dependencies.rb b/lib/gitlab/ci/error/missing_dependencies.rb deleted file mode 100644 index f4b1940d84f..00000000000 --- a/lib/gitlab/ci/error/missing_dependencies.rb +++ /dev/null @@ -1,7 +0,0 @@ -module Gitlab - module Ci - module Error - class MissingDependencies < StandardError; end - end - end -end diff --git a/spec/lib/gitlab/ci/error/missing_dependencies_spec.rb b/spec/lib/gitlab/ci/error/missing_dependencies_spec.rb deleted file mode 100644 index 039a4776dc3..00000000000 --- a/spec/lib/gitlab/ci/error/missing_dependencies_spec.rb +++ /dev/null @@ -1,5 +0,0 @@ -require 'spec_helper' - -describe Gitlab::Ci::Error::MissingDependencies do - it { expect(described_class).to be < StandardError } -end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 61ac2dd78d1..f8d8b1800b8 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1893,7 +1893,7 @@ describe Ci::Build do end context 'when depended jobs do not exist' do - it { expect { build.run! }.to raise_error(Gitlab::Ci::Error::MissingDependencies) } + it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } end end end From c3e0731d2efc777018b668d9e0b7f8aa2377d9fc Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Tue, 5 Sep 2017 22:37:28 +0900 Subject: [PATCH 015/112] Add case when artifacts have not existed on dependencies --- app/models/ci/build.rb | 20 +++++++++++---- doc/ci/yaml/README.md | 2 +- spec/models/ci/build_spec.rb | 21 +++++++++++++++- spec/services/ci/register_job_service_spec.rb | 25 +++++++++++++++++++ 4 files changed, 61 insertions(+), 7 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 965ba35c8b0..9c44e9ef5fd 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -143,9 +143,7 @@ module Ci end before_transition any => [:running] do |build| - if build.specified_dependencies? && build.dependencies.empty? - raise MissingDependenciesError - end + build.validates_dependencies! end end @@ -486,8 +484,20 @@ module Ci options[:dependencies]&.empty? end - def specified_dependencies? - options.has_key?(:dependencies) && options[:dependencies].any? + def validates_dependencies! + dependencies.tap do |deps| + # When `dependencies` keyword is given and depended jobs are skipped by `only` keyword + if options[:dependencies]&.any? && deps.empty? + raise MissingDependenciesError + end + + # When artifacts of depended jobs have not existsed + deps.each do |dep| + if dep.options[:artifacts]&.any? && !dep.artifacts? + raise MissingDependenciesError + end + end + end end def hide_secrets(trace) diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index ef32e7658ee..f5391ff0768 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1107,7 +1107,7 @@ To use this feature, define `dependencies` in context of the job and pass a list of all previous jobs from which the artifacts should be downloaded. You can only define jobs from stages that are executed before the current one. An error will be shown if you define jobs from the current stage or next ones, -or there are no depended jobs in previous stages. +or there are no depended jobs with artifacts in previous stages. Defining an empty array will skip downloading any artifacts for that job. The status of the previous job is not considered when using `dependencies`, so if it failed or it is a manual job that was not run, no error occurs. diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index f8d8b1800b8..230546cf2da 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1887,9 +1887,28 @@ describe Ci::Build do let(:options) { { dependencies: ['test'] } } context 'when a depended job exists' do - let!(:pre_build) { create(:ci_build, pipeline: pipeline, name: 'test', stage_idx: 0) } + let!(:pre_stage_job) { create(:ci_build, pipeline: pipeline, name: 'test', stage_idx: 0) } it { expect { build.run! }.not_to raise_error } + + context 'when "artifacts" keyword is specified on depended job' do + let!(:pre_stage_job) do + create(:ci_build, :artifacts, pipeline: pipeline, name: 'test', stage_idx: 0, + options: { artifacts: { paths: ['binaries/'] } } ) + end + + context 'when artifacts of depended job has existsed' do + it { expect { build.run! }.not_to raise_error } + end + + context 'when artifacts of depended job has not existsed' do + before do + pre_stage_job.erase_artifacts! + end + + it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } + end + end end context 'when depended jobs do not exist' do diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index e779d02cc52..b5f88d6cdbe 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -291,6 +291,31 @@ module Ci it "picks the build" do expect(picked_job).to eq(pending_job) end + + context 'when "artifacts" keyword is specified on depended job' do + let!(:pre_stage_job) do + create(:ci_build, :success, :artifacts, pipeline: pipeline, name: job_name, stage_idx: 0, + options: { artifacts: { paths: ['binaries/'] } } ) + end + + context 'when artifacts of depended job has existsed' do + it "picks the build" do + expect(picked_job).to eq(pending_job) + end + end + + context 'when artifacts of depended job has not existsed' do + before do + pre_stage_job.erase_artifacts! + end + + it 'does not pick the build and drops the build' do + expect(picked_job).to be_nil + expect(pending_job.reload).to be_failed + expect(pending_job).to be_missing_dependency_failure + end + end + end end context 'when depended jobs do not exist' do From f5bfedc6122e00ed1182ad7caaeb7636eeebebe1 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Wed, 6 Sep 2017 00:51:05 +0900 Subject: [PATCH 016/112] Fix lint --- spec/models/ci/build_spec.rb | 8 ++++++-- spec/services/ci/register_job_service_spec.rb | 9 +++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 230546cf2da..6d33d0d917a 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1893,8 +1893,12 @@ describe Ci::Build do context 'when "artifacts" keyword is specified on depended job' do let!(:pre_stage_job) do - create(:ci_build, :artifacts, pipeline: pipeline, name: 'test', stage_idx: 0, - options: { artifacts: { paths: ['binaries/'] } } ) + create(:ci_build, + :artifacts, + pipeline: pipeline, + name: 'test', + stage_idx: 0, + options: { artifacts: { paths: ['binaries/'] } } ) end context 'when artifacts of depended job has existsed' do diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index b5f88d6cdbe..b86b9d7a42b 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -294,8 +294,13 @@ module Ci context 'when "artifacts" keyword is specified on depended job' do let!(:pre_stage_job) do - create(:ci_build, :success, :artifacts, pipeline: pipeline, name: job_name, stage_idx: 0, - options: { artifacts: { paths: ['binaries/'] } } ) + create(:ci_build, + :success, + :artifacts, + pipeline: pipeline, + name: job_name, + stage_idx: 0, + options: { artifacts: { paths: ['binaries/'] } } ) end context 'when artifacts of depended job has existsed' do From fba38b51b761cdc5edb964dc90eea051a33aab3e Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Mon, 27 Nov 2017 18:59:03 +0900 Subject: [PATCH 017/112] Add feature flag --- app/models/ci/build.rb | 2 ++ doc/ci/yaml/README.md | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 9c44e9ef5fd..cf666f86841 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -485,6 +485,8 @@ module Ci end def validates_dependencies! + return unless Feature.enabled?('ci_validates_dependencies') + dependencies.tap do |deps| # When `dependencies` keyword is given and depended jobs are skipped by `only` keyword if options[:dependencies]&.any? && deps.empty? diff --git a/doc/ci/yaml/README.md b/doc/ci/yaml/README.md index f5391ff0768..ea151853f50 100644 --- a/doc/ci/yaml/README.md +++ b/doc/ci/yaml/README.md @@ -1106,8 +1106,7 @@ Note that `artifacts` from all previous [stages](#stages) are passed by default. To use this feature, define `dependencies` in context of the job and pass a list of all previous jobs from which the artifacts should be downloaded. You can only define jobs from stages that are executed before the current one. -An error will be shown if you define jobs from the current stage or next ones, -or there are no depended jobs with artifacts in previous stages. +An error will be shown if you define jobs from the current stage or next ones. Defining an empty array will skip downloading any artifacts for that job. The status of the previous job is not considered when using `dependencies`, so if it failed or it is a manual job that was not run, no error occurs. @@ -1154,6 +1153,22 @@ deploy: script: make deploy ``` +### Validations for `dependencies` keyword + +> Introduced in GitLab 10.3 + +`dependencies` keyword doesn't check the depended `artifacts` strictly. Therefore +they do not fail even though it falls into the following conditions. + +1. A depended `artifacts` has been [erased](https://docs.gitlab.com/ee/api/jobs.html#erase-a-job). +1. A depended `artifacts` has been [expired](https://docs.gitlab.com/ee/ci/yaml/#artifacts-expire_in). + +To validate those conditions, you can flip the feature flag from a rails console: + +``` +Feature.enable('ci_validates_dependencies') +``` + ### before_script and after_script It's possible to overwrite the globally defined `before_script` and `after_script`: From 38d46754be49f13c1f92fd1f79ff49c76ec55c49 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Sat, 2 Dec 2017 16:14:47 +0900 Subject: [PATCH 018/112] Optimize valid_dependency method by ayufan thought --- app/models/ci/build.rb | 26 +++++++++++--------------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index cf666f86841..a29fb0ad2ca 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -143,7 +143,7 @@ module Ci end before_transition any => [:running] do |build| - build.validates_dependencies! + build.validates_dependencies! if Feature.enabled?('ci_validates_dependencies') end end @@ -485,20 +485,8 @@ module Ci end def validates_dependencies! - return unless Feature.enabled?('ci_validates_dependencies') - - dependencies.tap do |deps| - # When `dependencies` keyword is given and depended jobs are skipped by `only` keyword - if options[:dependencies]&.any? && deps.empty? - raise MissingDependenciesError - end - - # When artifacts of depended jobs have not existsed - deps.each do |dep| - if dep.options[:artifacts]&.any? && !dep.artifacts? - raise MissingDependenciesError - end - end + dependencies.each do |dependency| + raise MissingDependenciesError unless dependency.valid_dependency? end end @@ -612,5 +600,13 @@ module Ci update_project_statistics end end + + def valid_dependency? + return false unless complete? + return false if artifacts_expired? + return false if erased? + + true + end end end From 6171db2d2df337ef52460387a48f28136e809861 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Sat, 2 Dec 2017 16:23:19 +0900 Subject: [PATCH 019/112] Fix /build_spec.rb --- app/models/ci/build.rb | 16 ++++++++-------- spec/models/ci/build_spec.rb | 7 ++++++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index a29fb0ad2ca..fbda0962a91 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -490,6 +490,14 @@ module Ci end end + def valid_dependency? + return false unless complete? + return false if artifacts_expired? + return false if erased? + + true + end + def hide_secrets(trace) return unless trace @@ -600,13 +608,5 @@ module Ci update_project_statistics end end - - def valid_dependency? - return false unless complete? - return false if artifacts_expired? - return false if erased? - - true - end end end diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 6d33d0d917a..2cacf04a791 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1869,6 +1869,10 @@ describe Ci::Build do end describe 'state transition: any => [:running]' do + before do + stub_feature_flags(ci_validates_dependencies: true) + end + let(:build) { create(:ci_build, :pending, pipeline: pipeline, stage_idx: 1, options: options) } context 'when "dependencies" keyword is not defined' do @@ -1887,13 +1891,14 @@ describe Ci::Build do let(:options) { { dependencies: ['test'] } } context 'when a depended job exists' do - let!(:pre_stage_job) { create(:ci_build, pipeline: pipeline, name: 'test', stage_idx: 0) } + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) } it { expect { build.run! }.not_to raise_error } context 'when "artifacts" keyword is specified on depended job' do let!(:pre_stage_job) do create(:ci_build, + :success, :artifacts, pipeline: pipeline, name: 'test', From cd23850e0002f6c39b0c7f97c1dc484d7993af04 Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Wed, 6 Dec 2017 16:40:56 +0900 Subject: [PATCH 020/112] Fix tests --- spec/models/ci/build_spec.rb | 48 +++++++++++-------- spec/services/ci/register_job_service_spec.rb | 16 ++----- 2 files changed, 34 insertions(+), 30 deletions(-) diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 2cacf04a791..96d9fba2a2d 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -1891,11 +1891,7 @@ describe Ci::Build do let(:options) { { dependencies: ['test'] } } context 'when a depended job exists' do - let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) } - - it { expect { build.run! }.not_to raise_error } - - context 'when "artifacts" keyword is specified on depended job' do + context 'when depended job has artifacts' do let!(:pre_stage_job) do create(:ci_build, :success, @@ -1906,22 +1902,36 @@ describe Ci::Build do options: { artifacts: { paths: ['binaries/'] } } ) end - context 'when artifacts of depended job has existsed' do - it { expect { build.run! }.not_to raise_error } - end - - context 'when artifacts of depended job has not existsed' do - before do - pre_stage_job.erase_artifacts! - end - - it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } - end + it { expect { build.run! }.not_to raise_error } end - end - context 'when depended jobs do not exist' do - it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } + context 'when depended job does not have artifacts' do + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect { build.run! }.not_to raise_error } + end + + context 'when depended job has not been completed yet' do + let!(:pre_stage_job) { create(:ci_build, :running, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } + end + + context 'when artifacts of depended job has been expired' do + let!(:pre_stage_job) { create(:ci_build, :success, :expired, pipeline: pipeline, name: 'test', stage_idx: 0) } + + it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } + end + + context 'when artifacts of depended job has been erased' do + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: 'test', stage_idx: 0, erased_at: 1.minute.ago) } + + before do + pre_stage_job.erase + end + + it { expect { build.run! }.to raise_error(Ci::Build::MissingDependenciesError) } + end end end end diff --git a/spec/services/ci/register_job_service_spec.rb b/spec/services/ci/register_job_service_spec.rb index b86b9d7a42b..19e78faa591 100644 --- a/spec/services/ci/register_job_service_spec.rb +++ b/spec/services/ci/register_job_service_spec.rb @@ -277,6 +277,10 @@ module Ci end context 'when "dependencies" keyword is specified' do + before do + stub_feature_flags(ci_validates_dependencies: true) + end + let!(:pre_stage_job) { create(:ci_build, :success, pipeline: pipeline, name: job_name, stage_idx: 0) } let!(:pending_job) do @@ -311,7 +315,7 @@ module Ci context 'when artifacts of depended job has not existsed' do before do - pre_stage_job.erase_artifacts! + pre_stage_job.erase end it 'does not pick the build and drops the build' do @@ -322,16 +326,6 @@ module Ci end end end - - context 'when depended jobs do not exist' do - let(:job_name) { 'robocop' } - - it 'does not pick the build and drops the build' do - expect(picked_job).to be_nil - expect(pending_job.reload).to be_failed - expect(pending_job).to be_missing_dependency_failure - end - end end def execute(runner) From 3b11df7bee6d574488a394b46ced22a824d5081d Mon Sep 17 00:00:00 2001 From: Shinya Maeda Date: Wed, 6 Dec 2017 21:24:11 +0900 Subject: [PATCH 021/112] Fix pipeline --- spec/models/ci/pipeline_spec.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb index d4b1e7c8dd4..bb89e093890 100644 --- a/spec/models/ci/pipeline_spec.rb +++ b/spec/models/ci/pipeline_spec.rb @@ -1244,7 +1244,7 @@ describe Ci::Pipeline, :mailer do describe '#execute_hooks' do let!(:build_a) { create_build('a', 0) } - let!(:build_b) { create_build('b', 1) } + let!(:build_b) { create_build('b', 0) } let!(:hook) do create(:project_hook, project: project, pipeline_events: enabled) @@ -1300,6 +1300,8 @@ describe Ci::Pipeline, :mailer do end context 'when stage one failed' do + let!(:build_b) { create_build('b', 1) } + before do build_a.drop end From dd0223f530a0616ca6c0e40db3860f61322b6553 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Wed, 6 Dec 2017 16:30:03 +0100 Subject: [PATCH 022/112] Address Robert's and Axil's feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- PROCESS.md | 2 +- doc/development/automatic_ce_ee_merge.md | 26 +++++++++++++----------- doc/development/ee_features.md | 2 +- doc/development/writing_documentation.md | 2 +- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/PROCESS.md b/PROCESS.md index 758e773ae4d..3fcf676b302 100644 --- a/PROCESS.md +++ b/PROCESS.md @@ -131,7 +131,7 @@ request, even if there are no conflicts. This is to reduce the size of the subsequent EE merge, as we often merge a lot to CE on the release date. For more information, see [Automatic CE->EE merge][automatic_ce_ee_merge] and -[Guidelines for implementing Enterprise Edition feature][ee_features]. +[Guidelines for implementing Enterprise Edition features][ee_features]. ### After the 7th diff --git a/doc/development/automatic_ce_ee_merge.md b/doc/development/automatic_ce_ee_merge.md index 1a2a968ef54..9e59ddc8cce 100644 --- a/doc/development/automatic_ce_ee_merge.md +++ b/doc/development/automatic_ce_ee_merge.md @@ -5,7 +5,6 @@ Enterprise Edition (look for the [`CE Upstream` merge requests]). This merge is done automatically in a [scheduled pipeline](https://gitlab.com/gitlab-org/release-tools/-/jobs/43201679). - If a merge is already in progress, the job [doesn't create a new one](https://gitlab.com/gitlab-org/release-tools/-/jobs/43157687). **If you are pinged in a `CE Upstream` merge request to resolve a conflict, @@ -16,10 +15,12 @@ It's ok to resolve more conflicts than the one that you are asked to resolve. In that case, it's a good habit to ask for a double-check on your resolution by someone who is familiar with the code you touched. -### Always merge EE merge request before their CE counterpart +[`CE Upstream` merge requests]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests?label_name%5B%5D=CE+upstream + +### Always merge EE merge requests before their CE counterparts **In order to avoid conflicts in the CE->EE merge, you should always merge the -EE version of your CE merge request (if present).** +EE version of your CE merge request first, if present.** The rationale for this is that as CE->EE merges are done automatically every few hours, it can happen that: @@ -30,26 +31,27 @@ hours, it can happen that: merged yet 1. The automatic merge bot will ping someone to resolve the conflict **that are already resolved in the EE merge request that isn't merged yet** -1. That's a waste of time, and that's why you should merge EE merge request - before their CE counterpart + +That's a waste of time, and that's why you should merge EE merge request before +their CE counterpart. ## Avoiding CE->EE merge conflicts beforehand To avoid the conflicts beforehand, check out the -[Guidelines for implementing Enterprise Edition feature](ee_features.md). +[Guidelines for implementing Enterprise Edition features](ee_features.md). In any case, the CI `ee_compat_check` job will tell you if you need to open an EE version of your CE merge request. ### Conflicts detection in CE merge requests -For each commit (except on `master`), the `rake ee_compat_check` CI job tries to +For each commit (except on `master`), the `ee_compat_check` CI job tries to detect if the current branch's changes will conflict during the CE->EE merge. The job reports what files are conflicting and how to setup a merge request against EE. -#### How the job works? +#### How the job works 1. Generates the diff between your branch and current CE `master` 1. Tries to apply it to current EE `master` @@ -69,9 +71,9 @@ Notes: - This task is not a silver-bullet, its current goal is to bring awareness to developers that their work needs to be ported to EE. -- Community contributors shouldn't submit merge requests against EE, but - reviewers should take actions by either creating such EE merge request or - asking a GitLab developer to do it **before the merge request is merged**. +- Community contributors shouldn't be required to submit merge requests against + EE, but reviewers should take actions by either creating such EE merge request + or asking a GitLab developer to do it **before the merge request is merged**. - If you branch is too far behind `master`, the job will fail. In that case you should rebase your branch upon latest `master`. - Code reviews for merge requests often consist of multiple iterations of @@ -82,7 +84,7 @@ Notes: This helps to identify significant conflicts sooner, but also reduces the number of times you have to resolve conflicts. - Please remember to - [always have you EE merge request merged before the CE one](#always-merge-ee-merge-request-before-their-ce-counterpart). + [always have your EE merge request merged before the CE version](#always-merge-ee-merge-requests-before-their-ce-counterparts). - You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html) to avoid resolving the same conflicts multiple times. diff --git a/doc/development/ee_features.md b/doc/development/ee_features.md index eb33d17443a..1af839a27e1 100644 --- a/doc/development/ee_features.md +++ b/doc/development/ee_features.md @@ -1,4 +1,4 @@ -# Guidelines for implementing Enterprise Edition feature +# Guidelines for implementing Enterprise Edition features - **Write the code and the tests.**: As with any code, EE features should have good test coverage to prevent regressions. diff --git a/doc/development/writing_documentation.md b/doc/development/writing_documentation.md index b6def7ef541..48e04a40050 100644 --- a/doc/development/writing_documentation.md +++ b/doc/development/writing_documentation.md @@ -142,7 +142,7 @@ tests. If it doesn't, the whole test suite will run (including docs). --- When you submit a merge request to GitLab Community Edition (CE), there is an -additional job called `rake ee_compat_check` that runs against Enterprise +additional job called `ee_compat_check` that runs against Enterprise Edition (EE) and checks if your changes can apply cleanly to the EE codebase. If that job fails, read the instructions in the job log for what to do next. Contributors do not need to submit their changes to EE, GitLab Inc. employees From 41e045a1aedf8be472f97763d44c2e28ad53765c Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Wed, 6 Dec 2017 16:20:40 +0000 Subject: [PATCH 023/112] Fixed merge request locked icon size Closes #40876 --- app/views/projects/_md_preview.html.haml | 2 +- app/views/shared/notes/_notes_with_form.html.haml | 2 +- changelogs/unreleased/merge-request-lock-icon-size-fix.yml | 5 +++++ 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 changelogs/unreleased/merge-request-lock-icon-size-fix.yml diff --git a/app/views/projects/_md_preview.html.haml b/app/views/projects/_md_preview.html.haml index 2cd5d0c60ea..c5e3a7945bd 100644 --- a/app/views/projects/_md_preview.html.haml +++ b/app/views/projects/_md_preview.html.haml @@ -2,7 +2,7 @@ - if defined?(@merge_request) && @merge_request.discussion_locked? .issuable-note-warning - = icon('lock', class: 'icon') + = sprite_icon('lock', size: 16, css_class: 'icon') %span = _('This merge request is locked.') = _('Only project members can comment.') diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index c6e18108c7a..e11f778adf5 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -27,7 +27,7 @@ - elsif discussion_locked .disabled-comment.text-center.prepend-top-default %span.issuable-note-warning - %span.icon= sprite_icon('lock', size: 14) + = sprite_icon('lock', size: 16, css_class: 'icon') %span This = issuable.class.to_s.titleize.downcase diff --git a/changelogs/unreleased/merge-request-lock-icon-size-fix.yml b/changelogs/unreleased/merge-request-lock-icon-size-fix.yml new file mode 100644 index 00000000000..09c059a3011 --- /dev/null +++ b/changelogs/unreleased/merge-request-lock-icon-size-fix.yml @@ -0,0 +1,5 @@ +--- +title: Fixed merge request lock icon size +merge_request: +author: +type: fixed From cd03bf847d0edff179317818e95efb4551dabecf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?R=C3=A9my=20Coutable?= Date: Wed, 6 Dec 2017 17:23:53 +0100 Subject: [PATCH 024/112] Add "Guidelines for implementing Enterprise Edition features" in CE development doc since the doc page is already there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Rémy Coutable --- doc/development/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/doc/development/README.md b/doc/development/README.md index 944880d8ac7..7e4c767692a 100644 --- a/doc/development/README.md +++ b/doc/development/README.md @@ -17,6 +17,7 @@ comments: false - [Generate a changelog entry with `bin/changelog`](changelog.md) - [Code review guidelines](code_review.md) for reviewing code and having code reviewed. - [Automatic CE->EE merge](automatic_ce_ee_merge.md) +- [Guidelines for implementing Enterprise Edition features](ee_features.md) ## UX and frontend guides From 307e024c12b34cac66bbd13052d936a443398564 Mon Sep 17 00:00:00 2001 From: Phil Hughes Date: Thu, 7 Dec 2017 08:48:17 +0000 Subject: [PATCH 025/112] Fixed remove deploy key loading icon not being removed after canceling Closes #37595 --- .../deploy_keys/components/action_btn.vue | 9 +++++++-- .../javascripts/deploy_keys/components/app.vue | 5 ++++- changelogs/unreleased/deploy-keys-loading-icon.yml | 5 +++++ .../deploy_keys/components/action_btn_spec.js | 2 +- .../javascripts/deploy_keys/components/app_spec.js | 14 ++++++++++++++ 5 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 changelogs/unreleased/deploy-keys-loading-icon.yml diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index 3f993213dd0..f9f2f9bf693 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -32,7 +32,9 @@ doAction() { this.isLoading = true; - eventHub.$emit(`${this.type}.key`, this.deployKey); + eventHub.$emit(`${this.type}.key`, this.deployKey, () => { + this.isLoading = false; + }); }, }, computed: { @@ -50,6 +52,9 @@ :disabled="isLoading" @click="doAction"> {{ text }} - + diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 54e13b79a4f..fe046449054 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -47,12 +47,15 @@ .then(() => this.fetchKeys()) .catch(() => new Flash('Error enabling deploy key')); }, - disableKey(deployKey) { + disableKey(deployKey, callback) { // eslint-disable-next-line no-alert if (confirm('You are going to remove this deploy key. Are you sure?')) { this.service.disableKey(deployKey.id) .then(() => this.fetchKeys()) + .then(callback) .catch(() => new Flash('Error removing deploy key')); + } else { + callback(); } }, }, diff --git a/changelogs/unreleased/deploy-keys-loading-icon.yml b/changelogs/unreleased/deploy-keys-loading-icon.yml new file mode 100644 index 00000000000..e3cb5bc6924 --- /dev/null +++ b/changelogs/unreleased/deploy-keys-loading-icon.yml @@ -0,0 +1,5 @@ +--- +title: Fixed deploy keys remove button loading state not resetting +merge_request: +author: +type: fixed diff --git a/spec/javascripts/deploy_keys/components/action_btn_spec.js b/spec/javascripts/deploy_keys/components/action_btn_spec.js index 5b93fbc5575..7025c3d836c 100644 --- a/spec/javascripts/deploy_keys/components/action_btn_spec.js +++ b/spec/javascripts/deploy_keys/components/action_btn_spec.js @@ -34,7 +34,7 @@ describe('Deploy keys action btn', () => { setTimeout(() => { expect( eventHub.$emit, - ).toHaveBeenCalledWith('enable.key', deployKey); + ).toHaveBeenCalledWith('enable.key', deployKey, jasmine.anything()); done(); }); diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js index 700897f50b0..0ca9290d3d2 100644 --- a/spec/javascripts/deploy_keys/components/app_spec.js +++ b/spec/javascripts/deploy_keys/components/app_spec.js @@ -139,4 +139,18 @@ describe('Deploy keys app component', () => { it('hasKeys returns true when there are keys', () => { expect(vm.hasKeys).toEqual(3); }); + + it('resets remove button loading state', (done) => { + spyOn(window, 'confirm').and.returnValue(false); + + const btn = vm.$el.querySelector('.btn-warning'); + + btn.click(); + + Vue.nextTick(() => { + expect(btn.querySelector('.fa')).toBeNull(); + + done(); + }); + }); }); From 77c9632e9cbc9d424934852faf61d8dea322b6d0 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Mon, 27 Nov 2017 17:19:17 -0200 Subject: [PATCH 026/112] Turn push file into a scenario --- qa/qa.rb | 4 ++ qa/qa/scenario/gitlab/repository/push.rb | 40 ++++++++++++++++++++ qa/qa/specs/features/repository/push_spec.rb | 19 ++-------- 3 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 qa/qa/scenario/gitlab/repository/push.rb diff --git a/qa/qa.rb b/qa/qa.rb index 06b6a76489b..4cbcf585030 100644 --- a/qa/qa.rb +++ b/qa/qa.rb @@ -46,6 +46,10 @@ module QA autoload :Create, 'qa/scenario/gitlab/project/create' end + module Repository + autoload :Push, 'qa/scenario/gitlab/repository/push' + end + module Sandbox autoload :Prepare, 'qa/scenario/gitlab/sandbox/prepare' end diff --git a/qa/qa/scenario/gitlab/repository/push.rb b/qa/qa/scenario/gitlab/repository/push.rb new file mode 100644 index 00000000000..bc5b6307d13 --- /dev/null +++ b/qa/qa/scenario/gitlab/repository/push.rb @@ -0,0 +1,40 @@ +require "pry-byebug" + +module QA + module Scenario + module Gitlab + module Repository + class Push < Scenario::Template + attr_writer :file_name, + :file_content, + :commit_message, + :branch_name + + def initialize + @file_name = 'file.txt' + @file_content = '# This is test project' + @commit_message = "Add #{@file_name}" + @branch_name = 'master' + end + + def perform + Git::Repository.perform do |repository| + repository.location = Page::Project::Show.act do + choose_repository_clone_http + repository_location + end + + repository.use_default_credentials + repository.clone + repository.configure_identity('GitLab QA', 'root@gitlab.com') + + repository.add_file(@file_name, @file_content) + repository.commit(@commit_message) + repository.push_changes(@branch_name) + end + end + end + end + end + end +end diff --git a/qa/qa/specs/features/repository/push_spec.rb b/qa/qa/specs/features/repository/push_spec.rb index 30935dc1e13..5b930b9818a 100644 --- a/qa/qa/specs/features/repository/push_spec.rb +++ b/qa/qa/specs/features/repository/push_spec.rb @@ -10,21 +10,10 @@ module QA scenario.description = 'project with repository' end - Git::Repository.perform do |repository| - repository.location = Page::Project::Show.act do - choose_repository_clone_http - repository_location - end - - repository.use_default_credentials - - repository.act do - clone - configure_identity('GitLab QA', 'root@gitlab.com') - add_file('README.md', '# This is test project') - commit('Add README.md') - push_changes - end + Scenario::Gitlab::Repository::Push.perform do |scenario| + scenario.file_name = 'README.md' + scenario.file_content = '# This is test project' + scenario.commit_message = 'Add README.md' end Page::Project::Show.act do From e415855c6a7c97e6540d4776dd880fa5bfbdf0b6 Mon Sep 17 00:00:00 2001 From: Felipe Artur Date: Wed, 6 Dec 2017 15:02:00 -0200 Subject: [PATCH 027/112] Check if user is in project page before performing a push --- qa/qa/scenario/gitlab/repository/push.rb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/qa/qa/scenario/gitlab/repository/push.rb b/qa/qa/scenario/gitlab/repository/push.rb index bc5b6307d13..b00ab0c313a 100644 --- a/qa/qa/scenario/gitlab/repository/push.rb +++ b/qa/qa/scenario/gitlab/repository/push.rb @@ -5,6 +5,9 @@ module QA module Gitlab module Repository class Push < Scenario::Template + PAGE_REGEX_CHECK = + %r{\/#{Runtime::Namespace.sandbox_name}\/qa-test[^\/]+\/{1}[^\/]+\z}.freeze + attr_writer :file_name, :file_content, :commit_message, @@ -20,6 +23,10 @@ module QA def perform Git::Repository.perform do |repository| repository.location = Page::Project::Show.act do + unless PAGE_REGEX_CHECK.match(current_path) + raise "To perform this scenario the current page should be project show." + end + choose_repository_clone_http repository_location end From 9d6630a0b4aff209b514b49db70bc14891c7c613 Mon Sep 17 00:00:00 2001 From: Grzegorz Bizon Date: Thu, 7 Dec 2017 14:31:39 +0100 Subject: [PATCH 028/112] Fix QA group creation by filling required fileds --- qa/qa/page/group/new.rb | 1 + 1 file changed, 1 insertion(+) diff --git a/qa/qa/page/group/new.rb b/qa/qa/page/group/new.rb index cb743a7bf11..53fdaaed078 100644 --- a/qa/qa/page/group/new.rb +++ b/qa/qa/page/group/new.rb @@ -4,6 +4,7 @@ module QA class New < Page::Base def set_path(path) fill_in 'group_path', with: path + fill_in 'group_name', with: path end def set_description(description) From e4eba908cd85c3ad7b9861c3edbd3c81623242a0 Mon Sep 17 00:00:00 2001 From: Douwe Maan Date: Thu, 29 Jun 2017 16:19:09 -0500 Subject: [PATCH 029/112] Allow commenting on individual commits inside an MR --- .../diff_notes/diff_notes_bundle.js | 3 +- .../diff_notes/services/resolve.js | 2 +- app/controllers/projects/commit_controller.rb | 17 ++++++++ .../merge_requests/diffs_controller.rb | 41 ++++++++++++------- app/helpers/commits_helper.rb | 8 ++++ app/models/concerns/discussion_on_diff.rb | 4 ++ app/models/diff_discussion.rb | 6 ++- app/models/diff_note.rb | 7 ++++ app/models/discussion.rb | 1 + app/models/note.rb | 6 ++- app/services/system_note_service.rb | 15 +++++-- app/views/discussions/_discussion.html.haml | 14 +++++-- .../projects/commit/_commit_box.html.haml | 12 +++++- app/views/projects/commit/show.html.haml | 3 ++ app/views/projects/commits/_commit.html.haml | 24 ++++++++--- app/views/projects/commits/_commits.html.haml | 7 +++- .../merge_requests/_commits.html.haml | 2 +- .../diffs/_commit_widget.html.haml | 5 +++ .../diffs/_different_base.html.haml | 11 +++++ .../merge_requests/diffs/_diffs.html.haml | 15 ++++--- .../_not_all_comments_displayed.html.haml | 19 +++++++++ ....html.haml => _version_controls.html.haml} | 26 +----------- .../projects/merge_requests/show.html.haml | 2 +- 23 files changed, 185 insertions(+), 65 deletions(-) create mode 100644 app/views/projects/merge_requests/diffs/_commit_widget.html.haml create mode 100644 app/views/projects/merge_requests/diffs/_different_base.html.haml create mode 100644 app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml rename app/views/projects/merge_requests/diffs/{_versions.html.haml => _version_controls.html.haml} (79%) diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js b/app/assets/javascripts/diff_notes/diff_notes_bundle.js index 0863c3406bd..2f22361d6d2 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js @@ -16,7 +16,8 @@ import './components/diff_note_avatars'; import './components/new_issue_for_discussion'; $(() => { - const projectPath = document.querySelector('.merge-request').dataset.projectPath; + const projectPathHolder = document.querySelector('.merge-request') || document.querySelector('.commit-box') + const projectPath = projectPathHolder.dataset.projectPath; const COMPONENT_SELECTOR = 'resolve-btn, resolve-discussion-btn, jump-to-discussion, comment-and-resolve-btn, new-issue-for-discussion-btn'; window.gl = window.gl || {}; diff --git a/app/assets/javascripts/diff_notes/services/resolve.js b/app/assets/javascripts/diff_notes/services/resolve.js index 6eae54f830b..96fe23640af 100644 --- a/app/assets/javascripts/diff_notes/services/resolve.js +++ b/app/assets/javascripts/diff_notes/services/resolve.js @@ -43,7 +43,7 @@ class ResolveServiceClass { discussion.resolveAllNotes(resolvedBy); } - gl.mrWidget.checkStatus(); + if (gl.mrWidget) gl.mrWidget.checkStatus(); discussion.updateHeadline(data); }) .catch(() => new Flash('An error occurred when trying to resolve a discussion. Please try again.')); diff --git a/app/controllers/projects/commit_controller.rb b/app/controllers/projects/commit_controller.rb index 6ff96a3f295..2e7344b1cad 100644 --- a/app/controllers/projects/commit_controller.rb +++ b/app/controllers/projects/commit_controller.rb @@ -134,6 +134,23 @@ class Projects::CommitController < Projects::ApplicationController @grouped_diff_discussions = commit.grouped_diff_discussions @discussions = commit.discussions + if merge_request_iid = params[:merge_request_iid] + @merge_request = MergeRequestsFinder.new(current_user, project_id: @project.id).find_by(iid: merge_request_iid) + + if @merge_request + @new_diff_note_attrs.merge!( + noteable_type: 'MergeRequest', + noteable_id: @merge_request.id + ) + + merge_request_commit_notes = @merge_request.notes.where(commit_id: @commit.id).inc_relations_for_view + merge_request_commit_diff_discussions = merge_request_commit_notes.grouped_diff_discussions(@commit.diff_refs) + @grouped_diff_discussions.merge!(merge_request_commit_diff_discussions) do |line_code, left, right| + left + right + end + end + end + @notes = (@grouped_diff_discussions.values.flatten + @discussions).flat_map(&:notes) @notes = prepare_notes_for_rendering(@notes, @commit) end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 9f966889995..42a6e5be14f 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -20,18 +20,33 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic private def define_diff_vars + @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff.order_id_desc + + if commit_id = params[:commit_id].presence + @commit = @merge_request.target_project.commit(commit_id) + @compare = @commit + else + @compare = find_merge_request_diff_compare + end + + return render_404 unless @compare + + @diffs = @compare.diffs(diff_options) + end + + def find_merge_request_diff_compare @merge_request_diff = - if params[:diff_id] - @merge_request.merge_request_diffs.viewable.find(params[:diff_id]) + if diff_id = params[:diff_id].presence + @merge_request.merge_request_diffs.viewable.find_by(id: diff_id) else @merge_request.merge_request_diff end - @merge_request_diffs = @merge_request.merge_request_diffs.viewable.order_id_desc + return unless @merge_request_diff + @comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id } - if params[:start_sha].present? - @start_sha = params[:start_sha] + if @start_sha = params[:start_sha].presence @start_version = @comparable_diffs.find { |diff| diff.head_commit_sha == @start_sha } unless @start_version @@ -40,20 +55,18 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic end end - @compare = - if @start_sha - @merge_request_diff.compare_with(@start_sha) - else - @merge_request_diff - end - - @diffs = @compare.diffs(diff_options) + if @start_sha + @merge_request_diff.compare_with(@start_sha) + else + @merge_request_diff + end end def define_diff_comment_vars @new_diff_note_attrs = { noteable_type: 'MergeRequest', - noteable_id: @merge_request.id + noteable_id: @merge_request.id, + commit_id: @commit&.id } @diff_notes_disabled = false diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index f68e2cd3afa..361d56b211c 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -228,4 +228,12 @@ module CommitsHelper [commits, 0] end end + + def commit_path(project, commit, merge_request: nil) + if merge_request&.persisted? + diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, commit_id: commit.id) + else + namespace_project_commit_path(project.namespace, project, commit.id) + end + end end diff --git a/app/models/concerns/discussion_on_diff.rb b/app/models/concerns/discussion_on_diff.rb index f5cbb3becad..4b4d519f3df 100644 --- a/app/models/concerns/discussion_on_diff.rb +++ b/app/models/concerns/discussion_on_diff.rb @@ -32,6 +32,10 @@ module DiscussionOnDiff first_note.position.new_path end + def on_merge_request_commit? + for_merge_request? && commit_id.present? + end + # Returns an array of at most 16 highlighted lines above a diff note def truncated_diff_lines(highlight: true) lines = highlight ? highlighted_diff_lines : diff_lines diff --git a/app/models/diff_discussion.rb b/app/models/diff_discussion.rb index 6eba87da1a1..4a65738214b 100644 --- a/app/models/diff_discussion.rb +++ b/app/models/diff_discussion.rb @@ -24,7 +24,11 @@ class DiffDiscussion < Discussion return unless for_merge_request? return {} if active? - noteable.version_params_for(position.diff_refs) + if on_merge_request_commit? + { commit_id: commit_id } + else + noteable.version_params_for(position.diff_refs) + end end def reply_attributes diff --git a/app/models/diff_note.rb b/app/models/diff_note.rb index ae5f138a920..b53d44cda95 100644 --- a/app/models/diff_note.rb +++ b/app/models/diff_note.rb @@ -17,6 +17,7 @@ class DiffNote < Note validates :noteable_type, inclusion: { in: NOTEABLE_TYPES } validate :positions_complete validate :verify_supported + validate :diff_refs_match_commit, if: :for_commit? before_validation :set_original_position, on: :create before_validation :update_position, on: :create, if: :on_text? @@ -135,6 +136,12 @@ class DiffNote < Note errors.add(:position, "is invalid") end + def diff_refs_match_commit + return if self.original_position.diff_refs == self.commit.diff_refs + + errors.add(:commit_id, 'does not match the diff refs') + end + def keep_around_commits project.repository.keep_around(self.original_position.base_sha) project.repository.keep_around(self.original_position.start_sha) diff --git a/app/models/discussion.rb b/app/models/discussion.rb index 437df923d2d..92482a1a875 100644 --- a/app/models/discussion.rb +++ b/app/models/discussion.rb @@ -11,6 +11,7 @@ class Discussion :author, :noteable, + :commit_id, :for_commit?, :for_merge_request?, diff --git a/app/models/note.rb b/app/models/note.rb index 733bbbc013f..1357e75d907 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -230,10 +230,14 @@ class Note < ActiveRecord::Base for_personal_snippet? end + def commit + project.commit(commit_id) if commit_id.present? + end + # override to return commits, which are not active record def noteable if for_commit? - @commit ||= project.commit(commit_id) + @commit ||= commit else super end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 30a5aab13bf..f90c6fcafb8 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -23,7 +23,7 @@ module SystemNoteService body = "added #{commits_text}\n\n" body << existing_commit_summary(noteable, existing_commits, oldrev) - body << new_commit_summary(new_commits).join("\n") + body << new_commit_summary(noteable, new_commits).join("\n") body << "\n\n[Compare with previous version](#{diff_comparison_url(noteable, project, oldrev)})" create_note(NoteSummary.new(noteable, project, author, body, action: 'commit', commit_count: total_count)) @@ -486,9 +486,9 @@ module SystemNoteService # new_commits - Array of new Commit objects # # Returns an Array of Strings - def new_commit_summary(new_commits) + def new_commit_summary(merge_request, new_commits) new_commits.collect do |commit| - "* #{commit.short_id} - #{escape_html(commit.title)}" + "* [#{commit.short_id}](#{merge_request_commit_url(merge_request, commit)}) - #{escape_html(commit.title)}" end end @@ -668,4 +668,13 @@ module SystemNoteService start_sha: oldrev ) end + + def merge_request_commit_url(merge_request, commit) + url_helpers.diffs_namespace_project_merge_request_url( + project.namespace, + project, + merge_request.iid, + commit_id: commit.id + ) + end end diff --git a/app/views/discussions/_discussion.html.haml b/app/views/discussions/_discussion.html.haml index 0f03163a2e8..205320ed87c 100644 --- a/app/views/discussions/_discussion.html.haml +++ b/app/views/discussions/_discussion.html.haml @@ -32,9 +32,17 @@ - elsif discussion.diff_discussion? on = conditional_link_to url.present?, url do - - unless discussion.active? - an old version of - the diff + - if discussion.on_merge_request_commit? + - unless discussion.active? + an outdated change in + commit + + %span.commit-sha= Commit.truncate_sha(discussion.commit_id) + - else + - unless discussion.active? + an old version of + the diff + = time_ago_with_tooltip(discussion.created_at, placement: "bottom", html_class: "note-created-ago") = render "discussions/headline", discussion: discussion diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 5f607c2ab25..f2414d43578 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -47,7 +47,7 @@ %li= link_to s_("DownloadCommit|Email Patches"), project_commit_path(@project, @commit, format: :patch) %li= link_to s_("DownloadCommit|Plain Diff"), project_commit_path(@project, @commit, format: :diff) -.commit-box +.commit-box{ data: { project_path: project_path(@project) } } %h3.commit-title = markdown(@commit.title, pipeline: :single_line, author: @commit.author) - if @commit.description.present? @@ -80,3 +80,13 @@ - if last_pipeline.duration in = time_interval_in_words last_pipeline.duration + + - if @merge_request + .well-segment + = icon('info-circle fw') + + This commit is part of merge request + = succeed '.' do + = link_to @merge_request.to_reference, namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + + Comments created here will be created in the context of that merge request. diff --git a/app/views/projects/commit/show.html.haml b/app/views/projects/commit/show.html.haml index abb292f8f27..2890e9d2b65 100644 --- a/app/views/projects/commit/show.html.haml +++ b/app/views/projects/commit/show.html.haml @@ -6,6 +6,9 @@ - @content_class = limited_container_width - page_title "#{@commit.title} (#{@commit.short_id})", "Commits" - page_description @commit.description +- content_for :page_specific_javascripts do + = page_specific_javascript_bundle_tag('common_vue') + = page_specific_javascript_bundle_tag('diff_notes') .container-fluid{ class: [limited_container_width, container_class] } = render "commit_box" diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 1b91a94a9f8..022ded21362 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -1,6 +1,16 @@ -- ref = local_assigns.fetch(:ref) +- view_details = local_assigns.fetch(:view_details, false) +- merge_request = local_assigns.fetch(:merge_request, nil) +- project = local_assigns.fetch(:project) { merge_request&.project } +- ref = local_assigns.fetch(:ref) { merge_request&.source_branch } +- link = commit_path(project, commit, merge_request: merge_request) -- cache_key = [project.full_path, commit.id, current_application_settings, @path.presence, current_controller?(:commits), I18n.locale] +- if @note_counts + - note_count = @note_counts.fetch(commit.id, 0) +- else + - notes = commit.notes + - note_count = notes.user.count + +- cache_key = [project.full_path, commit.id, current_application_settings, note_count, @path.presence, current_controller?(:commits), merge_request, I18n.locale] - cache_key.push(commit.status(ref)) if commit.status(ref) = cache(cache_key, expires_in: 1.day) do @@ -11,7 +21,7 @@ .commit-detail .commit-content - = link_to_markdown_field(commit, :title, project_commit_path(project, commit.id), class: "commit-row-message item-title") + = link_to_markdown_field(commit, :title, link, class: "commit-row-message item-title") %span.commit-row-message.visible-xs-inline · = commit.short_id @@ -31,8 +41,7 @@ - commit_text = _('%{commit_author_link} committed %{commit_timeago}') % { commit_author_link: commit_author_link, commit_timeago: commit_timeago } #{ commit_text.html_safe } - - .commit-actions.hidden-xs + .commit-actions.flex-row.hidden-xs - if request.xhr? = render partial: 'projects/commit/signature', object: commit.signature - else @@ -41,6 +50,9 @@ - if commit.status(ref) = render_commit_status(commit, ref: ref) - = link_to commit.short_id, project_commit_path(project, commit), class: "commit-sha btn btn-transparent btn-link" + = link_to commit.short_id, link, class: "commit-sha btn btn-transparent btn-link" = clipboard_button(text: commit.id, title: _("Copy commit SHA to clipboard")) = link_to_browse_code(project, commit) + + - if view_details && merge_request + = link_to "View details", namespace_project_commit_path(project.namespace, project, commit.id, merge_request_iid: merge_request.iid), class: "btn btn-default" diff --git a/app/views/projects/commits/_commits.html.haml b/app/views/projects/commits/_commits.html.haml index d14897428d0..ac6852751be 100644 --- a/app/views/projects/commits/_commits.html.haml +++ b/app/views/projects/commits/_commits.html.haml @@ -1,4 +1,7 @@ -- ref = local_assigns.fetch(:ref) +- merge_request = local_assigns.fetch(:merge_request, nil) +- project = local_assigns.fetch(:project) { merge_request&.project } +- ref = local_assigns.fetch(:ref) { merge_request&.source_branch } + - commits, hidden = limited_commits(@commits) - commits.chunk { |c| c.committed_date.in_time_zone.to_date }.each do |day, commits| @@ -8,7 +11,7 @@ %li.commits-row{ data: { day: day } } %ul.content-list.commit-list.flex-list - = render partial: 'projects/commits/commit', collection: commits, locals: { project: project, ref: ref } + = render partial: 'projects/commits/commit', collection: commits, locals: { project: project, ref: ref, merge_request: merge_request } - if hidden > 0 %li.alert.alert-warning diff --git a/app/views/projects/merge_requests/_commits.html.haml b/app/views/projects/merge_requests/_commits.html.haml index 11793919ff7..b414518b597 100644 --- a/app/views/projects/merge_requests/_commits.html.haml +++ b/app/views/projects/merge_requests/_commits.html.haml @@ -5,4 +5,4 @@ = custom_icon ('illustration_no_commits') - else %ol#commits-list.list-unstyled - = render "projects/commits/commits", project: @merge_request.source_project, ref: @merge_request.source_branch + = render "projects/commits/commits", merge_request: @merge_request diff --git a/app/views/projects/merge_requests/diffs/_commit_widget.html.haml b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml new file mode 100644 index 00000000000..2e5594f8cbe --- /dev/null +++ b/app/views/projects/merge_requests/diffs/_commit_widget.html.haml @@ -0,0 +1,5 @@ +- if @commit + .info-well.hidden-xs.prepend-top-default + .well-segment + %ul.blob-commit-info + = render 'projects/commits/commit', commit: @commit, merge_request: @merge_request, view_details: true diff --git a/app/views/projects/merge_requests/diffs/_different_base.html.haml b/app/views/projects/merge_requests/diffs/_different_base.html.haml new file mode 100644 index 00000000000..aeeaa053c5f --- /dev/null +++ b/app/views/projects/merge_requests/diffs/_different_base.html.haml @@ -0,0 +1,11 @@ +- if @merge_request_diff && different_base?(@start_version, @merge_request_diff) + .mr-version-controls + .content-block + = icon('info-circle') + Selected versions have different base commits. + Changes will include + = link_to namespace_project_compare_path(@project.namespace, @project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do + new commits + from + = succeed '.' do + %code= @merge_request.target_branch diff --git a/app/views/projects/merge_requests/diffs/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml index 3d7a8f9d870..63f6c3e6716 100644 --- a/app/views/projects/merge_requests/diffs/_diffs.html.haml +++ b/app/views/projects/merge_requests/diffs/_diffs.html.haml @@ -1,7 +1,9 @@ -- if @merge_request_diff.collected? || @merge_request_diff.overflow? - = render 'projects/merge_requests/diffs/versions' - = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, merge_request: true -- elsif @merge_request_diff.empty? += render 'projects/merge_requests/diffs/version_controls' += render 'projects/merge_requests/diffs/different_base' += render 'projects/merge_requests/diffs/not_all_comments_displayed' += render 'projects/merge_requests/diffs/commit_widget' + +- if @merge_request_diff&.empty? .nothing-here-block = image_tag 'illustrations/merge_request_changes_empty.svg' %p @@ -9,5 +11,8 @@ %strong= @merge_request.source_branch into %strong= @merge_request.target_branch - %p= link_to 'Create commit', project_new_blob_path(@project, @merge_request.source_branch), class: 'btn btn-save' +- else + - diff_viewable = @merge_request_diff ? @merge_request_diff.collected? || @merge_request_diff.overflow? : true + - if diff_viewable + = render "projects/diffs/diffs", diffs: @diffs, environment: @environment, merge_request: true diff --git a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml new file mode 100644 index 00000000000..26988d34917 --- /dev/null +++ b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml @@ -0,0 +1,19 @@ +- if @commit || @start_version || (@merge_request_diff && !@merge_request_diff.latest?) + .mr-version-controls + .content-block.comments-disabled-notif + = icon('info-circle') + Not all comments are displayed because you're + = succeed '.' do + - if @commit + viewing only the changes in commit + + = link_to @commit.short_id, diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, commit_id: @commit.id), class: "commit-sha" + - elsif @start_version + comparing two versions of the diff + - else + viewing an old version of the diff + + .pull-right + = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' do + Show latest version + = "of the diff" if @commit diff --git a/app/views/projects/merge_requests/diffs/_versions.html.haml b/app/views/projects/merge_requests/diffs/_version_controls.html.haml similarity index 79% rename from app/views/projects/merge_requests/diffs/_versions.html.haml rename to app/views/projects/merge_requests/diffs/_version_controls.html.haml index 9f7152b9824..1c26f0405d2 100644 --- a/app/views/projects/merge_requests/diffs/_versions.html.haml +++ b/app/views/projects/merge_requests/diffs/_version_controls.html.haml @@ -1,4 +1,4 @@ -- if @merge_request_diffs.size > 1 +- if @merge_request_diff && @merge_request_diffs.size > 1 .mr-version-controls .mr-version-menus-container.content-block Changes between @@ -71,27 +71,3 @@ (base) %div %strong.commit-sha= short_sha(@merge_request_diff.base_commit_sha) - - - if different_base?(@start_version, @merge_request_diff) - .content-block - = icon('info-circle') - Selected versions have different base commits. - Changes will include - = link_to project_compare_path(@project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do - new commits - from - = succeed '.' do - %code= @merge_request.target_branch - - - if @start_version || !@merge_request_diff.latest? - .comments-disabled-notif.content-block - = icon('info-circle') - Not all comments are displayed because you're - - if @start_version - comparing two versions - - else - viewing an old version - of the diff. - - .pull-right - = link_to 'Show latest version', diffs_project_merge_request_path(@project, @merge_request), class: 'btn btn-sm' diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index d88e3d794d3..4e3f6f9edc9 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -8,7 +8,7 @@ = webpack_bundle_tag('common_vue') = webpack_bundle_tag('diff_notes') -.merge-request{ 'data-mr-action': "#{j params[:tab].presence || 'show'}", 'data-url' => merge_request_path(@merge_request, format: :json), 'data-project-path' => project_path(@merge_request.project) } +.merge-request{ data: { mr_action: j(params[:tab].presence || 'show'), url: merge_request_path(@merge_request, format: :json), project_path: project_path(@merge_request.project) } } = render "projects/merge_requests/mr_title" .merge-request-details.issuable-details{ data: { id: @merge_request.project.id } } From 142edf0afcb83f220175d02ea74b71d90753a875 Mon Sep 17 00:00:00 2001 From: "micael.bergeron" Date: Wed, 8 Nov 2017 15:39:29 -0500 Subject: [PATCH 030/112] diff notes created in merge request on a commit have the right context add a spec for commit merge request diff notes --- .../merge_requests/application_controller.rb | 7 +++++- .../merge_requests/diffs_controller.rb | 8 +------ app/helpers/merge_requests_helper.rb | 24 +++++++++++++++++++ .../merge_requests/refresh_service.rb | 2 +- app/services/system_note_service.rb | 4 ++-- .../_not_all_comments_displayed.html.haml | 2 +- .../projects/merge_requests/show.html.haml | 8 +++---- .../projects/commit_controller_spec.rb | 15 ++++++++++++ 8 files changed, 54 insertions(+), 16 deletions(-) diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 1269759fc2b..3b764433c01 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -1,6 +1,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationController before_action :check_merge_requests_available! before_action :merge_request + before_action :commit before_action :authorize_read_merge_request! private @@ -9,6 +10,11 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) end + def commit + return nil unless commit_id = params[:commit_id].presence + @commit ||= merge_request.target_project.commit(commit_id) + end + def merge_request_params params.require(:merge_request).permit(merge_request_params_attributes) end @@ -28,7 +34,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont :task_num, :title, :discussion_locked, - label_ids: [] ] end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 42a6e5be14f..1312c83373f 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -22,13 +22,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic def define_diff_vars @merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff.order_id_desc - if commit_id = params[:commit_id].presence - @commit = @merge_request.target_project.commit(commit_id) - @compare = @commit - else - @compare = find_merge_request_diff_compare - end - + @compare = commit || find_merge_request_diff_compare return render_404 unless @compare @diffs = @compare.diffs(diff_options) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 5b2c58d193d..004aaeb2c56 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -101,6 +101,30 @@ module MergeRequestsHelper }.merge(merge_params_ee(merge_request)) end + def tab_link_for(tab, options={}, &block) + data_attrs = { + action: tab.to_s, + target: "##{tab.to_s}", + toggle: options.fetch(:force_link, false) ? '' : 'tab' + } + + url = case tab + when :show + data_attrs.merge!(target: '#notes') + project_merge_request_path(@project, @merge_request) + when :commits + commits_project_merge_request_path(@project, @merge_request) + when :pipelines + pipelines_project_merge_request_path(@project, @merge_request) + when :diffs + diffs_project_merge_request_path(@project, @merge_request) + else + raise "Cannot create tab #{tab}." + end + + link_to(url, data: data_attrs, &block) + end + def merge_params_ee(merge_request) {} end diff --git a/app/services/merge_requests/refresh_service.rb b/app/services/merge_requests/refresh_service.rb index 434dda89db0..9f05535d4d4 100644 --- a/app/services/merge_requests/refresh_service.rb +++ b/app/services/merge_requests/refresh_service.rb @@ -6,7 +6,7 @@ module MergeRequests @oldrev, @newrev = oldrev, newrev @branch_name = Gitlab::Git.ref_name(ref) - find_new_commits + Gitlab::GitalyClient.allow_n_plus_1_calls(&method(:find_new_commits)) # Be sure to close outstanding MRs before reloading them to avoid generating an # empty diff during a manual merge close_merge_requests diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index f90c6fcafb8..385b34120ef 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -671,8 +671,8 @@ module SystemNoteService def merge_request_commit_url(merge_request, commit) url_helpers.diffs_namespace_project_merge_request_url( - project.namespace, - project, + merge_request.target_project.namespace, + merge_request.target_project, merge_request.iid, commit_id: commit.id ) diff --git a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml index 26988d34917..60c419a3cda 100644 --- a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml +++ b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml @@ -13,7 +13,7 @@ - else viewing an old version of the diff - .pull-right + .text-right = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' do Show latest version = "of the diff" if @commit diff --git a/app/views/projects/merge_requests/show.html.haml b/app/views/projects/merge_requests/show.html.haml index 4e3f6f9edc9..0d7abe8137f 100644 --- a/app/views/projects/merge_requests/show.html.haml +++ b/app/views/projects/merge_requests/show.html.haml @@ -38,21 +38,21 @@ .nav-links.scrolling-tabs %ul.merge-request-tabs %li.notes-tab - = link_to project_merge_request_path(@project, @merge_request), data: { target: 'div#notes', action: 'show', toggle: 'tab' } do + = tab_link_for :show, force_link: @commit.present? do Discussion %span.badge= @merge_request.related_notes.user.count - if @merge_request.source_project %li.commits-tab - = link_to commits_project_merge_request_path(@project, @merge_request), data: { target: 'div#commits', action: 'commits', toggle: 'tab' } do + = tab_link_for :commits do Commits %span.badge= @commits_count - if @pipelines.any? %li.pipelines-tab - = link_to pipelines_project_merge_request_path(@project, @merge_request), data: { target: '#pipelines', action: 'pipelines', toggle: 'tab' } do + = tab_link_for :pipelines do Pipelines %span.badge.js-pipelines-mr-count= @pipelines.size %li.diffs-tab - = link_to diffs_project_merge_request_path(@project, @merge_request), data: { target: 'div#diffs', action: 'diffs', toggle: 'tab' } do + = tab_link_for :diffs do Changes %span.badge= @merge_request.diff_size #resolve-count-app.line-resolve-all-container.prepend-top-10{ "v-cloak" => true } diff --git a/spec/controllers/projects/commit_controller_spec.rb b/spec/controllers/projects/commit_controller_spec.rb index fd90c0d8bad..a5b603d6bff 100644 --- a/spec/controllers/projects/commit_controller_spec.rb +++ b/spec/controllers/projects/commit_controller_spec.rb @@ -132,6 +132,21 @@ describe Projects::CommitController do expect(response).to be_success end end + + context 'in the context of a merge_request' do + let(:merge_request) { create(:merge_request, source_project: project) } + let(:commit) { merge_request.commits.first } + + it 'prepare diff notes in the context of the merge request' do + go(id: commit.id, merge_request_iid: merge_request.iid) + + expect(assigns(:new_diff_note_attrs)).to eq({ noteable_type: 'MergeRequest', + noteable_id: merge_request.id, + commit_id: commit.id + }) + expect(response).to be_ok + end + end end describe 'GET branches' do From e35656318a0ef78a3a4b5257298201fbefe4fbfa Mon Sep 17 00:00:00 2001 From: Micael Bergeron Date: Fri, 10 Nov 2017 18:09:59 +0000 Subject: [PATCH 031/112] add changelog --- .../unreleased/dm-commit-diff-discussions-in-mr-context.yml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 changelogs/unreleased/dm-commit-diff-discussions-in-mr-context.yml diff --git a/changelogs/unreleased/dm-commit-diff-discussions-in-mr-context.yml b/changelogs/unreleased/dm-commit-diff-discussions-in-mr-context.yml new file mode 100644 index 00000000000..1f8b42ea21f --- /dev/null +++ b/changelogs/unreleased/dm-commit-diff-discussions-in-mr-context.yml @@ -0,0 +1,5 @@ +--- +title: Make diff notes created on a commit in a merge request to persist a rebase. +merge_request: 12148 +author: +type: added From 6b3f0fee151283348b44a69342ec1a6738cd2de0 Mon Sep 17 00:00:00 2001 From: "micael.bergeron" Date: Tue, 14 Nov 2017 11:48:40 -0500 Subject: [PATCH 032/112] corrects the url building --- .../merge_requests/application_controller.rb | 6 ----- .../merge_requests/diffs_controller.rb | 8 +++++++ app/helpers/commits_helper.rb | 4 ++-- app/services/system_note_service.rb | 7 +++--- .../projects/commit/_commit_box.html.haml | 2 +- app/views/projects/commits/_commit.html.haml | 10 ++------ .../diffs/_different_base.html.haml | 4 ++-- .../merge_requests/diffs/_diffs.html.haml | 10 ++++---- .../_not_all_comments_displayed.html.haml | 24 +++++++++---------- spec/services/system_note_service_spec.rb | 13 ++++++++-- 10 files changed, 45 insertions(+), 43 deletions(-) diff --git a/app/controllers/projects/merge_requests/application_controller.rb b/app/controllers/projects/merge_requests/application_controller.rb index 3b764433c01..793ae03fb88 100644 --- a/app/controllers/projects/merge_requests/application_controller.rb +++ b/app/controllers/projects/merge_requests/application_controller.rb @@ -1,7 +1,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationController before_action :check_merge_requests_available! before_action :merge_request - before_action :commit before_action :authorize_read_merge_request! private @@ -10,11 +9,6 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont @issuable = @merge_request ||= @project.merge_requests.find_by!(iid: params[:id]) end - def commit - return nil unless commit_id = params[:commit_id].presence - @commit ||= merge_request.target_project.commit(commit_id) - end - def merge_request_params params.require(:merge_request).permit(merge_request_params_attributes) end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 1312c83373f..07bf9db5a34 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -4,6 +4,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic include RendersNotes before_action :apply_diff_view_cookie! + before_action :commit before_action :define_diff_vars before_action :define_diff_comment_vars @@ -28,6 +29,13 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic @diffs = @compare.diffs(diff_options) end + def commit + return nil unless commit_id = params[:commit_id].presence + return nil unless @merge_request.all_commit_shas.include?(commit_id) + + @commit ||= @project.commit(commit_id) + end + def find_merge_request_diff_compare @merge_request_diff = if diff_id = params[:diff_id].presence diff --git a/app/helpers/commits_helper.rb b/app/helpers/commits_helper.rb index 361d56b211c..2d304f7eb91 100644 --- a/app/helpers/commits_helper.rb +++ b/app/helpers/commits_helper.rb @@ -231,9 +231,9 @@ module CommitsHelper def commit_path(project, commit, merge_request: nil) if merge_request&.persisted? - diffs_namespace_project_merge_request_path(project.namespace, project, merge_request, commit_id: commit.id) + diffs_project_merge_request_path(project, merge_request, commit_id: commit.id) else - namespace_project_commit_path(project.namespace, project, commit.id) + project_commit_path(project, commit) end end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 385b34120ef..5f8a1bf07e2 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -670,11 +670,10 @@ module SystemNoteService end def merge_request_commit_url(merge_request, commit) - url_helpers.diffs_namespace_project_merge_request_url( - merge_request.target_project.namespace, + url_helpers.diffs_project_merge_request_url( merge_request.target_project, - merge_request.iid, - commit_id: commit.id + merge_request, + commit_id: commit ) end end diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index f2414d43578..09934c09865 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -87,6 +87,6 @@ This commit is part of merge request = succeed '.' do - = link_to @merge_request.to_reference, namespace_project_merge_request_path(@project.namespace, @project, @merge_request) + = link_to @merge_request.to_reference, diffs_project_merge_request_path(@project, @merge_request, commit_id: @commit.id) Comments created here will be created in the context of that merge request. diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index 022ded21362..45b4ef12ec9 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -4,13 +4,7 @@ - ref = local_assigns.fetch(:ref) { merge_request&.source_branch } - link = commit_path(project, commit, merge_request: merge_request) -- if @note_counts - - note_count = @note_counts.fetch(commit.id, 0) -- else - - notes = commit.notes - - note_count = notes.user.count - -- cache_key = [project.full_path, commit.id, current_application_settings, note_count, @path.presence, current_controller?(:commits), merge_request, I18n.locale] +- cache_key = [project.full_path, commit.id, current_application_settings, @path.presence, current_controller?(:commits), merge_request.iid, view_details, I18n.locale] - cache_key.push(commit.status(ref)) if commit.status(ref) = cache(cache_key, expires_in: 1.day) do @@ -55,4 +49,4 @@ = link_to_browse_code(project, commit) - if view_details && merge_request - = link_to "View details", namespace_project_commit_path(project.namespace, project, commit.id, merge_request_iid: merge_request.iid), class: "btn btn-default" + = link_to "View details", project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: "btn btn-default" diff --git a/app/views/projects/merge_requests/diffs/_different_base.html.haml b/app/views/projects/merge_requests/diffs/_different_base.html.haml index aeeaa053c5f..0e57066f9c9 100644 --- a/app/views/projects/merge_requests/diffs/_different_base.html.haml +++ b/app/views/projects/merge_requests/diffs/_different_base.html.haml @@ -4,8 +4,8 @@ = icon('info-circle') Selected versions have different base commits. Changes will include - = link_to namespace_project_compare_path(@project.namespace, @project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do + = link_to project_compare_path(@project, from: @start_version.base_commit_sha, to: @merge_request_diff.base_commit_sha) do new commits from = succeed '.' do - %code= @merge_request.target_branch + %code.ref-name= @merge_request.target_branch diff --git a/app/views/projects/merge_requests/diffs/_diffs.html.haml b/app/views/projects/merge_requests/diffs/_diffs.html.haml index 63f6c3e6716..60c91024b23 100644 --- a/app/views/projects/merge_requests/diffs/_diffs.html.haml +++ b/app/views/projects/merge_requests/diffs/_diffs.html.haml @@ -6,11 +6,11 @@ - if @merge_request_diff&.empty? .nothing-here-block = image_tag 'illustrations/merge_request_changes_empty.svg' - %p - Nothing to merge from - %strong= @merge_request.source_branch - into - %strong= @merge_request.target_branch + = succeed '.' do + No changes between + %span.ref-name= @merge_request.source_branch + and + %span.ref-name= @merge_request.target_branch %p= link_to 'Create commit', project_new_blob_path(@project, @merge_request.source_branch), class: 'btn btn-save' - else - diff_viewable = @merge_request_diff ? @merge_request_diff.collected? || @merge_request_diff.overflow? : true diff --git a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml index 60c419a3cda..e4a1dc786b9 100644 --- a/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml +++ b/app/views/projects/merge_requests/diffs/_not_all_comments_displayed.html.haml @@ -1,19 +1,17 @@ - if @commit || @start_version || (@merge_request_diff && !@merge_request_diff.latest?) .mr-version-controls - .content-block.comments-disabled-notif + .content-block.comments-disabled-notif.clearfix = icon('info-circle') - Not all comments are displayed because you're = succeed '.' do - if @commit - viewing only the changes in commit - - = link_to @commit.short_id, diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request, commit_id: @commit.id), class: "commit-sha" - - elsif @start_version - comparing two versions of the diff + Only comments from the following commit are shown below - else - viewing an old version of the diff - - .text-right - = link_to diffs_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'btn btn-sm' do - Show latest version - = "of the diff" if @commit + Not all comments are displayed because you're + - if @start_version + comparing two versions of the diff + - else + viewing an old version of the diff + .pull-right + = link_to diffs_project_merge_request_path(@project, @merge_request), class: 'btn btn-sm' do + Show latest version + = "of the diff" if @commit diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index a918383ecd2..148f81b6a58 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -690,11 +690,20 @@ describe SystemNoteService do end describe '.new_commit_summary' do + let(:merge_request) { create(:merge_request, :simple, target_project: project, source_project: project) } + it 'escapes HTML titles' do commit = double(title: '