Merge branch 'master' into 'url-utility-es-module'
# Conflicts: # app/assets/javascripts/issue_show/components/app.vue
This commit is contained in:
commit
a5d2732ce9
143 changed files with 2820 additions and 1094 deletions
|
@ -1 +1 @@
|
|||
5.10.1
|
||||
5.10.2
|
||||
|
|
3
Gemfile
3
Gemfile
|
@ -411,3 +411,6 @@ gem 'flipper-active_record', '~> 0.10.2'
|
|||
# Structured logging
|
||||
gem 'lograge', '~> 0.5'
|
||||
gem 'grape_logging', '~> 1.7'
|
||||
|
||||
# Asset synchronization
|
||||
gem 'asset_sync', '~> 2.2.0'
|
||||
|
|
|
@ -58,6 +58,11 @@ GEM
|
|||
asciidoctor (1.5.3)
|
||||
asciidoctor-plantuml (0.0.7)
|
||||
asciidoctor (~> 1.5)
|
||||
asset_sync (2.2.0)
|
||||
activemodel (>= 4.1.0)
|
||||
fog-core
|
||||
mime-types (>= 2.99)
|
||||
unf
|
||||
ast (2.3.0)
|
||||
atomic (1.1.99)
|
||||
attr_encrypted (3.0.3)
|
||||
|
@ -975,6 +980,7 @@ DEPENDENCIES
|
|||
asana (~> 0.6.0)
|
||||
asciidoctor (~> 1.5.2)
|
||||
asciidoctor-plantuml (= 0.0.7)
|
||||
asset_sync (~> 2.2.0)
|
||||
attr_encrypted (~> 3.0.0)
|
||||
awesome_print (~> 1.2.0)
|
||||
babosa (~> 1.0.2)
|
||||
|
|
|
@ -130,7 +130,8 @@ freeze date (the 7th) should have a corresponding Enterprise Edition merge
|
|||
request, even if there are no conflicts. This is to reduce the size of the
|
||||
subsequent EE merge, as we often merge a lot to CE on the release date. For more
|
||||
information, see
|
||||
[limit conflicts with EE when developing on CE][limit_ee_conflicts].
|
||||
[Automatic CE->EE merge][automatic_ce_ee_merge] and
|
||||
[Guidelines for implementing Enterprise Edition features][ee_features].
|
||||
|
||||
### After the 7th
|
||||
|
||||
|
@ -281,4 +282,5 @@ still an issue I encourage you to open it on the [GitLab.com issue tracker](http
|
|||
["Implement design & UI elements" guidelines]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#implement-design-ui-elements
|
||||
[Thoughtbot code review guide]: https://github.com/thoughtbot/guides/tree/master/code-review
|
||||
[done]: https://gitlab.com/gitlab-org/gitlab-ce/blob/master/CONTRIBUTING.md#definition-of-done
|
||||
[limit_ee_conflicts]: https://docs.gitlab.com/ce/development/limit_ee_conflicts.html
|
||||
[automatic_ce_ee_merge]: https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html
|
||||
[ee_features]: https://docs.gitlab.com/ce/development/ee_features.html
|
||||
|
|
|
@ -121,7 +121,7 @@ export default class ImageFile {
|
|||
return $('.swipe.view', this.file).each((function(_this) {
|
||||
return function(index, view) {
|
||||
var $swipeWrap, $swipeBar, $swipeFrame, wrapPadding, ref;
|
||||
ref = this.prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1];
|
||||
ref = _this.prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1];
|
||||
$swipeFrame = $('.swipe-frame', view);
|
||||
$swipeWrap = $('.swipe-wrap', view);
|
||||
$swipeBar = $('.swipe-bar', view);
|
||||
|
@ -158,7 +158,7 @@ export default class ImageFile {
|
|||
return $('.onion-skin.view', this.file).each((function(_this) {
|
||||
return function(index, view) {
|
||||
var $frame, $track, $dragger, $frameAdded, framePadding, ref, dragging = false;
|
||||
ref = this.prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1];
|
||||
ref = _this.prepareFrames(view), maxWidth = ref[0], maxHeight = ref[1];
|
||||
$frame = $('.onion-skin-frame', view);
|
||||
$frameAdded = $('.frame.added', view);
|
||||
$track = $('.drag-track', view);
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
/* eslint-disable func-names, space-before-function-paren, wrap-iife, quotes, no-var, object-shorthand, consistent-return, no-unused-vars, comma-dangle, vars-on-top, prefer-template, max-len */
|
||||
|
||||
window.Compare = (function() {
|
||||
function Compare(opts) {
|
||||
export default class Compare {
|
||||
constructor(opts) {
|
||||
this.opts = opts;
|
||||
this.source_loading = $(".js-source-loading");
|
||||
this.target_loading = $(".js-target-loading");
|
||||
|
@ -34,12 +34,12 @@ window.Compare = (function() {
|
|||
this.initialState();
|
||||
}
|
||||
|
||||
Compare.prototype.initialState = function() {
|
||||
initialState() {
|
||||
this.getSourceHtml();
|
||||
return this.getTargetHtml();
|
||||
};
|
||||
this.getTargetHtml();
|
||||
}
|
||||
|
||||
Compare.prototype.getTargetProject = function() {
|
||||
getTargetProject() {
|
||||
return $.ajax({
|
||||
url: this.opts.targetProjectUrl,
|
||||
data: {
|
||||
|
@ -52,22 +52,22 @@ window.Compare = (function() {
|
|||
return $('.js-target-branch-dropdown .dropdown-content').html(html);
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Compare.prototype.getSourceHtml = function() {
|
||||
return this.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', {
|
||||
getSourceHtml() {
|
||||
return this.constructor.sendAjax(this.opts.sourceBranchUrl, this.source_loading, '.mr_source_commit', {
|
||||
ref: $("input[name='merge_request[source_branch]']").val()
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Compare.prototype.getTargetHtml = function() {
|
||||
return this.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', {
|
||||
getTargetHtml() {
|
||||
return this.constructor.sendAjax(this.opts.targetBranchUrl, this.target_loading, '.mr_target_commit', {
|
||||
target_project_id: $("input[name='merge_request[target_project_id]']").val(),
|
||||
ref: $("input[name='merge_request[target_branch]']").val()
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
Compare.prototype.sendAjax = function(url, loading, target, data) {
|
||||
static sendAjax(url, loading, target, data) {
|
||||
var $target;
|
||||
$target = $(target);
|
||||
return $.ajax({
|
||||
|
@ -84,7 +84,5 @@ window.Compare = (function() {
|
|||
gl.utils.localTimeAgo($('.js-timeago', className));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return Compare;
|
||||
})();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,68 +1,60 @@
|
|||
/* eslint-disable func-names, space-before-function-paren, one-var, no-var, one-var-declaration-per-line, object-shorthand, comma-dangle, prefer-arrow-callback, no-else-return, newline-per-chained-call, wrap-iife, max-len */
|
||||
|
||||
window.CompareAutocomplete = (function() {
|
||||
function CompareAutocomplete() {
|
||||
this.initDropdown();
|
||||
}
|
||||
|
||||
CompareAutocomplete.prototype.initDropdown = function() {
|
||||
return $('.js-compare-dropdown').each(function() {
|
||||
var $dropdown, selected;
|
||||
$dropdown = $(this);
|
||||
selected = $dropdown.data('selected');
|
||||
const $dropdownContainer = $dropdown.closest('.dropdown');
|
||||
const $fieldInput = $(`input[name="${$dropdown.data('field-name')}"]`, $dropdownContainer);
|
||||
const $filterInput = $('input[type="search"]', $dropdownContainer);
|
||||
$dropdown.glDropdown({
|
||||
data: function(term, callback) {
|
||||
return $.ajax({
|
||||
url: $dropdown.data('refs-url'),
|
||||
data: {
|
||||
ref: $dropdown.data('ref'),
|
||||
search: term,
|
||||
}
|
||||
}).done(function(refs) {
|
||||
return callback(refs);
|
||||
});
|
||||
},
|
||||
selectable: true,
|
||||
filterable: true,
|
||||
filterRemote: true,
|
||||
fieldName: $dropdown.data('field-name'),
|
||||
filterInput: 'input[type="search"]',
|
||||
renderRow: function(ref) {
|
||||
var link;
|
||||
if (ref.header != null) {
|
||||
return $('<li />').addClass('dropdown-header').text(ref.header);
|
||||
} else {
|
||||
link = $('<a />').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref));
|
||||
return $('<li />').append(link);
|
||||
export default function initCompareAutocomplete() {
|
||||
$('.js-compare-dropdown').each(function() {
|
||||
var $dropdown, selected;
|
||||
$dropdown = $(this);
|
||||
selected = $dropdown.data('selected');
|
||||
const $dropdownContainer = $dropdown.closest('.dropdown');
|
||||
const $fieldInput = $(`input[name="${$dropdown.data('field-name')}"]`, $dropdownContainer);
|
||||
const $filterInput = $('input[type="search"]', $dropdownContainer);
|
||||
$dropdown.glDropdown({
|
||||
data: function(term, callback) {
|
||||
return $.ajax({
|
||||
url: $dropdown.data('refs-url'),
|
||||
data: {
|
||||
ref: $dropdown.data('ref'),
|
||||
search: term,
|
||||
}
|
||||
},
|
||||
id: function(obj, $el) {
|
||||
return $el.attr('data-ref');
|
||||
},
|
||||
toggleLabel: function(obj, $el) {
|
||||
return $el.text().trim();
|
||||
}).done(function(refs) {
|
||||
return callback(refs);
|
||||
});
|
||||
},
|
||||
selectable: true,
|
||||
filterable: true,
|
||||
filterRemote: true,
|
||||
fieldName: $dropdown.data('field-name'),
|
||||
filterInput: 'input[type="search"]',
|
||||
renderRow: function(ref) {
|
||||
var link;
|
||||
if (ref.header != null) {
|
||||
return $('<li />').addClass('dropdown-header').text(ref.header);
|
||||
} else {
|
||||
link = $('<a />').attr('href', '#').addClass(ref === selected ? 'is-active' : '').text(ref).attr('data-ref', escape(ref));
|
||||
return $('<li />').append(link);
|
||||
}
|
||||
});
|
||||
$filterInput.on('keyup', (e) => {
|
||||
const keyCode = e.keyCode || e.which;
|
||||
if (keyCode !== 13) return;
|
||||
const text = $filterInput.val();
|
||||
$fieldInput.val(text);
|
||||
$('.dropdown-toggle-text', $dropdown).text(text);
|
||||
$dropdownContainer.removeClass('open');
|
||||
});
|
||||
|
||||
$dropdownContainer.on('click', '.dropdown-content a', (e) => {
|
||||
$dropdown.prop('title', e.target.text.replace(/_+?/g, '-'));
|
||||
if ($dropdown.hasClass('has-tooltip')) {
|
||||
$dropdown.tooltip('fixTitle');
|
||||
}
|
||||
});
|
||||
},
|
||||
id: function(obj, $el) {
|
||||
return $el.attr('data-ref');
|
||||
},
|
||||
toggleLabel: function(obj, $el) {
|
||||
return $el.text().trim();
|
||||
}
|
||||
});
|
||||
$filterInput.on('keyup', (e) => {
|
||||
const keyCode = e.keyCode || e.which;
|
||||
if (keyCode !== 13) return;
|
||||
const text = $filterInput.val();
|
||||
$fieldInput.val(text);
|
||||
$('.dropdown-toggle-text', $dropdown).text(text);
|
||||
$dropdownContainer.removeClass('open');
|
||||
});
|
||||
};
|
||||
|
||||
return CompareAutocomplete;
|
||||
})();
|
||||
$dropdownContainer.on('click', '.dropdown-content a', (e) => {
|
||||
$dropdown.prop('title', e.target.text.replace(/_+?/g, '-'));
|
||||
if ($dropdown.hasClass('has-tooltip')) {
|
||||
$dropdown.tooltip('fixTitle');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -22,8 +22,8 @@ import NewCommitForm from './new_commit_form';
|
|||
import Project from './project';
|
||||
import projectAvatar from './project_avatar';
|
||||
/* global MergeRequest */
|
||||
/* global Compare */
|
||||
/* global CompareAutocomplete */
|
||||
import Compare from './compare';
|
||||
import initCompareAutocomplete from './compare_autocomplete';
|
||||
/* global ProjectFindFile */
|
||||
import ProjectNew from './project_new';
|
||||
import projectImport from './project_import';
|
||||
|
@ -622,7 +622,7 @@ import ProjectVariables from './project_variables';
|
|||
projectAvatar();
|
||||
switch (path[1]) {
|
||||
case 'compare':
|
||||
new CompareAutocomplete();
|
||||
initCompareAutocomplete();
|
||||
break;
|
||||
case 'edit':
|
||||
shortcut_handler = new ShortcutsNavigation();
|
||||
|
|
|
@ -8,18 +8,7 @@ import IssuablesHelper from './helpers/issuables_helper';
|
|||
|
||||
export default class Issue {
|
||||
constructor() {
|
||||
if ($('a.btn-close').length) {
|
||||
this.taskList = new TaskList({
|
||||
dataType: 'issue',
|
||||
fieldName: 'description',
|
||||
selector: '.detail-page-description',
|
||||
onSuccess: (result) => {
|
||||
document.querySelector('#task_status').innerText = result.task_status;
|
||||
document.querySelector('#task_status_short').innerText = result.task_status_short;
|
||||
}
|
||||
});
|
||||
this.initIssueBtnEventListeners();
|
||||
}
|
||||
if ($('a.btn-close').length) this.initIssueBtnEventListeners();
|
||||
|
||||
Issue.$btnNewBranch = $('#new-branch');
|
||||
Issue.createMrDropdownWrap = document.querySelector('.create-mr-dropdown-wrap');
|
||||
|
|
|
@ -9,6 +9,7 @@ import titleComponent from './title.vue';
|
|||
import descriptionComponent from './description.vue';
|
||||
import editedComponent from './edited.vue';
|
||||
import formComponent from './form.vue';
|
||||
import RecaptchaDialogImplementor from '../../vue_shared/mixins/recaptcha_dialog_implementor';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
|
@ -149,6 +150,11 @@ export default {
|
|||
editedComponent,
|
||||
formComponent,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
RecaptchaDialogImplementor,
|
||||
],
|
||||
|
||||
methods: {
|
||||
openForm() {
|
||||
if (!this.showForm) {
|
||||
|
@ -164,9 +170,11 @@ export default {
|
|||
closeForm() {
|
||||
this.showForm = false;
|
||||
},
|
||||
|
||||
updateIssuable() {
|
||||
this.service.updateIssuable(this.store.formState)
|
||||
.then(res => res.json())
|
||||
.then(data => this.checkForSpam(data))
|
||||
.then((data) => {
|
||||
if (location.pathname !== data.web_url) {
|
||||
urlUtils.visitUrl(data.web_url);
|
||||
|
@ -179,11 +187,24 @@ export default {
|
|||
this.store.updateState(data);
|
||||
eventHub.$emit('close.form');
|
||||
})
|
||||
.catch(() => {
|
||||
eventHub.$emit('close.form');
|
||||
window.Flash(`Error updating ${this.issuableType}`);
|
||||
.catch((error) => {
|
||||
if (error && error.name === 'SpamError') {
|
||||
this.openRecaptcha();
|
||||
} else {
|
||||
eventHub.$emit('close.form');
|
||||
window.Flash(`Error updating ${this.issuableType}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
closeRecaptchaDialog() {
|
||||
this.store.setFormState({
|
||||
updateLoading: false,
|
||||
});
|
||||
|
||||
this.closeRecaptcha();
|
||||
},
|
||||
|
||||
deleteIssuable() {
|
||||
this.service.deleteIssuable()
|
||||
.then(res => res.json())
|
||||
|
@ -237,9 +258,9 @@ export default {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div>
|
||||
<div v-if="canUpdate && showForm">
|
||||
<form-component
|
||||
v-if="canUpdate && showForm"
|
||||
:form-state="formState"
|
||||
:can-destroy="canDestroy"
|
||||
:issuable-templates="issuableTemplates"
|
||||
|
@ -251,30 +272,37 @@ export default {
|
|||
:can-attach-file="canAttachFile"
|
||||
:enable-autocomplete="enableAutocomplete"
|
||||
/>
|
||||
<div v-else>
|
||||
<title-component
|
||||
:issuable-ref="issuableRef"
|
||||
:can-update="canUpdate"
|
||||
:title-html="state.titleHtml"
|
||||
:title-text="state.titleText"
|
||||
:show-inline-edit-button="showInlineEditButton"
|
||||
/>
|
||||
<description-component
|
||||
v-if="state.descriptionHtml"
|
||||
:can-update="canUpdate"
|
||||
:description-html="state.descriptionHtml"
|
||||
:description-text="state.descriptionText"
|
||||
:updated-at="state.updatedAt"
|
||||
:task-status="state.taskStatus"
|
||||
:issuable-type="issuableType"
|
||||
:update-url="updateEndpoint"
|
||||
/>
|
||||
<edited-component
|
||||
v-if="hasUpdated"
|
||||
:updated-at="state.updatedAt"
|
||||
:updated-by-name="state.updatedByName"
|
||||
:updated-by-path="state.updatedByPath"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<recaptcha-dialog
|
||||
v-show="showRecaptcha"
|
||||
:html="recaptchaHTML"
|
||||
@close="closeRecaptchaDialog"
|
||||
/>
|
||||
</div>
|
||||
<div v-else>
|
||||
<title-component
|
||||
:issuable-ref="issuableRef"
|
||||
:can-update="canUpdate"
|
||||
:title-html="state.titleHtml"
|
||||
:title-text="state.titleText"
|
||||
:show-inline-edit-button="showInlineEditButton"
|
||||
/>
|
||||
<description-component
|
||||
v-if="state.descriptionHtml"
|
||||
:can-update="canUpdate"
|
||||
:description-html="state.descriptionHtml"
|
||||
:description-text="state.descriptionText"
|
||||
:updated-at="state.updatedAt"
|
||||
:task-status="state.taskStatus"
|
||||
:issuable-type="issuableType"
|
||||
:update-url="updateEndpoint"
|
||||
/>
|
||||
<edited-component
|
||||
v-if="hasUpdated"
|
||||
:updated-at="state.updatedAt"
|
||||
:updated-by-name="state.updatedByName"
|
||||
:updated-by-path="state.updatedByPath"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -1,9 +1,14 @@
|
|||
<script>
|
||||
import animateMixin from '../mixins/animate';
|
||||
import TaskList from '../../task_list';
|
||||
import RecaptchaDialogImplementor from '../../vue_shared/mixins/recaptcha_dialog_implementor';
|
||||
|
||||
export default {
|
||||
mixins: [animateMixin],
|
||||
mixins: [
|
||||
animateMixin,
|
||||
RecaptchaDialogImplementor,
|
||||
],
|
||||
|
||||
props: {
|
||||
canUpdate: {
|
||||
type: Boolean,
|
||||
|
@ -51,6 +56,7 @@
|
|||
this.updateTaskStatusText();
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
renderGFM() {
|
||||
$(this.$refs['gfm-content']).renderGFM();
|
||||
|
@ -61,9 +67,19 @@
|
|||
dataType: this.issuableType,
|
||||
fieldName: 'description',
|
||||
selector: '.detail-page-description',
|
||||
onSuccess: this.taskListUpdateSuccess.bind(this),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
taskListUpdateSuccess(data) {
|
||||
try {
|
||||
this.checkForSpam(data);
|
||||
} catch (error) {
|
||||
if (error && error.name === 'SpamError') this.openRecaptcha();
|
||||
}
|
||||
},
|
||||
|
||||
updateTaskStatusText() {
|
||||
const taskRegexMatches = this.taskStatus.match(/(\d+) of ((?!0)\d+)/);
|
||||
const $issuableHeader = $('.issuable-meta');
|
||||
|
@ -109,5 +125,11 @@
|
|||
:data-update-url="updateUrl"
|
||||
>
|
||||
</textarea>
|
||||
|
||||
<recaptcha-dialog
|
||||
v-show="showRecaptcha"
|
||||
:html="recaptchaHTML"
|
||||
@close="closeRecaptcha"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
@ -40,9 +40,6 @@ import './admin';
|
|||
import './aside';
|
||||
import loadAwardsHandler from './awards_handler';
|
||||
import bp from './breakpoints';
|
||||
import './commits';
|
||||
import './compare';
|
||||
import './compare_autocomplete';
|
||||
import './confirm_danger_modal';
|
||||
import Flash, { removeFlashClickListener } from './flash';
|
||||
import './gl_dropdown';
|
||||
|
|
|
@ -21,6 +21,8 @@
|
|||
hasMetrics: convertPermissionToBoolean(metricsData.hasMetrics),
|
||||
documentationPath: metricsData.documentationPath,
|
||||
settingsPath: metricsData.settingsPath,
|
||||
tagsPath: metricsData.tagsPath,
|
||||
projectPath: metricsData.projectPath,
|
||||
metricsEndpoint: metricsData.additionalMetrics,
|
||||
deploymentEndpoint: metricsData.deploymentEndpoint,
|
||||
emptyGettingStartedSvgPath: metricsData.emptyGettingStartedSvgPath,
|
||||
|
@ -112,6 +114,8 @@
|
|||
:hover-data="hoverData"
|
||||
:update-aspect-ratio="updateAspectRatio"
|
||||
:deployment-data="store.deploymentData"
|
||||
:project-path="projectPath"
|
||||
:tags-path="tagsPath"
|
||||
/>
|
||||
</graph-group>
|
||||
</div>
|
||||
|
|
|
@ -30,6 +30,14 @@
|
|||
required: false,
|
||||
default: () => ({}),
|
||||
},
|
||||
projectPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
tagsPath: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
|
||||
mixins: [MonitoringMixin],
|
||||
|
@ -251,6 +259,14 @@
|
|||
:line-color="path.lineColor"
|
||||
:area-color="path.areaColor"
|
||||
/>
|
||||
<rect
|
||||
class="prometheus-graph-overlay"
|
||||
:width="(graphWidth - 70)"
|
||||
:height="(graphHeight - 100)"
|
||||
transform="translate(-5, 20)"
|
||||
ref="graphOverlay"
|
||||
@mousemove="handleMouseOverGraph($event)">
|
||||
</rect>
|
||||
<graph-deployment
|
||||
:show-deploy-info="showDeployInfo"
|
||||
:deployment-data="reducedDeploymentData"
|
||||
|
@ -267,14 +283,6 @@
|
|||
:graph-height-offset="graphHeightOffset"
|
||||
:show-flag-content="showFlagContent"
|
||||
/>
|
||||
<rect
|
||||
class="prometheus-graph-overlay"
|
||||
:width="(graphWidth - 70)"
|
||||
:height="(graphHeight - 100)"
|
||||
transform="translate(-5, 20)"
|
||||
ref="graphOverlay"
|
||||
@mousemove="handleMouseOverGraph($event)">
|
||||
</rect>
|
||||
</svg>
|
||||
</svg>
|
||||
</div>
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
<script>
|
||||
import { dateFormat, timeFormat } from '../../utils/date_time_formatters';
|
||||
import { dateFormatWithName, timeFormat } from '../../utils/date_time_formatters';
|
||||
import Icon from '../../../vue_shared/components/icon.vue';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
|
@ -25,6 +26,10 @@
|
|||
},
|
||||
},
|
||||
|
||||
components: {
|
||||
Icon,
|
||||
},
|
||||
|
||||
computed: {
|
||||
calculatedHeight() {
|
||||
return this.graphHeight - this.graphHeightOffset;
|
||||
|
@ -33,7 +38,7 @@
|
|||
|
||||
methods: {
|
||||
refText(d) {
|
||||
return d.tag ? d.ref : d.sha.slice(0, 6);
|
||||
return d.tag ? d.ref : d.sha.slice(0, 8);
|
||||
},
|
||||
|
||||
formatTime(deploymentTime) {
|
||||
|
@ -41,7 +46,7 @@
|
|||
},
|
||||
|
||||
formatDate(deploymentTime) {
|
||||
return dateFormat(deploymentTime);
|
||||
return dateFormatWithName(deploymentTime);
|
||||
},
|
||||
|
||||
nameDeploymentClass(deployment) {
|
||||
|
@ -54,11 +59,19 @@
|
|||
|
||||
positionFlag(deployment) {
|
||||
let xPosition = 3;
|
||||
if (deployment.xPos > (this.graphWidth - 200)) {
|
||||
xPosition = -97;
|
||||
if (deployment.xPos > (this.graphWidth - 225)) {
|
||||
xPosition = -142;
|
||||
}
|
||||
return xPosition;
|
||||
},
|
||||
|
||||
svgContainerHeight(tag) {
|
||||
let svgHeight = 80;
|
||||
if (!tag) {
|
||||
svgHeight -= 20;
|
||||
}
|
||||
return svgHeight;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
@ -91,35 +104,75 @@
|
|||
class="js-deploy-info-box"
|
||||
:x="positionFlag(deployment)"
|
||||
y="0"
|
||||
width="92"
|
||||
height="60">
|
||||
width="134"
|
||||
:height="svgContainerHeight(deployment.tag)">
|
||||
<rect
|
||||
class="rect-text-metric deploy-info-rect rect-metric"
|
||||
x="1"
|
||||
y="1"
|
||||
rx="2"
|
||||
width="90"
|
||||
height="58">
|
||||
width="132"
|
||||
:height="svgContainerHeight(deployment.tag) - 2">
|
||||
</rect>
|
||||
<g
|
||||
transform="translate(5, 2)">
|
||||
<text
|
||||
class="deploy-info-text text-metric-bold">
|
||||
{{refText(deployment)}}
|
||||
</text>
|
||||
</g>
|
||||
<text
|
||||
class="deploy-info-text"
|
||||
y="18"
|
||||
transform="translate(5, 2)">
|
||||
{{formatDate(deployment.time)}}
|
||||
</text>
|
||||
<text
|
||||
class="deploy-info-text text-metric-bold"
|
||||
y="38"
|
||||
transform="translate(5, 2)">
|
||||
{{formatTime(deployment.time)}}
|
||||
Deployed
|
||||
</text>
|
||||
<!--The date info-->
|
||||
<g transform="translate(5, 20)">
|
||||
<text class="deploy-info-text">
|
||||
{{formatDate(deployment.time)}}
|
||||
</text>
|
||||
<text
|
||||
class="deploy-info-text text-metric-bold"
|
||||
x="62">
|
||||
{{formatTime(deployment.time)}}
|
||||
</text>
|
||||
</g>
|
||||
<line
|
||||
class="divider-line"
|
||||
x1="0"
|
||||
y1="38"
|
||||
x2="132"
|
||||
:y2="38"
|
||||
stroke="#000">
|
||||
</line>
|
||||
<!--Commit information-->
|
||||
<g transform="translate(5, 40)">
|
||||
<icon
|
||||
name="commit"
|
||||
:width="12"
|
||||
:height="12"
|
||||
:y="3">
|
||||
</icon>
|
||||
<a :xlink:href="deployment.commitUrl">
|
||||
<text
|
||||
class="deploy-info-text deploy-info-text-link"
|
||||
transform="translate(20, 2)">
|
||||
{{refText(deployment)}}
|
||||
</text>
|
||||
</a>
|
||||
</g>
|
||||
<!--Tag information-->
|
||||
<g
|
||||
transform="translate(5, 55)"
|
||||
v-if="deployment.tag">
|
||||
<icon
|
||||
name="label"
|
||||
:width="12"
|
||||
:height="12"
|
||||
:y="5">
|
||||
</icon>
|
||||
<a :xlink:href="deployment.tagUrl">
|
||||
<text
|
||||
class="deploy-info-text deploy-info-text-link"
|
||||
transform="translate(20, 2)"
|
||||
y="2">
|
||||
{{deployment.tag}}
|
||||
</text>
|
||||
</a>
|
||||
</g>
|
||||
</svg>
|
||||
</g>
|
||||
<svg
|
||||
|
|
|
@ -33,7 +33,9 @@ const mixins = {
|
|||
id: deployment.id,
|
||||
time,
|
||||
sha: deployment.sha,
|
||||
commitUrl: `${this.projectPath}/commit/${deployment.sha}`,
|
||||
tag: deployment.tag,
|
||||
tagUrl: `${this.tagsPath}/${deployment.tag}`,
|
||||
ref: deployment.ref.name,
|
||||
xPos,
|
||||
showDeploymentFlag: false,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import d3 from 'd3';
|
||||
|
||||
export const dateFormat = d3.time.format('%b %-d, %Y');
|
||||
export const dateFormatWithName = d3.time.format('%a, %b %-d');
|
||||
export const timeFormat = d3.time.format('%-I:%M%p');
|
||||
export const bisectDate = d3.bisector(d => d.time).left;
|
||||
|
||||
|
|
|
@ -36,6 +36,30 @@
|
|||
required: false,
|
||||
default: '',
|
||||
},
|
||||
|
||||
width: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
|
||||
height: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
|
||||
y: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
|
||||
x: {
|
||||
type: Number,
|
||||
required: false,
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
|
@ -51,7 +75,11 @@
|
|||
|
||||
<template>
|
||||
<svg
|
||||
:class="[iconSizeClass, cssClasses]">
|
||||
:class="[iconSizeClass, cssClasses]"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:x="x"
|
||||
:y="y">
|
||||
<use
|
||||
v-bind="{'xlink:href':spriteHref}"/>
|
||||
</svg>
|
||||
|
|
|
@ -38,7 +38,8 @@ export default {
|
|||
},
|
||||
primaryButtonLabel: {
|
||||
type: String,
|
||||
required: true,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
submitDisabled: {
|
||||
type: Boolean,
|
||||
|
@ -113,8 +114,9 @@ export default {
|
|||
{{ closeButtonLabel }}
|
||||
</button>
|
||||
<button
|
||||
v-if="primaryButtonLabel"
|
||||
type="button"
|
||||
class="btn pull-right"
|
||||
class="btn pull-right js-primary-button"
|
||||
:disabled="submitDisabled"
|
||||
:class="btnKindClass"
|
||||
@click="emitSubmit(true)">
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
<script>
|
||||
import PopupDialog from './popup_dialog.vue';
|
||||
|
||||
export default {
|
||||
name: 'recaptcha-dialog',
|
||||
|
||||
props: {
|
||||
html: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
script: {},
|
||||
scriptSrc: 'https://www.google.com/recaptcha/api.js',
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
PopupDialog,
|
||||
},
|
||||
|
||||
methods: {
|
||||
appendRecaptchaScript() {
|
||||
this.removeRecaptchaScript();
|
||||
|
||||
const script = document.createElement('script');
|
||||
script.src = this.scriptSrc;
|
||||
script.classList.add('js-recaptcha-script');
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
|
||||
this.script = script;
|
||||
|
||||
document.body.appendChild(script);
|
||||
},
|
||||
|
||||
removeRecaptchaScript() {
|
||||
if (this.script instanceof Element) this.script.remove();
|
||||
},
|
||||
|
||||
close() {
|
||||
this.removeRecaptchaScript();
|
||||
this.$emit('close');
|
||||
},
|
||||
|
||||
submit() {
|
||||
this.$el.querySelector('form').submit();
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
html() {
|
||||
this.appendRecaptchaScript();
|
||||
},
|
||||
},
|
||||
|
||||
mounted() {
|
||||
window.recaptchaDialogCallback = this.submit.bind(this);
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<popup-dialog
|
||||
kind="warning"
|
||||
class="recaptcha-dialog js-recaptcha-dialog"
|
||||
:hide-footer="true"
|
||||
:title="__('Please solve the reCAPTCHA')"
|
||||
@toggle="close"
|
||||
>
|
||||
<div slot="body">
|
||||
<p>
|
||||
{{__('We want to be sure it is you, please confirm you are not a robot.')}}
|
||||
</p>
|
||||
<div
|
||||
ref="recaptcha"
|
||||
v-html="html"
|
||||
></div>
|
||||
</div>
|
||||
</popup-dialog>
|
||||
</template>
|
|
@ -0,0 +1,36 @@
|
|||
import RecaptchaDialog from '../components/recaptcha_dialog.vue';
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
showRecaptcha: false,
|
||||
recaptchaHTML: '',
|
||||
};
|
||||
},
|
||||
|
||||
components: {
|
||||
RecaptchaDialog,
|
||||
},
|
||||
|
||||
methods: {
|
||||
openRecaptcha() {
|
||||
this.showRecaptcha = true;
|
||||
},
|
||||
|
||||
closeRecaptcha() {
|
||||
this.showRecaptcha = false;
|
||||
},
|
||||
|
||||
checkForSpam(data) {
|
||||
if (!data.recaptcha_html) return data;
|
||||
|
||||
this.recaptchaHTML = data.recaptcha_html;
|
||||
|
||||
const spamError = new Error(data.error_message);
|
||||
spamError.name = 'SpamError';
|
||||
spamError.message = 'SpamError';
|
||||
|
||||
throw spamError;
|
||||
},
|
||||
},
|
||||
};
|
|
@ -48,3 +48,10 @@ body.modal-open {
|
|||
display: block;
|
||||
}
|
||||
|
||||
.recaptcha-dialog .recaptcha-form {
|
||||
display: inline-block;
|
||||
|
||||
.recaptcha {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -201,8 +201,9 @@
|
|||
stroke-width: 1;
|
||||
}
|
||||
|
||||
.deploy-info-text {
|
||||
dominant-baseline: text-before-edge;
|
||||
.divider-line {
|
||||
stroke-width: 1;
|
||||
stroke: $gray-darkest;
|
||||
}
|
||||
|
||||
.prometheus-state {
|
||||
|
@ -312,6 +313,20 @@
|
|||
stroke: $gray-darker;
|
||||
}
|
||||
|
||||
.deploy-info-text {
|
||||
dominant-baseline: text-before-edge;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.deploy-info-text-link {
|
||||
font-family: $monospace_font;
|
||||
fill: $gl-link-color;
|
||||
|
||||
&:hover {
|
||||
fill: $gl-link-hover-color;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: $screen-sm-max) {
|
||||
.label-axis-text,
|
||||
.text-metric-usage,
|
||||
|
|
|
@ -5,7 +5,7 @@ class Admin::HealthCheckController < Admin::ApplicationController
|
|||
end
|
||||
|
||||
def reset_storage_health
|
||||
Gitlab::Git::Storage::CircuitBreaker.reset_all!
|
||||
Gitlab::Git::Storage::FailureInfo.reset_all!
|
||||
redirect_to admin_health_check_path,
|
||||
notice: _('Git storage health information has been reset')
|
||||
end
|
||||
|
|
|
@ -21,11 +21,11 @@ module IssuableActions
|
|||
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
recaptcha_check_with_fallback { render :edit }
|
||||
recaptcha_check_if_spammable { render :edit }
|
||||
end
|
||||
|
||||
format.json do
|
||||
render_entity_json
|
||||
recaptcha_check_if_spammable(false) { render_entity_json }
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -80,6 +80,12 @@ module IssuableActions
|
|||
|
||||
private
|
||||
|
||||
def recaptcha_check_if_spammable(should_redirect = true, &block)
|
||||
return yield unless @issuable.is_a? Spammable
|
||||
|
||||
recaptcha_check_with_fallback(should_redirect, &block)
|
||||
end
|
||||
|
||||
def render_conflict_response
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
|
|
|
@ -23,8 +23,8 @@ module SpammableActions
|
|||
@spam_config_loaded = Gitlab::Recaptcha.load_configurations!
|
||||
end
|
||||
|
||||
def recaptcha_check_with_fallback(&fallback)
|
||||
if spammable.valid?
|
||||
def recaptcha_check_with_fallback(should_redirect = true, &fallback)
|
||||
if should_redirect && spammable.valid?
|
||||
redirect_to spammable_path
|
||||
elsif render_recaptcha?
|
||||
ensure_spam_config_loaded!
|
||||
|
@ -33,7 +33,18 @@ module SpammableActions
|
|||
flash[:alert] = 'There was an error with the reCAPTCHA. Please solve the reCAPTCHA again.'
|
||||
end
|
||||
|
||||
render :verify
|
||||
respond_to do |format|
|
||||
format.html do
|
||||
render :verify
|
||||
end
|
||||
|
||||
format.json do
|
||||
locals = { spammable: spammable, script: false, has_submit: false }
|
||||
recaptcha_html = render_to_string(partial: 'shared/recaptcha_form', formats: :html, locals: locals)
|
||||
|
||||
render json: { recaptcha_html: recaptcha_html }
|
||||
end
|
||||
end
|
||||
else
|
||||
yield
|
||||
end
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
class HealthController < ActionController::Base
|
||||
protect_from_forgery with: :exception
|
||||
protect_from_forgery with: :exception, except: :storage_check
|
||||
include RequiresWhitelistedMonitoringClient
|
||||
|
||||
CHECKS = [
|
||||
|
@ -23,6 +23,15 @@ class HealthController < ActionController::Base
|
|||
render_check_results(results)
|
||||
end
|
||||
|
||||
def storage_check
|
||||
results = Gitlab::Git::Storage::Checker.check_all
|
||||
|
||||
render json: {
|
||||
check_interval: Gitlab::CurrentSettings.current_application_settings.circuitbreaker_check_interval,
|
||||
results: results
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def render_check_results(results)
|
||||
|
|
|
@ -124,17 +124,6 @@ module ApplicationSettingsHelper
|
|||
_('The number of attempts GitLab will make to access a storage.')
|
||||
end
|
||||
|
||||
def circuitbreaker_backoff_threshold_help_text
|
||||
_("The number of failures after which GitLab will start temporarily "\
|
||||
"disabling access to a storage shard on a host")
|
||||
end
|
||||
|
||||
def circuitbreaker_failure_wait_time_help_text
|
||||
_("When access to a storage fails. GitLab will prevent access to the "\
|
||||
"storage for the time specified here. This allows the filesystem to "\
|
||||
"recover. Repositories on failing shards are temporarly unavailable")
|
||||
end
|
||||
|
||||
def circuitbreaker_failure_reset_time_help_text
|
||||
_("The time in seconds GitLab will keep failure information. When no "\
|
||||
"failures occur during this time, information about the mount is reset.")
|
||||
|
@ -145,6 +134,11 @@ module ApplicationSettingsHelper
|
|||
"timeout error will be raised.")
|
||||
end
|
||||
|
||||
def circuitbreaker_check_interval_help_text
|
||||
_("The time in seconds between storage checks. When a previous check did "\
|
||||
"complete yet, GitLab will skip a check.")
|
||||
end
|
||||
|
||||
def visible_attributes
|
||||
[
|
||||
:admin_notification_email,
|
||||
|
@ -154,10 +148,9 @@ module ApplicationSettingsHelper
|
|||
:akismet_enabled,
|
||||
:auto_devops_enabled,
|
||||
:circuitbreaker_access_retries,
|
||||
:circuitbreaker_backoff_threshold,
|
||||
:circuitbreaker_check_interval,
|
||||
:circuitbreaker_failure_count_threshold,
|
||||
:circuitbreaker_failure_reset_time,
|
||||
:circuitbreaker_failure_wait_time,
|
||||
:circuitbreaker_storage_timeout,
|
||||
:clientside_sentry_dsn,
|
||||
:clientside_sentry_enabled,
|
||||
|
|
|
@ -18,16 +18,12 @@ module StorageHealthHelper
|
|||
current_failures = circuit_breaker.failure_count
|
||||
|
||||
translation_params = { number_of_failures: current_failures,
|
||||
maximum_failures: maximum_failures,
|
||||
number_of_seconds: circuit_breaker.failure_wait_time }
|
||||
maximum_failures: maximum_failures }
|
||||
|
||||
if circuit_breaker.circuit_broken?
|
||||
s_("%{number_of_failures} of %{maximum_failures} failures. GitLab will not "\
|
||||
"retry automatically. Reset storage information when the problem is "\
|
||||
"resolved.") % translation_params
|
||||
elsif circuit_breaker.backing_off?
|
||||
_("%{number_of_failures} of %{maximum_failures} failures. GitLab will "\
|
||||
"block access for %{number_of_seconds} seconds.") % translation_params
|
||||
else
|
||||
_("%{number_of_failures} of %{maximum_failures} failures. GitLab will "\
|
||||
"allow access on the next attempt.") % translation_params
|
||||
|
|
|
@ -153,11 +153,10 @@ class ApplicationSetting < ActiveRecord::Base
|
|||
presence: true,
|
||||
numericality: { greater_than_or_equal_to: 0 }
|
||||
|
||||
validates :circuitbreaker_backoff_threshold,
|
||||
:circuitbreaker_failure_count_threshold,
|
||||
:circuitbreaker_failure_wait_time,
|
||||
validates :circuitbreaker_failure_count_threshold,
|
||||
:circuitbreaker_failure_reset_time,
|
||||
:circuitbreaker_storage_timeout,
|
||||
:circuitbreaker_check_interval,
|
||||
presence: true,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
|
||||
|
@ -165,13 +164,6 @@ class ApplicationSetting < ActiveRecord::Base
|
|||
presence: true,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 1 }
|
||||
|
||||
validates_each :circuitbreaker_backoff_threshold do |record, attr, value|
|
||||
if value.to_i >= record.circuitbreaker_failure_count_threshold
|
||||
record.errors.add(attr, _("The circuitbreaker backoff threshold should be "\
|
||||
"lower than the failure count threshold"))
|
||||
end
|
||||
end
|
||||
|
||||
validates :gitaly_timeout_default,
|
||||
presence: true,
|
||||
numericality: { only_integer: true, greater_than_or_equal_to: 0 }
|
||||
|
|
|
@ -72,7 +72,7 @@ class Event < ActiveRecord::Base
|
|||
# We're using preload for "push_event_payload" as otherwise the association
|
||||
# is not always available (depending on the query being built).
|
||||
includes(:author, :project, project: :namespace)
|
||||
.preload(:target, :push_event_payload)
|
||||
.preload(:push_event_payload, target: :author)
|
||||
end
|
||||
|
||||
scope :for_milestone_id, ->(milestone_id) { where(target_type: "Milestone", target_id: milestone_id) }
|
||||
|
|
|
@ -40,6 +40,7 @@ class Namespace < ActiveRecord::Base
|
|||
namespace_path: true
|
||||
|
||||
validate :nesting_level_allowed
|
||||
validate :allowed_path_by_redirects
|
||||
|
||||
delegate :name, to: :owner, allow_nil: true, prefix: true
|
||||
|
||||
|
@ -257,4 +258,14 @@ class Namespace < ActiveRecord::Base
|
|||
Namespace.where(id: descendants.select(:id))
|
||||
.update_all(share_with_group_lock: true)
|
||||
end
|
||||
|
||||
def allowed_path_by_redirects
|
||||
return if path.nil?
|
||||
|
||||
errors.add(:path, "#{path} has been taken before. Please use another one") if namespace_previously_created_with_same_path?
|
||||
end
|
||||
|
||||
def namespace_previously_created_with_same_path?
|
||||
RedirectRoute.permanent.exists?(path: path)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -17,4 +17,32 @@ class RedirectRoute < ActiveRecord::Base
|
|||
|
||||
where(wheres, path, "#{sanitize_sql_like(path)}/%")
|
||||
end
|
||||
|
||||
scope :permanent, -> do
|
||||
if column_permanent_exists?
|
||||
where(permanent: true)
|
||||
else
|
||||
none
|
||||
end
|
||||
end
|
||||
|
||||
scope :temporary, -> do
|
||||
if column_permanent_exists?
|
||||
where(permanent: [false, nil])
|
||||
else
|
||||
all
|
||||
end
|
||||
end
|
||||
|
||||
default_value_for :permanent, false
|
||||
|
||||
def permanent=(value)
|
||||
if self.class.column_permanent_exists?
|
||||
super
|
||||
end
|
||||
end
|
||||
|
||||
def self.column_permanent_exists?
|
||||
ActiveRecord::Base.connection.column_exists?(:redirect_routes, :permanent)
|
||||
end
|
||||
end
|
||||
|
|
|
@ -8,6 +8,8 @@ class Route < ActiveRecord::Base
|
|||
presence: true,
|
||||
uniqueness: { case_sensitive: false }
|
||||
|
||||
validate :ensure_permanent_paths
|
||||
|
||||
after_create :delete_conflicting_redirects
|
||||
after_update :delete_conflicting_redirects, if: :path_changed?
|
||||
after_update :create_redirect_for_old_path
|
||||
|
@ -40,7 +42,7 @@ class Route < ActiveRecord::Base
|
|||
# We are not calling route.delete_conflicting_redirects here, in hopes
|
||||
# of avoiding deadlocks. The parent (self, in this method) already
|
||||
# called it, which deletes conflicts for all descendants.
|
||||
route.create_redirect(old_path) if attributes[:path]
|
||||
route.create_redirect(old_path, permanent: permanent_redirect?) if attributes[:path]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -50,16 +52,30 @@ class Route < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def conflicting_redirects
|
||||
RedirectRoute.matching_path_and_descendants(path)
|
||||
RedirectRoute.temporary.matching_path_and_descendants(path)
|
||||
end
|
||||
|
||||
def create_redirect(path)
|
||||
RedirectRoute.create(source: source, path: path)
|
||||
def create_redirect(path, permanent: false)
|
||||
RedirectRoute.create(source: source, path: path, permanent: permanent)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_redirect_for_old_path
|
||||
create_redirect(path_was) if path_changed?
|
||||
create_redirect(path_was, permanent: permanent_redirect?) if path_changed?
|
||||
end
|
||||
|
||||
def permanent_redirect?
|
||||
source_type != "Project"
|
||||
end
|
||||
|
||||
def ensure_permanent_paths
|
||||
return if path.nil?
|
||||
|
||||
errors.add(:path, "#{path} has been taken before. Please use another one") if conflicting_redirect_exists?
|
||||
end
|
||||
|
||||
def conflicting_redirect_exists?
|
||||
RedirectRoute.permanent.matching_path_and_descendants(path).exists?
|
||||
end
|
||||
end
|
||||
|
|
|
@ -1054,13 +1054,13 @@ class User < ActiveRecord::Base
|
|||
end
|
||||
|
||||
def todos_done_count(force: false)
|
||||
Rails.cache.fetch(['users', id, 'todos_done_count'], force: force) do
|
||||
Rails.cache.fetch(['users', id, 'todos_done_count'], force: force, expires_in: 20.minutes) do
|
||||
TodosFinder.new(self, state: :done).execute.count
|
||||
end
|
||||
end
|
||||
|
||||
def todos_pending_count(force: false)
|
||||
Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force) do
|
||||
Rails.cache.fetch(['users', id, 'todos_pending_count'], force: force, expires_in: 20.minutes) do
|
||||
TodosFinder.new(self, state: :pending).execute.count
|
||||
end
|
||||
end
|
||||
|
|
|
@ -12,18 +12,19 @@ module Ci
|
|||
def execute(source, ignore_skip_ci: false, save_on_errors: true, trigger_request: nil, schedule: nil, &block)
|
||||
@pipeline = Ci::Pipeline.new
|
||||
|
||||
command = OpenStruct.new(source: source,
|
||||
origin_ref: params[:ref],
|
||||
checkout_sha: params[:checkout_sha],
|
||||
after_sha: params[:after],
|
||||
before_sha: params[:before],
|
||||
trigger_request: trigger_request,
|
||||
schedule: schedule,
|
||||
ignore_skip_ci: ignore_skip_ci,
|
||||
save_incompleted: save_on_errors,
|
||||
seeds_block: block,
|
||||
project: project,
|
||||
current_user: current_user)
|
||||
command = Gitlab::Ci::Pipeline::Chain::Command.new(
|
||||
source: source,
|
||||
origin_ref: params[:ref],
|
||||
checkout_sha: params[:checkout_sha],
|
||||
after_sha: params[:after],
|
||||
before_sha: params[:before],
|
||||
trigger_request: trigger_request,
|
||||
schedule: schedule,
|
||||
ignore_skip_ci: ignore_skip_ci,
|
||||
save_incompleted: save_on_errors,
|
||||
seeds_block: block,
|
||||
project: project,
|
||||
current_user: current_user)
|
||||
|
||||
sequence = Gitlab::Ci::Pipeline::Chain::Sequence
|
||||
.new(pipeline, command, SEQUENCE)
|
||||
|
|
|
@ -545,6 +545,12 @@
|
|||
|
||||
%fieldset
|
||||
%legend Git Storage Circuitbreaker settings
|
||||
.form-group
|
||||
= f.label :circuitbreaker_check_interval, _('Check interval'), class: 'control-label col-sm-2'
|
||||
.col-sm-10
|
||||
= f.number_field :circuitbreaker_check_interval, class: 'form-control'
|
||||
.help-block
|
||||
= circuitbreaker_check_interval_help_text
|
||||
.form-group
|
||||
= f.label :circuitbreaker_access_retries, _('Number of access attempts'), class: 'control-label col-sm-2'
|
||||
.col-sm-10
|
||||
|
@ -557,18 +563,6 @@
|
|||
= f.number_field :circuitbreaker_storage_timeout, class: 'form-control'
|
||||
.help-block
|
||||
= circuitbreaker_storage_timeout_help_text
|
||||
.form-group
|
||||
= f.label :circuitbreaker_backoff_threshold, _('Number of failures before backing off'), class: 'control-label col-sm-2'
|
||||
.col-sm-10
|
||||
= f.number_field :circuitbreaker_backoff_threshold, class: 'form-control'
|
||||
.help-block
|
||||
= circuitbreaker_backoff_threshold_help_text
|
||||
.form-group
|
||||
= f.label :circuitbreaker_failure_wait_time, _('Seconds to wait after a storage failure'), class: 'control-label col-sm-2'
|
||||
.col-sm-10
|
||||
= f.number_field :circuitbreaker_failure_wait_time, class: 'form-control'
|
||||
.help-block
|
||||
= circuitbreaker_failure_wait_time_help_text
|
||||
.form-group
|
||||
= f.label :circuitbreaker_failure_count_threshold, _('Maximum git storage failures'), class: 'control-label col-sm-2'
|
||||
.col-sm-10
|
||||
|
|
|
@ -1,5 +1,4 @@
|
|||
- humanized_resource_name = spammable.class.model_name.human.downcase
|
||||
- resource_name = spammable.class.model_name.singular
|
||||
|
||||
%h3.page-title
|
||||
Anti-spam verification
|
||||
|
@ -8,16 +7,4 @@
|
|||
%p
|
||||
#{"We detected potential spam in the #{humanized_resource_name}. Please solve the reCAPTCHA to proceed."}
|
||||
|
||||
= form_for form do |f|
|
||||
.recaptcha
|
||||
- params[resource_name].each do |field, value|
|
||||
= hidden_field(resource_name, field, value: value)
|
||||
= hidden_field_tag(:spam_log_id, spammable.spam_log.id)
|
||||
= hidden_field_tag(:recaptcha_verification, true)
|
||||
= recaptcha_tags
|
||||
|
||||
-# Yields a block with given extra params.
|
||||
= yield
|
||||
|
||||
.row-content-block.footer-block
|
||||
= f.submit "Submit #{humanized_resource_name}", class: 'btn btn-create'
|
||||
= render 'shared/recaptcha_form', spammable: spammable
|
||||
|
|
|
@ -19,4 +19,6 @@
|
|||
"empty-loading-svg-path": image_path('illustrations/monitoring/loading'),
|
||||
"empty-unable-to-connect-svg-path": image_path('illustrations/monitoring/unable_to_connect'),
|
||||
"additional-metrics": additional_metrics_project_environment_path(@project, @environment, format: :json),
|
||||
"project-path": project_path(@project),
|
||||
"tags-path": project_tags_path(@project),
|
||||
"has-metrics": "#{@environment.has_metrics?}", deployment_endpoint: project_environment_deployments_path(@project, @environment, format: :json) } }
|
||||
|
|
19
app/views/shared/_recaptcha_form.html.haml
Normal file
19
app/views/shared/_recaptcha_form.html.haml
Normal file
|
@ -0,0 +1,19 @@
|
|||
- resource_name = spammable.class.model_name.singular
|
||||
- humanized_resource_name = spammable.class.model_name.human.downcase
|
||||
- script = local_assigns.fetch(:script, true)
|
||||
- has_submit = local_assigns.fetch(:has_submit, true)
|
||||
|
||||
= form_for resource_name, method: :post, html: { class: 'recaptcha-form js-recaptcha-form' } do |f|
|
||||
.recaptcha
|
||||
- params[resource_name].each do |field, value|
|
||||
= hidden_field(resource_name, field, value: value)
|
||||
= hidden_field_tag(:spam_log_id, spammable.spam_log.id)
|
||||
= hidden_field_tag(:recaptcha_verification, true)
|
||||
= recaptcha_tags script: script, callback: 'recaptchaDialogCallback'
|
||||
|
||||
-# Yields a block with given extra params.
|
||||
= yield
|
||||
|
||||
- if has_submit
|
||||
.row-content-block.footer-block
|
||||
= f.submit "Submit #{humanized_resource_name}", class: 'btn btn-create'
|
11
bin/storage_check
Executable file
11
bin/storage_check
Executable file
|
@ -0,0 +1,11 @@
|
|||
#!/usr/bin/env ruby
|
||||
|
||||
require 'optparse'
|
||||
require 'net/http'
|
||||
require 'json'
|
||||
require 'socket'
|
||||
require 'logger'
|
||||
|
||||
require_relative '../lib/gitlab/storage_check'
|
||||
|
||||
Gitlab::StorageCheck::CLI.start!(ARGV)
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add recaptcha modal to issue updates detected as spam
|
||||
merge_request: 15408
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Allow git pull/push on group/user/project redirects
|
||||
merge_request: 15670
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Changed the deploy markers on the prometheus dashboard to be more verbose
|
||||
merge_request: 38032
|
||||
author:
|
||||
type: changed
|
5
changelogs/unreleased/40031-include-assset_sync-gem.yml
Normal file
5
changelogs/unreleased/40031-include-assset_sync-gem.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add assets_sync gem to Gemfile
|
||||
merge_request: 15734
|
||||
author:
|
||||
type: added
|
6
changelogs/unreleased/anchor-issue-references.yml
Normal file
6
changelogs/unreleased/anchor-issue-references.yml
Normal file
|
@ -0,0 +1,6 @@
|
|||
---
|
||||
title: Fix false positive issue references in merge requests caused by header anchor
|
||||
links.
|
||||
merge_request:
|
||||
author:
|
||||
type: fixed
|
5
changelogs/unreleased/bvl-circuitbreaker-process.yml
Normal file
5
changelogs/unreleased/bvl-circuitbreaker-process.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Monitor NFS shards for circuitbreaker in a separate process
|
||||
merge_request: 15426
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Add docs for why you might be signed out when using the Remember me token
|
||||
merge_request: 15756
|
||||
author:
|
||||
type: other
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix N+1 query when displaying events
|
||||
merge_request:
|
||||
author:
|
||||
type: performance
|
5
changelogs/unreleased/sh-fix-import-rake-task.yml
Normal file
5
changelogs/unreleased/sh-fix-import-rake-task.yml
Normal file
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix gitlab:import:repos Rake task moving repositories into the wrong location
|
||||
merge_request:
|
||||
author:
|
||||
type: fixed
|
31
config/initializers/asset_sync.rb
Normal file
31
config/initializers/asset_sync.rb
Normal file
|
@ -0,0 +1,31 @@
|
|||
AssetSync.configure do |config|
|
||||
# Disable the asset_sync gem by default. If it is enabled, but not configured,
|
||||
# asset_sync will cause the build to fail.
|
||||
config.enabled = if ENV.has_key?('ASSET_SYNC_ENABLED')
|
||||
ENV['ASSET_SYNC_ENABLED'] == 'true'
|
||||
else
|
||||
false
|
||||
end
|
||||
|
||||
# Pulled from https://github.com/AssetSync/asset_sync/blob/v2.2.0/lib/asset_sync/engine.rb#L15-L40
|
||||
# This allows us to disable asset_sync by default and configure through environment variables
|
||||
# Updates to asset_sync gem should be checked
|
||||
config.fog_provider = ENV['FOG_PROVIDER'] if ENV.has_key?('FOG_PROVIDER')
|
||||
config.fog_directory = ENV['FOG_DIRECTORY'] if ENV.has_key?('FOG_DIRECTORY')
|
||||
config.fog_region = ENV['FOG_REGION'] if ENV.has_key?('FOG_REGION')
|
||||
|
||||
config.aws_access_key_id = ENV['AWS_ACCESS_KEY_ID'] if ENV.has_key?('AWS_ACCESS_KEY_ID')
|
||||
config.aws_secret_access_key = ENV['AWS_SECRET_ACCESS_KEY'] if ENV.has_key?('AWS_SECRET_ACCESS_KEY')
|
||||
config.aws_reduced_redundancy = ENV['AWS_REDUCED_REDUNDANCY'] == true if ENV.has_key?('AWS_REDUCED_REDUNDANCY')
|
||||
|
||||
config.rackspace_username = ENV['RACKSPACE_USERNAME'] if ENV.has_key?('RACKSPACE_USERNAME')
|
||||
config.rackspace_api_key = ENV['RACKSPACE_API_KEY'] if ENV.has_key?('RACKSPACE_API_KEY')
|
||||
|
||||
config.google_storage_access_key_id = ENV['GOOGLE_STORAGE_ACCESS_KEY_ID'] if ENV.has_key?('GOOGLE_STORAGE_ACCESS_KEY_ID')
|
||||
config.google_storage_secret_access_key = ENV['GOOGLE_STORAGE_SECRET_ACCESS_KEY'] if ENV.has_key?('GOOGLE_STORAGE_SECRET_ACCESS_KEY')
|
||||
|
||||
config.existing_remote_files = ENV['ASSET_SYNC_EXISTING_REMOTE_FILES'] || "keep"
|
||||
|
||||
config.gzip_compression = (ENV['ASSET_SYNC_GZIP_COMPRESSION'] == 'true') if ENV.has_key?('ASSET_SYNC_GZIP_COMPRESSION')
|
||||
config.manifest = (ENV['ASSET_SYNC_MANIFEST'] == 'true') if ENV.has_key?('ASSET_SYNC_MANIFEST')
|
||||
end
|
|
@ -42,6 +42,7 @@ Rails.application.routes.draw do
|
|||
scope path: '-' do
|
||||
get 'liveness' => 'health#liveness'
|
||||
get 'readiness' => 'health#readiness'
|
||||
post 'storage_check' => 'health#storage_check'
|
||||
resources :metrics, only: [:index]
|
||||
mount Peek::Railtie => '/peek'
|
||||
|
||||
|
|
|
@ -0,0 +1,20 @@
|
|||
class AddCircuitbreakerCheckIntervalToApplicationSettings < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
# Set this constant to true if this migration requires downtime.
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_column_with_default :application_settings,
|
||||
:circuitbreaker_check_interval,
|
||||
:integer,
|
||||
default: 1
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column :application_settings,
|
||||
:circuitbreaker_check_interval
|
||||
end
|
||||
end
|
18
db/migrate/20171204204233_add_permanent_to_redirect_route.rb
Normal file
18
db/migrate/20171204204233_add_permanent_to_redirect_route.rb
Normal file
|
@ -0,0 +1,18 @@
|
|||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class AddPermanentToRedirectRoute < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
disable_ddl_transaction!
|
||||
|
||||
# Set this constant to true if this migration requires downtime.
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
add_column(:redirect_routes, :permanent, :boolean)
|
||||
end
|
||||
|
||||
def down
|
||||
remove_column(:redirect_routes, :permanent)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,19 @@
|
|||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class AddPermanentIndexToRedirectRoute < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
# Set this constant to true if this migration requires downtime.
|
||||
DOWNTIME = false
|
||||
|
||||
disable_ddl_transaction!
|
||||
|
||||
def up
|
||||
add_concurrent_index(:redirect_routes, :permanent)
|
||||
end
|
||||
|
||||
def down
|
||||
remove_concurrent_index(:redirect_routes, :permanent) if index_exists?(:redirect_routes, :permanent)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,34 @@
|
|||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class UpdateCircuitbreakerDefaults < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
class ApplicationSetting < ActiveRecord::Base; end
|
||||
|
||||
def up
|
||||
change_column_default :application_settings,
|
||||
:circuitbreaker_failure_count_threshold,
|
||||
3
|
||||
change_column_default :application_settings,
|
||||
:circuitbreaker_storage_timeout,
|
||||
15
|
||||
|
||||
ApplicationSetting.update_all(circuitbreaker_failure_count_threshold: 3,
|
||||
circuitbreaker_storage_timeout: 15)
|
||||
end
|
||||
|
||||
def down
|
||||
change_column_default :application_settings,
|
||||
:circuitbreaker_failure_count_threshold,
|
||||
160
|
||||
change_column_default :application_settings,
|
||||
:circuitbreaker_storage_timeout,
|
||||
30
|
||||
|
||||
ApplicationSetting.update_all(circuitbreaker_failure_count_threshold: 160,
|
||||
circuitbreaker_storage_timeout: 30)
|
||||
end
|
||||
end
|
|
@ -0,0 +1,26 @@
|
|||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class RemoveOldCircuitbreakerConfig < ActiveRecord::Migration
|
||||
include Gitlab::Database::MigrationHelpers
|
||||
|
||||
DOWNTIME = false
|
||||
|
||||
def up
|
||||
remove_column :application_settings,
|
||||
:circuitbreaker_backoff_threshold
|
||||
remove_column :application_settings,
|
||||
:circuitbreaker_failure_wait_time
|
||||
end
|
||||
|
||||
def down
|
||||
add_column :application_settings,
|
||||
:circuitbreaker_backoff_threshold,
|
||||
:integer,
|
||||
default: 80
|
||||
add_column :application_settings,
|
||||
:circuitbreaker_failure_wait_time,
|
||||
:integer,
|
||||
default: 30
|
||||
end
|
||||
end
|
11
db/schema.rb
11
db/schema.rb
|
@ -11,7 +11,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema.define(version: 20171205190711) do
|
||||
ActiveRecord::Schema.define(version: 20171206221519) do
|
||||
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
@ -135,12 +135,10 @@ ActiveRecord::Schema.define(version: 20171205190711) do
|
|||
t.boolean "hashed_storage_enabled", default: false, null: false
|
||||
t.boolean "project_export_enabled", default: true, null: false
|
||||
t.boolean "auto_devops_enabled", default: false, null: false
|
||||
t.integer "circuitbreaker_failure_count_threshold", default: 160
|
||||
t.integer "circuitbreaker_failure_wait_time", default: 30
|
||||
t.integer "circuitbreaker_failure_count_threshold", default: 3
|
||||
t.integer "circuitbreaker_failure_reset_time", default: 1800
|
||||
t.integer "circuitbreaker_storage_timeout", default: 30
|
||||
t.integer "circuitbreaker_storage_timeout", default: 15
|
||||
t.integer "circuitbreaker_access_retries", default: 3
|
||||
t.integer "circuitbreaker_backoff_threshold", default: 80
|
||||
t.boolean "throttle_unauthenticated_enabled", default: false, null: false
|
||||
t.integer "throttle_unauthenticated_requests_per_period", default: 3600, null: false
|
||||
t.integer "throttle_unauthenticated_period_in_seconds", default: 3600, null: false
|
||||
|
@ -150,6 +148,7 @@ ActiveRecord::Schema.define(version: 20171205190711) do
|
|||
t.boolean "throttle_authenticated_web_enabled", default: false, null: false
|
||||
t.integer "throttle_authenticated_web_requests_per_period", default: 7200, null: false
|
||||
t.integer "throttle_authenticated_web_period_in_seconds", default: 3600, null: false
|
||||
t.integer "circuitbreaker_check_interval", default: 1, null: false
|
||||
t.boolean "password_authentication_enabled_for_web"
|
||||
t.boolean "password_authentication_enabled_for_git", default: true
|
||||
t.integer "gitaly_timeout_default", default: 55, null: false
|
||||
|
@ -1527,10 +1526,12 @@ ActiveRecord::Schema.define(version: 20171205190711) do
|
|||
t.string "path", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.boolean "permanent"
|
||||
end
|
||||
|
||||
add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path", unique: true, using: :btree
|
||||
add_index "redirect_routes", ["path"], name: "index_redirect_routes_on_path_text_pattern_ops", using: :btree, opclasses: {"path"=>"varchar_pattern_ops"}
|
||||
add_index "redirect_routes", ["permanent"], name: "index_redirect_routes_on_permanent", using: :btree
|
||||
add_index "redirect_routes", ["source_type", "source_id"], name: "index_redirect_routes_on_source_type_and_source_id", using: :btree
|
||||
|
||||
create_table "releases", force: :cascade do |t|
|
||||
|
|
|
@ -70,10 +70,9 @@ PUT /application/settings
|
|||
| `akismet_api_key` | string | no | API key for akismet spam protection |
|
||||
| `akismet_enabled` | boolean | no | Enable or disable akismet spam protection |
|
||||
| `circuitbreaker_access_retries | integer | no | The number of attempts GitLab will make to access a storage. |
|
||||
| `circuitbreaker_backoff_threshold | integer | no | The number of failures after which GitLab will start temporarily disabling access to a storage shard on a host. |
|
||||
| `circuitbreaker_check_interval` | integer | no | Number of seconds in between storage checks. |
|
||||
| `circuitbreaker_failure_count_threshold` | integer | no | The number of failures of after which GitLab will completely prevent access to the storage. |
|
||||
| `circuitbreaker_failure_reset_time` | integer | no | Time in seconds GitLab will keep storage failure information. When no failures occur during this time, the failure information is reset. |
|
||||
| `circuitbreaker_failure_wait_time` | integer | no | Time in seconds GitLab will block access to a failing storage to allow it to recover. |
|
||||
| `circuitbreaker_storage_timeout` | integer | no | Seconds to wait for a storage access attempt |
|
||||
| `clientside_sentry_dsn` | string | no | Required if `clientside_sentry_dsn` is enabled |
|
||||
| `clientside_sentry_enabled` | boolean | no | Enable Sentry error reporting for the client side |
|
||||
|
|
|
@ -16,7 +16,8 @@ comments: false
|
|||
- [GitLab core team & GitLab Inc. contribution process](https://gitlab.com/gitlab-org/gitlab-ce/blob/master/PROCESS.md)
|
||||
- [Generate a changelog entry with `bin/changelog`](changelog.md)
|
||||
- [Code review guidelines](code_review.md) for reviewing code and having code reviewed.
|
||||
- [Limit conflicts with EE when developing on CE](limit_ee_conflicts.md)
|
||||
- [Automatic CE->EE merge](automatic_ce_ee_merge.md)
|
||||
- [Guidelines for implementing Enterprise Edition features](ee_features.md)
|
||||
|
||||
## UX and frontend guides
|
||||
|
||||
|
|
93
doc/development/automatic_ce_ee_merge.md
Normal file
93
doc/development/automatic_ce_ee_merge.md
Normal file
|
@ -0,0 +1,93 @@
|
|||
# Automatic CE->EE merge
|
||||
|
||||
GitLab Community Edition is merged automatically every 3 hours into the
|
||||
Enterprise Edition (look for the [`CE Upstream` merge requests]).
|
||||
|
||||
This merge is done automatically in a
|
||||
[scheduled pipeline](https://gitlab.com/gitlab-org/release-tools/-/jobs/43201679).
|
||||
If a merge is already in progress, the job [doesn't create a new one](https://gitlab.com/gitlab-org/release-tools/-/jobs/43157687).
|
||||
|
||||
**If you are pinged in a `CE Upstream` merge request to resolve a conflict,
|
||||
please resolve the conflict as soon as possible or ask someone else to do it!**
|
||||
|
||||
>**Note:**
|
||||
It's ok to resolve more conflicts than the one that you are asked to resolve. In
|
||||
that case, it's a good habit to ask for a double-check on your resolution by
|
||||
someone who is familiar with the code you touched.
|
||||
|
||||
[`CE Upstream` merge requests]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests?label_name%5B%5D=CE+upstream
|
||||
|
||||
### Always merge EE merge requests before their CE counterparts
|
||||
|
||||
**In order to avoid conflicts in the CE->EE merge, you should always merge the
|
||||
EE version of your CE merge request first, if present.**
|
||||
|
||||
The rationale for this is that as CE->EE merges are done automatically every few
|
||||
hours, it can happen that:
|
||||
|
||||
1. A CE merge request that needs EE-specific changes is merged
|
||||
1. The automatic CE->EE merge happens
|
||||
1. Conflicts due to the CE merge request occur since its EE merge request isn't
|
||||
merged yet
|
||||
1. The automatic merge bot will ping someone to resolve the conflict **that are
|
||||
already resolved in the EE merge request that isn't merged yet**
|
||||
|
||||
That's a waste of time, and that's why you should merge EE merge request before
|
||||
their CE counterpart.
|
||||
|
||||
## Avoiding CE->EE merge conflicts beforehand
|
||||
|
||||
To avoid the conflicts beforehand, check out the
|
||||
[Guidelines for implementing Enterprise Edition features](ee_features.md).
|
||||
|
||||
In any case, the CI `ee_compat_check` job will tell you if you need to open an
|
||||
EE version of your CE merge request.
|
||||
|
||||
### Conflicts detection in CE merge requests
|
||||
|
||||
For each commit (except on `master`), the `ee_compat_check` CI job tries to
|
||||
detect if the current branch's changes will conflict during the CE->EE merge.
|
||||
|
||||
The job reports what files are conflicting and how to setup a merge request
|
||||
against EE.
|
||||
|
||||
#### How the job works
|
||||
|
||||
1. Generates the diff between your branch and current CE `master`
|
||||
1. Tries to apply it to current EE `master`
|
||||
1. If it applies cleanly, the job succeeds, otherwise...
|
||||
1. Detects a branch with the `ee-` prefix or `-ee` suffix in EE
|
||||
1. If it exists, generate the diff between this branch and current EE `master`
|
||||
1. Tries to apply it to current EE `master`
|
||||
1. If it applies cleanly, the job succeeds
|
||||
|
||||
In the case where the job fails, it means you should create a `ee-<ce_branch>`
|
||||
or `<ce_branch>-ee` branch, push it to EE and open a merge request against EE
|
||||
`master`.
|
||||
At this point if you retry the failing job in your CE merge request, it should
|
||||
now pass.
|
||||
|
||||
Notes:
|
||||
|
||||
- This task is not a silver-bullet, its current goal is to bring awareness to
|
||||
developers that their work needs to be ported to EE.
|
||||
- Community contributors shouldn't be required to submit merge requests against
|
||||
EE, but reviewers should take actions by either creating such EE merge request
|
||||
or asking a GitLab developer to do it **before the merge request is merged**.
|
||||
- If you branch is too far behind `master`, the job will fail. In that case you
|
||||
should rebase your branch upon latest `master`.
|
||||
- Code reviews for merge requests often consist of multiple iterations of
|
||||
feedback and fixes. There is no need to update your EE MR after each
|
||||
iteration. Instead, create an EE MR as soon as you see the
|
||||
`ee_compat_check` job failing. After you receive the final approval
|
||||
from a Maintainer (but **before the CE MR is merged**) update the EE MR.
|
||||
This helps to identify significant conflicts sooner, but also reduces the
|
||||
number of times you have to resolve conflicts.
|
||||
- Please remember to
|
||||
[always have your EE merge request merged before the CE version](#always-merge-ee-merge-requests-before-their-ce-counterparts).
|
||||
- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html)
|
||||
to avoid resolving the same conflicts multiple times.
|
||||
|
||||
---
|
||||
|
||||
[Return to Development documentation](README.md)
|
|
@ -1,4 +1,4 @@
|
|||
# Guidelines for implementing Enterprise Edition feature
|
||||
# Guidelines for implementing Enterprise Edition features
|
||||
|
||||
- **Write the code and the tests.**: As with any code, EE features should have
|
||||
good test coverage to prevent regressions.
|
||||
|
@ -380,3 +380,9 @@ to avoid conflicts during CE to EE merge.
|
|||
}
|
||||
}
|
||||
```
|
||||
|
||||
## gitlab-svgs
|
||||
|
||||
Conflicts in `app/assets/images/icons.json` or `app/assets/images/icons.svg` can
|
||||
be resolved simply by regenerating those assets with
|
||||
[`yarn run svg`](https://gitlab.com/gitlab-org/gitlab-svgs).
|
||||
|
|
|
@ -1,347 +0,0 @@
|
|||
# Limit conflicts with EE when developing on CE
|
||||
|
||||
This guide contains best-practices for avoiding conflicts between CE and EE.
|
||||
|
||||
## Daily CE Upstream merge
|
||||
|
||||
GitLab Community Edition is merged daily into the Enterprise Edition (look for
|
||||
the [`CE Upstream` merge requests]). The daily merge is currently done manually
|
||||
by four individuals.
|
||||
|
||||
**If a developer pings you in a `CE Upstream` merge request for help with
|
||||
resolving conflicts, please help them because it means that you didn't do your
|
||||
job to reduce the conflicts nor to ease their resolution in the first place!**
|
||||
|
||||
To avoid the conflicts beforehand when working on CE, there are a few tools and
|
||||
techniques that can help you:
|
||||
|
||||
- know what are the usual types of conflicts and how to prevent them
|
||||
- the CI `rake ee_compat_check` job tells you if you need to open an EE-version
|
||||
of your CE merge request
|
||||
|
||||
[`CE Upstream` merge requests]: https://gitlab.com/gitlab-org/gitlab-ee/merge_requests?label_name%5B%5D=CE+upstream
|
||||
|
||||
## Check the status of the CI `rake ee_compat_check` job
|
||||
|
||||
For each commit (except on `master`), the `rake ee_compat_check` CI job tries to
|
||||
detect if the current branch's changes will conflict during the CE->EE merge.
|
||||
|
||||
The job reports what files are conflicting and how to setup a merge request
|
||||
against EE. Here is roughly how it works:
|
||||
|
||||
1. Generates the diff between your branch and current CE `master`
|
||||
1. Tries to apply it to current EE `master`
|
||||
1. If it applies cleanly, the job succeeds, otherwise...
|
||||
1. Detects a branch with the `-ee` suffix in EE
|
||||
1. If it exists, generate the diff between this branch and current EE `master`
|
||||
1. Tries to apply it to current EE `master`
|
||||
1. If it applies cleanly, the job succeeds
|
||||
|
||||
In the case where the job fails, it means you should create a `<ce_branch>-ee`
|
||||
branch, push it to EE and open a merge request against EE `master`. At this
|
||||
point if you retry the failing job in your CE merge request, it should now pass.
|
||||
|
||||
Notes:
|
||||
|
||||
- This task is not a silver-bullet, its current goal is to bring awareness to
|
||||
developers that their work needs to be ported to EE.
|
||||
- Community contributors shouldn't submit merge requests against EE, but
|
||||
reviewers should take actions by either creating such EE merge request or
|
||||
asking a GitLab developer to do it once the merge request is merged.
|
||||
- If you branch is more than 500 commits behind `master`, the job will fail and
|
||||
you should rebase your branch upon latest `master`.
|
||||
- Code reviews for merge requests often consist of multiple iterations of
|
||||
feedback and fixes. There is no need to update your EE MR after each
|
||||
iteration. Instead, create an EE MR as soon as you see the
|
||||
`rake ee_compat_check` job failing. After you receive the final acceptance
|
||||
from a Maintainer (but before the CE MR is merged) update the EE MR.
|
||||
This helps to identify significant conflicts sooner, but also reduces the
|
||||
number of times you have to resolve conflicts.
|
||||
- You can use [`git rerere`](https://git-scm.com/blog/2010/03/08/rerere.html)
|
||||
to avoid resolving the same conflicts multiple times.
|
||||
|
||||
## Possible type of conflicts
|
||||
|
||||
### Controllers
|
||||
|
||||
#### List or arrays are augmented in EE
|
||||
|
||||
In controllers, the most common type of conflict is with `before_action` that
|
||||
has a list of actions in CE but EE adds some actions to that list.
|
||||
|
||||
The same problem often occurs for `params.require` / `params.permit` calls.
|
||||
|
||||
##### Mitigations
|
||||
|
||||
Separate CE and EE actions/keywords. For instance for `params.require` in
|
||||
`ProjectsController`:
|
||||
|
||||
```ruby
|
||||
def project_params
|
||||
params.require(:project).permit(project_params_ce)
|
||||
# On EE, this is always:
|
||||
# params.require(:project).permit(project_params_ce << project_params_ee)
|
||||
end
|
||||
|
||||
# Always returns an array of symbols, created however best fits the use case.
|
||||
# It _should_ be sorted alphabetically.
|
||||
def project_params_ce
|
||||
%i[
|
||||
description
|
||||
name
|
||||
path
|
||||
]
|
||||
end
|
||||
|
||||
# (On EE)
|
||||
def project_params_ee
|
||||
%i[
|
||||
approvals_before_merge
|
||||
approver_group_ids
|
||||
approver_ids
|
||||
...
|
||||
]
|
||||
end
|
||||
```
|
||||
|
||||
#### Additional condition(s) in EE
|
||||
|
||||
For instance for LDAP:
|
||||
|
||||
```diff
|
||||
def destroy
|
||||
@key = current_user.keys.find(params[:id])
|
||||
- @key.destroy
|
||||
+ @key.destroy unless @key.is_a? LDAPKey
|
||||
|
||||
respond_to do |format|
|
||||
```
|
||||
|
||||
Or for Geo:
|
||||
|
||||
```diff
|
||||
def after_sign_out_path_for(resource)
|
||||
- current_application_settings.after_sign_out_path.presence || new_user_session_path
|
||||
+ if Gitlab::Geo.secondary?
|
||||
+ Gitlab::Geo.primary_node.oauth_logout_url(@geo_logout_state)
|
||||
+ else
|
||||
+ current_application_settings.after_sign_out_path.presence || new_user_session_path
|
||||
+ end
|
||||
end
|
||||
```
|
||||
|
||||
Or even for audit log:
|
||||
|
||||
```diff
|
||||
def approve_access_request
|
||||
- Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute
|
||||
+ member = Members::ApproveAccessRequestService.new(membershipable, current_user, params).execute
|
||||
+
|
||||
+ log_audit_event(member, action: :create)
|
||||
|
||||
redirect_to polymorphic_url([membershipable, :members])
|
||||
end
|
||||
```
|
||||
|
||||
### Views
|
||||
|
||||
#### Additional view code in EE
|
||||
|
||||
A block of code added in CE conflicts because there is already another block
|
||||
at the same place in EE
|
||||
|
||||
##### Mitigations
|
||||
|
||||
Blocks of code that are EE-specific should be moved to partials as much as
|
||||
possible to avoid conflicts with big chunks of HAML code that that are not fun
|
||||
to resolve when you add the indentation to the equation.
|
||||
|
||||
For instance this kind of thing:
|
||||
|
||||
```haml
|
||||
.form-group.detail-page-description
|
||||
= form.label :description, 'Description', class: 'control-label'
|
||||
.col-sm-10
|
||||
= render layout: 'projects/md_preview', locals: { preview_class: "md-preview", referenced_users: true } do
|
||||
= render 'projects/zen', f: form, attr: :description,
|
||||
classes: 'note-textarea',
|
||||
placeholder: "Write a comment or drag your files here...",
|
||||
supports_quick_actions: !issuable.persisted?
|
||||
= render 'projects/notes/hints', supports_quick_actions: !issuable.persisted?
|
||||
.clearfix
|
||||
.error-alert
|
||||
- if issuable.is_a?(Issue)
|
||||
.form-group
|
||||
.col-sm-offset-2.col-sm-10
|
||||
.checkbox
|
||||
= form.label :confidential do
|
||||
= form.check_box :confidential
|
||||
This issue is confidential and should only be visible to team members with at least Reporter access.
|
||||
- if can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project)
|
||||
- has_due_date = issuable.has_attribute?(:due_date)
|
||||
%hr
|
||||
.row
|
||||
%div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") }
|
||||
.form-group.issue-assignee
|
||||
= form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
|
||||
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
|
||||
.issuable-form-select-holder
|
||||
- if issuable.assignee_id
|
||||
= form.hidden_field :assignee_id
|
||||
= dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
|
||||
placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
|
||||
.form-group.issue-milestone
|
||||
= form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}"
|
||||
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
|
||||
.issuable-form-select-holder
|
||||
= render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
|
||||
.form-group
|
||||
- has_labels = @labels && @labels.any?
|
||||
= form.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}"
|
||||
= form.hidden_field :label_ids, multiple: true, value: ''
|
||||
.col-sm-10{ class: "#{"col-lg-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" }
|
||||
.issuable-form-select-holder
|
||||
= render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label"
|
||||
- if issuable.respond_to?(:weight)
|
||||
- weight_options = Issue.weight_options
|
||||
- weight_options.delete(Issue::WEIGHT_ALL)
|
||||
- weight_options.delete(Issue::WEIGHT_ANY)
|
||||
.form-group
|
||||
= form.label :label_ids, class: "control-label #{"col-lg-4" if has_due_date}" do
|
||||
Weight
|
||||
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
|
||||
.issuable-form-select-holder
|
||||
- if issuable.weight
|
||||
= form.hidden_field :weight
|
||||
= dropdown_tag(issuable.weight || "Weight", options: { title: "Select weight", toggle_class: 'js-weight-select js-issuable-form-weight', dropdown_class: "dropdown-menu-selectable dropdown-menu-weight",
|
||||
placeholder: "Search weight", data: { field_name: "#{issuable.class.model_name.param_key}[weight]" , default_label: "Weight" } }) do
|
||||
%ul
|
||||
- weight_options.each do |weight|
|
||||
%li
|
||||
%a{href: "#", data: { id: weight, none: weight === Issue::WEIGHT_NONE }, class: ("is-active" if issuable.weight == weight)}
|
||||
= weight
|
||||
- if has_due_date
|
||||
.col-lg-6
|
||||
.form-group
|
||||
= form.label :due_date, "Due date", class: "control-label"
|
||||
.col-sm-10
|
||||
.issuable-form-select-holder
|
||||
= form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date"
|
||||
```
|
||||
|
||||
could be simplified by using partials:
|
||||
|
||||
```haml
|
||||
= render 'shared/issuable/form/description', issuable: issuable, form: form
|
||||
|
||||
- if issuable.respond_to?(:confidential)
|
||||
.form-group
|
||||
.col-sm-offset-2.col-sm-10
|
||||
.checkbox
|
||||
= form.label :confidential do
|
||||
= form.check_box :confidential
|
||||
This issue is confidential and should only be visible to team members with at least Reporter access.
|
||||
|
||||
= render 'shared/issuable/form/metadata', issuable: issuable, form: form
|
||||
```
|
||||
|
||||
and then the `app/views/shared/issuable/form/_metadata.html.haml` could be as follows:
|
||||
|
||||
```haml
|
||||
- issuable = local_assigns.fetch(:issuable)
|
||||
|
||||
- return unless can?(current_user, :"admin_#{issuable.to_ability_name}", issuable.project)
|
||||
|
||||
- has_due_date = issuable.has_attribute?(:due_date)
|
||||
- has_labels = @labels && @labels.any?
|
||||
- form = local_assigns.fetch(:form)
|
||||
|
||||
%hr
|
||||
.row
|
||||
%div{ class: (has_due_date ? "col-lg-6" : "col-sm-12") }
|
||||
.form-group.issue-assignee
|
||||
= form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
|
||||
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
|
||||
.issuable-form-select-holder
|
||||
- if issuable.assignee_id
|
||||
= form.hidden_field :assignee_id
|
||||
= dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
|
||||
placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
|
||||
.form-group.issue-milestone
|
||||
= form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}"
|
||||
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
|
||||
.issuable-form-select-holder
|
||||
= render "shared/issuable/milestone_dropdown", selected: issuable.milestone, name: "#{issuable.class.model_name.param_key}[milestone_id]", show_any: false, show_upcoming: false, extra_class: "js-issuable-form-dropdown js-dropdown-keep-input", dropdown_title: "Select milestone"
|
||||
.form-group
|
||||
- has_labels = @labels && @labels.any?
|
||||
= form.label :label_ids, "Labels", class: "control-label #{"col-lg-4" if has_due_date}"
|
||||
= form.hidden_field :label_ids, multiple: true, value: ''
|
||||
.col-sm-10{ class: "#{"col-lg-8" if has_due_date} #{'issuable-form-padding-top' if !has_labels}" }
|
||||
.issuable-form-select-holder
|
||||
= render "shared/issuable/label_dropdown", classes: ["js-issuable-form-dropdown"], selected: issuable.labels, data_options: { field_name: "#{issuable.class.model_name.param_key}[label_ids][]", show_any: false }, dropdown_title: "Select label"
|
||||
|
||||
= render "shared/issuable/form/weight", issuable: issuable, form: form
|
||||
|
||||
- if has_due_date
|
||||
.col-lg-6
|
||||
.form-group
|
||||
= form.label :due_date, "Due date", class: "control-label"
|
||||
.col-sm-10
|
||||
.issuable-form-select-holder
|
||||
= form.text_field :due_date, id: "issuable-due-date", class: "datepicker form-control", placeholder: "Select due date"
|
||||
```
|
||||
|
||||
and then the `app/views/shared/issuable/form/_weight.html.haml` could be as follows:
|
||||
|
||||
```haml
|
||||
- issuable = local_assigns.fetch(:issuable)
|
||||
|
||||
- return unless issuable.respond_to?(:weight)
|
||||
|
||||
- has_due_date = issuable.has_attribute?(:due_date)
|
||||
- form = local_assigns.fetch(:form)
|
||||
|
||||
.form-group
|
||||
= form.label :label_ids, class: "control-label #{"col-lg-4" if has_due_date}" do
|
||||
Weight
|
||||
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
|
||||
.issuable-form-select-holder
|
||||
- if issuable.weight
|
||||
= form.hidden_field :weight
|
||||
|
||||
= weight_dropdown_tag(issuable, toggle_class: 'js-issuable-form-weight') do
|
||||
%ul
|
||||
- Issue.weight_options.each do |weight|
|
||||
%li
|
||||
%a{ href: '#', data: { id: weight, none: weight === Issue::WEIGHT_NONE }, class: ("is-active" if issuable.weight == weight) }
|
||||
= weight
|
||||
```
|
||||
|
||||
Note:
|
||||
|
||||
- The safeguards at the top allow to get rid of an unneccessary indentation level
|
||||
- Here we only moved the 'Weight' code to a partial since this is the only
|
||||
EE-specific code in that view, so it's the most likely to conflict, but you
|
||||
are encouraged to use partials even for code that's in CE to logically split
|
||||
big views into several smaller files.
|
||||
|
||||
#### Indentation issue
|
||||
|
||||
Sometimes a code block is indented more or less in EE because there's an
|
||||
additional condition.
|
||||
|
||||
##### Mitigations
|
||||
|
||||
Blocks of code that are EE-specific should be moved to partials as much as
|
||||
possible to avoid conflicts with big chunks of HAML code that that are not fun
|
||||
to resolve when you add the indentation in the equation.
|
||||
|
||||
### Assets
|
||||
|
||||
#### gitlab-svgs
|
||||
|
||||
Conflicts in `app/assets/images/icons.json` or `app/assets/images/icons.svg` can be resolved simply by regenerating those assets with [`yarn run svg`](https://gitlab.com/gitlab-org/gitlab-svgs).
|
||||
|
||||
---
|
||||
|
||||
[Return to Development documentation](README.md)
|
|
@ -142,7 +142,7 @@ tests. If it doesn't, the whole test suite will run (including docs).
|
|||
---
|
||||
|
||||
When you submit a merge request to GitLab Community Edition (CE), there is an
|
||||
additional job called `rake ee_compat_check` that runs against Enterprise
|
||||
additional job called `ee_compat_check` that runs against Enterprise
|
||||
Edition (EE) and checks if your changes can apply cleanly to the EE codebase.
|
||||
If that job fails, read the instructions in the job log for what to do next.
|
||||
Contributors do not need to submit their changes to EE, GitLab Inc. employees
|
||||
|
|
|
@ -6,6 +6,7 @@ This page gathers all the resources for the topic **Authentication** within GitL
|
|||
|
||||
- [SSH](../../ssh/README.md)
|
||||
- [Two-Factor Authentication (2FA)](../../user/profile/account/two_factor_authentication.md#two-factor-authentication)
|
||||
- [Why do I keep getting signed out?](../../user/profile/index.md#why-do-i-keep-getting-signed-out)
|
||||
- **Articles:**
|
||||
- [Support for Universal 2nd Factor Authentication - YubiKeys](https://about.gitlab.com/2016/06/22/gitlab-adds-support-for-u2f/)
|
||||
- [Security Webcast with Yubico](https://about.gitlab.com/2016/08/31/gitlab-and-yubico-security-webcast/)
|
||||
|
|
|
@ -1,8 +1,32 @@
|
|||
# User account
|
||||
|
||||
When logged into their GitLab account, users can customize their
|
||||
When signed into their GitLab account, users can customize their
|
||||
experience according to the best approach to their cases.
|
||||
|
||||
## Signing in
|
||||
|
||||
There are several ways to sign into your GitLab account.
|
||||
See the [authentication topic](../../topics/authentication/index.md) for more details.
|
||||
|
||||
### Why do I keep getting signed out?
|
||||
|
||||
When signing in to the main GitLab application, a `_gitlab_session` cookie is
|
||||
set. `_gitlab_session` is cleared client-side when you close your browser
|
||||
and expires after "Application settings -> Session duration (minutes)"/`session_expire_delay`
|
||||
(defaults to `10080` minutes = 7 days).
|
||||
|
||||
When signing in to the main GitLab application, you can also check the
|
||||
"Remember me" option which sets the `remember_user_token`
|
||||
cookie (via [`devise`](https://github.com/plataformatec/devise)).
|
||||
`remember_user_token` expires after
|
||||
`config/initializers/devise.rb` -> `config.remember_for` (defaults to 2 weeks).
|
||||
|
||||
When the `_gitlab_session` expires or isn't available, GitLab uses the `remember_user_token`
|
||||
to get you a new `_gitlab_session` and keep you signed in through browser restarts.
|
||||
|
||||
After your `remember_user_token` expires and your `_gitlab_session` is cleared/expired,
|
||||
you will be asked to sign in again to verify your identity (which is for security reasons).
|
||||
|
||||
## Username
|
||||
|
||||
Your `username` is a unique [`namespace`](../group/index.md#namespaces)
|
||||
|
|
|
@ -41,7 +41,7 @@ module API
|
|||
detail 'This feature was introduced in GitLab 9.5'
|
||||
end
|
||||
delete do
|
||||
Gitlab::Git::Storage::CircuitBreaker.reset_all!
|
||||
Gitlab::Git::Storage::FailureInfo.reset_all!
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -4,6 +4,7 @@ module API
|
|||
before { authenticate_by_gitlab_shell_token! }
|
||||
|
||||
helpers ::API::Helpers::InternalHelpers
|
||||
helpers ::Gitlab::Identifier
|
||||
|
||||
namespace 'internal' do
|
||||
# Check if git command is allowed to project
|
||||
|
@ -176,17 +177,25 @@ module API
|
|||
|
||||
post '/post_receive' do
|
||||
status 200
|
||||
|
||||
PostReceive.perform_async(params[:gl_repository], params[:identifier],
|
||||
params[:changes])
|
||||
broadcast_message = BroadcastMessage.current&.last&.message
|
||||
reference_counter_decreased = Gitlab::ReferenceCounter.new(params[:gl_repository]).decrease
|
||||
|
||||
{
|
||||
output = {
|
||||
merge_request_urls: merge_request_urls,
|
||||
broadcast_message: broadcast_message,
|
||||
reference_counter_decreased: reference_counter_decreased
|
||||
}
|
||||
|
||||
project = Gitlab::GlRepository.parse(params[:gl_repository]).first
|
||||
user = identify(params[:identifier])
|
||||
redirect_message = Gitlab::Checks::ProjectMoved.fetch_redirect_message(user.id, project.id)
|
||||
if redirect_message
|
||||
output[:redirected_message] = redirect_message
|
||||
end
|
||||
|
||||
output
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -32,6 +32,7 @@ module Banzai
|
|||
.gsub(PUNCTUATION_REGEXP, '') # remove punctuation
|
||||
.tr(' ', '-') # replace spaces with dash
|
||||
.squeeze('-') # replace multiple dashes with one
|
||||
.gsub(/\A(\d+)\z/, 'anchor-\1') # digits-only hrefs conflict with issue refs
|
||||
|
||||
uniq = headers[id] > 0 ? "-#{headers[id]}" : ''
|
||||
headers[id] += 1
|
||||
|
|
|
@ -55,6 +55,7 @@ module Gitlab
|
|||
name: project_name,
|
||||
path: project_name,
|
||||
skip_disk_validation: true,
|
||||
import_type: 'gitlab_project',
|
||||
namespace_id: group&.id).execute
|
||||
|
||||
if project.persisted? && mv_repo(project)
|
||||
|
|
|
@ -7,6 +7,8 @@ module Gitlab
|
|||
@root_path = root_path
|
||||
@repo_path = repo_path
|
||||
|
||||
@root_path << '/' unless root_path.ends_with?('/')
|
||||
|
||||
# Split path into 'all/the/namespaces' and 'project_name'
|
||||
@group_path, _, @project_name = repo_relative_path.rpartition('/')
|
||||
end
|
||||
|
|
65
lib/gitlab/checks/project_moved.rb
Normal file
65
lib/gitlab/checks/project_moved.rb
Normal file
|
@ -0,0 +1,65 @@
|
|||
module Gitlab
|
||||
module Checks
|
||||
class ProjectMoved
|
||||
REDIRECT_NAMESPACE = "redirect_namespace".freeze
|
||||
|
||||
def initialize(project, user, redirected_path, protocol)
|
||||
@project = project
|
||||
@user = user
|
||||
@redirected_path = redirected_path
|
||||
@protocol = protocol
|
||||
end
|
||||
|
||||
def self.fetch_redirect_message(user_id, project_id)
|
||||
redirect_key = redirect_message_key(user_id, project_id)
|
||||
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
message = redis.get(redirect_key)
|
||||
redis.del(redirect_key)
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
def add_redirect_message
|
||||
Gitlab::Redis::SharedState.with do |redis|
|
||||
key = self.class.redirect_message_key(user.id, project.id)
|
||||
redis.setex(key, 5.minutes, redirect_message)
|
||||
end
|
||||
end
|
||||
|
||||
def redirect_message(rejected: false)
|
||||
<<~MESSAGE.strip_heredoc
|
||||
Project '#{redirected_path}' was moved to '#{project.full_path}'.
|
||||
|
||||
Please update your Git remote:
|
||||
|
||||
#{remote_url_message(rejected)}
|
||||
MESSAGE
|
||||
end
|
||||
|
||||
def permanent_redirect?
|
||||
RedirectRoute.permanent.exists?(path: redirected_path)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :project, :redirected_path, :protocol, :user
|
||||
|
||||
def self.redirect_message_key(user_id, project_id)
|
||||
"#{REDIRECT_NAMESPACE}:#{user_id}:#{project_id}"
|
||||
end
|
||||
|
||||
def remote_url_message(rejected)
|
||||
if rejected
|
||||
"git remote set-url origin #{url} and try again."
|
||||
else
|
||||
"git remote set-url origin #{url}"
|
||||
end
|
||||
end
|
||||
|
||||
def url
|
||||
protocol == 'ssh' ? project.ssh_url_to_repo : project.http_url_to_repo
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -3,14 +3,13 @@ module Gitlab
|
|||
module Pipeline
|
||||
module Chain
|
||||
class Base
|
||||
attr_reader :pipeline, :project, :current_user
|
||||
attr_reader :pipeline, :command
|
||||
|
||||
delegate :project, :current_user, to: :command
|
||||
|
||||
def initialize(pipeline, command)
|
||||
@pipeline = pipeline
|
||||
@command = command
|
||||
|
||||
@project = command.project
|
||||
@current_user = command.current_user
|
||||
end
|
||||
|
||||
def perform!
|
||||
|
|
|
@ -3,20 +3,18 @@ module Gitlab
|
|||
module Pipeline
|
||||
module Chain
|
||||
class Build < Chain::Base
|
||||
include Chain::Helpers
|
||||
|
||||
def perform!
|
||||
@pipeline.assign_attributes(
|
||||
source: @command.source,
|
||||
project: @project,
|
||||
ref: ref,
|
||||
sha: sha,
|
||||
before_sha: before_sha,
|
||||
tag: tag_exists?,
|
||||
project: @command.project,
|
||||
ref: @command.ref,
|
||||
sha: @command.sha,
|
||||
before_sha: @command.before_sha,
|
||||
tag: @command.tag_exists?,
|
||||
trigger_requests: Array(@command.trigger_request),
|
||||
user: @current_user,
|
||||
user: @command.current_user,
|
||||
pipeline_schedule: @command.schedule,
|
||||
protected: protected_ref?
|
||||
protected: @command.protected_ref?
|
||||
)
|
||||
|
||||
@pipeline.set_config_source
|
||||
|
@ -25,32 +23,6 @@ module Gitlab
|
|||
def break?
|
||||
false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ref
|
||||
@ref ||= Gitlab::Git.ref_name(origin_ref)
|
||||
end
|
||||
|
||||
def sha
|
||||
@project.commit(origin_sha || origin_ref).try(:id)
|
||||
end
|
||||
|
||||
def origin_ref
|
||||
@command.origin_ref
|
||||
end
|
||||
|
||||
def origin_sha
|
||||
@command.checkout_sha || @command.after_sha
|
||||
end
|
||||
|
||||
def before_sha
|
||||
@command.checkout_sha || @command.before_sha || Gitlab::Git::BLANK_SHA
|
||||
end
|
||||
|
||||
def protected_ref?
|
||||
@project.protected_for?(ref)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
61
lib/gitlab/ci/pipeline/chain/command.rb
Normal file
61
lib/gitlab/ci/pipeline/chain/command.rb
Normal file
|
@ -0,0 +1,61 @@
|
|||
module Gitlab
|
||||
module Ci
|
||||
module Pipeline
|
||||
module Chain
|
||||
Command = Struct.new(
|
||||
:source, :project, :current_user,
|
||||
:origin_ref, :checkout_sha, :after_sha, :before_sha,
|
||||
:trigger_request, :schedule,
|
||||
:ignore_skip_ci, :save_incompleted,
|
||||
:seeds_block
|
||||
) do
|
||||
include Gitlab::Utils::StrongMemoize
|
||||
|
||||
def initialize(**params)
|
||||
params.each do |key, value|
|
||||
self[key] = value
|
||||
end
|
||||
end
|
||||
|
||||
def branch_exists?
|
||||
strong_memoize(:is_branch) do
|
||||
project.repository.branch_exists?(ref)
|
||||
end
|
||||
end
|
||||
|
||||
def tag_exists?
|
||||
strong_memoize(:is_tag) do
|
||||
project.repository.tag_exists?(ref)
|
||||
end
|
||||
end
|
||||
|
||||
def ref
|
||||
strong_memoize(:ref) do
|
||||
Gitlab::Git.ref_name(origin_ref)
|
||||
end
|
||||
end
|
||||
|
||||
def sha
|
||||
strong_memoize(:sha) do
|
||||
project.commit(origin_sha || origin_ref).try(:id)
|
||||
end
|
||||
end
|
||||
|
||||
def origin_sha
|
||||
checkout_sha || after_sha
|
||||
end
|
||||
|
||||
def before_sha
|
||||
self[:before_sha] || checkout_sha || Gitlab::Git::BLANK_SHA
|
||||
end
|
||||
|
||||
def protected_ref?
|
||||
strong_memoize(:protected_ref) do
|
||||
project.protected_for?(ref)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -3,18 +3,6 @@ module Gitlab
|
|||
module Pipeline
|
||||
module Chain
|
||||
module Helpers
|
||||
def branch_exists?
|
||||
return @is_branch if defined?(@is_branch)
|
||||
|
||||
@is_branch = project.repository.branch_exists?(pipeline.ref)
|
||||
end
|
||||
|
||||
def tag_exists?
|
||||
return @is_tag if defined?(@is_tag)
|
||||
|
||||
@is_tag = project.repository.tag_exists?(pipeline.ref)
|
||||
end
|
||||
|
||||
def error(message)
|
||||
pipeline.errors.add(:base, message)
|
||||
end
|
||||
|
|
|
@ -14,7 +14,7 @@ module Gitlab
|
|||
|
||||
unless allowed_to_trigger_pipeline?
|
||||
if can?(current_user, :create_pipeline, project)
|
||||
return error("Insufficient permissions for protected ref '#{pipeline.ref}'")
|
||||
return error("Insufficient permissions for protected ref '#{command.ref}'")
|
||||
else
|
||||
return error('Insufficient permissions to create a new pipeline')
|
||||
end
|
||||
|
@ -29,7 +29,7 @@ module Gitlab
|
|||
if current_user
|
||||
allowed_to_create?
|
||||
else # legacy triggers don't have a corresponding user
|
||||
!project.protected_for?(@pipeline.ref)
|
||||
!@command.protected_ref?
|
||||
end
|
||||
end
|
||||
|
||||
|
@ -38,10 +38,10 @@ module Gitlab
|
|||
|
||||
access = Gitlab::UserAccess.new(current_user, project: project)
|
||||
|
||||
if branch_exists?
|
||||
access.can_update_branch?(@pipeline.ref)
|
||||
elsif tag_exists?
|
||||
access.can_create_tag?(@pipeline.ref)
|
||||
if @command.branch_exists?
|
||||
access.can_update_branch?(@command.ref)
|
||||
elsif @command.tag_exists?
|
||||
access.can_create_tag?(@command.ref)
|
||||
else
|
||||
true # Allow it for now and we'll reject when we check ref existence
|
||||
end
|
||||
|
|
|
@ -7,14 +7,11 @@ module Gitlab
|
|||
include Chain::Helpers
|
||||
|
||||
def perform!
|
||||
unless branch_exists? || tag_exists?
|
||||
unless @command.branch_exists? || @command.tag_exists?
|
||||
return error('Reference not found')
|
||||
end
|
||||
|
||||
## TODO, we check commit in the service, that is why
|
||||
# there is no repository access here.
|
||||
#
|
||||
unless pipeline.sha
|
||||
unless @command.sha
|
||||
return error('Commit not found')
|
||||
end
|
||||
end
|
||||
|
|
|
@ -280,7 +280,7 @@ module Gitlab
|
|||
The `#{branch}` branch applies cleanly to EE/master!
|
||||
|
||||
Much ❤️! For more information, see
|
||||
https://docs.gitlab.com/ce/development/limit_ee_conflicts.html#check-the-rake-ee_compat_check-in-your-merge-requests
|
||||
https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html
|
||||
#{THANKS_FOR_READING_BANNER}
|
||||
}
|
||||
end
|
||||
|
@ -357,7 +357,7 @@ module Gitlab
|
|||
Once this is done, you can retry this failed build, and it should pass.
|
||||
|
||||
Stay 💪 ! For more information, see
|
||||
https://docs.gitlab.com/ce/development/limit_ee_conflicts.html#check-the-rake-ee_compat_check-in-your-merge-requests
|
||||
https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html
|
||||
#{THANKS_FOR_READING_BANNER}
|
||||
}
|
||||
end
|
||||
|
@ -378,7 +378,7 @@ module Gitlab
|
|||
retry this build.
|
||||
|
||||
Stay 💪 ! For more information, see
|
||||
https://docs.gitlab.com/ce/development/limit_ee_conflicts.html#check-the-rake-ee_compat_check-in-your-merge-requests
|
||||
https://docs.gitlab.com/ce/development/automatic_ce_ee_merge.html
|
||||
#{THANKS_FOR_READING_BANNER}
|
||||
}
|
||||
end
|
||||
|
|
98
lib/gitlab/git/storage/checker.rb
Normal file
98
lib/gitlab/git/storage/checker.rb
Normal file
|
@ -0,0 +1,98 @@
|
|||
module Gitlab
|
||||
module Git
|
||||
module Storage
|
||||
class Checker
|
||||
include CircuitBreakerSettings
|
||||
|
||||
attr_reader :storage_path, :storage, :hostname, :logger
|
||||
|
||||
def self.check_all(logger = Rails.logger)
|
||||
threads = Gitlab.config.repositories.storages.keys.map do |storage_name|
|
||||
Thread.new do
|
||||
Thread.current[:result] = new(storage_name, logger).check_with_lease
|
||||
end
|
||||
end
|
||||
|
||||
threads.map do |thread|
|
||||
thread.join
|
||||
thread[:result]
|
||||
end
|
||||
end
|
||||
|
||||
def initialize(storage, logger = Rails.logger)
|
||||
@storage = storage
|
||||
config = Gitlab.config.repositories.storages[@storage]
|
||||
@storage_path = config['path']
|
||||
@logger = logger
|
||||
|
||||
@hostname = Gitlab::Environment.hostname
|
||||
end
|
||||
|
||||
def check_with_lease
|
||||
lease_key = "storage_check:#{cache_key}"
|
||||
lease = Gitlab::ExclusiveLease.new(lease_key, timeout: storage_timeout)
|
||||
result = { storage: storage, success: nil }
|
||||
|
||||
if uuid = lease.try_obtain
|
||||
result[:success] = check
|
||||
|
||||
Gitlab::ExclusiveLease.cancel(lease_key, uuid)
|
||||
else
|
||||
logger.warn("#{hostname}: #{storage}: Skipping check, previous check still running")
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
def check
|
||||
if Gitlab::Git::Storage::ForkedStorageCheck.storage_available?(storage_path, storage_timeout, access_retries)
|
||||
track_storage_accessible
|
||||
true
|
||||
else
|
||||
track_storage_inaccessible
|
||||
logger.error("#{hostname}: #{storage}: Not accessible.")
|
||||
false
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def track_storage_inaccessible
|
||||
first_failure = current_failure_info.first_failure || Time.now
|
||||
last_failure = Time.now
|
||||
|
||||
Gitlab::Git::Storage.redis.with do |redis|
|
||||
redis.pipelined do
|
||||
redis.hset(cache_key, :first_failure, first_failure.to_i)
|
||||
redis.hset(cache_key, :last_failure, last_failure.to_i)
|
||||
redis.hincrby(cache_key, :failure_count, 1)
|
||||
redis.expire(cache_key, failure_reset_time)
|
||||
maintain_known_keys(redis)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def track_storage_accessible
|
||||
Gitlab::Git::Storage.redis.with do |redis|
|
||||
redis.pipelined do
|
||||
redis.hset(cache_key, :first_failure, nil)
|
||||
redis.hset(cache_key, :last_failure, nil)
|
||||
redis.hset(cache_key, :failure_count, 0)
|
||||
maintain_known_keys(redis)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def maintain_known_keys(redis)
|
||||
expire_time = Time.now.to_i + failure_reset_time
|
||||
redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, expire_time, cache_key)
|
||||
redis.zremrangebyscore(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, '-inf', Time.now.to_i)
|
||||
end
|
||||
|
||||
def current_failure_info
|
||||
FailureInfo.load(cache_key)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -4,22 +4,11 @@ module Gitlab
|
|||
class CircuitBreaker
|
||||
include CircuitBreakerSettings
|
||||
|
||||
FailureInfo = Struct.new(:last_failure, :failure_count)
|
||||
|
||||
attr_reader :storage,
|
||||
:hostname,
|
||||
:storage_path
|
||||
:hostname
|
||||
|
||||
delegate :last_failure, :failure_count, to: :failure_info
|
||||
|
||||
def self.reset_all!
|
||||
Gitlab::Git::Storage.redis.with do |redis|
|
||||
all_storage_keys = redis.zrange(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, -1)
|
||||
redis.del(*all_storage_keys) unless all_storage_keys.empty?
|
||||
end
|
||||
|
||||
RequestStore.delete(:circuitbreaker_cache)
|
||||
end
|
||||
delegate :last_failure, :failure_count, :no_failures?,
|
||||
to: :failure_info
|
||||
|
||||
def self.for_storage(storage)
|
||||
cached_circuitbreakers = RequestStore.fetch(:circuitbreaker_cache) do
|
||||
|
@ -46,9 +35,6 @@ module Gitlab
|
|||
def initialize(storage, hostname)
|
||||
@storage = storage
|
||||
@hostname = hostname
|
||||
|
||||
config = Gitlab.config.repositories.storages[@storage]
|
||||
@storage_path = config['path']
|
||||
end
|
||||
|
||||
def perform
|
||||
|
@ -65,15 +51,6 @@ module Gitlab
|
|||
failure_count > failure_count_threshold
|
||||
end
|
||||
|
||||
def backing_off?
|
||||
return false if no_failures?
|
||||
|
||||
recent_failure = last_failure > failure_wait_time.seconds.ago
|
||||
too_many_failures = failure_count > backoff_threshold
|
||||
|
||||
recent_failure && too_many_failures
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# The circuitbreaker can be enabled for the entire fleet using a Feature
|
||||
|
@ -86,88 +63,13 @@ module Gitlab
|
|||
end
|
||||
|
||||
def failure_info
|
||||
@failure_info ||= get_failure_info
|
||||
end
|
||||
|
||||
# Memoizing the `storage_available` call means we only do it once per
|
||||
# request when the storage is available.
|
||||
#
|
||||
# When the storage appears not available, and the memoized value is `false`
|
||||
# we might want to try again.
|
||||
def storage_available?
|
||||
return @storage_available if @storage_available
|
||||
|
||||
if @storage_available = Gitlab::Git::Storage::ForkedStorageCheck
|
||||
.storage_available?(storage_path, storage_timeout, access_retries)
|
||||
track_storage_accessible
|
||||
else
|
||||
track_storage_inaccessible
|
||||
end
|
||||
|
||||
@storage_available
|
||||
@failure_info ||= FailureInfo.load(cache_key)
|
||||
end
|
||||
|
||||
def check_storage_accessible!
|
||||
if circuit_broken?
|
||||
raise Gitlab::Git::Storage::CircuitOpen.new("Circuit for #{storage} is broken", failure_reset_time)
|
||||
end
|
||||
|
||||
if backing_off?
|
||||
raise Gitlab::Git::Storage::Failing.new("Backing off access to #{storage}", failure_wait_time)
|
||||
end
|
||||
|
||||
unless storage_available?
|
||||
raise Gitlab::Git::Storage::Inaccessible.new("#{storage} not accessible", failure_wait_time)
|
||||
end
|
||||
end
|
||||
|
||||
def no_failures?
|
||||
last_failure.blank? && failure_count == 0
|
||||
end
|
||||
|
||||
def track_storage_inaccessible
|
||||
@failure_info = FailureInfo.new(Time.now, failure_count + 1)
|
||||
|
||||
Gitlab::Git::Storage.redis.with do |redis|
|
||||
redis.pipelined do
|
||||
redis.hset(cache_key, :last_failure, last_failure.to_i)
|
||||
redis.hincrby(cache_key, :failure_count, 1)
|
||||
redis.expire(cache_key, failure_reset_time)
|
||||
maintain_known_keys(redis)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def track_storage_accessible
|
||||
@failure_info = FailureInfo.new(nil, 0)
|
||||
|
||||
Gitlab::Git::Storage.redis.with do |redis|
|
||||
redis.pipelined do
|
||||
redis.hset(cache_key, :last_failure, nil)
|
||||
redis.hset(cache_key, :failure_count, 0)
|
||||
maintain_known_keys(redis)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def maintain_known_keys(redis)
|
||||
expire_time = Time.now.to_i + failure_reset_time
|
||||
redis.zadd(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, expire_time, cache_key)
|
||||
redis.zremrangebyscore(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, '-inf', Time.now.to_i)
|
||||
end
|
||||
|
||||
def get_failure_info
|
||||
last_failure, failure_count = Gitlab::Git::Storage.redis.with do |redis|
|
||||
redis.hmget(cache_key, :last_failure, :failure_count)
|
||||
end
|
||||
|
||||
last_failure = Time.at(last_failure.to_i) if last_failure.present?
|
||||
|
||||
FailureInfo.new(last_failure, failure_count.to_i)
|
||||
end
|
||||
|
||||
def cache_key
|
||||
@cache_key ||= "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}#{storage}:#{hostname}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -6,10 +6,6 @@ module Gitlab
|
|||
application_settings.circuitbreaker_failure_count_threshold
|
||||
end
|
||||
|
||||
def failure_wait_time
|
||||
application_settings.circuitbreaker_failure_wait_time
|
||||
end
|
||||
|
||||
def failure_reset_time
|
||||
application_settings.circuitbreaker_failure_reset_time
|
||||
end
|
||||
|
@ -22,8 +18,12 @@ module Gitlab
|
|||
application_settings.circuitbreaker_access_retries
|
||||
end
|
||||
|
||||
def backoff_threshold
|
||||
application_settings.circuitbreaker_backoff_threshold
|
||||
def check_interval
|
||||
application_settings.circuitbreaker_check_interval
|
||||
end
|
||||
|
||||
def cache_key
|
||||
@cache_key ||= "#{Gitlab::Git::Storage::REDIS_KEY_PREFIX}#{storage}:#{hostname}"
|
||||
end
|
||||
|
||||
private
|
||||
|
|
39
lib/gitlab/git/storage/failure_info.rb
Normal file
39
lib/gitlab/git/storage/failure_info.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
module Gitlab
|
||||
module Git
|
||||
module Storage
|
||||
class FailureInfo
|
||||
attr_accessor :first_failure, :last_failure, :failure_count
|
||||
|
||||
def self.reset_all!
|
||||
Gitlab::Git::Storage.redis.with do |redis|
|
||||
all_storage_keys = redis.zrange(Gitlab::Git::Storage::REDIS_KNOWN_KEYS, 0, -1)
|
||||
redis.del(*all_storage_keys) unless all_storage_keys.empty?
|
||||
end
|
||||
|
||||
RequestStore.delete(:circuitbreaker_cache)
|
||||
end
|
||||
|
||||
def self.load(cache_key)
|
||||
first_failure, last_failure, failure_count = Gitlab::Git::Storage.redis.with do |redis|
|
||||
redis.hmget(cache_key, :first_failure, :last_failure, :failure_count)
|
||||
end
|
||||
|
||||
last_failure = Time.at(last_failure.to_i) if last_failure.present?
|
||||
first_failure = Time.at(first_failure.to_i) if first_failure.present?
|
||||
|
||||
new(first_failure, last_failure, failure_count.to_i)
|
||||
end
|
||||
|
||||
def initialize(first_failure, last_failure, failure_count)
|
||||
@first_failure = first_failure
|
||||
@last_failure = last_failure
|
||||
@failure_count = failure_count
|
||||
end
|
||||
|
||||
def no_failures?
|
||||
first_failure.blank? && last_failure.blank? && failure_count == 0
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -11,6 +11,9 @@ module Gitlab
|
|||
# These will always have nil values
|
||||
attr_reader :storage_path
|
||||
|
||||
delegate :last_failure, :failure_count, :no_failures?,
|
||||
to: :failure_info
|
||||
|
||||
def initialize(storage, hostname, error: nil)
|
||||
@storage = storage
|
||||
@hostname = hostname
|
||||
|
@ -29,16 +32,17 @@ module Gitlab
|
|||
false
|
||||
end
|
||||
|
||||
def last_failure
|
||||
circuit_broken? ? Time.now : nil
|
||||
end
|
||||
|
||||
def failure_count
|
||||
circuit_broken? ? failure_count_threshold : 0
|
||||
end
|
||||
|
||||
def failure_info
|
||||
Gitlab::Git::Storage::CircuitBreaker::FailureInfo.new(last_failure, failure_count)
|
||||
@failure_info ||=
|
||||
if circuit_broken?
|
||||
Gitlab::Git::Storage::FailureInfo.new(Time.now,
|
||||
Time.now,
|
||||
failure_count_threshold)
|
||||
else
|
||||
Gitlab::Git::Storage::FailureInfo.new(nil,
|
||||
nil,
|
||||
0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -102,18 +102,15 @@ module Gitlab
|
|||
end
|
||||
|
||||
def check_project_moved!
|
||||
return unless redirected_path
|
||||
return if redirected_path.nil?
|
||||
|
||||
url = protocol == 'ssh' ? project.ssh_url_to_repo : project.http_url_to_repo
|
||||
message = <<-MESSAGE.strip_heredoc
|
||||
Project '#{redirected_path}' was moved to '#{project.full_path}'.
|
||||
project_moved = Checks::ProjectMoved.new(project, user, redirected_path, protocol)
|
||||
|
||||
Please update your Git remote and try again:
|
||||
|
||||
git remote set-url origin #{url}
|
||||
MESSAGE
|
||||
|
||||
raise ProjectMovedError, message
|
||||
if project_moved.permanent_redirect?
|
||||
project_moved.add_redirect_message
|
||||
else
|
||||
raise ProjectMovedError, project_moved.redirect_message(rejected: true)
|
||||
end
|
||||
end
|
||||
|
||||
def check_command_disabled!(cmd)
|
||||
|
|
|
@ -2,9 +2,8 @@
|
|||
# key-13 or user-36 or last commit
|
||||
module Gitlab
|
||||
module Identifier
|
||||
def identify(identifier, project, newrev)
|
||||
def identify(identifier, project = nil, newrev = nil)
|
||||
if identifier.blank?
|
||||
# Local push from gitlab
|
||||
identify_using_commit(project, newrev)
|
||||
elsif identifier =~ /\Auser-\d+\Z/
|
||||
# git push over http
|
||||
|
@ -17,6 +16,8 @@ module Gitlab
|
|||
|
||||
# Tries to identify a user based on a commit SHA.
|
||||
def identify_using_commit(project, ref)
|
||||
return if project.nil? && ref.nil?
|
||||
|
||||
commit = project.commit(ref)
|
||||
|
||||
return if !commit || !commit.author_email
|
||||
|
|
11
lib/gitlab/storage_check.rb
Normal file
11
lib/gitlab/storage_check.rb
Normal file
|
@ -0,0 +1,11 @@
|
|||
require_relative 'storage_check/cli'
|
||||
require_relative 'storage_check/gitlab_caller'
|
||||
require_relative 'storage_check/option_parser'
|
||||
require_relative 'storage_check/response'
|
||||
|
||||
module Gitlab
|
||||
module StorageCheck
|
||||
ENDPOINT = '/-/storage_check'.freeze
|
||||
Options = Struct.new(:target, :token, :interval, :dryrun)
|
||||
end
|
||||
end
|
69
lib/gitlab/storage_check/cli.rb
Normal file
69
lib/gitlab/storage_check/cli.rb
Normal file
|
@ -0,0 +1,69 @@
|
|||
module Gitlab
|
||||
module StorageCheck
|
||||
class CLI
|
||||
def self.start!(args)
|
||||
runner = new(Gitlab::StorageCheck::OptionParser.parse!(args))
|
||||
runner.start_loop
|
||||
end
|
||||
|
||||
attr_reader :logger, :options
|
||||
|
||||
def initialize(options)
|
||||
@options = options
|
||||
@logger = Logger.new(STDOUT)
|
||||
end
|
||||
|
||||
def start_loop
|
||||
logger.info "Checking #{options.target} every #{options.interval} seconds"
|
||||
|
||||
if options.dryrun
|
||||
logger.info "Dryrun, exiting..."
|
||||
return
|
||||
end
|
||||
|
||||
begin
|
||||
loop do
|
||||
response = GitlabCaller.new(options).call!
|
||||
log_response(response)
|
||||
update_settings(response)
|
||||
|
||||
sleep options.interval
|
||||
end
|
||||
rescue Interrupt
|
||||
logger.info "Ending storage-check"
|
||||
end
|
||||
end
|
||||
|
||||
def update_settings(response)
|
||||
previous_interval = options.interval
|
||||
|
||||
if response.valid?
|
||||
options.interval = response.check_interval || previous_interval
|
||||
end
|
||||
|
||||
if previous_interval != options.interval
|
||||
logger.info "Interval changed: #{options.interval} seconds"
|
||||
end
|
||||
end
|
||||
|
||||
def log_response(response)
|
||||
unless response.valid?
|
||||
return logger.error("Invalid response checking nfs storage: #{response.http_response.inspect}")
|
||||
end
|
||||
|
||||
if response.responsive_shards.any?
|
||||
logger.debug("Responsive shards: #{response.responsive_shards.join(', ')}")
|
||||
end
|
||||
|
||||
warnings = []
|
||||
if response.skipped_shards.any?
|
||||
warnings << "Skipped shards: #{response.skipped_shards.join(', ')}"
|
||||
end
|
||||
if response.failing_shards.any?
|
||||
warnings << "Failing shards: #{response.failing_shards.join(', ')}"
|
||||
end
|
||||
logger.warn(warnings.join(' - ')) if warnings.any?
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
39
lib/gitlab/storage_check/gitlab_caller.rb
Normal file
39
lib/gitlab/storage_check/gitlab_caller.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
require 'excon'
|
||||
|
||||
module Gitlab
|
||||
module StorageCheck
|
||||
class GitlabCaller
|
||||
def initialize(options)
|
||||
@options = options
|
||||
end
|
||||
|
||||
def call!
|
||||
Gitlab::StorageCheck::Response.new(get_response)
|
||||
rescue Errno::ECONNREFUSED, Excon::Error
|
||||
# Server not ready, treated as invalid response.
|
||||
Gitlab::StorageCheck::Response.new(nil)
|
||||
end
|
||||
|
||||
def get_response
|
||||
scheme, *other_parts = URI.split(@options.target)
|
||||
socket_path = if scheme == 'unix'
|
||||
other_parts.compact.join
|
||||
end
|
||||
|
||||
connection = Excon.new(@options.target, socket: socket_path)
|
||||
connection.post(path: Gitlab::StorageCheck::ENDPOINT,
|
||||
headers: headers)
|
||||
end
|
||||
|
||||
def headers
|
||||
@headers ||= begin
|
||||
headers = {}
|
||||
headers['Content-Type'] = headers['Accept'] = 'application/json'
|
||||
headers['TOKEN'] = @options.token if @options.token
|
||||
|
||||
headers
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
39
lib/gitlab/storage_check/option_parser.rb
Normal file
39
lib/gitlab/storage_check/option_parser.rb
Normal file
|
@ -0,0 +1,39 @@
|
|||
module Gitlab
|
||||
module StorageCheck
|
||||
class OptionParser
|
||||
def self.parse!(args)
|
||||
# Start out with some defaults
|
||||
options = Gitlab::StorageCheck::Options.new(nil, nil, 1, false)
|
||||
|
||||
parser = ::OptionParser.new do |opts|
|
||||
opts.banner = "Usage: bin/storage_check [options]"
|
||||
|
||||
opts.on('-t=string', '--target string', 'URL or socket to trigger storage check') do |value|
|
||||
options.target = value
|
||||
end
|
||||
|
||||
opts.on('-T=string', '--token string', 'Health token to use') { |value| options.token = value }
|
||||
|
||||
opts.on('-i=n', '--interval n', ::OptionParser::DecimalInteger, 'Seconds between checks') do |value|
|
||||
options.interval = value
|
||||
end
|
||||
|
||||
opts.on('-d', '--dryrun', "Output what will be performed, but don't start the process") do |value|
|
||||
options.dryrun = value
|
||||
end
|
||||
end
|
||||
parser.parse!(args)
|
||||
|
||||
unless options.target
|
||||
raise ::OptionParser::InvalidArgument.new('Provide a URI to provide checks')
|
||||
end
|
||||
|
||||
if URI.parse(options.target).scheme.nil?
|
||||
raise ::OptionParser::InvalidArgument.new('Add the scheme to the target, `unix://`, `https://` or `http://` are supported')
|
||||
end
|
||||
|
||||
options
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
77
lib/gitlab/storage_check/response.rb
Normal file
77
lib/gitlab/storage_check/response.rb
Normal file
|
@ -0,0 +1,77 @@
|
|||
require 'json'
|
||||
|
||||
module Gitlab
|
||||
module StorageCheck
|
||||
class Response
|
||||
attr_reader :http_response
|
||||
|
||||
def initialize(http_response)
|
||||
@http_response = http_response
|
||||
end
|
||||
|
||||
def valid?
|
||||
@http_response && (200...299).cover?(@http_response.status) &&
|
||||
@http_response.headers['Content-Type'].include?('application/json') &&
|
||||
parsed_response
|
||||
end
|
||||
|
||||
def check_interval
|
||||
return nil unless parsed_response
|
||||
|
||||
parsed_response['check_interval']
|
||||
end
|
||||
|
||||
def responsive_shards
|
||||
divided_results[:responsive_shards]
|
||||
end
|
||||
|
||||
def skipped_shards
|
||||
divided_results[:skipped_shards]
|
||||
end
|
||||
|
||||
def failing_shards
|
||||
divided_results[:failing_shards]
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def results
|
||||
return [] unless parsed_response
|
||||
|
||||
parsed_response['results']
|
||||
end
|
||||
|
||||
def divided_results
|
||||
return @divided_results if @divided_results
|
||||
|
||||
@divided_results = {}
|
||||
@divided_results[:responsive_shards] = []
|
||||
@divided_results[:skipped_shards] = []
|
||||
@divided_results[:failing_shards] = []
|
||||
|
||||
results.each do |info|
|
||||
name = info['storage']
|
||||
|
||||
case info['success']
|
||||
when true
|
||||
@divided_results[:responsive_shards] << name
|
||||
when false
|
||||
@divided_results[:failing_shards] << name
|
||||
else
|
||||
@divided_results[:skipped_shards] << name
|
||||
end
|
||||
end
|
||||
|
||||
@divided_results
|
||||
end
|
||||
|
||||
def parsed_response
|
||||
return @parsed_response if defined?(@parsed_response)
|
||||
|
||||
@parsed_response = JSON.parse(@http_response.body)
|
||||
rescue JSON::JSONError
|
||||
@parsed_response = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
4
qa/qa.rb
4
qa/qa.rb
|
@ -46,6 +46,10 @@ module QA
|
|||
autoload :Create, 'qa/scenario/gitlab/project/create'
|
||||
end
|
||||
|
||||
module Repository
|
||||
autoload :Push, 'qa/scenario/gitlab/repository/push'
|
||||
end
|
||||
|
||||
module Sandbox
|
||||
autoload :Prepare, 'qa/scenario/gitlab/sandbox/prepare'
|
||||
end
|
||||
|
|
47
qa/qa/scenario/gitlab/repository/push.rb
Normal file
47
qa/qa/scenario/gitlab/repository/push.rb
Normal file
|
@ -0,0 +1,47 @@
|
|||
require "pry-byebug"
|
||||
|
||||
module QA
|
||||
module Scenario
|
||||
module Gitlab
|
||||
module Repository
|
||||
class Push < Scenario::Template
|
||||
PAGE_REGEX_CHECK =
|
||||
%r{\/#{Runtime::Namespace.sandbox_name}\/qa-test[^\/]+\/{1}[^\/]+\z}.freeze
|
||||
|
||||
attr_writer :file_name,
|
||||
:file_content,
|
||||
:commit_message,
|
||||
:branch_name
|
||||
|
||||
def initialize
|
||||
@file_name = 'file.txt'
|
||||
@file_content = '# This is test project'
|
||||
@commit_message = "Add #{@file_name}"
|
||||
@branch_name = 'master'
|
||||
end
|
||||
|
||||
def perform
|
||||
Git::Repository.perform do |repository|
|
||||
repository.location = Page::Project::Show.act do
|
||||
unless PAGE_REGEX_CHECK.match(current_path)
|
||||
raise "To perform this scenario the current page should be project show."
|
||||
end
|
||||
|
||||
choose_repository_clone_http
|
||||
repository_location
|
||||
end
|
||||
|
||||
repository.use_default_credentials
|
||||
repository.clone
|
||||
repository.configure_identity('GitLab QA', 'root@gitlab.com')
|
||||
|
||||
repository.add_file(@file_name, @file_content)
|
||||
repository.commit(@commit_message)
|
||||
repository.push_changes(@branch_name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -10,21 +10,10 @@ module QA
|
|||
scenario.description = 'project with repository'
|
||||
end
|
||||
|
||||
Git::Repository.perform do |repository|
|
||||
repository.location = Page::Project::Show.act do
|
||||
choose_repository_clone_http
|
||||
repository_location
|
||||
end
|
||||
|
||||
repository.use_default_credentials
|
||||
|
||||
repository.act do
|
||||
clone
|
||||
configure_identity('GitLab QA', 'root@gitlab.com')
|
||||
add_file('README.md', '# This is test project')
|
||||
commit('Add README.md')
|
||||
push_changes
|
||||
end
|
||||
Scenario::Gitlab::Repository::Push.perform do |scenario|
|
||||
scenario.file_name = 'README.md'
|
||||
scenario.file_content = '# This is test project'
|
||||
scenario.commit_message = 'Add README.md'
|
||||
end
|
||||
|
||||
Page::Project::Show.act do
|
||||
|
|
13
spec/bin/storage_check_spec.rb
Normal file
13
spec/bin/storage_check_spec.rb
Normal file
|
@ -0,0 +1,13 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe 'bin/storage_check' do
|
||||
it 'is executable' do
|
||||
command = %w[bin/storage_check -t unix://the/path/to/a/unix-socket.sock -i 10 -d]
|
||||
expected_output = 'Checking unix://the/path/to/a/unix-socket.sock every 10 seconds'
|
||||
|
||||
output, status = Gitlab::Popen.popen(command, Rails.root.to_s)
|
||||
|
||||
expect(status).to eq(0)
|
||||
expect(output).to include(expected_output)
|
||||
end
|
||||
end
|
|
@ -1,6 +1,6 @@
|
|||
require 'spec_helper'
|
||||
|
||||
describe Admin::HealthCheckController, broken_storage: true do
|
||||
describe Admin::HealthCheckController do
|
||||
let(:admin) { create(:admin) }
|
||||
|
||||
before do
|
||||
|
@ -17,7 +17,7 @@ describe Admin::HealthCheckController, broken_storage: true do
|
|||
|
||||
describe 'POST reset_storage_health' do
|
||||
it 'resets all storage health information' do
|
||||
expect(Gitlab::Git::Storage::CircuitBreaker).to receive(:reset_all!)
|
||||
expect(Gitlab::Git::Storage::FailureInfo).to receive(:reset_all!)
|
||||
|
||||
post :reset_storage_health
|
||||
end
|
||||
|
|
|
@ -14,6 +14,48 @@ describe HealthController do
|
|||
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'false')
|
||||
end
|
||||
|
||||
describe '#storage_check' do
|
||||
before do
|
||||
allow(Gitlab::RequestContext).to receive(:client_ip).and_return(whitelisted_ip)
|
||||
end
|
||||
|
||||
subject { post :storage_check }
|
||||
|
||||
it 'checks all the configured storages' do
|
||||
expect(Gitlab::Git::Storage::Checker).to receive(:check_all).and_call_original
|
||||
|
||||
subject
|
||||
end
|
||||
|
||||
it 'returns the check interval' do
|
||||
stub_env('IN_MEMORY_APPLICATION_SETTINGS', 'true')
|
||||
stub_application_setting(circuitbreaker_check_interval: 10)
|
||||
|
||||
subject
|
||||
|
||||
expect(json_response['check_interval']).to eq(10)
|
||||
end
|
||||
|
||||
context 'with failing storages', :broken_storage do
|
||||
before do
|
||||
stub_storage_settings(
|
||||
broken: { path: 'tmp/tests/non-existent-repositories' }
|
||||
)
|
||||
end
|
||||
|
||||
it 'includes the failure information' do
|
||||
subject
|
||||
|
||||
expected_results = [
|
||||
{ 'storage' => 'broken', 'success' => false },
|
||||
{ 'storage' => 'default', 'success' => true }
|
||||
]
|
||||
|
||||
expect(json_response['results']).to eq(expected_results)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#readiness' do
|
||||
shared_context 'endpoint responding with readiness data' do
|
||||
let(:request_params) { {} }
|
||||
|
|
|
@ -272,6 +272,20 @@ describe Projects::IssuesController do
|
|||
expect(response).to have_http_status(:ok)
|
||||
expect(issue.reload.title).to eq('New title')
|
||||
end
|
||||
|
||||
context 'when Akismet is enabled and the issue is identified as spam' do
|
||||
before do
|
||||
stub_application_setting(recaptcha_enabled: true)
|
||||
allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
|
||||
allow_any_instance_of(AkismetService).to receive(:spam?).and_return(true)
|
||||
end
|
||||
|
||||
it 'renders json with recaptcha_html' do
|
||||
subject
|
||||
|
||||
expect(JSON.parse(response.body)).to have_key('recaptcha_html')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when user does not have access to update issue' do
|
||||
|
@ -504,17 +518,16 @@ describe Projects::IssuesController do
|
|||
expect(spam_logs.first.recaptcha_verified).to be_falsey
|
||||
end
|
||||
|
||||
it 'renders json errors' do
|
||||
it 'renders recaptcha_html json response' do
|
||||
update_issue
|
||||
|
||||
expect(json_response)
|
||||
.to eql("errors" => ["Your issue has been recognized as spam. Please, change the content or solve the reCAPTCHA to proceed."])
|
||||
expect(json_response).to have_key('recaptcha_html')
|
||||
end
|
||||
|
||||
it 'returns 422 status' do
|
||||
it 'returns 200 status' do
|
||||
update_issue
|
||||
|
||||
expect(response).to have_gitlab_http_status(422)
|
||||
expect(response).to have_gitlab_http_status(200)
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
require 'spec_helper'
|
||||
|
||||
feature "Admin Health Check", :feature, :broken_storage do
|
||||
feature "Admin Health Check", :feature do
|
||||
include StubENV
|
||||
|
||||
before do
|
||||
|
@ -36,6 +36,7 @@ feature "Admin Health Check", :feature, :broken_storage do
|
|||
|
||||
context 'when services are up' do
|
||||
before do
|
||||
stub_storage_settings({}) # Hide the broken storage
|
||||
visit admin_health_check_path
|
||||
end
|
||||
|
||||
|
@ -56,10 +57,8 @@ feature "Admin Health Check", :feature, :broken_storage do
|
|||
end
|
||||
end
|
||||
|
||||
context 'with repository storage failures' do
|
||||
context 'with repository storage failures', :broken_storage do
|
||||
before do
|
||||
# Track a failure
|
||||
Gitlab::Git::Storage::CircuitBreaker.for_storage('broken').perform { nil } rescue nil
|
||||
visit admin_health_check_path
|
||||
end
|
||||
|
||||
|
@ -67,9 +66,10 @@ feature "Admin Health Check", :feature, :broken_storage do
|
|||
hostname = Gitlab::Environment.hostname
|
||||
maximum_failures = Gitlab::CurrentSettings.current_application_settings
|
||||
.circuitbreaker_failure_count_threshold
|
||||
number_of_failures = maximum_failures + 1
|
||||
|
||||
expect(page).to have_content('broken: failed storage access attempt on host:')
|
||||
expect(page).to have_content("#{hostname}: 1 of #{maximum_failures} failures.")
|
||||
expect(page).to have_content("broken: #{number_of_failures} failed storage access attempts:")
|
||||
expect(page).to have_content("#{hostname}: #{number_of_failures} of #{maximum_failures} failures.")
|
||||
end
|
||||
|
||||
it 'allows resetting storage failures' do
|
||||
|
|
|
@ -185,6 +185,18 @@ feature 'image diff notes', :js do
|
|||
expect(page).to have_content(diff_note.note)
|
||||
end
|
||||
end
|
||||
|
||||
describe 'image view modes' do
|
||||
before do
|
||||
visit project_commit_path(project, '2f63565e7aac07bcdadb654e253078b727143ec4')
|
||||
end
|
||||
|
||||
it 'resizes image in onion skin view mode' do
|
||||
find('.view-modes-menu .onion-skin').click
|
||||
|
||||
expect(find('.onion-skin-frame')['style']).to match('width: 228px; height: 240px;')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def create_image_diff_note
|
||||
|
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue