Commit 59195b98 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'master' into new-nav-fix-contextual-breadcrumbs

parents 436047f6 69e17c22
......@@ -362,6 +362,7 @@ db:migrate:reset-mysql:
- git fetch origin v8.14.10
- git checkout -f FETCH_HEAD
- bundle install $BUNDLE_INSTALL_FLAGS
- cp config/gitlab.yml.example config/gitlab.yml
- bundle exec rake db:drop db:create db:schema:load db:seed_fu
- git checkout $CI_COMMIT_SHA
- bundle install $BUNDLE_INSTALL_FLAGS
......
......@@ -386,7 +386,7 @@ gem 'vmstat', '~> 2.3.0'
gem 'sys-filesystem', '~> 1.1.6'
# Gitaly GRPC client
gem 'gitaly', '~> 0.13.0'
gem 'gitaly', '~> 0.14.0'
gem 'toml-rb', '~> 0.3.15', require: false
......
......@@ -278,7 +278,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
gitaly (0.13.0)
gitaly (0.14.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (4.7.6)
......@@ -980,7 +980,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.2.0)
gitaly (~> 0.13.0)
gitaly (~> 0.14.0)
github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.5.1)
......
import DropLab from './droplab/drop_lab';
import ISetter from './droplab/plugins/input_setter';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter);
class CloseReopenReportToggle {
constructor(opts = {}) {
this.dropdownTrigger = opts.dropdownTrigger;
this.dropdownList = opts.dropdownList;
this.button = opts.button;
}
initDroplab() {
this.reopenItem = this.dropdownList.querySelector('.reopen-item');
this.closeItem = this.dropdownList.querySelector('.close-item');
this.droplab = new DropLab();
const config = this.setConfig();
this.droplab.init(this.dropdownTrigger, this.dropdownList, [InputSetter], config);
}
updateButton(isClosed) {
this.toggleButtonType(isClosed);
this.button.blur();
}
toggleButtonType(isClosed) {
const [showItem, hideItem] = this.getButtonTypes(isClosed);
showItem.classList.remove('hidden');
showItem.classList.add('droplab-item-selected');
hideItem.classList.add('hidden');
hideItem.classList.remove('droplab-item-selected');
showItem.click();
}
getButtonTypes(isClosed) {
return isClosed ? [this.reopenItem, this.closeItem] : [this.closeItem, this.reopenItem];
}
setDisable(shouldDisable = true) {
if (shouldDisable) {
this.button.setAttribute('disabled', 'true');
this.dropdownTrigger.setAttribute('disabled', 'true');
} else {
this.button.removeAttribute('disabled');
this.dropdownTrigger.removeAttribute('disabled');
}
}
setConfig() {
const config = {
InputSetter: [
{
input: this.button,
valueAttribute: 'data-text',
inputAttribute: 'data-value',
},
{
input: this.button,
valueAttribute: 'data-text',
inputAttribute: 'title',
},
{
input: this.button,
valueAttribute: 'data-button-class',
inputAttribute: 'class',
},
{
input: this.dropdownTrigger,
valueAttribute: 'data-toggle-class',
inputAttribute: 'class',
},
{
input: this.button,
valueAttribute: 'data-url',
inputAttribute: 'href',
},
{
input: this.button,
valueAttribute: 'data-method',
inputAttribute: 'data-method',
},
],
};
return config;
}
}
export default CloseReopenReportToggle;
import DropLab from './droplab/drop_lab';
import InputSetter from './droplab/plugins/input_setter';
import ISetter from './droplab/plugins/input_setter';
// Todo: Remove this when fixing issue in input_setter plugin
const InputSetter = Object.assign({}, ISetter);
class CommentTypeToggle {
constructor(opts = {}) {
......
......@@ -30,6 +30,7 @@ class GfmAutoComplete {
this.input.each((i, input) => {
const $input = $(input);
$input.off('focus.setupAtWho').on('focus.setupAtWho', this.setupAtWho.bind(this, $input));
$input.on('change.atwho', () => input.dispatchEvent(new Event('input')));
// This triggers at.js again
// Needed for quick actions with suffixes (ex: /label ~)
$input.on('inserted-commands.atwho', $input.trigger.bind($input, 'keyup'));
......
import CloseReopenReportToggle from '../close_reopen_report_toggle';
function initCloseReopenReport() {
const container = document.querySelector('.js-issuable-close-dropdown');
if (!container) return undefined;
const dropdownTrigger = container.querySelector('.js-issuable-close-toggle');
const dropdownList = container.querySelector('.js-issuable-close-menu');
const button = container.querySelector('.js-issuable-close-button');
const closeReopenReportToggle = new CloseReopenReportToggle({
dropdownTrigger,
dropdownList,
button,
});
closeReopenReportToggle.initDroplab();
return closeReopenReportToggle;
}
const IssuablesHelper = {
initCloseReopenReport,
};
export default IssuablesHelper;
......@@ -6,6 +6,7 @@ import '~/lib/utils/text_utility';
import './flash';
import TaskList from './task_list';
import CreateMergeRequestDropdown from './create_merge_request_dropdown';
import IssuablesHelper from './helpers/issuables_helper';
class Issue {
constructor() {
......@@ -28,6 +29,11 @@ class Issue {
Issue.initMergeRequests();
Issue.initRelatedBranches();
this.closeButtons = $('a.btn-close');
this.reopenButtons = $('a.btn-reopen');
this.initCloseReopenReport();
if (Issue.createMrDropdownWrap) {
this.createMergeRequestDropdown = new CreateMergeRequestDropdown(Issue.createMrDropdownWrap);
}
......@@ -35,13 +41,8 @@ class Issue {
initIssueBtnEventListeners() {
const issueFailMessage = 'Unable to update this issue at this time.';
const closeButtons = $('a.btn-close');
const isClosedBadge = $('div.status-box-closed');
const isOpenBadge = $('div.status-box-open');
const projectIssuesCounter = $('.issue_counter');
const reopenButtons = $('a.btn-reopen');
return closeButtons.add(reopenButtons).on('click', (e) => {
return $(document).on('click', 'a.btn-close, a.btn-reopen', (e) => {
var $button, shouldSubmit, url;
e.preventDefault();
e.stopImmediatePropagation();
......@@ -50,7 +51,9 @@ class Issue {
if (shouldSubmit) {
Issue.submitNoteForm($button.closest('form'));
}
$button.prop('disabled', true);
this.disableCloseReopenButton($button);
url = $button.attr('href');
return $.ajax({
type: 'PUT',
......@@ -58,15 +61,19 @@ class Issue {
})
.fail(() => new Flash(issueFailMessage))
.done((data) => {
const isClosedBadge = $('div.status-box-closed');
const isOpenBadge = $('div.status-box-open');
const projectIssuesCounter = $('.issue_counter');
if ('id' in data) {
$(document).trigger('issuable:change');
const isClosed = $button.hasClass('btn-close');
closeButtons.toggleClass('hidden', isClosed);
reopenButtons.toggleClass('hidden', !isClosed);
isClosedBadge.toggleClass('hidden', !isClosed);
isOpenBadge.toggleClass('hidden', isClosed);
this.toggleCloseReopenButton(isClosed);
let numProjectIssues = Number(projectIssuesCounter.text().replace(/[^\d]/, ''));
numProjectIssues = isClosed ? numProjectIssues - 1 : numProjectIssues + 1;
projectIssuesCounter.text(gl.text.addDelimiter(numProjectIssues));
......@@ -83,12 +90,34 @@ class Issue {
} else {
new Flash(issueFailMessage);
}
$button.prop('disabled', false);
})
.then(() => {
this.disableCloseReopenButton($button, false);
});
});
}
initCloseReopenReport() {
this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
if (this.closeButtons) this.closeButtons = this.closeButtons.not('.issuable-close-button');
if (this.reopenButtons) this.reopenButtons = this.reopenButtons.not('.issuable-close-button');
}
disableCloseReopenButton($button, shouldDisable) {
if (this.closeReopenReportToggle) {
this.closeReopenReportToggle.setDisable(shouldDisable);
} else {
$button.prop('disabled', shouldDisable);
}
}
toggleCloseReopenButton(isClosed) {
if (this.closeReopenReportToggle) this.closeReopenReportToggle.updateButton(isClosed);
this.closeButtons.toggleClass('hidden', isClosed);
this.reopenButtons.toggleClass('hidden', !isClosed);
}
static submitNoteForm(form) {
var noteText;
noteText = form.find("textarea.js-note-text").val();
......
......@@ -4,6 +4,7 @@
import 'vendor/jquery.waitforimages';
import TaskList from './task_list';
import './merge_request_tabs';
import IssuablesHelper from './helpers/issuables_helper';
(function() {
this.MergeRequest = (function() {
......@@ -21,9 +22,12 @@ import './merge_request_tabs';
return _this.showAllCommits();
};
})(this));
this.initTabs();
this.initMRBtnListeners();
this.initCommitMessageListeners();
this.closeReopenReportToggle = IssuablesHelper.initCloseReopenReport();
if ($("a.btn-close").length) {
this.taskList = new TaskList({
dataType: 'merge_request',
......@@ -64,11 +68,15 @@ import './merge_request_tabs';
if (shouldSubmit && $this.data('submitted')) {
return;
}
if (this.closeReopenReportToggle) this.closeReopenReportToggle.setDisable();
if (shouldSubmit) {
if ($this.hasClass('btn-comment-and-close') || $this.hasClass('btn-comment-and-reopen')) {
e.preventDefault();
e.stopImmediatePropagation();
return _this.submitNoteForm($this.closest('form'), $this);
_this.submitNoteForm($this.closest('form'), $this);
}
}
});
......
......@@ -62,7 +62,7 @@ import findAndFollowLink from './shortcuts_dashboard_navigation';
if (Cookies.get(performanceBarCookieName) === 'true') {
Cookies.remove(performanceBarCookieName, { path: '/' });
} else {
Cookies.set(performanceBarCookieName, true, { path: '/' });
Cookies.set(performanceBarCookieName, 'true', { path: '/' });
}
gl.utils.refreshCurrentPage();
};
......
......@@ -20,17 +20,29 @@
color: $text;
border-color: $border;
> .icon {
color: $text;
}
&:hover,
&:focus {
background-color: $hover-background;
border-color: $hover-border;
color: $hover-text;
> .icon {
color: $hover-text;
}
}
&:active {
background-color: $active-background;
border-color: $active-border;
color: $hover-text;
> .icon {
color: $hover-text;
}
}
}
......@@ -163,7 +175,8 @@
@include btn-orange;
}
&.btn-close {
&.btn-close,
&.btn-close-color {
@include btn-outline($white-light, $orange-600, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700);
}
......@@ -181,7 +194,8 @@
float: right;
}
&.btn-reopen {
&.btn-reopen,
.btn-reopen-color {
/* should be same as parent class for now */
}
......
......@@ -295,9 +295,74 @@
}
}
.filtered-search-box-input-container .dropdown-menu,
.filtered-search-box-input-container .dropdown-menu-nav,
.comment-type-dropdown .dropdown-menu {
.droplab-dropdown {
.description {
display: inline-block;
white-space: normal;
margin-left: 5px;
}
.dropdown-toggle > i {
pointer-events: none;
}
li {
padding: $gl-btn-padding $gl-btn-padding 2px;
cursor: pointer;
> a,
> button {
display: flex;
margin: 0;
padding: 0;
border-radius: 0;
text-overflow: inherit;
background-color: inherit;
color: inherit;
border: inherit;
text-align: left;
&:hover,
&:focus {
background-color: inherit;
color: inherit;
}
&.btn .fa:not(:last-child) {
margin-left: 5px;
}
}
&:hover,
&:focus {
background-color: $dropdown-hover-color;
color: $white-light;
}
&.droplab-item-selected i {
visibility: visible;
}
.icon {
visibility: hidden;
}
}
.icon {
display: inline-block;
vertical-align: top;
padding-top: 2px;
}
.divider {
margin: 0 8px;
padding: 0;
border-top: $gray-darkest;
}
}
.droplab-dropdown .dropdown-menu,
.droplab-dropdown .dropdown-menu-nav {
display: none;
opacity: 1;
visibility: visible;
......
......@@ -70,6 +70,13 @@
.input-token {
max-width: 200px;
padding: 0;
&:hover,
&:focus {
background-color: inherit;
color: inherit;
}
}
.input-token:only-child,
......@@ -156,6 +163,16 @@
}
}
.droplab-dropdown li.filtered-search-token {
padding: 0;
&:hover,
&:focus {
background-color: inherit;
color: inherit;
}
}
.filtered-search-term {
.name {
background-color: inherit;
......
......@@ -265,7 +265,7 @@ $diff-view-modes-border: #c1c1c1;
/*
* Fonts
*/
$monospace_font: 'Menlo', 'Liberation Mono', 'Consolas', 'DejaVu Sans Mono', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
$monospace_font: 'Menlo', 'DejaVu Sans Mono', 'Liberation Mono', 'Consolas', 'Ubuntu Mono', 'Courier New', 'andale mono', 'lucida console', monospace;
$regular_font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";
/*
......
......@@ -31,6 +31,12 @@ $new-sidebar-width: 220px;
&:hover {
background-color: $border-color;
}
.project-title,
.group-title {
overflow: hidden;
text-overflow: ellipsis;
}
}
.settings-avatar {
......
......@@ -799,3 +799,28 @@
}
}
}
.issuable-close-button,
.issuable-close-toggle {
@include transition(border-color, color);
}
.issuable-close-dropdown {
.dropdown-menu {
min-width: 270px;
left: auto;
right: 0;
}
.description {
margin-bottom: 10px;
.text {
margin: 0;
}
}
.dropdown-toggle > .icon {
margin: 0 3px;
}
}
......@@ -356,7 +356,6 @@
color: $white-light;
padding-right: 2px;
margin-top: 2px;
pointer-events: none;
}
}
......@@ -366,56 +365,6 @@
width: 298px;
}
.description {
display: inline-block;
white-space: normal;
margin-left: 8px;
padding-right: 33px;
}
li {
padding-top: 6px;
& > a {
margin: 0;
padding: 0;
color: inherit;
border-radius: 0;
text-overflow: inherit;
&:hover,
&:focus {
background-color: inherit;
color: inherit;
}
}
&:hover,
&:focus {
background-color: $dropdown-hover-color;
color: $white-light;
}
&.droplab-item-selected i {
visibility: visible;
}
i {
visibility: hidden;
}
}
i {
display: inline-block;
vertical-align: top;
padding-top: 2px;
}
.divider {
margin: 0 8px;
padding: 0;
border-top: $gray-darkest;
}
@media (max-width: $screen-xs-max) {
display: flex;
......
......@@ -126,6 +126,8 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController
:metrics_port,
:metrics_sample_interval,
:metrics_timeout,
:performance_bar_allowed_group_id,
:performance_bar_enabled,
:recaptcha_enabled,
:recaptcha_private_key,
:recaptcha_site_key,
......
......@@ -9,7 +9,7 @@ class ApplicationController < ActionController::Base
include SentryHelper
include WorkhorseHelper
include EnforcesTwoFactorAuthentication
include Peek::Rblineprof::CustomControllerHelpers
include WithPerformanceBar
before_action :authenticate_user_from_private_token!
before_action :authenticate_user_from_rss_token!
......@@ -68,21 +68,6 @@ class ApplicationController < ActionController::Base
end
end
def peek_enabled?
return false unless Gitlab::PerformanceBar.enabled?
return false unless current_user
if RequestStore.active?
if RequestStore.store.key?(:peek_enabled)
RequestStore.store[:peek_enabled]
else
RequestStore.store[:peek_enabled] = cookies[:perf_bar_enabled].present?
end
else
cookies[:perf_bar_enabled].present?
end
end
protected
# This filter handles both private tokens and personal access tokens
......
module WithPerformanceBar
extend ActiveSupport::Concern
included do
include Peek::Rblineprof::CustomControllerHelpers
end
def peek_enabled?
return false unless Gitlab::PerformanceBar.enabled?(current_user)
if RequestStore.active?
RequestStore.fetch(:peek_enabled) { cookies[:perf_bar_enabled].present? }
else
cookies[:perf_bar_enabled].present?
end
end
end
......@@ -17,8 +17,7 @@ class Projects::MergeRequests::ApplicationController < Projects::ApplicationCont
end
def merge_request_params
params.require(:merge_request)
.permit(merge_request_params_attributes)
params.require(:merge_request).permit(merge_request_params_attributes)
end
def merge_request_params_attributes
......
......@@ -245,6 +245,53 @@ module IssuablesHelper
@counts[cache_key][state]
end
def close_issuable_url(issuable)
issuable_url(issuable, close_reopen_params(issuable, :close))
end
def reopen_issuable_url(issuable)
issuable_url(issuable, close_reopen_params(issuable, :reopen))
end
def close_reopen_issuable_url(issuable, should_inverse = false)
issuable.closed? ^ should_inverse ? reopen_issuable_url(issuable) : close_issuable_url(issuable)
end
def issuable_url(issuable, *options)
case issuable
when Issue
issue_url(issuable, *options)
when MergeRequest
merge_request_url(issuable, *options)
end
end
def issuable_button_visibility(issuable, closed)
case issuable
when Issue
issue_button_visibility(issuable, closed)
when MergeRequest
merge_request_button_visibility(issuable, closed)
end
end
def issuable_close_reopen_button_method(issuable)
case issuable
when Issue
''
when MergeRequest
'put'
end
end
def issuable_author_is_current_user(issuable)
issuable.author == current_user
end
def issuable_display_type(issuable)
issuable.model_name.human.downcase
end
private
def sidebar_gutter_collapsed?
......@@ -270,8 +317,6 @@ module IssuablesHelper
issue_template_names
when MergeRequest
merge_request_template_names
else
raise 'Unknown issuable type!'
end
end
......@@ -301,4 +346,12 @@ module IssuablesHelper
container: (is_collapsed ? 'body' : nil)
}
end
def close_reopen_params(issuable, action)
{
issuable.model_name.to_s.underscore => { state_event: action }
}.tap do |params|
params[:format] = :json if issuable.is_a?(Issue)
end
end
end
......@@ -234,6 +234,7 @@ class ApplicationSetting < ActiveRecord::Base
koding_url: nil,
max_artifacts_size: Settings.artifacts['max_size'],
max_attachment_size: Settings.gitlab['max_attachment_size'],
performance_bar_allowed_group_id: nil,
plantuml_enabled: false,
plantuml_url: nil,
recaptcha_enabled: false,
......@@ -336,6 +337,48 @@ class ApplicationSetting < ActiveRecord::Base
super(levels.map { |level| Gitlab::VisibilityLevel.level_value(level) })
end
def performance_bar_allowed_group_id=(group_full_path)
group_full_path = nil if group_full_path.blank?
if group_full_path.nil?
if group_full_path != performance_bar_allowed_group_id
super(group_full_path)
Gitlab::PerformanceBar.expire_allowed_user_ids_cache
end
return
end
group = Group.find_by_full_path(group_full_path)
if group
if group.id != performance_bar_allowed_group_id
super(group.id)
Gitlab::PerformanceBar.expire_allowed_user_ids_cache
end
else
super(nil)
Gitlab::PerformanceBar.expire_allowed_user_ids_cache
end
end
def performance_bar_allowed_group
Group.find_by_id(performance_bar_allowed_group_id)
end
# Return true if the Performance Bar is enabled for a given group
def performance_bar_enabled
performance_bar_allowed_group_id.present?
end
# - If `enable` is true, we early return since the actual attribute that holds
# the enabling/disabling is `performance_bar_allowed_group_id`
# - If `enable` is false, we set `performance_bar_allowed_group_id` to `nil`
def performance_bar_enabled=(enable)
return if enable
self.performance_bar_allowed_group_id = nil
end
# Choose one of the available repository storage options. Currently all have
# equal weighting.
def pick_repository_storage
......
module EachBatch
extend ActiveSupport::Concern
module ClassMethods
# Iterates over the rows in a relation in batches, similar to Rails'
# `in_batches` but in a more efficient way.
#
# Unlike `in_batches` provided by Rails this method does not support a
# custom start/end range, nor does it provide support for the `load:`
# keyword argument.
#
# This method will yield an ActiveRecord::Relation to the supplied block, or
# return an Enumerator if no block is given.
#
# Example:
#
# User.each_batch do |relation|
# relation.update_all(updated_at: Time.now)
# end
#
# The supplied block is also passed an optional batch index:
#
# User.each_batch do |relation, index|
# puts index # => 1, 2, 3, ...
# end
#
# You can also specify an alternative column to use for ordering the rows:
#
# User.each_batch(column: :created_at) do |relation|
# ...
# end
#
# This will produce SQL queries along the lines of:
#
# User Load (0.7ms) SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 41654) ORDER BY "users"."id" ASC LIMIT 1 OFFSET 1000
# (0.7ms) SELECT COUNT(*) FROM "users" WHERE ("users"."id" >= 41654) AND ("users"."id" < 42687)
#
# of - The number of rows to retrieve per batch.
# column - The column to use for ordering the batches.
def each_batch(of: 1000, column: primary_key)
unless column
raise ArgumentError,
'the column: argument must be set to a column name to use for ordering rows'
end
start = except(:select)
.select(column)
.reorder(column => :asc)
.take
return unless start
start_id = start[column]
arel_table = self.arel_table
1.step do |index|
stop = except(:select)
.select(column)
.where(arel_table[column].gteq(start_id))
.reorder(column => :asc)
.offset(of)
.limit(1)
.take
relation = where(arel_table[column].gteq(start_id))
if stop
stop_id = stop[column]
start_id = stop_id
relation = relation.where(arel_table[column].lt(stop_id))
end
# Any ORDER BYs are useless for this relation and can lead to less
# efficient UPDATE queries, hence we get rid of it.
yield relation.except(:order), index
break unless stop
end
end
end
end
class GitlabIssueTrackerService < IssueTrackerService
include Gitlab::Routing.url_helpers
include Gitlab::Routing
validates :project_url, :issues_url, :new_issue_url, presence: true, url: true, if: :activated?
......
class JiraService < IssueTrackerService
include Gitlab::Routing.url_helpers
include Gitlab::Routing
validates :url, url: true, presence: true, if: :activated?
validates :api_url, url: true, allow_blank: true
......
module ChatNames
class AuthorizeUserService
include Gitlab::Routing.url_helpers
include Gitlab::Routing
def initialize(service, params)
@service = service
......
......@@ -35,11 +35,12 @@ module MergeRequests
# target branch manually
def close_merge_requests
commit_ids = @commits.map(&:id)
merge_requests = @project.merge_requests.opened.where(target_branch: @branch_name).to_a
merge_requests = @project.merge_requests.preload(:merge_request_diff).opened.where(target_branch: @branch_name).to_a
merge_requests = merge_requests.select(&:diff_head_commit)
merge_requests = merge_requests.select do |merge_request|
commit_ids.include?(merge_request.diff_head_sha)
commit_ids.include?(merge_request.diff_head_sha) &&
merge_request.merge_request_diff.state != 'empty'
end
filter_merge_requests(merge_requests).each do |merge_request|
......
......@@ -332,6 +332,22 @@
%strong.cred WARNING:
Environment variable `prometheus_multiproc_dir` does not exist or is not pointing to a valid directory.
%fieldset
%legend Profiling - Performance Bar
%p
Enable the Performance Bar for a given group.
= link_to icon('question-circle'), help_page_path('administration/monitoring/performance/performance_bar')
.form-group
.col-sm-offset-2.col-sm-10
.checkbox
= f.label :performance_bar_enabled do
= f.check_box :performance_bar_enabled
Enable the Performance Bar
.form-group
= f.label :performance_bar_allowed_group_id, 'Allowed group', class: 'control-label col-sm-2'
.col-sm-10
= f.text_field :performance_bar_allowed_group_id, class: 'form-control', placeholder: 'my-org/my-group', value: @application_setting.performance_bar_allowed_group&.full_path
%fieldset
%legend Background Jobs
%p
......
......@@ -12,10 +12,13 @@
.content-wrapper{ class: "#{(layout_nav_class unless show_new_nav?)}" }
.alert-wrapper
= render "layouts/broadcast"
- if show_new_nav?
- if content_for?(:new_global_flash)
= yield :new_global_flash
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
= render "layouts/flash"
= yield :flash_message
- if show_new_nav? && !@hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content{ id: "content-body" }
= yield
......@@ -4,7 +4,7 @@
= icon('wrench')
.project-title Admin Area
%ul.sidebar-top-level-items
= nav_link(controller: %w(dashboard admin projects users groups builds runners cohorts), html_options: {class: 'home'}) do
= nav_link(controller: %w(dashboard admin projects users groups jobs runners cohorts), html_options: {class: 'home'}) do
= link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do
%span
Overview
......@@ -26,7 +26,7 @@
= link_to admin_groups_path, title: 'Groups' do
%span
Groups
= nav_link path: 'builds#index' do
= nav_link path: 'jobs#index' do
= link_to admin_jobs_path, title: 'Jobs' do
%span
Jobs
......
.nav-sidebar
= link_to group_path(@group), title: 'Group', class: 'context-header' do
= link_to group_path(@group), title: @group.name, class: 'context-header' do
.avatar-container.s40.group-avatar
= image_tag group_icon(@group), class: "avatar s40 avatar-tile"
.group-title
......
.nav-sidebar
- can_edit = can?(current_user, :admin_project, @project)
= link_to project_path(@project), title: 'Project', class: 'context-header' do
= link_to project_path(@project), title: @project.name, class: 'context-header' do
.avatar-container.s40.project-avatar
= project_icon(@project, alt: @project.name, class: 'avatar s40 avatar-tile')
.project-title
......
- @no_container = true
- flash_message_container = show_new_nav? ? :new_global_flash : :flash_message
= content_for :flash_message do
= content_for flash_message_container do
- if current_user && can?(current_user, :download_code, @project)
= render 'shared/no_ssh'
= render 'shared/no_password'
......
......@@ -30,24 +30,23 @@
.dropdown-menu.dropdown-menu-align-right.hidden-lg
%ul
- if can_update_issue
%li
= link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'issuable-edit'
%li
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%li
= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
%li= link_to 'Edit', edit_project_issue_path(@project, @issue)
- unless current_user == @issue.author
%li= link_to 'Report abuse', new_abuse_report_path(user_id: @issue.author.id, ref_url: issue_url(@issue))
- if can_update_issue
%li= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
%li= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
- if can_report_spam
%li
= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
%li= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'btn-spam', title: 'Submit as spam'
- if can_update_issue || can_report_spam
%li.divider
%li
= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link'
%li= link_to 'New issue', new_project_issue_path(@project), title: 'New issue', id: 'new_issue_link'
- if can_update_issue
= link_to 'Edit', edit_project_issue_path(@project, @issue), class: 'hidden-xs hidden-sm btn btn-grouped issuable-edit'
= link_to 'Close issue', issue_path(@issue, issue: { state_event: :close }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issue_button_visibility(@issue, true)}", title: 'Close issue'
= link_to 'Reopen issue', issue_path(@issue, issue: { state_event: :reopen }, format: 'json'), class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issue_button_visibility(@issue, false)}", title: 'Reopen issue'
= render 'shared/issuable/close_reopen_button', issuable: @issue, can_update: can_update_issue
- if can_report_spam
= link_to 'Submit as spam', mark_as_spam_project_issue_path(@project, @issue), method: :post, class: 'hidden-xs hidden-sm btn btn-grouped btn-spam', title: 'Submit as spam'
= link_to new_project_issue_path(@project), class: 'hidden-xs hidden-sm btn btn-grouped new-issue-link btn-new btn-inverted', title: 'New issue', id: 'new_issue_link' do
......
- can_update_merge_request = can?(current_user, :update_merge_request, @merge_request)
- if @merge_request.closed_without_fork?
.alert.alert-danger
%p The source project of this merge request has been removed.
......@@ -15,21 +17,24 @@
.issuable-meta
= issuable_meta(@merge_request, @project, "Merge request")
- if can?(current_user, :update_merge_request, @merge_request)
.issuable-actions
.clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options
= icon('caret-down')
.dropdown-menu.dropdown-menu-align-right.hidden-lg
%ul
.issuable-actions
.clearfix.issue-btn-group.dropdown
%button.btn.btn-default.pull-left.hidden-md.hidden-lg{ type: "button", data: { toggle: "dropdown" } }
Options
= icon('caret-down')
.dropdown-menu.dropdown-menu-align-right.hidden-lg
%ul
- if can_update_merge_request
%li= link_to 'Edit', edit_namespace_project_merge_request_path(@project.namespace, @project, @merge_request), class: 'issuable-edit'
- unless current_user == @merge_request.author
%li= link_to 'Report abuse', new_abuse_report_path(user_id: @merge_request.author.id, ref_url: merge_request_url(@merge_request))
- if can_update_merge_request
%li{ class: merge_request_button_visibility(@merge_request, true) }
= link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, title: 'Close merge request'
%li{ class: merge_request_button_visibility(@merge_request, false) }
= link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: 'reopen-mr-link', title: 'Reopen merge request'
%li
= link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: 'issuable-edit'
= link_to 'Close', merge_request_path(@merge_request, merge_request: { state_event: :close }), method: :put, class: "hidden-xs hidden-sm btn btn-grouped btn-close #{merge_request_button_visibility(@merge_request, true)}", title: 'Close merge request'
= link_to 'Reopen', merge_request_path(@merge_request, merge_request: {state_event: :reopen }), method: :put, class: "hidden-xs hidden-sm btn btn-grouped btn-reopen reopen-mr-link #{merge_request_button_visibility(@merge_request, false)}", title: 'Reopen merge request'
= link_to edit_project_merge_request_path(@project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped issuable-edit" do
Edit
- if can_update_merge_request
= link_to 'Edit', edit_project_merge_request_path(@project, @merge_request), class: "hidden-xs hidden-sm btn btn-grouped issuable-edit"
= render 'shared/issuable/close_reopen_button', issuable: @merge_request, can_update: can_update_merge_request
- @no_container = true
- @breadcrumb_title = "Project"
- flash_message_container = show_new_nav? ? :new_global_flash : :flash_message
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, project_path(@project, rss_url_options), title: "#{@project.name} activity")
= content_for :flash_message do
= content_for flash_message_container do
- if current_user && can?(current_user, :download_code, @project)
= render 'shared/no_ssh'
= render 'shared/no_password'
......
- is_current_user = issuable_author_is_current_user(issuable)
- display_issuable_type = issuable_display_type(issuable)
- button_method = issuable_close_reopen_button_method(issuable)
- if can_update && is_current_user
= link_to "Close #{display_issuable_type}", close_issuable_url(issuable), method: button_method,
class: "hidden-xs hidden-sm btn btn-grouped btn-close #{issuable_button_visibility(issuable, true)}", title: "Close #{display_issuable_type}"
= link_to "Reopen #{display_issuable_type}", reopen_issuable_url(issuable), method: button_method,
class: "hidden-xs hidden-sm btn btn-grouped btn-reopen #{issuable_button_visibility(issuable, false)}", title: "Reopen #{display_issuable_type}"
- elsif can_update && !is_current_user
= render 'shared/issuable/close_reopen_report_toggle', issuable: issuable
- else
= link_to 'Report abuse', new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
class: 'hidden-xs hidden-sm btn btn-grouped btn-close-color', title: 'Report abuse'
- display_issuable_type = issuable_display_type(issuable)
- button_action = issuable.closed? ? 'reopen' : 'close'
- display_button_action = button_action.capitalize
- button_responsive_class = 'hidden-xs hidden-sm'
- button_class = "#{button_responsive_class} btn btn-grouped js-issuable-close-button issuable-close-button"
- toggle_class = "#{button_responsive_class} btn btn-nr dropdown-toggle js-issuable-close-toggle"
- button_method = issuable_close_reopen_button_method(issuable)
.pull-left.btn-group.prepend-left-10.issuable-close-dropdown.droplab-dropdown.js-issuable-close-dropdown
= link_to "#{display_button_action} #{display_issuable_type}", close_reopen_issuable_url(issuable),
method: button_method, class: "#{button_class} btn-#{button_action}", title: "#{display_button_action} #{display_issuable_type}"
= button_tag type: 'button', class: "#{toggle_class} btn-#{button_action}-color",
data: { 'dropdown-trigger' => '#issuable-close-menu' }, 'aria-label' => 'Toggle dropdown' do
= icon('caret-down', class: 'toggle-icon icon')
%ul#issuable-close-menu.js-issuable-close-menu.dropdown-menu{ class: button_responsive_class, data: { dropdown: true } }
%li.close-item{ class: "#{issuable_button_visibility(issuable, true) || 'droplab-item-selected'}",
data: { text: "Close #{display_issuable_type}", url: close_issuable_url(issuable),
button_class: "#{button_class} btn-close", toggle_class: "#{toggle_class} btn-close-color", method: button_method } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
%strong.title
Close
= display_issuable_type
%li.reopen-item{ class: "#{issuable_button_visibility(issuable, false) || 'droplab-item-selected'}",
data: { text: "Reopen #{display_issuable_type}", url: reopen_issuable_url(issuable),
button_class: "#{button_class} btn-reopen", toggle_class: "#{toggle_class} btn-reopen-color", method: button_method } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
%strong.title
Reopen
= display_issuable_type
%li.divider.droplab-item-ignore
%li.report-item{ data: { text: 'Report abuse', url: new_abuse_report_path(user_id: issuable.author.id, ref_url: issuable_url(issuable)),
button_class: "#{button_class} btn-close-color", toggle_class: "#{toggle_class} btn-close-color", method: '' } }
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
%strong.title Report abuse
%p.text
Report
= display_issuable_type.pluralize
that are abusive, inappropriate or spam.
......@@ -19,7 +19,7 @@
content_class: "filtered-search-history-dropdown-content",
title: "Recent searches" }) do
.js-filtered-search-history-dropdown{ data: { project_full_path: @project.full_path } }
.filtered-search-box-input-container
.filtered-search-box-input-container.droplab-dropdown
.scroll-container
%ul.tokens-container.list-unstyled
%li.input-token
......
- noteable_name = @note.noteable.human_class_name
.pull-left.btn-group.append-right-10.comment-type-dropdown.js-comment-type-dropdown
.pull-left.btn-group.append-right-10.droplab-dropdown.comment-type-dropdown.js-comment-type-dropdown
%input.btn.btn-nr.btn-create.comment-btn.js-comment-button.js-comment-submit-button{ type: 'submit', value: 'Comment' }
- if @note.can_be_discussion_note?
......@@ -9,8 +9,8 @@
%ul#resolvable-comment-menu.dropdown-menu{ data: { dropdown: true } }
%li#comment.droplab-item-selected{ data: { value: '', 'submit-text' => 'Comment', 'close-text' => "Comment & close #{noteable_name}", 'reopen-text' => "Comment & reopen #{noteable_name}" } }
%a{ href: '#' }
= icon('check')
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
%strong Comment
%p
......@@ -19,8 +19,8 @@
%li.divider.droplab-item-ignore
%li#discussion{ data: { value: 'DiscussionNote', 'submit-text' => 'Start discussion', 'close-text' => "Start discussion & close #{noteable_name}", 'reopen-text' => "Start discussion & reopen #{noteable_name}" } }
%a{ href: '#' }
= icon('check')
%button.btn.btn-transparent
= icon('check', class: 'icon')
.description
%strong Start discussion
%p
......
---
title: "#20628 Enable implicit grant in GitLab as OAuth Provider"
merge_request: 12384
author: Mateusz Pytel
---
title: Change order of monospace fonts to fix bug on some linux distros
merge_request:
author:
---
title: Allow to enable the performance bar per user or Feature group
merge_request: 12362
author:
---
title: Allow admins to retrieve user agent details for an issue or snippet
merge_request: 12655
author:
---
title: Don't mark empty MRs as merged on push to the target branch
merge_request:
author:
---
title: Fixed GFM references not being included when updating issues inline
merge_request:
author:
......@@ -166,9 +166,10 @@ module Gitlab
config.after_initialize do
Rails.application.reload_routes!
named_routes_set = Gitlab::Application.routes.named_routes
project_url_helpers = Module.new do
named_routes_set.helper_names.each do |name|
extend ActiveSupport::Concern
Gitlab::Application.routes.named_routes.helper_names.each do |name|
next unless name.include?('namespace_project')
define_method(name.sub('namespace_project', 'project')) do |project, *args|
......@@ -177,14 +178,7 @@ module Gitlab
end
end
named_routes_set.url_helpers_module.include project_url_helpers
named_routes_set.url_helpers_module.extend project_url_helpers
Gitlab::Routing.url_helpers.include project_url_helpers
Gitlab::Routing.url_helpers.extend project_url_helpers
GitlabRoutingHelper.include project_url_helpers
GitlabRoutingHelper.extend project_url_helpers
Gitlab::Routing.add_helpers(project_url_helpers)
end
end
end
......@@ -657,7 +657,10 @@ test:
client_id: 'YOUR_AUTH0_CLIENT_ID',
client_secret: 'YOUR_AUTH0_CLIENT_SECRET',
namespace: 'YOUR_AUTH0_DOMAIN' } }
- { name: 'authentiq',
app_id: 'YOUR_CLIENT_ID',
app_secret: 'YOUR_CLIENT_SECRET',
args: { scope: 'aq:name email~rs address aq:push' } }
ldap:
enabled: false
servers:
......
......@@ -87,9 +87,7 @@ Doorkeeper.configure do
# "password" => Resource Owner Password Credentials Grant Flow
# "client_credentials" => Client Credentials Grant Flow
#
# If not specified, Doorkeeper enables all the four grant flows.
#
grant_flows %w(authorization_code password client_credentials)
grant_flows %w(authorization_code implicit password client_credentials)
# Under some circumstances you might want to have applications auto-approved,
# so that the user skips the authorization step.
......
class AddPerformanceBarAllowedGroupIdToApplicationSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
add_column :application_settings, :performance_bar_allowed_group_id, :integer
end
end
......@@ -126,6 +126,7 @@ ActiveRecord::Schema.define(version: 20170724184243) do
t.boolean "prometheus_metrics_enabled", default: false, null: false
t.boolean "help_page_hide_commercial_content", default: false
t.string "help_page_support_url"
t.integer "performance_bar_allowed_group_id"
end
create_table "audit_events", force: :cascade do |t|
......
......@@ -167,6 +167,7 @@ have access to GitLab administration tools and settings.
- [Operations](administration/operations.md): Keeping GitLab up and running.
- [Polling](administration/polling.md): Configure how often the GitLab UI polls for updates.
- [Request Profiling](administration/monitoring/performance/request_profiling.md): Get a detailed profile on slow requests.
- [Performance Bar](administration/monitoring/performance/performance_bar.md): Get performance information for the current page.
### Customization
......
# Performance Bar
A Performance Bar can be displayed, to dig into the performance of a page. When
activated, it looks as follows:
![Performance Bar](img/performance_bar.png)
It allows you to:
- see the current host serving the page
- see the timing of the page (backend, frontend)
- the number of DB queries, the time it took, and the detail of these queries
![SQL profiling using the Performance Bar](img/performance_bar_sql_queries.png)
- the number of calls to Redis, and the time it took
- the number of background jobs created by Sidekiq, and the time it took
- the number of Ruby GC calls, and the time it took
- profile the code used to generate the page, line by line
![Line profiling using the Performance Bar](img/performance_bar_line_profiling.png)
## Enable the Performance Bar via the Admin panel
GitLab Performance Bar is disabled by default. To enable it for a given group,
navigate to the Admin area in **Settings > Profiling - Performance Bar**
(`/admin/application_settings`).
The only required setting you need to set is the full path of the group that
will be allowed to display the Performance Bar.
Make sure _Enable the Performance Bar_ is checked and hit
**Save** to save the changes.
---
![GitLab Performance Bar Admin Settings](img/performance_bar_configuration_settings.png)
---
......@@ -17,6 +17,7 @@ following locations:
- [Deploy Keys](deploy_keys.md)
- [Environments](environments.md)
- [Events](events.md)
- [Feature flags](features.md)
- [Gitignores templates](templates/gitignores.md)
- [GitLab CI Config templates](templates/gitlab_ci_ymls.md)
- [Groups](groups.md)
......
# Features API
# Features flags API
All methods require administrator authorization.
......@@ -61,7 +61,8 @@ POST /features/:name
| `feature_group` | string | no | A Feature group name |
| `user` | string | no | A GitLab username |
Note that `feature_group` and `user` are mutually exclusive.
Note that you can enable or disable a feature for both a `feature_group` and a
`user` with a single API call.
```bash
curl --data "value=30" --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/features/new_library
......
......@@ -964,3 +964,30 @@ Example response:
## Comments on issues
Comments are done via the [notes](notes.md) resource.
## Get user agent details
Available only for admins.
```
GET /projects/:id/issues/:issue_iid/user_agent_detail
```
| Attribute | Type | Required | Description |
|-------------|---------|----------|--------------------------------------|
| `id` | integer/string | yes | The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user |
| `issue_iid` | integer | yes | The internal ID of a project's issue |
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/5/issues/93/user_agent_detail
```
Example response:
```json
{
"user_agent": "AppleWebKit/537.36",
"ip_address": "127.0.0.1",
"akismet_submitted": false
}
```
# GitLab as an OAuth2 provider
This document covers using the OAuth2 protocol to access GitLab.
This document covers using the [OAuth2](https://oauth.net/2/) protocol to allow other services access Gitlab resources on user's behalf.
If you want GitLab to be an OAuth authentication service provider to sign into other services please see the [Oauth2 provider documentation](../integration/oauth_provider.md).
If you want GitLab to be an OAuth authentication service provider to sign into other services please see the [OAuth2 provider](../integration/oauth_provider.md)
documentation.
OAuth2 is a protocol that enables us to authenticate a user without requiring them to give their password to a third-party.
This functionality is based on [doorkeeper gem](https://github.com/doorkeeper-gem/doorkeeper).
This functionality is based on [doorkeeper gem](https://github.com/doorkeeper-gem/doorkeeper)
## Supported OAuth2 Flows
## Web Application Flow
Gitlab currently supports following authorization flows:
This is the most common type of flow and is used by server-side clients that wish to access GitLab on a user's behalf.
* *Web Application Flow* - Most secure and common type of flow, designed for the applications with secure server-side.
* *Implicit Flow* - This flow is designed for user-agent only apps (e.g. single page web application running on GitLab Pages).
* *Resource Owner Password Credentials Flow* - To be used **only** for securely hosted, first-party services.
>**Note:**
This flow **should not** be used for client-side clients as you would leak your `client_secret`. Client-side clients should use the Implicit Grant (which is currently unsupported).
Please refer to [OAuth RFC](https://tools.ietf.org/html/rfc6749) to find out in details how all those flows work and pick the right one for your use case.
For more detailed information, check out the [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.1)
Both *web application* and *implicit* flows require `application` to be registered first via `/profile/applications` page
in your user's account. During registration, by enabling proper scopes you can limit the range of resources which the `application` can access. Upon creation
you'll obtain `application` credentials: _Application ID_ and _Client Secret_ - **keep them secure**.
In the following sections you will be introduced to the three steps needed for this flow.
>**Important:** OAuth specification advises sending `state` parameter with each request to `/oauth/authorize`. We highly recommended to send a unique
value with each request and validate it against the one in redirect request. This is important to prevent [CSRF attacks]. The `state` param really should
have been a requirement in the standard!
### 1. Registering the client
In the following sections you will find detailed instructions on how to obtain authorization with each flow.
First, you should create an application (`/profile/applications`) in your user's account.
Each application gets a unique App ID and App Secret parameters.
### Web Application Flow
>**Note:**
**You should not share/leak your App ID or App Secret.**
Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.1) for a detailed flow description
### 2. Requesting authorization
#### 1. Requesting authorization code
To request the authorization code, you should redirect the user to the `/oauth/authorize` endpoint:
To request the authorization code, you should redirect the user to the `/oauth/authorize` endpoint with following GET parameters:
```
https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=your_unique_state_hash
https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=code&state=YOUR_UNIQUE_STATE_HASH
```
This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided.
This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided. The redirect will
include the GET `code` parameter, for example:
The redirect will include the GET `code` parameter, for example:
```
http://myapp.com/oauth/redirect?code=1234567890&state=your_unique_state_hash
```
`http://myapp.com/oauth/redirect?code=1234567890&state=YOUR_UNIQUE_STATE_HASH`
You should then use the `code` to request an access token.
>**Important:**
It is highly recommended that you send a `state` value with the request to `/oauth/authorize` and
validate that value is returned and matches in the redirect request.
This is important to prevent [CSRF attacks](http://www.oauthsecurity.com/#user-content-authorization-code-flow),
`state` really should have been a requirement in the standard!
### 3. Requesting the access token
#### 2. Requesting access token
Once you have the authorization code you can request an `access_token` using the code, to do that you can use any HTTP client. In the following example, we are using Ruby's `rest-client`:
Once you have the authorization code you can request an `access_token` using the code, to do that you can use any HTTP client. In the following example,
we are using Ruby's `rest-client`:
```
parameters = 'client_id=APP_ID&client_secret=APP_SECRET&code=RETURNED_CODE&grant_type=authorization_code&redirect_uri=REDIRECT_URI'
......@@ -72,28 +68,40 @@ The `redirect_uri` must match the `redirect_uri` used in the original authorizat
You can now make requests to the API with the access token returned.
### Use the access token to access the API
The access token allows you to make requests to the API on a behalf of a user.
### Implicit Grant
Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.2) for a detailed flow description.
Unlike the web flow, the client receives an `access token` immediately as a result of the authorization request. The flow does not use client secret
or authorization code because all of the application code and storage is easily accessible, therefore __secrets__ can leak easily.
>**Important:** Avoid using this flow for applications that store data outside of the Gitlab instance. If you do, make sure to verify `application id`
associated with access token before granting access to the data
(see [/oauth/token/info](https://github.com/doorkeeper-gem/doorkeeper/wiki/API-endpoint-descriptions-and-examples#get----oauthtokeninfo)).
#### 1. Requesting access token
To request the access token, you should redirect the user to the `/oauth/authorize` endpoint using `token` response type:
```
GET https://gitlab.example.com/api/v4/user?access_token=OAUTH-TOKEN
https://gitlab.example.com/oauth/authorize?client_id=APP_ID&redirect_uri=REDIRECT_URI&response_type=token&state=YOUR_UNIQUE_STATE_HASH
```
Or you can put the token to the Authorization header:
This will ask the user to approve the applications access to their account and then redirect back to the `REDIRECT_URI` you provided. The redirect
will include a fragment with `access_token` as well as token details in GET parameters, for example:
```
curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v4/user
http://myapp.com/oauth/redirect#access_token=ABCDExyz123&state=YOUR_UNIQUE_STATE_HASH&token_type=bearer&expires_in=3600
```
## Resource Owner Password Credentials
### Resource Owner Password Credentials
## Deprecation Notice
Check [RFC spec](http://tools.ietf.org/html/rfc6749#section-4.3) for a detailed flow description.
1. Starting in GitLab 8.11, the Resource Owner Password Credentials has been *disabled* for users with two-factor authentication turned on.
2. These users can access the API using [personal access tokens] instead.
---
> **Deprecation notice:** Starting in GitLab 8.11, the Resource Owner Password Credentials has been *disabled* for users with two-factor authentication
turned on. These users can access the API using [personal access tokens] instead.
In this flow, a token is requested in exchange for the resource owner credentials (username and password).
The credentials should only be used when there is a high degree of trust between the resource owner and the client (e.g. the
......@@ -101,12 +109,16 @@ client is part of the device operating system or a highly privileged application
available (such as an authorization code).
>**Important:**
Never store the users credentials and only use this grant type when your client is deployed to a trusted environment, in 99% of cases [personal access tokens] are a better choice.
Never store the users credentials and only use this grant type when your client is deployed to a trusted environment, in 99% of cases [personal access tokens]
are a better choice.
Even though this grant type requires direct client access to the resource owner credentials, the resource owner credentials are used
for a single request and are exchanged for an access token. This grant type can eliminate the need for the client to store the
resource owner credentials for future use, by exchanging the credentials with a long-lived access token or refresh token.
You can do POST request to `/oauth/token` with parameters:
#### 1. Requesting access token
POST request to `/oauth/token` with parameters:
```
{
......@@ -134,4 +146,18 @@ access_token = client.password.get_token('user@example.com', 'secret')
puts access_token.token
```
## Access Gitlab API with `access token`
The `access token` allows you to make requests to the API on a behalf of a user. You can pass the token either as GET parameter
```
GET https://gitlab.example.com/api/v4/user?access_token=OAUTH-TOKEN
```
or you can put the token to the Authorization header:
```
curl --header "Authorization: Bearer OAUTH-TOKEN" https://gitlab.example.com/api/v4/user
```
[personal access tokens]: ../user/profile/personal_access_tokens.md
[CSRF attacks]: http://www.oauthsecurity.com/#user-content-authorization-code-flow
\ No newline at end of file
......@@ -119,3 +119,35 @@ Parameters:
- `id` (required) - The ID or [URL-encoded path of the project](README.md#namespaced-path-encoding) owned by the authenticated user
- `snippet_id` (required) - The ID of a project's snippet
## Get user agent details
> **Notes:**
> [Introduced][ce-29508] in GitLab 9.4.
Available only for admins.
```
GET /projects/:id/snippets/:snippet_id/user_agent_detail
```
| Attribute | Type | Required | Description |
|-------------|---------|----------|--------------------------------------|
| `id` | Integer | yes | The ID of a snippet |
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/projects/1/snippets/1/user_agent_detail
```
Example response:
```json
{
"user_agent": "AppleWebKit/537.36",
"ip_address": "127.0.0.1",
"akismet_submitted": false
}
```
[ce-[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508
......@@ -234,3 +234,35 @@ Example response:
}
]
```
## Get user agent details
> **Notes:**
> [Introduced][ce-29508] in GitLab 9.4.
Available only for admins.
```
GET /snippets/:id/user_agent_detail
```
| Attribute | Type | Required | Description |
|-------------|---------|----------|--------------------------------------|
| `id` | Integer | yes | The ID of a snippet |
```bash
curl --request GET --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/snippets/1/user_agent_detail
```
Example response:
```json
{
"user_agent": "AppleWebKit/537.36",
"ip_address": "127.0.0.1",
"akismet_submitted": false
}
```
[ce-[ce-29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508]: https://gitlab.com/gitlab-org/gitlab-ce/issues/29508
......@@ -55,6 +55,7 @@
- [Single Table Inheritance](single_table_inheritance.md)
- [Background Migrations](background_migrations.md)
- [Storing SHA1 Hashes As Binary](sha1_as_binary.md)
- [Iterating Tables In Batches](iterating_tables_in_batches.md)
## i18n
......
......@@ -3,5 +3,19 @@
Starting from GitLab 9.3 we support feature flags via
[Flipper](https://github.com/jnunemaker/flipper/). You should use the `Feature`
class (defined in `lib/feature.rb`) in your code to get, set and list feature
flags. During runtime you can set the values for the gates via the
[admin API](../api/features.md).
flags.
During runtime you can set the values for the gates via the
[features API](../api/features.md) (accessible to admins only).
## Feature groups
Starting from GitLab 9.4 we support feature groups via
[Flipper groups](https://github.com/jnunemaker/flipper/blob/v0.10.2/docs/Gates.md#2-group).
Feature groups must be defined statically in `lib/feature.rb` (in the
`.register_feature_groups` method), but their implementation can obviously be
dynamic (querying the DB etc.).
Once defined in `lib/feature.rb`, you will be able to activate a
feature for a given feature group via the [`feature_group` param of the features API](../api/features.md#set-or-create-a-feature)
# Iterating Tables In Batches
Rails provides a method called `in_batches` that can be used to iterate over
rows in batches. For example:
```ruby
User.in_batches(of: 10) do |relation|
relation.update_all(updated_at: Time.now)
end
```
Unfortunately this method is implemented in a way that is not very efficient,
both query and memory usage wise.
To work around this you can include the `EachBatch` module into your models,
then use the `each_batch` class method. For example:
```ruby
class User < ActiveRecord::Base
include EachBatch
end
User.each_batch(of: 10) do |relation|
relation.update_all(updated_at: Time.now)
end
```
This will end up producing queries such as:
```
User Load (0.7ms) SELECT "users"."id" FROM "users" WHERE ("users"."id" >= 41654) ORDER BY "users"."id" ASC LIMIT 1 OFFSET 1000
(0.7ms) SELECT COUNT(*) FROM "users" WHERE ("users"."id" >= 41654) AND ("users"."id" < 42687)
```
The API of this method is similar to `in_batches`, though it doesn't support
all of the arguments that `in_batches` supports. You should always use
`each_batch` _unless_ you have a specific need for `in_batches`.
......@@ -39,6 +39,9 @@ mysql> SET storage_engine=INNODB;
# If you have MySQL < 5.7.7 and want to enable utf8mb4 character set support with your GitLab install, you must set the following NOW:
mysql> SET GLOBAL innodb_file_per_table=1, innodb_file_format=Barracuda, innodb_large_prefix=1;
# If you use MySQL with replication, or just have MySQL configured with binary logging, you need to run the following to allow the use of `TRIGGER`:
mysql> SET GLOBAL log_bin_trust_function_creators = 1;
# Create the GitLab production database
mysql> CREATE DATABASE IF NOT EXISTS `gitlabhq_production` DEFAULT CHARACTER SET `utf8` COLLATE `utf8_general_ci`;
......@@ -60,7 +63,15 @@ mysql> \q
```
You are done installing the database for now and can go back to the rest of the installation.
Please proceed to the rest of the installation before running through the utf8mb4 support section.
Please proceed to the rest of the installation **before** running through the steps below.
### `log_bin_trust_function_creators`
If you use MySQL with replication, or just have MySQL configured with binary logging, all of your MySQL servers will need to have `log_bin_trust_function_creators` enabled to allow the use of `TRIGGER` in migrations. You have already set this global variable in the steps above, but to make it persistent, add the following to your `my.cnf` file:
```
log_bin_trust_function_creators=1
```
### MySQL utf8mb4 support
......
......@@ -164,6 +164,19 @@ permissions on the database:
```bash
mysql -u root -p -e "GRANT TRIGGER ON \`gitlabhq_production\`.* TO 'git'@'localhost';"
```
If you use MySQL with replication, or just have MySQL configured with binary logging,
you will need to also run the following on all of your MySQL servers:
```bash
mysql -u root -p -e "SET GLOBAL log_bin_trust_function_creators = 1;"
```
You can make this setting permanent by adding it to your `my.cnf`:
```
log_bin_trust_function_creators=1
```
### 11. Update configuration files
......
......@@ -888,5 +888,11 @@ module API
expose :dependencies, using: Dependency
end
end
class UserAgentDetail < Grape::Entity
expose :user_agent
expose :ip_address
expose :submitted, as: :akismet_submitted
end
end
end
......@@ -14,14 +14,12 @@ module API
end
end
def gate_target(params)
if params[:feature_group]
Feature.group(params[:feature_group])
elsif params[:user]
User.find_by_username(params[:user])
else
gate_value(params)
end
def gate_targets(params)
targets = []
targets << Feature.group(params[:feature_group]) if params[:feature_group]
targets << User.find_by_username(params[:user]) if params[:user]
targets
end
end
......@@ -42,18 +40,25 @@ module API
requires :value, type: String, desc: '`true` or `false` to enable/disable, an integer for percentage of time'
optional :feature_group, type: String, desc: 'A Feature group name'
optional :user, type: String, desc: 'A GitLab username'
mutually_exclusive :feature_group, :user
end
post ':name' do
feature = Feature.get(params[:name])
target = gate_target(params)
targets = gate_targets(params)
value = gate_value(params)
case value
when true
feature.enable(target)
if targets.present?
targets.each { |target| feature.enable(target) }
else
feature.enable
end
when false
feature.disable(target)
if targets.present?
targets.each { |target| feature.disable(target) }
else
feature.disable
end
else
feature.enable_percentage_of_time(value)
end
......
......@@ -241,6 +241,22 @@ module API
present paginate(merge_requests), with: Entities::MergeRequestBasic, current_user: current_user, project: user_project
end
desc 'Get the user agent details for an issue' do
success Entities::UserAgentDetail
end
params do
requires :issue_iid, type: Integer, desc: 'The internal ID of a project issue'
end
get ":id/issues/:issue_iid/user_agent_detail" do
authenticated_as_admin!
issue = find_project_issue(params[:issue_iid])
return not_found!('UserAgentDetail') unless issue.user_agent_detail
present issue.user_agent_detail, with: Entities::UserAgentDetail
end
end
end
end
......@@ -131,6 +131,22 @@ module API
content_type 'text/plain'
present snippet.content
end
desc 'Get the user agent details for a project snippet' do
success Entities::UserAgentDetail
end
params do
requires :snippet_id, type: Integer, desc: 'The ID of a project snippet'
end
get ":id/snippets/:snippet_id/user_agent_detail" do
authenticated_as_admin!
snippet = Snippet.find_by!(id: params[:id])
return not_found!('UserAgentDetail') unless snippet.user_agent_detail
present snippet.user_agent_detail, with: Entities::UserAgentDetail
end
end
end
end
......@@ -140,6 +140,22 @@ module API
content_type 'text/plain'
present snippet.content
end
desc 'Get the user agent details for a snippet' do
success Entities::UserAgentDetail
end
params do
requires :id, type: Integer, desc: 'The ID of a snippet'
end
get ":id/user_agent_detail" do
authenticated_as_admin!
snippet = Snippet.find_by!(id: params[:id])
return not_found!('UserAgentDetail') unless snippet.user_agent_detail
present snippet.user_agent_detail, with: Entities::UserAgentDetail
end
end
end
end
......@@ -4,7 +4,7 @@ module Gitlab
# Abstract class for badge metadata
#
class Metadata
include Gitlab::Routing.url_helpers
include Gitlab::Routing
include ActionView::Helpers::AssetTagHelper
include ActionView::Helpers::UrlHelper
......
module Gitlab
module Conflict
class File
include Gitlab::Routing.url_helpers
include Gitlab::Routing
include IconsHelper
MissingResolution = Class.new(ResolutionError)
......
......@@ -4,7 +4,7 @@ module Gitlab
class RepositoryPush
attr_reader :author_id, :ref, :action
include Gitlab::Routing.url_helpers
include Gitlab::Routing
include DiffHelper
delegate :namespace, :name_with_namespace, to: :project, prefix: :project
......
......@@ -41,10 +41,6 @@ module Gitlab
commit_id: sha
)
when :BLOB
# EncodingDetector checks the first 1024 * 1024 bytes for NUL byte, libgit2 checks
# only the first 8000 (https://github.com/libgit2/libgit2/blob/2ed855a9e8f9af211e7274021c2264e600c0f86b/src/filter.h#L15),
# which is what we use below to keep a consistent behavior.
detect = CharlockHolmes::EncodingDetector.new(8000).detect(entry.data)
new(
id: entry.oid,
name: name,
......@@ -53,7 +49,7 @@ module Gitlab
mode: entry.mode.to_s(8),
path: path,
commit_id: sha,
binary: detect && detect[:type] == :binary
binary: binary?(entry.data)
)
end
end
......@@ -87,14 +83,28 @@ module Gitlab
end
def raw(repository, sha)
blob = repository.lookup(sha)
Gitlab::GitalyClient.migrate(:git_blob_raw) do |is_enabled|
if is_enabled
Gitlab::GitalyClient::Blob.new(repository).get_blob(oid: sha, limit: MAX_DATA_DISPLAY_SIZE)
else
blob = repository.lookup(sha)
new(
id: blob.oid,
size: blob.size,
data: blob.content(MAX_DATA_DISPLAY_SIZE),
binary: blob.binary?
)
end
end
end
new(
id: blob.oid,
size: blob.size,
data: blob.content(MAX_DATA_DISPLAY_SIZE),
binary: blob.binary?
)
def binary?(data)
# EncodingDetector checks the first 1024 * 1024 bytes for NUL byte, libgit2 checks
# only the first 8000 (https://github.com/libgit2/libgit2/blob/2ed855a9e8f9af211e7274021c2264e600c0f86b/src/filter.h#L15),
# which is what we use below to keep a consistent behavior.
detect = CharlockHolmes::EncodingDetector.new(8000).detect(data)
detect && detect[:type] == :binary
end
# Recursive search of blob id by path
......@@ -165,8 +175,17 @@ module Gitlab
return if @data == '' # don't mess with submodule blobs
return @data if @loaded_all_data
Gitlab::GitalyClient.migrate(:git_blob_load_all_data) do |is_enabled|
@data = begin
if is_enabled
Gitlab::GitalyClient::Blob.new(repository).get_blob(oid: id, limit: -1).data
else
repository.lookup(id).content
end
end
end
@loaded_all_data = true
@data = repository.lookup(id).content
@loaded_size = @data.bytesize
@binary = nil
end
......
module Gitlab
module GitalyClient
class Blob
def initialize(repository)
@gitaly_repo = repository.gitaly_repository
end
def get_blob(oid:, limit:)
request = Gitaly::GetBlobRequest.new(
repository: @gitaly_repo,
oid: oid,
limit: limit
)
response = GitalyClient.call(@gitaly_repo.storage_name, :blob_service, :get_blob, request)
blob = response.first
return unless blob.oid.present?
data = response.reduce(blob.data.dup) { |memo, msg| memo << msg.data.dup }
Gitlab::Git::Blob.new(
id: blob.oid,
size: blob.size,
data: data,
binary: Gitlab::Git::Blob.binary?(data)
)
end
end
end
end
......@@ -34,7 +34,7 @@ module Gitlab
write_csv do |csv|
ActiveRecord::Base.transaction do
User.with_two_factor.in_batches do |relation|
User.with_two_factor.in_batches do |relation| # rubocop: disable Cop/InBatches
rows = relation.pluck(:id, :encrypted_otp_secret, :encrypted_otp_secret_iv, :encrypted_otp_secret_salt)
rows.each do |row|
user = %i[id ciphertext iv salt].zip(row).to_h
......
module Gitlab
module PerformanceBar
def self.enabled?
Rails.env.development? || Feature.enabled?('gitlab_performance_bar')
include Gitlab::CurrentSettings
ALLOWED_USER_IDS_KEY = 'performance_bar_allowed_user_ids'.freeze
def self.enabled?(user = nil)
return false unless user && allowed_group_id
allowed_user_ids.include?(user.id)
end
def self.allowed_group_id
current_application_settings.performance_bar_allowed_group_id
end
def self.allowed_user_ids
Rails.cache.fetch(ALLOWED_USER_IDS_KEY) do
group = Group.find_by_id(allowed_group_id)
if group
GroupMembersFinder.new(group).execute.pluck(:user_id)
else
[]
end
end
end
def self.expire_allowed_user_ids_cache
Rails.cache.delete(ALLOWED_USER_IDS_KEY)
end
end
end
......@@ -2,10 +2,30 @@ module Gitlab
module Routing
extend ActiveSupport::Concern
mattr_accessor :_includers
self._includers = []
included do
Gitlab::Routing._includers << self
include Gitlab::Routing.url_helpers
end
def self.add_helpers(mod)
url_helpers.include mod
url_helpers.extend mod
app_url_helpers = Gitlab::Application.routes.named_routes.url_helpers_module
app_url_helpers.include mod
app_url_helpers.extend mod
GitlabRoutingHelper.include mod
GitlabRoutingHelper.extend mod
_includers.each do |klass|
klass.include mod
end
end
# Returns the URL helpers Module.
#
# This method caches the output as Rails' "url_helpers" method creates an
......
......@@ -2,7 +2,7 @@ module Gitlab
module SlashCommands
module Presenters
class Base
include Gitlab::Routing.url_helpers
include Gitlab::Routing
def initialize(resource = nil)
@resource = resource
......
module Gitlab
class UrlBuilder
include Gitlab::Routing.url_helpers
include Gitlab::Routing
include GitlabRoutingHelper
include ActionView::RecordIdentifier
......
require_relative '../model_helpers'
module RuboCop
module Cop
# Cop that prevents the use of `in_batches`
class InBatches < RuboCop::Cop::Cop
MSG = 'Do not use `in_batches`, use `each_batch` from the EachBatch module instead'.freeze
def on_send(node)
return unless node.children[1] == :in_batches
add_offense(node, :selector)
end
end
end
end
......@@ -5,6 +5,7 @@ require_relative 'cop/redirect_with_status'
require_relative 'cop/polymorphic_associations'
require_relative 'cop/project_path_helper'
require_relative 'cop/active_record_dependent'
require_relative 'cop/in_batches'
require_relative 'cop/migration/add_column'
require_relative 'cop/migration/add_column_with_default_to_large_table'
require_relative 'cop/migration/add_concurrent_foreign_key'
......
require 'spec_helper'
describe 'Issuables Close/Reopen/Report toggle', :feature do
let(:user) { create(:user) }
shared_examples 'an issuable close/reopen/report toggle' do
let(:container) { find('.issuable-close-dropdown') }
let(:human_model_name) { issuable.model_name.human.downcase }
it 'shows toggle' do
expect(page).to have_link("Close #{human_model_name}")
expect(page).to have_selector('.issuable-close-dropdown')
end
it 'opens a dropdown when toggle is clicked' do
container.find('.dropdown-toggle').click
expect(container).to have_selector('.dropdown-menu')
expect(container).to have_content("Close #{human_model_name}")
expect(container).to have_content('Report abuse')
expect(container).to have_content("Report #{human_model_name.pluralize} that are abusive, inappropriate or spam.")
expect(container).to have_selector('.close-item.droplab-item-selected')
expect(container).to have_selector('.report-item')
expect(container).not_to have_selector('.report-item.droplab-item-selected')
expect(container).not_to have_selector('.reopen-item')
end
it 'changes the button when an item is selected' do
button = container.find('.issuable-close-button')
container.find('.dropdown-toggle').click
container.find('.report-item').click
expect(container).not_to have_selector('.dropdown-menu')
expect(button).to have_content('Report abuse')
container.find('.dropdown-toggle').click
container.find('.close-item').click
expect(button).to have_content("Close #{human_model_name}")
end
end
context 'on an issue' do
let(:project) { create(:empty_project) }
let(:issuable) { create(:issue, project: project) }
before do
project.add_master(user)
login_as user
end
context 'when user has permission to update', :js do
before do
visit project_issue_path(project, issuable)
end
it_behaves_like 'an issuable close/reopen/report toggle'
end
context 'when user doesnt have permission to update' do
let(:cant_project) { create(:empty_project) }
let(:cant_issuable) { create(:issue, project: cant_project) }
before do
cant_project.add_guest(user)
visit project_issue_path(cant_project, cant_issuable)
end
it 'only shows the `Report abuse` and `New issue` buttons' do
expect(page).to have_link('Report abuse')
expect(page).to have_link('New issue')
expect(page).not_to have_link('Close issue')
expect(page).not_to have_link('Reopen issue')
expect(page).not_to have_link('Edit')
end
end
end
context 'on a merge request' do
let(:project) { create(:project) }
let(:issuable) { create(:merge_request, source_project: project) }
before do
project.add_master(user)
login_as user
end
context 'when user has permission to update', :js do
before do
visit project_merge_request_path(project, issuable)
end
it_behaves_like 'an issuable close/reopen/report toggle'
end
context 'when user doesnt have permission to update' do
let(:cant_project) { create(:project) }
let(:cant_issuable) { create(:merge_request, source_project: cant_project) }
before do
cant_project.add_reporter(user)
visit project_merge_request_path(cant_project, cant_issuable)
end
it 'only shows a `Report abuse` button' do
expect(page).to have_link('Report abuse')
expect(page).not_to have_link('Close merge request')
expect(page).not_to have_link('Reopen merge request')
expect(page).not_to have_link('Edit')
end
end
end
end
......@@ -133,7 +133,7 @@ describe 'Visual tokens', js: true, feature: true do
describe 'editing milestone token' do
before do
input_filtered_search('milestone:%10.0 author:none', submit: false)
first('.tokens-container .filtered-search-token').double_click
first('.tokens-container .filtered-search-token').click
first('#js-dropdown-milestone .filter-dropdown .filter-dropdown-item')
end
......
......@@ -14,6 +14,18 @@ feature 'GFM autocomplete', feature: true, js: true do
wait_for_requests
end
it 'updates issue descripton with GFM reference' do
find('.issuable-edit').click
find('#issue-description').native.send_keys("@#{user.name[0...3]}")
find('.atwho-view .cur').trigger('click')
click_button 'Save changes'
expect(find('.description')).to have_content(user.to_reference)
end
it 'opens autocomplete menu when field starts with text' do
page.within '.timeline-content-form' do
find('#note_note').native.send_keys('')
......
......@@ -13,7 +13,7 @@ feature 'OAuth Login', js: true do
end
providers = [:github, :twitter, :bitbucket, :gitlab, :google_oauth2,
:facebook, :cas3, :auth0]
:facebook, :cas3, :auth0, :authentiq]
before(:all) do
# The OmniAuth `full_host` parameter doesn't get set correctly (it gets set to something like `http://localhost`
......
......@@ -33,22 +33,24 @@ describe 'User can display performance bar', :js do
end
end
let(:group) { create(:group) }
context 'when user is logged-out' do
before do
visit root_path
end
context 'when the gitlab_performance_bar feature is disabled' do
context 'when the performance_bar feature is disabled' do
before do
Feature.disable('gitlab_performance_bar')
stub_application_setting(performance_bar_allowed_group_id: nil)
end
it_behaves_like 'performance bar is disabled'
end
context 'when the gitlab_performance_bar feature is enabled' do
context 'when the performance_bar feature is enabled' do
before do
Feature.enable('gitlab_performance_bar')
stub_application_setting(performance_bar_allowed_group_id: group.id)
end
it_behaves_like 'performance bar is disabled'
......@@ -57,22 +59,25 @@ describe 'User can display performance bar', :js do
context 'when user is logged-in' do
before do
gitlab_sign_in(create(:user))
user = create(:user)
gitlab_sign_in(user)
group.add_guest(user)
visit root_path
end
context 'when the gitlab_performance_bar feature is disabled' do
context 'when the performance_bar feature is disabled' do
before do
Feature.disable('gitlab_performance_bar')
stub_application_setting(performance_bar_allowed_group_id: nil)
end
it_behaves_like 'performance bar is disabled'
end
context 'when the gitlab_performance_bar feature is enabled' do
context 'when the performance_bar feature is enabled' do
before do
Feature.enable('gitlab_performance_bar')
stub_application_setting(performance_bar_allowed_group_id: group.id)
end
it_behaves_like 'performance bar is enabled'
......
import CloseReopenReportToggle from '~/close_reopen_report_toggle';
import DropLab from '~/droplab/drop_lab';
describe('CloseReopenReportToggle', () => {
describe('class constructor', () => {
const dropdownTrigger = {};
const dropdownList = {};
const button = {};
let commentTypeToggle;
beforeEach(function () {
commentTypeToggle = new CloseReopenReportToggle({
dropdownTrigger,
dropdownList,
button,
});
});
it('sets .dropdownTrigger', function () {
expect(commentTypeToggle.dropdownTrigger).toBe(dropdownTrigger);
});
it('sets .dropdownList', function () {
expect(commentTypeToggle.dropdownList).toBe(dropdownList);
});
it('sets .button', function () {
expect(commentTypeToggle.button).toBe(button);
});
});
describe('initDroplab', () => {
let closeReopenReportToggle;
const dropdownList = jasmine.createSpyObj('dropdownList', ['querySelector']);
const dropdownTrigger = {};
const button = {};
const reopenItem = {};
const closeItem = {};
const config = {};
beforeEach(() => {
spyOn(DropLab.prototype, 'init');
dropdownList.querySelector.and.returnValues(reopenItem, closeItem);
closeReopenReportToggle = new CloseReopenReportToggle({
dropdownTrigger,
dropdownList,
button,
});
spyOn(closeReopenReportToggle, 'setConfig').and.returnValue(config);
closeReopenReportToggle.initDroplab();
});
it('sets .reopenItem and .closeItem', () => {
expect(dropdownList.querySelector).toHaveBeenCalledWith('.reopen-item');
expect(dropdownList.querySelector).toHaveBeenCalledWith('.close-item');
expect(closeReopenReportToggle.reopenItem).toBe(reopenItem);
expect(closeReopenReportToggle.closeItem).toBe(closeItem);
});
it('sets .droplab', () => {
expect(closeReopenReportToggle.droplab).toEqual(jasmine.any(Object));
});
it('calls .setConfig', () => {
expect(closeReopenReportToggle.setConfig).toHaveBeenCalled();
});
it('calls droplab.init', () => {
expect(DropLab.prototype.init).toHaveBeenCalledWith(
dropdownTrigger,
dropdownList,
jasmine.any(Array),
config,
);
});
});
describe('updateButton', () => {
let closeReopenReportToggle;
const dropdownList = {};
const dropdownTrigger = {};
const button = jasmine.createSpyObj('button', ['blur']);
const isClosed = true;
beforeEach(() => {
closeReopenReportToggle = new CloseReopenReportToggle({
dropdownTrigger,
dropdownList,
button,
});
spyOn(closeReopenReportToggle, 'toggleButtonType');
closeReopenReportToggle.updateButton(isClosed);
});
it('calls .toggleButtonType', () => {
expect(closeReopenReportToggle.toggleButtonType).toHaveBeenCalledWith(isClosed);
});
it('calls .button.blur', () => {
expect(closeReopenReportToggle.button.blur).toHaveBeenCalled();
});
});
describe('toggleButtonType', () => {
let closeReopenReportToggle;
const dropdownList = {};
const dropdownTrigger = {};
const button = {};
const isClosed = true;
const showItem = jasmine.createSpyObj('showItem', ['click']);
const hideItem = {};
showItem.classList = jasmine.createSpyObj('classList', ['add', 'remove']);
hideItem.classList = jasmine.createSpyObj('classList', ['add', 'remove']);
beforeEach(() => {
closeReopenReportToggle = new CloseReopenReportToggle({
dropdownTrigger,
dropdownList,
button,
});
spyOn(closeReopenReportToggle, 'getButtonTypes').and.returnValue([showItem, hideItem]);
closeReopenReportToggle.toggleButtonType(isClosed);
});
it('calls .getButtonTypes', () => {
expect(closeReopenReportToggle.getButtonTypes).toHaveBeenCalledWith(isClosed);
});
it('removes hide class and add selected class to showItem, opposite for hideItem', () => {
expect(showItem.classList.remove).toHaveBeenCalledWith('hidden');
expect(showItem.classList.add).toHaveBeenCalledWith('droplab-item-selected');
expect(hideItem.classList.add).toHaveBeenCalledWith('hidden');
expect(hideItem.classList.remove).toHaveBeenCalledWith('droplab-item-selected');
});
it('clicks the showItem', () => {
expect(showItem.click).toHaveBeenCalled();
});
});
describe('getButtonTypes', () => {
let closeReopenReportToggle;
const dropdownList = {};
const dropdownTrigger = {};
const button = {};
const reopenItem = {};
const closeItem = {};
beforeEach(() => {
closeReopenReportToggle = new CloseReopenReportToggle({
dropdownTrigger,
dropdownList,
button,
});
closeReopenReportToggle.reopenItem = reopenItem;
closeReopenReportToggle.closeItem = closeItem;
});
it('returns reopenItem, closeItem if isClosed is true', () => {
const buttonTypes = closeReopenReportToggle.getButtonTypes(true);
expect(buttonTypes).toEqual([reopenItem, closeItem]);
});
it('returns closeItem, reopenItem if isClosed is false', () => {
const buttonTypes = closeReopenReportToggle.getButtonTypes(false);
expect(buttonTypes).toEqual([closeItem, reopenItem]);
});
});
describe('setDisable', () => {
let closeReopenReportToggle;
const dropdownList = {};
const dropdownTrigger = jasmine.createSpyObj('button', ['setAttribute', 'removeAttribute']);
const button = jasmine.createSpyObj('button', ['setAttribute', 'removeAttribute']);
beforeEach(() => {
closeReopenReportToggle = new CloseReopenReportToggle({
dropdownTrigger,
dropdownList,
button,
});
});
it('disable .button and .dropdownTrigger if shouldDisable is true', () => {
closeReopenReportToggle.setDisable(true);
expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
});
it('disable .button and .dropdownTrigger if shouldDisable is undefined', () => {
closeReopenReportToggle.setDisable();
expect(button.setAttribute).toHaveBeenCalledWith('disabled', 'true');
expect(dropdownTrigger.setAttribute).toHaveBeenCalledWith('disabled', 'true');
});
it('enable .button and .dropdownTrigger if shouldDisable is false', () => {
closeReopenReportToggle.setDisable(false);
expect(button.removeAttribute).toHaveBeenCalledWith('disabled');
expect(dropdownTrigger.removeAttribute).toHaveBeenCalledWith('disabled');
});
});
describe('setConfig', () => {
let closeReopenReportToggle;
const dropdownList = {};
const dropdownTrigger = {};
const button = {};
let config;
beforeEach(() => {
closeReopenReportToggle = new CloseReopenReportToggle({
dropdownTrigger,
dropdownList,
button,
});
config = closeReopenReportToggle.setConfig();
});
it('returns a config object', () => {
expect(config).toEqual({
InputSetter: [
{
input: button,
valueAttribute: 'data-text',
inputAttribute: 'data-value',
},
{
input: button,
valueAttribute: 'data-text',
inputAttribute: 'title',
},
{
input: button,
valueAttribute: 'data-button-class',
inputAttribute: 'class',
},
{
input: dropdownTrigger,
valueAttribute: 'data-toggle-class',
inputAttribute: 'class',
},
{
input: button,
valueAttribute: 'data-url',
inputAttribute: 'href',
},
{
input: button,
valueAttribute: 'data-method',
inputAttribute: 'data-method',
},
],
});
});
});
});
/* eslint-disable space-before-function-paren, one-var, one-var-declaration-per-line, no-use-before-define, comma-dangle, max-len */
import Issue from '~/issue';
import CloseReopenReportToggle from '~/close_reopen_report_toggle';
import '~/lib/utils/text_utility';
describe('Issue', function() {
let $boxClosed, $boxOpen, $btnClose, $btnReopen;
let $boxClosed, $boxOpen, $btn;
preloadFixtures('issues/closed-issue.html.raw');
preloadFixtures('issues/issue-with-task-list.html.raw');
......@@ -20,9 +20,7 @@ describe('Issue', function() {
function expectIssueState(isIssueOpen) {
expectVisibility($boxClosed, !isIssueOpen);
expectVisibility($boxOpen, isIssueOpen);
expectVisibility($btnClose, isIssueOpen);
expectVisibility($btnReopen, !isIssueOpen);
expect($btn).toHaveText(isIssueOpen ? 'Close issue' : 'Reopen issue');
}
function expectNewBranchButtonState(isPending, canCreate) {
......@@ -57,7 +55,7 @@ describe('Issue', function() {
}
}
function findElements() {
function findElements(isIssueInitiallyOpen) {
$boxClosed = $('div.status-box-closed');
expect($boxClosed).toExist();
expect($boxClosed).toHaveText('Closed');
......@@ -66,13 +64,9 @@ describe('Issue', function() {
expect($boxOpen).toExist();
expect($boxOpen).toHaveText('Open');
$btnClose = $('.btn-close.btn-grouped');
expect($btnClose).toExist();
expect($btnClose).toHaveText('Close issue');
$btnReopen = $('.btn-reopen.btn-grouped');
expect($btnReopen).toExist();
expect($btnReopen).toHaveText('Reopen issue');
$btn = $('.js-issuable-close-button');
expect($btn).toExist();
expect($btn).toHaveText(isIssueInitiallyOpen ? 'Close issue' : 'Reopen issue');
}
describe('task lists', function() {
......@@ -99,7 +93,6 @@ describe('Issue', function() {
function ajaxSpy(req) {
if (req.url === this.$triggeredButton.attr('href')) {
expect(req.type).toBe('PUT');
expect(this.$triggeredButton).toHaveProp('disabled', true);
expectNewBranchButtonState(true, false);
return this.issueStateDeferred;
} else if (req.url === Issue.createMrDropdownWrap.dataset.canCreatePath) {
......@@ -119,10 +112,11 @@ describe('Issue', function() {
loadFixtures('issues/closed-issue.html.raw');
}
findElements();
findElements(isIssueInitiallyOpen);
this.issue = new Issue();
expectIssueState(isIssueInitiallyOpen);
this.$triggeredButton = isIssueInitiallyOpen ? $btnClose : $btnReopen;
this.$triggeredButton = $btn;
this.$projectIssuesCounter = $('.issue_counter');
this.$projectIssuesCounter.text('1,001');
......@@ -143,7 +137,7 @@ describe('Issue', function() {
});
expectIssueState(!isIssueInitiallyOpen);
expect(this.$triggeredButton).toHaveProp('disabled', false);
expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
expect(this.$projectIssuesCounter.text()).toBe(isIssueInitiallyOpen ? '1,000' : '1,002');
expectNewBranchButtonState(false, !isIssueInitiallyOpen);
});
......@@ -158,7 +152,7 @@ describe('Issue', function() {
});
expectIssueState(isIssueInitiallyOpen);
expect(this.$triggeredButton).toHaveProp('disabled', false);
expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
expectErrorMessage();
expect(this.$projectIssuesCounter.text()).toBe('1,001');
expectNewBranchButtonState(false, isIssueInitiallyOpen);
......@@ -172,7 +166,7 @@ describe('Issue', function() {
});
expectIssueState(isIssueInitiallyOpen);
expect(this.$triggeredButton).toHaveProp('disabled', true);
expect(this.$triggeredButton.get(0).getAttribute('disabled')).toBeNull();
expectErrorMessage();
expect(this.$projectIssuesCounter.text()).toBe('1,001');
expectNewBranchButtonState(false, isIssueInitiallyOpen);
......@@ -195,4 +189,37 @@ describe('Issue', function() {
});
});
});
describe('units', () => {
describe('class constructor', () => {
it('calls .initCloseReopenReport', () => {
spyOn(Issue.prototype, 'initCloseReopenReport');
new Issue(); // eslint-disable-line no-new
expect(Issue.prototype.initCloseReopenReport).toHaveBeenCalled();
});
});
describe('initCloseReopenReport', () => {
it('calls .initDroplab', () => {
const container = jasmine.createSpyObj('container', ['querySelector']);
const dropdownTrigger = {};
const dropdownList = {};
const button = {};
spyOn(document, 'querySelector').and.returnValue(container);
spyOn(CloseReopenReportToggle.prototype, 'initDroplab');
container.querySelector.and.returnValues(dropdownTrigger, dropdownList, button);
Issue.prototype.initCloseReopenReport();
expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown');
expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle');
expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu');
expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button');
expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled();
});
});
});
});
......@@ -2,10 +2,12 @@
/* global MergeRequest */
import '~/merge_request';
import CloseReopenReportToggle from '~/close_reopen_report_toggle';
import IssuablesHelper from '~/helpers/issuables_helper';
(function() {
describe('MergeRequest', function() {
return describe('task lists', function() {
describe('task lists', function() {
preloadFixtures('merge_requests/merge_request_with_task_list.html.raw');
beforeEach(function() {
loadFixtures('merge_requests/merge_request_with_task_list.html.raw');
......@@ -27,5 +29,34 @@ import '~/merge_request';
return $('.js-task-list-field').trigger('tasklist:changed');
});
});
describe('class constructor', () => {
it('calls .initCloseReopenReport', () => {
spyOn(IssuablesHelper, 'initCloseReopenReport');
new MergeRequest(); // eslint-disable-line no-new
expect(IssuablesHelper.initCloseReopenReport).toHaveBeenCalled();
});
it('calls .initDroplab', () => {
const container = jasmine.createSpyObj('container', ['querySelector']);
const dropdownTrigger = {};
const dropdownList = {};
const button = {};
spyOn(CloseReopenReportToggle.prototype, 'initDroplab');
spyOn(document, 'querySelector').and.returnValue(container);
container.querySelector.and.returnValues(dropdownTrigger, dropdownList, button);
new MergeRequest(); // eslint-disable-line no-new
expect(document.querySelector).toHaveBeenCalledWith('.js-issuable-close-dropdown');
expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-toggle');
expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-menu');
expect(container.querySelector).toHaveBeenCalledWith('.js-issuable-close-button');
expect(CloseReopenReportToggle.prototype.initDroplab).toHaveBeenCalled();
});
});
});
}).call(window);
......@@ -3,7 +3,7 @@ require 'spec_helper'
describe ExtractsPath, lib: true do
include ExtractsPath
include RepoHelpers
include Gitlab::Routing.url_helpers
include Gitlab::Routing
let(:project) { double('project') }
let(:request) { double('request') }
......
......@@ -111,7 +111,7 @@ describe Gitlab::Git::Blob, seed_helper: true do
end
end
describe '.raw' do
shared_examples 'finding blobs by ID' do
let(:raw_blob) { Gitlab::Git::Blob.raw(repository, SeedRepo::RubyBlob::ID) }
it { expect(raw_blob.id).to eq(SeedRepo::RubyBlob::ID) }
it { expect(raw_blob.data[0..10]).to eq("require \'fi") }
......@@ -136,6 +136,16 @@ describe Gitlab::Git::Blob, seed_helper: true do
end
end
describe '.raw' do
context 'when the blob_raw Gitaly feature is enabled' do
it_behaves_like 'finding blobs by ID'
end
context 'when the blob_raw Gitaly feature is disabled', skip_gitaly_mock: true do
it_behaves_like 'finding blobs by ID'
end
end
describe 'encoding' do
context 'file with russian text' do
let(:blob) { Gitlab::Git::Blob.find(repository, SeedRepo::Commit::ID, "encoding/russian.rb") }
......
require 'spec_helper'
describe Gitlab::PerformanceBar do
shared_examples 'allowed user IDs are cached' do
before do
# Warm the Redis cache
described_class.enabled?(user)
end
it 'caches the allowed user IDs in cache', :caching do
expect do
expect(described_class.enabled?(user)).to be_truthy
end.not_to exceed_query_limit(0)
end
end
describe '.enabled?' do
let(:user) { create(:user) }
before do
stub_application_setting(performance_bar_allowed_group_id: -1)
end
it 'returns false when given user is nil' do
expect(described_class.enabled?(nil)).to be_falsy
end
it 'returns false when allowed_group_id is nil' do
expect(described_class).to receive(:allowed_group_id).and_return(nil)
expect(described_class.enabled?(user)).to be_falsy
end
context 'when allowed group ID does not exist' do
it 'returns false' do
expect(described_class.enabled?(user)).to be_falsy
end
end
context 'when allowed group exists' do
let!(:my_group) { create(:group, path: 'my-group') }
before do
stub_application_setting(performance_bar_allowed_group_id: my_group.id)
end
context 'when user is not a member of the allowed group' do
it 'returns false' do
expect(described_class.enabled?(user)).to be_falsy
end
it_behaves_like 'allowed user IDs are cached'
end
context 'when user is a member of the allowed group' do
before do
my_group.add_developer(user)
end
it 'returns true' do
expect(described_class.enabled?(user)).to be_truthy
end
it_behaves_like 'allowed user IDs are cached'
end
end
context 'when allowed group is nested', :nested_groups do
let!(:nested_my_group) { create(:group, parent: create(:group, path: 'my-org'), path: 'my-group') }
before do
create(:group, path: 'my-group')
nested_my_group.add_developer(user)
stub_application_setting(performance_bar_allowed_group_id: nested_my_group.id)
end
it 'returns the nested group' do
expect(described_class.enabled?(user)).to be_truthy
end
end
context 'when a nested group has the same path', :nested_groups do
before do
create(:group, :nested, path: 'my-group').add_developer(user)
end
it 'returns false' do
expect(described_class.enabled?(user)).to be_falsy
end
end
end
end
......@@ -214,6 +214,160 @@ describe ApplicationSetting, models: true do
end
end
describe 'performance bar settings' do
describe 'performance_bar_allowed_group_id=' do
context 'with a blank path' do
before do
setting.performance_bar_allowed_group_id = create(:group).full_path
end
it 'persists nil for a "" path and clears allowed user IDs cache' do
expect(Gitlab::PerformanceBar).to receive(:expire_allowed_user_ids_cache)
setting.performance_bar_allowed_group_id = ''
expect(setting.performance_bar_allowed_group_id).to be_nil
end
end
context 'with an invalid path' do
it 'does not persist an invalid group path' do
setting.performance_bar_allowed_group_id = 'foo'
expect(setting.performance_bar_allowed_group_id).to be_nil
end
end
context 'with a path to an existing group' do
let(:group) { create(:group) }
it 'persists a valid group path and clears allowed user IDs cache' do
expect(Gitlab::PerformanceBar).to receive(:expire_allowed_user_ids_cache)
setting.performance_bar_allowed_group_id = group.full_path
expect(setting.performance_bar_allowed_group_id).to eq(group.id)
end
context 'when the given path is the same' do
context 'with a blank path' do
before do
setting.performance_bar_allowed_group_id = nil
end
it 'clears the cached allowed user IDs' do
expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
setting.performance_bar_allowed_group_id = ''
end
end
context 'with a valid path' do
before do
setting.performance_bar_allowed_group_id = group.full_path
end
it 'clears the cached allowed user IDs' do
expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
setting.performance_bar_allowed_group_id = group.full_path
end
end
end
end
end
describe 'performance_bar_allowed_group' do
context 'with no performance_bar_allowed_group_id saved' do
it 'returns nil' do
expect(setting.performance_bar_allowed_group).to be_nil
end
end
context 'with a performance_bar_allowed_group_id saved' do
let(:group) { create(:group) }
before do
setting.performance_bar_allowed_group_id = group.full_path
end
it 'returns the group' do
expect(setting.performance_bar_allowed_group).to eq(group)
end
end
end
describe 'performance_bar_enabled' do
context 'with the Performance Bar is enabled' do
let(:group) { create(:group) }
before do
setting.performance_bar_allowed_group_id = group.full_path
end
it 'returns true' do
expect(setting.performance_bar_enabled).to be_truthy
end
end
end
describe 'performance_bar_enabled=' do
context 'when the performance bar is enabled' do
let(:group) { create(:group) }
before do
setting.performance_bar_allowed_group_id = group.full_path
end
context 'when passing true' do
it 'does not clear allowed user IDs cache' do
expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
setting.performance_bar_enabled = true
expect(setting.performance_bar_allowed_group_id).to eq(group.id)
expect(setting.performance_bar_enabled).to be_truthy
end
end
context 'when passing false' do
it 'disables the performance bar and clears allowed user IDs cache' do
expect(Gitlab::PerformanceBar).to receive(:expire_allowed_user_ids_cache)
setting.performance_bar_enabled = false
expect(setting.performance_bar_allowed_group_id).to be_nil
expect(setting.performance_bar_enabled).to be_falsey
end
end
end
context 'when the performance bar is disabled' do
context 'when passing true' do
it 'does nothing and does not clear allowed user IDs cache' do
expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
setting.performance_bar_enabled = true
expect(setting.performance_bar_allowed_group_id).to be_nil
expect(setting.performance_bar_enabled).to be_falsey
end
end
context 'when passing false' do
it 'does nothing and does not clear allowed user IDs cache' do
expect(Gitlab::PerformanceBar).not_to receive(:expire_allowed_user_ids_cache)
setting.performance_bar_enabled = false
expect(setting.performance_bar_allowed_group_id).to be_nil
expect(setting.performance_bar_enabled).to be_falsey
end
end
end
end
end
describe 'usage ping settings' do
context 'when the usage ping is disabled in gitlab.yml' do
before do
......
require 'spec_helper'
describe EachBatch do
describe '.each_batch' do
let(:model) do
Class.new(ActiveRecord::Base) do
include EachBatch
self.table_name = 'users'
end
end
before do
5.times { create(:user, updated_at: 1.day.ago) }
end
it 'yields an ActiveRecord::Relation when a block is given' do
model.each_batch do |relation|
expect(relation).to be_a_kind_of(ActiveRecord::Relation)
end
end
it 'yields a batch index as the second argument' do
model.each_batch do |_, index|
expect(index).to eq(1)
end
end
it 'accepts a custom batch size' do
amount = 0
model.each_batch(of: 1) { amount += 1 }
expect(amount).to eq(5)
end
it 'does not include ORDER BYs in the yielded relations' do
model.each_batch do |relation|
expect(relation.to_sql).not_to include('ORDER BY')
end
end
it 'allows updating of the yielded relations' do
time = Time.now
model.each_batch do |relation|
relation.update_all(updated_at: time)
end
expect(model.where(updated_at: time).count).to eq(5)
end
end
end
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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