Commit 51cd05e9 authored by Valery Sizov's avatar Valery Sizov

Merge branch 'ce-to-ee-2017-12-16' into 'master'

CE upstream - Saturday

Closes gitlab-ce#38019, gitlab-qa#86, #4125, and gitaly#808

See merge request gitlab-org/gitlab-ee!3803
parents 577f1260 b679480c
image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.5-golang-1.8-git-2.13-chrome-62.0-node-8.x-yarn-1.2-postgresql-9.6" image: "dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.3.5-golang-1.8-git-2.14-chrome-63.0-node-8.x-yarn-1.2-postgresql-9.6"
.dedicated-runner: &dedicated-runner .dedicated-runner: &dedicated-runner
retry: 1 retry: 1
......
...@@ -416,7 +416,7 @@ group :ed25519 do ...@@ -416,7 +416,7 @@ group :ed25519 do
end end
# Gitaly GRPC client # Gitaly GRPC client
gem 'gitaly-proto', '~> 0.59.0', require: 'gitaly' gem 'gitaly-proto', '~> 0.61.0', require: 'gitaly'
gem 'toml-rb', '~> 0.3.15', require: false gem 'toml-rb', '~> 0.3.15', require: false
......
...@@ -308,7 +308,7 @@ GEM ...@@ -308,7 +308,7 @@ GEM
po_to_json (>= 1.0.0) po_to_json (>= 1.0.0)
rails (>= 3.2.0) rails (>= 3.2.0)
gherkin-ruby (0.3.2) gherkin-ruby (0.3.2)
gitaly-proto (0.59.0) gitaly-proto (0.61.0)
google-protobuf (~> 3.1) google-protobuf (~> 3.1)
grpc (~> 1.0) grpc (~> 1.0)
github-linguist (4.7.6) github-linguist (4.7.6)
...@@ -1077,7 +1077,7 @@ DEPENDENCIES ...@@ -1077,7 +1077,7 @@ DEPENDENCIES
gettext (~> 3.2.2) gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0) gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.2.0) gettext_i18n_rails_js (~> 1.2.0)
gitaly-proto (~> 0.59.0) gitaly-proto (~> 0.61.0)
github-linguist (~> 4.7.0) github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1) gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-license (~> 1.0) gitlab-license (~> 1.0)
......
/* eslint-disable no-param-reassign, class-methods-use-this */ /* eslint-disable no-param-reassign, class-methods-use-this */
/* global Pager */
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
import Pager from './pager';
import { localTimeAgo } from './lib/utils/datetime_utility'; import { localTimeAgo } from './lib/utils/datetime_utility';
export default class Activities { export default class Activities {
......
/* eslint-disable func-names, wrap-iife, consistent-return, /* eslint-disable func-names, wrap-iife, consistent-return,
no-return-assign, no-param-reassign, one-var-declaration-per-line, no-unused-vars, no-return-assign, no-param-reassign, one-var-declaration-per-line, no-unused-vars,
prefer-template, object-shorthand, prefer-arrow-callback */ prefer-template, object-shorthand, prefer-arrow-callback */
/* global Pager */
import { pluralize } from './lib/utils/text_utility'; import { pluralize } from './lib/utils/text_utility';
import { localTimeAgo } from './lib/utils/datetime_utility'; import { localTimeAgo } from './lib/utils/datetime_utility';
import Pager from './pager';
export default (function () { export default (function () {
const CommitsList = {}; const CommitsList = {};
......
...@@ -7,8 +7,8 @@ import IssuableForm from './issuable_form'; ...@@ -7,8 +7,8 @@ import IssuableForm from './issuable_form';
import LabelsSelect from './labels_select'; import LabelsSelect from './labels_select';
/* global MilestoneSelect */ /* global MilestoneSelect */
import NewBranchForm from './new_branch_form'; import NewBranchForm from './new_branch_form';
/* global NotificationsForm */ import NotificationsForm from './notifications_form';
/* global NotificationsDropdown */ import notificationsDropdown from './notifications_dropdown';
import groupAvatar from './group_avatar'; import groupAvatar from './group_avatar';
import GroupLabelSubscription from './group_label_subscription'; import GroupLabelSubscription from './group_label_subscription';
import LineHighlighter from './line_highlighter'; import LineHighlighter from './line_highlighter';
...@@ -455,7 +455,7 @@ import initGroupAnalytics from './init_group_analytics'; ...@@ -455,7 +455,7 @@ import initGroupAnalytics from './init_group_analytics';
const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup'); const newGroupChildWrapper = document.querySelector('.js-new-project-subgroup');
shortcut_handler = new ShortcutsNavigation(); shortcut_handler = new ShortcutsNavigation();
new NotificationsForm(); new NotificationsForm();
new NotificationsDropdown(); notificationsDropdown();
new ProjectsList(); new ProjectsList();
if (newGroupChildWrapper) { if (newGroupChildWrapper) {
...@@ -712,7 +712,7 @@ import initGroupAnalytics from './init_group_analytics'; ...@@ -712,7 +712,7 @@ import initGroupAnalytics from './init_group_analytics';
break; break;
case 'profiles': case 'profiles':
new NotificationsForm(); new NotificationsForm();
new NotificationsDropdown(); notificationsDropdown();
break; break;
case 'projects': case 'projects':
new Project(); new Project();
...@@ -736,7 +736,7 @@ import initGroupAnalytics from './init_group_analytics'; ...@@ -736,7 +736,7 @@ import initGroupAnalytics from './init_group_analytics';
case 'show': case 'show':
new Star(); new Star();
new ProjectNew(); new ProjectNew();
new NotificationsDropdown(); notificationsDropdown();
break; break;
case 'wikis': case 'wikis':
new Wikis(); new Wikis();
......
...@@ -34,16 +34,11 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility'; ...@@ -34,16 +34,11 @@ import { getLocationHash, visitUrl } from './lib/utils/url_utility';
import './behaviors/'; import './behaviors/';
// everything else // everything else
import './activities';
import './admin';
import loadAwardsHandler from './awards_handler'; import loadAwardsHandler from './awards_handler';
import bp from './breakpoints'; import bp from './breakpoints';
import './confirm_danger_modal'; import './confirm_danger_modal';
import Flash, { removeFlashClickListener } from './flash'; import Flash, { removeFlashClickListener } from './flash';
import './gl_dropdown'; import './gl_dropdown';
import './gl_field_error';
import './gl_field_errors';
import './gl_form';
import initTodoToggle from './header'; import initTodoToggle from './header';
import initImporterStatus from './importer_status'; import initImporterStatus from './importer_status';
import './layout_nav'; import './layout_nav';
...@@ -51,11 +46,7 @@ import LazyLoader from './lazy_loader'; ...@@ -51,11 +46,7 @@ import LazyLoader from './lazy_loader';
import './line_highlighter'; import './line_highlighter';
import initLogoAnimation from './logo'; import initLogoAnimation from './logo';
import './milestone_select'; import './milestone_select';
import './notifications_dropdown';
import './notifications_form';
import './pager';
import './preview_markdown'; import './preview_markdown';
import './project_import';
import './projects_dropdown'; import './projects_dropdown';
import './render_gfm'; import './render_gfm';
import initBreadcrumbs from './breadcrumb'; import initBreadcrumbs from './breadcrumb';
......
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
import 'vendor/jquery.waitforimages'; import 'vendor/jquery.waitforimages';
import TaskList from './task_list'; import TaskList from './task_list';
import './merge_request_tabs'; import MergeRequestTabs from './merge_request_tabs';
import IssuablesHelper from './helpers/issuables_helper'; import IssuablesHelper from './helpers/issuables_helper';
import { addDelimiter } from './lib/utils/text_utility'; import { addDelimiter } from './lib/utils/text_utility';
...@@ -49,7 +49,7 @@ MergeRequest.prototype.initTabs = function() { ...@@ -49,7 +49,7 @@ MergeRequest.prototype.initTabs = function() {
if (window.mrTabs) { if (window.mrTabs) {
window.mrTabs.unbindEvents(); window.mrTabs.unbindEvents();
} }
window.mrTabs = new gl.MergeRequestTabs(this.opts); window.mrTabs = new MergeRequestTabs(this.opts);
}; };
MergeRequest.prototype.showAllCommits = function() { MergeRequest.prototype.showAllCommits = function() {
......
...@@ -63,387 +63,382 @@ import Notes from './notes'; ...@@ -63,387 +63,382 @@ import Notes from './notes';
// //
/* eslint-enable max-len */ /* eslint-enable max-len */
(() => { // Store the `location` object, allowing for easier stubbing in tests
// Store the `location` object, allowing for easier stubbing in tests let location = window.location;
let location = window.location;
class MergeRequestTabs {
constructor({ action, setUrl, stubLocation } = {}) {
const mergeRequestTabs = document.querySelector('.js-tabs-affix');
const navbar = document.querySelector('.navbar-gitlab');
const paddingTop = 16;
this.diffsLoaded = false;
this.pipelinesLoaded = false;
this.commitsLoaded = false;
this.fixedLayoutPref = null;
this.setUrl = setUrl !== undefined ? setUrl : true;
this.setCurrentAction = this.setCurrentAction.bind(this);
this.tabShown = this.tabShown.bind(this);
this.showTab = this.showTab.bind(this);
this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0;
if (mergeRequestTabs) {
this.stickyTop += mergeRequestTabs.offsetHeight;
}
if (stubLocation) { export default class MergeRequestTabs {
location = stubLocation;
}
this.bindEvents(); constructor({ action, setUrl, stubLocation } = {}) {
this.activateTab(action); const mergeRequestTabs = document.querySelector('.js-tabs-affix');
this.initAffix(); const navbar = document.querySelector('.navbar-gitlab');
} const paddingTop = 16;
bindEvents() { this.diffsLoaded = false;
$(document) this.pipelinesLoaded = false;
.on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown) this.commitsLoaded = false;
.on('click', '.js-show-tab', this.showTab); this.fixedLayoutPref = null;
$('.merge-request-tabs a[data-toggle="tab"]') this.setUrl = setUrl !== undefined ? setUrl : true;
.on('click', this.clickTab); this.setCurrentAction = this.setCurrentAction.bind(this);
} this.tabShown = this.tabShown.bind(this);
this.showTab = this.showTab.bind(this);
this.stickyTop = navbar ? navbar.offsetHeight - paddingTop : 0;
// Used in tests if (mergeRequestTabs) {
unbindEvents() { this.stickyTop += mergeRequestTabs.offsetHeight;
$(document) }
.off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
.off('click', '.js-show-tab', this.showTab);
$('.merge-request-tabs a[data-toggle="tab"]') if (stubLocation) {
.off('click', this.clickTab); location = stubLocation;
} }
destroyPipelinesView() { this.bindEvents();
if (this.commitPipelinesTable) { this.activateTab(action);
this.commitPipelinesTable.$destroy(); this.initAffix();
this.commitPipelinesTable = null; }
document.querySelector('#commit-pipeline-table-view').innerHTML = ''; bindEvents() {
} $(document)
} .on('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
.on('click', '.js-show-tab', this.showTab);
showTab(e) { $('.merge-request-tabs a[data-toggle="tab"]')
e.preventDefault(); .on('click', this.clickTab);
this.activateTab($(e.target).data('action')); }
}
clickTab(e) { // Used in tests
if (e.currentTarget && isMetaClick(e)) { unbindEvents() {
const targetLink = e.currentTarget.getAttribute('href'); $(document)
e.stopImmediatePropagation(); .off('shown.bs.tab', '.merge-request-tabs a[data-toggle="tab"]', this.tabShown)
e.preventDefault(); .off('click', '.js-show-tab', this.showTab);
window.open(targetLink, '_blank');
} $('.merge-request-tabs a[data-toggle="tab"]')
.off('click', this.clickTab);
}
destroyPipelinesView() {
if (this.commitPipelinesTable) {
this.commitPipelinesTable.$destroy();
this.commitPipelinesTable = null;
document.querySelector('#commit-pipeline-table-view').innerHTML = '';
} }
}
tabShown(e) { showTab(e) {
const $target = $(e.target); e.preventDefault();
const action = $target.data('action'); this.activateTab($(e.target).data('action'));
}
if (action === 'commits') { clickTab(e) {
this.loadCommits($target.attr('href')); if (e.currentTarget && isMetaClick(e)) {
this.expandView(); const targetLink = e.currentTarget.getAttribute('href');
this.resetViewContainer(); e.stopImmediatePropagation();
this.destroyPipelinesView(); e.preventDefault();
} else if (this.isDiffAction(action)) { window.open(targetLink, '_blank');
this.loadDiff($target.attr('href')); }
if (bp.getBreakpointSize() !== 'lg') { }
this.shrinkView();
}
if (this.diffViewType() === 'parallel') {
this.expandViewContainer();
}
this.destroyPipelinesView();
} else if (action === 'pipelines') {
this.resetViewContainer();
this.mountPipelinesView();
} else {
if (bp.getBreakpointSize() !== 'xs') {
this.expandView();
}
this.resetViewContainer();
this.destroyPipelinesView();
initDiscussionTab(); tabShown(e) {
const $target = $(e.target);
const action = $target.data('action');
if (action === 'commits') {
this.loadCommits($target.attr('href'));
this.expandView();
this.resetViewContainer();
this.destroyPipelinesView();
} else if (this.isDiffAction(action)) {
this.loadDiff($target.attr('href'));
if (bp.getBreakpointSize() !== 'lg') {
this.shrinkView();
} }
if (this.setUrl) { if (this.diffViewType() === 'parallel') {
this.setCurrentAction(action); this.expandViewContainer();
} }
this.destroyPipelinesView();
} else if (action === 'pipelines') {
this.resetViewContainer();
this.mountPipelinesView();
} else {
if (bp.getBreakpointSize() !== 'xs') {
this.expandView();
}
this.resetViewContainer();
this.destroyPipelinesView();
initDiscussionTab();
}
if (this.setUrl) {
this.setCurrentAction(action);
} }
}
scrollToElement(container) { scrollToElement(container) {
if (location.hash) { if (location.hash) {
const offset = 0 - ( const offset = 0 - (
$('.navbar-gitlab').outerHeight() + $('.navbar-gitlab').outerHeight() +
$('.js-tabs-affix').outerHeight() $('.js-tabs-affix').outerHeight()
); );
const $el = $(`${container} ${location.hash}:not(.match)`); const $el = $(`${container} ${location.hash}:not(.match)`);
if ($el.length) { if ($el.length) {
$.scrollTo($el[0], { offset }); $.scrollTo($el[0], { offset });
}
} }
} }
}
// Activate a tab based on the current action
activateTab(action) {
// important note: the .tab('show') method triggers 'shown.bs.tab' event itself
$(`.merge-request-tabs a[data-action='${action}']`).tab('show');
}
// Activate a tab based on the current action // Replaces the current Merge Request-specific action in the URL with a new one
activateTab(action) { //
// important note: the .tab('show') method triggers 'shown.bs.tab' event itself // If the action is "notes", the URL is reset to the standard
$(`.merge-request-tabs a[data-action='${action}']`).tab('show'); // `MergeRequests#show` route.
//
// Examples:
//
// location.pathname # => "/namespace/project/merge_requests/1"
// setCurrentAction('diffs')
// location.pathname # => "/namespace/project/merge_requests/1/diffs"
//
// location.pathname # => "/namespace/project/merge_requests/1/diffs"
// setCurrentAction('show')
// location.pathname # => "/namespace/project/merge_requests/1"
//
// location.pathname # => "/namespace/project/merge_requests/1/diffs"
// setCurrentAction('commits')
// location.pathname # => "/namespace/project/merge_requests/1/commits"
//
// Returns the new URL String
setCurrentAction(action) {
this.currentAction = action;
// Remove a trailing '/commits' '/diffs' '/pipelines'
let newState = location.pathname.replace(/\/(commits|diffs|pipelines)(\.html)?\/?$/, '');
// Append the new action if we're on a tab other than 'notes'
if (this.currentAction !== 'show' && this.currentAction !== 'new') {
newState += `/${this.currentAction}`;
} }
// Replaces the current Merge Request-specific action in the URL with a new one // Ensure parameters and hash come along for the ride
// newState += location.search + location.hash;
// If the action is "notes", the URL is reset to the standard
// `MergeRequests#show` route.
//
// Examples:
//
// location.pathname # => "/namespace/project/merge_requests/1"
// setCurrentAction('diffs')
// location.pathname # => "/namespace/project/merge_requests/1/diffs"
//
// location.pathname # => "/namespace/project/merge_requests/1/diffs"
// setCurrentAction('show')
// location.pathname # => "/namespace/project/merge_requests/1"
//
// location.pathname # => "/namespace/project/merge_requests/1/diffs"
// setCurrentAction('commits')
// location.pathname # => "/namespace/project/merge_requests/1/commits"
//
// Returns the new URL String
setCurrentAction(action) {
this.currentAction = action;
// Remove a trailing '/commits' '/diffs' '/pipelines' // TODO: Consider refactoring in light of turbolinks removal.
let newState = location.pathname.replace(/\/(commits|diffs|pipelines)(\.html)?\/?$/, '');
// Append the new action if we're on a tab other than 'notes' // Replace the current history state with the new one without breaking
if (this.currentAction !== 'show' && this.currentAction !== 'new') { // Turbolinks' history.
newState += `/${this.currentAction}`; //
} // See https://github.com/rails/turbolinks/issues/363
window.history.replaceState({
url: newState,
}, document.title, newState);
// Ensure parameters and hash come along for the ride return newState;
newState += location.search + location.hash; }
// TODO: Consider refactoring in light of turbolinks removal. loadCommits(source) {
if (this.commitsLoaded) {
return;
}
this.ajaxGet({
url: `${source}.json`,
success: (data) => {
document.querySelector('div#commits').innerHTML = data.html;
localTimeAgo($('.js-timeago', 'div#commits'));
this.commitsLoaded = true;
this.scrollToElement('#commits');
},
});
}
// Replace the current history state with the new one without breaking mountPipelinesView() {
// Turbolinks' history. const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view');
// const CommitPipelinesTable = gl.CommitPipelinesTable;
// See https://github.com/rails/turbolinks/issues/363 this.commitPipelinesTable = new CommitPipelinesTable({
window.history.replaceState({ propsData: {
url: newState, endpoint: pipelineTableViewEl.dataset.endpoint,
}, document.title, newState); helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
},
}).$mount();
// $mount(el) replaces the el with the new rendered component. We need it in order to mount
// it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
}
return newState; loadDiff(source) {
if (this.diffsLoaded) {
document.dispatchEvent(new CustomEvent('scroll'));
return;
} }
loadCommits(source) { // We extract pathname for the current Changes tab anchor href
if (this.commitsLoaded) { // some pages like MergeRequestsController#new has query parameters on that anchor
return; const urlPathname = parseUrlPathname(source);
}
this.ajaxGet({
url: `${source}.json`,
success: (data) => {
document.querySelector('div#commits').innerHTML = data.html;
localTimeAgo($('.js-timeago', 'div#commits'));
this.commitsLoaded = true;
this.scrollToElement('#commits');
},
});
}
mountPipelinesView() { this.ajaxGet({
const pipelineTableViewEl = document.querySelector('#commit-pipeline-table-view'); url: `${urlPathname}.json${location.search}`,
const CommitPipelinesTable = gl.CommitPipelinesTable; success: (data) => {
this.commitPipelinesTable = new CommitPipelinesTable({ const $container = $('#diffs');
propsData: { $container.html(data.html);
endpoint: pipelineTableViewEl.dataset.endpoint,
helpPagePath: pipelineTableViewEl.dataset.helpPagePath,
emptyStateSvgPath: pipelineTableViewEl.dataset.emptyStateSvgPath,
errorStateSvgPath: pipelineTableViewEl.dataset.errorStateSvgPath,
autoDevopsHelpPath: pipelineTableViewEl.dataset.helpAutoDevopsPath,
},
}).$mount();
// $mount(el) replaces the el with the new rendered component. We need it in order to mount initChangesDropdown(this.stickyTop);
// it everytime this tab is clicked - https://vuejs.org/v2/api/#vm-mount
pipelineTableViewEl.appendChild(this.commitPipelinesTable.$el);
}
loadDiff(source) { if (typeof gl.diffNotesCompileComponents !== 'undefined') {
if (this.diffsLoaded) { gl.diffNotesCompileComponents();
document.dispatchEvent(new CustomEvent('scroll')); }
return;
} localTimeAgo($('.js-timeago', 'div#diffs'));
syntaxHighlight($('#diffs .js-syntax-highlight'));
// We extract pathname for the current Changes tab anchor href if (this.diffViewType() === 'parallel' && this.isDiffAction(this.currentAction)) {
// some pages like MergeRequestsController#new has query parameters on that anchor this.expandViewContainer();
const urlPathname = parseUrlPathname(source); }
this.diffsLoaded = true;
this.ajaxGet({
url: `${urlPathname}.json${location.search}`, new Diff();
success: (data) => { this.scrollToElement('#diffs');
const $container = $('#diffs');
$container.html(data.html); $('.diff-file').each((i, el) => {
new BlobForkSuggestion({
initChangesDropdown(this.stickyTop); openButtons: $(el).find('.js-edit-blob-link-fork-toggler'),
forkButtons: $(el).find('.js-fork-suggestion-button'),
if (typeof gl.diffNotesCompileComponents !== 'undefined') { cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'),
gl.diffNotesCompileComponents(); suggestionSections: $(el).find('.js-file-fork-suggestion-section'),
} actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'),
})
localTimeAgo($('.js-timeago', 'div#diffs')); .init();
syntaxHighlight($('#diffs .js-syntax-highlight')); });
if (this.diffViewType() === 'parallel' && this.isDiffAction(this.currentAction)) { // Scroll any linked note into view
this.expandViewContainer(); // Similar to `toggler_behavior` in the discussion tab
} const hash = getLocationHash();
this.diffsLoaded = true; const anchor = hash && $container.find(`.note[id="${hash}"]`);
if (anchor && anchor.length > 0) {
new Diff(); const notesContent = anchor.closest('.notes_content');
this.scrollToElement('#diffs'); const lineType = notesContent.hasClass('new') ? 'new' : 'old';
Notes.instance.toggleDiffNote({
$('.diff-file').each((i, el) => { target: anchor,
new BlobForkSuggestion({ lineType,
openButtons: $(el).find('.js-edit-blob-link-fork-toggler'), forceShow: true,
forkButtons: $(el).find('.js-fork-suggestion-button'),
cancelButtons: $(el).find('.js-cancel-fork-suggestion-button'),
suggestionSections: $(el).find('.js-file-fork-suggestion-section'),
actionTextPieces: $(el).find('.js-file-fork-suggestion-section-action'),
})
.init();
}); });
anchor[0].scrollIntoView();
handleLocationHash();
// We have multiple elements on the page with `#note_xxx`
// (discussion and diff tabs) and `:target` only applies to the first
anchor.addClass('target');
}
},
});
}
// Scroll any linked note into view // Show or hide the loading spinner
// Similar to `toggler_behavior` in the discussion tab //
const hash = getLocationHash(); // status - Boolean, true to show, false to hide
const anchor = hash && $container.find(`.note[id="${hash}"]`); toggleLoading(status) {
if (anchor && anchor.length > 0) { $('.mr-loading-status .loading').toggle(status);
const notesContent = anchor.closest('.notes_content'); }
const lineType = notesContent.hasClass('new') ? 'new' : 'old';
Notes.instance.toggleDiffNote({
target: anchor,
lineType,
forceShow: true,
});
anchor[0].scrollIntoView();
handleLocationHash();
// We have multiple elements on the page with `#note_xxx`
// (discussion and diff tabs) and `:target` only applies to the first
anchor.addClass('target');
}
},
});
}
// Show or hide the loading spinner ajaxGet(options) {
// const defaults = {
// status - Boolean, true to show, false to hide beforeSend: () => this.toggleLoading(true),
toggleLoading(status) { error: () => new Flash('An error occurred while fetching this tab.', 'alert'),
$('.mr-loading-status .loading').toggle(status); complete: () => this.toggleLoading(false),
} dataType: 'json',
type: 'GET',
};
$.ajax($.extend({}, defaults, options));
}
ajaxGet(options) { diffViewType() {
const defaults = { return $('.inline-parallel-buttons a.active').data('view-type');
beforeSend: () => this.toggleLoading(true), }
error: () => new Flash('An error occurred while fetching this tab.', 'alert'),
complete: () => this.toggleLoading(false),
dataType: 'json',
type: 'GET',
};
$.ajax($.extend({}, defaults, options));
}
diffViewType() { isDiffAction(action) {
return $('.inline-parallel-buttons a.active').data('view-type'); return action === 'diffs' || action === 'new/diffs';
} }
isDiffAction(action) { expandViewContainer() {
return action === 'diffs' || action === 'new/diffs'; const $wrapper = $('.content-wrapper .container-fluid').not('.breadcrumbs');
if (this.fixedLayoutPref === null) {
this.fixedLayoutPref = $wrapper.hasClass('container-limited');
} }
$wrapper.removeClass('container-limited');
}
expandViewContainer() { resetViewContainer() {
const $wrapper = $('.content-wrapper .container-fluid').not('.breadcrumbs'); if (this.fixedLayoutPref !== null) {
if (this.fixedLayoutPref === null) { $('.content-wrapper .container-fluid')
this.fixedLayoutPref = $wrapper.hasClass('container-limited'); .toggleClass('container-limited', this.fixedLayoutPref);
}
$wrapper.removeClass('container-limited');
} }
}
resetViewContainer() { shrinkView() {
if (this.fixedLayoutPref !== null) { const $gutterIcon = $('.js-sidebar-toggle i:visible');
$('.content-wrapper .container-fluid')
.toggleClass('container-limited', this.fixedLayoutPref);
}
}
shrinkView() { // Wait until listeners are set
const $gutterIcon = $('.js-sidebar-toggle i:visible'); setTimeout(() => {
// Only when sidebar is expanded
if ($gutterIcon.is('.fa-angle-double-right')) {
$gutterIcon.closest('a').trigger('click', [true]);
}
}, 0);
}
// Wait until listeners are set // Expand the issuable sidebar unless the user explicitly collapsed it
setTimeout(() => { expandView() {
// Only when sidebar is expanded if (Cookies.get('collapsed_gutter') === 'true') {
if ($gutterIcon.is('.fa-angle-double-right')) { return;
$gutterIcon.closest('a').trigger('click', [true]);
}
}, 0);
} }
const $gutterIcon = $('.js-sidebar-toggle i:visible');
// Expand the issuable sidebar unless the user explicitly collapsed it // Wait until listeners are set
expandView() { setTimeout(() => {
if (Cookies.get('collapsed_gutter') === 'true') { // Only when sidebar is collapsed
return; if ($gutterIcon.is('.fa-angle-double-left')) {
$gutterIcon.closest('a').trigger('click', [true]);
} }
const $gutterIcon = $('.js-sidebar-toggle i:visible'); }, 0);
}
// Wait until listeners are set initAffix() {
setTimeout(() => { const $tabs = $('.js-tabs-affix');
// Only when sidebar is collapsed const $fixedNav = $('.navbar-gitlab');
if ($gutterIcon.is('.fa-angle-double-left')) {
$gutterIcon.closest('a').trigger('click', [true]); // Screen space on small screens is usually very sparse
} // So we dont affix the tabs on these
}, 0); if (bp.getBreakpointSize() === 'xs' || !$tabs.length) return;
}
/**
If the browser does not support position sticky, it returns the position as static.
If the browser does support sticky, then we allow the browser to handle it, if not
then we default back to Bootstraps affix
**/
if ($tabs.css('position') !== 'static') return;
const $diffTabs = $('#diff-notes-app');
$tabs.off('affix.bs.affix affix-top.bs.affix')
.affix({
offset: {
top: () => (
$diffTabs.offset().top - $tabs.height() - $fixedNav.height()
),
},
})
.on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() }))
.on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' }));
initAffix() { // Fix bug when reloading the page already scrolling
const $tabs = $('.js-tabs-affix'); if ($tabs.hasClass('affix')) {
const $fixedNav = $('.navbar-gitlab'); $tabs.trigger('affix.bs.affix');
// Screen space on small screens is usually very sparse
// So we dont affix the tabs on these
if (bp.getBreakpointSize() === 'xs' || !$tabs.length) return;
/**
If the browser does not support position sticky, it returns the position as static.
If the browser does support sticky, then we allow the browser to handle it, if not
then we default back to Bootstraps affix
**/
if ($tabs.css('position') !== 'static') return;
const $diffTabs = $('#diff-notes-app');
$tabs.off('affix.bs.affix affix-top.bs.affix')
.affix({
offset: {
top: () => (
$diffTabs.offset().top - $tabs.height() - $fixedNav.height()
),
},
})
.on('affix.bs.affix', () => $diffTabs.css({ marginTop: $tabs.height() }))
.on('affix-top.bs.affix', () => $diffTabs.css({ marginTop: '' }));
// Fix bug when reloading the page already scrolling
if ($tabs.hasClass('affix')) {
$tabs.trigger('affix.bs.affix');
}
} }
} }
}
window.gl = window.gl || {};
window.gl.MergeRequestTabs = MergeRequestTabs;
})();
/* eslint-disable func-names, space-before-function-paren, wrap-iife, one-var, no-var, one-var-declaration-per-line, no-unused-vars, consistent-return, prefer-arrow-callback, no-else-return, max-len */
import Flash from './flash'; import Flash from './flash';
(function() { export default function notificationsDropdown() {
this.NotificationsDropdown = (function() { $(document).on('click', '.update-notification', function updateNotificationCallback(e) {
function NotificationsDropdown() { e.preventDefault();
$(document).off('click', '.update-notification').on('click', '.update-notification', function(e) { if ($(this).is('.is-active') && $(this).data('notification-level') === 'custom') {
var form, label, notificationLevel; return;
e.preventDefault();
if ($(this).is('.is-active') && $(this).data('notification-level') === 'custom') {
return;
}
notificationLevel = $(this).data('notification-level');
label = $(this).data('notification-title');
form = $(this).parents('.notification-form:first');
form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner');
form.find('#notification_setting_level').val(notificationLevel);
return form.submit();
});
$(document).off('ajax:success', '.notification-form').on('ajax:success', '.notification-form', function(e, data) {
if (data.saved) {
return $(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html);
} else {
return new Flash('Failed to save new settings', 'alert');
}
});
} }
return NotificationsDropdown; const notificationLevel = $(this).data('notification-level');
})(); const form = $(this).parents('.notification-form:first');
}).call(window);
form.find('.js-notification-loading').toggleClass('fa-bell fa-spin fa-spinner');
form.find('#notification_setting_level').val(notificationLevel);
form.submit();
});
$(document).on('ajax:success', '.notification-form', (e, data) => {
if (data.saved) {
$(e.currentTarget).closest('.js-notification-dropdown').replaceWith(data.html);
} else {
Flash('Failed to save new settings', 'alert');
}
});
}
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, one-var, one-var-declaration-per-line, newline-per-chained-call, comma-dangle, consistent-return, prefer-arrow-callback, max-len */ export default class NotificationsForm {
(function() { constructor() {
this.NotificationsForm = (function() { this.toggleCheckbox = this.toggleCheckbox.bind(this);
function NotificationsForm() { this.initEventListeners();
this.toggleCheckbox = this.toggleCheckbox.bind(this); }
this.removeEventListeners();
this.initEventListeners();
}
NotificationsForm.prototype.removeEventListeners = function() { initEventListeners() {
return $(document).off('change', '.js-custom-notification-event'); $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox);
}; }
NotificationsForm.prototype.initEventListeners = function() { toggleCheckbox(e) {
return $(document).on('change', '.js-custom-notification-event', this.toggleCheckbox); const $checkbox = $(e.currentTarget);
}; const $parent = $checkbox.closest('.checkbox');
NotificationsForm.prototype.toggleCheckbox = function(e) { this.saveEvent($checkbox, $parent);
var $checkbox, $parent; }
$checkbox = $(e.currentTarget);
$parent = $checkbox.closest('.checkbox');
return this.saveEvent($checkbox, $parent);
};
NotificationsForm.prototype.showCheckboxLoadingSpinner = function($parent) { // eslint-disable-next-line class-methods-use-this
return $parent.addClass('is-loading').find('.custom-notification-event-loading').removeClass('fa-check').addClass('fa-spin fa-spinner').removeClass('is-done'); showCheckboxLoadingSpinner($parent) {
}; $parent.addClass('is-loading')
.find('.custom-notification-event-loading')
.removeClass('fa-check')
.addClass('fa-spin fa-spinner')
.removeClass('is-done');
}
NotificationsForm.prototype.saveEvent = function($checkbox, $parent) { saveEvent($checkbox, $parent) {
var form; const form = $parent.parents('form:first');
form = $parent.parents('form:first');
return $.ajax({
url: form.attr('action'),
method: form.attr('method'),
dataType: 'json',
data: form.serialize(),
beforeSend: (function(_this) {
return function() {
return _this.showCheckboxLoadingSpinner($parent);
};
})(this)
}).done(function(data) {
$checkbox.enable();
if (data.saved) {
$parent.find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done');
return setTimeout(function() {
return $parent.removeClass('is-loading').find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done');
}, 2000);
}
});
};
return NotificationsForm; return $.ajax({
})(); url: form.attr('action'),
}).call(window); method: form.attr('method'),
dataType: 'json',
data: form.serialize(),
beforeSend: () => {
this.showCheckboxLoadingSpinner($parent);
},
}).done((data) => {
$checkbox.enable();
if (data.saved) {
$parent.find('.custom-notification-event-loading').toggleClass('fa-spin fa-spinner fa-check is-done');
setTimeout(() => {
$parent.removeClass('is-loading')
.find('.custom-notification-event-loading')
.toggleClass('fa-spin fa-spinner fa-check is-done');
}, 2000);
}
});
}
}
import { getParameterByName } from '~/lib/utils/common_utils'; import { getParameterByName } from '~/lib/utils/common_utils';
import { removeParams } from './lib/utils/url_utility'; import { removeParams } from './lib/utils/url_utility';
(() => { const ENDLESS_SCROLL_BOTTOM_PX = 400;
const ENDLESS_SCROLL_BOTTOM_PX = 400; const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
const ENDLESS_SCROLL_FIRE_DELAY_MS = 1000;
const Pager = { export default {
init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) { init(limit = 0, preload = false, disable = false, prepareData = $.noop, callback = $.noop) {
this.url = $('.content_list').data('href') || removeParams(['limit', 'offset']); this.url = $('.content_list').data('href') || removeParams(['limit', 'offset']);
this.limit = limit; this.limit = limit;
this.offset = parseInt(getParameterByName('offset'), 10) || this.limit; this.offset = parseInt(getParameterByName('offset'), 10) || this.limit;
this.disable = disable; this.disable = disable;
this.prepareData = prepareData; this.prepareData = prepareData;
this.callback = callback; this.callback = callback;
this.loading = $('.loading').first(); this.loading = $('.loading').first();
if (preload) { if (preload) {
this.offset = 0; this.offset = 0;
this.getOld(); this.getOld();
} }
this.initLoadMore(); this.initLoadMore();
}, },
getOld() { getOld() {
this.loading.show(); this.loading.show();
$.ajax({ $.ajax({
type: 'GET', type: 'GET',
url: this.url, url: this.url,
data: `limit=${this.limit}&offset=${this.offset}`, data: `limit=${this.limit}&offset=${this.offset}`,
dataType: 'json', dataType: 'json',
error: () => this.loading.hide(), error: () => this.loading.hide(),
success: (data) => { success: (data) => {
this.append(data.count, this.prepareData(data.html)); this.append(data.count, this.prepareData(data.html));
this.callback(); this.callback();
// keep loading until we've filled the viewport height // keep loading until we've filled the viewport height
if (!this.disable && !this.isScrollable()) { if (!this.disable && !this.isScrollable()) {
this.getOld(); this.getOld();
} else { } else {
this.loading.hide(); this.loading.hide();
} }
}, },
}); });
}, },
append(count, html) { append(count, html) {
$('.content_list').append(html); $('.content_list').append(html);
if (count > 0) { if (count > 0) {
this.offset += count; this.offset += count;
} else { } else {
this.disable = true; this.disable = true;
} }
}, },
isScrollable() { isScrollable() {
const $w = $(window); const $w = $(window);
return $(document).height() > $w.height() + $w.scrollTop() + ENDLESS_SCROLL_BOTTOM_PX; return $(document).height() > $w.height() + $w.scrollTop() + ENDLESS_SCROLL_BOTTOM_PX;
}, },
initLoadMore() { initLoadMore() {
$(document).unbind('scroll'); $(document).unbind('scroll');
$(document).endlessScroll({ $(document).endlessScroll({
bottomPixels: ENDLESS_SCROLL_BOTTOM_PX, bottomPixels: ENDLESS_SCROLL_BOTTOM_PX,
fireDelay: ENDLESS_SCROLL_FIRE_DELAY_MS, fireDelay: ENDLESS_SCROLL_FIRE_DELAY_MS,
fireOnce: true, fireOnce: true,
ceaseFire: () => this.disable === true, ceaseFire: () => this.disable === true,
callback: () => { callback: () => {
if (!this.loading.is(':visible')) { if (!this.loading.is(':visible')) {
this.loading.show(); this.loading.show();
this.getOld(); this.getOld();
} }
}, },
}); });
}, },
}; };
window.Pager = Pager;
})();
...@@ -65,10 +65,12 @@ export default { ...@@ -65,10 +65,12 @@ export default {
<div class="mr-widget-body media"> <div class="mr-widget-body media">
<status-icon status="success" /> <status-icon status="success" />
<div class="media-body"> <div class="media-body">
<h4> <h4 class="flex-container-block">
Set by <span class="append-right-10">
<mr-widget-author :author="mr.setToMWPSBy" /> Set by
to be merged automatically when the pipeline succeeds <mr-widget-author :author="mr.setToMWPSBy" />
to be merged automatically when the pipeline succeeds
</span>
<a <a
v-if="mr.canCancelAutomaticMerge" v-if="mr.canCancelAutomaticMerge"
@click.prevent="cancelAutomaticMerge" @click.prevent="cancelAutomaticMerge"
...@@ -94,8 +96,13 @@ export default { ...@@ -94,8 +96,13 @@ export default {
<p v-if="mr.shouldRemoveSourceBranch"> <p v-if="mr.shouldRemoveSourceBranch">
The source branch will be removed The source branch will be removed
</p> </p>
<p v-else> <p
The source branch will not be removed v-else
class="flex-container-block"
>
<span class="append-right-10">
The source branch will not be removed
</span>
<a <a
v-if="canRemoveSourceBranch" v-if="canRemoveSourceBranch"
:disabled="isRemovingSourceBranch" :disabled="isRemovingSourceBranch"
......
<script> <script>
import { s__ } from '../../locale';
import icon from './icon.vue';
import loadingIcon from './loading_icon.vue'; import loadingIcon from './loading_icon.vue';
const ICON_ON = 'status_success_borderless';
const ICON_OFF = 'status_failed_borderless';
const LABEL_ON = s__('ToggleButton|Toggle Status: ON');
const LABEL_OFF = s__('ToggleButton|Toggle Status: OFF');
export default { export default {
props: { props: {
name: { name: {
...@@ -22,19 +29,10 @@ ...@@ -22,19 +29,10 @@
required: false, required: false,
default: false, default: false,
}, },
enabledText: {
type: String,
required: false,
default: 'Enabled',
},
disabledText: {
type: String,
required: false,
default: 'Disabled',
},
}, },
components: { components: {
icon,
loadingIcon, loadingIcon,
}, },
...@@ -43,6 +41,15 @@ ...@@ -43,6 +41,15 @@
event: 'change', event: 'change',
}, },
computed: {
toggleIcon() {
return this.value ? ICON_ON : ICON_OFF;
},
ariaLabel() {
return this.value ? LABEL_ON : LABEL_OFF;
},
},
methods: { methods: {
toggleFeature() { toggleFeature() {
if (!this.disabledInput) this.$emit('change', !this.value); if (!this.disabledInput) this.$emit('change', !this.value);
...@@ -60,10 +67,8 @@ ...@@ -60,10 +67,8 @@
/> />
<button <button
type="button" type="button"
aria-label="Toggle"
class="project-feature-toggle" class="project-feature-toggle"
:data-enabled-text="enabledText" :aria-label="ariaLabel"
:data-disabled-text="disabledText"
:class="{ :class="{
'is-checked': value, 'is-checked': value,
'is-disabled': disabledInput, 'is-disabled': disabledInput,
...@@ -72,6 +77,11 @@ ...@@ -72,6 +77,11 @@
@click="toggleFeature" @click="toggleFeature"
> >
<loadingIcon class="loading-icon" /> <loadingIcon class="loading-icon" />
<span class="toggle-icon">
<icon
css-classes="toggle-icon-svg"
:name="toggleIcon"/>
</span>
</button> </button>
</label> </label>
</template> </template>
...@@ -396,3 +396,8 @@ span.idiff { ...@@ -396,3 +396,8 @@ span.idiff {
.file-fork-suggestion-note { .file-fork-suggestion-note {
margin-right: 1.5em; margin-right: 1.5em;
} }
.label-lfs {
color: $common-gray-light;
border: 1px solid $common-gray-light;
}
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
border: 0; border: 0;
outline: 0; outline: 0;
display: block; display: block;
width: 100px; width: 50px;
height: 24px; height: 24px;
cursor: pointer; cursor: pointer;
user-select: none; user-select: none;
...@@ -42,31 +42,31 @@ ...@@ -42,31 +42,31 @@
background: none; background: none;
} }
&::before { .toggle-icon {
color: $feature-toggle-text-color;
font-size: 12px;
line-height: 24px;
position: absolute;
top: 0;
left: 25px;
right: 5px;
text-align: center;
overflow: hidden;
text-overflow: ellipsis;
animation: animate-disabled .2s ease-in;
content: attr(data-disabled-text);
}
&::after {
position: relative; position: relative;
display: block; display: block;
content: "";
width: 22px;
height: 18px;
left: 0; left: 0;
border-radius: 9px; border-radius: 9px;
background: $feature-toggle-color; background: $feature-toggle-color;
transition: all .2s ease; transition: all .2s ease;
&,
.toggle-icon-svg {
width: 18px;
height: 18px;
}
.toggle-icon-svg {
fill: $feature-toggle-color-disabled;
}
.toggle-status-checked {
display: none;
}
.toggle-status-unchecked {
display: inline;
}
} }
.loading-icon { .loading-icon {
...@@ -77,11 +77,10 @@ ...@@ -77,11 +77,10 @@
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
} }
&.is-loading { &.is-loading {
&::before { .toggle-icon {
display: none; display: none;
} }
...@@ -100,15 +99,20 @@ ...@@ -100,15 +99,20 @@
&.is-checked { &.is-checked {
background: $feature-toggle-color-enabled; background: $feature-toggle-color-enabled;
&::before { .toggle-icon {
left: 5px; left: calc(100% - 18px);
right: 25px;
animation: animate-enabled .2s ease-in;
content: attr(data-enabled-text);
}
&::after { .toggle-icon-svg {
left: calc(100% - 22px); fill: $feature-toggle-color-enabled;
}
.toggle-status-checked {
display: inline;
}
.toggle-status-unchecked {
display: none;
}
} }
} }
......
...@@ -110,7 +110,7 @@ class Projects::JobsController < Projects::ApplicationController ...@@ -110,7 +110,7 @@ class Projects::JobsController < Projects::ApplicationController
def erase def erase
if @build.erase(erased_by: current_user) if @build.erase(erased_by: current_user)
redirect_to project_job_path(project, @build), redirect_to project_job_path(project, @build),
notice: "Build has been successfully erased!" notice: "Job has been successfully erased!"
else else
respond_422 respond_422
end end
......
...@@ -11,7 +11,10 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap ...@@ -11,7 +11,10 @@ class Projects::MergeRequests::CreationsController < Projects::MergeRequests::Ap
before_action :build_merge_request, except: [:create] before_action :build_merge_request, except: [:create]
def new def new
define_new_vars # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/40934
Gitlab::GitalyClient.allow_n_plus_1_calls do
define_new_vars
end
end end
def create def create
......
...@@ -26,6 +26,7 @@ class Projects::TreeController < Projects::ApplicationController ...@@ -26,6 +26,7 @@ class Projects::TreeController < Projects::ApplicationController
respond_to do |format| respond_to do |format|
format.html do format.html do
lfs_blob_ids
@last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit @last_commit = @repository.last_commit_for_path(@commit.id, @tree.path) || @commit
end end
......
...@@ -11,6 +11,7 @@ class ProjectsController < Projects::ApplicationController ...@@ -11,6 +11,7 @@ class ProjectsController < Projects::ApplicationController
before_action :assign_ref_vars, only: [:show], if: :repo_exists? before_action :assign_ref_vars, only: [:show], if: :repo_exists?
before_action :assign_tree_vars, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :assign_tree_vars, only: [:show], if: [:repo_exists?, :project_view_files?]
before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?] before_action :tree, only: [:show], if: [:repo_exists?, :project_view_files?]
before_action :lfs_blob_ids, only: [:show], if: [:repo_exists?, :project_view_files?]
before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export] before_action :project_export_enabled, only: [:export, :download_export, :remove_export, :generate_new_export]
# Authorize # Authorize
......
...@@ -16,11 +16,11 @@ class Identity < ActiveRecord::Base ...@@ -16,11 +16,11 @@ class Identity < ActiveRecord::Base
end end
def ldap? def ldap?
provider.starts_with?('ldap') Gitlab::OAuth::Provider.ldap_provider?(provider)
end end
def self.normalize_uid(provider, uid) def self.normalize_uid(provider, uid)
if provider.to_s.starts_with?('ldap') if Gitlab::OAuth::Provider.ldap_provider?(provider)
Gitlab::LDAP::Person.normalize_dn(uid) Gitlab::LDAP::Person.normalize_dn(uid)
else else
uid.to_s uid.to_s
......
...@@ -995,7 +995,7 @@ class Repository ...@@ -995,7 +995,7 @@ class Repository
def merge_base(first_commit_id, second_commit_id) def merge_base(first_commit_id, second_commit_id)
first_commit_id = commit(first_commit_id).try(:id) || first_commit_id first_commit_id = commit(first_commit_id).try(:id) || first_commit_id
second_commit_id = commit(second_commit_id).try(:id) || second_commit_id second_commit_id = commit(second_commit_id).try(:id) || second_commit_id
rugged.merge_base(first_commit_id, second_commit_id) raw_repository.merge_base(first_commit_id, second_commit_id)
rescue Rugged::ReferenceError rescue Rugged::ReferenceError
nil nil
end end
......
...@@ -757,7 +757,7 @@ class User < ActiveRecord::Base ...@@ -757,7 +757,7 @@ class User < ActiveRecord::Base
def ldap_user? def ldap_user?
if identities.loaded? if identities.loaded?
identities.find { |identity| identity.provider.start_with?('ldap') && !identity.extern_uid.nil? } identities.find { |identity| Gitlab::OAuth::Provider.ldap_provider?(identity.provider) && !identity.extern_uid.nil? }
else else
identities.exists?(["provider LIKE ? AND extern_uid IS NOT NULL", "ldap%"]) identities.exists?(["provider LIKE ? AND extern_uid IS NOT NULL", "ldap%"])
end end
......
...@@ -6,11 +6,11 @@ class UserSyncedAttributesMetadata < ActiveRecord::Base ...@@ -6,11 +6,11 @@ class UserSyncedAttributesMetadata < ActiveRecord::Base
SYNCABLE_ATTRIBUTES = %i[name email location].freeze SYNCABLE_ATTRIBUTES = %i[name email location].freeze
def read_only?(attribute) def read_only?(attribute)
Gitlab.config.omniauth.sync_profile_from_provider && synced?(attribute) sync_profile_from_provider? && synced?(attribute)
end end
def read_only_attributes def read_only_attributes
return [] unless Gitlab.config.omniauth.sync_profile_from_provider return [] unless sync_profile_from_provider?
SYNCABLE_ATTRIBUTES.select { |key| synced?(key) } SYNCABLE_ATTRIBUTES.select { |key| synced?(key) }
end end
...@@ -22,4 +22,10 @@ class UserSyncedAttributesMetadata < ActiveRecord::Base ...@@ -22,4 +22,10 @@ class UserSyncedAttributesMetadata < ActiveRecord::Base
def set_attribute_synced(attribute, value) def set_attribute_synced(attribute, value)
write_attribute("#{attribute}_synced", value) write_attribute("#{attribute}_synced", value)
end end
private
def sync_profile_from_provider?
Gitlab::OAuth::Provider.sync_profile_from_provider?(provider)
end
end end
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
Unable to collect CPU info Unable to collect CPU info
.col-sm-4 .col-sm-4
.light-well .light-well
%h4 Memory %h4 Memory Usage
.data .data
- if @memory - if @memory
%h1 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)} %h1 #{number_to_human_size(@memory.active_bytes)} / #{number_to_human_size(@memory.total_bytes)}
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
Unable to collect memory info Unable to collect memory info
.col-sm-4 .col-sm-4
.light-well .light-well
%h4 Disks %h4 Disk Usage
.data .data
- @disks.each do |disk| - @disks.each do |disk|
%h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])} %h1 #{number_to_human_size(disk[:bytes_used])} / #{number_to_human_size(disk[:bytes_total])}
...@@ -34,4 +34,4 @@ ...@@ -34,4 +34,4 @@
.light-well .light-well
%h4 Uptime %h4 Uptime
.data .data
%h1= time_ago_with_tooltip(Rails.application.config.booted_at) %h1= distance_of_time_in_words_to_now(Rails.application.config.booted_at)
...@@ -161,7 +161,6 @@ ...@@ -161,7 +161,6 @@
%ul %ul
%li User will not be able to login %li User will not be able to login
%li User will not be able to access git repositories %li User will not be able to access git repositories
%li User will be removed from joined projects and groups
%li Personal projects will be left %li Personal projects will be left
%li Owned groups will be left %li Owned groups will be left
%br %br
......
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
%br %br
%span.descr %span.descr
Pipelines need to be configured to enable this feature. Pipelines need to be configured to enable this feature.
= link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds') = link_to icon('question-circle'), help_page_path('user/project/merge_requests/merge_when_pipeline_succeeds', anchor: 'only-allow-merge-requests-to-be-merged-if-the-pipeline-succeeds'), target: '_blank'
.checkbox .checkbox
= form.label :only_allow_merge_if_all_discussions_are_resolved do = form.label :only_allow_merge_if_all_discussions_are_resolved do
= form.check_box :only_allow_merge_if_all_discussions_are_resolved = form.check_box :only_allow_merge_if_all_discussions_are_resolved
......
...@@ -8,3 +8,6 @@ ...@@ -8,3 +8,6 @@
%small %small
= number_to_human_size(blob.raw_size) = number_to_human_size(blob.raw_size)
- if blob.stored_externally? && blob.external_storage == :lfs
%span.label.label-lfs.append-right-5 LFS
...@@ -16,7 +16,8 @@ ...@@ -16,7 +16,8 @@
class: "js-toggle-cluster-list project-feature-toggle #{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}", class: "js-toggle-cluster-list project-feature-toggle #{'is-checked' if cluster.enabled?} #{'is-disabled' if !cluster.can_toggle_cluster?}",
"aria-label": s_("ClusterIntegration|Toggle Cluster"), "aria-label": s_("ClusterIntegration|Toggle Cluster"),
disabled: !cluster.can_toggle_cluster?, disabled: !cluster.can_toggle_cluster?,
data: { "enabled-text": s_("ClusterIntegration|Active"), data: { endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } }
"disabled-text": s_("ClusterIntegration|Inactive"),
endpoint: namespace_project_cluster_path(@project.namespace, @project, cluster, format: :json) } }
= icon("spinner spin", class: "loading-icon") = icon("spinner spin", class: "loading-icon")
%span.toggle-icon
= sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
= sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
...@@ -7,8 +7,10 @@ ...@@ -7,8 +7,10 @@
%button{ type: 'button', %button{ type: 'button',
class: "js-toggle-cluster project-feature-toggle #{'is-checked' unless !@cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}", class: "js-toggle-cluster project-feature-toggle #{'is-checked' unless !@cluster.enabled?} #{'is-disabled' unless can?(current_user, :update_cluster, @cluster)}",
"aria-label": s_("ClusterIntegration|Toggle Cluster"), "aria-label": s_("ClusterIntegration|Toggle Cluster"),
disabled: !can?(current_user, :update_cluster, @cluster), disabled: !can?(current_user, :update_cluster, @cluster) }
data: { "enabled-text": s_("ClusterIntegration|Active"), "disabled-text": s_("ClusterIntegration|Inactive"), } } %span.toggle-icon
= sprite_icon('status_success_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-checked')
= sprite_icon('status_failed_borderless', size: 16, css_class: 'toggle-icon-svg toggle-status-unchecked')
- if can?(current_user, :update_cluster, @cluster) - if can?(current_user, :update_cluster, @cluster)
.form-group .form-group
......
- is_lfs_blob = @lfs_blob_ids.include?(blob_item.id)
%tr{ class: "tree-item #{tree_hex_class(blob_item)}" } %tr{ class: "tree-item #{tree_hex_class(blob_item)}" }
%td.tree-item-file-name %td.tree-item-file-name
= tree_icon(type, blob_item.mode, blob_item.name) = tree_icon(type, blob_item.mode, blob_item.name)
- file_name = blob_item.name - file_name = blob_item.name
= link_to project_blob_path(@project, tree_join(@id || @commit.id, blob_item.name)), class: 'str-truncated', title: file_name do = link_to project_blob_path(@project, tree_join(@id || @commit.id, blob_item.name)), class: 'str-truncated', title: file_name do
%span= file_name %span= file_name
- if is_lfs_blob
%span.label.label-lfs.prepend-left-5 LFS
%td.hidden-xs.tree-commit %td.hidden-xs.tree-commit
%td.tree-time-ago.cgray.text-right %td.tree-time-ago.cgray.text-right
= render 'projects/tree/spinner' = render 'projects/tree/spinner'
---
title: Fixes the wording of headers in system info page
merge_request: 15802
author: Gilbert Roulot
type: fixed
---
title: Update feature toggle design to use icons and make it i18n friendly
merge_request: 15904
author:
type: changed
---
title: fix button alignment on MWPS component
merge_request:
author:
type: fixed
---
title: Make sure user email is read only when synced with LDAP
merge_request: 15915
author:
type: fixed
---
title: Added badge to tree & blob views to indicate LFS tracked files
merge_request:
author:
type: added
---
title: Removed incorrect guidance stating blocked users will be removed from groups
and project as members
merge_request: 15947
author: CesarApodaca
type: fixed
...@@ -497,6 +497,7 @@ production: &base ...@@ -497,6 +497,7 @@ production: &base
# Sync user's profile from the specified Omniauth providers every time the user logs in (default: empty). # Sync user's profile from the specified Omniauth providers every time the user logs in (default: empty).
# Define the allowed providers using an array, e.g. ["cas3", "saml", "twitter"], # Define the allowed providers using an array, e.g. ["cas3", "saml", "twitter"],
# or as true/false to allow all providers or none. # or as true/false to allow all providers or none.
# When authenticating using LDAP, the user's email is always synced.
# sync_profile_from_provider: [] # sync_profile_from_provider: []
# Select which info to sync from the providers above. (default: email). # Select which info to sync from the providers above. (default: email).
......
...@@ -80,7 +80,7 @@ Make sure you have the right version of Git installed ...@@ -80,7 +80,7 @@ Make sure you have the right version of Git installed
# Install Git # Install Git
sudo apt-get install -y git-core sudo apt-get install -y git-core
# Make sure Git is version 2.13.6 or higher # Make sure Git is version 2.14.3 or higher
git --version git --version
Is the system packaged Git too old? Remove it and compile from source. Is the system packaged Git too old? Remove it and compile from source.
......
...@@ -229,16 +229,18 @@ In order to enable/disable an OmniAuth provider, go to Admin Area -> Settings -> ...@@ -229,16 +229,18 @@ In order to enable/disable an OmniAuth provider, go to Admin Area -> Settings ->
## Keep OmniAuth user profiles up to date ## Keep OmniAuth user profiles up to date
You can enable profile syncing from selected OmniAuth providers and for all or for specific user information. You can enable profile syncing from selected OmniAuth providers and for all or for specific user information.
When authenticating using LDAP, the user's email is always synced.
```ruby ```ruby
gitlab_rails['sync_profile_from_provider'] = ['twitter', 'google_oauth2'] gitlab_rails['sync_profile_from_provider'] = ['twitter', 'google_oauth2']
gitlab_rails['sync_profile_attributes'] = ['name', 'email', 'location'] gitlab_rails['sync_profile_attributes'] = ['name', 'email', 'location']
``` ```
**For installations from source** **For installations from source**
```yaml ```yaml
omniauth: omniauth:
sync_profile_from_provider: ['twitter', 'google_oauth2'] sync_profile_from_provider: ['twitter', 'google_oauth2']
sync_profile_claims_from_provider: ['email', 'location'] sync_profile_attributes: ['email', 'location']
``` ```
\ No newline at end of file
...@@ -147,6 +147,10 @@ has a `.gitlab-ci.yml` or not: ...@@ -147,6 +147,10 @@ has a `.gitlab-ci.yml` or not:
All you need to do is remove your existing `.gitlab-ci.yml`, and you can even All you need to do is remove your existing `.gitlab-ci.yml`, and you can even
do that in a branch to test Auto DevOps before committing to `master`. do that in a branch to test Auto DevOps before committing to `master`.
NOTE: **Note:**
Starting with GitLab 10.3, when enabling Auto DevOps, a pipeline is
automatically run on the default branch.
NOTE: **Note:** NOTE: **Note:**
If you are a GitLab Administrator, you can enable Auto DevOps instance wide If you are a GitLab Administrator, you can enable Auto DevOps instance wide
in **Admin Area > Settings > Continuous Integration and Deployment**. Doing that, in **Admin Area > Settings > Continuous Integration and Deployment**. Doing that,
...@@ -211,6 +215,18 @@ check out. ...@@ -211,6 +215,18 @@ check out.
Any security warnings are also [shown in the merge request widget](../../user/project/merge_requests/sast.md). Any security warnings are also [shown in the merge request widget](../../user/project/merge_requests/sast.md).
### Auto SAST
> Introduced in [GitLab Enterprise Edition Ultimate][ee] 10.3.
Static Application Security Testing (SAST) uses the
[gl-sast Docker image](https://gitlab.com/gitlab-org/gl-sast) to run static
analysis on the current code and checks for potential security issues. Once the
report is created, it's uploaded as an artifact which you can later download and
check out.
Any security warnings are also [shown in the merge request widget](https://docs.gitlab.com/ee/user/project/merge_requests/sast.html).
### Auto Review Apps ### Auto Review Apps
NOTE: **Note:** NOTE: **Note:**
......
...@@ -456,6 +456,7 @@ module API ...@@ -456,6 +456,7 @@ module API
warden.try(:authenticate) if verified_request? warden.try(:authenticate) if verified_request?
end end
# rubocop:disable Gitlab/ModuleWithInstanceVariables
def initial_current_user def initial_current_user
return @initial_current_user if defined?(@initial_current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables return @initial_current_user if defined?(@initial_current_user) # rubocop:disable Gitlab/ModuleWithInstanceVariables
...@@ -465,6 +466,7 @@ module API ...@@ -465,6 +466,7 @@ module API
unauthorized! unauthorized!
end end
end end
# rubocop:enable Gitlab/ModuleWithInstanceVariables
def sudo! def sudo!
return unless sudo_identifier return unless sudo_identifier
......
...@@ -128,7 +128,6 @@ module ExtractsPath ...@@ -128,7 +128,6 @@ module ExtractsPath
@hex_path = Digest::SHA1.hexdigest(@path) @hex_path = Digest::SHA1.hexdigest(@path)
@logs_path = logs_file_project_ref_path(@project, @ref, @path) @logs_path = logs_file_project_ref_path(@project, @ref, @path)
rescue RuntimeError, NoMethodError, InvalidPathError rescue RuntimeError, NoMethodError, InvalidPathError
render_404 render_404
end end
...@@ -138,6 +137,11 @@ module ExtractsPath ...@@ -138,6 +137,11 @@ module ExtractsPath
@tree ||= @repo.tree(@commit.id, @path) # rubocop:disable Gitlab/ModuleWithInstanceVariables @tree ||= @repo.tree(@commit.id, @path) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end end
def lfs_blob_ids
blob_ids = tree.blobs.map(&:id)
@lfs_blob_ids = Gitlab::Git::Blob.batch_lfs_pointers(@project.repository, blob_ids).map(&:id) # rubocop:disable Gitlab/ModuleWithInstanceVariables
end
private private
# overriden in subclasses, do not remove # overriden in subclasses, do not remove
......
...@@ -4,11 +4,11 @@ module Gitlab ...@@ -4,11 +4,11 @@ module Gitlab
attr_reader :merge_request, :resolver attr_reader :merge_request, :resolver
def initialize(merge_request) def initialize(merge_request)
source_repo = merge_request.source_project.repository.raw
our_commit = merge_request.source_branch_head.raw our_commit = merge_request.source_branch_head.raw
their_commit = merge_request.target_branch_head.raw their_commit = merge_request.target_branch_head.raw
target_repo = merge_request.target_project.repository.raw target_repo = merge_request.target_project.repository.raw
@resolver = Gitlab::Git::Conflict::Resolver.new(source_repo, our_commit, target_repo, their_commit) @source_repo = merge_request.source_project.repository.raw
@resolver = Gitlab::Git::Conflict::Resolver.new(target_repo, our_commit.id, their_commit.id)
@merge_request = merge_request @merge_request = merge_request
end end
...@@ -18,7 +18,7 @@ module Gitlab ...@@ -18,7 +18,7 @@ module Gitlab
target_branch: merge_request.target_branch, target_branch: merge_request.target_branch,
commit_message: commit_message || default_commit_message commit_message: commit_message || default_commit_message
} }
resolver.resolve_conflicts(user, files, args) resolver.resolve_conflicts(@source_repo, user, files, args)
ensure ensure
@merge_request.clear_memoized_shas @merge_request.clear_memoized_shas
end end
......
...@@ -5,38 +5,31 @@ module Gitlab ...@@ -5,38 +5,31 @@ module Gitlab
ConflictSideMissing = Class.new(StandardError) ConflictSideMissing = Class.new(StandardError)
ResolutionError = Class.new(StandardError) ResolutionError = Class.new(StandardError)
def initialize(repository, our_commit, target_repository, their_commit) def initialize(target_repository, our_commit_oid, their_commit_oid)
@repository = repository
@our_commit = our_commit.rugged_commit
@target_repository = target_repository @target_repository = target_repository
@their_commit = their_commit.rugged_commit @our_commit_oid = our_commit_oid
@their_commit_oid = their_commit_oid
end end
def conflicts def conflicts
@conflicts ||= begin @conflicts ||= begin
target_index = @target_repository.rugged.merge_commits(@our_commit, @their_commit) target_index = @target_repository.rugged.merge_commits(@our_commit_oid, @their_commit_oid)
# We don't need to do `with_repo_branch_commit` here, because the target # We don't need to do `with_repo_branch_commit` here, because the target
# project always fetches source refs when creating merge request diffs. # project always fetches source refs when creating merge request diffs.
target_index.conflicts.map do |conflict| conflict_files(@target_repository, target_index)
raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours]
Gitlab::Git::Conflict::File.new(
@target_repository,
@our_commit.oid,
conflict,
target_index.merge_file(conflict[:ours][:path])[:data]
)
end
end end
end end
def resolve_conflicts(user, files, source_branch:, target_branch:, commit_message:) def resolve_conflicts(source_repository, user, files, source_branch:, target_branch:, commit_message:)
@repository.with_repo_branch_commit(@target_repository, target_branch) do source_repository.with_repo_branch_commit(@target_repository, target_branch) do
index = source_repository.rugged.merge_commits(@our_commit_oid, @their_commit_oid)
conflicts = conflict_files(source_repository, index)
files.each do |file_params| files.each do |file_params|
conflict_file = conflict_for_path(file_params[:old_path], file_params[:new_path]) conflict_file = conflict_for_path(conflicts, file_params[:old_path], file_params[:new_path])
write_resolved_file_to_index(conflict_file, file_params) write_resolved_file_to_index(source_repository, index, conflict_file, file_params)
end end
unless index.conflicts.empty? unless index.conflicts.empty?
...@@ -47,14 +40,14 @@ module Gitlab ...@@ -47,14 +40,14 @@ module Gitlab
commit_params = { commit_params = {
message: commit_message, message: commit_message,
parents: [@our_commit, @their_commit].map(&:oid) parents: [@our_commit_oid, @their_commit_oid]
} }
@repository.commit_index(user, source_branch, index, commit_params) source_repository.commit_index(user, source_branch, index, commit_params)
end end
end end
def conflict_for_path(old_path, new_path) def conflict_for_path(conflicts, old_path, new_path)
conflicts.find do |conflict| conflicts.find do |conflict|
conflict.their_path == old_path && conflict.our_path == new_path conflict.their_path == old_path && conflict.our_path == new_path
end end
...@@ -62,15 +55,20 @@ module Gitlab ...@@ -62,15 +55,20 @@ module Gitlab
private private
# We can only write when getting the merge index from the source def conflict_files(repository, index)
# project, because we will write to that project. We don't use this all index.conflicts.map do |conflict|
# the time because this fetches a ref into the source project, which raise ConflictSideMissing unless conflict[:theirs] && conflict[:ours]
# isn't needed for reading.
def index Gitlab::Git::Conflict::File.new(
@index ||= @repository.rugged.merge_commits(@our_commit, @their_commit) repository,
@our_commit_oid,
conflict,
index.merge_file(conflict[:ours][:path])[:data]
)
end
end end
def write_resolved_file_to_index(file, params) def write_resolved_file_to_index(repository, index, file, params)
if params[:sections] if params[:sections]
resolved_lines = file.resolve_lines(params[:sections]) resolved_lines = file.resolve_lines(params[:sections])
new_file = resolved_lines.map { |line| line[:full_line] }.join("\n") new_file = resolved_lines.map { |line| line[:full_line] }.join("\n")
...@@ -82,7 +80,8 @@ module Gitlab ...@@ -82,7 +80,8 @@ module Gitlab
our_path = file.our_path our_path = file.our_path
index.add(path: our_path, oid: @repository.rugged.write(new_file, :blob), mode: file.our_mode) oid = repository.rugged.write(new_file, :blob)
index.add(path: our_path, oid: oid, mode: file.our_mode)
index.conflict_remove(our_path) index.conflict_remove(our_path)
end end
end end
......
...@@ -131,7 +131,7 @@ module Gitlab ...@@ -131,7 +131,7 @@ module Gitlab
oldrev = branch.target oldrev = branch.target
if oldrev == repository.rugged.merge_base(newrev, branch.target) if oldrev == repository.merge_base(newrev, branch.target)
oldrev oldrev
else else
raise Gitlab::Git::CommitError.new('Branch diverged') raise Gitlab::Git::CommitError.new('Branch diverged')
......
...@@ -538,8 +538,15 @@ module Gitlab ...@@ -538,8 +538,15 @@ module Gitlab
# Returns the SHA of the most recent common ancestor of +from+ and +to+ # Returns the SHA of the most recent common ancestor of +from+ and +to+
def merge_base_commit(from, to) def merge_base_commit(from, to)
rugged.merge_base(from, to) gitaly_migrate(:merge_base) do |is_enabled|
if is_enabled
gitaly_repository_client.find_merge_base(from, to)
else
rugged.merge_base(from, to)
end
end
end end
alias_method :merge_base, :merge_base_commit
# Gitaly note: JV: check gitlab-ee before removing this method. # Gitaly note: JV: check gitlab-ee before removing this method.
def rugged_is_ancestor?(ancestor_id, descendant_id) def rugged_is_ancestor?(ancestor_id, descendant_id)
......
...@@ -69,6 +69,16 @@ module Gitlab ...@@ -69,6 +69,16 @@ module Gitlab
response.value response.value
end end
def find_merge_base(*revisions)
request = Gitaly::FindMergeBaseRequest.new(
repository: @gitaly_repo,
revisions: revisions.map { |r| GitalyClient.encode(r) }
)
response = GitalyClient.call(@storage, :repository_service, :find_merge_base, request)
response.base.presence
end
def fetch_source_branch(source_repository, source_branch, local_ref) def fetch_source_branch(source_repository, source_branch, local_ref)
request = Gitaly::FetchSourceBranchRequest.new( request = Gitaly::FetchSourceBranchRequest.new(
repository: @gitaly_repo, repository: @gitaly_repo,
......
...@@ -38,10 +38,6 @@ module Gitlab ...@@ -38,10 +38,6 @@ module Gitlab
ldap_config.block_auto_created_users ldap_config.block_auto_created_users
end end
def sync_profile_from_provider?
true
end
def allowed? def allowed?
Gitlab::LDAP::Access.allowed?(gl_user) Gitlab::LDAP::Access.allowed?(gl_user)
end end
......
...@@ -19,6 +19,18 @@ module Gitlab ...@@ -19,6 +19,18 @@ module Gitlab
name.to_s.start_with?('ldap') name.to_s.start_with?('ldap')
end end
def self.sync_profile_from_provider?(provider)
return true if ldap_provider?(provider)
providers = Gitlab.config.omniauth.sync_profile_from_provider
if providers.is_a?(Array)
providers.include?(provider)
else
providers
end
end
def self.config_for(name) def self.config_for(name)
name = name.to_s name = name.to_s
if ldap_provider?(name) if ldap_provider?(name)
......
...@@ -14,7 +14,7 @@ module Gitlab ...@@ -14,7 +14,7 @@ module Gitlab
def initialize(auth_hash) def initialize(auth_hash)
self.auth_hash = auth_hash self.auth_hash = auth_hash
update_profile if sync_profile_from_provider? update_profile
add_or_update_user_identities add_or_update_user_identities
end end
...@@ -197,29 +197,31 @@ module Gitlab ...@@ -197,29 +197,31 @@ module Gitlab
end end
def sync_profile_from_provider? def sync_profile_from_provider?
providers = Gitlab.config.omniauth.sync_profile_from_provider Gitlab::OAuth::Provider.sync_profile_from_provider?(auth_hash.provider)
if providers.is_a?(Array)
providers.include?(auth_hash.provider)
else
providers
end
end end
def update_profile def update_profile
user_synced_attributes_metadata = gl_user.user_synced_attributes_metadata || gl_user.build_user_synced_attributes_metadata return unless sync_profile_from_provider? || creating_linked_ldap_user?
UserSyncedAttributesMetadata::SYNCABLE_ATTRIBUTES.each do |key| metadata = gl_user.user_synced_attributes_metadata || gl_user.build_user_synced_attributes_metadata
if auth_hash.has_attribute?(key) && gl_user.sync_attribute?(key)
gl_user[key] = auth_hash.public_send(key) # rubocop:disable GitlabSecurity/PublicSend if sync_profile_from_provider?
user_synced_attributes_metadata.set_attribute_synced(key, true) UserSyncedAttributesMetadata::SYNCABLE_ATTRIBUTES.each do |key|
else if auth_hash.has_attribute?(key) && gl_user.sync_attribute?(key)
user_synced_attributes_metadata.set_attribute_synced(key, false) gl_user[key] = auth_hash.public_send(key) # rubocop:disable GitlabSecurity/PublicSend
metadata.set_attribute_synced(key, true)
else
metadata.set_attribute_synced(key, false)
end
end end
metadata.provider = auth_hash.provider
end end
user_synced_attributes_metadata.provider = auth_hash.provider if creating_linked_ldap_user? && gl_user.email == ldap_person.email.first
gl_user.user_synced_attributes_metadata = user_synced_attributes_metadata metadata.set_attribute_synced(:email, true)
metadata.provider = ldap_person.provider
end
end end
def log def log
......
...@@ -385,6 +385,8 @@ module Gitlab ...@@ -385,6 +385,8 @@ module Gitlab
success success
end end
# Delete branch from remote repository
#
# storage - project's storage path # storage - project's storage path
# project_name - project's disk path # project_name - project's disk path
# remote_name - remote name # remote_name - remote name
......
...@@ -21,6 +21,7 @@ module Omnibus ...@@ -21,6 +21,7 @@ module Omnibus
if id if id
puts "Triggered https://gitlab.com/#{Omnibus::PROJECT_PATH}/pipelines/#{id}" puts "Triggered https://gitlab.com/#{Omnibus::PROJECT_PATH}/pipelines/#{id}"
puts "Waiting for downstream pipeline status"
else else
raise "Trigger failed! The response from the trigger is: #{res.body}" raise "Trigger failed! The response from the trigger is: #{res.body}"
end end
...@@ -39,7 +40,9 @@ module Omnibus ...@@ -39,7 +40,9 @@ module Omnibus
"ref" => ENV["OMNIBUS_BRANCH"] || "master", "ref" => ENV["OMNIBUS_BRANCH"] || "master",
"variables[GITLAB_VERSION]" => ENV["CI_COMMIT_SHA"], "variables[GITLAB_VERSION]" => ENV["CI_COMMIT_SHA"],
"variables[ALTERNATIVE_SOURCES]" => true, "variables[ALTERNATIVE_SOURCES]" => true,
"variables[ee]" => ee? ? 'true' : 'false' "variables[ee]" => ee? ? 'true' : 'false',
"variables[TRIGGERED_USER]" => ENV["GITLAB_USER_NAME"],
"variables[TRIGGER_SOURCE]" => "https://gitlab.com/gitlab-org/#{ENV['CI_PROJECT_NAME']}/-/jobs/#{ENV['CI_JOB_ID']}"
} }
end end
...@@ -63,14 +66,14 @@ module Omnibus ...@@ -63,14 +66,14 @@ module Omnibus
def wait! def wait!
loop do loop do
raise 'Pipeline timeout!' if timeout? raise "Pipeline timed out after waiting for #{duration} minutes!" if timeout?
case status case status
when :created, :pending, :running when :created, :pending, :running
puts "Waiting another #{INTERVAL} seconds ..." print "."
sleep INTERVAL sleep INTERVAL
when :success when :success
puts "Omnibus pipeline succeeded!" puts "Omnibus pipeline succeeded in #{duration} minutes!"
break break
else else
raise "Omnibus pipeline did not succeed!" raise "Omnibus pipeline did not succeed!"
...@@ -84,6 +87,10 @@ module Omnibus ...@@ -84,6 +87,10 @@ module Omnibus
Time.now.to_i > (@start + MAX_DURATION) Time.now.to_i > (@start + MAX_DURATION)
end end
def duration
(Time.now.to_i - @start) / 60
end
def status def status
req = Net::HTTP::Get.new(@uri) req = Net::HTTP::Get.new(@uri)
req['PRIVATE-TOKEN'] = ENV['GITLAB_QA_ACCESS_TOKEN'] req['PRIVATE-TOKEN'] = ENV['GITLAB_QA_ACCESS_TOKEN']
......
...@@ -18,8 +18,8 @@ describe 'Admin System Info' do ...@@ -18,8 +18,8 @@ describe 'Admin System Info' do
it 'shows system info page' do it 'shows system info page' do
expect(page).to have_content 'CPU 2 cores' expect(page).to have_content 'CPU 2 cores'
expect(page).to have_content 'Memory 4 GB / 16 GB' expect(page).to have_content 'Memory Usage 4 GB / 16 GB'
expect(page).to have_content 'Disks' expect(page).to have_content 'Disk Usage'
expect(page).to have_content 'Uptime' expect(page).to have_content 'Uptime'
end end
end end
...@@ -33,8 +33,8 @@ describe 'Admin System Info' do ...@@ -33,8 +33,8 @@ describe 'Admin System Info' do
it 'shows system info page with no CPU info' do it 'shows system info page with no CPU info' do
expect(page).to have_content 'CPU Unable to collect CPU info' expect(page).to have_content 'CPU Unable to collect CPU info'
expect(page).to have_content 'Memory 4 GB / 16 GB' expect(page).to have_content 'Memory Usage 4 GB / 16 GB'
expect(page).to have_content 'Disks' expect(page).to have_content 'Disk Usage'
expect(page).to have_content 'Uptime' expect(page).to have_content 'Uptime'
end end
end end
...@@ -48,8 +48,8 @@ describe 'Admin System Info' do ...@@ -48,8 +48,8 @@ describe 'Admin System Info' do
it 'shows system info page with no CPU info' do it 'shows system info page with no CPU info' do
expect(page).to have_content 'CPU 2 cores' expect(page).to have_content 'CPU 2 cores'
expect(page).to have_content 'Memory Unable to collect memory info' expect(page).to have_content 'Memory Usage Unable to collect memory info'
expect(page).to have_content 'Disks' expect(page).to have_content 'Disk Usage'
expect(page).to have_content 'Uptime' expect(page).to have_content 'Uptime'
end end
end end
......
require 'spec_helper'
feature 'Projects tree' do
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
before do
project.add_master(user)
sign_in(user)
visit project_tree_path(project, 'master')
end
it 'renders tree table' do
expect(page).to have_selector('.tree-item')
expect(page).not_to have_selector('.label-lfs', text: 'LFS')
end
context 'LFS' do
before do
visit project_tree_path(project, File.join('master', 'files/lfs'))
end
it 'renders LFS badge on blob item' do
expect(page).to have_selector('.label-lfs', text: 'LFS')
end
end
end
...@@ -9,6 +9,7 @@ describe TreeHelper do ...@@ -9,6 +9,7 @@ describe TreeHelper do
before do before do
@id = sha @id = sha
@project = project @project = project
@lfs_blob_ids = []
end end
it 'displays all entries without a warning' do it 'displays all entries without a warning' do
......
/* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow, max-len */ /* eslint-disable no-unused-expressions, no-prototype-builtins, no-new, no-shadow, max-len */
import 'vendor/jquery.endless-scroll'; import 'vendor/jquery.endless-scroll';
import '~/pager';
import Activities from '~/activities'; import Activities from '~/activities';
(() => { (() => {
......
import 'vendor/jquery.endless-scroll'; import 'vendor/jquery.endless-scroll';
import '~/pager';
import CommitsList from '~/commits'; import CommitsList from '~/commits';
describe('Commits List', () => { describe('Commits List', () => {
......
/* eslint-disable no-var, comma-dangle, object-shorthand */ /* eslint-disable no-var, comma-dangle, object-shorthand */
import * as urlUtils from '~/lib/utils/url_utility'; import * as urlUtils from '~/lib/utils/url_utility';
import '~/merge_request_tabs'; import MergeRequestTabs from '~/merge_request_tabs';
import '~/commit/pipelines/pipelines_bundle'; import '~/commit/pipelines/pipelines_bundle';
import '~/breakpoints'; import '~/breakpoints';
import '~/lib/utils/common_utils'; import '~/lib/utils/common_utils';
...@@ -31,7 +31,7 @@ import 'vendor/jquery.scrollTo'; ...@@ -31,7 +31,7 @@ import 'vendor/jquery.scrollTo';
); );
beforeEach(function () { beforeEach(function () {
this.class = new gl.MergeRequestTabs({ stubLocation: stubLocation }); this.class = new MergeRequestTabs({ stubLocation: stubLocation });
setLocation(); setLocation();
this.spies = { this.spies = {
......
/* global fixture */ /* global fixture */
import * as utils from '~/lib/utils/url_utility'; import * as utils from '~/lib/utils/url_utility';
import '~/pager'; import Pager from '~/pager';
describe('pager', () => { describe('pager', () => {
const Pager = window.Pager;
it('is defined on window', () => {
expect(window.Pager).toBeDefined();
});
describe('init', () => { describe('init', () => {
const originalHref = window.location.href; const originalHref = window.location.href;
......
...@@ -30,9 +30,9 @@ describe('Toggle Button', () => { ...@@ -30,9 +30,9 @@ describe('Toggle Button', () => {
expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true'); expect(vm.$el.querySelector('input').getAttribute('value')).toEqual('true');
}); });
it('renders Enabled and Disabled text data attributes', () => { it('renders input status icon', () => {
expect(vm.$el.querySelector('button').getAttribute('data-enabled-text')).toEqual('Enabled'); expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1);
expect(vm.$el.querySelector('button').getAttribute('data-disabled-text')).toEqual('Disabled'); expect(vm.$el.querySelectorAll('svg.s16.toggle-icon-svg').length).toEqual(1);
}); });
}); });
...@@ -49,6 +49,14 @@ describe('Toggle Button', () => { ...@@ -49,6 +49,14 @@ describe('Toggle Button', () => {
expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true); expect(vm.$el.querySelector('button').classList.contains('is-checked')).toEqual(true);
}); });
it('sets aria-label representing toggle state', () => {
vm.value = true;
expect(vm.ariaLabel).toEqual('Toggle Status: ON');
vm.value = false;
expect(vm.ariaLabel).toEqual('Toggle Status: OFF');
});
it('emits change event when clicked', () => { it('emits change event when clicked', () => {
vm.$el.querySelector('button').click(); vm.$el.querySelector('button').click();
......
...@@ -41,7 +41,8 @@ describe Gitlab::Git::GitlabProjects do ...@@ -41,7 +41,8 @@ describe Gitlab::Git::GitlabProjects do
end end
it "fails if the source path doesn't exist" do it "fails if the source path doesn't exist" do
expect(logger).to receive(:error).with("mv-project failed: source path <#{tmp_repos_path}/bad-src.git> does not exist.") expected_source_path = File.join(tmp_repos_path, 'bad-src.git')
expect(logger).to receive(:error).with("mv-project failed: source path <#{expected_source_path}> does not exist.")
result = build_gitlab_projects(tmp_repos_path, 'bad-src.git').mv_project('repo.git') result = build_gitlab_projects(tmp_repos_path, 'bad-src.git').mv_project('repo.git')
expect(result).to be_falsy expect(result).to be_falsy
...@@ -50,7 +51,8 @@ describe Gitlab::Git::GitlabProjects do ...@@ -50,7 +51,8 @@ describe Gitlab::Git::GitlabProjects do
it 'fails if the destination path already exists' do it 'fails if the destination path already exists' do
FileUtils.mkdir_p(File.join(tmp_repos_path, 'already-exists.git')) FileUtils.mkdir_p(File.join(tmp_repos_path, 'already-exists.git'))
message = "mv-project failed: destination path <#{tmp_repos_path}/already-exists.git> already exists." expected_distination_path = File.join(tmp_repos_path, 'already-exists.git')
message = "mv-project failed: destination path <#{expected_distination_path}> already exists."
expect(logger).to receive(:error).with(message) expect(logger).to receive(:error).with(message)
expect(gl_projects.mv_project('already-exists.git')).to be_falsy expect(gl_projects.mv_project('already-exists.git')).to be_falsy
......
...@@ -41,7 +41,6 @@ describe Gitlab::LDAP::User do ...@@ -41,7 +41,6 @@ describe Gitlab::LDAP::User do
it "does not mark existing ldap user as changed" do it "does not mark existing ldap user as changed" do
create(:omniauth_user, email: 'john@example.com', extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', provider: 'ldapmain') create(:omniauth_user, email: 'john@example.com', extern_uid: 'uid=john smith,ou=people,dc=example,dc=com', provider: 'ldapmain')
ldap_user.gl_user.user_synced_attributes_metadata(provider: 'ldapmain', email: true)
expect(ldap_user.changed?).to be_falsey expect(ldap_user.changed?).to be_falsey
end end
end end
...@@ -147,11 +146,15 @@ describe Gitlab::LDAP::User do ...@@ -147,11 +146,15 @@ describe Gitlab::LDAP::User do
expect(ldap_user.gl_user.email).to eq(info[:email]) expect(ldap_user.gl_user.email).to eq(info[:email])
end end
it "has user_synced_attributes_metadata email set to true" do it "has email set as synced" do
expect(ldap_user.gl_user.user_synced_attributes_metadata.email_synced).to be_truthy expect(ldap_user.gl_user.user_synced_attributes_metadata.email_synced).to be_truthy
end end
it "has synced_attribute_provider set to ldapmain" do it "has email set as read-only" do
expect(ldap_user.gl_user.read_only_attribute?(:email)).to be_truthy
end
it "has synced attributes provider set to ldapmain" do
expect(ldap_user.gl_user.user_synced_attributes_metadata.provider).to eql 'ldapmain' expect(ldap_user.gl_user.user_synced_attributes_metadata.provider).to eql 'ldapmain'
end end
end end
...@@ -165,9 +168,13 @@ describe Gitlab::LDAP::User do ...@@ -165,9 +168,13 @@ describe Gitlab::LDAP::User do
expect(ldap_user.gl_user.temp_oauth_email?).to be_truthy expect(ldap_user.gl_user.temp_oauth_email?).to be_truthy
end end
it "has synced attribute email set to false" do it "has email set as not synced" do
expect(ldap_user.gl_user.user_synced_attributes_metadata.email_synced).to be_falsey expect(ldap_user.gl_user.user_synced_attributes_metadata.email_synced).to be_falsey
end end
it "does not have email set as read-only" do
expect(ldap_user.gl_user.read_only_attribute?(:email)).to be_falsey
end
end end
end end
......
...@@ -202,11 +202,13 @@ describe Gitlab::OAuth::User do ...@@ -202,11 +202,13 @@ describe Gitlab::OAuth::User do
end end
context "and no account for the LDAP user" do context "and no account for the LDAP user" do
it "creates a user with dual LDAP and omniauth identities" do before do
allow(Gitlab::LDAP::Person).to receive(:find_by_uid).and_return(ldap_user) allow(Gitlab::LDAP::Person).to receive(:find_by_uid).and_return(ldap_user)
oauth_user.save oauth_user.save
end
it "creates a user with dual LDAP and omniauth identities" do
expect(gl_user).to be_valid expect(gl_user).to be_valid
expect(gl_user.username).to eql uid expect(gl_user.username).to eql uid
expect(gl_user.email).to eql 'johndoe@example.com' expect(gl_user.email).to eql 'johndoe@example.com'
...@@ -219,6 +221,18 @@ describe Gitlab::OAuth::User do ...@@ -219,6 +221,18 @@ describe Gitlab::OAuth::User do
] ]
) )
end end
it "has email set as synced" do
expect(gl_user.user_synced_attributes_metadata.email_synced).to be_truthy
end
it "has email set as read-only" do
expect(gl_user.read_only_attribute?(:email)).to be_truthy
end
it "has synced attributes provider set to ldapmain" do
expect(gl_user.user_synced_attributes_metadata.provider).to eql 'ldapmain'
end
end end
context "and LDAP user has an account already" do context "and LDAP user has an account already" do
...@@ -440,11 +454,15 @@ describe Gitlab::OAuth::User do ...@@ -440,11 +454,15 @@ describe Gitlab::OAuth::User do
expect(gl_user.email).to eq(info_hash[:email]) expect(gl_user.email).to eq(info_hash[:email])
end end
it "has external_attributes set to true" do it "has email set as synced" do
expect(gl_user.user_synced_attributes_metadata).not_to be_nil expect(gl_user.user_synced_attributes_metadata.email_synced).to be_truthy
end
it "has email set as read-only" do
expect(gl_user.read_only_attribute?(:email)).to be_truthy
end end
it "has attributes_provider set to my-provider" do it "has synced attributes provider set to my-provider" do
expect(gl_user.user_synced_attributes_metadata.provider).to eql 'my-provider' expect(gl_user.user_synced_attributes_metadata.provider).to eql 'my-provider'
end end
end end
...@@ -458,10 +476,13 @@ describe Gitlab::OAuth::User do ...@@ -458,10 +476,13 @@ describe Gitlab::OAuth::User do
expect(gl_user.email).not_to eq(info_hash[:email]) expect(gl_user.email).not_to eq(info_hash[:email])
end end
it "has user_synced_attributes_metadata set to nil" do it "has email set as not synced" do
expect(gl_user.user_synced_attributes_metadata.provider).to eql 'my-provider'
expect(gl_user.user_synced_attributes_metadata.email_synced).to be_falsey expect(gl_user.user_synced_attributes_metadata.email_synced).to be_falsey
end end
it "does not have email set as read-only" do
expect(gl_user.read_only_attribute?(:email)).to be_falsey
end
end end
end end
...@@ -508,11 +529,15 @@ describe Gitlab::OAuth::User do ...@@ -508,11 +529,15 @@ describe Gitlab::OAuth::User do
expect(gl_user.email).to eq(info_hash[:email]) expect(gl_user.email).to eq(info_hash[:email])
end end
it "has email_synced_attribute set to true" do it "has email set as synced" do
expect(gl_user.user_synced_attributes_metadata.email_synced).to be(true) expect(gl_user.user_synced_attributes_metadata.email_synced).to be(true)
end end
it "has my-provider as attributes_provider" do it "has email set as read-only" do
expect(gl_user.read_only_attribute?(:email)).to be_truthy
end
it "has synced attributes provider set to my-provider" do
expect(gl_user.user_synced_attributes_metadata.provider).to eql 'my-provider' expect(gl_user.user_synced_attributes_metadata.provider).to eql 'my-provider'
end end
end end
...@@ -524,7 +549,14 @@ describe Gitlab::OAuth::User do ...@@ -524,7 +549,14 @@ describe Gitlab::OAuth::User do
it "does not update the user email" do it "does not update the user email" do
expect(gl_user.email).not_to eq(info_hash[:email]) expect(gl_user.email).not_to eq(info_hash[:email])
expect(gl_user.user_synced_attributes_metadata.email_synced).to be(false) end
it "has email set as not synced" do
expect(gl_user.user_synced_attributes_metadata.email_synced).to be_falsey
end
it "does not have email set as read-only" do
expect(gl_user.read_only_attribute?(:email)).to be_falsey
end end
end end
end end
......
...@@ -1061,7 +1061,7 @@ describe Repository do ...@@ -1061,7 +1061,7 @@ describe Repository do
it 'runs without errors' do it 'runs without errors' do
# old_rev is an ancestor of new_rev # old_rev is an ancestor of new_rev
expect(repository.rugged.merge_base(old_rev, new_rev)).to eq(old_rev) expect(repository.merge_base(old_rev, new_rev)).to eq(old_rev)
# old_rev is not a direct ancestor (parent) of new_rev # old_rev is not a direct ancestor (parent) of new_rev
expect(repository.rugged.lookup(new_rev).parent_ids).not_to include(old_rev) expect(repository.rugged.lookup(new_rev).parent_ids).not_to include(old_rev)
...@@ -1083,7 +1083,7 @@ describe Repository do ...@@ -1083,7 +1083,7 @@ describe Repository do
it 'raises an exception' do it 'raises an exception' do
# The 'master' branch is NOT an ancestor of new_rev. # The 'master' branch is NOT an ancestor of new_rev.
expect(repository.rugged.merge_base(old_rev, new_rev)).not_to eq(old_rev) expect(repository.merge_base(old_rev, new_rev)).not_to eq(old_rev)
# Updating 'master' to new_rev would lose the commits on 'master' that # Updating 'master' to new_rev would lose the commits on 'master' that
# are not contained in new_rev. This should not be allowed. # are not contained in new_rev. This should not be allowed.
......
...@@ -213,7 +213,7 @@ describe MergeRequests::Conflicts::ResolveService do ...@@ -213,7 +213,7 @@ describe MergeRequests::Conflicts::ResolveService do
MergeRequests::Conflicts::ListService.new(merge_request).conflicts.resolver MergeRequests::Conflicts::ListService.new(merge_request).conflicts.resolver
end end
let(:regex_conflict) do let(:regex_conflict) do
resolver.conflict_for_path('files/ruby/regex.rb', 'files/ruby/regex.rb') resolver.conflict_for_path(resolver.conflicts, 'files/ruby/regex.rb', 'files/ruby/regex.rb')
end end
let(:invalid_params) do let(:invalid_params) do
......
require 'spec_helper'
describe 'projects/tree/_blob_item' do
let(:project) { create(:project, :repository) }
let(:repository) { project.repository }
let(:blob_item) { Gitlab::Git::Tree.where(repository, SeedRepo::Commit::ID, 'files/ruby').first }
before do
assign(:project, project)
assign(:repository, repository)
assign(:id, File.join('master', ''))
assign(:lfs_blob_ids, [])
end
it 'renders blob item' do
render_partial(blob_item)
expect(rendered).to have_content(blob_item.name)
expect(rendered).not_to have_selector('.label-lfs', text: 'LFS')
end
describe 'LFS blob' do
before do
assign(:lfs_blob_ids, [blob_item].map(&:id))
render_partial(blob_item)
end
it 'renders LFS badge' do
expect(rendered).to have_selector('.label-lfs', text: 'LFS')
end
end
def render_partial(blob_item)
render partial: 'projects/tree/blob_item', locals: {
blob_item: blob_item,
type: 'blob'
}
end
end
...@@ -9,6 +9,7 @@ describe 'projects/tree/show' do ...@@ -9,6 +9,7 @@ describe 'projects/tree/show' do
before do before do
assign(:project, project) assign(:project, project)
assign(:repository, repository) assign(:repository, repository)
assign(:lfs_blob_ids, [])
allow(view).to receive(:can?).and_return(true) allow(view).to receive(:can?).and_return(true)
allow(view).to receive(:can_collaborate_with_project?).and_return(true) allow(view).to receive(:can_collaborate_with_project?).and_return(true)
......
...@@ -54,9 +54,9 @@ ...@@ -54,9 +54,9 @@
lodash "^4.2.0" lodash "^4.2.0"
to-fast-properties "^2.0.0" to-fast-properties "^2.0.0"
"@gitlab-org/gitlab-svgs@^1.2.0": "@gitlab-org/gitlab-svgs@^1.3.0":
version "1.2.0" version "1.3.0"
resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.2.0.tgz#0b1181b5d2dd56a959528529750417c5f49159ca" resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.3.0.tgz#07f2aa75d6e0e857eaa20c38a3bad7e6c22c420c"
"@types/jquery@^2.0.40": "@types/jquery@^2.0.40":
version "2.0.48" version "2.0.48"
......
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