Commit dd8d40c3 authored by Rémy Coutable's avatar Rémy Coutable

Merge branch 'ce-upstream' into 'master'

CE upstream

Closes gitaly#109 and gitlab-ce#28515

See merge request !1371
parents ee4d5504 24177b42
......@@ -423,3 +423,4 @@ cache gems:
- vendor/cache
only:
- master@gitlab-org/gitlab-ce
- master@gitlab-org/gitlab-ee
......@@ -81,7 +81,7 @@ gem 'kaminari', '~> 0.17.0'
gem 'hamlit', '~> 2.6.1'
# Files attachments
gem 'carrierwave', '~> 0.10.0'
gem 'carrierwave', '~> 0.11.0'
# Drag and Drop UI
gem 'dropzonejs-rails', '~> 0.7.1'
......@@ -115,7 +115,7 @@ gem 'faraday_middleware-aws-signers-v4'
gem 'html-pipeline', '~> 1.11.0'
gem 'deckar01-task_list', '1.0.6', require: 'task_list/railtie'
gem 'gitlab-markup', '~> 1.5.1'
gem 'redcarpet', '~> 3.3.3'
gem 'redcarpet', '~> 3.4'
gem 'RedCloth', '~> 4.3.2'
gem 'rdoc', '~> 4.2'
gem 'org-ruby', '~> 0.9.12'
......@@ -241,7 +241,7 @@ gem 'uglifier', '~> 2.7.2'
gem 'addressable', '~> 2.3.8'
gem 'bootstrap-sass', '~> 3.3.0'
gem 'font-awesome-rails', '~> 4.6.1'
gem 'font-awesome-rails', '~> 4.7'
gem 'gemojione', '~> 3.0'
gem 'gon', '~> 6.1.0'
gem 'jquery-atwho-rails', '~> 1.3.2'
......
......@@ -111,11 +111,12 @@ GEM
capybara-screenshot (1.0.11)
capybara (>= 1.0, < 3)
launchy
carrierwave (0.10.0)
carrierwave (0.11.2)
activemodel (>= 3.2.0)
activesupport (>= 3.2.0)
json (>= 1.7)
mime-types (>= 1.16)
mimemagic (>= 0.3.0)
cause (0.1)
charlock_holmes (0.7.3)
chronic (0.10.2)
......@@ -255,7 +256,7 @@ GEM
fog-xml (0.1.2)
fog-core
nokogiri (~> 1.5, >= 1.5.11)
font-awesome-rails (4.6.1.0)
font-awesome-rails (4.7.0.1)
railties (>= 3.2, < 5.1)
foreman (0.78.0)
thor (~> 0.19.1)
......@@ -619,7 +620,7 @@ GEM
recaptcha (3.0.0)
json
recursive-open-struct (1.0.0)
redcarpet (3.3.3)
redcarpet (3.4.0)
redis (3.2.2)
redis-actionpack (5.0.1)
actionpack (>= 4.0, < 6)
......@@ -887,7 +888,7 @@ DEPENDENCIES
bundler-audit (~> 0.5.0)
capybara (~> 2.6.2)
capybara-screenshot (~> 1.0.0)
carrierwave (~> 0.10.0)
carrierwave (~> 0.11.0)
charlock_holmes (~> 0.7.3)
chronic (~> 0.10.2)
chronic_duration (~> 0.10.6)
......@@ -918,7 +919,7 @@ DEPENDENCIES
fog-local (~> 0.3)
fog-openstack (~> 0.1)
fog-rackspace (~> 0.1.1)
font-awesome-rails (~> 4.6.1)
font-awesome-rails (~> 4.7)
foreman (~> 0.78.0)
fuubar (~> 2.0.0)
gemnasium-gitlab-service (~> 0.2)
......@@ -1000,7 +1001,7 @@ DEPENDENCIES
rblineprof (~> 0.3.6)
rdoc (~> 4.2)
recaptcha (~> 3.0)
redcarpet (~> 3.3.3)
redcarpet (~> 3.4)
redis (~> 3.2)
redis-namespace (~> 1.5.2)
redis-rails (~> 5.0.1)
......
class BindInOut {
constructor(bindIn, bindOut) {
this.in = bindIn;
this.out = bindOut;
this.eventWrapper = {};
this.eventType = /(INPUT|TEXTAREA)/.test(bindIn.tagName) ? 'keyup' : 'change';
}
addEvents() {
this.eventWrapper.updateOut = this.updateOut.bind(this);
this.in.addEventListener(this.eventType, this.eventWrapper.updateOut);
return this;
}
updateOut() {
this.out.textContent = this.in.value;
return this;
}
removeEvents() {
this.in.removeEventListener(this.eventType, this.eventWrapper.updateOut);
return this;
}
static initAll() {
const ins = document.querySelectorAll('*[data-bind-in]');
return [].map.call(ins, anIn => BindInOut.init(anIn));
}
static init(anIn, anOut) {
const out = anOut || document.querySelector(`*[data-bind-out="${anIn.dataset.bindIn}"]`);
if (!out) return null;
const bindInOut = new BindInOut(anIn, out);
return bindInOut.addEvents().updateOut();
}
}
export default BindInOut;
......@@ -21,8 +21,7 @@
// %a.js-toggle-button
// %div.js-toggle-content
//
$('body').on('click', '.js-toggle-button', function(e) {
e.preventDefault();
$('body').on('click', '.js-toggle-button', function() {
toggleContainer($(this).closest('.js-toggle-container'));
});
......
......@@ -113,7 +113,7 @@ require('./lib/utils/common_utils');
return `<dl>\n${lines.join('\n')}\n</dl>`;
},
'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr'(el, text) {
'sub, dt, dd, kbd, q, samp, var, ruby, rt, rp, abbr, summary, details'(el, text) {
const tag = el.nodeName.toLowerCase();
return `<${tag}>${text}</${tag}>`;
},
......
......@@ -38,6 +38,7 @@ import PrometheusGraph from './monitoring/prometheus_graph'; // TODO: Maybe Make
/* global WeightSelect */
/* global AdminEmailSelect */
import BindInOut from './behaviors/bind_in_out';
import GroupsList from './groups_list';
import ProjectsList from './projects_list';
......@@ -233,9 +234,14 @@ const UserCallout = require('./user_callout');
new UsersSelect();
break;
case 'groups:new':
case 'admin:groups:new':
case 'groups:create':
case 'admin:groups:create':
BindInOut.initAll();
case 'groups:new':
case 'admin:groups:new':
case 'groups:edit':
case 'admin:groups:edit':
case 'admin:groups:new':
new GroupAvatar();
break;
case 'projects:tree:show':
......
/* eslint-disable func-names, space-before-function-paren */
/*= require raphael */
/*= require g.raphael */
/*= require g.bar */
(function() {
}).call(window);
......@@ -49,6 +49,7 @@ require('./behaviors/details_behavior');
require('./behaviors/quick_submit');
require('./behaviors/requires_input');
require('./behaviors/toggler_behavior');
require('./behaviors/bind_in_out');
// blob
require('./blob/blob_ci_yaml');
......
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, quotes, quote-props, prefer-template, comma-dangle, max-len */
/* global BranchGraph */
(function() {
this.Network = (function() {
function Network(opts) {
var vph;
$("#filter_ref").click(function() {
return $(this).closest('form').submit();
});
this.branch_graph = new BranchGraph($(".network-graph"), opts);
vph = $(window).height() - 250;
$('.network-graph').css({
'height': vph + 'px'
});
}
import BranchGraph from './branch_graph';
return Network;
})();
}).call(window);
export default (function() {
function Network(opts) {
var vph;
$("#filter_ref").click(function() {
return $(this).closest('form').submit();
});
this.branch_graph = new BranchGraph($(".network-graph"), opts);
vph = $(window).height() - 250;
$('.network-graph').css({
'height': vph + 'px'
});
}
return Network;
})();
/* eslint-disable func-names, space-before-function-paren, prefer-arrow-callback, quotes, no-var, vars-on-top, camelcase, comma-dangle, consistent-return, max-len */
/* global Network */
/* global ShortcutsNetwork */
require('./branch_graph');
require('./network');
import Network from './network';
(function() {
$(function() {
if (!$(".network-graph").length) return;
$(function() {
if (!$(".network-graph").length) return;
var network_graph;
network_graph = new Network({
url: $(".network-graph").attr('data-url'),
commit_url: $(".network-graph").attr('data-commit-url'),
ref: $(".network-graph").attr('data-ref'),
commit_id: $(".network-graph").attr('data-commit-id')
});
return new ShortcutsNetwork(network_graph.branch_graph);
var network_graph;
network_graph = new Network({
url: $(".network-graph").attr('data-url'),
commit_url: $(".network-graph").attr('data-commit-url'),
ref: $(".network-graph").attr('data-ref'),
commit_id: $(".network-graph").attr('data-commit-id')
});
}).call(window);
return new ShortcutsNetwork(network_graph.branch_graph);
});
import Raphael from 'raphael/raphael';
Raphael.prototype.commitTooltip = function commitTooltip(x, y, commit) {
const boxWidth = 300;
const icon = this.image(gon.relative_url_root + commit.author.icon, x, y, 20, 20);
const nameText = this.text(x + 25, y + 10, commit.author.name);
const idText = this.text(x, y + 35, commit.id);
const messageText = this.text(x, y + 50, commit.message.replace(/\r?\n/g, ' \n '));
const textSet = this.set(icon, nameText, idText, messageText).attr({
'text-anchor': 'start',
font: '12px Monaco, monospace',
});
nameText.attr({
font: '14px Arial',
'font-weight': 'bold',
});
idText.attr({
fill: '#AAA',
});
messageText.node.style['white-space'] = 'pre';
this.textWrap(messageText, boxWidth - 50);
const rect = this.rect(x - 10, y - 10, boxWidth, 100, 4).attr({
fill: '#FFF',
stroke: '#000',
'stroke-linecap': 'round',
'stroke-width': 2,
});
const tooltip = this.set(rect, textSet);
rect.attr({
height: tooltip.getBBox().height + 10,
width: tooltip.getBBox().width + 10,
});
tooltip.transform(['t', 20, 20]);
return tooltip;
};
Raphael.prototype.textWrap = function testWrap(t, width) {
const content = t.attr('text');
const abc = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
t.attr({
text: abc,
});
const letterWidth = t.getBBox().width / abc.length;
t.attr({
text: content,
});
const words = content.split(' ');
let x = 0;
const s = [];
for (let j = 0, len = words.length; j < len; j += 1) {
const word = words[j];
if (x + (word.length * letterWidth) > width) {
s.push('\n');
x = 0;
}
if (word === '\n') {
s.push('\n');
x = 0;
} else {
s.push(`${word} `);
x += word.length * letterWidth;
}
}
t.attr({
text: s.join('').trim(),
});
const b = t.getBBox();
const h = Math.abs(b.y2) + 1;
return t.attr({
y: h,
});
};
export default Raphael;
......@@ -198,7 +198,7 @@ require('./task_list');
this.refreshing = true;
return $.ajax({
url: this.notes_url,
data: "last_fetched_at=" + this.last_fetched_at,
headers: { "X-Last-Fetched-At": this.last_fetched_at },
dataType: "json",
success: (function(_this) {
return function(data) {
......
......@@ -16,6 +16,9 @@ require('./shortcuts');
Mousetrap.bind('g p', function() {
return ShortcutsNavigation.findAndFollowLink('.shortcuts-project');
});
Mousetrap.bind('g e', function() {
return ShortcutsNavigation.findAndFollowLink('.shortcuts-project-activity');
});
Mousetrap.bind('g f', function() {
return ShortcutsNavigation.findAndFollowLink('.shortcuts-tree');
});
......@@ -28,6 +31,9 @@ require('./shortcuts');
Mousetrap.bind('g n', function() {
return ShortcutsNavigation.findAndFollowLink('.shortcuts-network');
});
Mousetrap.bind('g g', function() {
return ShortcutsNavigation.findAndFollowLink('.shortcuts-repository-charts');
});
Mousetrap.bind('g i', function() {
return ShortcutsNavigation.findAndFollowLink('.shortcuts-issues');
});
......
......@@ -60,6 +60,15 @@
});
};
$('.assign-to-me-link').on('click', (e) => {
e.preventDefault();
$(e.currentTarget).hide();
const $input = $(`input[name="${$dropdown.data('field-name')}"]`);
$input.val(gon.current_user_id);
selectedId = $input.val();
$dropdown.find('.dropdown-toggle-text').text(gon.current_user_fullname).removeClass('is-default');
});
$block.on('click', '.js-assign-yourself', function(e) {
e.preventDefault();
......@@ -199,6 +208,11 @@
if ($dropdown.hasClass('js-filter-bulk-update') || $dropdown.hasClass('js-issuable-form-dropdown')) {
e.preventDefault();
selectedId = user.id;
if (selectedId === gon.current_user_id) {
$('.assign-to-me-link').hide();
} else {
$('.assign-to-me-link').show();
}
return;
}
if ($el.closest('.add-issues-modal').length) {
......@@ -234,11 +248,16 @@
id: function (user) {
return user.id;
},
opened: function(e) {
const $el = $(e.currentTarget);
$el.find('.is-active').removeClass('is-active');
$el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active');
},
renderRow: function(user) {
var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username;
username = user.username ? "@" + user.username : "";
avatar = user.avatar_url ? user.avatar_url : false;
selected = user.id === selectedId ? "is-active" : "";
selected = user.id === parseInt(selectedId, 10) ? "is-active" : "";
img = "";
if (user.beforeDivider != null) {
"<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>";
......@@ -248,7 +267,7 @@
}
}
// split into three parts so we can remove the username section if nessesary
listWithName = "<li> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>";
listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>";
listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>";
listClosingTags = "</a> </li>";
if (username === '') {
......
......@@ -96,7 +96,7 @@
.dropdown-menu-toggle {
@extend .dropdown-toggle;
padding-right: 20px;
padding-right: 25px;
position: relative;
width: 163px;
text-overflow: ellipsis;
......
.filter-item {
margin-right: 6px;
vertical-align: top;
&.reset-filters {
......@@ -14,6 +13,20 @@
width: 132px;
}
}
.filter-item:not(:last-child) {
margin-right: 6px;
}
.sort-filter {
display: inline-block;
float: right;
}
.dropdown-menu-sort {
left: auto;
right: 0;
}
}
@media (max-width: $screen-xs-max) {
......
......@@ -260,24 +260,34 @@ header {
font-size: 18px;
.navbar-nav {
display: table;
table-layout: fixed;
width: 100%;
margin: 0;
float: none !important;
.visible-xs,
.visible-sm {
display: table-cell !important;
}
text-align: right;
}
.navbar-collapse {
padding-left: 5px;
.nav > li {
display: table-cell;
width: 1%;
.nav > li:not(.hidden-xs) {
display: table-cell!important;
width: 25%;
a {
margin-right: 8px;
}
}
}
}
.header-user-dropdown-toggle {
text-align: center;
}
.header-user-avatar {
float: none;
}
}
.header-user {
......
......@@ -8,6 +8,19 @@ body {
&.navless {
background-color: $white-light !important;
}
&.card-content {
background-color: $gray-darker;
.content-wrapper {
padding: 0;
.container-fluid,
.container-limited {
background-color: $gray-darker;
}
}
}
}
.container {
......
......@@ -86,6 +86,16 @@
position: fixed;
}
/*
* Fix <summary> elements on firefox
* See https://github.com/necolas/normalize.css/issues/640
* and https://github.com/twbs/bootstrap/issues/21060
*
*/
summary {
display: list-item;
}
@import "bootstrap/responsive-utilities";
// Labels
......
......@@ -20,8 +20,9 @@
outline: none;
resize: none;
height: 100vh;
max-height: calc(100vh - 10px);
max-width: 900px;
margin: 0 auto;
margin: 0 auto 10px;
}
.zen-control-leave {
......
......@@ -121,3 +121,19 @@
table.pipeline-project-metrics tr td {
padding: $gl-padding;
}
.mattermost-icon svg {
width: 16px;
height: 16px;
vertical-align: text-bottom;
}
.mattermost-team-name {
color: $gl-text-color-secondary;
}
.mattermost-info {
display: block;
color: $gl-text-color-secondary;
margin-top: 10px;
}
......@@ -3,7 +3,6 @@
*
*/
.mr-state-widget {
background: $gray-light;
color: $gl-text-color;
border: 1px solid $border-color;
border-radius: 2px;
......@@ -109,12 +108,17 @@
@media (max-width: $screen-xs-max) {
flex-wrap: wrap;
}
.ci-status-icon > .icon-link > svg {
width: 22px;
height: 22px;
}
}
.mr-widget-body,
.ci_widget,
.mr-widget-footer {
padding: $gl-padding;
padding: 16px;
}
.mr-widget-pipeline-graph {
......@@ -174,10 +178,6 @@
}
}
p:last-child {
margin-bottom: 0;
}
.btn-grouped {
margin-left: 0;
margin-right: 7px;
......@@ -340,8 +340,61 @@
}
}
.remove-message-pipes {
ul {
margin: 10px 0 0 12px;
padding: 0;
list-style: none;
border-left: 2px solid $border-color;
display: inline-block;
}
li {
position: relative;
margin: 0;
padding: 0;
display: block;
span {
margin-left: 15px;
max-height: 20px;
}
}
li::before {
content: '';
position: absolute;
border-top: 2px solid $border-color;
height: 1px;
top: 8px;
width: 8px;
}
li:last-child {
&::before {
top: 18px;
}
span {
display: block;
position: relative;
top: 5px;
margin-top: 5px;
}
}
}
.mr-source-target {
background-color: $gray-light;
line-height: 31px;
border-style: solid;
border-width: 1px;
border-color: $border-color;
border-top-right-radius: 3px;
border-top-left-radius: 3px;
border-bottom: none;
padding: 16px;
margin-bottom: -1px;
}
.panel-new-merge-request {
......@@ -426,6 +479,11 @@
}
}
.assign-to-me-link {
padding-left: 12px;
white-space: nowrap;
}
.table-holder {
.ci-table {
......@@ -437,6 +495,8 @@
}
.merged-buttons {
margin-top: 20px;
.btn {
float: left;
......
......@@ -494,11 +494,11 @@ a.deploy-project-label {
.project-stats {
font-size: 0;
text-align: center;
border-bottom: 1px solid $border-color;
.nav {
padding-top: 12px;
padding-bottom: 12px;
border-bottom: 1px solid $border-color;
}
.nav > li {
......@@ -645,30 +645,15 @@ pre.light-well {
}
.project-last-commit {
background-color: $gray-light;
border: 1px solid $border-color;
border-radius: $border-radius-base;
padding: 12px;
@media (min-width: $screen-sm-min) {
margin-top: $gl-padding;
}
&.container-fluid {
padding-top: 12px;
padding-bottom: 12px;
background-color: $gray-light;
border: 1px solid $border-color;
border-right-width: 0;
border-left-width: 0;
@media (min-width: $screen-sm-min) {
border-right-width: 1px;
border-left-width: 1px;
}
}
&.container-limited {
@media (min-width: 1281px) {
border-radius: $border-radius-base;
}
}
.ci-status {
margin-right: $gl-padding;
}
......
......@@ -170,7 +170,11 @@
@media (max-width: $screen-sm-max) {
.todos-filters {
.dropdown-menu-toggle {
width: 135px;
width: 130px;
}
.dropdown-menu-toggle-sort {
width: auto;
}
}
}
......@@ -200,10 +204,6 @@
}
.todos-filters {
.row-content-block {
padding-bottom: 50px;
}
.dropdown-menu-toggle {
width: 100%;
}
......
......@@ -147,6 +147,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:two_factor_grace_period,
:user_default_external,
:user_oauth_applications,
:unique_ips_limit_per_user,
:unique_ips_limit_time_window,
:unique_ips_limit_enabled,
:version_check_enabled,
:terminal_max_session_time,
......
......@@ -40,6 +40,10 @@ class ApplicationController < ActionController::Base
render_403
end
rescue_from Gitlab::Auth::TooManyIps do |e|
head :forbidden, retry_after: Gitlab::Auth::UniqueIpsLimiter.config.unique_ips_limit_time_window
end
def redirect_back_or_default(default: root_path, options: {})
redirect_to request.referer.present? ? :back : default, options
end
......
......@@ -32,7 +32,13 @@ class GroupsController < Groups::ApplicationController
@group = Groups::CreateService.new(current_user, group_params).execute
if @group.persisted?
redirect_to @group, notice: "Group '#{@group.name}' was successfully created."
notice = if @group.chat_team.present?
"Group '#{@group.name}' and its Mattermost team were successfully created."
else
"Group '#{@group.name}' was successfully created."
end
redirect_to @group, notice: notice
else
render action: "new"
end
......@@ -142,7 +148,9 @@ class GroupsController < Groups::ApplicationController
:request_access_enabled,
:share_with_group_lock,
:visibility_level,
:parent_id
:parent_id,
:create_chat_team,
:chat_team_name
]
end
......
......@@ -47,11 +47,14 @@ class ProfilesController < Profiles::ApplicationController
end
def update_username
@user.update_attributes(username: user_params[:username])
respond_to do |format|
format.js
if @user.update_attributes(username: user_params[:username])
options = { notice: "Username successfully changed" }
else
message = @user.errors.full_messages.uniq.join('. ')
options = { alert: "Username change failed - #{message}" }
end
redirect_back_or_default(default: { action: 'show' }, options: options)
end
private
......
......@@ -20,7 +20,7 @@ class Projects::BranchesController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
render json: @repository.branch_names
render json: @branches.map(&:name)
end
end
end
......
......@@ -211,6 +211,11 @@ class Projects::NotesController < Projects::ApplicationController
end
def find_current_user_notes
@notes = NotesFinder.new(project, current_user, params).execute.inc_author
@notes = NotesFinder.new(project, current_user, params.merge(last_fetched_at: last_fetched_at))
.execute.inc_author
end
def last_fetched_at
request.headers['X-Last-Fetched-At']
end
end
......@@ -34,16 +34,18 @@ class IssuableFinder
items = by_scope(items)
items = by_state(items)
items = by_group(items)
items = by_project(items)
items = by_search(items)
items = by_milestone(items)
items = by_assignee(items)
items = by_author(items)
items = by_label(items)
items = by_weight(items)
items = by_due_date(items)
items = by_non_archived(items)
items = by_iids(items)
items = by_milestone(items)
items = by_label(items)
# Filtering by project HAS TO be the last because we use the project IDs yielded by the issuable query thus far
items = by_project(items)
sort(items)
end
......@@ -109,8 +111,7 @@ class IssuableFinder
@project = project
end
def projects
return @projects if defined?(@projects)
def projects(items = nil)
return @projects = project if project?
projects =
......@@ -119,7 +120,7 @@ class IssuableFinder
elsif group
GroupProjectsFinder.new(group).execute(current_user)
else
ProjectsFinder.new.execute(current_user)
projects_finder.execute(current_user, item_project_ids(items))
end
@projects = projects.with_feature_available_for_user(klass, current_user).reorder(nil)
......@@ -259,9 +260,9 @@ class IssuableFinder
def by_project(items)
items =
if project?
items.of_projects(projects).references_project
elsif projects
items.merge(projects.reorder(nil)).join_project
items.of_projects(projects(items)).references_project
elsif projects(items)
items.merge(projects(items).reorder(nil)).join_project
else
items.none
end
......@@ -316,13 +317,14 @@ class IssuableFinder
if filter_by_no_milestone?
items = items.left_joins_milestones.where(milestone_id: [-1, nil])
elsif filter_by_upcoming_milestone?
upcoming_ids = Milestone.upcoming_ids_by_projects(projects)
upcoming_ids = Milestone.upcoming_ids_by_projects(projects(items))
items = items.left_joins_milestones.where(milestone_id: upcoming_ids)
else
items = items.with_milestone(params[:milestone_title])
items_projects = projects(items)
if projects
items = items.where(milestones: { project_id: projects })
if items_projects
items = items.where(milestones: { project_id: items_projects })
end
end
end
......@@ -336,9 +338,10 @@ class IssuableFinder
items = items.without_label
else
items = items.with_label(label_names, params[:sort])
items_projects = projects(items)
if projects
label_ids = LabelsFinder.new(current_user, project_ids: projects).execute(skip_authorization: true).select(:id)
if items_projects
label_ids = LabelsFinder.new(current_user, project_ids: items_projects).execute(skip_authorization: true).select(:id)
items = items.where(labels: { id: label_ids })
end
end
......@@ -423,4 +426,8 @@ class IssuableFinder
def current_user_related?
params[:scope] == 'created-by-me' || params[:scope] == 'authored' || params[:scope] == 'assigned-to-me'
end
def projects_finder
@projects_finder ||= ProjectsFinder.new
end
end
......@@ -41,4 +41,8 @@ class IssuesFinder < IssuableFinder
user_id: user.id,
project_ids: user.authorized_projects(Gitlab::Access::REPORTER).select(:id))
end
def item_project_ids(items)
items&.reorder(nil)&.select(:project_id)
end
end
......@@ -20,4 +20,10 @@ class MergeRequestsFinder < IssuableFinder
def klass
MergeRequest
end
private
def item_project_ids(items)
items&.reorder(nil)&.select(:target_project_id)
end
end
......@@ -12,29 +12,43 @@ module LicenseHelper
HistoricalData.max_historical_user_count
end
def license_message(signed_in: signed_in?, is_admin: (current_user && current_user.is_admin?))
@license_message ||=
# in_html is set to false from an initializer, which shouldn't try to render
# HTML links.
#
def license_message(signed_in: signed_in?, is_admin: (current_user && current_user.is_admin?), in_html: true)
@license_message =
if License.current
yes_license_message(signed_in, is_admin)
else
no_license_message(is_admin)
no_license_message(is_admin, in_html: in_html)
end
end
private
def no_license_message(is_admin)
def no_license_message(is_admin, in_html: true)
upload_a_license =
if in_html
link_to('Upload a license', new_admin_license_path)
else
'Upload a license'
end
message = []
message << 'No GitLab Enterprise Edition license has been provided yet.'
message << 'Pushing code and creation of issues and merge requests has been disabled.'
message <<
if is_admin
"#{link_to('Upload a license', new_admin_license_path)} in the admin area to activate this functionality."
"#{upload_a_license} in the admin area to activate this functionality."
else
'Ask an admin to upload a license to activate this functionality.'
end
content_tag(:p, message.join(' ').html_safe)
if in_html
content_tag(:p, message.join(' ').html_safe)
else
message.join(' ')
end
end
def yes_license_message(signed_in, is_admin)
......
module MattermostHelper
def mattermost_teams_options(teams)
teams_options = teams.map do |id, options|
[options['display_name'] || options['name'], id]
teams.map do |team|
[team['display_name'] || team['name'], team['id']]
end
teams_options.compact.unshift(['Select team...', '0'])
end
end
module TriggersHelper
def builds_trigger_url(project_id, ref: nil)
if ref.nil?
"#{Settings.gitlab.url}/api/v3/projects/#{project_id}/trigger/builds"
"#{Settings.gitlab.url}/api/v4/projects/#{project_id}/trigger/pipeline"
else
"#{Settings.gitlab.url}/api/v3/projects/#{project_id}/ref/#{ref}/trigger/builds"
"#{Settings.gitlab.url}/api/v4/projects/#{project_id}/ref/#{ref}/trigger/pipeline"
end
end
......
......@@ -10,4 +10,5 @@ class Appearance < ActiveRecord::Base
mount_uploader :logo, AttachmentUploader
mount_uploader :header_logo, AttachmentUploader
has_many :uploads, as: :model, dependent: :destroy
end
......@@ -65,6 +65,16 @@ class ApplicationSetting < ActiveRecord::Base
presence: true,
if: :akismet_enabled
validates :unique_ips_limit_per_user,
numericality: { greater_than_or_equal_to: 1 },
presence: true,
if: :unique_ips_limit_enabled
validates :unique_ips_limit_time_window,
numericality: { greater_than_or_equal_to: 0 },
presence: true,
if: :unique_ips_limit_enabled
validates :koding_url,
presence: true,
if: :koding_enabled
......@@ -203,6 +213,9 @@ class ApplicationSetting < ActiveRecord::Base
domain_whitelist: Settings.gitlab['domain_whitelist'],
gravatar_enabled: Settings.gravatar['enabled'],
help_page_text: nil,
unique_ips_limit_per_user: 10,
unique_ips_limit_time_window: 3600,
unique_ips_limit_enabled: false,
housekeeping_bitmaps_enabled: true,
housekeeping_enabled: true,
housekeeping_full_repack_period: 50,
......
class ChatTeam < ActiveRecord::Base
validates :team_id, presence: true
belongs_to :namespace
end
......@@ -64,6 +64,10 @@ module Ci
end
state_machine :status do
event :actionize do
transition created: :manual
end
after_transition any => [:pending] do |build|
build.run_after_commit do
BuildQueueWorker.perform_async(id)
......@@ -95,16 +99,21 @@ module Ci
.fabricate!
end
def manual?
self.when == 'manual'
end
def other_actions
pipeline.manual_actions.where.not(name: name)
end
def playable?
project.builds_enabled? && commands.present? && manual? && skipped?
project.builds_enabled? && has_commands? &&
action? && manual?
end
def action?
self.when == 'manual'
end
def has_commands?
commands.present?
end
def play(current_user)
......@@ -123,7 +132,7 @@ module Ci
end
def retryable?
project.builds_enabled? && commands.present? &&
project.builds_enabled? && has_commands? &&
(success? || failed? || canceled?)
end
......@@ -553,7 +562,7 @@ module Ci
]
variables << { key: 'CI_BUILD_TAG', value: ref, public: true } if tag?
variables << { key: 'CI_BUILD_TRIGGERED', value: 'true', public: true } if trigger_request
variables << { key: 'CI_BUILD_MANUAL', value: 'true', public: true } if manual?
variables << { key: 'CI_BUILD_MANUAL', value: 'true', public: true } if action?
variables
end
......
......@@ -49,6 +49,10 @@ module Ci
transition any - [:canceled] => :canceled
end
event :block do
transition any - [:manual] => :manual
end
# IMPORTANT
# Do not add any operations to this state_machine
# Create a separate worker for each new operation
......@@ -321,6 +325,7 @@ module Ci
when 'failed' then drop
when 'canceled' then cancel
when 'skipped' then skip
when 'manual' then block
end
end
end
......
......@@ -127,18 +127,15 @@ module Ci
def tick_runner_queue
SecureRandom.hex.tap do |new_update|
Gitlab::Redis.with do |redis|
redis.set(runner_queue_key, new_update, ex: RUNNER_QUEUE_EXPIRY_TIME)
end
::Gitlab::Workhorse.set_key_and_notify(runner_queue_key, new_update,
expire: RUNNER_QUEUE_EXPIRY_TIME, overwrite: true)
end
end
def ensure_runner_queue_value
Gitlab::Redis.with do |redis|
value = SecureRandom.hex
redis.set(runner_queue_key, value, ex: RUNNER_QUEUE_EXPIRY_TIME, nx: true)
redis.get(runner_queue_key)
end
new_value = SecureRandom.hex
::Gitlab::Workhorse.set_key_and_notify(runner_queue_key, new_value,
expire: RUNNER_QUEUE_EXPIRY_TIME, overwrite: false)
end
def is_runner_queue_value_latest?(value)
......
......@@ -5,10 +5,11 @@ module Ci
acts_as_paranoid
belongs_to :project, foreign_key: :gl_project_id
belongs_to :owner, class_name: "User"
has_many :trigger_requests, dependent: :destroy
validates :token, presence: true
validates :token, uniqueness: true
validates :token, presence: true, uniqueness: true
before_validation :set_default_values
......@@ -25,7 +26,11 @@ module Ci
end
def short_token
token[0...10]
token[0...4]
end
def can_show_token?(user)
owner.blank? || owner == user
end
end
end
......@@ -29,9 +29,11 @@ class CommitStatus < ActiveRecord::Base
end
scope :exclude_ignored, -> do
# We want to ignore failed_but_allowed jobs
# We want to ignore failed but allowed to fail jobs.
#
# TODO, we also skip ignored optional manual actions.
where("allow_failure = ? OR status IN (?)",
false, all_state_names - [:failed, :canceled])
false, all_state_names - [:failed, :canceled, :manual])
end
scope :retried, -> { where.not(id: latest) }
......@@ -42,11 +44,11 @@ class CommitStatus < ActiveRecord::Base
state_machine :status do
event :enqueue do
transition [:created, :skipped] => :pending
transition [:created, :skipped, :manual] => :pending
end
event :process do
transition skipped: :created
transition [:skipped, :manual] => :created
end
event :run do
......@@ -66,7 +68,7 @@ class CommitStatus < ActiveRecord::Base
end
event :cancel do
transition [:created, :pending, :running] => :canceled
transition [:created, :pending, :running, :manual] => :canceled
end
before_transition created: [:pending, :running] do |commit_status|
......@@ -86,7 +88,7 @@ class CommitStatus < ActiveRecord::Base
commit_status.run_after_commit do
pipeline.try do |pipeline|
if complete?
if complete? || manual?
PipelineProcessWorker.perform_async(pipeline.id)
else
PipelineUpdateWorker.perform_async(pipeline.id)
......
......@@ -2,22 +2,21 @@ module HasStatus
extend ActiveSupport::Concern
DEFAULT_STATUS = 'created'.freeze
AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped].freeze
STARTED_STATUSES = %w[running success failed skipped].freeze
BLOCKED_STATUS = 'manual'.freeze
AVAILABLE_STATUSES = %w[created pending running success failed canceled skipped manual].freeze
STARTED_STATUSES = %w[running success failed skipped manual].freeze
ACTIVE_STATUSES = %w[pending running].freeze
COMPLETED_STATUSES = %w[success failed canceled skipped].freeze
ORDERED_STATUSES = %w[failed pending running canceled success skipped].freeze
ORDERED_STATUSES = %w[manual failed pending running canceled success skipped].freeze
class_methods do
def status_sql
scope = if respond_to?(:exclude_ignored)
exclude_ignored
else
all
end
scope = respond_to?(:exclude_ignored) ? exclude_ignored : all
builds = scope.select('count(*)').to_sql
created = scope.created.select('count(*)').to_sql
success = scope.success.select('count(*)').to_sql
manual = scope.manual.select('count(*)').to_sql
pending = scope.pending.select('count(*)').to_sql
running = scope.running.select('count(*)').to_sql
skipped = scope.skipped.select('count(*)').to_sql
......@@ -30,7 +29,8 @@ module HasStatus
WHEN (#{builds})=(#{success})+(#{skipped}) THEN 'success'
WHEN (#{builds})=(#{success})+(#{skipped})+(#{canceled}) THEN 'canceled'
WHEN (#{builds})=(#{created})+(#{skipped})+(#{pending}) THEN 'pending'
WHEN (#{running})+(#{pending})+(#{created})>0 THEN 'running'
WHEN (#{running})+(#{pending})>0 THEN 'running'
WHEN (#{manual})>0 THEN 'manual'
ELSE 'failed'
END)"
end
......@@ -63,6 +63,7 @@ module HasStatus
state :success, value: 'success'
state :canceled, value: 'canceled'
state :skipped, value: 'skipped'
state :manual, value: 'manual'
end
scope :created, -> { where(status: 'created') }
......@@ -73,12 +74,13 @@ module HasStatus
scope :failed, -> { where(status: 'failed') }
scope :canceled, -> { where(status: 'canceled') }
scope :skipped, -> { where(status: 'skipped') }
scope :manual, -> { where(status: 'manual') }
scope :running_or_pending, -> { where(status: [:running, :pending]) }
scope :finished, -> { where(status: [:success, :failed, :canceled]) }
scope :failed_or_canceled, -> { where(status: [:failed, :canceled]) }
scope :cancelable, -> do
where(status: [:running, :pending, :created])
where(status: [:running, :pending, :created, :manual])
end
end
......@@ -94,6 +96,10 @@ module HasStatus
COMPLETED_STATUSES.include?(status)
end
def blocked?
BLOCKED_STATUS == status
end
private
def calculate_duration
......
......@@ -24,6 +24,11 @@ class ExternalIssue
def ==(other)
other.is_a?(self.class) && (to_s == other.to_s)
end
alias_method :eql?, :==
def hash
[self.class, to_s].hash
end
def project
@project
......
......@@ -40,6 +40,7 @@ class Group < Namespace
numericality: { only_integer: true, greater_than_or_equal_to: 0, allow_nil: true }
mount_uploader :avatar, AvatarUploader
has_many :uploads, as: :model, dependent: :destroy
after_create :post_create_hook
after_destroy :post_destroy_hook
......@@ -256,4 +257,14 @@ class Group < Namespace
def users_with_parents
User.where(id: members_with_parents.select(:user_id))
end
def mattermost_team_params
max_length = 59
{
name: path[0..max_length],
display_name: name[0..max_length],
type: public? ? 'O' : 'I' # Open vs Invite-only
}
end
end
......@@ -21,6 +21,7 @@ class Namespace < ActiveRecord::Base
belongs_to :parent, class_name: "Namespace"
has_many :children, class_name: "Namespace", foreign_key: :parent_id
has_one :chat_team, dependent: :destroy
validates :owner, presence: true, unless: ->(n) { n.type == "Group" }
validates :name,
......
......@@ -87,6 +87,7 @@ class Note < ActiveRecord::Base
before_validation :nullify_blank_type, :nullify_blank_line_code
before_validation :set_discussion_id
after_save :keep_around_commit, unless: :for_personal_snippet?
after_save :expire_etag_cache
class << self
def model_name
......@@ -278,4 +279,16 @@ class Note < ActiveRecord::Base
self.class.build_discussion_id(noteable_type, noteable_id || commit_id)
end
end
def expire_etag_cache
return unless for_issue?
key = Gitlab::Routing.url_helpers.namespace_project_noteable_notes_path(
noteable.project.namespace,
noteable.project,
target_type: noteable_type.underscore,
target_id: noteable.id
)
Gitlab::EtagCaching::Store.new.touch(key)
end
end
......@@ -236,6 +236,7 @@ class Project < ActiveRecord::Base
before_validation :mark_remote_mirrors_for_removal
mount_uploader :avatar, AvatarUploader
has_many :uploads, as: :model, dependent: :destroy
# Scopes
default_scope { where(pending_delete: false) }
......
class Upload < ActiveRecord::Base
# Upper limit for foreground checksum processing
CHECKSUM_THRESHOLD = 100.megabytes
belongs_to :model, polymorphic: true
validates :size, presence: true
validates :path, presence: true
validates :model, presence: true
validates :uploader, presence: true
before_save :calculate_checksum, if: :foreground_checksum?
after_commit :schedule_checksum, unless: :foreground_checksum?
def self.remove_path(path)
where(path: path).destroy_all
end
def self.record(uploader)
remove_path(uploader.relative_path)
create(
size: uploader.file.size,
path: uploader.relative_path,
model: uploader.model,
uploader: uploader.class.to_s
)
end
def absolute_path
return path unless relative_path?
uploader_class.absolute_path(self)
end
def calculate_checksum
return unless exist?
self.checksum = Digest::SHA256.file(absolute_path).hexdigest
end
def exist?
File.exist?(absolute_path)
end
private
def foreground_checksum?
size <= CHECKSUM_THRESHOLD
end
def schedule_checksum
UploadChecksumWorker.perform_async(id)
end
def relative_path?
!path.start_with?('/')
end
def uploader_class
Object.const_get(uploader)
end
end
......@@ -106,6 +106,7 @@ class User < ActiveRecord::Base
# Protected Branch Access
has_many :protected_branch_merge_access_levels, dependent: :destroy, class_name: ProtectedBranch::MergeAccessLevel
has_many :protected_branch_push_access_levels, dependent: :destroy, class_name: ProtectedBranch::PushAccessLevel
has_many :triggers, dependent: :destroy, class_name: 'Ci::Trigger', foreign_key: :owner_id
has_many :assigned_issues, dependent: :nullify, foreign_key: :assignee_id, class_name: "Issue"
has_many :assigned_merge_requests, dependent: :nullify, foreign_key: :assignee_id, class_name: "MergeRequest"
......@@ -201,6 +202,7 @@ class User < ActiveRecord::Base
end
mount_uploader :avatar, AvatarUploader
has_many :uploads, as: :model, dependent: :destroy
# Scopes
scope :admins, -> { where(admin: true) }
......
......@@ -12,7 +12,7 @@ class BuildEntity < Grape::Entity
path_to(:retry_namespace_project_build, build)
end
expose :play_path, if: ->(build, _) { build.manual? } do |build|
expose :play_path, if: ->(build, _) { build.playable? } do |build|
path_to(:play_namespace_project_build, build)
end
......
......@@ -3,7 +3,7 @@ module Ci
def execute(project, trigger, ref, variables = nil)
trigger_request = trigger.trigger_requests.create(variables: variables)
pipeline = Ci::CreatePipelineService.new(project, nil, ref: ref).
pipeline = Ci::CreatePipelineService.new(project, trigger.owner, ref: ref).
execute(ignore_skip_ci: true, trigger_request: trigger_request)
if pipeline.persisted?
trigger_request
......
......@@ -22,6 +22,8 @@ module Ci
def process_stage(index)
current_status = status_for_prior_stages(index)
return if HasStatus::BLOCKED_STATUS == current_status
if HasStatus::COMPLETED_STATUSES.include?(current_status)
created_builds_in_stage(index).select do |build|
Gitlab::OptimisticLocking.retry_lock(build) do |subject|
......@@ -33,7 +35,7 @@ module Ci
def process_build(build, current_status)
if valid_statuses_for_when(build.when).include?(current_status)
build.enqueue
build.action? ? build.actionize : build.enqueue
true
else
build.skip
......@@ -49,6 +51,8 @@ module Ci
%w[failed]
when 'always'
%w[success failed skipped]
when 'manual'
%w[success]
else
[]
end
......
......@@ -2,6 +2,7 @@ module Groups
class CreateService < Groups::BaseService
def initialize(user, params = {})
@current_user, @params = user, params.dup
@chat_team = @params.delete(:create_chat_team)
end
def execute
......@@ -24,9 +25,23 @@ module Groups
end
@group.name ||= @group.path.dup
if create_chat_team?
response = Mattermost::CreateTeamService.new(@group, current_user).execute
return @group if @group.errors.any?
@group.build_chat_team(name: response['name'], team_id: response['id'])
end
@group.save
@group.add_owner(current_user)
@group
end
private
def create_chat_team?
Gitlab.config.mattermost.enabled && @chat_team && group.chat_team.nil?
end
end
end
module Mattermost
class CreateTeamService < ::BaseService
def initialize(group, current_user)
@group, @current_user = group, current_user
end
def execute
# The user that creates the team will be Team Admin
Mattermost::Team.new(current_user).create(@group.mattermost_team_params)
rescue Mattermost::ClientError => e
@group.errors.add(:mattermost_team, e.message)
end
end
end
......@@ -34,6 +34,8 @@ module Projects
end
rescue => e
error(e.message)
ensure
build.erase_artifacts! unless build.has_expiring_artifacts?
end
private
......
class AttachmentUploader < GitlabUploader
include RecordsUploads
include UploaderHelper
storage :file
......
class AvatarUploader < GitlabUploader
include RecordsUploads
include UploaderHelper
storage :file
......
class FileUploader < GitlabUploader
include RecordsUploads
include UploaderHelper
MARKDOWN_PATTERN = %r{\!?\[.*?\]\(/uploads/(?<secret>[0-9a-f]{32})/(?<file>.*?)\)}
storage :file
def self.absolute_path(upload_record)
File.join(
self.dynamic_path_segment(upload_record.model),
upload_record.path
)
end
# Returns the part of `store_dir` that can change based on the model's current
# path
#
# This is used to build Upload paths dynamically based on the model's current
# namespace and path, allowing us to ignore renames or transfers.
#
# model - Object that responds to `path_with_namespace`
#
# Returns a String without a trailing slash
def self.dynamic_path_segment(model)
File.join(CarrierWave.root, base_dir, model.path_with_namespace)
end
attr_accessor :project
attr_reader :secret
......@@ -13,13 +35,21 @@ class FileUploader < GitlabUploader
end
def store_dir
File.join(base_dir, @project.path_with_namespace, @secret)
File.join(dynamic_path_segment, @secret)
end
def cache_dir
File.join(base_dir, 'tmp', @project.path_with_namespace, @secret)
end
def model
project
end
def relative_path
self.file.path.sub("#{dynamic_path_segment}/", '')
end
def to_markdown
to_h[:markdown]
end
......@@ -40,6 +70,10 @@ class FileUploader < GitlabUploader
private
def dynamic_path_segment
self.class.dynamic_path_segment(model)
end
def generate_secret
SecureRandom.hex
end
......
class GitlabUploader < CarrierWave::Uploader::Base
def self.absolute_path(upload_record)
File.join(CarrierWave.root, upload_record.path)
end
def self.base_dir
'uploads'
end
......@@ -18,4 +22,15 @@ class GitlabUploader < CarrierWave::Uploader::Base
def move_to_store
true
end
# Designed to be overridden by child uploaders that have a dynamic path
# segment -- that is, a path that changes based on mutable attributes of its
# associated model
#
# For example, `FileUploader` builds the storage path based on the associated
# project model's `path_with_namespace` value, which can change when the
# project or its containing namespace is moved or renamed.
def relative_path
self.file.path.sub("#{root}/", '')
end
end
module RecordsUploads
extend ActiveSupport::Concern
included do
after :store, :record_upload
before :remove, :destroy_upload
end
private
# After storing an attachment, create a corresponding Upload record
#
# NOTE: We're ignoring the argument passed to this callback because we want
# the `SanitizedFile` object from `CarrierWave::Uploader::Base#file`, not the
# `Tempfile` object the callback gets.
#
# Called `after :store`
def record_upload(_tempfile)
return unless file_storage?
return unless file.exists?
Upload.record(self)
end
# Before removing an attachment, destroy any Upload records at the same path
#
# Called `before :remove`
def destroy_upload(*args)
return unless file_storage?
return unless file
Upload.remove_path(relative_path)
end
end
......@@ -380,6 +380,29 @@
Generate API key at
%a{ href: 'http://www.akismet.com', target: 'blank' } http://www.akismet.com
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :unique_ips_limit_enabled do
= f.check_box :unique_ips_limit_enabled
Limit sign in from multiple ips
%span.help-block#unique_ip_help_block
Helps prevent malicious users hide their activity
.form-group
= f.label :unique_ips_limit_per_user, 'IPs per user', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :unique_ips_limit_per_user, class: 'form-control'
.help-block
Maximum number of unique IPs per user
.form-group
= f.label :unique_ips_limit_time_window, 'IP expiration time', class: 'control-label col-sm-2'
.col-sm-10
= f.number_field :unique_ips_limit_time_window, class: 'form-control'
.help-block
How many seconds an IP will be counted towards the limit
%fieldset
%legend Abuse reports
.form-group
......
......@@ -46,16 +46,16 @@
= hidden_field_tag(:action_id, params[:action_id])
= dropdown_tag(todo_actions_dropdown_label(params[:action_id], 'Action'), options: { toggle_class: 'js-action-search js-filter-submit', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit',
data: { data: todo_actions_options, default_label: 'Action' } })
.pull-right
.dropdown.inline.prepend-left-10
%button.dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
.filter-item.sort-filter
.dropdown
%button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', 'data-toggle' => 'dropdown' }
%span.light
- if @sort.present?
= sort_options_hash[@sort]
- else
= sort_title_recently_created
= icon('chevron-down')
%ul.dropdown-menu.dropdown-menu-align-right.dropdown-menu-sort
%ul.dropdown-menu.dropdown-menu-sort
%li
= link_to todos_filter_path(sort: sort_value_priority) do
= sort_title_priority
......
.form-group
= f.label :create_chat_team, class: 'control-label' do
%span.mattermost-icon
= custom_icon('icon_mattermost')
Mattermost
.col-sm-10
.checkbox.js-toggle-container
= f.label :create_chat_team do
.js-toggle-button= f.check_box(:create_chat_team, { checked: true }, true, false)
Create a Mattermost team for this group
%br
%small.light.js-toggle-content
Mattermost URL:
= Settings.mattermost.host
%span> /
%span{ "data-bind-out" => "create_chat_team" }
......@@ -16,6 +16,8 @@
= render 'shared/visibility_level', f: f, visibility_level: default_group_visibility, can_change_visibility_level: true, form_model: @group
= render 'create_chat_team', f: f if Gitlab.config.mattermost.enabled
.form-group
.col-sm-offset-2.col-sm-10
= render 'shared/group_tips'
......
......@@ -128,6 +128,12 @@
.key p
%td
Go to the project's home page
%tr
%td.shortcut
.key g
.key e
%td
Go to the project's activity feed
%tr
%td.shortcut
.key g
......@@ -152,6 +158,12 @@
.key n
%td
Go to network graph
%tr
%td.shortcut
.key g
.key g
%td
Go to repository charts
%tr
%td.shortcut
.key g
......
!!! 5
%html{ lang: "en", class: "#{page_class}" }
= render "layouts/head"
%body{ data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } }
%body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}" } }
= Gon::Base.render_data
= render "layouts/header/default", title: header_title
......
......@@ -19,7 +19,7 @@
%ul.nav.navbar-nav
%li.hidden-sm.hidden-xs
= render 'layouts/search' unless current_controller?(:search)
%li.visible-sm.visible-xs
%li.visible-sm-inline-block.visible-xs-inline-block
= link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('search')
- if current_user
......
......@@ -71,18 +71,30 @@
%span
Snippets
-# Global shortcut to network page for compatibility
-# Shortcut to Project > Activity
%li.hidden
= link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do
%span
Activity
-# Shortcut to Repository > Graph (formerly, Network)
- if project_nav_tab? :network
%li.hidden
= link_to namespace_project_network_path(@project.namespace, @project, current_ref), title: 'Network', class: 'shortcuts-network' do
Network
Graph
-# Shortcut to Repository > Charts (formerly, top-nav item "Graphs")
- unless @project.empty_repo?
%li.hidden
= link_to charts_namespace_project_graph_path(@project.namespace, @project, current_ref), title: 'Charts', class: 'shortcuts-repository-charts' do
Charts
-# Shortcut to create a new issue
-# Shortcut to Issues > New Issue
%li.hidden
= link_to new_namespace_project_issue_path(@project.namespace, @project), class: 'shortcuts-new-issue' do
Create a new issue
-# Shortcut to builds page
-# Shortcut to Pipelines > Jobs
- if project_nav_tab? :builds
%li.hidden
= link_to project_builds_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
......
......@@ -93,7 +93,7 @@
%p
Changing your username will change path to all personal projects!
.col-lg-9
= form_for @user, url: update_username_profile_path, method: :put, remote: true, html: {class: "update-username"} do |f|
= form_for @user, url: update_username_profile_path, method: :put, html: {class: "update-username"} do |f|
.form-group
= f.label :username, "Path", class: "label-light"
.input-group
......
- if @user.valid?
:plain
new Flash("Username successfully changed", "notice")
- else
- error = @user.errors.full_messages.first
:plain
new Flash("Username change failed - #{escape_javascript error.html_safe}", "alert")
......@@ -46,7 +46,7 @@
%span.label.label-info triggered
- if build.try(:allow_failure)
%span.label.label-danger allowed to fail
- if build.manual?
- if build.action?
%span.label.label-info manual
- if pipeline_link
......
= form_for [@project.namespace.becomes(Namespace), @project, @issue], html: { class: 'form-horizontal issue-form common-note-form js-quick-submit js-requires-input' } do |f|
= render 'shared/issuable/form', f: f, issuable: @issue
:javascript
$('.assign-to-me-link').on('click', function(e){
$('#issue_assignee_id').val("#{current_user.id}").trigger("change");
e.preventDefault();
});
......@@ -2,16 +2,15 @@
This service will be installed on the Mattermost instance at
%strong= link_to Gitlab.config.mattermost.host, Gitlab.config.mattermost.host
%hr
= form_for(:mattermost, method: :post, url: namespace_project_mattermost_path(@project.namespace, @project)) do |f|
= form_for(:mattermost, method: :post, url: namespace_project_mattermost_path(@project.namespace, @project), html: { class: 'js-requires-input'} ) do |f|
%h4 Team
%p
= @teams.one? ? 'The team' : 'Select the team'
where the slash commands will be used in
- selected_id = @teams.one? ? @teams.keys.first : 0
- options = mattermost_teams_options(@teams)
- options = options_for_select(options, selected_id)
= f.select(:team_id, options, {}, { class: 'form-control', disabled: @teams.one?, selected: selected_id })
= f.hidden_field(:team_id, value: selected_id) if @teams.one?
- selected_id = @teams.one? ? @teams.first['id'] : nil
- options = options_for_select(mattermost_teams_options(@teams), selected_id)
= f.select(:team_id, options, { include_blank: 'Select team...'}, { class: 'form-control', disabled: @teams.one?, selected: selected_id, required: true })
= f.hidden_field(:team_id, value: selected_id, required: true) if @teams.one?
.help-block
- if @teams.one?
This is the only available team.
......@@ -25,7 +24,7 @@
%hr
%h4 Command trigger word
%p Choose the word that will trigger commands
= f.text_field(:trigger, value: @project.path, class: 'form-control')
= f.text_field(:trigger, value: @project.path, class: 'form-control', required: true)
.help-block
%p
Trigger word must be unique, and can't begin with a slash or contain any spaces.
......
- @body_class = 'card-content'
.service-installation
.inline.pull-right
= custom_icon('mattermost_logo', size: 48)
......
= form_for [@project.namespace.becomes(Namespace), @project, @merge_request], html: { class: 'merge-request-form form-horizontal common-note-form js-requires-input js-quick-submit' } do |f|
= render 'shared/issuable/form', f: f, issuable: @merge_request
:javascript
$('.assign-to-me-link').on('click', function(e){
$('#merge_request_assignee_id').val("#{current_user.id}").trigger("change");
e.preventDefault();
});
......@@ -51,11 +51,6 @@
.mr-loading-status
= spinner
:javascript
$('.assign-to-me-link').on('click', function(e){
$('#merge_request_assignee_id').val("#{current_user.id}").trigger("change");
e.preventDefault();
});
:javascript
var merge_request = new MergeRequest({
action: "#{(@show_changes_tab ? 'new/diffs' : 'new')}"
......
......@@ -29,9 +29,9 @@
%li= link_to "Email Patches", merge_request_path(@merge_request, format: :patch)
%li= link_to "Plain Diff", merge_request_path(@merge_request, format: :diff)
.normal
%span Request to merge
%span <b>Request to merge</b>
%span.label-branch= source_branch_with_namespace(@merge_request)
%span into
%span <b>into</b>
%span.label-branch
= link_to_if @merge_request.target_branch_exists?, @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch)
- if @merge_request.open? && @merge_request.diverged_from_target_branch?
......
......@@ -2,7 +2,7 @@
.mr-widget-heading
- %w[success success_with_warnings skipped canceled failed running pending].each do |status|
.ci_widget{ class: "ci-#{status}", style: ("display:none" unless @pipeline.status == status) }
%div{ class: "ci-status-icon-#{status}" }
%div{ class: "ci-status-icon ci-status-icon-#{status}" }
= link_to namespace_project_pipeline_path(@pipeline.project.namespace, @pipeline.project, @pipeline.id), class: 'icon-link' do
= ci_icon_for_status(status)
%span
......
......@@ -7,28 +7,46 @@
by #{link_to_member(@project, @merge_request.merge_event.author, avatar: true)}
#{time_ago_with_tooltip(@merge_request.merge_event.created_at)}
- if !@merge_request.source_branch_exists? || params[:deleted_source_branch]
%p
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
The source branch has been removed.
.remove-message-pipes
%ul
%li
%span
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
%li
%span
The source branch has been removed.
= render 'projects/merge_requests/widget/merged_buttons'
- elsif @merge_request.can_remove_source_branch?(current_user)
.remove_source_branch_widget
%p
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
You can remove the source branch now.
.remove_source_branch_widget.remove-message-pipes
%ul
%li
%span
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
%li
%span
You can remove the source branch now.
= render 'projects/merge_requests/widget/merged_buttons', source_branch_exists: true
.remove_source_branch_widget.failed.hide
%p
Failed to remove source branch '#{@merge_request.source_branch}'.
.remove_source_branch_in_progress.hide
%p
= icon('spinner spin')
Removing source branch '#{@merge_request.source_branch}'. Please wait, this page will be automatically reloaded.
.remove_source_branch_widget.failed.remove-message-pipes.hide
%ul
%li
%span
Failed to remove source branch '#{@merge_request.source_branch}'.
.remove_source_branch_in_progress.remove-message-pipes.hide
%ul
%li
%span
= icon('spinner spin')
Removing source branch '#{@merge_request.source_branch}'.
%li
%span
Please wait, this page will be automatically reloaded.
- else
%p
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
= render 'projects/merge_requests/widget/merged_buttons'
.remove-message-pipes
%ul
%li
%span
The changes were merged into
#{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}.
= render 'projects/merge_requests/widget/merged_buttons'
......@@ -9,6 +9,6 @@
= icon('trash-o')
Remove Source Branch
- if mr_can_be_reverted
= revert_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "warning")
= revert_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "close")
- if mr_can_be_cherry_picked
= cherry_pick_commit_link(@merge_request.merge_commit, namespace_project_merge_request_path(@project.namespace, @project, @merge_request), btn_class: "default")
......@@ -3,20 +3,24 @@
- can_merge = @merge_request.can_be_merged_via_command_line_by?(current_user)
%h4.has-conflicts
= icon("exclamation-triangle")
This merge request contains merge conflicts
%p
= icon("exclamation-triangle")
This merge request contains merge conflicts
%p
To merge this request, resolve these conflicts
- if can_resolve && !can_resolve_in_ui
locally
or
- unless can_merge
ask someone with write access to this repository to
merge it locally.
.remove-message-pipes
%ul
%li
%span
To merge this request, resolve these conflicts
- if can_resolve && !can_resolve_in_ui
locally
or
- unless can_merge
ask someone with write access to this repository to
merge it locally.
- if (can_resolve && can_resolve_in_ui) || can_merge
.btn-group
.merged-buttons.clearfix
- if can_resolve && can_resolve_in_ui
= link_to "Resolve conflicts", conflicts_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: "btn"
- if can_merge
......
......@@ -4,20 +4,20 @@
%h4
Set by #{link_to_member(@project, @merge_request.merge_user, avatar: true)}
to be merged automatically when the pipeline succeeds.
%div
%p
= succeed '.' do
The changes will be
- if @merge_request.squash
squashed and
- if @project.merge_requests_ff_only_enabled
fast-forward
merged into
%span.label-branch= @merge_request.target_branch
- if @merge_request.remove_source_branch?
The source branch will be removed.
- else
The source branch will not be removed.
.remove-message-pipes
%ul
%li
%span
= succeed '.' do
The changes will be merged into #{link_to @merge_request.target_branch, namespace_project_commits_path(@project.namespace, @project, @merge_request.target_branch), class: "label-branch"}
- if @merge_request.remove_source_branch?
%li
%span
The source branch will be removed.
- else
%li
%span
The source branch will not be removed.
- remove_source_branch_button = !@merge_request.remove_source_branch? && @merge_request.can_remove_source_branch?(current_user) && @merge_request.merge_user == current_user
- user_can_cancel_automatic_merge = @merge_request.can_cancel_merge_when_pipeline_succeeds?(current_user)
......
- page_title "Graph", @ref
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/raphael.js')
= page_specific_javascript_bundle_tag('network')
= render "projects/commits/head"
= render "head"
......
......@@ -23,4 +23,4 @@
to post a comment
:javascript
var notes = new Notes("#{namespace_project_notes_path(namespace_id: @project.namespace, project_id: @project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}")
var notes = new Notes("#{namespace_project_noteable_notes_path(namespace_id: @project.namespace, project_id: @project, target_id: @noteable.id, target_type: @noteable.class.name.underscore)}", #{@notes.map(&:id).to_json}, #{Time.now.to_i}, "#{diff_view}")
......@@ -77,8 +77,9 @@
Set up auto deploy
- if @repository.commit
.project-last-commit{ class: container_class }
= render 'projects/last_commit', commit: @repository.commit, ref: current_ref, project: @project
%div{ class: container_class }
.project-last-commit
= render 'projects/last_commit', commit: @repository.commit, ref: current_ref, project: @project
%div{ class: container_class }
- if @project.archived?
......
......@@ -18,7 +18,8 @@
= f.text_field :path, placeholder: 'open-source', class: 'form-control',
autofocus: local_assigns[:autofocus] || false, required: true,
pattern: Gitlab::Regex::NAMESPACE_REGEX_STR_JS,
title: 'Please choose a group name with no special characters.'
title: 'Please choose a group name with no special characters.',
"data-bind-in" => "#{'create_chat_team' if Gitlab.config.mattermost.enabled}"
- if parent
= f.hidden_field :parent_id, value: parent.id
......
......@@ -45,11 +45,11 @@
- if current_user && defined?(@project)
.label-subscription.inline
- if label.is_a?(ProjectLabel)
%button.js-subscribe-button.label-subscribe-button.btn.btn-default.btn-action{ type: 'button', title: label_subscription_toggle_button_text(label, @project), data: { toggle: 'tooltip', status: status, url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
%button.js-subscribe-button.label-subscribe-button.btn.btn-default{ type: 'button', data: { status: status, url: toggle_subscription_namespace_project_label_path(@project.namespace, @project, label) } }
%span= label_subscription_toggle_button_text(label, @project)
= icon('spinner spin', class: 'label-subscribe-button-loading')
- else
%button.js-unsubscribe-button.label-subscribe-button.btn.btn-default.btn-action{ type: 'button', class: ('hidden' if status.unsubscribed?), title: 'Unsubscribe', data: { toggle: 'tooltip', url: group_label_unsubscribe_path(label, @project) } }
%button.js-unsubscribe-button.label-subscribe-button.btn.btn-default{ type: 'button', class: ('hidden' if status.unsubscribed?), data: { url: group_label_unsubscribe_path(label, @project) } }
%span Unsubscribe
= icon('spinner spin', class: 'label-subscribe-button-loading')
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500"><path d="M250.05 34c1.9.04 3.8.11 5.6.2l-29.79 35.51c-.07.01-.15.03-.23.04C149.26 84.1 98.22 146.5 98.22 222.97c0 41.56 23.07 90.5 59.75 119.1 28.61 22.32 64.29 36.9 101.21 36.9 93.4 0 160.15-68.61 160.15-156 0-34.91-15.99-72.77-41.76-100.76l-1.63-47.39c54.45 39.15 89.95 103.02 90.06 175.17v.01c0 119.29-96.7 216-216 216-119.29 0-216-96.71-216-216S130.71 34 250 34h.05zm64.1 20.29c.66-.04 1.32.03 1.96.25 3.01 1 3.85 3.57 3.93 6.45l3.84 146.88c.76 28.66-17.16 68.44-60.39 68.56-30.97.08-63.68-20.83-63.68-60.13.01-14.73 5.61-31.26 19.25-48.11l90.03-111.18c1.15-1.42 3.08-2.58 5.06-2.72z"/></svg>
......@@ -13,10 +13,10 @@
= form.label :assignee_id, "Assignee", class: "control-label #{"col-lg-4" if has_due_date}"
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
.issuable-form-select-holder
- if issuable.assignee_id
= form.hidden_field :assignee_id
= form.hidden_field :assignee_id
= dropdown_tag(user_dropdown_label(issuable.assignee_id, "Assignee"), options: { toggle_class: "js-dropdown-keep-input js-user-search js-issuable-form-dropdown js-assignee-search", title: "Select assignee", filter: true, dropdown_class: "dropdown-menu-user dropdown-menu-selectable dropdown-menu-assignee js-filter-submit",
placeholder: "Search assignee", data: { first_user: current_user.try(:username), null_user: true, current_user: true, project_id: issuable.project.try(:id), selected: issuable.assignee_id, field_name: "#{issuable.class.model_name.param_key}[assignee_id]", default_label: "Assignee"} })
= link_to 'Assign to me', '#', class: "assign-to-me-link #{'hide' if issuable.assignee_id == current_user.id}"
.form-group.issue-milestone
= form.label :milestone_id, "Milestone", class: "control-label #{"col-lg-4" if has_due_date}"
.col-sm-10{ class: ("col-lg-8" if has_due_date) }
......
class StuckCiBuildsWorker
include Sidekiq::Worker
include CronjobQueue
BUILD_STUCK_TIMEOUT = 1.day
def perform
return if Gitlab::Geo.secondary?
Rails.logger.info 'Cleaning stuck builds'
builds = Ci::Build.joins(:project).running_or_pending.where('ci_builds.updated_at < ?', BUILD_STUCK_TIMEOUT.ago)
builds.find_each(batch_size: 50).each do |build|
Rails.logger.debug "Dropping stuck #{build.status} build #{build.id} for runner #{build.runner_id}"
build.drop
end
# Update builds that failed to drop
builds.update_all(status: 'failed')
end
end
class StuckCiJobsWorker
include Sidekiq::Worker
include CronjobQueue
EXCLUSIVE_LEASE_KEY = 'stuck_ci_builds_worker_lease'.freeze
BUILD_RUNNING_OUTDATED_TIMEOUT = 1.hour
BUILD_PENDING_OUTDATED_TIMEOUT = 1.day
BUILD_PENDING_STUCK_TIMEOUT = 1.hour
def perform
return unless try_obtain_lease
Rails.logger.info "#{self.class}: Cleaning stuck builds"
drop :running, BUILD_RUNNING_OUTDATED_TIMEOUT
drop :pending, BUILD_PENDING_OUTDATED_TIMEOUT
drop_stuck :pending, BUILD_PENDING_STUCK_TIMEOUT
remove_lease
end
private
def try_obtain_lease
@uuid = Gitlab::ExclusiveLease.new(EXCLUSIVE_LEASE_KEY, timeout: 30.minutes).try_obtain
end
def remove_lease
Gitlab::ExclusiveLease.cancel(EXCLUSIVE_LEASE_KEY, @uuid)
end
def drop(status, timeout)
search(status, timeout) do |build|
drop_build :outdated, build, status, timeout
end
end
def drop_stuck(status, timeout)
search(status, timeout) do |build|
return unless build.stuck?
drop_build :stuck, build, status, timeout
end
end
def search(status, timeout)
builds = Ci::Build.where(status: status).where('ci_builds.updated_at < ?', timeout.ago)
builds.joins(:project).includes(:tags, :runner, project: :namespace).find_each(batch_size: 50).each do |build|
yield(build)
end
end
def drop_build(type, build, status, timeout)
Rails.logger.info "#{self.class}: Dropping #{type} build #{build.id} for runner #{build.runner_id} (status: #{status}, timeout: #{timeout})"
Gitlab::OptimisticLocking.retry_lock(build, 3) do |b|
b.drop
end
end
end
class UploadChecksumWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
def perform(upload_id)
upload = Upload.find(upload_id)
upload.calculate_checksum
upload.save!
rescue ActiveRecord::RecordNotFound
Rails.logger.error("UploadChecksumWorker: couldn't find upload #{upload_id}, skipping")
end
end
---
title: Remove remnants of git annex support.
merge_request:
author:
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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