Commit 2aa8a75f authored by Phil Hughes's avatar Phil Hughes

Merge branch 'master' into breadcrumbs-improvements

parents de82bd8e 1632ffa6
......@@ -208,7 +208,7 @@ update-tests-metadata:
- '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $KNAPSACK_RSPEC_SUITE_REPORT_PATH $KNAPSACK_SPINACH_SUITE_REPORT_PATH'
- '[[ -z ${TESTS_METADATA_S3_BUCKET} ]] || scripts/sync-reports put $TESTS_METADATA_S3_BUCKET $FLAKY_RSPEC_SUITE_REPORT_PATH'
- rm -f knapsack/${CI_PROJECT_NAME}/*_node_*.json
- rm -f rspec_flaky/${CI_PROJECT_NAME}/all_node_*.json
- rm -f rspec_flaky/${CI_PROJECT_NAME}/*_node_*.json
flaky-examples-check:
<<: *dedicated-runner
......
......@@ -181,7 +181,7 @@ gem 'connection_pool', '~> 2.0'
gem 'hipchat', '~> 1.5.0'
# JIRA integration
gem 'jira-ruby', '~> 1.1.2'
gem 'jira-ruby', '~> 1.4'
# Flowdock integration
gem 'gitlab-flowdock-git-hook', '~> 1.0.1'
......@@ -397,7 +397,7 @@ group :ed25519 do
end
# Gitaly GRPC client
gem 'gitaly-proto', '~> 0.31.0', require: 'gitaly'
gem 'gitaly-proto', '~> 0.32.0', require: 'gitaly'
gem 'toml-rb', '~> 0.3.15', require: false
......
......@@ -275,7 +275,7 @@ GEM
po_to_json (>= 1.0.0)
rails (>= 3.2.0)
gherkin-ruby (0.3.2)
gitaly-proto (0.31.0)
gitaly-proto (0.32.0)
google-protobuf (~> 3.1)
grpc (~> 1.0)
github-linguist (4.7.6)
......@@ -404,8 +404,9 @@ GEM
cause
json
ipaddress (0.8.3)
jira-ruby (1.1.2)
jira-ruby (1.4.1)
activesupport
multipart-post
oauth (~> 0.5, >= 0.5.0)
jquery-atwho-rails (1.3.2)
jquery-rails (4.1.1)
......@@ -1020,7 +1021,7 @@ DEPENDENCIES
gettext (~> 3.2.2)
gettext_i18n_rails (~> 1.8.0)
gettext_i18n_rails_js (~> 1.2.0)
gitaly-proto (~> 0.31.0)
gitaly-proto (~> 0.32.0)
github-linguist (~> 4.7.0)
gitlab-flowdock-git-hook (~> 1.0.1)
gitlab-markup (~> 1.5.1)
......@@ -1042,7 +1043,7 @@ DEPENDENCIES
html2text
httparty (~> 0.13.3)
influxdb (~> 0.2)
jira-ruby (~> 1.1.2)
jira-ruby (~> 1.4)
jquery-atwho-rails (~> 1.3.2)
jquery-rails (~> 4.1.0)
json-schema (~> 2.6.2)
......
......@@ -61,7 +61,7 @@ class DropdownHint extends gl.FilteredSearchDropdown {
.map(tokenKey => ({
icon: `fa-${tokenKey.icon}`,
hint: tokenKey.key,
tag: `<${tokenKey.tag}>`,
tag: `:${tokenKey.tag}`,
type: tokenKey.type,
}));
......
......@@ -637,11 +637,15 @@ GitLabDropdown = (function() {
value = this.options.id ? this.options.id(data) : data.id;
fieldName = this.options.fieldName;
if (value) { value = value.toString().replace(/'/g, '\\\''); }
field = this.dropdown.parent().find("input[name='" + fieldName + "'][value='" + value + "']");
if (field.length) {
selected = true;
if (value) {
value = value.toString().replace(/'/g, '\\\'');
field = this.dropdown.parent().find(`input[name='${fieldName}'][value='${value}']`);
if (field.length) {
selected = true;
}
} else {
field = this.dropdown.parent().find(`input[name='${fieldName}']`);
selected = !field.length;
}
}
// Set URL
......
......@@ -5,7 +5,6 @@
/* global SubscriptionSelect */
import IssuableBulkUpdateActions from './issuable_bulk_update_actions';
import SidebarHeightManager from './sidebar_height_manager';
const HIDDEN_CLASS = 'hidden';
const DISABLED_CONTENT_CLASS = 'disabled-content';
......@@ -50,13 +49,6 @@ export default class IssuableBulkUpdateSidebar {
new SubscriptionSelect();
}
getNavHeight() {
const navbarHeight = $('.navbar-gitlab').outerHeight();
const layoutNavHeight = $('.layout-nav').outerHeight();
const subNavScroll = $('.sub-nav-scroll').outerHeight();
return navbarHeight + layoutNavHeight + subNavScroll;
}
setupBulkUpdateActions() {
IssuableBulkUpdateActions.setOriginalDropdownData();
}
......@@ -84,23 +76,6 @@ export default class IssuableBulkUpdateSidebar {
this.toggleBulkEditButtonDisabled(enable);
this.toggleOtherFiltersDisabled(enable);
this.toggleCheckboxDisplay(enable);
if (enable) {
this.initAffix();
SidebarHeightManager.init();
}
}
initAffix() {
if (!this.$sidebar.hasClass('affix-top')) {
const offsetTop = $('.scrolling-tabs-container').outerHeight() + $('.sub-nav-scroll').outerHeight();
this.$sidebar.affix({
offset: {
top: offsetTop,
},
});
}
}
updateSelectedIssuableIds() {
......
......@@ -50,19 +50,10 @@ import initFlyOutNav from './fly_out_nav';
});
});
function applyScrollNavClass() {
const scrollOpacityHeight = 40;
$('.navbar-border').css('opacity', Math.min($(window).scrollTop() / scrollOpacityHeight, 1));
}
$(() => {
if (Cookies.get('new_nav') === 'true') {
const newNavSidebar = new NewNavSidebar();
newNavSidebar.bindEvents();
initFlyOutNav();
}
const newNavSidebar = new NewNavSidebar();
newNavSidebar.bindEvents();
$(window).on('scroll', _.throttle(applyScrollNavClass, 100));
initFlyOutNav();
});
}).call(window);
......@@ -4,7 +4,7 @@
import statusCodes from '../../lib/utils/http_status';
import MonitoringService from '../services/monitoring_service';
import GraphGroup from './graph_group.vue';
import GraphRow from './graph_row.vue';
import Graph from './graph.vue';
import EmptyState from './empty_state.vue';
import MonitoringStore from '../stores/monitoring_store';
import eventHub from '../event_hub';
......@@ -32,8 +32,8 @@
},
components: {
Graph,
GraphGroup,
GraphRow,
EmptyState,
},
......@@ -127,10 +127,10 @@
:key="index"
:name="groupData.group"
>
<graph-row
v-for="(row, index) in groupData.metrics"
<graph
v-for="(graphData, index) in groupData.metrics"
:key="index"
:row-data="row"
:graph-data="graphData"
:update-aspect-ratio="updateAspectRatio"
:deployment-data="store.deploymentData"
/>
......
......@@ -19,10 +19,6 @@
type: Object,
required: true,
},
classType: {
type: String,
required: true,
},
updateAspectRatio: {
type: Boolean,
required: true,
......@@ -207,12 +203,11 @@
},
};
</script>
<template>
<div
:class="classType">
<h5
class="text-center graph-title">
{{graphData.title}}
<div class="prometheus-graph">
<h5 class="text-center graph-title">
{{graphData.title}}
</h5>
<div
class="prometheus-svg-container"
......@@ -243,7 +238,7 @@
class="graph-data"
:viewBox="innerViewBox"
ref="graphData">
<monitoring-paths
<monitoring-paths
v-for="(path, index) in timeSeries"
:key="index"
:generated-line-path="path.linePath"
......
......@@ -14,7 +14,7 @@ export default {
<div class="panel-heading">
<h4>{{name}}</h4>
</div>
<div class="panel-body">
<div class="panel-body prometheus-graph-group">
<slot />
</div>
</div>
......
<script>
import Graph from './graph.vue';
export default {
props: {
rowData: {
type: Array,
required: true,
},
updateAspectRatio: {
type: Boolean,
required: true,
},
deploymentData: {
type: Array,
required: true,
},
},
components: {
Graph,
},
computed: {
bootstrapClass() {
return this.rowData.length >= 2 ? 'col-md-6' : 'col-md-12';
},
},
};
</script>
<template>
<div class="prometheus-row row">
<graph
v-for="(graphData, index) in rowData"
:graph-data="graphData"
:class-type="bootstrapClass"
:key="index"
:update-aspect-ratio="updateAspectRatio"
:deployment-data="deploymentData"
/>
</div>
</template>
......@@ -20,22 +20,6 @@ function normalizeMetrics(metrics) {
}));
}
function collate(array, rows = 2) {
const collatedArray = [];
let row = [];
array.forEach((value, index) => {
row.push(value);
if ((index + 1) % rows === 0) {
collatedArray.push(row);
row = [];
}
});
if (row.length > 0) {
collatedArray.push(row);
}
return collatedArray;
}
export default class MonitoringStore {
constructor() {
this.groups = [];
......@@ -45,7 +29,7 @@ export default class MonitoringStore {
storeMetrics(groups = []) {
this.groups = groups.map(group => ({
...group,
metrics: collate(normalizeMetrics(sortMetrics(group.metrics))),
metrics: normalizeMetrics(sortMetrics(group.metrics)),
}));
}
......@@ -54,12 +38,6 @@ export default class MonitoringStore {
}
getMetricsCount() {
let metricsCount = 0;
this.groups.forEach((group) => {
group.metrics.forEach((metric) => {
metricsCount = metricsCount += metric.length;
});
});
return metricsCount;
return this.groups.reduce((count, group) => count + group.metrics.length, 0);
}
}
......@@ -63,7 +63,7 @@ export default class NewNavSidebar {
if (breakpoint === 'sm' || breakpoint === 'md') {
this.toggleCollapsedSidebar(true);
} else if (breakpoint === 'lg') {
const collapse = Cookies.get('sidebar_collapsed') === 'true';
const collapse = this.$sidebar.hasClass('sidebar-icons-only');
this.toggleCollapsedSidebar(collapse);
}
}
......
......@@ -2,7 +2,6 @@
import _ from 'underscore';
import Cookies from 'js-cookie';
import SidebarHeightManager from './sidebar_height_manager';
(function() {
this.Sidebar = (function() {
......@@ -23,7 +22,6 @@ import SidebarHeightManager from './sidebar_height_manager';
};
Sidebar.prototype.addEventListeners = function() {
SidebarHeightManager.init();
const $document = $(document);
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
......
import _ from 'underscore';
import Cookies from 'js-cookie';
export default {
init() {
if (!this.initialized) {
if (Cookies.get('new_nav') === 'true' && $('.js-issuable-sidebar').length) return;
this.$window = $(window);
this.$rightSidebar = $('.js-right-sidebar');
this.$navHeight = $('.navbar-gitlab').outerHeight() +
$('.layout-nav').outerHeight() +
$('.sub-nav-scroll').outerHeight();
const throttledSetSidebarHeight = _.throttle(() => this.setSidebarHeight(), 20);
const debouncedSetSidebarHeight = _.debounce(() => this.setSidebarHeight(), 200);
this.$window.on('scroll', throttledSetSidebarHeight);
this.$window.on('resize', debouncedSetSidebarHeight);
this.initialized = true;
}
},
setSidebarHeight() {
const currentScrollDepth = window.pageYOffset || 0;
const diff = this.$navHeight - currentScrollDepth;
if (diff > 0) {
const newSidebarHeight = window.innerHeight - diff;
this.$rightSidebar.outerHeight(newSidebarHeight);
this.sidebarHeightIsCustom = true;
} else if (this.sidebarHeightIsCustom) {
this.$rightSidebar.outerHeight('100%');
this.sidebarHeightIsCustom = false;
}
},
};
......@@ -766,6 +766,7 @@
box-shadow: none;
padding: 8px 16px;
text-align: left;
white-space: normal;
width: 100%;
// make sure the text color is not overriden
......
......@@ -105,12 +105,11 @@ header {
top: -3px;
font-size: 10px;
}
}
.user-counter {
svg {
position: relative;
top: 2px;
height: 17px;
// hack to get SVG to line up with FA icons
height: 16px;
width: 23px;
fill: currentColor;
}
......@@ -325,12 +324,12 @@ header {
li {
.badge {
position: inherit;
top: -8px;
font-weight: $gl-font-weight-normal;
margin-left: -11px;
margin-left: -6px;
font-size: 11px;
color: $white-light;
padding: 1px 5px 2px;
padding: 0 5px;
line-height: 12px;
border-radius: 7px;
box-shadow: 0 1px 0 rgba($gl-header-color, .2);
......
......@@ -177,13 +177,14 @@ $row-hover: $blue-25;
$row-hover-border: $blue-100;
$progress-color: #c0392b;
$header-height: 50px;
$new-navbar-height: 40px;
$fixed-layout-width: 1280px;
$limited-layout-width: 990px;
$limited-layout-width-sm: 790px;
$container-text-max-width: 540px;
$gl-avatar-size: 40px;
$error-exclamation-point: $red-500;
$border-radius-default: 3px;
$border-radius-default: 4px;
$settings-icon-size: 18px;
$provider-btn-not-active-color: $blue-500;
$link-underline-blue: $blue-500;
......
......@@ -3,15 +3,21 @@
@import "bootstrap/variables";
@import "framework/mixins";
.content-wrapper.page-with-new-nav {
margin-top: $new-navbar-height;
}
header.navbar-gitlab-new {
color: $white-light;
background: linear-gradient(to right, $indigo-900, $indigo-800);
border-bottom: 0;
min-height: $new-navbar-height;
.header-content {
display: -webkit-flex;
display: flex;
padding-left: 0;
min-height: $new-navbar-height;
.title-container {
display: -webkit-flex;
......@@ -39,20 +45,13 @@ header.navbar-gitlab-new {
display: -webkit-flex;
display: flex;
align-items: center;
padding-right: $gl-padding;
padding-left: $gl-padding;
margin-left: -$gl-padding;
@media (min-width: $screen-sm-min) {
padding-right: $gl-padding;
padding-left: $gl-padding;
}
padding: 2px 8px;
margin: 5px 2px 5px -8px;
border-radius: $border-radius-default;
svg {
margin-top: -3px;
@media (min-width: $screen-sm-min) {
margin-right: 10px;
margin-right: 8px;
}
}
......@@ -61,7 +60,7 @@ header.navbar-gitlab-new {
svg {
width: 55px;
height: 15px;
height: 14px;
margin: 0;
fill: $white-light;
}
......@@ -69,9 +68,7 @@ header.navbar-gitlab-new {
&:hover,
&:focus {
.logo-text svg {
fill: $tanuki-yellow;
}
background-color: rgba($indigo-200, .2);
}
}
}
......@@ -91,6 +88,20 @@ header.navbar-gitlab-new {
right: 0;
}
}
&.menu-expanded {
@media (max-width: $screen-xs-max) {
.title-container,
.header-logo, {
display: none;
}
}
}
}
.dropdown-bold-header {
color: $gl-text-color-secondary;
font-size: 12px;
}
.navbar-collapse {
......@@ -99,14 +110,10 @@ header.navbar-gitlab-new {
box-shadow: 0;
@media (max-width: $screen-xs-max) {
margin-left: -$gl-padding;
margin-left: -8px;
margin-right: -10px;
}
.dropdown-bold-header {
color: initial;
}
.nav {
> li:not(.hidden-xs) a {
@media (max-width: $screen-xs-max) {
......@@ -120,7 +127,7 @@ header.navbar-gitlab-new {
.container-fluid {
.navbar-toggle {
min-width: 45px;
padding: 6px $gl-padding;
padding: 4px $gl-padding;
margin-right: -7px;
font-size: 14px;
text-align: center;
......@@ -157,31 +164,90 @@ header.navbar-gitlab-new {
}
> a {
background: none;
will-change: color;
margin: 4px 2px;
padding: 6px 8px;
color: $indigo-200;
height: 32px;
@media (max-width: $screen-xs-max) {
padding: 0;
}
svg {
fill: $indigo-200;
}
&.header-user-dropdown-toggle {
margin-left: 2px;
.header-user-avatar {
border-color: $indigo-200;
margin-right: 0;
}
}
}
&:hover,
&:focus {
color: $white-light;
opacity: 1;
.header-new-dropdown-toggle {
margin-right: 0;
}
> svg {
fill: $white-light;
}
> a:hover,
> a:focus {
text-decoration: none;
outline: 0;
opacity: 1;
color: $white-light;
@media (min-width: $screen-sm-min) {
background-color: rgba($indigo-200, .2);
}
svg {
fill: currentColor;
}
&.header-user-dropdown-toggle {
.header-user-avatar {
border-color: $white-light;
}
&.header-user-dropdown-toggle {
.header-user-avatar {
border-color: $white-light;
}
}
}
.impersonated-user,
.impersonated-user:hover {
margin-right: 1px;
background-color: $white-light;
border-top-right-radius: 0;
border-bottom-right-radius: 0;
svg {
fill: $indigo-900;
}
}
.impersonation-btn,
.impersonation-btn:hover {
background-color: $white-light;
margin-left: 0;
border-top-left-radius: 0;
border-bottom-left-radius: 0;
i {
color: $orange-500;
font-size: 20px;
}
}
&.active > a,
&.dropdown.open > a {
color: $indigo-900;
background-color: $white-light;
svg {
fill: currentColor;
}
}
}
}
}
......@@ -189,45 +255,76 @@ header.navbar-gitlab-new {
.navbar-sub-nav {
display: -webkit-flex;
display: flex;
margin-bottom: 0;
margin: 0 0 0 6px;
color: $indigo-200;
> li {
> a:hover,
> a:focus {
box-shadow: inset 0 -3px 0 rgba($indigo-200, .4);
text-decoration: none;
outline: 0;
color: $white-light;
}
.dropdown-chevron {
position: relative;
top: -1px;
font-size: 10px;
}
}
&.active > a {
box-shadow: inset 0 -3px 0 $indigo-500;
color: $white-light;
font-weight: $gl-font-weight-bold;
}
.navbar-gitlab-new {
.navbar-sub-nav,
.navbar-nav {
> li {
> a:hover,
> a:focus {
text-decoration: none;
outline: 0;
color: $white-light;
background-color: rgba($indigo-200, .2);
> a {
display: block;
padding: 16px 10px;
font-size: 13px;
color: currentColor;
box-shadow: inset 0 0 0 transparent;
will-change: box-shadow;
transition: box-shadow 0.15s;
svg {
fill: currentColor;
}
}
@media (min-width: $screen-sm-min) {
padding: 15px $gl-padding;
font-size: 14px;
&.active > a,
&.dropdown.open > a {
color: $indigo-900;
background-color: $white-light;
svg {
fill: currentColor;
}
}
> a {
display: flex;
align-items: center;
justify-content: center;
padding: 6px 8px;
margin: 4px 2px;
font-size: 12px;
color: currentColor;
border-radius: $border-radius-default;
height: 32px;
font-weight: $gl-font-weight-bold;
svg {
fill: currentColor;
}
}
&.line-separator {
border-left: 1px solid rgba($indigo-200, .2);
margin: 8px;
}
}
}
}
.dropdown-chevron {
position: relative;
top: -1px;
font-size: 10px;
}
.admin-icon i {
font-size: 18px;
}
.caret-down {
height: 11px;
width: 11px;
margin-left: 4px;
fill: currentColor;
}
.header-user .dropdown-menu-nav,
......@@ -236,10 +333,14 @@ header.navbar-gitlab-new {
}
.search {
margin: 4px 8px 0;
form {
height: 32px;
border: 0;
border-radius: $border-radius-default;
background-color: rgba($indigo-200, .2);
transition: border-color ease-in-out 0.15s, box-shadow ease-in-out 0.15s, background-color ease-in-out 0.15s;
transition: border-color ease-in-out 0.15s, background-color ease-in-out 0.15s;
&:hover {
background-color: rgba($indigo-200, .3);
......@@ -248,31 +349,50 @@ header.navbar-gitlab-new {
}
&.search-active form {
background-color: rgba($indigo-200, .3);
background-color: $white-light;
box-shadow: none;
.search-input {
color: $gl-text-color;
transition: color ease-in-out 0.15s;
}
.search-input::placeholder {
color: $gl-text-color-tertiary;
}
.search-input-wrap {
.search-icon,
.clear-icon {
color: $gl-text-color-tertiary;
transition: color ease-in-out 0.15s;
}
}
}
.search-input {
color: $white-light;
background: none;
transition: color ease-in-out 0.15s;
}
.search-input::placeholder {
color: rgba($indigo-200, .8);
transition: color ease-in-out 0.15s;
}
.location-badge {
font-size: 12px;
color: $indigo-100;
background-color: rgba($indigo-200, .1);
transition: color 0.15s;
will-change: color;
margin: -4px 4px -4px -4px;
line-height: 25px;
padding: 4px 8px;
border-radius: 2px 0 0 2px;
border-right: 1px solid $indigo-800;
height: 34px;
height: 32px;
transition: border-color ease-in-out 0.15s;
}
.search-input-wrap {
......@@ -284,8 +404,9 @@ header.navbar-gitlab-new {
&.search-active {
.location-badge {
color: $white-light;
background-color: rgba($indigo-200, .2);
color: $gl-text-color;
background-color: $nav-badge-bg;
border-color: $border-color;
}
.search-input-wrap {
......@@ -403,3 +524,14 @@ header.navbar-gitlab-new {
}
}
}
.btn-sign-in {
margin-top: 3px;
background-color: $indigo-100;
color: $indigo-900;
font-weight: $gl-font-weight-bold;
&:hover {
background-color: $white-light;
}
}
......@@ -26,7 +26,7 @@ $new-sidebar-collapsed-width: 50px;
// Override position: absolute
.right-sidebar {
position: fixed;
height: calc(100% - #{$header-height});
height: calc(100% - #{$new-navbar-height});
}
.issues-bulk-update.right-sidebar.right-sidebar-expanded .issuable-sidebar-header {
......@@ -92,7 +92,7 @@ $new-sidebar-collapsed-width: 50px;
z-index: 400;
width: $new-sidebar-width;
transition: left $sidebar-transition-duration;
top: $header-height;
top: $new-navbar-height;
bottom: 0;
left: 0;
background-color: $gray-normal;
......@@ -188,7 +188,7 @@ $new-sidebar-collapsed-width: 50px;
}
.with-performance-bar .nav-sidebar {
top: $header-height + $performance-bar-height;
top: $new-navbar-height + $performance-bar-height;
}
.sidebar-sub-level-items {
......@@ -452,7 +452,7 @@ $new-sidebar-collapsed-width: 50px;
// Make issue boards full-height now that sub-nav is gone
.boards-list {
height: calc(100vh - #{$header-height});
height: calc(100vh - #{$new-navbar-height});
@media (min-width: $screen-sm-min) {
height: 475px; // Needed for PhantomJS
......@@ -463,7 +463,7 @@ $new-sidebar-collapsed-width: 50px;
}
.with-performance-bar .boards-list {
height: calc(100vh - #{$header-height} - #{$performance-bar-height});
height: calc(100vh - #{$new-navbar-height} - #{$performance-bar-height});
}
......
......@@ -440,6 +440,7 @@
&.right-sidebar {
top: 0;
bottom: 0;
height: 100%;
}
.issuable-sidebar-header {
......
......@@ -227,6 +227,26 @@
margin-top: 20px;
}
.prometheus-graph-group {
display: flex;
flex-wrap: wrap;
padding: $gl-padding / 2;
}
.prometheus-graph {
flex: 1 0 auto;
min-width: 450px;
padding: $gl-padding / 2;
h5 {
font-size: 16px;
}
@media (max-width: $screen-sm-max) {
min-width: 100%;
}
}
.prometheus-svg-container {
position: relative;
height: 0;
......@@ -297,9 +317,3 @@
}
}
}
.prometheus-row {
h5 {
font-size: 16px;
}
}
......@@ -190,6 +190,8 @@ input[type="checkbox"]:hover {
}
.search-holder {
@include new-style-dropdown;
@media (min-width: $screen-sm-min) {
display: -webkit-flex;
display: flex;
......
......@@ -302,10 +302,6 @@ module ApplicationHelper
end
end
def show_new_nav?
true
end
def collapsed_sidebar?
cookies["sidebar_collapsed"] == "true"
end
......
......@@ -86,7 +86,7 @@ module GroupsHelper
def group_title_link(group, hidable: false, show_avatar: false)
link_to(group_path(group), class: "group-path breadcrumb-item-text js-breadcrumb-item-text #{'hidable' if hidable}") do
output =
if (show_new_nav? && group.try(:avatar_url) || (show_new_nav? && show_avatar)) && !Rails.env.test?
if (group.try(:avatar_url) || show_avatar)) && !Rails.env.test?
image_tag(group_icon(group), class: "avatar-tile", width: 15, height: 15)
else
""
......
module NavHelper
def page_with_sidebar_class
class_name = page_gutter_class
class_name << 'page-with-new-sidebar' if defined?(@new_sidebar) && @new_sidebar
class_name << 'page-with-icon-sidebar' if collapsed_sidebar? && @new_sidebar
class_name << 'page-with-new-sidebar' if defined?(@left_sidebar) && @left_sidebar
class_name << 'page-with-icon-sidebar' if collapsed_sidebar? && @left_sidebar
class_name
end
......@@ -30,24 +30,15 @@ module NavHelper
end
end
def nav_header_class
class_names = []
class_names << 'with-horizontal-nav' if defined?(nav) && nav
class_names
def nav_control_class
"nav-control" if current_user
end
def layout_nav_class
return [] if show_new_nav?
def user_dropdown_class
class_names = []
class_names << 'page-with-layout-nav' if defined?(nav) && nav
class_names << 'page-with-sub-nav' if content_for?(:sub_nav)
class_names << 'header-user-dropdown-toggle'
class_names << 'impersonated-user' if session[:impersonator_id]
class_names
end
def nav_control_class
"nav-control" if current_user
end
end
......@@ -4,7 +4,7 @@ module PageLayoutHelper
@page_title.push(*titles.compact) if titles.any?
if show_new_nav? && titles.any? && !defined?(@breadcrumb_title)
if titles.any? && !defined?(@breadcrumb_title)
@breadcrumb_title = @page_title.last
end
......
......@@ -62,7 +62,7 @@ module ProjectsHelper
project_link = link_to project_path(project), { class: ("project-item-select-holder" unless show_new_nav?) } do
output =
if show_new_nav? && project.avatar_url && !Rails.env.test?
if project.avatar_url && !Rails.env.test?
project_icon(project, alt: project.name, class: 'avatar-tile', width: 15, height: 15)
else
""
......
......@@ -6,6 +6,7 @@
#
module Issuable
extend ActiveSupport::Concern
include Gitlab::SQL::Pattern
include CacheMarkdownField
include Participable
include Mentionable
......@@ -122,7 +123,9 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def search(query)
where(arel_table[:title].matches("%#{query}%"))
title = to_fuzzy_arel(:title, query)
where(title)
end
# Searches for records with a matching title or description.
......@@ -133,10 +136,10 @@ module Issuable
#
# Returns an ActiveRecord::Relation.
def full_search(query)
t = arel_table
pattern = "%#{query}%"
title = to_fuzzy_arel(:title, query)
description = to_fuzzy_arel(:description, query)
where(t[:title].matches(pattern).or(t[:description].matches(pattern)))
where(title&.or(description))
end
def sort(method, excluded_labels: [])
......
......@@ -16,6 +16,7 @@ class Group < Namespace
source: :user
has_many :requesters, -> { where.not(requested_at: nil) }, dependent: :destroy, as: :source, class_name: 'GroupMember' # rubocop:disable Cop/ActiveRecordDependent
has_many :members_and_requesters, as: :source, class_name: 'GroupMember'
has_many :milestones
has_many :project_group_links, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent
......
......@@ -126,20 +126,11 @@ class Member < ActiveRecord::Base
find_by(invite_token: invite_token)
end
def add_user(source, user, access_level, current_user: nil, expires_at: nil)
user = retrieve_user(user)
def add_user(source, user, access_level, existing_members: nil, current_user: nil, expires_at: nil)
# `user` can be either a User object, User ID or an email to be invited
member = retrieve_member(source, user, existing_members)
access_level = retrieve_access_level(access_level)
# `user` can be either a User object or an email to be invited
member =
if user.is_a?(User)
source.members.find_by(user_id: user.id) ||
source.requesters.find_by(user_id: user.id) ||
source.members.build(user_id: user.id)
else
source.members.build(invite_email: user)
end
return member unless can_update_member?(current_user, member)
member.attributes = {
......@@ -165,17 +156,15 @@ class Member < ActiveRecord::Base
def add_users(source, users, access_level, current_user: nil, expires_at: nil)
return [] unless users.present?
# Collect all user ids into separate array
# so we can use single sql query to get user objects
user_ids = users.select { |user| user =~ /\A\d+\Z/ }
users = users - user_ids + User.where(id: user_ids)
emails, users, existing_members = parse_users_list(source, users)
self.transaction do
users.map do |user|
(emails + users).map! do |user|
add_user(
source,
user,
access_level,
existing_members: existing_members,
current_user: current_user,
expires_at: expires_at
)
......@@ -189,6 +178,31 @@ class Member < ActiveRecord::Base
private
def parse_users_list(source, list)
emails, user_ids, users = [], [], []
existing_members = {}
list.each do |item|
case item
when User
users << item
when Integer
user_ids << item
when /\A\d+\Z/
user_ids << item.to_i
when Devise.email_regexp
emails << item
end
end
if user_ids.present?
users.concat(User.where(id: user_ids))
existing_members = source.members_and_requesters.where(user_id: user_ids).index_by(&:user_id)
end
[emails, users, existing_members]
end
# This method is used to find users that have been entered into the "Add members" field.
# These can be the User objects directly, their IDs, their emails, or new emails to be invited.
def retrieve_user(user)
......@@ -197,6 +211,20 @@ class Member < ActiveRecord::Base
User.find_by(id: user) || User.find_by(email: user) || user
end
def retrieve_member(source, user, existing_members)
user = retrieve_user(user)
if user.is_a?(User)
if existing_members
existing_members[user.id] || source.members.build(user_id: user.id)
else
source.members_and_requesters.find_or_initialize_by(user_id: user.id)
end
else
source.members.build(invite_email: user)
end
end
def retrieve_access_level(access_level)
access_levels.fetch(access_level) { access_level.to_i }
end
......
......@@ -144,6 +144,7 @@ class Project < ActiveRecord::Base
has_many :requesters, -> { where.not(requested_at: nil) },
as: :source, class_name: 'ProjectMember', dependent: :delete_all # rubocop:disable Cop/ActiveRecordDependent
has_many :members_and_requesters, as: :source, class_name: 'ProjectMember'
has_many :deploy_keys_projects
has_many :deploy_keys, through: :deploy_keys_projects
......
- if show_new_nav? && current_user.can_create_group?
- if current_user.can_create_group?
- content_for :breadcrumbs_extra do
= link_to "New group", new_group_path, class: "btn btn-new"
......@@ -10,8 +10,8 @@
= nav_link(page: explore_groups_path) do
= link_to explore_groups_path, title: 'Explore public groups' do
Explore public groups
.nav-controls{ class: ("nav-controls-new-nav" if show_new_nav?) }
.nav-controls.nav-controls-new-nav
= render 'shared/groups/search_form'
= render 'shared/groups/dropdown'
- if current_user.can_create_group?
= link_to "New group", new_group_path, class: "btn btn-new #{("visible-xs" if show_new_nav?)}"
= link_to "New group", new_group_path, class: "btn btn-new visible-xs"
= content_for :flash_message do
= render 'shared/project_limit'
- if show_new_nav? && current_user.can_create_project?
- if current_user.can_create_project?
- content_for :breadcrumbs_extra do
= link_to "New project", new_project_path, class: 'btn btn-new'
......@@ -19,8 +19,8 @@
= link_to explore_root_path, title: 'Explore', data: {placement: 'right'} do
Explore projects
.nav-controls{ class: ("nav-controls-new-nav" if show_new_nav?) }
.nav-controls.nav-controls-new-nav
= render 'shared/projects/search_form'
= render 'shared/projects/dropdown'
- if current_user.can_create_project?
= link_to "New project", new_project_path, class: "btn btn-new #{("visible-xs" if show_new_nav?)}"
= link_to "New project", new_project_path, class: "btn btn-new visible-xs"
- if show_new_nav? && current_user
- if current_user
- content_for :breadcrumbs_extra do
= link_to "New snippet", new_snippet_path, class: "btn btn-new", title: "New snippet"
......@@ -10,7 +10,3 @@
= nav_link(page: explore_snippets_path) do
= link_to explore_snippets_path, title: 'Explore snippets', data: {placement: 'right'} do
Explore Snippets
- if current_user
.nav-controls.hidden-xs{ class: ("hidden-sm hidden-md hidden-lg" if show_new_nav?) }
= link_to "New snippet", new_snippet_path, class: "btn btn-new", title: "New snippet"
......@@ -4,15 +4,14 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{current_user.name} issues")
- if show_new_nav?
- content_for :breadcrumbs_extra do
= link_to params.merge(rss_url_options), class: 'btn has-tooltip append-right-10', title: 'Subscribe' do
= icon('rss')
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :issues
- content_for :breadcrumbs_extra do
= link_to params.merge(rss_url_options), class: 'btn has-tooltip append-right-10', title: 'Subscribe' do
= icon('rss')
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :issues
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
= link_to params.merge(rss_url_options), class: 'btn has-tooltip', title: 'Subscribe' do
= icon('rss')
= render 'shared/new_project_item_select', path: 'issues/new', label: "New issue", with_feature_enabled: 'issues', type: :issues
......
......@@ -2,13 +2,12 @@
- page_title "Merge Requests"
- header_title "Merge Requests", merge_requests_dashboard_path(assignee_id: current_user.id)
- if show_new_nav?
- content_for :breadcrumbs_extra do
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests', type: :merge_requests
- content_for :breadcrumbs_extra do
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests', type: :merge_requests
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", with_feature_enabled: 'merge_requests', type: :merge_requests
= render 'shared/issuable/filter', type: :merge_requests
......
......@@ -2,14 +2,13 @@
- page_title 'Milestones'
- header_title 'Milestones', dashboard_milestones_path
- if show_new_nav?
- content_for :breadcrumbs_extra do
= render 'shared/new_project_item_select', path: 'milestones/new', label: 'New milestone', include_groups: true, type: :milestones
- content_for :breadcrumbs_extra do
= render 'shared/new_project_item_select', path: 'milestones/new', label: 'New milestone', include_groups: true, type: :milestones
.top-area
= render 'shared/milestones_filter', counts: @milestone_states
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
= render 'shared/new_project_item_select', path: 'milestones/new', label: 'New milestone', include_groups: true, type: :milestones
.milestones
......
......@@ -8,7 +8,7 @@
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search'
- if show_new_nav? && group_issues_exists
- if group_issues_exists
- content_for :breadcrumbs_extra do
= link_to params.merge(rss_url_options), class: 'btn btn-default append-right-10' do
= icon('rss')
......@@ -19,7 +19,7 @@
- if group_issues_exists
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
= link_to params.merge(rss_url_options), class: 'btn' do
= icon('rss')
%span.icon-label
......
- page_title 'Labels'
- if show_new_nav? && can?(current_user, :admin_label, @group)
- if can?(current_user, :admin_label, @group)
- content_for :breadcrumbs_extra do
= link_to "New label", new_group_label_path(@group), class: "btn btn-new"
......@@ -10,7 +10,7 @@
.nav-text
Labels can be applied to issues and merge requests. Group labels are available for any project within the group.
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
- if can?(current_user, :admin_label, @group)
= link_to "New label", new_group_label_path(@group), class: "btn btn-new"
......
......@@ -4,7 +4,7 @@
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search'
- if show_new_nav? && current_user
- if current_user
- content_for :breadcrumbs_extra do
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests
......@@ -14,7 +14,7 @@
.top-area
= render 'shared/issuable/nav', type: :merge_requests
- if current_user
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
= render 'shared/new_project_item_select', path: 'merge_requests/new', label: "New merge request", type: :merge_requests
= render 'shared/issuable/search_bar', type: :merge_requests
......
- page_title "Milestones"
- if show_new_nav? && can?(current_user, :admin_milestones, @group)
- if can?(current_user, :admin_milestones, @group)
- content_for :breadcrumbs_extra do
= link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-new"
......@@ -8,7 +8,7 @@
.top-area
= render 'shared/milestones_filter', counts: @milestone_states
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
- if can?(current_user, :admin_milestones, @group)
= link_to "New milestone", new_group_milestone_path(@group), class: "btn btn-new"
......
......@@ -32,9 +32,9 @@
= stylesheet_link_tag "test", media: "all" if Rails.env.test?
= stylesheet_link_tag 'performance_bar' if performance_bar_enabled?
- if show_new_nav?
= stylesheet_link_tag "new_nav", media: "all"
= stylesheet_link_tag "new_sidebar", media: "all"
// TODO: Combine these 2 stylesheets into application.scss
= stylesheet_link_tag "new_nav", media: "all"
= stylesheet_link_tag "new_sidebar", media: "all"
= Gon::Base.render_data
......
.page-with-sidebar{ class: page_with_sidebar_class }
- if show_new_nav?
- if defined?(nav) && nav
= render "layouts/nav/#{nav}"
- else
- if defined?(nav) && nav
.layout-nav
.container-fluid
= render "layouts/nav/#{nav}"
- if content_for?(:sub_nav)
= yield :sub_nav
.content-wrapper{ class: layout_nav_class }
- if show_new_nav?
.mobile-overlay
- if defined?(nav) && nav
= render "layouts/nav/sidebar/#{nav}"
.content-wrapper.page-with-new-nav
.mobile-overlay
.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
- unless @hide_breadcrumbs
= render "layouts/nav/breadcrumbs"
= render "layouts/flash"
%div{ class: "#{(container_class unless @no_container)} #{@content_class}" }
.content{ id: "content-body" }
= yield
- page_title "Admin Area"
- header_title "Admin Area", admin_root_path
- if show_new_nav?
- nav "new_admin_sidebar"
- @new_sidebar = true
- else
- nav "admin"
- nav "admin"
- @left_sidebar = true
= render template: "layouts/application"
......@@ -4,10 +4,7 @@
%body{ class: @body_class, data: { page: body_data_page, project: "#{@project.path if @project}", group: "#{@group.path if @group}", find_file: find_file_path } }
= render "layouts/init_auto_complete" if @gfm_form
= render 'peek/bar'
- if show_new_nav?
= render "layouts/header/new"
- else
= render "layouts/header/default", title: header_title
= render "layouts/header/default"
= render 'layouts/page', sidebar: sidebar, nav: nav
= yield :scripts_body
- page_title @group.name
- page_description @group.description unless page_description
- header_title group_title(@group) unless header_title
- if show_new_nav?
- nav "new_group_sidebar"
- @new_sidebar = true
- else
- nav "group"
- nav "group"
- @left_sidebar = true
= render template: "layouts/application"
%header.navbar.navbar-gitlab{ class: nav_header_class }
.navbar-border
%header.navbar.navbar-gitlab.navbar-gitlab-new
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
.container-fluid
.header-content
.dropdown.global-dropdown
%button.global-dropdown-toggle{ type: 'button', 'data-toggle' => 'dropdown' }
%span.sr-only Toggle navigation
= icon('bars')
.dropdown-menu-nav.global-dropdown-menu
- if current_user
= render 'layouts/nav/dashboard'
- else
= render 'layouts/nav/explore'
.title-container
%h1.title
= link_to root_path, title: 'Dashboard', id: 'logo' do
= brand_header_logo
%span.logo-text.hidden-xs
= render 'shared/logo_type.svg'
.header-logo
= link_to root_path, class: 'home', title: 'Dashboard', id: 'logo' do
= brand_header_logo
.title-container.js-title-container
%h1.title{ class: ('initializing' if @has_group_title) }= title
- if current_user
= render "layouts/nav/dashboard"
- else
= render "layouts/nav/explore"
.navbar-collapse.collapse
%ul.nav.navbar-nav
- if current_user
= render 'layouts/header/new_dropdown'
%li.hidden-sm.hidden-xs
= render 'layouts/search' unless current_controller?(:search)
%li.visible-sm-inline-block.visible-xs-inline-block
= link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('search')
- if current_user
- if session[:impersonator_id]
%li.impersonation
= link_to admin_impersonation_path, method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('user-secret fw')
- if current_user.admin?
%li
= link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('wrench fw')
= render 'layouts/header/new_dropdown'
- if Gitlab::Sherlock.enabled?
%li
= link_to sherlock_transactions_path, title: 'Sherlock Transactions',
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('tachometer fw')
%li
= link_to assigned_issues_dashboard_path, title: 'Issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
%li.user-counter
= link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('issues')
- issues_count = assigned_issuables_count(:issues)
%span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
%li
= link_to assigned_mrs_dashboard_path, title: 'Merge requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
%li.user-counter
= link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('mr_bold')
- merge_requests_count = assigned_issuables_count(:merge_requests)
%span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
= number_with_delimiter(merge_requests_count)
%li
%li.user-counter
= link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('check-circle fw')
= custom_icon('todo_done')
%span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) }
= todos_count_format(todos_pending_count)
%li.header-user.dropdown
= link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do
= image_tag avatar_icon(current_user, 26), width: 26, height: 26, class: "header-user-avatar"
= icon('caret-down')
= link_to current_user, class: user_dropdown_class, data: { toggle: "dropdown" } do
= image_tag avatar_icon(current_user, 23), width: 23, height: 23, class: "header-user-avatar"
= custom_icon('caret_down')
.dropdown-menu-nav.dropdown-menu-align-right
%ul
%li.current-user
......@@ -74,18 +56,24 @@
= link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
= link_to "Settings", profile_path
- if current_user
%li
= link_to "Help", help_path
%li.divider
%li
= link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
- if session[:impersonator_id]
%li.impersonation
= link_to admin_impersonation_path, class: 'impersonation-btn', method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('user-secret')
- else
%li
%div
= link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in btn-success'
= link_to "Sign in / Register", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in'
%button.navbar-toggle{ type: 'button' }
%button.navbar-toggle.hidden-sm.hidden-md.hidden-lg{ type: 'button' }
%span.sr-only Toggle navigation
= icon('ellipsis-v')
= yield :header_content
= icon('ellipsis-v', class: 'js-navbar-toggle-right')
= icon('times', class: 'js-navbar-toggle-left')
= render 'shared/outdated_browser'
%header.navbar.navbar-gitlab.navbar-gitlab-new{ class: nav_header_class }
%a.sr-only.gl-accessibility{ href: "#content-body", tabindex: "1" } Skip to content
.container-fluid
.header-content
.title-container
%h1.title
= link_to root_path, title: 'Dashboard', id: 'logo' do
= brand_header_logo
%span.logo-text.hidden-xs
= render 'shared/logo_type.svg'
- if current_user
= render "layouts/nav/new_dashboard"
- else
= render "layouts/nav/new_explore"
.navbar-collapse.collapse
%ul.nav.navbar-nav
%li.hidden-sm.hidden-xs
= render 'layouts/search' unless current_controller?(:search)
%li.visible-sm-inline-block.visible-xs-inline-block
= link_to search_path, title: 'Search', aria: { label: "Search" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('search')
- if current_user
- if session[:impersonator_id]
%li.impersonation
= link_to admin_impersonation_path, method: :delete, title: "Stop impersonation", aria: { label: 'Stop impersonation' }, data: { toggle: 'tooltip', placement: 'bottom', container: 'body' } do
= icon('user-secret fw')
- if current_user.admin?
%li
= link_to admin_root_path, title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('wrench fw')
= render 'layouts/header/new_dropdown'
- if Gitlab::Sherlock.enabled?
%li
= link_to sherlock_transactions_path, title: 'Sherlock Transactions',
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('tachometer fw')
%li
= link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues', aria: { label: "Issues" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('issues')
- issues_count = assigned_issuables_count(:issues)
%span.badge.issues-count{ class: ('hidden' if issues_count.zero?) }
= number_with_delimiter(issues_count)
%li
= link_to assigned_mrs_dashboard_path, title: 'Merge requests', class: 'dashboard-shortcuts-merge_requests', aria: { label: "Merge requests" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= custom_icon('mr_bold')
- merge_requests_count = assigned_issuables_count(:merge_requests)
%span.badge.merge-requests-count{ class: ('hidden' if merge_requests_count.zero?) }
= number_with_delimiter(merge_requests_count)
%li
= link_to dashboard_todos_path, title: 'Todos', aria: { label: "Todos" }, class: 'shortcuts-todos', data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('check-circle fw')
%span.badge.todos-count{ class: ('hidden' if todos_pending_count.zero?) }
= todos_count_format(todos_pending_count)
%li.header-user.dropdown
= link_to current_user, class: "header-user-dropdown-toggle", data: { toggle: "dropdown" } do
= image_tag avatar_icon(current_user, 26), width: 26, height: 26, class: "header-user-avatar"
= icon('chevron-down')
.dropdown-menu-nav.dropdown-menu-align-right
%ul
%li.current-user
.user-name.bold
= current_user.name
@#{current_user.username}
%li.divider
%li
= link_to "Profile", current_user, class: 'profile-link', data: { user: current_user.username }
%li
= link_to "Settings", profile_path
%li.divider
%li
= link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
- else
%li
%div
= link_to "Sign in", new_session_path(:user, redirect_to_referer: 'yes'), class: 'btn btn-sign-in btn-success'
%button.navbar-toggle.hidden-sm.hidden-md.hidden-lg{ type: 'button' }
%span.sr-only Toggle navigation
= icon('ellipsis-v', class: 'js-navbar-toggle-right')
= icon('times', class: 'js-navbar-toggle-left')
= render 'shared/outdated_browser'
%li.header-new.dropdown
= link_to new_project_path, class: "header-new-dropdown-toggle has-tooltip", title: "New...", ref: 'tooltip', aria: { label: "New..." }, data: { toggle: 'dropdown', placement: 'bottom', container: 'body' } do
- if show_new_nav?
= icon('plus')
= icon('chevron-down')
- else
= icon('plus fw')
= icon('caret-down')
= custom_icon('plus_square')
= custom_icon('caret_down')
.dropdown-menu-nav.dropdown-menu-align-right
%ul
- if @group&.persisted?
......
= render 'layouts/nav/admin_settings'
.scrolling-tabs-container{ class: nav_control_class }
.fade-left
= icon('angle-left')
.fade-right
= icon('angle-right')
%ul.nav-links.scrolling-tabs
= nav_link(controller: %w(dashboard admin projects users groups builds runners cohorts), html_options: {class: 'home'}) do
= link_to admin_root_path, title: 'Overview', class: 'shortcuts-tree' do
%span
Overview
= nav_link(controller: %w(conversational_development_index system_info background_jobs logs health_check requests_profiles)) do
= link_to admin_conversational_development_index_path, title: 'Monitoring' do
%span
Monitoring
= nav_link(controller: :broadcast_messages) do
= link_to admin_broadcast_messages_path, title: 'Messages' do
%span
Messages
= nav_link(controller: [:hooks, :hook_logs]) do
= link_to admin_hooks_path, title: 'Hooks' do
%span
System Hooks
= nav_link(controller: :applications) do
= link_to admin_applications_path, title: 'Applications' do
%span
Applications
= nav_link(controller: :abuse_reports) do
= link_to admin_abuse_reports_path, title: "Abuse Reports" do
%span
Abuse Reports
%span.badge.count= number_with_delimiter(AbuseReport.count(:all))
- if akismet_enabled?
= nav_link(controller: :spam_logs) do
= link_to admin_spam_logs_path, title: "Spam Logs" do
%span
Spam Logs
.controls
.dropdown.admin-settings-dropdown
%a.dropdown-new.btn.btn-default{ href: '#', 'data-toggle' => 'dropdown' }
= icon('cog')
= icon('caret-down')
%ul.dropdown-menu.dropdown-menu-align-right
= nav_link(controller: :deploy_keys) do
= link_to admin_deploy_keys_path, title: 'Deploy Keys' do
%span
Deploy Keys
= nav_link(controller: :services) do
= link_to admin_application_settings_services_path, title: 'Service Templates' do
%span
Service Templates
= nav_link(controller: :labels) do
= link_to admin_labels_path, title: 'Labels' do
%span
Labels
= nav_link(controller: :appearances) do
= link_to admin_appearances_path, title: 'Appearances' do
%span
Appearance
%li.divider
= nav_link(controller: :application_settings) do
= link_to admin_application_settings_path, title: 'Settings' do
%span
Settings
......@@ -2,8 +2,8 @@
- hide_top_links = @hide_top_links || false
%nav.breadcrumbs{ role: "navigation", class: [container, @content_class] }
.breadcrumbs-container
- if defined?(@new_sidebar)
.breadcrumbs-container{ class: [container, @content_class] }
- if defined?(@left_sidebar)
= button_tag class: 'toggle-mobile-nav', type: 'button' do
%span.sr-only Open sidebar
= icon ('bars')
......
%ul
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: {class: "#{project_tab_class} home"}) do
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
P
%span
Projects
= nav_link(path: 'dashboard#activity') do
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
A
%span
Activity
- if koding_enabled?
= nav_link(controller: :koding) do
= link_to koding_path, title: 'Koding' do
%span
Koding
= nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
%ul.list-unstyled.navbar-sub-nav
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown" }) do
%a{ href: "#", data: { toggle: "dropdown" } }
Projects
= custom_icon('caret_down')
.dropdown-menu.projects-dropdown-menu
= render "layouts/nav/projects_dropdown/show"
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "hidden-xs" }) do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
G
%span
Groups
= nav_link(controller: 'dashboard/milestones') do
Groups
= nav_link(path: 'dashboard#activity', html_options: { class: "visible-lg" }) do
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
Activity
= nav_link(controller: 'dashboard/milestones', html_options: { class: "visible-lg" }) do
= link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
L
%span
Milestones
= nav_link(path: 'dashboard#issues') do
= link_to assigned_issues_dashboard_path, title: 'Issues', class: 'dashboard-shortcuts-issues' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
I
%span.badge.pull-right= number_with_delimiter(assigned_issuables_count(:issues))
%span
Issues
= nav_link(path: 'dashboard#merge_requests') do
= link_to assigned_mrs_dashboard_path, title: 'Merge Requests', class: 'dashboard-shortcuts-merge_requests' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
M
%span.badge.pull-right= number_with_delimiter(assigned_issuables_count(:merge_requests))
%span
Merge Requests
= nav_link(controller: 'dashboard/snippets') do
Milestones
= nav_link(controller: 'dashboard/snippets', html_options: { class: "visible-lg" }) do
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
S
%span
Snippets
%li.divider
%li
= link_to "Help", help_path, title: 'About GitLab CE', class: 'about-gitlab'
Snippets
%li.dropdown.hidden-lg
%a{ href: "#", data: { toggle: "dropdown" } }
More
= custom_icon('caret_down')
.dropdown-menu
%ul
= nav_link(controller: ['dashboard/groups', 'explore/groups'], html_options: { class: "visible-xs" }) do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
Groups
= nav_link(path: 'dashboard#activity') do
= link_to activity_dashboard_path, title: 'Activity' do
Activity
= nav_link(controller: 'dashboard/milestones') do
= link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
Milestones
= nav_link(controller: 'dashboard/snippets') do
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
Snippets
-# Shortcut to Dashboard > Projects
%li.hidden
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
Projects
- if current_user.admin? || Gitlab::Sherlock.enabled?
%li.line-separator.hidden-xs
- if current_user.admin?
= nav_link(controller: 'admin/dashboard') do
= link_to admin_root_path, class: 'admin-icon', title: 'Admin area', aria: { label: "Admin area" }, data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('wrench fw')
- if Gitlab::Sherlock.enabled?
%li
= link_to sherlock_transactions_path, class: 'admin-icon', title: 'Sherlock Transactions',
data: {toggle: 'tooltip', placement: 'bottom', container: 'body'} do
= icon('tachometer fw')
%ul
%ul.list-unstyled.navbar-sub-nav
= nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do
= link_to explore_root_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
P
%span
Projects
Projects
= nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
= link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
G
%span
Groups
Groups
= nav_link(controller: :snippets) do
= link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
.shortcut-mappings
.key
= icon('arrow-up', 'aria-label' => 'hidden')
S
%span
Snippets
%li.divider
= nav_link(controller: :help) do
= link_to help_path, title: 'Help' do
%span
Help
Snippets
%li
= link_to "Help", help_path, title: 'About GitLab CE'
.scrolling-tabs-container{ class: nav_control_class }
.fade-left
= icon('angle-left')
.fade-right
= icon('angle-right')
%ul.nav-links.scrolling-tabs
= nav_link(path: ['groups#show', 'groups#activity', 'groups#subgroups'], html_options: { class: 'home' }) do
= link_to group_path(@group), title: 'Home' do
%span
Group
= nav_link(path: ['groups#issues', 'labels#index', 'milestones#index']) do
= link_to issues_group_path(@group), title: 'Issues' do
%span
Issues
- issues = IssuesFinder.new(current_user, group_id: @group.id, state: 'opened').execute
%span.badge.count= number_with_delimiter(issues.count)
= nav_link(path: 'groups#merge_requests') do
= link_to merge_requests_group_path(@group), title: 'Merge Requests' do
%span
Merge Requests
- merge_requests = MergeRequestsFinder.new(current_user, group_id: @group.id, state: 'opened', non_archived: true).execute
%span.badge.count= number_with_delimiter(merge_requests.count)
= nav_link(path: 'group_members#index') do
= link_to group_group_members_path(@group), title: 'Members' do
%span
Members
- if current_user && can?(current_user, :admin_group, @group)
= nav_link(path: %w[groups#projects groups#edit ci_cd#show]) do
= link_to edit_group_path(@group), title: 'Settings' do
%span
Settings
%ul.list-unstyled.navbar-sub-nav
= nav_link(path: ['root#index', 'projects#trending', 'projects#starred', 'dashboard/projects#index'], html_options: { id: 'nav-projects-dropdown', class: "home dropdown" }) do
%a{ href: '#', title: 'Projects', data: { toggle: 'dropdown' } }
Projects
= icon("chevron-down", class: "dropdown-chevron")
.dropdown-menu.projects-dropdown-menu
= render "layouts/nav/projects_dropdown/show"
= nav_link(controller: ['dashboard/groups', 'explore/groups']) do
= link_to dashboard_groups_path, class: 'dashboard-shortcuts-groups', title: 'Groups' do
Groups
= nav_link(path: 'dashboard#activity', html_options: { class: "hidden-xs hidden-sm hidden-md" }) do
= link_to activity_dashboard_path, class: 'dashboard-shortcuts-activity', title: 'Activity' do
Activity
%li.dropdown
%a{ href: "#", data: { toggle: "dropdown" } }
More
= icon("chevron-down", class: "dropdown-chevron")
.dropdown-menu
%ul
= nav_link(path: 'dashboard#activity', html_options: { class: "visible-xs visible-sm visible-md" }) do
= link_to activity_dashboard_path, title: 'Activity' do
Activity
= nav_link(controller: 'dashboard/milestones') do
= link_to dashboard_milestones_path, class: 'dashboard-shortcuts-milestones', title: 'Milestones' do
Milestones
= nav_link(controller: 'dashboard/snippets') do
= link_to dashboard_snippets_path, class: 'dashboard-shortcuts-snippets', title: 'Snippets' do
Snippets
%li.divider
%li
= link_to "Help", help_path, title: 'About GitLab CE'
-# Shortcut to Dashboard > Projects
%li.hidden
= link_to dashboard_projects_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
Projects
%ul.list-unstyled.navbar-sub-nav
= nav_link(path: ['dashboard#show', 'root#show', 'projects#trending', 'projects#starred', 'projects#index'], html_options: {class: 'home'}) do
= link_to explore_root_path, title: 'Projects', class: 'dashboard-shortcuts-projects' do
Projects
= nav_link(controller: [:groups, 'groups/milestones', 'groups/group_members']) do
= link_to explore_groups_path, title: 'Groups', class: 'dashboard-shortcuts-groups' do
Groups
%li.dropdown
%a{ href: "#", data: { toggle: "dropdown" } }
More
= icon("chevron-down", class: "dropdown-chevron")
.dropdown-menu
%ul
= nav_link(controller: :snippets) do
= link_to explore_snippets_path, title: 'Snippets', class: 'dashboard-shortcuts-snippets' do
Snippets
%li.divider
%li
= link_to "Help", help_path, title: 'About GitLab CE'
.scrolling-tabs-container
.fade-left
= icon('angle-left')
.fade-right
= icon('angle-right')
%ul.nav-links.scrolling-tabs
= nav_link(path: 'profiles#show', html_options: {class: 'home'}) do
= link_to profile_path, title: 'Profile Settings' do
%span
Profile
= nav_link(controller: [:accounts, :two_factor_auths]) do
= link_to profile_account_path, title: 'Account' do
%span
Account
- if current_application_settings.user_oauth_applications?
= nav_link(controller: 'oauth/applications') do
= link_to applications_profile_path, title: 'Applications' do
%span
Applications
= nav_link(controller: :chat_names) do
= link_to profile_chat_names_path, title: 'Chat' do
%span
Chat
= nav_link(controller: :personal_access_tokens) do
= link_to profile_personal_access_tokens_path, title: 'Access Tokens' do
%span
Access Tokens
= nav_link(controller: :emails) do
= link_to profile_emails_path, title: 'Emails' do
%span
Emails
- unless current_user.ldap_user?
= nav_link(controller: :passwords) do
= link_to edit_profile_password_path, title: 'Password' do
%span
Password
= nav_link(controller: :notifications) do
= link_to profile_notifications_path, title: 'Notifications' do
%span
Notifications
= nav_link(controller: :keys) do
= link_to profile_keys_path, title: 'SSH Keys' do
%span
SSH Keys
= nav_link(controller: :gpg_keys) do
= link_to profile_gpg_keys_path, title: 'GPG Keys' do
%span
GPG Keys
= nav_link(controller: :preferences) do
= link_to profile_preferences_path, title: 'Preferences' do
%span
Preferences
= nav_link(path: 'profiles#audit_log') do
= link_to audit_log_profile_path, title: 'Authentication log' do
%span
Authentication log
- can_edit = can?(current_user, :admin_project, @project)
.scrolling-tabs-container{ class: nav_control_class }
.fade-left
= icon('angle-left')
.fade-right
= icon('angle-right')
%ul.nav-links.scrolling-tabs
= nav_link(path: ['projects#show', 'projects#activity', 'cycle_analytics#show'], html_options: { class: 'home' }) do
= link_to project_path(@project), title: 'Project', class: 'shortcuts-project' do
%span
Project
- if project_nav_tab? :files
= nav_link(controller: %w(tree blob blame edit_tree new_tree find_file commit commits compare projects/repositories tags branches releases graphs network)) do
= link_to project_tree_path(@project), title: 'Repository', class: 'shortcuts-tree' do
%span
Repository
- if project_nav_tab? :container_registry
= nav_link(controller: %w[projects/registry/repositories]) do
= link_to project_container_registry_index_path(@project), title: 'Container Registry', class: 'shortcuts-container-registry' do
%span
Registry
- if project_nav_tab? :issues
= nav_link(controller: @project.issues_enabled? ? [:issues, :labels, :milestones, :boards] : :issues) do
= link_to project_issues_path(@project), title: 'Issues', class: 'shortcuts-issues' do
%span
Issues
- if @project.issues_enabled?
%span.badge.count.issue_counter
= number_with_delimiter(@project.open_issues_count)
- if project_nav_tab? :merge_requests
- controllers = [:merge_requests, 'projects/merge_requests/conflicts']
- controllers.push(:merge_requests, :labels, :milestones) unless @project.issues_enabled?
= nav_link(controller: controllers) do
= link_to project_merge_requests_path(@project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do
%span
Merge Requests
%span.badge.count.merge_counter.js-merge-counter
= number_with_delimiter(@project.open_merge_requests_count)
- if project_nav_tab? :pipelines
= nav_link(controller: [:pipelines, :builds, :environments, :artifacts]) do
= link_to project_pipelines_path(@project), title: 'Pipelines', class: 'shortcuts-pipelines' do
%span
Pipelines
- if project_nav_tab? :wiki
= nav_link(controller: :wikis) do
= link_to get_project_wiki_path(@project), title: 'Wiki', class: 'shortcuts-wiki' do
%span
Wiki
- if project_nav_tab? :snippets
= nav_link(controller: :snippets) do
= link_to project_snippets_path(@project), title: 'Snippets', class: 'shortcuts-snippets' do
%span
Snippets
- if project_nav_tab? :project_members
= nav_link(controller: :project_members) do
= link_to project_project_members_path(@project), title: 'Members', class: 'shortcuts-members' do
%span
Members
- if project_nav_tab? :settings
= nav_link(path: %w[projects#edit members#show integrations#show services#edit repository#show ci_cd#show pages#show]) do
= link_to edit_project_path(@project), title: 'Settings', class: 'shortcuts-tree' do
%span
Settings
-# Shortcut to Project > Activity
%li.hidden
= link_to activity_project_path(@project), title: 'Activity', class: 'shortcuts-project-activity' do
%span
Activity
-# Shortcut to Repository > Graph (formerly, Network)
- if project_nav_tab? :network
%li.hidden
= link_to project_network_path(@project, current_ref), title: 'Network', class: 'shortcuts-network' do
Graph
-# Shortcut to Repository > Charts (formerly, top-nav item "Graphs")
- unless @project.empty_repo?
%li.hidden
= link_to charts_project_graph_path(@project, current_ref), title: 'Charts', class: 'shortcuts-repository-charts' do
Charts
-# Shortcut to Issues > New Issue
%li.hidden
= link_to new_project_issue_path(@project), class: 'shortcuts-new-issue' do
Create a new issue
-# Shortcut to Pipelines > Jobs
- if project_nav_tab? :builds
%li.hidden
= link_to project_jobs_path(@project), title: 'Jobs', class: 'shortcuts-builds' do
Jobs
-# Shortcut to commits page
- if project_nav_tab? :commits
%li.hidden
= link_to project_commits_path(@project), title: 'Commits', class: 'shortcuts-commits' do
Commits
-# Shortcut to issue boards
%li.hidden
= link_to 'Issue Boards', project_boards_path(@project), title: 'Issue Boards', class: 'shortcuts-issue-boards'
- page_title "User Settings"
- header_title "User Settings", profile_path unless header_title
- sidebar "dashboard"
- if show_new_nav?
- nav "new_profile_sidebar"
- @new_sidebar = true
- else
- nav "profile"
- nav "profile"
- @left_sidebar = true
= render template: "layouts/application"
- page_title @project.name_with_namespace
- page_description @project.description unless page_description
- header_title project_title(@project) unless header_title
- if show_new_nav?
- nav "new_project_sidebar"
- @new_sidebar = true
- else
- nav "project"
- nav "project"
- @left_sidebar = true
- content_for :project_javascripts do
- project = @target_project || @project
......
- page_title 'Two-Factor Authentication', 'Account'
- if show_new_nav?
- add_to_breadcrumbs("Account", profile_account_path)
- else
- header_title "Two-Factor Authentication", profile_two_factor_auth_path
- add_to_breadcrumbs("Account", profile_account_path)
- @content_class = "limit-container-width" unless fluid_layout
= render 'profiles/head'
......
- project = local_assigns.fetch(:project)
- flash_message_container = show_new_nav? ? :new_global_flash : :flash_message
= content_for flash_message_container do
= content_for :flash_message do
= render partial: 'deletion_failed', locals: { project: project }
- if current_user && can?(current_user, :download_code, project)
= render 'shared/no_ssh'
......
- @no_container = true
- page_title "Environments"
- add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
= render "projects/pipelines/head"
- content_for :page_specific_javascripts do
......
......@@ -13,15 +13,14 @@
= content_for :meta_tags do
= auto_discovery_link_tag(:atom, params.merge(rss_url_options), title: "#{@project.name} issues")
- if show_new_nav?
- content_for :breadcrumbs_extra do
= render "projects/issues/nav_btns"
- content_for :breadcrumbs_extra do
= render "projects/issues/nav_btns"
- if project_issues(@project).exists?
%div{ class: (container_class) }
.top-area
= render 'shared/issuable/nav', type: :issues
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
= render "projects/issues/nav_btns"
= render 'shared/issuable/search_bar', type: :issues
......
......@@ -39,8 +39,7 @@
%ul
- if can_update_issue
%li= link_to 'Edit', edit_project_issue_path(@project, @issue)
/ TODO: simplify condition back #36860
- if @issue.author && current_user != @issue.author
- 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 js-btn-issue-action #{issue_button_visibility(@issue, true)}", title: 'Close issue'
......
......@@ -3,7 +3,7 @@
- hide_class = ''
- can_admin_label = can?(current_user, :admin_label, @project)
- if show_new_nav? && can?(current_user, :admin_label, @project)
- if can?(current_user, :admin_label, @project)
- content_for :breadcrumbs_extra do
= link_to "New label", new_namespace_project_label_path(@project.namespace, @project), class: "btn btn-new"
......@@ -18,7 +18,7 @@
Star a label to make it a priority label. Order the prioritized labels to change their relative priority, by dragging.
- if can_admin_label
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
= link_to new_project_label_path(@project), class: "btn btn-new" do
New label
......
......@@ -12,9 +12,8 @@
= webpack_bundle_tag 'common_vue'
= webpack_bundle_tag 'filtered_search'
- if show_new_nav?
- content_for :breadcrumbs_extra do
= render "projects/merge_requests/nav_btns", merge_project: merge_project, new_merge_request_path: new_merge_request_path
- content_for :breadcrumbs_extra do
= render "projects/merge_requests/nav_btns", merge_project: merge_project, new_merge_request_path: new_merge_request_path
= render 'projects/last_push'
......@@ -22,7 +21,7 @@
%div{ class: container_class }
.top-area
= render 'shared/issuable/nav', type: :merge_requests
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
= render "projects/merge_requests/nav_btns", merge_project: merge_project, new_merge_request_path: new_merge_request_path
= render 'shared/issuable/search_bar', type: :merge_requests
......
- @no_container = true
- page_title 'Milestones'
- if show_new_nav? && can?(current_user, :admin_milestone, @project)
- if can?(current_user, :admin_milestone, @project)
- content_for :breadcrumbs_extra do
= link_to "New milestone", new_namespace_project_milestone_path(@project.namespace, @project), class: 'btn btn-new', title: 'New milestone'
......@@ -11,10 +11,10 @@
.top-area
= render 'shared/milestones_filter', counts: milestone_counts(@project.milestones)
.nav-controls{ class: ("nav-controls-new-nav" if show_new_nav?) }
.nav-controls.nav-controls-new-nav
= render 'shared/milestones_sort_dropdown'
- if can?(current_user, :admin_milestone, @project)
= link_to new_project_milestone_path(@project), class: "btn btn-new #{("visible-xs" if show_new_nav?)}", title: 'New milestone' do
= link_to new_project_milestone_path(@project), class: "btn btn-new visible-xs", title: 'New milestone' do
New milestone
.milestones
......
......@@ -7,7 +7,7 @@
- @no_container = true
- page_title _("Pipeline Schedules")
- if show_new_nav? && can?(current_user, :create_pipeline_schedule, @project)
- if can?(current_user, :create_pipeline_schedule, @project)
- content_for :breadcrumbs_extra do
= link_to _('New schedule'), new_namespace_project_pipeline_schedule_path(@project.namespace, @project), class: 'btn btn-create'
......@@ -20,7 +20,7 @@
= render "tabs", schedule_path_proc: schedule_path_proc, all_schedules: @all_schedules, scope: @scope
- if can?(current_user, :create_pipeline_schedule, @project)
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
= link_to new_project_pipeline_schedule_path(@project), class: 'btn btn-create' do
%span= _('New schedule')
......
......@@ -2,8 +2,7 @@
- @breadcrumb_link = namespace_project_pipeline_schedules_path(@project.namespace, @project)
- page_title _("New Pipeline Schedule")
- if show_new_nav?
- add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
- add_to_breadcrumbs("Pipelines", project_pipelines_path(@project))
%h3.page-title
= _("Schedule a new pipeline")
......
......@@ -60,8 +60,21 @@
= f.check_box :public_builds
%strong Public pipelines
.help-block
Allow everyone to access pipelines for public and internal projects
Allow public access to pipelines and job details, including output logs and artifacts
= link_to icon('question-circle'), help_page_path('user/project/pipelines/settings', anchor: 'visibility-of-pipelines'), target: '_blank'
.bs-callout.bs-callout-info
%p If enabled:
%ul
%li
For public projects, anyone can view pipelines and access job details (output logs and artifacts)
%li
For internal projects, any logged in user can view pipelines and access job details (output logs and artifacts)
%li
For private projects, any member (guest or higher) can view pipelines and access job details (output logs and artifacts)
%p
If disabled, the access level will depend on the user's
permissions in the project.
%hr
.form-group
.checkbox
......
- breadcrumb_title "Integrations"
- page_title @service.title, "Services"
- if show_new_nav?
- add_to_breadcrumbs("Settings", edit_project_path(@project))
- add_to_breadcrumbs("Settings", edit_project_path(@project))
= render "projects/settings/head"
= render 'form'
- page_title "Snippets"
- if show_new_nav? && can?(current_user, :create_project_snippet, @project)
- if can?(current_user, :create_project_snippet, @project)
- content_for :breadcrumbs_extra do
= link_to "New snippet", new_namespace_project_snippet_path(@project.namespace, @project), class: "btn btn-new", title: "New snippet"
......@@ -9,7 +9,7 @@
- include_private = @project.team.member?(current_user) || current_user.admin?
= render partial: 'snippets/snippets_scope_menu', locals: { subject: @project, include_private: include_private }
.nav-controls{ class: ("visible-xs" if show_new_nav?) }
.nav-controls.visible-xs
- if can?(current_user, :create_project_snippet, @project)
= link_to "New snippet", new_project_snippet_path(@project), class: "btn btn-new", title: "New snippet"
......
- @no_container = true
- @sort ||= sort_value_recently_updated
- page_title "Tags"
- add_to_breadcrumbs("Repository", project_tree_path(@project))
= render "projects/commits/head"
.flex-list{ class: container_class }
......
<svg width="28" height="28" class="tanuki-logo" viewBox="0 0 36 36">
<svg width="24" height="24" class="tanuki-logo" viewBox="0 0 36 36">
<path class="tanuki-shape tanuki-left-ear" fill="#e24329" d="M2 14l9.38 9v-9l-4-12.28c-.205-.632-1.176-.632-1.38 0z"/>
<path class="tanuki-shape tanuki-right-ear" fill="#e24329" d="M34 14l-9.38 9v-9l4-12.28c.205-.632 1.176-.632 1.38 0z"/>
<path class="tanuki-shape tanuki-nose" fill="#e24329" d="M18,34.38 3,14 33,14 Z"/>
......
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" class="caret-down" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M8 10.243l-4.95-4.95a1 1 0 0 0-1.414 1.414l5.657 5.657a.997.997 0 0 0 1.414 0l5.657-5.657a1 1 0 0 0-1.414-1.414L8 10.243z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="m5 5.563v4.875c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-4.875c-1.024-.4-1.75-1.397-1.75-2.563 0-1.519 1.231-2.75 2.75-2.75 1.519 0 2.75 1.231 2.75 2.75 0 1.166-.726 2.162-1.75 2.563m-1 8.687c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25m0-10c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/><path d="m10.501 2c1.381.001 2.499 1.125 2.499 2.506v5.931c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-5.931c0-.279-.225-.506-.499-.506v.926c0 .346-.244.474-.569.271l-2.952-1.844c-.314-.196-.325-.507 0-.71l2.952-1.844c.314-.196.569-.081.569.271v.93m1.499 12.25c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" height="16" width="16" viewBox="0 0 16 16"><path d="m5 5.563v4.875c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-4.875c-1.024-.4-1.75-1.397-1.75-2.563 0-1.519 1.231-2.75 2.75-2.75 1.519 0 2.75 1.231 2.75 2.75 0 1.166-.726 2.162-1.75 2.563m-1 8.687c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25m0-10c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/><path d="m10.501 2c1.381.001 2.499 1.125 2.499 2.506v5.931c1.024.4 1.75 1.397 1.75 2.563 0 1.519-1.231 2.75-2.75 2.75-1.519 0-2.75-1.231-2.75-2.75 0-1.166.726-2.162 1.75-2.563v-5.931c0-.279-.225-.506-.499-.506v.926c0 .346-.244.474-.569.271l-2.952-1.844c-.314-.196-.325-.507 0-.71l2.952-1.844c.314-.196.569-.081.569.271v.93m1.499 12.25c.69 0 1.25-.56 1.25-1.25 0-.69-.56-1.25-1.25-1.25-.69 0-1.25.56-1.25 1.25 0 .69.56 1.25 1.25 1.25"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path fill-rule="evenodd" d="M9 7V4c0-.552-.448-1-1-1s-1 .448-1 1v3H4c-.552 0-1 .448-1 1s.448 1 1 1h3v3c0 .552.448 1 1 1s1-.448 1-1V9h3c.552 0 1-.448 1-1s-.448-1-1-1H9zM3 0h10c1.657 0 3 1.343 3 3v10c0 1.657-1.343 3-3 3H3c-1.657 0-3-1.343-3-3V3c0-1.657 1.343-3 3-3z"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"><path d="M8.243 7.485l4.95-4.95a1 1 0 1 1 1.414 1.415L8.95 9.607a.997.997 0 0 1-1.414 0l-2.83-2.83a1 1 0 0 1 1.415-1.413l2.123 2.12zM12 11a1 1 0 0 1 2 0v2a3 3 0 0 1-3 3H3a3 3 0 0 1-3-3V5a3 3 0 0 1 3-3h2a1 1 0 1 1 0 2H3a1 1 0 0 0-1 1v8a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1v-2z"/></svg>
......@@ -9,7 +9,6 @@
class: "hidden-xs hidden-sm btn btn-grouped btn-reopen js-btn-issue-action #{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
- elsif issuable.author
/ TODO: change back to else #36860
- 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'
......@@ -37,15 +37,13 @@
%li.divider.droplab-item-ignore
/ TODO: remove condition #36860
- if issuable.author
%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.
%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.
......@@ -3,7 +3,7 @@
= page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('sidebar')
%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { "offset-top" => ("50" unless show_new_nav?), "spy" => ("affix" unless show_new_nav?), signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
%aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { signed: { in: current_user.present? } }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header
......
---
title: Update gpg documentation with gpg2
merge_request: 13851
author: M M Arif
type: other
---
title: Migrate issues authored by deleted user to the Ghost user
merge_request:
author:
type: fixed
---
title: 'API: Add GPG key management'
merge_request: 13828
author: Robert Schilling
type: added
---
title: Support a multi-word fuzzy seach issues/merge requests on search bar
merge_request: 13780
author: Hiroyuki Sato
type: changed
---
title: Bump jira-ruby gem to 1.4.1 to fix issues with HTTP proxies
merge_request:
author:
type: fixed
class MigrateIssuesToGhostUser < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
class User < ActiveRecord::Base
self.table_name = 'users'
end
class Issue < ActiveRecord::Base
self.table_name = 'issues'
include ::EachBatch
end
def reset_column_in_migration_models
ActiveRecord::Base.clear_cache!
::User.reset_column_information
end
def up
reset_column_in_migration_models
# we use the model method because rewriting it is too complicated and would require copying multiple methods
ghost_id = ::User.ghost.id
Issue.where('NOT EXISTS (?)', User.unscoped.select(1).where('issues.author_id = users.id')).each_batch do |relation|
relation.update_all(author_id: ghost_id)
end
end
def down
end
end
class AddForeignKeyToIssueAuthor < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_foreign_key(:issues, :users, column: :author_id, on_delete: :nullify)
end
def down
remove_foreign_key(:issues, column: :author_id)
end
end
......@@ -11,7 +11,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20170831195038) do
ActiveRecord::Schema.define(version: 20170901071411) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
......@@ -1708,6 +1708,7 @@ ActiveRecord::Schema.define(version: 20170831195038) do
add_foreign_key "issue_assignees", "users", name: "fk_5e0c8d9154", on_delete: :cascade
add_foreign_key "issue_metrics", "issues", on_delete: :cascade
add_foreign_key "issues", "projects", name: "fk_899c8f3231", on_delete: :cascade
add_foreign_key "issues", "users", column: "author_id", name: "fk_05f1e72feb", on_delete: :cascade
add_foreign_key "label_priorities", "labels", on_delete: :cascade
add_foreign_key "label_priorities", "projects", on_delete: :cascade
add_foreign_key "labels", "namespaces", column: "group_id", on_delete: :cascade
......
......@@ -61,16 +61,7 @@ following locations:
## Road to GraphQL
Going forward, we will start on moving to
[GraphQL](http://graphql.org/learn/best-practices/) and deprecate the use of
controller-specific endpoints. GraphQL has a number of benefits:
1. We avoid having to maintain two different APIs.
2. Callers of the API can request only what they need.
3. It is versioned by default.
It will co-exist with the current v4 REST API. If we have a v5 API, this should
be a compatibility layer on top of GraphQL.
We have changed our plans to move to GraphQL. After reviewing the GraphQL license, anything related to the Facebook BSD plus patent license will not be allowed at GitLab.
## Basic usage
......@@ -246,8 +237,8 @@ The following table gives an overview of how the API functions generally behave.
| ------------ | ----------- |
| `GET` | Access one or more resources and return the result as JSON. |
| `POST` | Return `201 Created` if the resource is successfully created and return the newly created resource as JSON. |
| `GET` / `PUT` / `DELETE` | Return `200 OK` if the resource is accessed, modified or deleted successfully. The (modified) result is returned as JSON. |
| `DELETE` | Designed to be idempotent, meaning a request to a resource still returns `200 OK` even it was deleted before or is not available. The reasoning behind this, is that the user is not really interested if the resource existed before or not. |
| `GET` / `PUT` | Return `200 OK` if the resource is accessed or modified successfully. The (modified) result is returned as JSON. |
| `DELETE` | Returns `204 No Content` if the resuource was deleted successfully. |
The following table shows the possible return codes for API requests.
......
......@@ -94,7 +94,7 @@ Example response:
## Delete an environment
It returns `200` if the environment was successfully deleted, and `404` if the environment does not exist.
It returns `204` if the environment was successfully deleted, and `404` if the environment does not exist.
```
DELETE /projects/:id/environments/:environment_id
......
......@@ -95,8 +95,7 @@ Parameters:
## Delete snippet
Deletes an existing project snippet. This is an idempotent function and deleting a non-existent
snippet still returns a `200 OK` status code.
Deletes an existing project snippet. This returns a `204 No Content` status code if the operation was successfully or `404` if the resource was not found.
```
DELETE /projects/:id/snippets/:snippet_id
......
......@@ -299,10 +299,7 @@ e.g. when renaming the email address to some existing one.
## User deletion
Deletes a user. Available only for administrators.
This is an idempotent function, calling this function for a non-existent user id
still returns a status code `200 OK`.
The JSON response differs if the user was actually deleted or not.
In the former the user is returned and in the latter not.
This returns a `204 No Content` status code if the operation was successfully or `404` if the resource was not found.
```
DELETE /users/:id
......@@ -524,8 +521,7 @@ Parameters:
## Delete SSH key for current user
Deletes key owned by currently authenticated user.
This is an idempotent function and calling it on a key that is already deleted
or not available results in `200 OK`.
This returns a `204 No Content` status code if the operation was successfully or `404` if the resource was not found.
```
DELETE /user/keys/:key_id
......@@ -548,7 +544,216 @@ Parameters:
- `id` (required) - id of specified user
- `key_id` (required) - SSH key ID
Will return `200 OK` on success, or `404 Not found` if either user or key cannot be found.
## List all GPG keys
Get a list of currently authenticated user's GPG keys.
```
GET /user/gpg_keys
```
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/gpg_keys
```
Example response:
```json
[
{
"id": 1,
"key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----",
"created_at": "2017-09-05T09:17:46.264Z"
}
]
```
## Get a specific GPG key
Get a specific GPG key of currently authenticated user.
```
GET /user/gpg_keys/:key_id
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `key_id` | integer | yes | The ID of the GPG key |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/gpg_keys/1
```
Example response:
```json
{
"id": 1,
"key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----",
"created_at": "2017-09-05T09:17:46.264Z"
}
```
## Add a GPG key
Creates a new GPG key owned by the currently authenticated user.
```
POST /user/gpg_keys
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| key | string | yes | The new GPG key |
```bash
curl --data "key=-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFV..." --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/gpg_keys
```
Example response:
```json
[
{
"id": 1,
"key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----",
"created_at": "2017-09-05T09:17:46.264Z"
}
]
```
## Delete a GPG key
Delete a GPG key owned by currently authenticated user.
```
DELETE /user/gpg_keys/:key_id
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `key_id` | integer | yes | The ID of the GPG key |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/user/gpg_keys/1
```
Returns `204 No Content` on success, or `404 Not found` if the key cannot be found.
## List all GPG keys for given user
Get a list of a specified user's GPG keys. Available only for admins.
```
GET /users/:id/gpg_keys
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the user |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/2/gpg_keys
```
Example response:
```json
[
{
"id": 1,
"key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----",
"created_at": "2017-09-05T09:17:46.264Z"
}
]
```
## Get a specific GPG key for a given user
Get a specific GPG key for a given user. Available only for admins.
```
GET /users/:id/gpg_keys/:key_id
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the user |
| `key_id` | integer | yes | The ID of the GPG key |
```bash
curl --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/2/gpg_keys/1
```
Example response:
```json
{
"id": 1,
"key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----",
"created_at": "2017-09-05T09:17:46.264Z"
}
```
## Add a GPG key for a given user
Create new GPG key owned by the specified user. Available only for admins.
```
POST /users/:id/gpg_keys
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the user |
| `key_id` | integer | yes | The ID of the GPG key |
```bash
curl --data "key=-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFV..." --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/2/gpg_keys
```
Example response:
```json
[
{
"id": 1,
"key": "-----BEGIN PGP PUBLIC KEY BLOCK-----\r\n\r\nxsBNBFVjnlIBCACibzXOLCiZiL2oyzYUaTOCkYnSUhymg3pdbfKtd4mpBa58xKBj\r\nt1pTHVpw3Sk03wmzhM/Ndlt1AV2YhLv++83WKr+gAHFYFiCV/tnY8bx3HqvVoy8O\r\nCfxWhw4QZK7+oYzVmJj8ZJm3ZjOC4pzuegNWlNLCUdZDx9OKlHVXLCX1iUbjdYWa\r\nqKV6tdV8hZolkbyjedQgrpvoWyeSHHpwHF7yk4gNJWMMI5rpcssL7i6mMXb/sDzO\r\nVaAtU5wiVducsOa01InRFf7QSTxoAm6Xy0PGv/k48M6xCALa9nY+BzlOv47jUT57\r\nvilf4Szy9dKD0v9S0mQ+IHB+gNukWrnwtXx5ABEBAAHNFm5hbWUgKGNvbW1lbnQp\r\nIDxlbUBpbD7CwHUEEwECACkFAlVjnlIJEINgJNgv009/AhsDAhkBBgsJCAcDAgYV\r\nCAIJCgsEFgIDAQAAxqMIAFBHuBA8P1v8DtHonIK8Lx2qU23t8Mh68HBIkSjk2H7/\r\noO2cDWCw50jZ9D91PXOOyMPvBWV2IE3tARzCvnNGtzEFRtpIEtZ0cuctxeIF1id5\r\ncrfzdMDsmZyRHAOoZ9VtuD6mzj0ybQWMACb7eIHjZDCee3Slh3TVrLy06YRdq2I4\r\nbjMOPePtK5xnIpHGpAXkB3IONxyITpSLKsA4hCeP7gVvm7r7TuQg1ygiUBlWbBYn\r\niE5ROzqZjG1s7dQNZK/riiU2umGqGuwAb2IPvNiyuGR3cIgRE4llXH/rLuUlspAp\r\no4nlxaz65VucmNbN1aMbDXLJVSqR1DuE00vEsL1AItI=\r\n=XQoy\r\n-----END PGP PUBLIC KEY BLOCK-----",
"created_at": "2017-09-05T09:17:46.264Z"
}
]
```
## Delete a GPG key for a given user
Delete a GPG key owned by a specified user. Available only for admins.
```
DELETE /users/:id/gpg_keys/:key_id
```
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer | yes | The ID of the user |
| `key_id` | integer | yes | The ID of the GPG key |
```bash
curl --request DELETE --header "PRIVATE-TOKEN: 9koXpg98eAheJpvBs5tK" https://gitlab.example.com/api/v4/users/2/gpg_keys/1
```
## List emails
......@@ -654,8 +859,7 @@ Parameters:
## Delete email for current user
Deletes email owned by currently authenticated user.
This is an idempotent function and calling it on a email that is already deleted
or not available results in `200 OK`.
This returns a `204 No Content` status code if the operation was successfully or `404` if the resource was not found.
```
DELETE /user/emails/:email_id
......@@ -678,8 +882,6 @@ Parameters:
- `id` (required) - id of specified user
- `email_id` (required) - email ID
Will return `200 OK` on success, or `404 Not found` if either user or email cannot be found.
## Block user
Blocks the specified user. Available only for admin.
......
doc/user/project/issues/img/confidential_issues_system_notes.png

2.28 KB | W: | H:

doc/user/project/issues/img/confidential_issues_system_notes.png

4.12 KB | W: | H:

doc/user/project/issues/img/confidential_issues_system_notes.png
doc/user/project/issues/img/confidential_issues_system_notes.png
doc/user/project/issues/img/confidential_issues_system_notes.png
doc/user/project/issues/img/confidential_issues_system_notes.png
  • 2-up
  • Swipe
  • Onion skin
......@@ -66,10 +66,30 @@ in the pipelines settings page.
## Visibility of pipelines
For public and internal projects, the pipelines page can be accessed by
anyone and those logged in respectively. If you wish to hide it so that only
the members of the project or group have access to it, uncheck the **Public
pipelines** checkbox and save the changes.
Access to pipelines and job details (including output of logs and artifacts)
is checked against your current user access level and the **Public pipelines**
project setting.
If **Public pipelines** is enabled (default):
- for **public** projects, anyone can view the pipelines and access the job details
(output logs and artifacts)
- for **internal** projects, any logged in user can view the pipelines
and access the job details
(output logs and artifacts)
- for **private** projects, any member (guest or higher) can view the pipelines
and access the job details
(output logs and artifacts)
If **Public pipelines** is disabled:
- for **public** projects, anyone can view the pipelines, but only members
(reporter or higher) can access the job details (output logs and artifacts)
- for **internal** projects, any logged in user can view the pipelines,
but only members (reporter or higher) can access the job details (output logs
and artifacts)
- for **private** projects, only members (reporter or higher)
can view the pipelines and access the job details (output logs and artifacts)
## Auto-cancel pending pipelines
......
......@@ -31,6 +31,16 @@ to be met:
## Generating a GPG key
>**Notes:**
- If your Operating System has `gpg2` installed, replace `gpg` with `gpg2` in
the following commands.
- If Git is using `gpg` and you get errors like `secret key not available` or
`gpg: signing failed: secret key not available`, run the following command to
change to `gpg2`:
```
git config --global gpg.program gpg2
```
If you don't already have a GPG key, the following steps will help you get
started:
......
......@@ -40,6 +40,20 @@ The same process is valid for merge requests. Navigate to your project's **Merge
and click **Search or filter results...**. Merge requests can be filtered by author, assignee,
milestone, and label.
### Searching for specific terms
You can filter issues and merge requests by specific terms included in titles or descriptions.
* Syntax
* Searches look for all the words in a query, in any order. E.g.: searching
issues for `display bug` will return all issues matching both those words, in any order.
* To find the exact term, use double quotes: `"display bug"`
* Limitation
* For performance reasons, terms shorter than 3 chars are ignored. E.g.: searching
issues for `included in titles` is same as `included titles`
![filter issues by specific terms](img/issue_search_by_term.png)
### Issues and merge requests per group
Similar to **Issues and merge requests per project**, you can also search for issues
......
Spinach.hooks.before_scenario do
allow(Gitlab::GitalyClient).to receive(:feature_enabled?).and_return(true)
end
......@@ -491,6 +491,10 @@ module API
expose :user, using: Entities::UserPublic
end
class GPGKey < Grape::Entity
expose :id, :key, :created_at
end
class Note < Grape::Entity
# Only Issue and MergeRequest have iid
NOTEABLE_TYPES_WITH_IID = %w(Issue MergeRequest).freeze
......
......@@ -233,6 +233,86 @@ module API
destroy_conditionally!(key)
end
desc 'Add a GPG key to a specified user. Available only for admins.' do
detail 'This feature was added in GitLab 10.0'
success Entities::GPGKey
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
requires :key, type: String, desc: 'The new GPG key'
end
post ':id/gpg_keys' do
authenticated_as_admin!
user = User.find_by(id: params.delete(:id))
not_found!('User') unless user
key = user.gpg_keys.new(declared_params(include_missing: false))
if key.save
present key, with: Entities::GPGKey
else
render_validation_error!(key)
end
end
desc 'Get the GPG keys of a specified user. Available only for admins.' do
detail 'This feature was added in GitLab 10.0'
success Entities::GPGKey
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
use :pagination
end
get ':id/gpg_keys' do
authenticated_as_admin!
user = User.find_by(id: params[:id])
not_found!('User') unless user
present paginate(user.gpg_keys), with: Entities::GPGKey
end
desc 'Delete an existing GPG key from a specified user. Available only for admins.' do
detail 'This feature was added in GitLab 10.0'
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
requires :key_id, type: Integer, desc: 'The ID of the GPG key'
end
delete ':id/gpg_keys/:key_id' do
authenticated_as_admin!
user = User.find_by(id: params[:id])
not_found!('User') unless user
key = user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key
status 204
key.destroy
end
desc 'Revokes an existing GPG key from a specified user. Available only for admins.' do
detail 'This feature was added in GitLab 10.0'
end
params do
requires :id, type: Integer, desc: 'The ID of the user'
requires :key_id, type: Integer, desc: 'The ID of the GPG key'
end
post ':id/gpg_keys/:key_id/revoke' do
authenticated_as_admin!
user = User.find_by(id: params[:id])
not_found!('User') unless user
key = user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key
key.revoke
status :accepted
end
desc 'Add an email address to a specified user. Available only for admins.' do
success Entities::Email
end
......@@ -492,6 +572,76 @@ module API
destroy_conditionally!(key)
end
desc "Get the currently authenticated user's GPG keys" do
detail 'This feature was added in GitLab 10.0'
success Entities::GPGKey
end
params do
use :pagination
end
get 'gpg_keys' do
present paginate(current_user.gpg_keys), with: Entities::GPGKey
end
desc 'Get a single GPG key owned by currently authenticated user' do
detail 'This feature was added in GitLab 10.0'
success Entities::GPGKey
end
params do
requires :key_id, type: Integer, desc: 'The ID of the GPG key'
end
get 'gpg_keys/:key_id' do
key = current_user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key
present key, with: Entities::GPGKey
end
desc 'Add a new GPG key to the currently authenticated user' do
detail 'This feature was added in GitLab 10.0'
success Entities::GPGKey
end
params do
requires :key, type: String, desc: 'The new GPG key'
end
post 'gpg_keys' do
key = current_user.gpg_keys.new(declared_params)
if key.save
present key, with: Entities::GPGKey
else
render_validation_error!(key)
end
end
desc 'Revoke a GPG key owned by currently authenticated user' do
detail 'This feature was added in GitLab 10.0'
end
params do
requires :key_id, type: Integer, desc: 'The ID of the GPG key'
end
post 'gpg_keys/:key_id/revoke' do
key = current_user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key
key.revoke
status :accepted
end
desc 'Delete a GPG key from the currently authenticated user' do
detail 'This feature was added in GitLab 10.0'
end
params do
requires :key_id, type: Integer, desc: 'The ID of the SSH key'
end
delete 'gpg_keys/:key_id' do
key = current_user.gpg_keys.find_by(id: params[:key_id])
not_found!('GPG Key') unless key
status 204
key.destroy
end
desc "Get the currently authenticated user's email addresses" do
success Entities::Email
end
......
......@@ -134,15 +134,19 @@ module Gitlab
# This is to work around a bug in libgit2 that causes in-memory refs to
# be stale/invalid when packed-refs is changed.
# See https://gitlab.com/gitlab-org/gitlab-ce/issues/15392#note_14538333
#
# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/474
def find_branch(name, force_reload = false)
reload_rugged if force_reload
gitaly_migrate(:find_branch) do |is_enabled|
if is_enabled
gitaly_ref_client.find_branch(name)
else
reload_rugged if force_reload
rugged_ref = rugged.branches[name]
if rugged_ref
target_commit = Gitlab::Git::Commit.find(self, rugged_ref.target)
Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target, target_commit)
rugged_ref = rugged.branches[name]
if rugged_ref
target_commit = Gitlab::Git::Commit.find(self, rugged_ref.target)
Gitlab::Git::Branch.new(self, rugged_ref.name, rugged_ref.target, target_commit)
end
end
end
end
......
......@@ -78,6 +78,20 @@ module Gitlab
raise ArgumentError, e.message
end
def find_branch(branch_name)
request = Gitaly::DeleteBranchRequest.new(
repository: @gitaly_repo,
name: GitalyClient.encode(branch_name)
)
response = GitalyClient.call(@repository.storage, :ref_service, :find_branch, request)
branch = response.branch
return unless branch
target_commit = Gitlab::Git::Commit.decorate(@repository, branch.target_commit)
Gitlab::Git::Branch.new(@repository, encode!(branch.name.dup), branch.target_commit.id, target_commit)
end
private
def consume_refs_response(response)
......
......@@ -4,6 +4,7 @@ module Gitlab
extend ActiveSupport::Concern
MIN_CHARS_FOR_PARTIAL_MATCHING = 3
REGEX_QUOTED_WORD = /(?<=^| )"[^"]+"(?= |$)/
class_methods do
def to_pattern(query)
......@@ -17,6 +18,28 @@ module Gitlab
def partial_matching?(query)
query.length >= MIN_CHARS_FOR_PARTIAL_MATCHING
end
def to_fuzzy_arel(column, query)
words = select_fuzzy_words(query)
matches = words.map { |word| arel_table[column].matches(to_pattern(word)) }
matches.reduce { |result, match| result.and(match) }
end
def select_fuzzy_words(query)
quoted_words = query.scan(REGEX_QUOTED_WORD)
query = quoted_words.reduce(query) { |q, quoted_word| q.sub(quoted_word, '') }
words = query.split(/\s+/)
quoted_words.map! { |quoted_word| quoted_word[1..-2] }
words.concat(quoted_words)
words.select { |word| partial_matching?(word) }
end
end
end
end
......
......@@ -7,26 +7,22 @@ module SystemCheck
set_skip_reason 'skipped (omnibus-gitlab has no init script)'
def skip?
omnibus_gitlab?
end
return true if omnibus_gitlab?
def multi_check
recipe_path = Rails.root.join('lib/support/init.d/', 'gitlab')
unless init_file_exists?
self.skip_reason = "can't check because of previous errors"
unless File.exist?(SCRIPT_PATH)
$stdout.puts "can't check because of previous errors".color(:magenta)
return
true
end
end
def check?
recipe_path = Rails.root.join('lib/support/init.d/', 'gitlab')
recipe_content = File.read(recipe_path)
script_content = File.read(SCRIPT_PATH)
if recipe_content == script_content
$stdout.puts 'yes'.color(:green)
else
$stdout.puts 'no'.color(:red)
show_error
end
recipe_content == script_content
end
def show_error
......@@ -38,6 +34,12 @@ module SystemCheck
)
fix_and_rerun
end
private
def init_file_exists?
File.exist?(SCRIPT_PATH)
end
end
end
end
......@@ -62,6 +62,25 @@ module SystemCheck
call_or_return(@skip_reason) || 'skipped'
end
# Define a reason why we skipped the SystemCheck (during runtime)
#
# This is used when you need dynamic evaluation like when you have
# multiple reasons why a check can fail
#
# @param [String] reason to be displayed
def skip_reason=(reason)
@skip_reason = reason
end
# Skip reason defined during runtime
#
# This value have precedence over the one defined in the subclass
#
# @return [String] the reason
def skip_reason
@skip_reason
end
# Does the check support automatically repair routine?
#
# @return [Boolean] whether check implemented `#repair!` method or not
......
module SystemCheck
module IncomingEmail
class ForemanConfiguredCheck < SystemCheck::BaseCheck
set_name 'Foreman configured correctly?'
def check?
path = Rails.root.join('Procfile')
File.exist?(path) && File.read(path) =~ /^mail_room:/
end
def show_error
try_fixing_it(
'Enable mail_room in your Procfile.'
)
for_more_information(
'doc/administration/reply_by_email.md'
)
fix_and_rerun
end
end
end
end
module SystemCheck
module IncomingEmail
class ImapAuthenticationCheck < SystemCheck::BaseCheck
set_name 'IMAP server credentials are correct?'
def check?
if mailbox_config
begin
imap = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl])
imap.starttls if config[:start_tls]
imap.login(config[:email], config[:password])
connected = true
rescue
connected = false
end
end
connected
end
def show_error
try_fixing_it(
'Check that the information in config/gitlab.yml is correct'
)
for_more_information(
'doc/administration/reply_by_email.md'
)
fix_and_rerun
end
private
def mailbox_config
return @config if @config
config_path = Rails.root.join('config', 'mail_room.yml').to_s
erb = ERB.new(File.read(config_path))
erb.filename = config_path
config_file = YAML.load(erb.result)
@config = config_file[:mailboxes]&.first
end
end
end
end
module SystemCheck
module IncomingEmail
class InitdConfiguredCheck < SystemCheck::BaseCheck
set_name 'Init.d configured correctly?'
def skip?
omnibus_gitlab?
end
def check?
mail_room_configured?
end
def show_error
try_fixing_it(
'Enable mail_room in the init.d configuration.'
)
for_more_information(
'doc/administration/reply_by_email.md'
)
fix_and_rerun
end
private
def mail_room_configured?
path = '/etc/default/gitlab'
File.exist?(path) && File.read(path).include?('mail_room_enabled=true')
end
end
end
end
module SystemCheck
module IncomingEmail
class MailRoomRunningCheck < SystemCheck::BaseCheck
set_name 'MailRoom running?'
def skip?
return true if omnibus_gitlab?
unless mail_room_configured?
self.skip_reason = "can't check because of previous errors"
true
end
end
def check?
mail_room_running?
end
def show_error
try_fixing_it(
sudo_gitlab('RAILS_ENV=production bin/mail_room start')
)
for_more_information(
see_installation_guide_section('Install Init Script'),
'see log/mail_room.log for possible errors'
)
fix_and_rerun
end
private
def mail_room_configured?
path = '/etc/default/gitlab'
File.exist?(path) && File.read(path).include?('mail_room_enabled=true')
end
def mail_room_running?
ps_ux, _ = Gitlab::Popen.popen(%w(ps uxww))
ps_ux.include?("mail_room")
end
end
end
end
......@@ -23,7 +23,7 @@ module SystemCheck
#
# @param [BaseCheck] check class
def <<(check)
raise ArgumentError unless check < BaseCheck
raise ArgumentError unless check.is_a?(Class) && check < BaseCheck
@checks << check
end
......@@ -48,7 +48,7 @@ module SystemCheck
# When implements skip method, we run it first, and if true, skip the check
if check.can_skip? && check.skip?
$stdout.puts check_klass.skip_reason.color(:magenta)
$stdout.puts check.skip_reason.try(:color, :magenta) || check_klass.skip_reason.color(:magenta)
return
end
......
......@@ -309,133 +309,24 @@ namespace :gitlab do
desc "GitLab | Check the configuration of Reply by email"
task check: :environment do
warn_user_is_not_gitlab
start_checking "Reply by email"
if Gitlab.config.incoming_email.enabled
check_imap_authentication
checks = [
SystemCheck::IncomingEmail::ImapAuthenticationCheck
]
if Rails.env.production?
check_initd_configured_correctly
check_mail_room_running
checks << SystemCheck::IncomingEmail::InitdConfiguredCheck
checks << SystemCheck::IncomingEmail::MailRoomRunningCheck
else
check_foreman_configured_correctly
checks << SystemCheck::IncomingEmail::ForemanConfiguredCheck
end
else
puts 'Reply by email is disabled in config/gitlab.yml'
end
finished_checking "Reply by email"
end
# Checks
########################
def check_initd_configured_correctly
return if omnibus_gitlab?
print "Init.d configured correctly? ... "
path = "/etc/default/gitlab"
if File.exist?(path) && File.read(path).include?("mail_room_enabled=true")
puts "yes".color(:green)
else
puts "no".color(:red)
try_fixing_it(
"Enable mail_room in the init.d configuration."
)
for_more_information(
"doc/administration/reply_by_email.md"
)
fix_and_rerun
end
end
def check_foreman_configured_correctly
print "Foreman configured correctly? ... "
path = Rails.root.join("Procfile")
if File.exist?(path) && File.read(path) =~ /^mail_room:/
puts "yes".color(:green)
SystemCheck.run('Reply by email', checks)
else
puts "no".color(:red)
try_fixing_it(
"Enable mail_room in your Procfile."
)
for_more_information(
"doc/administration/reply_by_email.md"
)
fix_and_rerun
end
end
def check_mail_room_running
return if omnibus_gitlab?
print "MailRoom running? ... "
path = "/etc/default/gitlab"
unless File.exist?(path) && File.read(path).include?("mail_room_enabled=true")
puts "can't check because of previous errors".color(:magenta)
return
end
if mail_room_running?
puts "yes".color(:green)
else
puts "no".color(:red)
try_fixing_it(
sudo_gitlab("RAILS_ENV=production bin/mail_room start")
)
for_more_information(
see_installation_guide_section("Install Init Script"),
"see log/mail_room.log for possible errors"
)
fix_and_rerun
end
end
def check_imap_authentication
print "IMAP server credentials are correct? ... "
config_path = Rails.root.join('config', 'mail_room.yml').to_s
erb = ERB.new(File.read(config_path))
erb.filename = config_path
config_file = YAML.load(erb.result)
config = config_file[:mailboxes].first
if config
begin
imap = Net::IMAP.new(config[:host], port: config[:port], ssl: config[:ssl])
imap.starttls if config[:start_tls]
imap.login(config[:email], config[:password])
connected = true
rescue
connected = false
end
end
if connected
puts "yes".color(:green)
else
puts "no".color(:red)
try_fixing_it(
"Check that the information in config/gitlab.yml is correct"
)
for_more_information(
"doc/administration/reply_by_email.md"
)
fix_and_rerun
puts 'Reply by email is disabled in config/gitlab.yml'
end
end
def mail_room_running?
ps_ux, _ = Gitlab::Popen.popen(%w(ps uxww))
ps_ux.include?("mail_room")
end
end
namespace :ldap do
......
......@@ -8,8 +8,8 @@ describe 'Issue Boards add issue modal', :js do
let!(:label) { create(:label, project: project) }
let!(:list1) { create(:list, board: board, label: planning, position: 0) }
let!(:list2) { create(:list, board: board, label: label, position: 1) }
let!(:issue) { create(:issue, project: project) }
let!(:issue2) { create(:issue, project: project) }
let!(:issue) { create(:issue, project: project, title: 'abc', description: 'def') }
let!(:issue2) { create(:issue, project: project, title: 'hij', description: 'klm') }
before do
project.team << [user, :master]
......
......@@ -73,15 +73,15 @@ describe 'Issue Boards', js: true do
let!(:list2) { create(:list, board: board, label: development, position: 1) }
let!(:confidential_issue) { create(:labeled_issue, :confidential, project: project, author: user, labels: [planning], relative_position: 9) }
let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], labels: [planning], relative_position: 8) }
let!(:issue2) { create(:labeled_issue, project: project, author: user2, labels: [planning], relative_position: 7) }
let!(:issue3) { create(:labeled_issue, project: project, labels: [planning], relative_position: 6) }
let!(:issue4) { create(:labeled_issue, project: project, labels: [planning], relative_position: 5) }
let!(:issue5) { create(:labeled_issue, project: project, labels: [planning], milestone: milestone, relative_position: 4) }
let!(:issue6) { create(:labeled_issue, project: project, labels: [planning, development], relative_position: 3) }
let!(:issue7) { create(:labeled_issue, project: project, labels: [development], relative_position: 2) }
let!(:issue8) { create(:closed_issue, project: project) }
let!(:issue9) { create(:labeled_issue, project: project, labels: [planning, testing, bug, accepting], relative_position: 1) }
let!(:issue1) { create(:labeled_issue, project: project, title: 'aaa', description: '111', assignees: [user], labels: [planning], relative_position: 8) }
let!(:issue2) { create(:labeled_issue, project: project, title: 'bbb', description: '222', author: user2, labels: [planning], relative_position: 7) }
let!(:issue3) { create(:labeled_issue, project: project, title: 'ccc', description: '333', labels: [planning], relative_position: 6) }
let!(:issue4) { create(:labeled_issue, project: project, title: 'ddd', description: '444', labels: [planning], relative_position: 5) }
let!(:issue5) { create(:labeled_issue, project: project, title: 'eee', description: '555', labels: [planning], milestone: milestone, relative_position: 4) }
let!(:issue6) { create(:labeled_issue, project: project, title: 'fff', description: '666', labels: [planning, development], relative_position: 3) }
let!(:issue7) { create(:labeled_issue, project: project, title: 'ggg', description: '777', labels: [development], relative_position: 2) }
let!(:issue8) { create(:closed_issue, project: project, title: 'hhh', description: '888') }
let!(:issue9) { create(:labeled_issue, project: project, title: 'iii', description: '999', labels: [planning, testing, bug, accepting], relative_position: 1) }
before do
visit project_board_path(project, board)
......
......@@ -40,18 +40,4 @@ feature 'Issue Detail', :js do
end
end
end
context 'when authored by a user who is later deleted' do
before do
issue.update_attribute(:author_id, nil)
sign_in(user)
visit project_issue_path(project, issue)
end
it 'shows the issue' do
page.within('.issuable-details') do
expect(find('h2')).to have_content(issue.title)
end
end
end
end
......@@ -4,7 +4,10 @@ import '~/gl_dropdown';
import '~/lib/utils/common_utils';
import '~/lib/utils/url_utility';
(() => {
describe('glDropdown', function describeDropdown() {
preloadFixtures('static/gl_dropdown.html.raw');
loadJSONFixtures('projects.json');
const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-link';
const SEARCH_INPUT_SELECTOR = '.dropdown-input-field';
const ITEM_SELECTOR = `.dropdown-content li:not(${NON_SELECTABLE_CLASSES})`;
......@@ -39,187 +42,217 @@ import '~/lib/utils/url_utility';
remoteCallback = callback.bind({}, data);
};
describe('Dropdown', function describeDropdown() {
preloadFixtures('static/gl_dropdown.html.raw');
loadJSONFixtures('projects.json');
function initDropDown(hasRemote, isFilterable, extraOpts = {}) {
const options = Object.assign({
selectable: true,
filterable: isFilterable,
data: hasRemote ? remoteMock.bind({}, this.projectsData) : this.projectsData,
search: {
fields: ['name']
},
text: project => (project.name_with_namespace || project.name),
id: project => project.id,
}, extraOpts);
this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown(options);
}
function initDropDown(hasRemote, isFilterable, extraOpts = {}) {
const options = Object.assign({
selectable: true,
filterable: isFilterable,
data: hasRemote ? remoteMock.bind({}, this.projectsData) : this.projectsData,
search: {
fields: ['name']
},
text: project => (project.name_with_namespace || project.name),
id: project => project.id,
}, extraOpts);
this.dropdownButtonElement = $('#js-project-dropdown', this.dropdownContainerElement).glDropdown(options);
}
beforeEach(() => {
loadFixtures('static/gl_dropdown.html.raw');
this.dropdownContainerElement = $('.dropdown.inline');
this.$dropdownMenuElement = $('.dropdown-menu', this.dropdownContainerElement);
this.projectsData = getJSONFixture('projects.json');
});
beforeEach(() => {
loadFixtures('static/gl_dropdown.html.raw');
this.dropdownContainerElement = $('.dropdown.inline');
this.$dropdownMenuElement = $('.dropdown-menu', this.dropdownContainerElement);
this.projectsData = getJSONFixture('projects.json');
});
afterEach(() => {
$('body').unbind('keydown');
this.dropdownContainerElement.unbind('keyup');
});
afterEach(() => {
$('body').unbind('keydown');
this.dropdownContainerElement.unbind('keyup');
});
it('should open on click', () => {
initDropDown.call(this, false);
expect(this.dropdownContainerElement).not.toHaveClass('open');
this.dropdownButtonElement.click();
expect(this.dropdownContainerElement).toHaveClass('open');
});
it('should open on click', () => {
initDropDown.call(this, false);
expect(this.dropdownContainerElement).not.toHaveClass('open');
this.dropdownButtonElement.click();
expect(this.dropdownContainerElement).toHaveClass('open');
});
it('escapes HTML as text', () => {
this.projectsData[0].name_with_namespace = '<script>alert("testing");</script>';
it('escapes HTML as text', () => {
this.projectsData[0].name_with_namespace = '<script>alert("testing");</script>';
initDropDown.call(this, false);
initDropDown.call(this, false);
this.dropdownButtonElement.click();
this.dropdownButtonElement.click();
expect(
$('.dropdown-content li:first-child').text(),
).toBe('<script>alert("testing");</script>');
});
expect(
$('.dropdown-content li:first-child').text(),
).toBe('<script>alert("testing");</script>');
});
it('should output HTML when highlighting', () => {
this.projectsData[0].name_with_namespace = 'testing';
$('.dropdown-input .dropdown-input-field').val('test');
it('should output HTML when highlighting', () => {
this.projectsData[0].name_with_namespace = 'testing';
$('.dropdown-input .dropdown-input-field').val('test');
initDropDown.call(this, false, true, {
highlight: true,
});
initDropDown.call(this, false, true, {
highlight: true,
});
this.dropdownButtonElement.click();
this.dropdownButtonElement.click();
expect(
$('.dropdown-content li:first-child').text(),
).toBe('testing');
expect(
$('.dropdown-content li:first-child').text(),
).toBe('testing');
expect(
$('.dropdown-content li:first-child a').html(),
).toBe('<b>t</b><b>e</b><b>s</b><b>t</b>ing');
});
expect(
$('.dropdown-content li:first-child a').html(),
).toBe('<b>t</b><b>e</b><b>s</b><b>t</b>ing');
describe('that is open', () => {
beforeEach(() => {
initDropDown.call(this, false, false);
this.dropdownButtonElement.click();
});
describe('that is open', () => {
beforeEach(() => {
initDropDown.call(this, false, false);
this.dropdownButtonElement.click();
it('should select a following item on DOWN keypress', () => {
expect($(FOCUSED_ITEM_SELECTOR, this.$dropdownMenuElement).length).toBe(0);
const randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 1)) + 0);
navigateWithKeys('down', randomIndex, () => {
expect($(FOCUSED_ITEM_SELECTOR, this.$dropdownMenuElement).length).toBe(1);
expect($(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.$dropdownMenuElement)).toHaveClass('is-focused');
});
});
it('should select a following item on DOWN keypress', () => {
expect($(FOCUSED_ITEM_SELECTOR, this.$dropdownMenuElement).length).toBe(0);
const randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 1)) + 0);
navigateWithKeys('down', randomIndex, () => {
it('should select a previous item on UP keypress', () => {
expect($(FOCUSED_ITEM_SELECTOR, this.$dropdownMenuElement).length).toBe(0);
navigateWithKeys('down', (this.projectsData.length - 1), () => {
expect($(FOCUSED_ITEM_SELECTOR, this.$dropdownMenuElement).length).toBe(1);
const randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 2)) + 0);
navigateWithKeys('up', randomIndex, () => {
expect($(FOCUSED_ITEM_SELECTOR, this.$dropdownMenuElement).length).toBe(1);
expect($(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.$dropdownMenuElement)).toHaveClass('is-focused');
expect($(`${ITEM_SELECTOR}:eq(${((this.projectsData.length - 2) - randomIndex)}) a`, this.$dropdownMenuElement)).toHaveClass('is-focused');
});
});
});
it('should select a previous item on UP keypress', () => {
expect($(FOCUSED_ITEM_SELECTOR, this.$dropdownMenuElement).length).toBe(0);
navigateWithKeys('down', (this.projectsData.length - 1), () => {
expect($(FOCUSED_ITEM_SELECTOR, this.$dropdownMenuElement).length).toBe(1);
const randomIndex = (Math.floor(Math.random() * (this.projectsData.length - 2)) + 0);
navigateWithKeys('up', randomIndex, () => {
expect($(FOCUSED_ITEM_SELECTOR, this.$dropdownMenuElement).length).toBe(1);
expect($(`${ITEM_SELECTOR}:eq(${((this.projectsData.length - 2) - randomIndex)}) a`, this.$dropdownMenuElement)).toHaveClass('is-focused');
});
it('should click the selected item on ENTER keypress', () => {
expect(this.dropdownContainerElement).toHaveClass('open');
const randomIndex = Math.floor(Math.random() * (this.projectsData.length - 1)) + 0;
navigateWithKeys('down', randomIndex, () => {
spyOn(gl.utils, 'visitUrl').and.stub();
navigateWithKeys('enter', null, () => {
expect(this.dropdownContainerElement).not.toHaveClass('open');
const link = $(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.$dropdownMenuElement);
expect(link).toHaveClass('is-active');
const linkedLocation = link.attr('href');
if (linkedLocation && linkedLocation !== '#') expect(gl.utils.visitUrl).toHaveBeenCalledWith(linkedLocation);
});
});
});
it('should click the selected item on ENTER keypress', () => {
expect(this.dropdownContainerElement).toHaveClass('open');
const randomIndex = Math.floor(Math.random() * (this.projectsData.length - 1)) + 0;
navigateWithKeys('down', randomIndex, () => {
spyOn(gl.utils, 'visitUrl').and.stub();
navigateWithKeys('enter', null, () => {
expect(this.dropdownContainerElement).not.toHaveClass('open');
const link = $(`${ITEM_SELECTOR}:eq(${randomIndex}) a`, this.$dropdownMenuElement);
expect(link).toHaveClass('is-active');
const linkedLocation = link.attr('href');
if (linkedLocation && linkedLocation !== '#') expect(gl.utils.visitUrl).toHaveBeenCalledWith(linkedLocation);
});
});
it('should close on ESC keypress', () => {
expect(this.dropdownContainerElement).toHaveClass('open');
this.dropdownContainerElement.trigger({
type: 'keyup',
which: ARROW_KEYS.ESC,
keyCode: ARROW_KEYS.ESC
});
expect(this.dropdownContainerElement).not.toHaveClass('open');
});
});
it('should close on ESC keypress', () => {
expect(this.dropdownContainerElement).toHaveClass('open');
this.dropdownContainerElement.trigger({
type: 'keyup',
which: ARROW_KEYS.ESC,
keyCode: ARROW_KEYS.ESC
});
expect(this.dropdownContainerElement).not.toHaveClass('open');
describe('opened and waiting for a remote callback', () => {
beforeEach(() => {
initDropDown.call(this, true, true);
this.dropdownButtonElement.click();
});
it('should show loading indicator while search results are being fetched by backend', () => {
const dropdownMenu = document.querySelector('.dropdown-menu');
expect(dropdownMenu.className.indexOf('is-loading') !== -1).toEqual(true);
remoteCallback();
expect(dropdownMenu.className.indexOf('is-loading') !== -1).toEqual(false);
});
it('should not focus search input while remote task is not complete', () => {
expect($(document.activeElement)).not.toEqual($(SEARCH_INPUT_SELECTOR));
remoteCallback();
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
it('should focus search input after remote task is complete', () => {
remoteCallback();
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
it('should focus on input when opening for the second time after transition', () => {
remoteCallback();
this.dropdownContainerElement.trigger({
type: 'keyup',
which: ARROW_KEYS.ESC,
keyCode: ARROW_KEYS.ESC
});
this.dropdownButtonElement.click();
this.dropdownContainerElement.trigger('transitionend');
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
});
describe('input focus with array data', () => {
it('should focus input when passing array data to drop down', () => {
initDropDown.call(this, false, true);
this.dropdownButtonElement.click();
this.dropdownContainerElement.trigger('transitionend');
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
});
it('should still have input value on close and restore', () => {
const $searchInput = $(SEARCH_INPUT_SELECTOR);
initDropDown.call(this, false, true);
$searchInput
.trigger('focus')
.val('g')
.trigger('input');
expect($searchInput.val()).toEqual('g');
this.dropdownButtonElement.trigger('hidden.bs.dropdown');
$searchInput
.trigger('blur')
.trigger('focus');
expect($searchInput.val()).toEqual('g');
});
describe('renderItem', () => {
describe('without selected value', () => {
let dropdown;
describe('opened and waiting for a remote callback', () => {
beforeEach(() => {
initDropDown.call(this, true, true);
this.dropdownButtonElement.click();
const dropdownOptions = {
};
const $dropdownDiv = $('<div />');
$dropdownDiv.glDropdown(dropdownOptions);
dropdown = $dropdownDiv.data('glDropdown');
});
it('should show loading indicator while search results are being fetched by backend', () => {
const dropdownMenu = document.querySelector('.dropdown-menu');
it('marks items without ID as active', () => {
const dummyData = { };
expect(dropdownMenu.className.indexOf('is-loading') !== -1).toEqual(true);
remoteCallback();
expect(dropdownMenu.className.indexOf('is-loading') !== -1).toEqual(false);
});
const html = dropdown.renderItem(dummyData, null, null);
it('should not focus search input while remote task is not complete', () => {
expect($(document.activeElement)).not.toEqual($(SEARCH_INPUT_SELECTOR));
remoteCallback();
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
const link = html.querySelector('a');
expect(link).toHaveClass('is-active');
});
it('should focus search input after remote task is complete', () => {
remoteCallback();
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
it('does not mark items with ID as active', () => {
const dummyData = {
id: 'ea'
};
it('should focus on input when opening for the second time after transition', () => {
remoteCallback();
this.dropdownContainerElement.trigger({
type: 'keyup',
which: ARROW_KEYS.ESC,
keyCode: ARROW_KEYS.ESC
});
this.dropdownButtonElement.click();
this.dropdownContainerElement.trigger('transitionend');
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
});
});
const html = dropdown.renderItem(dummyData, null, null);
describe('input focus with array data', () => {
it('should focus input when passing array data to drop down', () => {
initDropDown.call(this, false, true);
this.dropdownButtonElement.click();
this.dropdownContainerElement.trigger('transitionend');
expect($(document.activeElement)).toEqual($(SEARCH_INPUT_SELECTOR));
const link = html.querySelector('a');
expect(link).not.toHaveClass('is-active');
});
});
it('should still have input value on close and restore', () => {
const $searchInput = $(SEARCH_INPUT_SELECTOR);
initDropDown.call(this, false, true);
$searchInput
.trigger('focus')
.val('g')
.trigger('input');
expect($searchInput.val()).toEqual('g');
this.dropdownButtonElement.trigger('hidden.bs.dropdown');
$searchInput
.trigger('blur')
.trigger('focus');
expect($searchInput.val()).toEqual('g');
});
});
})();
});
import Vue from 'vue';
import GraphRow from '~/monitoring/components/graph_row.vue';
import MonitoringMixins from '~/monitoring/mixins/monitoring_mixins';
import { deploymentData, convertDatesMultipleSeries, singleRowMetricsMultipleSeries } from './mock_data';
const createComponent = (propsData) => {
const Component = Vue.extend(GraphRow);
return new Component({
propsData,
}).$mount();
};
const convertedMetrics = convertDatesMultipleSeries(singleRowMetricsMultipleSeries);
describe('GraphRow', () => {
beforeEach(() => {
spyOn(MonitoringMixins.methods, 'formatDeployments').and.returnValue({});
});
describe('Computed props', () => {
it('bootstrapClass is set to col-md-6 when rowData is higher/equal to 2', () => {
const component = createComponent({
rowData: convertedMetrics,
updateAspectRatio: false,
deploymentData,
});
expect(component.bootstrapClass).toEqual('col-md-6');
});
it('bootstrapClass is set to col-md-12 when rowData is lower than 2', () => {
const component = createComponent({
rowData: [convertedMetrics[0]],
updateAspectRatio: false,
deploymentData,
});
expect(component.bootstrapClass).toEqual('col-md-12');
});
});
it('has one column', () => {
const component = createComponent({
rowData: convertedMetrics,
updateAspectRatio: false,
deploymentData,
});
expect(component.$el.querySelectorAll('.prometheus-svg-container').length)
.toEqual(component.rowData.length);
});
it('has two columns', () => {
const component = createComponent({
rowData: convertedMetrics,
updateAspectRatio: false,
deploymentData,
});
expect(component.$el.querySelectorAll('.col-md-6').length)
.toEqual(component.rowData.length);
});
});
......@@ -5,10 +5,10 @@ describe('MonitoringStore', () => {
this.store = new MonitoringStore();
this.store.storeMetrics(MonitoringMock.data);
it('contains one group that contains two queries sorted by priority in one row', () => {
it('contains one group that contains two queries sorted by priority', () => {
expect(this.store.groups).toBeDefined();
expect(this.store.groups.length).toEqual(1);
expect(this.store.groups[0].metrics.length).toEqual(1);
expect(this.store.groups[0].metrics.length).toEqual(2);
});
it('gets the metrics count for every group', () => {
......
......@@ -916,27 +916,37 @@ describe Gitlab::Git::Repository, seed_helper: true do
end
describe '#find_branch' do
it 'should return a Branch for master' do
branch = repository.find_branch('master')
shared_examples 'finding a branch' do
it 'should return a Branch for master' do
branch = repository.find_branch('master')
expect(branch).to be_a_kind_of(Gitlab::Git::Branch)
expect(branch.name).to eq('master')
end
expect(branch).to be_a_kind_of(Gitlab::Git::Branch)
expect(branch.name).to eq('master')
end
it 'should handle non-existent branch' do
branch = repository.find_branch('this-is-garbage')
it 'should handle non-existent branch' do
branch = repository.find_branch('this-is-garbage')
expect(branch).to eq(nil)
expect(branch).to eq(nil)
end
end
it 'should reload Rugged::Repository and return master' do
expect(Rugged::Repository).to receive(:new).twice.and_call_original
context 'when Gitaly find_branch feature is enabled' do
it_behaves_like 'finding a branch'
end
repository.find_branch('master')
branch = repository.find_branch('master', force_reload: true)
context 'when Gitaly find_branch feature is disabled', skip_gitaly_mock: true do
it_behaves_like 'finding a branch'
expect(branch).to be_a_kind_of(Gitlab::Git::Branch)
expect(branch.name).to eq('master')
it 'should reload Rugged::Repository and return master' do
expect(Rugged::Repository).to receive(:new).twice.and_call_original
repository.find_branch('master')
branch = repository.find_branch('master', force_reload: true)
expect(branch).to be_a_kind_of(Gitlab::Git::Branch)
expect(branch.name).to eq('master')
end
end
end
......
......@@ -264,6 +264,7 @@ project:
- statistics
- container_repositories
- uploads
- members_and_requesters
award_emoji:
- awardable
- user
......
......@@ -52,4 +52,124 @@ describe Gitlab::SQL::Pattern do
end
end
end
describe '.select_fuzzy_words' do
subject(:select_fuzzy_words) { Issue.select_fuzzy_words(query) }
context 'with a word equal to 3 chars' do
let(:query) { 'foo' }
it 'returns array cotaining a word' do
expect(select_fuzzy_words).to match_array(['foo'])
end
end
context 'with a word shorter than 3 chars' do
let(:query) { 'fo' }
it 'returns empty array' do
expect(select_fuzzy_words).to match_array([])
end
end
context 'with two words both equal to 3 chars' do
let(:query) { 'foo baz' }
it 'returns array containing two words' do
expect(select_fuzzy_words).to match_array(%w[foo baz])
end
end
context 'with two words divided by two spaces both equal to 3 chars' do
let(:query) { 'foo baz' }
it 'returns array containing two words' do
expect(select_fuzzy_words).to match_array(%w[foo baz])
end
end
context 'with two words equal to 3 chars and shorter than 3 chars' do
let(:query) { 'foo ba' }
it 'returns array containing a word' do
expect(select_fuzzy_words).to match_array(['foo'])
end
end
context 'with a multi-word surrounded by double quote' do
let(:query) { '"really bar"' }
it 'returns array containing a multi-word' do
expect(select_fuzzy_words).to match_array(['really bar'])
end
end
context 'with a multi-word surrounded by double quote and two words' do
let(:query) { 'foo "really bar" baz' }
it 'returns array containing a multi-word and tow words' do
expect(select_fuzzy_words).to match_array(['foo', 'really bar', 'baz'])
end
end
context 'with a multi-word surrounded by double quote missing a spece before the first double quote' do
let(:query) { 'foo"really bar"' }
it 'returns array containing two words with double quote' do
expect(select_fuzzy_words).to match_array(['foo"really', 'bar"'])
end
end
context 'with a multi-word surrounded by double quote missing a spece after the second double quote' do
let(:query) { '"really bar"baz' }
it 'returns array containing two words with double quote' do
expect(select_fuzzy_words).to match_array(['"really', 'bar"baz'])
end
end
context 'with two multi-word surrounded by double quote and two words' do
let(:query) { 'foo "really bar" baz "awesome feature"' }
it 'returns array containing two multi-words and tow words' do
expect(select_fuzzy_words).to match_array(['foo', 'really bar', 'baz', 'awesome feature'])
end
end
end
describe '.to_fuzzy_arel' do
subject(:to_fuzzy_arel) { Issue.to_fuzzy_arel(:title, query) }
context 'with a word equal to 3 chars' do
let(:query) { 'foo' }
it 'returns a single ILIKE condition' do
expect(to_fuzzy_arel.to_sql).to match(/title.*I?LIKE '\%foo\%'/)
end
end
context 'with a word shorter than 3 chars' do
let(:query) { 'fo' }
it 'returns nil' do
expect(to_fuzzy_arel).to be_nil
end
end
context 'with two words both equal to 3 chars' do
let(:query) { 'foo baz' }
it 'returns a joining LIKE condition using a AND' do
expect(to_fuzzy_arel.to_sql).to match(/title.+I?LIKE '\%foo\%' AND .*title.*I?LIKE '\%baz\%'/)
end
end
context 'with a multi-word surrounded by double quote and two words' do
let(:query) { 'foo "really bar" baz' }
it 'returns a joining LIKE condition using a AND' do
expect(to_fuzzy_arel.to_sql).to match(/title.+I?LIKE '\%foo\%' AND .*title.*I?LIKE '\%baz\%' AND .*title.*I?LIKE '\%really bar\%'/)
end
end
end
end
......@@ -35,6 +35,20 @@ describe SystemCheck::SimpleExecutor do
end
end
class DynamicSkipCheck < SystemCheck::BaseCheck
set_name 'dynamic skip check'
set_skip_reason 'this is a skip reason'
def skip?
self.skip_reason = 'this is a dynamic skip reason'
true
end
def check?
raise 'should not execute this'
end
end
class MultiCheck < SystemCheck::BaseCheck
set_name 'multi check'
......@@ -127,6 +141,10 @@ describe SystemCheck::SimpleExecutor do
expect(subject.checks.size).to eq(1)
end
it 'errors out when passing multiple items' do
expect { subject << [SimpleCheck, OtherCheck] }.to raise_error(ArgumentError)
end
end
subject { described_class.new('Test') }
......@@ -205,10 +223,14 @@ describe SystemCheck::SimpleExecutor do
subject.run_check(SkipCheck)
end
it 'displays #skip_reason' do
it 'displays .skip_reason' do
expect { subject.run_check(SkipCheck) }.to output(/this is a skip reason/).to_stdout
end
it 'displays #skip_reason' do
expect { subject.run_check(DynamicSkipCheck) }.to output(/this is a dynamic skip reason/).to_stdout
end
it 'does not execute #check when #skip? is true' do
expect_any_instance_of(SkipCheck).not_to receive(:check?)
......
require 'spec_helper'
require Rails.root.join('db', 'migrate', '20170825104051_migrate_issues_to_ghost_user.rb')
describe MigrateIssuesToGhostUser, :migration do
describe '#up' do
let(:projects) { table(:projects) }
let(:issues) { table(:issues) }
let(:users) { table(:users) }
before do
projects.create!(name: 'gitlab')
user = users.create(email: 'test@example.com')
issues.create(title: 'Issue 1', author_id: nil, project_id: 1)
issues.create(title: 'Issue 2', author_id: user.id, project_id: 1)
end
context 'when ghost user exists' do
let!(:ghost) { users.create(ghost: true, email: 'ghost@example.com') }
it 'does not create a new user' do
expect { schema_migrate_up! }.not_to change { User.count }
end
it 'migrates issues where author = nil to the ghost user' do
schema_migrate_up!
expect(issues.first.reload.author_id).to eq(ghost.id)
end
it 'does not change issues authored by an existing user' do
expect { schema_migrate_up! }.not_to change { issues.second.reload.author_id}
end
end
context 'when ghost user does not exist' do
it 'creates a new user' do
expect { schema_migrate_up! }.to change { User.count }.by(1)
end
it 'migrates issues where author = nil to the ghost user' do
schema_migrate_up!
expect(issues.first.reload.author_id).to eq(User.ghost.id)
end
it 'does not change issues authored by an existing user' do
expect { schema_migrate_up! }.not_to change { issues.second.reload.author_id}
end
end
end
end
......@@ -66,56 +66,76 @@ describe Issuable do
end
describe ".search" do
let!(:searchable_issue) { create(:issue, title: "Searchable issue") }
let!(:searchable_issue) { create(:issue, title: "Searchable awesome issue") }
it 'returns notes with a matching title' do
it 'returns issues with a matching title' do
expect(issuable_class.search(searchable_issue.title))
.to eq([searchable_issue])
end
it 'returns notes with a partially matching title' do
it 'returns issues with a partially matching title' do
expect(issuable_class.search('able')).to eq([searchable_issue])
end
it 'returns notes with a matching title regardless of the casing' do
it 'returns issues with a matching title regardless of the casing' do
expect(issuable_class.search(searchable_issue.title.upcase))
.to eq([searchable_issue])
end
it 'returns issues with a fuzzy matching title' do
expect(issuable_class.search('searchable issue')).to eq([searchable_issue])
end
it 'returns all issues with a query shorter than 3 chars' do
expect(issuable_class.search('zz')).to eq(issuable_class.all)
end
end
describe ".full_search" do
let!(:searchable_issue) do
create(:issue, title: "Searchable issue", description: 'kittens')
create(:issue, title: "Searchable awesome issue", description: 'Many cute kittens')
end
it 'returns notes with a matching title' do
it 'returns issues with a matching title' do
expect(issuable_class.full_search(searchable_issue.title))
.to eq([searchable_issue])
end
it 'returns notes with a partially matching title' do
it 'returns issues with a partially matching title' do
expect(issuable_class.full_search('able')).to eq([searchable_issue])
end
it 'returns notes with a matching title regardless of the casing' do
it 'returns issues with a matching title regardless of the casing' do
expect(issuable_class.full_search(searchable_issue.title.upcase))
.to eq([searchable_issue])
end
it 'returns notes with a matching description' do
it 'returns issues with a fuzzy matching title' do
expect(issuable_class.full_search('searchable issue')).to eq([searchable_issue])
end
it 'returns issues with a matching description' do
expect(issuable_class.full_search(searchable_issue.description))
.to eq([searchable_issue])
end
it 'returns notes with a partially matching description' do
it 'returns issues with a partially matching description' do
expect(issuable_class.full_search(searchable_issue.description))
.to eq([searchable_issue])
end
it 'returns notes with a matching description regardless of the casing' do
it 'returns issues with a matching description regardless of the casing' do
expect(issuable_class.full_search(searchable_issue.description.upcase))
.to eq([searchable_issue])
end
it 'returns issues with a fuzzy matching description' do
expect(issuable_class.full_search('many kittens')).to eq([searchable_issue])
end
it 'returns all issues with a query shorter than 3 chars' do
expect(issuable_class.search('zz')).to eq(issuable_class.all)
end
end
describe '.to_ability_name' do
......
......@@ -9,6 +9,7 @@ describe Group do
it { is_expected.to have_many(:users).through(:group_members) }
it { is_expected.to have_many(:owners).through(:group_members) }
it { is_expected.to have_many(:requesters).dependent(:destroy) }
it { is_expected.to have_many(:members_and_requesters) }
it { is_expected.to have_many(:project_group_links).dependent(:destroy) }
it { is_expected.to have_many(:shared_projects).through(:project_group_links) }
it { is_expected.to have_many(:notification_settings).dependent(:destroy) }
......@@ -25,22 +26,8 @@ describe Group do
group.add_developer(developer)
end
describe '#members' do
it 'includes members and exclude requesters' do
member_user_ids = group.members.pluck(:user_id)
expect(member_user_ids).to include(developer.id)
expect(member_user_ids).not_to include(requester.id)
end
end
describe '#requesters' do
it 'does not include requesters' do
requester_user_ids = group.requesters.pluck(:user_id)
expect(requester_user_ids).to include(requester.id)
expect(requester_user_ids).not_to include(developer.id)
end
it_behaves_like 'members and requesters associations' do
let(:namespace) { group }
end
end
end
......
......@@ -409,6 +409,15 @@ describe Member do
expect(members).to be_a Array
expect(members).to be_empty
end
it 'supports differents formats' do
list = ['joe@local.test', admin, user1.id, user2.id.to_s]
members = described_class.add_users(source, list, :master)
expect(members.size).to eq(4)
expect(members.first).to be_invite
end
end
end
end
......
......@@ -74,6 +74,7 @@ describe Project do
it { is_expected.to have_many(:forks).through(:forked_project_links) }
it { is_expected.to have_many(:uploads).dependent(:destroy) }
it { is_expected.to have_many(:pipeline_schedules) }
it { is_expected.to have_many(:members_and_requesters) }
context 'after initialized' do
it "has a project_feature" do
......@@ -90,22 +91,8 @@ describe Project do
project.team << [developer, :developer]
end
describe '#members' do
it 'includes members and exclude requesters' do
member_user_ids = project.members.pluck(:user_id)
expect(member_user_ids).to include(developer.id)
expect(member_user_ids).not_to include(requester.id)
end
end
describe '#requesters' do
it 'does not include requesters' do
requester_user_ids = project.requesters.pluck(:user_id)
expect(requester_user_ids).to include(requester.id)
expect(requester_user_ids).not_to include(developer.id)
end
it_behaves_like 'members and requesters associations' do
let(:namespace) { project }
end
end
......
......@@ -4,6 +4,7 @@ describe API::Users do
let(:user) { create(:user) }
let(:admin) { create(:admin) }
let(:key) { create(:key, user: user) }
let(:gpg_key) { create(:gpg_key, user: user) }
let(:email) { create(:email, user: user) }
let(:omniauth_user) { create(:omniauth_user) }
let(:ldap_user) { create(:omniauth_user, provider: 'ldapmain') }
......@@ -753,6 +754,164 @@ describe API::Users do
end
end
describe 'POST /users/:id/keys' do
before do
admin
end
it 'does not create invalid GPG key' do
post api("/users/#{user.id}/gpg_keys", admin)
expect(response).to have_http_status(400)
expect(json_response['error']).to eq('key is missing')
end
it 'creates GPG key' do
key_attrs = attributes_for :gpg_key
expect do
post api("/users/#{user.id}/gpg_keys", admin), key_attrs
expect(response).to have_http_status(201)
end.to change { user.gpg_keys.count }.by(1)
end
it 'returns 400 for invalid ID' do
post api('/users/999999/gpg_keys', admin)
expect(response).to have_http_status(400)
end
end
describe 'GET /user/:id/gpg_keys' do
before do
admin
end
context 'when unauthenticated' do
it 'returns authentication error' do
get api("/users/#{user.id}/gpg_keys")
expect(response).to have_http_status(401)
end
end
context 'when authenticated' do
it 'returns 404 for non-existing user' do
get api('/users/999999/gpg_keys', admin)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns 404 error if key not foud' do
delete api("/users/#{user.id}/gpg_keys/42", admin)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 GPG Key Not Found')
end
it 'returns array of GPG keys' do
user.gpg_keys << gpg_key
user.save
get api("/users/#{user.id}/gpg_keys", admin)
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.first['key']).to eq(gpg_key.key)
end
end
end
describe 'DELETE /user/:id/gpg_keys/:key_id' do
before do
admin
end
context 'when unauthenticated' do
it 'returns authentication error' do
delete api("/users/#{user.id}/keys/42")
expect(response).to have_http_status(401)
end
end
context 'when authenticated' do
it 'deletes existing key' do
user.gpg_keys << gpg_key
user.save
expect do
delete api("/users/#{user.id}/gpg_keys/#{gpg_key.id}", admin)
expect(response).to have_http_status(204)
end.to change { user.gpg_keys.count }.by(-1)
end
it 'returns 404 error if user not found' do
user.keys << key
user.save
delete api("/users/999999/gpg_keys/#{gpg_key.id}", admin)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns 404 error if key not foud' do
delete api("/users/#{user.id}/gpg_keys/42", admin)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 GPG Key Not Found')
end
end
end
describe 'POST /user/:id/gpg_keys/:key_id/revoke' do
before do
admin
end
context 'when unauthenticated' do
it 'returns authentication error' do
post api("/users/#{user.id}/gpg_keys/42/revoke")
expect(response).to have_http_status(401)
end
end
context 'when authenticated' do
it 'revokes existing key' do
user.gpg_keys << gpg_key
user.save
expect do
post api("/users/#{user.id}/gpg_keys/#{gpg_key.id}/revoke", admin)
expect(response).to have_http_status(:accepted)
end.to change { user.gpg_keys.count }.by(-1)
end
it 'returns 404 error if user not found' do
user.gpg_keys << gpg_key
user.save
post api("/users/999999/gpg_keys/#{gpg_key.id}/revoke", admin)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 User Not Found')
end
it 'returns 404 error if key not foud' do
post api("/users/#{user.id}/gpg_keys/42/revoke", admin)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 GPG Key Not Found')
end
end
end
describe "POST /users/:id/emails" do
before do
admin
......@@ -1153,6 +1312,173 @@ describe API::Users do
end
end
describe 'GET /user/gpg_keys' do
context 'when unauthenticated' do
it 'returns authentication error' do
get api('/user/gpg_keys')
expect(response).to have_http_status(401)
end
end
context 'when authenticated' do
it 'returns array of GPG keys' do
user.gpg_keys << gpg_key
user.save
get api('/user/gpg_keys', user)
expect(response).to have_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.first['key']).to eq(gpg_key.key)
end
context 'scopes' do
let(:path) { '/user/gpg_keys' }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
end
end
end
describe 'GET /user/gpg_keys/:key_id' do
it 'returns a single key' do
user.gpg_keys << gpg_key
user.save
get api("/user/gpg_keys/#{gpg_key.id}", user)
expect(response).to have_http_status(200)
expect(json_response['key']).to eq(gpg_key.key)
end
it 'returns 404 Not Found within invalid ID' do
get api('/user/gpg_keys/42', user)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 GPG Key Not Found')
end
it "returns 404 error if admin accesses user's GPG key" do
user.gpg_keys << gpg_key
user.save
get api("/user/gpg_keys/#{gpg_key.id}", admin)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 GPG Key Not Found')
end
it 'returns 404 for invalid ID' do
get api('/users/gpg_keys/ASDF', admin)
expect(response).to have_http_status(404)
end
context 'scopes' do
let(:path) { "/user/gpg_keys/#{gpg_key.id}" }
let(:api_call) { method(:api) }
include_examples 'allows the "read_user" scope'
end
end
describe 'POST /user/gpg_keys' do
it 'creates a GPG key' do
key_attrs = attributes_for :gpg_key
expect do
post api('/user/gpg_keys', user), key_attrs
expect(response).to have_http_status(201)
end.to change { user.gpg_keys.count }.by(1)
end
it 'returns a 401 error if unauthorized' do
post api('/user/gpg_keys'), key: 'some key'
expect(response).to have_http_status(401)
end
it 'does not create GPG key without key' do
post api('/user/gpg_keys', user)
expect(response).to have_http_status(400)
expect(json_response['error']).to eq('key is missing')
end
end
describe 'POST /user/gpg_keys/:key_id/revoke' do
it 'revokes existing GPG key' do
user.gpg_keys << gpg_key
user.save
expect do
post api("/user/gpg_keys/#{gpg_key.id}/revoke", user)
expect(response).to have_http_status(:accepted)
end.to change { user.gpg_keys.count}.by(-1)
end
it 'returns 404 if key ID not found' do
post api('/user/gpg_keys/42/revoke', user)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 GPG Key Not Found')
end
it 'returns 401 error if unauthorized' do
user.gpg_keys << gpg_key
user.save
post api("/user/gpg_keys/#{gpg_key.id}/revoke")
expect(response).to have_http_status(401)
end
it 'returns a 404 for invalid ID' do
post api('/users/gpg_keys/ASDF/revoke', admin)
expect(response).to have_http_status(404)
end
end
describe 'DELETE /user/gpg_keys/:key_id' do
it 'deletes existing GPG key' do
user.gpg_keys << gpg_key
user.save
expect do
delete api("/user/gpg_keys/#{gpg_key.id}", user)
expect(response).to have_http_status(204)
end.to change { user.gpg_keys.count}.by(-1)
end
it 'returns 404 if key ID not found' do
delete api('/user/gpg_keys/42', user)
expect(response).to have_http_status(404)
expect(json_response['message']).to eq('404 GPG Key Not Found')
end
it 'returns 401 error if unauthorized' do
user.gpg_keys << gpg_key
user.save
delete api("/user/gpg_keys/#{gpg_key.id}")
expect(response).to have_http_status(401)
end
it 'returns a 404 for invalid ID' do
delete api('/users/gpg_keys/ASDF', admin)
expect(response).to have_http_status(404)
end
end
describe "GET /user/emails" do
context "when unauthenticated" do
it "returns authentication error" do
......
RSpec.shared_examples 'members and requesters associations' do
describe '#members_and_requesters' do
it 'includes members and requesters' do
member_and_requester_user_ids = namespace.members_and_requesters.pluck(:user_id)
expect(member_and_requester_user_ids).to include(requester.id, developer.id)
end
end
describe '#members' do
it 'includes members and exclude requesters' do
member_user_ids = namespace.members.pluck(:user_id)
expect(member_user_ids).to include(developer.id)
expect(member_user_ids).not_to include(requester.id)
end
end
describe '#requesters' do
it 'does not include requesters' do
requester_user_ids = namespace.requesters.pluck(:user_id)
expect(requester_user_ids).to include(requester.id)
expect(requester_user_ids).not_to include(developer.id)
end
end
end
require 'spec_helper'
describe 'layouts/nav/_project' do
describe 'layouts/nav/sidebar/_project' do
describe 'container registry tab' do
before do
project = create(:project, :repository)
stub_container_registry_config(enabled: true)
assign(:project, create(:project, :repository))
assign(:project, project)
assign(:repository, project.repository)
allow(view).to receive(:current_ref).and_return('master')
allow(view).to receive(:can?).and_return(true)
......
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