Commit 6ff2fc84 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'master' into ee-multi-file-editor-dirty-diff-indicator

parents 216aecfa 689b3119
/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, prefer-template, quotes, no-unused-vars, prefer-arrow-callback, max-len */
import Clipboard from 'clipboard'; import Clipboard from 'clipboard';
var genericError, genericSuccess, showTooltip; function showTooltip(target, title) {
const $target = $(target);
const originalTitle = $target.data('original-title');
if (!$target.data('hideTooltip')) {
$target
.attr('title', title)
.tooltip('fixTitle')
.tooltip('show')
.attr('title', originalTitle)
.tooltip('fixTitle');
}
}
genericSuccess = function(e) { function genericSuccess(e) {
showTooltip(e.trigger, 'Copied'); showTooltip(e.trigger, 'Copied');
// Clear the selection and blur the trigger so it loses its border // Clear the selection and blur the trigger so it loses its border
e.clearSelection(); e.clearSelection();
return $(e.trigger).blur(); $(e.trigger).blur();
}; }
// Safari doesn't support `execCommand`, so instead we inform the user to /**
// copy manually. * Safari > 10 doesn't support `execCommand`, so instead we inform the user to copy manually.
// * See http://clipboardjs.com/#browser-support
// See http://clipboardjs.com/#browser-support */
genericError = function(e) { function genericError(e) {
var key; let key;
if (/Mac/i.test(navigator.userAgent)) { if (/Mac/i.test(navigator.userAgent)) {
key = '⌘'; // Command key = '⌘'; // Command
} else { } else {
key = 'Ctrl'; key = 'Ctrl';
} }
return showTooltip(e.trigger, "Press " + key + "-C to copy"); showTooltip(e.trigger, `Press ${key}-C to copy`);
}; }
showTooltip = function(target, title) {
var $target = $(target);
var originalTitle = $target.data('original-title');
if (!$target.data('hideTooltip')) {
$target
.attr('title', 'Copied')
.tooltip('fixTitle')
.tooltip('show')
.attr('title', originalTitle)
.tooltip('fixTitle');
}
};
$(function() { export default function initCopyToClipboard() {
const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]'); const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess); clipboard.on('success', genericSuccess);
clipboard.on('error', genericError); clipboard.on('error', genericError);
// This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting of plain text or GFM. /**
// The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and `gfm` keys into the `data-clipboard-text` * This a workaround around ClipboardJS limitations to allow the context-specific copy/pasting
// attribute that ClipboardJS reads from. * of plain text or GFM. The Ruby `clipboard_button` helper sneaks a JSON hash with `text` and
// When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` attribute`), sets its value * `gfm` keys into the `data-clipboard-text` attribute that ClipboardJS reads from.
// to the value of this data attribute, focusses on it, and finally programmatically issues the 'Copy' command, * When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly`
// this code intercepts the copy command/event at the last minute to deconstruct this JSON hash and set the * attribute`), sets its value to the value of this data attribute, focusses on it, and finally
// `text/plain` and `text/x-gfm` copy data types to the intended values. * programmatically issues the 'Copy' command, this code intercepts the copy command/event at
$(document).on('copy', 'body > textarea[readonly]', function(e) { * the last minute to deconstruct this JSON hash and set the `text/plain` and `text/x-gfm` copy
* data types to the intended values.
*/
$(document).on('copy', 'body > textarea[readonly]', (e) => {
const clipboardData = e.originalEvent.clipboardData; const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return; if (!clipboardData) return;
...@@ -71,4 +70,4 @@ $(function() { ...@@ -71,4 +70,4 @@ $(function() {
clipboardData.setData('text/plain', json.text); clipboardData.setData('text/plain', json.text);
clipboardData.setData('text/x-gfm', json.gfm); clipboardData.setData('text/x-gfm', json.gfm);
}); });
}); }
import './autosize'; import './autosize';
import './bind_in_out'; import './bind_in_out';
import initCopyAsGFM from './copy_as_gfm'; import initCopyAsGFM from './copy_as_gfm';
import initCopyToClipboard from './copy_to_clipboard';
import './details_behavior'; import './details_behavior';
import installGlEmojiElement from './gl_emoji'; import installGlEmojiElement from './gl_emoji';
import './quick_submit'; import './quick_submit';
...@@ -9,3 +10,4 @@ import './toggler_behavior'; ...@@ -9,3 +10,4 @@ import './toggler_behavior';
installGlEmojiElement(); installGlEmojiElement();
initCopyAsGFM(); initCopyAsGFM();
initCopyToClipboard();
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
import Flash from './flash'; import Flash from './flash';
import DropLab from './droplab/drop_lab'; import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter'; import ISetter from './droplab/plugins/input_setter';
import { __, sprintf } from './locale';
// Todo: Remove this when fixing issue in input_setter plugin // Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter); const InputSetter = Object.assign({}, ISetter);
...@@ -12,28 +13,49 @@ const CREATE_BRANCH = 'create-branch'; ...@@ -12,28 +13,49 @@ const CREATE_BRANCH = 'create-branch';
export default class CreateMergeRequestDropdown { export default class CreateMergeRequestDropdown {
constructor(wrapperEl) { constructor(wrapperEl) {
this.wrapperEl = 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.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.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.unavailableButton = this.wrapperEl.querySelector('.unavailable');
this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa'); this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa');
this.unavailableButtonText = this.unavailableButton.querySelector('.text'); 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.canCreatePath = this.wrapperEl.dataset.canCreatePath;
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath; this.createMrPath = this.wrapperEl.dataset.createMrPath;
this.droplabInitialized = false; this.droplabInitialized = false;
this.isCreatingBranch = false;
this.isCreatingMergeRequest = false; this.isCreatingMergeRequest = false;
this.isGettingRef = false;
this.mergeRequestCreated = false; this.mergeRequestCreated = false;
this.isCreatingBranch = false; this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500);
this.branchCreated = false; 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.init();
this.checkAbilityToCreateBranch();
} }
available() { available() {
...@@ -41,41 +63,13 @@ export default class CreateMergeRequestDropdown { ...@@ -41,41 +63,13 @@ export default class CreateMergeRequestDropdown {
this.unavailableButton.classList.add('hide'); this.unavailableButton.classList.add('hide');
} }
unavailable() { bindEvents() {
this.availableButton.classList.add('hide'); this.createMergeRequestButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
this.unavailableButton.classList.remove('hide'); this.createTargetButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
} this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this));
enable() { this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.createMergeRequestButton.classList.remove('disabled'); this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
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';
}
} }
checkAbilityToCreateBranch() { checkAbilityToCreateBranch() {
...@@ -107,49 +101,233 @@ export default class CreateMergeRequestDropdown { ...@@ -107,49 +101,233 @@ export default class CreateMergeRequestDropdown {
}); });
} }
initDroplab() { createBranch() {
this.droplab = new DropLab(); 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.'));
}
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));
this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter], return false;
this.getDroplabConfig());
} }
getDroplabConfig() { getDroplabConfig() {
return { return {
InputSetter: [{ addActiveClassToDropdownButton: true,
InputSetter: [
{
input: this.createMergeRequestButton, input: this.createMergeRequestButton,
valueAttribute: 'data-value', valueAttribute: 'data-value',
inputAttribute: 'data-action', inputAttribute: 'data-action',
}, { },
{
input: this.createMergeRequestButton, input: this.createMergeRequestButton,
valueAttribute: 'data-text', valueAttribute: 'data-text',
}], },
{
input: this.createTargetButton,
valueAttribute: 'data-value',
inputAttribute: 'data-action',
},
{
input: this.createTargetButton,
valueAttribute: 'data-text',
},
],
}; };
} }
bindEvents() { static getInputSelectedText(input) {
this.createMergeRequestButton const start = input.selectionStart;
.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this)); 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() { isBusy() {
return this.isCreatingMergeRequest || return this.isCreatingMergeRequest ||
this.mergeRequestCreated || this.mergeRequestCreated ||
this.isCreatingBranch || this.isCreatingBranch ||
this.branchCreated; this.branchCreated ||
this.isGettingRef;
}
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(e) { onClickCreateMergeRequestButton(event) {
let xhr = null; let xhr = null;
e.preventDefault(); event.preventDefault();
if (this.isBusy()) { if (this.isBusy()) {
return; return;
} }
if (e.target.dataset.action === CREATE_MERGE_REQUEST) { if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest(); xhr = this.createMergeRequest();
} else if (e.target.dataset.action === CREATE_BRANCH) { } else if (event.target.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch(); xhr = this.createBranch();
} }
...@@ -163,31 +341,131 @@ export default class CreateMergeRequestDropdown { ...@@ -163,31 +341,131 @@ export default class CreateMergeRequestDropdown {
this.disable(); this.disable();
} }
createMergeRequest() { onClickSetFocusOnBranchNameInput() {
return $.ajax({ this.branchInput.focus();
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.'));
} }
createBranch() { // `TAB` autocompletes the source.
return $.ajax({ static processTab(event) {
method: 'POST', if (event.keyCode !== 9 || this.isGettingRef) return;
dataType: 'json',
url: this.createBranchPath, const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput);
beforeSend: () => (this.isCreatingBranch = true),
}) // if nothing selected, we don't need to autocomplete anything. Do the default TAB action.
.done((data) => { // If a user manually selected text, don't autocomplete anything. Do the default TAB action.
this.branchCreated = true; if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return;
window.location.href = data.url;
}) event.preventDefault();
.fail(() => new Flash('Failed to create a branch for this issue. Please try again.')); 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();
}
} }
} }
...@@ -583,6 +583,13 @@ import initGroupAnalytics from './init_group_analytics'; ...@@ -583,6 +583,13 @@ import initGroupAnalytics from './init_group_analytics';
case 'projects:settings:ci_cd:show': case 'projects:settings:ci_cd:show':
// Initialize expandable settings panels // Initialize expandable settings panels
initSettingsPanels(); 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': case 'groups:settings:ci_cd:show':
new ProjectVariables(); new ProjectVariables();
break; break;
......
...@@ -3,6 +3,7 @@ const DATA_DROPDOWN = 'data-dropdown'; ...@@ -3,6 +3,7 @@ const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected'; const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active'; const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore'; const IGNORE_CLASS = 'droplab-item-ignore';
const IGNORE_HIDING_CLASS = 'droplab-item-ignore-hiding';
// Matches `{{anything}}` and `{{ everything }}`. // Matches `{{anything}}` and `{{ everything }}`.
const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g; const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g;
...@@ -13,4 +14,5 @@ export { ...@@ -13,4 +14,5 @@ export {
ACTIVE_CLASS, ACTIVE_CLASS,
TEMPLATE_REGEX, TEMPLATE_REGEX,
IGNORE_CLASS, IGNORE_CLASS,
IGNORE_HIDING_CLASS,
}; };
import utils from './utils'; import utils from './utils';
import { SELECTED_CLASS, IGNORE_CLASS } from './constants'; import { SELECTED_CLASS, IGNORE_CLASS, IGNORE_HIDING_CLASS } from './constants';
class DropDown { class DropDown {
constructor(list) { constructor(list, config = {}) {
this.currentIndex = 0; this.currentIndex = 0;
this.hidden = true; this.hidden = true;
this.list = typeof list === 'string' ? document.querySelector(list) : list; this.list = typeof list === 'string' ? document.querySelector(list) : list;
this.items = []; this.items = [];
this.eventWrapper = {}; this.eventWrapper = {};
if (config.addActiveClassToDropdownButton) {
this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle');
}
this.getItems(); this.getItems();
this.initTemplateString(); this.initTemplateString();
this.addEvents(); this.addEvents();
...@@ -42,7 +45,7 @@ class DropDown { ...@@ -42,7 +45,7 @@ class DropDown {
this.addSelectedClass(selected); this.addSelectedClass(selected);
e.preventDefault(); e.preventDefault();
this.hide(); if (!e.target.classList.contains(IGNORE_HIDING_CLASS)) this.hide();
const listEvent = new CustomEvent('click.dl', { const listEvent = new CustomEvent('click.dl', {
detail: { detail: {
...@@ -67,7 +70,20 @@ class DropDown { ...@@ -67,7 +70,20 @@ class DropDown {
addEvents() { addEvents() {
this.eventWrapper.clickEvent = this.clickEvent.bind(this); this.eventWrapper.clickEvent = this.clickEvent.bind(this);
this.eventWrapper.closeDropdown = this.closeDropdown.bind(this);
this.list.addEventListener('click', this.eventWrapper.clickEvent); 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) { setData(data) {
...@@ -110,6 +126,8 @@ class DropDown { ...@@ -110,6 +126,8 @@ class DropDown {
this.list.style.display = 'block'; this.list.style.display = 'block';
this.currentIndex = 0; this.currentIndex = 0;
this.hidden = false; this.hidden = false;
if (this.dropdownToggle) this.dropdownToggle.classList.add('active');
} }
hide() { hide() {
...@@ -117,6 +135,8 @@ class DropDown { ...@@ -117,6 +135,8 @@ class DropDown {
this.list.style.display = 'none'; this.list.style.display = 'none';
this.currentIndex = 0; this.currentIndex = 0;
this.hidden = true; this.hidden = true;
if (this.dropdownToggle) this.dropdownToggle.classList.remove('active');
} }
toggle() { toggle() {
...@@ -128,6 +148,7 @@ class DropDown { ...@@ -128,6 +148,7 @@ class DropDown {
destroy() { destroy() {
this.hide(); this.hide();
this.list.removeEventListener('click', this.eventWrapper.clickEvent); this.list.removeEventListener('click', this.eventWrapper.clickEvent);
this.list.removeEventListener('keyup', this.eventWrapper.closeDropdown);
} }
static setImagesSrc(template) { static setImagesSrc(template) {
......
...@@ -3,7 +3,7 @@ import DropDown from './drop_down'; ...@@ -3,7 +3,7 @@ import DropDown from './drop_down';
class Hook { class Hook {
constructor(trigger, list, plugins, config) { constructor(trigger, list, plugins, config) {
this.trigger = trigger; this.trigger = trigger;
this.list = new DropDown(list); this.list = new DropDown(list, config);
this.type = 'Hook'; this.type = 'Hook';
this.event = 'click'; this.event = 'click';
this.plugins = plugins || []; this.plugins = plugins || [];
......
...@@ -41,7 +41,7 @@ const createFlashEl = (message, type, isInContentWrapper = false) => ` ...@@ -41,7 +41,7 @@ const createFlashEl = (message, type, isInContentWrapper = false) => `
`; `;
const removeFlashClickListener = (flashEl, fadeTransition) => { const removeFlashClickListener = (flashEl, fadeTransition) => {
flashEl.parentNode.addEventListener('click', () => hideFlash(flashEl, fadeTransition)); flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
}; };
/* /*
......
...@@ -16,6 +16,10 @@ export default { ...@@ -16,6 +16,10 @@ export default {
required: true, required: true,
type: String, type: String,
}, },
updateEndpoint: {
required: true,
type: String,
},
canUpdate: { canUpdate: {
required: true, required: true,
type: Boolean, type: Boolean,
...@@ -262,6 +266,8 @@ export default { ...@@ -262,6 +266,8 @@ export default {
:description-text="state.descriptionText" :description-text="state.descriptionText"
:updated-at="state.updatedAt" :updated-at="state.updatedAt"
:task-status="state.taskStatus" :task-status="state.taskStatus"
:issuable-type="issuableType"
:update-url="updateEndpoint"
/> />
<edited-component <edited-component
v-if="hasUpdated" v-if="hasUpdated"
......
...@@ -22,6 +22,16 @@ ...@@ -22,6 +22,16 @@
required: false, required: false,
default: '', default: '',
}, },
issuableType: {
type: String,
required: false,
default: 'issue',
},
updateUrl: {
type: String,
required: false,
default: null,
},
}, },
data() { data() {
return { return {
...@@ -48,7 +58,7 @@ ...@@ -48,7 +58,7 @@
if (this.canUpdate) { if (this.canUpdate) {
// eslint-disable-next-line no-new // eslint-disable-next-line no-new
new TaskList({ new TaskList({
dataType: 'issue', dataType: this.issuableType,
fieldName: 'description', fieldName: 'description',
selector: '.detail-page-description', selector: '.detail-page-description',
}); });
...@@ -95,7 +105,9 @@ ...@@ -95,7 +105,9 @@
<textarea <textarea
class="hidden js-task-list-field" class="hidden js-task-list-field"
v-if="descriptionText" v-if="descriptionText"
v-model="descriptionText"> v-model="descriptionText"
:data-update-url="updateUrl"
>
</textarea> </textarea>
</div> </div>
</template> </template>
...@@ -79,7 +79,7 @@ ...@@ -79,7 +79,7 @@
v-tooltip v-tooltip
v-if="showInlineEditButton && canUpdate" v-if="showInlineEditButton && canUpdate"
type="button" type="button"
class="btn-blank btn-edit note-action-button" class="btn btn-default btn-edit btn-svg"
v-html="pencilIcon" v-html="pencilIcon"
title="Edit title and description" title="Edit title and description"
data-placement="bottom" data-placement="bottom"
......
...@@ -44,7 +44,6 @@ import './commits'; ...@@ -44,7 +44,6 @@ import './commits';
import './compare'; import './compare';
import './compare_autocomplete'; import './compare_autocomplete';
import './confirm_danger_modal'; import './confirm_danger_modal';
import './copy_to_clipboard';
import './diff'; import './diff';
import './files_comment_button'; import './files_comment_button';
import Flash, { removeFlashClickListener } from './flash'; import Flash, { removeFlashClickListener } from './flash';
...@@ -319,6 +318,8 @@ $(function () { ...@@ -319,6 +318,8 @@ $(function () {
const flashContainer = document.querySelector('.flash-container'); const flashContainer = document.querySelector('.flash-container');
if (flashContainer && flashContainer.children.length) { if (flashContainer && flashContainer.children.length) {
removeFlashClickListener(flashContainer.children[0]); flashContainer.querySelectorAll('.flash-alert, .flash-notice, .flash-success').forEach((flashEl) => {
removeFlashClickListener(flashEl);
});
} }
}); });
...@@ -17,13 +17,14 @@ export default class Project { ...@@ -17,13 +17,14 @@ export default class Project {
$('a', $cloneOptions).on('click', (e) => { $('a', $cloneOptions).on('click', (e) => {
const $this = $(e.currentTarget); const $this = $(e.currentTarget);
const url = $this.attr('href'); const url = $this.attr('href');
const activeText = $this.find('.dropdown-menu-inner-title').text();
e.preventDefault(); e.preventDefault();
$('.is-active', $cloneOptions).not($this).removeClass('is-active'); $('.is-active', $cloneOptions).not($this).removeClass('is-active');
$this.toggleClass('is-active'); $this.toggleClass('is-active');
$projectCloneField.val(url); $projectCloneField.val(url);
$cloneBtnText.text($this.text()); $cloneBtnText.text(activeText);
$('#modal-geo-info').data({ $('#modal-geo-info').data({
cloneUrlSecondary: $this.attr('href'), cloneUrlSecondary: $this.attr('href'),
......
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)),
);
}
<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>
<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>
<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>
...@@ -40,20 +40,24 @@ export default { ...@@ -40,20 +40,24 @@ export default {
</script> </script>
<template> <template>
<div class="repository-view"> <div
<div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}"> class="multi-file"
:class="{
'is-collapsed': isCollapsed
}"
>
<repo-sidebar/> <repo-sidebar/>
<div <div
v-if="isCollapsed" v-if="isCollapsed"
class="panel-right" class="multi-file-edit-pane"
> >
<repo-tabs/> <repo-tabs />
<component <component
class="multi-file-edit-pane-content"
:is="currentBlobView" :is="currentBlobView"
/> />
<repo-file-buttons/> <repo-file-buttons />
</div>
</div> </div>
<repo-commit-section v-if="changedFiles.length" /> <repo-commit-section />
</div> </div>
</template> </template>
<script> <script>
import { mapGetters, mapState, mapActions } from 'vuex'; 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 PopupDialog from '../../vue_shared/components/popup_dialog.vue';
import { n__ } from '../../locale'; import commitFilesList from './commit_sidebar/list.vue';
export default { export default {
components: { components: {
PopupDialog, PopupDialog,
icon,
commitFilesList,
},
directives: {
tooltip,
}, },
data() { data() {
return { return {
...@@ -13,6 +20,7 @@ export default { ...@@ -13,6 +20,7 @@ export default {
submitCommitsLoading: false, submitCommitsLoading: false,
startNewMR: false, startNewMR: false,
commitMessage: '', commitMessage: '',
collapsed: true,
}; };
}, },
computed: { computed: {
...@@ -23,10 +31,10 @@ export default { ...@@ -23,10 +31,10 @@ export default {
'changedFiles', 'changedFiles',
]), ]),
commitButtonDisabled() { commitButtonDisabled() {
return !this.commitMessage || this.submitCommitsLoading; return this.commitMessage === '' || this.submitCommitsLoading || !this.changedFiles.length;
}, },
commitButtonText() { commitMessageCount() {
return n__('Commit %d file', 'Commit %d files', this.changedFiles.length); return this.commitMessage.length;
}, },
}, },
methods: { methods: {
...@@ -77,12 +85,20 @@ export default { ...@@ -77,12 +85,20 @@ export default {
this.submitCommitsLoading = false; this.submitCommitsLoading = false;
}); });
}, },
toggleCollapsed() {
this.collapsed = !this.collapsed;
},
}, },
}; };
</script> </script>
<template> <template>
<div id="commit-area"> <div
class="multi-file-commit-panel"
:class="{
'is-collapsed': collapsed,
}"
>
<popup-dialog <popup-dialog
v-if="showNewBranchDialog" v-if="showNewBranchDialog"
:primary-button-label="__('Create new branch')" :primary-button-label="__('Create new branch')"
...@@ -92,78 +108,71 @@ export default { ...@@ -92,78 +108,71 @@ export default {
@toggle="showNewBranchDialog = false" @toggle="showNewBranchDialog = false"
@submit="makeCommit(true)" @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 <form
class="form-horizontal" class="form-horizontal multi-file-commit-form"
@submit.prevent="tryCommit()"> @submit.prevent="tryCommit"
<fieldset> v-if="!collapsed"
<div class="form-group"> >
<label class="col-md-4 control-label staged-files"> <div class="multi-file-commit-fieldset">
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 <textarea
id="commit-message" class="form-control multi-file-commit-message"
class="form-control"
name="commit-message" name="commit-message"
v-model="commitMessage"> v-model="commitMessage"
placeholder="Commit message"
>
</textarea> </textarea>
</div> </div>
</div> <div class="multi-file-commit-fieldset">
<div class="form-group target-branch">
<label <label
class="col-md-4 control-label" v-tooltip
for="target-branch"> title="Create a new merge request with these changes"
Target branch data-container="body"
data-placement="top"
>
<input
type="checkbox"
v-model="startNewMR"
/>
Merge Request
</label> </label>
<div class="col-md-6">
<span class="help-block">
{{currentBranch}}
</span>
</div>
</div>
<div class="col-md-offset-4 col-md-6">
<button <button
type="submit" type="submit"
:disabled="commitButtonDisabled" :disabled="commitButtonDisabled"
class="btn btn-success"> class="btn btn-default btn-sm append-right-10 prepend-left-10"
>
<i <i
v-if="submitCommitsLoading" v-if="submitCommitsLoading"
class="js-commit-loading-icon fa fa-spinner fa-spin" class="js-commit-loading-icon fa fa-spinner fa-spin"
aria-hidden="true" aria-hidden="true"
aria-label="loading"> aria-label="loading"
>
</i> </i>
<span class="commit-summary"> Commit
{{ commitButtonText }}
</span>
</button> </button>
</div> <div
<div class="col-md-offset-4 col-md-6"> class="multi-file-commit-message-count"
<div class="checkbox"> >
<label> {{ commitMessageCount }}
<input type="checkbox" v-model="startNewMR">
<span>Start a <strong>new merge request</strong> with these changes</span>
</label>
</div> </div>
</div> </div>
</fieldset>
</form> </form>
</div> </div>
</template> </template>
...@@ -57,7 +57,7 @@ ...@@ -57,7 +57,7 @@
class="file" class="file"
@click.prevent="clickedTreeRow(file)"> @click.prevent="clickedTreeRow(file)">
<td <td
class="multi-file-table-col-name" class="multi-file-table-name"
:colspan="submoduleColSpan" :colspan="submoduleColSpan"
> >
<i <i
...@@ -90,12 +90,11 @@ ...@@ -90,12 +90,11 @@
</td> </td>
<template v-if="!isCollapsed && !isSubmodule"> <template v-if="!isCollapsed && !isSubmodule">
<td class="hidden-sm hidden-xs"> <td class="multi-file-table-col-commit-message hidden-sm hidden-xs">
<a <a
v-if="file.lastCommit.message" v-if="file.lastCommit.message"
@click.stop @click.stop
:href="file.lastCommit.url" :href="file.lastCommit.url"
class="commit-message"
> >
{{ file.lastCommit.message }} {{ file.lastCommit.message }}
</a> </a>
......
...@@ -22,12 +22,12 @@ export default { ...@@ -22,12 +22,12 @@ export default {
<template> <template>
<div <div
v-if="showButtons" v-if="showButtons"
class="repo-file-buttons" class="multi-file-editor-btn-group"
> >
<a <a
:href="activeFile.rawPath" :href="activeFile.rawPath"
target="_blank" target="_blank"
class="btn btn-default raw" class="btn btn-default btn-sm raw"
rel="noopener noreferrer"> rel="noopener noreferrer">
{{ rawDownloadButtonLabel }} {{ rawDownloadButtonLabel }}
</a> </a>
...@@ -38,17 +38,17 @@ export default { ...@@ -38,17 +38,17 @@ export default {
aria-label="File actions"> aria-label="File actions">
<a <a
:href="activeFile.blamePath" :href="activeFile.blamePath"
class="btn btn-default blame"> class="btn btn-default btn-sm blame">
Blame Blame
</a> </a>
<a <a
:href="activeFile.commitsPath" :href="activeFile.commitsPath"
class="btn btn-default history"> class="btn btn-default btn-sm history">
History History
</a> </a>
<a <a
:href="activeFile.permalink" :href="activeFile.permalink"
class="btn btn-default permalink"> class="btn btn-default btn-sm permalink">
Permalink Permalink
</a> </a>
</div> </div>
......
...@@ -32,10 +32,12 @@ export default { ...@@ -32,10 +32,12 @@ export default {
</script> </script>
<template> <template>
<div class="blob-viewer-container"> <div>
<div <div
v-if="!activeFile.renderError" v-if="!activeFile.renderError"
v-html="activeFile.html"> v-html="activeFile.html"
class="multi-file-preview-holder"
>
</div> </div>
<div <div
v-else-if="activeFile.tempFile" v-else-if="activeFile.tempFile"
......
...@@ -44,20 +44,16 @@ export default { ...@@ -44,20 +44,16 @@ export default {
</script> </script>
<template> <template>
<div id="sidebar" :class="{'sidebar-mini' : isCollapsed}"> <div class="ide-file-list">
<table class="table"> <table class="table">
<thead> <thead>
<tr> <tr>
<th <th
v-if="isCollapsed" v-if="isCollapsed"
class="repo-file-options title"
> >
<strong class="clgray">
{{ projectName }}
</strong>
</th> </th>
<template v-else> <template v-else>
<th class="name multi-file-table-col-name"> <th class="name multi-file-table-name">
Name Name
</th> </th>
<th class="hidden-sm hidden-xs last-commit"> <th class="hidden-sm hidden-xs last-commit">
...@@ -79,7 +75,7 @@ export default { ...@@ -79,7 +75,7 @@ export default {
:key="n" :key="n"
/> />
<repo-file <repo-file
v-for="(file, index) in treeList" v-for="file in treeList"
:key="file.key" :key="file.key"
:file="file" :file="file"
/> />
......
...@@ -41,30 +41,35 @@ export default { ...@@ -41,30 +41,35 @@ export default {
<template> <template>
<li <li
:class="{ active : tab.active }"
@click="setFileActive(tab)" @click="setFileActive(tab)"
> >
<button <button
type="button" type="button"
class="close-btn" class="multi-file-tab-close"
@click.stop.prevent="closeFile({ file: tab })" @click.stop.prevent="closeFile({ file: tab })"
:aria-label="closeLabel"> :aria-label="closeLabel"
:class="{
'modified': tab.changed,
}"
:disabled="tab.changed"
>
<i <i
class="fa" class="fa"
:class="changedClass" :class="changedClass"
aria-hidden="true"> aria-hidden="true"
>
</i> </i>
</button> </button>
<a <div
href="#" class="multi-file-tab"
class="repo-tab" :class="{active : tab.active }"
:title="tab.url" :title="tab.url"
@click.prevent.stop="setFileActive(tab)"> >
{{tab.name}} {{ tab.name }}
<fileStatusIcon <file-status-icon
:file="tab"> :file="tab"
</fileStatusIcon> />
</a> </div>
</li> </li>
</template> </template>
...@@ -16,14 +16,12 @@ ...@@ -16,14 +16,12 @@
<template> <template>
<ul <ul
id="tabs" class="multi-file-tabs list-unstyled append-bottom-0"
class="list-unstyled"
> >
<repo-tab <repo-tab
v-for="tab in openFiles" v-for="tab in openFiles"
:key="tab.id" :key="tab.id"
:tab="tab" :tab="tab"
/> />
<li class="tabs-divider" />
</ul> </ul>
</template> </template>
...@@ -34,3 +34,7 @@ export const canEditFile = (state) => { ...@@ -34,3 +34,7 @@ export const canEditFile = (state) => {
openedFiles.length && openedFiles.length &&
(currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary); (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);
...@@ -125,7 +125,7 @@ ...@@ -125,7 +125,7 @@
@include transition(border-color); @include transition(border-color);
} }
.note-action-button .link-highlight, .note-action-button,
.toolbar-btn, .toolbar-btn,
.dropdown-toggle-caret { .dropdown-toggle-caret {
@include transition(color); @include transition(color);
......
...@@ -88,17 +88,6 @@ ...@@ -88,17 +88,6 @@
border-color: $border-dark; border-color: $border-dark;
color: $color; color: $color;
} }
svg {
path {
fill: $color;
}
use {
stroke: $color;
}
}
} }
@mixin btn-green { @mixin btn-green {
...@@ -142,6 +131,13 @@ ...@@ -142,6 +131,13 @@
} }
} }
@mixin btn-svg {
height: $gl-padding;
width: $gl-padding;
top: 0;
vertical-align: text-top;
}
.btn { .btn {
@include btn-default; @include btn-default;
@include btn-white; @include btn-white;
...@@ -444,3 +440,7 @@ ...@@ -444,3 +440,7 @@
text-decoration: none; text-decoration: none;
} }
} }
.btn-svg svg {
@include btn-svg;
}
...@@ -2,14 +2,43 @@ ...@@ -2,14 +2,43 @@
.cgray { color: $common-gray; } .cgray { color: $common-gray; }
.clgray { color: $common-gray-light; } .clgray { color: $common-gray-light; }
.cred { color: $common-red; } .cred { color: $common-red; }
svg.cred { fill: $common-red; }
.cgreen { color: $common-green; } .cgreen { color: $common-green; }
svg.cgreen { fill: $common-green; }
.cdark { color: $common-gray-dark; } .cdark { color: $common-gray-dark; }
.text-plain,
.text-plain:hover {
color: $gl-text-color;
}
.text-secondary { .text-secondary {
color: $gl-text-color-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; } .underlined-link { text-decoration: underline; }
.hint { font-style: italic; color: $hint-color; } .hint { font-style: italic; color: $hint-color; }
.light { color: $common-gray; } .light { color: $common-gray; }
......
...@@ -34,8 +34,15 @@ ...@@ -34,8 +34,15 @@
} }
} }
.flash-success {
@extend .alert;
@extend .alert-success;
margin: 0;
}
.flash-notice, .flash-notice,
.flash-alert { .flash-alert,
.flash-success {
border-radius: $border-radius-default; border-radius: $border-radius-default;
.container-fluid, .container-fluid,
...@@ -48,7 +55,8 @@ ...@@ -48,7 +55,8 @@
margin-bottom: 0; margin-bottom: 0;
.flash-notice, .flash-notice,
.flash-alert { .flash-alert,
.flash-success {
border-radius: 0; border-radius: 0;
} }
} }
......
.ci-status-icon-success, .ci-status-icon-success,
.ci-status-icon-passed { .ci-status-icon-passed {
color: $green-500; svg {
fill: $green-500;
}
} }
.ci-status-icon-failed { .ci-status-icon-failed {
color: $gl-danger; svg {
fill: $gl-danger;
}
} }
.ci-status-icon-pending, .ci-status-icon-pending,
.ci-status-icon-failed_with_warnings, .ci-status-icon-failed_with_warnings,
.ci-status-icon-success_with_warnings { .ci-status-icon-success_with_warnings {
color: $orange-500; svg {
fill: $orange-500;
}
} }
.ci-status-icon-running { .ci-status-icon-running {
color: $blue-400; svg {
fill: $blue-400;
}
} }
.ci-status-icon-canceled, .ci-status-icon-canceled,
.ci-status-icon-disabled, .ci-status-icon-disabled,
.ci-status-icon-not-found { .ci-status-icon-not-found {
color: $gl-text-color; svg {
fill: $gl-text-color;
}
} }
.ci-status-icon-created, .ci-status-icon-created,
.ci-status-icon-skipped { .ci-status-icon-skipped {
color: $gray-darkest; svg {
fill: $gray-darkest;
}
} }
.ci-status-icon-manual { .ci-status-icon-manual {
color: $gl-text-color; svg {
fill: $gl-text-color;
}
} }
.icon-link { .icon-link {
......
...@@ -195,33 +195,6 @@ summary { ...@@ -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 // Prevent datetimes on tooltips to break into two lines
.local-timeago { .local-timeago {
white-space: nowrap; white-space: nowrap;
......
...@@ -70,14 +70,13 @@ ...@@ -70,14 +70,13 @@
.title { .title {
padding: 0; padding: 0;
margin-bottom: 16px; margin-bottom: $gl-padding;
border-bottom: 0; border-bottom: 0;
} }
.btn-edit { .btn-edit {
margin-left: auto; margin-left: auto;
// Set height to match title height height: $gl-padding * 2;
height: 2em;
} }
// Border around images in issue and MR descriptions. // Border around images in issue and MR descriptions.
......
...@@ -218,7 +218,24 @@ ul.related-merge-requests > li { ...@@ -218,7 +218,24 @@ ul.related-merge-requests > li {
} }
.create-mr-dropdown-wrap { .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) { .btn-group:not(.hide) {
display: flex; display: flex;
...@@ -229,15 +246,16 @@ ul.related-merge-requests > li { ...@@ -229,15 +246,16 @@ ul.related-merge-requests > li {
flex-shrink: 0; flex-shrink: 0;
} }
.dropdown-menu { .create-merge-request-dropdown-menu {
width: 300px; width: 300px;
opacity: 1; opacity: 1;
visibility: visible; visibility: visible;
transform: translateY(0); transform: translateY(0);
display: none; display: none;
margin-top: 4px;
} }
.dropdown-toggle { .create-merge-request-dropdown-toggle {
.fa-caret-down { .fa-caret-down {
pointer-events: none; pointer-events: none;
color: inherit; color: inherit;
...@@ -245,18 +263,50 @@ ul.related-merge-requests > li { ...@@ -245,18 +263,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) { li:not(.divider) {
padding: 8px 9px;
&:last-child {
padding-bottom: 8px;
}
&.droplab-item-selected { &.droplab-item-selected {
.icon-container { .icon-container {
i { i {
visibility: visible; visibility: visible;
} }
} }
.description {
display: block;
}
}
&.droplab-item-ignore {
padding-top: 8px;
} }
.icon-container { .icon-container {
float: left; float: left;
padding-left: 6px;
i { i {
visibility: hidden; visibility: hidden;
...@@ -264,13 +314,12 @@ ul.related-merge-requests > li { ...@@ -264,13 +314,12 @@ ul.related-merge-requests > li {
} }
.description { .description {
padding-left: 30px; padding-left: 22px;
font-size: 13px;
strong {
display: block;
font-weight: $gl-font-weight-bold;
} }
input,
span {
margin: 4px 0 0;
} }
} }
} }
......
...@@ -543,10 +543,7 @@ ul.notes { ...@@ -543,10 +543,7 @@ ul.notes {
} }
svg { svg {
height: 16px; @include btn-svg;
width: 16px;
top: 0;
vertical-align: text-top;
} }
.award-control-icon-positive, .award-control-icon-positive,
...@@ -780,12 +777,6 @@ ul.notes { ...@@ -780,12 +777,6 @@ ul.notes {
} }
} }
svg {
fill: currentColor;
height: 16px;
width: 16px;
}
.loading { .loading {
margin: 0; margin: 0;
height: auto; height: auto;
......
...@@ -410,6 +410,18 @@ ...@@ -410,6 +410,18 @@
} }
} }
} }
.clone-dropdown-btn {
background-color: $white-light;
}
.clone-options-dropdown {
min-width: 240px;
.dropdown-menu-inner-content {
min-width: 320px;
}
}
} }
.project-repo-buttons { .project-repo-buttons {
...@@ -893,10 +905,6 @@ pre.light-well { ...@@ -893,10 +905,6 @@ pre.light-well {
font-size: $gl-font-size; font-size: $gl-font-size;
} }
a {
color: $gl-text-color;
}
.avatar-container, .avatar-container,
.controls { .controls {
flex: 0 0 auto; flex: 0 0 auto;
......
...@@ -35,127 +35,92 @@ ...@@ -35,127 +35,92 @@
} }
} }
.repository-view { .multi-file {
border: 1px solid $border-color; display: flex;
border-radius: $border-radius-default; height: calc(100vh - 145px);
color: $almost-black; border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
.code.white pre .hll { &.is-collapsed {
background-color: $well-light-border !important; .ide-file-list {
max-width: 250px;
} }
.tree-content-holder {
display: -webkit-flex;
display: flex;
min-height: 300px;
} }
.tree-content-holder-mini { .file-status-icon {
height: 100vh; width: 10px;
height: 10px;
} }
}
.panel-right { .ide-file-list {
display: -webkit-flex; flex: 1;
display: flex; overflow: scroll;
-webkit-flex-direction: column;
flex-direction: column;
width: 80%;
height: 100%;
.monaco-editor.vs {
.current-line {
border: 0;
background: $well-light-border;
}
.line-numbers { .file {
cursor: pointer; cursor: pointer;
min-width: initial;
&:hover {
text-decoration: underline;
}
}
} }
.blob-no-preview { a {
.vertical-center { color: $gl-text-color;
justify-content: center;
width: 100%;
}
} }
&.blob-editor-container { th {
overflow: hidden; position: sticky;
top: 0;
} }
}
.blob-viewer-container { .multi-file-table-name,
-webkit-flex: 1; .multi-file-table-col-commit-message {
flex: 1; white-space: nowrap;
overflow: auto; overflow: hidden;
text-overflow: ellipsis;
> div, max-width: 0;
.file-content:not(.wiki) { }
display: flex;
}
> div, .multi-file-table-name {
.file-content, width: 350px;
.blob-viewer, }
.line-number,
.blob-content,
.code {
min-height: 100%;
width: 100%;
}
.line-numbers { .multi-file-table-col-commit-message {
min-width: 44px; width: 50%;
} }
.blob-content { .multi-file-edit-pane {
display: flex;
flex-direction: column;
flex: 1; flex: 1;
overflow-x: auto; border-left: 1px solid $white-dark;
} overflow: hidden;
} }
#tabs { .multi-file-tabs {
position: relative;
flex-shrink: 0;
display: flex; display: flex;
width: 100%; overflow: scroll;
padding-left: 0; background-color: $white-normal;
margin-bottom: 0; box-shadow: inset 0 -1px $white-dark;
white-space: nowrap;
overflow-y: hidden;
overflow-x: auto;
li { > li {
position: relative; position: relative;
background: $gray-normal; }
padding: #{$gl-padding / 2} $gl-padding; }
.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-right: 1px solid $white-dark;
border-bottom: 1px solid $white-dark; border-bottom: 1px solid $white-dark;
cursor: pointer; cursor: pointer;
&.active { &.active {
background: $white-light; background-color: $white-light;
border-bottom: 0; border-bottom-color: $white-light;
}
a {
@include str-truncated(100px);
color: $gl-text-color;
vertical-align: middle;
text-decoration: none;
margin-right: 12px;
&:focus {
outline: none;
}
} }
}
.close-btn { .multi-file-tab-close {
position: absolute; position: absolute;
right: 8px; right: 8px;
top: 50%; top: 50%;
...@@ -163,132 +128,132 @@ ...@@ -163,132 +128,132 @@
background: none; background: none;
border: 0; border: 0;
font-size: $gl-font-size; font-size: $gl-font-size;
color: $gray-darkest;
transform: translateY(-50%); transform: translateY(-50%);
}
.close-icon:hover { &:not(.modified):hover,
&:not(.modified):focus {
color: $hint-color; color: $hint-color;
} }
.close-icon, &.modified {
.unsaved-icon { color: $indigo-700;
color: $gray-darkest;
}
.unsaved-icon {
color: $brand-success;
} }
}
&.tabs-divider { .multi-file-edit-pane-content {
width: 100%; flex: 1;
background-color: $white-light; height: 0;
border-right: 0; }
border-top-right-radius: 2px;
}
}
}
.repo-file-buttons { .multi-file-editor-btn-group {
background-color: $white-light; padding: $grid-size;
padding: 5px 10px; border-top: 1px solid $white-dark;
border-top: 1px solid $white-normal; }
}
#binary-viewer { // Not great, but this is to deal with our current output
height: 80vh; .multi-file-preview-holder {
overflow: auto; height: 100%;
margin: 0; overflow: scroll;
.blob-viewer { .blob-viewer {
padding-top: 20px;
padding-left: 20px;
}
.binary-unknown {
text-align: center;
padding-top: 100px;
background: $gray-light;
height: 100%; height: 100%;
font-size: 17px;
span {
display: block;
}
}
}
} }
#commit-area { .file-content.code {
background: $gray-light; display: flex;
padding: 20px;
.help-block { i {
padding-top: 7px; margin-left: -10px;
margin-top: 0;
} }
} }
#view-toggler { .line-numbers {
height: 41px; min-width: 50px;
position: relative;
display: block;
border-bottom: 1px solid $white-normal;
background: $white-light;
margin-top: -5px;
} }
#binary-viewer { .file-content,
img { .line-numbers,
max-width: 100%; .blob-content,
} .code {
min-height: 100%;
} }
}
#sidebar { .multi-file-commit-panel {
flex: 1; display: flex;
flex-direction: column;
height: 100%; height: 100%;
width: 290px;
padding: $gl-padding;
background-color: $gray-light;
border-left: 1px solid $white-dark;
&.sidebar-mini { &.is-collapsed {
width: 20%; width: 60px;
border-right: 1px solid $white-normal; padding: 0;
overflow: auto;
}
.table {
margin-bottom: 0;
} }
}
tr { .multi-file-commit-panel-section {
.repo-file-options { display: flex;
padding: 2px 16px; flex-direction: column;
width: 100%; flex: 1;
} }
.title { .multi-file-commit-panel-header {
font-size: 10px; display: flex;
text-transform: uppercase; align-items: center;
white-space: nowrap; padding: 0 0 12px;
overflow: hidden; margin-bottom: 12px;
text-overflow: ellipsis; border-bottom: 1px solid $white-dark;
vertical-align: middle;
}
.file-icon { &.is-collapsed {
margin-right: 5px; border-bottom: 1px solid $white-dark;
}
td { svg {
white-space: nowrap; margin-left: auto;
margin-right: auto;
} }
} }
}
.file { .multi-file-commit-panel-collapse-btn {
cursor: pointer; padding-top: 0;
} padding-bottom: 0;
margin-left: auto;
font-size: 20px;
a { &.is-collapsed {
@include str-truncated(250px); margin-right: auto;
color: $almost-black;
} }
}
.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;
} }
.file-status-icon { .file-status-icon {
...@@ -299,16 +264,28 @@ ...@@ -299,16 +264,28 @@
} }
.render-error { .multi-file-commit-list-path {
min-height: calc(100vh - 62px); @include str-truncated(100%);
}
.multi-file-commit-form {
padding-top: 12px;
border-top: 1px solid $white-dark;
}
p { .multi-file-commit-fieldset {
width: 100%; display: flex;
align-items: center;
padding-bottom: 12px;
.btn {
flex: 1;
} }
} }
.multi-file-table-col-name { .multi-file-commit-message.form-control {
width: 350px; height: 80px;
resize: none;
} }
.dirty-diff { .dirty-diff {
......
...@@ -161,7 +161,8 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -161,7 +161,8 @@ class Projects::IssuesController < Projects::ApplicationController
end end
def create_merge_request 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 if result[:status] == :success
render json: MergeRequestCreateSerializer.new.represent(result[:merge_request]) render json: MergeRequestCreateSerializer.new.represent(result[:merge_request])
......
...@@ -6,13 +6,21 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController ...@@ -6,13 +6,21 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
end end
def update def update
if @project.update(update_params) Projects::UpdateService.new(project, current_user, update_params).tap do |service|
if service.execute
flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated." 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) redirect_to project_settings_ci_cd_path(@project)
else else
render 'show' render 'show'
end end
end end
end
private private
...@@ -21,6 +29,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController ...@@ -21,6 +29,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
:runners_token, :builds_enabled, :build_allow_git_fetch, :runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_in_minutes, :build_coverage_regex, :public_builds, :build_timeout_in_minutes, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path, :auto_cancel_pending_pipelines, :ci_config_path,
:run_auto_devops_pipeline_implicit, :run_auto_devops_pipeline_explicit,
auto_devops_attributes: [:id, :domain, :enabled] auto_devops_attributes: [:id, :domain, :enabled]
) )
end end
......
...@@ -31,9 +31,9 @@ module ApplicationSettingsHelper ...@@ -31,9 +31,9 @@ module ApplicationSettingsHelper
def enabled_project_button(project, protocol) def enabled_project_button(project, protocol)
case protocol case protocol
when 'ssh' when 'ssh'
ssh_clone_button(project, 'bottom', append_link: false) ssh_clone_button(project, append_link: false)
else else
http_clone_button(project, 'bottom', append_link: false) http_clone_button(project, append_link: false)
end end
end end
......
...@@ -8,6 +8,22 @@ module AutoDevopsHelper ...@@ -8,6 +8,22 @@ module AutoDevopsHelper
!project.ci_service !project.ci_service
end 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) def auto_devops_warning_message(project)
missing_domain = !project.auto_devops&.has_domain? missing_domain = !project.auto_devops&.has_domain?
missing_service = !project.kubernetes_service&.active? missing_service = !project.kubernetes_service&.active?
......
...@@ -56,44 +56,41 @@ module ButtonHelper ...@@ -56,44 +56,41 @@ module ButtonHelper
end end
end end
def http_clone_button(project, placement = 'right', append_link: true) def http_clone_button(project, append_link: true)
klass = 'http-selector'
klass << ' has-tooltip' if current_user.try(:require_extra_setup_for_git_auth?)
protocol = gitlab_config.protocol.upcase protocol = gitlab_config.protocol.upcase
dropdown_description = http_dropdown_description(protocol)
append_url = project.http_url_to_repo if append_link
geo_url = geo_primary_http_url_to_repo(project) if Gitlab::Geo.secondary?
dropdown_item_with_description(protocol, dropdown_description, href: append_url, geo_url: geo_url)
end
tooltip_title = def http_dropdown_description(protocol)
if current_user.try(:require_password_creation_for_git?) if current_user.try(:require_password_creation_for_git?)
_("Set a password on your account to pull or push via %{protocol}.") % { protocol: protocol } _("Set a password on your account to pull or push via %{protocol}.") % { protocol: protocol }
else else
_("Create a personal access token on your account to pull or push via %{protocol}.") % { protocol: protocol } _("Create a personal access token on your account to pull or push via %{protocol}.") % { protocol: protocol }
end end
end
content_tag (append_link ? :a : :span), protocol, def ssh_clone_button(project, append_link: true)
class: klass, dropdown_description = _("You won't be able to pull or push project code via SSH until you add an SSH key to your profile") if current_user.try(:require_ssh_key?)
href: (project.http_url_to_repo if append_link), append_url = project.ssh_url_to_repo if append_link
data: { geo_url = geo_primary_ssh_url_to_repo(project) if Gitlab::Geo.secondary?
html: true,
placement: placement, dropdown_item_with_description('SSH', dropdown_description, href: append_url, geo_url: geo_url)
container: 'body',
title: tooltip_title,
primary_url: (geo_primary_http_url_to_repo(project) if Gitlab::Geo.secondary?)
}
end end
def ssh_clone_button(project, placement = 'right', append_link: true) def dropdown_item_with_description(title, description, href: nil, geo_url: nil)
klass = 'ssh-selector' button_content = content_tag(:strong, title, class: 'dropdown-menu-inner-title')
klass << ' has-tooltip' if current_user.try(:require_ssh_key?) button_content << content_tag(:span, description, class: 'dropdown-menu-inner-content') if description
content_tag (append_link ? :a : :span), 'SSH', content_tag (href ? :a : :span),
class: klass, button_content,
href: (project.ssh_url_to_repo if append_link), class: "#{title.downcase}-selector",
href: (href if href),
data: { data: {
html: true, primary_url: (geo_url if geo_url)
placement: placement,
container: 'body',
title: _('Add an SSH key to your profile to pull or push via SSH.'),
primary_url: (geo_primary_ssh_url_to_repo(project) if Gitlab::Geo.secondary?)
} }
end end
......
...@@ -213,6 +213,7 @@ module IssuablesHelper ...@@ -213,6 +213,7 @@ module IssuablesHelper
def issuable_initial_data(issuable) def issuable_initial_data(issuable)
data = { data = {
endpoint: issuable_path(issuable), endpoint: issuable_path(issuable),
updateEndpoint: "#{issuable_path(issuable)}.json",
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable), canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable), canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
canAdmin: can?(current_user, :"admin_#{issuable.to_ability_name}", issuable), canAdmin: can?(current_user, :"admin_#{issuable.to_ability_name}", issuable),
......
...@@ -8,19 +8,17 @@ class GeoNode < ActiveRecord::Base ...@@ -8,19 +8,17 @@ class GeoNode < ActiveRecord::Base
has_many :namespaces, through: :geo_node_namespace_links has_many :namespaces, through: :geo_node_namespace_links
has_one :status, class_name: 'GeoNodeStatus' has_one :status, class_name: 'GeoNodeStatus'
default_values schema: lambda { Gitlab.config.gitlab.protocol }, default_values url: ->(record) { record.class.current_node_url },
host: lambda { Gitlab.config.gitlab.host },
port: lambda { Gitlab.config.gitlab.port },
relative_url_root: lambda { Gitlab.config.gitlab.relative_url_root },
primary: false, primary: false,
clone_protocol: 'http' clone_protocol: 'http'
accepts_nested_attributes_for :geo_node_key accepts_nested_attributes_for :geo_node_key
validates :host, host: true, presence: true, uniqueness: { case_sensitive: false, scope: :port } validates :url, presence: true, uniqueness: { case_sensitive: false }
validate :check_url_is_valid
validates :primary, uniqueness: { message: 'node already exists' }, if: :primary validates :primary, uniqueness: { message: 'node already exists' }, if: :primary
validates :schema, inclusion: %w(http https)
validates :relative_url_root, length: { minimum: 0, allow_nil: false }
validates :access_key, presence: true validates :access_key, presence: true
validates :encrypted_secret_access_key, presence: true validates :encrypted_secret_access_key, presence: true
validates :clone_protocol, presence: true, inclusion: %w(ssh http) validates :clone_protocol, presence: true, inclusion: %w(ssh http)
...@@ -35,16 +33,33 @@ class GeoNode < ActiveRecord::Base ...@@ -35,16 +33,33 @@ class GeoNode < ActiveRecord::Base
before_validation :ensure_access_keys! before_validation :ensure_access_keys!
scope :with_url_prefix, ->(prefix) { where('url LIKE ?', "#{prefix}%") }
attr_encrypted :secret_access_key, attr_encrypted :secret_access_key,
key: Gitlab::Application.secrets.db_key_base, key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-gcm', algorithm: 'aes-256-gcm',
mode: :per_attribute_iv, mode: :per_attribute_iv,
encode: true encode: true
class << self
def current_node_url
RequestStore.fetch('geo_node:current_node_url') do
cfg = Gitlab.config.gitlab
uri = URI.parse("#{cfg.protocol}://#{cfg.host}:#{cfg.port}#{cfg.relative_url_root}")
uri.path += '/' unless uri.path.end_with?('/')
uri.to_s
end
end
def current_node
GeoNode.find_by(url: current_node_url)
end
end
def current? def current?
host == Gitlab.config.gitlab.host && self.class.current_node_url == url
port == Gitlab.config.gitlab.port &&
relative_url_root == Gitlab.config.gitlab.relative_url_root
end end
def secondary? def secondary?
...@@ -55,24 +70,23 @@ class GeoNode < ActiveRecord::Base ...@@ -55,24 +70,23 @@ class GeoNode < ActiveRecord::Base
secondary? && clone_protocol == 'ssh' secondary? && clone_protocol == 'ssh'
end end
def uri def url
if relative_url_root value = read_attribute(:url)
relative_url = relative_url_root.starts_with?('/') ? relative_url_root : "/#{relative_url_root}" value += '/' if value.present? && !value.end_with?('/')
end
URI.parse(URI::Generic.build(scheme: schema, host: host, port: port, path: relative_url).normalize.to_s) value
end end
def url def url=(value)
uri.to_s value += '/' if value.present? && !value.end_with?('/')
write_attribute(:url, value)
@uri = nil
end end
def url=(new_url) def uri
new_uri = URI.parse(new_url) @uri ||= URI.parse(url) if url.present?
self.schema = new_uri.scheme
self.host = new_uri.host
self.port = new_uri.port
self.relative_url_root = new_uri.path != '/' ? new_uri.path : ''
end end
def geo_transfers_url(file_type, file_id) def geo_transfers_url(file_type, file_id)
...@@ -203,7 +217,7 @@ class GeoNode < ActiveRecord::Base ...@@ -203,7 +217,7 @@ class GeoNode < ActiveRecord::Base
private private
def geo_api_url(suffix) def geo_api_url(suffix)
URI.join(uri, "#{uri.path}/", "api/#{API::API.version}/geo/#{suffix}").to_s URI.join(uri, "#{uri.path}", "api/#{API::API.version}/geo/#{suffix}").to_s
end end
def ensure_access_keys! def ensure_access_keys!
...@@ -216,11 +230,7 @@ class GeoNode < ActiveRecord::Base ...@@ -216,11 +230,7 @@ class GeoNode < ActiveRecord::Base
end end
def url_helper_args def url_helper_args
if relative_url_root { protocol: uri.scheme, host: uri.host, port: uri.port, script_name: uri.path }
relative_url = relative_url_root.starts_with?('/') ? relative_url_root : "/#{relative_url_root}"
end
{ protocol: schema, host: host, port: port, script_name: relative_url }
end end
def build_dependents def build_dependents
...@@ -246,13 +256,19 @@ class GeoNode < ActiveRecord::Base ...@@ -246,13 +256,19 @@ class GeoNode < ActiveRecord::Base
# Prevent locking yourself out # Prevent locking yourself out
def check_not_adding_primary_as_secondary def check_not_adding_primary_as_secondary
if host == Gitlab.config.gitlab.host && if url == self.class.current_node_url
port == Gitlab.config.gitlab.port &&
relative_url_root == Gitlab.config.gitlab.relative_url_root
errors.add(:base, 'Current node must be the primary node or you will be locking yourself out') errors.add(:base, 'Current node must be the primary node or you will be locking yourself out')
end end
end end
def check_url_is_valid
if uri.present? && !%w[http https].include?(uri.scheme)
errors.add(:url, 'scheme must be http or https')
end
rescue URI::InvalidURIError
errors.add(:url, 'is invalid')
end
def update_clone_url def update_clone_url
self.clone_url_prefix = Gitlab.config.gitlab_shell.ssh_path_prefix self.clone_url_prefix = Gitlab.config.gitlab_shell.ssh_path_prefix
end end
......
...@@ -3,6 +3,8 @@ module MergeRequests ...@@ -3,6 +3,8 @@ module MergeRequests
prepend EE::MergeRequests::BuildService prepend EE::MergeRequests::BuildService
def execute def execute
@issue_iid = params.delete(:issue_iid)
self.merge_request = MergeRequest.new(params) self.merge_request = MergeRequest.new(params)
merge_request.compare_commits = [] merge_request.compare_commits = []
merge_request.source_project = find_source_project merge_request.source_project = find_source_project
...@@ -109,20 +111,30 @@ module MergeRequests ...@@ -109,20 +111,30 @@ module MergeRequests
# #
def assign_title_and_description def assign_title_and_description
assign_title_and_description_from_single_commit assign_title_and_description_from_single_commit
assign_title_from_issue assign_title_from_issue
merge_request.title ||= source_branch.titleize.humanize merge_request.title ||= source_branch.titleize.humanize
merge_request.title = wip_title if compare_commits.empty? merge_request.title = wip_title if compare_commits.empty?
append_closes_description append_closes_description
end 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.description = closes_issue
end
end
def assign_title_and_description_from_single_commit def assign_title_and_description_from_single_commit
commits = compare_commits commits = compare_commits
return unless commits && commits.count == 1 return unless commits&.count == 1
commit = commits.first commit = commits.first
merge_request.title ||= commit.title merge_request.title ||= commit.title
...@@ -132,36 +144,19 @@ module MergeRequests ...@@ -132,36 +144,19 @@ module MergeRequests
def assign_title_from_issue def assign_title_from_issue
return unless issue return unless issue
merge_request.title =
case issue case issue
when Issue when Issue then "Resolve \"#{issue.title}\""
merge_request.title ||= "Resolve \"#{issue.title}\"" when ExternalIssue then "Resolve #{issue.title}"
when ExternalIssue
merge_request.title ||= "Resolve #{issue.title}"
end
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.description = closes_issue
end end
end end
def issue_iid def issue_iid
return @issue_iid if defined?(@issue_iid) @issue_iid ||= source_branch.match(/\A(\d+)-/).try(:[], 1)
@issue_iid = source_branch[/\A(\d+)-/, 1]
end end
def issue def issue
return @issue if defined?(@issue) @issue ||= target_project.get_issue(issue_iid, current_user)
@issue = target_project.get_issue(issue_iid, current_user)
end end
end end
end end
module MergeRequests module MergeRequests
class CreateFromIssueService < MergeRequests::CreateService 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 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? params[:label_ids] = issue.label_ids if issue.label_ids.any?
...@@ -21,20 +32,16 @@ module MergeRequests ...@@ -21,20 +32,16 @@ module MergeRequests
private private
def issue_iid
@isssue_iid ||= params.delete(:issue_iid)
end
def issue 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 end
def branch_name def branch_name
@branch_name ||= issue.to_branch_name @branch ||= @branch_name || issue.to_branch_name
end end
def ref def ref
project.default_branch || 'master' @ref || project.default_branch || 'master'
end end
def merge_request def merge_request
...@@ -43,6 +50,7 @@ module MergeRequests ...@@ -43,6 +50,7 @@ module MergeRequests
def merge_request_params def merge_request_params
{ {
issue_iid: @issue_iid,
source_project_id: project.id, source_project_id: project.id,
source_branch: branch_name, source_branch: branch_name,
target_project_id: project.id, target_project_id: project.id,
......
...@@ -25,7 +25,7 @@ module Projects ...@@ -25,7 +25,7 @@ module Projects
return error("Could not set the default branch") unless project.change_head(params[:default_branch]) return error("Could not set the default branch") unless project.change_head(params[:default_branch])
end end
if project.update_attributes(params.except(:default_branch)) if project.update_attributes(update_params)
if project.previous_changes.include?('path') if project.previous_changes.include?('path')
project.rename_repo project.rename_repo
else else
...@@ -41,8 +41,16 @@ module Projects ...@@ -41,8 +41,16 @@ module Projects
end end
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 private
def update_params
params.except(:default_branch, :run_auto_devops_pipeline_explicit, :run_auto_devops_pipeline_implicit)
end
def changing_storage_size? def changing_storage_size?
new_repository_storage = params[:repository_storage] new_repository_storage = params[:repository_storage]
......
.flash-container.flash-container-page .flash-container.flash-container-page
- if alert -# We currently only support `alert`, `notice`, `success`
.flash-alert - flash.each do |key, value|
%div{ class: "flash-#{key}" }
%div{ class: (container_class) } %div{ class: (container_class) }
%span= alert %span= value
- elsif notice
.flash-notice
%div{ class: (container_class) }
%span= notice
...@@ -67,8 +67,8 @@ ...@@ -67,8 +67,8 @@
- if @commit.last_pipeline - if @commit.last_pipeline
- last_pipeline = @commit.last_pipeline - last_pipeline = @commit.last_pipeline
.well-segment.pipeline-info .well-segment.pipeline-info
.status-icon-container{ class: "ci-status-icon-#{last_pipeline.status}" } .status-icon-container
= link_to project_pipeline_path(@project, last_pipeline.id) do = link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do
= ci_icon_for_status(last_pipeline.status) = ci_icon_for_status(last_pipeline.status)
#{ _('Pipeline') } #{ _('Pipeline') }
= link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id) = link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id)
......
- can_create_merge_request = can?(current_user, :create_merge_request, @project) - can_create_merge_request = can?(current_user, :create_merge_request, @project)
- data_action = can_create_merge_request ? 'create-mr' : 'create-branch' - 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) - 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 .btn-group.unavailable
%button.btn.btn-grouped{ type: 'button', disabled: 'disabled' } %button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
= icon('spinner', class: 'fa-spin') = icon('spinner', class: 'fa-spin')
%span.text %span.text
Checking branch availability… Checking branch availability…
.btn-group.available.hide .btn-group.available.hide
%input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: value, data: { action: data_action } } %button.btn.js-create-merge-request.btn-default{ type: 'button', 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' } } = 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') = 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 - if can_create_merge_request
%li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } } %li.create-item.droplab-item-selected.droplab-item-ignore-hiding{ role: 'button', data: { value: 'create-mr', text: 'Create merge request' } }
.menu-item .menu-item.droplab-item-ignore-hiding
.icon-container .icon-container.droplab-item-ignore-hiding= icon('check')
= icon('check') .description.droplab-item-ignore-hiding Create merge request and branch
.description
%strong Create a merge request %li.create-item.droplab-item-ignore-hiding{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: 'Create branch' } }
%span .menu-item.droplab-item-ignore-hiding
Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'. .icon-container.droplab-item-ignore-hiding= icon('check')
%li.divider.droplab-item-ignore .description.droplab-item-ignore-hiding Create branch
%li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } } %li.divider
.menu-item
.icon-container %li.droplab-item-ignore
= icon('check') Branch name
.description %input.js-branch-name.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" }
%strong Create a branch %span.js-branch-message.branch-message.droplab-item-ignore
%span
Creates a branch named after this issue, from '#{@project.default_branch}'. %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
...@@ -13,29 +13,39 @@ ...@@ -13,29 +13,39 @@
%p.settings-message.text-center %p.settings-message.text-center
= message.html_safe = message.html_safe
= f.fields_for :auto_devops_attributes, @auto_devops do |form| = f.fields_for :auto_devops_attributes, @auto_devops do |form|
.radio .radio.js-auto-devops-enable-radio-wrapper
= form.label :enabled_true do = 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 %strong Enable Auto DevOps
%br %br
%span.descr %span.descr
The Auto DevOps pipeline configuration will be used when there is no <code>.gitlab-ci.yml</code> in the project. 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.label :enabled_false do
= form.radio_button :enabled, 'false' = form.radio_button :enabled, 'false', class: 'js-auto-devops-enable-radio'
%strong Disable Auto DevOps %strong Disable Auto DevOps
%br %br
%span.descr %span.descr
An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery. An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery.
.radio .radio.js-auto-devops-enable-radio-wrapper
= form.label :enabled_nil do = form.label :enabled_ do
= form.radio_button :enabled, '' = form.radio_button :enabled, '', class: 'js-auto-devops-enable-radio'
%strong Instance default (#{current_application_settings.auto_devops_enabled? ? 'enabled' : 'disabled'}) %strong Instance default (#{current_application_settings.auto_devops_enabled? ? 'enabled' : 'disabled'})
%br %br
%span.descr %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>. 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 %p
You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages. 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' = form.text_field :domain, class: 'form-control', placeholder: 'domain.com'
......
...@@ -11,6 +11,6 @@ ...@@ -11,6 +11,6 @@
= webpack_bundle_tag 'common_vue' = webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'repo' = 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/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
%span %span
= enabled_project_button(project, enabled_protocol) = enabled_project_button(project, enabled_protocol)
- else - else
%a#clone-dropdown.clone-dropdown-btn.btn{ href: '#', data: { toggle: 'dropdown' } } %a#clone-dropdown.btn.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown' } }
%span %span
= default_clone_protocol.upcase = default_clone_protocol.upcase
= icon('caret-down') = icon('caret-down')
......
...@@ -20,7 +20,7 @@ ...@@ -20,7 +20,7 @@
= project_icon(project, alt: '', class: 'avatar project-avatar s40') = project_icon(project, alt: '', class: 'avatar project-avatar s40')
.project-details .project-details
%h3.prepend-top-0.append-bottom-0 %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.project-full-name
%span.namespace-name %span.namespace-name
- if project.namespace && !skip_namespace - if project.namespace && !skip_namespace
......
- @no_container = true;
#repo{ data: { root: @path.empty?.to_s, #repo{ data: { root: @path.empty?.to_s,
root_url: project_tree_path(project), root_url: project_tree_path(project),
url: content_url, url: content_url,
......
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
---
title: Fix viewing default push rules on a Geo secondary
merge_request: 3559
author:
type: fixed
---
title: Fix Advanced Search Syntax documentation
merge_request: 3571
author:
type: fixed
---
title: Add border for epic edit button
merge_request:
author:
type: other
---
title: Fix tasklist for epics
merge_request:
author:
type: fixed
---
title: Handle outdated replicas in the DB load balancer
merge_request:
author:
type: added
---
title: Add an ability to use a custom branch name on creation from issues
merge_request: 13884
author: Vitaliy @blackst0ne Klachkov
type: added
---
title: Add the option to automatically run a pipeline after updating AutoDevOps settings
merge_request: 15380
author:
type: changed
---
title: Removed tooltip from clone dropdown
merge_request: 15334
author:
type: other
...@@ -28,6 +28,7 @@ ...@@ -28,6 +28,7 @@
- [build, 2] - [build, 2]
- [pipeline, 2] - [pipeline, 2]
- [pipeline_processing, 5] - [pipeline_processing, 5]
- [pipeline_creation, 4]
- [pipeline_default, 3] - [pipeline_default, 3]
- [pipeline_cache, 3] - [pipeline_cache, 3]
- [pipeline_hooks, 2] - [pipeline_hooks, 2]
......
class StoreGeoNodesUrlDirectly < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
class GeoNode < ActiveRecord::Base
def compute_unified_url!
uri = URI.parse("#{schema}://#{host}#{relative_url_root}")
uri.port = port if port.present?
uri.path += '/' unless uri.path.end_with?('/')
update!(url: uri.to_s)
end
def compute_split_url!
uri = URI.parse(url)
update!(
schema: uri.scheme,
host: uri.host,
port: uri.port,
relative_url_root: uri.path
)
end
end
def up
add_column :geo_nodes, :url, :string
GeoNode.find_each { |node| node.compute_unified_url! }
change_column_null(:geo_nodes, :url, false)
end
def down
GeoNode.find_each { |node| node.compute_split_url! }
remove_column :geo_nodes, :url, :string
end
end
class IndexGeoNodesUrl < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :geo_nodes, :url, unique: true
end
def down
remove_concurrent_index :geo_nodes, :url, unique: true
end
end
class RemoveGeoNodesUrlPartColumns < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
remove_column :geo_nodes, :schema, :string
remove_column :geo_nodes, :host, :string
remove_column :geo_nodes, :port, :integer
remove_column :geo_nodes, :relative_url_root, :string
end
def down
add_column :geo_nodes, :schema, :string
add_column :geo_nodes, :host, :string
add_column :geo_nodes, :port, :integer
add_column :geo_nodes, :relative_url_root, :string
add_concurrent_index :geo_nodes, :host
end
end
...@@ -11,7 +11,7 @@ ...@@ -11,7 +11,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20171124070437) do ActiveRecord::Schema.define(version: 20171124165823) do
# These are extensions that must be enabled in order to support this database # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" enable_extension "plpgsql"
...@@ -959,10 +959,6 @@ ActiveRecord::Schema.define(version: 20171124070437) do ...@@ -959,10 +959,6 @@ ActiveRecord::Schema.define(version: 20171124070437) do
add_index "geo_node_statuses", ["geo_node_id"], name: "index_geo_node_statuses_on_geo_node_id", unique: true, using: :btree add_index "geo_node_statuses", ["geo_node_id"], name: "index_geo_node_statuses_on_geo_node_id", unique: true, using: :btree
create_table "geo_nodes", force: :cascade do |t| create_table "geo_nodes", force: :cascade do |t|
t.string "schema"
t.string "host"
t.integer "port"
t.string "relative_url_root"
t.boolean "primary" t.boolean "primary"
t.integer "geo_node_key_id" t.integer "geo_node_key_id"
t.integer "oauth_application_id" t.integer "oauth_application_id"
...@@ -974,11 +970,12 @@ ActiveRecord::Schema.define(version: 20171124070437) do ...@@ -974,11 +970,12 @@ ActiveRecord::Schema.define(version: 20171124070437) do
t.integer "files_max_capacity", default: 10, null: false t.integer "files_max_capacity", default: 10, null: false
t.integer "repos_max_capacity", default: 25, null: false t.integer "repos_max_capacity", default: 25, null: false
t.string "clone_protocol", default: "http", null: false t.string "clone_protocol", default: "http", null: false
t.string "url", null: false
end end
add_index "geo_nodes", ["access_key"], name: "index_geo_nodes_on_access_key", using: :btree add_index "geo_nodes", ["access_key"], name: "index_geo_nodes_on_access_key", using: :btree
add_index "geo_nodes", ["host"], name: "index_geo_nodes_on_host", using: :btree
add_index "geo_nodes", ["primary"], name: "index_geo_nodes_on_primary", using: :btree add_index "geo_nodes", ["primary"], name: "index_geo_nodes_on_primary", using: :btree
add_index "geo_nodes", ["url"], name: "index_geo_nodes_on_url", unique: true, using: :btree
create_table "geo_repositories_changed_events", id: :bigserial, force: :cascade do |t| create_table "geo_repositories_changed_events", id: :bigserial, force: :cascade do |t|
t.integer "geo_node_id", null: false t.integer "geo_node_id", null: false
......
...@@ -156,6 +156,40 @@ log entries easier. For example: ...@@ -156,6 +156,40 @@ log entries easier. For example:
[DB-LB] Host 10.123.2.7 came back online [DB-LB] Host 10.123.2.7 came back online
``` ```
## Handling Stale Reads
> [Introduced][ee-3526] in [GitLab Enterprise Edition Premium][eep] 10.3.
To prevent reading from an outdated secondary the load balancer will check if it
is in sync with the primary. If the data is determined to be recent enough the
secondary can be used, otherwise it will be ignored. To reduce the overhead of
these checks we only perform these checks at certain intervals.
There are three configuration options that influence this behaviour:
| Option | Description | Default |
|------------------------------|----------------------------------------------------------------------------------------------------------------|------------|
| `max_replication_difference` | The amount of data (in bytes) a secondary is allowed to lag behind when it hasn't replicated data for a while. | 8 MB |
| `max_replication_lag_time` | The maximum number of seconds a secondary is allowed to lag behind before we stop using it. | 60 seconds |
| `replica_check_interval` | The minimum number of seconds we have to wait before checking the status of a secondary. | 60 seconds |
The defaults should be sufficient for most users. Should you want to change them
you can specify them in `config/database.yml` like so:
```yaml
production:
username: gitlab
database: gitlab
encoding: unicode
load_balancing:
hosts:
- host1.example.com
- host2.example.com
max_replication_difference: 16777216 # 16 MB
max_replication_lag_time: 30
replica_check_interval: 30
```
[hot-standby]: https://www.postgresql.org/docs/9.6/static/hot-standby.html [hot-standby]: https://www.postgresql.org/docs/9.6/static/hot-standby.html
[ee-1283]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1283 [ee-1283]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/1283
[eep]: https://about.gitlab.com/gitlab-ee/ [eep]: https://about.gitlab.com/gitlab-ee/
...@@ -163,3 +197,4 @@ log entries easier. For example: ...@@ -163,3 +197,4 @@ log entries easier. For example:
[restart gitlab]: restart_gitlab.md#installations-from-source "How to restart GitLab" [restart gitlab]: restart_gitlab.md#installations-from-source "How to restart GitLab"
[wikipedia]: https://en.wikipedia.org/wiki/Load_balancing_(computing) [wikipedia]: https://en.wikipedia.org/wiki/Load_balancing_(computing)
[db-req]: ../install/requirements.md#database [db-req]: ../install/requirements.md#database
[ee-3526]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/3526
...@@ -121,7 +121,7 @@ Google Cloud. ...@@ -121,7 +121,7 @@ Google Cloud.
## Enabling Auto DevOps ## Enabling Auto DevOps
NOTE: **Note:** **Note:**
If you haven't done already, read the [prerequisites](#prerequisites) to make 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 full use of Auto DevOps. If this is your fist time, we recommend you follow the
[quick start guide](#quick-start). [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 ...@@ -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 1. Go to your project's **Settings > CI/CD > General pipelines settings** and
find the Auto DevOps section find the Auto DevOps section
1. Select "Enable Auto DevOps" 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) 1. Optionally, but recommended, add in the [base domain](#auto-devops-base-domain)
that will be used by Kubernetes to deploy your application that will be used by Kubernetes to deploy your application
1. Hit **Save changes** for the changes to take effect 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 Now that it's enabled, there are a few more steps depending on whether your project
has a `.gitlab-ci.yml` or not: has a `.gitlab-ci.yml` or not:
......
...@@ -7,7 +7,7 @@ work for on-premises installations where you can configure the ...@@ -7,7 +7,7 @@ work for on-premises installations where you can configure the
with Slack on making this configurable for all GitLab installations, but there's with Slack on making this configurable for all GitLab installations, but there's
no ETA. no ETA.
It was first introduced in GitLab 9.4 and distributed to Slack App Directory in It was first introduced in GitLab 9.4 and distributed to Slack App Directory in
GitLab 10.2 (with availability toward the end of November 2017). GitLab 10.2.
Slack provides a native application which you can enable via your project's Slack provides a native application which you can enable via your project's
integrations on GitLab.com. integrations on GitLab.com.
...@@ -15,12 +15,9 @@ integrations on GitLab.com. ...@@ -15,12 +15,9 @@ integrations on GitLab.com.
## Slack App Directory ## Slack App Directory
The simplest way to enable the GitLab Slack application for your workspace is to The simplest way to enable the GitLab Slack application for your workspace is to
install the GitLab application from install the [GitLab application](https://slack-platform.slack.com/apps/A676ADMV5-gitlab) from
the [Slack App Directory](https://slack.com/apps). the [Slack App Directory](https://slack.com/apps).
> This will be available toward the end of November 2017, and the docs will be updated here
when it is ready.
Clicking install will take you to the Clicking install will take you to the
[GitLab Slack application landing page](https://gitlab.com/profile/slack/edit) [GitLab Slack application landing page](https://gitlab.com/profile/slack/edit)
where you can select a project to enable the GitLab Slack application for. where you can select a project to enable the GitLab Slack application for.
......
...@@ -23,13 +23,13 @@ you need the search results to be as efficient as possible. You have a feeling ...@@ -23,13 +23,13 @@ you need the search results to be as efficient as possible. You have a feeling
of what you want to find (e.g., a function name), but at the same you're also of what you want to find (e.g., a function name), but at the same you're also
not so sure. not so sure.
In that case, using the regular expressions in your query will yield much better In that case, using the advanced search syntax in your query will yield much
results. better results.
## Using the Advanced Syntax Search ## Using the Advanced Syntax Search
The Advanced Syntax Search supports queries of ranges, wildcards, regular The Advanced Syntax Search supports fuzzy or exact search queries with prefixes,
expressions, fuzziness and much more. boolean operators, and much more.
Full details can be found in the [Elasticsearch documentation][elastic], but Full details can be found in the [Elasticsearch documentation][elastic], but
here's a quick guide: here's a quick guide:
...@@ -42,7 +42,6 @@ here's a quick guide: ...@@ -42,7 +42,6 @@ here's a quick guide:
* To group terms together, use parentheses: `bug | (display +sound)` * To group terms together, use parentheses: `bug | (display +sound)`
* To match a partial word, use `*`: `bug find_by_*` * To match a partial word, use `*`: `bug find_by_*`
* To find a term containing one of these symbols, use `\`: `argument \-last` * To find a term containing one of these symbols, use `\`: `argument \-last`
* To limit the results based on the time "created_at:[2012-01-01 TO 2012-12-31]" and other sweet stuff
[ee]: https://about.gitlab.com/gitlab-ee/ [ee]: https://about.gitlab.com/gitlab-ee/
[elastic]: https://www.elastic.co/guide/en/elasticsearch/reference/5.3/query-dsl-simple-query-string-query.html#_simple_query_string_syntax [elastic]: https://www.elastic.co/guide/en/elasticsearch/reference/5.3/query-dsl-simple-query-string-query.html#_simple_query_string_syntax
...@@ -12,6 +12,10 @@ ...@@ -12,6 +12,10 @@
type: String, type: String,
required: true, required: true,
}, },
updateEndpoint: {
type: String,
required: true,
},
canUpdate: { canUpdate: {
required: true, required: true,
type: Boolean, type: Boolean,
...@@ -111,7 +115,9 @@ ...@@ -111,7 +115,9 @@
:can-update="canUpdate" :can-update="canUpdate"
:can-destroy="canDestroy" :can-destroy="canDestroy"
:endpoint="endpoint" :endpoint="endpoint"
:update-endpoint="updateEndpoint"
:issuable-ref="issuableRef" :issuable-ref="issuableRef"
issuable-type="epic"
:initial-title-html="initialTitleHtml" :initial-title-html="initialTitleHtml"
:initial-title-text="initialTitleText" :initial-title-text="initialTitleText"
:initial-description-html="initialDescriptionHtml" :initial-description-html="initialDescriptionHtml"
......
...@@ -110,7 +110,7 @@ class Admin::GeoNodesController < Admin::ApplicationController ...@@ -110,7 +110,7 @@ class Admin::GeoNodesController < Admin::ApplicationController
end end
def has_insecure_nodes? def has_insecure_nodes?
GeoNode.where(schema: 'http').any? GeoNode.with_url_prefix('http://').exists?
end end
def flash_now(type, message) def flash_now(type, message)
......
...@@ -40,6 +40,6 @@ class Admin::PushRulesController < Admin::ApplicationController ...@@ -40,6 +40,6 @@ class Admin::PushRulesController < Admin::ApplicationController
end end
def push_rule def push_rule
@push_rule ||= PushRule.find_or_create_by(is_sample: true) @push_rule ||= PushRule.find_or_initialize_by(is_sample: true)
end end
end end
...@@ -39,8 +39,14 @@ module EE ...@@ -39,8 +39,14 @@ module EE
def redirect_allowed_to?(uri) def redirect_allowed_to?(uri)
raise NotImplementedError unless defined?(super) raise NotImplementedError unless defined?(super)
# Redirect is not only allowed to current host, but also to other Geo nodes # Redirect is not only allowed to current host, but also to other Geo
super || ::Gitlab::Geo.geo_node?(host: uri.host, port: uri.port) # nodes. relative_url_root *must* be ignored here as we don't know what
# is root and what is path
super || begin
truncated = uri.dup.tap { |uri| uri.path = '/' }
::GeoNode.with_url_prefix(truncated).exists?
end
end end
end end
end end
...@@ -6,7 +6,7 @@ ...@@ -6,7 +6,7 @@
%hr.clearfix %hr.clearfix
= form_for [:admin, @push_rule] do |f| = form_for @push_rule, url: admin_push_rule_path, method: :put do |f|
- if @push_rule.errors.any? - if @push_rule.errors.any?
.alert.alert-danger .alert.alert-danger
- @push_rule.errors.full_messages.each do |msg| - @push_rule.errors.full_messages.each do |msg|
......
...@@ -24,15 +24,30 @@ module Gitlab ...@@ -24,15 +24,30 @@ module Gitlab
[].freeze [].freeze
end end
# Returns the additional hosts to use for load balancing. # Returns a Hash containing the load balancing configuration.
def self.hosts def self.configuration
hash = ActiveRecord::Base.configurations[Rails.env]['load_balancing'] ActiveRecord::Base.configurations[Rails.env]['load_balancing'] || {}
end
if hash # Returns the maximum replica lag size in bytes.
hash['hosts'] || [] def self.max_replication_difference
else (configuration['max_replication_difference'] || 8.megabytes).to_i
[] end
# Returns the maximum lag time for a replica.
def self.max_replication_lag_time
(configuration['max_replication_lag_time'] || 60.0).to_f
end end
# Returns the interval (in seconds) to use for checking the status of a
# replica.
def self.replica_check_interval
(configuration['replica_check_interval'] || 60).to_f
end
# Returns the additional hosts to use for load balancing.
def self.hosts
configuration['hosts'] || []
end end
def self.log(level, message) def self.log(level, message)
......
...@@ -3,15 +3,21 @@ module Gitlab ...@@ -3,15 +3,21 @@ module Gitlab
module LoadBalancing module LoadBalancing
# A single database host used for load balancing. # A single database host used for load balancing.
class Host class Host
attr_reader :pool attr_reader :pool, :last_checked_at, :intervals, :load_balancer
delegate :connection, :release_connection, to: :pool delegate :connection, :release_connection, to: :pool
# host - The address of the database. # host - The address of the database.
def initialize(host) # load_balancer - The LoadBalancer that manages this Host.
def initialize(host, load_balancer)
@host = host @host = host
@load_balancer = load_balancer
@pool = Database.create_connection_pool(LoadBalancing.pool_size, host) @pool = Database.create_connection_pool(LoadBalancing.pool_size, host)
@online = true @online = true
@last_checked_at = Time.zone.now
interval = LoadBalancing.replica_check_interval
@intervals = (interval..(interval * 2)).step(0.5).to_a
end end
def offline! def offline!
...@@ -23,28 +29,78 @@ module Gitlab ...@@ -23,28 +29,78 @@ module Gitlab
# Returns true if the host is online. # Returns true if the host is online.
def online? def online?
return true if @online return @online unless check_replica_status?
begin refresh_status
retried = 0
@online = begin LoadBalancing.log(:info, "Host #{@host} came back online") if @online
connection.active?
rescue @online
if retried < 3 end
release_connection
retried += 1 def refresh_status
retry @online = replica_is_up_to_date?
@last_checked_at = Time.zone.now
end
def check_replica_status?
(Time.zone.now - last_checked_at) >= intervals.sample
end
def replica_is_up_to_date?
replication_lag_below_threshold? || data_is_recent_enough?
end
def replication_lag_below_threshold?
if (lag_time = replication_lag_time)
lag_time <= LoadBalancing.max_replication_lag_time
else else
false false
end end
end end
LoadBalancing.log(:info, "Host #{@host} came back online") if @online # Returns true if the replica has replicated enough data to be useful.
def data_is_recent_enough?
# It's possible for a replica to not replay WAL data for a while,
# despite being up to date. This can happen when a primary does not
# receive any writes a for a while.
#
# To prevent this from happening we check if the lag size (in bytes)
# of the replica is small enough for the replica to be useful. We
# only do this if we haven't replicated in a while so we only need
# to connect to the primary when truly necessary.
if (lag_size = replication_lag_size)
lag_size <= LoadBalancing.max_replication_difference
else
false
end
end
@online # Returns the replication lag time of this secondary in seconds as a
ensure # float.
release_connection #
# This method will return nil if no lag time could be calculated.
def replication_lag_time
row = query_and_release('SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp()))::float as lag')
row['lag'].to_f if row.any?
end end
# Returns the number of bytes this secondary is lagging behind the
# primary.
#
# This method will return nil if no lag size could be calculated.
def replication_lag_size
location = connection.quote(primary_write_location)
row = query_and_release("SELECT pg_xlog_location_diff(#{location}, pg_last_xlog_replay_location())::float AS diff")
row['diff'].to_i if row.any?
end
def primary_write_location
load_balancer.primary_write_location
ensure
load_balancer.release_primary_connection
end end
# Returns true if this host has caught up to the given transaction # Returns true if this host has caught up to the given transaction
...@@ -60,9 +116,15 @@ module Gitlab ...@@ -60,9 +116,15 @@ module Gitlab
query = "SELECT NOT pg_is_in_recovery() OR " \ query = "SELECT NOT pg_is_in_recovery() OR " \
"pg_xlog_location_diff(pg_last_xlog_replay_location(), #{string}) >= 0 AS result" "pg_xlog_location_diff(pg_last_xlog_replay_location(), #{string}) >= 0 AS result"
row = connection.select_all(query).first row = query_and_release(query)
row['result'] == 't'
end
row && row['result'] == 't' def query_and_release(sql)
connection.select_all(sql).first || {}
rescue
{}
ensure ensure
release_connection release_connection
end end
......
...@@ -15,7 +15,7 @@ module Gitlab ...@@ -15,7 +15,7 @@ module Gitlab
# hosts - The hostnames/addresses of the additional databases. # hosts - The hostnames/addresses of the additional databases.
def initialize(hosts = []) def initialize(hosts = [])
@host_list = HostList.new(hosts.map { |addr| Host.new(addr) }) @host_list = HostList.new(hosts.map { |addr| Host.new(addr, self) })
end end
# Yields a connection that can be used for reads. # Yields a connection that can be used for reads.
......
...@@ -18,11 +18,7 @@ module Gitlab ...@@ -18,11 +18,7 @@ module Gitlab
FDW_SCHEMA = 'gitlab_secondary'.freeze FDW_SCHEMA = 'gitlab_secondary'.freeze
def self.current_node def self.current_node
self.cache_value(:geo_node_current) do self.cache_value(:geo_node_current) { GeoNode.current_node }
GeoNode.find_by(host: Gitlab.config.gitlab.host,
port: Gitlab.config.gitlab.port,
relative_url_root: Gitlab.config.gitlab.relative_url_root)
end
end end
def self.primary_node def self.primary_node
...@@ -72,10 +68,6 @@ module Gitlab ...@@ -72,10 +68,6 @@ module Gitlab
::License.feature_available?(:geo) ::License.feature_available?(:geo)
end end
def self.geo_node?(host:, port:)
GeoNode.where(host: host, port: port).exists?
end
def self.fdw? def self.fdw?
self.cache_value(:geo_fdw?) do self.cache_value(:geo_fdw?) do
::Geo::BaseRegistry.connection.execute( ::Geo::BaseRegistry.connection.execute(
......
...@@ -175,15 +175,7 @@ namespace :geo do ...@@ -175,15 +175,7 @@ namespace :geo do
end end
def set_primary_geo_node def set_primary_geo_node
params = { node = GeoNode.new(primary: true, url: GeoNode.current_node_url)
schema: Gitlab.config.gitlab.protocol,
host: Gitlab.config.gitlab.host,
port: Gitlab.config.gitlab.port,
relative_url_root: Gitlab.config.gitlab.relative_url_root,
primary: true
}
node = GeoNode.new(params)
puts "Saving primary GeoNode with URL #{node.url}".color(:green) puts "Saving primary GeoNode with URL #{node.url}".color(:green)
node.save node.save
......
...@@ -249,7 +249,7 @@ describe Admin::GeoNodesController, :postgresql do ...@@ -249,7 +249,7 @@ describe Admin::GeoNodesController, :postgresql do
end end
context 'with a secondary node' do context 'with a secondary node' do
let(:geo_node) { create(:geo_node, host: 'example.com', port: 80, enabled: true) } let(:geo_node) { create(:geo_node, url: 'http://example.com') }
context 'when succeed' do context 'when succeed' do
before do before do
......
...@@ -12,19 +12,22 @@ describe Projects::PipelinesSettingsController do ...@@ -12,19 +12,22 @@ describe Projects::PipelinesSettingsController do
end end
describe 'PATCH update' do describe 'PATCH update' do
before do subject do
patch :update, patch :update,
namespace_id: project.namespace.to_param, namespace_id: project.namespace.to_param,
project_id: project, project_id: project,
project: { project: { auto_devops_attributes: params,
auto_devops_attributes: params run_auto_devops_pipeline_implicit: 'false',
} run_auto_devops_pipeline_explicit: auto_devops_pipeline }
end end
context 'when updating the auto_devops settings' do context 'when updating the auto_devops settings' do
let(:params) { { enabled: '', domain: 'mepmep.md' } } let(:params) { { enabled: '', domain: 'mepmep.md' } }
let(:auto_devops_pipeline) { 'false' }
it 'redirects to the settings page' do it 'redirects to the settings page' do
subject
expect(response).to have_gitlab_http_status(302) expect(response).to have_gitlab_http_status(302)
expect(flash[:notice]).to eq("Pipelines settings for '#{project.name}' were successfully updated.") expect(flash[:notice]).to eq("Pipelines settings for '#{project.name}' were successfully updated.")
end end
...@@ -33,11 +36,32 @@ describe Projects::PipelinesSettingsController do ...@@ -33,11 +36,32 @@ describe Projects::PipelinesSettingsController do
let(:params) { { enabled: '' } } let(:params) { { enabled: '' } }
it 'allows enabled to be set to nil' do it 'allows enabled to be set to nil' do
subject
project_auto_devops.reload project_auto_devops.reload
expect(project_auto_devops.enabled).to be_nil expect(project_auto_devops.enabled).to be_nil
end end
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 end
end end
...@@ -3,7 +3,16 @@ require 'spec_helper' ...@@ -3,7 +3,16 @@ require 'spec_helper'
feature 'Update Epic', :js do feature 'Update Epic', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:group) { create(:group, :public) } let(:group) { create(:group, :public) }
let(:epic) { create(:epic, group: group) }
let(:markdown) do
<<-MARKDOWN.strip_heredoc
This is a task list:
- [ ] Incomplete entry 1
MARKDOWN
end
let(:epic) { create(:epic, group: group, description: markdown) }
before do before do
stub_licensed_features(epics: true) stub_licensed_features(epics: true)
...@@ -51,6 +60,16 @@ feature 'Update Epic', :js do ...@@ -51,6 +60,16 @@ feature 'Update Epic', :js do
expect(page).not_to have_selector('.uploading-container .button-attach-file') expect(page).not_to have_selector('.uploading-container .button-attach-file')
end end
it 'updates the tasklist' do
expect(page).to have_selector('ul.task-list', count: 1)
expect(page).to have_selector('li.task-list-item', count: 1)
expect(page).to have_selector('ul input[checked]', count: 0)
find('.task-list .task-list-item', text: 'Incomplete entry 1').find('input').click
expect(page).to have_selector('ul input[checked]', count: 1)
end
# Autocomplete is disabled for epics until #4084 is resolved # Autocomplete is disabled for epics until #4084 is resolved
describe 'autocomplete disabled' do describe 'autocomplete disabled' do
it 'does not open atwho container' do it 'does not open atwho container' do
......
...@@ -2,16 +2,19 @@ require 'rails_helper' ...@@ -2,16 +2,19 @@ require 'rails_helper'
feature 'Geo clone instructions', :js do feature 'Geo clone instructions', :js do
include Devise::Test::IntegrationHelpers include Devise::Test::IntegrationHelpers
include ::EE::GeoHelpers
let(:project) { create(:project, :empty_repo) } let(:project) { create(:project, :empty_repo) }
let(:developer) { create(:user) } let(:developer) { create(:user) }
background do background do
primary = create(:geo_node, :primary, schema: 'https', host: 'primary.domain.com', port: 443) primary = create(:geo_node, :primary, url: 'https://primary.domain.com')
primary.update_attribute(:clone_url_prefix, 'git@primary.domain.com:') primary.update_columns(clone_url_prefix: 'git@primary.domain.com:')
allow(Gitlab::Geo).to receive(:secondary?).and_return(true) secondary = create(:geo_node)
project.team << [developer, :developer] stub_current_geo_node(secondary)
project.add_developer(developer)
sign_in(developer) sign_in(developer)
end end
......
...@@ -32,7 +32,6 @@ describe EE::GitlabRoutingHelper do ...@@ -32,7 +32,6 @@ describe EE::GitlabRoutingHelper do
context 'HTTP' do context 'HTTP' do
before do before do
allow(helper).to receive(:default_clone_protocol).and_return('http') allow(helper).to receive(:default_clone_protocol).and_return('http')
primary.update!(schema: 'http')
end end
context 'project' do context 'project' do
...@@ -51,7 +50,7 @@ describe EE::GitlabRoutingHelper do ...@@ -51,7 +50,7 @@ describe EE::GitlabRoutingHelper do
context 'HTTPS' do context 'HTTPS' do
before do before do
allow(helper).to receive(:default_clone_protocol).and_return('https') allow(helper).to receive(:default_clone_protocol).and_return('https')
primary.update!(schema: 'https') primary.update!(url: 'https://localhost:123/relative')
end end
context 'project' do context 'project' do
......
...@@ -7,9 +7,17 @@ describe RemoveSystemHookFromGeoNodes, :migration do ...@@ -7,9 +7,17 @@ describe RemoveSystemHookFromGeoNodes, :migration do
before do before do
allow_any_instance_of(WebHookService).to receive(:execute) allow_any_instance_of(WebHookService).to receive(:execute)
node_attrs = {
schema: 'http',
host: 'localhost',
port: 3000
}
create(:system_hook) create(:system_hook)
geo_nodes.create! attributes_for(:geo_node, :primary) hook_id = create(:system_hook).id
geo_nodes.create! attributes_for(:geo_node, system_hook_id: create(:system_hook).id)
geo_nodes.create!(node_attrs.merge(primary: true))
geo_nodes.create!(node_attrs.merge(system_hook_id: hook_id, port: 3001))
end end
it 'destroy all system hooks for secondary nodes' do it 'destroy all system hooks for secondary nodes' do
......
FactoryGirl.define do FactoryGirl.define do
factory :geo_node do factory :geo_node do
host { Gitlab.config.gitlab.host } sequence(:url) do |port|
sequence(:port) {|n| n} uri = URI.parse("http://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.relative_url_root}")
uri.port = port
uri.path += '/' unless uri.path.end_with?('/')
uri.to_s
end
trait :ssh do trait :ssh do
clone_protocol 'ssh' clone_protocol 'ssh'
...@@ -10,7 +15,13 @@ FactoryGirl.define do ...@@ -10,7 +15,13 @@ FactoryGirl.define do
trait :primary do trait :primary do
primary true primary true
port { Gitlab.config.gitlab.port } url do
uri = URI.parse("http://#{Gitlab.config.gitlab.host}:#{Gitlab.config.gitlab.relative_url_root}")
uri.port = Gitlab.config.gitlab.port
uri.path += '/' unless uri.path.end_with?('/')
uri.to_s
end
end end
end end
end end
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
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
...@@ -8,13 +8,14 @@ feature "Pipelines settings" do ...@@ -8,13 +8,14 @@ feature "Pipelines settings" do
background do background do
sign_in(user) sign_in(user)
project.team << [user, role] project.team << [user, role]
visit project_pipelines_settings_path(project)
end end
context 'for developer' do context 'for developer' do
given(:role) { :developer } given(:role) { :developer }
scenario 'to be disallowed to view' do scenario 'to be disallowed to view' do
visit project_settings_ci_cd_path(project)
expect(page.status_code).to eq(404) expect(page.status_code).to eq(404)
end end
end end
...@@ -23,6 +24,8 @@ feature "Pipelines settings" do ...@@ -23,6 +24,8 @@ feature "Pipelines settings" do
given(:role) { :master } given(:role) { :master }
scenario 'be allowed to change' do scenario 'be allowed to change' do
visit project_settings_ci_cd_path(project)
fill_in('Test coverage parsing', with: 'coverage_regex') fill_in('Test coverage parsing', with: 'coverage_regex')
click_on 'Save changes' click_on 'Save changes'
...@@ -32,6 +35,8 @@ feature "Pipelines settings" do ...@@ -32,6 +35,8 @@ feature "Pipelines settings" do
end end
scenario 'updates auto_cancel_pending_pipelines' do scenario 'updates auto_cancel_pending_pipelines' do
visit project_settings_ci_cd_path(project)
page.check('Auto-cancel redundant, pending pipelines') page.check('Auto-cancel redundant, pending pipelines')
click_on 'Save changes' click_on 'Save changes'
...@@ -42,7 +47,10 @@ feature "Pipelines settings" do ...@@ -42,7 +47,10 @@ feature "Pipelines settings" do
expect(checkbox).to be_checked expect(checkbox).to be_checked
end end
scenario 'update auto devops settings' do describe 'Auto DevOps' do
it 'update auto devops settings' do
visit project_settings_ci_cd_path(project)
fill_in('project_auto_devops_attributes_domain', with: 'test.com') fill_in('project_auto_devops_attributes_domain', with: 'test.com')
page.choose('project_auto_devops_attributes_enabled_false') page.choose('project_auto_devops_attributes_enabled_false')
click_on 'Save changes' click_on 'Save changes'
...@@ -51,5 +59,107 @@ feature "Pipelines settings" do ...@@ -51,5 +59,107 @@ feature "Pipelines settings" do
expect(project.auto_devops).to be_present expect(project.auto_devops).to be_present
expect(project.auto_devops).not_to be_enabled expect(project.auto_devops).not_to be_enabled
end 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
end end
...@@ -26,9 +26,11 @@ feature 'Multi-file editor new directory', :js do ...@@ -26,9 +26,11 @@ feature 'Multi-file editor new directory', :js do
click_button('Create directory') click_button('Create directory')
end end
find('.multi-file-commit-panel-collapse-btn').click
fill_in('commit-message', with: 'commit message') fill_in('commit-message', with: 'commit message')
click_button('Commit 1 file') click_button('Commit')
expect(page).to have_selector('td', text: 'commit message') expect(page).to have_selector('td', text: 'commit message')
end end
......
...@@ -26,9 +26,11 @@ feature 'Multi-file editor new file', :js do ...@@ -26,9 +26,11 @@ feature 'Multi-file editor new file', :js do
click_button('Create file') click_button('Create file')
end end
find('.multi-file-commit-panel-collapse-btn').click
fill_in('commit-message', with: 'commit message') fill_in('commit-message', with: 'commit message')
click_button('Commit 1 file') click_button('Commit')
expect(page).to have_selector('td', text: 'commit message') expect(page).to have_selector('td', text: 'commit message')
end end
......
...@@ -26,7 +26,7 @@ feature 'Multi-file editor upload file', :js do ...@@ -26,7 +26,7 @@ feature 'Multi-file editor upload file', :js do
find('.add-to-tree').click 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)) expect(find('.blob-editor-container .lines-content')['innerText']).to have_content(File.open(txt_file, &:readline))
end end
...@@ -39,7 +39,7 @@ feature 'Multi-file editor upload file', :js do ...@@ -39,7 +39,7 @@ feature 'Multi-file editor upload file', :js do
find('.add-to-tree').click 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).not_to have_selector('.monaco-editor')
expect(page).to have_content('The source could not be displayed for this temporary file.') expect(page).to have_content('The source could not be displayed for this temporary file.')
end end
......
...@@ -82,4 +82,104 @@ describe AutoDevopsHelper do ...@@ -82,4 +82,104 @@ describe AutoDevopsHelper do
it { is_expected.to eq(false) } it { is_expected.to eq(false) }
end end
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 end
...@@ -26,9 +26,10 @@ describe ButtonHelper do ...@@ -26,9 +26,10 @@ describe ButtonHelper do
context 'when user has password automatically set' do context 'when user has password automatically set' do
let(:user) { create(:user, password_automatically_set: true) } let(:user) { create(:user, password_automatically_set: true) }
it 'shows a password tooltip' do it 'shows the password text on the dropdown' do
expect(element.attr('class')).to include(has_tooltip_class) description = element.search('.dropdown-menu-inner-content').first
expect(element.attr('data-title')).to eq('Set a password on your account to pull or push via HTTP.')
expect(description.inner_text).to eq 'Set a password on your account to pull or push via HTTP.'
end end
end end
end end
...@@ -39,17 +40,10 @@ describe ButtonHelper do ...@@ -39,17 +40,10 @@ describe ButtonHelper do
end end
context 'when user has no personal access tokens' do context 'when user has no personal access tokens' do
it 'has a personal access token tooltip ' do it 'has a personal access token text on the dropdown description ' do
expect(element.attr('class')).to include(has_tooltip_class) description = element.search('.dropdown-menu-inner-content').first
expect(element.attr('data-title')).to eq('Create a personal access token on your account to pull or push via HTTP.')
end
end
context 'when user has a personal access token' do
it 'shows no tooltip' do
create(:personal_access_token, user: user)
expect(element.attr('class')).not_to include(has_tooltip_class) expect(description.inner_text).to eq 'Create a personal access token on your account to pull or push via HTTP.'
end end
end end
end end
...@@ -63,6 +57,41 @@ describe ButtonHelper do ...@@ -63,6 +57,41 @@ describe ButtonHelper do
end end
end end
describe 'ssh_button' do
let(:user) { create(:user) }
let(:project) { build_stubbed(:project) }
def element
element = helper.ssh_clone_button(project)
Nokogiri::HTML::DocumentFragment.parse(element).first_element_child
end
before do
allow(helper).to receive(:current_user).and_return(user)
end
context 'without an ssh key on the user' do
it 'shows a warning on the dropdown description' do
description = element.search('.dropdown-menu-inner-content').first
expect(description.inner_text).to eq "You won't be able to pull or push project code via SSH until you add an SSH key to your profile"
end
end
context 'with an ssh key on the user' do
before do
create(:key, user: user)
end
it 'there is no warning on the dropdown description' do
description = element.search('.dropdown-menu-inner-content').first
expect(description).to eq nil
end
end
end
describe 'clipboard_button' do describe 'clipboard_button' do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { build_stubbed(:project) } let(:project) { build_stubbed(:project) }
......
...@@ -174,6 +174,7 @@ describe IssuablesHelper do ...@@ -174,6 +174,7 @@ describe IssuablesHelper do
expected_data = { expected_data = {
'endpoint' => "/#{@project.full_path}/issues/#{issue.iid}", 'endpoint' => "/#{@project.full_path}/issues/#{issue.iid}",
'updateEndpoint' => "/#{@project.full_path}/issues/#{issue.iid}.json",
'canUpdate' => true, 'canUpdate' => true,
'canDestroy' => true, 'canDestroy' => true,
'canAdmin' => true, 'canAdmin' => true,
...@@ -198,6 +199,7 @@ describe IssuablesHelper do ...@@ -198,6 +199,7 @@ describe IssuablesHelper do
expected_data = { expected_data = {
'endpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}", 'endpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}",
'updateEndpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}.json",
'issueLinksEndpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues", 'issueLinksEndpoint' => "/groups/#{@group.full_path}/-/epics/#{epic.iid}/issues",
'canUpdate' => true, 'canUpdate' => true,
'canDestroy' => true, 'canDestroy' => true,
......
...@@ -279,7 +279,12 @@ describe('DropDown', function () { ...@@ -279,7 +279,12 @@ describe('DropDown', function () {
describe('addEvents', function () { describe('addEvents', function () {
beforeEach(function () { beforeEach(function () {
this.list = { addEventListener: () => {} }; this.list = { addEventListener: () => {} };
this.dropdown = { list: this.list, clickEvent: () => {}, eventWrapper: {} }; this.dropdown = {
list: this.list,
clickEvent: () => {},
closeDropdown: () => {},
eventWrapper: {},
};
spyOn(this.list, 'addEventListener'); spyOn(this.list, 'addEventListener');
...@@ -288,6 +293,7 @@ describe('DropDown', function () { ...@@ -288,6 +293,7 @@ describe('DropDown', function () {
it('should call .addEventListener', function () { it('should call .addEventListener', function () {
expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function)); expect(this.list.addEventListener).toHaveBeenCalledWith('click', jasmine.any(Function));
expect(this.list.addEventListener).toHaveBeenCalledWith('keyup', jasmine.any(Function));
}); });
}); });
......
...@@ -24,7 +24,7 @@ describe('Hook', function () { ...@@ -24,7 +24,7 @@ describe('Hook', function () {
}); });
it('should call DropDown constructor', 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 () { it('should set .type', function () {
......
...@@ -34,6 +34,7 @@ describe('EpicShowApp', () => { ...@@ -34,6 +34,7 @@ describe('EpicShowApp', () => {
canUpdate, canUpdate,
canDestroy, canDestroy,
endpoint, endpoint,
updateEndpoint,
initialTitleHtml, initialTitleHtml,
initialTitleText, initialTitleText,
startDate, startDate,
...@@ -59,6 +60,7 @@ describe('EpicShowApp', () => { ...@@ -59,6 +60,7 @@ describe('EpicShowApp', () => {
canUpdate, canUpdate,
canDestroy, canDestroy,
endpoint, endpoint,
updateEndpoint,
issuableRef: '', issuableRef: '',
initialTitleHtml, initialTitleHtml,
initialTitleText, initialTitleText,
......
export const contentProps = { export const contentProps = {
endpoint: '', endpoint: '',
updateEndpoint: gl.TEST_HOST,
canAdmin: true, canAdmin: true,
canUpdate: true, canUpdate: true,
canDestroy: true, canDestroy: true,
......
...@@ -278,7 +278,7 @@ describe('Flash', () => { ...@@ -278,7 +278,7 @@ describe('Flash', () => {
removeFlashClickListener(flashEl, false); removeFlashClickListener(flashEl, false);
flashEl.parentNode.click(); flashEl.click();
setTimeout(() => { setTimeout(() => {
expect(document.querySelector('.flash')).toBeNull(); expect(document.querySelector('.flash')).toBeNull();
......
...@@ -35,11 +35,12 @@ describe('Issuable output', () => { ...@@ -35,11 +35,12 @@ describe('Issuable output', () => {
canUpdate: true, canUpdate: true,
canDestroy: true, canDestroy: true,
endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes', endpoint: '/gitlab-org/gitlab-shell/issues/9/realtime_changes',
updateEndpoint: gl.TEST_HOST,
issuableRef: '#1', issuableRef: '#1',
initialTitleHtml: '', initialTitleHtml: '',
initialTitleText: '', initialTitleText: '',
initialDescriptionHtml: '', initialDescriptionHtml: 'test',
initialDescriptionText: '', initialDescriptionText: 'test',
markdownPreviewPath: '/', markdownPreviewPath: '/',
markdownDocsPath: '/', markdownDocsPath: '/',
projectNamespace: '/', projectNamespace: '/',
......
import Vue from 'vue'; import Vue from 'vue';
import descriptionComponent from '~/issue_show/components/description.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', () => { describe('Description component', () => {
let vm; let vm;
let DescriptionComponent;
const props = {
canUpdate: true,
descriptionHtml: 'test',
descriptionText: 'test',
updatedAt: new Date().toString(),
taskStatus: '',
updateUrl: gl.TEST_HOST,
};
beforeEach(() => { beforeEach(() => {
const Component = Vue.extend(descriptionComponent); DescriptionComponent = Vue.extend(descriptionComponent);
if (!document.querySelector('.issuable-meta')) { if (!document.querySelector('.issuable-meta')) {
const metaData = document.createElement('div'); const metaData = document.createElement('div');
...@@ -15,15 +26,11 @@ describe('Description component', () => { ...@@ -15,15 +26,11 @@ describe('Description component', () => {
document.body.appendChild(metaData); document.body.appendChild(metaData);
} }
vm = new Component({ vm = mountComponent(DescriptionComponent, props);
propsData: { });
canUpdate: true,
descriptionHtml: 'test', afterEach(() => {
descriptionText: 'test', vm.$destroy();
updatedAt: new Date().toString(),
taskStatus: '',
},
}).$mount();
}); });
it('animates description changes', (done) => { it('animates description changes', (done) => {
...@@ -44,34 +51,46 @@ describe('Description component', () => { ...@@ -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 describe('TaskList', () => {
beforeEach(() => {
// it('re-inits the TaskList when description changed', (done) => { vm = mountComponent(DescriptionComponent, Object.assign({}, props, {
// spyOn(gl, 'TaskList'); issuableType: 'issuableType',
// vm.descriptionHtml = 'changed'; }));
// spyOn(taskList, 'default');
// setTimeout(() => { });
// expect(
// gl.TaskList, it('re-inits the TaskList when description changed', (done) => {
// ).toHaveBeenCalled(); vm.descriptionHtml = 'changed';
//
// done(); setTimeout(() => {
// }); expect(taskList.default).toHaveBeenCalled();
// }); done();
});
// it('does not re-init the TaskList when canUpdate is false', (done) => { });
// spyOn(gl, 'TaskList');
// vm.canUpdate = false; it('does not re-init the TaskList when canUpdate is false', (done) => {
// vm.descriptionHtml = 'changed'; vm.canUpdate = false;
// vm.descriptionHtml = 'changed';
// setTimeout(() => {
// expect( setTimeout(() => {
// gl.TaskList, expect(taskList.default).not.toHaveBeenCalled();
// ).not.toHaveBeenCalled(); done();
// });
// 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', () => { describe('taskStatus', () => {
it('adds full taskStatus', (done) => { it('adds full taskStatus', (done) => {
...@@ -126,4 +145,8 @@ describe('Description component', () => { ...@@ -126,4 +145,8 @@ describe('Description component', () => {
}); });
}); });
}); });
it('sets data-update-url', () => {
expect(vm.$el.querySelector('textarea').dataset.updateUrl).toEqual(gl.TEST_HOST);
});
}); });
...@@ -80,19 +80,19 @@ describe('Title component', () => { ...@@ -80,19 +80,19 @@ describe('Title component', () => {
}); });
it('should not show by default', () => { 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', () => { it('should not show if canUpdate is false', () => {
vm.showInlineEditButton = true; vm.showInlineEditButton = true;
vm.canUpdate = false; 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', () => { it('should show if showInlineEditButton and canUpdate', () => {
vm.showInlineEditButton = true; vm.showInlineEditButton = true;
vm.canUpdate = 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', () => { it('should trigger open.form event when clicked', () => {
...@@ -100,7 +100,7 @@ describe('Title component', () => { ...@@ -100,7 +100,7 @@ describe('Title component', () => {
vm.canUpdate = true; vm.canUpdate = true;
Vue.nextTick(() => { Vue.nextTick(() => {
vm.$el.querySelector('.note-action-button').click(); vm.$el.querySelector('.btn-edit').click();
expect(eventHub.$emit).toHaveBeenCalledWith('open.form'); expect(eventHub.$emit).toHaveBeenCalledWith('open.form');
}); });
}); });
......
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');
});
});
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');
});
});
});
});
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');
});
});
...@@ -25,8 +25,12 @@ describe('RepoCommitSection', () => { ...@@ -25,8 +25,12 @@ describe('RepoCommitSection', () => {
return comp.$mount(); return comp.$mount();
} }
beforeEach(() => { beforeEach((done) => {
vm = createComponent(); vm = createComponent();
vm.collapsed = false;
Vue.nextTick(done);
}); });
afterEach(() => { afterEach(() => {
...@@ -36,12 +40,11 @@ describe('RepoCommitSection', () => { ...@@ -36,12 +40,11 @@ describe('RepoCommitSection', () => {
}); });
it('renders a commit section', () => { it('renders a commit section', () => {
const changedFileElements = [...vm.$el.querySelectorAll('.changed-files > li')]; const changedFileElements = [...vm.$el.querySelectorAll('.multi-file-commit-list li')];
const submitCommit = vm.$el.querySelector('.btn'); const submitCommit = vm.$el.querySelector('form .btn');
const targetBranch = vm.$el.querySelector('.target-branch');
expect(vm.$el.querySelector(':scope > form')).toBeTruthy(); expect(vm.$el.querySelector('.multi-file-commit-form')).not.toBeNull();
expect(vm.$el.querySelector('.staged-files').textContent.trim()).toEqual('Staged files (2)'); expect(vm.$el.querySelector('.multi-file-commit-panel-section header').textContent.trim()).toEqual('Staged');
expect(changedFileElements.length).toEqual(2); expect(changedFileElements.length).toEqual(2);
changedFileElements.forEach((changedFile, i) => { changedFileElements.forEach((changedFile, i) => {
...@@ -49,10 +52,7 @@ describe('RepoCommitSection', () => { ...@@ -49,10 +52,7 @@ describe('RepoCommitSection', () => {
}); });
expect(submitCommit.disabled).toBeTruthy(); expect(submitCommit.disabled).toBeTruthy();
expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeFalsy(); expect(submitCommit.querySelector('.fa-spinner.fa-spin')).toBeNull();
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');
}); });
describe('when submitting', () => { describe('when submitting', () => {
...@@ -69,7 +69,7 @@ describe('RepoCommitSection', () => { ...@@ -69,7 +69,7 @@ describe('RepoCommitSection', () => {
}); });
it('allows you to submit', () => { it('allows you to submit', () => {
expect(vm.$el.querySelector('.btn').disabled).toBeTruthy(); expect(vm.$el.querySelector('form .btn').disabled).toBeTruthy();
}); });
it('submits commit', (done) => { it('submits commit', (done) => {
......
...@@ -29,7 +29,6 @@ describe('RepoSidebar', () => { ...@@ -29,7 +29,6 @@ describe('RepoSidebar', () => {
const thead = vm.$el.querySelector('thead'); const thead = vm.$el.querySelector('thead');
const tbody = vm.$el.querySelector('tbody'); const tbody = vm.$el.querySelector('tbody');
expect(vm.$el.id).toEqual('sidebar');
expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy(); expect(vm.$el.classList.contains('sidebar-mini')).toBeFalsy();
expect(thead.querySelector('.name').textContent.trim()).toEqual('Name'); expect(thead.querySelector('.name').textContent.trim()).toEqual('Name');
expect(thead.querySelector('.last-commit').textContent.trim()).toEqual('Last commit'); expect(thead.querySelector('.last-commit').textContent.trim()).toEqual('Last commit');
...@@ -40,18 +39,6 @@ describe('RepoSidebar', () => { ...@@ -40,18 +39,6 @@ describe('RepoSidebar', () => {
expect(tbody.querySelector('.file')).toBeTruthy(); 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) => { it('renders 5 loading files if tree is loading', (done) => {
vm.$store.state.tree = []; vm.$store.state.tree = [];
vm.$store.state.loading = true; vm.$store.state.loading = true;
......
...@@ -24,8 +24,8 @@ describe('RepoTab', () => { ...@@ -24,8 +24,8 @@ describe('RepoTab', () => {
tab: file(), tab: file(),
}); });
vm.$store.state.openFiles.push(vm.tab); vm.$store.state.openFiles.push(vm.tab);
const close = vm.$el.querySelector('.close-btn'); const close = vm.$el.querySelector('.multi-file-tab-close');
const name = vm.$el.querySelector(`a[title="${vm.tab.url}"]`); const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`);
expect(close.querySelector('.fa-times')).toBeTruthy(); expect(close.querySelector('.fa-times')).toBeTruthy();
expect(name.textContent.trim()).toEqual(vm.tab.name); expect(name.textContent.trim()).toEqual(vm.tab.name);
...@@ -50,7 +50,7 @@ describe('RepoTab', () => { ...@@ -50,7 +50,7 @@ describe('RepoTab', () => {
spyOn(vm, 'closeFile'); spyOn(vm, 'closeFile');
vm.$el.querySelector('.close-btn').click(); vm.$el.querySelector('.multi-file-tab-close').click();
expect(vm.closeFile).toHaveBeenCalledWith({ file: vm.tab }); expect(vm.closeFile).toHaveBeenCalledWith({ file: vm.tab });
}); });
...@@ -62,7 +62,7 @@ describe('RepoTab', () => { ...@@ -62,7 +62,7 @@ describe('RepoTab', () => {
tab, tab,
}); });
expect(vm.$el.querySelector('.close-btn .fa-circle')).toBeTruthy(); expect(vm.$el.querySelector('.multi-file-tab-close .fa-circle')).not.toBeNull();
}); });
describe('locked file', () => { describe('locked file', () => {
...@@ -91,7 +91,7 @@ describe('RepoTab', () => { ...@@ -91,7 +91,7 @@ describe('RepoTab', () => {
}); });
it('renders a tooltip', () => { it('renders a tooltip', () => {
expect(vm.$el.querySelector('.repo-tab span').dataset.originalTitle).toContain('Locked by testuser'); expect(vm.$el.querySelector('span').dataset.originalTitle).toContain('Locked by testuser');
}); });
}); });
...@@ -107,7 +107,7 @@ describe('RepoTab', () => { ...@@ -107,7 +107,7 @@ describe('RepoTab', () => {
vm.$store.state.openFiles.push(tab); vm.$store.state.openFiles.push(tab);
vm.$store.dispatch('setFileActive', tab); vm.$store.dispatch('setFileActive', tab);
vm.$el.querySelector('.close-btn').click(); vm.$el.querySelector('.multi-file-tab-close').click();
vm.$nextTick(() => { vm.$nextTick(() => {
expect(tab.opened).toBeTruthy(); expect(tab.opened).toBeTruthy();
...@@ -125,7 +125,7 @@ describe('RepoTab', () => { ...@@ -125,7 +125,7 @@ describe('RepoTab', () => {
vm.$store.state.openFiles.push(tab); vm.$store.state.openFiles.push(tab);
vm.$store.dispatch('setFileActive', tab); vm.$store.dispatch('setFileActive', tab);
vm.$el.querySelector('.close-btn').click(); vm.$el.querySelector('.multi-file-tab-close').click();
vm.$nextTick(() => { vm.$nextTick(() => {
expect(tab.opened).toBeFalsy(); expect(tab.opened).toBeFalsy();
......
...@@ -25,12 +25,11 @@ describe('RepoTabs', () => { ...@@ -25,12 +25,11 @@ describe('RepoTabs', () => {
vm.$store.state.openFiles = openedFiles; vm.$store.state.openFiles = openedFiles;
vm.$nextTick(() => { 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[0].classList.contains('active')).toBeTruthy();
expect(tabs[1].classList.contains('active')).toBeFalsy(); expect(tabs[1].classList.contains('active')).toBeFalsy();
expect(tabs[2].classList.contains('tabs-divider')).toBeTruthy();
done(); done();
}); });
......
...@@ -116,4 +116,31 @@ describe('Multi-file store getters', () => { ...@@ -116,4 +116,31 @@ describe('Multi-file store getters', () => {
expect(getters.canEditFile(localState)).toBeFalsy(); 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');
});
});
}); });
...@@ -2,13 +2,16 @@ require 'spec_helper' ...@@ -2,13 +2,16 @@ require 'spec_helper'
describe Gitlab::Database::LoadBalancing::HostList do describe Gitlab::Database::LoadBalancing::HostList do
before do before do
allow(Gitlab::Database).to receive(:create_connection_pool) allow(Gitlab::Database)
.to receive(:create_connection_pool)
.and_return(ActiveRecord::Base.connection_pool) .and_return(ActiveRecord::Base.connection_pool)
end end
let(:load_balancer) { double(:load_balancer) }
let(:host_list) do let(:host_list) do
hosts = Array.new(2) do hosts = Array.new(2) do
Gitlab::Database::LoadBalancing::Host.new('localhost') Gitlab::Database::LoadBalancing::Host.new('localhost', load_balancer)
end end
described_class.new(hosts) described_class.new(hosts)
......
require 'spec_helper' require 'spec_helper'
describe Gitlab::Database::LoadBalancing::Host do describe Gitlab::Database::LoadBalancing::Host, :postgresql do
let(:host) { described_class.new('localhost') } let(:load_balancer) do
Gitlab::Database::LoadBalancing::LoadBalancer.new(%w[localhost])
end
let(:host) { load_balancer.host_list.hosts.first }
before do before do
allow(Gitlab::Database).to receive(:create_connection_pool) allow(Gitlab::Database).to receive(:create_connection_pool)
...@@ -33,68 +37,186 @@ describe Gitlab::Database::LoadBalancing::Host do ...@@ -33,68 +37,186 @@ describe Gitlab::Database::LoadBalancing::Host do
end end
describe '#online?' do describe '#online?' do
let(:error) { Class.new(RuntimeError) } context 'when the replica status is recent enough' do
it 'returns the latest status' do
Timecop.freeze do
host = described_class.new('localhost', load_balancer)
expect(host).not_to receive(:refresh_status)
expect(host).to be_online
end
end
end
context 'when the replica status is outdated' do
it 'refreshes the status' do
host.offline!
expect(host)
.to receive(:check_replica_status?)
.and_return(true)
expect(host).to be_online
end
end
end
describe '#refresh_status' do
it 'refreshes the status' do
host.offline!
expect(host)
.to receive(:replica_is_up_to_date?)
.and_call_original
host.refresh_status
expect(host).to be_online
end
end
describe '#check_replica_status?' do
it 'returns true when we need to check the replica status' do
allow(host)
.to receive(:last_checked_at)
.and_return(1.year.ago)
expect(host.check_replica_status?).to eq(true)
end
it 'returns false when we do not need to check the replica status' do
Timecop.freeze do
allow(host)
.to receive(:last_checked_at)
.and_return(Time.zone.now)
expect(host.check_replica_status?).to eq(false)
end
end
end
describe '#replica_is_up_to_date?' do
context 'when the lag time is below the threshold' do
it 'returns true' do
expect(host)
.to receive(:replication_lag_below_threshold?)
.and_return(true)
expect(host.replica_is_up_to_date?).to eq(true)
end
end
context 'when the lag time exceeds the threshold' do
before do before do
allow(host.pool).to receive(:disconnect!) allow(host)
.to receive(:replication_lag_below_threshold?)
.and_return(false)
end end
it 'returns true when the host is online' do it 'returns true if the data is recent enough' do
expect(host).not_to receive(:connection) expect(host)
expect(host).not_to receive(:release_connection) .to receive(:data_is_recent_enough?)
.and_return(true)
expect(host.online?).to eq(true) expect(host.replica_is_up_to_date?).to eq(true)
end end
it 'returns true when the host was marked as offline but is online again' do it 'returns false when the data is not recent enough' do
connection = double(:connection, active?: true) expect(host)
.to receive(:data_is_recent_enough?)
.and_return(false)
allow(host).to receive(:connection).and_return(connection) expect(host.replica_is_up_to_date?).to eq(false)
end
end
end
host.offline! describe '#replication_lag_below_threshold' do
it 'returns true when the lag time is below the threshold' do
expect(host)
.to receive(:replication_lag_time)
.and_return(1)
expect(host).to receive(:release_connection) expect(host.replication_lag_below_threshold?).to eq(true)
expect(host.online?).to eq(true)
end end
it 'returns false when the host is offline' do it 'returns false when the lag time exceeds the threshold' do
connection = double(:connection, active?: false) expect(host)
.to receive(:replication_lag_time)
.and_return(9000)
allow(host).to receive(:connection).and_return(connection) expect(host.replication_lag_below_threshold?).to eq(false)
expect(host).to receive(:release_connection) end
host.offline! it 'returns false when no lag time could be calculated' do
expect(host)
.to receive(:replication_lag_time)
.and_return(nil)
expect(host.replication_lag_below_threshold?).to eq(false)
end
end
expect(host.online?).to eq(false) describe '#data_is_recent_enough?' do
it 'returns true when the data is recent enough' do
expect(host.data_is_recent_enough?).to eq(true)
end end
it 'returns false when a connection could not be established' do it 'returns false when the data is not recent enough' do
expect(host).to receive(:connection).exactly(4).times.and_raise(error) diff = Gitlab::Database::LoadBalancing.max_replication_difference * 2
expect(host).to receive(:release_connection).exactly(4).times
host.offline! expect(host)
.to receive(:query_and_release)
.and_return({ 'diff' => diff })
expect(host.online?).to eq(false) expect(host.data_is_recent_enough?).to eq(false)
end end
it 'retries when a connection error is thrown' do it 'returns false when no lag size could be calculated' do
connection = double(:connection, active?: true) expect(host)
raised = false .to receive(:replication_lag_size)
.and_return(nil)
allow(host).to receive(:connection) do expect(host.data_is_recent_enough?).to eq(false)
unless raised end
raised = true
raise error.new
end end
connection describe '#replication_lag_time' do
it 'returns the lag time as a Float' do
expect(host.replication_lag_time).to be_an_instance_of(Float)
end end
expect(host).to receive(:release_connection).twice it 'returns nil when the database query returned no rows' do
expect(host)
.to receive(:query_and_release)
.and_return({})
host.offline! expect(host.replication_lag_time).to be_nil
end
end
describe '#replication_lag_size' do
it 'returns the lag size as an Integer' do
# On newer versions of Ruby the class is Integer, but on CI we run a
# version that still uses Fixnum.
classes = [Fixnum, Integer] # rubocop: disable Lint/UnifiedInteger
expect(host.online?).to eq(true) expect(classes).to include(host.replication_lag_size.class)
end
it 'returns nil when the database query returned no rows' do
expect(host)
.to receive(:query_and_release)
.and_return({})
expect(host.replication_lag_size).to be_nil
end
end
describe '#primary_write_location' do
it 'returns the write location of the primary' do
expect(host.primary_write_location).to be_an_instance_of(String)
expect(host.primary_write_location).not_to be_empty
end end
end end
...@@ -119,4 +241,29 @@ describe Gitlab::Database::LoadBalancing::Host do ...@@ -119,4 +241,29 @@ describe Gitlab::Database::LoadBalancing::Host do
expect(host.caught_up?('foo')).to eq(false) expect(host.caught_up?('foo')).to eq(false)
end end
end end
describe '#query_and_release' do
it 'executes a SQL query' do
results = host.query_and_release('SELECT 10 AS number')
expect(results).to be_an_instance_of(Hash)
expect(results['number'].to_i).to eq(10)
end
it 'releases the connection after running the query' do
expect(host)
.to receive(:release_connection)
.once
host.query_and_release('SELECT 10 AS number')
end
it 'returns an empty Hash in the event of an error' do
expect(host.connection)
.to receive(:select_all)
.and_raise(RuntimeError, 'kittens')
expect(host.query_and_release('SELECT 10 AS number')).to eq({})
end
end
end end
...@@ -9,10 +9,89 @@ describe Gitlab::Database::LoadBalancing do ...@@ -9,10 +9,89 @@ describe Gitlab::Database::LoadBalancing do
end end
end end
describe '.configuration' do
it 'returns a Hash' do
config = { 'hosts' => %w(foo) }
allow(ActiveRecord::Base.configurations[Rails.env])
.to receive(:[])
.with('load_balancing')
.and_return(config)
expect(described_class.configuration).to eq(config)
end
end
describe '.max_replication_difference' do
context 'without an explicitly configured value' do
it 'returns the default value' do
allow(described_class)
.to receive(:configuration)
.and_return({})
expect(described_class.max_replication_difference).to eq(8.megabytes)
end
end
context 'with an explicitly configured value' do
it 'returns the configured value' do
allow(described_class)
.to receive(:configuration)
.and_return({ 'max_replication_difference' => 4 })
expect(described_class.max_replication_difference).to eq(4)
end
end
end
describe '.max_replication_lag_time' do
context 'without an explicitly configured value' do
it 'returns the default value' do
allow(described_class)
.to receive(:configuration)
.and_return({})
expect(described_class.max_replication_lag_time).to eq(60)
end
end
context 'with an explicitly configured value' do
it 'returns the configured value' do
allow(described_class)
.to receive(:configuration)
.and_return({ 'max_replication_lag_time' => 4 })
expect(described_class.max_replication_lag_time).to eq(4)
end
end
end
describe '.replica_check_interval' do
context 'without an explicitly configured value' do
it 'returns the default value' do
allow(described_class)
.to receive(:configuration)
.and_return({})
expect(described_class.replica_check_interval).to eq(60)
end
end
context 'with an explicitly configured value' do
it 'returns the configured value' do
allow(described_class)
.to receive(:configuration)
.and_return({ 'replica_check_interval' => 4 })
expect(described_class.replica_check_interval).to eq(4)
end
end
end
describe '.hosts' do describe '.hosts' do
it 'returns a list of hosts' do it 'returns a list of hosts' do
allow(ActiveRecord::Base.configurations[Rails.env]).to receive(:[]) allow(described_class)
.with('load_balancing') .to receive(:configuration)
.and_return({ 'hosts' => %w(foo bar baz) }) .and_return({ 'hosts' => %w(foo bar baz) })
expect(described_class.hosts).to eq(%w(foo bar baz)) expect(described_class.hosts).to eq(%w(foo bar baz))
......
...@@ -149,16 +149,6 @@ describe Gitlab::Geo, :geo do ...@@ -149,16 +149,6 @@ describe Gitlab::Geo, :geo do
end end
end end
describe 'geo_node?' do
it 'returns true if a node with specific host and port exists' do
expect(described_class.geo_node?(host: primary_node.host, port: primary_node.port)).to be_truthy
end
it 'returns false if specified host and port doesnt match any existing node' do
expect(described_class.geo_node?(host: 'inexistent', port: 1234)).to be_falsey
end
end
describe 'license_allows?' do describe 'license_allows?' do
it 'returns true if license has Geo addon' do it 'returns true if license has Geo addon' do
stub_licensed_features(geo: true) stub_licensed_features(geo: true)
......
...@@ -4,8 +4,8 @@ describe GeoNode, type: :model do ...@@ -4,8 +4,8 @@ describe GeoNode, type: :model do
using RSpec::Parameterized::TableSyntax using RSpec::Parameterized::TableSyntax
include ::EE::GeoHelpers include ::EE::GeoHelpers
let(:new_node) { create(:geo_node, schema: 'https', host: 'localhost', port: 3000, relative_url_root: 'gitlab') } let(:new_node) { create(:geo_node, url: 'https://localhost:3000/gitlab') }
let(:new_primary_node) { create(:geo_node, :primary, schema: 'https', host: 'localhost', port: 3000, relative_url_root: 'gitlab') } let(:new_primary_node) { create(:geo_node, :primary, url: 'https://localhost:3000/gitlab') }
let(:empty_node) { described_class.new } let(:empty_node) { described_class.new }
let(:primary_node) { create(:geo_node, :primary) } let(:primary_node) { create(:geo_node, :primary) }
let(:node) { create(:geo_node) } let(:node) { create(:geo_node) }
...@@ -34,10 +34,7 @@ describe GeoNode, type: :model do ...@@ -34,10 +34,7 @@ describe GeoNode, type: :model do
let(:gitlab_host) { 'gitlabhost' } let(:gitlab_host) { 'gitlabhost' }
where(:attribute, :value) do where(:attribute, :value) do
:schema | 'http' :url | Gitlab::Routing.url_helpers.root_url
:host | 'gitlabhost'
:port | 80
:relative_url_root | ''
:primary | false :primary | false
:repos_max_capacity | 25 :repos_max_capacity | 25
:files_max_capacity | 10 :files_max_capacity | 10
...@@ -45,22 +42,13 @@ describe GeoNode, type: :model do ...@@ -45,22 +42,13 @@ describe GeoNode, type: :model do
end end
with_them do with_them do
before do
allow(Gitlab.config.gitlab).to receive(:host) { gitlab_host }
end
it { expect(empty_node[attribute]).to eq(value) } it { expect(empty_node[attribute]).to eq(value) }
end end
end end
context 'prevent locking yourself out' do context 'prevent locking yourself out' do
it 'does not accept adding a non primary node with same details as current_node' do it 'does not accept adding a non primary node with same details as current_node' do
node = GeoNode.new( node = build(:geo_node, :primary, primary: false)
host: Gitlab.config.gitlab.host,
port: Gitlab.config.gitlab.port,
relative_url_root: Gitlab.config.gitlab.relative_url_root,
geo_node_key: build(:geo_node_key)
)
expect(node).not_to be_valid expect(node).not_to be_valid
expect(node.errors.full_messages.count).to eq(1) expect(node.errors.full_messages.count).to eq(1)
...@@ -129,18 +117,16 @@ describe GeoNode, type: :model do ...@@ -129,18 +117,16 @@ describe GeoNode, type: :model do
end end
describe '#current?' do describe '#current?' do
subject { described_class.new }
it 'returns true when node is the current node' do it 'returns true when node is the current node' do
stub_current_geo_node(subject) node = described_class.new(url: described_class.current_node_url)
expect(subject.current?).to eq true expect(node.current?).to be_truthy
end end
it 'returns false when node is not the current node' do it 'returns false when node is not the current node' do
subject.port = Gitlab.config.gitlab.port + 1 node = described_class.new(url: 'http://another.node.com:8080/foo')
expect(subject.current?).to eq false expect(node.current?).to be_falsy
end end
end end
...@@ -150,8 +136,9 @@ describe GeoNode, type: :model do ...@@ -150,8 +136,9 @@ describe GeoNode, type: :model do
expect(new_node.uri).to be_a URI expect(new_node.uri).to be_a URI
end end
it 'includes schema home port and relative_url' do it 'includes schema, host, port and relative_url_root with a terminating /' do
expected_uri = URI.parse(dummy_url) expected_uri = URI.parse(dummy_url)
expected_uri.path += '/'
expect(new_node.uri).to eq(expected_uri) expect(new_node.uri).to eq(expected_uri)
end end
end end
...@@ -172,18 +159,18 @@ describe GeoNode, type: :model do ...@@ -172,18 +159,18 @@ describe GeoNode, type: :model do
expect(new_node.url).to be_a String expect(new_node.url).to be_a String
end end
it 'includes schema home port and relative_url' do it 'includes schema home port and relative_url with a terminating /' do
expected_url = 'https://localhost:3000/gitlab' expected_url = 'https://localhost:3000/gitlab/'
expect(new_node.url).to eq(expected_url) expect(new_node.url).to eq(expected_url)
end end
it 'defaults to existing HTTPS and relative URL if present' do it 'defaults to existing HTTPS and relative URL with a terminating / if present' do
stub_config_setting(port: 443) stub_config_setting(port: 443)
stub_config_setting(protocol: 'https') stub_config_setting(protocol: 'https')
stub_config_setting(relative_url_root: '/gitlab') stub_config_setting(relative_url_root: '/gitlab')
node = GeoNode.new node = GeoNode.new
expect(node.url).to eq('https://localhost/gitlab') expect(node.url).to eq('https://localhost/gitlab/')
end end
end end
...@@ -195,15 +182,15 @@ describe GeoNode, type: :model do ...@@ -195,15 +182,15 @@ describe GeoNode, type: :model do
end end
it 'sets schema field based on url' do it 'sets schema field based on url' do
expect(subject.schema).to eq('https') expect(subject.uri.scheme).to eq('https')
end end
it 'sets host field based on url' do it 'sets host field based on url' do
expect(subject.host).to eq('localhost') expect(subject.uri.host).to eq('localhost')
end end
it 'sets port field based on specified by url' do it 'sets port field based on specified by url' do
expect(subject.port).to eq(3000) expect(subject.uri.port).to eq(3000)
end end
context 'when unspecified ports' do context 'when unspecified ports' do
...@@ -212,12 +199,14 @@ describe GeoNode, type: :model do ...@@ -212,12 +199,14 @@ describe GeoNode, type: :model do
it 'sets port 80 when http and no port is specified' do it 'sets port 80 when http and no port is specified' do
subject.url = dummy_http subject.url = dummy_http
expect(subject.port).to eq(80)
expect(subject.uri.port).to eq(80)
end end
it 'sets port 443 when https and no port is specified' do it 'sets port 443 when https and no port is specified' do
subject.url = dummy_https subject.url = dummy_https
expect(subject.port).to eq(443)
expect(subject.uri.port).to eq(443)
end end
end end
end end
......
...@@ -17,7 +17,7 @@ describe Geo::BaseSyncService do ...@@ -17,7 +17,7 @@ describe Geo::BaseSyncService do
end end
describe '#primary_ssh_path_prefix' do describe '#primary_ssh_path_prefix' do
let!(:primary_node) { create(:geo_node, :primary, host: 'primary-geo-node') } let!(:primary_node) { create(:geo_node, :primary) }
it 'raises exception when clone_url_prefix is nil' do it 'raises exception when clone_url_prefix is nil' do
allow_any_instance_of(GeoNode).to receive(:clone_url_prefix) { nil } allow_any_instance_of(GeoNode).to receive(:clone_url_prefix) { nil }
......
...@@ -3,7 +3,7 @@ require 'spec_helper' ...@@ -3,7 +3,7 @@ require 'spec_helper'
describe Geo::RepositorySyncService do describe Geo::RepositorySyncService do
include ::EE::GeoHelpers include ::EE::GeoHelpers
set(:primary) { create(:geo_node, :primary, host: 'primary-geo-node', relative_url_root: '/gitlab') } set(:primary) { create(:geo_node, :primary) }
set(:secondary) { create(:geo_node) } set(:secondary) { create(:geo_node) }
let(:lease) { double(try_obtain: true) } let(:lease) { double(try_obtain: true) }
...@@ -19,7 +19,7 @@ describe Geo::RepositorySyncService do ...@@ -19,7 +19,7 @@ describe Geo::RepositorySyncService do
describe '#execute' do describe '#execute' do
let(:project) { create(:project_empty_repo) } let(:project) { create(:project_empty_repo) }
let(:repository) { project.repository } let(:repository) { project.repository }
let(:url_to_repo) { "#{primary.url}/#{project.full_path}.git" } let(:url_to_repo) { "#{primary.url}#{project.full_path}.git" }
before do before do
allow(Gitlab::ExclusiveLease).to receive(:new) allow(Gitlab::ExclusiveLease).to receive(:new)
......
...@@ -3,7 +3,7 @@ require 'spec_helper' ...@@ -3,7 +3,7 @@ require 'spec_helper'
RSpec.describe Geo::WikiSyncService do RSpec.describe Geo::WikiSyncService do
include ::EE::GeoHelpers include ::EE::GeoHelpers
set(:primary) { create(:geo_node, :primary, host: 'primary-geo-node', relative_url_root: '/gitlab') } set(:primary) { create(:geo_node, :primary) }
set(:secondary) { create(:geo_node) } set(:secondary) { create(:geo_node) }
let(:lease) { double(try_obtain: true) } let(:lease) { double(try_obtain: true) }
...@@ -19,7 +19,7 @@ RSpec.describe Geo::WikiSyncService do ...@@ -19,7 +19,7 @@ RSpec.describe Geo::WikiSyncService do
describe '#execute' do describe '#execute' do
let(:project) { create(:project_empty_repo) } let(:project) { create(:project_empty_repo) }
let(:repository) { project.wiki.repository } let(:repository) { project.wiki.repository }
let(:url_to_repo) { "#{primary.url}/#{project.full_path}.wiki.git" } let(:url_to_repo) { "#{primary.url}#{project.full_path}.wiki.git" }
before do before do
allow(Gitlab::ExclusiveLease).to receive(:new) allow(Gitlab::ExclusiveLease).to receive(:new)
......
...@@ -6,8 +6,10 @@ describe MergeRequests::CreateFromIssueService do ...@@ -6,8 +6,10 @@ describe MergeRequests::CreateFromIssueService do
let(:label_ids) { create_pair(:label, project: project).map(&:id) } let(:label_ids) { create_pair(:label, project: project).map(&:id) }
let(:milestone_id) { create(:milestone, project: project).id } let(:milestone_id) { create(:milestone, project: project).id }
let(:issue) { create(:issue, project: project, milestone_id: milestone_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) { 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 before do
project.add_developer(user) project.add_developer(user)
...@@ -17,8 +19,8 @@ describe MergeRequests::CreateFromIssueService do ...@@ -17,8 +19,8 @@ describe MergeRequests::CreateFromIssueService do
it 'returns an error with invalid issue iid' do it 'returns an error with invalid issue iid' do
result = described_class.new(project, user, issue_iid: -1).execute result = described_class.new(project, user, issue_iid: -1).execute
expect(result[:status]).to eq :error expect(result[:status]).to eq(:error)
expect(result[:message]).to eq 'Invalid issue iid' expect(result[:message]).to eq('Invalid issue iid')
end end
it 'delegates issue search to IssuesFinder' do it 'delegates issue search to IssuesFinder' do
...@@ -53,6 +55,12 @@ describe MergeRequests::CreateFromIssueService do ...@@ -53,6 +55,12 @@ describe MergeRequests::CreateFromIssueService do
expect(project.repository.branch_exists?(issue.to_branch_name)).to be_truthy expect(project.repository.branch_exists?(issue.to_branch_name)).to be_truthy
end 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 it 'creates a system note' do
expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name) expect(SystemNoteService).to receive(:new_issue_branch).with(issue, project, user, issue.to_branch_name)
...@@ -72,19 +80,25 @@ describe MergeRequests::CreateFromIssueService do ...@@ -72,19 +80,25 @@ describe MergeRequests::CreateFromIssueService do
it 'sets the merge request author to current user' do it 'sets the merge request author to current user' do
result = service.execute result = service.execute
expect(result[:merge_request].author).to eq user expect(result[:merge_request].author).to eq(user)
end end
it 'sets the merge request source branch to the new issue branch' do it 'sets the merge request source branch to the new issue branch' do
result = service.execute 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 end
it 'sets the merge request target branch to the project default branch' do it 'sets the merge request target branch to the project default branch' do
result = service.execute 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 end
end end
...@@ -4,14 +4,15 @@ describe Projects::UpdateService, '#execute' do ...@@ -4,14 +4,15 @@ describe Projects::UpdateService, '#execute' do
include StubConfiguration include StubConfiguration
include ProjectForksHelper include ProjectForksHelper
let(:gitlab_shell) { Gitlab::Shell.new }
let(:user) { create(:user) } let(:user) { create(:user) }
let(:admin) { create(:admin) }
let(:project) do let(:project) do
create(:project, creator: user, namespace: user.namespace) create(:project, creator: user, namespace: user.namespace)
end end
describe '#execute' do
let(:gitlab_shell) { Gitlab::Shell.new }
let(:admin) { create(:admin) }
context 'when changing visibility level' do context 'when changing visibility level' do
context 'when visibility_level is INTERNAL' do context 'when visibility_level is INTERNAL' do
it 'updates the project to internal' do it 'updates the project to internal' do
...@@ -196,6 +197,29 @@ describe Projects::UpdateService, '#execute' do ...@@ -196,6 +197,29 @@ describe Projects::UpdateService, '#execute' do
}) })
end end
end end
end
describe '#run_auto_devops_pipeline?' do
subject { described_class.new(project, user, params).run_auto_devops_pipeline? }
context 'when neither pipeline setting is true' do
let(:params) { {} }
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
context 'when run_auto_devops_pipeline_implicit is true' do
let(:params) { { run_auto_devops_pipeline_implicit: 'true' } }
it { is_expected.to eq(true) }
end
end
describe 'repository_storage' do describe 'repository_storage' do
let(:admin_user) { create(:user, admin: true) } let(:admin_user) { create(:user, admin: true) }
...@@ -258,6 +282,8 @@ describe Projects::UpdateService, '#execute' do ...@@ -258,6 +282,8 @@ describe Projects::UpdateService, '#execute' do
end end
it 'returns an error result when record cannot be updated' do it 'returns an error result when record cannot be updated' do
admin = create(:admin)
result = update_project(project, admin, { name: 'foo&bar' }) result = update_project(project, admin, { name: 'foo&bar' })
expect(result).to eq({ status: :error, message: "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." }) expect(result).to eq({ status: :error, message: "Name can contain only letters, digits, emojis, '_', '.', dash, space. It must start with letter, digit, emoji or '_'." })
......
...@@ -20,7 +20,7 @@ describe 'geo rake tasks' do ...@@ -20,7 +20,7 @@ describe 'geo rake tasks' do
node = GeoNode.first node = GeoNode.first
expect(node.schema).to eq('https') expect(node.uri.scheme).to eq('https')
expect(node.primary).to be_truthy expect(node.primary).to be_truthy
expect(node.geo_node_key).to be_nil expect(node.geo_node_key).to be_nil
end end
......
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
...@@ -4,7 +4,7 @@ describe Geo::PruneEventLogWorker, :geo do ...@@ -4,7 +4,7 @@ describe Geo::PruneEventLogWorker, :geo do
include ::EE::GeoHelpers include ::EE::GeoHelpers
subject(:worker) { described_class.new } subject(:worker) { described_class.new }
set(:primary) { create(:geo_node, :primary, host: 'primary-geo-node') } set(:primary) { create(:geo_node, :primary) }
set(:secondary) { create(:geo_node) } set(:secondary) { create(:geo_node) }
before do before do
......
...@@ -5,8 +5,9 @@ require 'spec_helper' ...@@ -5,8 +5,9 @@ require 'spec_helper'
describe Geo::RepositorySyncWorker, :geo, :truncate do describe Geo::RepositorySyncWorker, :geo, :truncate do
include ::EE::GeoHelpers include ::EE::GeoHelpers
let(:secondary) { create(:geo_node) } let!(:primary) { create(:geo_node, :primary) }
let(:synced_group) { create(:group) } let!(:secondary) { create(:geo_node) }
let!(:synced_group) { create(:group) }
let!(:project_in_synced_group) { create(:project, group: synced_group) } let!(:project_in_synced_group) { create(:project, group: synced_group) }
let!(:unsynced_project) { create(:project) } let!(:unsynced_project) { create(:project) }
......
...@@ -1297,13 +1297,13 @@ cli-width@^2.0.0: ...@@ -1297,13 +1297,13 @@ cli-width@^2.0.0:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a" resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.1.0.tgz#b234ca209b29ef66fc518d9b98d5847b00edf00a"
clipboard@^1.5.5, clipboard@^1.6.1: clipboard@^1.5.5, clipboard@^1.7.1:
version "1.6.1" version "1.7.1"
resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.6.1.tgz#65c5b654812466b0faab82dc6ba0f1d2f8e4be53" resolved "https://registry.yarnpkg.com/clipboard/-/clipboard-1.7.1.tgz#360d6d6946e99a7a1fef395e42ba92b5e9b5a16b"
dependencies: dependencies:
good-listener "^1.2.0" good-listener "^1.2.2"
select "^1.1.2" select "^1.1.2"
tiny-emitter "^1.0.0" tiny-emitter "^2.0.0"
cliui@^2.1.0: cliui@^2.1.0:
version "2.1.0" version "2.1.0"
...@@ -2900,7 +2900,7 @@ globby@^6.1.0: ...@@ -2900,7 +2900,7 @@ globby@^6.1.0:
pify "^2.0.0" pify "^2.0.0"
pinkie-promise "^2.0.0" pinkie-promise "^2.0.0"
good-listener@^1.2.0: good-listener@^1.2.2:
version "1.2.2" version "1.2.2"
resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50"
dependencies: dependencies:
...@@ -6138,9 +6138,9 @@ timers-browserify@^2.0.2: ...@@ -6138,9 +6138,9 @@ timers-browserify@^2.0.2:
dependencies: dependencies:
setimmediate "^1.0.4" setimmediate "^1.0.4"
tiny-emitter@^1.0.0: tiny-emitter@^2.0.0:
version "1.1.0" version "2.0.2"
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-1.1.0.tgz#ab405a21ffed814a76c19739648093d70654fecb" resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.0.2.tgz#82d27468aca5ade8e5fd1e6d22b57dd43ebdfb7c"
tmp@0.0.31, tmp@0.0.x: tmp@0.0.31, tmp@0.0.x:
version "0.0.31" version "0.0.31"
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment