Commit 2fc79403 authored by Jarka Kadlecova's avatar Jarka Kadlecova

Merge branch 'master' of gitlab.com:gitlab-org/gitlab-ee into 3551-epic-issues

# Conflicts:
#	ee/app/assets/javascripts/epics/epic_show/components/epic_show_app.vue
parents 8bf82c8d 7362ea9a
......@@ -478,6 +478,7 @@ db:migrate:reset-mysql:
stage: test
variables:
SETUP_DB: "false"
CREATE_DB_USER: "true"
script:
- git fetch https://gitlab.com/gitlab-org/gitlab-ee.git v9.3.0-ee
- git checkout -f FETCH_HEAD
......@@ -522,6 +523,7 @@ db:rollback-mysql:
variables:
SIZE: "1"
SETUP_DB: "false"
CREATE_DB_USER: "true"
script:
- git clone https://gitlab.com/gitlab-org/gitlab-test.git
/home/git/repositories/gitlab-org/gitlab-test.git
......@@ -557,7 +559,6 @@ gitlab:assets:compile:
NODE_ENV: "production"
RAILS_ENV: "production"
SETUP_DB: "false"
USE_DB: "false"
SKIP_STORAGE_VALIDATION: "true"
WEBPACK_REPORT: "true"
NO_COMPRESSION: "true"
......@@ -612,6 +613,16 @@ codequality:
artifacts:
paths: [codeclimate.json]
qa:internal:
stage: test
variables:
SETUP_DB: "false"
services: []
script:
- cd qa/
- bundle install
- bundle exec rspec
coverage:
<<: *dedicated-runner
<<: *except-docs
......
Please view this file on the master branch, on stable branches it's out of date.
## 10.1.3 (2017-11-10)
- [FIXED] Fix: Failed to rebase MR from forked repo.
## 10.1.2 (2017-11-08)
- [SECURITY] Fix vulnerability that could allow any user of a Geo instance to clone any repository on the secondary instance.
- [SECURITY] Geo JSON web tokens now expire after two minutes to reduce risk of compromise.
## 10.1.1 (2017-10-31)
- No changes.
- [FIXED] Fix LDAP group sync for nested groups e.g. when base has uppercase or extraneous spaces. !3217
- [FIXED] Geo: read-only safeguards was not working on Secondary node. !3227
- [FIXED] fix height of rebase and approve buttons.
......
......@@ -2,6 +2,28 @@
documentation](doc/development/changelog.md) for instructions on adding your own
entry.
## 10.1.3 (2017-11-10)
- [SECURITY] Prevent OAuth phishing attack by presenting detailed wording about app to user during authorization.
- [FIXED] Fix cancel button not working while uploading on the new issue page. !15137
- [FIXED] Fix webhooks recent deliveries. !15146 (Alexander Randa (@randaalex))
- [FIXED] Fix issues with forked projects of which the source was deleted. !15150
- [FIXED] Fix GPG signature popup info in Safari and Firefox. !15228
- [FIXED] Make sure group and project creation is blocked for new users that are external by default.
- [FIXED] Fix arguments Import/Export error importing project merge requests.
- [FIXED] Fix diff parser so it tolerates to diff special markers in the content.
- [FIXED] Fix a migration that adds merge_requests_ff_only_enabled column to MR table.
- [FIXED] Render 404 when polling commit notes without having permissions.
- [FIXED] Show error message when fast-forward merge is not possible.
- [FIXED] Avoid regenerating the ref path for the environment.
- [PERFORMANCE] Remove Filesystem check metrics that use too much CPU to handle requests.
## 10.1.2 (2017-11-08)
- [SECURITY] Add X-Content-Type-Options header in API responses to make it more difficult to find other vulnerabilities.
- [SECURITY] Properly translate IP addresses written in decimal, octal, or other formats in SSRF protections in project imports.
- [FIXED] Fix TRIGGER checks for MySQL.
## 10.1.1 (2017-10-31)
- [FIXED] Auto Devops kubernetes default namespace is now correctly built out of gitlab project group-name. !14642 (Mircea Danila Dumitrescu)
......
/* eslint-disable no-new*/
import './smart_interval';
import axios from 'axios';
import SmartInterval from '~/smart_interval';
import { parseSeconds, stringifyTime } from './lib/utils/pretty_time';
const healthyClass = 'geo-node-healthy';
......@@ -31,7 +32,7 @@ class GeoNodeStatus {
this.$advancedStatus = $('.js-advanced-geo-node-status-toggler', this.$status);
this.$advancedStatus.on('click', GeoNodeStatus.toggleShowAdvancedStatus);
this.statusInterval = new gl.SmartInterval({
this.statusInterval = new SmartInterval({
callback: this.getStatus.bind(this),
startingInterval: 30000,
maxInterval: 120000,
......@@ -59,78 +60,105 @@ class GeoNodeStatus {
static formatCount(count) {
if (count !== null) {
gl.text.addDelimiter(count);
return gl.text.addDelimiter(count);
}
return notAvailable;
}
getStatus() {
$.getJSON(this.endpoint, (status) => {
this.setStatusIcon(status.healthy);
this.setHealthStatus(status.healthy);
return axios.get(this.endpoint)
.then((response) => {
this.handleStatus(response.data);
return response;
})
.catch((err) => {
this.handleError(err);
});
}
handleStatus(status) {
this.setStatusIcon(status.healthy);
this.setHealthStatus(status.healthy);
// Replication lag can be nil if the secondary isn't actually streaming
if (status.db_replication_lag_seconds !== null && status.db_replication_lag_seconds >= 0) {
const parsedTime = parseSeconds(status.db_replication_lag_seconds, {
hoursPerDay: 24,
daysPerWeek: 7,
});
this.$dbReplicationLag.text(stringifyTime(parsedTime));
} else {
this.$dbReplicationLag.text('UNKNOWN');
}
const repoText = GeoNodeStatus.formatCountAndPercentage(
status.repositories_synced_count,
status.repositories_count,
status.repositories_synced_in_percentage);
const repoFailedText = GeoNodeStatus.formatCount(status.repositories_failed_count);
const lfsText = GeoNodeStatus.formatCountAndPercentage(
status.lfs_objects_synced_count,
status.lfs_objects_count,
status.lfs_objects_synced_in_percentage);
const lfsFailedText = GeoNodeStatus.formatCount(status.lfs_objects_failed_count);
const attachmentText = GeoNodeStatus.formatCountAndPercentage(
status.attachments_synced_count,
status.attachments_count,
status.attachments_synced_in_percentage);
const attachmentFailedText = GeoNodeStatus.formatCount(status.attachments_failed_count);
this.$repositoriesSynced.text(repoText);
this.$repositoriesFailed.text(repoFailedText);
this.$lfsObjectsSynced.text(lfsText);
this.$lfsObjectsFailed.text(lfsFailedText);
this.$attachmentsSynced.text(attachmentText);
this.$attachmentsFailed.text(attachmentFailedText);
let eventDate = notAvailable;
let cursorDate = notAvailable;
if (status.last_event_timestamp !== null && status.last_event_timestamp > 0) {
eventDate = gl.utils.formatDate(new Date(status.last_event_timestamp * 1000));
}
if (status.cursor_last_event_timestamp !== null && status.cursor_last_event_timestamp > 0) {
cursorDate = gl.utils.formatDate(new Date(status.cursor_last_event_timestamp * 1000));
}
this.$lastEventSeen.text(`${status.last_event_id} (${eventDate})`);
this.$lastCursorEvent.text(`${status.cursor_last_event_id} (${cursorDate})`);
if (status.health === 'Healthy') {
this.$health.text('');
} else {
const strippedData = $('<div>').html(`${status.health}`).text();
this.$health.html(`<code class="geo-health">${strippedData}</code>`);
}
this.$status.show();
});
if (status.db_replication_lag_seconds !== null && status.db_replication_lag_seconds >= 0) {
const parsedTime = parseSeconds(status.db_replication_lag_seconds, {
hoursPerDay: 24,
daysPerWeek: 7,
});
this.$dbReplicationLag.text(stringifyTime(parsedTime));
} else {
this.$dbReplicationLag.text('UNKNOWN');
}
const repoText = GeoNodeStatus.formatCountAndPercentage(
status.repositories_synced_count,
status.repositories_count,
status.repositories_synced_in_percentage);
const repoFailedText = GeoNodeStatus.formatCount(status.repositories_failed_count);
const lfsText = GeoNodeStatus.formatCountAndPercentage(
status.lfs_objects_synced_count,
status.lfs_objects_count,
status.lfs_objects_synced_in_percentage);
const lfsFailedText = GeoNodeStatus.formatCount(status.lfs_objects_failed_count);
const attachmentText = GeoNodeStatus.formatCountAndPercentage(
status.attachments_synced_count,
status.attachments_count,
status.attachments_synced_in_percentage);
const attachmentFailedText = GeoNodeStatus.formatCount(status.attachments_failed_count);
this.$repositoriesSynced.text(repoText);
this.$repositoriesFailed.text(repoFailedText);
this.$lfsObjectsSynced.text(lfsText);
this.$lfsObjectsFailed.text(lfsFailedText);
this.$attachmentsSynced.text(attachmentText);
this.$attachmentsFailed.text(attachmentFailedText);
let eventDate = notAvailable;
let cursorDate = notAvailable;
let lastEventSeen = notAvailable;
let lastCursorEvent = notAvailable;
if (status.last_event_timestamp !== null && status.last_event_timestamp > 0) {
eventDate = gl.utils.formatDate(new Date(status.last_event_timestamp * 1000));
}
if (status.cursor_last_event_timestamp !== null && status.cursor_last_event_timestamp > 0) {
cursorDate = gl.utils.formatDate(new Date(status.cursor_last_event_timestamp * 1000));
}
if (status.last_event_id !== null) {
lastEventSeen = `${status.last_event_id} (${eventDate})`;
}
if (status.cursor_last_event_id !== null) {
lastCursorEvent = `${status.cursor_last_event_id} (${cursorDate})`;
}
this.$lastEventSeen.text(lastEventSeen);
this.$lastCursorEvent.text(lastCursorEvent);
if (status.health === 'Healthy') {
this.$health.text('');
} else {
const strippedData = $('<div>').html(`${status.health}`).text();
this.$health.html(`<code class="geo-health">${strippedData}</code>`);
}
this.$status.show();
}
handleError(err) {
this.setStatusIcon(false);
this.setHealthStatus(false);
this.$health.html(`<code class="geo-health">${err}</code>`);
this.$status.show();
}
setStatusIcon(healthy) {
......
......@@ -331,7 +331,7 @@ GitLabDropdown = (function() {
if (_this.dropdown.find('.dropdown-toggle-page').length) {
selector = ".dropdown-page-one " + selector;
}
return $(selector);
return $(selector, this.instance.dropdown);
};
})(this),
data: (function(_this) {
......
......@@ -135,7 +135,6 @@ window.dateFormat = dateFormat;
* @param {Number} seconds
* @return {String}
*/
// eslint-disable-next-line import/prefer-default-export
export function timeIntervalInWords(intervalInSeconds) {
const secondsInteger = parseInt(intervalInSeconds, 10);
const minutes = Math.floor(secondsInteger / 60);
......@@ -149,3 +148,17 @@ export function timeIntervalInWords(intervalInSeconds) {
}
return text;
}
export function dateInWords(date, abbreviated = false) {
if (!date) return date;
const month = date.getMonth();
const year = date.getFullYear();
const monthNames = [s__('January'), s__('February'), s__('March'), s__('April'), s__('May'), s__('June'), s__('July'), s__('August'), s__('September'), s__('October'), s__('November'), s__('December')];
const monthNamesAbbr = [s__('Jan'), s__('Feb'), s__('Mar'), s__('Apr'), s__('May'), s__('Jun'), s__('Jul'), s__('Aug'), s__('Sep'), s__('Oct'), s__('Nov'), s__('Dec')];
const monthName = abbreviated ? monthNamesAbbr[month] : monthNames[month];
return `${monthName} ${date.getDate()}, ${year}`;
}
......@@ -24,6 +24,10 @@ export function highCountTrim(count) {
return count > 99 ? '99+' : count;
}
export function capitalizeFirstCharacter(text) {
return `${text[0].toUpperCase()}${text.slice(1)}`;
}
gl.text.randomString = function() {
return Math.random().toString(36).substring(7);
};
......
......@@ -66,8 +66,7 @@
<div class="ci-widget media">
<template v-if="hasCIError">
<div class="ci-status-icon ci-status-icon-failed ci-error js-ci-error append-right-10">
<icon
name="status_failed"/>
<icon name="status_failed" />
</div>
<div class="media-body">
Could not connect to the CI server. Please check your settings and try again
......@@ -86,7 +85,9 @@
class="pipeline-id">
#{{pipeline.id}}
</a>
{{pipeline.details.status.label}} for
<a
:href="pipeline.commit.commit_path"
class="commit-sha js-commit-link">
......
<script>
import Pikaday from 'pikaday';
import { parsePikadayDate, pikadayToString } from '../../lib/utils/datefix';
export default {
name: 'datePicker',
props: {
label: {
type: String,
required: false,
default: 'Date picker',
},
selectedDate: {
type: Date,
required: false,
},
minDate: {
type: Date,
required: false,
},
maxDate: {
type: Date,
required: false,
},
},
methods: {
selected(dateText) {
this.$emit('newDateSelected', this.calendar.toString(dateText));
},
toggled() {
this.$emit('hidePicker');
},
},
mounted() {
this.calendar = new Pikaday({
field: this.$el.querySelector('.dropdown-menu-toggle'),
theme: 'gitlab-theme animate-picker',
format: 'yyyy-mm-dd',
container: this.$el,
defaultDate: this.selectedDate,
setDefaultDate: !!this.selectedDate,
minDate: this.minDate,
maxDate: this.maxDate,
parse: dateString => parsePikadayDate(dateString),
toString: date => pikadayToString(date),
onSelect: this.selected.bind(this),
onClose: this.toggled.bind(this),
});
this.$el.append(this.calendar.el);
this.calendar.show();
},
beforeDestroy() {
this.calendar.destroy();
},
};
</script>
<template>
<div class="pikaday-container">
<div class="dropdown open">
<button
type="button"
class="dropdown-menu-toggle"
data-toggle="dropdown"
@click="toggled"
>
<span class="dropdown-toggle-text">
{{label}}
</span>
<i
class="fa fa-chevron-down"
aria-hidden="true"
>
</i>
</button>
</div>
</div>
</template>
<script>
export default {
name: 'collapsedCalendarIcon',
props: {
containerClass: {
type: String,
required: false,
default: '',
},
text: {
type: String,
required: false,
default: '',
},
showIcon: {
type: Boolean,
required: false,
default: true,
},
},
methods: {
click() {
this.$emit('click');
},
},
};
</script>
<template>
<div
:class="containerClass"
@click="click"
>
<i
v-if="showIcon"
class="fa fa-calendar"
aria-hidden="true"
>
</i>
<slot>
<span>
{{ text }}
</span>
</slot>
</div>
</template>
<script>
import { dateInWords } from '../../../lib/utils/datetime_utility';
import toggleSidebar from './toggle_sidebar.vue';
import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
export default {
name: 'sidebarCollapsedGroupedDatePicker',
props: {
collapsed: {
type: Boolean,
required: false,
default: true,
},
showToggleSidebar: {
type: Boolean,
required: false,
default: false,
},
minDate: {
type: Date,
required: false,
},
maxDate: {
type: Date,
required: false,
},
disableClickableIcons: {
type: Boolean,
required: false,
default: false,
},
},
components: {
toggleSidebar,
collapsedCalendarIcon,
},
computed: {
hasMinAndMaxDates() {
return this.minDate && this.maxDate;
},
hasNoMinAndMaxDates() {
return !this.minDate && !this.maxDate;
},
showMinDateBlock() {
return this.minDate || this.hasNoMinAndMaxDates;
},
showFromText() {
return !this.maxDate && this.minDate;
},
iconClass() {
const disabledClass = this.disableClickableIcons ? 'disabled' : '';
return `block sidebar-collapsed-icon calendar-icon ${disabledClass}`;
},
},
methods: {
toggleSidebar() {
this.$emit('toggleCollapse');
},
dateText(dateType = 'min') {
const date = this[`${dateType}Date`];
const dateWords = dateInWords(date, true);
const parsedDateWords = dateWords ? dateWords.replace(',', '') : dateWords;
return date ? parsedDateWords : 'None';
},
},
};
</script>
<template>
<div class="block sidebar-grouped-item">
<div
v-if="showToggleSidebar"
class="issuable-sidebar-header"
>
<toggle-sidebar
:collapsed="collapsed"
@toggle="toggleSidebar"
/>
</div>
<collapsed-calendar-icon
v-if="showMinDateBlock"
:container-class="iconClass"
@click="toggleSidebar"
>
<span class="sidebar-collapsed-value">
<span v-if="showFromText">From</span>
<span>{{ dateText('min') }}</span>
</span>
</collapsed-calendar-icon>
<div
v-if="hasMinAndMaxDates"
class="text-center sidebar-collapsed-divider"
>
-
</div>
<collapsed-calendar-icon
v-if="maxDate"
:container-class="iconClass"
:show-icon="!minDate"
@click="toggleSidebar"
>
<span class="sidebar-collapsed-value">
<span v-if="!minDate">Until</span>
<span>{{ dateText('max') }}</span>
</span>
</collapsed-calendar-icon>
</div>
</template>
<script>
import datePicker from '../pikaday.vue';
import loadingIcon from '../loading_icon.vue';
import toggleSidebar from './toggle_sidebar.vue';
import collapsedCalendarIcon from './collapsed_calendar_icon.vue';
import { dateInWords } from '../../../lib/utils/datetime_utility';
export default {
name: 'sidebarDatePicker',
props: {
collapsed: {
type: Boolean,
required: false,
default: true,
},
showToggleSidebar: {
type: Boolean,
required: false,
default: false,
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
editable: {
type: Boolean,
required: false,
default: false,
},
label: {
type: String,
required: false,
default: 'Date picker',
},
selectedDate: {
type: Date,
required: false,
},
minDate: {
type: Date,
required: false,
},
maxDate: {
type: Date,
required: false,
},
},
data() {
return {
editing: false,
};
},
components: {
datePicker,
toggleSidebar,
loadingIcon,
collapsedCalendarIcon,
},
computed: {
selectedAndEditable() {
return this.selectedDate && this.editable;
},
selectedDateWords() {
return dateInWords(this.selectedDate, true);
},
collapsedText() {
return this.selectedDateWords ? this.selectedDateWords : 'None';
},
},
methods: {
stopEditing() {
this.editing = false;
},
toggleDatePicker() {
this.editing = !this.editing;
},
newDateSelected(date = null) {
this.date = date;
this.editing = false;
this.$emit('saveDate', date);
},
toggleSidebar() {
this.$emit('toggleCollapse');
},
},
};
</script>
<template>
<div class="block">
<div class="issuable-sidebar-header">
<toggle-sidebar
:collapsed="collapsed"
@toggle="toggleSidebar"
/>
</div>
<collapsed-calendar-icon
class="sidebar-collapsed-icon"
:text="collapsedText"
/>
<div class="title">
{{ label }}
<loading-icon
v-if="isLoading"
:inline="true"
/>
<div class="pull-right">
<button
v-if="editable && !editing"
type="button"
class="btn-blank btn-link btn-primary-hover-link btn-sidebar-action"
@click="toggleDatePicker"
>
Edit
</button>
<toggle-sidebar
v-if="showToggleSidebar"
:collapsed="collapsed"
@toggle="toggleSidebar"
/>
</div>
</div>
<div class="value">
<date-picker
v-if="editing"
:selected-date="selectedDate"
:min-date="minDate"
:max-date="maxDate"
:label="label"
@newDateSelected="newDateSelected"
@hidePicker="stopEditing"
/>
<span
v-else
class="value-content"
>
<template v-if="selectedDate">
<strong>{{ selectedDateWords }}</strong>
<span
v-if="selectedAndEditable"
class="no-value"
>
-
<button
type="button"
class="btn-blank btn-link btn-secondary-hover-link"
@click="newDateSelected(null)"
>
remove
</button>
</span>
</template>
<span
v-else
class="no-value"
>
None
</span>
</span>
</div>
</div>
</template>
<script>
export default {
name: 'toggleSidebar',
props: {
collapsed: {
type: Boolean,
required: true,
},
},
methods: {
toggle() {
this.$emit('toggle');
},
},
};
</script>
<template>
<button
type="button"
class="btn btn-blank gutter-toggle btn-sidebar-action"
@click="toggle"
>
<i
aria-label="toggle collapse"
class="fa"
:class="{ 'fa-angle-double-right': !collapsed, 'fa-angle-double-left': collapsed }"
></i>
</button>
</template>
......@@ -412,6 +412,7 @@
padding: 0;
background: transparent;
border: 0;
border-radius: 0;
&:hover,
&:active,
......@@ -421,3 +422,25 @@
box-shadow: none;
}
}
.btn-link.btn-secondary-hover-link {
color: $gl-text-color-secondary;
&:hover,
&:active,
&:focus {
color: $gl-link-color;
text-decoration: none;
}
}
.btn-link.btn-primary-hover-link {
color: inherit;
&:hover,
&:active,
&:focus {
color: $gl-link-color;
text-decoration: none;
}
}
......@@ -43,11 +43,13 @@
}
.sidebar-collapsed-icon {
cursor: pointer;
.btn {
background-color: $gray-light;
}
&:not(.disabled) {
cursor: pointer;
}
}
}
......@@ -55,6 +57,10 @@
padding-right: 0;
z-index: 300;
.btn-sidebar-action {
display: inline-flex;
}
@media (min-width: $screen-sm-min) and (max-width: $screen-sm-max) {
&:not(.wiki-sidebar):not(.build-sidebar):not(.issuable-bulk-update-sidebar) .content-wrapper {
padding-right: $gutter_collapsed_width;
......@@ -136,3 +142,18 @@
.issuable-sidebar {
@include new-style-dropdown;
}
.pikaday-container {
.pika-single {
margin-top: 2px;
width: 250px;
}
.dropdown-menu-toggle {
line-height: 20px;
}
}
.sidebar-collapsed-icon .sidebar-collapsed-value {
font-size: 12px;
}
......@@ -284,10 +284,15 @@
font-weight: $gl-font-weight-normal;
}
.no-value {
.no-value,
.btn-secondary-hover-link {
color: $gl-text-color-secondary;
}
.btn-secondary-hover-link:hover {
color: $gl-link-color;
}
.sidebar-collapsed-icon {
display: none;
}
......@@ -353,7 +358,8 @@
.gutter-toggle {
width: 100%;
margin-left: 0;
padding-left: 25px;
padding-left: 0;
text-align: center;
}
.sidebar-collapsed-icon {
......@@ -367,7 +373,7 @@
fill: $issuable-sidebar-color;
}
&:hover,
&:hover:not(.disabled),
&:hover .todo-undone {
color: $gl-text-color;
......@@ -954,3 +960,21 @@
.add-issuable-form-actions {
margin-top: $gl-padding;
}
.right-sidebar-collapsed {
.sidebar-grouped-item {
.sidebar-collapsed-icon {
margin-bottom: 0;
}
.sidebar-collapsed-divider {
line-height: 5px;
font-size: 12px;
color: $theme-gray-700;
+ .sidebar-collapsed-icon {
padding-top: 0;
}
}
}
}
......@@ -781,10 +781,9 @@
.code-quality-container {
border-top: 1px solid $gray-darker;
border-bottom: 1px solid $gray-darker;
padding: $gl-padding-top;
background-color: $gray-light;
margin: 4px -16px 0;
margin: $gl-padding -16px -16px;
.mr-widget-code-quality-list {
list-style: none;
......
......@@ -11,7 +11,8 @@ module NavHelper
if current_path?('merge_requests#show') ||
current_path?('projects/merge_requests/conflicts#show') ||
current_path?('issues#show') ||
current_path?('milestones#show')
current_path?('milestones#show') ||
current_path?('epics#show')
if cookies[:collapsed_gutter] == 'true'
%w[page-gutter right-sidebar-collapsed]
else
......
......@@ -19,11 +19,10 @@ module Geo
end
def execute
project = Project.find(project_id)
project.expire_caches_before_rename(old_disk_path)
if migrating_from_legacy_storage?(project)
Geo::MoveRepositoryService.new(project, old_disk_path, new_disk_path).execute
if migrating_from_legacy_storage? && !move_repository
raise RepositoryCannotBeRenamed, "Repository #{old_disk_path} could not be renamed to #{new_disk_path}"
end
true
......@@ -31,12 +30,20 @@ module Geo
private
def migrating_from_legacy_storage?(project)
def project
@project ||= Project.find(project_id)
end
def migrating_from_legacy_storage?
from_legacy_storage? && project.hashed_storage?(:repository)
end
def from_legacy_storage?
old_storage_version.nil? || old_storage_version.zero?
end
def move_repository
Geo::MoveRepositoryService.new(project, old_disk_path, new_disk_path).execute
end
end
end
module Geo
RepositoryCannotBeRenamed = Class.new(StandardError)
class MoveRepositoryService
include Gitlab::ShellAdapter
include Gitlab::Geo::ProjectLogHelpers
attr_reader :project, :old_disk_path, :new_disk_path
......@@ -11,29 +14,21 @@ module Geo
end
def execute
# Make sure target directory exists (used when transfering repositories)
project.ensure_storage_path_exists
move_project_repository && move_wiki_repository
rescue
log_error('Repository cannot be renamed')
false
end
if gitlab_shell.mv_repository(project.repository_storage_path,
old_disk_path, new_disk_path)
# If repository moved successfully we need to send update instructions to users.
# However we cannot allow rollback since we moved repository
# So we basically we mute exceptions in next actions
begin
gitlab_shell.mv_repository(project.repository_storage_path,
"#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
rescue
# Returning false does not rollback after_* transaction but gives
# us information about failing some of tasks
false
end
else
# if we cannot move namespace directory we should rollback
# db changes in order to prevent out of sync between db and fs
raise StandardError.new('Repository cannot be renamed')
end
private
def move_project_repository
gitlab_shell.mv_repository(project.repository_storage_path, old_disk_path, new_disk_path)
end
true
def move_wiki_repository
gitlab_shell.mv_repository(project.repository_storage_path, "#{old_disk_path}.wiki", "#{new_disk_path}.wiki")
end
end
end
......@@ -13,11 +13,22 @@ module Geo
end
def execute
project = Project.find(project_id)
project.expire_caches_before_rename(old_disk_path)
return true if project.hashed_storage?(:repository)
if project.legacy_storage? && !move_repository
raise RepositoryCannotBeRenamed, "Repository #{old_disk_path} could not be renamed to #{new_disk_path}"
end
true
end
private
def project
@project ||= Project.find(project_id)
end
def move_repository
Geo::MoveRepositoryService.new(project, old_disk_path, new_disk_path).execute
end
end
......
......@@ -6,6 +6,8 @@ module MergeRequests
# Executed when you do merge via GitLab UI
#
class MergeService < MergeRequests::BaseService
prepend EE::MergeRequests::MergeService
MergeError = Class.new(StandardError)
attr_reader :merge_request, :source
......@@ -18,17 +20,7 @@ module MergeRequests
@merge_request = merge_request
unless @merge_request.mergeable?
return handle_merge_error(log_message: 'Merge request is not mergeable', save_message_on_model: true)
end
check_size_limit
@source = find_merge_source
unless @source
return handle_merge_error(log_message: 'No source for merge', save_message_on_model: true)
end
error_check!
merge_request.in_locked_state do
if commit
......@@ -65,6 +57,19 @@ module MergeRequests
private
def error_check!
error =
if @merge_request.should_be_rebased?
'Only fast-forward merge is allowed for your project. Please update your source branch'
elsif !@merge_request.mergeable?
'Merge request is not mergeable'
elsif !source
'No source for merge'
end
raise MergeError, error if error
end
def commit
message = params[:commit_message] || merge_request.merge_commit_message
......@@ -115,25 +120,8 @@ module MergeRequests
merge_request.to_reference(full: true)
end
def check_size_limit
if @merge_request.target_project.above_size_limit?
message = Gitlab::RepositorySizeError.new(@merge_request.target_project).merge_error
raise MergeError, message
end
end
def find_merge_source
return merge_request.diff_head_sha unless merge_request.squash
squash_result = SquashService.new(project, current_user, params).execute(merge_request)
case squash_result[:status]
when :success
squash_result[:squash_sha]
when :error
raise MergeError, squash_result[:message]
end
def source
@source ||= @merge_request.diff_head_sha
end
end
end
......@@ -27,7 +27,7 @@
Geo nodes (#{@nodes.count})
%ul.well-list.geo-nodes
- @nodes.each do |node|
%li{ id: dom_id(node), class: node_class(node), data: { status_url: status_admin_geo_node_path(node) } }
%li{ id: dom_id(node), class: node_class(node), data: { status_url: status_admin_geo_node_path(node, format: :json) } }
.node-block
= node_status_icon(node)
%strong= node.url
......
......@@ -61,7 +61,7 @@
= link_to "Help", help_path
%li.divider
%li
= link_to "Sign out", destroy_user_session_path, method: :delete, class: "sign-out-link"
= link_to "Sign out", destroy_user_session_path, 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
......
......@@ -2,7 +2,7 @@
.create_access_levels-container
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-create wide',
dropdown_class: 'dropdown-menu-selectable',
dropdown_class: 'dropdown-menu-selectable capitalize-header',
data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes' }})
= render 'projects/protected_tags/shared/create_protected_tag'
......@@ -33,7 +33,7 @@
= s_('TagsPage|Optionally, add a message to the tag.')
%hr
.form-group
= label_tag :release_description, 'Release notes', class: 'control-label'
= label_tag :release_description, s_('TagsPage|Release notes'), class: 'control-label'
.col-sm-10
= render layout: 'projects/md_preview', locals: { url: preview_markdown_path(@project), referenced_users: true } do
= render 'projects/zen', attr: :release_description, classes: 'note-textarea', placeholder: s_('TagsPage|Write your release notes or drag files here...'), current_text: @release_description
......@@ -41,6 +41,6 @@
.help-block
= s_('TagsPage|Optionally, add release notes to the tag. They will be stored in the GitLab database and displayed on the tags page.')
.form-actions
= button_tag 'Create tag', class: 'btn btn-create', tabindex: 3
= link_to 'Cancel', project_tags_path(@project), class: 'btn btn-cancel'
= button_tag s_('TagsPage|Create tag'), class: 'btn btn-create', tabindex: 3
= link_to s_('TagsPage|Cancel'), project_tags_path(@project), class: 'btn btn-cancel'
%script#availableRefs{ type: "application/json" }= @project.repository.ref_names.to_json.html_safe
......@@ -2,10 +2,6 @@ class UpdateMergeRequestsWorker
include Sidekiq::Worker
include DedicatedSidekiqQueue
def metrics_tags
@metrics_tags || {}
end
def perform(project_id, user_id, oldrev, newrev, ref)
project = Project.find_by(id: project_id)
return unless project
......@@ -13,11 +9,6 @@ class UpdateMergeRequestsWorker
user = User.find_by(id: user_id)
return unless user
@metrics_tags = {
project_id: project_id,
user_id: user_id
}
MergeRequests::RefreshService.new(project, user).execute(oldrev, newrev, ref)
end
end
---
title: Fix TRIGGER checks for MySQL
title: Add sidebar for epic
merge_request:
author:
type: fixed
type: added
---
title: 'Fix: Failed to rebase MR from forked repo'
title: 'Geo: Fix handling of nil values on advanced section in admin screen'
merge_request:
author:
type: fixed
---
title: Fix arguments Import/Export error importing project merge requests
title: Geo - Allow Sidekiq to retry failed jobs to rename project repositories
merge_request:
author:
type: fixed
---
title: Remove update merge request worker tagging.
merge_request:
author:
type: removed
---
title: Fix GPG signature popup info in Safari and Firefox
merge_request: 15228
author:
type: fixed
---
title: Moves mini graph of pipeline to the end of sentence in MR widget. Cleans HTML
and tests
merge_request:
author:
type: fixed
---
title: Fix webhooks recent deliveries
merge_request: 15146
author: Alexander Randa (@randaalex)
type: fixed
---
title: Fix issues with forked projects of which the source was deleted
merge_request: 15150
author:
type: fixed
---
title: Make sure group and project creation is blocked for new users that are external
by default
merge_request:
author:
type: fixed
---
title: Change 'Sign Out' route from a DELETE to a GET
merge_request: 39708
author: Joe Marty
type: changed
---
title: Remove Filesystem check metrics that use too much CPU to handle requests
title: Speed up issues list APIs
merge_request:
author:
type: performance
---
title: Fix diff parser so it tolerates to diff special markers in the content
merge_request:
author:
type: fixed
---
title: Fix a migration that adds merge_requests_ff_only_enabled column to MR table
merge_request:
author:
type: fixed
---
title: Render 404 when polling commit notes without having permissions
merge_request:
author:
type: fixed
---
title: Fix cancel button not working while uploading on the new issue page
merge_request: 15137
author:
type: fixed
---
title: Avoid regenerating the ref path for the environment
merge_request:
author:
type: fixed
---
title: Add /groups/:id/subgroups endpoint to API
merge_request: 15142
author: marbemac
type: added
......@@ -195,7 +195,7 @@ Devise.setup do |config|
config.navigational_formats = [:"*/*", "*/*", :html, :zip]
# The default HTTP method used to sign out a resource. Default is :delete.
config.sign_out_via = :delete
config.sign_out_via = :get
# ==> OmniAuth
# To configure a new OmniAuth provider copy and edit omniauth.rb.sample
......
......@@ -9,7 +9,7 @@ mapping structure from the projects URLs:
* Project's repository: `#{namespace}/#{project_name}.git`
* Project's wiki: `#{namespace}/#{project_name}.wiki.git`
This structure made simple to migrate from existing solutions to GitLab and easy for Administrators to find where the
repository is stored.
......@@ -27,7 +27,7 @@ of load in big installations, and can be even worst if they are using any type o
Last, for GitLab Geo, this storage type means we have to synchronize the disk state, replicate renames in the correct
order or we may end-up with wrong repository or missing data temporarily.
This pattern also exists in other objects stored in GitLab, like issue Attachments, GitLab Pages artifacts,
This pattern also exists in other objects stored in GitLab, like issue Attachments, GitLab Pages artifacts,
Docker Containers for the integrated Registry, etc.
## Hashed Storage
......@@ -62,9 +62,9 @@ you will never mistakenly restore a repository in the wrong project (considering
### How to migrate to Hashed Storage
In GitLab, go to **Admin > Settings**, find the **Repository Storage** section and select
In GitLab, go to **Admin > Settings**, find the **Repository Storage** section and select
"_Create new projects using hashed storage paths_".
To migrate your existing projects to the new storage type, check the specific [rake tasks].
[ce-28283]: https://gitlab.com/gitlab-org/gitlab-ce/issues/28283
......@@ -79,14 +79,14 @@ coverage status below.
Note that things stored in an S3 compatible endpoint will not have the downsides mentioned earlier, if they are not
prefixed with `#{namespace}/#{project_name}`, which is true for CI Cache and LFS Objects.
| Storable Object | Legacy Storage | Hashed Storage | S3 Compatible | GitLab Version |
| ----------------| -------------- | -------------- | ------------- | -------------- |
| Storable Object | Legacy Storage | Hashed Storage | S3 Compatible | GitLab Version |
| --------------- | -------------- | -------------- | ------------- | -------------- |
| Repository | Yes | Yes | - | 10.0 |
| Attachments | Yes | Yes | - | 10.2 |
| Avatars | Yes | No | - | - |
| Avatars | Yes | No | - | - |
| Pages | Yes | No | - | - |
| Docker Registry | Yes | No | - | - |
| CI Build Logs | No | No | - | - |
| CI Artifacts | No | No | - | - |
| CI Build Logs | No | No | - | - |
| CI Artifacts | No | No | Yes (EEP) | - |
| CI Cache | No | No | Yes | - |
| LFS Objects | Yes | No | Yes (EEP) | - |
| LFS Objects | Yes | No | Yes (EEP) | - |
......@@ -9,13 +9,13 @@ Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `skip_groups` | array of integers | no | Skip the group IDs passes |
| `all_available` | boolean | no | Show all the groups you have access to |
| `search` | string | no | Return list of authorized groups matching the search criteria |
| `skip_groups` | array of integers | no | Skip the group IDs passed |
| `all_available` | boolean | no | Show all the groups you have access to (defaults to `false` for authenticated users) |
| `search` | string | no | Return the list of authorized groups matching the search criteria |
| `order_by` | string | no | Order groups by `name` or `path`. Default is `name` |
| `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` |
| `statistics` | boolean | no | Include group statistics (admins only) |
| `owned` | boolean | no | Limit by groups owned by the current user |
| `owned` | boolean | no | Limit to groups owned by the current user |
```
GET /groups
......@@ -80,6 +80,47 @@ You can filter by [custom attributes](custom_attributes.md) with:
GET /groups?custom_attributes[key]=value&custom_attributes[other_key]=other_value
```
## List a groups's subgroups
Get a list of visible direct subgroups in this group.
When accessed without authentication, only public groups are returned.
Parameters:
| Attribute | Type | Required | Description |
| --------- | ---- | -------- | ----------- |
| `id` | integer/string | yes | The ID or [URL-encoded path of the group](README.md#namespaced-path-encoding) of the parent group |
| `skip_groups` | array of integers | no | Skip the group IDs passed |
| `all_available` | boolean | no | Show all the groups you have access to (defaults to `false` for authenticated users) |
| `search` | string | no | Return the list of authorized groups matching the search criteria |
| `order_by` | string | no | Order groups by `name` or `path`. Default is `name` |
| `sort` | string | no | Order groups in `asc` or `desc` order. Default is `asc` |
| `statistics` | boolean | no | Include group statistics (admins only) |
| `owned` | boolean | no | Limit to groups owned by the current user |
```
GET /groups/:id/subgroups
```
```json
[
{
"id": 1,
"name": "Foobar Group",
"path": "foo-bar",
"description": "An interesting group",
"visibility": "public",
"lfs_enabled": true,
"avatar_url": "http://gitlab.example.com/uploads/group/avatar/1/foo.jpg",
"web_url": "http://gitlab.example.com/groups/foo-bar",
"request_access_enabled": false,
"full_name": "Foobar Group",
"full_path": "foo-bar",
"parent_id": 123
}
]
```
## List a group's projects
Get a list of projects in this group. When accessed without authentication, only
......
......@@ -58,6 +58,8 @@ Parameters:
"project_id": 3,
"title": "test1",
"state": "opened",
"created_at": "2017-04-29T08:46:00Z",
"updated_at": "2017-04-29T08:46:00Z",
"upvotes": 0,
"downvotes": 0,
"author": {
......@@ -170,6 +172,8 @@ Parameters:
"project_id": 3,
"title": "test1",
"state": "opened",
"created_at": "2017-04-29T08:46:00Z",
"updated_at": "2017-04-29T08:46:00Z",
"upvotes": 0,
"downvotes": 0,
"author": {
......@@ -248,6 +252,8 @@ Parameters:
"project_id": 3,
"title": "test1",
"state": "merged",
"created_at": "2017-04-29T08:46:00Z",
"updated_at": "2017-04-29T08:46:00Z",
"upvotes": 0,
"downvotes": 0,
"author": {
......
# File Storage in GitLab
We use the [CarrierWave] gem to handle file upload, store and retrieval.
There are many places where file uploading is used, according to contexts:
* System
- Instance Logo (logo visible in sign in/sign up pages)
- Header Logo (one displayed in the navigation bar)
* Group
- Group avatars
* User
- User avatars
- User snippet attachments
* Project
- Project avatars
- Issues/MR Markdown attachments
- Issues/MR Legacy Markdown attachments
- CI Build Artifacts
- LFS Objects
## Disk storage
GitLab started saving everything on local disk. While directory location changed from previous versions,
they are still not 100% standardized. You can see them below:
| Description | In DB? | Relative path | Uploader class | model_type |
| ------------------------------------- | ------ | ----------------------------------------------------------- | ---------------------- | ---------- |
| Instance logo | yes | uploads/-/system/appearance/logo/:id/:filename | `AttachmentUploader` | Appearance |
| Header logo | yes | uploads/-/system/appearance/header_logo/:id/:filename | `AttachmentUploader` | Appearance |
| Group avatars | yes | uploads/-/system/group/avatar/:id/:filename | `AvatarUploader` | Group |
| User avatars | yes | uploads/-/system/user/avatar/:id/:filename | `AvatarUploader` | User |
| User snippet attachments | yes | uploads/-/system/personal_snippet/:id/:random_hex/:filename | `PersonalFileUploader` | Snippet |
| Project avatars | yes | uploads/-/system/project/avatar/:id/:filename | `AvatarUploader` | Project |
| Issues/MR Markdown attachments | yes | uploads/:project_path_with_namespace/:random_hex/:filename | `FileUploader` | Project |
| Issues/MR Legacy Markdown attachments | no | uploads/-/system/note/attachment/:id/:filename | `AttachmentUploader` | Note |
| CI Artifacts (CE) | yes | shared/artifacts/:year_:month/:project_id/:id | `ArtifactUploader` | Ci::Build |
| LFS Objects (CE) | yes | shared/lfs-objects/:hex/:hex/:object_hash | `LfsObjectUploader` | LfsObject |
CI Artifacts and LFS Objects behave differently in CE and EE. In CE they inherit the `GitlabUploader`
while in EE they inherit the `ObjectStoreUploader` and store files in and S3 API compatible object store.
In the case of Issues/MR Markdown attachments, there is a different approach using the [Hashed Storage] layout,
instead of basing the path into a mutable variable `:project_path_with_namespace`, it's possible to use the
hash of the project ID instead, if project migrates to the new approach (introduced in 10.2).
[CarrierWave]: https://github.com/carrierwaveuploader/carrierwave
[Hashed Storage]: ../administration/repository_storage_types.md
......@@ -110,7 +110,7 @@ You can mark that content for translation with:
In JavaScript we added the `__()` (double underscore parenthesis) function
for translations.
### Updating the PO files with the new content
## Updating the PO files with the new content
Now that the new content is marked for translation, we need to update the PO
files with the following command:
......@@ -119,23 +119,20 @@ files with the following command:
bundle exec rake gettext:find
```
This command will update the `locale/**/gitlab.edit.po` file with the
new content that the parser has found.
This command will update the `locale/gitlab.pot` file with the newly externalized
strings and remove any strings that aren't used anymore. You should check this
file in. Once the changes are on master, they will be picked up by
[Crowdin](http://translate.gitlab.com) and be presented for translation.
New translations will be added with their default content and will be marked
fuzzy. To use the translation, look for the `#, fuzzy` mention in `gitlab.edit.po`
and remove it.
The command also updates the translation files for each language: `locale/*/gitlab.po`
These changes can be discarded, the languange files will be updated by Crowdin
automatically.
We need to make sure we remove the `fuzzy` translations before generating the
`locale/**/gitlab.po` file. When they aren't removed, the resulting `.po` will
be treated as a binary file which could overwrite translations that were merged
before the new translations.
Discard all of them at once like this:
When we are just preparing a page to be translated, but not actually adding any
translations. There's no need to generate `.po` files.
Translations that aren't used in the source code anymore will be marked with
`~#`; these can be removed to keep our translation files clutter-free.
```sh
git checkout locale/*/gitlab.po
```
### Validating PO files
......
......@@ -137,7 +137,17 @@ Using hashed storage significantly improves Geo replication - project and group
renames no longer require synchronization between nodes - so we recommend it is
used for all GitLab Geo installations.
### Step 4. Managing the secondary GitLab node
### Step 4. (Optional) Configuring the secondary to trust the primary
You can safely skip this step if your primary uses a CA-issued HTTPS certificate.
If your primary is using a self-signed certificate for *HTTPS* support, you will
need to add that certificate to the secondary's trust store. Retrieve the
certificate from the primary and follow
[these instructions](https://docs.gitlab.com/omnibus/settings/ssl.html)
on the secondary.
### Step 5. Managing the secondary GitLab node
You can monitor the status of the syncing process on a secondary node
by visiting the primary node's **Admin Area ➔ Geo Nodes** (`/admin/geo_nodes`)
......@@ -231,7 +241,7 @@ to add a new secondary in the short term, you can follow these instructions:
```
Follow the steps above to set up the new Geo node. When you reach
[Step 4: Enabling the secondary GitLab node](#step-4-enabling-the-secondary-gitlab-node)
[Step 5: Enabling the secondary GitLab node](#step-5-managing-the-secondary-gitlab-node)
select "SSH (deprecated)" instead of "HTTP/HTTPS", and populate the "Public Key"
with the output of the previous command (beginning `ssh-rsa AAAA...`).
......
......@@ -128,8 +128,23 @@ Using hashed storage significantly improves Geo replication - project and group
renames no longer require synchronization between nodes - so we recommend it is
used for all GitLab Geo installations.
### Step 4. (Optional) Configuring the secondary to trust the primary
### Step 4. Managing the secondary GitLab node
You can safely skip this step if your primary uses a CA-issued HTTPS certificate.
If your primary is using a self-signed certificate for *HTTPS* support, you will
need to add that certificate to the secondary's trust store. Retrieve the
certificate from the primary and follow your distribution's instructions for
adding it to the secondary's trust store. In Debian/Ubuntu, for example, with a
certificate file of `primary.geo.example.com.crt`, you would follow these steps:
```
sudo -i
cp primary.geo.example.com.crt /usr/local/share/ca-certificates
update-ca-certificates
```
### Step 5. Managing the secondary GitLab node
You can monitor the status of the syncing process on a secondary node
by visiting the primary node's **Admin Area ➔ Geo Nodes** (`/admin/geo_nodes`)
......
---
comments: false
---
# From 10.1 to 10.2
Make sure you view this update guide from the tag (version) of GitLab you would
like to install. In most cases this should be the highest numbered production
tag (without rc in it). You can select the tag in the version dropdown at the
top left corner of GitLab (below the menu bar).
If the highest number stable branch is unclear please check the
[GitLab Blog](https://about.gitlab.com/blog/archives.html) for installation
guide links by version.
### 1. Stop server
```bash
sudo service gitlab stop
```
### 2. Backup
```bash
cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production
```
### 3. Update Ruby
NOTE: GitLab 9.0 and higher only support Ruby 2.3.x and dropped support for Ruby 2.1.x. Be
sure to upgrade your interpreter if necessary.
You can check which version you are running with `ruby -v`.
Download and compile Ruby:
```bash
mkdir /tmp/ruby && cd /tmp/ruby
curl --remote-name --progress https://cache.ruby-lang.org/pub/ruby/2.3/ruby-2.3.5.tar.gz
echo '3247e217d6745c27ef23bdc77b6abdb4b57a118f ruby-2.3.5.tar.gz' | shasum -c - && tar xzf ruby-2.3.5.tar.gz
cd ruby-2.3.5
./configure --disable-install-rdoc
make
sudo make install
```
Install Bundler:
```bash
sudo gem install bundler --no-ri --no-rdoc
```
### 4. Update Node
GitLab now runs [webpack](http://webpack.js.org) to compile frontend assets and
it has a minimum requirement of node v4.3.0.
You can check which version you are running with `node -v`. If you are running
a version older than `v4.3.0` you will need to update to a newer version. You
can find instructions to install from community maintained packages or compile
from source at the nodejs.org website.
<https://nodejs.org/en/download/>
Since 8.17, GitLab requires the use of yarn `>= v0.17.0` to manage
JavaScript dependencies.
```bash
curl --silent --show-error https://dl.yarnpkg.com/debian/pubkey.gpg | sudo apt-key add -
echo "deb https://dl.yarnpkg.com/debian/ stable main" | sudo tee /etc/apt/sources.list.d/yarn.list
sudo apt-get update
sudo apt-get install yarn
```
More information can be found on the [yarn website](https://yarnpkg.com/en/docs/install).
### 5. Update Go
NOTE: GitLab 9.2 and higher only supports Go 1.8.3 and dropped support for Go
1.5.x through 1.7.x. Be sure to upgrade your installation if necessary.
You can check which version you are running with `go version`.
Download and install Go:
```bash
# Remove former Go installation folder
sudo rm -rf /usr/local/go
curl --remote-name --progress https://storage.googleapis.com/golang/go1.8.3.linux-amd64.tar.gz
echo '1862f4c3d3907e59b04a757cfda0ea7aa9ef39274af99a784f5be843c80c6772 go1.8.3.linux-amd64.tar.gz' | shasum -a256 -c - && \
sudo tar -C /usr/local -xzf go1.8.3.linux-amd64.tar.gz
sudo ln -sf /usr/local/go/bin/{go,godoc,gofmt} /usr/local/bin/
rm go1.8.3.linux-amd64.tar.gz
```
### 6. Get latest code
```bash
cd /home/git/gitlab
sudo -u git -H git fetch --all
sudo -u git -H git checkout -- db/schema.rb # local changes will be restored automatically
sudo -u git -H git checkout -- locale
```
For GitLab Community Edition:
```bash
cd /home/git/gitlab
sudo -u git -H git checkout 10-2-stable
```
OR
For GitLab Enterprise Edition:
```bash
cd /home/git/gitlab
sudo -u git -H git checkout 10-2-stable-ee
```
### 7. Update gitlab-shell
```bash
cd /home/git/gitlab-shell
sudo -u git -H git fetch --all --tags
sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_SHELL_VERSION)
sudo -u git -H bin/compile
```
### 8. Update gitlab-workhorse
Install and compile gitlab-workhorse. GitLab-Workhorse uses
[GNU Make](https://www.gnu.org/software/make/).
If you are not using Linux you may have to run `gmake` instead of
`make` below.
```bash
cd /home/git/gitlab-workhorse
sudo -u git -H git fetch --all --tags
sudo -u git -H git checkout v$(</home/git/gitlab/GITLAB_WORKHORSE_VERSION)
sudo -u git -H make
```
### 9. Update Gitaly
#### New Gitaly configuration options required
In order to function Gitaly needs some additional configuration information. Below we assume you installed Gitaly in `/home/git/gitaly` and GitLab Shell in `/home/git/gitlab-shell`.
```shell
echo '
[gitaly-ruby]
dir = "/home/git/gitaly/ruby"
[gitlab-shell]
dir = "/home/git/gitlab-shell"
' | sudo -u git tee -a /home/git/gitaly/config.toml
```
#### Check Gitaly configuration
Due to a bug in the `rake gitlab:gitaly:install` script your Gitaly
configuration file may contain syntax errors. The block name
`[[storages]]`, which may occur more than once in your `config.toml`
file, should be `[[storage]]` instead.
```shell
sudo -u git -H sed -i.pre-10.1 's/\[\[storages\]\]/[[storage]]/' /home/git/gitaly/config.toml
```
#### Compile Gitaly
```shell
cd /home/git/gitaly
sudo -u git -H git fetch --all --tags
sudo -u git -H git checkout v$(</home/git/gitlab/GITALY_SERVER_VERSION)
sudo -u git -H make
```
### 10. Update MySQL permissions
If you are using MySQL you need to grant the GitLab user the necessary
permissions on the database:
```bash
mysql -u root -p -e "GRANT TRIGGER ON \`gitlabhq_production\`.* TO 'git'@'localhost';"
```
If you use MySQL with replication, or just have MySQL configured with binary logging,
you will need to also run the following on all of your MySQL servers:
```bash
mysql -u root -p -e "SET GLOBAL log_bin_trust_function_creators = 1;"
```
You can make this setting permanent by adding it to your `my.cnf`:
```
log_bin_trust_function_creators=1
```
### 11. Update configuration files
#### New configuration options for `gitlab.yml`
There might be configuration options available for [`gitlab.yml`][yaml]. View them with the command below and apply them manually to your current `gitlab.yml`:
```sh
cd /home/git/gitlab
git diff origin/10-1-stable:config/gitlab.yml.example origin/10-2-stable:config/gitlab.yml.example
```
#### Nginx configuration
Ensure you're still up-to-date with the latest NGINX configuration changes:
```sh
cd /home/git/gitlab
# For HTTPS configurations
git diff origin/10-1-stable:lib/support/nginx/gitlab-ssl origin/10-2-stable:lib/support/nginx/gitlab-ssl
# For HTTP configurations
git diff origin/10-1-stable:lib/support/nginx/gitlab origin/10-2-stable:lib/support/nginx/gitlab
```
If you are using Strict-Transport-Security in your installation to continue using it you must enable it in your Nginx
configuration as GitLab application no longer handles setting it.
If you are using Apache instead of NGINX please see the updated [Apache templates].
Also note that because Apache does not support upstreams behind Unix sockets you
will need to let gitlab-workhorse listen on a TCP port. You can do this
via [/etc/default/gitlab].
[Apache templates]: https://gitlab.com/gitlab-org/gitlab-recipes/tree/master/web-server/apache
[/etc/default/gitlab]: https://gitlab.com/gitlab-org/gitlab-ce/blob/10-2-stable/lib/support/init.d/gitlab.default.example#L38
#### SMTP configuration
If you're installing from source and use SMTP to deliver mail, you will need to add the following line
to config/initializers/smtp_settings.rb:
```ruby
ActionMailer::Base.delivery_method = :smtp
```
See [smtp_settings.rb.sample] as an example.
[smtp_settings.rb.sample]: https://gitlab.com/gitlab-org/gitlab-ce/blob/10-2-stable/config/initializers/smtp_settings.rb.sample#L13
#### Init script
There might be new configuration options available for [`gitlab.default.example`][gl-example]. View them with the command below and apply them manually to your current `/etc/default/gitlab`:
```sh
cd /home/git/gitlab
git diff origin/10-1-stable:lib/support/init.d/gitlab.default.example origin/10-2-stable:lib/support/init.d/gitlab.default.example
```
Ensure you're still up-to-date with the latest init script changes:
```bash
cd /home/git/gitlab
sudo cp lib/support/init.d/gitlab /etc/init.d/gitlab
```
For Ubuntu 16.04.1 LTS:
```bash
sudo systemctl daemon-reload
```
### 12. Install libs, migrations, etc.
```bash
cd /home/git/gitlab
# MySQL installations (note: the line below states '--without postgres')
sudo -u git -H bundle install --without postgres development test --deployment
# PostgreSQL installations (note: the line below states '--without mysql')
sudo -u git -H bundle install --without mysql development test --deployment
# Optional: clean up old gems
sudo -u git -H bundle clean
# Run database migrations
sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production
# Compile GetText PO files
sudo -u git -H bundle exec rake gettext:compile RAILS_ENV=production
# Update node dependencies and recompile assets
sudo -u git -H bundle exec rake yarn:install gitlab:assets:clean gitlab:assets:compile RAILS_ENV=production NODE_ENV=production
# Clean up cache
sudo -u git -H bundle exec rake cache:clear RAILS_ENV=production
```
**MySQL installations**: Run through the `MySQL strings limits` and `Tables and data conversion to utf8mb4` [tasks](../install/database_mysql.md).
### 13. Start application
```bash
sudo service gitlab start
sudo service nginx restart
```
### 14. Check application status
Check if GitLab and its environment are configured correctly:
```bash
cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production
```
To make sure you didn't miss anything run a more thorough check:
```bash
cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
```
If all items are green, then congratulations, the upgrade is complete!
## Things went south? Revert to previous version (10.0)
### 1. Revert the code to the previous version
Follow the [upgrade guide from 9.5 to 10.0](9.5-to-10.0.md), except for the
database migration (the backup is already migrated to the previous version).
### 2. Restore from the backup
```bash
cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production
```
If you have more than one backup `*.tar` file(s) please add `BACKUP=timestamp_of_backup` to the command above.
[yaml]: https://gitlab.com/gitlab-org/gitlab-ce/blob/10-2-stable/config/gitlab.yml.example
[gl-example]: https://gitlab.com/gitlab-org/gitlab-ce/blob/10-2-stable/lib/support/init.d/gitlab.default.example
# From Community Edition 10.2 to Enterprise Edition 10.2
This guide assumes you have a correctly configured and tested installation of
GitLab Community Edition 10.2. If you run into any trouble or if you have any
questions please contact us at [support@gitlab.com].
### 0. Backup
Make a backup just in case something goes wrong:
```bash
cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:backup:create RAILS_ENV=production
```
For installations using MySQL, this may require granting "LOCK TABLES"
privileges to the GitLab user on the database version.
### 1. Stop server
```bash
sudo service gitlab stop
```
### 2. Get the EE code
```bash
cd /home/git/gitlab
sudo -u git -H git remote add -f ee https://gitlab.com/gitlab-org/gitlab-ee.git
sudo -u git -H git checkout 10-2-stable-ee
```
### 3. Install libs, migrations, etc.
```bash
cd /home/git/gitlab
# MySQL installations (note: the line below states '--without postgres')
sudo -u git -H bundle install --without postgres development test --deployment
# PostgreSQL installations (note: the line below states '--without mysql')
sudo -u git -H bundle install --without mysql development test --deployment
# Run database migrations
sudo -u git -H bundle exec rake db:migrate RAILS_ENV=production
# Clean up assets and cache
sudo -u git -H bundle exec rake assets:clean assets:precompile cache:clear RAILS_ENV=production
```
### 4. Start application
```bash
sudo service gitlab start
sudo service nginx restart
```
### 5. Check application status
Check if GitLab and its environment are configured correctly:
```bash
sudo -u git -H bundle exec rake gitlab:env:info RAILS_ENV=production
```
To make sure you didn't miss anything run a more thorough check with:
```bash
sudo -u git -H bundle exec rake gitlab:check RAILS_ENV=production
```
If all items are green, then congratulations upgrade complete!
## Things went south? Revert to previous version (Community Edition 10.2)
### 1. Revert the code to the previous version
```bash
cd /home/git/gitlab
sudo -u git -H git checkout 10-2-stable
```
### 2. Restore from the backup
```bash
cd /home/git/gitlab
sudo -u git -H bundle exec rake gitlab:backup:restore RAILS_ENV=production
```
[support@gitlab.com]: mailto:support@gitlab.com
......@@ -57,59 +57,6 @@ Here's what you'll need to have installed:
sudo gitlab-rake db:create db:migrate
```
1. Stop Unicorn in case it's interfering the next step:
``` bash
sudo gitlab-ctl stop unicorn
```
After these steps, you'll have a fresh PostgreSQL database with up-to-date schema.
## Migrate data from MySQL to PostgreSQL
Now, you can use pgloader to migrate the data from MySQL to PostgreSQL:
1. Save the following snippet in a `commands.load` file, and edit with your
database `username`, `password` and `host`:
```
LOAD DATABASE
FROM mysql://username:password@host/gitlabhq_production
INTO postgresql://gitlab-psql@unix://var/opt/gitlab/postgresql:/gitlabhq_production
WITH include no drop, truncate, disable triggers, create no tables,
create no indexes, preserve index names, no foreign keys,
data only
ALTER SCHEMA 'gitlabhq_production' RENAME TO 'public'
;
```
1. Start the migration:
``` bash
sudo -u gitlab-psql pgloader commands.load
```
1. Once the migration finishes, start GitLab:
``` bash
sudo gitlab-ctl start
```
Now, you can verify that everything worked by visiting GitLab.
## Troubleshooting
### Experiencing 500 errors after the migration
If you experience 500 errors after the migration, try to clear the cache:
``` bash
sudo gitlab-rake cache:clear
```
=======
1. Stop Unicorn to prevent other database access from interfering with the loading of data:
``` bash
......
......@@ -18,7 +18,8 @@ Into a single commit on merge:
![A squashed commit followed by a merge commit][squashed-commit]
Note that the squashed commit is still followed by a merge commit, as the merge
The squashed commit's commit message is the merge request title. And note that
the squashed commit is still followed by a merge commit, as the merge
method for this example repository uses a merge commit. Squashing also works
with the fast-forward merge strategy, see
[squashing and fast-forward merge](#squashing-and-fast-forward-merge) for more
......
......@@ -28,20 +28,22 @@
<template>
<div class="detail-page-header">
Opened
<timeagoTooltip
:time="created"
/>
by
<strong>
<user-avatar-link
:link-href="author.url"
:img-src="author.src"
:img-size="24"
:tooltipText="author.username"
:username="author.name"
imgCssClasses="avatar-inline"
<div class="issuable-meta">
Opened
<timeagoTooltip
:time="created"
/>
</strong>
by
<strong>
<user-avatar-link
:link-href="author.url"
:img-src="author.src"
:img-size="24"
:tooltipText="author.username"
:username="author.name"
imgCssClasses="avatar-inline"
/>
</strong>
</div>
</div>
</template>
......@@ -2,6 +2,7 @@
import issuableApp from '~/issue_show/components/app.vue';
import relatedIssuesRoot from '~/issuable/related_issues/components/related_issues_root.vue';
import epicHeader from './epic_header.vue';
import epicSidebar from '../../sidebar/components/sidebar_app.vue';
export default {
name: 'epicShowApp',
......@@ -64,9 +65,18 @@
type: String,
required: true,
},
startDate: {
type: String,
required: false,
},
endDate: {
type: String,
required: false,
},
},
components: {
epicHeader,
epicSidebar,
issuableApp,
relatedIssuesRoot,
},
......@@ -85,21 +95,29 @@
:author="author"
:created="created"
/>
<div class="issuable-details detail-page-description content-block">
<issuable-app
:can-update="canUpdate"
:can-destroy="canDestroy"
<div class="issuable-details content-block">
<div class="detail-page-description">
<issuable-app
:can-update="canUpdate"
:can-destroy="canDestroy"
:endpoint="endpoint"
:issuable-ref="issuableRef"
:initial-title-html="initialTitleHtml"
:initial-title-text="initialTitleText"
:initial-description-html="initialDescriptionHtml"
:initial-description-text="initialDescriptionText"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-inline-edit-button="true"
/>
</div>
<epic-sidebar
:endpoint="endpoint"
:issuable-ref="issuableRef"
:initial-title-html="initialTitleHtml"
:initial-title-text="initialTitleText"
:initial-description-html="initialDescriptionHtml"
:initial-description-text="initialDescriptionText"
:markdown-preview-path="markdownPreviewPath"
:markdown-docs-path="markdownDocsPath"
:project-path="projectPath"
:project-namespace="projectNamespace"
:show-inline-edit-button="true"
:editable="canUpdate"
:initialStartDate="startDate"
:initialEndDate="endDate"
/>
<related-issues-root
:endpoint="issueLinksEndpoint"
......
......@@ -12,6 +12,10 @@ document.addEventListener('DOMContentLoaded', () => {
canDestroy: false,
});
// Convert backend casing to match frontend style guide
props.startDate = props.start_date;
props.endDate = props.end_date;
return new Vue({
el,
components: {
......
<script>
import Cookies from 'js-cookie';
import Flash from '~/flash';
import { capitalizeFirstCharacter } from '~/lib/utils/text_utility';
import sidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue';
import sidebarCollapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
import SidebarService from '../services/sidebar_service';
import Store from '../stores/sidebar_store';
export default {
name: 'epicSidebar',
props: {
endpoint: {
type: String,
required: true,
},
editable: {
type: Boolean,
required: false,
default: false,
},
initialStartDate: {
type: String,
required: false,
},
initialEndDate: {
type: String,
required: false,
},
},
data() {
const store = new Store({
startDate: this.initialStartDate,
endDate: this.initialEndDate,
});
return {
store,
// Backend will pass the appropriate css class for the contentContainer
collapsed: Cookies.get('collapsed_gutter') === 'true',
savingStartDate: false,
savingEndDate: false,
service: new SidebarService(this.endpoint),
};
},
components: {
sidebarDatePicker,
sidebarCollapsedGroupedDatePicker,
},
methods: {
toggleSidebar() {
this.collapsed = !this.collapsed;
const contentContainer = this.$el.closest('.page-with-sidebar');
contentContainer.classList.toggle('right-sidebar-expanded');
contentContainer.classList.toggle('right-sidebar-collapsed');
Cookies.set('collapsed_gutter', this.collapsed);
},
saveDate(dateType = 'start', newDate) {
const type = dateType === 'start' ? dateType : 'end';
const capitalizedType = capitalizeFirstCharacter(type);
const serviceMethod = `update${capitalizedType}Date`;
const savingBoolean = `saving${capitalizedType}Date`;
this[savingBoolean] = true;
return this.service[serviceMethod](newDate)
.then(() => {
this[savingBoolean] = false;
this.store[`${type}Date`] = newDate;
})
.catch(() => {
this[savingBoolean] = false;
Flash(`An error occurred while saving ${type} date`);
});
},
saveStartDate(date) {
return this.saveDate('start', date);
},
saveEndDate(date) {
return this.saveDate('end', date);
},
},
};
</script>
<template>
<aside
class="right-sidebar"
:class="{ 'right-sidebar-expanded' : !collapsed, 'right-sidebar-collapsed': collapsed }"
>
<div class="issuable-sidebar">
<sidebar-date-picker
v-if="!collapsed"
:collapsed="collapsed"
:is-loading="savingStartDate"
:editable="editable"
label="Planned start date"
:selected-date="store.startDateTime"
:max-date="store.endDateTime"
:show-toggle-sidebar="true"
@saveDate="saveStartDate"
@toggleCollapse="toggleSidebar"
/>
<sidebar-date-picker
v-if="!collapsed"
:collapsed="collapsed"
:is-loading="savingEndDate"
:editable="editable"
label="Planned finish date"
:selected-date="store.endDateTime"
:min-date="store.startDateTime"
@saveDate="saveEndDate"
@toggleCollapse="toggleSidebar"
/>
<sidebar-collapsed-grouped-date-picker
v-if="collapsed"
:collapsed="collapsed"
:min-date="store.startDateTime"
:max-date="store.endDateTime"
:show-toggle-sidebar="true"
@toggleCollapse="toggleSidebar"
/>
</div>
</aside>
</template>
import Vue from 'vue';
import VueResource from 'vue-resource';
Vue.use(VueResource);
export default class SidebarService {
constructor(endpoint) {
this.endpoint = endpoint;
this.resource = Vue.resource(`${this.endpoint}.json`, {});
}
updateStartDate(startDate) {
return this.resource.update({
start_date: startDate,
});
}
updateEndDate(endDate) {
return this.resource.update({
end_date: endDate,
});
}
}
import { parsePikadayDate } from '~/lib/utils/datefix';
export default class SidebarStore {
constructor({ startDate, endDate }) {
this.startDate = startDate;
this.endDate = endDate;
}
get startDateTime() {
return this.startDate ? parsePikadayDate(this.startDate) : null;
}
get endDateTime() {
return this.endDate ? parsePikadayDate(this.endDate) : null;
}
}
......@@ -9,7 +9,9 @@ module EpicsHelper
url: user_path(author),
username: "@#{author.username}",
src: avatar_icon(@epic.author)
}
},
start_date: @epic.start_date,
end_date: @epic.end_date
}
data.to_json
......
module EE
module MergeRequests
module MergeService
def error_check!
raise NotImplementedError unless defined?(super)
check_size_limit
super
end
def source
return merge_request.diff_head_sha unless merge_request.squash
squash_result = ::MergeRequests::SquashService.new(project, current_user, params).execute(merge_request)
case squash_result[:status]
when :success
squash_result[:squash_sha]
when :error
raise ::MergeRequests::MergeService::MergeError, squash_result[:message]
end
end
private
def check_size_limit
if @merge_request.target_project.above_size_limit?
message = ::Gitlab::RepositorySizeError.new(@merge_request.target_project).merge_error
raise ::MergeRequests::MergeService::MergeError, message
end
end
end
end
end
......@@ -2,7 +2,7 @@
.create_access_levels-container
= dropdown_tag('Select',
options: { toggle_class: 'js-allowed-to-create js-multiselect wide',
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable', filter: true,
dropdown_class: 'dropdown-menu-user dropdown-menu-selectable capitalize-header', filter: true,
data: { input_id: 'create_access_levels_attributes', default_label: 'Select' } })
.help-block
Only groups that
......
......@@ -34,24 +34,7 @@ module API
optional :statistics, type: Boolean, default: false, desc: 'Include project statistics'
end
def present_groups(groups, options = {})
options = options.reverse_merge(
with: Entities::Group,
current_user: current_user
)
groups = groups.with_statistics if options[:statistics]
present paginate(groups), options
end
end
resource :groups do
include CustomAttributesEndpoints
desc 'Get a groups list' do
success Entities::Group
end
params do
params :group_list_params do
use :statistics_params
optional :skip_groups, type: Array[Integer], desc: 'Array of group ids to exclude from list'
optional :all_available, type: Boolean, desc: 'Show all group that you have access to'
......@@ -61,19 +44,47 @@ module API
optional :sort, type: String, values: %w[asc desc], default: 'asc', desc: 'Sort by asc (ascending) or desc (descending)'
use :pagination
end
get do
def find_groups(params)
find_params = {
all_available: params[:all_available],
owned: params[:owned],
custom_attributes: params[:custom_attributes]
custom_attributes: params[:custom_attributes],
owned: params[:owned]
}
find_params[:parent] = find_group!(params[:id]) if params[:id]
groups = GroupsFinder.new(current_user, find_params).execute
groups = groups.search(params[:search]) if params[:search].present?
groups = groups.where.not(id: params[:skip_groups]) if params[:skip_groups].present?
groups = groups.reorder(params[:order_by] => params[:sort])
present_groups groups, statistics: params[:statistics] && current_user.admin?
groups
end
def present_groups(params, groups)
options = {
with: Entities::Group,
current_user: current_user,
statistics: params[:statistics] && current_user.admin?
}
groups = groups.with_statistics if options[:statistics]
present paginate(groups), options
end
end
resource :groups do
include CustomAttributesEndpoints
desc 'Get a groups list' do
success Entities::Group
end
params do
use :group_list_params
end
get do
groups = find_groups(params)
present_groups params, groups
end
desc 'Create a group. Available only for users who can create groups.' do
......@@ -199,6 +210,17 @@ module API
present paginate(projects), with: entity, current_user: current_user
end
desc 'Get a list of subgroups in this group.' do
success Entities::Group
end
params do
use :group_list_params
end
get ":id/subgroups" do
groups = find_groups(params)
present_groups params, groups
end
desc 'Transfer a project to the group namespace. Available only for admin.' do
success Entities::GroupDetail
end
......
......@@ -73,7 +73,7 @@ module API
desc: 'Return issues for the given scope: `created-by-me`, `assigned-to-me` or `all`'
end
get do
issues = find_issues
issues = paginate(find_issues)
options = {
with: Entities::IssueBasic,
......@@ -81,7 +81,7 @@ module API
issuable_metadata: issuable_meta_data(issues, 'Issue')
}
present paginate(issues), options
present issues, options
end
end
......@@ -100,7 +100,7 @@ module API
get ":id/issues" do
group = find_group!(params[:id])
issues = find_issues(group_id: group.id)
issues = paginate(find_issues(group_id: group.id))
options = {
with: Entities::IssueBasic,
......@@ -108,7 +108,7 @@ module API
issuable_metadata: issuable_meta_data(issues, 'Issue')
}
present paginate(issues), options
present issues, options
end
end
......@@ -129,7 +129,7 @@ module API
get ":id/issues" do
project = find_project!(params[:id])
issues = find_issues(project_id: project.id)
issues = paginate(find_issues(project_id: project.id))
options = {
with: Entities::IssueBasic,
......@@ -138,7 +138,7 @@ module API
issuable_metadata: issuable_meta_data(issues, 'Issue')
}
present paginate(issues), options
present issues, options
end
desc 'Get a single project issue' do
......
......@@ -48,11 +48,14 @@ module Gitlab
end
def update_page(page_path, title, format, content, commit_details)
assert_type!(format, Symbol)
assert_type!(commit_details, CommitDetails)
gollum_wiki.update_page(gollum_page_by_path(page_path), title, format, content, commit_details.to_h)
nil
@repository.gitaly_migrate(:wiki_update_page) do |is_enabled|
if is_enabled
gitaly_update_page(page_path, title, format, content, commit_details)
gollum_wiki.clear_cache
else
gollum_update_page(page_path, title, format, content, commit_details)
end
end
end
def pages
......@@ -149,6 +152,14 @@ module Gitlab
nil
end
def gollum_update_page(page_path, title, format, content, commit_details)
assert_type!(format, Symbol)
assert_type!(commit_details, CommitDetails)
gollum_wiki.update_page(gollum_page_by_path(page_path), title, format, content, commit_details.to_h)
nil
end
def gollum_find_page(title:, version: nil, dir: nil)
if version
version = Gitlab::Git::Commit.find(@repository, version).id
......@@ -172,6 +183,10 @@ module Gitlab
gitaly_wiki_client.write_page(name, format, content, commit_details)
end
def gitaly_update_page(page_path, title, format, content, commit_details)
gitaly_wiki_client.update_page(page_path, title, format, content, commit_details)
end
def gitaly_delete_page(page_path, commit_details)
gitaly_wiki_client.delete_page(page_path, commit_details)
end
......
......@@ -37,6 +37,31 @@ module Gitlab
end
end
def update_page(page_path, title, format, content, commit_details)
request = Gitaly::WikiUpdatePageRequest.new(
repository: @gitaly_repo,
page_path: GitalyClient.encode(page_path),
title: GitalyClient.encode(title),
format: format.to_s,
commit_details: gitaly_commit_details(commit_details)
)
strio = StringIO.new(content)
enum = Enumerator.new do |y|
until strio.eof?
chunk = strio.read(MAX_MSG_SIZE)
request.content = GitalyClient.encode(chunk)
y.yield request
request = Gitaly::WikiUpdatePageRequest.new
end
end
GitalyClient.call(@repository.storage, :wiki_service, :wiki_update_page, enum)
end
def delete_page(page_path, commit_details)
request = Gitaly::WikiDeletePageRequest.new(
repository: @gitaly_repo,
......
......@@ -38,7 +38,14 @@ module Gitlab
# otherwise hitting the rate limit will result in a thread
# being blocked in a `sleep()` call for up to an hour.
def initialize(token, per_page: 100, parallel: true)
@octokit = Octokit::Client.new(access_token: token, per_page: per_page)
@octokit = Octokit::Client.new(
access_token: token,
per_page: per_page,
api_endpoint: api_endpoint
)
@octokit.connection_options[:ssl] = { verify: verify_ssl }
@parallel = parallel
end
......@@ -122,6 +129,8 @@ module Gitlab
# whether we are running in parallel mode or not. For more information see
# `#rate_or_wait_for_rate_limit`.
def with_rate_limit
return yield unless rate_limiting_enabled?
request_count_counter.increment
raise_or_wait_for_rate_limit unless requests_remaining?
......@@ -163,8 +172,31 @@ module Gitlab
octokit.rate_limit.resets_in + 5
end
def respond_to_missing?(method, include_private = false)
octokit.respond_to?(method, include_private)
def rate_limiting_enabled?
@rate_limiting_enabled ||= api_endpoint.include?('.github.com')
end
def api_endpoint
custom_api_endpoint || default_api_endpoint
end
def custom_api_endpoint
github_omniauth_provider.dig('args', 'client_options', 'site')
end
def default_api_endpoint
OmniAuth::Strategies::GitHub.default_options[:client_options][:site]
end
def verify_ssl
github_omniauth_provider.fetch('verify_ssl', true)
end
def github_omniauth_provider
@github_omniauth_provider ||=
Gitlab.config.omniauth.providers
.find { |provider| provider.name == 'github' }
.to_h
end
def rate_limit_counter
......
module Gitlab
module IssuableMetadata
def issuable_meta_data(issuable_collection, collection_type)
# ActiveRecord uses Object#extend for null relations.
if !(issuable_collection.singleton_class < ActiveRecord::NullRelation) &&
issuable_collection.respond_to?(:limit_value) &&
issuable_collection.limit_value.nil?
raise 'Collection must have a limit applied for preloading meta-data'
end
# map has to be used here since using pluck or select will
# throw an error when ordering issuables by priority which inserts
# a new order into the collection.
......
......@@ -11,8 +11,6 @@ module Gitlab
# Old gitlad-shell messages don't provide enqueued_at/created_at attributes
trans.set(:sidekiq_queue_duration, Time.now.to_f - (message['enqueued_at'] || message['created_at'] || 0))
trans.run { yield }
worker.metrics_tags.each { |tag, value| trans.add_tag(tag, value) } if worker.respond_to?(:metrics_tags)
rescue Exception => error # rubocop: disable Lint/RescueException
trans.add_event(:sidekiq_exception)
......
......@@ -66,11 +66,7 @@ module Gitlab
end
def whitelisted_routes
logout_route || grack_route || @whitelisted.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route
end
def logout_route
route_hash[:controller] == 'sessions' && route_hash[:action] == 'destroy'
grack_route || @whitelisted.any? { |path| request.path.include?(path) } || lfs_route || sidekiq_route
end
def sidekiq_route
......
......@@ -14,7 +14,9 @@ class GithubImport
end
def run!
@repo = GithubRepos.new(@options, @current_user, @github_repo).choose_one!
@repo = GithubRepos
.new(@options[:token], @current_user, @github_repo)
.choose_one!
raise 'No repo found!' unless @repo
......@@ -28,7 +30,7 @@ class GithubImport
private
def show_warning!
puts "This will import GitHub #{@repo['full_name'].bright} into GitLab #{@project_path.bright} as #{@current_user.name}"
puts "This will import GitHub #{@repo.full_name.bright} into GitLab #{@project_path.bright} as #{@current_user.name}"
puts "Permission checks are ignored. Press any key to continue.".color(:red)
STDIN.getch
......@@ -65,16 +67,16 @@ class GithubImport
@current_user,
name: name,
path: name,
description: @repo['description'],
description: @repo.description,
namespace_id: namespace.id,
visibility_level: visibility_level,
skip_wiki: @repo['has_wiki']
skip_wiki: @repo.has_wiki
).execute
project.update!(
import_type: 'github',
import_source: @repo['full_name'],
import_url: @repo['clone_url'].sub('://', "://#{@options[:token]}@")
import_source: @repo.full_name,
import_url: @repo.clone_url.sub('://', "://#{@options[:token]}@")
)
project
......@@ -93,13 +95,15 @@ class GithubImport
end
def visibility_level
@repo['private'] ? Gitlab::VisibilityLevel::PRIVATE : Gitlab::CurrentSettings.current_application_settings.default_project_visibility
@repo.private ? Gitlab::VisibilityLevel::PRIVATE : Gitlab::CurrentSettings.current_application_settings.default_project_visibility
end
end
class GithubRepos
def initialize(options, current_user, github_repo)
@options = options
def initialize(token, current_user, github_repo)
@client = Gitlab::GithubImport::Client.new(token)
@client.octokit.auto_paginate = true
@current_user = current_user
@github_repo = github_repo
end
......@@ -108,17 +112,17 @@ class GithubRepos
return found_github_repo if @github_repo
repos.each do |repo|
print "ID: #{repo['id'].to_s.bright}".color(:green)
print "\tName: #{repo['full_name']}\n".color(:green)
print "ID: #{repo.id.to_s.bright}".color(:green)
print "\tName: #{repo.full_name}\n".color(:green)
end
print 'ID? '.bright
repos.find { |repo| repo['id'] == repo_id }
repos.find { |repo| repo.id == repo_id }
end
def found_github_repo
repos.find { |repo| repo['full_name'] == @github_repo }
repos.find { |repo| repo.full_name == @github_repo }
end
def repo_id
......@@ -126,7 +130,7 @@ class GithubRepos
end
def repos
Github::Repositories.new(@options).fetch
@client.octokit.list_repositories
end
end
......
......@@ -4,4 +4,4 @@ require_relative '../qa'
QA::Scenario
.const_get(ARGV.shift)
.perform(*ARGV)
.launch!(ARGV)
......@@ -18,6 +18,7 @@ module QA
##
# Support files
#
autoload :Bootable, 'qa/scenario/bootable'
autoload :Actable, 'qa/scenario/actable'
autoload :Entrypoint, 'qa/scenario/entrypoint'
autoload :Template, 'qa/scenario/template'
......
......@@ -3,7 +3,7 @@ module QA
module Main
class Entry < Page::Base
def initialize
visit('/')
visit(Runtime::Scenario.gitlab_address)
# This resolves cold boot / background tasks problems
#
......
......@@ -3,7 +3,7 @@ module QA
module Mattermost
class Login < Page::Base
def initialize
visit(Runtime::Scenario.mattermost + '/login')
visit(Runtime::Scenario.mattermost_address + '/login')
end
def sign_in_using_oauth
......
......@@ -3,7 +3,7 @@ module QA
module Mattermost
class Main < Page::Base
def initialize
visit(Runtime::Scenario.mattermost)
visit(Runtime::Scenario.mattermost_address)
end
end
end
......
module QA
module Runtime
##
# Singleton approach to global test scenario arguments.
#
module Scenario
extend self
attr_accessor :mattermost
attr_reader :attributes
def define(attribute, value)
(@attributes ||= {}).store(attribute.to_sym, value)
define_singleton_method(attribute) do
@attributes[attribute.to_sym].tap do |value|
if value.to_s.empty?
raise ArgumentError, "Empty `#{attribute}` attribute!"
end
end
end
end
def method_missing(name, *)
raise ArgumentError, "Scenario attribute `#{name}` not defined!"
end
end
end
end
require 'optparse'
module QA
module Scenario
module Bootable
Option = Struct.new(:name, :arg, :desc)
def self.included(base)
base.extend(ClassMethods)
end
module ClassMethods
def launch!(argv)
return self.perform(*argv) unless has_attributes?
arguments = OptionParser.new do |parser|
options.to_a.each do |opt|
parser.on(opt.arg, opt.desc) do |value|
Runtime::Scenario.define(opt.name, value)
end
end
end
arguments.parse!(argv)
self.perform(**Runtime::Scenario.attributes)
end
private
def attribute(name, arg, desc)
options.push(Option.new(name, arg, desc))
end
def options
@options ||= []
end
def has_attributes?
options.any?
end
end
end
end
end
......@@ -5,18 +5,10 @@ module QA
# including staging and on-premises installation.
#
class Entrypoint < Template
def self.tags(*tags)
@tags = tags
end
def self.get_tags
@tags
end
include Bootable
def perform(address, *files)
Specs::Config.perform do |specs|
specs.address = address
end
Runtime::Scenario.define(:gitlab_address, address)
##
# Perform before hooks, which are different for CE and EE
......@@ -24,13 +16,19 @@ module QA
Runtime::Release.perform_before_hooks
Specs::Runner.perform do |specs|
specs.rspec(
tty: true,
tags: self.class.get_tags,
files: files.any? ? files : 'qa/specs/features'
)
specs.tty = true
specs.tags = self.class.get_tags
specs.files = files.any? ? files : 'qa/specs/features'
end
end
def self.tags(*tags)
@tags = tags
end
def self.get_tags
@tags
end
end
end
end
......@@ -7,11 +7,12 @@ module QA
# including staging and on-premises installation.
#
class Mattermost < Scenario::Entrypoint
tags :mattermost
tags :core, :mattermost
def perform(address, mattermost, *files)
Runtime::Scenario.mattermost = mattermost
super(address, files)
Runtime::Scenario.define(:mattermost_address, mattermost)
super(address, *files)
end
end
end
......
......@@ -9,15 +9,7 @@ require 'selenium-webdriver'
module QA
module Specs
class Config < Scenario::Template
attr_writer :address
def initialize
@address = ENV['GITLAB_URL']
end
def perform
raise 'Please configure GitLab address!' unless @address
configure_rspec!
configure_capybara!
end
......@@ -56,7 +48,6 @@ module QA
end
Capybara.configure do |config|
config.app_host = @address
config.default_driver = :chrome
config.javascript_driver = :chrome
config.default_max_wait_time = 4
......
......@@ -2,16 +2,22 @@ require 'rspec/core'
module QA
module Specs
class Runner
include Scenario::Actable
class Runner < Scenario::Template
attr_accessor :tty, :tags, :files
def rspec(tty: false, tags: [], files: ['qa/specs/features'])
def initialize
@tty = false
@tags = []
@files = ['qa/specs/features']
end
def perform
args = []
args << '--tty' if tty
tags.to_a.each do |tag|
args << ['-t', tag.to_s]
end
args << files
args.push('--tty') if tty
tags.to_a.each { |tag| args.push(['-t', tag.to_s]) }
args.push(files)
Specs::Config.perform
RSpec::Core::Runner.run(args.flatten, $stderr, $stdout).tap do |status|
abort if status.nonzero?
......
describe QA::Runtime::Scenario do
subject do
Module.new.extend(described_class)
end
it 'makes it possible to define global scenario attributes' do
subject.define(:my_attribute, 'some-value')
subject.define(:another_attribute, 'another-value')
expect(subject.my_attribute).to eq 'some-value'
expect(subject.another_attribute).to eq 'another-value'
expect(subject.attributes)
.to eq(my_attribute: 'some-value', another_attribute: 'another-value')
end
it 'raises error when attribute is not known' do
expect { subject.invalid_accessor }
.to raise_error ArgumentError, /invalid_accessor/
end
it 'raises error when attribute is empty' do
subject.define(:empty_attribute, '')
expect { subject.empty_attribute }
.to raise_error ArgumentError, /empty_attribute/
end
end
describe QA::Scenario::Bootable do
subject do
Class.new(QA::Scenario::Template)
.include(described_class)
end
it 'makes it possible to define the scenario attribute' do
subject.class_eval do
attribute :something, '--something SOMETHING', 'Some attribute'
attribute :another, '--another ANOTHER', 'Some other attribute'
end
expect(subject).to receive(:perform)
.with(something: 'test', another: 'other')
subject.launch!(%w[--another other --something test])
end
it 'does not require attributes to be defined' do
expect(subject).to receive(:perform).with('some', 'argv')
subject.launch!(%w[some argv])
end
end
......@@ -6,31 +6,30 @@ describe QA::Scenario::Entrypoint do
end
context '#perform' do
let(:config) { spy('Specs::Config') }
let(:arguments) { spy('Runtime::Scenario') }
let(:release) { spy('Runtime::Release') }
let(:runner) { spy('Specs::Runner') }
before do
allow(config).to receive(:perform) { |&block| block.call config }
allow(runner).to receive(:perform) { |&block| block.call runner }
stub_const('QA::Specs::Config', config)
stub_const('QA::Runtime::Release', release)
stub_const('QA::Runtime::Scenario', arguments)
stub_const('QA::Specs::Runner', runner)
allow(runner).to receive(:perform).and_yield(runner)
end
it 'should set address' do
it 'sets an address of the subject' do
subject.perform("hello")
expect(config).to have_received(:address=).with("hello")
expect(arguments).to have_received(:define)
.with(:gitlab_address, "hello")
end
context 'no paths' do
it 'should call runner with default arguments' do
subject.perform("test")
expect(runner).to have_received(:rspec)
.with(hash_including(files: 'qa/specs/features'))
expect(runner).to have_received(:files=).with('qa/specs/features')
end
end
......@@ -38,8 +37,7 @@ describe QA::Scenario::Entrypoint do
it 'should call runner with paths' do
subject.perform('test', 'path1', 'path2')
expect(runner).to have_received(:rspec)
.with(hash_including(files: %w(path1 path2)))
expect(runner).to have_received(:files=).with(%w[path1 path2])
end
end
end
......
#!/bin/bash
mysql --user=root --host=mysql <<EOF
CREATE DATABASE IF NOT EXISTS gitlabhq_test;
CREATE USER IF NOT EXISTS 'gitlab'@'%';
GRANT ALL PRIVILEGES ON gitlabhq_test.* TO 'gitlab'@'%';
FLUSH PRIVILEGES;
EOF
#!/bin/bash
psql -h postgres -U postgres postgres <<EOF
DROP DATABASE IF EXISTS gitlabhq_test;
CREATE DATABASE gitlabhq_test;
CREATE USER gitlab;
GRANT ALL PRIVILEGES ON DATABASE gitlabhq_test TO gitlab;
EOF
. scripts/utils.sh
export SETUP_DB=${SETUP_DB:-true}
export CREATE_DB_USER=${CREATE_DB_USER:-$SETUP_DB}
export USE_BUNDLE_INSTALL=${USE_BUNDLE_INSTALL:-true}
export BUNDLE_INSTALL_FLAGS="--without production --jobs $(nproc) --path vendor --retry 3 --quiet"
......@@ -29,6 +30,9 @@ cp config/database.yml.$GITLAB_DATABASE config/database.yml
# EE-only
cp config/database_geo.yml.$GITLAB_DATABASE config/database_geo.yml
# Set user to a non-superuser to ensure we test permissions
sed -i 's/username: root/username: gitlab/g' config/database.yml
if [ "$GITLAB_DATABASE" = 'postgresql' ]; then
sed -i 's/localhost/postgres/g' config/database.yml
......@@ -53,6 +57,16 @@ sed -i 's/localhost/redis/g' config/redis.queues.yml
cp config/redis.shared_state.yml.example config/redis.shared_state.yml
sed -i 's/localhost/redis/g' config/redis.shared_state.yml
# Some tasks (e.g. db:seed_fu) need to have a properly-configured database
# user but not necessarily a full schema loaded
if [ "$CREATE_DB_USER" != "false" ]; then
if [ "$GITLAB_DATABASE" = 'postgresql' ]; then
. scripts/create_postgres_user.sh
else
. scripts/create_mysql_user.sh
fi
fi
if [ "$SETUP_DB" != "false" ]; then
bundle exec rake db:drop db:create db:schema:load db:migrate
......
......@@ -8,7 +8,7 @@ describe EpicsHelper do
user = create(:user)
@epic = create(:epic, author: user)
expect(JSON.parse(epic_meta_data).keys).to match_array(%w[created author])
expect(JSON.parse(epic_meta_data).keys).to match_array(%w[created author start_date end_date])
expect(JSON.parse(epic_meta_data)['author']).to eq({
'name' => user.name,
'url' => "/#{user.username}",
......
......@@ -41,7 +41,7 @@ describe "Git HTTP requests (Geo)" do
it { travel_to(2.minutes.ago) { is_expected.to have_gitlab_http_status(:unauthorized) } }
end
xcontext 'expired Geo JWT token' do
context 'expired Geo JWT token' do
let(:env) { valid_geo_env }
it { travel_to(Time.now + 2.minutes) { is_expected.to have_gitlab_http_status(:unauthorized) } }
......
require 'spec_helper'
describe MergeRequests::MergeService do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request, :simple) }
let(:project) { merge_request.project }
before do
project.add_master(user)
end
describe '#execute' do
context 'project has exceeded size limit' do
let(:service) { described_class.new(project, user, commit_message: 'Awesome message') }
before do
allow(project).to receive(:above_size_limit?).and_return(true)
perform_enqueued_jobs do
service.execute(merge_request)
end
end
it 'returns the correct error message' do
expect(merge_request.merge_error).to include('This merge request cannot be merged')
end
end
end
end
......@@ -67,6 +67,28 @@ feature 'Create New Merge Request', :js do
expect(page).to have_content 'git checkout -b orphaned-branch origin/orphaned-branch'
end
it 'allows filtering multiple dropdowns' do
visit project_new_merge_request_path(project)
first('.js-source-branch').click
input = find('.dropdown-source-branch .dropdown-input-field')
input.click
input.send_keys('orphaned-branch')
find('.dropdown-source-branch .dropdown-content li', match: :first)
source_items = all('.dropdown-source-branch .dropdown-content li')
expect(source_items.count).to eq(1)
first('.js-target-branch').click
find('.dropdown-target-branch .dropdown-content li', match: :first)
target_items = all('.dropdown-target-branch .dropdown-content li')
expect(target_items.count).to be > 1
end
context 'when approvals are disabled for the target project' do
it 'does not show approval settings' do
visit project_new_merge_request_path(project, merge_request: { target_branch: 'master', source_branch: 'feature_conflict' })
......
......@@ -171,7 +171,7 @@ describe('Api', () => {
it('creates a new group label', (done) => {
const namespace = 'some namespace';
const labelData = { some: 'data' };
const expectedUrl = `${dummyUrlRoot}/groups/${namespace}/labels`;
const expectedUrl = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace);
const expectedData = {
label: labelData,
};
......
import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
import * as datetimeUtility from '~/lib/utils/datetime_utility';
(() => {
describe('Date time utils', () => {
......@@ -89,10 +89,22 @@ import { timeIntervalInWords } from '~/lib/utils/datetime_utility';
describe('timeIntervalInWords', () => {
it('should return string with number of minutes and seconds', () => {
expect(timeIntervalInWords(9.54)).toEqual('9 seconds');
expect(timeIntervalInWords(1)).toEqual('1 second');
expect(timeIntervalInWords(200)).toEqual('3 minutes 20 seconds');
expect(timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds');
expect(datetimeUtility.timeIntervalInWords(9.54)).toEqual('9 seconds');
expect(datetimeUtility.timeIntervalInWords(1)).toEqual('1 second');
expect(datetimeUtility.timeIntervalInWords(200)).toEqual('3 minutes 20 seconds');
expect(datetimeUtility.timeIntervalInWords(6008)).toEqual('100 minutes 8 seconds');
});
});
describe('dateInWords', () => {
const date = new Date('07/01/2016');
it('should return date in words', () => {
expect(datetimeUtility.dateInWords(date)).toEqual('July 1, 2016');
});
it('should return abbreviated month name', () => {
expect(datetimeUtility.dateInWords(date, true)).toEqual('Jul 1, 2016');
});
});
})();
import Vue from 'vue';
import epicShowApp from 'ee/epics/epic_show/components/epic_show_app.vue';
import epicHeader from 'ee/epics/epic_show/components/epic_header.vue';
import epicSidebar from 'ee/epics/sidebar/components/sidebar_app.vue';
import issuableApp from '~/issue_show/components/app.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
import { props } from '../mock_data';
......@@ -10,6 +11,7 @@ describe('EpicShowApp', () => {
let vm;
let headerVm;
let issuableAppVm;
let sidebarVm;
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify(issueShowData.initialRequest), {
......@@ -26,6 +28,8 @@ describe('EpicShowApp', () => {
endpoint,
initialTitleHtml,
initialTitleText,
startDate,
endDate,
markdownPreviewPath,
markdownDocsPath,
author,
......@@ -57,6 +61,14 @@ describe('EpicShowApp', () => {
projectNamespace: '',
showInlineEditButton: true,
});
const EpicSidebar = Vue.extend(epicSidebar);
sidebarVm = mountComponent(EpicSidebar, {
endpoint,
editable: canUpdate,
initialStartDate: startDate,
initialEndDate: endDate,
});
});
afterEach(() => {
......@@ -70,4 +82,8 @@ describe('EpicShowApp', () => {
it('should render issuable-app', () => {
expect(vm.$el.innerHTML.indexOf(issuableAppVm.$el.innerHTML) !== -1).toEqual(true);
});
it('should render epic-sidebar', () => {
expect(vm.$el.innerHTML.indexOf(sidebarVm.$el.innerHTML) !== -1).toEqual(true);
});
});
......@@ -7,6 +7,8 @@ export const contentProps = {
groupPath: '',
initialTitleHtml: '',
initialTitleText: '',
startDate: '2017-01-01',
endDate: '2017-10-10',
};
export const headerProps = {
......
import Vue from 'vue';
import Cookies from 'js-cookie';
import epicSidebar from 'ee/epics/sidebar/components/sidebar_app.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('epicSidebar', () => {
let vm;
let originalCookieState;
let EpicSidebar;
beforeEach(() => {
setFixtures(`
<div class="page-with-sidebar right-sidebar-expanded">
<div id="epic-sidebar"></div>
</div>
`);
originalCookieState = Cookies.get('collapsed_gutter');
Cookies.set('collapsed_gutter', null);
EpicSidebar = Vue.extend(epicSidebar);
vm = mountComponent(EpicSidebar, {
endpoint: gl.TEST_HOST,
}, '#epic-sidebar');
});
afterEach(() => {
Cookies.set('collapsed_gutter', originalCookieState);
});
it('should render right-sidebar-expanded class when not collapsed', () => {
expect(vm.$el.classList.contains('right-sidebar-expanded')).toEqual(true);
});
it('should render min date sidebar-date-picker', () => {
vm = mountComponent(EpicSidebar, {
endpoint: gl.TEST_HOST,
initialStartDate: '2017-01-01',
});
expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2017');
});
it('should render max date sidebar-date-picker', () => {
vm = mountComponent(EpicSidebar, {
endpoint: gl.TEST_HOST,
initialEndDate: '2018-01-01',
});
expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2018');
});
it('should render both sidebar-date-picker', () => {
vm = mountComponent(EpicSidebar, {
endpoint: gl.TEST_HOST,
initialStartDate: '2017-01-01',
initialEndDate: '2018-01-01',
});
const datePickers = vm.$el.querySelectorAll('.block');
expect(datePickers[0].querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2017');
expect(datePickers[1].querySelector('.value-content strong').innerText.trim()).toEqual('Jan 1, 2018');
});
describe('when collapsed', () => {
beforeEach(() => {
Cookies.set('collapsed_gutter', 'true');
vm = mountComponent(EpicSidebar, {
endpoint: gl.TEST_HOST,
initialStartDate: '2017-01-01',
});
});
it('should render right-sidebar-collapsed class', () => {
expect(vm.$el.classList.contains('right-sidebar-collapsed')).toEqual(true);
});
it('should render collapsed grouped date picker', () => {
expect(vm.$el.querySelector('.sidebar-collapsed-icon span').innerText.trim()).toEqual('From Jan 1 2017');
});
});
describe('toggleSidebar', () => {
it('should toggle collapsed_gutter cookie', () => {
expect(vm.$el.classList.contains('right-sidebar-expanded')).toEqual(true);
vm.$el.querySelector('.gutter-toggle').click();
expect(Cookies.get('collapsed_gutter')).toEqual('true');
});
it('should toggle contentContainer css class', () => {
const contentContainer = document.querySelector('.page-with-sidebar');
expect(contentContainer.classList.contains('right-sidebar-expanded')).toEqual(true);
expect(contentContainer.classList.contains('right-sidebar-collapsed')).toEqual(false);
vm.$el.querySelector('.gutter-toggle').click();
expect(contentContainer.classList.contains('right-sidebar-expanded')).toEqual(false);
expect(contentContainer.classList.contains('right-sidebar-collapsed')).toEqual(true);
});
});
describe('saveDate', () => {
let interceptor;
let component;
beforeEach(() => {
interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({}), {
status: 200,
}));
};
Vue.http.interceptors.push(interceptor);
component = new EpicSidebar({
propsData: {
endpoint: gl.TEST_HOST,
},
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should save startDate', (done) => {
const date = '2017-01-01';
expect(component.store.startDate).toBeUndefined();
component.saveStartDate(date)
.then(() => {
expect(component.store.startDate).toEqual(date);
done();
})
.catch(done.fail);
});
it('should save endDate', (done) => {
const date = '2017-01-01';
expect(component.store.endDate).toBeUndefined();
component.saveEndDate(date)
.then(() => {
expect(component.store.endDate).toEqual(date);
done();
})
.catch(done.fail);
});
it('should handle errors gracefully', () => {});
});
describe('saveDate error', () => {
let interceptor;
let component;
beforeEach(() => {
interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({}), {
status: 500,
}));
};
Vue.http.interceptors.push(interceptor);
component = new EpicSidebar({
propsData: {
endpoint: gl.TEST_HOST,
},
});
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('should handle errors gracefully', (done) => {
const date = '2017-01-01';
expect(component.store.startDate).toBeUndefined();
component.saveDate('start', date)
.then(() => {
expect(component.store.startDate).toBeUndefined();
done();
})
.catch(done.fail);
});
});
});
import SidebarStore from 'ee/epics/sidebar/stores/sidebar_store';
describe('Sidebar Store', () => {
const dateString = '2017-01-20';
describe('constructor', () => {
it('should set startDate', () => {
const store = new SidebarStore({
startDate: dateString,
});
expect(store.startDate).toEqual(dateString);
});
it('should set endDate', () => {
const store = new SidebarStore({
endDate: dateString,
});
expect(store.endDate).toEqual(dateString);
});
});
describe('startDateTime', () => {
it('should return null when there is no startDate', () => {
const store = new SidebarStore({});
expect(store.startDateTime).toEqual(null);
});
it('should return date', () => {
const store = new SidebarStore({
startDate: dateString,
});
const date = store.startDateTime;
expect(date.getDate()).toEqual(20);
expect(date.getMonth()).toEqual(0);
expect(date.getFullYear()).toEqual(2017);
});
});
describe('endDateTime', () => {
it('should return null when there is no endDate', () => {
const store = new SidebarStore({});
expect(store.endDateTime).toEqual(null);
});
it('should return date', () => {
const store = new SidebarStore({
endDate: dateString,
});
const date = store.endDateTime;
expect(date.getDate()).toEqual(20);
expect(date.getMonth()).toEqual(0);
expect(date.getFullYear()).toEqual(2017);
});
});
});
import _ from 'underscore';
import Vue from 'vue';
import RelatedIssuesService from '~/issuable/related_issues/services/related_issues_service';
const issuable1 = {
id: 200,
reference: 'foo/bar#123',
title: 'some title',
path: '/foo/bar/issues/123',
state: 'opened',
destroy_relation_path: '/foo/bar/issues/123/related_issues/1',
};
describe('RelatedIssuesService', () => {
let service;
beforeEach(() => {
service = new RelatedIssuesService('');
});
describe('fetchRelatedIssues', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify([issuable1]), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('fetch related issues', (done) => {
service.fetchRelatedIssues()
.then(res => res.json())
.then((relatedIssues) => {
expect(relatedIssues).toEqual([issuable1]);
done();
})
.catch((err) => {
done.fail(`Failed to fetch related issues:\n${err}`);
});
});
});
describe('addRelatedIssues', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
message: `${issuable1.reference} was successfully related`,
status: 'success',
}), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('add related issues', (done) => {
service.addRelatedIssues([issuable1.reference])
.then(res => res.json())
.then((resData) => {
expect(resData.status).toEqual('success');
done();
})
.catch((err) => {
done.fail(`Failed to add related issues:\n${err}`);
});
});
});
describe('removeRelatedIssue', () => {
const interceptor = (request, next) => {
next(request.respondWith(JSON.stringify({
message: 'Relation was removed',
status: 'success',
}), {
status: 200,
}));
};
beforeEach(() => {
Vue.http.interceptors.push(interceptor);
});
afterEach(() => {
Vue.http.interceptors = _.without(Vue.http.interceptors, interceptor);
});
it('remove related issue', (done) => {
service.removeRelatedIssue('...')
.then(res => res.json())
.then((resData) => {
expect(resData.status).toEqual('success');
done();
})
.catch((err) => {
done.fail(`Failed to fetch issue:\n${err}`);
});
});
});
});
import { highCountTrim } from '~/lib/utils/text_utility';
import * as textUtility from '~/lib/utils/text_utility';
describe('text_utility', () => {
describe('gl.text.getTextWidth', () => {
......@@ -37,12 +37,18 @@ describe('text_utility', () => {
describe('highCountTrim', () => {
it('returns 99+ for count >= 100', () => {
expect(highCountTrim(105)).toBe('99+');
expect(highCountTrim(100)).toBe('99+');
expect(textUtility.highCountTrim(105)).toBe('99+');
expect(textUtility.highCountTrim(100)).toBe('99+');
});
it('returns exact number for count < 100', () => {
expect(highCountTrim(45)).toBe(45);
expect(textUtility.highCountTrim(45)).toBe(45);
});
});
describe('capitalizeFirstCharacter', () => {
it('returns string with first letter capitalized', () => {
expect(textUtility.capitalizeFirstCharacter('gitlab')).toEqual('Gitlab');
});
});
......
import Vue from 'vue';
import SidebarService from '~/sidebar/services/sidebar_service';
import Mock from './mock_data';
describe('Sidebar service', () => {
beforeEach(() => {
Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
this.service = new SidebarService({
endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription',
moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move',
projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15',
});
});
afterEach(() => {
SidebarService.singleton = null;
Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor);
});
it('gets the data', (done) => {
this.service.get()
.then((resp) => {
expect(resp).toBeDefined();
done();
})
.then(done)
.catch(done.fail);
});
it('updates the data', (done) => {
this.service.update('issue[assignee_ids]', [1])
.then((resp) => {
expect(resp).toBeDefined();
})
.then(done)
.catch(done.fail);
});
it('gets projects for autocomplete', (done) => {
this.service.getProjectsAutocomplete()
.then((resp) => {
expect(resp).toBeDefined();
})
.then(done)
.catch(done.fail);
});
it('moves the issue to another project', (done) => {
this.service.moveIssue(123)
.then((resp) => {
expect(resp).toBeDefined();
})
.then(done)
.catch(done.fail);
});
it('toggles the subscription', (done) => {
this.service.toggleSubscription()
.then((resp) => {
expect(resp).toBeDefined();
})
.then(done)
.catch(done.fail);
});
});
......@@ -145,41 +145,41 @@ describe('MRWidgetPipeline', () => {
expect(vm.$el.querySelector('.js-mini-pipeline-graph')).toEqual(null);
});
});
});
describe('when upstream pipelines are passed', () => {
beforeEach(() => {
vm = mountComponent(Component, {
pipeline: Object.assign({}, mockData.pipeline, {
triggered_by: mockLinkedPipelines.triggered_by,
}),
hasCi: true,
ciStatus: 'success',
describe('when upstream pipelines are passed', () => {
beforeEach(() => {
vm = mountComponent(Component, {
pipeline: Object.assign({}, mockData.pipeline, {
triggered_by: mockLinkedPipelines.triggered_by,
}),
hasCi: true,
ciStatus: 'success',
});
});
});
it('should coerce triggeredBy into a collection', () => {
expect(vm.triggeredBy.length).toBe(1);
});
it('should coerce triggeredBy into a collection', () => {
expect(vm.triggeredBy.length).toBe(1);
});
it('should render the linked pipelines mini list', () => {
expect(vm.$el.querySelector('.linked-pipeline-mini-list.is-upstream')).not.toBeNull();
it('should render the linked pipelines mini list', () => {
expect(vm.$el.querySelector('.linked-pipeline-mini-list.is-upstream')).not.toBeNull();
});
});
});
describe('when downstream pipelines are passed', () => {
beforeEach(() => {
vm = mountComponent(Component, {
pipeline: Object.assign({}, mockData.pipeline, {
triggered: mockLinkedPipelines.triggered,
}),
hasCi: true,
ciStatus: 'success',
describe('when downstream pipelines are passed', () => {
beforeEach(() => {
vm = mountComponent(Component, {
pipeline: Object.assign({}, mockData.pipeline, {
triggered: mockLinkedPipelines.triggered,
}),
hasCi: true,
ciStatus: 'success',
});
});
});
it('should render the linked pipelines mini list', () => {
expect(vm.$el.querySelector('.linked-pipeline-mini-list.is-downstream')).not.toBeNull();
it('should render the linked pipelines mini list', () => {
expect(vm.$el.querySelector('.linked-pipeline-mini-list.is-downstream')).not.toBeNull();
});
});
});
});
import Vue from 'vue';
import VueResource from 'vue-resource';
import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service';
Vue.use(VueResource);
describe('MRWidgetService', () => {
const mr = {
mergePath: './',
mergeCheckPath: './',
cancelAutoMergePath: './',
removeWIPPath: './',
sourceBranchPath: './',
ciEnvironmentsStatusPath: './',
statusPath: './',
mergeActionsContentPath: './',
isServiceStore: true,
};
it('should have store and resources created in constructor', () => {
const service = new MRWidgetService(mr);
expect(service.mergeResource).toBeDefined();
expect(service.mergeCheckResource).toBeDefined();
expect(service.cancelAutoMergeResource).toBeDefined();
expect(service.removeWIPResource).toBeDefined();
expect(service.removeSourceBranchResource).toBeDefined();
expect(service.deploymentsResource).toBeDefined();
expect(service.pollResource).toBeDefined();
expect(service.mergeActionsContentResource).toBeDefined();
});
it('should have methods defined', () => {
window.history.pushState({}, null, '/');
const service = new MRWidgetService(mr);
expect(service.merge()).toBeDefined();
expect(service.cancelAutomaticMerge()).toBeDefined();
expect(service.removeWIP()).toBeDefined();
expect(service.removeSourceBranch()).toBeDefined();
expect(service.fetchDeployments()).toBeDefined();
expect(service.poll()).toBeDefined();
expect(service.checkStatus()).toBeDefined();
expect(service.fetchMergeActionsContent()).toBeDefined();
expect(MRWidgetService.stopEnvironment()).toBeDefined();
});
});
import Vue from 'vue';
import datePicker from '~/vue_shared/components/pikaday.vue';
import mountComponent from '../../helpers/vue_mount_component_helper';
describe('datePicker', () => {
let vm;
beforeEach(() => {
const DatePicker = Vue.extend(datePicker);
vm = mountComponent(DatePicker, {
label: 'label',
});
});
it('should render label text', () => {
expect(vm.$el.querySelector('.dropdown-toggle-text').innerText.trim()).toEqual('label');
});
it('should show calendar', () => {
expect(vm.$el.querySelector('.pika-single')).toBeDefined();
});
it('should toggle when dropdown is clicked', () => {
const hidePicker = jasmine.createSpy();
vm.$on('hidePicker', hidePicker);
vm.$el.querySelector('.dropdown-menu-toggle').click();
expect(hidePicker).toHaveBeenCalled();
});
});
import Vue from 'vue';
import collapsedCalendarIcon from '~/vue_shared/components/sidebar/collapsed_calendar_icon.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('collapsedCalendarIcon', () => {
let vm;
beforeEach(() => {
const CollapsedCalendarIcon = Vue.extend(collapsedCalendarIcon);
vm = mountComponent(CollapsedCalendarIcon, {
containerClass: 'test-class',
text: 'text',
showIcon: false,
});
});
it('should add class to container', () => {
expect(vm.$el.classList.contains('test-class')).toEqual(true);
});
it('should hide calendar icon if showIcon', () => {
expect(vm.$el.querySelector('.fa-calendar')).toBeNull();
});
it('should render text', () => {
expect(vm.$el.querySelector('span').innerText.trim()).toEqual('text');
});
it('should emit click event when container is clicked', () => {
const click = jasmine.createSpy();
vm.$on('click', click);
vm.$el.click();
expect(click).toHaveBeenCalled();
});
});
import Vue from 'vue';
import collapsedGroupedDatePicker from '~/vue_shared/components/sidebar/collapsed_grouped_date_picker.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('collapsedGroupedDatePicker', () => {
let vm;
beforeEach(() => {
const CollapsedGroupedDatePicker = Vue.extend(collapsedGroupedDatePicker);
vm = mountComponent(CollapsedGroupedDatePicker, {
showToggleSidebar: true,
});
});
it('should render toggle sidebar if showToggleSidebar', (done) => {
expect(vm.$el.querySelector('.issuable-sidebar-header')).toBeDefined();
vm.showToggleSidebar = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.issuable-sidebar-header')).toBeNull();
done();
});
});
it('toggleCollapse events', () => {
const toggleCollapse = jasmine.createSpy();
beforeEach((done) => {
vm.minDate = new Date('07/17/2016');
Vue.nextTick(done);
});
it('should emit when sidebar is toggled', () => {
vm.$el.querySelector('.gutter-toggle').click();
expect(toggleCollapse).toHaveBeenCalled();
});
it('should emit when collapsed-calendar-icon is clicked', () => {
vm.$el.querySelector('.sidebar-collapsed-icon').click();
expect(toggleCollapse).toHaveBeenCalled();
});
});
describe('minDate and maxDate', () => {
beforeEach((done) => {
vm.minDate = new Date('07/17/2016');
vm.maxDate = new Date('07/17/2017');
Vue.nextTick(done);
});
it('should render both collapsed-calendar-icon', () => {
const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon');
expect(icons.length).toEqual(2);
expect(icons[0].innerText.trim()).toEqual('Jul 17 2016');
expect(icons[1].innerText.trim()).toEqual('Jul 17 2017');
});
});
describe('minDate', () => {
beforeEach((done) => {
vm.minDate = new Date('07/17/2016');
Vue.nextTick(done);
});
it('should render minDate in collapsed-calendar-icon', () => {
const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon');
expect(icons.length).toEqual(1);
expect(icons[0].innerText.trim()).toEqual('From Jul 17 2016');
});
});
describe('maxDate', () => {
beforeEach((done) => {
vm.maxDate = new Date('07/17/2017');
Vue.nextTick(done);
});
it('should render maxDate in collapsed-calendar-icon', () => {
const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon');
expect(icons.length).toEqual(1);
expect(icons[0].innerText.trim()).toEqual('Until Jul 17 2017');
});
});
describe('no dates', () => {
it('should render None', () => {
const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon');
expect(icons.length).toEqual(1);
expect(icons[0].innerText.trim()).toEqual('None');
});
});
});
import Vue from 'vue';
import sidebarDatePicker from '~/vue_shared/components/sidebar/date_picker.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('sidebarDatePicker', () => {
let vm;
beforeEach(() => {
const SidebarDatePicker = Vue.extend(sidebarDatePicker);
vm = mountComponent(SidebarDatePicker, {
label: 'label',
isLoading: true,
});
});
it('should emit toggleCollapse when collapsed toggle sidebar is clicked', () => {
const toggleCollapse = jasmine.createSpy();
vm.$on('toggleCollapse', toggleCollapse);
vm.$el.querySelector('.issuable-sidebar-header .gutter-toggle').click();
expect(toggleCollapse).toHaveBeenCalled();
});
it('should render collapsed-calendar-icon', () => {
expect(vm.$el.querySelector('.sidebar-collapsed-icon')).toBeDefined();
});
it('should render label', () => {
expect(vm.$el.querySelector('.title').innerText.trim()).toEqual('label');
});
it('should render loading-icon when isLoading', () => {
expect(vm.$el.querySelector('.fa-spin')).toBeDefined();
});
it('should render value when not editing', () => {
expect(vm.$el.querySelector('.value-content')).toBeDefined();
});
it('should render None if there is no selectedDate', () => {
expect(vm.$el.querySelector('.value-content span').innerText.trim()).toEqual('None');
});
it('should render date-picker when editing', (done) => {
vm.editing = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.pika-label')).toBeDefined();
done();
});
});
describe('editable', () => {
beforeEach((done) => {
vm.editable = true;
Vue.nextTick(done);
});
it('should render edit button', () => {
expect(vm.$el.querySelector('.title .btn-blank').innerText.trim()).toEqual('Edit');
});
it('should enable editing when edit button is clicked', (done) => {
vm.isLoading = false;
Vue.nextTick(() => {
vm.$el.querySelector('.title .btn-blank').click();
expect(vm.editing).toEqual(true);
done();
});
});
});
it('should render date if selectedDate', (done) => {
vm.selectedDate = new Date('07/07/2017');
Vue.nextTick(() => {
expect(vm.$el.querySelector('.value-content strong').innerText.trim()).toEqual('Jul 7, 2017');
done();
});
});
describe('selectedDate and editable', () => {
beforeEach((done) => {
vm.selectedDate = new Date('07/07/2017');
vm.editable = true;
Vue.nextTick(done);
});
it('should render remove button if selectedDate and editable', () => {
expect(vm.$el.querySelector('.value-content .btn-blank').innerText.trim()).toEqual('remove');
});
it('should emit saveDate when remove button is clicked', () => {
const saveDate = jasmine.createSpy();
vm.$on('saveDate', saveDate);
vm.$el.querySelector('.value-content .btn-blank').click();
expect(saveDate).toHaveBeenCalled();
});
});
describe('showToggleSidebar', () => {
beforeEach((done) => {
vm.showToggleSidebar = true;
Vue.nextTick(done);
});
it('should render toggle-sidebar when showToggleSidebar', () => {
expect(vm.$el.querySelector('.title .gutter-toggle')).toBeDefined();
});
it('should emit toggleCollapse when toggle sidebar is clicked', () => {
const toggleCollapse = jasmine.createSpy();
vm.$on('toggleCollapse', toggleCollapse);
vm.$el.querySelector('.title .gutter-toggle').click();
expect(toggleCollapse).toHaveBeenCalled();
});
});
});
import Vue from 'vue';
import toggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue';
import mountComponent from '../../../helpers/vue_mount_component_helper';
describe('toggleSidebar', () => {
let vm;
beforeEach(() => {
const ToggleSidebar = Vue.extend(toggleSidebar);
vm = mountComponent(ToggleSidebar, {
collapsed: true,
});
});
it('should render << when collapsed', () => {
expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-left')).toEqual(true);
});
it('should render >> when collapsed', () => {
vm.collapsed = false;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-right')).toEqual(true);
});
});
it('should emit toggle event when button clicked', () => {
const toggle = jasmine.createSpy();
vm.$on('toggle', toggle);
vm.$el.click();
expect(toggle).toHaveBeenCalled();
});
});
......@@ -185,6 +185,17 @@ describe Gitlab::GithubImport::Client do
client.with_rate_limit { }
end
it 'ignores rate limiting when disabled' do
expect(client)
.to receive(:rate_limiting_enabled?)
.and_return(false)
expect(client)
.not_to receive(:requests_remaining?)
expect(client.with_rate_limit { 10 }).to eq(10)
end
end
describe '#requests_remaining?' do
......@@ -260,27 +271,122 @@ describe Gitlab::GithubImport::Client do
end
end
describe '#method_missing' do
it 'delegates missing methods to the request method' do
client = described_class.new('foo')
describe '#api_endpoint' do
let(:client) { described_class.new('foo') }
context 'without a custom endpoint configured in Omniauth' do
it 'returns the default API endpoint' do
expect(client)
.to receive(:custom_api_endpoint)
.and_return(nil)
expect(client).to receive(:milestones).with(state: 'all')
expect(client.api_endpoint).to eq('https://api.github.com')
end
end
client.milestones(state: 'all')
context 'with a custom endpoint configured in Omniauth' do
it 'returns the custom endpoint' do
endpoint = 'https://github.kittens.com'
expect(client)
.to receive(:custom_api_endpoint)
.and_return(endpoint)
expect(client.api_endpoint).to eq(endpoint)
end
end
end
describe '#respond_to_missing?' do
it 'returns true for methods supported by Octokit' do
client = described_class.new('foo')
describe '#custom_api_endpoint' do
let(:client) { described_class.new('foo') }
expect(client.respond_to?(:milestones)).to eq(true)
context 'without a custom endpoint' do
it 'returns nil' do
expect(client)
.to receive(:github_omniauth_provider)
.and_return({})
expect(client.custom_api_endpoint).to be_nil
end
end
it 'returns false for methods not supported by Octokit' do
context 'with a custom endpoint' do
it 'returns the API endpoint' do
endpoint = 'https://github.kittens.com'
expect(client)
.to receive(:github_omniauth_provider)
.and_return({ 'args' => { 'client_options' => { 'site' => endpoint } } })
expect(client.custom_api_endpoint).to eq(endpoint)
end
end
end
describe '#default_api_endpoint' do
it 'returns the default API endpoint' do
client = described_class.new('foo')
expect(client.respond_to?(:kittens)).to eq(false)
expect(client.default_api_endpoint).to eq('https://api.github.com')
end
end
describe '#verify_ssl' do
let(:client) { described_class.new('foo') }
context 'without a custom configuration' do
it 'returns true' do
expect(client)
.to receive(:github_omniauth_provider)
.and_return({})
expect(client.verify_ssl).to eq(true)
end
end
context 'with a custom configuration' do
it 'returns the configured value' do
expect(client.verify_ssl).to eq(false)
end
end
end
describe '#github_omniauth_provider' do
let(:client) { described_class.new('foo') }
context 'without a configured provider' do
it 'returns an empty Hash' do
expect(Gitlab.config.omniauth)
.to receive(:providers)
.and_return([])
expect(client.github_omniauth_provider).to eq({})
end
end
context 'with a configured provider' do
it 'returns the provider details as a Hash' do
hash = client.github_omniauth_provider
expect(hash['name']).to eq('github')
expect(hash['url']).to eq('https://github.com/')
end
end
end
describe '#rate_limiting_enabled?' do
let(:client) { described_class.new('foo') }
it 'returns true when using GitHub.com' do
expect(client.rate_limiting_enabled?).to eq(true)
end
it 'returns false for GitHub enterprise installations' do
expect(client)
.to receive(:api_endpoint)
.and_return('https://github.kittens.com/')
expect(client.rate_limiting_enabled?).to eq(false)
end
end
end
require 'spec_helper'
describe Gitlab::IssuableMetadata do
let(:user) { create(:user) }
let!(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace) }
let(:user) { create(:user) }
let!(:project) { create(:project, :public, :repository, creator: user, namespace: user.namespace) }
subject { Class.new { include Gitlab::IssuableMetadata }.new }
......@@ -10,6 +10,10 @@ describe Gitlab::IssuableMetadata do
expect(subject.issuable_meta_data(Issue.none, 'Issue')).to eq({})
end
it 'raises an error when given a collection with no limit' do
expect { subject.issuable_meta_data(Issue.all, 'Issue') }.to raise_error(/must have a limit/)
end
context 'issues' do
let!(:issue) { create(:issue, author: user, project: project) }
let!(:closed_issue) { create(:issue, state: :closed, author: user, project: project) }
......@@ -19,7 +23,7 @@ describe Gitlab::IssuableMetadata do
let!(:closing_issues) { create(:merge_requests_closing_issues, issue: issue, merge_request: merge_request) }
it 'aggregates stats on issues' do
data = subject.issuable_meta_data(Issue.all, 'Issue')
data = subject.issuable_meta_data(Issue.all.limit(10), 'Issue')
expect(data.count).to eq(2)
expect(data[issue.id].upvotes).to eq(1)
......@@ -42,7 +46,7 @@ describe Gitlab::IssuableMetadata do
let!(:note) { create(:note_on_merge_request, author: user, project: project, noteable: merge_request, note: "a comment on a MR") }
it 'aggregates stats on merge requests' do
data = subject.issuable_meta_data(MergeRequest.all, 'MergeRequest')
data = subject.issuable_meta_data(MergeRequest.all.limit(10), 'MergeRequest')
expect(data.count).to eq(2)
expect(data[merge_request.id].upvotes).to eq(1)
......
......@@ -4,32 +4,40 @@ describe Gitlab::Metrics::SidekiqMiddleware do
let(:middleware) { described_class.new }
let(:message) { { 'args' => ['test'], 'enqueued_at' => Time.new(2016, 6, 23, 6, 59).to_f } }
def run(worker, message)
expect(Gitlab::Metrics::BackgroundTransaction).to receive(:new)
.with(worker.class)
.and_call_original
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set)
.with(:sidekiq_queue_duration, instance_of(Float))
describe '#call' do
it 'tracks the transaction' do
worker = double(:worker, class: double(:class, name: 'TestWorker'))
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
expect(Gitlab::Metrics::BackgroundTransaction).to receive(:new)
.with(worker.class)
.and_call_original
middleware.call(worker, message, :test) { nil }
end
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set)
.with(:sidekiq_queue_duration, instance_of(Float))
describe '#call' do
let(:test_worker_class) { double(:class, name: 'TestWorker') }
let(:worker) { double(:worker, class: test_worker_class) }
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
it 'tracks the transaction' do
run(worker, message)
middleware.call(worker, message, :test) { nil }
end
it 'tracks the transaction (for messages without `enqueued_at`)' do
run(worker, {})
worker = double(:worker, class: double(:class, name: 'TestWorker'))
expect(Gitlab::Metrics::BackgroundTransaction).to receive(:new)
.with(worker.class)
.and_call_original
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:set)
.with(:sidekiq_queue_duration, instance_of(Float))
expect_any_instance_of(Gitlab::Metrics::Transaction).to receive(:finish)
middleware.call(worker, {}, :test) { nil }
end
it 'tracks any raised exceptions' do
worker = double(:worker, class: double(:class, name: 'TestWorker'))
expect_any_instance_of(Gitlab::Metrics::Transaction)
.to receive(:run).and_raise(RuntimeError)
......
......@@ -91,13 +91,6 @@ describe Gitlab::Middleware::ReadOnly do
end
context 'whitelisted requests' do
it 'expects DELETE request to logout to be allowed' do
response = request.delete('/users/sign_out')
expect(response).not_to be_a_redirect
expect(subject).not_to disallow_request
end
it 'expects a POST internal request to be allowed' do
response = request.post("/api/#{API::API.version}/internal")
......
......@@ -471,6 +471,142 @@ describe API::Groups do
end
end
describe 'GET /groups/:id/subgroups', :nested_groups do
let!(:subgroup1) { create(:group, parent: group1) }
let!(:subgroup2) { create(:group, :private, parent: group1) }
let!(:subgroup3) { create(:group, :private, parent: group2) }
context 'when unauthenticated' do
it 'returns only public subgroups' do
get api("/groups/#{group1.id}/subgroups")
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(subgroup1.id)
expect(json_response.first['parent_id']).to eq(group1.id)
end
it 'returns 404 for a private group' do
get api("/groups/#{group2.id}/subgroups")
expect(response).to have_gitlab_http_status(404)
end
end
context 'when authenticated as user' do
context 'when user is not member of a public group' do
it 'returns no subgroups for the public group' do
get api("/groups/#{group1.id}/subgroups", user2)
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(0)
end
context 'when using all_available in request' do
it 'returns public subgroups' do
get api("/groups/#{group1.id}/subgroups", user2), all_available: true
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
expect(json_response[0]['id']).to eq(subgroup1.id)
expect(json_response[0]['parent_id']).to eq(group1.id)
end
end
end
context 'when user is not member of a private group' do
it 'returns 404 for the private group' do
get api("/groups/#{group2.id}/subgroups", user1)
expect(response).to have_gitlab_http_status(404)
end
end
context 'when user is member of public group' do
before do
group1.add_guest(user2)
end
it 'returns private subgroups' do
get api("/groups/#{group1.id}/subgroups", user2)
expect(response).to have_gitlab_http_status(200)
expect(response).to include_pagination_headers
expect(json_response).to be_an Array
expect(json_response.length).to eq(2)
private_subgroups = json_response.select { |group| group['visibility'] == 'private' }
expect(private_subgroups.length).to eq(1)
expect(private_subgroups.first['id']).to eq(subgroup2.id)
expect(private_subgroups.first['parent_id']).to eq(group1.id)
end
context 'when using statistics in request' do
it 'does not include statistics' do
get api("/groups/#{group1.id}/subgroups", user2), statistics: true
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.first).not_to include 'statistics'
end
end
end
context 'when user is member of private group' do
before do
group2.add_guest(user1)
end
it 'returns subgroups' do
get api("/groups/#{group2.id}/subgroups", user1)
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
expect(json_response.first['id']).to eq(subgroup3.id)
expect(json_response.first['parent_id']).to eq(group2.id)
end
end
end
context 'when authenticated as admin' do
it 'returns private subgroups of a public group' do
get api("/groups/#{group1.id}/subgroups", admin)
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(2)
end
it 'returns subgroups of a private group' do
get api("/groups/#{group2.id}/subgroups", admin)
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.length).to eq(1)
end
it 'does not include statistics by default' do
get api("/groups/#{group1.id}/subgroups", admin)
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.first).not_to include('statistics')
end
it 'includes statistics if requested' do
get api("/groups/#{group1.id}/subgroups", admin), statistics: true
expect(response).to have_gitlab_http_status(200)
expect(json_response).to be_an Array
expect(json_response.first).to include('statistics')
end
end
end
describe "POST /groups" do
context "when authenticated as user without group permissions" do
it "does not create group" do
......
......@@ -257,8 +257,10 @@ describe "Authentication", "routing" do
expect(post("/users/sign_in")).to route_to('sessions#create')
end
it "DELETE /users/sign_out" do
expect(delete("/users/sign_out")).to route_to('sessions#destroy')
# sign_out with GET instead of DELETE facilitates ad-hoc single-sign-out processes
# (https://gitlab.com/gitlab-org/gitlab-ce/issues/39708)
it "GET /users/sign_out" do
expect(get("/users/sign_out")).to route_to('sessions#destroy')
end
it "POST /users/password" do
......
require 'spec_helper'
describe Geo::HashedStorageMigratedEventStore do
set(:project) { create(:project, :hashed, path: 'bar') }
let(:project) { create(:project, :hashed, path: 'bar') }
set(:secondary_node) { create(:geo_node) }
let(:old_disk_path) { "#{project.namespace.full_path}/foo" }
let(:old_wiki_disk_path) { "#{old_disk_path}.wiki" }
......
......@@ -2,20 +2,39 @@ require 'spec_helper'
describe Geo::HashedStorageMigrationService do
let(:project) { create(:project, :repository, :hashed) }
let(:new_path) { "#{project.full_path}+renamed" }
let(:old_path) { project.full_path }
let(:new_path) { "#{old_path}+renamed" }
subject(:service) { described_class.new(project.id, old_disk_path: old_path, new_disk_path: new_path, old_storage_version: nil) }
describe '#execute' do
it 'moves project backed by legacy storage' do
service = described_class.new(
project.id,
old_disk_path: project.full_path,
new_disk_path: new_path,
old_storage_version: nil
)
context 'project backed by legacy storage' do
it 'moves the project repositories' do
expect_any_instance_of(Geo::MoveRepositoryService).to receive(:execute)
.once.and_return(true)
expect_any_instance_of(Geo::MoveRepositoryService).to receive(:execute).once
service.execute
end
service.execute
it 'raises an error when project repository can not be moved' do
allow_any_instance_of(Gitlab::Shell).to receive(:mv_repository)
.with(project.repository_storage_path, old_path, new_path)
.and_return(false)
expect { service.execute }.to raise_error(Geo::RepositoryCannotBeRenamed, "Repository #{old_path} could not be renamed to #{new_path}")
end
it 'raises an error when wiki repository can not be moved' do
allow_any_instance_of(Gitlab::Shell).to receive(:mv_repository)
.with(project.repository_storage_path, old_path, new_path)
.and_return(true)
allow_any_instance_of(Gitlab::Shell).to receive(:mv_repository)
.with(project.repository_storage_path, "#{old_path}.wiki", "#{new_path}.wiki")
.and_return(false)
expect { service.execute }.to raise_error(Geo::RepositoryCannotBeRenamed, "Repository #{old_path} could not be renamed to #{new_path}")
end
end
it 'does not move project backed by hashed storage' do
......@@ -33,8 +52,6 @@ describe Geo::HashedStorageMigrationService do
end
describe '#async_execute' do
subject(:service) { described_class.new(project.id, old_disk_path: project.full_path, new_disk_path: new_path, old_storage_version: nil) }
it 'starts the worker' do
expect(Geo::HashedStorageMigrationWorker).to receive(:perform_async)
......
require 'spec_helper'
describe Geo::MoveRepositoryService do
let(:project) { create(:project, :repository) }
let(:new_path) { "#{project.full_path}+renamed" }
describe '#execute' do
it 'renames the path' do
old_path = project.repository.path_to_repo
full_new_path = File.join(project.repository_storage_path, new_path)
let(:project) { create(:project, :repository, :wiki_repo) }
let(:old_path) { project.full_path }
let(:new_path) { "#{project.full_path}+renamed" }
subject(:service) { described_class.new(project, old_path, new_path) }
service = described_class.new(project, project.full_path, new_path)
it 'renames the project repositories' do
old_disk_path = project.repository.path_to_repo
old_wiki_disk_path = project.wiki.repository.path_to_repo
full_new_path = File.join(project.repository_storage_path, new_path)
expect(File.directory?(old_path)).to be_truthy
expect(File.directory?(old_disk_path)).to be_truthy
expect(File.directory?(old_wiki_disk_path)).to be_truthy
expect(service.execute).to eq(true)
expect(File.directory?(old_path)).to be_falsey
expect(File.directory?(old_disk_path)).to be_falsey
expect(File.directory?(old_wiki_disk_path)).to be_falsey
expect(File.directory?("#{full_new_path}.git")).to be_truthy
expect(File.directory?("#{full_new_path}.wiki.git")).to be_truthy
end
it 'returns false when project repository can not be renamed' do
allow_any_instance_of(Gitlab::Shell).to receive(:mv_repository)
.with(project.repository_storage_path, old_path, new_path)
.and_return(false)
expect(service.execute).to eq false
end
it 'returns false when wiki repository can not be renamed' do
allow_any_instance_of(Gitlab::Shell).to receive(:mv_repository)
.with(project.repository_storage_path, old_path, new_path)
.and_return(true)
allow_any_instance_of(Gitlab::Shell).to receive(:mv_repository)
.with(project.repository_storage_path, "#{old_path}.wiki", "#{new_path}.wiki")
.and_return(false)
expect(service.execute).to eq false
end
end
end
......@@ -2,15 +2,39 @@ require 'spec_helper'
describe Geo::RenameRepositoryService do
let(:project) { create(:project, :repository) }
let(:new_path) { "#{project.full_path}+renamed" }
let(:old_path) { project.full_path }
let(:new_path) { "#{old_path}+renamed" }
subject(:service) { described_class.new(project.id, old_path, new_path) }
describe '#execute' do
it 'moves project backed by legacy storage' do
service = described_class.new(project.id, project.full_path, new_path)
context 'project backed by legacy storage' do
it 'moves the project repositories' do
expect_any_instance_of(Geo::MoveRepositoryService).to receive(:execute)
.once.and_return(true)
expect_any_instance_of(Geo::MoveRepositoryService).to receive(:execute).once
service.execute
end
service.execute
it 'raises an error when project repository can not be moved' do
allow_any_instance_of(Gitlab::Shell).to receive(:mv_repository)
.with(project.repository_storage_path, old_path, new_path)
.and_return(false)
expect { service.execute }.to raise_error(Geo::RepositoryCannotBeRenamed, "Repository #{old_path} could not be renamed to #{new_path}")
end
it 'raises an error when wiki repository can not be moved' do
allow_any_instance_of(Gitlab::Shell).to receive(:mv_repository)
.with(project.repository_storage_path, old_path, new_path)
.and_return(true)
allow_any_instance_of(Gitlab::Shell).to receive(:mv_repository)
.with(project.repository_storage_path, "#{old_path}.wiki", "#{new_path}.wiki")
.and_return(false)
expect { service.execute }.to raise_error(Geo::RepositoryCannotBeRenamed, "Repository #{old_path} could not be renamed to #{new_path}")
end
end
it 'does not move project backed by hashed storage' do
......@@ -24,8 +48,6 @@ describe Geo::RenameRepositoryService do
end
describe '#async_execute' do
subject(:service) { described_class.new(project.id, project.full_path, new_path) }
it 'starts the worker' do
expect(Geo::RenameRepositoryWorker).to receive(:perform_async)
......
......@@ -38,22 +38,6 @@ describe MergeRequests::MergeService do
end
end
context 'project has exceeded size limit' do
let(:service) { described_class.new(project, user, commit_message: 'Awesome message') }
before do
allow(project).to receive(:above_size_limit?).and_return(true)
perform_enqueued_jobs do
service.execute(merge_request)
end
end
it 'returns the correct error message' do
expect(merge_request.merge_error).to include('This merge request cannot be merged')
end
end
context 'closes related issues' do
let(:service) { described_class.new(project, user, commit_message: 'Awesome message') }
......@@ -297,6 +281,28 @@ describe MergeRequests::MergeService do
expect(merge_request.merge_error).to include(error_message)
expect(Rails.logger).to have_received(:error).with(a_string_matching(error_message))
end
context "when fast-forward merge is not allowed" do
before do
allow_any_instance_of(Repository).to receive(:ancestor?).and_return(nil)
end
%w(semi-linear ff).each do |merge_method|
it "logs and saves error if merge is #{merge_method} only" do
merge_method = 'rebase_merge' if merge_method == 'semi-linear'
merge_request.project.update(merge_method: merge_method)
error_message = 'Only fast-forward merge is allowed for your project. Please update your source branch'
allow(service).to receive(:execute_hooks)
service.execute(merge_request)
expect(merge_request).to be_open
expect(merge_request.merge_commit_sha).to be_nil
expect(merge_request.merge_error).to include(error_message)
expect(Rails.logger).to have_received(:error).with(a_string_matching(error_message))
end
end
end
end
end
end
......
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