Commit a9d9895d authored by Zeger-Jan van de Weg's avatar Zeger-Jan van de Weg

Merge branch 'master' into 'zj-mattermost-slash-config'

# Conflicts:
#   config/gitlab.yml.example
#   lib/mattermost/session.rb
#   spec/lib/mattermost/session_spec.rb
parents b23f32a7 546fa165
...@@ -292,7 +292,8 @@ Style/MultilineMethodDefinitionBraceLayout: ...@@ -292,7 +292,8 @@ Style/MultilineMethodDefinitionBraceLayout:
# Checks indentation of binary operations that span more than one line. # Checks indentation of binary operations that span more than one line.
Style/MultilineOperationIndentation: Style/MultilineOperationIndentation:
Enabled: false Enabled: true
EnforcedStyle: indented
# Avoid multi-line `? :` (the ternary operator), use if/unless instead. # Avoid multi-line `? :` (the ternary operator), use if/unless instead.
Style/MultilineTernaryOperator: Style/MultilineTernaryOperator:
......
...@@ -22,7 +22,6 @@ gem 'doorkeeper', '~> 4.2.0' ...@@ -22,7 +22,6 @@ gem 'doorkeeper', '~> 4.2.0'
gem 'omniauth', '~> 1.3.1' gem 'omniauth', '~> 1.3.1'
gem 'omniauth-auth0', '~> 1.4.1' gem 'omniauth-auth0', '~> 1.4.1'
gem 'omniauth-azure-oauth2', '~> 0.0.6' gem 'omniauth-azure-oauth2', '~> 0.0.6'
gem 'omniauth-bitbucket', '~> 0.0.2'
gem 'omniauth-cas3', '~> 1.1.2' gem 'omniauth-cas3', '~> 1.1.2'
gem 'omniauth-facebook', '~> 4.0.0' gem 'omniauth-facebook', '~> 4.0.0'
gem 'omniauth-github', '~> 1.1.1' gem 'omniauth-github', '~> 1.1.1'
...@@ -67,7 +66,7 @@ gem 'gollum-rugged_adapter', '~> 0.4.2', require: false ...@@ -67,7 +66,7 @@ gem 'gollum-rugged_adapter', '~> 0.4.2', require: false
gem 'github-linguist', '~> 4.7.0', require: 'linguist' gem 'github-linguist', '~> 4.7.0', require: 'linguist'
# API # API
gem 'grape', '~> 0.15.0' gem 'grape', '~> 0.18.0'
gem 'grape-entity', '~> 0.6.0' gem 'grape-entity', '~> 0.6.0'
gem 'rack-cors', '~> 0.4.0', require: 'rack/cors' gem 'rack-cors', '~> 0.4.0', require: 'rack/cors'
......
...@@ -284,15 +284,15 @@ GEM ...@@ -284,15 +284,15 @@ GEM
json json
multi_json multi_json
request_store (>= 1.0) request_store (>= 1.0)
grape (0.15.0) grape (0.18.0)
activesupport activesupport
builder builder
hashie (>= 2.1.0) hashie (>= 2.1.0)
multi_json (>= 1.3.2) multi_json (>= 1.3.2)
multi_xml (>= 0.5.2) multi_xml (>= 0.5.2)
mustermann-grape (~> 0.4.0)
rack (>= 1.3.0) rack (>= 1.3.0)
rack-accept rack-accept
rack-mount
virtus (>= 1.0.0) virtus (>= 1.0.0)
grape-entity (0.6.0) grape-entity (0.6.0)
activesupport activesupport
...@@ -400,6 +400,10 @@ GEM ...@@ -400,6 +400,10 @@ GEM
multi_json (1.12.1) multi_json (1.12.1)
multi_xml (0.5.5) multi_xml (0.5.5)
multipart-post (2.0.0) multipart-post (2.0.0)
mustermann (0.4.0)
tool (~> 0.2)
mustermann-grape (0.4.0)
mustermann (= 0.4.0)
mysql2 (0.3.20) mysql2 (0.3.20)
net-ldap (0.12.1) net-ldap (0.12.1)
net-ssh (3.0.1) net-ssh (3.0.1)
...@@ -428,10 +432,6 @@ GEM ...@@ -428,10 +432,6 @@ GEM
jwt (~> 1.0) jwt (~> 1.0)
omniauth (~> 1.0) omniauth (~> 1.0)
omniauth-oauth2 (~> 1.1) omniauth-oauth2 (~> 1.1)
omniauth-bitbucket (0.0.2)
multi_json (~> 1.7)
omniauth (~> 1.1)
omniauth-oauth (~> 1.0)
omniauth-cas3 (1.1.3) omniauth-cas3 (1.1.3)
addressable (~> 2.3) addressable (~> 2.3)
nokogiri (~> 1.6.6) nokogiri (~> 1.6.6)
...@@ -505,14 +505,12 @@ GEM ...@@ -505,14 +505,12 @@ GEM
pry-rails (0.3.4) pry-rails (0.3.4)
pry (>= 0.9.10) pry (>= 0.9.10)
pyu-ruby-sasl (0.0.3.3) pyu-ruby-sasl (0.0.3.3)
rack (1.6.4) rack (1.6.5)
rack-accept (0.4.5) rack-accept (0.4.5)
rack (>= 0.4) rack (>= 0.4)
rack-attack (4.4.1) rack-attack (4.4.1)
rack rack
rack-cors (0.4.0) rack-cors (0.4.0)
rack-mount (0.8.3)
rack (>= 1.0.0)
rack-oauth2 (1.2.3) rack-oauth2 (1.2.3)
activesupport (>= 2.3) activesupport (>= 2.3)
attr_required (>= 0.0.5) attr_required (>= 0.0.5)
...@@ -743,6 +741,7 @@ GEM ...@@ -743,6 +741,7 @@ GEM
tilt (2.0.5) tilt (2.0.5)
timecop (0.8.1) timecop (0.8.1)
timfel-krb5-auth (0.8.3) timfel-krb5-auth (0.8.3)
tool (0.2.3)
truncato (0.7.8) truncato (0.7.8)
htmlentities (~> 4.3.1) htmlentities (~> 4.3.1)
nokogiri (~> 1.6.1) nokogiri (~> 1.6.1)
...@@ -861,7 +860,7 @@ DEPENDENCIES ...@@ -861,7 +860,7 @@ DEPENDENCIES
gollum-lib (~> 4.2) gollum-lib (~> 4.2)
gollum-rugged_adapter (~> 0.4.2) gollum-rugged_adapter (~> 0.4.2)
gon (~> 6.1.0) gon (~> 6.1.0)
grape (~> 0.15.0) grape (~> 0.18.0)
grape-entity (~> 0.6.0) grape-entity (~> 0.6.0)
haml_lint (~> 0.18.2) haml_lint (~> 0.18.2)
hamlit (~> 2.6.1) hamlit (~> 2.6.1)
...@@ -899,7 +898,6 @@ DEPENDENCIES ...@@ -899,7 +898,6 @@ DEPENDENCIES
omniauth (~> 1.3.1) omniauth (~> 1.3.1)
omniauth-auth0 (~> 1.4.1) omniauth-auth0 (~> 1.4.1)
omniauth-azure-oauth2 (~> 0.0.6) omniauth-azure-oauth2 (~> 0.0.6)
omniauth-bitbucket (~> 0.0.2)
omniauth-cas3 (~> 1.1.2) omniauth-cas3 (~> 1.1.2)
omniauth-facebook (~> 4.0.0) omniauth-facebook (~> 4.0.0)
omniauth-github (~> 1.1.1) omniauth-github (~> 1.1.1)
......
...@@ -60,7 +60,7 @@ ...@@ -60,7 +60,7 @@
content: this.editor.getValue() content: this.editor.getValue()
}, function(response) { }, function(response) {
currentPane.empty().append(response); currentPane.empty().append(response);
return currentPane.syntaxHighlight(); return currentPane.renderGFM();
}); });
} else { } else {
this.$toggleButton.show(); this.$toggleButton.show();
......
...@@ -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;
......
...@@ -449,7 +449,7 @@ ...@@ -449,7 +449,7 @@
</span> </span>
</td> </td>
<td> <td class="environments-build-cell">
<a v-if="shouldRenderBuildName" <a v-if="shouldRenderBuildName"
class="build-link" class="build-link"
:href="model.last_deployment.deployable.build_path"> :href="model.last_deployment.deployable.build_path">
......
/* 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;
};
}
...@@ -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() {
......
...@@ -30,7 +30,7 @@ ...@@ -30,7 +30,7 @@
this.form.addClass('gfm-form'); this.form.addClass('gfm-form');
// remove notify commit author checkbox for non-commit notes // remove notify commit author checkbox for non-commit notes
gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button')); gl.utils.disableButtonIfEmptyField(this.form.find('.js-note-text'), this.form.find('.js-comment-button'));
GitLab.GfmAutoComplete.setup(this.form.find('.js-gfm-input')); gl.GfmAutoComplete.setup(this.form.find('.js-gfm-input'));
new DropzoneInput(this.form); new DropzoneInput(this.form);
autosize(this.textarea); autosize(this.textarea);
// form and textarea event listeners // form and textarea event listeners
......
...@@ -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;
}, },
......
...@@ -19,7 +19,7 @@ ...@@ -19,7 +19,7 @@
this.renderWipExplanation = bind(this.renderWipExplanation, this); this.renderWipExplanation = bind(this.renderWipExplanation, this);
this.resetAutosave = bind(this.resetAutosave, this); this.resetAutosave = bind(this.resetAutosave, this);
this.handleSubmit = bind(this.handleSubmit, this); this.handleSubmit = bind(this.handleSubmit, this);
GitLab.GfmAutoComplete.setup(); gl.GfmAutoComplete.setup();
new UsersSelect(); new UsersSelect();
new ZenMode(); new ZenMode();
this.titleField = this.form.find("input[name*='[title]']"); this.titleField = this.form.find("input[name*='[title]']");
......
...@@ -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, no-var, space-before-blocks, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, padded-blocks, max-len */ /* eslint-disable func-names, space-before-function-paren, no-var, space-before-blocks, prefer-rest-params, wrap-iife, quotes, no-underscore-dangle, one-var, one-var-declaration-per-line, consistent-return, dot-notation, quote-props, comma-dangle, object-shorthand, padded-blocks, max-len, prefer-arrow-callback */
/* global MergeRequestTabs */ /* global MergeRequestTabs */
/*= require jquery.waitforimages */ /*= require jquery.waitforimages */
...@@ -27,6 +27,7 @@ ...@@ -27,6 +27,7 @@
// Prevent duplicate event bindings // Prevent duplicate event bindings
this.disableTaskList(); this.disableTaskList();
this.initMRBtnListeners(); this.initMRBtnListeners();
this.initCommitMessageListeners();
if ($("a.btn-close").length) { if ($("a.btn-close").length) {
this.initTaskList(); this.initTaskList();
} }
...@@ -108,6 +109,26 @@ ...@@ -108,6 +109,26 @@
// note so that we can re-use its form here // note so that we can re-use its form here
}; };
MergeRequest.prototype.initCommitMessageListeners = function() {
var textarea = $('textarea.js-commit-message');
$('a.js-with-description-link').on('click', function(e) {
e.preventDefault();
textarea.val(textarea.data('messageWithDescription'));
$('p.js-with-description-hint').hide();
$('p.js-without-description-hint').show();
});
$('a.js-without-description-link').on('click', function(e) {
e.preventDefault();
textarea.val(textarea.data('messageWithoutDescription'));
$('p.js-with-description-hint').show();
$('p.js-without-description-hint').hide();
});
};
return MergeRequest; return MergeRequest;
})(); })();
......
...@@ -309,7 +309,7 @@ ...@@ -309,7 +309,7 @@
} }
row = form.closest("tr"); row = form.closest("tr");
note_html = $(note.html); note_html = $(note.html);
note_html.syntaxHighlight(); note_html.renderGFM();
// is this the first note of discussion? // is this the first note of discussion?
discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']"); discussionContainer = $(".notes[data-discussion-id='" + note.discussion_id + "']");
if ((note.original_discussion_id != null) && discussionContainer.length === 0) { if ((note.original_discussion_id != null) && discussionContainer.length === 0) {
...@@ -326,7 +326,7 @@ ...@@ -326,7 +326,7 @@
discussionContainer.append(note_html); discussionContainer.append(note_html);
// Init discussion on 'Discussion' page if it is merge request page // Init discussion on 'Discussion' page if it is merge request page
if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) { if ($('body').attr('data-page').indexOf('projects:merge_request') === 0) {
$('ul.main-notes-list').append(note.discussion_html).syntaxHighlight(); $('ul.main-notes-list').append(note.discussion_html).renderGFM();
} }
} else { } else {
// append new note to all matching discussions // append new note to all matching discussions
...@@ -467,7 +467,7 @@ ...@@ -467,7 +467,7 @@
// Convert returned HTML to a jQuery object so we can modify it further // Convert returned HTML to a jQuery object so we can modify it further
$html = $(note.html); $html = $(note.html);
gl.utils.localTimeAgo($('.js-timeago', $html)); gl.utils.localTimeAgo($('.js-timeago', $html));
$html.syntaxHighlight(); $html.renderGFM();
$html.find('.js-task-list-container').taskList('enable'); $html.find('.js-task-list-container').taskList('enable');
// Find the note's `li` element by ID and replace it with the updated HTML // Find the note's `li` element by ID and replace it with the updated HTML
$note_li = $('.note-row-' + note.id); $note_li = $('.note-row-' + note.id);
......
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
return this.renderMarkdown(mdText, (function(_this) { return this.renderMarkdown(mdText, (function(_this) {
return function(response) { return function(response) {
preview.html(response.body); preview.html(response.body);
preview.syntaxHighlight(); preview.renderGFM();
return _this.renderReferencedUsers(response.references.users, form); return _this.renderReferencedUsers(response.references.users, form);
}; };
})(this)); })(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 */
// Render Gitlab flavoured Markdown
//
// Delegates to syntax highlight and render math
//
(function() {
$.fn.renderGFM = function() {
this.find('.js-syntax-highlight').syntaxHighlight();
this.find('.js-render-math').renderMath();
};
$(document).on('ready page:load', function() {
return $('body').renderGFM();
});
}).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, no-console */
// Renders math using KaTeX in any element with the
// `js-render-math` class
//
// ### Example Markup
//
// <code class="js-render-math"></div>
//
(function() {
// Only load once
var katexLoaded = false;
// Loop over all math elements and render math
var renderWithKaTeX = function (elements) {
elements.each(function () {
var mathNode = $('<span></span>');
var $this = $(this);
var display = $this.attr('data-math-style') === 'display';
try {
katex.render($this.text(), mathNode.get(0), { displayMode: display });
mathNode.insertAfter($this);
$this.remove();
} catch (err) {
// What can we do??
console.log(err.message);
}
});
};
$.fn.renderMath = function() {
var $this = this;
if ($this.length === 0) return;
if (katexLoaded) renderWithKaTeX($this);
else {
// Request CSS file so it is in the cache
$.get(gon.katex_css_url, function() {
var css = $('<link>',
{ rel: 'stylesheet',
type: 'text/css',
href: gon.katex_css_url,
});
css.appendTo('head');
// Load KaTeX js
$.getScript(gon.katex_js_url, function() {
katexLoaded = true;
renderWithKaTeX($this); // Run KaTeX
});
});
}
};
}).call(this);
...@@ -10,8 +10,10 @@ ...@@ -10,8 +10,10 @@
// <div class="js-syntax-highlight"></div> // <div class="js-syntax-highlight"></div>
// //
(function() { (function() {
$.fn.syntaxHighlight = function() { $.fn.syntaxHighlight = function() {
var $children; var $children;
if ($(this).hasClass('js-syntax-highlight')) { if ($(this).hasClass('js-syntax-highlight')) {
// Given the element itself, apply highlighting // Given the element itself, apply highlighting
return $(this).addClass(gon.user_color_scheme); return $(this).addClass(gon.user_color_scheme);
...@@ -24,8 +26,4 @@ ...@@ -24,8 +26,4 @@
} }
}; };
$(document).on('ready page:load', function() {
return $('.js-syntax-highlight').syntaxHighlight();
});
}).call(this); }).call(this);
...@@ -98,7 +98,7 @@ ...@@ -98,7 +98,7 @@
@extend .dropdown-toggle; @extend .dropdown-toggle;
padding-right: 20px; padding-right: 20px;
position: relative; position: relative;
width: 160px; width: 163px;
text-overflow: ellipsis; text-overflow: ellipsis;
overflow: hidden; overflow: hidden;
...@@ -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 {
......
...@@ -26,6 +26,45 @@ body { ...@@ -26,6 +26,45 @@ body {
.container-limited { .container-limited {
max-width: $fixed-layout-width; max-width: $fixed-layout-width;
&.limit-container-width {
max-width: $limited-layout-width;
}
}
.alert-wrapper {
margin-bottom: $gl-padding;
.alert {
margin-bottom: 0;
}
/* Stripe the background colors so that adjacent alert-warnings are distinct from one another */
.alert-warning {
transition: background-color 0.15s, border-color 0.15s;
background-color: lighten($gl-warning, 4%);
border-color: lighten($gl-warning, 4%);
}
.alert-warning + .alert-warning {
background-color: $gl-warning;
border-color: $gl-warning;
}
.alert-warning + .alert-warning + .alert-warning {
background-color: darken($gl-warning, 4%);
border-color: darken($gl-warning, 4%);
}
.alert-warning + .alert-warning + .alert-warning + .alert-warning {
background-color: darken($gl-warning, 8%);
border-color: darken($gl-warning, 8%);
}
.alert-warning:only-of-type {
background-color: $gl-warning;
border-color: $gl-warning;
}
} }
......
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
@import "bootstrap/alerts"; @import "bootstrap/alerts";
// @import "bootstrap/progress-bars"; // @import "bootstrap/progress-bars";
@import "bootstrap/list-group"; @import "bootstrap/list-group";
// @import "bootstrap/wells"; @import "bootstrap/wells";
@import "bootstrap/close"; @import "bootstrap/close";
@import "bootstrap/panels"; @import "bootstrap/panels";
......
...@@ -154,6 +154,8 @@ $row-hover-border: #b2d7ff; ...@@ -154,6 +154,8 @@ $row-hover-border: #b2d7ff;
$progress-color: #c0392b; $progress-color: #c0392b;
$header-height: 50px; $header-height: 50px;
$fixed-layout-width: 1280px; $fixed-layout-width: 1280px;
$limited-layout-width: 990px;
$gl-avatar-size: 40px;
$error-exclamation-point: #e62958; $error-exclamation-point: #e62958;
$border-radius-default: 2px; $border-radius-default: 2px;
$settings-icon-size: 18px; $settings-icon-size: 18px;
...@@ -436,7 +438,7 @@ $jq-ui-default-color: #777; ...@@ -436,7 +438,7 @@ $jq-ui-default-color: #777;
$label-gray-bg: #f8fafc; $label-gray-bg: #f8fafc;
$label-inverse-bg: #333; $label-inverse-bg: #333;
$label-remove-border: rgba(0, 0, 0, .1); $label-remove-border: rgba(0, 0, 0, .1);
$label-border-radius: 14px; $label-border-radius: 100px;
/* /*
* Lint * Lint
...@@ -472,7 +474,6 @@ $project-option-descr-color: #54565b; ...@@ -472,7 +474,6 @@ $project-option-descr-color: #54565b;
$project-breadcrumb-color: #999; $project-breadcrumb-color: #999;
$project-private-forks-notice-odd: #2aa056; $project-private-forks-notice-odd: #2aa056;
$project-network-controls-color: #888; $project-network-controls-color: #888;
$project-limit-message-bg: #f28d35;
/* /*
* Runners * Runners
...@@ -522,3 +523,9 @@ $body-text-shadow: rgba(255,255,255,0.01); ...@@ -522,3 +523,9 @@ $body-text-shadow: rgba(255,255,255,0.01);
*/ */
$ui-dev-kit-example-color: #bbb; $ui-dev-kit-example-color: #bbb;
$ui-dev-kit-example-border: #ddd; $ui-dev-kit-example-border: #ddd;
/*
Pipeline Graph
*/
$stage-hover-bg: #eaf3fc;
$stage-hover-border: #d1e7fc;
...@@ -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%;
}
}
}
}
}
...@@ -30,19 +30,25 @@ ...@@ -30,19 +30,25 @@
display: table-cell; display: table-cell;
} }
.environments-name,
.environments-commit, .environments-commit,
.environments-actions { .environments-actions {
width: 20%; width: 20%;
} }
.environments-deploy,
.environments-build,
.environments-date { .environments-date {
width: 10%; width: 10%;
} }
.environments-name { .environments-deploy,
width: 30%; .environments-build {
width: 15%;
}
.environment-name,
.environments-build-cell,
.deployment-column {
word-break: break-all;
} }
.deployment-column { .deployment-column {
......
// Limit MR description for side-by-side diff view
.limit-container-width {
.detail-page-header {
max-width: calc(#{$limited-layout-width} - (#{$gl-padding} * 2));
margin-left: auto;
margin-right: auto;
}
.issuable-details {
.detail-page-description,
.mr-source-target,
.mr-state-widget,
.merge-manually {
max-width: calc(#{$limited-layout-width} - (#{$gl-padding} * 2));
margin-left: auto;
margin-right: auto;
}
.merge-request-tabs-holder {
&.affix {
border-bottom: 1px solid $border-color;
.nav-links {
border: 0;
}
}
.container-fluid {
padding-left: 0;
padding-right: 0;
max-width: calc(#{$limited-layout-width} - (#{$gl-padding} * 2));
margin-left: auto;
margin-right: auto;
}
}
}
.diffs {
.mr-version-controls,
.files-changed {
max-width: calc(#{$limited-layout-width} - (#{$gl-padding} * 2));
margin-left: auto;
margin-right: auto;
}
}
}
.issuable-details { .issuable-details {
section { section {
.issuable-discussion { .issuable-discussion {
...@@ -9,7 +56,6 @@ ...@@ -9,7 +56,6 @@
.description img:not(.emoji) { .description img:not(.emoji) {
border: 1px solid $white-normal; border: 1px solid $white-normal;
padding: 5px; padding: 5px;
margin: 5px;
max-height: calc(100vh - 100px); max-height: calc(100vh - 100px);
} }
} }
......
...@@ -98,7 +98,7 @@ ...@@ -98,7 +98,7 @@
} }
.label { .label {
padding: 9px; padding: 8px 9px 9px;
font-size: 14px; font-size: 14px;
} }
} }
...@@ -201,6 +201,8 @@ ...@@ -201,6 +201,8 @@
.label-remove { .label-remove {
border-left: 1px solid $label-remove-border; border-left: 1px solid $label-remove-border;
z-index: 3; z-index: 3;
border-radius: $label-border-radius;
padding: 6px 10px 6px 9px;
} }
.btn { .btn {
......
...@@ -78,6 +78,21 @@ ...@@ -78,6 +78,21 @@
float: right; float: right;
} }
.dropdown {
width: 100%;
margin-top: 5px;
.dropdown-menu-toggle {
vertical-align: middle;
width: 100%;
}
@media (min-width: $screen-sm-min) {
margin-top: 0;
width: 155px;
}
}
.form-control { .form-control {
width: 100%; width: 100%;
padding-right: 35px; padding-right: 35px;
...@@ -85,12 +100,22 @@ ...@@ -85,12 +100,22 @@
@media (min-width: $screen-sm-min) { @media (min-width: $screen-sm-min) {
width: 350px; width: 350px;
} }
&.input-short {
@media (min-width: $screen-md-min) {
width: 170px;
}
@media (min-width: $screen-lg-min) {
width: 210px;
}
}
} }
} }
.member-search-btn { .member-search-btn {
position: absolute; position: absolute;
right: 0; right: 4px;
top: 0; top: 0;
height: 35px; height: 35px;
padding-left: 10px; padding-left: 10px;
...@@ -99,4 +124,8 @@ ...@@ -99,4 +124,8 @@
background: transparent; background: transparent;
border: 0; border: 0;
outline: 0; outline: 0;
@media (min-width: $screen-sm-min) {
right: 160px;
}
} }
...@@ -383,10 +383,6 @@ ul.notes { ...@@ -383,10 +383,6 @@ ul.notes {
.note-action-button { .note-action-button {
margin-left: 10px; margin-left: 10px;
} }
@media (min-width: $screen-sm-min) {
position: relative;
}
} }
.discussion-actions { .discussion-actions {
......
This diff is collapsed.
...@@ -262,3 +262,13 @@ table.u2f-registrations { ...@@ -262,3 +262,13 @@ table.u2f-registrations {
border-right: solid 1px transparent; border-right: solid 1px transparent;
} }
} }
.oauth-application-show {
.scope-name {
font-weight: 600;
}
.scopes-list {
padding-left: 18px;
}
}
\ No newline at end of file
...@@ -6,12 +6,6 @@ ...@@ -6,12 +6,6 @@
} }
} }
.no-ssh-key-message,
.project-limit-message {
background-color: $project-limit-message-bg;
margin-bottom: 0;
}
.new_project, .new_project,
.edit-project { .edit-project {
......
class Admin::ApplicationsController < Admin::ApplicationController class Admin::ApplicationsController < Admin::ApplicationController
include OauthApplications
before_action :set_application, only: [:show, :edit, :update, :destroy] before_action :set_application, only: [:show, :edit, :update, :destroy]
before_action :load_scopes, only: [:new, :edit]
def index def index
@applications = Doorkeeper::Application.where("owner_id IS NULL") @applications = Doorkeeper::Application.where("owner_id IS NULL")
...@@ -47,6 +50,6 @@ class Admin::ApplicationsController < Admin::ApplicationController ...@@ -47,6 +50,6 @@ class Admin::ApplicationsController < Admin::ApplicationController
# Only allow a trusted parameter "white list" through. # Only allow a trusted parameter "white list" through.
def application_params def application_params
params[:doorkeeper_application].permit(:name, :redirect_uri) params[:doorkeeper_application].permit(:name, :redirect_uri, :scopes)
end end
end end
...@@ -262,7 +262,7 @@ class ApplicationController < ActionController::Base ...@@ -262,7 +262,7 @@ class ApplicationController < ActionController::Base
end end
def bitbucket_import_configured? def bitbucket_import_configured?
Gitlab::OAuth::Provider.enabled?(:bitbucket) && Gitlab::BitbucketImport.public_key.present? Gitlab::OAuth::Provider.enabled?(:bitbucket)
end end
def google_code_import_enabled? def google_code_import_enabled?
......
module OauthApplications
extend ActiveSupport::Concern
included do
before_action :prepare_scopes, only: [:create, :update]
end
def prepare_scopes
scopes = params.fetch(:doorkeeper_application, {}).fetch(:scopes, nil)
if scopes
params[:doorkeeper_application][:scopes] = scopes.join(' ')
end
end
def load_scopes
@scopes = Doorkeeper.configuration.scopes
end
end
class Groups::GroupMembersController < Groups::ApplicationController class Groups::GroupMembersController < Groups::ApplicationController
include MembershipActions include MembershipActions
include SortingHelper
# Authorize # Authorize
before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access] before_action :authorize_admin_group_member!, except: [:index, :leave, :request_access]
def index def index
@sort = params[:sort].presence || sort_value_name
@project = @group.projects.find(params[:project_id]) if params[:project_id] @project = @group.projects.find(params[:project_id]) if params[:project_id]
@members = @group.group_members @members = @group.group_members
@members = @members.non_invite unless can?(current_user, :admin_group, @group) @members = @members.non_invite unless can?(current_user, :admin_group, @group)
@members = @members.search(params[:search]) if params[:search].present?
@members = @members.sort(@sort)
@members = @members.page(params[:page]).per(50)
if params[:search].present?
users = @group.users.search(params[:search]).to_a
@members = @members.where(user_id: users)
end
@members = @members.order('access_level DESC').page(params[:page]).per(50)
@requesters = AccessRequestsFinder.new(@group).execute(current_user) @requesters = AccessRequestsFinder.new(@group).execute(current_user)
@group_member = @group.group_members.new @group_member = @group.group_members.new
......
...@@ -2,50 +2,57 @@ class Import::BitbucketController < Import::BaseController ...@@ -2,50 +2,57 @@ class Import::BitbucketController < Import::BaseController
before_action :verify_bitbucket_import_enabled before_action :verify_bitbucket_import_enabled
before_action :bitbucket_auth, except: :callback before_action :bitbucket_auth, except: :callback
rescue_from OAuth::Error, with: :bitbucket_unauthorized rescue_from OAuth2::Error, with: :bitbucket_unauthorized
rescue_from Gitlab::BitbucketImport::Client::Unauthorized, with: :bitbucket_unauthorized rescue_from Bitbucket::Error::Unauthorized, with: :bitbucket_unauthorized
def callback def callback
request_token = session.delete(:oauth_request_token) response = client.auth_code.get_token(params[:code], redirect_uri: callback_import_bitbucket_url)
raise "Session expired!" if request_token.nil?
request_token.symbolize_keys! session[:bitbucket_token] = response.token
session[:bitbucket_expires_at] = response.expires_at
access_token = client.get_token(request_token, params[:oauth_verifier], callback_import_bitbucket_url) session[:bitbucket_expires_in] = response.expires_in
session[:bitbucket_refresh_token] = response.refresh_token
session[:bitbucket_access_token] = access_token.token
session[:bitbucket_access_token_secret] = access_token.secret
redirect_to status_import_bitbucket_url redirect_to status_import_bitbucket_url
end end
def status def status
@repos = client.projects bitbucket_client = Bitbucket::Client.new(credentials)
@incompatible_repos = client.incompatible_projects repos = bitbucket_client.repos
@repos, @incompatible_repos = repos.partition { |repo| repo.valid? }
@already_added_projects = current_user.created_projects.where(import_type: "bitbucket") @already_added_projects = current_user.created_projects.where(import_type: 'bitbucket')
already_added_projects_names = @already_added_projects.pluck(:import_source) already_added_projects_names = @already_added_projects.pluck(:import_source)
@repos.to_a.reject!{ |repo| already_added_projects_names.include? "#{repo["owner"]}/#{repo["slug"]}" } @repos.to_a.reject! { |repo| already_added_projects_names.include?(repo.full_name) }
end end
def jobs def jobs
jobs = current_user.created_projects.where(import_type: "bitbucket").to_json(only: [:id, :import_status]) render json: current_user.created_projects
render json: jobs .where(import_type: 'bitbucket')
.to_json(only: [:id, :import_status])
end end
def create def create
bitbucket_client = Bitbucket::Client.new(credentials)
@repo_id = params[:repo_id].to_s @repo_id = params[:repo_id].to_s
repo = client.project(@repo_id.gsub('___', '/')) name = @repo_id.gsub('___', '/')
@project_name = repo['slug'] repo = bitbucket_client.repo(name)
@target_namespace = find_or_create_namespace(repo['owner'], client.user['user']['username']) @project_name = params[:new_name].presence || repo.name
unless Gitlab::BitbucketImport::KeyAdder.new(repo, current_user, access_params).execute repo_owner = repo.owner
render 'deploy_key' and return repo_owner = current_user.username if repo_owner == bitbucket_client.user.username
end @target_namespace = params[:new_namespace].presence || repo_owner
namespace = find_or_create_namespace(@target_namespace, current_user)
if current_user.can?(:create_projects, @target_namespace) if current_user.can?(:create_projects, namespace)
@project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @target_namespace, current_user, access_params).execute # The token in a session can be expired, we need to get most recent one because
# Bitbucket::Connection class refreshes it.
session[:bitbucket_token] = bitbucket_client.connection.token
@project = Gitlab::BitbucketImport::ProjectCreator.new(repo, @project_name, namespace, current_user, credentials).execute
else else
render 'unauthorized' render 'unauthorized'
end end
...@@ -54,8 +61,15 @@ class Import::BitbucketController < Import::BaseController ...@@ -54,8 +61,15 @@ class Import::BitbucketController < Import::BaseController
private private
def client def client
@client ||= Gitlab::BitbucketImport::Client.new(session[:bitbucket_access_token], @client ||= OAuth2::Client.new(provider.app_id, provider.app_secret, options)
session[:bitbucket_access_token_secret]) end
def provider
Gitlab::OAuth::Provider.config_for('bitbucket')
end
def options
OmniAuth::Strategies::Bitbucket.default_options[:client_options].deep_symbolize_keys
end end
def verify_bitbucket_import_enabled def verify_bitbucket_import_enabled
...@@ -63,26 +77,23 @@ class Import::BitbucketController < Import::BaseController ...@@ -63,26 +77,23 @@ class Import::BitbucketController < Import::BaseController
end end
def bitbucket_auth def bitbucket_auth
if session[:bitbucket_access_token].blank? go_to_bitbucket_for_permissions if session[:bitbucket_token].blank?
go_to_bitbucket_for_permissions
end
end end
def go_to_bitbucket_for_permissions def go_to_bitbucket_for_permissions
request_token = client.request_token(callback_import_bitbucket_url) redirect_to client.auth_code.authorize_url(redirect_uri: callback_import_bitbucket_url)
session[:oauth_request_token] = request_token
redirect_to client.authorize_url(request_token, callback_import_bitbucket_url)
end end
def bitbucket_unauthorized def bitbucket_unauthorized
go_to_bitbucket_for_permissions go_to_bitbucket_for_permissions
end end
def access_params def credentials
{ {
bitbucket_access_token: session[:bitbucket_access_token], token: session[:bitbucket_token],
bitbucket_access_token_secret: session[:bitbucket_access_token_secret] expires_at: session[:bitbucket_expires_at],
expires_in: session[:bitbucket_expires_in],
refresh_token: session[:bitbucket_refresh_token]
} }
end end
end end
...@@ -26,7 +26,7 @@ class JwtController < ApplicationController ...@@ -26,7 +26,7 @@ class JwtController < ApplicationController
@authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip) @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip)
render_unauthorized unless @authentication_result.success? && render_unauthorized unless @authentication_result.success? &&
(@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User)) (@authentication_result.actor.nil? || @authentication_result.actor.is_a?(User))
end end
rescue Gitlab::Auth::MissingPersonalTokenError rescue Gitlab::Auth::MissingPersonalTokenError
render_missing_personal_token render_missing_personal_token
......
...@@ -2,10 +2,12 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController ...@@ -2,10 +2,12 @@ class Oauth::ApplicationsController < Doorkeeper::ApplicationsController
include Gitlab::CurrentSettings include Gitlab::CurrentSettings
include Gitlab::GonHelper include Gitlab::GonHelper
include PageLayoutHelper include PageLayoutHelper
include OauthApplications
before_action :verify_user_oauth_applications_enabled before_action :verify_user_oauth_applications_enabled
before_action :authenticate_user! before_action :authenticate_user!
before_action :add_gon_variables before_action :add_gon_variables
before_action :load_scopes, only: [:index, :create, :edit]
layout 'profile' layout 'profile'
......
class Profiles::PersonalAccessTokensController < Profiles::ApplicationController class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
before_action :load_personal_access_tokens, only: :index
def index def index
@personal_access_token = current_user.personal_access_tokens.build set_index_vars
end end
def create def create
...@@ -12,7 +10,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController ...@@ -12,7 +10,7 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
flash[:personal_access_token] = @personal_access_token.token flash[:personal_access_token] = @personal_access_token.token
redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created." redirect_to profile_personal_access_tokens_path, notice: "Your new personal access token has been created."
else else
load_personal_access_tokens set_index_vars
render :index render :index
end end
end end
...@@ -32,10 +30,12 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController ...@@ -32,10 +30,12 @@ class Profiles::PersonalAccessTokensController < Profiles::ApplicationController
private private
def personal_access_token_params def personal_access_token_params
params.require(:personal_access_token).permit(:name, :expires_at) params.require(:personal_access_token).permit(:name, :expires_at, scopes: [])
end end
def load_personal_access_tokens def set_index_vars
@personal_access_token ||= current_user.personal_access_tokens.build
@scopes = Gitlab::Auth::SCOPES
@active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at) @active_personal_access_tokens = current_user.personal_access_tokens.active.order(:expires_at)
@inactive_personal_access_tokens = current_user.personal_access_tokens.inactive @inactive_personal_access_tokens = current_user.personal_access_tokens.inactive
end end
......
...@@ -22,6 +22,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -22,6 +22,7 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
end end
@qr_code = build_qr_code @qr_code = build_qr_code
@account_string = account_string
setup_u2f_registration setup_u2f_registration
end end
...@@ -78,11 +79,14 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController ...@@ -78,11 +79,14 @@ class Profiles::TwoFactorAuthsController < Profiles::ApplicationController
private private
def build_qr_code def build_qr_code
issuer = "#{issuer_host} | #{current_user.email}" uri = current_user.otp_provisioning_uri(account_string, issuer: issuer_host)
uri = current_user.otp_provisioning_uri(current_user.email, issuer: issuer)
RQRCode::render_qrcode(uri, :svg, level: :m, unit: 3) RQRCode::render_qrcode(uri, :svg, level: :m, unit: 3)
end end
def account_string
"#{issuer_host}:#{current_user.email}"
end
def issuer_host def issuer_host
Gitlab.config.gitlab.host Gitlab.config.gitlab.host
end end
......
class Projects::AutocompleteSourcesController < Projects::ApplicationController
before_action :load_autocomplete_service, except: [:emojis, :members]
def emojis
render json: Gitlab::AwardEmoji.urls
end
def members
render json: ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
end
def issues
render json: @autocomplete_service.issues
end
def merge_requests
render json: @autocomplete_service.merge_requests
end
def labels
render json: @autocomplete_service.labels
end
def milestones
render json: @autocomplete_service.milestones
end
def commands
render json: @autocomplete_service.commands(noteable, params[:type])
end
private
def load_autocomplete_service
@autocomplete_service = ::Projects::AutocompleteService.new(@project, current_user)
end
def noteable
case params[:type]
when 'Issue'
IssuesFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
when 'MergeRequest'
MergeRequestsFinder.new(current_user, project_id: @project.id).execute.find_by(iid: params[:type_id])
when 'Commit'
@project.commit(params[:type_id])
end
end
end
...@@ -217,6 +217,6 @@ class Projects::NotesController < Projects::ApplicationController ...@@ -217,6 +217,6 @@ class Projects::NotesController < Projects::ApplicationController
end end
def find_current_user_notes def find_current_user_notes
@notes = NotesFinder.new.execute(project, current_user, params) @notes = NotesFinder.new(project, current_user, params).execute.inc_author
end end
end end
class Projects::ProjectMembersController < Projects::ApplicationController class Projects::ProjectMembersController < Projects::ApplicationController
include MembershipActions include MembershipActions
include SortingHelper
# Authorize # Authorize
before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access] before_action :authorize_admin_project_member!, except: [:index, :leave, :request_access]
def index def index
@sort = params[:sort].presence || sort_value_name
@group_links = @project.project_group_links @group_links = @project.project_group_links
@project_members = @project.project_members @project_members = @project.project_members
...@@ -35,12 +37,13 @@ class Projects::ProjectMembersController < Projects::ApplicationController ...@@ -35,12 +37,13 @@ class Projects::ProjectMembersController < Projects::ApplicationController
@group_links = @project.project_group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id)) @group_links = @project.project_group_links.where(group_id: @project.invited_groups.search(params[:search]).select(:id))
end end
wheres = ["id IN (#{@project_members.select(:id).to_sql})"] wheres = ["members.id IN (#{@project_members.select(:id).to_sql})"]
wheres << "id IN (#{group_members.select(:id).to_sql})" if group_members wheres << "members.id IN (#{group_members.select(:id).to_sql})" if group_members
@project_members = Member. @project_members = Member.
where(wheres.join(' OR ')). where(wheres.join(' OR ')).
order(access_level: :desc).page(params[:page]) sort(@sort).
page(params[:page])
@requesters = AccessRequestsFinder.new(@project).execute(current_user) @requesters = AccessRequestsFinder.new(@project).execute(current_user)
......
...@@ -127,39 +127,6 @@ class ProjectsController < Projects::ApplicationController ...@@ -127,39 +127,6 @@ class ProjectsController < Projects::ApplicationController
redirect_to edit_project_path(@project), alert: ex.message redirect_to edit_project_path(@project), alert: ex.message
end end
def autocomplete_sources
noteable =
case params[:type]
when 'Issue'
IssuesFinder.new(current_user, project_id: @project.id).
execute.find_by(iid: params[:type_id])
when 'MergeRequest'
MergeRequestsFinder.new(current_user, project_id: @project.id).
execute.find_by(iid: params[:type_id])
when 'Commit'
@project.commit(params[:type_id])
else
nil
end
autocomplete = ::Projects::AutocompleteService.new(@project, current_user)
participants = ::Projects::ParticipantsService.new(@project, current_user).execute(noteable)
@suggestions = {
emojis: Gitlab::AwardEmoji.urls,
issues: autocomplete.issues,
milestones: autocomplete.milestones,
mergerequests: autocomplete.merge_requests,
labels: autocomplete.labels,
members: participants,
commands: autocomplete.commands(noteable, params[:type])
}
respond_to do |format|
format.json { render json: @suggestions }
end
end
def new_issue_address def new_issue_address
return render_404 unless Gitlab::IncomingEmail.supports_issue_creation? return render_404 unless Gitlab::IncomingEmail.supports_issue_creation?
......
...@@ -114,7 +114,7 @@ class SessionsController < Devise::SessionsController ...@@ -114,7 +114,7 @@ class SessionsController < Devise::SessionsController
def valid_otp_attempt?(user) def valid_otp_attempt?(user)
user.validate_and_consume_otp!(user_params[:otp_attempt]) || user.validate_and_consume_otp!(user_params[:otp_attempt]) ||
user.invalidate_otp_backup_code!(user_params[:otp_attempt]) user.invalidate_otp_backup_code!(user_params[:otp_attempt])
end end
def log_audit_event(user, options = {}) def log_audit_event(user, options = {})
......
...@@ -23,10 +23,26 @@ class IssuesFinder < IssuableFinder ...@@ -23,10 +23,26 @@ class IssuesFinder < IssuableFinder
private private
def init_collection def init_collection
Issue.visible_to_user(current_user) IssuesFinder.not_restricted_by_confidentiality(current_user)
end end
def iid_pattern def iid_pattern
@iid_pattern ||= %r{\A#{Regexp.escape(Issue.reference_prefix)}(?<iid>\d+)\z} @iid_pattern ||= %r{\A#{Regexp.escape(Issue.reference_prefix)}(?<iid>\d+)\z}
end end
def self.not_restricted_by_confidentiality(user)
return Issue.where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank?
return Issue.all if user.admin?
Issue.where('
issues.confidential IS NULL
OR issues.confidential IS FALSE
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
OR issues.assignee_id = :user_id
OR issues.project_id IN(:project_ids)))',
user_id: user.id,
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
end
end end
class NotesFinder class NotesFinder
FETCH_OVERLAP = 5.seconds FETCH_OVERLAP = 5.seconds
def execute(project, current_user, params) # Used to filter Notes
target_type = params[:target_type] # When used with target_type and target_id this returns notes specifically for the controller
target_id = params[:target_id] #
# Default to 0 to remain compatible with old clients # Arguments:
last_fetched_at = Time.at(params.fetch(:last_fetched_at, 0).to_i) # current_user - which user check authorizations with
# project - which project to look for notes on
notes = # params:
case target_type # target_type: string
when "commit" # target_id: integer
project.notes.for_commit_id(target_id).non_diff_notes # last_fetched_at: time
when "issue" # search: string
IssuesFinder.new(current_user, project_id: project.id).find(target_id).notes.inc_author #
when "merge_request" def initialize(project, current_user, params = {})
MergeRequestsFinder.new(current_user, project_id: project.id).find(target_id).mr_and_commit_notes.inc_author @project = project
when "snippet", "project_snippet" @current_user = current_user
project.snippets.find(target_id).notes @params = params
init_collection
end
def execute
@notes = since_fetch_at(@params[:last_fetched_at]) if @params[:last_fetched_at]
@notes
end
private
def init_collection
if @params[:target_id]
@notes = on_target(@params[:target_type], @params[:target_id])
else
@notes = notes_of_any_type
end
end
def notes_of_any_type
types = %w(commit issue merge_request snippet)
note_relations = types.map { |t| notes_for_type(t) }
note_relations.map!{ |notes| search(@params[:search], notes) } if @params[:search]
UnionFinder.new.find_union(note_relations, Note)
end
def noteables_for_type(noteable_type)
case noteable_type
when "issue"
IssuesFinder.new(@current_user, project_id: @project.id).execute
when "merge_request"
MergeRequestsFinder.new(@current_user, project_id: @project.id).execute
when "snippet", "project_snippet"
SnippetsFinder.new.execute(@current_user, filter: :by_project, project: @project)
else
raise 'invalid target_type'
end
end
def notes_for_type(noteable_type)
if noteable_type == "commit"
if Ability.allowed?(@current_user, :download_code, @project)
@project.notes.where(noteable_type: 'Commit')
else
Note.none
end
else
finder = noteables_for_type(noteable_type)
@project.notes.where(noteable_type: finder.base_class.name, noteable_id: finder.reorder(nil))
end
end
def on_target(target_type, target_id)
if target_type == "commit"
notes_for_type('commit').for_commit_id(target_id)
else
target = noteables_for_type(target_type).find(target_id)
if target.respond_to?(:related_notes)
target.related_notes
else else
raise 'invalid target_type' target.notes
end end
end
end
# Searches for notes matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
def search(query, notes_relation = @notes)
pattern = "%#{query}%"
notes_relation.where(Note.arel_table[:note].matches(pattern))
end
# Notes changed since last fetch
# Uses overlapping intervals to avoid worrying about race conditions
def since_fetch_at(fetch_time)
# Default to 0 to remain compatible with old clients
last_fetched_at = Time.at(@params.fetch(:last_fetched_at, 0).to_i)
# Use overlapping intervals to avoid worrying about race conditions @notes.where('updated_at > ?', last_fetched_at - FETCH_OVERLAP).fresh
notes.where('updated_at > ?', last_fetched_at - FETCH_OVERLAP).fresh
end end
end end
...@@ -7,12 +7,12 @@ module FormHelper ...@@ -7,12 +7,12 @@ module FormHelper
content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do content_tag(:div, class: 'alert alert-danger', id: 'error_explanation') do
content_tag(:h4, headline) << content_tag(:h4, headline) <<
content_tag(:ul) do content_tag(:ul) do
model.errors.full_messages. model.errors.full_messages.
map { |msg| content_tag(:li, msg) }. map { |msg| content_tag(:li, msg) }.
join. join.
html_safe html_safe
end end
end end
end end
end end
...@@ -12,11 +12,18 @@ module GroupsHelper ...@@ -12,11 +12,18 @@ module GroupsHelper
end end
def group_title(group, name = nil, url = nil) def group_title(group, name = nil, url = nil)
full_title = link_to(simple_sanitize(group.name), group_path(group)) full_title = ''
group.parents.each do |parent|
full_title += link_to(simple_sanitize(parent.name), group_path(parent))
full_title += ' / '.html_safe
end
full_title += link_to(simple_sanitize(group.name), group_path(group))
full_title += ' &middot; '.html_safe + link_to(simple_sanitize(name), url) if name full_title += ' &middot; '.html_safe + link_to(simple_sanitize(name), url) if name
content_tag :span do content_tag :span do
full_title full_title.html_safe
end end
end end
......
...@@ -36,4 +36,12 @@ module MembersHelper ...@@ -36,4 +36,12 @@ module MembersHelper
"Are you sure you want to leave the " \ "Are you sure you want to leave the " \
"\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?" "\"#{member_source.human_name}\" #{member_source.class.to_s.humanize(capitalize: false)}?"
end end
def filter_group_project_member_path(options = {})
options = params.slice(:search, :sort).merge(options)
path = request.path
path << "?#{options.to_param}"
path
end
end end
...@@ -59,6 +59,10 @@ module MergeRequestsHelper ...@@ -59,6 +59,10 @@ module MergeRequestsHelper
@mr_closes_issues ||= @merge_request.closes_issues @mr_closes_issues ||= @merge_request.closes_issues
end end
def mr_issues_mentioned_but_not_closing
@mr_issues_mentioned_but_not_closing ||= @merge_request.issues_mentioned_but_not_closing
end
def mr_change_branches_path(merge_request) def mr_change_branches_path(merge_request)
new_namespace_project_merge_request_path( new_namespace_project_merge_request_path(
@project.namespace, @project, @project.namespace, @project,
......
...@@ -7,12 +7,12 @@ module NavHelper ...@@ -7,12 +7,12 @@ module NavHelper
def page_gutter_class def page_gutter_class
if current_path?('merge_requests#show') || if current_path?('merge_requests#show') ||
current_path?('merge_requests#diffs') || current_path?('merge_requests#diffs') ||
current_path?('merge_requests#commits') || current_path?('merge_requests#commits') ||
current_path?('merge_requests#builds') || current_path?('merge_requests#builds') ||
current_path?('merge_requests#conflicts') || current_path?('merge_requests#conflicts') ||
current_path?('merge_requests#pipelines') || current_path?('merge_requests#pipelines') ||
current_path?('issues#show') current_path?('issues#show')
if cookies[:collapsed_gutter] == 'true' if cookies[:collapsed_gutter] == 'true'
"page-gutter right-sidebar-collapsed" "page-gutter right-sidebar-collapsed"
else else
...@@ -21,9 +21,9 @@ module NavHelper ...@@ -21,9 +21,9 @@ module NavHelper
elsif current_path?('builds#show') elsif current_path?('builds#show')
"page-gutter build-sidebar right-sidebar-expanded" "page-gutter build-sidebar right-sidebar-expanded"
elsif current_path?('wikis#show') || elsif current_path?('wikis#show') ||
current_path?('wikis#edit') || current_path?('wikis#edit') ||
current_path?('wikis#history') || current_path?('wikis#history') ||
current_path?('wikis#git_access') current_path?('wikis#git_access')
"page-gutter wiki-sidebar right-sidebar-expanded" "page-gutter wiki-sidebar right-sidebar-expanded"
end end
end end
......
...@@ -52,7 +52,7 @@ module ProjectsHelper ...@@ -52,7 +52,7 @@ module ProjectsHelper
def project_title(project) def project_title(project)
namespace_link = namespace_link =
if project.group if project.group
link_to(simple_sanitize(project.group.name), group_path(project.group)) group_title(project.group)
else else
owner = project.namespace.owner owner = project.namespace.owner
link_to(simple_sanitize(owner.name), user_path(owner)) link_to(simple_sanitize(owner.name), user_path(owner))
...@@ -390,7 +390,7 @@ module ProjectsHelper ...@@ -390,7 +390,7 @@ module ProjectsHelper
"success" "success"
end end
end end
def readme_cache_key def readme_cache_key
sha = @project.commit.try(:sha) || 'nil' sha = @project.commit.try(:sha) || 'nil'
[@project.path_with_namespace, sha, "readme"].join('-') [@project.path_with_namespace, sha, "readme"].join('-')
......
...@@ -25,7 +25,7 @@ module SortingHelper ...@@ -25,7 +25,7 @@ module SortingHelper
sort_value_recently_updated => sort_title_recently_updated, sort_value_recently_updated => sort_title_recently_updated,
sort_value_oldest_updated => sort_title_oldest_updated, sort_value_oldest_updated => sort_title_oldest_updated,
sort_value_recently_created => sort_title_recently_created, sort_value_recently_created => sort_title_recently_created,
sort_value_oldest_created => sort_title_oldest_created, sort_value_oldest_created => sort_title_oldest_created
} }
if current_controller?('admin/projects') if current_controller?('admin/projects')
...@@ -35,6 +35,19 @@ module SortingHelper ...@@ -35,6 +35,19 @@ module SortingHelper
options options
end end
def member_sort_options_hash
{
sort_value_access_level_asc => sort_title_access_level_asc,
sort_value_access_level_desc => sort_title_access_level_desc,
sort_value_last_joined => sort_title_last_joined,
sort_value_oldest_joined => sort_title_oldest_joined,
sort_value_name => sort_title_name_asc,
sort_value_name_desc => sort_title_name_desc,
sort_value_recently_signin => sort_title_recently_signin,
sort_value_oldest_signin => sort_title_oldest_signin
}
end
def sort_title_priority def sort_title_priority
'Priority' 'Priority'
end end
...@@ -95,6 +108,50 @@ module SortingHelper ...@@ -95,6 +108,50 @@ module SortingHelper
'Most popular' 'Most popular'
end end
def sort_title_last_joined
'Last joined'
end
def sort_title_oldest_joined
'Oldest joined'
end
def sort_title_access_level_asc
'Access level, ascending'
end
def sort_title_access_level_desc
'Access level, descending'
end
def sort_title_name_asc
'Name, ascending'
end
def sort_title_name_desc
'Name, descending'
end
def sort_value_last_joined
'last_joined'
end
def sort_value_oldest_joined
'oldest_joined'
end
def sort_value_access_level_asc
'access_level_asc'
end
def sort_value_access_level_desc
'access_level_desc'
end
def sort_value_name_desc
'name_desc'
end
def sort_value_priority def sort_value_priority
'priority' 'priority'
end end
......
...@@ -106,9 +106,9 @@ module TabHelper ...@@ -106,9 +106,9 @@ module TabHelper
def branches_tab_class def branches_tab_class
if current_controller?(:protected_branches) || if current_controller?(:protected_branches) ||
current_controller?(:branches) || current_controller?(:branches) ||
current_page?(namespace_project_repository_path(@project.namespace, current_page?(namespace_project_repository_path(@project.namespace,
@project)) @project))
'active' 'active'
end end
end end
......
...@@ -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,11 +151,11 @@ module Ci ...@@ -143,11 +151,11 @@ 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?
self.environment.present? environment.present?
end end
def starts_environment? def starts_environment?
...@@ -206,12 +214,14 @@ module Ci ...@@ -206,12 +214,14 @@ 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
variables += runner.predefined_variables if runner variables += runner.predefined_variables if runner
variables += project.container_registry_variables variables += project.container_registry_variables
variables += project.deployment_variables if has_environment?
variables += yaml_variables variables += yaml_variables
variables += user_variables variables += user_variables
variables += project.secret_variables variables += project.secret_variables
...@@ -219,6 +229,13 @@ module Ci ...@@ -219,6 +229,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
......
...@@ -30,7 +30,7 @@ module Milestoneish ...@@ -30,7 +30,7 @@ module Milestoneish
end end
def issues_visible_to_user(user) def issues_visible_to_user(user)
issues.visible_to_user(user) IssuesFinder.new(user).execute.where(id: issues)
end end
def upcoming? def upcoming?
......
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
...@@ -83,7 +83,7 @@ class Group < Namespace ...@@ -83,7 +83,7 @@ class Group < Namespace
end end
def human_name def human_name
name full_name
end end
def visibility_level_field def visibility_level_field
......
...@@ -60,61 +60,6 @@ class Issue < ActiveRecord::Base ...@@ -60,61 +60,6 @@ class Issue < ActiveRecord::Base
attributes attributes
end end
class << self
private
# Returns the project that the current scope belongs to if any, nil otherwise.
#
# Examples:
# - my_project.issues.without_due_date.owner_project => my_project
# - Issue.all.owner_project => nil
def owner_project
# No owner if we're not being called from an association
return unless all.respond_to?(:proxy_association)
owner = all.proxy_association.owner
# Check if the association is or belongs to a project
if owner.is_a?(Project)
owner
else
begin
owner.association(:project).target
rescue ActiveRecord::AssociationNotFoundError
nil
end
end
end
end
def self.visible_to_user(user)
return where('issues.confidential IS NULL OR issues.confidential IS FALSE') if user.blank?
return all if user.admin?
# Check if we are scoped to a specific project's issues
if owner_project
if owner_project.team.member?(user, Gitlab::Access::REPORTER)
# If the project is authorized for the user, they can see all issues in the project
return all
else
# else only non confidential and authored/assigned to them
return where('issues.confidential IS NULL OR issues.confidential IS FALSE
OR issues.author_id = :user_id OR issues.assignee_id = :user_id',
user_id: user.id)
end
end
where('
issues.confidential IS NULL
OR issues.confidential IS FALSE
OR (issues.confidential = TRUE
AND (issues.author_id = :user_id
OR issues.assignee_id = :user_id
OR issues.project_id IN(:project_ids)))',
user_id: user.id,
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
end
def self.reference_prefix def self.reference_prefix
'#' '#'
end end
......
...@@ -57,6 +57,11 @@ class Member < ActiveRecord::Base ...@@ -57,6 +57,11 @@ class Member < ActiveRecord::Base
scope :owners, -> { active.where(access_level: OWNER) } scope :owners, -> { active.where(access_level: OWNER) }
scope :owners_and_masters, -> { active.where(access_level: [OWNER, MASTER]) } scope :owners_and_masters, -> { active.where(access_level: [OWNER, MASTER]) }
scope :order_name_asc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'ASC')) }
scope :order_name_desc, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.name', 'DESC')) }
scope :order_recent_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'DESC')) }
scope :order_oldest_sign_in, -> { left_join_users.reorder(Gitlab::Database.nulls_last_order('users.last_sign_in_at', 'ASC')) }
before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? } before_validation :generate_invite_token, on: :create, if: -> (member) { member.invite_email.present? }
after_create :send_invite, if: :invite?, unless: :importing? after_create :send_invite, if: :invite?, unless: :importing?
...@@ -72,6 +77,34 @@ class Member < ActiveRecord::Base ...@@ -72,6 +77,34 @@ class Member < ActiveRecord::Base
default_value_for :notification_level, NotificationSetting.levels[:global] default_value_for :notification_level, NotificationSetting.levels[:global]
class << self class << self
def search(query)
joins(:user).merge(User.search(query))
end
def sort(method)
case method.to_s
when 'access_level_asc' then reorder(access_level: :asc)
when 'access_level_desc' then reorder(access_level: :desc)
when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then order_oldest_sign_in
when 'last_joined' then order_created_desc
when 'oldest_joined' then order_created_asc
else
order_by(method)
end
end
def left_join_users
users = User.arel_table
members = Member.arel_table
member_users = members.join(users, Arel::Nodes::OuterJoin).
on(members[:user_id].eq(users[:id])).
join_sources
joins(member_users)
end
def access_for_user_ids(user_ids) def access_for_user_ids(user_ids)
where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h where(user_id: user_ids).has_access.pluck(:user_id, :access_level).to_h
end end
...@@ -89,8 +122,8 @@ class Member < ActiveRecord::Base ...@@ -89,8 +122,8 @@ class Member < ActiveRecord::Base
member = member =
if user.is_a?(User) if user.is_a?(User)
source.members.find_by(user_id: user.id) || source.members.find_by(user_id: user.id) ||
source.requesters.find_by(user_id: user.id) || source.requesters.find_by(user_id: user.id) ||
source.members.build(user_id: user.id) source.members.build(user_id: user.id)
else else
source.members.build(invite_email: user) source.members.build(invite_email: user)
end end
......
...@@ -452,7 +452,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -452,7 +452,7 @@ class MergeRequest < ActiveRecord::Base
should_remove_source_branch? || force_remove_source_branch? should_remove_source_branch? || force_remove_source_branch?
end end
def mr_and_commit_notes def related_notes
# Fetch comments only from last 100 commits # Fetch comments only from last 100 commits
commits_for_notes_limit = 100 commits_for_notes_limit = 100
commit_ids = commits.last(commits_for_notes_limit).map(&:id) commit_ids = commits.last(commits_for_notes_limit).map(&:id)
...@@ -468,7 +468,7 @@ class MergeRequest < ActiveRecord::Base ...@@ -468,7 +468,7 @@ class MergeRequest < ActiveRecord::Base
end end
def discussions def discussions
@discussions ||= self.mr_and_commit_notes. @discussions ||= self.related_notes.
inc_relations_for_view. inc_relations_for_view.
fresh. fresh.
discussions discussions
...@@ -568,6 +568,19 @@ class MergeRequest < ActiveRecord::Base ...@@ -568,6 +568,19 @@ class MergeRequest < ActiveRecord::Base
end end
end end
def issues_mentioned_but_not_closing(current_user = self.author)
return [] unless target_branch == project.default_branch
ext = Gitlab::ReferenceExtractor.new(project, current_user)
ext.analyze(description)
issues = ext.issues
closing_issues = Gitlab::ClosingIssueExtractor.new(project, current_user).
closed_by_message(description)
issues - closing_issues
end
def target_project_path def target_project_path
if target_project if target_project
target_project.path_with_namespace target_project.path_with_namespace
...@@ -612,13 +625,24 @@ class MergeRequest < ActiveRecord::Base ...@@ -612,13 +625,24 @@ class MergeRequest < ActiveRecord::Base
self.target_project.repository.branch_names.include?(self.target_branch) self.target_project.repository.branch_names.include?(self.target_branch)
end end
def merge_commit_message def merge_commit_message(include_description: false)
message = "Merge branch '#{source_branch}' into '#{target_branch}'\n\n" closes_issues_references = closes_issues.map do |issue|
message << "#{title}\n\n" issue.to_reference(target_project)
message << "#{description}\n\n" if description.present? end
message = [
"Merge branch '#{source_branch}' into '#{target_branch}'",
title
]
if !include_description && closes_issues_references.present?
message << "Closes #{closes_issues_references.to_sentence}"
end
message << "#{description}" if include_description && description.present?
message << "See merge request #{to_reference}" message << "See merge request #{to_reference}"
message message.join("\n\n")
end end
def reset_merge_when_build_succeeds def reset_merge_when_build_succeeds
......
...@@ -161,6 +161,19 @@ class Namespace < ActiveRecord::Base ...@@ -161,6 +161,19 @@ class Namespace < ActiveRecord::Base
end end
end end
def full_name
@full_name ||=
if parent
parent.full_name + ' / ' + name
else
name
end
end
def parents
@parents ||= parent ? parent.parents + [parent] : []
end
private private
def repository_storage_paths def repository_storage_paths
......
...@@ -161,8 +161,8 @@ module Network ...@@ -161,8 +161,8 @@ module Network
def is_overlap?(range, overlap_space) def is_overlap?(range, overlap_space)
range.each do |i| range.each do |i|
if i != range.first && if i != range.first &&
i != range.last && i != range.last &&
@commits[i].spaces.include?(overlap_space) @commits[i].spaces.include?(overlap_space)
return true return true
end end
......
...@@ -107,23 +107,6 @@ class Note < ActiveRecord::Base ...@@ -107,23 +107,6 @@ class Note < ActiveRecord::Base
Discussion.for_diff_notes(active_notes). Discussion.for_diff_notes(active_notes).
map { |d| [d.line_code, d] }.to_h map { |d| [d.line_code, d] }.to_h
end end
# Searches for notes matching the given query.
#
# This method uses ILIKE on PostgreSQL and LIKE on MySQL.
#
# query - The search query as a String.
# as_user - Limit results to those viewable by a specific user
#
# Returns an ActiveRecord::Relation.
def search(query, as_user: nil)
table = arel_table
pattern = "%#{query}%"
Note.joins('LEFT JOIN issues ON issues.id = noteable_id').
where(table[:note].matches(pattern)).
merge(Issue.visible_to_user(as_user))
end
end end
def cross_reference? def cross_reference?
......
...@@ -2,6 +2,8 @@ class PersonalAccessToken < ActiveRecord::Base ...@@ -2,6 +2,8 @@ class PersonalAccessToken < ActiveRecord::Base
include TokenAuthenticatable include TokenAuthenticatable
add_authentication_token_field :token add_authentication_token_field :token
serialize :scopes, Array
belongs_to :user belongs_to :user
scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") } scope :active, -> { where(revoked: false).where("expires_at >= NOW() OR expires_at IS NULL") }
......
...@@ -95,7 +95,8 @@ class Project < ActiveRecord::Base ...@@ -95,7 +95,8 @@ class Project < ActiveRecord::Base
has_one :asana_service, dependent: :destroy has_one :asana_service, dependent: :destroy
has_one :gemnasium_service, dependent: :destroy has_one :gemnasium_service, dependent: :destroy
has_one :mattermost_slash_commands_service, dependent: :destroy has_one :mattermost_slash_commands_service, dependent: :destroy
has_one :slack_service, dependent: :destroy has_one :mattermost_notification_service, dependent: :destroy
has_one :slack_notification_service, dependent: :destroy
has_one :buildkite_service, dependent: :destroy has_one :buildkite_service, dependent: :destroy
has_one :bamboo_service, dependent: :destroy has_one :bamboo_service, dependent: :destroy
has_one :teamcity_service, dependent: :destroy has_one :teamcity_service, dependent: :destroy
...@@ -1229,6 +1230,12 @@ class Project < ActiveRecord::Base ...@@ -1229,6 +1230,12 @@ class Project < ActiveRecord::Base
end end
end end
def deployment_variables
return [] unless deployment_service
deployment_service.predefined_variables
end
def append_or_update_attribute(name, value) def append_or_update_attribute(name, value)
old_values = public_send(name.to_s) old_values = public_send(name.to_s)
......
require 'slack-notifier' require 'slack-notifier'
class SlackService module ChatMessage
class BaseMessage class BaseMessage
def initialize(params) def initialize(params)
raise NotImplementedError raise NotImplementedError
......
class SlackService module ChatMessage
class BuildMessage < BaseMessage class BuildMessage < BaseMessage
attr_reader :sha attr_reader :sha
attr_reader :ref_type attr_reader :ref_type
......
class SlackService module ChatMessage
class IssueMessage < BaseMessage class IssueMessage < BaseMessage
attr_reader :user_name attr_reader :user_name
attr_reader :title attr_reader :title
......
class SlackService module ChatMessage
class MergeMessage < BaseMessage class MergeMessage < BaseMessage
attr_reader :user_name attr_reader :user_name
attr_reader :project_name attr_reader :project_name
......
class SlackService module ChatMessage
class NoteMessage < BaseMessage class NoteMessage < BaseMessage
attr_reader :message attr_reader :message
attr_reader :user_name attr_reader :user_name
......
class SlackService module ChatMessage
class PipelineMessage < BaseMessage class PipelineMessage < BaseMessage
attr_reader :ref_type, :ref, :status, :project_name, :project_url, attr_reader :ref_type, :ref, :status, :project_name, :project_url,
:user_name, :duration, :pipeline_id :user_name, :duration, :pipeline_id
......
class SlackService module ChatMessage
class PushMessage < BaseMessage class PushMessage < BaseMessage
attr_reader :after attr_reader :after
attr_reader :before attr_reader :before
......
class SlackService module ChatMessage
class WikiPageMessage < BaseMessage class WikiPageMessage < BaseMessage
attr_reader :user_name attr_reader :user_name
attr_reader :title attr_reader :title
......
class SlackService < Service # Base class for Chat notifications services
# This class is not meant to be used directly, but only to inherit from.
class ChatNotificationService < Service
include ChatMessage
default_value_for :category, 'chat'
prop_accessor :webhook, :username, :channel prop_accessor :webhook, :username, :channel
boolean_accessor :notify_only_broken_builds, :notify_only_broken_pipelines boolean_accessor :notify_only_broken_builds, :notify_only_broken_pipelines
validates :webhook, presence: true, url: true, if: :activated? validates :webhook, presence: true, url: true, if: :activated?
def initialize_properties def initialize_properties
...@@ -14,35 +21,8 @@ class SlackService < Service ...@@ -14,35 +21,8 @@ class SlackService < Service
end end
end end
def title def can_test?
'Slack' valid?
end
def description
'A team communication tool for the 21st century'
end
def to_param
'slack'
end
def help
'This service sends notifications to your Slack channel.<br/>
To setup this Service you need to create a new <b>"Incoming webhook"</b> in your Slack integration panel,
and enter the Webhook URL below.'
end
def fields
default_fields =
[
{ type: 'text', name: 'webhook', placeholder: 'https://hooks.slack.com/services/...' },
{ type: 'text', name: 'username', placeholder: 'username' },
{ type: 'text', name: 'channel', placeholder: "#general" },
{ type: 'checkbox', name: 'notify_only_broken_builds' },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
]
default_fields + build_event_channels
end end
def supported_events def supported_events
...@@ -67,21 +47,16 @@ class SlackService < Service ...@@ -67,21 +47,16 @@ class SlackService < Service
message = get_message(object_kind, data) message = get_message(object_kind, data)
if message return false unless message
opt = {}
event_channel = get_channel_field(object_kind) || channel
opt[:channel] = event_channel if event_channel opt = {}
opt[:username] = username if username
notifier = Slack::Notifier.new(webhook, opt) opt[:channel] = get_channel_field(object_kind).presence || channel || default_channel
notifier.ping(message.pretext, attachments: message.attachments, fallback: message.fallback) opt[:username] = username if username
notifier = Slack::Notifier.new(webhook, opt)
notifier.ping(message.pretext, attachments: message.attachments, fallback: message.fallback)
true true
else
false
end
end end
def event_channel_names def event_channel_names
...@@ -96,6 +71,10 @@ class SlackService < Service ...@@ -96,6 +71,10 @@ class SlackService < Service
fields.reject { |field| field[:name].end_with?('channel') } fields.reject { |field| field[:name].end_with?('channel') }
end end
def default_channel
raise NotImplementedError
end
private private
def get_message(object_kind, data) def get_message(object_kind, data)
...@@ -124,7 +103,7 @@ class SlackService < Service ...@@ -124,7 +103,7 @@ class SlackService < Service
def build_event_channels def build_event_channels
supported_events.reduce([]) do |channels, event| supported_events.reduce([]) do |channels, event|
channels << { type: 'text', name: event_channel_name(event), placeholder: "#general" } channels << { type: 'text', name: event_channel_name(event), placeholder: default_channel }
end end
end end
...@@ -166,11 +145,3 @@ class SlackService < Service ...@@ -166,11 +145,3 @@ class SlackService < Service
end end
end end
end end
require "slack_service/issue_message"
require "slack_service/push_message"
require "slack_service/merge_message"
require "slack_service/note_message"
require "slack_service/build_message"
require "slack_service/pipeline_message"
require "slack_service/wiki_page_message"
# Base class for Chat services # Base class for Chat services
# This class is not meant to be used directly, but only to inherrit from. # This class is not meant to be used directly, but only to inherit from.
class ChatService < Service class ChatService < Service
default_value_for :category, 'chat' default_value_for :category, 'chat'
......
...@@ -8,4 +8,8 @@ class DeploymentService < Service ...@@ -8,4 +8,8 @@ class DeploymentService < Service
def supported_events def supported_events
[] []
end end
def predefined_variables
[]
end
end end
...@@ -85,8 +85,8 @@ class IssueTrackerService < Service ...@@ -85,8 +85,8 @@ class IssueTrackerService < Service
def enabled_in_gitlab_config def enabled_in_gitlab_config
Gitlab.config.issues_tracker && Gitlab.config.issues_tracker &&
Gitlab.config.issues_tracker.values.any? && Gitlab.config.issues_tracker.values.any? &&
issues_tracker issues_tracker
end end
def issues_tracker def issues_tracker
......
...@@ -83,6 +83,16 @@ class KubernetesService < DeploymentService ...@@ -83,6 +83,16 @@ class KubernetesService < DeploymentService
{ success: false, result: err } { success: false, result: err }
end end
def predefined_variables
variables = [
{ key: 'KUBE_URL', value: api_url, public: true },
{ key: 'KUBE_TOKEN', value: token, public: false },
{ key: 'KUBE_NAMESPACE', value: namespace, public: true }
]
variables << { key: 'KUBE_CA_PEM', value: ca_pem, public: true } if ca_pem.present?
variables
end
private private
def build_kubeclient(api_path = '/api', api_version = 'v1') def build_kubeclient(api_path = '/api', api_version = 'v1')
......
class MattermostNotificationService < ChatNotificationService
def title
'Mattermost notifications'
end
def description
'Receive event notifications in Mattermost'
end
def to_param
'mattermost_notification'
end
def help
'This service sends notifications about projects events to Mattermost channels.<br />
To set up this service:
<ol>
<li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#enabling-incoming-webhooks">Enable incoming webhooks</a> in your Mattermost installation. </li>
<li><a href="https://docs.mattermost.com/developer/webhooks-incoming.html#creating-integrations-using-incoming-webhooks">Add an incoming webhook</a> in your Mattermost team. The default channel can be overridden for each event. </li>
<li>Paste the webhook <strong>URL</strong> into the field bellow. </li>
<li>Select events below to enable notifications. The channel and username are optional. </li>
</ol>'
end
def fields
default_fields + build_event_channels
end
def default_fields
[
{ type: 'text', name: 'webhook', placeholder: 'http://mattermost_host/hooks/...' },
{ type: 'text', name: 'username', placeholder: 'username' },
{ type: 'checkbox', name: 'notify_only_broken_builds' },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
]
end
def default_channel
"#town-square"
end
end
class SlackNotificationService < ChatNotificationService
def title
'Slack notifications'
end
def description
'Receive event notifications in Slack'
end
def to_param
'slack_notification'
end
def help
'This service sends notifications about projects events to Slack channels.<br />
To setup this service:
<ol>
<li><a href="https://slack.com/apps/A0F7XDUAZ-incoming-webhooks">Add an incoming webhook</a> in your Slack team. The default channel can be overridden for each event. </li>
<li>Paste the <strong>Webhook URL</strong> into the field below. </li>
<li>Select events below to enable notifications. The channel and username are optional. </li>
</ol>'
end
def fields
default_fields + build_event_channels
end
def default_fields
[
{ type: 'text', name: 'webhook', placeholder: 'https://hooks.slack.com/services/...' },
{ type: 'text', name: 'username', placeholder: 'username' },
{ type: 'checkbox', name: 'notify_only_broken_builds' },
{ type: 'checkbox', name: 'notify_only_broken_pipelines' },
]
end
def default_channel
"#general"
end
end
...@@ -220,7 +220,8 @@ class Service < ActiveRecord::Base ...@@ -220,7 +220,8 @@ class Service < ActiveRecord::Base
pivotaltracker pivotaltracker
pushover pushover
redmine redmine
slack mattermost_notification
slack_notification
teamcity teamcity
] ]
end end
......
...@@ -178,6 +178,8 @@ class User < ActiveRecord::Base ...@@ -178,6 +178,8 @@ class User < ActiveRecord::Base
scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all } scope :not_in_project, ->(project) { project.users.present? ? where("id not in (:ids)", ids: project.users.map(&:id) ) : all }
scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') } scope :without_projects, -> { where('id NOT IN (SELECT DISTINCT(user_id) FROM members WHERE user_id IS NOT NULL AND requested_at IS NULL)') }
scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) } scope :todo_authors, ->(user_id, state) { where(id: Todo.where(user_id: user_id, state: state).select(:author_id)) }
scope :order_recent_sign_in, -> { reorder(last_sign_in_at: :desc) }
scope :order_oldest_sign_in, -> { reorder(last_sign_in_at: :asc) }
def self.with_two_factor def self.with_two_factor
joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id"). joins("LEFT OUTER JOIN u2f_registrations AS u2f ON u2f.user_id = users.id").
...@@ -205,8 +207,8 @@ class User < ActiveRecord::Base ...@@ -205,8 +207,8 @@ class User < ActiveRecord::Base
def sort(method) def sort(method)
case method.to_s case method.to_s
when 'recent_sign_in' then reorder(last_sign_in_at: :desc) when 'recent_sign_in' then order_recent_sign_in
when 'oldest_sign_in' then reorder(last_sign_in_at: :asc) when 'oldest_sign_in' then order_oldest_sign_in
else else
order_by(method) order_by(method)
end end
...@@ -390,7 +392,7 @@ class User < ActiveRecord::Base ...@@ -390,7 +392,7 @@ class User < ActiveRecord::Base
def namespace_uniq def namespace_uniq
# Return early if username already failed the first uniqueness validation # Return early if username already failed the first uniqueness validation
return if errors.key?(:username) && return if errors.key?(:username) &&
errors[:username].include?('has already been taken') errors[:username].include?('has already been taken')
existing_namespace = Namespace.by_path(username) existing_namespace = Namespace.by_path(username)
if existing_namespace && existing_namespace != namespace if existing_namespace && existing_namespace != namespace
......
...@@ -12,7 +12,7 @@ class NotePolicy < BasePolicy ...@@ -12,7 +12,7 @@ class NotePolicy < BasePolicy
end end
if @subject.for_merge_request? && if @subject.for_merge_request? &&
@subject.noteable.author == @user @subject.noteable.author == @user
can! :resolve_note can! :resolve_note
end end
end end
......
...@@ -3,7 +3,7 @@ class ProjectPolicy < BasePolicy ...@@ -3,7 +3,7 @@ class ProjectPolicy < BasePolicy
team_access!(user) team_access!(user)
owner = project.owner == user || owner = project.owner == user ||
(project.group && project.group.has_owner?(user)) (project.group && project.group.has_owner?(user))
owner_access! if user.admin? || owner owner_access! if user.admin? || owner
team_member_owner_access! if owner team_member_owner_access! if owner
...@@ -13,7 +13,7 @@ class ProjectPolicy < BasePolicy ...@@ -13,7 +13,7 @@ class ProjectPolicy < BasePolicy
public_access! public_access!
if project.request_access_enabled && if project.request_access_enabled &&
!(owner || user.admin? || project.team.member?(user) || project_group_member?(user)) !(owner || user.admin? || project.team.member?(user) || project_group_member?(user))
can! :request_access can! :request_access
end end
end end
...@@ -244,10 +244,10 @@ class ProjectPolicy < BasePolicy ...@@ -244,10 +244,10 @@ class ProjectPolicy < BasePolicy
def project_group_member?(user) def project_group_member?(user)
project.group && project.group &&
( (
project.group.members.exists?(user_id: user.id) || project.group.members.exists?(user_id: user.id) ||
project.group.requesters.exists?(user_id: user.id) project.group.requesters.exists?(user_id: user.id)
) )
end end
def named_abilities(name) def named_abilities(name)
......
AccessTokenValidationService = Struct.new(:token) do
# Results:
VALID = :valid
EXPIRED = :expired
REVOKED = :revoked
INSUFFICIENT_SCOPE = :insufficient_scope
def validate(scopes: [])
if token.expired?
return EXPIRED
elsif token.revoked?
return REVOKED
elsif !self.include_any_scope?(scopes)
return INSUFFICIENT_SCOPE
else
return VALID
end
end
# True if the token's scope contains any of the passed scopes.
def include_any_scope?(scopes)
if scopes.blank?
true
else
# Check whether the token is allowed access to any of the required scopes.
Set.new(scopes).intersection(Set.new(token.scopes)).present?
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
......
...@@ -5,7 +5,7 @@ module Groups ...@@ -5,7 +5,7 @@ module Groups
new_visibility = params[:visibility_level] new_visibility = params[:visibility_level]
if new_visibility && new_visibility.to_i != group.visibility_level if new_visibility && new_visibility.to_i != group.visibility_level
unless can?(current_user, :change_visibility_level, group) && unless can?(current_user, :change_visibility_level, group) &&
Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility) Gitlab::VisibilityLevel.allowed_for?(current_user, new_visibility)
deny_visibility_level(group, new_visibility) deny_visibility_level(group, new_visibility)
return group return group
......
...@@ -184,7 +184,8 @@ class IssuableBaseService < BaseService ...@@ -184,7 +184,8 @@ class IssuableBaseService < BaseService
old_labels = issuable.labels.to_a old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.to_a old_mentioned_users = issuable.mentioned_users.to_a
params[:label_ids] = process_label_ids(params, existing_label_ids: issuable.label_ids) label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
if params.present? && update_issuable(issuable, params) if params.present? && update_issuable(issuable, params)
# We do not touch as it will affect a update on updated_at field # We do not touch as it will affect a update on updated_at field
...@@ -201,6 +202,10 @@ class IssuableBaseService < BaseService ...@@ -201,6 +202,10 @@ class IssuableBaseService < BaseService
issuable issuable
end end
def labels_changing?(old_label_ids, new_label_ids)
old_label_ids.sort != new_label_ids.sort
end
def change_state(issuable) def change_state(issuable)
case params.delete(:state_event) case params.delete(:state_event)
when 'reopen' when 'reopen'
......
...@@ -10,7 +10,7 @@ module Issues ...@@ -10,7 +10,7 @@ module Issues
end end
if issue.previous_changes.include?('title') || if issue.previous_changes.include?('title') ||
issue.previous_changes.include?('description') issue.previous_changes.include?('description')
todo_service.update_issue(issue, current_user) todo_service.update_issue(issue, current_user)
end end
......
...@@ -42,7 +42,7 @@ module MergeRequests ...@@ -42,7 +42,7 @@ module MergeRequests
end end
if merge_request.source_project == merge_request.target_project && if merge_request.source_project == merge_request.target_project &&
merge_request.target_branch == merge_request.source_branch merge_request.target_branch == merge_request.source_branch
messages << 'You must select different branches' messages << 'You must select different branches'
end end
......
...@@ -25,7 +25,7 @@ module MergeRequests ...@@ -25,7 +25,7 @@ module MergeRequests
end end
if merge_request.previous_changes.include?('title') || if merge_request.previous_changes.include?('title') ||
merge_request.previous_changes.include?('description') merge_request.previous_changes.include?('description')
todo_service.update_merge_request(merge_request, current_user) todo_service.update_merge_request(merge_request, current_user)
end end
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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