Commit d6a97dab authored by GitLab Bot's avatar GitLab Bot

Merge remote-tracking branch 'upstream/master' into ce-to-ee-2018-04-24

# Conflicts:
#	app/controllers/ldap/omniauth_callbacks_controller.rb
#	config/routes/user.rb
#	doc/ci/variables/README.md
#	doc/ci/yaml/README.md
#	lib/gitlab/auth/ldap/user.rb
#	lib/gitlab/auth/saml/user.rb

[ci skip]
parents 4b542c17 8b41c406
......@@ -511,10 +511,11 @@ GEM
logging (2.2.2)
little-plugger (~> 1.1)
multi_json (~> 1.10)
lograge (0.5.1)
actionpack (>= 4, < 5.2)
activesupport (>= 4, < 5.2)
railties (>= 4, < 5.2)
lograge (0.10.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.2.2)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
......
<script>
import { mapActions, mapGetters, mapState } from 'vuex';
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import VirtualList from 'vue-virtual-scroll-list';
import Item from './item.vue';
import router from '../../ide_router';
import {
MAX_FILE_FINDER_RESULTS,
FILE_FINDER_ROW_HEIGHT,
FILE_FINDER_EMPTY_ROW_HEIGHT,
} from '../../constants';
import {
UP_KEY_CODE,
DOWN_KEY_CODE,
ENTER_KEY_CODE,
ESC_KEY_CODE,
} from '../../../lib/utils/keycodes';
export default {
components: {
Item,
VirtualList,
},
data() {
return {
focusedIndex: 0,
searchText: '',
mouseOver: false,
cancelMouseOver: false,
};
},
computed: {
...mapGetters(['allBlobs']),
...mapState(['fileFindVisible', 'loading']),
filteredBlobs() {
const searchText = this.searchText.trim();
if (searchText === '') {
return this.allBlobs.slice(0, MAX_FILE_FINDER_RESULTS);
}
return fuzzaldrinPlus
.filter(this.allBlobs, searchText, {
key: 'path',
maxResults: MAX_FILE_FINDER_RESULTS,
})
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
},
filteredBlobsLength() {
return this.filteredBlobs.length;
},
listShowCount() {
return this.filteredBlobsLength ? Math.min(this.filteredBlobsLength, 5) : 1;
},
listHeight() {
return this.filteredBlobsLength ? FILE_FINDER_ROW_HEIGHT : FILE_FINDER_EMPTY_ROW_HEIGHT;
},
showClearInputButton() {
return this.searchText.trim() !== '';
},
},
watch: {
fileFindVisible() {
this.$nextTick(() => {
if (!this.fileFindVisible) {
this.searchText = '';
} else {
this.focusedIndex = 0;
if (this.$refs.searchInput) {
this.$refs.searchInput.focus();
}
}
});
},
searchText() {
this.focusedIndex = 0;
},
focusedIndex() {
if (!this.mouseOver) {
this.$nextTick(() => {
const el = this.$refs.virtualScrollList.$el;
const scrollTop = this.focusedIndex * FILE_FINDER_ROW_HEIGHT;
const bottom = this.listShowCount * FILE_FINDER_ROW_HEIGHT;
if (this.focusedIndex === 0) {
// if index is the first index, scroll straight to start
el.scrollTop = 0;
} else if (this.focusedIndex === this.filteredBlobsLength - 1) {
// if index is the last index, scroll to the end
el.scrollTop = this.filteredBlobsLength * FILE_FINDER_ROW_HEIGHT;
} else if (scrollTop >= bottom + el.scrollTop) {
// if element is off the bottom of the scroll list, scroll down one item
el.scrollTop = scrollTop - bottom + FILE_FINDER_ROW_HEIGHT;
} else if (scrollTop < el.scrollTop) {
// if element is off the top of the scroll list, scroll up one item
el.scrollTop = scrollTop;
}
});
}
},
},
methods: {
...mapActions(['toggleFileFinder']),
clearSearchInput() {
this.searchText = '';
this.$nextTick(() => {
this.$refs.searchInput.focus();
});
},
onKeydown(e) {
switch (e.keyCode) {
case UP_KEY_CODE:
e.preventDefault();
this.mouseOver = false;
this.cancelMouseOver = true;
if (this.focusedIndex > 0) {
this.focusedIndex -= 1;
} else {
this.focusedIndex = this.filteredBlobsLength - 1;
}
break;
case DOWN_KEY_CODE:
e.preventDefault();
this.mouseOver = false;
this.cancelMouseOver = true;
if (this.focusedIndex < this.filteredBlobsLength - 1) {
this.focusedIndex += 1;
} else {
this.focusedIndex = 0;
}
break;
default:
break;
}
},
onKeyup(e) {
switch (e.keyCode) {
case ENTER_KEY_CODE:
this.openFile(this.filteredBlobs[this.focusedIndex]);
break;
case ESC_KEY_CODE:
this.toggleFileFinder(false);
break;
default:
break;
}
},
openFile(file) {
this.toggleFileFinder(false);
router.push(`/project${file.url}`);
},
onMouseOver(index) {
if (!this.cancelMouseOver) {
this.mouseOver = true;
this.focusedIndex = index;
}
},
onMouseMove(index) {
this.cancelMouseOver = false;
this.onMouseOver(index);
},
},
};
</script>
<template>
<div
class="ide-file-finder-overlay"
@mousedown.self="toggleFileFinder(false)"
>
<div
class="dropdown-menu diff-file-changes ide-file-finder show"
>
<div class="dropdown-input">
<input
type="search"
class="dropdown-input-field"
:placeholder="__('Search files')"
autocomplete="off"
v-model="searchText"
ref="searchInput"
@keydown="onKeydown($event)"
@keyup="onKeyup($event)"
/>
<i
aria-hidden="true"
class="fa fa-search dropdown-input-search"
:class="{
hidden: showClearInputButton
}"
></i>
<i
role="button"
:aria-label="__('Clear search input')"
class="fa fa-times dropdown-input-clear"
:class="{
show: showClearInputButton
}"
@click="clearSearchInput"
></i>
</div>
<div>
<virtual-list
:size="listHeight"
:remain="listShowCount"
wtag="ul"
ref="virtualScrollList"
>
<template v-if="filteredBlobsLength">
<li
v-for="(file, index) in filteredBlobs"
:key="file.key"
>
<item
class="disable-hover"
:file="file"
:search-text="searchText"
:focused="index === focusedIndex"
:index="index"
@click="openFile"
@mouseover="onMouseOver"
@mousemove="onMouseMove"
/>
</li>
</template>
<li
v-else
class="dropdown-menu-empty-item"
>
<div class="append-right-default prepend-left-default prepend-top-8 append-bottom-8">
<template v-if="loading">
{{ __('Loading...') }}
</template>
<template v-else>
{{ __('No files found.') }}
</template>
</div>
</li>
</virtual-list>
</div>
</div>
</div>
</template>
<script>
import fuzzaldrinPlus from 'fuzzaldrin-plus';
import FileIcon from '../../../vue_shared/components/file_icon.vue';
import ChangedFileIcon from '../changed_file_icon.vue';
const MAX_PATH_LENGTH = 60;
export default {
components: {
ChangedFileIcon,
FileIcon,
},
props: {
file: {
type: Object,
required: true,
},
focused: {
type: Boolean,
required: true,
},
searchText: {
type: String,
required: true,
},
index: {
type: Number,
required: true,
},
},
computed: {
pathWithEllipsis() {
const path = this.file.path;
return path.length < MAX_PATH_LENGTH
? path
: `...${path.substr(path.length - MAX_PATH_LENGTH)}`;
},
nameSearchTextOccurences() {
return fuzzaldrinPlus.match(this.file.name, this.searchText);
},
pathSearchTextOccurences() {
return fuzzaldrinPlus.match(this.pathWithEllipsis, this.searchText);
},
},
methods: {
clickRow() {
this.$emit('click', this.file);
},
mouseOverRow() {
this.$emit('mouseover', this.index);
},
mouseMove() {
this.$emit('mousemove', this.index);
},
},
};
</script>
<template>
<button
type="button"
class="diff-changed-file"
:class="{
'is-focused': focused,
}"
@click.prevent="clickRow"
@mouseover="mouseOverRow"
@mousemove="mouseMove"
>
<file-icon
:file-name="file.name"
:size="16"
css-classes="diff-file-changed-icon append-right-8"
/>
<span class="diff-changed-file-content append-right-8">
<strong
class="diff-changed-file-name"
>
<span
v-for="(char, index) in file.name.split('')"
:key="index + char"
:class="{
highlighted: nameSearchTextOccurences.indexOf(index) >= 0,
}"
v-text="char"
>
</span>
</strong>
<span
class="diff-changed-file-path prepend-top-5"
>
<span
v-for="(char, index) in pathWithEllipsis.split('')"
:key="index + char"
:class="{
highlighted: pathSearchTextOccurences.indexOf(index) >= 0,
}"
v-text="char"
>
</span>
</span>
</span>
<span
v-if="file.changed || file.tempFile"
class="diff-changed-stats"
>
<changed-file-icon
:file="file"
/>
</span>
</button>
</template>
<script>
import { mapState, mapGetters } from 'vuex';
import ideSidebar from './ide_side_bar.vue';
import ideContextbar from './ide_context_bar.vue';
import repoTabs from './repo_tabs.vue';
import ideStatusBar from './ide_status_bar.vue';
import repoEditor from './repo_editor.vue';
import { mapActions, mapState, mapGetters } from 'vuex';
import Mousetrap from 'mousetrap';
import ideSidebar from './ide_side_bar.vue';
import ideContextbar from './ide_context_bar.vue';
import repoTabs from './repo_tabs.vue';
import ideStatusBar from './ide_status_bar.vue';
import repoEditor from './repo_editor.vue';
import FindFile from './file_finder/index.vue';
export default {
components: {
ideSidebar,
ideContextbar,
repoTabs,
ideStatusBar,
repoEditor,
},
props: {
emptyStateSvgPath: {
type: String,
required: true,
const originalStopCallback = Mousetrap.stopCallback;
export default {
components: {
ideSidebar,
ideContextbar,
repoTabs,
ideStatusBar,
repoEditor,
FindFile,
},
noChangesStateSvgPath: {
type: String,
required: true,
props: {
emptyStateSvgPath: {
type: String,
required: true,
},
noChangesStateSvgPath: {
type: String,
required: true,
},
committedStateSvgPath: {
type: String,
required: true,
},
},
committedStateSvgPath: {
type: String,
required: true,
computed: {
...mapState([
'changedFiles',
'openFiles',
'viewer',
'currentMergeRequestId',
'fileFindVisible',
]),
...mapGetters(['activeFile', 'hasChanges']),
},
},
computed: {
...mapState(['changedFiles', 'openFiles', 'viewer', 'currentMergeRequestId']),
...mapGetters(['activeFile', 'hasChanges']),
},
mounted() {
const returnValue = 'Are you sure you want to lose unsaved changes?';
window.onbeforeunload = e => {
if (!this.changedFiles.length) return undefined;
mounted() {
const returnValue = 'Are you sure you want to lose unsaved changes?';
window.onbeforeunload = e => {
if (!this.changedFiles.length) return undefined;
Object.assign(e, {
returnValue,
});
return returnValue;
};
Object.assign(e, {
returnValue,
Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
if (e.preventDefault) {
e.preventDefault();
}
this.toggleFileFinder(!this.fileFindVisible);
});
return returnValue;
};
},
};
Mousetrap.stopCallback = (e, el, combo) => this.mousetrapStopCallback(e, el, combo);
},
methods: {
...mapActions(['toggleFileFinder']),
mousetrapStopCallback(e, el, combo) {
if (combo === 't' && el.classList.contains('dropdown-input-field')) {
return true;
} else if (combo === 'command+p' || combo === 'ctrl+p') {
return false;
}
return originalStopCallback(e, el, combo);
},
},
};
</script>
<template>
<div
class="ide-view"
>
<find-file
v-show="fileFindVisible"
/>
<ide-sidebar />
<div
class="multi-file-edit-pane"
......
// Fuzzy file finder
export const MAX_FILE_FINDER_RESULTS = 40;
export const FILE_FINDER_ROW_HEIGHT = 55;
export const FILE_FINDER_EMPTY_ROW_HEIGHT = 33;
// Commit message textarea
export const MAX_TITLE_LENGTH = 50;
export const MAX_BODY_LENGTH = 72;
import _ from 'underscore';
import store from '../stores';
import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable';
import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options';
import gitlabTheme from './themes/gl_theme';
import keymap from './keymap.json';
export const clearDomElement = el => {
if (!el || !el.firstChild) return;
......@@ -53,6 +55,8 @@ export default class Editor {
)),
);
this.addCommands();
window.addEventListener('resize', this.debouncedUpdate, false);
}
}
......@@ -73,6 +77,8 @@ export default class Editor {
})),
);
this.addCommands();
window.addEventListener('resize', this.debouncedUpdate, false);
}
}
......@@ -189,4 +195,31 @@ export default class Editor {
static renderSideBySide(domElement) {
return domElement.offsetWidth >= 700;
}
addCommands() {
const getKeyCode = key => {
const monacoKeyMod = key.indexOf('KEY_') === 0;
return monacoKeyMod ? this.monaco.KeyCode[key] : this.monaco.KeyMod[key];
};
keymap.forEach(command => {
const keybindings = command.bindings.map(binding => {
const keys = binding.split('+');
// eslint-disable-next-line no-bitwise
return keys.length > 1 ? getKeyCode(keys[0]) | getKeyCode(keys[1]) : getKeyCode(keys[0]);
});
this.instance.addAction({
id: command.id,
label: command.label,
keybindings,
run() {
store.dispatch(command.action.name, command.action.params);
return null;
},
});
});
}
}
[
{
"id": "file-finder",
"label": "File finder",
"bindings": ["CtrlCmd+KEY_P"],
"action": {
"name": "toggleFileFinder",
"params": true
}
}
]
......@@ -137,6 +137,9 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => {
commit(types.UPDATE_DELAY_VIEWER_CHANGE, delay);
};
export const toggleFileFinder = ({ commit }, fileFindVisible) =>
commit(types.TOGGLE_FILE_FINDER, fileFindVisible);
export * from './actions/tree';
export * from './actions/file';
export * from './actions/project';
......
......@@ -42,4 +42,17 @@ export const collapseButtonTooltip = state =>
export const hasMergeRequest = state => !!state.currentMergeRequestId;
export const allBlobs = state =>
Object.keys(state.entries)
.reduce((acc, key) => {
const entry = state.entries[key];
if (entry.type === 'blob') {
acc.push(entry);
}
return acc;
}, [])
.sort((a, b) => b.lastOpenedAt - a.lastOpenedAt);
export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path);
......@@ -58,3 +58,5 @@ export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
......@@ -100,6 +100,11 @@ export default {
delayViewerUpdated,
});
},
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
Object.assign(state, {
fileFindVisible,
});
},
[types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) {
const changedFile = state.changedFiles.find(f => f.path === file.path);
......
......@@ -4,6 +4,7 @@ export default {
[types.SET_FILE_ACTIVE](state, { path, active }) {
Object.assign(state.entries[path], {
active,
lastOpenedAt: new Date().getTime(),
});
if (active && !state.entries[path].pending) {
......
......@@ -18,4 +18,5 @@ export default () => ({
entries: {},
viewer: 'editor',
delayViewerUpdated: false,
fileFindVisible: false,
});
......@@ -42,6 +42,7 @@ export const dataStructure = () => ({
viewMode: 'edit',
previewMode: null,
size: 0,
lastOpenedAt: 0,
});
export const decorateData = entity => {
......
......@@ -45,7 +45,7 @@ export default {
return timeIntervalInWords(this.job.queued);
},
runnerId() {
return `#${this.job.runner.id}`;
return `${this.job.runner.description} (#${this.job.runner.id})`;
},
retryButtonClass() {
let className = 'js-retry-button pull-right btn btn-retry visible-md-block visible-lg-block';
......
export const UP_KEY_CODE = 38;
export const DOWN_KEY_CODE = 40;
export const ENTER_KEY_CODE = 13;
export const ESC_KEY_CODE = 27;
......@@ -482,6 +482,7 @@ img.emoji {
.append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; }
.append-bottom-5 { margin-bottom: 5px; }
.append-bottom-8 { margin-bottom: $grid-size; }
.append-bottom-10 { margin-bottom: 10px; }
.append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; }
......
......@@ -43,7 +43,7 @@
border-color: $gray-darkest;
}
[data-toggle="dropdown"] {
[data-toggle='dropdown'] {
outline: 0;
}
}
......@@ -172,7 +172,11 @@
color: $brand-danger;
}
&:hover,
&.disable-hover {
text-decoration: none;
}
&:not(.disable-hover):hover,
&:active,
&:focus,
&.is-focused {
......@@ -508,17 +512,16 @@
}
&.is-indeterminate::before {
content: "\f068";
content: '\f068';
}
&.is-active::before {
content: "\f00c";
content: '\f00c';
}
}
}
}
.dropdown-title {
position: relative;
padding: 2px 25px 10px;
......@@ -724,7 +727,6 @@
}
}
.dropdown-menu-due-date {
.dropdown-content {
max-height: 230px;
......@@ -854,9 +856,13 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
.projects-list-frequent-container,
.projects-list-search-container, {
.projects-list-search-container {
padding: 8px 0;
overflow-y: auto;
li.section-empty.section-failure {
color: $callout-danger-color;
}
}
.section-header,
......@@ -867,13 +873,6 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
font-size: $gl-font-size;
}
.projects-list-frequent-container,
.projects-list-search-container {
li.section-empty.section-failure {
color: $callout-danger-color;
}
}
.search-input-container {
position: relative;
padding: 4px $gl-padding;
......@@ -905,8 +904,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
}
.projects-list-item-container {
.project-item-avatar-container
.project-item-metadata-container {
.project-item-avatar-container .project-item-metadata-container {
float: left;
}
......
......@@ -17,6 +17,7 @@
}
.ide-view {
position: relative;
display: flex;
height: calc(100vh - #{$header-height});
margin-top: 0;
......@@ -876,6 +877,26 @@
font-weight: $gl-font-weight-bold;
}
.ide-file-finder-overlay {
position: absolute;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 100;
}
.ide-file-finder {
top: 10px;
left: 50%;
transform: translateX(-50%);
.highlighted {
color: $blue-500;
font-weight: $gl-font-weight-bold;
}
}
.ide-commit-message-field {
height: 200px;
background-color: $white-light;
......
......@@ -167,8 +167,8 @@ module IssuableCollections
[:project, :author, :assignees, :labels, :milestone, project: :namespace]
when 'MergeRequest'
[
:source_project, :target_project, :author, :assignee, :labels, :milestone,
head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits
:target_project, :author, :assignee, :labels, :milestone,
source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits
]
end
end
......
class Ldap::OmniauthCallbacksController < OmniauthCallbacksController
extend ::Gitlab::Utils::Override
<<<<<<< HEAD
prepend EE::OmniauthCallbacksController
prepend EE::Ldap::OmniauthCallbacksController
=======
>>>>>>> upstream/master
def self.define_providers!
return unless Gitlab::Auth::LDAP::Config.enabled?
......
......@@ -83,6 +83,14 @@ module GitlabRoutingHelper
end
end
def edit_milestone_path(entity, *args)
if entity.parent.is_a?(Group)
edit_group_milestone_path(entity.parent, entity, *args)
else
edit_project_milestone_path(entity.parent, entity, *args)
end
end
def toggle_subscription_path(entity, *args)
if entity.is_a?(Issue)
toggle_subscription_project_issue_path(entity.project, entity)
......
......@@ -72,6 +72,11 @@ class Project < ActiveRecord::Base
after_save :update_project_statistics, if: :namespace_id_changed?
after_create :create_project_feature, unless: :project_feature
after_create :create_ci_cd_settings,
unless: :ci_cd_settings,
if: proc { ProjectCiCdSetting.available? }
after_create :set_last_activity_at
after_create :set_last_repository_updated_at
after_update :update_forks_visibility_level
......@@ -238,6 +243,7 @@ class Project < ActiveRecord::Base
has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
has_many :project_badges, class_name: 'ProjectBadge'
has_one :ci_cd_settings, class_name: 'ProjectCiCdSetting'
accepts_nested_attributes_for :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: true
......
class ProjectCiCdSetting < ActiveRecord::Base
belongs_to :project
# The version of the schema that first introduced this model/table.
MINIMUM_SCHEMA_VERSION = 20180403035759
def self.available?
@available ||=
ActiveRecord::Migrator.current_version >= MINIMUM_SCHEMA_VERSION
end
def self.reset_column_information
@available = nil
super
end
end
......@@ -24,7 +24,7 @@ class GroupPolicy < BasePolicy
condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) }
condition(:has_projects) do
GroupProjectsFinder.new(group: @subject, current_user: @user).execute.any?
GroupProjectsFinder.new(group: @subject, current_user: @user, options: { include_subgroups: true }).execute.any?
end
with_options scope: :subject, score: 0
......@@ -46,7 +46,11 @@ class GroupPolicy < BasePolicy
end
rule { admin } .enable :read_group
rule { has_projects } .enable :read_group
rule { has_projects }.policy do
enable :read_group
enable :read_label
end
rule { has_access }.enable :read_namespace
......
---
title: Introduce new ProjectCiCdSetting model with group_runners_enabled
merge_request: 18144
author:
type: performance
---
title: Reduce queries on merge requests list page for merge requests from forks
merge_request: 18561
author:
type: performance
---
title: Added Webhook SSRF prevention to documentation
merge_request: 18532
author:
type: other
---
title: Added fuzzy file finder to web IDE
merge_request:
author:
type: added
---
title: Fix users not seeing labels from private groups when being a member of a child project
merge_request:
author:
type: fixed
---
title: Bump lograge to 0.10.0 and remove monkey patch
merge_request:
author:
type: other
---
title: Show Runner's description on job's page
merge_request: 17321
author:
type: added
# Monkey patch lograge until https://github.com/roidrage/lograge/pull/241 is released
module Lograge
class RequestLogSubscriber < ActiveSupport::LogSubscriber
def strip_query_string(path)
index = path.index('?')
index ? path[0, index] : path
end
def extract_location
location = Thread.current[:lograge_location]
return {} unless location
Thread.current[:lograge_location] = nil
{ location: strip_query_string(location) }
end
end
end
# Only use Lograge for Rails
unless Sidekiq.server?
filename = File.join(Rails.root, 'log', "#{Rails.env}_json.log")
......
<<<<<<< HEAD
## EE-specific
get 'unsubscribes/:email', to: 'unsubscribes#show', as: :unsubscribe
post 'unsubscribes/:email', to: 'unsubscribes#create'
## EE-specific
=======
>>>>>>> upstream/master
# Allows individual providers to be directed to a chosen controller
# Call from inside devise_scope
def override_omniauth(provider, controller, path_prefix = '/users/auth')
......
class CreateProjectCiCdSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
unless table_exists?(:project_ci_cd_settings)
create_table(:project_ci_cd_settings) do |t|
t.integer(:project_id, null: false)
t.boolean(:group_runners_enabled, default: true, null: false)
end
end
disable_statement_timeout
# This particular INSERT will take between 10 and 20 seconds.
execute 'INSERT INTO project_ci_cd_settings (project_id) SELECT id FROM projects'
# We add the index and foreign key separately so the above INSERT statement
# takes as little time as possible.
add_concurrent_index(:project_ci_cd_settings, :project_id, unique: true)
add_foreign_key_with_retry
end
def down
drop_table :project_ci_cd_settings
end
def add_foreign_key_with_retry
if Gitlab::Database.mysql?
# When using MySQL we don't support online upgrades, thus projects can't
# be deleted while we are running this migration.
return add_project_id_foreign_key
end
# Between the initial INSERT and the addition of the foreign key some
# projects may have been removed, leaving orphaned rows in our new settings
# table.
loop do
remove_orphaned_settings
begin
add_project_id_foreign_key
break
rescue ActiveRecord::InvalidForeignKey
say 'project_ci_cd_settings contains some orphaned rows, retrying...'
end
end
end
def add_project_id_foreign_key
add_concurrent_foreign_key(:project_ci_cd_settings, :projects, column: :project_id)
end
def remove_orphaned_settings
execute <<~SQL
DELETE FROM project_ci_cd_settings
WHERE NOT EXISTS (
SELECT 1
FROM projects
WHERE projects.id = project_ci_cd_settings.project_id
)
SQL
end
end
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class PopulateMissingProjectCiCdSettings < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
# MySQL does not support online upgrades, thus there can't be any missing
# rows.
return if Gitlab::Database.mysql?
# Projects created after the initial migration but before the code started
# using ProjectCiCdSetting won't have a corresponding row in
# project_ci_cd_settings, so let's fix that.
execute <<~SQL
INSERT INTO project_ci_cd_settings (project_id)
SELECT id
FROM projects
WHERE NOT EXISTS (
SELECT 1
FROM project_ci_cd_settings
WHERE project_ci_cd_settings.project_id = projects.id
)
SQL
end
def down
# There's nothing to revert for this migration.
end
end
......@@ -1882,6 +1882,13 @@ ActiveRecord::Schema.define(version: 20180419031622) do
add_index "project_auto_devops", ["project_id"], name: "index_project_auto_devops_on_project_id", unique: true, using: :btree
create_table "project_ci_cd_settings", force: :cascade do |t|
t.integer "project_id", null: false
t.boolean "group_runners_enabled", default: true, null: false
end
add_index "project_ci_cd_settings", ["project_id"], name: "index_project_ci_cd_settings_on_project_id", unique: true, using: :btree
create_table "project_custom_attributes", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
......@@ -2809,6 +2816,7 @@ ActiveRecord::Schema.define(version: 20180419031622) do
add_foreign_key "project_authorizations", "projects", on_delete: :cascade
add_foreign_key "project_authorizations", "users", on_delete: :cascade
add_foreign_key "project_auto_devops", "projects", on_delete: :cascade
add_foreign_key "project_ci_cd_settings", "projects", name: "fk_24c15d2f2e", on_delete: :cascade
add_foreign_key "project_custom_attributes", "projects", on_delete: :cascade
add_foreign_key "project_deploy_tokens", "deploy_tokens", on_delete: :cascade
add_foreign_key "project_deploy_tokens", "projects", on_delete: :cascade
......
......@@ -71,43 +71,33 @@ Notice several options that you should consider using:
| `nobootwait` | Don't halt boot process waiting for this mount to become available
| `lookupcache=positive` | Tells the NFS client to honor `positive` cache results but invalidates any `negative` cache results. Negative cache results cause problems with Git. Specifically, a `git push` can fail to register uniformly across all NFS clients. The negative cache causes the clients to 'remember' that the files did not exist previously.
## Mount locations
## A single NFS mount
When using default Omnibus configuration you will need to share 5 data locations
between all GitLab cluster nodes. No other locations should be shared. The
following are the 5 locations you need to mount:
| Location | Description | Default configuration |
| -------- | ----------- | --------------------- |
| `/var/opt/gitlab/git-data` | Git repository data. This will account for a large portion of your data | `git_data_dirs({"default" => "/var/opt/gitlab/git-data"})`
| `/var/opt/gitlab/.ssh` | SSH `authorized_keys` file and keys used to import repositories from some other Git services | `user['home'] = '/var/opt/gitlab/'`
| `/var/opt/gitlab/gitlab-rails/uploads` | User uploaded attachments | `gitlab_rails['uploads_directory'] = '/var/opt/gitlab/gitlab-rails/uploads'`
| `/var/opt/gitlab/gitlab-rails/shared` | Build artifacts, GitLab Pages, LFS objects, temp files, etc. If you're using LFS this may also account for a large portion of your data | `gitlab_rails['shared_path'] = '/var/opt/gitlab/gitlab-rails/shared'`
| `/var/opt/gitlab/gitlab-ci/builds` | GitLab CI build traces | `gitlab_ci['builds_directory'] = '/var/opt/gitlab/gitlab-ci/builds'`
It's recommended to nest all gitlab data dirs within a mount, that allows automatic
restore of backups without manually moving existing data.
Other GitLab directories should not be shared between nodes. They contain
node-specific files and GitLab code that does not need to be shared. To ship
logs to a central location consider using remote syslog. GitLab Omnibus packages
provide configuration for [UDP log shipping][udp-log-shipping].
### Consolidating mount points
If you don't want to configure 5-6 different NFS mount points, you have a few
alternative options.
```
mountpoint
└── gitlab-data
├── builds
├── git-data
├── home-git
├── shared
└── uploads
```
#### Change default file locations
To do so, we'll need to configure Omnibus with the paths to each directory nested
in the mount point as follows:
Omnibus allows you to configure the file locations. With custom configuration
you can specify just one main mountpoint and have all of these locations
as subdirectories. Mount `/gitlab-data` then use the following Omnibus
Mount `/gitlab-nfs` then use the following Omnibus
configuration to move each data location to a subdirectory:
```ruby
git_data_dirs({"default" => "/gitlab-data/git-data"})
user['home'] = '/gitlab-data/home'
gitlab_rails['uploads_directory'] = '/gitlab-data/uploads'
gitlab_rails['shared_path'] = '/gitlab-data/shared'
gitlab_ci['builds_directory'] = '/gitlab-data/builds'
git_data_dirs({"default" => "/gitlab-nfs/gitlab-data/git-data"})
user['home'] = '/gitlab-nfs/gitlab-data/home'
gitlab_rails['uploads_directory'] = '/gitlab-nfs/gitlab-data/uploads'
gitlab_rails['shared_path'] = '/gitlab-nfs/gitlab-data/shared'
gitlab_ci['builds_directory'] = '/gitlab-nfs/gitlab-data/builds'
```
To move the `git` home directory, all GitLab services must be stopped. Run
......@@ -118,22 +108,52 @@ Run `sudo gitlab-ctl reconfigure` to start using the central location. Please
be aware that if you had existing data you will need to manually copy/rsync it
to these new locations and then restart GitLab.
#### Bind mounts
## Bind mounts
Alternatively to changing the configuration in Omnibus, bind mounts can be used
to store the data on an NFS mount.
Bind mounts provide a way to specify just one NFS mount and then
bind the default GitLab data locations to the NFS mount. Start by defining your
single NFS mount point as you normally would in `/etc/fstab`. Let's assume your
NFS mount point is `/gitlab-data`. Then, add the following bind mounts in
NFS mount point is `/gitlab-nfs`. Then, add the following bind mounts in
`/etc/fstab`:
```bash
/gitlab-data/git-data /var/opt/gitlab/git-data none bind 0 0
/gitlab-data/.ssh /var/opt/gitlab/.ssh none bind 0 0
/gitlab-data/uploads /var/opt/gitlab/gitlab-rails/uploads none bind 0 0
/gitlab-data/shared /var/opt/gitlab/gitlab-rails/shared none bind 0 0
/gitlab-data/builds /var/opt/gitlab/gitlab-ci/builds none bind 0 0
/gitlab-nfs/gitlab-data/git-data /var/opt/gitlab/git-data none bind 0 0
/gitlab-nfs/gitlab-data/.ssh /var/opt/gitlab/.ssh none bind 0 0
/gitlab-nfs/gitlab-data/uploads /var/opt/gitlab/gitlab-rails/uploads none bind 0 0
/gitlab-nfs/gitlab-data/shared /var/opt/gitlab/gitlab-rails/shared none bind 0 0
/gitlab-nfs/gitlab-data/builds /var/opt/gitlab/gitlab-ci/builds none bind 0 0
```
Using bind mounts will require manually making sure the data directories
are empty before attempting a restore. Read more about the
[restore prerequisites](../../raketasks/backup_restore.md).
## Multiple NFS mounts
When using default Omnibus configuration you will need to share 5 data locations
between all GitLab cluster nodes. No other locations should be shared. The
following are the 5 locations need to be shared:
| Location | Description | Default configuration |
| -------- | ----------- | --------------------- |
| `/var/opt/gitlab/git-data` | Git repository data. This will account for a large portion of your data | `git_data_dirs({"default" => "/var/opt/gitlab/git-data"})`
| `/var/opt/gitlab/.ssh` | SSH `authorized_keys` file and keys used to import repositories from some other Git services | `user['home'] = '/var/opt/gitlab/'`
| `/var/opt/gitlab/gitlab-rails/uploads` | User uploaded attachments | `gitlab_rails['uploads_directory'] = '/var/opt/gitlab/gitlab-rails/uploads'`
| `/var/opt/gitlab/gitlab-rails/shared` | Build artifacts, GitLab Pages, LFS objects, temp files, etc. If you're using LFS this may also account for a large portion of your data | `gitlab_rails['shared_path'] = '/var/opt/gitlab/gitlab-rails/shared'`
| `/var/opt/gitlab/gitlab-ci/builds` | GitLab CI build traces | `gitlab_ci['builds_directory'] = '/var/opt/gitlab/gitlab-ci/builds'`
Other GitLab directories should not be shared between nodes. They contain
node-specific files and GitLab code that does not need to be shared. To ship
logs to a central location consider using remote syslog. GitLab Omnibus packages
provide configuration for [UDP log shipping][udp-log-shipping].
Having multiple NFS mounts will require manually making sure the data directories
are empty before attempting a restore. Read more about the
[restore prerequisites](../../raketasks/backup_restore.md).
---
Read more on high-availability configuration:
......
......@@ -88,8 +88,11 @@ future GitLab releases.**
| **GITLAB_USER_NAME** | 10.0 | all | The real name of the user who started the job |
| **GITLAB_FEATURES** | 10.6 | all | The comma separated list of licensed features available for your instance and plan |
| **RESTORE_CACHE_ATTEMPTS** | 8.15 | 1.9 | Number of attempts to restore the cache running a job |
<<<<<<< HEAD
| **CHAT_INPUT** | 10.6 | all | Additional arguments passed in the [ChatOps](../chatops/README.md) command. |
| **CHAT_CHANNEL** | 10.6 | all | Source chat channel which triggered the [ChatOps](../chatops/README.md) command. |
=======
>>>>>>> upstream/master
| **CI_DEPLOY_USER** | 10.8 | all | Authentication username of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.|
| **CI_DEPLOY_PASSWORD** | 10.8 | all | Authentication password of the [GitLab Deploy Token][gitlab-deploy-token], only present if the Project has one related.|
......@@ -607,5 +610,8 @@ my-job:
[subgroups]: ../../user/group/subgroups/index.md
[builds-policies]: ../yaml/README.md#only-and-except-complex
[dynamic-environments]: ../environments.md#dynamic-environments
<<<<<<< HEAD
[trigger-job-token]: ../triggers/README.md#ci-job-token
=======
>>>>>>> upstream/master
[gitlab-deploy-token]: ../../user/project/deploy_tokens/index.md#gitlab-deploy-token
......@@ -1714,8 +1714,13 @@ capitalization, the commit will be created but the pipeline will be skipped.
## Validate the .gitlab-ci.yml
Each instance of GitLab CI has an embedded debug tool called Lint, which validates the
<<<<<<< HEAD
content of your `.gitlab-ci.yml` files. You can find the Lint under the page `ci/lint` of your
project namespace (e.g, `http://gitlab-example.com/gitlab-org/project-123/ci/lint`)
=======
content of your `.gitlab-ci.yml` files. You can find the Lint under the page `ci/lint` of your
project namespace (e.g, `http://gitlab-example.com/gitlab-org/project-123/-/ci/lint`)
>>>>>>> upstream/master
## Using reserved keywords
......
......@@ -498,6 +498,13 @@ more of the following options:
Read what the [backup timestamp is about](#backup-timestamp).
- `force=yes` - Does not ask if the authorized_keys file should get regenerated and assumes 'yes' for warning that database tables will be removed.
If you are restoring into directories that are mountpoints you will need to make
sure these directories are empty before attempting a restore. Otherwise GitLab
will attempt to move these directories before restoring the new data and this
would cause an error.
Read more on [configuring NFS mounts](../administration/high_availability/nfs.md)
### Restore for installation from source
```
......
......@@ -2,12 +2,19 @@
If you have non-GitLab web services running on your GitLab server or within its local network, these may be vulnerable to exploitation via Webhooks.
With [Webhooks](../user/project/integrations/webhooks.md), you and your project masters and owners can set up URLs to be triggered when specific things happen to projects. Normally, these requests are sent to external web services specifically set up for this purpose, that process the request and its attached data in some appropriate way.
With [Webhooks](../user/project/integrations/webhooks.md), you and your project masters and owners can set up URLs to be triggered when specific things happen to projects. Normally, these requests are sent to external web services specifically set up for this purpose, that process the request and its attached data in some appropriate way.
Things get hairy, however, when a Webhook is set up with a URL that doesn't point to an external, but to an internal service, that may do something completely unintended when the webhook is triggered and the POST request is sent.
Because Webhook requests are made by the GitLab server itself, these have complete access to everything running on the server (http://localhost:123) or within the server's local network (http://192.168.1.12:345), even if these services are otherwise protected and inaccessible from the outside world.
If a web service does not require authentication, Webhooks can be used to trigger destructive commands by getting the GitLab server to make POST requests to endpoints like "http://localhost:123/some-resource/delete".
If a web service does not require authentication, Webhooks can be used to trigger destructive commands by getting the GitLab server to make POST requests to endpoints like "http://localhost:123/some-resource/delete".
To prevent this type of exploitation from happening, make sure that you are aware of every web service GitLab could potentially have access to, and that all of these are set up to require authentication for every potentially destructive command. Enabling authentication but leaving a default password is not enough.
To prevent this type of exploitation from happening, starting with GitLab 10.6, all Webhook requests to the current GitLab instance server address and/or in a private network will be forbidden by default. That means that all requests made to 127.0.0.1, ::1 and 0.0.0.0, as well as IPv4 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 and IPv6 site-local (ffc0::/10) addresses won't be allowed.
This behavior can be overridden by enabling the option *"Allow requests to the local network from hooks and services"* in the *"Outbound requests"* section inside the Admin area under **Settings** (`/admin/application_settings`):
![Outbound requests admin settings](img/outbound_requests_section.png)
>**Note:**
*System hooks* are exempt from this protection because they are set up by admins.
@dashboard
Feature: Project Find File
Background:
Given I sign in as a user
And I own a project
And I visit my project's files page
@javascript
Scenario: Navigate to find file by shortcut
Given I press "t"
Then I should see "find file" page
Scenario: Navigate to find file
Given I click Find File button
Then I should see "find file" page
@javascript
Scenario: I search file
Given I visit project find file page
And I fill in file find with "change"
Then I should not see ".gitignore" in files
And I should not see ".gitmodules" in files
And I should see "CHANGELOG" in files
And I should not see "VERSION" in files
@javascript
Scenario: I search file that not exist
Given I visit project find file page
And I fill in file find with "asdfghjklqwertyuizxcvbnm"
Then I should not see ".gitignore" in files
And I should not see ".gitmodules" in files
And I should not see "CHANGELOG" in files
And I should not see "VERSION" in files
@javascript
Scenario: I search file that partially matches
Given I visit project find file page
And I fill in file find with "git"
Then I should see ".gitignore" in files
And I should see ".gitmodules" in files
And I should not see "CHANGELOG" in files
And I should not see "VERSION" in files
class Spinach::Features::ProjectFindFile < Spinach::FeatureSteps
include SharedAuthentication
include SharedPaths
include SharedProject
include SharedProjectTab
step 'I press "t"' do
find('body').native.send_key('t')
end
step 'I click Find File button' do
click_link 'Find file'
end
step 'I should see "find file" page' do
ensure_active_main_tab('Repository')
expect(page).to have_selector('.file-finder-holder', count: 1)
end
step 'I fill in Find by path with "git"' do
ensure_active_main_tab('Repository')
expect(page).to have_selector('.file-finder-holder', count: 1)
end
step 'I fill in file find with "git"' do
find_file "git"
end
step 'I fill in file find with "change"' do
find_file "change"
end
step 'I fill in file find with "asdfghjklqwertyuizxcvbnm"' do
find_file "asdfghjklqwertyuizxcvbnm"
end
step 'I should see "VERSION" in files' do
expect(page).to have_content("VERSION")
end
step 'I should not see "VERSION" in files' do
expect(page).not_to have_content("VERSION")
end
step 'I should see "CHANGELOG" in files' do
expect(page).to have_content("CHANGELOG")
end
step 'I should not see "CHANGELOG" in files' do
expect(page).not_to have_content("CHANGELOG")
end
step 'I should see ".gitmodules" in files' do
expect(page).to have_content(".gitmodules")
end
step 'I should not see ".gitmodules" in files' do
expect(page).not_to have_content(".gitmodules")
end
step 'I should see ".gitignore" in files' do
expect(page).to have_content(".gitignore")
end
step 'I should not see ".gitignore" in files' do
expect(page).not_to have_content(".gitignore")
end
def find_file(text)
fill_in 'file_find', with: text
end
end
......@@ -216,10 +216,6 @@ module SharedPaths
visit edit_project_path(@project)
end
step "I visit my project's files page" do
visit project_tree_path(@project, root_ref)
end
step 'I visit a binary file in the repo' do
visit project_blob_path(@project,
File.join(root_ref, 'files/images/logo-black.png'))
......
......@@ -53,6 +53,8 @@ module Backup
FileUtils.mv(files, timestamped_files_path)
rescue Errno::EACCES
access_denied_error(app_files_dir)
rescue Errno::EBUSY
resource_busy_error(app_files_dir)
end
end
end
......
......@@ -13,5 +13,19 @@ module Backup
EOS
raise message
end
def resource_busy_error(path)
message = <<~EOS
### NOTICE ###
As part of restore, the task tried to rename `#{path}` before restoring.
This could not be completed, perhaps `#{path}` is a mountpoint?
To complete the restore, please move the contents of `#{path}` to a
different location and run the restore task again.
EOS
raise message
end
end
end
......@@ -81,6 +81,8 @@ module Backup
FileUtils.mv(files, bk_repos_path)
rescue Errno::EACCES
access_denied_error(path)
rescue Errno::EBUSY
resource_busy_error(path)
end
end
end
......
......@@ -9,7 +9,10 @@ module Gitlab
module LDAP
class User < Gitlab::Auth::OAuth::User
extend ::Gitlab::Utils::Override
<<<<<<< HEAD
prepend ::EE::Gitlab::Auth::LDAP::User
=======
>>>>>>> upstream/master
class << self
def find_by_uid_and_provider(uid, provider)
......
......@@ -20,6 +20,7 @@ module Gitlab
user ||= find_or_build_ldap_user if auto_link_ldap_user?
user ||= build_new_user if signup_enabled?
<<<<<<< HEAD
if user_in_required_group?
unblock_user(user, "in required group") if user.persisted? && user.blocked?
elsif user.persisted?
......@@ -31,6 +32,12 @@ module Gitlab
if user
user.external = !(auth_hash.groups & saml_config.external_groups).empty? if external_users_enabled?
user.admin = !(auth_hash.groups & saml_config.admin_groups).empty? if admin_groups_enabled?
=======
if external_users_enabled? && user
# Check if there is overlap between the user's groups and the external groups
# setting then set user as external or internal.
user.external = !(auth_hash.groups & saml_config.external_groups).empty?
>>>>>>> upstream/master
end
user
......@@ -49,6 +56,7 @@ module Gitlab
Gitlab::Auth::Saml::Config
end
<<<<<<< HEAD
def block_user(user, reason)
user.ldap_block
log_user_changes(user, "#{reason}, blocking")
......@@ -71,6 +79,8 @@ module Gitlab
required_groups.empty? || !(auth_hash.groups & required_groups).empty?
end
=======
>>>>>>> upstream/master
def auto_link_saml_user?
Gitlab.config.omniauth.auto_link_saml_user
end
......
......@@ -66,6 +66,7 @@ project_tree:
- :custom_attributes
- :prometheus_metrics
- :project_badges
- :ci_cd_settings
# Only include the following attributes for the models specified.
included_attributes:
......@@ -75,6 +76,8 @@ included_attributes:
- :username
author:
- :name
ci_cd_settings:
- :group_runners_enabled
# Do not include the following attributes for the models specified.
excluded_attributes:
......
......@@ -18,7 +18,8 @@ module Gitlab
auto_devops: :project_auto_devops,
label: :project_label,
custom_attributes: 'ProjectCustomAttribute',
project_badges: 'Badge' }.freeze
project_badges: 'Badge',
ci_cd_settings: 'ProjectCiCdSetting' }.freeze
USER_REFERENCES = %w[author_id assignee_id updated_by_id user_id created_by_id last_edited_by_id merge_user_id resolved_by_id closed_by_id].freeze
......
......@@ -170,6 +170,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
context 'on issue sidebar' do
before do
project_1.add_developer(user)
visit project_issue_path(project_1, issue)
end
......@@ -180,6 +182,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, project: project_1) }
before do
project_1.add_developer(user)
visit project_board_path(project_1, board)
wait_for_requests
......@@ -194,6 +198,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, group: parent) }
before do
parent.add_developer(user)
visit group_board_path(parent, board)
wait_for_requests
......@@ -211,6 +217,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
context 'on project issuable list' do
before do
project_1.add_developer(user)
visit project_issues_path(project_1)
end
......@@ -237,6 +245,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, project: project_1) }
before do
project_1.add_developer(user)
visit project_board_path(project_1, board)
end
......@@ -247,6 +257,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, group: parent) }
before do
parent.add_developer(user)
visit group_board_path(parent, board)
end
......@@ -259,6 +271,7 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, project: project_1) }
before do
project_1.add_developer(user)
visit project_board_path(project_1, board)
find('.js-new-board-list').click
wait_for_requests
......@@ -281,6 +294,7 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, group: parent) }
before do
parent.add_developer(user)
visit group_board_path(parent, board)
find('.js-new-board-list').click
wait_for_requests
......
require 'spec_helper'
describe 'User find project file' do
let(:user) { create :user }
let(:project) { create :project, :repository }
before do
sign_in(user)
project.add_master(user)
visit project_tree_path(project, project.repository.root_ref)
end
def active_main_tab
find('.sidebar-top-level-items > li.active')
end
def find_file(text)
fill_in 'file_find', with: text
end
it 'navigates to find file by shortcut', :js do
find('body').native.send_key('t')
expect(active_main_tab).to have_content('Repository')
expect(page).to have_selector('.file-finder-holder', count: 1)
end
it 'navigates to find file' do
click_link 'Find file'
expect(active_main_tab).to have_content('Repository')
expect(page).to have_selector('.file-finder-holder', count: 1)
end
it 'searches CHANGELOG file', :js do
click_link 'Find file'
find_file 'change'
expect(page).to have_content('CHANGELOG')
expect(page).not_to have_content('.gitignore')
expect(page).not_to have_content('VERSION')
end
it 'does not find file when search not exist file', :js do
click_link 'Find file'
find_file 'asdfghjklqwertyuizxcvbnm'
expect(page).not_to have_content('CHANGELOG')
expect(page).not_to have_content('.gitignore')
expect(page).not_to have_content('VERSION')
end
it 'searches file by partially matches', :js do
click_link 'Find file'
find_file 'git'
expect(page).to have_content('.gitignore')
expect(page).to have_content('.gitmodules')
expect(page).not_to have_content('CHANGELOG')
expect(page).not_to have_content('VERSION')
end
end
......@@ -89,4 +89,19 @@ describe GitlabRoutingHelper do
expect(preview_markdown_path(project)).to eq("/#{project.full_path}/preview_markdown")
end
end
describe '#edit_milestone_path' do
it 'returns group milestone edit path when given entity parent is a Group' do
group = create(:group)
milestone = create(:milestone, group: group)
expect(edit_milestone_path(milestone)).to eq("/groups/#{group.path}/-/milestones/#{milestone.iid}/edit")
end
it 'returns project milestone edit path when given entity parent is not a Group' do
milestone = create(:milestone, group: nil)
expect(edit_milestone_path(milestone)).to eq("/#{milestone.project.full_path}/milestones/#{milestone.iid}/edit")
end
end
end
import Vue from 'vue';
import store from '~/ide/stores';
import FindFileComponent from '~/ide/components/file_finder/index.vue';
import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
import router from '~/ide/ide_router';
import { file, resetStore } from '../../helpers';
import { mountComponentWithStore } from '../../../helpers/vue_mount_component_helper';
describe('IDE File finder item spec', () => {
const Component = Vue.extend(FindFileComponent);
let vm;
beforeEach(done => {
setFixtures('<div id="app"></div>');
vm = mountComponentWithStore(Component, {
store,
el: '#app',
props: {
index: 0,
},
});
setTimeout(done);
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
describe('with entries', () => {
beforeEach(done => {
Vue.set(vm.$store.state.entries, 'folder', {
...file('folder'),
path: 'folder',
type: 'folder',
});
Vue.set(vm.$store.state.entries, 'index.js', {
...file('index.js'),
path: 'index.js',
type: 'blob',
url: '/index.jsurl',
});
Vue.set(vm.$store.state.entries, 'component.js', {
...file('component.js'),
path: 'component.js',
type: 'blob',
});
setTimeout(done);
});
it('renders list of blobs', () => {
expect(vm.$el.textContent).toContain('index.js');
expect(vm.$el.textContent).toContain('component.js');
expect(vm.$el.textContent).not.toContain('folder');
});
it('filters entries', done => {
vm.searchText = 'index';
vm.$nextTick(() => {
expect(vm.$el.textContent).toContain('index.js');
expect(vm.$el.textContent).not.toContain('component.js');
done();
});
});
it('shows clear button when searchText is not empty', done => {
vm.searchText = 'index';
vm.$nextTick(() => {
expect(vm.$el.querySelector('.dropdown-input-clear').classList).toContain('show');
expect(vm.$el.querySelector('.dropdown-input-search').classList).toContain('hidden');
done();
});
});
it('clear button resets searchText', done => {
vm.searchText = 'index';
vm
.$nextTick()
.then(() => {
vm.$el.querySelector('.dropdown-input-clear').click();
})
.then(vm.$nextTick)
.then(() => {
expect(vm.searchText).toBe('');
})
.then(done)
.catch(done.fail);
});
it('clear button focues search input', done => {
spyOn(vm.$refs.searchInput, 'focus');
vm.searchText = 'index';
vm
.$nextTick()
.then(() => {
vm.$el.querySelector('.dropdown-input-clear').click();
})
.then(vm.$nextTick)
.then(() => {
expect(vm.$refs.searchInput.focus).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
describe('listShowCount', () => {
it('returns 1 when no filtered entries exist', done => {
vm.searchText = 'testing 123';
vm.$nextTick(() => {
expect(vm.listShowCount).toBe(1);
done();
});
});
it('returns entries length when not filtered', () => {
expect(vm.listShowCount).toBe(2);
});
});
describe('listHeight', () => {
it('returns 55 when entries exist', () => {
expect(vm.listHeight).toBe(55);
});
it('returns 33 when entries dont exist', done => {
vm.searchText = 'testing 123';
vm.$nextTick(() => {
expect(vm.listHeight).toBe(33);
done();
});
});
});
describe('filteredBlobsLength', () => {
it('returns length of filtered blobs', done => {
vm.searchText = 'index';
vm.$nextTick(() => {
expect(vm.filteredBlobsLength).toBe(1);
done();
});
});
});
describe('watches', () => {
describe('searchText', () => {
it('resets focusedIndex when updated', done => {
vm.focusedIndex = 1;
vm.searchText = 'test';
vm.$nextTick(() => {
expect(vm.focusedIndex).toBe(0);
done();
});
});
});
describe('fileFindVisible', () => {
it('returns searchText when false', done => {
vm.searchText = 'test';
vm.$store.state.fileFindVisible = true;
vm
.$nextTick()
.then(() => {
vm.$store.state.fileFindVisible = false;
})
.then(vm.$nextTick)
.then(() => {
expect(vm.searchText).toBe('');
})
.then(done)
.catch(done.fail);
});
});
});
describe('openFile', () => {
beforeEach(() => {
spyOn(router, 'push');
spyOn(vm, 'toggleFileFinder');
});
it('closes file finder', () => {
vm.openFile(vm.$store.state.entries['index.js']);
expect(vm.toggleFileFinder).toHaveBeenCalled();
});
it('pushes to router', () => {
vm.openFile(vm.$store.state.entries['index.js']);
expect(router.push).toHaveBeenCalledWith('/project/index.jsurl');
});
});
describe('onKeyup', () => {
it('opens file on enter key', done => {
const event = new CustomEvent('keyup');
event.keyCode = ENTER_KEY_CODE;
spyOn(vm, 'openFile');
vm.$refs.searchInput.dispatchEvent(event);
vm.$nextTick(() => {
expect(vm.openFile).toHaveBeenCalledWith(vm.$store.state.entries['index.js']);
done();
});
});
it('closes file finder on esc key', done => {
const event = new CustomEvent('keyup');
event.keyCode = ESC_KEY_CODE;
spyOn(vm, 'toggleFileFinder');
vm.$refs.searchInput.dispatchEvent(event);
vm.$nextTick(() => {
expect(vm.toggleFileFinder).toHaveBeenCalled();
done();
});
});
});
describe('onKeyDown', () => {
let el;
beforeEach(() => {
el = vm.$refs.searchInput;
});
describe('up key', () => {
const event = new CustomEvent('keydown');
event.keyCode = UP_KEY_CODE;
it('resets to last index when at top', () => {
el.dispatchEvent(event);
expect(vm.focusedIndex).toBe(1);
});
it('minus 1 from focusedIndex', () => {
vm.focusedIndex = 1;
el.dispatchEvent(event);
expect(vm.focusedIndex).toBe(0);
});
});
describe('down key', () => {
const event = new CustomEvent('keydown');
event.keyCode = DOWN_KEY_CODE;
it('resets to first index when at bottom', () => {
vm.focusedIndex = 1;
el.dispatchEvent(event);
expect(vm.focusedIndex).toBe(0);
});
it('adds 1 to focusedIndex', () => {
el.dispatchEvent(event);
expect(vm.focusedIndex).toBe(1);
});
});
});
});
describe('without entries', () => {
it('renders loading text when loading', done => {
store.state.loading = true;
vm.$nextTick(() => {
expect(vm.$el.textContent).toContain('Loading...');
done();
});
});
it('renders no files text', () => {
expect(vm.$el.textContent).toContain('No files found.');
});
});
});
import Vue from 'vue';
import ItemComponent from '~/ide/components/file_finder/item.vue';
import { file } from '../../helpers';
import createComponent from '../../../helpers/vue_mount_component_helper';
describe('IDE File finder item spec', () => {
const Component = Vue.extend(ItemComponent);
let vm;
let localFile;
beforeEach(() => {
localFile = {
...file(),
name: 'test file',
path: 'test/file',
};
vm = createComponent(Component, {
file: localFile,
focused: true,
searchText: '',
index: 0,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders file name & path', () => {
expect(vm.$el.textContent).toContain('test file');
expect(vm.$el.textContent).toContain('test/file');
});
describe('focused', () => {
it('adds is-focused class', () => {
expect(vm.$el.classList).toContain('is-focused');
});
it('does not have is-focused class when not focused', done => {
vm.focused = false;
vm.$nextTick(() => {
expect(vm.$el.classList).not.toContain('is-focused');
done();
});
});
});
describe('changed file icon', () => {
it('does not render when not a changed or temp file', () => {
expect(vm.$el.querySelector('.diff-changed-stats')).toBe(null);
});
it('renders when a changed file', done => {
vm.file.changed = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
done();
});
});
it('renders when a temp file', done => {
vm.file.tempFile = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.diff-changed-stats')).not.toBe(null);
done();
});
});
});
it('emits event when clicked', () => {
spyOn(vm, '$emit');
vm.$el.click();
expect(vm.$emit).toHaveBeenCalledWith('click', vm.file);
});
describe('path', () => {
let el;
beforeEach(done => {
vm.searchText = 'file';
el = vm.$el.querySelector('.diff-changed-file-path');
vm.$nextTick(done);
});
it('highlights text', () => {
expect(el.querySelectorAll('.highlighted').length).toBe(4);
});
it('adds ellipsis to long text', done => {
vm.file.path = new Array(70)
.fill()
.map((_, i) => `${i}-`)
.join('');
vm.$nextTick(() => {
expect(el.textContent).toBe(`...${vm.file.path.substr(vm.file.path.length - 60)}`);
done();
});
});
});
describe('name', () => {
let el;
beforeEach(done => {
vm.searchText = 'file';
el = vm.$el.querySelector('.diff-changed-file-name');
vm.$nextTick(done);
});
it('highlights text', () => {
expect(el.querySelectorAll('.highlighted').length).toBe(4);
});
it('does not add ellipsis to long text', done => {
vm.file.name = new Array(70)
.fill()
.map((_, i) => `${i}-`)
.join('');
vm.$nextTick(() => {
expect(el.textContent).not.toBe(`...${vm.file.name.substr(vm.file.name.length - 60)}`);
done();
});
});
});
});
import Vue from 'vue';
import Mousetrap from 'mousetrap';
import store from '~/ide/stores';
import ide from '~/ide/components/ide.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
......@@ -38,4 +39,68 @@ describe('ide component', () => {
done();
});
});
describe('file finder', () => {
beforeEach(done => {
spyOn(vm, 'toggleFileFinder');
vm.$store.state.fileFindVisible = true;
vm.$nextTick(done);
});
it('calls toggleFileFinder on `t` key press', done => {
Mousetrap.trigger('t');
vm
.$nextTick()
.then(() => {
expect(vm.toggleFileFinder).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('calls toggleFileFinder on `command+p` key press', done => {
Mousetrap.trigger('command+p');
vm
.$nextTick()
.then(() => {
expect(vm.toggleFileFinder).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('calls toggleFileFinder on `ctrl+p` key press', done => {
Mousetrap.trigger('ctrl+p');
vm
.$nextTick()
.then(() => {
expect(vm.toggleFileFinder).toHaveBeenCalled();
})
.then(done)
.catch(done.fail);
});
it('always allows `command+p` to trigger toggleFileFinder', () => {
expect(
vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'),
).toBe(false);
});
it('always allows `ctrl+p` to trigger toggleFileFinder', () => {
expect(
vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'),
).toBe(false);
});
it('onlys handles `t` when focused in input-field', () => {
expect(
vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'),
).toBe(true);
});
});
});
......@@ -340,4 +340,17 @@ describe('Multi-file store actions', () => {
.catch(done.fail);
});
});
describe('toggleFileFinder', () => {
it('commits TOGGLE_FILE_FINDER', done => {
testAction(
actions.toggleFileFinder,
true,
null,
[{ type: 'TOGGLE_FILE_FINDER', payload: true }],
[],
done,
);
});
});
});
......@@ -64,4 +64,24 @@ describe('IDE store getters', () => {
expect(getters.currentMergeRequest(localState)).toBeNull();
});
});
describe('allBlobs', () => {
beforeEach(() => {
Object.assign(localState.entries, {
index: { type: 'blob', name: 'index', lastOpenedAt: 0 },
app: { type: 'blob', name: 'blob', lastOpenedAt: 0 },
folder: { type: 'folder', name: 'folder', lastOpenedAt: 0 },
});
});
it('returns only blobs', () => {
expect(getters.allBlobs(localState).length).toBe(2);
});
it('returns list sorted by lastOpenedAt', () => {
localState.entries.app.lastOpenedAt = new Date().getTime();
expect(getters.allBlobs(localState)[0].name).toBe('blob');
});
});
});
......@@ -86,4 +86,12 @@ describe('Multi-file store mutations', () => {
expect(localState.viewer).toBe('diff');
});
});
describe('TOGGLE_FILE_FINDER', () => {
it('updates fileFindVisible', () => {
mutations.TOGGLE_FILE_FINDER(localState, true);
expect(localState.fileFindVisible).toBe(true);
});
});
});
......@@ -102,7 +102,7 @@ describe('Sidebar details block', () => {
});
it('should render runner ID', () => {
expect(trimWhitespace(vm.$el.querySelector('.js-job-runner'))).toEqual('Runner: #1');
expect(trimWhitespace(vm.$el.querySelector('.js-job-runner'))).toEqual('Runner: local ci runner (#1)');
});
it('should render timeout information', () => {
......
......@@ -62,5 +62,19 @@ describe Backup::Files do
subject.restore
end
end
describe 'folders that are a mountpoint' do
before do
allow(FileUtils).to receive(:mv).and_raise(Errno::EBUSY)
allow(subject).to receive(:run_pipeline!).and_return(true)
end
it 'shows error message' do
expect(subject).to receive(:resource_busy_error).with("/var/gitlab-registry")
.and_call_original
expect { subject.restore }.to raise_error(/is a mountpoint/)
end
end
end
end
......@@ -81,6 +81,18 @@ describe Backup::Repository do
subject.restore
end
end
describe 'folder that is a mountpoint' do
before do
allow(FileUtils).to receive(:mv).and_raise(Errno::EBUSY)
end
it 'shows error message' do
expect(subject).to receive(:resource_busy_error).and_call_original
expect { subject.restore }.to raise_error(/is a mountpoint/)
end
end
end
describe '#empty_repo?' do
......
......@@ -325,6 +325,7 @@ project:
- internal_ids
- project_deploy_tokens
- deploy_tokens
- ci_cd_settings
award_emoji:
- awardable
- user
......
......@@ -585,3 +585,5 @@ Badge:
- created_at
- updated_at
- type
ProjectCiCdSetting:
- group_runners_enabled
# frozen_string_literal: true
require 'spec_helper'
describe ProjectCiCdSetting do
describe '.available?' do
before do
described_class.reset_column_information
end
it 'returns true' do
expect(described_class).to be_available
end
it 'memoizes the schema version' do
expect(ActiveRecord::Migrator)
.to receive(:current_version)
.and_call_original
.once
2.times { described_class.available? }
end
end
end
......@@ -95,6 +95,15 @@ describe Project do
end
end
context 'when creating a new project' do
it 'automatically creates a CI/CD settings row' do
project = create(:project)
expect(project.ci_cd_settings).to be_an_instance_of(ProjectCiCdSetting)
expect(project.ci_cd_settings).to be_persisted
end
end
describe '#members & #requesters' do
let(:project) { create(:project, :public, :access_requestable) }
let(:requester) { create(:user) }
......
......@@ -8,9 +8,9 @@ describe GroupPolicy do
let(:owner) { create(:user) }
let(:auditor) { create(:user, :auditor) }
let(:admin) { create(:admin) }
let(:group) { create(:group) }
let(:group) { create(:group, :private) }
let(:guest_permissions) { [:read_group, :upload_file, :read_namespace] }
let(:guest_permissions) { [:read_label, :read_group, :upload_file, :read_namespace] }
let(:reporter_permissions) { [:admin_label] }
......@@ -51,6 +51,7 @@ describe GroupPolicy do
end
context 'with no user' do
let(:group) { create(:group, :public) }
let(:current_user) { nil }
it do
......@@ -64,6 +65,28 @@ describe GroupPolicy do
end
end
context 'has projects' do
let(:current_user) { create(:user) }
let(:project) { create(:project, namespace: group) }
before do
project.add_developer(current_user)
end
it do
expect_allowed(:read_group, :read_label)
end
context 'in subgroups', :nested_groups do
let(:subgroup) { create(:group, :private, parent: group) }
let(:project) { create(:project, namespace: subgroup) }
it do
expect_allowed(:read_group, :read_label)
end
end
end
context 'guests' do
let(:current_user) { guest }
......
shared_examples 'issuables list meta-data' do |issuable_type, action = nil|
before do
@issuable_ids = []
%w[fix improve/awesome].each do |source_branch|
issuable =
if issuable_type == :issue
create(issuable_type, project: project, author: project.creator)
else
create(issuable_type, source_project: project, source_branch: source_branch, author: project.creator)
end
@issuable_ids << issuable.id
end
end
include ProjectForksHelper
it "creates indexed meta-data object for issuable notes and votes count" do
def get_action(action, project)
if action
get action, author_id: project.creator.id
else
get :index, namespace_id: project.namespace, project_id: project
end
end
def create_issuable(issuable_type, project, source_branch:)
if issuable_type == :issue
create(issuable_type, project: project, author: project.creator)
else
create(issuable_type, source_project: project, source_branch: source_branch, author: project.creator)
end
end
before do
@issuable_ids = %w[fix improve/awesome].map do |source_branch|
create_issuable(issuable_type, project, source_branch: source_branch).id
end
end
it "creates indexed meta-data object for issuable notes and votes count" do
get_action(action, project)
meta_data = assigns(:issuable_meta_data)
......@@ -29,18 +34,29 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil|
end
end
it "avoids N+1 queries" do
control = ActiveRecord::QueryRecorder.new { get_action(action, project) }
issuable = create_issuable(issuable_type, project, source_branch: 'csv')
if issuable_type == :merge_request
issuable.update!(source_project: fork_project(project))
end
expect { get_action(action, project) }.not_to exceed_query_limit(control.count)
end
describe "when given empty collection" do
let(:project2) { create(:project, :public) }
it "doesn't execute any queries with false conditions" do
get_action =
get_empty =
if action
proc { get action, author_id: project.creator.id }
else
proc { get :index, namespace_id: project2.namespace, project_id: project2 }
end
expect(&get_action).not_to make_queries_matching(/WHERE (?:1=0|0=1)/)
expect(&get_empty).not_to make_queries_matching(/WHERE (?:1=0|0=1)/)
end
end
end
......@@ -8474,6 +8474,10 @@ vue-template-es2015-compiler@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/vue-template-es2015-compiler/-/vue-template-es2015-compiler-1.6.0.tgz#dc42697133302ce3017524356a6c61b7b69b4a18"
vue-virtual-scroll-list@^1.2.5:
version "1.2.5"
resolved "https://registry.yarnpkg.com/vue-virtual-scroll-list/-/vue-virtual-scroll-list-1.2.5.tgz#bcbd010f7cdb035eba8958ebf807c6214d9a167a"
vue@^2.5.13:
version "2.5.13"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.13.tgz#95bd31e20efcf7a7f39239c9aa6787ce8cf578e1"
......
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