diff options
139 files changed, 2897 insertions, 1116 deletions
diff --git a/Gemfile.lock b/Gemfile.lock index 16fd440279b..7375fce8b1e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1153,7 +1153,7 @@ DEPENDENCIES sanitize (~> 2.0) sass-rails (~> 5.0.6) scss_lint (~> 0.54.0) - seed-fu (~> 2.3.5) + seed-fu (~> 2.3.7) select2-rails (~> 3.5.9) selenium-webdriver (~> 3.5) sentry-raven (~> 2.5.3) diff --git a/app/assets/javascripts/create_merge_request_dropdown.js b/app/assets/javascripts/create_merge_request_dropdown.js index bf40eb3ee11..23425672b16 100644 --- a/app/assets/javascripts/create_merge_request_dropdown.js +++ b/app/assets/javascripts/create_merge_request_dropdown.js @@ -2,6 +2,7 @@ import Flash from './flash'; import DropLab from './droplab/drop_lab'; import ISetter from './droplab/plugins/input_setter'; +import { __, sprintf } from './locale'; // Todo: Remove this when fixing issue in input_setter plugin const InputSetter = Object.assign({}, ISetter); @@ -12,28 +13,49 @@ const CREATE_BRANCH = 'create-branch'; export default class CreateMergeRequestDropdown { constructor(wrapperEl) { this.wrapperEl = wrapperEl; + this.availableButton = this.wrapperEl.querySelector('.available'); + this.branchInput = this.wrapperEl.querySelector('.js-branch-name'); + this.branchMessage = this.wrapperEl.querySelector('.js-branch-message'); this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request'); - this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); + this.createTargetButton = this.wrapperEl.querySelector('.js-create-target'); this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu'); - this.availableButton = this.wrapperEl.querySelector('.available'); + this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle'); + this.refInput = this.wrapperEl.querySelector('.js-ref'); + this.refMessage = this.wrapperEl.querySelector('.js-ref-message'); this.unavailableButton = this.wrapperEl.querySelector('.unavailable'); this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa'); this.unavailableButtonText = this.unavailableButton.querySelector('.text'); - this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.branchCreated = false; + this.branchIsValid = true; this.canCreatePath = this.wrapperEl.dataset.canCreatePath; + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; this.createMrPath = this.wrapperEl.dataset.createMrPath; this.droplabInitialized = false; + this.isCreatingBranch = false; this.isCreatingMergeRequest = false; + this.isGettingRef = false; this.mergeRequestCreated = false; - this.isCreatingBranch = false; - this.branchCreated = false; + this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500); + this.refIsValid = true; + this.refsPath = this.wrapperEl.dataset.refsPath; + this.suggestedRef = this.refInput.value; - this.init(); - } + // These regexps are used to replace + // a backend generated new branch name and its source (ref) + // with user's inputs. + this.regexps = { + branch: { + createBranchPath: new RegExp('(branch_name=)(.+?)(?=&issue)'), + createMrPath: new RegExp('(branch_name=)(.+?)(?=&ref)'), + }, + ref: { + createBranchPath: new RegExp('(ref=)(.+?)$'), + createMrPath: new RegExp('(ref=)(.+?)$'), + }, + }; - init() { - this.checkAbilityToCreateBranch(); + this.init(); } available() { @@ -41,41 +63,13 @@ export default class CreateMergeRequestDropdown { this.unavailableButton.classList.add('hide'); } - unavailable() { - this.availableButton.classList.add('hide'); - this.unavailableButton.classList.remove('hide'); - } - - enable() { - this.createMergeRequestButton.classList.remove('disabled'); - this.createMergeRequestButton.removeAttribute('disabled'); - - this.dropdownToggle.classList.remove('disabled'); - this.dropdownToggle.removeAttribute('disabled'); - } - - disable() { - this.createMergeRequestButton.classList.add('disabled'); - this.createMergeRequestButton.setAttribute('disabled', 'disabled'); - - this.dropdownToggle.classList.add('disabled'); - this.dropdownToggle.setAttribute('disabled', 'disabled'); - } - - hide() { - this.wrapperEl.classList.add('hide'); - } - - setUnavailableButtonState(isLoading = true) { - if (isLoading) { - this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin'); - this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle'); - this.unavailableButtonText.textContent = 'Checking branch availability…'; - } else { - this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin'); - this.unavailableButtonArrow.classList.add('fa-exclamation-triangle'); - this.unavailableButtonText.textContent = 'New branch unavailable'; - } + bindEvents() { + this.createMergeRequestButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + this.createTargetButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this)); + this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this)); + this.refInput.addEventListener('keyup', this.onChangeInput.bind(this)); + this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this)); } checkAbilityToCreateBranch() { @@ -107,49 +101,233 @@ export default class CreateMergeRequestDropdown { }); } - initDroplab() { - this.droplab = new DropLab(); + createBranch() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createBranchPath, + beforeSend: () => (this.isCreatingBranch = true), + }) + .done((data) => { + this.branchCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); + } - this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter], - this.getDroplabConfig()); + createMergeRequest() { + return $.ajax({ + method: 'POST', + dataType: 'json', + url: this.createMrPath, + beforeSend: () => (this.isCreatingMergeRequest = true), + }) + .done((data) => { + this.mergeRequestCreated = true; + window.location.href = data.url; + }) + .fail(() => new Flash('Failed to create Merge Request. Please try again.')); + } + + disable() { + this.disableCreateAction(); + + this.dropdownToggle.classList.add('disabled'); + this.dropdownToggle.setAttribute('disabled', 'disabled'); + } + + disableCreateAction() { + this.createMergeRequestButton.classList.add('disabled'); + this.createMergeRequestButton.setAttribute('disabled', 'disabled'); + + this.createTargetButton.classList.add('disabled'); + this.createTargetButton.setAttribute('disabled', 'disabled'); + } + + enable() { + this.createMergeRequestButton.classList.remove('disabled'); + this.createMergeRequestButton.removeAttribute('disabled'); + + this.createTargetButton.classList.remove('disabled'); + this.createTargetButton.removeAttribute('disabled'); + + this.dropdownToggle.classList.remove('disabled'); + this.dropdownToggle.removeAttribute('disabled'); + } + + static findByValue(objects, ref, returnFirstMatch = false) { + if (!objects || !objects.length) return false; + if (objects.indexOf(ref) > -1) return ref; + if (returnFirstMatch) return objects.find(item => new RegExp(`^${ref}`).test(item)); + + return false; } getDroplabConfig() { return { - InputSetter: [{ - input: this.createMergeRequestButton, - valueAttribute: 'data-value', - inputAttribute: 'data-action', - }, { - input: this.createMergeRequestButton, - valueAttribute: 'data-text', - }], + addActiveClassToDropdownButton: true, + InputSetter: [ + { + input: this.createMergeRequestButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, + { + input: this.createMergeRequestButton, + valueAttribute: 'data-text', + }, + { + input: this.createTargetButton, + valueAttribute: 'data-value', + inputAttribute: 'data-action', + }, + { + input: this.createTargetButton, + valueAttribute: 'data-text', + }, + ], }; } - bindEvents() { - this.createMergeRequestButton - .addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); + static getInputSelectedText(input) { + const start = input.selectionStart; + const end = input.selectionEnd; + + return input.value.substr(start, end - start); + } + + getRef(ref, target = 'all') { + if (!ref) return false; + + return $.ajax({ + method: 'GET', + dataType: 'json', + url: this.refsPath + ref, + beforeSend: () => { + this.isGettingRef = true; + }, + }) + .always(() => { + this.isGettingRef = false; + }) + .done((data) => { + const branches = data[Object.keys(data)[0]]; + const tags = data[Object.keys(data)[1]]; + let result; + + if (target === 'branch') { + result = CreateMergeRequestDropdown.findByValue(branches, ref); + } else { + result = CreateMergeRequestDropdown.findByValue(branches, ref, true) || + CreateMergeRequestDropdown.findByValue(tags, ref, true); + this.suggestedRef = result; + } + + return this.updateInputState(target, ref, result); + }) + .fail(() => { + this.unavailable(); + this.disable(); + new Flash('Failed to get ref.'); + + return false; + }); + } + + getTargetData(target) { + return { + input: this[`${target}Input`], + message: this[`${target}Message`], + }; + } + + hide() { + this.wrapperEl.classList.add('hide'); + } + + init() { + this.checkAbilityToCreateBranch(); + } + + initDroplab() { + this.droplab = new DropLab(); + + this.droplab.init( + this.dropdownToggle, + this.dropdownList, + [InputSetter], + this.getDroplabConfig(), + ); + } + + inputsAreValid() { + return this.branchIsValid && this.refIsValid; } isBusy() { return this.isCreatingMergeRequest || this.mergeRequestCreated || this.isCreatingBranch || - this.branchCreated; + this.branchCreated || + this.isGettingRef; } - onClickCreateMergeRequestButton(e) { + onChangeInput(event) { + let target; + let value; + + if (event.srcElement === this.branchInput) { + target = 'branch'; + value = this.branchInput.value; + } else if (event.srcElement === this.refInput) { + target = 'ref'; + value = event.srcElement.value.slice(0, event.srcElement.selectionStart) + + event.srcElement.value.slice(event.srcElement.selectionEnd); + } else { + return false; + } + + if (this.isGettingRef) return false; + + // `ENTER` key submits the data. + if (event.keyCode === 13 && this.inputsAreValid()) { + event.preventDefault(); + return this.createMergeRequestButton.click(); + } + + // If the input is empty, use the original value generated by the backend. + if (!value) { + this.createBranchPath = this.wrapperEl.dataset.createBranchPath; + this.createMrPath = this.wrapperEl.dataset.createMrPath; + + if (target === 'branch') { + this.branchIsValid = true; + } else { + this.refIsValid = true; + } + + this.enable(); + this.showAvailableMessage(target); + return true; + } + + this.showCheckingMessage(target); + this.refDebounce(value, target); + + return true; + } + + onClickCreateMergeRequestButton(event) { let xhr = null; - e.preventDefault(); + event.preventDefault(); if (this.isBusy()) { return; } - if (e.target.dataset.action === CREATE_MERGE_REQUEST) { + if (event.target.dataset.action === CREATE_MERGE_REQUEST) { xhr = this.createMergeRequest(); - } else if (e.target.dataset.action === CREATE_BRANCH) { + } else if (event.target.dataset.action === CREATE_BRANCH) { xhr = this.createBranch(); } @@ -163,31 +341,131 @@ export default class CreateMergeRequestDropdown { this.disable(); } - createMergeRequest() { - return $.ajax({ - method: 'POST', - dataType: 'json', - url: this.createMrPath, - beforeSend: () => (this.isCreatingMergeRequest = true), - }) - .done((data) => { - this.mergeRequestCreated = true; - window.location.href = data.url; - }) - .fail(() => new Flash('Failed to create Merge Request. Please try again.')); + onClickSetFocusOnBranchNameInput() { + this.branchInput.focus(); } - createBranch() { - return $.ajax({ - method: 'POST', - dataType: 'json', - url: this.createBranchPath, - beforeSend: () => (this.isCreatingBranch = true), - }) - .done((data) => { - this.branchCreated = true; - window.location.href = data.url; - }) - .fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); + // `TAB` autocompletes the source. + static processTab(event) { + if (event.keyCode !== 9 || this.isGettingRef) return; + + const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput); + + // if nothing selected, we don't need to autocomplete anything. Do the default TAB action. + // If a user manually selected text, don't autocomplete anything. Do the default TAB action. + if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return; + + event.preventDefault(); + window.getSelection().removeAllRanges(); + } + + removeMessage(target) { + const { input, message } = this.getTargetData(target); + const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline']; + const messageClasses = ['gl-field-hint', 'gl-field-error-message', 'gl-field-success-message']; + + inputClasses.forEach(cssClass => input.classList.remove(cssClass)); + messageClasses.forEach(cssClass => message.classList.remove(cssClass)); + message.style.display = 'none'; + } + + setUnavailableButtonState(isLoading = true) { + if (isLoading) { + this.unavailableButtonArrow.classList.add('fa-spin'); + this.unavailableButtonArrow.classList.add('fa-spinner'); + this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = __('Checking branch availability...'); + } else { + this.unavailableButtonArrow.classList.remove('fa-spin'); + this.unavailableButtonArrow.classList.remove('fa-spinner'); + this.unavailableButtonArrow.classList.add('fa-exclamation-triangle'); + this.unavailableButtonText.textContent = __('New branch unavailable'); + } + } + + showAvailableMessage(target) { + const { input, message } = this.getTargetData(target); + const text = target === 'branch' ? __('Branch name') : __('Source'); + + this.removeMessage(target); + input.classList.add('gl-field-success-outline'); + message.classList.add('gl-field-success-message'); + message.textContent = sprintf(__('%{text} is available'), { text }); + message.style.display = 'inline-block'; + } + + showCheckingMessage(target) { + const { message } = this.getTargetData(target); + const text = target === 'branch' ? __('branch name') : __('source'); + + this.removeMessage(target); + message.classList.add('gl-field-hint'); + message.textContent = sprintf(__('Checking %{text} availability…'), { text }); + message.style.display = 'inline-block'; + } + + showNotAvailableMessage(target) { + const { input, message } = this.getTargetData(target); + const text = target === 'branch' ? __('Branch is already taken') : __('Source is not available'); + + this.removeMessage(target); + input.classList.add('gl-field-error-outline'); + message.classList.add('gl-field-error-message'); + message.textContent = text; + message.style.display = 'inline-block'; + } + + unavailable() { + this.availableButton.classList.add('hide'); + this.unavailableButton.classList.remove('hide'); + } + + updateInputState(target, ref, result) { + // target - 'branch' or 'ref' - which the input field we are searching a ref for. + // ref - string - what a user typed. + // result - string - what has been found on backend. + + const pathReplacement = `$1${ref}`; + + // If a found branch equals exact the same text a user typed, + // that means a new branch cannot be created as it already exists. + if (ref === result) { + if (target === 'branch') { + this.branchIsValid = false; + this.showNotAvailableMessage('branch'); + } else { + this.refIsValid = true; + this.refInput.dataset.value = ref; + this.showAvailableMessage('ref'); + this.createBranchPath = this.createBranchPath.replace(this.regexps.ref.createBranchPath, + pathReplacement); + this.createMrPath = this.createMrPath.replace(this.regexps.ref.createMrPath, + pathReplacement); + } + } else if (target === 'branch') { + this.branchIsValid = true; + this.showAvailableMessage('branch'); + this.createBranchPath = this.createBranchPath.replace(this.regexps.branch.createBranchPath, + pathReplacement); + this.createMrPath = this.createMrPath.replace(this.regexps.branch.createMrPath, + pathReplacement); + } else { + this.refIsValid = false; + this.refInput.dataset.value = ref; + this.disableCreateAction(); + this.showNotAvailableMessage('ref'); + + // Show ref hint. + if (result) { + this.refInput.value = result; + this.refInput.setSelectionRange(ref.length, result.length); + } + } + + if (this.inputsAreValid()) { + this.enable(); + } else { + this.disableCreateAction(); + } } } diff --git a/app/assets/javascripts/dispatcher.js b/app/assets/javascripts/dispatcher.js index 34708977d20..a21c92f24d6 100644 --- a/app/assets/javascripts/dispatcher.js +++ b/app/assets/javascripts/dispatcher.js @@ -383,6 +383,7 @@ import ProjectVariables from './project_variables'; projectImport(); break; case 'projects:pipelines:new': + case 'projects:pipelines:create': new NewBranchForm($('.js-new-pipeline-form')); break; case 'projects:pipelines:builds': @@ -521,6 +522,13 @@ 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/droplab/constants.js b/app/assets/javascripts/droplab/constants.js index 868d47e91b3..673e9bb4c0f 100644 --- a/app/assets/javascripts/droplab/constants.js +++ b/app/assets/javascripts/droplab/constants.js @@ -3,6 +3,7 @@ const DATA_DROPDOWN = 'data-dropdown'; const SELECTED_CLASS = 'droplab-item-selected'; const ACTIVE_CLASS = 'droplab-item-active'; const IGNORE_CLASS = 'droplab-item-ignore'; +const IGNORE_HIDING_CLASS = 'droplab-item-ignore-hiding'; // Matches `{{anything}}` and `{{ everything }}`. const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; @@ -13,4 +14,5 @@ export { ACTIVE_CLASS, TEMPLATE_REGEX, IGNORE_CLASS, + IGNORE_HIDING_CLASS, }; diff --git a/app/assets/javascripts/droplab/drop_down.js b/app/assets/javascripts/droplab/drop_down.js index 3901bb177fe..5eb0a339a1c 100644 --- a/app/assets/javascripts/droplab/drop_down.js +++ b/app/assets/javascripts/droplab/drop_down.js @@ -1,15 +1,18 @@ import utils from './utils'; -import { SELECTED_CLASS, IGNORE_CLASS } from './constants'; +import { SELECTED_CLASS, IGNORE_CLASS, IGNORE_HIDING_CLASS } from './constants'; class DropDown { - constructor(list) { + constructor(list, config = {}) { this.currentIndex = 0; this.hidden = true; this.list = typeof list === 'string' ? document.querySelector(list) : list; this.items = []; - this.eventWrapper = {}; + if (config.addActiveClassToDropdownButton) { + this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle'); + } + this.getItems(); this.initTemplateString(); this.addEvents(); @@ -42,7 +45,7 @@ class DropDown { this.addSelectedClass(selected); e.preventDefault(); - this.hide(); + if (!e.target.classList.contains(IGNORE_HIDING_CLASS)) this.hide(); const listEvent = new CustomEvent('click.dl', { detail: { @@ -67,7 +70,20 @@ class DropDown { addEvents() { this.eventWrapper.clickEvent = this.clickEvent.bind(this); + this.eventWrapper.closeDropdown = this.closeDropdown.bind(this); + this.list.addEventListener('click', this.eventWrapper.clickEvent); + this.list.addEventListener('keyup', this.eventWrapper.closeDropdown); + } + + closeDropdown(event) { + // `ESC` key closes the dropdown. + if (event.keyCode === 27) { + event.preventDefault(); + return this.toggle(); + } + + return true; } setData(data) { @@ -110,6 +126,8 @@ class DropDown { this.list.style.display = 'block'; this.currentIndex = 0; this.hidden = false; + + if (this.dropdownToggle) this.dropdownToggle.classList.add('active'); } hide() { @@ -117,6 +135,8 @@ class DropDown { this.list.style.display = 'none'; this.currentIndex = 0; this.hidden = true; + + if (this.dropdownToggle) this.dropdownToggle.classList.remove('active'); } toggle() { @@ -128,6 +148,7 @@ class DropDown { destroy() { this.hide(); this.list.removeEventListener('click', this.eventWrapper.clickEvent); + this.list.removeEventListener('keyup', this.eventWrapper.closeDropdown); } static setImagesSrc(template) { diff --git a/app/assets/javascripts/droplab/hook.js b/app/assets/javascripts/droplab/hook.js index cf78165b0d8..8a8dcde9f88 100644 --- a/app/assets/javascripts/droplab/hook.js +++ b/app/assets/javascripts/droplab/hook.js @@ -3,7 +3,7 @@ import DropDown from './drop_down'; class Hook { constructor(trigger, list, plugins, config) { this.trigger = trigger; - this.list = new DropDown(list); + this.list = new DropDown(list, config); this.type = 'Hook'; this.event = 'click'; this.plugins = plugins || []; diff --git a/app/assets/javascripts/flash.js b/app/assets/javascripts/flash.js index 67261c1c9b4..44deab9288e 100644 --- a/app/assets/javascripts/flash.js +++ b/app/assets/javascripts/flash.js @@ -41,7 +41,7 @@ const createFlashEl = (message, type, isInContentWrapper = false) => ` `; const removeFlashClickListener = (flashEl, fadeTransition) => { - flashEl.parentNode.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); + flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); }; /* diff --git a/app/assets/javascripts/issue_show/components/app.vue b/app/assets/javascripts/issue_show/components/app.vue index 1f9571ff413..5bdc7c99503 100644 --- a/app/assets/javascripts/issue_show/components/app.vue +++ b/app/assets/javascripts/issue_show/components/app.vue @@ -16,6 +16,10 @@ export default { required: true, type: String, }, + updateEndpoint: { + required: true, + type: String, + }, canUpdate: { required: true, type: Boolean, @@ -262,6 +266,8 @@ export default { :description-text="state.descriptionText" :updated-at="state.updatedAt" :task-status="state.taskStatus" + :issuable-type="issuableType" + :update-url="updateEndpoint" /> <edited-component v-if="hasUpdated" diff --git a/app/assets/javascripts/issue_show/components/description.vue b/app/assets/javascripts/issue_show/components/description.vue index 48bad8f1e68..b7559ced946 100644 --- a/app/assets/javascripts/issue_show/components/description.vue +++ b/app/assets/javascripts/issue_show/components/description.vue @@ -22,6 +22,16 @@ required: false, default: '', }, + issuableType: { + type: String, + required: false, + default: 'issue', + }, + updateUrl: { + type: String, + required: false, + default: null, + }, }, data() { return { @@ -48,7 +58,7 @@ if (this.canUpdate) { // eslint-disable-next-line no-new new TaskList({ - dataType: 'issue', + dataType: this.issuableType, fieldName: 'description', selector: '.detail-page-description', }); @@ -95,7 +105,9 @@ <textarea class="hidden js-task-list-field" v-if="descriptionText" - v-model="descriptionText"> + v-model="descriptionText" + :data-update-url="updateUrl" + > </textarea> </div> </template> diff --git a/app/assets/javascripts/issue_show/components/title.vue b/app/assets/javascripts/issue_show/components/title.vue index 00002709ac6..a363d06d950 100644 --- a/app/assets/javascripts/issue_show/components/title.vue +++ b/app/assets/javascripts/issue_show/components/title.vue @@ -79,7 +79,7 @@ v-tooltip v-if="showInlineEditButton && canUpdate" type="button" - class="btn-blank btn-edit note-action-button" + class="btn btn-default btn-edit btn-svg" v-html="pencilIcon" title="Edit title and description" data-placement="bottom" diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 6fa1e84c170..33cc807912c 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -190,7 +190,7 @@ export const insertText = (target, text) => { target.selectionStart = target.selectionEnd = selectionStart + insertedText.length; // Trigger autosave - $(target).trigger('input'); + target.dispatchEvent(new Event('input')); // Trigger autosize const event = document.createEvent('Event'); diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 08e326cba9c..5e0edd823be 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -301,6 +301,8 @@ $(function () { const flashContainer = document.querySelector('.flash-container'); if (flashContainer && flashContainer.children.length) { - removeFlashClickListener(flashContainer.children[0]); + flashContainer.querySelectorAll('.flash-alert, .flash-notice, .flash-success').forEach((flashEl) => { + removeFlashClickListener(flashEl); + }); } }); diff --git a/app/assets/javascripts/projects/ci_cd_settings_bundle.js b/app/assets/javascripts/projects/ci_cd_settings_bundle.js new file mode 100644 index 00000000000..90e418f6771 --- /dev/null +++ b/app/assets/javascripts/projects/ci_cd_settings_bundle.js @@ -0,0 +1,19 @@ +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/assets/javascripts/projects_dropdown/components/projects_list_item.vue b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue index fe5179de206..d482a7025de 100644 --- a/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue +++ b/app/assets/javascripts/projects_dropdown/components/projects_list_item.vue @@ -48,6 +48,27 @@ export default { } return this.projectName; }, + /** + * Smartly truncates project namespace by doing two things; + * 1. Only include Group names in path by removing project name + * 2. Only include first and last group names in the path + * when namespace has more than 2 groups present + * + * First part (removal of project name from namespace) can be + * done from backend but doing so involves migration of + * existing project namespaces which is not wise thing to do. + */ + truncatedNamespace() { + const namespaceArr = this.namespace.split(' / '); + namespaceArr.splice(-1, 1); + let namespace = namespaceArr.join(' / '); + + if (namespaceArr.length > 2) { + namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`; + } + + return namespace; + }, }, }; </script> @@ -87,9 +108,7 @@ export default { <div class="project-namespace" :title="namespace" - > - {{namespace}} - </div> + >{{truncatedNamespace}}</div> </div> </a> </li> diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list.vue b/app/assets/javascripts/repo/components/commit_sidebar/list.vue new file mode 100644 index 00000000000..fb862e7bf01 --- /dev/null +++ b/app/assets/javascripts/repo/components/commit_sidebar/list.vue @@ -0,0 +1,89 @@ +<script> + import icon from '../../../vue_shared/components/icon.vue'; + import listItem from './list_item.vue'; + import listCollapsed from './list_collapsed.vue'; + + export default { + components: { + icon, + listItem, + listCollapsed, + }, + props: { + title: { + type: String, + required: true, + }, + fileList: { + type: Array, + required: true, + }, + collapsed: { + type: Boolean, + required: true, + }, + }, + methods: { + toggleCollapsed() { + this.$emit('toggleCollapsed'); + }, + }, + }; +</script> + +<template> + <div class="multi-file-commit-panel-section"> + <header + class="multi-file-commit-panel-header" + :class="{ + 'is-collapsed': collapsed, + }" + > + <icon + name="list-bulleted" + :size="18" + css-classes="append-right-default" + /> + <template v-if="!collapsed"> + {{ title }} + <button + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn" + @click="toggleCollapsed" + > + <i + aria-hidden="true" + class="fa fa-angle-double-right" + > + </i> + </button> + </template> + </header> + <div class="multi-file-commit-list"> + <list-collapsed + v-if="collapsed" + /> + <template v-else> + <ul + v-if="fileList.length" + class="list-unstyled append-bottom-0" + > + <li + v-for="file in fileList" + :key="file.key" + > + <list-item + :file="file" + /> + </li> + </ul> + <div + v-else + class="help-block prepend-top-0" + > + No changes + </div> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue b/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue new file mode 100644 index 00000000000..6a0262f271b --- /dev/null +++ b/app/assets/javascripts/repo/components/commit_sidebar/list_collapsed.vue @@ -0,0 +1,35 @@ +<script> + import { mapGetters } from 'vuex'; + import icon from '../../../vue_shared/components/icon.vue'; + + export default { + components: { + icon, + }, + computed: { + ...mapGetters([ + 'addedFiles', + 'modifiedFiles', + ]), + }, + }; +</script> + +<template> + <div + class="multi-file-commit-list-collapsed text-center" + > + <icon + name="file-addition" + :size="18" + css-classes="multi-file-addition append-bottom-10" + /> + {{ addedFiles.length }} + <icon + name="file-modified" + :size="18" + css-classes="multi-file-modified prepend-top-10 append-bottom-10" + /> + {{ modifiedFiles.length }} + </div> +</template> diff --git a/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue b/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue new file mode 100644 index 00000000000..742f746e02f --- /dev/null +++ b/app/assets/javascripts/repo/components/commit_sidebar/list_item.vue @@ -0,0 +1,36 @@ +<script> + import icon from '../../../vue_shared/components/icon.vue'; + + export default { + components: { + icon, + }, + props: { + file: { + type: Object, + required: true, + }, + }, + computed: { + iconName() { + return this.file.tempFile ? 'file-addition' : 'file-modified'; + }, + iconClass() { + return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`; + }, + }, + }; +</script> + +<template> + <div class="multi-file-commit-list-item"> + <icon + :name="iconName" + :size="16" + :css-classes="iconClass" + /> + <span class="multi-file-commit-list-path"> + {{ file.path }} + </span> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index 98117802016..a00e1e9d809 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -40,20 +40,24 @@ export default { </script> <template> - <div class="repository-view"> - <div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}"> - <repo-sidebar/> - <div - v-if="isCollapsed" - class="panel-right" - > - <repo-tabs/> - <component - :is="currentBlobView" - /> - <repo-file-buttons/> - </div> + <div + class="multi-file" + :class="{ + 'is-collapsed': isCollapsed + }" + > + <repo-sidebar/> + <div + v-if="isCollapsed" + class="multi-file-edit-pane" + > + <repo-tabs /> + <component + class="multi-file-edit-pane-content" + :is="currentBlobView" + /> + <repo-file-buttons /> </div> - <repo-commit-section v-if="changedFiles.length" /> + <repo-commit-section /> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue index 377e3d65348..d3344d0c8dc 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -1,11 +1,18 @@ <script> import { mapGetters, mapState, mapActions } from 'vuex'; +import tooltip from '../../vue_shared/directives/tooltip'; +import icon from '../../vue_shared/components/icon.vue'; import PopupDialog from '../../vue_shared/components/popup_dialog.vue'; -import { n__ } from '../../locale'; +import commitFilesList from './commit_sidebar/list.vue'; export default { components: { PopupDialog, + icon, + commitFilesList, + }, + directives: { + tooltip, }, data() { return { @@ -13,6 +20,7 @@ export default { submitCommitsLoading: false, startNewMR: false, commitMessage: '', + collapsed: true, }; }, computed: { @@ -23,10 +31,10 @@ export default { 'changedFiles', ]), commitButtonDisabled() { - return !this.commitMessage || this.submitCommitsLoading; + return this.commitMessage === '' || this.submitCommitsLoading || !this.changedFiles.length; }, - commitButtonText() { - return n__('Commit %d file', 'Commit %d files', this.changedFiles.length); + commitMessageCount() { + return this.commitMessage.length; }, }, methods: { @@ -77,12 +85,20 @@ export default { this.submitCommitsLoading = false; }); }, + toggleCollapsed() { + this.collapsed = !this.collapsed; + }, }, }; </script> <template> -<div id="commit-area"> +<div + class="multi-file-commit-panel" + :class="{ + 'is-collapsed': collapsed, + }" +> <popup-dialog v-if="showNewBranchDialog" :primary-button-label="__('Create new branch')" @@ -92,78 +108,71 @@ export default { @toggle="showNewBranchDialog = false" @submit="makeCommit(true)" /> + <button + v-if="collapsed" + type="button" + class="btn btn-transparent multi-file-commit-panel-collapse-btn is-collapsed prepend-top-10 append-bottom-10" + @click="toggleCollapsed" + > + <i + aria-hidden="true" + class="fa fa-angle-double-left" + > + </i> + </button> + <commit-files-list + title="Staged" + :file-list="changedFiles" + :collapsed="collapsed" + @toggleCollapsed="toggleCollapsed" + /> <form - class="form-horizontal" - @submit.prevent="tryCommit()"> - <fieldset> - <div class="form-group"> - <label class="col-md-4 control-label staged-files"> - Staged files ({{changedFiles.length}}) - </label> - <div class="col-md-6"> - <ul class="list-unstyled changed-files"> - <li - v-for="(file, index) in changedFiles" - :key="index"> - <span class="help-block"> - {{ file.path }} - </span> - </li> - </ul> - </div> - </div> - <div class="form-group"> - <label - class="col-md-4 control-label" - for="commit-message"> - Commit message - </label> - <div class="col-md-6"> - <textarea - id="commit-message" - class="form-control" - name="commit-message" - v-model="commitMessage"> - </textarea> - </div> - </div> - <div class="form-group target-branch"> - <label - class="col-md-4 control-label" - for="target-branch"> - Target branch - </label> - <div class="col-md-6"> - <span class="help-block"> - {{currentBranch}} - </span> - </div> - </div> - <div class="col-md-offset-4 col-md-6"> - <button - type="submit" - :disabled="commitButtonDisabled" - class="btn btn-success"> - <i - v-if="submitCommitsLoading" - class="js-commit-loading-icon fa fa-spinner fa-spin" - aria-hidden="true" - aria-label="loading"> - </i> - <span class="commit-summary"> - {{ commitButtonText }} - </span> - </button> - </div> - <div class="col-md-offset-4 col-md-6"> - <div class="checkbox"> - <label> - <input type="checkbox" v-model="startNewMR"> - <span>Start a <strong>new merge request</strong> with these changes</span> - </label> - </div> + class="form-horizontal multi-file-commit-form" + @submit.prevent="tryCommit" + v-if="!collapsed" + > + <div class="multi-file-commit-fieldset"> + <textarea + class="form-control multi-file-commit-message" + name="commit-message" + v-model="commitMessage" + placeholder="Commit message" + > + </textarea> + </div> + <div class="multi-file-commit-fieldset"> + <label + v-tooltip + title="Create a new merge request with these changes" + data-container="body" + data-placement="top" + > + <input + type="checkbox" + v-model="startNewMR" + /> + Merge Request + </label> + <button + type="submit" + :disabled="commitButtonDisabled" + class="btn btn-default btn-sm append-right-10 prepend-left-10" + > + <i + v-if="submitCommitsLoading" + class="js-commit-loading-icon fa fa-spinner fa-spin" + aria-hidden="true" + aria-label="loading" + > + </i> + Commit + </button> + <div + class="multi-file-commit-message-count" + > + {{ commitMessageCount }} </div> - </fieldset> + </div> </form> </div> </template> diff --git a/app/assets/javascripts/repo/components/repo_file.vue b/app/assets/javascripts/repo/components/repo_file.vue index 5be47d568e7..75787ad6103 100644 --- a/app/assets/javascripts/repo/components/repo_file.vue +++ b/app/assets/javascripts/repo/components/repo_file.vue @@ -55,7 +55,7 @@ class="file" @click.prevent="clickedTreeRow(file)"> <td - class="multi-file-table-col-name" + class="multi-file-table-name" :colspan="submoduleColSpan" > <i @@ -85,12 +85,11 @@ </td> <template v-if="!isCollapsed && !isSubmodule"> - <td class="hidden-sm hidden-xs"> + <td class="multi-file-table-col-commit-message hidden-sm hidden-xs"> <a v-if="file.lastCommit.message" @click.stop :href="file.lastCommit.url" - class="commit-message" > {{ file.lastCommit.message }} </a> diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue index dd948ee84fb..34f0d51819a 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue @@ -22,12 +22,12 @@ export default { <template> <div v-if="showButtons" - class="repo-file-buttons" + class="multi-file-editor-btn-group" > <a :href="activeFile.rawPath" target="_blank" - class="btn btn-default raw" + class="btn btn-default btn-sm raw" rel="noopener noreferrer"> {{ rawDownloadButtonLabel }} </a> @@ -38,17 +38,17 @@ export default { aria-label="File actions"> <a :href="activeFile.blamePath" - class="btn btn-default blame"> + class="btn btn-default btn-sm blame"> Blame </a> <a :href="activeFile.commitsPath" - class="btn btn-default history"> + class="btn btn-default btn-sm history"> History </a> <a :href="activeFile.permalink" - class="btn btn-default permalink"> + class="btn btn-default btn-sm permalink"> Permalink </a> </div> diff --git a/app/assets/javascripts/repo/components/repo_preview.vue b/app/assets/javascripts/repo/components/repo_preview.vue index d1883299bd9..6ce9267f598 100644 --- a/app/assets/javascripts/repo/components/repo_preview.vue +++ b/app/assets/javascripts/repo/components/repo_preview.vue @@ -32,10 +32,12 @@ export default { </script> <template> -<div class="blob-viewer-container"> +<div> <div v-if="!activeFile.renderError" - v-html="activeFile.html"> + v-html="activeFile.html" + class="multi-file-preview-holder" + > </div> <div v-else-if="activeFile.tempFile" diff --git a/app/assets/javascripts/repo/components/repo_sidebar.vue b/app/assets/javascripts/repo/components/repo_sidebar.vue index 9365b09326f..4ea21913129 100644 --- a/app/assets/javascripts/repo/components/repo_sidebar.vue +++ b/app/assets/javascripts/repo/components/repo_sidebar.vue @@ -44,20 +44,16 @@ export default { </script> <template> -<div id="sidebar" :class="{'sidebar-mini' : isCollapsed}"> +<div class="ide-file-list"> <table class="table"> <thead> <tr> <th v-if="isCollapsed" - class="repo-file-options title" > - <strong class="clgray"> - {{ projectName }} - </strong> </th> <template v-else> - <th class="name multi-file-table-col-name"> + <th class="name multi-file-table-name"> Name </th> <th class="hidden-sm hidden-xs last-commit"> @@ -79,7 +75,7 @@ export default { :key="n" /> <repo-file - v-for="(file, index) in treeList" + v-for="file in treeList" :key="file.key" :file="file" /> diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue index da0714c368c..fb29a60df66 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/repo/components/repo_tab.vue @@ -36,27 +36,32 @@ export default { <template> <li - :class="{ active : tab.active }" @click="setFileActive(tab)" > <button type="button" - class="close-btn" + class="multi-file-tab-close" @click.stop.prevent="closeFile({ file: tab })" - :aria-label="closeLabel"> + :aria-label="closeLabel" + :class="{ + 'modified': tab.changed, + }" + :disabled="tab.changed" + > <i class="fa" :class="changedClass" - aria-hidden="true"> + aria-hidden="true" + > </i> </button> - <a - href="#" - class="repo-tab" + <div + class="multi-file-tab" + :class="{active : tab.active }" :title="tab.url" - @click.prevent.stop="setFileActive(tab)"> - {{tab.name}} - </a> + > + {{ tab.name }} + </div> </li> </template> diff --git a/app/assets/javascripts/repo/components/repo_tabs.vue b/app/assets/javascripts/repo/components/repo_tabs.vue index 59beae53e8d..ab0bef4f0ac 100644 --- a/app/assets/javascripts/repo/components/repo_tabs.vue +++ b/app/assets/javascripts/repo/components/repo_tabs.vue @@ -16,14 +16,12 @@ <template> <ul - id="tabs" - class="list-unstyled" + class="multi-file-tabs list-unstyled append-bottom-0" > <repo-tab v-for="tab in openFiles" :key="tab.id" :tab="tab" /> - <li class="tabs-divider" /> </ul> </template> diff --git a/app/assets/javascripts/repo/stores/getters.js b/app/assets/javascripts/repo/stores/getters.js index 1ed05ac6e35..5ce9f449905 100644 --- a/app/assets/javascripts/repo/stores/getters.js +++ b/app/assets/javascripts/repo/stores/getters.js @@ -34,3 +34,7 @@ export const canEditFile = (state) => { openedFiles.length && (currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); }; + +export const addedFiles = state => changedFiles(state).filter(f => f.tempFile); + +export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile); diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 374988bb590..728f9a27aca 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -125,7 +125,7 @@ @include transition(border-color); } -.note-action-button .link-highlight, +.note-action-button, .toolbar-btn, .dropdown-toggle-caret { @include transition(color); diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index dfa3d4c6fb9..cdc2aa196dd 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -88,17 +88,6 @@ border-color: $border-dark; color: $color; } - - svg { - - path { - fill: $color; - } - - use { - stroke: $color; - } - } } @mixin btn-green { @@ -142,6 +131,13 @@ } } +@mixin btn-svg { + height: $gl-padding; + width: $gl-padding; + top: 0; + vertical-align: text-top; +} + .btn { @include btn-default; @include btn-white; @@ -440,3 +436,7 @@ text-decoration: none; } } + +.btn-svg svg { + @include btn-svg; +} diff --git a/app/assets/stylesheets/framework/common.scss b/app/assets/stylesheets/framework/common.scss index 5e4ddf366ef..cb1aad90a9c 100644 --- a/app/assets/stylesheets/framework/common.scss +++ b/app/assets/stylesheets/framework/common.scss @@ -2,14 +2,43 @@ .cgray { color: $common-gray; } .clgray { color: $common-gray-light; } .cred { color: $common-red; } -svg.cred { fill: $common-red; } .cgreen { color: $common-green; } -svg.cgreen { fill: $common-green; } .cdark { color: $common-gray-dark; } + +.text-plain, +.text-plain:hover { + color: $gl-text-color; +} + .text-secondary { color: $gl-text-color-secondary; } +.text-primary, +.text-primary:hover { + color: $brand-primary; +} + +.text-success, +.text-success:hover { + color: $brand-success; +} + +.text-danger, +.text-danger:hover { + color: $brand-danger; +} + +.text-warning, +.text-warning:hover { + color: $brand-warning; +} + +.text-info, +.text-info:hover { + color: $brand-info; +} + .underlined-link { text-decoration: underline; } .hint { font-style: italic; color: $hint-color; } .light { color: $common-gray; } diff --git a/app/assets/stylesheets/framework/dropdowns.scss b/app/assets/stylesheets/framework/dropdowns.scss index 579bd48fac6..30d5d7a653b 100644 --- a/app/assets/stylesheets/framework/dropdowns.scss +++ b/app/assets/stylesheets/framework/dropdowns.scss @@ -1002,6 +1002,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu { max-width: 250px; overflow: hidden; text-overflow: ellipsis; + white-space: nowrap; } &:hover { diff --git a/app/assets/stylesheets/framework/flash.scss b/app/assets/stylesheets/framework/flash.scss index e1b086ebb2b..88ce119ee3a 100644 --- a/app/assets/stylesheets/framework/flash.scss +++ b/app/assets/stylesheets/framework/flash.scss @@ -34,8 +34,15 @@ } } + .flash-success { + @extend .alert; + @extend .alert-success; + margin: 0; + } + .flash-notice, - .flash-alert { + .flash-alert, + .flash-success { border-radius: $border-radius-default; .container-fluid, @@ -48,7 +55,8 @@ margin-bottom: 0; .flash-notice, - .flash-alert { + .flash-alert, + .flash-success { border-radius: 0; } } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 1ab5e6a93f9..9e45ed52163 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -1,35 +1,63 @@ .ci-status-icon-success, .ci-status-icon-passed { - color: $green-500; + &, + &:hover, + &:focus { + color: $green-500; + } } .ci-status-icon-failed { - color: $gl-danger; + &, + &:hover, + &:focus { + color: $gl-danger; + } } .ci-status-icon-pending, .ci-status-icon-failed_with_warnings, .ci-status-icon-success_with_warnings { - color: $orange-500; + &, + &:hover, + &:focus { + color: $orange-500; + } } .ci-status-icon-running { - color: $blue-400; + &, + &:hover, + &:focus { + color: $blue-400; + } } .ci-status-icon-canceled, .ci-status-icon-disabled, .ci-status-icon-not-found { - color: $gl-text-color; + &, + &:hover, + &:focus { + color: $gl-text-color; + } } .ci-status-icon-created, .ci-status-icon-skipped { - color: $gray-darkest; + &, + &:hover, + &:focus { + color: $gray-darkest; + } } .ci-status-icon-manual { - color: $gl-text-color; + &, + &:hover, + &:focus { + color: $gl-text-color; + } } .icon-link { diff --git a/app/assets/stylesheets/framework/new-nav.scss b/app/assets/stylesheets/framework/new-nav.scss deleted file mode 100644 index e69de29bb2d..00000000000 --- a/app/assets/stylesheets/framework/new-nav.scss +++ /dev/null diff --git a/app/assets/stylesheets/framework/tw_bootstrap.scss b/app/assets/stylesheets/framework/tw_bootstrap.scss index d5c6ddbb4a5..1c6e2bf3074 100644 --- a/app/assets/stylesheets/framework/tw_bootstrap.scss +++ b/app/assets/stylesheets/framework/tw_bootstrap.scss @@ -195,33 +195,6 @@ summary { } } -// Typography ================================================================= - -.text-primary, -.text-primary:hover { - color: $brand-primary; -} - -.text-success, -.text-success:hover { - color: $brand-success; -} - -.text-danger, -.text-danger:hover { - color: $brand-danger; -} - -.text-warning, -.text-warning:hover { - color: $brand-warning; -} - -.text-info, -.text-info:hover { - color: $brand-info; -} - // Prevent datetimes on tooltips to break into two lines .local-timeago { white-space: nowrap; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 7131c18ce3d..63c51747f92 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -70,14 +70,13 @@ .title { padding: 0; - margin-bottom: 16px; + margin-bottom: $gl-padding; border-bottom: 0; } .btn-edit { margin-left: auto; - // Set height to match title height - height: 2em; + height: $gl-padding * 2; } // Border around images in issue and MR descriptions. diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 8bb68ad2425..d3dda2e7d25 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -203,7 +203,24 @@ ul.related-merge-requests > li { } .create-mr-dropdown-wrap { - @include new-style-dropdown; + .branch-message, + .ref-message { + display: none; + } + + .ref::selection { + color: $placeholder-text-color; + } + + .dropdown { + .dropdown-menu-toggle { + min-width: 285px; + } + + .dropdown-select { + width: 285px; + } + } .btn-group:not(.hide) { display: flex; @@ -214,15 +231,16 @@ ul.related-merge-requests > li { flex-shrink: 0; } - .dropdown-menu { + .create-merge-request-dropdown-menu { width: 300px; opacity: 1; visibility: visible; transform: translateY(0); display: none; + margin-top: 4px; } - .dropdown-toggle { + .create-merge-request-dropdown-toggle { .fa-caret-down { pointer-events: none; color: inherit; @@ -230,18 +248,50 @@ ul.related-merge-requests > li { } } + .droplab-item-ignore { + pointer-events: auto; + } + + .create-item { + cursor: pointer; + margin: 0 1px; + + &:hover, + &:focus { + background-color: $dropdown-item-hover-bg; + color: $gl-text-color; + } + } + + li.divider { + margin: 8px 10px; + } + li:not(.divider) { + padding: 8px 9px; + + &:last-child { + padding-bottom: 8px; + } + &.droplab-item-selected { .icon-container { i { visibility: visible; } } + + .description { + display: block; + } + } + + &.droplab-item-ignore { + padding-top: 8px; } .icon-container { float: left; - padding-left: 6px; i { visibility: hidden; @@ -249,13 +299,12 @@ ul.related-merge-requests > li { } .description { - padding-left: 30px; - font-size: 13px; + padding-left: 22px; + } - strong { - display: block; - font-weight: $gl-font-weight-bold; - } + input, + span { + margin: 4px 0 0; } } } diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 2461b818219..4fe182c9fce 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -543,10 +543,7 @@ ul.notes { } svg { - height: 16px; - width: 16px; - top: 0; - vertical-align: text-top; + @include btn-svg; } .award-control-icon-positive, @@ -780,12 +777,6 @@ ul.notes { } } - svg { - fill: currentColor; - height: 16px; - width: 16px; - } - .loading { margin: 0; height: auto; diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index aaad6dbba8e..2c83b30500d 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -291,14 +291,7 @@ } svg { - - path { - fill: $layout-link-gray; - } - - use { - stroke: $layout-link-gray; - } + fill: $layout-link-gray; } .fa-caret-down { @@ -886,10 +879,6 @@ pre.light-well { font-size: $gl-font-size; } - a { - color: $gl-text-color; - } - .avatar-container, .controls { flex: 0 0 auto; diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index d93c51d5448..6d274cb4ae0 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -35,270 +35,243 @@ } } -.repository-view { - border: 1px solid $border-color; - border-radius: $border-radius-default; - color: $almost-black; +.multi-file { + display: flex; + height: calc(100vh - 145px); + border-top: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + + &.is-collapsed { + .ide-file-list { + max-width: 250px; + } + } +} - .code.white pre .hll { - background-color: $well-light-border !important; +.ide-file-list { + flex: 1; + overflow: scroll; + + .file { + cursor: pointer; } - .tree-content-holder { - display: -webkit-flex; - display: flex; - min-height: 300px; + a { + color: $gl-text-color; } - .tree-content-holder-mini { - height: 100vh; + th { + position: sticky; + top: 0; } +} - .panel-right { - display: -webkit-flex; - display: flex; - -webkit-flex-direction: column; - flex-direction: column; - width: 80%; - height: 100%; +.multi-file-table-name, +.multi-file-table-col-commit-message { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 0; +} - .monaco-editor.vs { - .current-line { - border: 0; - background: $well-light-border; - } +.multi-file-table-name { + width: 350px; +} - .line-numbers { - cursor: pointer; +.multi-file-table-col-commit-message { + width: 50%; +} - &:hover { - text-decoration: underline; - } - } - } +.multi-file-edit-pane { + display: flex; + flex-direction: column; + flex: 1; + border-left: 1px solid $white-dark; + overflow: hidden; +} - .blob-no-preview { - .vertical-center { - justify-content: center; - width: 100%; - } - } +.multi-file-tabs { + display: flex; + overflow: scroll; + background-color: $white-normal; + box-shadow: inset 0 -1px $white-dark; - &.blob-editor-container { - overflow: hidden; - } + > li { + position: relative; + } +} - .blob-viewer-container { - -webkit-flex: 1; - flex: 1; - overflow: auto; - - > div, - .file-content:not(.wiki) { - display: flex; - } - - > div, - .file-content, - .blob-viewer, - .line-number, - .blob-content, - .code { - min-height: 100%; - width: 100%; - } - - .line-numbers { - min-width: 44px; - } - - .blob-content { - flex: 1; - overflow-x: auto; - } - } +.multi-file-tab { + @include str-truncated(150px); + padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding; + background-color: $gray-normal; + border-right: 1px solid $white-dark; + border-bottom: 1px solid $white-dark; + cursor: pointer; + + &.active { + background-color: $white-light; + border-bottom-color: $white-light; + } +} - #tabs { - position: relative; - flex-shrink: 0; - display: flex; - width: 100%; - padding-left: 0; - margin-bottom: 0; - white-space: nowrap; - overflow-y: hidden; - overflow-x: auto; - - li { - position: relative; - background: $gray-normal; - padding: #{$gl-padding / 2} $gl-padding; - border-right: 1px solid $white-dark; - border-bottom: 1px solid $white-dark; - cursor: pointer; - - &.active { - background: $white-light; - border-bottom: 0; - } - - a { - @include str-truncated(100px); - color: $gl-text-color; - vertical-align: middle; - text-decoration: none; - margin-right: 12px; - - &:focus { - outline: none; - } - } - - .close-btn { - position: absolute; - right: 8px; - top: 50%; - padding: 0; - background: none; - border: 0; - font-size: $gl-font-size; - transform: translateY(-50%); - } - - .close-icon:hover { - color: $hint-color; - } - - .close-icon, - .unsaved-icon { - color: $gray-darkest; - } - - .unsaved-icon { - color: $brand-success; - } - - &.tabs-divider { - width: 100%; - background-color: $white-light; - border-right: 0; - border-top-right-radius: 2px; - } - } - } +.multi-file-tab-close { + position: absolute; + right: 8px; + top: 50%; + padding: 0; + background: none; + border: 0; + font-size: $gl-font-size; + color: $gray-darkest; + transform: translateY(-50%); + + &:not(.modified):hover, + &:not(.modified):focus { + color: $hint-color; + } - .repo-file-buttons { - background-color: $white-light; - padding: 5px 10px; - border-top: 1px solid $white-normal; - } + &.modified { + color: $indigo-700; + } +} - #binary-viewer { - height: 80vh; - overflow: auto; - margin: 0; - - .blob-viewer { - padding-top: 20px; - padding-left: 20px; - } - - .binary-unknown { - text-align: center; - padding-top: 100px; - background: $gray-light; - height: 100%; - font-size: 17px; - - span { - display: block; - } - } - } +.multi-file-edit-pane-content { + flex: 1; + height: 0; +} + +.multi-file-editor-btn-group { + padding: $grid-size; + border-top: 1px solid $white-dark; +} + +// Not great, but this is to deal with our current output +.multi-file-preview-holder { + height: 100%; + overflow: scroll; + + .blob-viewer { + height: 100%; } - #commit-area { - background: $gray-light; - padding: 20px; + .file-content.code { + display: flex; - .help-block { - padding-top: 7px; - margin-top: 0; + i { + margin-left: -10px; } } - #view-toggler { - height: 41px; - position: relative; - display: block; - border-bottom: 1px solid $white-normal; - background: $white-light; - margin-top: -5px; + .line-numbers { + min-width: 50px; } - #binary-viewer { - img { - max-width: 100%; - } + .file-content, + .line-numbers, + .blob-content, + .code { + min-height: 100%; } +} - #sidebar { - flex: 1; - height: 100%; +.multi-file-commit-panel { + display: flex; + flex-direction: column; + height: 100%; + width: 290px; + padding: $gl-padding; + background-color: $gray-light; + border-left: 1px solid $white-dark; + + &.is-collapsed { + width: 60px; + padding: 0; + } +} - &.sidebar-mini { - width: 20%; - border-right: 1px solid $white-normal; - overflow: auto; - } +.multi-file-commit-panel-section { + display: flex; + flex-direction: column; + flex: 1; +} - .table { - margin-bottom: 0; - } +.multi-file-commit-panel-header { + display: flex; + align-items: center; + padding: 0 0 12px; + margin-bottom: 12px; + border-bottom: 1px solid $white-dark; - tr { - .repo-file-options { - padding: 2px 16px; - width: 100%; - } - - .title { - font-size: 10px; - text-transform: uppercase; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - vertical-align: middle; - } - - .file-icon { - margin-right: 5px; - } - - td { - white-space: nowrap; - } - } + &.is-collapsed { + border-bottom: 1px solid $white-dark; - .file { - cursor: pointer; + svg { + margin-left: auto; + margin-right: auto; } + } +} - a { - @include str-truncated(250px); - color: $almost-black; - } +.multi-file-commit-panel-collapse-btn { + padding-top: 0; + padding-bottom: 0; + margin-left: auto; + font-size: 20px; + + &.is-collapsed { + margin-right: auto; + } +} + +.multi-file-commit-list { + flex: 1; + overflow: scroll; +} + +.multi-file-commit-list-item { + display: flex; + align-items: center; +} + +.multi-file-addition { + fill: $green-500; +} + +.multi-file-modified { + fill: $orange-500; +} + +.multi-file-commit-list-collapsed { + display: flex; + flex-direction: column; + + > svg { + margin-left: auto; + margin-right: auto; } } -.render-error { - min-height: calc(100vh - 62px); +.multi-file-commit-list-path { + @include str-truncated(100%); +} + +.multi-file-commit-form { + padding-top: 12px; + border-top: 1px solid $white-dark; +} + +.multi-file-commit-fieldset { + display: flex; + align-items: center; + padding-bottom: 12px; - p { - width: 100%; + .btn { + flex: 1; } } -.multi-file-table-col-name { - width: 350px; +.multi-file-commit-message.form-control { + height: 80px; + resize: none; } diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index f6d9f88032f..744e448e8df 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -54,7 +54,7 @@ module IssuableActions end def destroy - Issuable::DestroyService.new(project, current_user).execute(issuable) + Issuable::DestroyService.new(issuable.project, current_user).execute(issuable) TodoService.new.destroy_issuable(issuable, current_user) name = issuable.human_class_name diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 28fee0465d5..d7a3441a245 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -158,7 +158,8 @@ class Projects::IssuesController < Projects::ApplicationController end def create_merge_request - result = ::MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute + create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid) + result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute if result[:status] == :success render json: MergeRequestCreateSerializer.new.represent(result[:merge_request]) diff --git a/app/controllers/projects/merge_requests/creations_controller.rb b/app/controllers/projects/merge_requests/creations_controller.rb index 764a9c7111e..1511fc08c89 100644 --- a/app/controllers/projects/merge_requests/creations_controller.rb +++ b/app/controllers/projects/merge_requests/creations_controller.rb @@ -65,7 +65,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap if params[:ref].present? @ref = params[:ref] - @commit = @repository.commit("refs/heads/#{@ref}") + @commit = @repository.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref) end render layout: false @@ -76,7 +76,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap if params[:ref].present? @ref = params[:ref] - @commit = @target_project.commit("refs/heads/#{@ref}") + @commit = @target_project.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref) end render layout: false diff --git a/app/controllers/projects/pipelines_settings_controller.rb b/app/controllers/projects/pipelines_settings_controller.rb index abab2e2f0c9..b890818c475 100644 --- a/app/controllers/projects/pipelines_settings_controller.rb +++ b/app/controllers/projects/pipelines_settings_controller.rb @@ -6,11 +6,19 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController end def update - if @project.update(update_params) - flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." - redirect_to project_settings_ci_cd_path(@project) - else - render 'show' + Projects::UpdateService.new(project, current_user, update_params).tap do |service| + if service.execute + flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." + + if service.run_auto_devops_pipeline? + CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false) + flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe + end + + redirect_to project_settings_ci_cd_path(@project) + else + render 'show' + end end end @@ -21,6 +29,7 @@ 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/finders/notes_finder.rb b/app/finders/notes_finder.rb index 02eb983bf55..12157818bcd 100644 --- a/app/finders/notes_finder.rb +++ b/app/finders/notes_finder.rb @@ -104,8 +104,7 @@ class NotesFinder query = @params[:search] return notes unless query - pattern = "%#{query}%" - notes.where(Note.arel_table[:note].matches(pattern)) + notes.search(query) end # Notes changed since last fetch diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 6fc4248b245..5bb84984142 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -177,6 +177,9 @@ module ApplicationSettingsHelper :ed25519_key_restriction, :email_author_in_body, :enabled_git_access_protocol, + :gitaly_timeout_default, + :gitaly_timeout_medium, + :gitaly_timeout_fast, :gravatar_enabled, :hashed_storage_enabled, :help_page_hide_commercial_content, diff --git a/app/helpers/auto_devops_helper.rb b/app/helpers/auto_devops_helper.rb index 483b957decb..069c29feb80 100644 --- a/app/helpers/auto_devops_helper.rb +++ b/app/helpers/auto_devops_helper.rb @@ -8,6 +8,22 @@ 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.kubernetes_service&.active? diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb index a9840d19178..4c60f4b0cd0 100644 --- a/app/helpers/issuables_helper.rb +++ b/app/helpers/issuables_helper.rb @@ -212,6 +212,7 @@ module IssuablesHelper def issuable_initial_data(issuable) data = { endpoint: issuable_path(issuable), + updateEndpoint: "#{issuable_path(issuable)}.json", canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable), canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable), issuableRef: issuable.to_reference, diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 01455a52d2a..3117c98c846 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -172,6 +172,27 @@ class ApplicationSetting < ActiveRecord::Base end end + validates :gitaly_timeout_default, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + + validates :gitaly_timeout_medium, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :gitaly_timeout_medium, + numericality: { less_than_or_equal_to: :gitaly_timeout_default }, + if: :gitaly_timeout_default + validates :gitaly_timeout_medium, + numericality: { greater_than_or_equal_to: :gitaly_timeout_fast }, + if: :gitaly_timeout_fast + + validates :gitaly_timeout_fast, + presence: true, + numericality: { only_integer: true, greater_than_or_equal_to: 0 } + validates :gitaly_timeout_fast, + numericality: { less_than_or_equal_to: :gitaly_timeout_default }, + if: :gitaly_timeout_default + SUPPORTED_KEY_TYPES.each do |type| validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type } end @@ -308,7 +329,10 @@ class ApplicationSetting < ActiveRecord::Base two_factor_grace_period: 48, user_default_external: false, polling_interval_multiplier: 1, - usage_ping_enabled: Settings.gitlab['usage_ping_enabled'] + usage_ping_enabled: Settings.gitlab['usage_ping_enabled'], + gitaly_timeout_fast: 10, + gitaly_timeout_medium: 30, + gitaly_timeout_default: 55 } end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index c6509f89117..d39610a8995 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -1,6 +1,7 @@ module Ci class Runner < ActiveRecord::Base extend Gitlab::Ci::Model + include Gitlab::SQL::Pattern RUNNER_QUEUE_EXPIRY_TIME = 60.minutes ONLINE_CONTACT_TIMEOUT = 1.hour @@ -59,10 +60,7 @@ module Ci # # Returns an ActiveRecord::Relation. def self.search(query) - t = arel_table - pattern = "%#{query}%" - - where(t[:token].matches(pattern).or(t[:description].matches(pattern))) + fuzzy_search(query, [:token, :description]) end def self.contact_time_deadline diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 27cd3118f81..5ca4a7086cb 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -122,9 +122,7 @@ module Issuable # # Returns an ActiveRecord::Relation. def search(query) - title = to_fuzzy_arel(:title, query) - - where(title) + fuzzy_search(query, [:title]) end # Searches for records with a matching title or description. @@ -135,10 +133,7 @@ module Issuable # # Returns an ActiveRecord::Relation. def full_search(query) - title = to_fuzzy_arel(:title, query) - description = to_fuzzy_arel(:description, query) - - where(title&.or(description)) + fuzzy_search(query, [:title, :description]) end def sort(method, excluded_labels: []) diff --git a/app/models/email.rb b/app/models/email.rb index 2da8b050149..d6516761f0a 100644 --- a/app/models/email.rb +++ b/app/models/email.rb @@ -1,5 +1,6 @@ class Email < ActiveRecord::Base include Sortable + include Gitlab::SQL::Pattern belongs_to :user diff --git a/app/models/group.rb b/app/models/group.rb index dc4500360b9..76262acf50c 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -50,20 +50,6 @@ class Group < Namespace Gitlab::Database.postgresql? end - # Searches for groups matching the given query. - # - # This method uses ILIKE on PostgreSQL and LIKE on MySQL. - # - # query - The search query as a String - # - # Returns an ActiveRecord::Relation. - def search(query) - table = Namespace.arel_table - pattern = "%#{query}%" - - where(table[:name].matches(pattern).or(table[:path].matches(pattern))) - end - def sort(method) if method == 'storage_size_desc' # storage_size is a virtual column so we need to diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index 2825735abc4..e232feaeada 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -364,16 +364,28 @@ class MergeRequest < ActiveRecord::Base # We use these attributes to force these to the intended values. attr_writer :target_branch_sha, :source_branch_sha + def source_branch_ref + return @source_branch_sha if @source_branch_sha + return unless source_branch + + Gitlab::Git::BRANCH_REF_PREFIX + source_branch + end + + def target_branch_ref + return @target_branch_sha if @target_branch_sha + return unless target_branch + + Gitlab::Git::BRANCH_REF_PREFIX + target_branch + end + def source_branch_head return unless source_project - source_branch_ref = @source_branch_sha || source_branch source_project.repository.commit(source_branch_ref) if source_branch_ref end def target_branch_head - target_branch_ref = @target_branch_sha || target_branch - target_project.repository.commit(target_branch_ref) if target_branch_ref + target_project.repository.commit(target_branch_ref) end def branch_merge_base_commit diff --git a/app/models/milestone.rb b/app/models/milestone.rb index 01458120cda..c06ee8083f0 100644 --- a/app/models/milestone.rb +++ b/app/models/milestone.rb @@ -13,6 +13,7 @@ class Milestone < ActiveRecord::Base include Referable include StripAttribute include Milestoneish + include Gitlab::SQL::Pattern cache_markdown_field :title, pipeline: :single_line cache_markdown_field :description @@ -73,10 +74,7 @@ class Milestone < ActiveRecord::Base # # Returns an ActiveRecord::Relation. def search(query) - t = arel_table - pattern = "%#{query}%" - - where(t[:title].matches(pattern).or(t[:description].matches(pattern))) + fuzzy_search(query, [:title, :description]) end def filter_by_state(milestones, state) diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 4d401e7ba18..fa76729a702 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -9,6 +9,7 @@ class Namespace < ActiveRecord::Base include Routable include AfterCommitQueue include Storage::LegacyNamespace + include Gitlab::SQL::Pattern # Prevent users from creating unreasonably deep level of nesting. # The number 20 was taken based on maximum nesting level of @@ -86,10 +87,7 @@ class Namespace < ActiveRecord::Base # # Returns an ActiveRecord::Relation def search(query) - t = arel_table - pattern = "%#{query}%" - - where(t[:name].matches(pattern).or(t[:path].matches(pattern))) + fuzzy_search(query, [:name, :path]) end def clean_path(path) diff --git a/app/models/note.rb b/app/models/note.rb index 50c9caf8529..340fe087f82 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -14,6 +14,7 @@ class Note < ActiveRecord::Base include ResolvableNote include IgnorableColumn include Editable + include Gitlab::SQL::Pattern module SpecialRole FIRST_TIME_CONTRIBUTOR = :first_time_contributor @@ -167,6 +168,10 @@ class Note < ActiveRecord::Base def has_special_role?(role, note) note.special_role == role end + + def search(query) + fuzzy_search(query, [:note]) + end end def cross_reference? diff --git a/app/models/project.rb b/app/models/project.rb index 85d580fe0fa..5a3f591c2e7 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -426,17 +426,11 @@ class Project < ActiveRecord::Base # # query - The search query as a String. def search(query) - pattern = to_pattern(query) - - where( - arel_table[:path].matches(pattern) - .or(arel_table[:name].matches(pattern)) - .or(arel_table[:description].matches(pattern)) - ) + fuzzy_search(query, [:path, :name, :description]) end def search_by_title(query) - non_archived.where(arel_table[:name].matches(to_pattern(query))) + non_archived.fuzzy_search(query, [:name]) end def visibility_levels diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 2a5f07a15c4..05a16f11b59 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -9,6 +9,7 @@ class Snippet < ActiveRecord::Base include Mentionable include Spammable include Editable + include Gitlab::SQL::Pattern extend Gitlab::CurrentSettings @@ -135,10 +136,7 @@ class Snippet < ActiveRecord::Base # # Returns an ActiveRecord::Relation. def search(query) - t = arel_table - pattern = "%#{query}%" - - where(t[:title].matches(pattern).or(t[:file_name].matches(pattern))) + fuzzy_search(query, [:title, :file_name]) end # Searches for snippets with matching content. @@ -149,10 +147,7 @@ class Snippet < ActiveRecord::Base # # Returns an ActiveRecord::Relation. def search_code(query) - table = Snippet.arel_table - pattern = "%#{query}%" - - where(table[:content].matches(pattern)) + fuzzy_search(query, [:content]) end end end diff --git a/app/models/user.rb b/app/models/user.rb index cf6b36559a8..14941fd7f98 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -313,9 +313,6 @@ class User < ActiveRecord::Base # # Returns an ActiveRecord::Relation. def search(query) - table = arel_table - pattern = User.to_pattern(query) - order = <<~SQL CASE WHEN users.name = %{query} THEN 0 @@ -325,11 +322,8 @@ class User < ActiveRecord::Base END SQL - where( - table[:name].matches(pattern) - .or(table[:email].matches(pattern)) - .or(table[:username].matches(pattern)) - ).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name) + fuzzy_search(query, [:name, :email, :username]) + .reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name) end # searches user by given pattern @@ -337,16 +331,16 @@ class User < ActiveRecord::Base # This method uses ILIKE on PostgreSQL and LIKE on MySQL. def search_with_secondary_emails(query) - table = arel_table email_table = Email.arel_table - pattern = "%#{query}%" - matched_by_emails_user_ids = email_table.project(email_table[:user_id]).where(email_table[:email].matches(pattern)) + matched_by_emails_user_ids = email_table + .project(email_table[:user_id]) + .where(Email.fuzzy_arel_match(:email, query)) where( - table[:name].matches(pattern) - .or(table[:email].matches(pattern)) - .or(table[:username].matches(pattern)) - .or(table[:id].in(matched_by_emails_user_ids)) + fuzzy_arel_match(:name, query) + .or(fuzzy_arel_match(:email, query)) + .or(fuzzy_arel_match(:username, query)) + .or(arel_table[:id].in(matched_by_emails_user_ids)) ) end diff --git a/app/services/compare_service.rb b/app/services/compare_service.rb index 53f16a236d2..1db91c3c90c 100644 --- a/app/services/compare_service.rb +++ b/app/services/compare_service.rb @@ -1,17 +1,17 @@ require 'securerandom' -# Compare 2 branches for one repo or between repositories +# Compare 2 refs for one repo or between repositories # and return Gitlab::Git::Compare object that responds to commits and diffs class CompareService - attr_reader :start_project, :start_branch_name + attr_reader :start_project, :start_ref_name - def initialize(new_start_project, new_start_branch_name) + def initialize(new_start_project, new_start_ref_name) @start_project = new_start_project - @start_branch_name = new_start_branch_name + @start_ref_name = new_start_ref_name end - def execute(target_project, target_branch, straight: false) - raw_compare = target_project.repository.compare_source_branch(target_branch, start_project.repository, start_branch_name, straight: straight) + def execute(target_project, target_ref, straight: false) + raw_compare = target_project.repository.compare_source_branch(target_ref, start_project.repository, start_ref_name, straight: straight) Compare.new(raw_compare, target_project, straight: straight) if raw_compare end diff --git a/app/services/merge_requests/build_service.rb b/app/services/merge_requests/build_service.rb index f3b99e1ec8c..c2fb01466df 100644 --- a/app/services/merge_requests/build_service.rb +++ b/app/services/merge_requests/build_service.rb @@ -1,6 +1,8 @@ module MergeRequests class BuildService < MergeRequests::BaseService def execute + @issue_iid = params.delete(:issue_iid) + self.merge_request = MergeRequest.new(params) merge_request.compare_commits = [] merge_request.source_project = find_source_project @@ -18,7 +20,17 @@ module MergeRequests attr_accessor :merge_request - delegate :target_branch, :source_branch, :source_project, :target_project, :compare_commits, :wip_title, :description, :errors, to: :merge_request + delegate :target_branch, + :target_branch_ref, + :target_project, + :source_branch, + :source_branch_ref, + :source_project, + :compare_commits, + :wip_title, + :description, + :errors, + to: :merge_request def find_source_project return source_project if source_project.present? && can?(current_user, :read_project, source_project) @@ -54,10 +66,10 @@ module MergeRequests def compare_branches compare = CompareService.new( source_project, - source_branch + source_branch_ref ).execute( target_project, - target_branch + target_branch_ref ) if compare @@ -106,37 +118,53 @@ module MergeRequests # more than one commit in the MR # def assign_title_and_description - if match = source_branch.match(/\A(\d+)-/) - iid = match[1] - end + assign_title_and_description_from_single_commit + assign_title_from_issue - commits = compare_commits - if commits && commits.count == 1 - commit = commits.first - merge_request.title = commit.title - merge_request.description ||= commit.description.try(:strip) - elsif iid && issue = target_project.get_issue(iid, current_user) - case issue - when Issue - merge_request.title = "Resolve \"#{issue.title}\"" - when ExternalIssue - merge_request.title = "Resolve #{issue.title}" - end + merge_request.title ||= source_branch.titleize.humanize + merge_request.title = wip_title if compare_commits.empty? + + append_closes_description + end + + def append_closes_description + return unless issue_iid + + closes_issue = "Closes ##{issue_iid}" + + if description.present? + merge_request.description += closes_issue.prepend("\n\n") else - merge_request.title = source_branch.titleize.humanize + merge_request.description = closes_issue end + end - if iid - closes_issue = "Closes ##{iid}" + def assign_title_and_description_from_single_commit + commits = compare_commits + + return unless commits&.count == 1 + + commit = commits.first + merge_request.title ||= commit.title + merge_request.description ||= commit.description.try(:strip) + end + + def assign_title_from_issue + return unless issue - if description.present? - merge_request.description += closes_issue.prepend("\n\n") - else - merge_request.description = closes_issue + merge_request.title = + case issue + when Issue then "Resolve \"#{issue.title}\"" + when ExternalIssue then "Resolve #{issue.title}" end - end + end + + def issue_iid + @issue_iid ||= source_branch.match(/\A(\d+)-/).try(:[], 1) + end - merge_request.title = wip_title if commits.empty? + def issue + @issue ||= target_project.get_issue(issue_iid, current_user) end end end diff --git a/app/services/merge_requests/create_from_issue_service.rb b/app/services/merge_requests/create_from_issue_service.rb index da39a380451..89dab1dd028 100644 --- a/app/services/merge_requests/create_from_issue_service.rb +++ b/app/services/merge_requests/create_from_issue_service.rb @@ -1,7 +1,18 @@ module MergeRequests class CreateFromIssueService < MergeRequests::CreateService + def initialize(project, user, params) + # branch - the name of new branch + # ref - the source of new branch. + + @branch_name = params[:branch_name] + @issue_iid = params[:issue_iid] + @ref = params[:ref] + + super(project, user) + end + def execute - return error('Invalid issue iid') unless issue_iid.present? && issue.present? + return error('Invalid issue iid') unless @issue_iid.present? && issue.present? params[:label_ids] = issue.label_ids if issue.label_ids.any? @@ -21,20 +32,16 @@ module MergeRequests private - def issue_iid - @isssue_iid ||= params.delete(:issue_iid) - end - def issue - @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid) + @issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: @issue_iid) end def branch_name - @branch_name ||= issue.to_branch_name + @branch ||= @branch_name || issue.to_branch_name end def ref - project.default_branch || 'master' + @ref || project.default_branch || 'master' end def merge_request @@ -43,6 +50,7 @@ module MergeRequests def merge_request_params { + issue_iid: @issue_iid, source_project_id: project.id, source_branch: branch_name, target_project_id: project.id, diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index 13e292a18bf..72eecc61c96 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(params.except(:default_branch)) + if project.update_attributes(update_params) if project.previous_changes.include?('path') project.rename_repo else @@ -31,8 +31,16 @@ module Projects end end + def run_auto_devops_pipeline? + params.dig(:run_auto_devops_pipeline_explicit) == 'true' || params.dig(:run_auto_devops_pipeline_implicit) == 'true' + 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/app/views/admin/application_settings/_form.html.haml b/app/views/admin/application_settings/_form.html.haml index 64249c91dd0..a9d0503bc73 100644 --- a/app/views/admin/application_settings/_form.html.haml +++ b/app/views/admin/application_settings/_form.html.haml @@ -732,6 +732,30 @@ Number of Git pushes after which 'git gc' is run. %fieldset + %legend Gitaly Timeouts + .form-group + = f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :gitaly_timeout_default, class: 'form-control' + .help-block + Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced + for git fetch/push operations or Sidekiq jobs. + .form-group + = f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :gitaly_timeout_fast, class: 'form-control' + .help-block + Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast. + If they exceed this threshold, there may be a problem with a storage shard and 'failing fast' + can help maintain the stability of the GitLab instance. + .form-group + = f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2' + .col-sm-10 + = f.number_field :gitaly_timeout_medium, class: 'form-control' + .help-block + Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout. + + %fieldset %legend Web terminal .form-group = f.label :terminal_max_session_time, 'Max session time', class: 'control-label col-sm-2' diff --git a/app/views/events/_event.atom.builder b/app/views/events/_event.atom.builder index e2aec532a9d..38741fe6662 100644 --- a/app/views/events/_event.atom.builder +++ b/app/views/events/_event.atom.builder @@ -5,7 +5,12 @@ xml.entry do xml.link href: event_feed_url(event) xml.title truncate(event_feed_title(event), length: 80) xml.updated event.updated_at.xmlschema - xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author_email)) + + # We're deliberately re-using "event.author" here since this data is + # eager-loaded. This allows us to re-use the user object's Email address, + # instead of having to run additional queries to figure out what Email to use + # for the avatar. + xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author)) xml.author do xml.username event.author_username diff --git a/app/views/layouts/_flash.html.haml b/app/views/layouts/_flash.html.haml index baa8036de10..1db32379df3 100644 --- a/app/views/layouts/_flash.html.haml +++ b/app/views/layouts/_flash.html.haml @@ -1,10 +1,6 @@ .flash-container.flash-container-page - - if alert - .flash-alert + -# We currently only support `alert`, `notice`, `success` + - flash.each do |key, value| + %div{ class: "flash-#{key}" } %div{ class: (container_class) } - %span= alert - - - elsif notice - .flash-notice - %div{ class: (container_class) } - %span= notice + %span= value diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index 8b9c1bbb602..5f607c2ab25 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -67,8 +67,8 @@ - if @commit.last_pipeline - last_pipeline = @commit.last_pipeline .well-segment.pipeline-info - .status-icon-container{ class: "ci-status-icon-#{last_pipeline.status}" } - = link_to project_pipeline_path(@project, last_pipeline.id) do + .status-icon-container + = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do = ci_icon_for_status(last_pipeline.status) #{ _('Pipeline') } = link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id) diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 4f78102be0c..331d62cf247 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -1,34 +1,50 @@ - can_create_merge_request = can?(current_user, :create_merge_request, @project) - data_action = can_create_merge_request ? 'create-mr' : 'create-branch' -- value = can_create_merge_request ? 'Create a merge request' : 'Create a branch' +- value = can_create_merge_request ? 'Create merge request' : 'Create branch' - if can?(current_user, :push_code, @project) - .create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_project_issue_path(@project, @issue), create_mr_path: create_merge_request_project_issue_path(@project, @issue), create_branch_path: project_branches_path(@project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } } + - can_create_path = can_create_branch_project_issue_path(@project, @issue) + - create_mr_path = create_merge_request_project_issue_path(@project, @issue, branch_name: @issue.to_branch_name, ref: @project.default_branch) + - create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid) + - refs_path = refs_namespace_project_path(@project.namespace, @project, search: '') + + .create-mr-dropdown-wrap{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } } .btn-group.unavailable %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' } = icon('spinner', class: 'fa-spin') %span.text Checking branch availability… .btn-group.available.hide - %input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: value, data: { action: data_action } } - %button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } } + %button.btn.js-create-merge-request.btn-default{ type: 'button', data: { action: data_action } } + = value + + %button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-default.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } } = icon('caret-down') - %ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } } + + %ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } } - if can_create_merge_request - %li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } } - .menu-item - .icon-container - = icon('check') - .description - %strong Create a merge request - %span - Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'. - %li.divider.droplab-item-ignore - %li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } - .menu-item - .icon-container - = icon('check') - .description - %strong Create a branch - %span - Creates a branch named after this issue, from '#{@project.default_branch}'. + %li.create-item.droplab-item-selected.droplab-item-ignore-hiding{ role: 'button', data: { value: 'create-mr', text: 'Create merge request' } } + .menu-item.droplab-item-ignore-hiding + .icon-container.droplab-item-ignore-hiding= icon('check') + .description.droplab-item-ignore-hiding Create merge request and branch + + %li.create-item.droplab-item-ignore-hiding{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: 'Create branch' } } + .menu-item.droplab-item-ignore-hiding + .icon-container.droplab-item-ignore-hiding= icon('check') + .description.droplab-item-ignore-hiding Create branch + %li.divider + + %li.droplab-item-ignore + Branch name + %input.js-branch-name.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" } + %span.js-branch-message.branch-message.droplab-item-ignore + + %li.droplab-item-ignore + Source (branch or tag) + %input.js-ref.ref.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } } + %span.js-ref-message.ref-message.droplab-item-ignore + + %li.droplab-item-ignore + %button.btn.btn-success.js-create-target.droplab-item-ignore{ type: 'button', data: { action: 'create-mr' } } + Create merge request + diff --git a/app/views/projects/pipelines_settings/_show.html.haml b/app/views/projects/pipelines_settings/_show.html.haml index 77211099830..ee4fa663b9f 100644 --- a/app/views/projects/pipelines_settings/_show.html.haml +++ b/app/views/projects/pipelines_settings/_show.html.haml @@ -13,29 +13,39 @@ %p.settings-message.text-center = message.html_safe = f.fields_for :auto_devops_attributes, @auto_devops do |form| - .radio + .radio.js-auto-devops-enable-radio-wrapper = form.label :enabled_true do - = form.radio_button :enabled, 'true' + = form.radio_button :enabled, 'true', class: 'js-auto-devops-enable-radio' %strong Enable Auto DevOps %br %span.descr The Auto DevOps pipeline configuration will be used when there is no <code>.gitlab-ci.yml</code> in the project. - .radio + - 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 = form.label :enabled_false do - = form.radio_button :enabled, 'false' + = form.radio_button :enabled, 'false', class: 'js-auto-devops-enable-radio' %strong Disable Auto DevOps %br %span.descr An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery. - .radio - = form.label :enabled_nil do - = form.radio_button :enabled, '' + .radio.js-auto-devops-enable-radio-wrapper + = form.label :enabled_ do + = form.radio_button :enabled, '', class: 'js-auto-devops-enable-radio' %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 <code>.gitlab-ci.yml</code>. - %br + - 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/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 745a6040488..64cc70053ef 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -11,6 +11,6 @@ = webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'repo' -%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] } +%div{ class: [(container_class unless show_new_repo?), ("limit-container-width" unless fluid_layout)] } = render 'projects/last_push' = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 52a8fe8bb67..98bfc7c4d36 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -20,7 +20,7 @@ = project_icon(project, alt: '', class: 'avatar project-avatar s40') .project-details %h3.prepend-top-0.append-bottom-0 - = link_to project_path(project), class: dom_class(project) do + = link_to project_path(project), class: 'text-plain' do %span.project-full-name %span.namespace-name - if project.namespace && !skip_namespace diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml index 5867ea58378..87e8c416194 100644 --- a/app/views/shared/repo/_repo.html.haml +++ b/app/views/shared/repo/_repo.html.haml @@ -1,3 +1,4 @@ +- @no_container = true; #repo{ data: { root: @path.empty?.to_s, root_url: project_tree_path(project), url: content_url, diff --git a/app/workers/create_pipeline_worker.rb b/app/workers/create_pipeline_worker.rb new file mode 100644 index 00000000000..865ad1ba420 --- /dev/null +++ b/app/workers/create_pipeline_worker.rb @@ -0,0 +1,16 @@ +class CreatePipelineWorker + include Sidekiq::Worker + include PipelineQueue + + enqueue_in group: :creation + + def perform(project_id, user_id, ref, source, params = {}) + project = Project.find(project_id) + user = User.find(user_id) + params = params.deep_symbolize_keys + + Ci::CreatePipelineService + .new(project, user, ref: ref) + .execute(source, **params) + end +end diff --git a/changelogs/unreleased/15588-fix-empty-dropdown-on-create-new-pipeline-in-case-of-validation-errors.yml b/changelogs/unreleased/15588-fix-empty-dropdown-on-create-new-pipeline-in-case-of-validation-errors.yml new file mode 100644 index 00000000000..a4934c8896d --- /dev/null +++ b/changelogs/unreleased/15588-fix-empty-dropdown-on-create-new-pipeline-in-case-of-validation-errors.yml @@ -0,0 +1,5 @@ +--- +title: Initializes the branches dropdown when the 'Start new pipeline' failed due to validation errors +merge_request: 15588 +author: Christiaan Van den Poel +type: fixed diff --git a/changelogs/unreleased/21143-customize-branch-name-when-using-create-branch-in-an-issue.yml b/changelogs/unreleased/21143-customize-branch-name-when-using-create-branch-in-an-issue.yml new file mode 100644 index 00000000000..d4c209aaf2b --- /dev/null +++ b/changelogs/unreleased/21143-customize-branch-name-when-using-create-branch-in-an-issue.yml @@ -0,0 +1,5 @@ +--- +title: Add an ability to use a custom branch name on creation from issues +merge_request: 13884 +author: Vitaliy @blackst0ne Klachkov +type: added diff --git a/changelogs/unreleased/38962-automatically-run-a-pipeline-when-auto-devops-is-turned-on-in-project-settings.yml b/changelogs/unreleased/38962-automatically-run-a-pipeline-when-auto-devops-is-turned-on-in-project-settings.yml new file mode 100644 index 00000000000..a4d703bc69f --- /dev/null +++ b/changelogs/unreleased/38962-automatically-run-a-pipeline-when-auto-devops-is-turned-on-in-project-settings.yml @@ -0,0 +1,5 @@ +--- +title: Add the option to automatically run a pipeline after updating AutoDevOps settings +merge_request: 15380 +author: +type: changed diff --git a/changelogs/unreleased/39827-fix-projects-dropdown-overflow.yml b/changelogs/unreleased/39827-fix-projects-dropdown-overflow.yml new file mode 100644 index 00000000000..ebd7b582e8a --- /dev/null +++ b/changelogs/unreleased/39827-fix-projects-dropdown-overflow.yml @@ -0,0 +1,5 @@ +--- +title: Fix item name and namespace text overflow in Projects dropdown +merge_request: 15451 +author: +type: fixed diff --git a/changelogs/unreleased/40373-fix-issue-note-submit-disabled-on-paste.yml b/changelogs/unreleased/40373-fix-issue-note-submit-disabled-on-paste.yml new file mode 100644 index 00000000000..e683e60397e --- /dev/null +++ b/changelogs/unreleased/40373-fix-issue-note-submit-disabled-on-paste.yml @@ -0,0 +1,6 @@ +--- +title: Fix Issue comment submit button being disabled when pasting content from another + GFM note +merge_request: 15530 +author: +type: fixed diff --git a/changelogs/unreleased/40530-merge-request-generates-wrong-diff-when-branch-and-tag-have-the-same-name.yml b/changelogs/unreleased/40530-merge-request-generates-wrong-diff-when-branch-and-tag-have-the-same-name.yml new file mode 100644 index 00000000000..e9fae6fe0d7 --- /dev/null +++ b/changelogs/unreleased/40530-merge-request-generates-wrong-diff-when-branch-and-tag-have-the-same-name.yml @@ -0,0 +1,5 @@ +--- +title: Fix merge requests where the source or target branch name matches a tag name +merge_request: 15591 +author: +type: fixed diff --git a/changelogs/unreleased/an-gitaly-timeouts.yml b/changelogs/unreleased/an-gitaly-timeouts.yml new file mode 100644 index 00000000000..e18d82b2704 --- /dev/null +++ b/changelogs/unreleased/an-gitaly-timeouts.yml @@ -0,0 +1,5 @@ +--- +title: Add timeouts for Gitaly calls +merge_request: 15047 +author: +type: performance diff --git a/changelogs/unreleased/bvl-fork-networks-for-deleted-projects.yml b/changelogs/unreleased/bvl-fork-networks-for-deleted-projects.yml new file mode 100644 index 00000000000..2acb98db785 --- /dev/null +++ b/changelogs/unreleased/bvl-fork-networks-for-deleted-projects.yml @@ -0,0 +1,5 @@ +--- +title: Create a fork network for forks with a deleted source +merge_request: 15595 +author: +type: fixed diff --git a/changelogs/unreleased/dm-search-pattern.yml b/changelogs/unreleased/dm-search-pattern.yml new file mode 100644 index 00000000000..1670d8c4b9a --- /dev/null +++ b/changelogs/unreleased/dm-search-pattern.yml @@ -0,0 +1,5 @@ +--- +title: Use fuzzy search with minimum length of 3 characters where appropriate +merge_request: +author: +type: performance diff --git a/changelogs/unreleased/events-atom-feed-author-query.yml b/changelogs/unreleased/events-atom-feed-author-query.yml new file mode 100644 index 00000000000..84c51f25de7 --- /dev/null +++ b/changelogs/unreleased/events-atom-feed-author-query.yml @@ -0,0 +1,5 @@ +--- +title: Reuse authors when rendering event Atom feeds +merge_request: +author: +type: performance diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index a8b918177de..bc7c431731a 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -28,6 +28,7 @@ - [build, 2] - [pipeline, 2] - [pipeline_processing, 5] + - [pipeline_creation, 4] - [pipeline_default, 3] - [pipeline_cache, 3] - [pipeline_hooks, 2] diff --git a/db/migrate/20171101130535_add_gitaly_timeout_properties_to_application_settings.rb b/db/migrate/20171101130535_add_gitaly_timeout_properties_to_application_settings.rb new file mode 100644 index 00000000000..de621e7111c --- /dev/null +++ b/db/migrate/20171101130535_add_gitaly_timeout_properties_to_application_settings.rb @@ -0,0 +1,31 @@ +# See http://doc.gitlab.com/ce/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddGitalyTimeoutPropertiesToApplicationSettings < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + def up + add_column_with_default :application_settings, + :gitaly_timeout_default, + :integer, + default: 55 + add_column_with_default :application_settings, + :gitaly_timeout_medium, + :integer, + default: 30 + add_column_with_default :application_settings, + :gitaly_timeout_fast, + :integer, + default: 10 + end + + def down + remove_column :application_settings, :gitaly_timeout_default + remove_column :application_settings, :gitaly_timeout_medium + remove_column :application_settings, :gitaly_timeout_fast + end +end diff --git a/db/post_migrate/20171124150326_reschedule_fork_network_creation.rb b/db/post_migrate/20171124150326_reschedule_fork_network_creation.rb new file mode 100644 index 00000000000..05430efe1f6 --- /dev/null +++ b/db/post_migrate/20171124150326_reschedule_fork_network_creation.rb @@ -0,0 +1,27 @@ +class RescheduleForkNetworkCreation < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + MIGRATION = 'PopulateForkNetworksRange'.freeze + BATCH_SIZE = 100 + DELAY_INTERVAL = 15.seconds + + disable_ddl_transaction! + + class ForkedProjectLink < ActiveRecord::Base + include EachBatch + + self.table_name = 'forked_project_links' + end + + def up + say 'Populating the `fork_networks` based on existing `forked_project_links`' + + queue_background_migration_jobs_by_range_at_intervals(ForkedProjectLink, MIGRATION, DELAY_INTERVAL, batch_size: BATCH_SIZE) + end + + def down + # nothing + end +end diff --git a/db/schema.rb b/db/schema.rb index 339c98d7798..effb2604af2 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: 20171124132536) do +ActiveRecord::Schema.define(version: 20171124150326) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -150,6 +150,9 @@ ActiveRecord::Schema.define(version: 20171124132536) do t.integer "throttle_authenticated_web_period_in_seconds", default: 3600, 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 + t.integer "gitaly_timeout_medium", default: 30, null: false + t.integer "gitaly_timeout_fast", default: 10, null: false end create_table "audit_events", force: :cascade do |t| diff --git a/doc/administration/troubleshooting/sidekiq.md b/doc/administration/troubleshooting/sidekiq.md index b71f8fabbc8..9d157720ad2 100644 --- a/doc/administration/troubleshooting/sidekiq.md +++ b/doc/administration/troubleshooting/sidekiq.md @@ -11,7 +11,7 @@ troubleshooting steps that will help you diagnose the bottleneck. debug steps with GitLab Support so the backtraces can be analyzed by our team. It may reveal a bug or necessary improvement in GitLab. -> **Note:** In any of the backtraces, be weary of suspecting cases where every +> **Note:** In any of the backtraces, be wary of suspecting cases where every thread appears to be waiting in the database, Redis, or waiting to acquire a mutex. This **may** mean there's contention in the database, for example, but look for one thread that is different than the rest. This other thread diff --git a/doc/topics/autodevops/img/auto_devops_settings.png b/doc/topics/autodevops/img/auto_devops_settings.png Binary files differnew file mode 100644 index 00000000000..b572cc5b855 --- /dev/null +++ b/doc/topics/autodevops/img/auto_devops_settings.png diff --git a/doc/topics/autodevops/index.md b/doc/topics/autodevops/index.md index 28308fc905c..914217772b8 100644 --- a/doc/topics/autodevops/index.md +++ b/doc/topics/autodevops/index.md @@ -121,7 +121,7 @@ Google Cloud. ## Enabling Auto DevOps -NOTE: **Note:** +**Note:** If you haven't done already, read the [prerequisites](#prerequisites) to make full use of Auto DevOps. If this is your fist time, we recommend you follow the [quick start guide](#quick-start). @@ -129,10 +129,14 @@ 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 +![Project AutoDevops settings section](img/auto_devops_settings.png) + Now that it's enabled, there are a few more steps depending on whether your project has a `.gitlab-ci.yml` or not: diff --git a/lib/api/helpers/internal_helpers.rb b/lib/api/helpers/internal_helpers.rb index 4b3c473b0bb..d6dea4c30e3 100644 --- a/lib/api/helpers/internal_helpers.rb +++ b/lib/api/helpers/internal_helpers.rb @@ -2,8 +2,8 @@ module API module Helpers module InternalHelpers SSH_GITALY_FEATURES = { - 'git-receive-pack' => :ssh_receive_pack, - 'git-upload-pack' => :ssh_upload_pack + 'git-receive-pack' => [:ssh_receive_pack, Gitlab::GitalyClient::MigrationStatus::OPT_IN], + 'git-upload-pack' => [:ssh_upload_pack, Gitlab::GitalyClient::MigrationStatus::OPT_OUT] }.freeze def wiki? @@ -102,8 +102,8 @@ module API # Return the Gitaly Address if it is enabled def gitaly_payload(action) - feature = SSH_GITALY_FEATURES[action] - return unless feature && Gitlab::GitalyClient.feature_enabled?(feature) + feature, status = SSH_GITALY_FEATURES[action] + return unless feature && Gitlab::GitalyClient.feature_enabled?(feature, status: status) { repository: repository.gitaly_repository, diff --git a/lib/api/settings.rb b/lib/api/settings.rb index 06373fe5069..cee4d309816 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -123,6 +123,9 @@ module API end optional :terminal_max_session_time, type: Integer, desc: 'Maximum time for web terminal websocket connection (in seconds). Set to 0 for unlimited time.' optional :polling_interval_multiplier, type: BigDecimal, desc: 'Interval multiplier used by endpoints that perform polling. Set to 0 to disable polling.' + optional :gitaly_timeout_default, type: Integer, desc: 'Default Gitaly timeout, in seconds. Set to 0 to disable timeouts.' + optional :gitaly_timeout_medium, type: Integer, desc: 'Medium Gitaly timeout, in seconds. Set to 0 to disable timeouts.' + optional :gitaly_timeout_fast, type: Integer, desc: 'Gitaly fast operation timeout, in seconds. Set to 0 to disable timeouts.' ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| optional :"#{type}_key_restriction", diff --git a/lib/gitlab/background_migration/populate_fork_networks_range.rb b/lib/gitlab/background_migration/populate_fork_networks_range.rb index f8508b5fbdf..a976cb4c243 100644 --- a/lib/gitlab/background_migration/populate_fork_networks_range.rb +++ b/lib/gitlab/background_migration/populate_fork_networks_range.rb @@ -1,30 +1,55 @@ # frozen_string_literal: true -# rubocop:disable Metrics/MethodLength -# rubocop:disable Metrics/LineLength -# rubocop:disable Style/Documentation module Gitlab module BackgroundMigration + # This background migration is going to create all `fork_networks` and + # the `fork_network_members` for the roots of fork networks based on the + # existing `forked_project_links`. + # + # When the source of a fork is deleted, we will create the fork with the + # target project as the root. This way, when there are forks of the target + # project, they will be joined into the same fork network. + # + # When the `fork_networks` and memberships for the root projects are created + # the `CreateForkNetworkMembershipsRange` migration is scheduled. This + # migration will create the memberships for all remaining forks-of-forks class PopulateForkNetworksRange def perform(start_id, end_id) - log("Creating fork networks for forked project links: #{start_id} - #{end_id}") + create_fork_networks_for_existing_projects(start_id, end_id) + create_fork_networks_for_missing_projects(start_id, end_id) + create_fork_networks_memberships_for_root_projects(start_id, end_id) + delay = BackgroundMigration::CreateForkNetworkMembershipsRange::RESCHEDULE_DELAY # rubocop:disable Metrics/LineLength + BackgroundMigrationWorker.perform_in( + delay, "CreateForkNetworkMembershipsRange", [start_id, end_id] + ) + end + + def create_fork_networks_for_existing_projects(start_id, end_id) + log("Creating fork networks: #{start_id} - #{end_id}") ActiveRecord::Base.connection.execute <<~INSERT_NETWORKS INSERT INTO fork_networks (root_project_id) SELECT DISTINCT forked_project_links.forked_from_project_id FROM forked_project_links + -- Exclude the forks that are not the first level fork of a project WHERE NOT EXISTS ( SELECT true FROM forked_project_links inner_links WHERE inner_links.forked_to_project_id = forked_project_links.forked_from_project_id ) + + /* Exclude the ones that are already created, in case the fork network + was already created for another fork of the project. + */ AND NOT EXISTS ( SELECT true FROM fork_networks WHERE forked_project_links.forked_from_project_id = fork_networks.root_project_id ) + + -- Only create a fork network for a root project that still exists AND EXISTS ( SELECT true FROM projects @@ -32,7 +57,45 @@ module Gitlab ) AND forked_project_links.id BETWEEN #{start_id} AND #{end_id} INSERT_NETWORKS + end + + def create_fork_networks_for_missing_projects(start_id, end_id) + log("Creating fork networks with missing root: #{start_id} - #{end_id}") + ActiveRecord::Base.connection.execute <<~INSERT_NETWORKS + INSERT INTO fork_networks (root_project_id) + SELECT DISTINCT forked_project_links.forked_to_project_id + + FROM forked_project_links + + -- Exclude forks that are not the root forks + WHERE NOT EXISTS ( + SELECT true + FROM forked_project_links inner_links + WHERE inner_links.forked_to_project_id = forked_project_links.forked_from_project_id + ) + /* Exclude the ones that are already created, in case this migration is + re-run + */ + AND NOT EXISTS ( + SELECT true + FROM fork_networks + WHERE forked_project_links.forked_to_project_id = fork_networks.root_project_id + ) + + /* Exclude projects for which the project still exists, those are + Processed in the previous step of this migration + */ + AND NOT EXISTS ( + SELECT true + FROM projects + WHERE projects.id = forked_project_links.forked_from_project_id + ) + AND forked_project_links.id BETWEEN #{start_id} AND #{end_id} + INSERT_NETWORKS + end + + def create_fork_networks_memberships_for_root_projects(start_id, end_id) log("Creating memberships for root projects: #{start_id} - #{end_id}") ActiveRecord::Base.connection.execute <<~INSERT_ROOT @@ -41,8 +104,12 @@ module Gitlab FROM fork_networks + /* Joining both on forked_from- and forked_to- so we could create the + memberships for forks for which the source was deleted + */ INNER JOIN forked_project_links ON forked_project_links.forked_from_project_id = fork_networks.root_project_id + OR forked_project_links.forked_to_project_id = fork_networks.root_project_id WHERE NOT EXISTS ( SELECT true @@ -51,9 +118,6 @@ module Gitlab ) AND forked_project_links.id BETWEEN #{start_id} AND #{end_id} INSERT_ROOT - - delay = BackgroundMigration::CreateForkNetworkMembershipsRange::RESCHEDULE_DELAY - BackgroundMigrationWorker.perform_in(delay, "CreateForkNetworkMembershipsRange", [start_id, end_id]) end def log(message) diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index a6e7c410bdd..d399636bb28 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -1046,9 +1046,15 @@ module Gitlab end def with_repo_tmp_commit(start_repository, start_branch_name, sha) + source_ref = start_branch_name + + unless Gitlab::Git.branch_ref?(source_ref) + source_ref = "#{Gitlab::Git::BRANCH_REF_PREFIX}#{source_ref}" + end + tmp_ref = fetch_ref( start_repository, - source_ref: "#{Gitlab::Git::BRANCH_REF_PREFIX}#{start_branch_name}", + source_ref: source_ref, target_ref: "refs/tmp/#{SecureRandom.hex}" ) diff --git a/lib/gitlab/gitaly_client.rb b/lib/gitlab/gitaly_client.rb index d7375938ab6..f27cd800bdd 100644 --- a/lib/gitlab/gitaly_client.rb +++ b/lib/gitlab/gitaly_client.rb @@ -117,11 +117,11 @@ module Gitlab # kwargs.merge(deadline: Time.now + 10) # end # - def self.call(storage, service, rpc, request, remote_storage: nil) + def self.call(storage, service, rpc, request, remote_storage: nil, timeout: nil) start = Gitlab::Metrics::System.monotonic_time enforce_gitaly_request_limits(:call) - kwargs = request_kwargs(storage, remote_storage: remote_storage) + kwargs = request_kwargs(storage, timeout, remote_storage: remote_storage) kwargs = yield(kwargs) if block_given? stub(service, storage).__send__(rpc, request, kwargs) # rubocop:disable GitlabSecurity/PublicSend @@ -140,7 +140,7 @@ module Gitlab end private_class_method :current_transaction_labels - def self.request_kwargs(storage, remote_storage: nil) + def self.request_kwargs(storage, timeout, remote_storage: nil) encoded_token = Base64.strict_encode64(token(storage).to_s) metadata = { 'authorization' => "Bearer #{encoded_token}", @@ -152,7 +152,22 @@ module Gitlab metadata['call_site'] = feature.to_s if feature metadata['gitaly-servers'] = address_metadata(remote_storage) if remote_storage - { metadata: metadata } + result = { metadata: metadata } + + # nil timeout indicates that we should use the default + timeout = default_timeout if timeout.nil? + + return result unless timeout > 0 + + # Do not use `Time.now` for deadline calculation, since it + # will be affected by Timecop in some tests, but grpc's c-core + # uses system time instead of timecop's time, so tests will fail + # `Time.at(Process.clock_gettime(Process::CLOCK_REALTIME))` will + # circumvent timecop + deadline = Time.at(Process.clock_gettime(Process::CLOCK_REALTIME)) + timeout + result[:deadline] = deadline + + result end def self.token(storage) @@ -325,6 +340,26 @@ module Gitlab Google::Protobuf::RepeatedField.new(:bytes, a.map { |s| self.encode(s) } ) end + # The default timeout on all Gitaly calls + def self.default_timeout + return 0 if Sidekiq.server? + + timeout(:gitaly_timeout_default) + end + + def self.fast_timeout + timeout(:gitaly_timeout_fast) + end + + def self.medium_timeout + timeout(:gitaly_timeout_medium) + end + + def self.timeout(timeout_name) + Gitlab::CurrentSettings.current_application_settings[timeout_name] + end + private_class_method :timeout + # Count a stack. Used for n+1 detection def self.count_stack return unless RequestStore.active? diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index da5505cb2fe..34807d280e5 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -16,7 +16,7 @@ module Gitlab revision: GitalyClient.encode(revision) ) - response = GitalyClient.call(@repository.storage, :commit_service, :list_files, request) + response = GitalyClient.call(@repository.storage, :commit_service, :list_files, request, timeout: GitalyClient.medium_timeout) response.flat_map do |msg| msg.paths.map { |d| EncodingHelper.encode!(d.dup) } end @@ -29,7 +29,7 @@ module Gitlab child_id: child_id ) - GitalyClient.call(@repository.storage, :commit_service, :commit_is_ancestor, request).value + GitalyClient.call(@repository.storage, :commit_service, :commit_is_ancestor, request, timeout: GitalyClient.fast_timeout).value end def diff(from, to, options = {}) @@ -77,7 +77,7 @@ module Gitlab limit: limit.to_i ) - response = GitalyClient.call(@repository.storage, :commit_service, :tree_entry, request) + response = GitalyClient.call(@repository.storage, :commit_service, :tree_entry, request, timeout: GitalyClient.medium_timeout) entry = nil data = '' @@ -102,7 +102,7 @@ module Gitlab path: path.present? ? GitalyClient.encode(path) : '.' ) - response = GitalyClient.call(@repository.storage, :commit_service, :get_tree_entries, request) + response = GitalyClient.call(@repository.storage, :commit_service, :get_tree_entries, request, timeout: GitalyClient.medium_timeout) response.flat_map do |message| message.entries.map do |gitaly_tree_entry| @@ -129,7 +129,7 @@ module Gitlab request.before = Google::Protobuf::Timestamp.new(seconds: options[:before].to_i) if options[:before].present? request.path = options[:path] if options[:path].present? - GitalyClient.call(@repository.storage, :commit_service, :count_commits, request).count + GitalyClient.call(@repository.storage, :commit_service, :count_commits, request, timeout: GitalyClient.medium_timeout).count end def last_commit_for_path(revision, path) @@ -139,7 +139,7 @@ module Gitlab path: GitalyClient.encode(path.to_s) ) - gitaly_commit = GitalyClient.call(@repository.storage, :commit_service, :last_commit_for_path, request).commit + gitaly_commit = GitalyClient.call(@repository.storage, :commit_service, :last_commit_for_path, request, timeout: GitalyClient.fast_timeout).commit return unless gitaly_commit Gitlab::Git::Commit.new(@repository, gitaly_commit) @@ -152,7 +152,7 @@ module Gitlab to: to ) - response = GitalyClient.call(@repository.storage, :commit_service, :commits_between, request) + response = GitalyClient.call(@repository.storage, :commit_service, :commits_between, request, timeout: GitalyClient.medium_timeout) consume_commits_response(response) end @@ -165,7 +165,7 @@ module Gitlab ) request.order = opts[:order].upcase if opts[:order].present? - response = GitalyClient.call(@repository.storage, :commit_service, :find_all_commits, request) + response = GitalyClient.call(@repository.storage, :commit_service, :find_all_commits, request, timeout: GitalyClient.medium_timeout) consume_commits_response(response) end @@ -179,7 +179,7 @@ module Gitlab offset: offset.to_i ) - response = GitalyClient.call(@repository.storage, :commit_service, :commits_by_message, request) + response = GitalyClient.call(@repository.storage, :commit_service, :commits_by_message, request, timeout: GitalyClient.medium_timeout) consume_commits_response(response) end @@ -197,7 +197,7 @@ module Gitlab path: GitalyClient.encode(path) ) - response = GitalyClient.call(@repository.storage, :commit_service, :raw_blame, request) + response = GitalyClient.call(@repository.storage, :commit_service, :raw_blame, request, timeout: GitalyClient.medium_timeout) response.reduce("") { |memo, msg| memo << msg.data } end @@ -207,7 +207,7 @@ module Gitlab revision: GitalyClient.encode(revision) ) - response = GitalyClient.call(@repository.storage, :commit_service, :find_commit, request) + response = GitalyClient.call(@repository.storage, :commit_service, :find_commit, request, timeout: GitalyClient.medium_timeout) response.commit end @@ -217,7 +217,7 @@ module Gitlab repository: @gitaly_repo, revision: GitalyClient.encode(revision) ) - response = GitalyClient.call(@repository.storage, :diff_service, :commit_patch, request) + response = GitalyClient.call(@repository.storage, :diff_service, :commit_patch, request, timeout: GitalyClient.medium_timeout) response.sum(&:data) end @@ -227,7 +227,7 @@ module Gitlab repository: @gitaly_repo, revision: GitalyClient.encode(revision) ) - GitalyClient.call(@repository.storage, :commit_service, :commit_stats, request) + GitalyClient.call(@repository.storage, :commit_service, :commit_stats, request, timeout: GitalyClient.medium_timeout) end def find_commits(options) @@ -245,7 +245,7 @@ module Gitlab request.paths = GitalyClient.encode_repeated(Array(options[:path])) if options[:path].present? - response = GitalyClient.call(@repository.storage, :commit_service, :find_commits, request) + response = GitalyClient.call(@repository.storage, :commit_service, :find_commits, request, timeout: GitalyClient.medium_timeout) consume_commits_response(response) end @@ -259,7 +259,7 @@ module Gitlab request_params.merge!(Gitlab::Git::DiffCollection.collection_limits(options).to_h) request = Gitaly::CommitDiffRequest.new(request_params) - response = GitalyClient.call(@repository.storage, :diff_service, :commit_diff, request) + response = GitalyClient.call(@repository.storage, :diff_service, :commit_diff, request, timeout: GitalyClient.medium_timeout) GitalyClient::DiffStitcher.new(response) end diff --git a/lib/gitlab/gitaly_client/ref_service.rb b/lib/gitlab/gitaly_client/ref_service.rb index 31b04bc2650..066e4e183c0 100644 --- a/lib/gitlab/gitaly_client/ref_service.rb +++ b/lib/gitlab/gitaly_client/ref_service.rb @@ -46,7 +46,8 @@ module Gitlab commit_id: commit_id, prefix: ref_prefix ) - encode!(GitalyClient.call(@storage, :ref_service, :find_ref_name, request).name.dup) + response = GitalyClient.call(@storage, :ref_service, :find_ref_name, request, timeout: GitalyClient.medium_timeout) + encode!(response.name.dup) end def count_tag_names diff --git a/lib/gitlab/gitaly_client/repository_service.rb b/lib/gitlab/gitaly_client/repository_service.rb index 70cb16bd810..b9e606592d7 100644 --- a/lib/gitlab/gitaly_client/repository_service.rb +++ b/lib/gitlab/gitaly_client/repository_service.rb @@ -10,7 +10,9 @@ module Gitlab def exists? request = Gitaly::RepositoryExistsRequest.new(repository: @gitaly_repo) - GitalyClient.call(@storage, :repository_service, :repository_exists, request).exists + response = GitalyClient.call(@storage, :repository_service, :repository_exists, request, timeout: GitalyClient.fast_timeout) + + response.exists end def garbage_collect(create_bitmap) @@ -30,7 +32,8 @@ module Gitlab def repository_size request = Gitaly::RepositorySizeRequest.new(repository: @gitaly_repo) - GitalyClient.call(@storage, :repository_service, :repository_size, request).size + response = GitalyClient.call(@storage, :repository_service, :repository_size, request) + response.size end def apply_gitattributes(revision) @@ -61,7 +64,7 @@ module Gitlab def has_local_branches? request = Gitaly::HasLocalBranchesRequest.new(repository: @gitaly_repo) - response = GitalyClient.call(@storage, :repository_service, :has_local_branches, request) + response = GitalyClient.call(@storage, :repository_service, :has_local_branches, request, timeout: GitalyClient.fast_timeout) response.value end diff --git a/lib/gitlab/sql/pattern.rb b/lib/gitlab/sql/pattern.rb index 7c2d1d8f887..5f0c98cb5a4 100644 --- a/lib/gitlab/sql/pattern.rb +++ b/lib/gitlab/sql/pattern.rb @@ -4,9 +4,15 @@ module Gitlab extend ActiveSupport::Concern MIN_CHARS_FOR_PARTIAL_MATCHING = 3 - REGEX_QUOTED_WORD = /(?<=^| )"[^"]+"(?= |$)/ + REGEX_QUOTED_WORD = /(?<=\A| )"[^"]+"(?= |\z)/ class_methods do + def fuzzy_search(query, columns) + matches = columns.map { |col| fuzzy_arel_match(col, query) }.compact.reduce(:or) + + where(matches) + end + def to_pattern(query) if partial_matching?(query) "%#{sanitize_sql_like(query)}%" @@ -19,12 +25,19 @@ module Gitlab query.length >= MIN_CHARS_FOR_PARTIAL_MATCHING end - def to_fuzzy_arel(column, query) - words = select_fuzzy_words(query) + def fuzzy_arel_match(column, query) + query = query.squish + return nil unless query.present? - matches = words.map { |word| arel_table[column].matches(to_pattern(word)) } + words = select_fuzzy_words(query) - matches.reduce { |result, match| result.and(match) } + if words.any? + words.map { |word| arel_table[column].matches(to_pattern(word)) }.reduce(:and) + else + # No words of at least 3 chars, but we can search for an exact + # case insensitive match with the query as a whole + arel_table[column].matches(sanitize_sql_like(query)) + end end def select_fuzzy_words(query) @@ -32,7 +45,7 @@ module Gitlab query = quoted_words.reduce(query) { |q, quoted_word| q.sub(quoted_word, '') } - words = query.split(/\s+/) + words = query.split quoted_words.map! { |quoted_word| quoted_word[1..-2] } diff --git a/spec/controllers/projects/pipelines_settings_controller_spec.rb b/spec/controllers/projects/pipelines_settings_controller_spec.rb index 21b6a6d45f5..b2d83a02290 100644 --- a/spec/controllers/projects/pipelines_settings_controller_spec.rb +++ b/spec/controllers/projects/pipelines_settings_controller_spec.rb @@ -12,19 +12,22 @@ describe Projects::PipelinesSettingsController do end describe 'PATCH update' do - before do + subject do patch :update, namespace_id: project.namespace.to_param, project_id: project, - project: { - auto_devops_attributes: params - } + project: { auto_devops_attributes: params, + run_auto_devops_pipeline_implicit: 'false', + run_auto_devops_pipeline_explicit: auto_devops_pipeline } 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 + expect(response).to have_gitlab_http_status(302) expect(flash[:notice]).to eq("Pipelines settings for '#{project.name}' were successfully updated.") end @@ -33,11 +36,32 @@ describe Projects::PipelinesSettingsController do let(:params) { { enabled: '' } } it 'allows enabled to be set to nil' do + subject project_auto_devops.reload expect(project_auto_devops.enabled).to be_nil end end + + context 'when run_auto_devops_pipeline is true' do + let(:auto_devops_pipeline) { 'true' } + + it 'queues a CreatePipelineWorker' do + expect(CreatePipelineWorker).to receive(:perform_async).with(project.id, user.id, project.default_branch, :web, any_args) + + subject + end + end + + context 'when run_auto_devops_pipeline is not true' do + let(:auto_devops_pipeline) { 'false' } + + it 'does not queue a CreatePipelineWorker' do + expect(CreatePipelineWorker).not_to receive(:perform_async).with(project.id, user.id, :web, any_args) + + subject + end + end end end end diff --git a/spec/features/issues/create_branch_merge_request_spec.rb b/spec/features/issues/create_branch_merge_request_spec.rb deleted file mode 100644 index edea95c6699..00000000000 --- a/spec/features/issues/create_branch_merge_request_spec.rb +++ /dev/null @@ -1,106 +0,0 @@ -require 'rails_helper' - -feature 'Create Branch/Merge Request Dropdown on issue page', :feature, :js do - let(:user) { create(:user) } - let!(:project) { create(:project, :repository) } - let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') } - - context 'for team members' do - before do - project.team << [user, :developer] - sign_in(user) - end - - it 'allows creating a merge request from the issue page' do - visit project_issue_path(project, issue) - - perform_enqueued_jobs do - select_dropdown_option('create-mr') - - expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"') - expect(current_path).to eq(project_merge_request_path(project, MergeRequest.first)) - - wait_for_requests - end - - visit project_issue_path(project, issue) - - expect(page).to have_content("created branch 1-cherry-coloured-funk") - expect(page).to have_content("mentioned in merge request !1") - end - - it 'allows creating a branch from the issue page' do - visit project_issue_path(project, issue) - - select_dropdown_option('create-branch') - - wait_for_requests - - expect(page).to have_selector('.dropdown-toggle-text ', text: '1-cherry-coloured-funk') - expect(current_path).to eq project_tree_path(project, '1-cherry-coloured-funk') - end - - context "when there is a referenced merge request" do - let!(:note) do - create(:note, :on_issue, :system, project: project, noteable: issue, - note: "mentioned in #{referenced_mr.to_reference}") - end - - let(:referenced_mr) do - create(:merge_request, :simple, source_project: project, target_project: project, - description: "Fixes #{issue.to_reference}", author: user) - end - - before do - referenced_mr.cache_merge_request_closes_issues!(user) - - visit project_issue_path(project, issue) - end - - it 'disables the create branch button' do - expect(page).to have_css('.create-mr-dropdown-wrap .unavailable:not(.hide)') - expect(page).to have_css('.create-mr-dropdown-wrap .available.hide', visible: false) - expect(page).to have_content /1 Related Merge Request/ - end - end - - context 'when merge requests are disabled' do - before do - project.project_feature.update(merge_requests_access_level: 0) - - visit project_issue_path(project, issue) - end - - it 'shows only create branch button' do - expect(page).not_to have_button('Create a merge request') - expect(page).to have_button('Create a branch') - end - end - - context 'when issue is confidential' do - it 'disables the create branch button' do - issue = create(:issue, :confidential, project: project) - - visit project_issue_path(project, issue) - - expect(page).not_to have_css('.create-mr-dropdown-wrap') - end - end - end - - context 'for visitors' do - before do - visit project_issue_path(project, issue) - end - - it 'shows no buttons' do - expect(page).not_to have_selector('.create-mr-dropdown-wrap') - end - end - - def select_dropdown_option(option) - find('.create-mr-dropdown-wrap .dropdown-toggle').click - find("li[data-value='#{option}']").click - find('.js-create-merge-request').click - end -end diff --git a/spec/features/issues/user_creates_branch_and_merge_request_spec.rb b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb new file mode 100644 index 00000000000..539d7e9ff01 --- /dev/null +++ b/spec/features/issues/user_creates_branch_and_merge_request_spec.rb @@ -0,0 +1,248 @@ +require 'rails_helper' + +describe 'User creates branch and merge request on issue page', :js do + let(:user) { create(:user) } + let!(:project) { create(:project, :repository) } + let(:issue) { create(:issue, project: project, title: 'Cherry-Coloured Funk') } + + context 'when signed out' do + before do + visit project_issue_path(project, issue) + end + + it "doesn't show 'Create merge request' button" do + expect(page).not_to have_selector('.create-mr-dropdown-wrap') + end + end + + context 'when signed in' do + before do + project.add_developer(user) + + sign_in(user) + end + + context 'when interacting with the dropdown' do + before do + visit project_issue_path(project, issue) + end + + # In order to improve tests performance, all UI checks are placed in this test. + it 'shows elements' do + button_create_merge_request = find('.js-create-merge-request') + button_toggle_dropdown = find('.create-mr-dropdown-wrap .dropdown-toggle') + + button_toggle_dropdown.click + + dropdown = find('.create-merge-request-dropdown-menu') + + page.within(dropdown) do + button_create_target = find('.js-create-target') + input_branch_name = find('.js-branch-name') + input_source = find('.js-ref') + li_create_branch = find("li[data-value='create-branch']") + li_create_merge_request = find("li[data-value='create-mr']") + + # Test that all elements are presented. + expect(page).to have_content('Create merge request and branch') + expect(page).to have_content('Create branch') + expect(page).to have_content('Branch name') + expect(page).to have_content('Source (branch or tag)') + expect(page).to have_button('Create merge request') + expect(page).to have_selector('.js-branch-name:focus') + + test_selection_mark(li_create_branch, li_create_merge_request, button_create_target, button_create_merge_request) + test_branch_name_checking(input_branch_name) + test_source_checking(input_source) + + # The button inside dropdown should be disabled if any errors occured. + expect(page).to have_button('Create branch', disabled: true) + end + + # The top level button should be disabled if any errors occured. + expect(page).to have_button('Create branch', disabled: true) + end + + context 'when branch name is auto-generated' do + it 'creates a merge request' do + perform_enqueued_jobs do + select_dropdown_option('create-mr') + + expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"') + expect(current_path).to eq(project_merge_request_path(project, MergeRequest.first)) + + wait_for_requests + end + + visit project_issue_path(project, issue) + + expect(page).to have_content('created branch 1-cherry-coloured-funk') + expect(page).to have_content('mentioned in merge request !1') + end + + it 'creates a branch' do + select_dropdown_option('create-branch') + + wait_for_requests + + expect(page).to have_selector('.dropdown-toggle-text ', text: '1-cherry-coloured-funk') + expect(current_path).to eq project_tree_path(project, '1-cherry-coloured-funk') + end + end + + context 'when branch name is custom' do + let(:branch_name) { 'custom-branch-name' } + + it 'creates a merge request' do + perform_enqueued_jobs do + select_dropdown_option('create-mr', branch_name) + + expect(page).to have_content('WIP: Resolve "Cherry-Coloured Funk"') + expect(page).to have_content('Request to merge custom-branch-name into') + expect(current_path).to eq(project_merge_request_path(project, MergeRequest.first)) + + wait_for_requests + end + + visit project_issue_path(project, issue) + + expect(page).to have_content('created branch custom-branch-name') + expect(page).to have_content('mentioned in merge request !1') + end + + it 'creates a branch' do + select_dropdown_option('create-branch', branch_name) + + wait_for_requests + + expect(page).to have_selector('.dropdown-toggle-text ', text: branch_name) + expect(current_path).to eq project_tree_path(project, branch_name) + end + end + end + + context "when there is a referenced merge request" do + let!(:note) do + create(:note, :on_issue, :system, project: project, noteable: issue, + note: "mentioned in #{referenced_mr.to_reference}") + end + + let(:referenced_mr) do + create(:merge_request, :simple, source_project: project, target_project: project, + description: "Fixes #{issue.to_reference}", author: user) + end + + before do + referenced_mr.cache_merge_request_closes_issues!(user) + + visit project_issue_path(project, issue) + end + + it 'disables the create branch button' do + expect(page).to have_css('.create-mr-dropdown-wrap .unavailable:not(.hide)') + expect(page).to have_css('.create-mr-dropdown-wrap .available.hide', visible: false) + expect(page).to have_content /1 Related Merge Request/ + end + end + + context 'when merge requests are disabled' do + before do + project.project_feature.update(merge_requests_access_level: 0) + + visit project_issue_path(project, issue) + end + + it 'shows only create branch button' do + expect(page).not_to have_button('Create merge request') + expect(page).to have_button('Create branch') + end + end + + context 'when issue is confidential' do + let(:issue) { create(:issue, :confidential, project: project) } + + it 'disables the create branch button' do + visit project_issue_path(project, issue) + + expect(page).not_to have_css('.create-mr-dropdown-wrap') + end + end + end + + private + + def select_dropdown_option(option, branch_name = nil) + find('.create-mr-dropdown-wrap .dropdown-toggle').click + find("li[data-value='#{option}']").click + + if branch_name + find('.js-branch-name').set(branch_name) + + # Javascript debounces AJAX calls. + # So we have to wait until AJAX requests are started. + # Details are in app/assets/javascripts/create_merge_request_dropdown.js + # this.refDebounce = _.debounce(...) + sleep 0.5 + + wait_for_requests + end + + find('.js-create-merge-request').click + end + + def test_branch_name_checking(input_branch_name) + expect(input_branch_name.value).to eq(issue.to_branch_name) + + input_branch_name.set('new-branch-name') + branch_name_message = find('.js-branch-message') + + expect(branch_name_message).to have_text('Checking branch name availability…') + + wait_for_requests + + expect(branch_name_message).to have_text('Branch name is available') + + input_branch_name.set(project.default_branch) + + expect(branch_name_message).to have_text('Checking branch name availability…') + + wait_for_requests + + expect(branch_name_message).to have_text('Branch is already taken') + end + + def test_selection_mark(li_create_branch, li_create_merge_request, button_create_target, button_create_merge_request) + page.within(li_create_merge_request) do + expect(page).to have_css('i.fa.fa-check') + expect(button_create_target).to have_text('Create merge request') + expect(button_create_merge_request).to have_text('Create merge request') + end + + li_create_branch.click + + page.within(li_create_branch) do + expect(page).to have_css('i.fa.fa-check') + expect(button_create_target).to have_text('Create branch') + expect(button_create_merge_request).to have_text('Create branch') + end + end + + def test_source_checking(input_source) + expect(input_source.value).to eq(project.default_branch) + + input_source.set('mas') # Intentionally entered first 3 letters of `master` to check autocomplete feature later. + source_message = find('.js-ref-message') + + expect(source_message).to have_text('Checking source availability…') + + wait_for_requests + + expect(source_message).to have_text('Source is not available') + + # JavaScript gets refs started with `mas` (entered above) and places the first match. + # User sees `mas` in black color (the part he entered) and the `ter` in gray color (a hint). + # Since hinting is implemented via text selection and rspec/capybara doesn't have matchers for it, + # we just checking the whole source name. + expect(input_source.value).to eq(project.default_branch) + end +end diff --git a/spec/features/projects/pipelines/pipelines_spec.rb b/spec/features/projects/pipelines/pipelines_spec.rb index 50f8f13d261..a1b1d94ae06 100644 --- a/spec/features/projects/pipelines/pipelines_spec.rb +++ b/spec/features/projects/pipelines/pipelines_spec.rb @@ -500,6 +500,18 @@ describe 'Pipelines', :js do end it { expect(page).to have_content('Missing .gitlab-ci.yml file') } + it 'creates a pipeline after first request failed and a valid gitlab-ci.yml file is available when trying again' do + click_button project.default_branch + + stub_ci_pipeline_to_return_yaml_file + + page.within '.dropdown-menu' do + click_link 'master' + end + + expect { click_on 'Create pipeline' } + .to change { Ci::Pipeline.count }.by(1) + end end end end diff --git a/spec/features/projects/settings/pipelines_settings_spec.rb b/spec/features/projects/settings/pipelines_settings_spec.rb index ea8f997409d..eb8e7265dd3 100644 --- a/spec/features/projects/settings/pipelines_settings_spec.rb +++ b/spec/features/projects/settings/pipelines_settings_spec.rb @@ -8,13 +8,14 @@ feature "Pipelines settings" do background do sign_in(user) project.team << [user, role] - visit project_pipelines_settings_path(project) end context 'for developer' do given(:role) { :developer } scenario 'to be disallowed to view' do + visit project_settings_ci_cd_path(project) + expect(page.status_code).to eq(404) end end @@ -23,6 +24,8 @@ feature "Pipelines settings" do given(:role) { :master } scenario 'be allowed to change' do + visit project_settings_ci_cd_path(project) + fill_in('Test coverage parsing', with: 'coverage_regex') click_on 'Save changes' @@ -32,6 +35,8 @@ feature "Pipelines settings" do end scenario 'updates auto_cancel_pending_pipelines' do + visit project_settings_ci_cd_path(project) + page.check('Auto-cancel redundant, pending pipelines') click_on 'Save changes' @@ -42,14 +47,119 @@ feature "Pipelines settings" do expect(checkbox).to be_checked end - scenario 'update auto devops settings' do - fill_in('project_auto_devops_attributes_domain', with: 'test.com') - page.choose('project_auto_devops_attributes_enabled_false') - click_on 'Save changes' + describe 'Auto DevOps' do + it 'update auto devops settings' do + visit project_settings_ci_cd_path(project) - expect(page.status_code).to eq(200) - expect(project.auto_devops).to be_present - expect(project.auto_devops).not_to be_enabled + fill_in('project_auto_devops_attributes_domain', with: 'test.com') + page.choose('project_auto_devops_attributes_enabled_false') + click_on 'Save changes' + + expect(page.status_code).to eq(200) + 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 diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb index 1686e7fa342..156293289dd 100644 --- a/spec/features/projects/tree/create_directory_spec.rb +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -26,9 +26,11 @@ feature 'Multi-file editor new directory', :js do click_button('Create directory') end + find('.multi-file-commit-panel-collapse-btn').click + fill_in('commit-message', with: 'commit message') - click_button('Commit 1 file') + click_button('Commit') expect(page).to have_selector('td', text: 'commit message') end diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb index 1e2de0711b8..8fb8476e631 100644 --- a/spec/features/projects/tree/create_file_spec.rb +++ b/spec/features/projects/tree/create_file_spec.rb @@ -26,9 +26,11 @@ feature 'Multi-file editor new file', :js do click_button('Create file') end + find('.multi-file-commit-panel-collapse-btn').click + fill_in('commit-message', with: 'commit message') - click_button('Commit 1 file') + click_button('Commit') expect(page).to have_selector('td', text: 'commit message') end diff --git a/spec/features/projects/tree/upload_file_spec.rb b/spec/features/projects/tree/upload_file_spec.rb index 8439bb5a69e..d4e57d1ecfa 100644 --- a/spec/features/projects/tree/upload_file_spec.rb +++ b/spec/features/projects/tree/upload_file_spec.rb @@ -26,7 +26,7 @@ feature 'Multi-file editor upload file', :js do find('.add-to-tree').click - expect(page).to have_selector('.repo-tab', text: 'doc_sample.txt') + expect(page).to have_selector('.multi-file-tab', text: 'doc_sample.txt') expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline)) end @@ -39,7 +39,7 @@ feature 'Multi-file editor upload file', :js do find('.add-to-tree').click - expect(page).to have_selector('.repo-tab', text: 'dk.png') + expect(page).to have_selector('.multi-file-tab', text: 'dk.png') expect(page).not_to have_selector('.monaco-editor') expect(page).to have_content('The source could not be displayed for this temporary file.') end diff --git a/spec/helpers/auto_devops_helper_spec.rb b/spec/helpers/auto_devops_helper_spec.rb index 5e272af6073..7266e1b84d1 100644 --- a/spec/helpers/auto_devops_helper_spec.rb +++ b/spec/helpers/auto_devops_helper_spec.rb @@ -82,4 +82,104 @@ 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/helpers/issuables_helper_spec.rb b/spec/helpers/issuables_helper_spec.rb index cb851d828f2..d601cbdb39b 100644 --- a/spec/helpers/issuables_helper_spec.rb +++ b/spec/helpers/issuables_helper_spec.rb @@ -174,6 +174,7 @@ describe IssuablesHelper do expected_data = { 'endpoint' => "/#{@project.full_path}/issues/#{issue.iid}", + 'updateEndpoint' => "/#{@project.full_path}/issues/#{issue.iid}.json", 'canUpdate' => true, 'canDestroy' => true, 'issuableRef' => "##{issue.iid}", diff --git a/spec/javascripts/behaviors/autosize_spec.js b/spec/javascripts/behaviors/autosize_spec.js index 67afba19190..960b731892a 100644 --- a/spec/javascripts/behaviors/autosize_spec.js +++ b/spec/javascripts/behaviors/autosize_spec.js @@ -1,21 +1,18 @@ -/* eslint-disable space-before-function-paren, no-var, comma-dangle, no-return-assign, max-len */ - import '~/behaviors/autosize'; -(function() { - describe('Autosize behavior', function() { - var load; - beforeEach(function() { - return setFixtures('<textarea class="js-autosize" style="resize: vertical"></textarea>'); - }); - it('does not overwrite the resize property', function() { - load(); - return expect($('textarea')).toHaveCss({ - resize: 'vertical' - }); +function load() { + $(document).trigger('load'); +} + +describe('Autosize behavior', () => { + beforeEach(() => { + setFixtures('<textarea class="js-autosize" style="resize: vertical"></textarea>'); + }); + + it('does not overwrite the resize property', () => { + load(); + expect($('textarea')).toHaveCss({ + resize: 'vertical', }); - return load = function() { - return $(document).trigger('load'); - }; }); -}).call(window); +}); diff --git a/spec/javascripts/droplab/drop_down_spec.js b/spec/javascripts/droplab/drop_down_spec.js index 1ef494a00b8..1225fe2cb66 100644 --- a/spec/javascripts/droplab/drop_down_spec.js +++ b/spec/javascripts/droplab/drop_down_spec.js @@ -279,7 +279,12 @@ describe('DropDown', function () { describe('addEvents', function () { beforeEach(function () { this.list = { addEventListener: () => {} }; - this.dropdown = { list: this.list, clickEvent: () => {}, eventWrapper: {} }; + this.dropdown = { + list: this.list, + clickEvent: () => {}, + closeDropdown: () => {}, + eventWrapper: {}, + }; spyOn(this.list, 'addEventListener'); @@ -288,6 +293,7 @@ describe('DropDown', function () { it('should call .addEventListener', function () { expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function)); + expect(this.list.addEventListener).toHaveBeenCalledWith('keyup', jasmine.any(Function)); }); }); diff --git a/spec/javascripts/droplab/hook_spec.js b/spec/javascripts/droplab/hook_spec.js index 75bf5f3d611..3d39bd0812b 100644 --- a/spec/javascripts/droplab/hook_spec.js +++ b/spec/javascripts/droplab/hook_spec.js @@ -24,7 +24,7 @@ describe('Hook', function () { }); it('should call DropDown constructor', function () { - expect(dropdownSrc.default).toHaveBeenCalledWith(this.list); + expect(dropdownSrc.default).toHaveBeenCalledWith(this.list, this.config); }); it('should set .type', function () { diff --git a/spec/javascripts/flash_spec.js b/spec/javascripts/flash_spec.js index b669aabcee4..97e3ab682c5 100644 --- a/spec/javascripts/flash_spec.js +++ b/spec/javascripts/flash_spec.js @@ -278,7 +278,7 @@ describe('Flash', () => { removeFlashClickListener(flashEl, false); - flashEl.parentNode.click(); + flashEl.click(); setTimeout(() => { expect(document.querySelector('.flash')).toBeNull(); diff --git a/spec/javascripts/issue_show/components/app_spec.js b/spec/javascripts/issue_show/components/app_spec.js index 5662c7387fb..b47a8bf705f 100644 --- a/spec/javascripts/issue_show/components/app_spec.js +++ b/spec/javascripts/issue_show/components/app_spec.js @@ -35,11 +35,12 @@ describe('Issuable output', () => { canUpdate: true, canDestroy: true, endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes', + updateEndpoint: gl.TEST_HOST, issuableRef: '#1', initialTitleHtml: '', initialTitleText: '', - initialDescriptionHtml: '', - initialDescriptionText: '', + initialDescriptionHtml: 'test', + initialDescriptionText: 'test', markdownPreviewPath: '/', markdownDocsPath: '/', projectNamespace: '/', diff --git a/spec/javascripts/issue_show/components/description_spec.js b/spec/javascripts/issue_show/components/description_spec.js index 360691a3546..163e5cdd062 100644 --- a/spec/javascripts/issue_show/components/description_spec.js +++ b/spec/javascripts/issue_show/components/description_spec.js @@ -1,11 +1,22 @@ import Vue from 'vue'; import descriptionComponent from '~/issue_show/components/description.vue'; +import * as taskList from '~/task_list'; +import mountComponent from '../../helpers/vue_mount_component_helper'; describe('Description component', () => { let vm; + let DescriptionComponent; + const props = { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + updatedAt: new Date().toString(), + taskStatus: '', + updateUrl: gl.TEST_HOST, + }; beforeEach(() => { - const Component = Vue.extend(descriptionComponent); + DescriptionComponent = Vue.extend(descriptionComponent); if (!document.querySelector('.issuable-meta')) { const metaData = document.createElement('div'); @@ -15,15 +26,11 @@ describe('Description component', () => { document.body.appendChild(metaData); } - vm = new Component({ - propsData: { - canUpdate: true, - descriptionHtml: 'test', - descriptionText: 'test', - updatedAt: new Date().toString(), - taskStatus: '', - }, - }).$mount(); + vm = mountComponent(DescriptionComponent, props); + }); + + afterEach(() => { + vm.$destroy(); }); it('animates description changes', (done) => { @@ -44,34 +51,46 @@ describe('Description component', () => { }); }); - // TODO: gl.TaskList no longer exists. rewrite these tests once we have a way to rewire ES modules - - // it('re-inits the TaskList when description changed', (done) => { - // spyOn(gl, 'TaskList'); - // vm.descriptionHtml = 'changed'; - // - // setTimeout(() => { - // expect( - // gl.TaskList, - // ).toHaveBeenCalled(); - // - // done(); - // }); - // }); - - // it('does not re-init the TaskList when canUpdate is false', (done) => { - // spyOn(gl, 'TaskList'); - // vm.canUpdate = false; - // vm.descriptionHtml = 'changed'; - // - // setTimeout(() => { - // expect( - // gl.TaskList, - // ).not.toHaveBeenCalled(); - // - // done(); - // }); - // }); + describe('TaskList', () => { + beforeEach(() => { + vm = mountComponent(DescriptionComponent, Object.assign({}, props, { + issuableType: 'issuableType', + })); + spyOn(taskList, 'default'); + }); + + it('re-inits the TaskList when description changed', (done) => { + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect(taskList.default).toHaveBeenCalled(); + done(); + }); + }); + + it('does not re-init the TaskList when canUpdate is false', (done) => { + vm.canUpdate = false; + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect(taskList.default).not.toHaveBeenCalled(); + done(); + }); + }); + + it('calls with issuableType dataType', (done) => { + vm.descriptionHtml = 'changed'; + + setTimeout(() => { + expect(taskList.default).toHaveBeenCalledWith({ + dataType: 'issuableType', + fieldName: 'description', + selector: '.detail-page-description', + }); + done(); + }); + }); + }); describe('taskStatus', () => { it('adds full taskStatus', (done) => { @@ -126,4 +145,8 @@ describe('Description component', () => { }); }); }); + + it('sets data-update-url', () => { + expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(gl.TEST_HOST); + }); }); diff --git a/spec/javascripts/issue_show/components/title_spec.js b/spec/javascripts/issue_show/components/title_spec.js index c1edc785d0f..5370f4e1fea 100644 --- a/spec/javascripts/issue_show/components/title_spec.js +++ b/spec/javascripts/issue_show/components/title_spec.js @@ -80,19 +80,19 @@ describe('Title component', () => { }); it('should not show by default', () => { - expect(vm.$el.querySelector('.note-action-button')).toBeNull(); + expect(vm.$el.querySelector('.btn-edit')).toBeNull(); }); it('should not show if canUpdate is false', () => { vm.showInlineEditButton = true; vm.canUpdate = false; - expect(vm.$el.querySelector('.note-action-button')).toBeNull(); + expect(vm.$el.querySelector('.btn-edit')).toBeNull(); }); it('should show if showInlineEditButton and canUpdate', () => { vm.showInlineEditButton = true; vm.canUpdate = true; - expect(vm.$el.querySelector('.note-action-button')).toBeDefined(); + expect(vm.$el.querySelector('.btn-edit')).toBeDefined(); }); it('should trigger open.form event when clicked', () => { @@ -100,7 +100,7 @@ describe('Title component', () => { vm.canUpdate = true; Vue.nextTick(() => { - vm.$el.querySelector('.note-action-button').click(); + vm.$el.querySelector('.btn-edit').click(); expect(eventHub.$emit).toHaveBeenCalledWith('open.form'); }); }); diff --git a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js index 171629fcd6b..edef150dd1e 100644 --- a/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js +++ b/spec/javascripts/projects_dropdown/components/projects_list_item_spec.js @@ -50,6 +50,18 @@ describe('ProjectsListItemComponent', () => { expect(vm.highlightedProjectName).toBe(mockProject.name); }); }); + + describe('truncatedNamespace', () => { + it('should truncate project name from namespace string', () => { + vm.namespace = 'platform / nokia-3310'; + expect(vm.truncatedNamespace).toBe('platform'); + }); + + it('should truncate namespace string from the middle if it includes more than two groups in path', () => { + vm.namespace = 'platform / hardware / broadcom / Wifi Group / Mobile Chipset / nokia-3310'; + expect(vm.truncatedNamespace).toBe('platform / ... / Mobile Chipset'); + }); + }); }); describe('template', () => { diff --git a/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js new file mode 100644 index 00000000000..f750061a6a1 --- /dev/null +++ b/spec/javascripts/repo/components/commit_sidebar/list_collapsed_spec.js @@ -0,0 +1,33 @@ +import Vue from 'vue'; +import store from '~/repo/stores'; +import listCollapsed from '~/repo/components/commit_sidebar/list_collapsed.vue'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list collapsed', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(listCollapsed); + + vm = createComponentWithStore(Component, store); + + vm.$store.state.openFiles.push(file(), file()); + vm.$store.state.openFiles[0].tempFile = true; + vm.$store.state.openFiles.forEach((f) => { + Object.assign(f, { + changed: true, + }); + }); + + vm.$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders added & modified files count', () => { + expect(vm.$el.textContent.replace(/\s+/g, ' ').trim()).toBe('1 1'); + }); +}); diff --git a/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js new file mode 100644 index 00000000000..18c9b46fcd9 --- /dev/null +++ b/spec/javascripts/repo/components/commit_sidebar/list_item_spec.js @@ -0,0 +1,53 @@ +import Vue from 'vue'; +import listItem from '~/repo/components/commit_sidebar/list_item.vue'; +import mountComponent from '../../../helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list item', () => { + let vm; + let f; + + beforeEach(() => { + const Component = Vue.extend(listItem); + + f = file(); + + vm = mountComponent(Component, { + file: f, + }); + }); + + afterEach(() => { + vm.$destroy(); + }); + + it('renders file path', () => { + expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path); + }); + + describe('computed', () => { + describe('iconName', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconName).toBe('file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconName).toBe('file-addition'); + }); + }); + + describe('iconClass', () => { + it('returns modified when not a tempFile', () => { + expect(vm.iconClass).toContain('multi-file-modified'); + }); + + it('returns addition when not a tempFile', () => { + f.tempFile = true; + + expect(vm.iconClass).toContain('multi-file-addition'); + }); + }); + }); +}); diff --git a/spec/javascripts/repo/components/commit_sidebar/list_spec.js b/spec/javascripts/repo/components/commit_sidebar/list_spec.js new file mode 100644 index 00000000000..df7e3c5de21 --- /dev/null +++ b/spec/javascripts/repo/components/commit_sidebar/list_spec.js @@ -0,0 +1,72 @@ +import Vue from 'vue'; +import store from '~/repo/stores'; +import commitSidebarList from '~/repo/components/commit_sidebar/list.vue'; +import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; +import { file } from '../../helpers'; + +describe('Multi-file editor commit sidebar list', () => { + let vm; + + beforeEach(() => { + const Component = Vue.extend(commitSidebarList); + + vm = createComponentWithStore(Component, store, { + title: 'Staged', + fileList: [], + collapsed: false, + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('empty file list', () => { + it('renders no changes text', () => { + expect(vm.$el.querySelector('.help-block').textContent.trim()).toBe('No changes'); + }); + }); + + describe('with a list of files', () => { + beforeEach((done) => { + const f = file('file name'); + f.changed = true; + vm.fileList.push(f); + + Vue.nextTick(done); + }); + + it('renders list', () => { + expect(vm.$el.querySelectorAll('li').length).toBe(1); + }); + }); + + describe('collapsed', () => { + beforeEach((done) => { + vm.collapsed = true; + + Vue.nextTick(done); + }); + + it('adds collapsed class', () => { + expect(vm.$el.querySelector('.is-collapsed')).not.toBeNull(); + }); + + it('hides list', () => { + expect(vm.$el.querySelector('.list-unstyled')).toBeNull(); + expect(vm.$el.querySelector('.help-block')).toBeNull(); + }); + + it('hides collapse button', () => { + expect(vm.$el.querySelector('.multi-file-commit-panel-collapse-btn')).toBeNull(); + }); + }); + + it('clicking toggle collapse button emits toggle event', () => { + spyOn(vm, '$emit'); + + vm.$el.querySelector('.multi-file-commit-panel-collapse-btn').click(); + + expect(vm.$emit).toHaveBeenCalledWith('toggleCollapsed'); + }); +}); diff --git a/spec/javascripts/repo/components/repo_commit_section_spec.js b/spec/javascripts/repo/components/repo_commit_section_spec.js index 0f991e1b727..1c794123095 100644 --- a/spec/javascripts/repo/components/repo_commit_section_spec.js +++ b/spec/javascripts/repo/components/repo_commit_section_spec.js @@ -25,8 +25,12 @@ describe('RepoCommitSection', () => { return comp.$mount(); } - beforeEach(() => { + beforeEach((done) => { vm = createComponent(); + + vm.collapsed = false; + + Vue.nextTick(done); }); afterEach(() => { @@ -36,12 +40,11 @@ describe('RepoCommitSection', () => { }); it('renders a commit section', () => { - const changedFileElements = [...vm.$el.querySelectorAll('.changed-files > li')]; - const submitCommit = vm.$el.querySelector('.btn'); - const targetBranch = vm.$el.querySelector('.target-branch'); + const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')]; + const submitCommit = vm.$el.querySelector('form .btn'); - expect(vm.$el.querySelector(':scope > form')).toBeTruthy(); - expect(vm.$el.querySelector('.staged-files').textContent.trim()).toEqual('Staged files (2)'); + expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull(); + expect(vm.$el.querySelector('.multi-file-commit-panel-section header').textContent.trim()).toEqual('Staged'); expect(changedFileElements.length).toEqual(2); changedFileElements.forEach((changedFile, i) => { @@ -49,10 +52,7 @@ describe('RepoCommitSection', () => { }); expect(submitCommit.disabled).toBeTruthy(); - expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeFalsy(); - expect(vm.$el.querySelector('.commit-summary').textContent.trim()).toEqual('Commit 2 files'); - expect(targetBranch.querySelector(':scope > label').textContent.trim()).toEqual('Target branch'); - expect(targetBranch.querySelector('.help-block').textContent.trim()).toEqual('master'); + expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull(); }); describe('when submitting', () => { @@ -69,7 +69,7 @@ describe('RepoCommitSection', () => { }); it('allows you to submit', () => { - expect(vm.$el.querySelector('.btn').disabled).toBeTruthy(); + expect(vm.$el.querySelector('form .btn').disabled).toBeTruthy(); }); it('submits commit', (done) => { diff --git a/spec/javascripts/repo/components/repo_sidebar_spec.js b/spec/javascripts/repo/components/repo_sidebar_spec.js index 7cb4dace491..df7cf8aabbb 100644 --- a/spec/javascripts/repo/components/repo_sidebar_spec.js +++ b/spec/javascripts/repo/components/repo_sidebar_spec.js @@ -29,7 +29,6 @@ describe('RepoSidebar', () => { const thead = vm.$el.querySelector('thead'); const tbody = vm.$el.querySelector('tbody'); - expect(vm.$el.id).toEqual('sidebar'); expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy(); expect(thead.querySelector('.name').textContent.trim()).toEqual('Name'); expect(thead.querySelector('.last-commit').textContent.trim()).toEqual('Last commit'); @@ -40,18 +39,6 @@ describe('RepoSidebar', () => { expect(tbody.querySelector('.file')).toBeTruthy(); }); - it('does not render a thead, renders repo-file-options and sets sidebar-mini class if isMini', (done) => { - vm.$store.state.openFiles.push(vm.$store.state.tree[0]); - - Vue.nextTick(() => { - expect(vm.$el.classList.contains('sidebar-mini')).toBeTruthy(); - expect(vm.$el.querySelector('thead')).toBeTruthy(); - expect(vm.$el.querySelector('thead .repo-file-options')).toBeTruthy(); - - done(); - }); - }); - it('renders 5 loading files if tree is loading', (done) => { vm.$store.state.tree = []; vm.$store.state.loading = true; diff --git a/spec/javascripts/repo/components/repo_tab_spec.js b/spec/javascripts/repo/components/repo_tab_spec.js index df0ca55aafc..7d2174196c9 100644 --- a/spec/javascripts/repo/components/repo_tab_spec.js +++ b/spec/javascripts/repo/components/repo_tab_spec.js @@ -24,8 +24,8 @@ describe('RepoTab', () => { tab: file(), }); vm.$store.state.openFiles.push(vm.tab); - const close = vm.$el.querySelector('.close-btn'); - const name = vm.$el.querySelector(`a[title="${vm.tab.url}"]`); + const close = vm.$el.querySelector('.multi-file-tab-close'); + const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`); expect(close.querySelector('.fa-times')).toBeTruthy(); expect(name.textContent.trim()).toEqual(vm.tab.name); @@ -50,7 +50,7 @@ describe('RepoTab', () => { spyOn(vm, 'closeFile'); - vm.$el.querySelector('.close-btn').click(); + vm.$el.querySelector('.multi-file-tab-close').click(); expect(vm.closeFile).toHaveBeenCalledWith({ file: vm.tab }); }); @@ -62,7 +62,7 @@ describe('RepoTab', () => { tab, }); - expect(vm.$el.querySelector('.close-btn .fa-circle')).toBeTruthy(); + expect(vm.$el.querySelector('.multi-file-tab-close .fa-circle')).not.toBeNull(); }); describe('methods', () => { @@ -77,7 +77,7 @@ describe('RepoTab', () => { vm.$store.state.openFiles.push(tab); vm.$store.dispatch('setFileActive', tab); - vm.$el.querySelector('.close-btn').click(); + vm.$el.querySelector('.multi-file-tab-close').click(); vm.$nextTick(() => { expect(tab.opened).toBeTruthy(); @@ -95,7 +95,7 @@ describe('RepoTab', () => { vm.$store.state.openFiles.push(tab); vm.$store.dispatch('setFileActive', tab); - vm.$el.querySelector('.close-btn').click(); + vm.$el.querySelector('.multi-file-tab-close').click(); vm.$nextTick(() => { expect(tab.opened).toBeFalsy(); diff --git a/spec/javascripts/repo/components/repo_tabs_spec.js b/spec/javascripts/repo/components/repo_tabs_spec.js index d0246cc72e6..1fb2242c051 100644 --- a/spec/javascripts/repo/components/repo_tabs_spec.js +++ b/spec/javascripts/repo/components/repo_tabs_spec.js @@ -25,12 +25,11 @@ describe('RepoTabs', () => { vm.$store.state.openFiles = openedFiles; vm.$nextTick(() => { - const tabs = [...vm.$el.querySelectorAll(':scope > li')]; + const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')]; - expect(tabs.length).toEqual(3); + expect(tabs.length).toEqual(2); expect(tabs[0].classList.contains('active')).toBeTruthy(); expect(tabs[1].classList.contains('active')).toBeFalsy(); - expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy(); done(); }); diff --git a/spec/javascripts/repo/stores/getters_spec.js b/spec/javascripts/repo/stores/getters_spec.js index a204b2386cd..952b8ec3a59 100644 --- a/spec/javascripts/repo/stores/getters_spec.js +++ b/spec/javascripts/repo/stores/getters_spec.js @@ -116,4 +116,31 @@ describe('Multi-file store getters', () => { expect(getters.canEditFile(localState)).toBeFalsy(); }); }); + + describe('modifiedFiles', () => { + it('returns a list of modified files', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('changed')); + localState.openFiles[1].changed = true; + + const modifiedFiles = getters.modifiedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('changed'); + }); + }); + + describe('addedFiles', () => { + it('returns a list of added files', () => { + localState.openFiles.push(file()); + localState.openFiles.push(file('added')); + localState.openFiles[1].changed = true; + localState.openFiles[1].tempFile = true; + + const modifiedFiles = getters.addedFiles(localState); + + expect(modifiedFiles.length).toBe(1); + expect(modifiedFiles[0].name).toBe('added'); + }); + }); }); diff --git a/spec/lib/gitlab/background_migration/populate_fork_networks_range_spec.rb b/spec/lib/gitlab/background_migration/populate_fork_networks_range_spec.rb index 994992f79d4..e52baf8dde7 100644 --- a/spec/lib/gitlab/background_migration/populate_fork_networks_range_spec.rb +++ b/spec/lib/gitlab/background_migration/populate_fork_networks_range_spec.rb @@ -62,12 +62,15 @@ describe Gitlab::BackgroundMigration::PopulateForkNetworksRange, :migration, sch expect(base2_membership).not_to be_nil end - it 'skips links that had their source project deleted' do - forked_project_links.create(id: 6, forked_from_project_id: 99999, forked_to_project_id: create(:project).id) + it 'creates a fork network for the fork of which the source was deleted' do + fork = create(:project) + forked_project_links.create(id: 6, forked_from_project_id: 99999, forked_to_project_id: fork.id) migration.perform(5, 8) expect(fork_networks.find_by(root_project_id: 99999)).to be_nil + expect(fork_networks.find_by(root_project_id: fork.id)).not_to be_nil + expect(fork_network_members.find_by(project_id: fork.id)).not_to be_nil end it 'schedules a job for inserting memberships for forks-of-forks' do diff --git a/spec/lib/gitlab/diff/position_tracer_spec.rb b/spec/lib/gitlab/diff/position_tracer_spec.rb index e5138705443..ddc4f6c5b5c 100644 --- a/spec/lib/gitlab/diff/position_tracer_spec.rb +++ b/spec/lib/gitlab/diff/position_tracer_spec.rb @@ -1771,9 +1771,9 @@ describe Gitlab::Diff::PositionTracer do describe "merge of target branch" do let(:merge_commit) do - update_file_again_commit + second_create_file_commit - merge_request = create(:merge_request, source_branch: second_create_file_commit.sha, target_branch: branch_name, source_project: project) + merge_request = create(:merge_request, source_branch: second_branch_name, target_branch: branch_name, source_project: project) repository.merge(current_user, merge_request.diff_head_sha, merge_request, "Merge branches") diff --git a/spec/lib/gitlab/gitaly_client_spec.rb b/spec/lib/gitlab/gitaly_client_spec.rb index a1f4e65b8d4..a871ed0df0e 100644 --- a/spec/lib/gitlab/gitaly_client_spec.rb +++ b/spec/lib/gitlab/gitaly_client_spec.rb @@ -278,4 +278,20 @@ describe Gitlab::GitalyClient, skip_gitaly_mock: true do end end end + + describe 'timeouts' do + context 'with default values' do + before do + stub_application_setting(gitaly_timeout_default: 55) + stub_application_setting(gitaly_timeout_medium: 30) + stub_application_setting(gitaly_timeout_fast: 10) + end + + it 'returns expected values' do + expect(described_class.default_timeout).to be(55) + expect(described_class.medium_timeout).to be(30) + expect(described_class.fast_timeout).to be(10) + end + end + end end diff --git a/spec/lib/gitlab/sql/pattern_spec.rb b/spec/lib/gitlab/sql/pattern_spec.rb index 48d56628ed5..ef51e3cc8df 100644 --- a/spec/lib/gitlab/sql/pattern_spec.rb +++ b/spec/lib/gitlab/sql/pattern_spec.rb @@ -137,22 +137,22 @@ describe Gitlab::SQL::Pattern do end end - describe '.to_fuzzy_arel' do - subject(:to_fuzzy_arel) { Issue.to_fuzzy_arel(:title, query) } + describe '.fuzzy_arel_match' do + subject(:fuzzy_arel_match) { Issue.fuzzy_arel_match(:title, query) } context 'with a word equal to 3 chars' do let(:query) { 'foo' } it 'returns a single ILIKE condition' do - expect(to_fuzzy_arel.to_sql).to match(/title.*I?LIKE '\%foo\%'/) + expect(fuzzy_arel_match.to_sql).to match(/title.*I?LIKE '\%foo\%'/) end end context 'with a word shorter than 3 chars' do let(:query) { 'fo' } - it 'returns nil' do - expect(to_fuzzy_arel).to be_nil + it 'returns a single equality condition' do + expect(fuzzy_arel_match.to_sql).to match(/title.*I?LIKE 'fo'/) end end @@ -160,7 +160,23 @@ describe Gitlab::SQL::Pattern do let(:query) { 'foo baz' } it 'returns a joining LIKE condition using a AND' do - expect(to_fuzzy_arel.to_sql).to match(/title.+I?LIKE '\%foo\%' AND .*title.*I?LIKE '\%baz\%'/) + expect(fuzzy_arel_match.to_sql).to match(/title.+I?LIKE '\%foo\%' AND .*title.*I?LIKE '\%baz\%'/) + end + end + + context 'with two words both shorter than 3 chars' do + let(:query) { 'fo ba' } + + it 'returns a single ILIKE condition' do + expect(fuzzy_arel_match.to_sql).to match(/title.*I?LIKE 'fo ba'/) + end + end + + context 'with two words, one shorter 3 chars' do + let(:query) { 'foo ba' } + + it 'returns a single ILIKE condition using the longer word' do + expect(fuzzy_arel_match.to_sql).to match(/title.+I?LIKE '\%foo\%'/) end end @@ -168,7 +184,7 @@ describe Gitlab::SQL::Pattern do let(:query) { 'foo "really bar" baz' } it 'returns a joining LIKE condition using a AND' do - expect(to_fuzzy_arel.to_sql).to match(/title.+I?LIKE '\%foo\%' AND .*title.*I?LIKE '\%baz\%' AND .*title.*I?LIKE '\%really bar\%'/) + expect(fuzzy_arel_match.to_sql).to match(/title.+I?LIKE '\%foo\%' AND .*title.*I?LIKE '\%baz\%' AND .*title.*I?LIKE '\%really bar\%'/) end end end diff --git a/spec/models/application_setting_spec.rb b/spec/models/application_setting_spec.rb index 51bf4e65e5d..0b7e16cc33c 100644 --- a/spec/models/application_setting_spec.rb +++ b/spec/models/application_setting_spec.rb @@ -219,6 +219,65 @@ describe ApplicationSetting do expect(subject).to be_valid end end + + context 'gitaly timeouts' do + [:gitaly_timeout_default, :gitaly_timeout_medium, :gitaly_timeout_fast].each do |timeout_name| + it do + is_expected.to validate_presence_of(timeout_name) + is_expected.to validate_numericality_of(timeout_name).only_integer + .is_greater_than_or_equal_to(0) + end + end + + [:gitaly_timeout_medium, :gitaly_timeout_fast].each do |timeout_name| + it "validates that #{timeout_name} is lower than timeout_default" do + subject[:gitaly_timeout_default] = 50 + subject[timeout_name] = 100 + + expect(subject).to be_invalid + end + end + + it 'accepts all timeouts equal' do + subject.gitaly_timeout_default = 0 + subject.gitaly_timeout_medium = 0 + subject.gitaly_timeout_fast = 0 + + expect(subject).to be_valid + end + + it 'accepts timeouts in descending order' do + subject.gitaly_timeout_default = 50 + subject.gitaly_timeout_medium = 30 + subject.gitaly_timeout_fast = 20 + + expect(subject).to be_valid + end + + it 'rejects timeouts in ascending order' do + subject.gitaly_timeout_default = 20 + subject.gitaly_timeout_medium = 30 + subject.gitaly_timeout_fast = 50 + + expect(subject).to be_invalid + end + + it 'rejects medium timeout larger than default' do + subject.gitaly_timeout_default = 30 + subject.gitaly_timeout_medium = 50 + subject.gitaly_timeout_fast = 20 + + expect(subject).to be_invalid + end + + it 'rejects medium timeout smaller than fast' do + subject.gitaly_timeout_default = 30 + subject.gitaly_timeout_medium = 15 + subject.gitaly_timeout_fast = 20 + + expect(subject).to be_invalid + end + end end describe '.current' do diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 584dfe9a5c1..a93e7e233a8 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -473,7 +473,7 @@ describe Ci::Runner do end describe '.search' do - let(:runner) { create(:ci_runner, token: '123abc') } + let(:runner) { create(:ci_runner, token: '123abc', description: 'test runner') } it 'returns runners with a matching token' do expect(described_class.search(runner.token)).to eq([runner]) diff --git a/spec/models/concerns/issuable_spec.rb b/spec/models/concerns/issuable_spec.rb index 765b2729918..a53b59c4e08 100644 --- a/spec/models/concerns/issuable_spec.rb +++ b/spec/models/concerns/issuable_spec.rb @@ -67,6 +67,7 @@ describe Issuable do describe ".search" do let!(:searchable_issue) { create(:issue, title: "Searchable awesome issue") } + let!(:searchable_issue2) { create(:issue, title: 'Aw') } it 'returns issues with a matching title' do expect(issuable_class.search(searchable_issue.title)) @@ -86,8 +87,8 @@ describe Issuable do expect(issuable_class.search('searchable issue')).to eq([searchable_issue]) end - it 'returns all issues with a query shorter than 3 chars' do - expect(issuable_class.search('zz')).to eq(issuable_class.all) + it 'returns issues with a matching title for a query shorter than 3 chars' do + expect(issuable_class.search(searchable_issue2.title.downcase)).to eq([searchable_issue2]) end end @@ -95,6 +96,7 @@ describe Issuable do let!(:searchable_issue) do create(:issue, title: "Searchable awesome issue", description: 'Many cute kittens') end + let!(:searchable_issue2) { create(:issue, title: "Aw", description: "Cu") } it 'returns issues with a matching title' do expect(issuable_class.full_search(searchable_issue.title)) @@ -133,8 +135,8 @@ describe Issuable do expect(issuable_class.full_search('many kittens')).to eq([searchable_issue]) end - it 'returns all issues with a query shorter than 3 chars' do - expect(issuable_class.search('zz')).to eq(issuable_class.all) + it 'returns issues with a matching description for a query shorter than 3 chars' do + expect(issuable_class.full_search(searchable_issue2.description.downcase)).to eq([searchable_issue2]) end end diff --git a/spec/models/merge_request_spec.rb b/spec/models/merge_request_spec.rb index 3cf8fc816ff..728028746d8 100644 --- a/spec/models/merge_request_spec.rb +++ b/spec/models/merge_request_spec.rb @@ -259,7 +259,7 @@ describe MergeRequest do end describe '#source_branch_sha' do - let(:last_branch_commit) { subject.source_project.repository.commit(subject.source_branch) } + let(:last_branch_commit) { subject.source_project.repository.commit(Gitlab::Git::BRANCH_REF_PREFIX + subject.source_branch) } context 'with diffs' do subject { create(:merge_request, :with_diffs) } @@ -273,6 +273,21 @@ describe MergeRequest do it 'returns the sha of the source branch last commit' do expect(subject.source_branch_sha).to eq(last_branch_commit.sha) end + + context 'when there is a tag name matching the branch name' do + let(:tag_name) { subject.source_branch } + + it 'returns the sha of the source branch last commit' do + subject.source_project.repository.add_tag(subject.author, + tag_name, + subject.target_branch_sha, + 'Add a tag') + + expect(subject.source_branch_sha).to eq(last_branch_commit.sha) + + subject.source_project.repository.rm_tag(subject.author, tag_name) + end + end end context 'when the merge request is being created' do @@ -933,7 +948,7 @@ describe MergeRequest do context 'with a completely different branch' do before do - subject.update(target_branch: 'v1.0.0') + subject.update(target_branch: 'csv') end it_behaves_like 'returning all SHA' @@ -941,7 +956,7 @@ describe MergeRequest do context 'with a branch having no difference' do before do - subject.update(target_branch: 'v1.1.0') + subject.update(target_branch: 'branch-merged') subject.reload # make sure commits were not cached end diff --git a/spec/models/snippet_spec.rb b/spec/models/snippet_spec.rb index de3ca300ae3..e09d89d235d 100644 --- a/spec/models/snippet_spec.rb +++ b/spec/models/snippet_spec.rb @@ -88,7 +88,7 @@ describe Snippet do end describe '.search' do - let(:snippet) { create(:snippet) } + let(:snippet) { create(:snippet, title: 'test snippet') } it 'returns snippets with a matching title' do expect(described_class.search(snippet.title)).to eq([snippet]) diff --git a/spec/requests/api/internal_spec.rb b/spec/requests/api/internal_spec.rb index 34ecdd1e164..67e1539cbc3 100644 --- a/spec/requests/api/internal_spec.rb +++ b/spec/requests/api/internal_spec.rb @@ -269,9 +269,8 @@ describe API::Internal do end context "git pull" do - context "gitaly disabled" do + context "gitaly disabled", :disable_gitaly do it "has the correct payload" do - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:ssh_upload_pack).and_return(false) pull(key, project) expect(response).to have_gitlab_http_status(200) @@ -285,7 +284,6 @@ describe API::Internal do context "gitaly enabled" do it "has the correct payload" do - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:ssh_upload_pack).and_return(true) pull(key, project) expect(response).to have_gitlab_http_status(200) @@ -304,9 +302,8 @@ describe API::Internal do end context "git push" do - context "gitaly disabled" do + context "gitaly disabled", :disable_gitaly do it "has the correct payload" do - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:ssh_receive_pack).and_return(false) push(key, project) expect(response).to have_gitlab_http_status(200) @@ -320,7 +317,6 @@ describe API::Internal do context "gitaly enabled" do it "has the correct payload" do - allow(Gitlab::GitalyClient).to receive(:feature_enabled?).with(:ssh_receive_pack).and_return(true) push(key, project) expect(response).to have_gitlab_http_status(200) diff --git a/spec/services/merge_requests/build_service_spec.rb b/spec/services/merge_requests/build_service_spec.rb index b46c419de14..fee293760f5 100644 --- a/spec/services/merge_requests/build_service_spec.rb +++ b/spec/services/merge_requests/build_service_spec.rb @@ -29,13 +29,27 @@ describe MergeRequests::BuildService do before do project.team << [user, :guest] + end + def stub_compare allow(CompareService).to receive_message_chain(:new, :execute).and_return(compare) allow(project).to receive(:commit).and_return(commit_1) allow(project).to receive(:commit).and_return(commit_2) end - describe 'execute' do + describe '#execute' do + it 'calls the compare service with the correct arguments' do + expect(CompareService).to receive(:new) + .with(project, Gitlab::Git::BRANCH_REF_PREFIX + source_branch) + .and_call_original + + expect_any_instance_of(CompareService).to receive(:execute) + .with(project, Gitlab::Git::BRANCH_REF_PREFIX + target_branch) + .and_call_original + + merge_request + end + context 'missing source branch' do let(:source_branch) { '' } @@ -52,6 +66,10 @@ describe MergeRequests::BuildService do let(:target_branch) { nil } let(:commits) { Commit.decorate([commit_1], project) } + before do + stub_compare + end + it 'creates compare object with target branch as default branch' do expect(merge_request.compare).to be_present expect(merge_request.target_branch).to eq(project.default_branch) @@ -77,6 +95,10 @@ describe MergeRequests::BuildService do context 'no commits in the diff' do let(:commits) { [] } + before do + stub_compare + end + it 'allows the merge request to be created' do expect(merge_request.can_be_created).to eq(true) end @@ -89,6 +111,10 @@ describe MergeRequests::BuildService do context 'one commit in the diff' do let(:commits) { Commit.decorate([commit_1], project) } + before do + stub_compare + end + it 'allows the merge request to be created' do expect(merge_request.can_be_created).to eq(true) end @@ -149,6 +175,10 @@ describe MergeRequests::BuildService do context 'more than one commit in the diff' do let(:commits) { Commit.decorate([commit_1, commit_2], project) } + before do + stub_compare + end + it 'allows the merge request to be created' do expect(merge_request.can_be_created).to eq(true) end diff --git a/spec/services/merge_requests/create_from_issue_service_spec.rb b/spec/services/merge_requests/create_from_issue_service_spec.rb index 313f87ae1f6..a7ab389b357 100644 --- a/spec/services/merge_requests/create_from_issue_service_spec.rb +++ b/spec/services/merge_requests/create_from_issue_service_spec.rb @@ -6,8 +6,10 @@ describe MergeRequests::CreateFromIssueService do let(:label_ids) { create_pair(:label, project: project).map(&:id) } let(:milestone_id) { create(:milestone, project: project).id } let(:issue) { create(:issue, project: project, milestone_id: milestone_id) } + let(:custom_source_branch) { 'custom-source-branch' } subject(:service) { described_class.new(project, user, issue_iid: issue.iid) } + subject(:service_with_custom_source_branch) { described_class.new(project, user, issue_iid: issue.iid, branch_name: custom_source_branch) } before do project.add_developer(user) @@ -17,8 +19,8 @@ describe MergeRequests::CreateFromIssueService do it 'returns an error with invalid issue iid' do result = described_class.new(project, user, issue_iid: -1).execute - expect(result[:status]).to eq :error - expect(result[:message]).to eq 'Invalid issue iid' + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Invalid issue iid') end it 'delegates issue search to IssuesFinder' do @@ -53,6 +55,12 @@ describe MergeRequests::CreateFromIssueService do expect(project.repository.branch_exists?(issue.to_branch_name)).to be_truthy end + it 'creates a branch using passed name' do + service_with_custom_source_branch.execute + + expect(project.repository.branch_exists?(custom_source_branch)).to be_truthy + end + it 'creates a system note' do expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name) @@ -72,19 +80,25 @@ describe MergeRequests::CreateFromIssueService do it 'sets the merge request author to current user' do result = service.execute - expect(result[:merge_request].author).to eq user + expect(result[:merge_request].author).to eq(user) end it 'sets the merge request source branch to the new issue branch' do result = service.execute - expect(result[:merge_request].source_branch).to eq issue.to_branch_name + expect(result[:merge_request].source_branch).to eq(issue.to_branch_name) + end + + it 'sets the merge request source branch to the passed branch name' do + result = service_with_custom_source_branch.execute + + expect(result[:merge_request].source_branch).to eq(custom_source_branch) end it 'sets the merge request target branch to the project default branch' do result = service.execute - expect(result[:merge_request].target_branch).to eq project.default_branch + expect(result[:merge_request].target_branch).to eq(project.default_branch) end end end diff --git a/spec/services/projects/update_service_spec.rb b/spec/services/projects/update_service_spec.rb index 3da222e2ed8..fcd71857af3 100644 --- a/spec/services/projects/update_service_spec.rb +++ b/spec/services/projects/update_service_spec.rb @@ -1,198 +1,222 @@ require 'spec_helper' -describe Projects::UpdateService, '#execute' do +describe Projects::UpdateService do include ProjectForksHelper - let(:gitlab_shell) { Gitlab::Shell.new } let(:user) { create(:user) } - let(:admin) { create(:admin) } - let(:project) do create(:project, creator: user, namespace: user.namespace) end - context 'when changing visibility level' do - context 'when visibility_level is INTERNAL' do - it 'updates the project to internal' do - result = update_project(project, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL) - - expect(result).to eq({ status: :success }) - expect(project).to be_internal - end - end - - context 'when visibility_level is PUBLIC' do - it 'updates the project to public' do - result = update_project(project, user, visibility_level: Gitlab::VisibilityLevel::PUBLIC) - expect(result).to eq({ status: :success }) - expect(project).to be_public - end - end - - context 'when visibility levels are restricted to PUBLIC only' do - before do - stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) - end + describe '#execute' do + let(:gitlab_shell) { Gitlab::Shell.new } + let(:admin) { create(:admin) } + context 'when changing visibility level' do context 'when visibility_level is INTERNAL' do it 'updates the project to internal' do result = update_project(project, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL) + expect(result).to eq({ status: :success }) expect(project).to be_internal end end context 'when visibility_level is PUBLIC' do - it 'does not update the project to public' do + it 'updates the project to public' do result = update_project(project, user, visibility_level: Gitlab::VisibilityLevel::PUBLIC) + expect(result).to eq({ status: :success }) + expect(project).to be_public + end + end - expect(result).to eq({ status: :error, message: 'New visibility level not allowed!' }) - expect(project).to be_private + context 'when visibility levels are restricted to PUBLIC only' do + before do + stub_application_setting(restricted_visibility_levels: [Gitlab::VisibilityLevel::PUBLIC]) end - context 'when updated by an admin' do - it 'updates the project to public' do - result = update_project(project, admin, visibility_level: Gitlab::VisibilityLevel::PUBLIC) + context 'when visibility_level is INTERNAL' do + it 'updates the project to internal' do + result = update_project(project, user, visibility_level: Gitlab::VisibilityLevel::INTERNAL) expect(result).to eq({ status: :success }) - expect(project).to be_public + expect(project).to be_internal end end - end - end - context 'When project visibility is higher than parent group' do - let(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } + context 'when visibility_level is PUBLIC' do + it 'does not update the project to public' do + result = update_project(project, user, visibility_level: Gitlab::VisibilityLevel::PUBLIC) - before do - project.update(namespace: group, visibility_level: group.visibility_level) + expect(result).to eq({ status: :error, message: 'New visibility level not allowed!' }) + expect(project).to be_private + end + + context 'when updated by an admin' do + it 'updates the project to public' do + result = update_project(project, admin, visibility_level: Gitlab::VisibilityLevel::PUBLIC) + expect(result).to eq({ status: :success }) + expect(project).to be_public + end + end + end end - it 'does not update project visibility level' do - result = update_project(project, admin, visibility_level: Gitlab::VisibilityLevel::PUBLIC) + context 'When project visibility is higher than parent group' do + let(:group) { create(:group, visibility_level: Gitlab::VisibilityLevel::INTERNAL) } + + before do + project.update(namespace: group, visibility_level: group.visibility_level) + end + + it 'does not update project visibility level' do + result = update_project(project, admin, visibility_level: Gitlab::VisibilityLevel::PUBLIC) - expect(result).to eq({ status: :error, message: 'Visibility level public is not allowed in a internal group.' }) - expect(project.reload).to be_internal + expect(result).to eq({ status: :error, message: 'Visibility level public is not allowed in a internal group.' }) + expect(project.reload).to be_internal + end end end - end - describe 'when updating project that has forks' do - let(:project) { create(:project, :internal) } - let(:forked_project) { fork_project(project) } + describe 'when updating project that has forks' do + let(:project) { create(:project, :internal) } + let(:forked_project) { fork_project(project) } - it 'updates forks visibility level when parent set to more restrictive' do - opts = { visibility_level: Gitlab::VisibilityLevel::PRIVATE } + it 'updates forks visibility level when parent set to more restrictive' do + opts = { visibility_level: Gitlab::VisibilityLevel::PRIVATE } - expect(project).to be_internal - expect(forked_project).to be_internal + expect(project).to be_internal + expect(forked_project).to be_internal - expect(update_project(project, admin, opts)).to eq({ status: :success }) + expect(update_project(project, admin, opts)).to eq({ status: :success }) - expect(project).to be_private - expect(forked_project.reload).to be_private - end + expect(project).to be_private + expect(forked_project.reload).to be_private + end - it 'does not update forks visibility level when parent set to less restrictive' do - opts = { visibility_level: Gitlab::VisibilityLevel::PUBLIC } + it 'does not update forks visibility level when parent set to less restrictive' do + opts = { visibility_level: Gitlab::VisibilityLevel::PUBLIC } - expect(project).to be_internal - expect(forked_project).to be_internal + expect(project).to be_internal + expect(forked_project).to be_internal - expect(update_project(project, admin, opts)).to eq({ status: :success }) + expect(update_project(project, admin, opts)).to eq({ status: :success }) - expect(project).to be_public - expect(forked_project.reload).to be_internal + expect(project).to be_public + expect(forked_project.reload).to be_internal + end end - end - context 'when updating a default branch' do - let(:project) { create(:project, :repository) } + context 'when updating a default branch' do + let(:project) { create(:project, :repository) } - it 'changes a default branch' do - update_project(project, admin, default_branch: 'feature') + it 'changes a default branch' do + update_project(project, admin, default_branch: 'feature') - expect(Project.find(project.id).default_branch).to eq 'feature' - end + expect(Project.find(project.id).default_branch).to eq 'feature' + end - it 'does not change a default branch' do - # The branch 'unexisted-branch' does not exist. - update_project(project, admin, default_branch: 'unexisted-branch') + it 'does not change a default branch' do + # The branch 'unexisted-branch' does not exist. + update_project(project, admin, default_branch: 'unexisted-branch') - expect(Project.find(project.id).default_branch).to eq 'master' + expect(Project.find(project.id).default_branch).to eq 'master' + end end - end - context 'when updating a project that contains container images' do - before do - stub_container_registry_config(enabled: true) - stub_container_registry_tags(repository: /image/, tags: %w[rc1]) - create(:container_repository, project: project, name: :image) - end + context 'when updating a project that contains container images' do + before do + stub_container_registry_config(enabled: true) + stub_container_registry_tags(repository: /image/, tags: %w[rc1]) + create(:container_repository, project: project, name: :image) + end - it 'does not allow to rename the project' do - result = update_project(project, admin, path: 'renamed') + it 'does not allow to rename the project' do + result = update_project(project, admin, path: 'renamed') - expect(result).to include(status: :error) - expect(result[:message]).to match(/contains container registry tags/) - end + expect(result).to include(status: :error) + expect(result[:message]).to match(/contains container registry tags/) + end - it 'allows to update other settings' do - result = update_project(project, admin, public_builds: true) + it 'allows to update other settings' do + result = update_project(project, admin, public_builds: true) - expect(result[:status]).to eq :success - expect(project.reload.public_builds).to be true + expect(result[:status]).to eq :success + expect(project.reload.public_builds).to be true + end end - end - context 'when renaming a project' do - let(:repository_storage) { 'default' } - let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } + context 'when renaming a project' do + let(:repository_storage) { 'default' } + let(:repository_storage_path) { Gitlab.config.repositories.storages[repository_storage]['path'] } - context 'with legacy storage' do - before do - gitlab_shell.add_repository(repository_storage, "#{user.namespace.full_path}/existing") - end + context 'with legacy storage' do + before do + gitlab_shell.add_repository(repository_storage, "#{user.namespace.full_path}/existing") + end + + after do + gitlab_shell.remove_repository(repository_storage_path, "#{user.namespace.full_path}/existing") + end + + it 'does not allow renaming when new path matches existing repository on disk' do + result = update_project(project, admin, path: 'existing') - after do - gitlab_shell.remove_repository(repository_storage_path, "#{user.namespace.full_path}/existing") + expect(result).to include(status: :error) + expect(result[:message]).to match('There is already a repository with that name on disk') + expect(project).not_to be_valid + expect(project.errors.messages).to have_key(:base) + expect(project.errors.messages[:base]).to include('There is already a repository with that name on disk') + end end - it 'does not allow renaming when new path matches existing repository on disk' do - result = update_project(project, admin, path: 'existing') + context 'with hashed storage' do + let(:project) { create(:project, :repository, creator: user, namespace: user.namespace) } - expect(result).to include(status: :error) - expect(result[:message]).to match('There is already a repository with that name on disk') - expect(project).not_to be_valid - expect(project.errors.messages).to have_key(:base) - expect(project.errors.messages[:base]).to include('There is already a repository with that name on disk') + before do + stub_application_setting(hashed_storage_enabled: true) + end + + it 'does not check if new path matches existing repository on disk' do + expect(project).not_to receive(:repository_with_same_path_already_exists?) + + result = update_project(project, admin, path: 'existing') + + expect(result).to include(status: :success) + end end end - context 'with hashed storage' do - let(:project) { create(:project, :repository, creator: user, namespace: user.namespace) } + context 'when passing invalid parameters' do + it 'returns an error result when record cannot be updated' do + result = update_project(project, admin, { name: 'foo&bar' }) - before do - stub_application_setting(hashed_storage_enabled: true) + expect(result).to eq({ + status: :error, + message: "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." + }) end + end + end - it 'does not check if new path matches existing repository on disk' do - expect(project).not_to receive(:repository_with_same_path_already_exists?) + describe '#run_auto_devops_pipeline?' do + subject { described_class.new(project, user, params).run_auto_devops_pipeline? } - result = update_project(project, admin, path: 'existing') + context 'when neither pipeline setting is true' do + let(:params) { {} } - expect(result).to include(status: :success) - 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' } } + + it { is_expected.to eq(true) } end - end - context 'when passing invalid parameters' do - it 'returns an error result when record cannot be updated' do - result = update_project(project, admin, { name: 'foo&bar' }) + context 'when run_auto_devops_pipeline_implicit is true' do + let(:params) { { run_auto_devops_pipeline_implicit: 'true' } } - expect(result).to eq({ - status: :error, - message: "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." - }) + it { is_expected.to eq(true) } end end diff --git a/spec/workers/create_pipeline_worker_spec.rb b/spec/workers/create_pipeline_worker_spec.rb new file mode 100644 index 00000000000..02cb0f46cb4 --- /dev/null +++ b/spec/workers/create_pipeline_worker_spec.rb @@ -0,0 +1,36 @@ +require 'spec_helper' + +describe CreatePipelineWorker do + describe '#perform' do + let(:worker) { described_class.new } + + context 'when a project not found' do + it 'does not call the Service' do + expect(Ci::CreatePipelineService).not_to receive(:new) + expect { worker.perform(99, create(:user).id, 'master', :web) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when a user not found' do + let(:project) { create(:project) } + + it 'does not call the Service' do + expect(Ci::CreatePipelineService).not_to receive(:new) + expect { worker.perform(project.id, 99, project.default_branch, :web) }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'when everything is ok' do + let(:project) { create(:project) } + let(:user) { create(:user) } + let(:create_pipeline_service) { instance_double(Ci::CreatePipelineService) } + + it 'calls the Service' do + expect(Ci::CreatePipelineService).to receive(:new).with(project, user, ref: project.default_branch).and_return(create_pipeline_service) + expect(create_pipeline_service).to receive(:execute).with(:web, any_args) + + worker.perform(project.id, user.id, project.default_branch, :web) + end + end + end +end |