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

This commit is contained in:
Phil Hughes 2017-11-30 09:52:58 +00:00
commit 386cbf22ee
No known key found for this signature in database
GPG Key ID: 32245528C52E0F9F
218 changed files with 4933 additions and 2721 deletions

View File

@ -579,7 +579,7 @@ codequality:
script:
- cp .rubocop.yml .rubocop.yml.bak
- grep -v "rubocop-gitlab-security" .rubocop.yml.bak > .rubocop.yml
- docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate:0.69.0 analyze -f json > raw_codeclimate.json
- docker run --env CODECLIMATE_CODE="$PWD" --volume "$PWD":/code --volume /var/run/docker.sock:/var/run/docker.sock --volume /tmp/cc:/tmp/cc codeclimate/codeclimate analyze -f json > raw_codeclimate.json
- cat raw_codeclimate.json | docker run -i stedolan/jq -c 'map({check_name,fingerprint,location})' > codeclimate.json
- mv .rubocop.yml.bak .rubocop.yml
artifacts:

View File

@ -1153,7 +1153,7 @@ DEPENDENCIES
sanitize (~> 2.0)
sass-rails (~> 5.0.6)
scss_lint (~> 0.54.0)
seed-fu (~> 2.3.5)
seed-fu (~> 2.3.7)
select2-rails (~> 3.5.9)
selenium-webdriver (~> 3.5)
sentry-raven (~> 2.5.3)

View File

@ -0,0 +1,73 @@
import Clipboard from 'clipboard';
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');
}
}
function genericSuccess(e) {
showTooltip(e.trigger, 'Copied');
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
$(e.trigger).blur();
}
/**
* Safari > 10 doesn't support `execCommand`, so instead we inform the user to copy manually.
* See http://clipboardjs.com/#browser-support
*/
function genericError(e) {
let key;
if (/Mac/i.test(navigator.userAgent)) {
key = '⌘'; // Command
} else {
key = 'Ctrl';
}
showTooltip(e.trigger, `Press ${key}-C to copy`);
}
export default function initCopyToClipboard() {
const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess);
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` attribute that ClipboardJS reads from.
* When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly`
* attribute`), sets its value to the value of this data attribute, focusses on it, and finally
* programmatically issues the 'Copy' command, this code intercepts the copy command/event at
* 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;
if (!clipboardData) return;
const text = e.target.value;
let json;
try {
json = JSON.parse(text);
} catch (ex) {
return;
}
if (!json.text || !json.gfm) return;
e.preventDefault();
clipboardData.setData('text/plain', json.text);
clipboardData.setData('text/x-gfm', json.gfm);
});
}

View File

@ -1,6 +1,7 @@
import './autosize';
import './bind_in_out';
import initCopyAsGFM from './copy_as_gfm';
import initCopyToClipboard from './copy_to_clipboard';
import './details_behavior';
import installGlEmojiElement from './gl_emoji';
import './quick_submit';
@ -9,3 +10,4 @@ import './toggler_behavior';
installGlEmojiElement();
initCopyAsGFM();
initCopyToClipboard();

View File

@ -1,74 +0,0 @@
/* 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 'vendor/clipboard';
var genericError, genericSuccess, showTooltip;
genericSuccess = function(e) {
showTooltip(e.trigger, 'Copied');
// Clear the selection and blur the trigger so it loses its border
e.clearSelection();
return $(e.trigger).blur();
};
// Safari doesn't support `execCommand`, so instead we inform the user to
// copy manually.
//
// See http://clipboardjs.com/#browser-support
genericError = function(e) {
var key;
if (/Mac/i.test(navigator.userAgent)) {
key = '⌘'; // Command
} else {
key = 'Ctrl';
}
return 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() {
const clipboard = new Clipboard('[data-clipboard-target], [data-clipboard-text]');
clipboard.on('success', genericSuccess);
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`
// attribute that ClipboardJS reads from.
// When ClipboardJS creates a new `textarea` (directly inside `body`, with a `readonly` attribute`), sets its value
// to the value of this data attribute, focusses on it, and finally programmatically issues the 'Copy' command,
// this code intercepts the copy command/event at 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]', function(e) {
const clipboardData = e.originalEvent.clipboardData;
if (!clipboardData) return;
const text = e.target.value;
let json;
try {
json = JSON.parse(text);
} catch (ex) {
return;
}
if (!json.text || !json.gfm) return;
e.preventDefault();
clipboardData.setData('text/plain', json.text);
clipboardData.setData('text/x-gfm', json.gfm);
});
});

View File

@ -2,6 +2,7 @@
import Flash from './flash';
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
import { __, sprintf } from './locale';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter);
@ -12,70 +13,63 @@ const CREATE_BRANCH = 'create-branch';
export default class CreateMergeRequestDropdown {
constructor(wrapperEl) {
this.wrapperEl = wrapperEl;
this.createMergeRequestButton = this.wrapperEl.querySelector('.js-create-merge-request');
this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
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.createTargetButton = this.wrapperEl.querySelector('.js-create-target');
this.dropdownList = this.wrapperEl.querySelector('.dropdown-menu');
this.dropdownToggle = this.wrapperEl.querySelector('.js-dropdown-toggle');
this.refInput = this.wrapperEl.querySelector('.js-ref');
this.refMessage = this.wrapperEl.querySelector('.js-ref-message');
this.unavailableButton = this.wrapperEl.querySelector('.unavailable');
this.unavailableButtonArrow = this.unavailableButton.querySelector('.fa');
this.unavailableButtonText = this.unavailableButton.querySelector('.text');
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.branchCreated = false;
this.branchIsValid = true;
this.canCreatePath = this.wrapperEl.dataset.canCreatePath;
this.createBranchPath = this.wrapperEl.dataset.createBranchPath;
this.createMrPath = this.wrapperEl.dataset.createMrPath;
this.droplabInitialized = false;
this.isCreatingMergeRequest = false;
this.mergeRequestCreated = false;
this.isCreatingBranch = false;
this.branchCreated = false;
this.isCreatingMergeRequest = false;
this.isGettingRef = false;
this.mergeRequestCreated = false;
this.refDebounce = _.debounce((value, target) => this.getRef(value, target), 500);
this.refIsValid = true;
this.refsPath = this.wrapperEl.dataset.refsPath;
this.suggestedRef = this.refInput.value;
// 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=)(.+?)$'),
},
};
this.init();
}
init() {
this.checkAbilityToCreateBranch();
}
available() {
this.availableButton.classList.remove('hide');
this.unavailableButton.classList.add('hide');
}
unavailable() {
this.availableButton.classList.add('hide');
this.unavailableButton.classList.remove('hide');
}
enable() {
this.createMergeRequestButton.classList.remove('disabled');
this.createMergeRequestButton.removeAttribute('disabled');
this.dropdownToggle.classList.remove('disabled');
this.dropdownToggle.removeAttribute('disabled');
}
disable() {
this.createMergeRequestButton.classList.add('disabled');
this.createMergeRequestButton.setAttribute('disabled', 'disabled');
this.dropdownToggle.classList.add('disabled');
this.dropdownToggle.setAttribute('disabled', 'disabled');
}
hide() {
this.wrapperEl.classList.add('hide');
}
setUnavailableButtonState(isLoading = true) {
if (isLoading) {
this.unavailableButtonArrow.classList.add('fa-spinner', 'fa-spin');
this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
this.unavailableButtonText.textContent = 'Checking branch availability…';
} else {
this.unavailableButtonArrow.classList.remove('fa-spinner', 'fa-spin');
this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
this.unavailableButtonText.textContent = 'New branch unavailable';
}
bindEvents() {
this.createMergeRequestButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
this.createTargetButton.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
this.branchInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.dropdownToggle.addEventListener('click', this.onClickSetFocusOnBranchNameInput.bind(this));
this.refInput.addEventListener('keyup', this.onChangeInput.bind(this));
this.refInput.addEventListener('keydown', CreateMergeRequestDropdown.processTab.bind(this));
}
checkAbilityToCreateBranch() {
@ -107,60 +101,18 @@ export default class CreateMergeRequestDropdown {
});
}
initDroplab() {
this.droplab = new DropLab();
this.droplab.init(this.dropdownToggle, this.dropdownList, [InputSetter],
this.getDroplabConfig());
}
getDroplabConfig() {
return {
InputSetter: [{
input: this.createMergeRequestButton,
valueAttribute: 'data-value',
inputAttribute: 'data-action',
}, {
input: this.createMergeRequestButton,
valueAttribute: 'data-text',
}],
};
}
bindEvents() {
this.createMergeRequestButton
.addEventListener('click', this.onClickCreateMergeRequestButton.bind(this));
}
isBusy() {
return this.isCreatingMergeRequest ||
this.mergeRequestCreated ||
this.isCreatingBranch ||
this.branchCreated;
}
onClickCreateMergeRequestButton(e) {
let xhr = null;
e.preventDefault();
if (this.isBusy()) {
return;
}
if (e.target.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
} else if (e.target.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch();
}
xhr.fail(() => {
this.isCreatingMergeRequest = false;
this.isCreatingBranch = false;
});
xhr.always(() => this.enable());
this.disable();
createBranch() {
return $.ajax({
method: 'POST',
dataType: 'json',
url: this.createBranchPath,
beforeSend: () => (this.isCreatingBranch = true),
})
.done((data) => {
this.branchCreated = true;
window.location.href = data.url;
})
.fail(() => new Flash('Failed to create a branch for this issue. Please try again.'));
}
createMergeRequest() {
@ -177,17 +129,343 @@ export default class CreateMergeRequestDropdown {
.fail(() => new Flash('Failed to create Merge Request. Please try again.'));
}
createBranch() {
disable() {
this.disableCreateAction();
this.dropdownToggle.classList.add('disabled');
this.dropdownToggle.setAttribute('disabled', 'disabled');
}
disableCreateAction() {
this.createMergeRequestButton.classList.add('disabled');
this.createMergeRequestButton.setAttribute('disabled', 'disabled');
this.createTargetButton.classList.add('disabled');
this.createTargetButton.setAttribute('disabled', 'disabled');
}
enable() {
this.createMergeRequestButton.classList.remove('disabled');
this.createMergeRequestButton.removeAttribute('disabled');
this.createTargetButton.classList.remove('disabled');
this.createTargetButton.removeAttribute('disabled');
this.dropdownToggle.classList.remove('disabled');
this.dropdownToggle.removeAttribute('disabled');
}
static findByValue(objects, ref, returnFirstMatch = false) {
if (!objects || !objects.length) return false;
if (objects.indexOf(ref) > -1) return ref;
if (returnFirstMatch) return objects.find(item => new RegExp(`^${ref}`).test(item));
return false;
}
getDroplabConfig() {
return {
addActiveClassToDropdownButton: true,
InputSetter: [
{
input: this.createMergeRequestButton,
valueAttribute: 'data-value',
inputAttribute: 'data-action',
},
{
input: this.createMergeRequestButton,
valueAttribute: 'data-text',
},
{
input: this.createTargetButton,
valueAttribute: 'data-value',
inputAttribute: 'data-action',
},
{
input: this.createTargetButton,
valueAttribute: 'data-text',
},
],
};
}
static getInputSelectedText(input) {
const start = input.selectionStart;
const end = input.selectionEnd;
return input.value.substr(start, end - start);
}
getRef(ref, target = 'all') {
if (!ref) return false;
return $.ajax({
method: 'POST',
method: 'GET',
dataType: 'json',
url: this.createBranchPath,
beforeSend: () => (this.isCreatingBranch = true),
url: this.refsPath + ref,
beforeSend: () => {
this.isGettingRef = true;
},
})
.always(() => {
this.isGettingRef = false;
})
.done((data) => {
this.branchCreated = true;
window.location.href = data.url;
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(() => new Flash('Failed to create a branch for this issue. Please try again.'));
.fail(() => {
this.unavailable();
this.disable();
new Flash('Failed to get ref.');
return false;
});
}
getTargetData(target) {
return {
input: this[`${target}Input`],
message: this[`${target}Message`],
};
}
hide() {
this.wrapperEl.classList.add('hide');
}
init() {
this.checkAbilityToCreateBranch();
}
initDroplab() {
this.droplab = new DropLab();
this.droplab.init(
this.dropdownToggle,
this.dropdownList,
[InputSetter],
this.getDroplabConfig(),
);
}
inputsAreValid() {
return this.branchIsValid && this.refIsValid;
}
isBusy() {
return this.isCreatingMergeRequest ||
this.mergeRequestCreated ||
this.isCreatingBranch ||
this.branchCreated ||
this.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(event) {
let xhr = null;
event.preventDefault();
if (this.isBusy()) {
return;
}
if (event.target.dataset.action === CREATE_MERGE_REQUEST) {
xhr = this.createMergeRequest();
} else if (event.target.dataset.action === CREATE_BRANCH) {
xhr = this.createBranch();
}
xhr.fail(() => {
this.isCreatingMergeRequest = false;
this.isCreatingBranch = false;
});
xhr.always(() => this.enable());
this.disable();
}
onClickSetFocusOnBranchNameInput() {
this.branchInput.focus();
}
// `TAB` autocompletes the source.
static processTab(event) {
if (event.keyCode !== 9 || this.isGettingRef) return;
const selectedText = CreateMergeRequestDropdown.getInputSelectedText(this.refInput);
// if nothing selected, we don't need to autocomplete anything. Do the default TAB action.
// If a user manually selected text, don't autocomplete anything. Do the default TAB action.
if (!selectedText || this.refInput.dataset.value === this.suggestedRef) return;
event.preventDefault();
window.getSelection().removeAllRanges();
}
removeMessage(target) {
const { input, message } = this.getTargetData(target);
const inputClasses = ['gl-field-error-outline', 'gl-field-success-outline'];
const messageClasses = ['gl-field-hint', 'gl-field-error-message', 'gl-field-success-message'];
inputClasses.forEach(cssClass => input.classList.remove(cssClass));
messageClasses.forEach(cssClass => message.classList.remove(cssClass));
message.style.display = 'none';
}
setUnavailableButtonState(isLoading = true) {
if (isLoading) {
this.unavailableButtonArrow.classList.add('fa-spin');
this.unavailableButtonArrow.classList.add('fa-spinner');
this.unavailableButtonArrow.classList.remove('fa-exclamation-triangle');
this.unavailableButtonText.textContent = __('Checking branch availability...');
} else {
this.unavailableButtonArrow.classList.remove('fa-spin');
this.unavailableButtonArrow.classList.remove('fa-spinner');
this.unavailableButtonArrow.classList.add('fa-exclamation-triangle');
this.unavailableButtonText.textContent = __('New branch unavailable');
}
}
showAvailableMessage(target) {
const { input, message } = this.getTargetData(target);
const text = target === 'branch' ? __('Branch name') : __('Source');
this.removeMessage(target);
input.classList.add('gl-field-success-outline');
message.classList.add('gl-field-success-message');
message.textContent = sprintf(__('%{text} is available'), { text });
message.style.display = 'inline-block';
}
showCheckingMessage(target) {
const { message } = this.getTargetData(target);
const text = target === 'branch' ? __('branch name') : __('source');
this.removeMessage(target);
message.classList.add('gl-field-hint');
message.textContent = sprintf(__('Checking %{text} availability…'), { text });
message.style.display = 'inline-block';
}
showNotAvailableMessage(target) {
const { input, message } = this.getTargetData(target);
const text = target === 'branch' ? __('Branch is already taken') : __('Source is not available');
this.removeMessage(target);
input.classList.add('gl-field-error-outline');
message.classList.add('gl-field-error-message');
message.textContent = text;
message.style.display = 'inline-block';
}
unavailable() {
this.availableButton.classList.add('hide');
this.unavailableButton.classList.remove('hide');
}
updateInputState(target, ref, result) {
// target - 'branch' or 'ref' - which the input field we are searching a ref for.
// ref - string - what a user typed.
// result - string - what has been found on backend.
const pathReplacement = `$1${ref}`;
// If a found branch equals exact the same text a user typed,
// that means a new branch cannot be created as it already exists.
if (ref === result) {
if (target === 'branch') {
this.branchIsValid = false;
this.showNotAvailableMessage('branch');
} else {
this.refIsValid = true;
this.refInput.dataset.value = ref;
this.showAvailableMessage('ref');
this.createBranchPath = this.createBranchPath.replace(this.regexps.ref.createBranchPath,
pathReplacement);
this.createMrPath = this.createMrPath.replace(this.regexps.ref.createMrPath,
pathReplacement);
}
} else if (target === 'branch') {
this.branchIsValid = true;
this.showAvailableMessage('branch');
this.createBranchPath = this.createBranchPath.replace(this.regexps.branch.createBranchPath,
pathReplacement);
this.createMrPath = this.createMrPath.replace(this.regexps.branch.createMrPath,
pathReplacement);
} else {
this.refIsValid = false;
this.refInput.dataset.value = ref;
this.disableCreateAction();
this.showNotAvailableMessage('ref');
// Show ref hint.
if (result) {
this.refInput.value = result;
this.refInput.setSelectionRange(ref.length, result.length);
}
}
if (this.inputsAreValid()) {
this.enable();
} else {
this.disableCreateAction();
}
}
}

View File

@ -383,6 +383,7 @@ import ProjectVariables from './project_variables';
projectImport();
break;
case 'projects:pipelines:new':
case 'projects:pipelines:create':
new NewBranchForm($('.js-new-pipeline-form'));
break;
case 'projects:pipelines:builds':
@ -521,6 +522,13 @@ import ProjectVariables from './project_variables';
case 'projects:settings:ci_cd:show':
// Initialize expandable settings panels
initSettingsPanels();
import(/* webpackChunkName: "ci-cd-settings" */ './projects/ci_cd_settings_bundle')
.then(ciCdSettings => ciCdSettings.default())
.catch((err) => {
Flash(s__('ProjectSettings|Problem setting up the CI/CD settings JavaScript'));
throw err;
});
case 'groups:settings:ci_cd:show':
new ProjectVariables();
break;

View File

@ -3,6 +3,7 @@ const DATA_DROPDOWN = 'data-dropdown';
const SELECTED_CLASS = 'droplab-item-selected';
const ACTIVE_CLASS = 'droplab-item-active';
const IGNORE_CLASS = 'droplab-item-ignore';
const IGNORE_HIDING_CLASS = 'droplab-item-ignore-hiding';
// Matches `{{anything}}` and `{{ everything }}`.
const TEMPLATE_REGEX = /\{\{(.+?)\}\}/g;
@ -13,4 +14,5 @@ export {
ACTIVE_CLASS,
TEMPLATE_REGEX,
IGNORE_CLASS,
IGNORE_HIDING_CLASS,
};

View File

@ -1,15 +1,18 @@
import utils from './utils';
import { SELECTED_CLASS, IGNORE_CLASS } from './constants';
import { SELECTED_CLASS, IGNORE_CLASS, IGNORE_HIDING_CLASS } from './constants';
class DropDown {
constructor(list) {
constructor(list, config = {}) {
this.currentIndex = 0;
this.hidden = true;
this.list = typeof list === 'string' ? document.querySelector(list) : list;
this.items = [];
this.eventWrapper = {};
if (config.addActiveClassToDropdownButton) {
this.dropdownToggle = this.list.parentNode.querySelector('.js-dropdown-toggle');
}
this.getItems();
this.initTemplateString();
this.addEvents();
@ -42,7 +45,7 @@ class DropDown {
this.addSelectedClass(selected);
e.preventDefault();
this.hide();
if (!e.target.classList.contains(IGNORE_HIDING_CLASS)) this.hide();
const listEvent = new CustomEvent('click.dl', {
detail: {
@ -67,7 +70,20 @@ class DropDown {
addEvents() {
this.eventWrapper.clickEvent = this.clickEvent.bind(this);
this.eventWrapper.closeDropdown = this.closeDropdown.bind(this);
this.list.addEventListener('click', this.eventWrapper.clickEvent);
this.list.addEventListener('keyup', this.eventWrapper.closeDropdown);
}
closeDropdown(event) {
// `ESC` key closes the dropdown.
if (event.keyCode === 27) {
event.preventDefault();
return this.toggle();
}
return true;
}
setData(data) {
@ -110,6 +126,8 @@ class DropDown {
this.list.style.display = 'block';
this.currentIndex = 0;
this.hidden = false;
if (this.dropdownToggle) this.dropdownToggle.classList.add('active');
}
hide() {
@ -117,6 +135,8 @@ class DropDown {
this.list.style.display = 'none';
this.currentIndex = 0;
this.hidden = true;
if (this.dropdownToggle) this.dropdownToggle.classList.remove('active');
}
toggle() {
@ -128,6 +148,7 @@ class DropDown {
destroy() {
this.hide();
this.list.removeEventListener('click', this.eventWrapper.clickEvent);
this.list.removeEventListener('keyup', this.eventWrapper.closeDropdown);
}
static setImagesSrc(template) {

View File

@ -3,7 +3,7 @@ import DropDown from './drop_down';
class Hook {
constructor(trigger, list, plugins, config) {
this.trigger = trigger;
this.list = new DropDown(list);
this.list = new DropDown(list, config);
this.type = 'Hook';
this.event = 'click';
this.plugins = plugins || [];

View File

@ -41,7 +41,7 @@ const createFlashEl = (message, type, isInContentWrapper = false) => `
`;
const removeFlashClickListener = (flashEl, fadeTransition) => {
flashEl.parentNode.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
flashEl.addEventListener('click', () => hideFlash(flashEl, fadeTransition));
};
/*

View File

@ -16,6 +16,10 @@ export default {
required: true,
type: String,
},
updateEndpoint: {
required: true,
type: String,
},
canUpdate: {
required: true,
type: Boolean,
@ -262,6 +266,8 @@ export default {
:description-text="state.descriptionText"
:updated-at="state.updatedAt"
:task-status="state.taskStatus"
:issuable-type="issuableType"
:update-url="updateEndpoint"
/>
<edited-component
v-if="hasUpdated"

View File

@ -22,6 +22,16 @@
required: false,
default: '',
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
updateUrl: {
type: String,
required: false,
default: null,
},
},
data() {
return {
@ -48,7 +58,7 @@
if (this.canUpdate) {
// eslint-disable-next-line no-new
new TaskList({
dataType: 'issue',
dataType: this.issuableType,
fieldName: 'description',
selector: '.detail-page-description',
});
@ -95,7 +105,9 @@
<textarea
class="hidden js-task-list-field"
v-if="descriptionText"
v-model="descriptionText">
v-model="descriptionText"
:data-update-url="updateUrl"
>
</textarea>
</div>
</template>

View File

@ -79,7 +79,7 @@
v-tooltip
v-if="showInlineEditButton && canUpdate"
type="button"
class="btn-blank btn-edit note-action-button"
class="btn btn-default btn-edit btn-svg"
v-html="pencilIcon"
title="Edit title and description"
data-placement="bottom"

View File

@ -190,7 +190,7 @@ export const insertText = (target, text) => {
target.selectionStart = target.selectionEnd = selectionStart + insertedText.length;
// Trigger autosave
$(target).trigger('input');
target.dispatchEvent(new Event('input'));
// Trigger autosize
const event = document.createEvent('Event');

View File

@ -44,7 +44,6 @@ import './commits';
import './compare';
import './compare_autocomplete';
import './confirm_danger_modal';
import './copy_to_clipboard';
import Flash, { removeFlashClickListener } from './flash';
import './gl_dropdown';
import './gl_field_error';
@ -301,6 +300,8 @@ $(function () {
const flashContainer = document.querySelector('.flash-container');
if (flashContainer && flashContainer.children.length) {
removeFlashClickListener(flashContainer.children[0]);
flashContainer.querySelectorAll('.flash-alert, .flash-notice, .flash-success').forEach((flashEl) => {
removeFlashClickListener(flashEl);
});
}
});

View File

@ -17,13 +17,14 @@ export default class Project {
$('a', $cloneOptions).on('click', (e) => {
const $this = $(e.currentTarget);
const url = $this.attr('href');
const activeText = $this.find('.dropdown-menu-inner-title').text();
e.preventDefault();
$('.is-active', $cloneOptions).not($this).removeClass('is-active');
$this.toggleClass('is-active');
$projectCloneField.val(url);
$cloneBtnText.text($this.text());
$cloneBtnText.text(activeText);
return $('.clone').text(url);
});

View File

@ -0,0 +1,19 @@
function updateAutoDevopsRadios(radioWrappers) {
radioWrappers.forEach((radioWrapper) => {
const radio = radioWrapper.querySelector('.js-auto-devops-enable-radio');
const runPipelineCheckboxWrapper = radioWrapper.querySelector('.js-run-auto-devops-pipeline-checkbox-wrapper');
const runPipelineCheckbox = radioWrapper.querySelector('.js-run-auto-devops-pipeline-checkbox');
if (runPipelineCheckbox) {
runPipelineCheckbox.checked = radio.checked;
runPipelineCheckboxWrapper.classList.toggle('hide', !radio.checked);
}
});
}
export default function initCiCdSettings() {
const radioWrappers = document.querySelectorAll('.js-auto-devops-enable-radio-wrapper');
radioWrappers.forEach(radioWrapper =>
radioWrapper.addEventListener('change', () => updateAutoDevopsRadios(radioWrappers)),
);
}

View File

@ -48,6 +48,27 @@ export default {
}
return this.projectName;
},
/**
* Smartly truncates project namespace by doing two things;
* 1. Only include Group names in path by removing project name
* 2. Only include first and last group names in the path
* when namespace has more than 2 groups present
*
* First part (removal of project name from namespace) can be
* done from backend but doing so involves migration of
* existing project namespaces which is not wise thing to do.
*/
truncatedNamespace() {
const namespaceArr = this.namespace.split(' / ');
namespaceArr.splice(-1, 1);
let namespace = namespaceArr.join(' / ');
if (namespaceArr.length > 2) {
namespace = `${namespaceArr[0]} / ... / ${namespaceArr.pop()}`;
}
return namespace;
},
},
};
</script>
@ -87,9 +108,7 @@ export default {
<div
class="project-namespace"
:title="namespace"
>
{{namespace}}
</div>
>{{truncatedNamespace}}</div>
</div>
</a>
</li>

View File

@ -0,0 +1,89 @@
<script>
import icon from '../../../vue_shared/components/icon.vue';
import listItem from './list_item.vue';
import listCollapsed from './list_collapsed.vue';
export default {
components: {
icon,
listItem,
listCollapsed,
},
props: {
title: {
type: String,
required: true,
},
fileList: {
type: Array,
required: true,
},
collapsed: {
type: Boolean,
required: true,
},
},
methods: {
toggleCollapsed() {
this.$emit('toggleCollapsed');
},
},
};
</script>
<template>
<div class="multi-file-commit-panel-section">
<header
class="multi-file-commit-panel-header"
:class="{
'is-collapsed': collapsed,
}"
>
<icon
name="list-bulleted"
:size="18"
css-classes="append-right-default"
/>
<template v-if="!collapsed">
{{ title }}
<button
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn"
@click="toggleCollapsed"
>
<i
aria-hidden="true"
class="fa fa-angle-double-right"
>
</i>
</button>
</template>
</header>
<div class="multi-file-commit-list">
<list-collapsed
v-if="collapsed"
/>
<template v-else>
<ul
v-if="fileList.length"
class="list-unstyled append-bottom-0"
>
<li
v-for="file in fileList"
:key="file.key"
>
<list-item
:file="file"
/>
</li>
</ul>
<div
v-else
class="help-block prepend-top-0"
>
No changes
</div>
</template>
</div>
</div>
</template>

View File

@ -0,0 +1,35 @@
<script>
import { mapGetters } from 'vuex';
import icon from '../../../vue_shared/components/icon.vue';
export default {
components: {
icon,
},
computed: {
...mapGetters([
'addedFiles',
'modifiedFiles',
]),
},
};
</script>
<template>
<div
class="multi-file-commit-list-collapsed text-center"
>
<icon
name="file-addition"
:size="18"
css-classes="multi-file-addition append-bottom-10"
/>
{{ addedFiles.length }}
<icon
name="file-modified"
:size="18"
css-classes="multi-file-modified prepend-top-10 append-bottom-10"
/>
{{ modifiedFiles.length }}
</div>
</template>

View File

@ -0,0 +1,36 @@
<script>
import icon from '../../../vue_shared/components/icon.vue';
export default {
components: {
icon,
},
props: {
file: {
type: Object,
required: true,
},
},
computed: {
iconName() {
return this.file.tempFile ? 'file-addition' : 'file-modified';
},
iconClass() {
return `multi-file-${this.file.tempFile ? 'addition' : 'modified'} append-right-8`;
},
},
};
</script>
<template>
<div class="multi-file-commit-list-item">
<icon
:name="iconName"
:size="16"
:css-classes="iconClass"
/>
<span class="multi-file-commit-list-path">
{{ file.path }}
</span>
</div>
</template>

View File

@ -40,20 +40,24 @@ export default {
</script>
<template>
<div class="repository-view">
<div class="tree-content-holder" :class="{'tree-content-holder-mini' : isCollapsed}">
<repo-sidebar/>
<div
v-if="isCollapsed"
class="panel-right"
>
<repo-tabs/>
<component
:is="currentBlobView"
/>
<repo-file-buttons/>
</div>
<div
class="multi-file"
:class="{
'is-collapsed': isCollapsed
}"
>
<repo-sidebar/>
<div
v-if="isCollapsed"
class="multi-file-edit-pane"
>
<repo-tabs />
<component
class="multi-file-edit-pane-content"
:is="currentBlobView"
/>
<repo-file-buttons />
</div>
<repo-commit-section v-if="changedFiles.length" />
<repo-commit-section />
</div>
</template>

View File

@ -1,11 +1,18 @@
<script>
import { mapGetters, mapState, mapActions } from 'vuex';
import tooltip from '../../vue_shared/directives/tooltip';
import icon from '../../vue_shared/components/icon.vue';
import PopupDialog from '../../vue_shared/components/popup_dialog.vue';
import { n__ } from '../../locale';
import commitFilesList from './commit_sidebar/list.vue';
export default {
components: {
PopupDialog,
icon,
commitFilesList,
},
directives: {
tooltip,
},
data() {
return {
@ -13,6 +20,7 @@ export default {
submitCommitsLoading: false,
startNewMR: false,
commitMessage: '',
collapsed: true,
};
},
computed: {
@ -23,10 +31,10 @@ export default {
'changedFiles',
]),
commitButtonDisabled() {
return !this.commitMessage || this.submitCommitsLoading;
return this.commitMessage === '' || this.submitCommitsLoading || !this.changedFiles.length;
},
commitButtonText() {
return n__('Commit %d file', 'Commit %d files', this.changedFiles.length);
commitMessageCount() {
return this.commitMessage.length;
},
},
methods: {
@ -77,12 +85,20 @@ export default {
this.submitCommitsLoading = false;
});
},
toggleCollapsed() {
this.collapsed = !this.collapsed;
},
},
};
</script>
<template>
<div id="commit-area">
<div
class="multi-file-commit-panel"
:class="{
'is-collapsed': collapsed,
}"
>
<popup-dialog
v-if="showNewBranchDialog"
:primary-button-label="__('Create new branch')"
@ -92,78 +108,71 @@ export default {
@toggle="showNewBranchDialog = false"
@submit="makeCommit(true)"
/>
<button
v-if="collapsed"
type="button"
class="btn btn-transparent multi-file-commit-panel-collapse-btn is-collapsed prepend-top-10 append-bottom-10"
@click="toggleCollapsed"
>
<i
aria-hidden="true"
class="fa fa-angle-double-left"
>
</i>
</button>
<commit-files-list
title="Staged"
:file-list="changedFiles"
:collapsed="collapsed"
@toggleCollapsed="toggleCollapsed"
/>
<form
class="form-horizontal"
@submit.prevent="tryCommit()">
<fieldset>
<div class="form-group">
<label class="col-md-4 control-label staged-files">
Staged files ({{changedFiles.length}})
</label>
<div class="col-md-6">
<ul class="list-unstyled changed-files">
<li
v-for="(file, index) in changedFiles"
:key="index">
<span class="help-block">
{{ file.path }}
</span>
</li>
</ul>
</div>
class="form-horizontal multi-file-commit-form"
@submit.prevent="tryCommit"
v-if="!collapsed"
>
<div class="multi-file-commit-fieldset">
<textarea
class="form-control multi-file-commit-message"
name="commit-message"
v-model="commitMessage"
placeholder="Commit message"
>
</textarea>
</div>
<div class="multi-file-commit-fieldset">
<label
v-tooltip
title="Create a new merge request with these changes"
data-container="body"
data-placement="top"
>
<input
type="checkbox"
v-model="startNewMR"
/>
Merge Request
</label>
<button
type="submit"
:disabled="commitButtonDisabled"
class="btn btn-default btn-sm append-right-10 prepend-left-10"
>
<i
v-if="submitCommitsLoading"
class="js-commit-loading-icon fa fa-spinner fa-spin"
aria-hidden="true"
aria-label="loading"
>
</i>
Commit
</button>
<div
class="multi-file-commit-message-count"
>
{{ commitMessageCount }}
</div>
<div class="form-group">
<label
class="col-md-4 control-label"
for="commit-message">
Commit message
</label>
<div class="col-md-6">
<textarea
id="commit-message"
class="form-control"
name="commit-message"
v-model="commitMessage">
</textarea>
</div>
</div>
<div class="form-group target-branch">
<label
class="col-md-4 control-label"
for="target-branch">
Target branch
</label>
<div class="col-md-6">
<span class="help-block">
{{currentBranch}}
</span>
</div>
</div>
<div class="col-md-offset-4 col-md-6">
<button
type="submit"
:disabled="commitButtonDisabled"
class="btn btn-success">
<i
v-if="submitCommitsLoading"
class="js-commit-loading-icon fa fa-spinner fa-spin"
aria-hidden="true"
aria-label="loading">
</i>
<span class="commit-summary">
{{ commitButtonText }}
</span>
</button>
</div>
<div class="col-md-offset-4 col-md-6">
<div class="checkbox">
<label>
<input type="checkbox" v-model="startNewMR">
<span>Start a <strong>new merge request</strong> with these changes</span>
</label>
</div>
</div>
</fieldset>
</div>
</form>
</div>
</template>

View File

@ -55,7 +55,7 @@
class="file"
@click.prevent="clickedTreeRow(file)">
<td
class="multi-file-table-col-name"
class="multi-file-table-name"
:colspan="submoduleColSpan"
>
<i
@ -85,12 +85,11 @@
</td>
<template v-if="!isCollapsed && !isSubmodule">
<td class="hidden-sm hidden-xs">
<td class="multi-file-table-col-commit-message hidden-sm hidden-xs">
<a
v-if="file.lastCommit.message"
@click.stop
:href="file.lastCommit.url"
class="commit-message"
>
{{ file.lastCommit.message }}
</a>

View File

@ -22,12 +22,12 @@ export default {
<template>
<div
v-if="showButtons"
class="repo-file-buttons"
class="multi-file-editor-btn-group"
>
<a
:href="activeFile.rawPath"
target="_blank"
class="btn btn-default raw"
class="btn btn-default btn-sm raw"
rel="noopener noreferrer">
{{ rawDownloadButtonLabel }}
</a>
@ -38,17 +38,17 @@ export default {
aria-label="File actions">
<a
:href="activeFile.blamePath"
class="btn btn-default blame">
class="btn btn-default btn-sm blame">
Blame
</a>
<a
:href="activeFile.commitsPath"
class="btn btn-default history">
class="btn btn-default btn-sm history">
History
</a>
<a
:href="activeFile.permalink"
class="btn btn-default permalink">
class="btn btn-default btn-sm permalink">
Permalink
</a>
</div>

View File

@ -32,10 +32,12 @@ export default {
</script>
<template>
<div class="blob-viewer-container">
<div>
<div
v-if="!activeFile.renderError"
v-html="activeFile.html">
v-html="activeFile.html"
class="multi-file-preview-holder"
>
</div>
<div
v-else-if="activeFile.tempFile"

View File

@ -44,20 +44,16 @@ export default {
</script>
<template>
<div id="sidebar" :class="{'sidebar-mini' : isCollapsed}">
<div class="ide-file-list">
<table class="table">
<thead>
<tr>
<th
v-if="isCollapsed"
class="repo-file-options title"
>
<strong class="clgray">
{{ projectName }}
</strong>
</th>
<template v-else>
<th class="name multi-file-table-col-name">
<th class="name multi-file-table-name">
Name
</th>
<th class="hidden-sm hidden-xs last-commit">
@ -79,7 +75,7 @@ export default {
:key="n"
/>
<repo-file
v-for="(file, index) in treeList"
v-for="file in treeList"
:key="file.key"
:file="file"
/>

View File

@ -36,27 +36,32 @@ export default {
<template>
<li
:class="{ active : tab.active }"
@click="setFileActive(tab)"
>
<button
type="button"
class="close-btn"
class="multi-file-tab-close"
@click.stop.prevent="closeFile({ file: tab })"
:aria-label="closeLabel">
:aria-label="closeLabel"
:class="{
'modified': tab.changed,
}"
:disabled="tab.changed"
>
<i
class="fa"
:class="changedClass"
aria-hidden="true">
aria-hidden="true"
>
</i>
</button>
<a
href="#"
class="repo-tab"
<div
class="multi-file-tab"
:class="{active : tab.active }"
:title="tab.url"
@click.prevent.stop="setFileActive(tab)">
{{tab.name}}
</a>
>
{{ tab.name }}
</div>
</li>
</template>

View File

@ -16,14 +16,12 @@
<template>
<ul
id="tabs"
class="list-unstyled"
class="multi-file-tabs list-unstyled append-bottom-0"
>
<repo-tab
v-for="tab in openFiles"
:key="tab.id"
:tab="tab"
/>
<li class="tabs-divider" />
</ul>
</template>

View File

@ -34,3 +34,7 @@ export const canEditFile = (state) => {
openedFiles.length &&
(currentActiveFile && !currentActiveFile.renderError && !currentActiveFile.binary);
};
export const addedFiles = state => changedFiles(state).filter(f => f.tempFile);
export const modifiedFiles = state => changedFiles(state).filter(f => !f.tempFile);

View File

@ -12,6 +12,9 @@
/>
*/
// only allow classes in images.scss e.g. s12
const validSizes = [8, 12, 16, 18, 24, 32, 48, 72];
export default {
props: {
name: {
@ -22,7 +25,10 @@
size: {
type: Number,
required: false,
default: 0,
default: 16,
validator(value) {
return validSizes.includes(value);
},
},
cssClasses: {
@ -42,10 +48,11 @@
},
};
</script>
<template>
<svg
:class="[iconSizeClass, cssClasses]">
<use
<use
v-bind="{'xlink:href':spriteHref}"/>
</svg>
</template>

View File

@ -125,7 +125,7 @@
@include transition(border-color);
}
.note-action-button .link-highlight,
.note-action-button,
.toolbar-btn,
.dropdown-toggle-caret {
@include transition(color);

View File

@ -88,17 +88,6 @@
border-color: $border-dark;
color: $color;
}
svg {
path {
fill: $color;
}
use {
stroke: $color;
}
}
}
@mixin btn-green {
@ -142,6 +131,13 @@
}
}
@mixin btn-svg {
height: $gl-padding;
width: $gl-padding;
top: 0;
vertical-align: text-top;
}
.btn {
@include btn-default;
@include btn-white;
@ -440,3 +436,7 @@
text-decoration: none;
}
}
.btn-svg svg {
@include btn-svg;
}

View File

@ -2,14 +2,43 @@
.cgray { color: $common-gray; }
.clgray { color: $common-gray-light; }
.cred { color: $common-red; }
svg.cred { fill: $common-red; }
.cgreen { color: $common-green; }
svg.cgreen { fill: $common-green; }
.cdark { color: $common-gray-dark; }
.text-plain,
.text-plain:hover {
color: $gl-text-color;
}
.text-secondary {
color: $gl-text-color-secondary;
}
.text-primary,
.text-primary:hover {
color: $brand-primary;
}
.text-success,
.text-success:hover {
color: $brand-success;
}
.text-danger,
.text-danger:hover {
color: $brand-danger;
}
.text-warning,
.text-warning:hover {
color: $brand-warning;
}
.text-info,
.text-info:hover {
color: $brand-info;
}
.underlined-link { text-decoration: underline; }
.hint { font-style: italic; color: $hint-color; }
.light { color: $common-gray; }

View File

@ -1002,6 +1002,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
max-width: 250px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&:hover {

View File

@ -34,8 +34,15 @@
}
}
.flash-success {
@extend .alert;
@extend .alert-success;
margin: 0;
}
.flash-notice,
.flash-alert {
.flash-alert,
.flash-success {
border-radius: $border-radius-default;
.container-fluid,
@ -48,7 +55,8 @@
margin-bottom: 0;
.flash-notice,
.flash-alert {
.flash-alert,
.flash-success {
border-radius: 0;
}
}

View File

@ -1,35 +1,49 @@
.ci-status-icon-success,
.ci-status-icon-passed {
color: $green-500;
svg {
fill: $green-500;
}
}
.ci-status-icon-failed {
color: $gl-danger;
svg {
fill: $gl-danger;
}
}
.ci-status-icon-pending,
.ci-status-icon-failed_with_warnings,
.ci-status-icon-success_with_warnings {
color: $orange-500;
svg {
fill: $orange-500;
}
}
.ci-status-icon-running {
color: $blue-400;
svg {
fill: $blue-400;
}
}
.ci-status-icon-canceled,
.ci-status-icon-disabled,
.ci-status-icon-not-found {
color: $gl-text-color;
svg {
fill: $gl-text-color;
}
}
.ci-status-icon-created,
.ci-status-icon-skipped {
color: $gray-darkest;
svg {
fill: $gray-darkest;
}
}
.ci-status-icon-manual {
color: $gl-text-color;
svg {
fill: $gl-text-color;
}
}
.icon-link {

View File

@ -195,33 +195,6 @@ summary {
}
}
// Typography =================================================================
.text-primary,
.text-primary:hover {
color: $brand-primary;
}
.text-success,
.text-success:hover {
color: $brand-success;
}
.text-danger,
.text-danger:hover {
color: $brand-danger;
}
.text-warning,
.text-warning:hover {
color: $brand-warning;
}
.text-info,
.text-info:hover {
color: $brand-info;
}
// Prevent datetimes on tooltips to break into two lines
.local-timeago {
white-space: nowrap;

View File

@ -70,14 +70,13 @@
.title {
padding: 0;
margin-bottom: 16px;
margin-bottom: $gl-padding;
border-bottom: 0;
}
.btn-edit {
margin-left: auto;
// Set height to match title height
height: 2em;
height: $gl-padding * 2;
}
// Border around images in issue and MR descriptions.

View File

@ -203,7 +203,24 @@ ul.related-merge-requests > li {
}
.create-mr-dropdown-wrap {
@include new-style-dropdown;
.branch-message,
.ref-message {
display: none;
}
.ref::selection {
color: $placeholder-text-color;
}
.dropdown {
.dropdown-menu-toggle {
min-width: 285px;
}
.dropdown-select {
width: 285px;
}
}
.btn-group:not(.hide) {
display: flex;
@ -214,15 +231,16 @@ ul.related-merge-requests > li {
flex-shrink: 0;
}
.dropdown-menu {
.create-merge-request-dropdown-menu {
width: 300px;
opacity: 1;
visibility: visible;
transform: translateY(0);
display: none;
margin-top: 4px;
}
.dropdown-toggle {
.create-merge-request-dropdown-toggle {
.fa-caret-down {
pointer-events: none;
color: inherit;
@ -230,18 +248,50 @@ ul.related-merge-requests > li {
}
}
.droplab-item-ignore {
pointer-events: auto;
}
.create-item {
cursor: pointer;
margin: 0 1px;
&:hover,
&:focus {
background-color: $dropdown-item-hover-bg;
color: $gl-text-color;
}
}
li.divider {
margin: 8px 10px;
}
li:not(.divider) {
padding: 8px 9px;
&:last-child {
padding-bottom: 8px;
}
&.droplab-item-selected {
.icon-container {
i {
visibility: visible;
}
}
.description {
display: block;
}
}
&.droplab-item-ignore {
padding-top: 8px;
}
.icon-container {
float: left;
padding-left: 6px;
i {
visibility: hidden;
@ -249,13 +299,12 @@ ul.related-merge-requests > li {
}
.description {
padding-left: 30px;
font-size: 13px;
padding-left: 22px;
}
strong {
display: block;
font-weight: $gl-font-weight-bold;
}
input,
span {
margin: 4px 0 0;
}
}
}

View File

@ -543,10 +543,7 @@ ul.notes {
}
svg {
height: 16px;
width: 16px;
top: 0;
vertical-align: text-top;
@include btn-svg;
}
.award-control-icon-positive,
@ -780,12 +777,6 @@ ul.notes {
}
}
svg {
fill: currentColor;
height: 16px;
width: 16px;
}
.loading {
margin: 0;
height: auto;

View File

@ -291,14 +291,7 @@
}
svg {
path {
fill: $layout-link-gray;
}
use {
stroke: $layout-link-gray;
}
fill: $layout-link-gray;
}
.fa-caret-down {
@ -402,6 +395,18 @@
}
}
}
.clone-dropdown-btn {
background-color: $white-light;
}
.clone-options-dropdown {
min-width: 240px;
.dropdown-menu-inner-content {
min-width: 320px;
}
}
}
.project-repo-buttons {
@ -886,10 +891,6 @@ pre.light-well {
font-size: $gl-font-size;
}
a {
color: $gl-text-color;
}
.avatar-container,
.controls {
flex: 0 0 auto;

View File

@ -35,275 +35,247 @@
}
}
.repository-view {
border: 1px solid $border-color;
border-radius: $border-radius-default;
color: $almost-black;
.multi-file {
display: flex;
height: calc(100vh - 145px);
border-top: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
.code.white pre .hll {
background-color: $well-light-border !important;
}
.tree-content-holder {
display: -webkit-flex;
display: flex;
min-height: 300px;
}
.tree-content-holder-mini {
height: 100vh;
}
.panel-right {
display: -webkit-flex;
display: flex;
-webkit-flex-direction: column;
flex-direction: column;
width: 80%;
height: 100%;
.monaco-editor.vs {
.current-line {
border: 0;
background: $well-light-border;
}
.line-numbers {
cursor: pointer;
min-width: initial;
&:hover {
text-decoration: underline;
}
}
}
.blob-no-preview {
.vertical-center {
justify-content: center;
width: 100%;
}
}
&.blob-editor-container {
overflow: hidden;
}
.blob-viewer-container {
-webkit-flex: 1;
flex: 1;
overflow: auto;
> div,
.file-content:not(.wiki) {
display: flex;
}
> div,
.file-content,
.blob-viewer,
.line-number,
.blob-content,
.code {
min-height: 100%;
width: 100%;
}
.line-numbers {
min-width: 44px;
}
.blob-content {
flex: 1;
overflow-x: auto;
}
}
#tabs {
position: relative;
flex-shrink: 0;
display: flex;
width: 100%;
padding-left: 0;
margin-bottom: 0;
white-space: nowrap;
overflow-y: hidden;
overflow-x: auto;
li {
position: relative;
background: $gray-normal;
padding: #{$gl-padding / 2} $gl-padding;
border-right: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
cursor: pointer;
&.active {
background: $white-light;
border-bottom: 0;
}
a {
@include str-truncated(100px);
color: $gl-text-color;
vertical-align: middle;
text-decoration: none;
margin-right: 12px;
&:focus {
outline: none;
}
}
.close-btn {
position: absolute;
right: 8px;
top: 50%;
padding: 0;
background: none;
border: 0;
font-size: $gl-font-size;
transform: translateY(-50%);
}
.close-icon:hover {
color: $hint-color;
}
.close-icon,
.unsaved-icon {
color: $gray-darkest;
}
.unsaved-icon {
color: $brand-success;
}
&.tabs-divider {
width: 100%;
background-color: $white-light;
border-right: 0;
border-top-right-radius: 2px;
}
}
}
.repo-file-buttons {
background-color: $white-light;
padding: 5px 10px;
border-top: 1px solid $white-normal;
}
#binary-viewer {
height: 80vh;
overflow: auto;
margin: 0;
.blob-viewer {
padding-top: 20px;
padding-left: 20px;
}
.binary-unknown {
text-align: center;
padding-top: 100px;
background: $gray-light;
height: 100%;
font-size: 17px;
span {
display: block;
}
}
}
}
#commit-area {
background: $gray-light;
padding: 20px;
.help-block {
padding-top: 7px;
margin-top: 0;
}
}
#view-toggler {
height: 41px;
position: relative;
display: block;
border-bottom: 1px solid $white-normal;
background: $white-light;
margin-top: -5px;
}
#binary-viewer {
img {
max-width: 100%;
}
}
#sidebar {
flex: 1;
height: 100%;
&.sidebar-mini {
width: 20%;
border-right: 1px solid $white-normal;
overflow: auto;
}
.table {
margin-bottom: 0;
}
tr {
.repo-file-options {
padding: 2px 16px;
width: 100%;
}
.title {
font-size: 10px;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: middle;
}
.file-icon {
margin-right: 5px;
}
td {
white-space: nowrap;
}
}
.file {
cursor: pointer;
}
a {
@include str-truncated(250px);
color: $almost-black;
&.is-collapsed {
.ide-file-list {
max-width: 250px;
}
}
}
.render-error {
min-height: calc(100vh - 62px);
.ide-file-list {
flex: 1;
overflow: scroll;
p {
width: 100%;
.file {
cursor: pointer;
}
a {
color: $gl-text-color;
}
th {
position: sticky;
top: 0;
}
}
.multi-file-table-col-name {
.multi-file-table-name,
.multi-file-table-col-commit-message {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 0;
}
.multi-file-table-name {
width: 350px;
}
.multi-file-table-col-commit-message {
width: 50%;
}
.multi-file-edit-pane {
display: flex;
flex-direction: column;
flex: 1;
border-left: 1px solid $white-dark;
overflow: hidden;
}
.multi-file-tabs {
display: flex;
overflow: scroll;
background-color: $white-normal;
box-shadow: inset 0 -1px $white-dark;
> li {
position: relative;
}
}
.multi-file-tab {
@include str-truncated(150px);
padding: ($gl-padding / 2) ($gl-padding + 12) ($gl-padding / 2) $gl-padding;
background-color: $gray-normal;
border-right: 1px solid $white-dark;
border-bottom: 1px solid $white-dark;
cursor: pointer;
&.active {
background-color: $white-light;
border-bottom-color: $white-light;
}
}
.multi-file-tab-close {
position: absolute;
right: 8px;
top: 50%;
padding: 0;
background: none;
border: 0;
font-size: $gl-font-size;
color: $gray-darkest;
transform: translateY(-50%);
&:not(.modified):hover,
&:not(.modified):focus {
color: $hint-color;
}
&.modified {
color: $indigo-700;
}
}
.multi-file-edit-pane-content {
flex: 1;
height: 0;
}
.multi-file-editor-btn-group {
padding: $grid-size;
border-top: 1px solid $white-dark;
}
// Not great, but this is to deal with our current output
.multi-file-preview-holder {
height: 100%;
overflow: scroll;
.blob-viewer {
height: 100%;
}
.file-content.code {
display: flex;
i {
margin-left: -10px;
}
}
.line-numbers {
min-width: 50px;
}
.file-content,
.line-numbers,
.blob-content,
.code {
min-height: 100%;
}
}
.multi-file-commit-panel {
display: flex;
flex-direction: column;
height: 100%;
width: 290px;
padding: $gl-padding;
background-color: $gray-light;
border-left: 1px solid $white-dark;
&.is-collapsed {
width: 60px;
padding: 0;
}
}
.multi-file-commit-panel-section {
display: flex;
flex-direction: column;
flex: 1;
}
.multi-file-commit-panel-header {
display: flex;
align-items: center;
padding: 0 0 12px;
margin-bottom: 12px;
border-bottom: 1px solid $white-dark;
&.is-collapsed {
border-bottom: 1px solid $white-dark;
svg {
margin-left: auto;
margin-right: auto;
}
}
}
.multi-file-commit-panel-collapse-btn {
padding-top: 0;
padding-bottom: 0;
margin-left: auto;
font-size: 20px;
&.is-collapsed {
margin-right: auto;
}
}
.multi-file-commit-list {
flex: 1;
overflow: scroll;
}
.multi-file-commit-list-item {
display: flex;
align-items: center;
}
.multi-file-addition {
fill: $green-500;
}
.multi-file-modified {
fill: $orange-500;
}
.multi-file-commit-list-collapsed {
display: flex;
flex-direction: column;
> svg {
margin-left: auto;
margin-right: auto;
}
}
.multi-file-commit-list-path {
@include str-truncated(100%);
}
.multi-file-commit-form {
padding-top: 12px;
border-top: 1px solid $white-dark;
}
.multi-file-commit-fieldset {
display: flex;
align-items: center;
padding-bottom: 12px;
.btn {
flex: 1;
}
}
.multi-file-commit-message.form-control {
height: 80px;
resize: none;
}
.dirty-diff {
// !important need to override monaco inline style
width: 4px !important;

View File

@ -54,7 +54,7 @@ module IssuableActions
end
def destroy
Issuable::DestroyService.new(project, current_user).execute(issuable)
Issuable::DestroyService.new(issuable.project, current_user).execute(issuable)
TodoService.new.destroy_issuable(issuable, current_user)
name = issuable.human_class_name

View File

@ -158,7 +158,8 @@ class Projects::IssuesController < Projects::ApplicationController
end
def create_merge_request
result = ::MergeRequests::CreateFromIssueService.new(project, current_user, issue_iid: issue.iid).execute
create_params = params.slice(:branch_name, :ref).merge(issue_iid: issue.iid)
result = ::MergeRequests::CreateFromIssueService.new(project, current_user, create_params).execute
if result[:status] == :success
render json: MergeRequestCreateSerializer.new.represent(result[:merge_request])

View File

@ -65,7 +65,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
if params[:ref].present?
@ref = params[:ref]
@commit = @repository.commit("refs/heads/#{@ref}")
@commit = @repository.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
end
render layout: false
@ -76,7 +76,7 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
if params[:ref].present?
@ref = params[:ref]
@commit = @target_project.commit("refs/heads/#{@ref}")
@commit = @target_project.commit(Gitlab::Git::BRANCH_REF_PREFIX + @ref)
end
render layout: false

View File

@ -27,7 +27,7 @@ class Projects::MergeRequests::DiffsController < Projects::MergeRequests::Applic
@merge_request.merge_request_diff
end
@merge_request_diffs = @merge_request.merge_request_diffs.viewable.select_without_diff.order_id_desc
@merge_request_diffs = @merge_request.merge_request_diffs.viewable.order_id_desc
@comparable_diffs = @merge_request_diffs.select { |diff| diff.id < @merge_request_diff.id }
if params[:start_sha].present?

View File

@ -6,11 +6,19 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
end
def update
if @project.update(update_params)
flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
redirect_to project_settings_ci_cd_path(@project)
else
render 'show'
Projects::UpdateService.new(project, current_user, update_params).tap do |service|
if service.execute
flash[:notice] = "Pipelines settings for '#{@project.name}' were successfully updated."
if service.run_auto_devops_pipeline?
CreatePipelineWorker.perform_async(project.id, current_user.id, project.default_branch, :web, ignore_skip_ci: true, save_on_errors: false)
flash[:success] = "A new Auto DevOps pipeline has been created, go to <a href=\"#{project_pipelines_path(@project)}\">Pipelines page</a> for details".html_safe
end
redirect_to project_settings_ci_cd_path(@project)
else
render 'show'
end
end
end
@ -21,6 +29,7 @@ class Projects::PipelinesSettingsController < Projects::ApplicationController
:runners_token, :builds_enabled, :build_allow_git_fetch,
:build_timeout_in_minutes, :build_coverage_regex, :public_builds,
:auto_cancel_pending_pipelines, :ci_config_path,
:run_auto_devops_pipeline_implicit, :run_auto_devops_pipeline_explicit,
auto_devops_attributes: [:id, :domain, :enabled]
)
end

View File

@ -104,8 +104,7 @@ class NotesFinder
query = @params[:search]
return notes unless query
pattern = "%#{query}%"
notes.where(Note.arel_table[:note].matches(pattern))
notes.search(query)
end
# Notes changed since last fetch

View File

@ -0,0 +1,22 @@
class RunnerJobsFinder
attr_reader :runner, :params
def initialize(runner, params = {})
@runner = runner
@params = params
end
def execute
items = @runner.builds
items = by_status(items)
items
end
private
def by_status(items)
return items unless HasStatus::AVAILABLE_STATUSES.include?(params[:status])
items.where(status: params[:status])
end
end

View File

@ -30,9 +30,9 @@ module ApplicationSettingsHelper
def enabled_project_button(project, protocol)
case protocol
when 'ssh'
ssh_clone_button(project, 'bottom', append_link: false)
ssh_clone_button(project, append_link: false)
else
http_clone_button(project, 'bottom', append_link: false)
http_clone_button(project, append_link: false)
end
end
@ -177,6 +177,9 @@ module ApplicationSettingsHelper
:ed25519_key_restriction,
:email_author_in_body,
:enabled_git_access_protocol,
:gitaly_timeout_default,
:gitaly_timeout_medium,
:gitaly_timeout_fast,
:gravatar_enabled,
:hashed_storage_enabled,
:help_page_hide_commercial_content,

View File

@ -8,6 +8,22 @@ module AutoDevopsHelper
!project.ci_service
end
def show_run_auto_devops_pipeline_checkbox_for_instance_setting?(project)
return false if project.repository.gitlab_ci_yml
if project&.auto_devops&.enabled.present?
!project.auto_devops.enabled && current_application_settings.auto_devops_enabled?
else
current_application_settings.auto_devops_enabled?
end
end
def show_run_auto_devops_pipeline_checkbox_for_explicit_setting?(project)
return false if project.repository.gitlab_ci_yml
!project.auto_devops_enabled?
end
def auto_devops_warning_message(project)
missing_domain = !project.auto_devops&.has_domain?
missing_service = !project.kubernetes_service&.active?

View File

@ -56,42 +56,36 @@ module ButtonHelper
end
end
def http_clone_button(project, placement = 'right', append_link: true)
klass = 'http-selector'
klass << ' has-tooltip' if current_user.try(:require_extra_setup_for_git_auth?)
def http_clone_button(project, append_link: true)
protocol = gitlab_config.protocol.upcase
dropdown_description = http_dropdown_description(protocol)
append_url = project.http_url_to_repo if append_link
tooltip_title =
if current_user.try(:require_password_creation_for_git?)
_("Set a password on your account to pull or push via %{protocol}.") % { protocol: protocol }
else
_("Create a personal access token on your account to pull or push via %{protocol}.") % { protocol: protocol }
end
content_tag (append_link ? :a : :span), protocol,
class: klass,
href: (project.http_url_to_repo if append_link),
data: {
html: true,
placement: placement,
container: 'body',
title: tooltip_title
}
dropdown_item_with_description(protocol, dropdown_description, href: append_url)
end
def ssh_clone_button(project, placement = 'right', append_link: true)
klass = 'ssh-selector'
klass << ' has-tooltip' if current_user.try(:require_ssh_key?)
def http_dropdown_description(protocol)
if current_user.try(:require_password_creation_for_git?)
_("Set a password on your account to pull or push via %{protocol}.") % { protocol: protocol }
else
_("Create a personal access token on your account to pull or push via %{protocol}.") % { protocol: protocol }
end
end
content_tag (append_link ? :a : :span), 'SSH',
class: klass,
href: (project.ssh_url_to_repo if append_link),
data: {
html: true,
placement: placement,
container: 'body',
title: _('Add an SSH key to your profile to pull or push via SSH.')
}
def ssh_clone_button(project, append_link: true)
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?)
append_url = project.ssh_url_to_repo if append_link
dropdown_item_with_description('SSH', dropdown_description, href: append_url)
end
def dropdown_item_with_description(title, description, href: nil)
button_content = content_tag(:strong, title, class: 'dropdown-menu-inner-title')
button_content << content_tag(:span, description, class: 'dropdown-menu-inner-content') if description
content_tag (href ? :a : :span),
button_content,
class: "#{title.downcase}-selector",
href: (href if href)
end
end

View File

@ -212,6 +212,7 @@ module IssuablesHelper
def issuable_initial_data(issuable)
data = {
endpoint: issuable_path(issuable),
updateEndpoint: "#{issuable_path(issuable)}.json",
canUpdate: can?(current_user, :"update_#{issuable.to_ability_name}", issuable),
canDestroy: can?(current_user, :"destroy_#{issuable.to_ability_name}", issuable),
issuableRef: issuable.to_reference,

View File

@ -172,6 +172,27 @@ class ApplicationSetting < ActiveRecord::Base
end
end
validates :gitaly_timeout_default,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :gitaly_timeout_medium,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :gitaly_timeout_medium,
numericality: { less_than_or_equal_to: :gitaly_timeout_default },
if: :gitaly_timeout_default
validates :gitaly_timeout_medium,
numericality: { greater_than_or_equal_to: :gitaly_timeout_fast },
if: :gitaly_timeout_fast
validates :gitaly_timeout_fast,
presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :gitaly_timeout_fast,
numericality: { less_than_or_equal_to: :gitaly_timeout_default },
if: :gitaly_timeout_default
SUPPORTED_KEY_TYPES.each do |type|
validates :"#{type}_key_restriction", presence: true, key_restriction: { type: type }
end
@ -308,7 +329,10 @@ class ApplicationSetting < ActiveRecord::Base
two_factor_grace_period: 48,
user_default_external: false,
polling_interval_multiplier: 1,
usage_ping_enabled: Settings.gitlab['usage_ping_enabled']
usage_ping_enabled: Settings.gitlab['usage_ping_enabled'],
gitaly_timeout_fast: 10,
gitaly_timeout_medium: 30,
gitaly_timeout_default: 55
}
end

View File

@ -1,6 +1,7 @@
module Ci
class Runner < ActiveRecord::Base
extend Gitlab::Ci::Model
include Gitlab::SQL::Pattern
RUNNER_QUEUE_EXPIRY_TIME = 60.minutes
ONLINE_CONTACT_TIMEOUT = 1.hour
@ -59,10 +60,7 @@ module Ci
#
# Returns an ActiveRecord::Relation.
def self.search(query)
t = arel_table
pattern = "%#{query}%"
where(t[:token].matches(pattern).or(t[:description].matches(pattern)))
fuzzy_search(query, [:token, :description])
end
def self.contact_time_deadline

View File

@ -16,6 +16,10 @@ module HasVariable
key: Gitlab::Application.secrets.db_key_base,
algorithm: 'aes-256-cbc'
def key=(new_key)
super(new_key.to_s.strip)
end
def to_runner_variable
{ key: key, value: value, public: false }
end

View File

@ -122,9 +122,7 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def search(query)
title = to_fuzzy_arel(:title, query)
where(title)
fuzzy_search(query, [:title])
end
# Searches for records with a matching title or description.
@ -135,10 +133,7 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def full_search(query)
title = to_fuzzy_arel(:title, query)
description = to_fuzzy_arel(:description, query)
where(title&.or(description))
fuzzy_search(query, [:title, :description])
end
def sort(method, excluded_labels: [])

View File

@ -1,5 +1,6 @@
class Email < ActiveRecord::Base
include Sortable
include Gitlab::SQL::Pattern
belongs_to :user

View File

@ -50,20 +50,6 @@ class Group < Namespace
Gitlab::Database.postgresql?
end
# Searches for groups matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String
#
# Returns an ActiveRecord::Relation.
def search(query)
table = Namespace.arel_table
pattern = "%#{query}%"
where(table[:name].matches(pattern).or(table[:path].matches(pattern)))
end
def sort(method)
if method == 'storage_size_desc'
# storage_size is a virtual column so we need to

View File

@ -283,9 +283,9 @@ class MergeRequest < ActiveRecord::Base
if persisted?
merge_request_diff.commit_shas
elsif compare_commits
compare_commits.reverse.map(&:sha)
compare_commits.to_a.reverse.map(&:sha)
else
[]
Array(diff_head_sha)
end
end
@ -364,16 +364,28 @@ class MergeRequest < ActiveRecord::Base
# We use these attributes to force these to the intended values.
attr_writer :target_branch_sha, :source_branch_sha
def source_branch_ref
return @source_branch_sha if @source_branch_sha
return unless source_branch
Gitlab::Git::BRANCH_REF_PREFIX + source_branch
end
def target_branch_ref
return @target_branch_sha if @target_branch_sha
return unless target_branch
Gitlab::Git::BRANCH_REF_PREFIX + target_branch
end
def source_branch_head
return unless source_project
source_branch_ref = @source_branch_sha || source_branch
source_project.repository.commit(source_branch_ref) if source_branch_ref
end
def target_branch_head
target_branch_ref = @target_branch_sha || target_branch
target_project.repository.commit(target_branch_ref) if target_branch_ref
target_project.repository.commit(target_branch_ref)
end
def branch_merge_base_commit
@ -482,7 +494,7 @@ class MergeRequest < ActiveRecord::Base
def merge_request_diff_for(diff_refs_or_sha)
@merge_request_diffs_by_diff_refs_or_sha ||= Hash.new do |h, diff_refs_or_sha|
diffs = merge_request_diffs.viewable.select_without_diff
diffs = merge_request_diffs.viewable
h[diff_refs_or_sha] =
if diff_refs_or_sha.is_a?(Gitlab::Diff::DiffRefs)
diffs.find_by_diff_refs(diff_refs_or_sha)
@ -887,7 +899,8 @@ class MergeRequest < ActiveRecord::Base
def compute_diverged_commits_count
return 0 unless source_branch_sha && target_branch_sha
Gitlab::Git::Commit.between(target_project.repository.raw_repository, source_branch_sha, target_branch_sha).size
target_project.repository
.count_commits_between(source_branch_sha, target_branch_sha)
end
private :compute_diverged_commits_count
@ -906,28 +919,18 @@ class MergeRequest < ActiveRecord::Base
# Note that this could also return SHA from now dangling commits
#
def all_commit_shas
if persisted?
# MySQL doesn't support LIMIT in a subquery.
diffs_relation =
if Gitlab::Database.postgresql?
merge_request_diffs.order(id: :desc).limit(100)
else
merge_request_diffs
end
return commit_shas unless persisted?
column_shas = MergeRequestDiffCommit
.where(merge_request_diff: diffs_relation)
.limit(10_000)
.pluck('sha')
diffs_relation = merge_request_diffs
serialised_shas = merge_request_diffs.where.not(st_commits: nil).flat_map(&:commit_shas)
# MySQL doesn't support LIMIT in a subquery.
diffs_relation = diffs_relation.recent if Gitlab::Database.postgresql?
(column_shas + serialised_shas).uniq
elsif compare_commits
compare_commits.to_a.reverse.map(&:id)
else
[diff_head_sha]
end
MergeRequestDiffCommit
.where(merge_request_diff: diffs_relation)
.limit(10_000)
.pluck('sha')
.uniq
end
def merge_commit

View File

@ -1,14 +1,14 @@
class MergeRequestDiff < ActiveRecord::Base
include Sortable
include Importable
include Gitlab::EncodingHelper
include ManualInverseAssociation
include IgnorableColumn
# Prevent store of diff if commits amount more then 500
# Don't display more than 100 commits at once
COMMITS_SAFE_SIZE = 100
# Valid types of serialized diffs allowed by Gitlab::Git::Diff
VALID_CLASSES = [Hash, Rugged::Patch, Rugged::Diff::Delta].freeze
ignore_column :st_commits,
:st_diffs
belongs_to :merge_request
manual_inverse_association :merge_request, :merge_request_diff
@ -16,9 +16,6 @@ class MergeRequestDiff < ActiveRecord::Base
has_many :merge_request_diff_files, -> { order(:merge_request_diff_id, :relative_order) }
has_many :merge_request_diff_commits, -> { order(:merge_request_diff_id, :relative_order) }
serialize :st_commits # rubocop:disable Cop/ActiveRecordSerialize
serialize :st_diffs # rubocop:disable Cop/ActiveRecordSerialize
state_machine :state, initial: :empty do
state :collected
state :overflow
@ -32,6 +29,8 @@ class MergeRequestDiff < ActiveRecord::Base
scope :viewable, -> { without_state(:empty) }
scope :recent, -> { order(id: :desc).limit(100) }
# All diff information is collected from repository after object is created.
# It allows you to override variables like head_commit_sha before getting diff.
after_create :save_git_content, unless: :importing?
@ -40,14 +39,6 @@ class MergeRequestDiff < ActiveRecord::Base
find_by(start_commit_sha: diff_refs.start_sha, head_commit_sha: diff_refs.head_sha, base_commit_sha: diff_refs.base_sha)
end
def self.select_without_diff
select(column_names - ['st_diffs'])
end
def st_commits
super || []
end
# Collect information about commits and diff from repository
# and save it to the database as serialized data
def save_git_content
@ -129,11 +120,7 @@ class MergeRequestDiff < ActiveRecord::Base
end
def commit_shas
if st_commits.present?
st_commits.map { |commit| commit[:id] }
else
merge_request_diff_commits.map(&:sha)
end
merge_request_diff_commits.map(&:sha)
end
def diff_refs=(new_diff_refs)
@ -208,34 +195,11 @@ class MergeRequestDiff < ActiveRecord::Base
end
def commits_count
if st_commits.present?
st_commits.size
else
merge_request_diff_commits.size
end
end
def utf8_st_diffs
return [] if st_diffs.blank?
st_diffs.map do |diff|
diff.each do |k, v|
diff[k] = encode_utf8(v) if v.respond_to?(:encoding)
end
end
merge_request_diff_commits.size
end
private
# Old GitLab implementations may have generated diffs as ["--broken-diff"].
# Avoid an error 500 by ignoring bad elements. See:
# https://gitlab.com/gitlab-org/gitlab-ce/issues/20776
def valid_raw_diff?(raw)
return false unless raw.respond_to?(:each)
raw.any? { |element| VALID_CLASSES.include?(element.class) }
end
def create_merge_request_diff_files(diffs)
rows = diffs.map.with_index do |diff, index|
diff_hash = diff.to_hash.merge(
@ -259,9 +223,7 @@ class MergeRequestDiff < ActiveRecord::Base
end
def load_diffs(options)
return Gitlab::Git::DiffCollection.new([]) unless diffs_from_database
raw = diffs_from_database
raw = merge_request_diff_files.map(&:to_hash)
if paths = options[:paths]
raw = raw.select do |diff|
@ -272,22 +234,8 @@ class MergeRequestDiff < ActiveRecord::Base
Gitlab::Git::DiffCollection.new(raw, options)
end
def diffs_from_database
return @diffs_from_database if defined?(@diffs_from_database)
@diffs_from_database =
if st_diffs.present?
if valid_raw_diff?(st_diffs)
st_diffs
end
elsif merge_request_diff_files.present?
merge_request_diff_files.map(&:to_hash)
end
end
def load_commits
commits = st_commits.presence || merge_request_diff_commits
commits = commits.map { |commit| Commit.from_hash(commit.to_hash, project) }
commits = merge_request_diff_commits.map { |commit| Commit.from_hash(commit.to_hash, project) }
CommitCollection
.new(merge_request.source_project, commits, merge_request.source_branch)

View File

@ -13,6 +13,7 @@ class Milestone < ActiveRecord::Base
include Referable
include StripAttribute
include Milestoneish
include Gitlab::SQL::Pattern
cache_markdown_field :title, pipeline: :single_line
cache_markdown_field :description
@ -73,10 +74,7 @@ class Milestone < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
t = arel_table
pattern = "%#{query}%"
where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
fuzzy_search(query, [:title, :description])
end
def filter_by_state(milestones, state)

View File

@ -9,6 +9,7 @@ class Namespace < ActiveRecord::Base
include Routable
include AfterCommitQueue
include Storage::LegacyNamespace
include Gitlab::SQL::Pattern
# Prevent users from creating unreasonably deep level of nesting.
# The number 20 was taken based on maximum nesting level of
@ -86,10 +87,7 @@ class Namespace < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation
def search(query)
t = arel_table
pattern = "%#{query}%"
where(t[:name].matches(pattern).or(t[:path].matches(pattern)))
fuzzy_search(query, [:name, :path])
end
def clean_path(path)

View File

@ -14,6 +14,7 @@ class Note < ActiveRecord::Base
include ResolvableNote
include IgnorableColumn
include Editable
include Gitlab::SQL::Pattern
module SpecialRole
FIRST_TIME_CONTRIBUTOR = :first_time_contributor
@ -167,6 +168,10 @@ class Note < ActiveRecord::Base
def has_special_role?(role, note)
note.special_role == role
end
def search(query)
fuzzy_search(query, [:note])
end
end
def cross_reference?

View File

@ -273,8 +273,9 @@ class Project < ActiveRecord::Base
scope :pending_delete, -> { where(pending_delete: true) }
scope :without_deleted, -> { where(pending_delete: false) }
scope :with_hashed_storage, -> { where('storage_version >= 1') }
scope :with_legacy_storage, -> { where(storage_version: [nil, 0]) }
scope :with_storage_feature, ->(feature) { where('storage_version >= :version', version: HASHED_STORAGE_FEATURES[feature]) }
scope :without_storage_feature, ->(feature) { where('storage_version < :version OR storage_version IS NULL', version: HASHED_STORAGE_FEATURES[feature]) }
scope :with_unmigrated_storage, -> { where('storage_version < :version OR storage_version IS NULL', version: LATEST_STORAGE_VERSION) }
scope :sorted_by_activity, -> { reorder(last_activity_at: :desc) }
scope :sorted_by_stars, -> { reorder('projects.star_count DESC') }
@ -425,17 +426,11 @@ class Project < ActiveRecord::Base
#
# query - The search query as a String.
def search(query)
pattern = to_pattern(query)
where(
arel_table[:path].matches(pattern)
.or(arel_table[:name].matches(pattern))
.or(arel_table[:description].matches(pattern))
)
fuzzy_search(query, [:path, :name, :description])
end
def search_by_title(query)
non_archived.where(arel_table[:name].matches(to_pattern(query)))
non_archived.fuzzy_search(query, [:name])
end
def visibility_levels

View File

@ -10,7 +10,9 @@ class ProtectedBranch < ActiveRecord::Base
def self.protected?(project, ref_name)
return true if project.empty_repo? && default_branch_protected?
self.matching(ref_name, protected_refs: project.protected_branches).present?
refs = project.protected_branches.select(:name)
self.matching(ref_name, protected_refs: refs).present?
end
def self.default_branch_protected?

View File

@ -5,6 +5,8 @@ class ProtectedTag < ActiveRecord::Base
protected_ref_access_levels :create
def self.protected?(project, ref_name)
self.matching(ref_name, protected_refs: project.protected_tags).present?
refs = project.protected_tags.select(:name)
self.matching(ref_name, protected_refs: refs).present?
end
end

View File

@ -9,6 +9,7 @@ class Snippet < ActiveRecord::Base
include Mentionable
include Spammable
include Editable
include Gitlab::SQL::Pattern
extend Gitlab::CurrentSettings
@ -135,10 +136,7 @@ class Snippet < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
t = arel_table
pattern = "%#{query}%"
where(t[:title].matches(pattern).or(t[:file_name].matches(pattern)))
fuzzy_search(query, [:title, :file_name])
end
# Searches for snippets with matching content.
@ -149,10 +147,7 @@ class Snippet < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search_code(query)
table = Snippet.arel_table
pattern = "%#{query}%"
where(table[:content].matches(pattern))
fuzzy_search(query, [:content])
end
end
end

View File

@ -4,7 +4,6 @@ module Storage
delegate :gitlab_shell, :repository_storage_path, to: :project
ROOT_PATH_PREFIX = '@hashed'.freeze
STORAGE_VERSION = 1
def initialize(project)
@project = project

View File

@ -313,9 +313,6 @@ class User < ActiveRecord::Base
#
# Returns an ActiveRecord::Relation.
def search(query)
table = arel_table
pattern = User.to_pattern(query)
order = <<~SQL
CASE
WHEN users.name = %{query} THEN 0
@ -325,11 +322,8 @@ class User < ActiveRecord::Base
END
SQL
where(
table[:name].matches(pattern)
.or(table[:email].matches(pattern))
.or(table[:username].matches(pattern))
).reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name)
fuzzy_search(query, [:name, :email, :username])
.reorder(order % { query: ActiveRecord::Base.connection.quote(query) }, :name)
end
# searches user by given pattern
@ -337,16 +331,16 @@ class User < ActiveRecord::Base
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
def search_with_secondary_emails(query)
table = arel_table
email_table = Email.arel_table
pattern = "%#{query}%"
matched_by_emails_user_ids = email_table.project(email_table[:user_id]).where(email_table[:email].matches(pattern))
matched_by_emails_user_ids = email_table
.project(email_table[:user_id])
.where(Email.fuzzy_arel_match(:email, query))
where(
table[:name].matches(pattern)
.or(table[:email].matches(pattern))
.or(table[:username].matches(pattern))
.or(table[:id].in(matched_by_emails_user_ids))
fuzzy_arel_match(:name, query)
.or(fuzzy_arel_match(:email, query))
.or(fuzzy_arel_match(:username, query))
.or(arel_table[:id].in(matched_by_emails_user_ids))
)
end

View File

@ -1,17 +1,17 @@
require 'securerandom'
# Compare 2 branches for one repo or between repositories
# Compare 2 refs for one repo or between repositories
# and return Gitlab::Git::Compare object that responds to commits and diffs
class CompareService
attr_reader :start_project, :start_branch_name
attr_reader :start_project, :start_ref_name
def initialize(new_start_project, new_start_branch_name)
def initialize(new_start_project, new_start_ref_name)
@start_project = new_start_project
@start_branch_name = new_start_branch_name
@start_ref_name = new_start_ref_name
end
def execute(target_project, target_branch, straight: false)
raw_compare = target_project.repository.compare_source_branch(target_branch, start_project.repository, start_branch_name, straight: straight)
def execute(target_project, target_ref, straight: false)
raw_compare = target_project.repository.compare_source_branch(target_ref, start_project.repository, start_ref_name, straight: straight)
Compare.new(raw_compare, target_project, straight: straight) if raw_compare
end

View File

@ -1,6 +1,8 @@
module MergeRequests
class BuildService < MergeRequests::BaseService
def execute
@issue_iid = params.delete(:issue_iid)
self.merge_request = MergeRequest.new(params)
merge_request.compare_commits = []
merge_request.source_project = find_source_project
@ -18,7 +20,17 @@ module MergeRequests
attr_accessor :merge_request
delegate :target_branch, :source_branch, :source_project, :target_project, :compare_commits, :wip_title, :description, :errors, to: :merge_request
delegate :target_branch,
:target_branch_ref,
:target_project,
:source_branch,
:source_branch_ref,
:source_project,
:compare_commits,
:wip_title,
:description,
:errors,
to: :merge_request
def find_source_project
return source_project if source_project.present? && can?(current_user, :read_project, source_project)
@ -54,10 +66,10 @@ module MergeRequests
def compare_branches
compare = CompareService.new(
source_project,
source_branch
source_branch_ref
).execute(
target_project,
target_branch
target_branch_ref
)
if compare
@ -106,37 +118,53 @@ module MergeRequests
# more than one commit in the MR
#
def assign_title_and_description
if match = source_branch.match(/\A(\d+)-/)
iid = match[1]
end
assign_title_and_description_from_single_commit
assign_title_from_issue
commits = compare_commits
if commits && commits.count == 1
commit = commits.first
merge_request.title = commit.title
merge_request.description ||= commit.description.try(:strip)
elsif iid && issue = target_project.get_issue(iid, current_user)
case issue
when Issue
merge_request.title = "Resolve \"#{issue.title}\""
when ExternalIssue
merge_request.title = "Resolve #{issue.title}"
end
merge_request.title ||= source_branch.titleize.humanize
merge_request.title = wip_title if compare_commits.empty?
append_closes_description
end
def append_closes_description
return unless issue_iid
closes_issue = "Closes ##{issue_iid}"
if description.present?
merge_request.description += closes_issue.prepend("\n\n")
else
merge_request.title = source_branch.titleize.humanize
merge_request.description = closes_issue
end
end
if iid
closes_issue = "Closes ##{iid}"
def assign_title_and_description_from_single_commit
commits = compare_commits
if description.present?
merge_request.description += closes_issue.prepend("\n\n")
else
merge_request.description = closes_issue
return unless commits&.count == 1
commit = commits.first
merge_request.title ||= commit.title
merge_request.description ||= commit.description.try(:strip)
end
def assign_title_from_issue
return unless issue
merge_request.title =
case issue
when Issue then "Resolve \"#{issue.title}\""
when ExternalIssue then "Resolve #{issue.title}"
end
end
end
merge_request.title = wip_title if commits.empty?
def issue_iid
@issue_iid ||= source_branch.match(/\A(\d+)-/).try(:[], 1)
end
def issue
@issue ||= target_project.get_issue(issue_iid, current_user)
end
end
end

View File

@ -1,7 +1,18 @@
module MergeRequests
class CreateFromIssueService < MergeRequests::CreateService
def initialize(project, user, params)
# branch - the name of new branch
# ref - the source of new branch.
@branch_name = params[:branch_name]
@issue_iid = params[:issue_iid]
@ref = params[:ref]
super(project, user)
end
def execute
return error('Invalid issue iid') unless issue_iid.present? && issue.present?
return error('Invalid issue iid') unless @issue_iid.present? && issue.present?
params[:label_ids] = issue.label_ids if issue.label_ids.any?
@ -21,20 +32,16 @@ module MergeRequests
private
def issue_iid
@isssue_iid ||= params.delete(:issue_iid)
end
def issue
@issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: issue_iid)
@issue ||= IssuesFinder.new(current_user, project_id: project.id).find_by(iid: @issue_iid)
end
def branch_name
@branch_name ||= issue.to_branch_name
@branch ||= @branch_name || issue.to_branch_name
end
def ref
project.default_branch || 'master'
@ref || project.default_branch || 'master'
end
def merge_request
@ -43,6 +50,7 @@ module MergeRequests
def merge_request_params
{
issue_iid: @issue_iid,
source_project_id: project.id,
source_branch: branch_name,
target_project_id: project.id,

View File

@ -0,0 +1,54 @@
module Projects
module HashedStorage
AttachmentMigrationError = Class.new(StandardError)
class MigrateAttachmentsService < BaseService
attr_reader :logger, :old_path, :new_path
def initialize(project, logger = nil)
@project = project
@logger = logger || Rails.logger
end
def execute
@old_path = project.full_path
@new_path = project.disk_path
origin = FileUploader.dynamic_path_segment(project)
project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:attachments]
target = FileUploader.dynamic_path_segment(project)
result = move_folder!(origin, target)
project.save!
if result && block_given?
yield
end
result
end
private
def move_folder!(old_path, new_path)
unless File.directory?(old_path)
logger.info("Skipped attachments migration from '#{old_path}' to '#{new_path}', source path doesn't exist or is not a directory (PROJECT_ID=#{project.id})")
return
end
if File.exist?(new_path)
logger.error("Cannot migrate attachments from '#{old_path}' to '#{new_path}', target path already exist (PROJECT_ID=#{project.id})")
raise AttachmentMigrationError, "Target path '#{new_path}' already exist"
end
# Create hashed storage base path folder
FileUtils.mkdir_p(File.dirname(new_path))
FileUtils.mv(old_path, new_path)
logger.info("Migrated project attachments from '#{old_path}' to '#{new_path}' (PROJECT_ID=#{project.id})")
true
end
end
end
end

View File

@ -0,0 +1,70 @@
module Projects
module HashedStorage
class MigrateRepositoryService < BaseService
include Gitlab::ShellAdapter
attr_reader :old_disk_path, :new_disk_path, :old_wiki_disk_path, :old_storage_version, :logger
def initialize(project, logger = nil)
@project = project
@logger = logger || Rails.logger
end
def execute
@old_disk_path = project.disk_path
has_wiki = project.wiki.repository_exists?
@old_storage_version = project.storage_version
project.storage_version = ::Project::HASHED_STORAGE_FEATURES[:repository]
project.ensure_storage_path_exists
@new_disk_path = project.disk_path
result = move_repository(@old_disk_path, @new_disk_path)
if has_wiki
@old_wiki_disk_path = "#{@old_disk_path}.wiki"
result &&= move_repository("#{@old_wiki_disk_path}", "#{@new_disk_path}.wiki")
end
unless result
rollback_folder_move
project.storage_version = nil
end
project.repository_read_only = false
project.save!
if result && block_given?
yield
end
result
end
private
def move_repository(from_name, to_name)
from_exists = gitlab_shell.exists?(project.repository_storage_path, "#{from_name}.git")
to_exists = gitlab_shell.exists?(project.repository_storage_path, "#{to_name}.git")
# If we don't find the repository on either original or target we should log that as it could be an issue if the
# project was not originally empty.
if !from_exists && !to_exists
logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..."
return false
elsif !from_exists
# Repository have been moved already.
return true
end
gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name)
end
def rollback_folder_move
move_repository(@new_disk_path, @old_disk_path)
move_repository("#{@new_disk_path}.wiki", "#{@old_disk_path}.wiki")
end
end
end
end

View File

@ -1,68 +1,22 @@
module Projects
class HashedStorageMigrationService < BaseService
include Gitlab::ShellAdapter
attr_reader :old_disk_path, :new_disk_path
attr_reader :logger
def initialize(project, logger = nil)
@project = project
@logger ||= Rails.logger
@logger = logger || Rails.logger
end
def execute
return if project.hashed_storage?(:repository)
@old_disk_path = project.disk_path
has_wiki = project.wiki.repository_exists?
project.storage_version = Storage::HashedProject::STORAGE_VERSION
project.ensure_storage_path_exists
@new_disk_path = project.disk_path
result = move_repository(@old_disk_path, @new_disk_path)
if has_wiki
result &&= move_repository("#{@old_disk_path}.wiki", "#{@new_disk_path}.wiki")
# Migrate repository from Legacy to Hashed Storage
unless project.hashed_storage?(:repository)
return unless HashedStorage::MigrateRepositoryService.new(project, logger).execute
end
unless result
rollback_folder_move
return
# Migrate attachments from Legacy to Hashed Storage
unless project.hashed_storage?(:attachments)
HashedStorage::MigrateAttachmentsService.new(project, logger).execute
end
project.repository_read_only = false
project.save!
block_given? ? yield : result
end
private
def move_repository(from_name, to_name)
from_exists = gitlab_shell.exists?(project.repository_storage_path, "#{from_name}.git")
to_exists = gitlab_shell.exists?(project.repository_storage_path, "#{to_name}.git")
# If we don't find the repository on either original or target we should log that as it could be an issue if the
# project was not originally empty.
if !from_exists && !to_exists
logger.warn "Can't find a repository on either source or target paths for #{project.full_path} (ID=#{project.id}) ..."
return false
elsif !from_exists
# Repository have been moved already.
return true
end
gitlab_shell.mv_repository(project.repository_storage_path, from_name, to_name)
end
def rollback_folder_move
move_repository(@new_disk_path, @old_disk_path)
move_repository("#{@new_disk_path}.wiki", "#{@old_disk_path}.wiki")
end
def logger
@logger
end
end
end

View File

@ -15,7 +15,7 @@ module Projects
return error("Could not set the default branch") unless project.change_head(params[:default_branch])
end
if project.update_attributes(params.except(:default_branch))
if project.update_attributes(update_params)
if project.previous_changes.include?('path')
project.rename_repo
else
@ -31,8 +31,16 @@ module Projects
end
end
def run_auto_devops_pipeline?
params.dig(:run_auto_devops_pipeline_explicit) == 'true' || params.dig(:run_auto_devops_pipeline_implicit) == 'true'
end
private
def update_params
params.except(:default_branch, :run_auto_devops_pipeline_explicit, :run_auto_devops_pipeline_implicit)
end
def renaming_project_with_container_registry_tags?
new_path = params[:path]

View File

@ -31,12 +31,19 @@ class FileUploader < GitlabUploader
# Returns a String without a trailing slash
def self.dynamic_path_segment(project)
if project.hashed_storage?(:attachments)
File.join(CarrierWave.root, base_dir, project.disk_path)
dynamic_path_builder(project.disk_path)
else
File.join(CarrierWave.root, base_dir, project.full_path)
dynamic_path_builder(project.full_path)
end
end
# Auxiliary method to build dynamic path segment when not using a project model
#
# Prefer to use the `.dynamic_path_segment` as it includes Hashed Storage specific logic
def self.dynamic_path_builder(path)
File.join(CarrierWave.root, base_dir, path)
end
attr_accessor :model
attr_reader :secret

View File

@ -731,6 +731,30 @@
.help-block
Number of Git pushes after which 'git gc' is run.
%fieldset
%legend Gitaly Timeouts
.form-group
= f.label :gitaly_timeout_default, 'Default Timeout Period', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :gitaly_timeout_default, class: 'form-control'
.help-block
Timeout for Gitaly calls from the GitLab application (in seconds). This timeout is not enforced
for git fetch/push operations or Sidekiq jobs.
.form-group
= f.label :gitaly_timeout_fast, 'Fast Timeout Period', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :gitaly_timeout_fast, class: 'form-control'
.help-block
Fast operation timeout (in seconds). Some Gitaly operations are expected to be fast.
If they exceed this threshold, there may be a problem with a storage shard and 'failing fast'
can help maintain the stability of the GitLab instance.
.form-group
= f.label :gitaly_timeout_medium, 'Medium Timeout Period', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :gitaly_timeout_medium, class: 'form-control'
.help-block
Medium operation timeout (in seconds). This should be a value between the Fast and the Default timeout.
%fieldset
%legend Web terminal
.form-group

View File

@ -5,7 +5,12 @@ xml.entry do
xml.link href: event_feed_url(event)
xml.title truncate(event_feed_title(event), length: 80)
xml.updated event.updated_at.xmlschema
xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author_email))
# We're deliberately re-using "event.author" here since this data is
# eager-loaded. This allows us to re-use the user object's Email address,
# instead of having to run additional queries to figure out what Email to use
# for the avatar.
xml.media :thumbnail, width: "40", height: "40", url: image_url(avatar_icon(event.author))
xml.author do
xml.username event.author_username

View File

@ -1,10 +1,8 @@
.flash-container.flash-container-page
- if alert
.flash-alert
%div{ class: (container_class) }
%span= alert
- elsif notice
.flash-notice
%div{ class: (container_class) }
%span= notice
-# We currently only support `alert`, `notice`, `success`
- flash.each do |key, value|
-# Don't show a flash message if the message is nil
- if value
%div{ class: "flash-#{key}" }
%div{ class: (container_class) }
%span= value

View File

@ -67,8 +67,8 @@
- if @commit.last_pipeline
- last_pipeline = @commit.last_pipeline
.well-segment.pipeline-info
.status-icon-container{ class: "ci-status-icon-#{last_pipeline.status}" }
= link_to project_pipeline_path(@project, last_pipeline.id) do
.status-icon-container
= link_to project_pipeline_path(@project, last_pipeline.id), class: "ci-status-icon-#{last_pipeline.status}" do
= ci_icon_for_status(last_pipeline.status)
#{ _('Pipeline') }
= link_to "##{last_pipeline.id}", project_pipeline_path(@project, last_pipeline.id)

View File

@ -0,0 +1,26 @@
- avatar = namespace_icon(namespace, 100)
- can_create_project = current_user.can?(:create_projects, namespace)
- if forked_project = namespace.find_fork_of(@project)
.bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default.forked
= link_to project_path(forked_project) do
- if /no_((\w*)_)*avatar/.match(avatar)
= project_identicon(namespace, class: "avatar s100 identicon")
- else
.avatar-container.s100
= image_tag(avatar, class: "avatar s100")
%h5.prepend-top-default
= namespace.human_name
- else
.bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default{ class: ("disabled" unless can_create_project) }
= link_to project_forks_path(@project, namespace_key: namespace.id),
method: "POST",
class: ("disabled has-tooltip" unless can_create_project),
title: (_('You have reached your project limit') unless can_create_project) do
- if /no_((\w*)_)*avatar/.match(avatar)
= project_identicon(namespace, class: "avatar s100 identicon")
- else
.avatar-container.s100
= image_tag(avatar, class: "avatar s100")
%h5.prepend-top-default
= namespace.human_name

View File

@ -14,22 +14,7 @@
%h5.prepend-top-0.append-bottom-0.prepend-left-default.append-right-default
Click to fork the project
- @namespaces.each do |namespace|
- avatar = namespace_icon(namespace, 100)
- can_create_project = current_user.can?(:create_projects, namespace)
- forked_project = namespace.find_fork_of(@project)
- fork_path = forked_project ? project_path(forked_project) : project_forks_path(@project, namespace_key: namespace.id)
.bordered-box.fork-thumbnail.text-center.prepend-left-default.append-right-default.prepend-top-default.append-bottom-default{ class: [("disabled" unless can_create_project), ("forked" if forked_project)] }
= link_to fork_path,
method: "POST",
class: [("js-fork-thumbnail" unless forked_project), ("disabled has-tooltip" unless can_create_project)],
title: (_('You have reached your project limit') unless can_create_project) do
- if /no_((\w*)_)*avatar/.match(avatar)
= project_identicon(namespace, class: "avatar s100 identicon")
- else
.avatar-container.s100
= image_tag(avatar, class: "avatar s100")
%h5.prepend-top-default
= namespace.human_name
= render 'fork_button', namespace: namespace
- else
%strong
No available namespaces to fork the project.

View File

@ -1,34 +1,50 @@
- can_create_merge_request = can?(current_user, :create_merge_request, @project)
- data_action = can_create_merge_request ? 'create-mr' : 'create-branch'
- value = can_create_merge_request ? 'Create a merge request' : 'Create a branch'
- value = can_create_merge_request ? 'Create merge request' : 'Create branch'
- if can?(current_user, :push_code, @project)
.create-mr-dropdown-wrap{ data: { can_create_path: can_create_branch_project_issue_path(@project, @issue), create_mr_path: create_merge_request_project_issue_path(@project, @issue), create_branch_path: project_branches_path(@project, branch_name: @issue.to_branch_name, issue_iid: @issue.iid) } }
- can_create_path = can_create_branch_project_issue_path(@project, @issue)
- create_mr_path = create_merge_request_project_issue_path(@project, @issue, branch_name: @issue.to_branch_name, ref: @project.default_branch)
- create_branch_path = project_branches_path(@project, branch_name: @issue.to_branch_name, ref: @project.default_branch, issue_iid: @issue.iid)
- refs_path = refs_namespace_project_path(@project.namespace, @project, search: '')
.create-mr-dropdown-wrap{ data: { can_create_path: can_create_path, create_mr_path: create_mr_path, create_branch_path: create_branch_path, refs_path: refs_path } }
.btn-group.unavailable
%button.btn.btn-grouped{ type: 'button', disabled: 'disabled' }
= icon('spinner', class: 'fa-spin')
%span.text
Checking branch availability…
.btn-group.available.hide
%input.btn.js-create-merge-request.btn-inverted.btn-success{ type: 'button', value: value, data: { action: data_action } }
%button.btn.btn-inverted.dropdown-toggle.btn-inverted.btn-success.js-dropdown-toggle{ type: 'button', data: { 'dropdown-trigger' => '#create-merge-request-dropdown' } }
%button.btn.js-create-merge-request.btn-default{ type: 'button', data: { action: data_action } }
= value
%button.btn.create-merge-request-dropdown-toggle.dropdown-toggle.btn-default.js-dropdown-toggle{ type: 'button', data: { dropdown: { trigger: '#create-merge-request-dropdown' } } }
= icon('caret-down')
%ul#create-merge-request-dropdown.dropdown-menu.dropdown-menu-align-right{ data: { dropdown: true } }
%ul#create-merge-request-dropdown.create-merge-request-dropdown-menu.dropdown-menu.dropdown-menu-align-right.gl-show-field-errors{ data: { dropdown: true } }
- if can_create_merge_request
%li.droplab-item-selected{ role: 'button', data: { value: 'create-mr', 'text' => 'Create a merge request' } }
.menu-item
.icon-container
= icon('check')
.description
%strong Create a merge request
%span
Creates a merge request named after this issue, with source branch created from '#{@project.default_branch}'.
%li.divider.droplab-item-ignore
%li{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', 'text' => 'Create a branch' } }
.menu-item
.icon-container
= icon('check')
.description
%strong Create a branch
%span
Creates a branch named after this issue, from '#{@project.default_branch}'.
%li.create-item.droplab-item-selected.droplab-item-ignore-hiding{ role: 'button', data: { value: 'create-mr', text: 'Create merge request' } }
.menu-item.droplab-item-ignore-hiding
.icon-container.droplab-item-ignore-hiding= icon('check')
.description.droplab-item-ignore-hiding Create merge request and branch
%li.create-item.droplab-item-ignore-hiding{ class: [!can_create_merge_request && 'droplab-item-selected'], role: 'button', data: { value: 'create-branch', text: 'Create branch' } }
.menu-item.droplab-item-ignore-hiding
.icon-container.droplab-item-ignore-hiding= icon('check')
.description.droplab-item-ignore-hiding Create branch
%li.divider
%li.droplab-item-ignore
Branch name
%input.js-branch-name.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@issue.to_branch_name}", value: "#{@issue.to_branch_name}" }
%span.js-branch-message.branch-message.droplab-item-ignore
%li.droplab-item-ignore
Source (branch or tag)
%input.js-ref.ref.form-control.droplab-item-ignore{ type: 'text', placeholder: "#{@project.default_branch}", value: "#{@project.default_branch}", data: { value: "#{@project.default_branch}" } }
%span.js-ref-message.ref-message.droplab-item-ignore
%li.droplab-item-ignore
%button.btn.btn-success.js-create-target.droplab-item-ignore{ type: 'button', data: { action: 'create-mr' } }
Create merge request

View File

@ -13,29 +13,39 @@
%p.settings-message.text-center
= message.html_safe
= f.fields_for :auto_devops_attributes, @auto_devops do |form|
.radio
.radio.js-auto-devops-enable-radio-wrapper
= form.label :enabled_true do
= form.radio_button :enabled, 'true'
= form.radio_button :enabled, 'true', class: 'js-auto-devops-enable-radio'
%strong Enable Auto DevOps
%br
%span.descr
The Auto DevOps pipeline configuration will be used when there is no <code>.gitlab-ci.yml</code> in the project.
.radio
- if show_run_auto_devops_pipeline_checkbox_for_explicit_setting?(@project)
.checkbox.hide.js-run-auto-devops-pipeline-checkbox-wrapper
= label_tag 'project[run_auto_devops_pipeline_explicit]' do
= check_box_tag 'project[run_auto_devops_pipeline_explicit]', true, false, class: 'js-run-auto-devops-pipeline-checkbox'
= s_('ProjectSettings|Immediately run a pipeline on the default branch')
.radio.js-auto-devops-enable-radio-wrapper
= form.label :enabled_false do
= form.radio_button :enabled, 'false'
= form.radio_button :enabled, 'false', class: 'js-auto-devops-enable-radio'
%strong Disable Auto DevOps
%br
%span.descr
An explicit <code>.gitlab-ci.yml</code> needs to be specified before you can begin using Continuous Integration and Delivery.
.radio
= form.label :enabled_nil do
= form.radio_button :enabled, ''
.radio.js-auto-devops-enable-radio-wrapper
= form.label :enabled_ do
= form.radio_button :enabled, '', class: 'js-auto-devops-enable-radio'
%strong Instance default (#{current_application_settings.auto_devops_enabled? ? 'enabled' : 'disabled'})
%br
%span.descr
Follow the instance default to either have Auto DevOps enabled or disabled when there is no project specific <code>.gitlab-ci.yml</code>.
%br
- if show_run_auto_devops_pipeline_checkbox_for_instance_setting?(@project)
.checkbox.hide.js-run-auto-devops-pipeline-checkbox-wrapper
= label_tag 'project[run_auto_devops_pipeline_implicit]' do
= check_box_tag 'project[run_auto_devops_pipeline_implicit]', true, false, class: 'js-run-auto-devops-pipeline-checkbox'
= s_('ProjectSettings|Immediately run a pipeline on the default branch')
%p
You need to specify a domain if you want to use Auto Review Apps and Auto Deploy stages.
= form.text_field :domain, class: 'form-control', placeholder: 'domain.com'

View File

@ -11,6 +11,6 @@
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'repo'
%div{ class: [container_class, ("limit-container-width" unless fluid_layout)] }
%div{ class: [(container_class unless show_new_repo?), ("limit-container-width" unless fluid_layout)] }
= render 'projects/last_push'
= render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id)

View File

@ -7,7 +7,7 @@
%span
= enabled_project_button(project, enabled_protocol)
- else
%a#clone-dropdown.clone-dropdown-btn.btn{ href: '#', data: { toggle: 'dropdown' } }
%a#clone-dropdown.btn.clone-dropdown-btn{ href: '#', data: { toggle: 'dropdown' } }
%span
= default_clone_protocol.upcase
= icon('caret-down')

View File

@ -20,7 +20,7 @@
= project_icon(project, alt: '', class: 'avatar project-avatar s40')
.project-details
%h3.prepend-top-0.append-bottom-0
= link_to project_path(project), class: dom_class(project) do
= link_to project_path(project), class: 'text-plain' do
%span.project-full-name
%span.namespace-name
- if project.namespace && !skip_namespace

View File

@ -1,3 +1,4 @@
- @no_container = true;
#repo{ data: { root: @path.empty?.to_s,
root_url: project_tree_path(project),
url: content_url,

View File

@ -0,0 +1,16 @@
class CreatePipelineWorker
include Sidekiq::Worker
include PipelineQueue
enqueue_in group: :creation
def perform(project_id, user_id, ref, source, params = {})
project = Project.find(project_id)
user = User.find(user_id)
params = params.deep_symbolize_keys
Ci::CreatePipelineService
.new(project, user, ref: ref)
.execute(source, **params)
end
end

View File

@ -2,10 +2,34 @@ class ProjectMigrateHashedStorageWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
LEASE_TIMEOUT = 30.seconds.to_i
def perform(project_id)
project = Project.find_by(id: project_id)
return if project.nil? || project.pending_delete?
::Projects::HashedStorageMigrationService.new(project, logger).execute
uuid = lease_for(project_id).try_obtain
if uuid
::Projects::HashedStorageMigrationService.new(project, logger).execute
else
false
end
rescue => ex
cancel_lease_for(project_id, uuid) if uuid
raise ex
end
def lease_for(project_id)
Gitlab::ExclusiveLease.new(lease_key(project_id), timeout: LEASE_TIMEOUT)
end
private
def lease_key(project_id)
"project_migrate_hashed_storage_worker:#{project_id}"
end
def cancel_lease_for(project_id, uuid)
Gitlab::ExclusiveLease.cancel(lease_key(project_id), uuid)
end
end

View File

@ -0,0 +1,5 @@
---
title: Initializes the branches dropdown when the 'Start new pipeline' failed due to validation errors
merge_request: 15588
author: Christiaan Van den Poel
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Add an ability to use a custom branch name on creation from issues
merge_request: 13884
author: Vitaliy @blackst0ne Klachkov
type: added

View File

@ -0,0 +1,5 @@
---
title: added support for ordering and sorting in notes api
merge_request: 15342
author: haseebeqx
type: added

View File

@ -0,0 +1,5 @@
---
title: Add the option to automatically run a pipeline after updating AutoDevOps settings
merge_request: 15380
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Removed tooltip from clone dropdown
merge_request: 15334
author:
type: other

Some files were not shown because too many files have changed in this diff Show More