Commit 1a4b6568 authored by James Edwards-Jones's avatar James Edwards-Jones

Merge branch 'ce-to-ee-2018-04-24' into 'master'

CE upstream - 2018-04-24 12:36 UTC

See merge request gitlab-org/gitlab-ee!5467
parents 5ade9350 ea8d8412
...@@ -511,10 +511,11 @@ GEM ...@@ -511,10 +511,11 @@ GEM
logging (2.2.2) logging (2.2.2)
little-plugger (~> 1.1) little-plugger (~> 1.1)
multi_json (~> 1.10) multi_json (~> 1.10)
lograge (0.5.1) lograge (0.10.0)
actionpack (>= 4, < 5.2) actionpack (>= 4)
activesupport (>= 4, < 5.2) activesupport (>= 4)
railties (>= 4, < 5.2) railties (>= 4)
request_store (~> 1.0)
loofah (2.2.2) loofah (2.2.2)
crass (~> 1.0.2) crass (~> 1.0.2)
nokogiri (>= 1.5.9) 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> <script>
import { mapState, mapGetters } from 'vuex'; import { mapActions, mapState, mapGetters } from 'vuex';
import ideSidebar from './ide_side_bar.vue'; import Mousetrap from 'mousetrap';
import ideContextbar from './ide_context_bar.vue'; import ideSidebar from './ide_side_bar.vue';
import repoTabs from './repo_tabs.vue'; import ideContextbar from './ide_context_bar.vue';
import ideStatusBar from './ide_status_bar.vue'; import repoTabs from './repo_tabs.vue';
import repoEditor from './repo_editor.vue'; import ideStatusBar from './ide_status_bar.vue';
import repoEditor from './repo_editor.vue';
import FindFile from './file_finder/index.vue';
export default { const originalStopCallback = Mousetrap.stopCallback;
components: {
ideSidebar, export default {
ideContextbar, components: {
repoTabs, ideSidebar,
ideStatusBar, ideContextbar,
repoEditor, repoTabs,
}, ideStatusBar,
props: { repoEditor,
emptyStateSvgPath: { FindFile,
type: String,
required: true,
}, },
noChangesStateSvgPath: { props: {
type: String, emptyStateSvgPath: {
required: true, type: String,
required: true,
},
noChangesStateSvgPath: {
type: String,
required: true,
},
committedStateSvgPath: {
type: String,
required: true,
},
}, },
committedStateSvgPath: { computed: {
type: String, ...mapState([
required: true, 'changedFiles',
'openFiles',
'viewer',
'currentMergeRequestId',
'fileFindVisible',
]),
...mapGetters(['activeFile', 'hasChanges']),
}, },
}, mounted() {
computed: { const returnValue = 'Are you sure you want to lose unsaved changes?';
...mapState(['changedFiles', 'openFiles', 'viewer', 'currentMergeRequestId']), window.onbeforeunload = e => {
...mapGetters(['activeFile', 'hasChanges']), if (!this.changedFiles.length) return undefined;
},
mounted() { Object.assign(e, {
const returnValue = 'Are you sure you want to lose unsaved changes?'; returnValue,
window.onbeforeunload = e => { });
if (!this.changedFiles.length) return undefined; return returnValue;
};
Object.assign(e, { Mousetrap.bind(['t', 'command+p', 'ctrl+p'], e => {
returnValue, 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> </script>
<template> <template>
<div <div
class="ide-view" class="ide-view"
> >
<find-file
v-show="fileFindVisible"
/>
<ide-sidebar /> <ide-sidebar />
<div <div
class="multi-file-edit-pane" class="multi-file-edit-pane"
......
// Fuzzy file finder // 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_TITLE_LENGTH = 50;
export const MAX_BODY_LENGTH = 72; export const MAX_BODY_LENGTH = 72;
import _ from 'underscore'; import _ from 'underscore';
import store from '../stores';
import DecorationsController from './decorations/controller'; import DecorationsController from './decorations/controller';
import DirtyDiffController from './diff/controller'; import DirtyDiffController from './diff/controller';
import Disposable from './common/disposable'; import Disposable from './common/disposable';
import ModelManager from './common/model_manager'; import ModelManager from './common/model_manager';
import editorOptions, { defaultEditorOptions } from './editor_options'; import editorOptions, { defaultEditorOptions } from './editor_options';
import gitlabTheme from './themes/gl_theme'; import gitlabTheme from './themes/gl_theme';
import keymap from './keymap.json';
export const clearDomElement = el => { export const clearDomElement = el => {
if (!el || !el.firstChild) return; if (!el || !el.firstChild) return;
...@@ -53,6 +55,8 @@ export default class Editor { ...@@ -53,6 +55,8 @@ export default class Editor {
)), )),
); );
this.addCommands();
window.addEventListener('resize', this.debouncedUpdate, false); window.addEventListener('resize', this.debouncedUpdate, false);
} }
} }
...@@ -73,6 +77,8 @@ export default class Editor { ...@@ -73,6 +77,8 @@ export default class Editor {
})), })),
); );
this.addCommands();
window.addEventListener('resize', this.debouncedUpdate, false); window.addEventListener('resize', this.debouncedUpdate, false);
} }
} }
...@@ -189,4 +195,31 @@ export default class Editor { ...@@ -189,4 +195,31 @@ export default class Editor {
static renderSideBySide(domElement) { static renderSideBySide(domElement) {
return domElement.offsetWidth >= 700; 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) => { ...@@ -137,6 +137,9 @@ export const updateDelayViewerUpdated = ({ commit }, delay) => {
commit(types.UPDATE_DELAY_VIEWER_CHANGE, 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/tree';
export * from './actions/file'; export * from './actions/file';
export * from './actions/project'; export * from './actions/project';
......
...@@ -42,6 +42,19 @@ export const collapseButtonTooltip = state => ...@@ -42,6 +42,19 @@ export const collapseButtonTooltip = state =>
export const hasMergeRequest = state => !!state.currentMergeRequestId; 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); export const getStagedFile = state => path => state.stagedFiles.find(f => f.path === path);
// prevent babel-plugin-rewire from generating an invalid default during karma tests // prevent babel-plugin-rewire from generating an invalid default during karma tests
......
...@@ -58,3 +58,5 @@ export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE'; ...@@ -58,3 +58,5 @@ export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE';
export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT'; export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT';
export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB';
export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB'; export const REMOVE_PENDING_TAB = 'REMOVE_PENDING_TAB';
export const TOGGLE_FILE_FINDER = 'TOGGLE_FILE_FINDER';
...@@ -100,6 +100,11 @@ export default { ...@@ -100,6 +100,11 @@ export default {
delayViewerUpdated, delayViewerUpdated,
}); });
}, },
[types.TOGGLE_FILE_FINDER](state, fileFindVisible) {
Object.assign(state, {
fileFindVisible,
});
},
[types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) { [types.UPDATE_FILE_AFTER_COMMIT](state, { file, lastCommit }) {
const changedFile = state.changedFiles.find(f => f.path === file.path); const changedFile = state.changedFiles.find(f => f.path === file.path);
......
...@@ -4,6 +4,7 @@ export default { ...@@ -4,6 +4,7 @@ export default {
[types.SET_FILE_ACTIVE](state, { path, active }) { [types.SET_FILE_ACTIVE](state, { path, active }) {
Object.assign(state.entries[path], { Object.assign(state.entries[path], {
active, active,
lastOpenedAt: new Date().getTime(),
}); });
if (active && !state.entries[path].pending) { if (active && !state.entries[path].pending) {
......
...@@ -18,4 +18,5 @@ export default () => ({ ...@@ -18,4 +18,5 @@ export default () => ({
entries: {}, entries: {},
viewer: 'editor', viewer: 'editor',
delayViewerUpdated: false, delayViewerUpdated: false,
fileFindVisible: false,
}); });
...@@ -42,6 +42,7 @@ export const dataStructure = () => ({ ...@@ -42,6 +42,7 @@ export const dataStructure = () => ({
viewMode: 'edit', viewMode: 'edit',
previewMode: null, previewMode: null,
size: 0, size: 0,
lastOpenedAt: 0,
}); });
export const decorateData = entity => { export const decorateData = entity => {
......
...@@ -45,7 +45,7 @@ export default { ...@@ -45,7 +45,7 @@ export default {
return timeIntervalInWords(this.job.queued); return timeIntervalInWords(this.job.queued);
}, },
runnerId() { runnerId() {
return `#${this.job.runner.id}`; return `${this.job.runner.description} (#${this.job.runner.id})`;
}, },
retryButtonClass() { retryButtonClass() {
let className = 'js-retry-button pull-right btn btn-retry visible-md-block visible-lg-block'; 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 { ...@@ -482,6 +482,7 @@ img.emoji {
.append-right-20 { margin-right: 20px; } .append-right-20 { margin-right: 20px; }
.append-bottom-0 { margin-bottom: 0; } .append-bottom-0 { margin-bottom: 0; }
.append-bottom-5 { margin-bottom: 5px; } .append-bottom-5 { margin-bottom: 5px; }
.append-bottom-8 { margin-bottom: $grid-size; }
.append-bottom-10 { margin-bottom: 10px; } .append-bottom-10 { margin-bottom: 10px; }
.append-bottom-15 { margin-bottom: 15px; } .append-bottom-15 { margin-bottom: 15px; }
.append-bottom-20 { margin-bottom: 20px; } .append-bottom-20 { margin-bottom: 20px; }
......
...@@ -43,7 +43,7 @@ ...@@ -43,7 +43,7 @@
border-color: $gray-darkest; border-color: $gray-darkest;
} }
[data-toggle="dropdown"] { [data-toggle='dropdown'] {
outline: 0; outline: 0;
} }
} }
...@@ -172,7 +172,11 @@ ...@@ -172,7 +172,11 @@
color: $brand-danger; color: $brand-danger;
} }
&:hover, &.disable-hover {
text-decoration: none;
}
&:not(.disable-hover):hover,
&:active, &:active,
&:focus, &:focus,
&.is-focused { &.is-focused {
...@@ -508,17 +512,16 @@ ...@@ -508,17 +512,16 @@
} }
&.is-indeterminate::before { &.is-indeterminate::before {
content: "\f068"; content: '\f068';
} }
&.is-active::before { &.is-active::before {
content: "\f00c"; content: '\f00c';
} }
} }
} }
} }
.dropdown-title { .dropdown-title {
position: relative; position: relative;
padding: 2px 25px 10px; padding: 2px 25px 10px;
...@@ -724,7 +727,6 @@ ...@@ -724,7 +727,6 @@
} }
} }
.dropdown-menu-due-date { .dropdown-menu-due-date {
.dropdown-content { .dropdown-content {
max-height: 230px; max-height: 230px;
...@@ -854,9 +856,13 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -854,9 +856,13 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
} }
.projects-list-frequent-container, .projects-list-frequent-container,
.projects-list-search-container, { .projects-list-search-container {
padding: 8px 0; padding: 8px 0;
overflow-y: auto; overflow-y: auto;
li.section-empty.section-failure {
color: $callout-danger-color;
}
} }
.section-header, .section-header,
...@@ -867,13 +873,6 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -867,13 +873,6 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
font-size: $gl-font-size; 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 { .search-input-container {
position: relative; position: relative;
padding: 4px $gl-padding; padding: 4px $gl-padding;
...@@ -905,8 +904,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu { ...@@ -905,8 +904,7 @@ header.header-content .dropdown-menu.projects-dropdown-menu {
} }
.projects-list-item-container { .projects-list-item-container {
.project-item-avatar-container .project-item-avatar-container .project-item-metadata-container {
.project-item-metadata-container {
float: left; float: left;
} }
......
...@@ -17,6 +17,7 @@ ...@@ -17,6 +17,7 @@
} }
.ide-view { .ide-view {
position: relative;
display: flex; display: flex;
height: calc(100vh - #{$header-height}); height: calc(100vh - #{$header-height});
margin-top: 0; margin-top: 0;
...@@ -876,6 +877,26 @@ ...@@ -876,6 +877,26 @@
font-weight: $gl-font-weight-bold; 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 { .ide-commit-message-field {
height: 200px; height: 200px;
background-color: $white-light; background-color: $white-light;
......
...@@ -167,8 +167,8 @@ module IssuableCollections ...@@ -167,8 +167,8 @@ module IssuableCollections
[:project, :author, :assignees, :labels, :milestone, project: :namespace] [:project, :author, :assignees, :labels, :milestone, project: :namespace]
when 'MergeRequest' when 'MergeRequest'
[ [
:source_project, :target_project, :author, :assignee, :labels, :milestone, :target_project, :author, :assignee, :labels, :milestone,
head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits source_project: :route, head_pipeline: :project, target_project: :namespace, latest_merge_request_diff: :merge_request_diff_commits
] ]
end end
end end
......
...@@ -83,6 +83,14 @@ module GitlabRoutingHelper ...@@ -83,6 +83,14 @@ module GitlabRoutingHelper
end end
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) def toggle_subscription_path(entity, *args)
if entity.is_a?(Issue) if entity.is_a?(Issue)
toggle_subscription_project_issue_path(entity.project, entity) toggle_subscription_project_issue_path(entity.project, entity)
......
...@@ -72,6 +72,11 @@ class Project < ActiveRecord::Base ...@@ -72,6 +72,11 @@ class Project < ActiveRecord::Base
after_save :update_project_statistics, if: :namespace_id_changed? after_save :update_project_statistics, if: :namespace_id_changed?
after_create :create_project_feature, unless: :project_feature 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_activity_at
after_create :set_last_repository_updated_at after_create :set_last_repository_updated_at
after_update :update_forks_visibility_level after_update :update_forks_visibility_level
...@@ -238,6 +243,7 @@ class Project < ActiveRecord::Base ...@@ -238,6 +243,7 @@ class Project < ActiveRecord::Base
has_many :custom_attributes, class_name: 'ProjectCustomAttribute' has_many :custom_attributes, class_name: 'ProjectCustomAttribute'
has_many :project_badges, class_name: 'ProjectBadge' 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 :variables, allow_destroy: true
accepts_nested_attributes_for :project_feature, update_only: 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 ...@@ -24,7 +24,7 @@ class GroupPolicy < BasePolicy
condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) } condition(:can_change_parent_share_with_group_lock) { can?(:change_share_with_group_lock, @subject.parent) }
condition(:has_projects) do 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 end
with_options scope: :subject, score: 0 with_options scope: :subject, score: 0
...@@ -46,7 +46,11 @@ class GroupPolicy < BasePolicy ...@@ -46,7 +46,11 @@ class GroupPolicy < BasePolicy
end end
rule { admin } .enable :read_group 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 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 # Only use Lograge for Rails
unless Sidekiq.server? unless Sidekiq.server?
filename = File.join(Rails.root, 'log', "#{Rails.env}_json.log") filename = File.join(Rails.root, 'log', "#{Rails.env}_json.log")
......
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 ...@@ -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 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| create_table "project_custom_attributes", force: :cascade do |t|
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
...@@ -2809,6 +2816,7 @@ ActiveRecord::Schema.define(version: 20180419031622) do ...@@ -2809,6 +2816,7 @@ ActiveRecord::Schema.define(version: 20180419031622) do
add_foreign_key "project_authorizations", "projects", on_delete: :cascade add_foreign_key "project_authorizations", "projects", on_delete: :cascade
add_foreign_key "project_authorizations", "users", 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_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_custom_attributes", "projects", on_delete: :cascade
add_foreign_key "project_deploy_tokens", "deploy_tokens", on_delete: :cascade add_foreign_key "project_deploy_tokens", "deploy_tokens", on_delete: :cascade
add_foreign_key "project_deploy_tokens", "projects", 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: ...@@ -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 | `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. | `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 It's recommended to nest all gitlab data dirs within a mount, that allows automatic
between all GitLab cluster nodes. No other locations should be shared. The restore of backups without manually moving existing data.
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'`
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 mountpoint
logs to a central location consider using remote syslog. GitLab Omnibus packages └── gitlab-data
provide configuration for [UDP log shipping][udp-log-shipping]. ├── builds
├── git-data
### Consolidating mount points ├── home-git
├── shared
If you don't want to configure 5-6 different NFS mount points, you have a few └── uploads
alternative options. ```
#### 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 Mount `/gitlab-nfs` then use the following Omnibus
you can specify just one main mountpoint and have all of these locations
as subdirectories. Mount `/gitlab-data` then use the following Omnibus
configuration to move each data location to a subdirectory: configuration to move each data location to a subdirectory:
```ruby ```ruby
git_data_dirs({"default" => "/gitlab-data/git-data"}) git_data_dirs({"default" => "/gitlab-nfs/gitlab-data/git-data"})
user['home'] = '/gitlab-data/home' user['home'] = '/gitlab-nfs/gitlab-data/home'
gitlab_rails['uploads_directory'] = '/gitlab-data/uploads' gitlab_rails['uploads_directory'] = '/gitlab-nfs/gitlab-data/uploads'
gitlab_rails['shared_path'] = '/gitlab-data/shared' gitlab_rails['shared_path'] = '/gitlab-nfs/gitlab-data/shared'
gitlab_ci['builds_directory'] = '/gitlab-data/builds' gitlab_ci['builds_directory'] = '/gitlab-nfs/gitlab-data/builds'
``` ```
To move the `git` home directory, all GitLab services must be stopped. Run 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 ...@@ -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 be aware that if you had existing data you will need to manually copy/rsync it
to these new locations and then restart GitLab. 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 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 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 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`: `/etc/fstab`:
```bash ```bash
/gitlab-data/git-data /var/opt/gitlab/git-data none bind 0 0 /gitlab-nfs/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-nfs/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-nfs/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-nfs/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/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: Read more on high-availability configuration:
......
...@@ -1714,8 +1714,8 @@ capitalization, the commit will be created but the pipeline will be skipped. ...@@ -1714,8 +1714,8 @@ capitalization, the commit will be created but the pipeline will be skipped.
## Validate the .gitlab-ci.yml ## Validate the .gitlab-ci.yml
Each instance of GitLab CI has an embedded debug tool called Lint, which validates the Each instance of GitLab CI has an embedded debug tool called Lint, which validates the
content of your `.gitlab-ci.yml` files. You can find the Lint under the page `ci/lint` of your 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`) project namespace (e.g, `http://gitlab-example.com/gitlab-org/project-123/-/ci/lint`)
## Using reserved keywords ## Using reserved keywords
......
...@@ -498,6 +498,13 @@ more of the following options: ...@@ -498,6 +498,13 @@ more of the following options:
Read what the [backup timestamp is about](#backup-timestamp). 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. - `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 ### Restore for installation from source
``` ```
......
...@@ -2,12 +2,19 @@ ...@@ -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. 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. 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. 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 ...@@ -216,10 +216,6 @@ module SharedPaths
visit edit_project_path(@project) visit edit_project_path(@project)
end 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 step 'I visit a binary file in the repo' do
visit project_blob_path(@project, visit project_blob_path(@project,
File.join(root_ref, 'files/images/logo-black.png')) File.join(root_ref, 'files/images/logo-black.png'))
......
...@@ -53,6 +53,8 @@ module Backup ...@@ -53,6 +53,8 @@ module Backup
FileUtils.mv(files, timestamped_files_path) FileUtils.mv(files, timestamped_files_path)
rescue Errno::EACCES rescue Errno::EACCES
access_denied_error(app_files_dir) access_denied_error(app_files_dir)
rescue Errno::EBUSY
resource_busy_error(app_files_dir)
end end
end end
end end
......
...@@ -13,5 +13,19 @@ module Backup ...@@ -13,5 +13,19 @@ module Backup
EOS EOS
raise message raise message
end 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
end end
...@@ -81,6 +81,8 @@ module Backup ...@@ -81,6 +81,8 @@ module Backup
FileUtils.mv(files, bk_repos_path) FileUtils.mv(files, bk_repos_path)
rescue Errno::EACCES rescue Errno::EACCES
access_denied_error(path) access_denied_error(path)
rescue Errno::EBUSY
resource_busy_error(path)
end end
end end
end end
......
...@@ -66,6 +66,7 @@ project_tree: ...@@ -66,6 +66,7 @@ project_tree:
- :custom_attributes - :custom_attributes
- :prometheus_metrics - :prometheus_metrics
- :project_badges - :project_badges
- :ci_cd_settings
# Only include the following attributes for the models specified. # Only include the following attributes for the models specified.
included_attributes: included_attributes:
...@@ -75,6 +76,8 @@ included_attributes: ...@@ -75,6 +76,8 @@ included_attributes:
- :username - :username
author: author:
- :name - :name
ci_cd_settings:
- :group_runners_enabled
# Do not include the following attributes for the models specified. # Do not include the following attributes for the models specified.
excluded_attributes: excluded_attributes:
......
...@@ -18,7 +18,8 @@ module Gitlab ...@@ -18,7 +18,8 @@ module Gitlab
auto_devops: :project_auto_devops, auto_devops: :project_auto_devops,
label: :project_label, label: :project_label,
custom_attributes: 'ProjectCustomAttribute', 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 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 ...@@ -170,6 +170,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
context 'on issue sidebar' do context 'on issue sidebar' do
before do before do
project_1.add_developer(user)
visit project_issue_path(project_1, issue) visit project_issue_path(project_1, issue)
end end
...@@ -180,6 +182,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do ...@@ -180,6 +182,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, project: project_1) } let(:board) { create(:board, project: project_1) }
before do before do
project_1.add_developer(user)
visit project_board_path(project_1, board) visit project_board_path(project_1, board)
wait_for_requests wait_for_requests
...@@ -194,6 +198,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do ...@@ -194,6 +198,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, group: parent) } let(:board) { create(:board, group: parent) }
before do before do
parent.add_developer(user)
visit group_board_path(parent, board) visit group_board_path(parent, board)
wait_for_requests wait_for_requests
...@@ -211,6 +217,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do ...@@ -211,6 +217,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
context 'on project issuable list' do context 'on project issuable list' do
before do before do
project_1.add_developer(user)
visit project_issues_path(project_1) visit project_issues_path(project_1)
end end
...@@ -237,6 +245,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do ...@@ -237,6 +245,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, project: project_1) } let(:board) { create(:board, project: project_1) }
before do before do
project_1.add_developer(user)
visit project_board_path(project_1, board) visit project_board_path(project_1, board)
end end
...@@ -247,6 +257,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do ...@@ -247,6 +257,8 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, group: parent) } let(:board) { create(:board, group: parent) }
before do before do
parent.add_developer(user)
visit group_board_path(parent, board) visit group_board_path(parent, board)
end end
...@@ -259,6 +271,7 @@ feature 'Labels Hierarchy', :js, :nested_groups do ...@@ -259,6 +271,7 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, project: project_1) } let(:board) { create(:board, project: project_1) }
before do before do
project_1.add_developer(user)
visit project_board_path(project_1, board) visit project_board_path(project_1, board)
find('.js-new-board-list').click find('.js-new-board-list').click
wait_for_requests wait_for_requests
...@@ -281,6 +294,7 @@ feature 'Labels Hierarchy', :js, :nested_groups do ...@@ -281,6 +294,7 @@ feature 'Labels Hierarchy', :js, :nested_groups do
let(:board) { create(:board, group: parent) } let(:board) { create(:board, group: parent) }
before do before do
parent.add_developer(user)
visit group_board_path(parent, board) visit group_board_path(parent, board)
find('.js-new-board-list').click find('.js-new-board-list').click
wait_for_requests 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 ...@@ -89,4 +89,19 @@ describe GitlabRoutingHelper do
expect(preview_markdown_path(project)).to eq("/#{project.full_path}/preview_markdown") expect(preview_markdown_path(project)).to eq("/#{project.full_path}/preview_markdown")
end end
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 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 Vue from 'vue';
import Mousetrap from 'mousetrap';
import store from '~/ide/stores'; import store from '~/ide/stores';
import ide from '~/ide/components/ide.vue'; import ide from '~/ide/components/ide.vue';
import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
...@@ -38,4 +39,68 @@ describe('ide component', () => { ...@@ -38,4 +39,68 @@ describe('ide component', () => {
done(); 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);
});
});
}); });
...@@ -339,4 +339,17 @@ describe('Multi-file store actions', () => { ...@@ -339,4 +339,17 @@ describe('Multi-file store actions', () => {
.catch(done.fail); .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', () => { ...@@ -64,4 +64,24 @@ describe('IDE store getters', () => {
expect(getters.currentMergeRequest(localState)).toBeNull(); 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', () => { ...@@ -86,4 +86,12 @@ describe('Multi-file store mutations', () => {
expect(localState.viewer).toBe('diff'); 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', () => { ...@@ -102,7 +102,7 @@ describe('Sidebar details block', () => {
}); });
it('should render runner ID', () => { 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', () => { it('should render timeout information', () => {
......
...@@ -62,5 +62,19 @@ describe Backup::Files do ...@@ -62,5 +62,19 @@ describe Backup::Files do
subject.restore subject.restore
end end
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
end end
...@@ -81,6 +81,18 @@ describe Backup::Repository do ...@@ -81,6 +81,18 @@ describe Backup::Repository do
subject.restore subject.restore
end end
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 end
describe '#empty_repo?' do describe '#empty_repo?' do
......
...@@ -325,6 +325,7 @@ project: ...@@ -325,6 +325,7 @@ project:
- internal_ids - internal_ids
- project_deploy_tokens - project_deploy_tokens
- deploy_tokens - deploy_tokens
- ci_cd_settings
award_emoji: award_emoji:
- awardable - awardable
- user - user
......
...@@ -585,3 +585,5 @@ Badge: ...@@ -585,3 +585,5 @@ Badge:
- created_at - created_at
- updated_at - updated_at
- type - 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 ...@@ -95,6 +95,15 @@ describe Project do
end end
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 describe '#members & #requesters' do
let(:project) { create(:project, :public, :access_requestable) } let(:project) { create(:project, :public, :access_requestable) }
let(:requester) { create(:user) } let(:requester) { create(:user) }
......
...@@ -8,9 +8,9 @@ describe GroupPolicy do ...@@ -8,9 +8,9 @@ describe GroupPolicy do
let(:owner) { create(:user) } let(:owner) { create(:user) }
let(:auditor) { create(:user, :auditor) } let(:auditor) { create(:user, :auditor) }
let(:admin) { create(:admin) } 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] } let(:reporter_permissions) { [:admin_label] }
...@@ -51,6 +51,7 @@ describe GroupPolicy do ...@@ -51,6 +51,7 @@ describe GroupPolicy do
end end
context 'with no user' do context 'with no user' do
let(:group) { create(:group, :public) }
let(:current_user) { nil } let(:current_user) { nil }
it do it do
...@@ -64,6 +65,28 @@ describe GroupPolicy do ...@@ -64,6 +65,28 @@ describe GroupPolicy do
end end
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 context 'guests' do
let(:current_user) { guest } let(:current_user) { guest }
......
shared_examples 'issuables list meta-data' do |issuable_type, action = nil| shared_examples 'issuables list meta-data' do |issuable_type, action = nil|
before do include ProjectForksHelper
@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
it "creates indexed meta-data object for issuable notes and votes count" do def get_action(action, project)
if action if action
get action, author_id: project.creator.id get action, author_id: project.creator.id
else else
get :index, namespace_id: project.namespace, project_id: project get :index, namespace_id: project.namespace, project_id: project
end 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) meta_data = assigns(:issuable_meta_data)
...@@ -29,18 +34,29 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil| ...@@ -29,18 +34,29 @@ shared_examples 'issuables list meta-data' do |issuable_type, action = nil|
end end
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 describe "when given empty collection" do
let(:project2) { create(:project, :public) } let(:project2) { create(:project, :public) }
it "doesn't execute any queries with false conditions" do it "doesn't execute any queries with false conditions" do
get_action = get_empty =
if action if action
proc { get action, author_id: project.creator.id } proc { get action, author_id: project.creator.id }
else else
proc { get :index, namespace_id: project2.namespace, project_id: project2 } proc { get :index, namespace_id: project2.namespace, project_id: project2 }
end 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 end
end end
...@@ -8478,6 +8478,10 @@ vue-template-es2015-compiler@^1.6.0: ...@@ -8478,6 +8478,10 @@ vue-template-es2015-compiler@^1.6.0:
version "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" 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: vue@^2.5.13:
version "2.5.13" version "2.5.13"
resolved "https://registry.yarnpkg.com/vue/-/vue-2.5.13.tgz#95bd31e20efcf7a7f39239c9aa6787ce8cf578e1" 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