Commit 3d273c3c authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch 'master' into 22604-manual-actions

* master: (51 commits)
  Fix spec/features/admin/admin_active_tab_spec.rb
  Fix eslint errors
  Don't open Asciidoc module twice
  Add `gitlab_rails['auto_migrate'] = false` to HA docs for Redis/PG [ci skip]
  Unify margin widths
  Add $CI_ENVIRONMENT_NAME and $CI_ENVIRONMENT_SLUG
  Add Okta authentication documentation
  Create environments when the build referencing them is created
  Add an environment slug
  Make the index on environment name and project id unique, fixing up any duplicates
  Rename `issue create` slash command to `issue new`
  Add changelog for !7850.
  Update Mattermost slash commands docs to explain how to create a newline and use <kbd> for user input. See HTML5 spec: https://www.w3.org/TR/html5/text-level-semantics.html#the-kbd-element
  Improve `issue create …` slash command with user input keys to create a newline in chat clients.
  Add explicit status test, feedback:
  Don't call anything on a block, use simple if
  Use described_class and update description
  Also use latest_status, feedback:
  Fixed bug with +1 not autocompleting
  added changelog entry
  ...
parents e42de89a 49a70d1e
...@@ -74,7 +74,9 @@ ...@@ -74,7 +74,9 @@
case 'projects:merge_requests:index': case 'projects:merge_requests:index':
case 'projects:issues:index': case 'projects:issues:index':
Issuable.init(); Issuable.init();
new gl.IssuableBulkActions(); new gl.IssuableBulkActions({
prefixId: page === 'projects:merge_requests:index' ? 'merge_request_' : 'issue_',
});
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
break; break;
case 'projects:issues:show': case 'projects:issues:show':
...@@ -144,10 +146,6 @@ ...@@ -144,10 +146,6 @@
new ZenMode(); new ZenMode();
new MergedButtons(); new MergedButtons();
break; break;
case 'projects:merge_requests:index':
shortcut_handler = new ShortcutsNavigation();
Issuable.init();
break;
case 'dashboard:activity': case 'dashboard:activity':
new gl.Activities(); new gl.Activities();
break; break;
......
/* eslint-disable no-restricted-syntax */
// Adapted from https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Object/assign#Polyfill
if (typeof Object.assign !== 'function') {
Object.assign = function assign(target, ...args) {
if (target == null) { // TypeError if undefined or null
throw new TypeError('Cannot convert undefined or null to object');
}
const to = Object(target);
for (let index = 0; index < args.length; index += 1) {
const nextSource = args[index];
if (nextSource != null) { // Skip over if undefined or null
for (const nextKey in nextSource) {
// Avoid bugs when hasOwnProperty is shadowed
if (Object.prototype.hasOwnProperty.call(nextSource, nextKey)) {
to[nextKey] = nextSource[nextKey];
}
}
}
}
return to;
};
}
...@@ -67,14 +67,15 @@ ...@@ -67,14 +67,15 @@
// The below is taken from At.js source // The below is taken from At.js source
// Tweaked to commands to start without a space only if char before is a non-word character // Tweaked to commands to start without a space only if char before is a non-word character
// https://github.com/ichord/At.js // https://github.com/ichord/At.js
var _a, _y, regexp, match; var _a, _y, regexp, match, atSymbols;
atSymbols = Object.keys(this.app.controllers).join('|');
subtext = subtext.split(' ').pop(); subtext = subtext.split(' ').pop();
flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&"); flag = flag.replace(/[\-\[\]\/\{\}\(\)\*\+\?\.\\\^\$\|]/g, "\\$&");
_a = decodeURI("%C3%80"); _a = decodeURI("%C3%80");
_y = decodeURI("%C3%BF"); _y = decodeURI("%C3%BF");
regexp = new RegExp("(?:\\B|\\W|\\s)" + flag + "(?!\\W)([A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]*)|([^\\x00-\\xff]*)$", 'gi'); regexp = new RegExp("(?:\\B|\\W|\\s)" + flag + "(?![" + atSymbols + "])([A-Za-z" + _a + "-" + _y + "0-9_\'\.\+\-]*)$", 'gi');
match = regexp.exec(subtext); match = regexp.exec(subtext);
......
...@@ -23,7 +23,6 @@ ...@@ -23,7 +23,6 @@
this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true; this.filterInputBlur = (ref = this.options.filterInputBlur) != null ? ref : true;
$inputContainer = this.input.parent(); $inputContainer = this.input.parent();
$clearButton = $inputContainer.find('.js-dropdown-input-clear'); $clearButton = $inputContainer.find('.js-dropdown-input-clear');
this.indeterminateIds = [];
$clearButton.on('click', (function(_this) { $clearButton.on('click', (function(_this) {
// Clear click // Clear click
return function(e) { return function(e) {
...@@ -348,12 +347,12 @@ ...@@ -348,12 +347,12 @@
$el = $(this); $el = $(this);
selected = self.rowClicked($el); selected = self.rowClicked($el);
if (self.options.clicked) { if (self.options.clicked) {
self.options.clicked(selected, $el, e); self.options.clicked(selected[0], $el, e, selected[1]);
} }
// Update label right after all modifications in dropdown has been done // Update label right after all modifications in dropdown has been done
if (self.options.toggleLabel) { if (self.options.toggleLabel) {
self.updateLabel(selected, $el, self); self.updateLabel(selected[0], $el, self);
} }
$el.trigger('blur'); $el.trigger('blur');
...@@ -444,12 +443,6 @@ ...@@ -444,12 +443,6 @@
this.resetRows(); this.resetRows();
this.addArrowKeyEvent(); this.addArrowKeyEvent();
if (this.options.setIndeterminateIds) {
this.options.setIndeterminateIds.call(this);
}
if (this.options.setActiveIds) {
this.options.setActiveIds.call(this);
}
// Makes indeterminate items effective // Makes indeterminate items effective
if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) { if (this.fullData && this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) {
this.parseData(this.fullData); this.parseData(this.fullData);
...@@ -483,11 +476,6 @@ ...@@ -483,11 +476,6 @@
if (this.options.filterable) { if (this.options.filterable) {
$input.blur().val(""); $input.blur().val("");
} }
// Triggering 'keyup' will re-render the dropdown which is not always required
// specially if we want to keep the state of the dropdown needed for bulk-assignment
if (!this.options.persistWhenHide) {
$input.trigger("input");
}
if (this.dropdown.find(".dropdown-toggle-page").length) { if (this.dropdown.find(".dropdown-toggle-page").length) {
$('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS); $('.dropdown-menu', this.dropdown).removeClass(PAGE_TWO_CLASS);
} }
...@@ -620,7 +608,8 @@ ...@@ -620,7 +608,8 @@
}; };
GitLabDropdown.prototype.rowClicked = function(el) { GitLabDropdown.prototype.rowClicked = function(el) {
var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value; var field, fieldName, groupName, isInput, selectedIndex, selectedObject, value, isMarking;
fieldName = this.options.fieldName; fieldName = this.options.fieldName;
isInput = $(this.el).is('input'); isInput = $(this.el).is('input');
if (this.renderedData) { if (this.renderedData) {
...@@ -641,7 +630,7 @@ ...@@ -641,7 +630,7 @@
el.addClass(ACTIVE_CLASS); el.addClass(ACTIVE_CLASS);
} }
return selectedObject; return [selectedObject];
} }
field = []; field = [];
...@@ -659,6 +648,7 @@ ...@@ -659,6 +648,7 @@
} }
if (el.hasClass(ACTIVE_CLASS)) { if (el.hasClass(ACTIVE_CLASS)) {
isMarking = false;
el.removeClass(ACTIVE_CLASS); el.removeClass(ACTIVE_CLASS);
if (field && field.length) { if (field && field.length) {
if (isInput) { if (isInput) {
...@@ -668,6 +658,7 @@ ...@@ -668,6 +658,7 @@
} }
} }
} else if (el.hasClass(INDETERMINATE_CLASS)) { } else if (el.hasClass(INDETERMINATE_CLASS)) {
isMarking = true;
el.addClass(ACTIVE_CLASS); el.addClass(ACTIVE_CLASS);
el.removeClass(INDETERMINATE_CLASS); el.removeClass(INDETERMINATE_CLASS);
if (field && field.length && value == null) { if (field && field.length && value == null) {
...@@ -677,6 +668,7 @@ ...@@ -677,6 +668,7 @@
this.addInput(fieldName, value, selectedObject); this.addInput(fieldName, value, selectedObject);
} }
} else { } else {
isMarking = true;
if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) { if (!this.options.multiSelect || el.hasClass('dropdown-clear-active')) {
this.dropdown.find("." + ACTIVE_CLASS).removeClass(ACTIVE_CLASS); this.dropdown.find("." + ACTIVE_CLASS).removeClass(ACTIVE_CLASS);
if (!isInput) { if (!isInput) {
...@@ -697,7 +689,7 @@ ...@@ -697,7 +689,7 @@
} }
} }
return selectedObject; return [selectedObject, isMarking];
}; };
GitLabDropdown.prototype.focusTextInput = function() { GitLabDropdown.prototype.focusTextInput = function() {
......
...@@ -144,6 +144,9 @@ ...@@ -144,6 +144,9 @@
const $issuesOtherFilters = $('.issues-other-filters'); const $issuesOtherFilters = $('.issues-other-filters');
const $issuesBulkUpdate = $('.issues_bulk_update'); const $issuesBulkUpdate = $('.issues_bulk_update');
this.issuableBulkActions.willUpdateLabels = false;
this.issuableBulkActions.setOriginalDropdownData();
if ($checkedIssues.length > 0) { if ($checkedIssues.length > 0) {
let ids = $.map($checkedIssues, function(value) { let ids = $.map($checkedIssues, function(value) {
return $(value).data('id'); return $(value).data('id');
...@@ -155,7 +158,6 @@ ...@@ -155,7 +158,6 @@
$updateIssuesIds.val([]); $updateIssuesIds.val([]);
$issuesBulkUpdate.hide(); $issuesBulkUpdate.hide();
$issuesOtherFilters.show(); $issuesOtherFilters.show();
this.issuableBulkActions.willUpdateLabels = false;
} }
return true; return true;
}, },
......
...@@ -5,9 +5,10 @@ ...@@ -5,9 +5,10 @@
((global) => { ((global) => {
class IssuableBulkActions { class IssuableBulkActions {
constructor({ container, form, issues } = {}) { constructor({ container, form, issues, prefixId } = {}) {
this.container = container || $('.content'), this.prefixId = prefixId || 'issue_';
this.form = form || this.getElement('.bulk-update'); this.form = form || this.getElement('.bulk-update');
this.$labelDropdown = this.form.find('.js-label-select');
this.issues = issues || this.getElement('.issues-list .issue'); this.issues = issues || this.getElement('.issues-list .issue');
this.form.data('bulkActions', this); this.form.data('bulkActions', this);
this.willUpdateLabels = false; this.willUpdateLabels = false;
...@@ -16,10 +17,6 @@ ...@@ -16,10 +17,6 @@
Issuable.initChecks(); Issuable.initChecks();
} }
getElement(selector) {
return this.container.find(selector);
}
bindEvents() { bindEvents() {
return this.form.off('submit').on('submit', this.onFormSubmit.bind(this)); return this.form.off('submit').on('submit', this.onFormSubmit.bind(this));
} }
...@@ -73,10 +70,7 @@ ...@@ -73,10 +70,7 @@
getUnmarkedIndeterminedLabels() { getUnmarkedIndeterminedLabels() {
const result = []; const result = [];
const labelsToKeep = []; const labelsToKeep = this.$labelDropdown.data('indeterminate');
this.getElement('.labels-filter .is-indeterminate')
.each((i, el) => labelsToKeep.push($(el).data('labelId')));
this.getLabelsFromSelection().forEach((id) => { this.getLabelsFromSelection().forEach((id) => {
if (labelsToKeep.indexOf(id) === -1) { if (labelsToKeep.indexOf(id) === -1) {
...@@ -106,45 +100,65 @@ ...@@ -106,45 +100,65 @@
} }
}; };
if (this.willUpdateLabels) { if (this.willUpdateLabels) {
this.getLabelsToApply().map(function(id) { formData.update.add_label_ids = this.$labelDropdown.data('marked');
return formData.update.add_label_ids.push(id); formData.update.remove_label_ids = this.$labelDropdown.data('unmarked');
});
this.getLabelsToRemove().map(function(id) {
return formData.update.remove_label_ids.push(id);
});
} }
return formData; return formData;
} }
getLabelsToApply() { setOriginalDropdownData() {
const $labelSelect = $('.bulk-update .js-label-select');
$labelSelect.data('common', this.getOriginalCommonIds());
$labelSelect.data('marked', this.getOriginalMarkedIds());
$labelSelect.data('indeterminate', this.getOriginalIndeterminateIds());
}
// From issuable's initial bulk selection
getOriginalCommonIds() {
const labelIds = []; const labelIds = [];
const $labels = this.form.find('.labels-filter input[name="update[label_ids][]"]');
$labels.each(function(k, label) { this.getElement('.selected_issue:checked').each((i, el) => {
if (label) { labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
return labelIds.push(parseInt($(label).val()));
}
}); });
return labelIds; return _.intersection.apply(this, labelIds);
} }
// From issuable's initial bulk selection
getOriginalMarkedIds() {
const labelIds = [];
this.getElement('.selected_issue:checked').each((i, el) => {
labelIds.push(this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels'));
});
return _.intersection.apply(this, labelIds);
}
/** // From issuable's initial bulk selection
* Returns Label IDs that will be removed from issue selection getOriginalIndeterminateIds() {
* @return {Array} Array of labels IDs const uniqueIds = [];
*/ const labelIds = [];
let issuableLabels = [];
getLabelsToRemove() {
const result = []; // Collect unique label IDs for all checked issues
const indeterminatedLabels = this.getUnmarkedIndeterminedLabels(); this.getElement('.selected_issue:checked').each((i, el) => {
const labelsToApply = this.getLabelsToApply(); issuableLabels = this.getElement(`#${this.prefixId}${el.dataset.id}`).data('labels');
indeterminatedLabels.map(function(id) { issuableLabels.forEach((labelId) => {
// We need to exclude label IDs that will be applied // Store unique IDs
// By not doing this will cause issues from selection to not add labels at all if (uniqueIds.indexOf(labelId) === -1) {
if (labelsToApply.indexOf(id) === -1) { uniqueIds.push(labelId);
return result.push(id); }
} });
// Store array of IDs per issuable
labelIds.push(issuableLabels);
}); });
return result; // Add uniqueIds to add it as argument for _.intersection
labelIds.unshift(uniqueIds);
// Return IDs that are present but not in all selected issueables
return _.difference(uniqueIds, _.intersection.apply(this, labelIds));
}
getElement(selector) {
this.scopeEl = this.scopeEl || $('.content');
return this.scopeEl.find(selector);
} }
} }
......
...@@ -8,8 +8,9 @@ ...@@ -8,8 +8,9 @@
var _this; var _this;
_this = this; _this = this;
$('.js-label-select').each(function(i, dropdown) { $('.js-label-select').each(function(i, dropdown) {
var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove; var $block, $colorPreview, $dropdown, $form, $loading, $selectbox, $sidebarCollapsedValue, $value, abilityName, defaultLabel, enableLabelCreateButton, issueURLSplit, issueUpdateURL, labelHTMLTemplate, labelNoneHTMLTemplate, labelUrl, namespacePath, projectPath, saveLabelData, selectedLabel, showAny, showNo, $sidebarLabelTooltip, initialSelected, $toggleText, fieldName, useId, propertyName, showMenuAbove, $container, $dropdownContainer;
$dropdown = $(dropdown); $dropdown = $(dropdown);
$dropdownContainer = $dropdown.closest('.labels-filter');
$toggleText = $dropdown.find('.dropdown-toggle-text'); $toggleText = $dropdown.find('.dropdown-toggle-text');
namespacePath = $dropdown.data('namespace-path'); namespacePath = $dropdown.data('namespace-path');
projectPath = $dropdown.data('project-path'); projectPath = $dropdown.data('project-path');
...@@ -125,7 +126,7 @@ ...@@ -125,7 +126,7 @@
}); });
}); });
}; };
return $dropdown.glDropdown({ $dropdown.glDropdown({
showMenuAbove: showMenuAbove, showMenuAbove: showMenuAbove,
data: function(term, callback) { data: function(term, callback) {
return $.ajax({ return $.ajax({
...@@ -172,33 +173,40 @@ ...@@ -172,33 +173,40 @@
}); });
}, },
renderRow: function(label, instance) { renderRow: function(label, instance) {
var $a, $li, active, color, colorEl, indeterminate, removesAll, selectedClass, spacing; var $a, $li, color, colorEl, indeterminate, removesAll, selectedClass, spacing, i, marked, dropdownName, dropdownValue;
$li = $('<li>'); $li = $('<li>');
$a = $('<a href="#">'); $a = $('<a href="#">');
selectedClass = []; selectedClass = [];
removesAll = label.id <= 0 || (label.id == null); removesAll = label.id <= 0 || (label.id == null);
if ($dropdown.hasClass('js-filter-bulk-update')) { if ($dropdown.hasClass('js-filter-bulk-update')) {
indeterminate = instance.indeterminateIds; indeterminate = $dropdown.data('indeterminate') || [];
active = instance.activeIds; marked = $dropdown.data('marked') || [];
if (indeterminate.indexOf(label.id) !== -1) { if (indeterminate.indexOf(label.id) !== -1) {
selectedClass.push('is-indeterminate'); selectedClass.push('is-indeterminate');
} }
if (active.indexOf(label.id) !== -1) {
if (marked.indexOf(label.id) !== -1) {
// Remove is-indeterminate class if the item will be marked as active // Remove is-indeterminate class if the item will be marked as active
i = selectedClass.indexOf('is-indeterminate'); i = selectedClass.indexOf('is-indeterminate');
if (i !== -1) { if (i !== -1) {
selectedClass.splice(i, 1); selectedClass.splice(i, 1);
} }
selectedClass.push('is-active'); selectedClass.push('is-active');
// Add input manually
instance.addInput(this.fieldName, label.id);
} }
} } else {
if (this.id(label) && $form.find("input[type='hidden'][name='" + ($dropdown.data('fieldName')) + "'][value='" + this.id(label).toString().replace(/'/g, '\\\'') + "']").length) { if (this.id(label)) {
selectedClass.push('is-active'); dropdownName = $dropdown.data('fieldName');
} dropdownValue = this.id(label).toString().replace(/'/g, '\\\'');
if ($dropdown.hasClass('js-multiselect') && removesAll) {
selectedClass.push('dropdown-clear-active'); if ($form.find("input[type='hidden'][name='" + dropdownName + "'][value='" + dropdownValue + "']").length) {
selectedClass.push('is-active');
}
}
if ($dropdown.hasClass('js-multiselect') && removesAll) {
selectedClass.push('dropdown-clear-active');
}
} }
if (label.duplicate) { if (label.duplicate) {
spacing = 100 / label.color.length; spacing = 100 / label.color.length;
...@@ -234,7 +242,6 @@ ...@@ -234,7 +242,6 @@
// Return generated html // Return generated html
return $li.html($a).prop('outerHTML'); return $li.html($a).prop('outerHTML');
}, },
persistWhenHide: $dropdown.data('persistWhenHide'),
search: { search: {
fields: ['title'] fields: ['title']
}, },
...@@ -313,18 +320,15 @@ ...@@ -313,18 +320,15 @@
} }
} }
} }
if ($dropdown.hasClass('js-filter-bulk-update')) {
// If we are persisting state we need the classes
if (!this.options.persistWhenHide) {
return $dropdown.parent().find('.is-active, .is-indeterminate').removeClass();
}
}
}, },
multiSelect: $dropdown.hasClass('js-multiselect'), multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'), vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(label, $el, e) { clicked: function(label, $el, e, isMarking) {
var isIssueIndex, isMRIndex, page; var isIssueIndex, isMRIndex, page;
_this.enableBulkLabelDropdown();
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index';
if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) { if ($dropdown.parent().find('.is-active:not(.dropdown-clear-active)').length) {
$dropdown.parent() $dropdown.parent()
...@@ -333,12 +337,11 @@ ...@@ -333,12 +337,11 @@
} }
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) { if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
_this.enableBulkLabelDropdown();
_this.setDropdownData($dropdown, isMarking, this.id(label));
return; return;
} }
page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index';
isMRIndex = page === 'projects:merge_requests:index';
if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) { if ($('html').hasClass('issue-boards-page') && !$dropdown.hasClass('js-issue-board-sidebar')) {
if (label.isAny) { if (label.isAny) {
gl.issueBoards.BoardsStore.state.filters['label_name'] = []; gl.issueBoards.BoardsStore.state.filters['label_name'] = [];
...@@ -400,17 +403,10 @@ ...@@ -400,17 +403,10 @@
} }
} }
}, },
setIndeterminateIds: function() {
if (this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) {
return this.indeterminateIds = _this.getIndeterminateIds();
}
},
setActiveIds: function() {
if (this.dropdown.find('.dropdown-menu-toggle').hasClass('js-filter-bulk-update')) {
return this.activeIds = _this.getActiveIds();
}
}
}); });
// Set dropdown data
_this.setOriginalDropdownData($dropdownContainer, $dropdown);
}); });
this.bindEvents(); this.bindEvents();
} }
...@@ -423,34 +419,9 @@ ...@@ -423,34 +419,9 @@
if ($('.selected_issue:checked').length) { if ($('.selected_issue:checked').length) {
return; return;
} }
// Remove inputs
$('.issues_bulk_update .labels-filter input[type="hidden"]').remove();
// Also restore button text
return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label'); return $('.issues_bulk_update .labels-filter .dropdown-toggle-text').text('Label');
}; };
LabelsSelect.prototype.getIndeterminateIds = function() {
var label_ids;
label_ids = [];
$('.selected_issue:checked').each(function(i, el) {
var issue_id;
issue_id = $(el).data('id');
return label_ids.push($("#issue_" + issue_id).data('labels'));
});
return _.flatten(label_ids);
};
LabelsSelect.prototype.getActiveIds = function() {
var label_ids;
label_ids = [];
$('.selected_issue:checked').each(function(i, el) {
var issue_id;
issue_id = $(el).data('id');
return label_ids.push($("#issue_" + issue_id).data('labels'));
});
return _.intersection.apply(_, label_ids);
};
LabelsSelect.prototype.enableBulkLabelDropdown = function() { LabelsSelect.prototype.enableBulkLabelDropdown = function() {
var issuableBulkActions; var issuableBulkActions;
if ($('.selected_issue:checked').length) { if ($('.selected_issue:checked').length) {
...@@ -459,8 +430,59 @@ ...@@ -459,8 +430,59 @@
} }
}; };
return LabelsSelect; LabelsSelect.prototype.setDropdownData = function($dropdown, isMarking, value) {
var i, markedIds, unmarkedIds, indeterminateIds;
var issuableBulkActions = $('.bulk-update').data('bulkActions');
markedIds = $dropdown.data('marked') || [];
unmarkedIds = $dropdown.data('unmarked') || [];
indeterminateIds = $dropdown.data('indeterminate') || [];
if (isMarking) {
markedIds.push(value);
i = indeterminateIds.indexOf(value);
if (i > -1) {
indeterminateIds.splice(i, 1);
}
i = unmarkedIds.indexOf(value);
if (i > -1) {
unmarkedIds.splice(i, 1);
}
} else {
// If marked item (not common) is unmarked
i = markedIds.indexOf(value);
if (i > -1) {
markedIds.splice(i, 1);
}
// If an indeterminate item is being unmarked
if (issuableBulkActions.getOriginalIndeterminateIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
// If a marked item is being unmarked
// (a marked item could also be a label that is present in all selection)
if (issuableBulkActions.getOriginalCommonIds().indexOf(value) > -1) {
unmarkedIds.push(value);
}
}
$dropdown.data('marked', markedIds);
$dropdown.data('unmarked', unmarkedIds);
$dropdown.data('indeterminate', indeterminateIds);
};
LabelsSelect.prototype.setOriginalDropdownData = function($container, $dropdown) {
var labels = [];
$container.find('[name="label_name[]"]').map(function() {
return labels.push(this.value);
});
$dropdown.data('marked', labels);
};
return LabelsSelect;
})(); })();
}).call(this); }).call(this);
/* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-undef, no-else-return, prefer-arrow-callback, padded-blocks, max-len */ /* eslint-disable func-names, space-before-function-paren, consistent-return, no-var, no-undef, no-else-return, prefer-arrow-callback, padded-blocks, max-len, no-console */
// Renders math using KaTeX in any element with the // Renders math using KaTeX in any element with the
// `js-render-math` class // `js-render-math` class
// //
......
...@@ -188,7 +188,6 @@ ...@@ -188,7 +188,6 @@
&.is-focused { &.is-focused {
background-color: $dropdown-link-hover-bg; background-color: $dropdown-link-hover-bg;
text-decoration: none; text-decoration: none;
outline: 0;
} }
&.dropdown-menu-empty-link { &.dropdown-menu-empty-link {
......
...@@ -51,8 +51,16 @@ ...@@ -51,8 +51,16 @@
.new-file-name { .new-file-name {
display: inline-block; display: inline-block;
width: 450px; max-width: 450px;
float: left; float: left;
@media(max-width: $screen-md-max) {
width: 280px;
}
@media(max-width: $screen-sm-max) {
width: 180px;
}
} }
.file-buttons { .file-buttons {
...@@ -114,3 +122,42 @@ ...@@ -114,3 +122,42 @@
} }
} }
} }
@media(max-width: $screen-xs-max){
.file-editor {
.file-title {
.pull-right {
height: auto;
}
}
.new-file-name {
max-width: none;
width: 100%;
margin-bottom: 3px;
}
.file-buttons {
display: block;
width: 100%;
margin-bottom: 10px;
.soft-wrap-toggle {
width: 100%;
margin: 3px 0;
}
.encoding-selector,
.license-selector,
.gitignore-selector,
.gitlab-ci-yml-selector {
display: block;
margin: 3px 0;
button {
width: 100%;
}
}
}
}
}
...@@ -9,6 +9,14 @@ module Ci ...@@ -9,6 +9,14 @@ module Ci
has_many :deployments, as: :deployable has_many :deployments, as: :deployable
# The "environment" field for builds is a String, and is the unexpanded name
def persisted_environment
@persisted_environment ||= Environment.find_by(
name: expanded_environment_name,
project_id: gl_project_id
)
end
serialize :options serialize :options
serialize :yaml_variables serialize :yaml_variables
...@@ -143,7 +151,7 @@ module Ci ...@@ -143,7 +151,7 @@ module Ci
end end
def expanded_environment_name def expanded_environment_name
ExpandVariables.expand(environment, variables) if environment ExpandVariables.expand(environment, simple_variables) if environment
end end
def has_environment? def has_environment?
...@@ -206,7 +214,8 @@ module Ci ...@@ -206,7 +214,8 @@ module Ci
slugified.gsub(/[^a-z0-9]/, '-')[0..62] slugified.gsub(/[^a-z0-9]/, '-')[0..62]
end end
def variables # Variables whose value does not depend on other variables
def simple_variables
variables = predefined_variables variables = predefined_variables
variables += project.predefined_variables variables += project.predefined_variables
variables += pipeline.predefined_variables variables += pipeline.predefined_variables
...@@ -219,6 +228,13 @@ module Ci ...@@ -219,6 +228,13 @@ module Ci
variables variables
end end
# All variables, including those dependent on other variables
def variables
variables = simple_variables
variables += persisted_environment.predefined_variables if persisted_environment.present?
variables
end
def merge_request def merge_request
merge_requests = MergeRequest.includes(:merge_request_diff) merge_requests = MergeRequest.includes(:merge_request_diff)
.where(source_branch: ref, source_project_id: pipeline.gl_project_id) .where(source_branch: ref, source_project_id: pipeline.gl_project_id)
......
...@@ -88,8 +88,24 @@ module Ci ...@@ -88,8 +88,24 @@ module Ci
end end
# ref can't be HEAD or SHA, can only be branch/tag name # ref can't be HEAD or SHA, can only be branch/tag name
scope :latest, ->(ref = nil) do
max_id = unscope(:select)
.select("max(#{quoted_table_name}.id)")
.group(:ref, :sha)
if ref
where(id: max_id, ref: ref)
else
where(id: max_id)
end
end
def self.latest_status(ref = nil)
latest(ref).status
end
def self.latest_successful_for(ref) def self.latest_successful_for(ref)
where(ref: ref).order(id: :desc).success.first success.latest(ref).first
end end
def self.truncate_sha(sha) def self.truncate_sha(sha)
......
...@@ -228,13 +228,9 @@ class Commit ...@@ -228,13 +228,9 @@ class Commit
def status(ref = nil) def status(ref = nil)
@statuses ||= {} @statuses ||= {}
if @statuses.key?(ref) return @statuses[ref] if @statuses.key?(ref)
@statuses[ref]
elsif ref @statuses[ref] = pipelines.latest_status(ref)
@statuses[ref] = pipelines.where(ref: ref).status
else
@statuses[ref] = pipelines.status
end
end end
def revert_branch_name def revert_branch_name
...@@ -270,7 +266,7 @@ class Commit ...@@ -270,7 +266,7 @@ class Commit
@merged_merge_request_hash ||= Hash.new do |hash, user| @merged_merge_request_hash ||= Hash.new do |hash, user|
hash[user] = merged_merge_request_no_cache(user) hash[user] = merged_merge_request_no_cache(user)
end end
@merged_merge_request_hash[current_user] @merged_merge_request_hash[current_user]
end end
......
class Environment < ActiveRecord::Base class Environment < ActiveRecord::Base
# Used to generate random suffixes for the slug
NUMBERS = '0'..'9'
SUFFIX_CHARS = ('a'..'z').to_a + NUMBERS.to_a
belongs_to :project, required: true, validate: true belongs_to :project, required: true, validate: true
has_many :deployments has_many :deployments
before_validation :nullify_external_url before_validation :nullify_external_url
before_validation :generate_slug, if: ->(env) { env.slug.blank? }
before_save :set_environment_type before_save :set_environment_type
validates :name, validates :name,
...@@ -13,6 +19,13 @@ class Environment < ActiveRecord::Base ...@@ -13,6 +19,13 @@ class Environment < ActiveRecord::Base
format: { with: Gitlab::Regex.environment_name_regex, format: { with: Gitlab::Regex.environment_name_regex,
message: Gitlab::Regex.environment_name_regex_message } message: Gitlab::Regex.environment_name_regex_message }
validates :slug,
presence: true,
uniqueness: { scope: :project_id },
length: { maximum: 24 },
format: { with: Gitlab::Regex.environment_slug_regex,
message: Gitlab::Regex.environment_slug_regex_message }
validates :external_url, validates :external_url,
uniqueness: { scope: :project_id }, uniqueness: { scope: :project_id },
length: { maximum: 255 }, length: { maximum: 255 },
...@@ -37,6 +50,13 @@ class Environment < ActiveRecord::Base ...@@ -37,6 +50,13 @@ class Environment < ActiveRecord::Base
state :stopped state :stopped
end end
def predefined_variables
[
{ key: 'CI_ENVIRONMENT_NAME', value: name, public: true },
{ key: 'CI_ENVIRONMENT_SLUG', value: slug, public: true },
]
end
def recently_updated_on_branch?(ref) def recently_updated_on_branch?(ref)
ref.to_s == last_deployment.try(:ref) ref.to_s == last_deployment.try(:ref)
end end
...@@ -107,4 +127,41 @@ class Environment < ActiveRecord::Base ...@@ -107,4 +127,41 @@ class Environment < ActiveRecord::Base
action.expanded_environment_name == environment action.expanded_environment_name == environment
end end
end end
# An environment name is not necessarily suitable for use in URLs, DNS
# or other third-party contexts, so provide a slugified version. A slug has
# the following properties:
# * contains only lowercase letters (a-z), numbers (0-9), and '-'
# * begins with a letter
# * has a maximum length of 24 bytes (OpenShift limitation)
# * cannot end with `-`
def generate_slug
# Lowercase letters and numbers only
slugified = name.to_s.downcase.gsub(/[^a-z0-9]/, '-')
# Must start with a letter
slugified = "env-" + slugified if NUMBERS.cover?(slugified[0])
# Maximum length: 24 characters (OpenShift limitation)
slugified = slugified[0..23]
# Cannot end with a "-" character (Kubernetes label limitation)
slugified = slugified[0..-2] if slugified[-1] == "-"
# Add a random suffix, shortening the current string if necessary, if it
# has been slugified. This ensures uniqueness.
slugified = slugified[0..16] + "-" + random_suffix if slugified != name
self.slug = slugified
end
private
# Slugifying a name may remove the uniqueness guarantee afforded by it being
# based on name (which must be unique). To compensate, we add a random
# 6-byte suffix in those circumstances. This is not *guaranteed* uniqueness,
# but the chance of collisions is vanishingly small
def random_suffix
(0..5).map { SUFFIX_CHARS.sample }.join
end
end end
...@@ -10,18 +10,29 @@ module Ci ...@@ -10,18 +10,29 @@ module Ci
end end
end end
def project
pipeline.project
end
private private
def create_build(build_attributes) def create_build(build_attributes)
build_attributes = build_attributes.merge( build_attributes = build_attributes.merge(
pipeline: pipeline, pipeline: pipeline,
project: pipeline.project, project: project,
ref: pipeline.ref, ref: pipeline.ref,
tag: pipeline.tag, tag: pipeline.tag,
user: current_user, user: current_user,
trigger_request: trigger_request trigger_request: trigger_request
) )
pipeline.builds.create(build_attributes) build = pipeline.builds.create(build_attributes)
# Create the environment before the build starts. This sets its slug and
# makes it available as an environment variable
project.environments.find_or_create_by(name: build.expanded_environment_name) if
build.has_environment?
build
end end
def new_builds def new_builds
......
module Ci module Ci
class ImageForBuildService class ImageForBuildService
def execute(project, opts) def execute(project, opts)
sha = opts[:sha] || ref_sha(project, opts[:ref]) ref = opts[:ref]
sha = opts[:sha] || ref_sha(project, ref)
pipelines = project.pipelines.where(sha: sha) pipelines = project.pipelines.where(sha: sha)
pipelines = pipelines.where(ref: opts[:ref]) if opts[:ref]
image_name = image_for_status(pipelines.status)
image_name = image_for_status(pipelines.latest_status(ref))
image_path = Rails.root.join('public/ci', image_name) image_path = Rails.root.join('public/ci', image_name)
OpenStruct.new(path: image_path, name: image_name) OpenStruct.new(path: image_path, name: image_name)
end end
......
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
- if params[:project_id].present? - if params[:project_id].present?
= hidden_field_tag(:project_id, params[:project_id]) = hidden_field_tag(:project_id, params[:project_id])
= dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', = dropdown_tag(project_dropdown_label(params[:project_id], 'Project'), options: { toggle_class: 'js-project-search js-filter-submit', title: 'Filter by project', filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit',
placeholder: 'Search projects', data: { data: todo_projects_options } }) placeholder: 'Search projects', data: { data: todo_projects_options, default_label: 'Project' } })
.filter-item.inline .filter-item.inline
- if params[:author_id].present? - if params[:author_id].present?
= hidden_field_tag(:author_id, params[:author_id]) = hidden_field_tag(:author_id, params[:author_id])
...@@ -42,12 +42,12 @@ ...@@ -42,12 +42,12 @@
- if params[:type].present? - if params[:type].present?
= hidden_field_tag(:type, params[:type]) = hidden_field_tag(:type, params[:type])
= dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', = dropdown_tag(todo_types_dropdown_label(params[:type], 'Type'), options: { toggle_class: 'js-type-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit',
data: { data: todo_types_options } }) data: { data: todo_types_options, default_label: 'Type' } })
.filter-item.inline.actions-filter .filter-item.inline.actions-filter
- if params[:action_id].present? - if params[:action_id].present?
= hidden_field_tag(:action_id, params[:action_id]) = hidden_field_tag(:action_id, params[:action_id])
= dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', = dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit',
data: { data: todo_actions_options }}) data: { data: todo_actions_options, default_label: 'Action' } })
.pull-right .pull-right
.dropdown.inline.prepend-left-10 .dropdown.inline.prepend-left-10
%button.dropdown-toggle{type: 'button', 'data-toggle' => 'dropdown'} %button.dropdown-toggle{type: 'button', 'data-toggle' => 'dropdown'}
......
%li{ class: mr_css_classes(merge_request) } %li{ id: dom_id(merge_request), class: mr_css_classes(merge_request), data: { labels: merge_request.label_ids, id: merge_request.id } }
- if @bulk_edit - if @bulk_edit
.issue-check .issue-check
= check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue" = check_box_tag dom_id(merge_request, "selected"), nil, false, 'data-id' => merge_request.id, class: "selected_issue"
......
.content-block.oneline-block
= icon("sort-amount-desc")
Most recent commits displayed first
%ol#commits-list.list-unstyled %ol#commits-list.list-unstyled
= render "projects/commits/commits", project: @merge_request.source_project, ref: @merge_request.source_branch = render "projects/commits/commits", project: @merge_request.source_project, ref: @merge_request.source_branch
---
title: Add a slug to environments
merge_request: 7983
author:
---
title: Add focus state to dropdown items
merge_request:
author:
---
title: Improve bulk assignment for issuables
merge_request:
author:
---
title: Improve help message for issue create slash command
merge_request: 7850
author:
---
title: Added go back anchor on error pages.
merge_request: 8087
author:
---
title: 25617 Fix placeholder color of todo filters
merge_request:
author:
---
title: Fixed file template dropdown for the "New File" editor for smaller/zoomed screens
merge_request:
author:
---
title: Move admin active tab spinach tests to rspec
merge_request: 8037
author: Semyon Pupkov
---
title: Remove unnecessary commits order message
merge_request: 8004
author:
---
title: Show commit status from latest pipeline
merge_request: 7333
author:
class FixupEnvironmentNameUniqueness < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = true
DOWNTIME_REASON = 'Renaming non-unique environments'
def up
environments = Arel::Table.new(:environments)
# Get all [project_id, name] pairs that occur more than once
finder_sql = environments.
group(environments[:project_id], environments[:name]).
having(Arel.sql("COUNT(1)").gt(1)).
project(environments[:project_id], environments[:name]).
to_sql
conflicting = connection.exec_query(finder_sql)
conflicting.rows.each do |project_id, name|
fix_duplicates(project_id, name)
end
end
def down
# Nothing to do
end
# Rename conflicting environments by appending "-#{id}" to all but the first
def fix_duplicates(project_id, name)
environments = Arel::Table.new(:environments)
finder_sql = environments.
where(environments[:project_id].eq(project_id)).
where(environments[:name].eq(name)).
order(environments[:id].asc).
project(environments[:id], environments[:name]).
to_sql
# Now we have the data for all the conflicting rows
conflicts = connection.exec_query(finder_sql).rows
conflicts.shift # Leave the first row alone
conflicts.each do |id, name|
update_sql =
Arel::UpdateManager.new(ActiveRecord::Base).
table(environments).
set(environments[:name] => name + "-" + id.to_s).
where(environments[:id].eq(id)).
to_sql
connection.exec_update(update_sql, self.class.name, [])
end
end
end
class CreateEnvironmentNameUniqueIndex < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
disable_ddl_transaction!
DOWNTIME = true
DOWNTIME_REASON = 'Making a non-unique index into a unique index'
def up
remove_index :environments, [:project_id, :name]
add_concurrent_index :environments, [:project_id, :name], unique: true
end
def down
remove_index :environments, [:project_id, :name], unique: true
add_concurrent_index :environments, [:project_id, :name]
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddEnvironmentSlug < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = true
DOWNTIME_REASON = 'Adding NOT NULL column environments.slug with dependent data'
# Used to generate random suffixes for the slug
NUMBERS = '0'..'9'
SUFFIX_CHARS = ('a'..'z').to_a + NUMBERS.to_a
def up
environments = Arel::Table.new(:environments)
add_column :environments, :slug, :string
finder = environments.project(:id, :name)
connection.exec_query(finder.to_sql).rows.each do |id, name|
updater = Arel::UpdateManager.new(ActiveRecord::Base).
table(environments).
set(environments[:slug] => generate_slug(name)).
where(environments[:id].eq(id))
connection.exec_update(updater.to_sql, self.class.name, [])
end
change_column_null :environments, :slug, false
end
def down
remove_column :environments, :slug
end
# Copy of the Environment#generate_slug implementation
def generate_slug(name)
# Lowercase letters and numbers only
slugified = name.to_s.downcase.gsub(/[^a-z0-9]/, '-')
# Must start with a letter
slugified = "env-" + slugified if NUMBERS.cover?(slugified[0])
# Maximum length: 24 characters (OpenShift limitation)
slugified = slugified[0..23]
# Cannot end with a "-" character (Kubernetes label limitation)
slugified = slugified[0..-2] if slugified[-1] == "-"
# Add a random suffix, shortening the current string if necessary, if it
# has been slugified. This ensures uniqueness.
slugified = slugified[0..16] + "-" + random_suffix if slugified != name
slugified
end
def random_suffix
(0..5).map { SUFFIX_CHARS.sample }.join
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddUniqueIndexForEnvironmentSlug < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = true
DOWNTIME_REASON = 'Adding a *unique* index to environments.slug'
disable_ddl_transaction!
def change
add_concurrent_index :environments, [:project_id, :slug], unique: true
end
end
...@@ -98,14 +98,14 @@ ActiveRecord::Schema.define(version: 20161212142807) do ...@@ -98,14 +98,14 @@ ActiveRecord::Schema.define(version: 20161212142807) do
t.text "help_page_text_html" t.text "help_page_text_html"
t.text "shared_runners_text_html" t.text "shared_runners_text_html"
t.text "after_sign_up_text_html" t.text "after_sign_up_text_html"
t.boolean "sidekiq_throttling_enabled", default: false
t.string "sidekiq_throttling_queues"
t.decimal "sidekiq_throttling_factor"
t.boolean "housekeeping_enabled", default: true, null: false t.boolean "housekeeping_enabled", default: true, null: false
t.boolean "housekeeping_bitmaps_enabled", default: true, null: false t.boolean "housekeeping_bitmaps_enabled", default: true, null: false
t.integer "housekeeping_incremental_repack_period", default: 10, null: false t.integer "housekeeping_incremental_repack_period", default: 10, null: false
t.integer "housekeeping_full_repack_period", default: 50, null: false t.integer "housekeeping_full_repack_period", default: 50, null: false
t.integer "housekeeping_gc_period", default: 200, null: false t.integer "housekeeping_gc_period", default: 200, null: false
t.boolean "sidekiq_throttling_enabled", default: false
t.string "sidekiq_throttling_queues"
t.decimal "sidekiq_throttling_factor"
t.boolean "html_emails_enabled", default: true t.boolean "html_emails_enabled", default: true
end end
...@@ -428,9 +428,11 @@ ActiveRecord::Schema.define(version: 20161212142807) do ...@@ -428,9 +428,11 @@ ActiveRecord::Schema.define(version: 20161212142807) do
t.string "external_url" t.string "external_url"
t.string "environment_type" t.string "environment_type"
t.string "state", default: "available", null: false t.string "state", default: "available", null: false
t.string "slug", null: false
end end
add_index "environments", ["project_id", "name"], name: "index_environments_on_project_id_and_name", using: :btree add_index "environments", ["project_id", "name"], name: "index_environments_on_project_id_and_name", unique: true, using: :btree
add_index "environments", ["project_id", "slug"], name: "index_environments_on_project_id_and_slug", unique: true, using: :btree
create_table "events", force: :cascade do |t| create_table "events", force: :cascade do |t|
t.string "target_type" t.string "target_type"
...@@ -737,8 +739,8 @@ ActiveRecord::Schema.define(version: 20161212142807) do ...@@ -737,8 +739,8 @@ ActiveRecord::Schema.define(version: 20161212142807) do
t.integer "visibility_level", default: 20, null: false t.integer "visibility_level", default: 20, null: false
t.boolean "request_access_enabled", default: false, null: false t.boolean "request_access_enabled", default: false, null: false
t.datetime "deleted_at" t.datetime "deleted_at"
t.text "description_html"
t.boolean "lfs_enabled" t.boolean "lfs_enabled"
t.text "description_html"
t.integer "parent_id" t.integer "parent_id"
end end
...@@ -1219,8 +1221,8 @@ ActiveRecord::Schema.define(version: 20161212142807) do ...@@ -1219,8 +1221,8 @@ ActiveRecord::Schema.define(version: 20161212142807) do
t.datetime "otp_grace_period_started_at" t.datetime "otp_grace_period_started_at"
t.boolean "ldap_email", default: false, null: false t.boolean "ldap_email", default: false, null: false
t.boolean "external", default: false t.boolean "external", default: false
t.string "incoming_email_token"
t.string "organization" t.string "organization"
t.string "incoming_email_token"
t.boolean "authorized_projects_populated" t.boolean "authorized_projects_populated"
end end
...@@ -1290,4 +1292,4 @@ ActiveRecord::Schema.define(version: 20161212142807) do ...@@ -1290,4 +1292,4 @@ ActiveRecord::Schema.define(version: 20161212142807) do
add_foreign_key "subscriptions", "projects", on_delete: :cascade add_foreign_key "subscriptions", "projects", on_delete: :cascade
add_foreign_key "trending_projects", "projects", on_delete: :cascade add_foreign_key "trending_projects", "projects", on_delete: :cascade
add_foreign_key "u2f_registrations", "users" add_foreign_key "u2f_registrations", "users"
end end
\ No newline at end of file
...@@ -7,5 +7,6 @@ providers. ...@@ -7,5 +7,6 @@ providers.
and 389 Server and 389 Server
- [OmniAuth](../../integration/omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google, - [OmniAuth](../../integration/omniauth.md) Sign in via Twitter, GitHub, GitLab.com, Google,
Bitbucket, Facebook, Shibboleth, Crowd and Azure Bitbucket, Facebook, Shibboleth, Crowd and Azure
- [SAML](../../integration/saml.md) Configure GitLab as a SAML 2.0 Service Provider
- [CAS](../../integration/cas.md) Configure GitLab to sign in using CAS - [CAS](../../integration/cas.md) Configure GitLab to sign in using CAS
- [SAML](../../integration/saml.md) Configure GitLab as a SAML 2.0 Service Provider
- [Okta](okta.md) Configure GitLab to sign in using Okta
# Okta SSO provider
Okta is a [Single Sign-on provider][okta-sso] that can be used to authenticate
with GitLab.
The following documentation enables Okta as a SAML provider.
## Configure the Okta application
1. On Okta go to the admin section and choose to **Add an App**.
1. When the app screen comes up you see another button to **Create an App** and
choose SAML 2.0 on the next screen.
1. Now, very important, add a logo
(you can choose it from https://about.gitlab.com/press/). You'll have to
crop and resize it.
1. Next, you'll need the to fill in the SAML general config. Here's an example
image.
![Okta admin panel view](img/okta_admin_panel.png)
1. The last part of the configuration is the feedback section where you can
just say you're a customer and creating an app for internal use.
1. When you have your app you'll have a few tabs on the top of the app's
profile. Click on the SAML 2.0 config instructions button which should
look like the following:
![Okta SAML settings](img/okta_saml_settings.png)
1. On the screen that comes up take note of the
**Identity Provider Single Sign-On URL** which you'll use for the
`idp_sso_target_url` on your GitLab config file.
1. **Before you leave Okta make sure you add your user and groups if any.**
---
Now that the Okta app is configured, it's time to enable it in GitLab.
## Configure GitLab
1. On your GitLab server, open the configuration file:
**For Omnibus GitLab installations**
```sh
sudo editor /etc/gitlab/gitlab.rb
```
**For installations from source**
```sh
cd /home/git/gitlab
sudo -u git -H editor config/gitlab.yml
```
1. See [Initial OmniAuth Configuration](../../integration/omniauth.md#initial-omniauth-configuration)
for initial settings.
1. To allow your users to use Okta to sign up without having to manually create
an account first, don't forget to add the following values to your
configuration:
**For Omnibus GitLab installations**
```ruby
gitlab_rails['omniauth_allow_single_sign_on'] = ['saml']
gitlab_rails['omniauth_block_auto_created_users'] = false
```
**For installations from source**
```yaml
allow_single_sign_on: ["saml"]
block_auto_created_users: false
```
1. You can also automatically link Okta users with existing GitLab users if
their email addresses match by adding the following setting:
**For Omnibus GitLab installations**
```ruby
gitlab_rails['omniauth_auto_link_saml_user'] = true
```
**For installations from source**
```yaml
auto_link_saml_user: true
```
1. Add the provider configuration.
>**Notes:**
>- Change the value for `assertion_consumer_service_url` to match the HTTPS endpoint
of GitLab (append `users/auth/saml/callback` to the HTTPS URL of your GitLab
installation to generate the correct value).
>- To get the `idp_cert_fingerprint` fingerprint, first download the
certificate from the Okta app you registered and then run:
`openssl x509 -in okta.cert -noout -fingerprint`. Substitute `okta.cert`
with the location of your certificate.
>- Change the value of `idp_sso_target_url`, with the value of the
**Identity Provider Single Sign-On URL** from the step when you
configured the Okta app.
>- Change the value of `issuer` to a unique name, which will identify the application
to the IdP.
>- Leave `name_identifier_format` as-is.
**For Omnibus GitLab installations**
```ruby
gitlab_rails['omniauth_providers'] = [
{
name: 'saml',
args: {
assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback',
idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8',
idp_sso_target_url: 'https://gitlab.oktapreview.com/app/gitlabdev773716_gitlabsaml_1/exk8odl81tBrjpD4B0h7/sso/saml',
issuer: 'https://gitlab.example.com',
name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
},
label: 'Okta' # optional label for SAML login button, defaults to "Saml"
}
]
```
**For installations from source**
```yaml
- {
name: 'saml',
args: {
assertion_consumer_service_url: 'https://gitlab.example.com/users/auth/saml/callback',
idp_cert_fingerprint: '43:51:43:a1:b5:fc:8b:b7:0a:3a:a9:b1:0f:66:73:a8',
idp_sso_target_url: 'https://gitlab.oktapreview.com/app/gitlabdev773716_gitlabsaml_1/exk8odl81tBrjpD4B0h7/sso/saml',
issuer: 'https://gitlab.example.com',
name_identifier_format: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient'
},
label: 'Okta' # optional label for SAML login button, defaults to "Saml"
}
```
1. [Reconfigure][reconf] or [restart] GitLab for Omnibus and installations
from source respectively for the changes to take effect.
You might want to try this out on a incognito browser window.
## Configuring groups
>**Note:**
Make sure the groups exist and are assigned to the Okta app.
You can take a look of the [SAML documentation][saml] on external groups since
it works the same.
[okta-sso]: https://www.okta.com/products/single-sign-on/
[saml]: ../../integration/saml.md#external-groups
[reconf]: ../restart_gitlab.md#omnibus-gitlab-reconfigure
[restart]: ../restart_gitlab.md#installations-from-source
...@@ -44,6 +44,9 @@ If you use a cloud-managed service, or provide your own PostgreSQL: ...@@ -44,6 +44,9 @@ If you use a cloud-managed service, or provide your own PostgreSQL:
gitlab_rails['db_password'] = 'DB password' gitlab_rails['db_password'] = 'DB password'
postgresql['md5_auth_cidr_addresses'] = ['0.0.0.0/0'] postgresql['md5_auth_cidr_addresses'] = ['0.0.0.0/0']
postgresql['listen_address'] = '0.0.0.0' postgresql['listen_address'] = '0.0.0.0'
# Disable automatic database migrations
gitlab_rails['auto_migrate'] = false
``` ```
1. Run `sudo gitlab-ctl reconfigure` to install and configure PostgreSQL. 1. Run `sudo gitlab-ctl reconfigure` to install and configure PostgreSQL.
...@@ -102,9 +105,6 @@ If you use a cloud-managed service, or provide your own PostgreSQL: ...@@ -102,9 +105,6 @@ If you use a cloud-managed service, or provide your own PostgreSQL:
1. Exit the database prompt by typing `\q` and Enter. 1. Exit the database prompt by typing `\q` and Enter.
1. Exit the `gitlab-psql` user by running `exit` twice. 1. Exit the `gitlab-psql` user by running `exit` twice.
1. Run `sudo gitlab-ctl reconfigure` a final time. 1. Run `sudo gitlab-ctl reconfigure` a final time.
1. Run `sudo touch /etc/gitlab/skip-auto-migrations` to prevent database migrations
from running on upgrade. Only the primary GitLab application server should
handle migrations.
--- ---
......
...@@ -287,14 +287,14 @@ The prerequisites for a HA Redis setup are the following: ...@@ -287,14 +287,14 @@ The prerequisites for a HA Redis setup are the following:
redis['password'] = 'redis-password-goes-here' redis['password'] = 'redis-password-goes-here'
``` ```
1. To prevent database migrations from running on upgrade, run: 1. Only the primary GitLab application server should handle migrations. To
prevent database migrations from running on upgrade, add the following
configuration to your `/etc/gitlab/gitlab.rb` file:
``` ```
sudo touch /etc/gitlab/skip-auto-migrations gitlab_rails['auto_migrate'] = false
``` ```
Only the primary GitLab application server should handle migrations.
1. [Reconfigure Omnibus GitLab][reconfigure] for the changes to take effect. 1. [Reconfigure Omnibus GitLab][reconfigure] for the changes to take effect.
### Step 2. Configuring the slave Redis instances ### Step 2. Configuring the slave Redis instances
......
...@@ -22,8 +22,9 @@ Example response: ...@@ -22,8 +22,9 @@ Example response:
[ [
{ {
"id": 1, "id": 1,
"name": "Env1", "name": "review/fix-foo",
"external_url": "https://env1.example.gitlab.com" "slug": "review-fix-foo-dfjre3",
"external_url": "https://review-fix-foo-dfjre3.example.gitlab.com"
} }
] ]
``` ```
...@@ -54,6 +55,7 @@ Example response: ...@@ -54,6 +55,7 @@ Example response:
{ {
"id": 1, "id": 1,
"name": "deploy", "name": "deploy",
"slug": "deploy",
"external_url": "https://deploy.example.gitlab.com" "external_url": "https://deploy.example.gitlab.com"
} }
``` ```
...@@ -85,6 +87,7 @@ Example response: ...@@ -85,6 +87,7 @@ Example response:
{ {
"id": 1, "id": 1,
"name": "staging", "name": "staging",
"slug": "staging",
"external_url": "https://staging.example.gitlab.com" "external_url": "https://staging.example.gitlab.com"
} }
``` ```
...@@ -112,6 +115,7 @@ Example response: ...@@ -112,6 +115,7 @@ Example response:
{ {
"id": 1, "id": 1,
"name": "deploy", "name": "deploy",
"slug": "deploy",
"external_url": "https://deploy.example.gitlab.com" "external_url": "https://deploy.example.gitlab.com"
} }
``` ```
...@@ -86,6 +86,13 @@ will later see, is exposed in various places within GitLab. Each time a job that ...@@ -86,6 +86,13 @@ will later see, is exposed in various places within GitLab. Each time a job that
has an environment specified and succeeds, a deployment is recorded, remembering has an environment specified and succeeds, a deployment is recorded, remembering
the Git SHA and environment name. the Git SHA and environment name.
>**Note:**
Starting with GitLab 8.15, the environment name is exposed to the Runner in
two forms: `$CI_ENVIRONMENT_NAME`, and `$CI_ENVIRONMENT_SLUG`. The first is
the name given in `.gitlab-ci.yml` (with any variables expanded), while the
second is a "cleaned-up" version of the name, suitable for use in URLs, DNS,
etc.
To sum up, with the above `.gitlab-ci.yml` we have achieved that: To sum up, with the above `.gitlab-ci.yml` we have achieved that:
- All branches will run the `test` and `build` jobs. - All branches will run the `test` and `build` jobs.
...@@ -157,7 +164,7 @@ that can be found in the deployments page ...@@ -157,7 +164,7 @@ that can be found in the deployments page
job with the commit associated with it. job with the commit associated with it.
>**Note:** >**Note:**
Bare in mind that your mileage will vary and it's entirely up to how you define Bear in mind that your mileage will vary and it's entirely up to how you define
the deployment process in the job's `script` whether the rollback succeeds or not. the deployment process in the job's `script` whether the rollback succeeds or not.
GitLab CI is just following orders. GitLab CI is just following orders.
...@@ -248,7 +255,7 @@ deploy_review: ...@@ -248,7 +255,7 @@ deploy_review:
- echo "Deploy a review app" - echo "Deploy a review app"
environment: environment:
name: review/$CI_BUILD_REF_NAME name: review/$CI_BUILD_REF_NAME
url: https://$CI_BUILD_REF_SLUG.example.com url: https://$CI_BUILD_REF_SLUG.review.example.com
only: only:
- branches - branches
except: except:
...@@ -266,9 +273,18 @@ ones. ...@@ -266,9 +273,18 @@ ones.
So, the first part is `review`, followed by a `/` and then `$CI_BUILD_REF_NAME` So, the first part is `review`, followed by a `/` and then `$CI_BUILD_REF_NAME`
which takes the value of the branch name. Since `$CI_BUILD_REF_NAME` itself may which takes the value of the branch name. Since `$CI_BUILD_REF_NAME` itself may
also contain `/`, or other characters that would be invalid in a domain name or also contain `/`, or other characters that would be invalid in a domain name or
URL, we use `$CI_BUILD_REF_SLUG` in the `environment:url` so that the environment URL, we use `$CI_ENVIRONMENT_SLUG` in the `environment:url` so that the
can get a specific and distinct URL for each branch. Again, the way you set up environment can get a specific and distinct URL for each branch. In this case,
the webserver to serve these requests is based on your setup. given a `$CI_BUILD_REF_NAME` of `100-Do-The-Thing`, the URL will be something
like `https://review-100-do-the-4f99a2.example.com`. Again, the way you set up
the web server to serve these requests is based on your setup.
You could also use `$CI_BUILD_REF_SLUG` in `environment:url`, e.g.:
`https://$CI_BUILD_REF_SLUG.review.example.com`. We use `$CI_ENVIRONMENT_SLUG`
here because it is guaranteed to be unique, but if you're using a workflow like
[GitLab Flow][gitlab-flow], collisions are very unlikely, and you may prefer
environment names to be more closely based on the branch name - the example
above would give you an URL like `https://100-do-the-thing.review.example.com`
Last but not least, we tell the job to run [`only`][only] on branches Last but not least, we tell the job to run [`only`][only] on branches
[`except`][only] master. [`except`][only] master.
...@@ -300,7 +316,7 @@ deploy_review: ...@@ -300,7 +316,7 @@ deploy_review:
- echo "Deploy a review app" - echo "Deploy a review app"
environment: environment:
name: review/$CI_BUILD_REF_NAME name: review/$CI_BUILD_REF_NAME
url: https://$CI_BUILD_REF_SLUG.example.com url: https://$CI_ENVIRONMENT_SLUG.example.com
only: only:
- branches - branches
except: except:
...@@ -419,7 +435,7 @@ deploy_review: ...@@ -419,7 +435,7 @@ deploy_review:
- echo "Deploy a review app" - echo "Deploy a review app"
environment: environment:
name: review/$CI_BUILD_REF_NAME name: review/$CI_BUILD_REF_NAME
url: https://$CI_BUILD_REF_SLUG.example.com url: https://$CI_ENVIRONMENT_SLUG.example.com
on_stop: stop_review on_stop: stop_review
only: only:
- branches - branches
...@@ -493,10 +509,6 @@ fetch = +refs/environments/*:refs/remotes/origin/environments/* ...@@ -493,10 +509,6 @@ fetch = +refs/environments/*:refs/remotes/origin/environments/*
## Limitations ## Limitations
1. `$CI_BUILD_REF_SLUG` is not *guaranteed* to be unique, so there is a small
chance of collisions between similarly-named branches (`fix-foo` would
conflict with `fix/foo`, for instance). Following a well-defined workflow
such as [GitLab Flow][gitlab-flow] can keep this from being a problem.
1. You are limited to use only the [CI predefined variables][variables] in the 1. You are limited to use only the [CI predefined variables][variables] in the
`environment: name`. If you try to re-use variables defined inside `script` `environment: name`. If you try to re-use variables defined inside `script`
as part of the environment name, it will not work. as part of the environment name, it will not work.
......
...@@ -52,6 +52,8 @@ version of Runner required. ...@@ -52,6 +52,8 @@ version of Runner required.
| **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name | | **CI_PROJECT_PATH** | 8.10 | 0.5 | The namespace with project name |
| **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project | | **CI_PROJECT_URL** | 8.10 | 0.5 | The HTTP address to access project |
| **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the build is run | | **CI_PROJECT_DIR** | all | all | The full path where the repository is cloned and where the build is run |
| **CI_ENVIRONMENT_NAME** | 8.15 | all | The name of the environment for this build |
| **CI_ENVIRONMENT_SLUG** | 8.15 | all | A simplified version of the environment name, suitable for inclusion in DNS, URLs, Kubernetes labels, etc. |
| **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry | | **CI_REGISTRY** | 8.10 | 0.5 | If the Container Registry is enabled it returns the address of GitLab's Container Registry |
| **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project | | **CI_REGISTRY_IMAGE** | 8.10 | 0.5 | If the Container Registry is enabled for the project it returns the address of the registry tied to the specific project |
| **CI_RUNNER_ID** | 8.10 | 0.5 | The unique id of runner being used | | **CI_RUNNER_ID** | 8.10 | 0.5 | The unique id of runner being used |
......
...@@ -690,7 +690,7 @@ The `stop_review_app` job is **required** to have the following keywords defined ...@@ -690,7 +690,7 @@ The `stop_review_app` job is **required** to have the following keywords defined
#### dynamic environments #### dynamic environments
> [Introduced][ce-6323] in GitLab 8.12 and GitLab Runner 1.6. > [Introduced][ce-6323] in GitLab 8.12 and GitLab Runner 1.6.
`$CI_BUILD_REF_SLUG` was [introduced][ce-8072] in GitLab 8.15. `$CI_ENVIRONMENT_SLUG` was [introduced][ce-7983] in GitLab 8.15
`environment` can also represent a configuration hash with `name` and `url`. `environment` can also represent a configuration hash with `name` and `url`.
These parameters can use any of the defined [CI variables](#variables) These parameters can use any of the defined [CI variables](#variables)
...@@ -703,15 +703,17 @@ deploy as review app: ...@@ -703,15 +703,17 @@ deploy as review app:
stage: deploy stage: deploy
script: make deploy script: make deploy
environment: environment:
name: review-apps/$CI_BUILD_REF_NAME name: review/$CI_BUILD_REF_NAME
url: https://$CI_BUILD_REF_SLUG.review.example.com/ url: https://$CI_ENVIRONMENT_SLUG.example.com/
``` ```
The `deploy as review app` job will be marked as deployment to dynamically The `deploy as review app` job will be marked as deployment to dynamically
create the `review-apps/$CI_BUILD_REF_NAME` environment, which `$CI_BUILD_REF_NAME` create the `review/$CI_BUILD_REF_NAME` environment, where `$CI_BUILD_REF_NAME`
is an [environment variable][variables] set by the Runner. If for example the is an [environment variable][variables] set by the Runner. The
`deploy as review app` job was run in a branch named `pow`, this environment `$CI_ENVIRONMENT_SLUG` variable is based on the environment name, but suitable
should be accessible under `https://pow.review.example.com/`. for inclusion in URLs. In this case, if the `deploy as review app` job was run
in a branch named `pow`, this environment would be accessible with an URL like
`https://review-pow-aaaaaa.example.com/`.
This of course implies that the underlying server which hosts the application This of course implies that the underlying server which hosts the application
is properly configured. is properly configured.
...@@ -720,10 +722,6 @@ The common use case is to create dynamic environments for branches and use them ...@@ -720,10 +722,6 @@ The common use case is to create dynamic environments for branches and use them
as Review Apps. You can see a simple example using Review Apps at as Review Apps. You can see a simple example using Review Apps at
https://gitlab.com/gitlab-examples/review-apps-nginx/. https://gitlab.com/gitlab-examples/review-apps-nginx/.
`$CI_BUILD_REF_SLUG` is another environment variable set by the runner, based on
`$CI_BUILD_REF_NAME` but lower-cased, and with some characters replaced with
`-`, making it suitable for use in URLs and domain names.
### artifacts ### artifacts
>**Notes:** >**Notes:**
...@@ -1243,5 +1241,5 @@ CI with various languages. ...@@ -1243,5 +1241,5 @@ CI with various languages.
[ce-6323]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6323 [ce-6323]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6323
[environment]: ../environments.md [environment]: ../environments.md
[ce-6669]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6669 [ce-6669]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/6669
[ce-8072]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/xxxx
[variables]: ../variables/README.md [variables]: ../variables/README.md
[ce-7983]: https://gitlab.com/gitlab-org/gitlab-ce/merge_requests/7983
...@@ -65,7 +65,7 @@ the administrator console. ...@@ -65,7 +65,7 @@ the administrator console.
### Step 3. Create a new custom slash command in Mattermost ### Step 3. Create a new custom slash command in Mattermost
Now that you have enabled the custom slash commands in Mattermost and opened Now that you have enabled custom slash commands in Mattermost and opened
the Mattermost slash commands service in GitLab, it's time to copy these values the Mattermost slash commands service in GitLab, it's time to copy these values
in a new slash command. in a new slash command.
...@@ -128,20 +128,16 @@ GitLab using the Mattermost commands. ...@@ -128,20 +128,16 @@ GitLab using the Mattermost commands.
## Available slash commands ## Available slash commands
The available slash commands so far are: The available slash commands are:
| Command | Description | Example | | Command | Description | Example |
| ------- | ----------- | ------- | | ------- | ----------- | ------- |
| `/<trigger> issue create <title>\n<description>` | Create a new issue in the project that `<trigger>` is tied to. `<description>` is optional. | `/trigger issue create We need to change the homepage` | | <kbd>/&lt;trigger&gt; issue new &lt;title&gt; <kbd>⇧ Shift</kbd>+<kbd>↵ Enter</kbd> &lt;description&gt;</kbd> | Create a new issue in the project that `<trigger>` is tied to. `<description>` is optional. | <samp>/gitlab issue new We need to change the homepage</samp> |
| `/<trigger> issue show <issue-number>` | Show the issue with ID `<issue-number>` from the project that `<trigger>` is tied to. | `/trigger issue show 42` | | <kbd>/&lt;trigger&gt; issue show &lt;issue-number&gt;</kbd> | Show the issue with ID `<issue-number>` from the project that `<trigger>` is tied to. | <samp>/gitlab issue show 42</samp> |
| `/<trigger> deploy <environment> to <environment>` | Start the CI job that deploys from one environment to another, for example `staging` to `production`. CI/CD must be [properly configured][ciyaml]. | `/trigger deploy staging to production` | | <kbd>/&lt;trigger&gt; deploy &lt;environment&gt; to &lt;environment&gt;</kbd> | Start the CI job that deploys from one environment to another, for example `staging` to `production`. CI/CD must be [properly configured][ciyaml]. | <samp>/gitlab deploy staging to production</samp> |
To see a list of available commands that can interact with GitLab, type the To see a list of available commands to interact with GitLab, type the
trigger word followed by `help`: trigger word followed by <kbd>help</kbd>. Example: <samp>/gitlab help</samp>
```
/my-project help
```
![Mattermost bot available commands](img/mattermost_bot_available_commands.png) ![Mattermost bot available commands](img/mattermost_bot_available_commands.png)
......
@admin
Feature: Admin Active Tab
Background:
Given I sign in as an admin
Scenario: On Admin Home
Given I visit admin page
Then the active main tab should be Overview
And no other main tabs should be active
Scenario: On Admin Projects
Given I visit admin projects page
Then the active main tab should be Overview
And the active sub tab should be Projects
And no other main tabs should be active
And no other sub tabs should be active
Scenario: On Admin Groups
Given I visit admin groups page
Then the active main tab should be Overview
And the active sub tab should be Groups
And no other main tabs should be active
And no other sub tabs should be active
Scenario: On Admin Users
Given I visit admin users page
Then the active main tab should be Overview
And the active sub tab should be Users
And no other main tabs should be active
And no other sub tabs should be active
Scenario: On Admin Logs
Given I visit admin logs page
Then the active main tab should be Monitoring
And the active sub tab should be Logs
And no other main tabs should be active
And no other sub tabs should be active
Scenario: On Admin Messages
Given I visit admin messages page
Then the active main tab should be Messages
And no other main tabs should be active
Scenario: On Admin Hooks
Given I visit admin hooks page
Then the active main tab should be Hooks
And no other main tabs should be active
Scenario: On Admin Resque
Given I visit admin Resque page
Then the active main tab should be Monitoring
And the active sub tab should be Resque
And no other main tabs should be active
And no other sub tabs should be active
class Spinach::Features::AdminActiveTab < Spinach::FeatureSteps
include SharedAuthentication
include SharedPaths
include SharedActiveTab
step 'the active main tab should be Overview' do
ensure_active_main_tab('Overview')
end
step 'the active sub tab should be Projects' do
ensure_active_sub_tab('Projects')
end
step 'the active sub tab should be Groups' do
ensure_active_sub_tab('Groups')
end
step 'the active sub tab should be Users' do
ensure_active_sub_tab('Users')
end
step 'the active main tab should be Hooks' do
ensure_active_main_tab('Hooks')
end
step 'the active main tab should be Monitoring' do
ensure_active_main_tab('Monitoring')
end
step 'the active sub tab should be Resque' do
ensure_active_sub_tab('Background Jobs')
end
step 'the active sub tab should be Logs' do
ensure_active_sub_tab('Logs')
end
step 'the active main tab should be Messages' do
ensure_active_main_tab('Messages')
end
end
...@@ -629,7 +629,7 @@ module API ...@@ -629,7 +629,7 @@ module API
end end
class EnvironmentBasic < Grape::Entity class EnvironmentBasic < Grape::Entity
expose :id, :name, :external_url expose :id, :name, :slug, :external_url
end end
class Environment < EnvironmentBasic class Environment < EnvironmentBasic
......
module API module API
# Environments RESTfull API endpoints # Environments RESTfull API endpoints
class Environments < Grape::API class Environments < Grape::API
include ::API::Helpers::CustomValidators
include PaginationParams include PaginationParams
before { authenticate! } before { authenticate! }
...@@ -29,6 +30,7 @@ module API ...@@ -29,6 +30,7 @@ module API
params do params do
requires :name, type: String, desc: 'The name of the environment to be created' requires :name, type: String, desc: 'The name of the environment to be created'
optional :external_url, type: String, desc: 'URL on which this deployment is viewable' optional :external_url, type: String, desc: 'URL on which this deployment is viewable'
optional :slug, absence: { message: "is automatically generated and cannot be changed" }
end end
post ':id/environments' do post ':id/environments' do
authorize! :create_environment, user_project authorize! :create_environment, user_project
...@@ -50,6 +52,7 @@ module API ...@@ -50,6 +52,7 @@ module API
requires :environment_id, type: Integer, desc: 'The environment ID' requires :environment_id, type: Integer, desc: 'The environment ID'
optional :name, type: String, desc: 'The new environment name' optional :name, type: String, desc: 'The new environment name'
optional :external_url, type: String, desc: 'The new URL on which this deployment is viewable' optional :external_url, type: String, desc: 'The new URL on which this deployment is viewable'
optional :slug, absence: { message: "is automatically generated and cannot be changed" }
end end
put ':id/environments/:environment_id' do put ':id/environments/:environment_id' do
authorize! :update_environment, user_project authorize! :update_environment, user_project
......
module API
module Helpers
module CustomValidators
class Absence < Grape::Validations::Base
def validate_param!(attr_name, params)
return if params.respond_to?(:key?) && !params.key?(attr_name)
raise Grape::Exceptions::Validation, params: [@scope.full_name(attr_name)], message: message(:absence)
end
end
end
end
end
Grape::Validations.register_validator(:absence, ::API::Helpers::CustomValidators::Absence)
...@@ -35,11 +35,7 @@ module Gitlab ...@@ -35,11 +35,7 @@ module Gitlab
html.html_safe html.html_safe
end end
end
end
module Gitlab
module Asciidoc
class Html5Converter < Asciidoctor::Converter::Html5Converter class Html5Converter < Asciidoctor::Converter::Html5Converter
extend Asciidoctor::Converter::Config extend Asciidoctor::Converter::Config
......
...@@ -20,8 +20,8 @@ module Gitlab ...@@ -20,8 +20,8 @@ module Gitlab
def status def status
@project.pipelines @project.pipelines
.where(sha: @sha, ref: @ref) .where(sha: @sha)
.status || 'unknown' .latest_status(@ref) || 'unknown'
end end
def metadata def metadata
......
...@@ -8,7 +8,7 @@ module Gitlab ...@@ -8,7 +8,7 @@ module Gitlab
end end
def self.help_message def self.help_message
'issue new <title>\n<description>' 'issue new <title> *`⇧ Shift`*+*`↵ Enter`* <description>'
end end
def self.allowed?(project, user) def self.allowed?(project, user)
......
...@@ -131,5 +131,14 @@ module Gitlab ...@@ -131,5 +131,14 @@ module Gitlab
def kubernetes_namespace_regex_message def kubernetes_namespace_regex_message
"can contain only letters, digits or '-', and cannot start or end with '-'" "can contain only letters, digits or '-', and cannot start or end with '-'"
end end
def environment_slug_regex
@environment_slug_regex ||= /\A[a-z]([a-z0-9-]*[a-z0-9])?\z/.freeze
end
def environment_slug_regex_message
"can contain only lowercase letters, digits, and '-'. " \
"Must start with a letter, and cannot end with '-'"
end
end end
end end
...@@ -46,6 +46,14 @@ ...@@ -46,6 +46,14 @@
margin: 40px auto; margin: 40px auto;
} }
a {
line-height: 100px;
font-weight: normal;
color: #4A8BEE;
font-size: 18px;
text-decoration: none;
}
.container { .container {
margin: auto 20px; margin: auto 20px;
} }
...@@ -63,6 +71,7 @@ ...@@ -63,6 +71,7 @@
<hr /> <hr />
<p>Make sure the address is correct and that the page hasn't moved.</p> <p>Make sure the address is correct and that the page hasn't moved.</p>
<p>Please contact your GitLab administrator if you think this is a mistake.</p> <p>Please contact your GitLab administrator if you think this is a mistake.</p>
<a href="javascript:history.back()">Go back</a>
</div> </div>
</body> </body>
</html> </html>
...@@ -46,6 +46,14 @@ ...@@ -46,6 +46,14 @@
margin: 40px auto; margin: 40px auto;
} }
a {
line-height: 100px;
font-weight: normal;
color: #4A8BEE;
font-size: 18px;
text-decoration: none;
}
.container { .container {
margin: auto 20px; margin: auto 20px;
} }
...@@ -63,6 +71,7 @@ ...@@ -63,6 +71,7 @@
<hr /> <hr />
<p>Make sure you have access to the thing you tried to change.</p> <p>Make sure you have access to the thing you tried to change.</p>
<p>Please contact your GitLab administrator if you think this is a mistake.</p> <p>Please contact your GitLab administrator if you think this is a mistake.</p>
<a href="javascript:history.back()">Go back</a>
</div> </div>
</body> </body>
</html> </html>
...@@ -46,6 +46,14 @@ ...@@ -46,6 +46,14 @@
margin: 40px auto; margin: 40px auto;
} }
a {
line-height: 100px;
font-weight: normal;
color: #4A8BEE;
font-size: 18px;
text-decoration: none;
}
.container { .container {
margin: auto 20px; margin: auto 20px;
} }
...@@ -63,6 +71,7 @@ ...@@ -63,6 +71,7 @@
<hr /> <hr />
<p>Try refreshing the page, or going back and attempting the action again.</p> <p>Try refreshing the page, or going back and attempting the action again.</p>
<p>Please contact your GitLab administrator if this problem persists.</p> <p>Please contact your GitLab administrator if this problem persists.</p>
<a href="javascript:history.back()">Go back</a>
</div> </div>
</body> </body>
</html> </html>
...@@ -46,6 +46,14 @@ ...@@ -46,6 +46,14 @@
margin: 40px auto; margin: 40px auto;
} }
a {
line-height: 100px;
font-weight: normal;
color: #4A8BEE;
font-size: 18px;
text-decoration: none;
}
.container { .container {
margin: auto 20px; margin: auto 20px;
} }
...@@ -63,6 +71,7 @@ ...@@ -63,6 +71,7 @@
<hr /> <hr />
<p>Try refreshing the page, or going back and attempting the action again.</p> <p>Try refreshing the page, or going back and attempting the action again.</p>
<p>Please contact your GitLab administrator if this problem persists.</p> <p>Please contact your GitLab administrator if this problem persists.</p>
<a href="javascript:history.back()">Go back</a>
</div> </div>
</body> </body>
</html> </html>
...@@ -46,6 +46,14 @@ ...@@ -46,6 +46,14 @@
margin: 40px auto; margin: 40px auto;
} }
a {
line-height: 100px;
font-weight: normal;
color: #4A8BEE;
font-size: 18px;
text-decoration: none;
}
.container { .container {
margin: auto 20px; margin: auto 20px;
} }
...@@ -63,6 +71,7 @@ ...@@ -63,6 +71,7 @@
<hr /> <hr />
<p>Try refreshing the page, or going back and attempting the action again.</p> <p>Try refreshing the page, or going back and attempting the action again.</p>
<p>Please contact your GitLab administrator if this problem persists.</p> <p>Please contact your GitLab administrator if this problem persists.</p>
<a href="javascript:history.back()">Go back</a>
</div> </div>
</body> </body>
</html> </html>
require 'spec_helper'
RSpec.describe 'admin active tab' do
before do
login_as :admin
end
shared_examples 'page has active tab' do |title|
it "activates #{title} tab" do
expect(page).to have_selector('.layout-nav .nav-links > li.active', count: 1)
expect(page.find('.layout-nav li.active')).to have_content(title)
end
end
shared_examples 'page has active sub tab' do |title|
it "activates #{title} sub tab" do
expect(page).to have_selector('.sub-nav li.active', count: 1)
expect(page.find('.sub-nav li.active')).to have_content(title)
end
end
context 'on home page' do
before do
visit admin_root_path
end
it_behaves_like 'page has active tab', 'Overview'
end
context 'on projects' do
before do
visit admin_projects_path
end
it_behaves_like 'page has active tab', 'Overview'
it_behaves_like 'page has active sub tab', 'Projects'
end
context 'on groups' do
before do
visit admin_groups_path
end
it_behaves_like 'page has active tab', 'Overview'
it_behaves_like 'page has active sub tab', 'Groups'
end
context 'on users' do
before do
visit admin_users_path
end
it_behaves_like 'page has active tab', 'Overview'
it_behaves_like 'page has active sub tab', 'Users'
end
context 'on logs' do
before do
visit admin_logs_path
end
it_behaves_like 'page has active tab', 'Monitoring'
it_behaves_like 'page has active sub tab', 'Logs'
end
context 'on messages' do
before do
visit admin_broadcast_messages_path
end
it_behaves_like 'page has active tab', 'Messages'
end
context 'on hooks' do
before do
visit admin_hooks_path
end
it_behaves_like 'page has active tab', 'Hooks'
end
context 'on background jobs' do
before do
visit admin_background_jobs_path
end
it_behaves_like 'page has active tab', 'Monitoring'
it_behaves_like 'page has active sub tab', 'Background Jobs'
end
end
...@@ -9,6 +9,7 @@ feature 'Issues > Labels bulk assignment', feature: true do ...@@ -9,6 +9,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
let!(:issue2) { create(:issue, project: project, title: "Issue 2") } let!(:issue2) { create(:issue, project: project, title: "Issue 2") }
let!(:bug) { create(:label, project: project, title: 'bug') } let!(:bug) { create(:label, project: project, title: 'bug') }
let!(:feature) { create(:label, project: project, title: 'feature') } let!(:feature) { create(:label, project: project, title: 'feature') }
let!(:wontfix) { create(:label, project: project, title: 'wontfix') }
context 'as an allowed user', js: true do context 'as an allowed user', js: true do
before do before do
...@@ -291,6 +292,45 @@ feature 'Issues > Labels bulk assignment', feature: true do ...@@ -291,6 +292,45 @@ feature 'Issues > Labels bulk assignment', feature: true do
expect(find("#issue_#{issue1.id}")).not_to have_content 'feature' expect(find("#issue_#{issue1.id}")).not_to have_content 'feature'
end end
end end
# Special case https://gitlab.com/gitlab-org/gitlab-ce/issues/24877
context 'unmarking common label' do
before do
issue1.labels << bug
issue1.labels << feature
issue2.labels << bug
visit namespace_project_issues_path(project.namespace, project)
end
it 'applies label from filtered results' do
check 'check_all_issues'
page.within('.issues_bulk_update') do
click_button 'Labels'
wait_for_ajax
expect(find('.dropdown-menu-labels li', text: 'bug')).to have_css('.is-active')
expect(find('.dropdown-menu-labels li', text: 'feature')).to have_css('.is-indeterminate')
click_link 'bug'
find('.dropdown-input-field', visible: true).set('wontfix')
click_link 'wontfix'
end
update_issues
page.within '.issues-holder' do
expect(find("#issue_#{issue1.id}")).not_to have_content 'bug'
expect(find("#issue_#{issue1.id}")).to have_content 'feature'
expect(find("#issue_#{issue1.id}")).to have_content 'wontfix'
expect(find("#issue_#{issue2.id}")).not_to have_content 'bug'
expect(find("#issue_#{issue2.id}")).not_to have_content 'feature'
expect(find("#issue_#{issue2.id}")).to have_content 'wontfix'
end
end
end
end end
context 'as a guest' do context 'as a guest' do
...@@ -320,7 +360,7 @@ feature 'Issues > Labels bulk assignment', feature: true do ...@@ -320,7 +360,7 @@ feature 'Issues > Labels bulk assignment', feature: true do
def open_labels_dropdown(items = [], unmark = false) def open_labels_dropdown(items = [], unmark = false)
page.within('.issues_bulk_update') do page.within('.issues_bulk_update') do
click_button 'Label' click_button 'Labels'
wait_for_ajax wait_for_ajax
items.map do |item| items.map do |item|
click_link item click_link item
......
...@@ -89,4 +89,12 @@ feature 'GFM autocomplete', feature: true, js: true do ...@@ -89,4 +89,12 @@ feature 'GFM autocomplete', feature: true, js: true do
end end
end end
end end
it 'doesnt open autocomplete after non-word character' do
page.within '.timeline-content-form' do
find('#note_note').native.send_keys("@#{user.username[0..2]}!")
end
expect(page).not_to have_selector('.atwho-view')
end
end end
/*= require extensions/object */
describe('Object extensions', () => {
describe('assign', () => {
it('merges source object into target object', () => {
const targetObj = {};
const sourceObj = {
foo: 'bar',
};
Object.assign(targetObj, sourceObj);
expect(targetObj.foo).toBe('bar');
});
it('merges object with the same properties', () => {
const targetObj = {
foo: 'bar',
};
const sourceObj = {
foo: 'baz',
};
Object.assign(targetObj, sourceObj);
expect(targetObj.foo).toBe('baz');
});
});
});
...@@ -69,8 +69,8 @@ describe Gitlab::Badge::Build::Status do ...@@ -69,8 +69,8 @@ describe Gitlab::Badge::Build::Status do
new_build.success! new_build.success!
end end
it 'reports the compound status' do it 'does not take outdated pipeline into account' do
expect(badge.status).to eq 'failed' expect(badge.status).to eq 'success'
end end
end end
end end
......
...@@ -29,4 +29,20 @@ describe Gitlab::Regex, lib: true do ...@@ -29,4 +29,20 @@ describe Gitlab::Regex, lib: true do
describe 'file path regex' do describe 'file path regex' do
it { expect('foo@/bar').to match(Gitlab::Regex.file_path_regex) } it { expect('foo@/bar').to match(Gitlab::Regex.file_path_regex) }
end end
describe 'environment slug regex' do
def be_matched
match(Gitlab::Regex.environment_slug_regex)
end
it { expect('foo').to be_matched }
it { expect('foo-1').to be_matched }
it { expect('FOO').not_to be_matched }
it { expect('foo/1').not_to be_matched }
it { expect('foo.1').not_to be_matched }
it { expect('foo*1').not_to be_matched }
it { expect('9foo').not_to be_matched }
it { expect('foo-').not_to be_matched }
end
end end
...@@ -87,6 +87,26 @@ describe Ci::Build, models: true do ...@@ -87,6 +87,26 @@ describe Ci::Build, models: true do
end end
end end
describe '#persisted_environment' do
before do
@environment = create(:environment, project: project, name: "foo-#{project.default_branch}")
end
subject { build.persisted_environment }
context 'referenced literally' do
let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-#{project.default_branch}") }
it { is_expected.to eq(@environment) }
end
context 'referenced with a variable' do
let(:build) { create(:ci_build, pipeline: pipeline, environment: "foo-$CI_BUILD_REF_NAME") }
it { is_expected.to eq(@environment) }
end
end
describe '#trace' do describe '#trace' do
it { expect(build.trace).to be_nil } it { expect(build.trace).to be_nil }
...@@ -328,6 +348,22 @@ describe Ci::Build, models: true do ...@@ -328,6 +348,22 @@ describe Ci::Build, models: true do
it { user_variables.each { |v| is_expected.to include(v) } } it { user_variables.each { |v| is_expected.to include(v) } }
end end
context 'when build has an environment' do
before do
build.update(environment: 'production')
create(:environment, project: build.project, name: 'production', slug: 'prod-slug')
end
let(:environment_variables) do
[
{ key: 'CI_ENVIRONMENT_NAME', value: 'production', public: true },
{ key: 'CI_ENVIRONMENT_SLUG', value: 'prod-slug', public: true }
]
end
it { environment_variables.each { |v| is_expected.to include(v) } }
end
context 'when build started manually' do context 'when build started manually' do
before do before do
build.update_attributes(when: :manual) build.update_attributes(when: :manual)
......
...@@ -381,6 +381,65 @@ describe Ci::Pipeline, models: true do ...@@ -381,6 +381,65 @@ describe Ci::Pipeline, models: true do
end end
end end
shared_context 'with some outdated pipelines' do
before do
create_pipeline(:canceled, 'ref', 'A')
create_pipeline(:success, 'ref', 'A')
create_pipeline(:failed, 'ref', 'B')
create_pipeline(:skipped, 'feature', 'C')
end
def create_pipeline(status, ref, sha)
create(:ci_empty_pipeline, status: status, ref: ref, sha: sha)
end
end
describe '.latest' do
include_context 'with some outdated pipelines'
context 'when no ref is specified' do
let(:pipelines) { described_class.latest.all }
it 'returns the latest pipeline for the same ref and different sha' do
expect(pipelines.map(&:sha)).to contain_exactly('A', 'B', 'C')
expect(pipelines.map(&:status)).
to contain_exactly('success', 'failed', 'skipped')
end
end
context 'when ref is specified' do
let(:pipelines) { described_class.latest('ref').all }
it 'returns the latest pipeline for ref and different sha' do
expect(pipelines.map(&:sha)).to contain_exactly('A', 'B')
expect(pipelines.map(&:status)).
to contain_exactly('success', 'failed')
end
end
end
describe '.latest_status' do
include_context 'with some outdated pipelines'
context 'when no ref is specified' do
let(:latest_status) { described_class.latest_status }
it 'returns the latest status for the same ref and different sha' do
expect(latest_status).to eq(described_class.latest.status)
expect(latest_status).to eq('failed')
end
end
context 'when ref is specified' do
let(:latest_status) { described_class.latest_status('ref') }
it 'returns the latest status for ref and different sha' do
expect(latest_status).to eq(described_class.latest_status('ref'))
expect(latest_status).to eq('failed')
end
end
end
describe '#status' do describe '#status' do
let!(:build) { create(:ci_build, :created, pipeline: pipeline, name: 'test') } let!(:build) { create(:ci_build, :created, pipeline: pipeline, name: 'test') }
......
...@@ -213,23 +213,19 @@ eos ...@@ -213,23 +213,19 @@ eos
end end
describe '#status' do describe '#status' do
context 'without arguments for compound status' do context 'without ref argument' do
shared_examples 'giving the status from pipeline' do before do
it do %w[success failed created pending].each do |status|
expect(commit.status).to eq(Ci::Pipeline.status) create(:ci_empty_pipeline,
end project: project,
end sha: commit.sha,
status: status)
context 'with pipelines' do
let!(:pipeline) do
create(:ci_empty_pipeline, project: project, sha: commit.sha)
end end
it_behaves_like 'giving the status from pipeline'
end end
context 'without pipelines' do it 'gives compound status from latest pipelines' do
it_behaves_like 'giving the status from pipeline' expect(commit.status).to eq(Ci::Pipeline.latest_status)
expect(commit.status).to eq('pending')
end end
end end
...@@ -255,8 +251,9 @@ eos ...@@ -255,8 +251,9 @@ eos
expect(commit.status('fix')).to eq(pipeline_from_fix.status) expect(commit.status('fix')).to eq(pipeline_from_fix.status)
end end
it 'gives compound status if ref is nil' do it 'gives compound status from latest pipelines if ref is nil' do
expect(commit.status(nil)).to eq(commit.status) expect(commit.status(nil)).to eq(Ci::Pipeline.latest_status)
expect(commit.status(nil)).to eq('failed')
end end
end end
end end
......
require 'spec_helper' require 'spec_helper'
describe Environment, models: true do describe Environment, models: true do
let(:environment) { create(:environment) } subject(:environment) { create(:environment) }
it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:project) }
it { is_expected.to have_many(:deployments) } it { is_expected.to have_many(:deployments) }
...@@ -15,15 +15,11 @@ describe Environment, models: true do ...@@ -15,15 +15,11 @@ describe Environment, models: true do
it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) } it { is_expected.to validate_uniqueness_of(:name).scoped_to(:project_id) }
it { is_expected.to validate_length_of(:name).is_at_most(255) } it { is_expected.to validate_length_of(:name).is_at_most(255) }
it { is_expected.to validate_length_of(:external_url).is_at_most(255) } it { is_expected.to validate_uniqueness_of(:slug).scoped_to(:project_id) }
it { is_expected.to validate_length_of(:slug).is_at_most(24) }
# To circumvent a not null violation of the name column:
# https://github.com/thoughtbot/shoulda-matchers/issues/336
it 'validates uniqueness of :external_url' do
create(:environment)
is_expected.to validate_uniqueness_of(:external_url).scoped_to(:project_id) it { is_expected.to validate_length_of(:external_url).is_at_most(255) }
end it { is_expected.to validate_uniqueness_of(:external_url).scoped_to(:project_id) }
describe '#nullify_external_url' do describe '#nullify_external_url' do
it 'replaces a blank url with nil' do it 'replaces a blank url with nil' do
...@@ -199,4 +195,38 @@ describe Environment, models: true do ...@@ -199,4 +195,38 @@ describe Environment, models: true do
expect(environment.actions_for('review/master')).to contain_exactly(review_action) expect(environment.actions_for('review/master')).to contain_exactly(review_action)
end end
end end
describe '#slug' do
it "is automatically generated" do
expect(environment.slug).not_to be_nil
end
it "is not regenerated if name changes" do
original_slug = environment.slug
environment.update_attributes!(name: environment.name.reverse)
expect(environment.slug).to eq(original_slug)
end
end
describe '#generate_slug' do
SUFFIX = "-[a-z0-9]{6}"
{
"staging-12345678901234567" => "staging-123456789" + SUFFIX,
"9-staging-123456789012345" => "env-9-staging-123" + SUFFIX,
"staging-1234567890123456" => "staging-1234567890123456",
"production" => "production",
"PRODUCTION" => "production" + SUFFIX,
"review/1-foo" => "review-1-foo" + SUFFIX,
"1-foo" => "env-1-foo" + SUFFIX,
"1/foo" => "env-1-foo" + SUFFIX,
"foo-" => "foo" + SUFFIX,
}.each do |name, matcher|
it "returns a slug matching #{matcher}, given #{name}" do
slug = described_class.new(name: name).generate_slug
expect(slug).to match(/\A#{matcher}\z/)
end
end
end
end end
...@@ -272,7 +272,7 @@ describe Group, models: true do ...@@ -272,7 +272,7 @@ describe Group, models: true do
end end
describe 'nested group' do describe 'nested group' do
subject { create(:group, :nested) } subject { build(:group, :nested) }
it { is_expected.to be_valid } it { is_expected.to be_valid }
it { expect(subject.parent).to be_kind_of(Group) } it { expect(subject.parent).to be_kind_of(Group) }
......
...@@ -46,6 +46,7 @@ describe API::Environments, api: true do ...@@ -46,6 +46,7 @@ describe API::Environments, api: true do
expect(response).to have_http_status(201) expect(response).to have_http_status(201)
expect(json_response['name']).to eq('mepmep') expect(json_response['name']).to eq('mepmep')
expect(json_response['slug']).to eq('mepmep')
expect(json_response['external']).to be nil expect(json_response['external']).to be nil
end end
...@@ -60,6 +61,13 @@ describe API::Environments, api: true do ...@@ -60,6 +61,13 @@ describe API::Environments, api: true do
expect(response).to have_http_status(400) expect(response).to have_http_status(400)
end end
it 'returns a 400 if slug is specified' do
post api("/projects/#{project.id}/environments", user), name: "foo", slug: "foo"
expect(response).to have_http_status(400)
expect(json_response["error"]).to eq("slug is automatically generated and cannot be changed")
end
end end
context 'a non member' do context 'a non member' do
...@@ -86,6 +94,15 @@ describe API::Environments, api: true do ...@@ -86,6 +94,15 @@ describe API::Environments, api: true do
expect(json_response['external_url']).to eq(url) expect(json_response['external_url']).to eq(url)
end end
it "won't allow slug to be changed" do
slug = environment.slug
api_url = api("/projects/#{project.id}/environments/#{environment.id}", user)
put api_url, slug: slug + "-foo"
expect(response).to have_http_status(400)
expect(json_response["error"]).to eq("slug is automatically generated and cannot be changed")
end
it "won't update the external_url if only the name is passed" do it "won't update the external_url if only the name is passed" do
url = environment.external_url url = environment.external_url
put api("/projects/#{project.id}/environments/#{environment.id}", user), put api("/projects/#{project.id}/environments/#{environment.id}", user),
......
...@@ -210,5 +210,22 @@ describe Ci::CreatePipelineService, services: true do ...@@ -210,5 +210,22 @@ describe Ci::CreatePipelineService, services: true do
expect(result.manual_actions).not_to be_empty expect(result.manual_actions).not_to be_empty
end end
end end
context 'with environment' do
before do
config = YAML.dump(deploy: { environment: { name: "review/$CI_BUILD_REF_NAME" }, script: 'ls' })
stub_ci_pipeline_yaml_file(config)
end
it 'creates the environment' do
result = execute(ref: 'refs/heads/master',
before: '00000000',
after: project.commit.id,
commits: [{ message: 'some msg' }])
expect(result).to be_persisted
expect(Environment.find_by(name: "review/master")).not_to be_nil
end
end
end end
end end
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment