diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index f34340fc21c24d12a364d362093e3f5dd7254a27..99e0d1ed987332a0f10efa6e9d7cafa625ffd15d 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -0.119.0 +0.120.0 diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue new file mode 100644 index 0000000000000000000000000000000000000000..c3ca147e850e098c7c72f997894deb516cef0917 --- /dev/null +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -0,0 +1,78 @@ +<script> +import $ from 'jquery'; +import { mapActions } from 'vuex'; +import { __ } from '~/locale'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; +import ChangedFileIcon from '../changed_file_icon.vue'; + +export default { + components: { + FileIcon, + ChangedFileIcon, + }, + props: { + activeFile: { + type: Object, + required: true, + }, + }, + computed: { + activeButtonText() { + return this.activeFile.staged ? __('Unstage') : __('Stage'); + }, + isStaged() { + return !this.activeFile.changed && this.activeFile.staged; + }, + }, + methods: { + ...mapActions(['stageChange', 'unstageChange']), + actionButtonClicked() { + if (this.activeFile.staged) { + this.unstageChange(this.activeFile.path); + } else { + this.stageChange(this.activeFile.path); + } + }, + showDiscardModal() { + $(document.getElementById(`discard-file-${this.activeFile.path}`)).modal('show'); + }, + }, +}; +</script> + +<template> + <div class="d-flex ide-commit-editor-header align-items-center"> + <file-icon + :file-name="activeFile.name" + :size="16" + class="mr-2" + /> + <strong class="mr-2"> + {{ activeFile.path }} + </strong> + <changed-file-icon + :file="activeFile" + /> + <div class="ml-auto"> + <button + v-if="!isStaged" + type="button" + class="btn btn-remove btn-inverted append-right-8" + @click="showDiscardModal" + > + {{ __('Discard') }} + </button> + <button + :class="{ + 'btn-success': !isStaged, + 'btn-warning': isStaged + }" + type="button" + class="btn btn-inverted" + @click="actionButtonClicked" + > + {{ activeButtonText }} + </button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list.vue b/app/assets/javascripts/ide/components/commit_sidebar/list.vue index d0fb0e3d99e249e8cb2df5cfba026f84ab744f82..3fdd35ad228d7c109755401b792b6714644a8b3a 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list.vue @@ -1,7 +1,9 @@ <script> +import $ from 'jquery'; import { mapActions } from 'vuex'; import { __, sprintf } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; import tooltip from '~/vue_shared/directives/tooltip'; import ListItem from './list_item.vue'; @@ -9,6 +11,7 @@ export default { components: { Icon, ListItem, + GlModal, }, directives: { tooltip, @@ -56,6 +59,11 @@ export default { type: String, required: true, }, + emptyStateText: { + type: String, + required: false, + default: __('No changes'), + }, }, computed: { titleText() { @@ -68,11 +76,19 @@ export default { }, }, methods: { - ...mapActions(['stageAllChanges', 'unstageAllChanges']), + ...mapActions(['stageAllChanges', 'unstageAllChanges', 'discardAllChanges']), actionBtnClicked() { this[this.action](); + + $(this.$refs.actionBtn).tooltip('hide'); + }, + openDiscardModal() { + $('#discard-all-changes').modal('show'); }, }, + discardModalText: __( + "You will loose all the unstaged changes you've made in this project. This action cannot be undone.", + ), }; </script> @@ -81,27 +97,32 @@ export default { class="ide-commit-list-container" > <header - class="multi-file-commit-panel-header" + class="multi-file-commit-panel-header d-flex mb-0" > <div - class="multi-file-commit-panel-header-title" + class="d-flex align-items-center flex-fill" > <icon v-once :name="iconName" :size="18" + class="append-right-8" /> - {{ titleText }} + <strong> + {{ titleText }} + </strong> <div class="d-flex ml-auto"> <button v-tooltip - v-show="filesLength" + ref="actionBtn" + :title="actionBtnText" + :aria-label="actionBtnText" + :disabled="!filesLength" :class="{ - 'd-flex': filesLength + 'disabled-content': !filesLength }" - :title="actionBtnText" type="button" - class="btn btn-default ide-staged-action-btn p-0 order-1 align-items-center" + class="d-flex ide-staged-action-btn p-0 border-0 align-items-center" data-placement="bottom" data-container="body" data-boundary="viewport" @@ -109,18 +130,32 @@ export default { > <icon :name="actionBtnIcon" - :size="12" + :size="16" class="ml-auto mr-auto" /> </button> - <span + <button + v-tooltip + v-if="!stagedList" + :title="__('Discard all changes')" + :aria-label="__('Discard all changes')" + :disabled="!filesLength" :class="{ - 'rounded-right': !filesLength + 'disabled-content': !filesLength }" - class="ide-commit-file-count order-0 rounded-left text-center" + type="button" + class="d-flex ide-staged-action-btn p-0 border-0 align-items-center" + data-placement="bottom" + data-container="body" + data-boundary="viewport" + @click="openDiscardModal" > - {{ filesLength }} - </span> + <icon + :size="16" + name="remove-all" + class="ml-auto mr-auto" + /> + </button> </div> </div> </header> @@ -143,9 +178,19 @@ export default { </ul> <p v-else - class="multi-file-commit-list form-text text-muted" + class="multi-file-commit-list form-text text-muted text-center" > - {{ __('No changes') }} + {{ emptyStateText }} </p> + <gl-modal + v-if="!stagedList" + id="discard-all-changes" + :footer-primary-button-text="__('Discard all changes')" + :header-title-text="__('Discard all unstaged changes?')" + footer-primary-button-variant="danger" + @submit="discardAllChanges" + > + {{ $options.discardModalText }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 391004dcd3c6b4cc6a2b5f71f485b23c1de72629..10c78a8030247045b8c651d72856958cdc756542 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -2,6 +2,7 @@ import { mapActions } from 'vuex'; import tooltip from '~/vue_shared/directives/tooltip'; import Icon from '~/vue_shared/components/icon.vue'; +import FileIcon from '~/vue_shared/components/file_icon.vue'; import StageButton from './stage_button.vue'; import UnstageButton from './unstage_button.vue'; import { viewerTypes } from '../../constants'; @@ -12,6 +13,7 @@ export default { Icon, StageButton, UnstageButton, + FileIcon, }, directives: { tooltip, @@ -48,7 +50,7 @@ export default { return `${getCommitIconMap(this.file).icon}${suffix}`; }, iconClass() { - return `${getCommitIconMap(this.file).class} append-right-8`; + return `${getCommitIconMap(this.file).class} ml-auto mr-auto`; }, fullKey() { return `${this.keyPrefix}-${this.file.key}`; @@ -105,17 +107,24 @@ export default { @click="openFileInEditor" > <span class="multi-file-commit-list-file-path d-flex align-items-center"> - <icon - :name="iconName" - :size="16" - :css-classes="iconClass" + <file-icon + :file-name="file.name" + class="append-right-8" />{{ file.name }} </span> + <div class="ml-auto d-flex align-items-center"> + <div class="d-flex align-items-center ide-commit-list-changed-icon"> + <icon + :name="iconName" + :size="16" + :css-classes="iconClass" + /> + </div> + <component + :is="actionComponent" + :path="file.path" + /> + </div> </div> - <component - :is="actionComponent" - :path="file.path" - class="d-flex position-absolute" - /> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue index e6044401c9fd5757647bad428794f58119bc8866..8a1836a5c92d66d2757e12c1ca57e1a6e952a4c8 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/stage_button.vue @@ -1,11 +1,15 @@ <script> +import $ from 'jquery'; import { mapActions } from 'vuex'; +import { sprintf, __ } from '~/locale'; import Icon from '~/vue_shared/components/icon.vue'; import tooltip from '~/vue_shared/directives/tooltip'; +import GlModal from '~/vue_shared/components/gl_modal.vue'; export default { components: { Icon, + GlModal, }, directives: { tooltip, @@ -16,8 +20,22 @@ export default { required: true, }, }, + computed: { + modalId() { + return `discard-file-${this.path}`; + }, + modalTitle() { + return sprintf( + __('Discard changes to %{path}?'), + { path: this.path }, + ); + }, + }, methods: { ...mapActions(['stageChange', 'discardFileChanges']), + showDiscardModal() { + $(document.getElementById(this.modalId)).modal('show'); + }, }, }; </script> @@ -25,51 +43,50 @@ export default { <template> <div v-once - class="multi-file-discard-btn dropdown" + class="multi-file-discard-btn d-flex" > <button v-tooltip :aria-label="__('Stage changes')" :title="__('Stage changes')" type="button" - class="btn btn-blank append-right-5 d-flex align-items-center" + class="btn btn-blank align-items-center" data-container="body" data-boundary="viewport" data-placement="bottom" - @click.stop="stageChange(path)" + @click.stop.prevent="stageChange(path)" > <icon - :size="12" + :size="16" name="mobile-issue-close" + class="ml-auto mr-auto" /> </button> <button v-tooltip - :title="__('More actions')" + :aria-label="__('Discard changes')" + :title="__('Discard changes')" type="button" - class="btn btn-blank d-flex align-items-center" + class="btn btn-blank align-items-center" data-container="body" data-boundary="viewport" data-placement="bottom" - data-toggle="dropdown" - data-display="static" + @click.stop.prevent="showDiscardModal" > <icon - :size="12" - name="ellipsis_h" + :size="16" + name="remove" + class="ml-auto mr-auto" /> </button> - <div class="dropdown-menu dropdown-menu-right"> - <ul> - <li> - <button - type="button" - @click.stop="discardFileChanges(path)" - > - {{ __('Discard changes') }} - </button> - </li> - </ul> - </div> + <gl-modal + :id="modalId" + :header-title-text="modalTitle" + :footer-primary-button-text="__('Discard changes')" + footer-primary-button-variant="danger" + @submit="discardFileChanges(path)" + > + {{ __("You will loose all changes you've made to this file. This action cannot be undone.") }} + </gl-modal> </div> </template> diff --git a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue index 9cec73ec00e8ab0ea6b40d9c32c23af9cd797531..86c4060207464ff8a3b612d277e72fec56ab8cd1 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/unstage_button.vue @@ -25,22 +25,23 @@ export default { <template> <div v-once - class="multi-file-discard-btn" + class="multi-file-discard-btn d-flex" > <button v-tooltip :aria-label="__('Unstage changes')" :title="__('Unstage changes')" type="button" - class="btn btn-blank d-flex align-items-center" + class="btn btn-blank align-items-center" data-container="body" data-boundary="viewport" data-placement="bottom" - @click="unstageChange(path)" + @click.stop.prevent="unstageChange(path)" > <icon - :size="12" - name="history" + :size="16" + name="redo" + class="ml-auto mr-auto" /> </button> </div> diff --git a/app/assets/javascripts/ide/components/file_templates/bar.vue b/app/assets/javascripts/ide/components/file_templates/bar.vue new file mode 100644 index 0000000000000000000000000000000000000000..23be5f45f1656c138da0a0c5421f5f3b6cc72f5b --- /dev/null +++ b/app/assets/javascripts/ide/components/file_templates/bar.vue @@ -0,0 +1,80 @@ +<script> +import { mapActions, mapGetters, mapState } from 'vuex'; +import Dropdown from './dropdown.vue'; + +export default { + components: { + Dropdown, + }, + computed: { + ...mapGetters(['activeFile']), + ...mapGetters('fileTemplates', ['templateTypes']), + ...mapState('fileTemplates', ['selectedTemplateType', 'updateSuccess']), + showTemplatesDropdown() { + return Object.keys(this.selectedTemplateType).length > 0; + }, + }, + watch: { + activeFile: 'setInitialType', + }, + mounted() { + this.setInitialType(); + }, + methods: { + ...mapActions('fileTemplates', [ + 'setSelectedTemplateType', + 'fetchTemplate', + 'undoFileTemplate', + ]), + setInitialType() { + const initialTemplateType = this.templateTypes.find(t => t.name === this.activeFile.name); + + if (initialTemplateType) { + this.setSelectedTemplateType(initialTemplateType); + } + }, + selectTemplateType(templateType) { + this.setSelectedTemplateType(templateType); + }, + selectTemplate(template) { + this.fetchTemplate(template); + }, + undo() { + this.undoFileTemplate(); + }, + }, +}; +</script> + +<template> + <div class="d-flex align-items-center ide-file-templates"> + <strong class="append-right-default"> + {{ __('File templates') }} + </strong> + <dropdown + :data="templateTypes" + :label="selectedTemplateType.name || __('Choose a type...')" + class="mr-2" + @click="selectTemplateType" + /> + <dropdown + v-if="showTemplatesDropdown" + :label="__('Choose a template...')" + :is-async-data="true" + :searchable="true" + :title="__('File templates')" + class="mr-2" + @click="selectTemplate" + /> + <transition name="fade"> + <button + v-show="updateSuccess" + type="button" + class="btn btn-default" + @click="undo" + > + {{ __('Undo') }} + </button> + </transition> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/file_templates/dropdown.vue b/app/assets/javascripts/ide/components/file_templates/dropdown.vue new file mode 100644 index 0000000000000000000000000000000000000000..13059937f852219c6a0187b64a9f3d8a8665cba2 --- /dev/null +++ b/app/assets/javascripts/ide/components/file_templates/dropdown.vue @@ -0,0 +1,125 @@ +<script> +import $ from 'jquery'; +import { mapActions, mapState } from 'vuex'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import DropdownButton from '~/vue_shared/components/dropdown/dropdown_button.vue'; + +export default { + components: { + DropdownButton, + LoadingIcon, + }, + props: { + data: { + type: Array, + required: false, + default: () => [], + }, + label: { + type: String, + required: true, + }, + title: { + type: String, + required: false, + default: null, + }, + isAsyncData: { + type: Boolean, + required: false, + default: false, + }, + searchable: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + search: '', + }; + }, + computed: { + ...mapState('fileTemplates', ['templates', 'isLoading']), + outputData() { + return (this.isAsyncData ? this.templates : this.data).filter(t => { + if (!this.searchable) return true; + + return t.name.toLowerCase().indexOf(this.search.toLowerCase()) >= 0; + }); + }, + showLoading() { + return this.isAsyncData ? this.isLoading : false; + }, + }, + mounted() { + $(this.$el).on('show.bs.dropdown', this.fetchTemplatesIfAsync); + }, + beforeDestroy() { + $(this.$el).off('show.bs.dropdown', this.fetchTemplatesIfAsync); + }, + methods: { + ...mapActions('fileTemplates', ['fetchTemplateTypes']), + fetchTemplatesIfAsync() { + if (this.isAsyncData) { + this.fetchTemplateTypes(); + } + }, + clickItem(item) { + this.$emit('click', item); + }, + }, +}; +</script> + +<template> + <div class="dropdown"> + <dropdown-button + :toggle-text="label" + data-display="static" + /> + <div class="dropdown-menu pb-0"> + <div + v-if="title" + class="dropdown-title ml-0 mr-0" + > + {{ title }} + </div> + <div + v-if="!showLoading && searchable" + class="dropdown-input" + > + <input + v-model="search" + :placeholder="__('Filter...')" + type="search" + class="dropdown-input-field" + /> + <i + aria-hidden="true" + class="fa fa-search dropdown-input-search" + ></i> + </div> + <div class="dropdown-content"> + <loading-icon + v-if="showLoading" + size="2" + /> + <ul v-else> + <li + v-for="(item, index) in outputData" + :key="index" + > + <button + type="button" + @click="clickItem(item)" + > + {{ item.name }} + </button> + </li> + </ul> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/ide/components/ide.vue b/app/assets/javascripts/ide/components/ide.vue index 6a5ab35a16a062a6427c0b35018a4d2bfb9f5025..a3add3b778ff0435d6c3e149d1116eebbd76154b 100644 --- a/app/assets/javascripts/ide/components/ide.vue +++ b/app/assets/javascripts/ide/components/ide.vue @@ -10,6 +10,7 @@ import RepoEditor from './repo_editor.vue'; import FindFile from './file_finder/index.vue'; import RightPane from './panes/right.vue'; import ErrorMessage from './error_message.vue'; +import CommitEditorHeader from './commit_sidebar/editor_header.vue'; const originalStopCallback = Mousetrap.stopCallback; @@ -23,6 +24,7 @@ export default { FindFile, RightPane, ErrorMessage, + CommitEditorHeader, }, computed: { ...mapState([ @@ -34,7 +36,7 @@ export default { 'currentProjectId', 'errorMessage', ]), - ...mapGetters(['activeFile', 'hasChanges', 'someUncommitedChanges']), + ...mapGetters(['activeFile', 'hasChanges', 'someUncommitedChanges', 'isCommitModeActive']), }, mounted() { window.onbeforeunload = e => this.onBeforeUnload(e); @@ -96,7 +98,12 @@ export default { <template v-if="activeFile" > + <commit-editor-header + v-if="isCommitModeActive" + :active-file="activeFile" + /> <repo-tabs + v-else :active-file="activeFile" :files="openFiles" :viewer="viewer" diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index e500ef0e1b5c1ac39c7dad8dfe1d962517936b33..bcd53ac1ba2ed42cc1dd3081ffbe913eafb2767a 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -1,6 +1,7 @@ <script> +import $ from 'jquery'; import { __ } from '~/locale'; -import { mapActions, mapState } from 'vuex'; +import { mapActions, mapState, mapGetters } from 'vuex'; import GlModal from '~/vue_shared/components/gl_modal.vue'; import { modalTypes } from '../../constants'; @@ -15,6 +16,7 @@ export default { }, computed: { ...mapState(['entryModal']), + ...mapGetters('fileTemplates', ['templateTypes']), entryName: { get() { if (this.entryModal.type === modalTypes.rename) { @@ -31,7 +33,9 @@ export default { if (this.entryModal.type === modalTypes.tree) { return __('Create new directory'); } else if (this.entryModal.type === modalTypes.rename) { - return this.entryModal.entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); + return this.entryModal.entry.type === modalTypes.tree + ? __('Rename folder') + : __('Rename file'); } return __('Create new file'); @@ -40,11 +44,16 @@ export default { if (this.entryModal.type === modalTypes.tree) { return __('Create directory'); } else if (this.entryModal.type === modalTypes.rename) { - return this.entryModal.entry.type === modalTypes.tree ? __('Rename folder') : __('Rename file'); + return this.entryModal.entry.type === modalTypes.tree + ? __('Rename folder') + : __('Rename file'); } return __('Create file'); }, + isCreatingNew() { + return this.entryModal.type !== modalTypes.rename; + }, }, methods: { ...mapActions(['createTempEntry', 'renameEntry']), @@ -61,6 +70,14 @@ export default { }); } }, + createFromTemplate(template) { + this.createTempEntry({ + name: template.name, + type: this.entryModal.type, + }); + + $('#ide-new-entry').modal('toggle'); + }, focusInput() { this.$refs.fieldName.focus(); }, @@ -77,6 +94,7 @@ export default { :header-title-text="modalTitle" :footer-primary-button-text="buttonLabel" footer-primary-button-variant="success" + modal-size="lg" @submit="submitForm" @open="focusInput" @closed="closedModal" @@ -84,16 +102,35 @@ export default { <div class="form-group row" > - <label class="label-bold col-form-label col-sm-3"> + <label class="label-bold col-form-label col-sm-2"> {{ __('Name') }} </label> - <div class="col-sm-9"> + <div class="col-sm-10"> <input ref="fieldName" v-model="entryName" type="text" class="form-control" + placeholder="/dir/file_name" /> + <ul + v-if="isCreatingNew" + class="prepend-top-default list-inline" + > + <li + v-for="(template, index) in templateTypes" + :key="index" + class="list-inline-item" + > + <button + type="button" + class="btn btn-missing p-1 pr-2 pl-2" + @click="createFromTemplate(template)" + > + {{ template.name }} + </button> + </li> + </ul> </div> </div> </gl-modal> diff --git a/app/assets/javascripts/ide/components/repo_commit_section.vue b/app/assets/javascripts/ide/components/repo_commit_section.vue index 6f1a941fbc4940d612228193c1fc1634b70ae5fe..d3b24c5b7934fff09c4bd13b27a906b9c4ac3511 100644 --- a/app/assets/javascripts/ide/components/repo_commit_section.vue +++ b/app/assets/javascripts/ide/components/repo_commit_section.vue @@ -95,8 +95,9 @@ export default { :file-list="changedFiles" :action-btn-text="__('Stage all changes')" :active-file-key="activeFileKey" + :empty-state-text="__('There are no unstaged changes')" action="stageAllChanges" - action-btn-icon="mobile-issue-close" + action-btn-icon="stage-all" item-action-component="stage-button" class="is-first" icon-name="unstaged" @@ -108,8 +109,9 @@ export default { :action-btn-text="__('Unstage all changes')" :staged-list="true" :active-file-key="activeFileKey" + :empty-state-text="__('There are no staged changes')" action="unstageAllChanges" - action-btn-icon="history" + action-btn-icon="unstage-all" item-action-component="unstage-button" icon-name="staged" /> diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index f55aa843444b7e41def9a0d89433016a52000890..d3a73e84cc7d6216ab932055cc121a566a902cd4 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -6,12 +6,14 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { activityBarViews, viewerTypes } from '../constants'; import Editor from '../lib/editor'; import ExternalLink from './external_link.vue'; +import FileTemplatesBar from './file_templates/bar.vue'; export default { components: { ContentViewer, DiffViewer, ExternalLink, + FileTemplatesBar, }, props: { file: { @@ -34,6 +36,7 @@ export default { 'isCommitModeActive', 'isReviewModeActive', ]), + ...mapGetters('fileTemplates', ['showFileTemplatesBar']), shouldHideEditor() { return this.file && this.file.binary && !this.file.content; }, @@ -216,7 +219,7 @@ export default { id="ide" class="blob-viewer-container blob-editor-container" > - <div class="ide-mode-tabs clearfix" > + <div class="ide-mode-tabs clearfix"> <ul v-if="!shouldHideEditor && isEditModeActive" class="nav-links float-left" @@ -249,6 +252,9 @@ export default { :file="file" /> </div> + <file-templates-bar + v-if="showFileTemplatesBar(file.name)" + /> <div v-show="!shouldHideEditor && file.viewMode ==='editor'" ref="editor" diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index aa02dfbddc4ac5be2fa99aad2163a23f82693add..b8b64aead302907a58cb219744547b879b3371dd 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -4,6 +4,7 @@ import { visitUrl } from '~/lib/utils/url_utility'; import flash from '~/flash'; import * as types from './mutation_types'; import FilesDecoratorWorker from './workers/files_decorator_worker'; +import { stageKeys } from '../constants'; export const redirectToUrl = (_, url) => visitUrl(url); @@ -122,14 +123,28 @@ export const scrollToTab = () => { }); }; -export const stageAllChanges = ({ state, commit }) => { +export const stageAllChanges = ({ state, commit, dispatch }) => { + const openFile = state.openFiles[0]; + commit(types.SET_LAST_COMMIT_MSG, ''); state.changedFiles.forEach(file => commit(types.STAGE_CHANGE, file.path)); + + dispatch('openPendingTab', { + file: state.stagedFiles.find(f => f.path === openFile.path), + keyPrefix: stageKeys.staged, + }); }; -export const unstageAllChanges = ({ state, commit }) => { +export const unstageAllChanges = ({ state, commit, dispatch }) => { + const openFile = state.openFiles[0]; + state.stagedFiles.forEach(file => commit(types.UNSTAGE_CHANGE, file.path)); + + dispatch('openPendingTab', { + file: state.changedFiles.find(f => f.path === openFile.path), + keyPrefix: stageKeys.unstaged, + }); }; export const updateViewer = ({ commit }, viewer) => { @@ -206,6 +221,7 @@ export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES); export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath = null }) => { const entry = state.entries[entryPath || path]; + commit(types.RENAME_ENTRY, { path, name, entryPath }); if (entry.type === 'tree') { @@ -214,7 +230,7 @@ export const renameEntry = ({ dispatch, commit, state }, { path, name, entryPath ); } - if (!entryPath) { + if (!entryPath && !entry.tempFile) { dispatch('deleteEntry', path); } }; diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 28b9d0df20142225f14834a40e3a00af9a2214d2..30dcf7ef4dfd5a0124e38533601a4c6778eba0d9 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -5,7 +5,7 @@ import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; import { setPageTitle } from '../utils'; -import { viewerTypes } from '../../constants'; +import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { const { path } = file; @@ -208,8 +208,9 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) = eventHub.$emit(`editor.update.model.dispose.unstaged-${file.key}`, file.content); }; -export const stageChange = ({ commit, state }, path) => { +export const stageChange = ({ commit, state, dispatch }, path) => { const stagedFile = state.stagedFiles.find(f => f.path === path); + const openFile = state.openFiles.find(f => f.path === path); commit(types.STAGE_CHANGE, path); commit(types.SET_LAST_COMMIT_MSG, ''); @@ -217,21 +218,39 @@ export const stageChange = ({ commit, state }, path) => { if (stagedFile) { eventHub.$emit(`editor.update.model.new.content.staged-${stagedFile.key}`, stagedFile.content); } + + if (openFile && openFile.active) { + const file = state.stagedFiles.find(f => f.path === path); + + dispatch('openPendingTab', { + file, + keyPrefix: stageKeys.staged, + }); + } }; -export const unstageChange = ({ commit }, path) => { +export const unstageChange = ({ commit, dispatch, state }, path) => { + const openFile = state.openFiles.find(f => f.path === path); + commit(types.UNSTAGE_CHANGE, path); + + if (openFile && openFile.active) { + const file = state.changedFiles.find(f => f.path === path); + + dispatch('openPendingTab', { + file, + keyPrefix: stageKeys.unstaged, + }); + } }; -export const openPendingTab = ({ commit, getters, dispatch, state }, { file, keyPrefix }) => { +export const openPendingTab = ({ commit, getters, state }, { file, keyPrefix }) => { if (getters.activeFile && getters.activeFile.key === `${keyPrefix}-${file.key}`) return false; state.openFiles.forEach(f => eventHub.$emit(`editor.update.model.dispose.${f.key}`)); commit(types.ADD_PENDING_TAB, { file, keyPrefix }); - dispatch('scrollToTab'); - router.push(`/project/${file.projectId}/tree/${state.currentBranchId}/`); return true; diff --git a/app/assets/javascripts/ide/stores/index.js b/app/assets/javascripts/ide/stores/index.js index a601dc8f5a0b726b8fe2bd65af23dfe3494b261a..877d88bb060f25f8ffaf90c0412d56e8820ae60b 100644 --- a/app/assets/javascripts/ide/stores/index.js +++ b/app/assets/javascripts/ide/stores/index.js @@ -8,6 +8,7 @@ import commitModule from './modules/commit'; import pipelines from './modules/pipelines'; import mergeRequests from './modules/merge_requests'; import branches from './modules/branches'; +import fileTemplates from './modules/file_templates'; Vue.use(Vuex); @@ -22,6 +23,7 @@ export const createStore = () => pipelines, mergeRequests, branches, + fileTemplates: fileTemplates(), }, }); diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js index 43237a294663bce690cf7f2c219c1f4ca62d6ee2..dd53213ed18a4ff734635780faa6fef60e77782c 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/actions.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/actions.js @@ -1,6 +1,7 @@ import Api from '~/api'; import { __ } from '~/locale'; import * as types from './mutation_types'; +import eventHub from '../../../eventhub'; export const requestTemplateTypes = ({ commit }) => commit(types.REQUEST_TEMPLATE_TYPES); export const receiveTemplateTypesError = ({ commit, dispatch }) => { @@ -31,9 +32,23 @@ export const fetchTemplateTypes = ({ dispatch, state }) => { .catch(() => dispatch('receiveTemplateTypesError')); }; -export const setSelectedTemplateType = ({ commit }, type) => +export const setSelectedTemplateType = ({ commit, dispatch, rootGetters }, type) => { commit(types.SET_SELECTED_TEMPLATE_TYPE, type); + if (rootGetters.activeFile.prevPath === type.name) { + dispatch('discardFileChanges', rootGetters.activeFile.path, { root: true }); + } else if (rootGetters.activeFile.name !== type.name) { + dispatch( + 'renameEntry', + { + path: rootGetters.activeFile.path, + name: type.name, + }, + { root: true }, + ); + } +}; + export const receiveTemplateError = ({ dispatch }, template) => { dispatch( 'setErrorMessage', @@ -69,6 +84,7 @@ export const setFileTemplate = ({ dispatch, commit, rootGetters }, template) => { root: true }, ); commit(types.SET_UPDATE_SUCCESS, true); + eventHub.$emit(`editor.update.model.new.content.${rootGetters.activeFile.key}`, template.content); }; export const undoFileTemplate = ({ dispatch, commit, rootGetters }) => { @@ -76,6 +92,12 @@ export const undoFileTemplate = ({ dispatch, commit, rootGetters }) => { dispatch('changeFileContent', { path: file.path, content: file.raw }, { root: true }); commit(types.SET_UPDATE_SUCCESS, false); + + eventHub.$emit(`editor.update.model.new.content.${file.key}`, file.raw); + + if (file.prevPath) { + dispatch('discardFileChanges', file.path, { root: true }); + } }; // prevent babel-plugin-rewire from generating an invalid default during karma tests diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js index 38318fd49bf6517bb5b3a528585e1e50bafe450a..628babe6a01976c6a96dc26501931cdbddeda35d 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js @@ -1,3 +1,5 @@ +import { activityBarViews } from '../../../constants'; + export const templateTypes = () => [ { name: '.gitlab-ci.yml', @@ -17,7 +19,8 @@ export const templateTypes = () => [ }, ]; -export const showFileTemplatesBar = (_, getters) => name => - getters.templateTypes.find(t => t.name === name); +export const showFileTemplatesBar = (_, getters, rootState) => name => + getters.templateTypes.find(t => t.name === name) && + rootState.currentActivityView === activityBarViews.edit; export default () => {}; diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/index.js b/app/assets/javascripts/ide/stores/modules/file_templates/index.js index dfa5ef54413240ed89c4df1c7545fb994c4fa0c6..383ff5db392e3abde781a9cf1cbc16bbb0fd9424 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/index.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/index.js @@ -3,10 +3,10 @@ import * as actions from './actions'; import * as getters from './getters'; import mutations from './mutations'; -export default { +export default () => ({ namespaced: true, actions, state: createState(), getters, mutations, -}; +}); diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index f2bb87ac6741ef364c67d517772062b4d0c64bd9..2c8535bda591cb6446c376476adda99160e14470 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -1,3 +1,4 @@ +import Vue from 'vue'; import * as types from './mutation_types'; import projectMutations from './mutations/project'; import mergeRequestMutation from './mutations/merge_request'; @@ -226,7 +227,7 @@ export default { path: newPath, name: entryPath ? oldEntry.name : name, tempFile: true, - prevPath: oldEntry.path, + prevPath: oldEntry.tempFile ? null : oldEntry.path, url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath), tree: [], parentPath, @@ -245,6 +246,20 @@ export default { if (newEntry.type === 'blob') { state.changedFiles = state.changedFiles.concat(newEntry); } + + if (state.entries[newPath].opened) { + state.openFiles.push(state.entries[newPath]); + } + + if (oldEntry.tempFile) { + const filterMethod = f => f.path !== oldEntry.path; + + state.openFiles = state.openFiles.filter(filterMethod); + state.changedFiles = state.changedFiles.filter(filterMethod); + parent.tree = parent.tree.filter(filterMethod); + + Vue.delete(state.entries, oldEntry.path); + } }, ...projectMutations, ...mergeRequestMutation, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 66f298248986910be8dbe98ce5346ad2a32ed60b..6ca246c1d63a1b1237ba855eb2c2e4603002228c 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -55,7 +55,7 @@ export default { f => f.path === file.path && f.pending && !(f.tempFile && !f.prevPath), ); - if (file.tempFile) { + if (file.tempFile && file.content === '') { Object.assign(state.entries[file.path], { content: raw, }); diff --git a/app/assets/javascripts/monitoring/components/graph.vue b/app/assets/javascripts/monitoring/components/graph.vue index e5680a0499f35aac3984857fbf41ca2dbdc2d531..a13f30e6079a0684d9fbf10ad13c25b2fb692184 100644 --- a/app/assets/javascripts/monitoring/components/graph.vue +++ b/app/assets/javascripts/monitoring/components/graph.vue @@ -82,11 +82,12 @@ export default { value: 0, }, currentXCoordinate: 0, - currentCoordinates: [], + currentCoordinates: {}, showFlag: false, showFlagContent: false, timeSeries: [], realPixelRatio: 1, + seriesUnderMouse: [], }; }, computed: { @@ -126,6 +127,9 @@ export default { this.draw(); }, methods: { + showDot(path) { + return this.showFlagContent && this.seriesUnderMouse.includes(path); + }, draw() { const breakpointSize = bp.getBreakpointSize(); const query = this.graphData.queries[0]; @@ -155,7 +159,24 @@ export default { point.y = e.clientY; point = point.matrixTransform(this.$refs.graphData.getScreenCTM().inverse()); point.x += 7; - const firstTimeSeries = this.timeSeries[0]; + + this.seriesUnderMouse = this.timeSeries.filter((series) => { + const mouseX = series.timeSeriesScaleX.invert(point.x); + let minDistance = Infinity; + + const closestTickMark = Object.keys(this.allXAxisValues).reduce((closest, x) => { + const distance = Math.abs(Number(new Date(x)) - Number(mouseX)); + if (distance < minDistance) { + minDistance = distance; + return x; + } + return closest; + }); + + return series.values.find(v => v.time.toString() === closestTickMark); + }); + + const firstTimeSeries = this.seriesUnderMouse[0]; const timeValueOverlay = firstTimeSeries.timeSeriesScaleX.invert(point.x); const overlayIndex = bisectDate(firstTimeSeries.values, timeValueOverlay, 1); const d0 = firstTimeSeries.values[overlayIndex - 1]; @@ -190,6 +211,17 @@ export default { axisXScale.domain(d3.extent(allValues, d => d.time)); axisYScale.domain([0, d3.max(allValues.map(d => d.value))]); + this.allXAxisValues = this.timeSeries.reduce((obj, series) => { + const seriesKeys = {}; + series.values.forEach(v => { + seriesKeys[v.time] = true; + }); + return { + ...obj, + ...seriesKeys, + }; + }, {}); + const xAxis = d3 .axisBottom() .scale(axisXScale) @@ -277,9 +309,8 @@ export default { :line-style="path.lineStyle" :line-color="path.lineColor" :area-color="path.areaColor" - :current-coordinates="currentCoordinates[index]" - :current-time-series-index="index" - :show-dot="showFlagContent" + :current-coordinates="currentCoordinates[path.metricTag]" + :show-dot="showDot(path)" /> <graph-deployment :deployment-data="reducedDeploymentData" @@ -303,7 +334,7 @@ export default { :graph-height="graphHeight" :graph-height-offset="graphHeightOffset" :show-flag-content="showFlagContent" - :time-series="timeSeries" + :time-series="seriesUnderMouse" :unit-of-display="unitOfDisplay" :legend-title="legendTitle" :deployment-flag-data="deploymentFlagData" diff --git a/app/assets/javascripts/monitoring/components/graph/flag.vue b/app/assets/javascripts/monitoring/components/graph/flag.vue index e00882526bcc9b65ad4812d279c719fe611c636c..f603ba6498dafa10502e7552465ba1e84e148cd0 100644 --- a/app/assets/javascripts/monitoring/components/graph/flag.vue +++ b/app/assets/javascripts/monitoring/components/graph/flag.vue @@ -52,7 +52,7 @@ export default { required: true, }, currentCoordinates: { - type: Array, + type: Object, required: true, }, }, @@ -91,8 +91,8 @@ export default { }, methods: { seriesMetricValue(seriesIndex, series) { - const indexFromCoordinates = this.currentCoordinates[seriesIndex] - ? this.currentCoordinates[seriesIndex].currentDataIndex : 0; + const indexFromCoordinates = this.currentCoordinates[series.metricTag] + ? this.currentCoordinates[series.metricTag].currentDataIndex : 0; const index = this.deploymentFlagData ? this.deploymentFlagData.seriesIndex : indexFromCoordinates; diff --git a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js index 4f23814ff3e07a760f5d5aae7e2966a11500d4b3..007451d5c7a07ced345eb8989faed0327b1936e4 100644 --- a/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js +++ b/app/assets/javascripts/monitoring/mixins/monitoring_mixins.js @@ -50,19 +50,24 @@ const mixins = { }, positionFlag() { - const timeSeries = this.timeSeries[0]; - const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate, 1); + const timeSeries = this.seriesUnderMouse[0]; + if (!timeSeries) { + return; + } + const hoveredDataIndex = bisectDate(timeSeries.values, this.hoverData.hoveredDate); this.currentData = timeSeries.values[hoveredDataIndex]; this.currentXCoordinate = Math.floor(timeSeries.timeSeriesScaleX(this.currentData.time)); - this.currentCoordinates = this.timeSeries.map((series) => { - const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate, 1); + this.currentCoordinates = {}; + + this.seriesUnderMouse.forEach((series) => { + const currentDataIndex = bisectDate(series.values, this.hoverData.hoveredDate); const currentData = series.values[currentDataIndex]; const currentX = Math.floor(series.timeSeriesScaleX(currentData.time)); const currentY = Math.floor(series.timeSeriesScaleY(currentData.value)); - return { + this.currentCoordinates[series.metricTag] = { currentX, currentY, currentDataIndex, diff --git a/app/assets/javascripts/monitoring/utils/multiple_time_series.js b/app/assets/javascripts/monitoring/utils/multiple_time_series.js index cee39fd0559e316cff92d91efec5c1c0a9e15b03..eff0d7325cd11b591b6dbb8970849f041acd9625 100644 --- a/app/assets/javascripts/monitoring/utils/multiple_time_series.js +++ b/app/assets/javascripts/monitoring/utils/multiple_time_series.js @@ -2,7 +2,7 @@ import _ from 'underscore'; import { scaleLinear, scaleTime } from 'd3-scale'; import { line, area, curveLinear } from 'd3-shape'; import { extent, max, sum } from 'd3-array'; -import { timeMinute } from 'd3-time'; +import { timeMinute, timeSecond } from 'd3-time'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; const d3 = { @@ -14,6 +14,7 @@ const d3 = { extent, max, timeMinute, + timeSecond, sum, }; @@ -51,6 +52,24 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom return defaultColorPalette[pick]; } + function findByDate(series, time) { + const val = series.find(v => Math.abs(d3.timeSecond.count(time, v.time)) < 60); + if (val) { + return val.value; + } + return NaN; + } + + // The timeseries data may have gaps in it + // but we need a regularly-spaced set of time/value pairs + // this gives us a complete range of one minute intervals + // offset the same amount as the original data + const [minX, maxX] = xDom; + const offset = d3.timeMinute(minX) - Number(minX); + const datesWithoutGaps = d3.timeSecond.every(60) + .range(d3.timeMinute.offset(minX, -1), maxX) + .map(d => d - offset); + query.result.forEach((timeSeries, timeSeriesNumber) => { let metricTag = ''; let lineColor = ''; @@ -119,9 +138,14 @@ function queryTimeSeries(query, graphWidth, graphHeight, graphHeightOffset, xDom }); } + const values = datesWithoutGaps.map(time => ({ + time, + value: findByDate(timeSeries.values, time), + })); + timeSeriesParsed.push({ - linePath: lineFunction(timeSeries.values), - areaPath: areaFunction(timeSeries.values), + linePath: lineFunction(values), + areaPath: areaFunction(values), timeSeriesScaleX, timeSeriesScaleY, values: timeSeries.values, diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 563a731ece443f846cfb08938c33d6187322d549..e07214c54cfa882925465a10aac3270ea2a06c23 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -166,6 +166,10 @@ @include btn-outline($white-light, $red-500, $red-500, $red-500, $white-light, $red-600, $red-600, $red-700); } + &.btn-warning { + @include btn-outline($white-light, $orange-500, $orange-500, $orange-500, $white-light, $orange-600, $orange-600, $orange-700); + } + &.btn-primary, &.btn-info { @include btn-outline($white-light, $blue-500, $blue-500, $blue-500, $white-light, $blue-600, $blue-600, $blue-700); diff --git a/app/assets/stylesheets/page_bundles/ide.scss b/app/assets/stylesheets/page_bundles/ide.scss index 5ff4e487d04619d6aa4a60628dc2162b553be935..45df8391f9a3cdc4e1fb0dd99eeac9825b1c2273 100644 --- a/app/assets/stylesheets/page_bundles/ide.scss +++ b/app/assets/stylesheets/page_bundles/ide.scss @@ -7,6 +7,8 @@ $ide-context-header-padding: 10px; $ide-project-avatar-end: $ide-context-header-padding + 48px; $ide-tree-padding: $gl-padding; $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; +$ide-commit-row-height: 32px; +$ide-commit-header-height: 48px; .project-refs-form, .project-refs-target-form { @@ -567,24 +569,11 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; } .multi-file-commit-panel-header { - display: flex; - align-items: center; - margin-bottom: 0; + height: $ide-commit-header-height; border-bottom: 1px solid $white-dark; padding: 12px 0; } -.multi-file-commit-panel-header-title { - display: flex; - flex: 1; - align-items: center; - - svg { - margin-right: $gl-btn-padding; - color: $theme-gray-700; - } -} - .multi-file-commit-panel-collapse-btn { border-left: 1px solid $white-dark; margin-left: auto; @@ -594,8 +583,6 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; flex: 1; overflow: auto; padding: $grid-size 0; - margin-left: -$grid-size; - margin-right: -$grid-size; min-height: 60px; &.form-text.text-muted { @@ -660,6 +647,8 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; .multi-file-commit-list-path { cursor: pointer; + height: $ide-commit-row-height; + padding-right: 0; &.is-active { background-color: $white-normal; @@ -668,6 +657,12 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; &:hover, &:focus { outline: 0; + + .multi-file-discard-btn { + > .btn { + display: flex; + } + } } svg { @@ -679,6 +674,7 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; .multi-file-commit-list-file-path { @include str-truncated(calc(100% - 30px)); + user-select: none; &:active { text-decoration: none; @@ -686,9 +682,11 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; } .multi-file-discard-btn { - top: 4px; - right: 8px; - bottom: 4px; + > .btn { + display: none; + width: $ide-commit-row-height; + height: $ide-commit-row-height; + } svg { top: 0; @@ -807,10 +805,9 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; } .ide-staged-action-btn { - width: 22px; - margin-left: -1px; - border-top-left-radius: 0; - border-bottom-left-radius: 0; + width: $ide-commit-row-height; + height: $ide-commit-row-height; + color: inherit; > svg { top: 0; @@ -1442,3 +1439,29 @@ $ide-tree-text-start: $ide-activity-bar-width + $ide-tree-padding; top: 50%; transform: translateY(-50%); } + +.ide-file-templates { + padding: $grid-size $gl-padding; + background-color: $gray-light; + border-bottom: 1px solid $white-dark; + + .dropdown { + min-width: 180px; + } + + .dropdown-content { + max-height: 222px; + } +} + +.ide-commit-editor-header { + height: 65px; + padding: 8px 16px; + background-color: $theme-gray-50; + box-shadow: inset 0 -1px $white-dark; +} + +.ide-commit-list-changed-icon { + width: $ide-commit-row-height; + height: $ide-commit-row-height; +} diff --git a/app/controllers/admin/logs_controller.rb b/app/controllers/admin/logs_controller.rb index 6a7af9edeec388384afee2daee8d923496703e52..5861f7b9d3dd56af7f1180a06e789ba01eb55a84 100644 --- a/app/controllers/admin/logs_controller.rb +++ b/app/controllers/admin/logs_controller.rb @@ -14,7 +14,8 @@ def loggers Gitlab::GitLogger, Gitlab::EnvironmentLogger, Gitlab::SidekiqLogger, - Gitlab::RepositoryCheckLogger + Gitlab::RepositoryCheckLogger, + Gitlab::ProjectServiceLogger ] end end diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 6f50cbb4a36bd25b45f17a5f67723b5a46cd0337..5671663f81eb153b4cfdd562ef10a7e4372a18b7 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -101,6 +101,7 @@ def user_params :organization, :preferred_language, :private_profile, + :include_private_contributions, status: [:emoji, :message] ) end diff --git a/app/controllers/projects/merge_requests/diffs_controller.rb b/app/controllers/projects/merge_requests/diffs_controller.rb index 48e02581d54ebbf59f13bbea489bc71396b9fa62..34de554212f25dd2119ed6e79483ffee1487aed9 100644 --- a/app/controllers/projects/merge_requests/diffs_controller.rb +++ b/app/controllers/projects/merge_requests/diffs_controller.rb @@ -21,6 +21,8 @@ def diff_for_path def render_diffs @environment = @merge_request.environments_for(current_user).last + @diffs.write_cache + render json: DiffsSerializer.new(current_user: current_user).represent(@diffs, additional_attributes) end diff --git a/app/finders/user_recent_events_finder.rb b/app/finders/user_recent_events_finder.rb index 876f086a3ef0d02dbbd74b7bb2a5982e8a91da6d..b874f6959c9e8dac78667ea1c3c2653742862e05 100644 --- a/app/finders/user_recent_events_finder.rb +++ b/app/finders/user_recent_events_finder.rb @@ -48,20 +48,6 @@ def target_events end def projects - # Compile a list of projects `current_user` interacted with - # and `target_user` is allowed to see. - - authorized = target_user - .project_interactions - .joins(:project_authorizations) - .where(project_authorizations: { user: current_user }) - .select(:id) - - visible = target_user - .project_interactions - .where(visibility_level: Gitlab::VisibilityLevel.levels_for_user(current_user)) - .select(:id) - - Gitlab::SQL::Union.new([authorized, visible]).to_sql + target_user.project_interactions.to_sql end end diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 269acf5b2e2ccfaf12bd46c5daed4eb2b529d9f4..34d54e2d68164d1420dd24b80b6a36001325f033 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -19,7 +19,7 @@ def link_to_author(event, self_added: false) name = self_added ? 'You' : author.name link_to name, user_path(author.username), title: name else - event.author_name + escape_once(event.author_name) end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index 3b3d4faf07fa1cd6e23d667b068e5dd2d12494e4..3e44b4eac9e871acb90f66ba14ad4722e5f48f92 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -111,6 +111,10 @@ def locking_enabled? def allows_multiple_assignees? false end + + def has_multiple_assignees? + assignees.count > 1 + end end class_methods do diff --git a/app/models/concerns/project_services_loggable.rb b/app/models/concerns/project_services_loggable.rb new file mode 100644 index 0000000000000000000000000000000000000000..248a21f357878e0244845079e0d51c0858cff6be --- /dev/null +++ b/app/models/concerns/project_services_loggable.rb @@ -0,0 +1,26 @@ +module ProjectServicesLoggable + def log_info(message, params = {}) + message = build_message(message, params) + + logger.info(message) + end + + def log_error(message, params = {}) + message = build_message(message, params) + + logger.error(message) + end + + def build_message(message, params = {}) + { + service_class: self.class.name, + project_id: project.id, + project_path: project.full_path, + message: message + }.merge(params) + end + + def logger + Gitlab::ProjectServiceLogger + end +end diff --git a/app/models/event.rb b/app/models/event.rb index e37dd5322ea93ea5366408c78c5bd138ff85c7e1..351576f038efd0e527af953e4d906f249b3ad484 100644 --- a/app/models/event.rb +++ b/app/models/event.rb @@ -157,15 +157,17 @@ def visible_to_user?(user = nil) if push? || commit_note? Ability.allowed?(user, :download_code, project) elsif membership_changed? - true + Ability.allowed?(user, :read_project, project) elsif created_project? - true + Ability.allowed?(user, :read_project, project) elsif issue? || issue_note? Ability.allowed?(user, :read_issue, note? ? note_target : target) elsif merge_request? || merge_request_note? Ability.allowed?(user, :read_merge_request, note? ? note_target : target) + elsif milestone? + Ability.allowed?(user, :read_project, project) else - milestone? + false # No other event types are visible end end diff --git a/app/models/project_services/asana_service.rb b/app/models/project_services/asana_service.rb index 35c19049c04003556006d72a4b757060f70317b2..568f870c2db4c0c79986bb17e3c1f909334e8fc2 100644 --- a/app/models/project_services/asana_service.rb +++ b/app/models/project_services/asana_service.rb @@ -101,7 +101,7 @@ def check_commit(message, push_msg) task.update(completed: true) end rescue => e - Rails.logger.error(e.message) + log_error(e.message) next end end diff --git a/app/models/project_services/irker_service.rb b/app/models/project_services/irker_service.rb index a783a314071f736e6dd8bf49da630dc22e4baa57..a15780c14f96e609ac8fbe93b1e247949d6be07e 100644 --- a/app/models/project_services/irker_service.rb +++ b/app/models/project_services/irker_service.rb @@ -104,7 +104,7 @@ def format_channel(recipient) new_recipient = URI.join(default_irc_uri, '/', recipient).to_s uri = consider_uri(URI.parse(new_recipient)) rescue - Rails.logger.error("Unable to create a valid URL from #{default_irc_uri} and #{recipient}") + log_error("Unable to create a valid URL", default_irc_uri: default_irc_uri, recipient: recipient) end end diff --git a/app/models/project_services/issue_tracker_service.rb b/app/models/project_services/issue_tracker_service.rb index 2b867256cb36a7590ac59205895c7bc23d312f31..1c2b2a8fc5d584cfbf855df7e7a9606481c1786e 100644 --- a/app/models/project_services/issue_tracker_service.rb +++ b/app/models/project_services/issue_tracker_service.rb @@ -92,7 +92,7 @@ def execute(data) rescue Gitlab::HTTP::Error, Timeout::Error, SocketError, Errno::ECONNRESET, Errno::ECONNREFUSED, OpenSSL::SSL::SSLError => error message = "#{self.type} had an error when trying to connect to #{self.project_url}: #{error.message}" end - Rails.logger.info(message) + log_info(message) result end diff --git a/app/models/project_services/jira_service.rb b/app/models/project_services/jira_service.rb index cc98b3f5a411b94cd4bbfb033c6ac3b0c7417ffa..ba7fcb0cf930ab69f8940b08a2b02771fd7c5594 100644 --- a/app/models/project_services/jira_service.rb +++ b/app/models/project_services/jira_service.rb @@ -205,7 +205,7 @@ def transition_issue(issue) begin issue.transitions.build.save!(transition: { id: transition_id }) rescue => error - Rails.logger.info "#{self.class.name} Issue Transition failed message ERROR: #{client_url} - #{error.message}" + log_error("Issue transition failed", error: error.message, client_url: client_url) return false end end @@ -257,9 +257,8 @@ def send_message(issue, message, remote_link_props) new_remote_link.save!(remote_link_props) end - result_message = "#{self.class.name} SUCCESS: Successfully posted to #{client_url}." - Rails.logger.info(result_message) - result_message + log_info("Successfully posted", client_url: client_url) + "SUCCESS: Successfully posted to http://jira.example.net." end end @@ -317,7 +316,7 @@ def jira_request rescue Timeout::Error, Errno::EINVAL, Errno::ECONNRESET, Errno::ECONNREFUSED, URI::InvalidURIError, JIRA::HTTPError, OpenSSL::SSL::SSLError => e @error = e.message - Rails.logger.info "#{self.class.name} Send message ERROR: #{client_url} - #{@error}" + log_error("Error sending message", client_url: client_url, error: @error) nil end diff --git a/app/models/service.rb b/app/models/service.rb index 17d3f26377358741bcd9bc1c784844ab0f21099b..55c31e3c3540c9acd51c4953b9483d25d898acdb 100644 --- a/app/models/service.rb +++ b/app/models/service.rb @@ -6,6 +6,7 @@ class Service < ActiveRecord::Base prepend EE::Service include Sortable include Importable + include ProjectServicesLoggable serialize :properties, JSON # rubocop:disable Cop/ActiveRecordSerialize diff --git a/app/services/merge_requests/reload_diffs_service.rb b/app/services/merge_requests/reload_diffs_service.rb index 8d85dc9eb5f146cd8fac1eaaef75660e824af74a..1390ae0e199c6cc76bfb8cc45608b24a6d2bcf91 100644 --- a/app/services/merge_requests/reload_diffs_service.rb +++ b/app/services/merge_requests/reload_diffs_service.rb @@ -30,7 +30,7 @@ def update_diff_discussion_positions(old_diff_refs) def clear_cache(new_diff) # Executing the iteration we cache highlighted diffs for each diff file of # MergeRequestDiff. - new_diff.diffs_collection.diff_files.to_a + new_diff.diffs_collection.write_cache # Remove cache for all diffs on this MR. Do not use the association on the # model, as that will interfere with other actions happening when @@ -38,7 +38,7 @@ def clear_cache(new_diff) MergeRequestDiff.where(merge_request: merge_request).each do |merge_request_diff| next if merge_request_diff == new_diff - merge_request_diff.diffs_collection.clear_cache! + merge_request_diff.diffs_collection.clear_cache end end end diff --git a/app/services/wikis/create_attachment_service.rb b/app/services/wikis/create_attachment_service.rb index 30fe0e371a6c1f48fef5bc7ec6e77b8d9185170f..df31ad7c8eaed8fae4a3e40dbbde7f3b90ac69d5 100644 --- a/app/services/wikis/create_attachment_service.rb +++ b/app/services/wikis/create_attachment_service.rb @@ -11,7 +11,7 @@ class CreateAttachmentService < Files::CreateService def initialize(*args) super - @file_name = truncate_file_name(params[:file_name]) + @file_name = clean_file_name(params[:file_name]) @file_path = File.join(ATTACHMENT_PATH, SecureRandom.hex, @file_name) if @file_name @commit_message ||= "Upload attachment #{@file_name}" @branch_name ||= wiki.default_branch @@ -23,8 +23,16 @@ def create_commit! private - def truncate_file_name(file_name) + def clean_file_name(file_name) return unless file_name.present? + + file_name = truncate_file_name(file_name) + # CommonMark does not allow Urls with whitespaces, so we have to replace them + # Using the same regex Carrierwave use to replace invalid characters + file_name.gsub(CarrierWave::SanitizedFile.sanitize_regexp, '_') + end + + def truncate_file_name(file_name) return file_name if file_name.length <= MAX_FILENAME_LENGTH extension = File.extname(file_name) diff --git a/app/uploaders/namespace_file_uploader.rb b/app/uploaders/namespace_file_uploader.rb index 52969762b7da1078cc8f49a4066bf19d56e4250b..b0154f85a5ce12c2bc58caddc97b60c410d96c63 100644 --- a/app/uploaders/namespace_file_uploader.rb +++ b/app/uploaders/namespace_file_uploader.rb @@ -6,8 +6,15 @@ def self.root options.storage_path end - def self.base_dir(model, _store = nil) - File.join(options.base_dir, 'namespace', model_path_segment(model)) + def self.base_dir(model, store = nil) + base_dirs(model)[store || Store::LOCAL] + end + + def self.base_dirs(model) + { + Store::LOCAL => File.join(options.base_dir, 'namespace', model_path_segment(model)), + Store::REMOTE => File.join('namespace', model_path_segment(model)) + } end def self.model_path_segment(model) @@ -18,11 +25,4 @@ def self.model_path_segment(model) def store_dir store_dirs[object_store] end - - def store_dirs - { - Store::LOCAL => File.join(base_dir, dynamic_segment), - Store::REMOTE => File.join('namespace', self.class.model_path_segment(model), dynamic_segment) - } - end end diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index 53a33adc14dcf20c62450e17e146618016c22465..5623f0f590a50b51089e17f09fc84b8282b2cac7 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -11,3 +11,5 @@ = render "events/event/note", event: event - else = render "events/event/common", event: event +- elsif @user.include_private_contributions? + = render "events/event/private", event: event diff --git a/app/views/events/_event_scope.html.haml b/app/views/events/_event_scope.html.haml index 8f7da7d8c4ffa203984affadd7127cfbb450c48a..989417224349386597d14ba4e89aee05eda6b5c5 100644 --- a/app/views/events/_event_scope.html.haml +++ b/app/views/events/_event_scope.html.haml @@ -1,7 +1,7 @@ %span.event-scope = event_preposition(event) - if event.project - = link_to_project event.project + = link_to_project(event.project) - else = event.project_name diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 01e72862114b40f56b6247116b975125a501e14a..829a3da15580c2132eb7280d157f396fc79d7826 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -1,7 +1,7 @@ = icon_for_profile_event(event) .event-title - %span.author_name= link_to_author event + %span.author_name= link_to_author(event) %span{ class: event.action_name } - if event.target = event.action_name diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index d8e59be57bb422193385c0011a1c5ecdff1c25f4..6ad7e15713181ea79db69879bb052791889b9ada 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -1,11 +1,11 @@ = icon_for_profile_event(event) .event-title - %span.author_name= link_to_author event + %span.author_name= link_to_author(event) %span{ class: event.action_name } = event_action_name(event) - if event.project - = link_to_project event.project + = link_to_project(event.project) - else = event.project_name diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index de6383e40979e722a962a9ffbe0889b776ab8d23..cdacd998a6987434c11780bbf5b92c5321406a95 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -1,7 +1,7 @@ = icon_for_profile_event(event) .event-title - %span.author_name= link_to_author event + %span.author_name= link_to_author(event) = event.action_name = event_note_title_html(event) diff --git a/app/views/events/event/_private.html.haml b/app/views/events/event/_private.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..ccd2aacb4eafa929930cc19d7d3cf93213b3a354 --- /dev/null +++ b/app/views/events/event/_private.html.haml @@ -0,0 +1,10 @@ +.event-inline.event-item + .event-item-timestamp + = time_ago_with_tooltip(event.created_at) + + .system-note-image= sprite_icon('eye-slash', size: 16, css_class: 'icon') + + .event-title + - author_name = capture do + %span.author_name= link_to_author(event) + = s_('Profiles|%{author_name} made a private contribution').html_safe % { author_name: author_name } diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 85f2d00bde31d642432528310f06253fabbd0017..5f0ee79cd9b1f0b77e08dce0d64e74cff8a0d170 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -3,7 +3,7 @@ = icon_for_profile_event(event) .event-title - %span.author_name= link_to_author event + %span.author_name= link_to_author(event) %span.pushed #{event.action_name} #{event.ref_type} %strong - commits_link = project_commits_path(project, event.ref_name) diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 9f79feb4ddd4ed94ab2c21dddab109244ee8d08d..0a1ee648d9787b7bafb5cf3ba3a8104d21912f4f 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -1,5 +1,6 @@ -- breadcrumb_title "Edit Profile" +- breadcrumb_title s_("Profiles|Edit Profile") - @content_class = "limit-container-width" unless fluid_layout +- gravatar_link = link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host = bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user prepend-top-default js-quick-submit' }, authenticity_token: true do |f| = form_errors(@user) @@ -7,34 +8,36 @@ .row .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Public Avatar + = s_("Profiles|Public Avatar") %p - if @user.avatar? - You can change your avatar here - if gravatar_enabled? - or remove the current avatar to revert to #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host} + = s_("Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } + - else + = s_("Profiles|You can change your avatar here") - else - You can upload an avatar here - if gravatar_enabled? - or change it at #{link_to Gitlab.config.gravatar.host, 'https://' + Gitlab.config.gravatar.host} + = s_("Profiles|You can upload your avatar here or change it at %{gravatar_link}").html_safe % { gravatar_link: gravatar_link } + - else + = s_("Profiles|You can upload your avatar here") .col-lg-8 .clearfix.avatar-image.append-bottom-default = link_to avatar_icon_for_user(@user, 400), target: '_blank', rel: 'noopener noreferrer' do = image_tag avatar_icon_for_user(@user, 160), alt: '', class: 'avatar s160' - %h5.prepend-top-0= _("Upload new avatar") + %h5.prepend-top-0= s_("Profiles|Upload new avatar") .prepend-top-5.append-bottom-10 - %button.btn.js-choose-user-avatar-button{ type: 'button' }= _("Choose file...") - %span.avatar-file-name.prepend-left-default.js-avatar-filename= _("No file chosen") + %button.btn.js-choose-user-avatar-button{ type: 'button' }= s_("Profiles|Choose file...") + %span.avatar-file-name.prepend-left-default.js-avatar-filename= s_("Profiles|No file chosen") = f.file_field_without_bootstrap :avatar, class: 'js-user-avatar-input hidden', accept: 'image/*' - .form-text.text-muted= _("The maximum file size allowed is 200KB.") + .form-text.text-muted= s_("Profiles|The maximum file size allowed is 200KB.") - if @user.avatar? %hr - = link_to _('Remove avatar'), profile_avatar_path, data: { confirm: _('Avatar will be removed. Are you sure?') }, method: :delete, class: 'btn btn-danger btn-inverted' + = link_to s_("Profiles|Remove avatar"), profile_avatar_path, data: { confirm: s_("Profiles|Avatar will be removed. Are you sure?") }, method: :delete, class: 'btn btn-danger btn-inverted' %hr .row .col-lg-4.profile-settings-sidebar - %h4.prepend-top-0= s_("User|Current status") + %h4.prepend-top-0= s_("Profiles|Current status") %p= s_("Profiles|This emoji and message will appear on your profile and throughout the interface.") .col-lg-8 = f.fields_for :status, @user.status do |status_form| @@ -66,62 +69,66 @@ .row .col-lg-4.profile-settings-sidebar %h4.prepend-top-0 - Main settings + = s_("Profiles|Main settings") %p - This information will appear on your profile. + = s_("Profiles|This information will appear on your profile.") - if current_user.ldap_user? - Some options are unavailable for LDAP accounts + = s_("Profiles|Some options are unavailable for LDAP accounts") .col-lg-8 .row - if @user.read_only_attribute?(:name) = f.text_field :name, required: true, readonly: true, wrapper: { class: 'col-md-9' }, - help: "Your name was automatically set based on your #{ attribute_provider_label(:name) } account, so people you know can recognize you." + help: s_("Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you.") % { provider_label: attribute_provider_label(:name) } - else = f.text_field :name, label: 'Full name', required: true, wrapper: { class: 'col-md-9' }, help: "Enter your name, so people you know can recognize you." = f.text_field :id, readonly: true, label: 'User ID', wrapper: { class: 'col-md-3' } - if @user.read_only_attribute?(:email) - = f.text_field :email, required: true, readonly: true, help: "Your email address was automatically set based on your #{ attribute_provider_label(:email) } account." + = f.text_field :email, required: true, readonly: true, help: s_("Profiles|Your email address was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:email) } - else = f.text_field :email, required: true, value: (@user.email unless @user.temp_oauth_email?), help: user_email_help_text(@user) = f.select :public_email, options_for_select(@user.all_emails, selected: @user.public_email), - { help: 'This email will be displayed on your public profile.', include_blank: 'Do not show on profile' }, + { help: s_("Profiles|This email will be displayed on your public profile."), include_blank: s_("Profiles|Do not show on profile") }, control_class: 'select2' = f.select :preferred_language, Gitlab::I18n::AVAILABLE_LANGUAGES.map { |value, label| [label, value] }, - { help: 'This feature is experimental and translations are not complete yet.' }, + { help: s_("Profiles|This feature is experimental and translations are not complete yet.") }, control_class: 'select2' = f.text_field :skype = f.text_field :linkedin = f.text_field :twitter - = f.text_field :website_url, label: 'Website' + = f.text_field :website_url, label: s_("Profiles|Website") - if @user.read_only_attribute?(:location) - = f.text_field :location, readonly: true, help: "Your location was automatically set based on your #{ attribute_provider_label(:location) } account." + = f.text_field :location, readonly: true, help: s_("Profiles|Your location was automatically set based on your %{provider_label} account.") % { provider_label: attribute_provider_label(:location) } - else = f.text_field :location = f.text_field :organization - = f.text_area :bio, rows: 4, maxlength: 250, help: 'Tell us about yourself in fewer than 250 characters.' + = f.text_area :bio, rows: 4, maxlength: 250, help: s_("Profiles|Tell us about yourself in fewer than 250 characters.") %hr - %h5 Private profile + %h5= ("Private profile") - private_profile_label = capture do - Don't display activity-related personal information on your profile + = s_("Profiles|Don't display activity-related personal information on your profiles") = link_to icon('question-circle'), help_page_path('user/profile/index.md', anchor: 'private-profile') = f.check_box :private_profile, label: private_profile_label + %h5= s_("Profiles|Private contributions") + = f.check_box :include_private_contributions, label: 'Include private contributions on my profile' + .help-block + = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information.") .prepend-top-default.append-bottom-default - = f.submit 'Update profile settings', class: 'btn btn-success' - = link_to 'Cancel', user_path(current_user), class: 'btn btn-cancel' + = f.submit s_("Profiles|Update profile settings"), class: 'btn btn-success' + = link_to _("Cancel"), user_path(current_user), class: 'btn btn-cancel' .modal.modal-profile-crop .modal-dialog .modal-content .modal-header %h4.modal-title - Position and size your new avatar - %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _('Close') } + = s_("Profiles|Position and size your new avatar") + %button.close{ type: "button", "data-dismiss": "modal", "aria-label" => _("Close") } %span{ "aria-hidden": true } × .modal-body .profile-crop-image-container - %img.modal-profile-crop-image{ alt: 'Avatar cropper' } + %img.modal-profile-crop-image{ alt: s_("Profiles|Avatar cropper") } .crop-controls .btn-group %button.btn.btn-primary{ data: { method: 'zoom', option: '0.1' } } @@ -130,4 +137,4 @@ %span.fa.fa-search-minus .modal-footer %button.btn.btn-primary.js-upload-user-avatar{ type: 'button' } - Set new profile picture + = s_("Profiles|Set new profile picture") diff --git a/app/views/projects/project_members/index.html.haml b/app/views/projects/project_members/index.html.haml index d49273e7b2a3109aa340ba4e8fefbfefd4ee325d..553b2bf04e5fde15e1a9a745e3840114faa2fb78 100644 --- a/app/views/projects/project_members/index.html.haml +++ b/app/views/projects/project_members/index.html.haml @@ -20,17 +20,17 @@ - if !membership_locked? && @project.allowed_to_share_with_group? %ul.nav-links.nav.nav-tabs.gitlab-tabs{ role: 'tablist' } %li.nav-tab{ role: 'presentation' } - %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Add member + %a.nav-link.active{ href: '#invite-member-pane', id: 'invite-member-tab', data: { toggle: 'tab' }, role: 'tab' } Invite member %li.nav-tab{ role: 'presentation', class: ('active' if membership_locked?) } %a.nav-link{ href: '#invite-group-pane', id: 'invite-group-tab', data: { toggle: 'tab' }, role: 'tab' } Invite group .tab-content.gitlab-tab-content .tab-pane.active{ id: 'invite-member-pane', role: 'tabpanel' } - = render 'projects/project_members/new_project_member', tab_title: 'Add member' + = render 'projects/project_members/new_project_member', tab_title: 'Invite member' .tab-pane{ id: 'invite-group-pane', role: 'tabpanel', class: ('active' if membership_locked?) } = render 'projects/project_members/new_project_group', tab_title: 'Invite group' - elsif !membership_locked? - .invite-member= render 'projects/project_members/new_project_member', tab_title: 'Add member' + .invite-member= render 'projects/project_members/new_project_member', tab_title: 'Invite member' - elsif @project.allowed_to_share_with_group? .invite-group= render 'projects/project_members/new_project_group', tab_title: 'Invite group' diff --git a/app/views/users/calendar_activities.html.haml b/app/views/users/calendar_activities.html.haml index 2d4656e8608434608c007e33d935be31deccfba1..938cb579e9fbc5988f0be64d58b2f5383529bb46 100644 --- a/app/views/users/calendar_activities.html.haml +++ b/app/views/users/calendar_activities.html.haml @@ -1,6 +1,5 @@ %h4.prepend-top-20 - Contributions for - %strong= @calendar_date.to_s(:medium) + = _("Contributions for <strong>%{calendar_date}</strong>").html_safe % { calendar_date: @calendar_date.to_s(:medium) } - if @events.any? %ul.bordered-list @@ -9,25 +8,28 @@ %span.light %i.fa.fa-clock-o = event.created_at.strftime('%-I:%M%P') - - if event.push? - #{event.action_name} #{event.ref_type} + - if event.visible_to_user?(current_user) + - if event.push? + #{event.action_name} #{event.ref_type} + %strong + - commits_path = project_commits_path(event.project, event.ref_name) + = link_to_if event.project.repository.branch_exists?(event.ref_name), event.ref_name, commits_path + - else + = event_action_name(event) + %strong + - if event.note? + = link_to event.note_target.to_reference, event_note_target_url(event), class: 'has-tooltip', title: event.target_title + - elsif event.target + = link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title + + at %strong - - commits_path = project_commits_path(event.project, event.ref_name) - = link_to_if event.project.repository.branch_exists?(event.ref_name), event.ref_name, commits_path + - if event.project + = link_to_project(event.project) + - else + = event.project_name - else - = event_action_name(event) - %strong - - if event.note? - = link_to event.note_target.to_reference, event_note_target_url(event), class: 'has-tooltip', title: event.target_title - - elsif event.target - = link_to event.target.to_reference, [event.project.namespace.becomes(Namespace), event.project, event.target], class: 'has-tooltip', title: event.target_title - - at - %strong - - if event.project - = link_to_project event.project - - else - = event.project_name + made a private contribution - else %p - No contributions found for #{@calendar_date.to_s(:medium)} + = _('No contributions were found') diff --git a/app/workers/new_merge_request_worker.rb b/app/workers/new_merge_request_worker.rb index 5d8b8904502bc80780570ceeaa7c1e933c5c3693..62f9d9b6f57e5513287a140ecd0a336089ec3819 100644 --- a/app/workers/new_merge_request_worker.rb +++ b/app/workers/new_merge_request_worker.rb @@ -9,6 +9,8 @@ def perform(merge_request_id, user_id) EventCreateService.new.open_mr(issuable, user) NotificationService.new.new_merge_request(issuable, user) + + issuable.diffs.write_cache issuable.create_cross_references!(user) end diff --git a/changelogs/unreleased/ee-6381-multiseries.yml b/changelogs/unreleased/ee-6381-multiseries.yml new file mode 100644 index 0000000000000000000000000000000000000000..a749e31d27ce50d9bdd0a4f878397a143895638b --- /dev/null +++ b/changelogs/unreleased/ee-6381-multiseries.yml @@ -0,0 +1,5 @@ +--- +title: Allow gaps in multiseries metrics charts +merge_request: 21427 +author: +type: fixed diff --git a/changelogs/unreleased/feat-update-contribution-calendar.yml b/changelogs/unreleased/feat-update-contribution-calendar.yml new file mode 100644 index 0000000000000000000000000000000000000000..4ada8b1fcd5482875aeea8a70c766564ee80bc14 --- /dev/null +++ b/changelogs/unreleased/feat-update-contribution-calendar.yml @@ -0,0 +1,5 @@ +--- +title: Include private contributions to contributions calendar +merge_request: 17296 +author: George Tsiolis +type: added diff --git a/changelogs/unreleased/fix-namespace-uploader.yml b/changelogs/unreleased/fix-namespace-uploader.yml new file mode 100644 index 0000000000000000000000000000000000000000..081adc9a6f165a30b11d17d0931aa74d6382d7e5 --- /dev/null +++ b/changelogs/unreleased/fix-namespace-uploader.yml @@ -0,0 +1,5 @@ +--- +title: Fix NamespaceUploader.base_dir for remote uploads +merge_request: +author: +type: fixed diff --git a/changelogs/unreleased/fj-51194-fix-wiki-attachments-with-whitespaces.yml b/changelogs/unreleased/fj-51194-fix-wiki-attachments-with-whitespaces.yml new file mode 100644 index 0000000000000000000000000000000000000000..a6464807e01388535c68be2027a366ac7413b6b8 --- /dev/null +++ b/changelogs/unreleased/fj-51194-fix-wiki-attachments-with-whitespaces.yml @@ -0,0 +1,5 @@ +--- +title: Replace white spaces in wiki attachments file names +merge_request: 21569 +author: +type: fixed diff --git a/changelogs/unreleased/ide-commit-panel-improved.yml b/changelogs/unreleased/ide-commit-panel-improved.yml new file mode 100644 index 0000000000000000000000000000000000000000..245214185e5ecebfeac456ba7ab7c69969e4ab82 --- /dev/null +++ b/changelogs/unreleased/ide-commit-panel-improved.yml @@ -0,0 +1,5 @@ +--- +title: Improved commit panel in Web IDE +merge_request: 21471 +author: +type: changed diff --git a/changelogs/unreleased/ide-file-templates.yml b/changelogs/unreleased/ide-file-templates.yml new file mode 100644 index 0000000000000000000000000000000000000000..68983670b25ba77ddd1d69387f9d94d6c7176eeb --- /dev/null +++ b/changelogs/unreleased/ide-file-templates.yml @@ -0,0 +1,5 @@ +--- +title: Added file templates to the Web IDE +merge_request: +author: +type: added diff --git a/changelogs/unreleased/issue_50488.yml b/changelogs/unreleased/issue_50488.yml new file mode 100644 index 0000000000000000000000000000000000000000..dad7ae55a0d8f80569b445bc543f124552c45e29 --- /dev/null +++ b/changelogs/unreleased/issue_50488.yml @@ -0,0 +1,5 @@ +--- +title: Move project services log to a separate file +merge_request: +author: +type: other diff --git a/changelogs/unreleased/osw-send-max-patch-bytes-to-gitaly.yml b/changelogs/unreleased/osw-send-max-patch-bytes-to-gitaly.yml new file mode 100644 index 0000000000000000000000000000000000000000..3c50448e3ffb43b2d89c7a28fcedf583a677b77f --- /dev/null +++ b/changelogs/unreleased/osw-send-max-patch-bytes-to-gitaly.yml @@ -0,0 +1,5 @@ +--- +title: Send max_patch_bytes to Gitaly via Gitaly::CommitDiffRequest +merge_request: 21575 +author: +type: other diff --git a/changelogs/unreleased/osw-write-cache-upon-mr-creation-and-cache-refactoring.yml b/changelogs/unreleased/osw-write-cache-upon-mr-creation-and-cache-refactoring.yml new file mode 100644 index 0000000000000000000000000000000000000000..4fba33decfa55016ce856f18b8274bfb989a0536 --- /dev/null +++ b/changelogs/unreleased/osw-write-cache-upon-mr-creation-and-cache-refactoring.yml @@ -0,0 +1,5 @@ +--- +title: Write diff highlighting cache upon MR creation (refactors caching) +merge_request: 21489 +author: +type: performance diff --git a/changelogs/unreleased/sh-remove-orphaned-label-links.yml b/changelogs/unreleased/sh-remove-orphaned-label-links.yml new file mode 100644 index 0000000000000000000000000000000000000000..b035b57ff1b009c4856044c8bec9741ef2c9f4bf --- /dev/null +++ b/changelogs/unreleased/sh-remove-orphaned-label-links.yml @@ -0,0 +1,5 @@ +--- +title: Remove orphaned label links +merge_request: 21552 +author: +type: fixed diff --git a/changelogs/unreleased/zj-cleanup-port-gitaly.yml b/changelogs/unreleased/zj-cleanup-port-gitaly.yml new file mode 100644 index 0000000000000000000000000000000000000000..25e13b0fd503238d63c8235717962acc7e00d533 --- /dev/null +++ b/changelogs/unreleased/zj-cleanup-port-gitaly.yml @@ -0,0 +1,5 @@ +--- +title: Administrative cleanup rake tasks now leverage Gitaly +merge_request: 21588 +author: +type: changed diff --git a/config/prometheus/common_metrics.yml b/config/prometheus/common_metrics.yml index 8ef9af0a47358b7d787c7c49688058c61c0978f2..4ebd77ca7a1a40dfdafcd912e3c7560a051a7025 100644 --- a/config/prometheus/common_metrics.yml +++ b/config/prometheus/common_metrics.yml @@ -213,4 +213,3 @@ label: Pod average unit: "cores" track: canary - diff --git a/db/migrate/20180228172924_add_include_private_contributions_to_users.rb b/db/migrate/20180228172924_add_include_private_contributions_to_users.rb new file mode 100644 index 0000000000000000000000000000000000000000..ea3ebdd83d1b91524f79629e85633e206666e63f --- /dev/null +++ b/db/migrate/20180228172924_add_include_private_contributions_to_users.rb @@ -0,0 +1,7 @@ +class AddIncludePrivateContributionsToUsers < ActiveRecord::Migration + DOWNTIME = false + + def change + add_column :users, :include_private_contributions, :boolean + end +end diff --git a/db/post_migrate/20180906051323_remove_orphaned_label_links.rb b/db/post_migrate/20180906051323_remove_orphaned_label_links.rb new file mode 100644 index 0000000000000000000000000000000000000000..b56b74f483ebf6db50cd8e4bbb1061c5a9733850 --- /dev/null +++ b/db/post_migrate/20180906051323_remove_orphaned_label_links.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +class RemoveOrphanedLabelLinks < ActiveRecord::Migration + include Gitlab::Database::MigrationHelpers + + DOWNTIME = false + + disable_ddl_transaction! + + class LabelLinks < ActiveRecord::Base + self.table_name = 'label_links' + include EachBatch + + def self.orphaned + where('NOT EXISTS ( SELECT 1 FROM labels WHERE labels.id = label_links.label_id )') + end + end + + def up + # Some of these queries can take up to 10 seconds to run on GitLab.com, + # which is pretty close to our 15 second statement timeout. To ensure a + # smooth deployment procedure we disable the statement timeouts for this + # migration, just in case. + disable_statement_timeout do + # On GitLab.com there are over 2,000,000 orphaned label links. On + # staging, removing 100,000 rows generated a max replication lag of 6.7 + # MB. In total, removing all these rows will only generate about 136 MB + # of data, so it should be safe to do this. + LabelLinks.orphaned.each_batch(of: 100_000) do |batch| + batch.delete_all + end + end + + add_concurrent_foreign_key(:label_links, :labels, column: :label_id, on_delete: :cascade) + end + + def down + # There is no way to restore orphaned label links. + if foreign_key_exists?(:label_links, column: :label_id) + remove_foreign_key(:label_links, column: :label_id) + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 5392a1845785045e193475628ef137fa08c6a760..d91c7d9fcd3cf8f185449a5ddbd0de71a8cb49da 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -2892,6 +2892,7 @@ t.string "feed_token" t.boolean "private_profile" t.integer "roadmap_layout", limit: 2 + t.boolean "include_private_contributions" end add_index "users", ["admin"], name: "index_users_on_admin", using: :btree @@ -3108,6 +3109,7 @@ add_foreign_key "issues", "users", column: "author_id", name: "fk_05f1e72feb", on_delete: :nullify add_foreign_key "issues", "users", column: "closed_by_id", name: "fk_c63cbf6c25", on_delete: :nullify add_foreign_key "issues", "users", column: "updated_by_id", name: "fk_ffed080f01", on_delete: :nullify + add_foreign_key "label_links", "labels", name: "fk_d97dd08678", on_delete: :cascade add_foreign_key "label_priorities", "labels", on_delete: :cascade add_foreign_key "label_priorities", "projects", on_delete: :cascade add_foreign_key "labels", "namespaces", column: "group_id", on_delete: :cascade diff --git a/doc/administration/logs.md b/doc/administration/logs.md index 0fbb4481fb8ad14d530d9a3c34875948a0d22367..98134075b94b41e3c35c4ed3215b12838a403e49 100644 --- a/doc/administration/logs.md +++ b/doc/administration/logs.md @@ -113,6 +113,19 @@ October 07, 2014 11:25: User "Claudie Hodkiewicz" (nasir_stehr@olson.co.uk) was October 07, 2014 11:25: Project "project133" was removed ``` +## `integrations_json.log` + +This file lives in `/var/log/gitlab/gitlab-rails/integrations_json.log` for +Omnibus GitLab packages or in `/home/git/gitlab/log/integrations_json.log` for +installations from source. + +It contains information about [integrations](../user/project/integrations/project_services.md) activities such as JIRA, Asana and Irker services. It uses JSON format like the example below: + +``` json +{"severity":"ERROR","time":"2018-09-06T14:56:20.439Z","service_class":"JiraService","project_id":8,"project_path":"h5bp/html5-boilerplate","message":"Error sending message","client_url":"http://jira.gitlap.com:8080","error":"execution expired"} +{"severity":"INFO","time":"2018-09-06T17:15:16.365Z","service_class":"JiraService","project_id":3,"project_path":"namespace2/project2","message":"Successfully posted","client_url":"http://jira.example.net"} +``` + ## `githost.log` This file lives in `/var/log/gitlab/gitlab-rails/githost.log` for diff --git a/doc/development/prometheus_metrics.md b/doc/development/prometheus_metrics.md index 6977815a979a9430814367ce3130b8ecd0361216..b6b6d9665ea1fccbb4a811f015d3e71da6e04511 100644 --- a/doc/development/prometheus_metrics.md +++ b/doc/development/prometheus_metrics.md @@ -6,8 +6,7 @@ We strive to support the 2-4 most important metrics for each common system servi ### Query identifier -The requirement for adding a new metrics is to make each query to have an unique identifier. -Identifier is used to update the metric later when changed. +The requirement for adding a new metric is to make each query to have an unique identifier which is used to update the metric later when changed: ```yaml - group: Response metrics (NGINX Ingress) @@ -25,9 +24,10 @@ Identifier is used to update the metric later when changed. After you add or change existing _common_ metric you have to create a new database migration that will query and update all existing metrics. -**Note: If a query metric (which is identified by `id:`) is removed it will not be removed from database by default.** -**You might want to add additional database migration that makes a decision what to do with removed one.** -**For example: you might be interested in migrating all dependent data to a different metric.** +NOTE: **Note:** +If a query metric (which is identified by `id:`) is removed it will not be removed from database by default. +You might want to add additional database migration that makes a decision what to do with removed one. +For example: you might be interested in migrating all dependent data to a different metric. ```ruby class ImportCommonMetrics < ActiveRecord::Migration diff --git a/doc/user/profile/index.md b/doc/user/profile/index.md index b1b822f25bddc3743539e0fc0c40df98435ef3f4..6b225147232d324134cc9b1379594d5baaefdeb5 100644 --- a/doc/user/profile/index.md +++ b/doc/user/profile/index.md @@ -91,6 +91,18 @@ To enable private profile: NOTE: **Note:** You and GitLab admins can see your the abovementioned information on your profile even if it is private. +## Private contributions + +> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/14078) in GitLab 11.3. + +Enabling private contributions will include contributions to private projects, in the user contribution calendar graph and user recent activity. + +To enable private contributions: + +1. Navigate to your personal [profile settings](#profile-settings). +2. Check the "Private contributions" option. +3. Hit **Update profile settings**. + ## Current status > Introduced in GitLab 11.2. diff --git a/doc/user/project/web_ide/index.md b/doc/user/project/web_ide/index.md index 4d6956144275506fa94fd4bb459c3b73e476231b..98305de041797975bfb40d1128deda3e34c628bc 100644 --- a/doc/user/project/web_ide/index.md +++ b/doc/user/project/web_ide/index.md @@ -78,13 +78,14 @@ switching to a different branch. The Web IDE can be used to preview JavaScript projects right in the browser. This feature uses CodeSandbox to compile and bundle the JavaScript used to -preview the web application. On public projects, an `Open in CodeSandbox` -button is visible which will transfer the contents of the project into a -CodeSandbox project to share with others. -**Note** this button is not visible on private or internal projects. +preview the web application.  +Additionally, for public projects an `Open in CodeSandbox` button is available +to transfer the contents of the project into a public CodeSandbox project to +quickly share your project with others. + ### Enabling Client Side Evaluation The Client Side Evaluation feature needs to be enabled in the GitLab instances diff --git a/spec/features/projects/members/invite_group_and_members_spec.rb b/ee/spec/features/projects/members/invite_group_and_members_spec.rb similarity index 69% rename from spec/features/projects/members/invite_group_and_members_spec.rb rename to ee/spec/features/projects/members/invite_group_and_members_spec.rb index 36d3c14b720d999ac58c06c3d4dcf4c242bd5f53..6d175c148947bf01509b5713553b1fc109481adb 100644 --- a/spec/features/projects/members/invite_group_and_members_spec.rb +++ b/ee/spec/features/projects/members/invite_group_and_members_spec.rb @@ -1,12 +1,12 @@ require 'spec_helper' -describe 'Project > Members > Invite Group', :js do +describe 'Project > Members > Invite group and members', :js do include Select2Helper include ActionView::Helpers::DateHelper let(:maintainer) { create(:user) } - describe 'Invite group lock' do + describe 'Share group lock' do shared_examples 'the project cannot be shared with groups' do it 'user is only able to share with members' do visit project_settings_members_path(project) @@ -189,95 +189,4 @@ end end end - - describe 'setting an expiration date for a group link' do - let(:project) { create(:project) } - let!(:group) { create(:group) } - - around do |example| - Timecop.freeze { example.run } - end - - before do - project.add_maintainer(maintainer) - sign_in(maintainer) - - visit project_settings_members_path(project) - - click_on 'invite-group-tab' - - select2 group.id, from: '#link_group_id' - - fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d') - click_on 'invite-group-tab' - find('.btn-create').click - end - - it 'the group link shows the expiration time with a warning class' do - page.within('.project-members-groups') do - # Using distance_of_time_in_words_to_now because it is not the same as - # subtraction, and this way avoids time zone issues as well - expires_in_text = distance_of_time_in_words_to_now(project.project_group_links.first.expires_at) - expect(page).to have_content(expires_in_text) - expect(page).to have_selector('.text-warning') - end - end - end - - describe 'the groups dropdown' do - context 'with multiple groups to choose from' do - let(:project) { create(:project) } - - before do - project.add_maintainer(maintainer) - sign_in(maintainer) - - create(:group).add_owner(maintainer) - create(:group).add_owner(maintainer) - - visit project_settings_members_path(project) - - click_link 'Invite group' - - find('.ajax-groups-select.select2-container') - - execute_script 'GROUP_SELECT_PER_PAGE = 1;' - open_select2 '#link_group_id' - end - - it 'should infinitely scroll' do - expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 1) - - scroll_select2_to_bottom('.select2-drop .select2-results:visible') - - expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 2) - end - end - - context 'for a project in a nested group' do - let(:group) { create(:group) } - let!(:nested_group) { create(:group, parent: group) } - let!(:group_to_share_with) { create(:group) } - let!(:project) { create(:project, namespace: nested_group) } - - before do - project.add_maintainer(maintainer) - sign_in(maintainer) - group.add_maintainer(maintainer) - group_to_share_with.add_maintainer(maintainer) - end - - it 'the groups dropdown does not show ancestors', :nested_groups do - visit project_settings_members_path(project) - - click_on 'invite-group-tab' - click_link 'Search for a group' - - page.within '.select2-drop' do - expect(page).to have_content(group_to_share_with.name) - expect(page).not_to have_content(group.name) - end - end - end - end end diff --git a/lib/banzai/filter/spaced_link_filter.rb b/lib/banzai/filter/spaced_link_filter.rb index 574a8a6c7a5171b010b16d1a94f57af06e39fa21..a4dd6abfe03caa440241ee2e8219dc86e58f4e4f 100644 --- a/lib/banzai/filter/spaced_link_filter.rb +++ b/lib/banzai/filter/spaced_link_filter.rb @@ -8,8 +8,9 @@ module Filter # # Based on Banzai::Filter::AutolinkFilter # - # CommonMark does not allow spaces in the url portion of a link. - # For example, `[example](page slug)` is not valid. However, + # CommonMark does not allow spaces in the url portion of a link/url. + # For example, `[example](page slug)` is not valid. + # Neither is ``. However, # in our wikis, we support (via RedCarpet) this type of link, allowing # wiki pages to be easily linked by their title. This filter adds that functionality. # The intent is for this to only be used in Wikis - in general, we want @@ -20,10 +21,17 @@ class SpacedLinkFilter < HTML::Pipeline::Filter # Pattern to match a standard markdown link # - # Rubular: http://rubular.com/r/z9EAHxYmKI - LINK_PATTERN = /\[([^\]]+)\]\(([^)"]+)(?: \"([^\"]+)\")?\)/ - - # Text matching LINK_PATTERN inside these elements will not be linked + # Rubular: http://rubular.com/r/2EXEQ49rg5 + LINK_OR_IMAGE_PATTERN = %r{ + (?<preview_operator>!)? + \[(?<text>.+?)\] + \( + (?<new_link>.+?) + (?<title>\ ".+?")? + \) + }x + + # Text matching LINK_OR_IMAGE_PATTERN inside these elements will not be linked IGNORE_PARENTS = %w(a code kbd pre script style).to_set # The XPath query to use for finding text nodes to parse. @@ -38,7 +46,7 @@ def call doc.xpath(TEXT_QUERY).each do |node| content = node.to_html - next unless content.match(LINK_PATTERN) + next unless content.match(LINK_OR_IMAGE_PATTERN) html = spaced_link_filter(content) @@ -53,25 +61,37 @@ def call private def spaced_link_match(link) - match = LINK_PATTERN.match(link) - return link unless match && match[1] && match[2] + match = LINK_OR_IMAGE_PATTERN.match(link) + return link unless match # escape the spaces in the url so that it's a valid markdown link, # then run it through the markdown processor again, let it do its magic - text = match[1] - new_link = match[2].gsub(' ', '%20') - title = match[3] ? " \"#{match[3]}\"" : '' - html = Banzai::Filter::MarkdownFilter.call("[#{text}](#{new_link}#{title})", context) + html = Banzai::Filter::MarkdownFilter.call(transform_markdown(match), context) # link is wrapped in a <p>, so strip that off html.sub('<p>', '').chomp('</p>') end def spaced_link_filter(text) - Gitlab::StringRegexMarker.new(CGI.unescapeHTML(text), text.html_safe).mark(LINK_PATTERN) do |link, left:, right:| + Gitlab::StringRegexMarker.new(CGI.unescapeHTML(text), text.html_safe).mark(LINK_OR_IMAGE_PATTERN) do |link, left:, right:| spaced_link_match(link) end end + + def transform_markdown(match) + preview_operator, text, new_link, title = process_match(match) + + "#{preview_operator}[#{text}](#{new_link}#{title})" + end + + def process_match(match) + [ + match[:preview_operator], + match[:text], + match[:new_link].gsub(' ', '%20'), + match[:title] + ] + end end end end diff --git a/lib/banzai/pipeline/wiki_pipeline.rb b/lib/banzai/pipeline/wiki_pipeline.rb index 737ff0cc8184576f0f26eb7793be585073c03d37..d2fe5a6492f575cfce3b48b0234b4507e56c556d 100644 --- a/lib/banzai/pipeline/wiki_pipeline.rb +++ b/lib/banzai/pipeline/wiki_pipeline.rb @@ -5,7 +5,7 @@ def self.filters @filters ||= begin super.insert_after(Filter::TableOfContentsFilter, Filter::GollumTagsFilter) .insert_before(Filter::TaskListFilter, Filter::WikiLinkFilter) - .insert_before(Filter::WikiLinkFilter, Filter::SpacedLinkFilter) + .insert_before(Filter::VideoLinkFilter, Filter::SpacedLinkFilter) end end end diff --git a/lib/gitlab/contributions_calendar.rb b/lib/gitlab/contributions_calendar.rb index 4c28489f45a3f56530fd19ee9818cae0717dbe6d..58ca077e636ce7889bdb08915eb8812150b83b7d 100644 --- a/lib/gitlab/contributions_calendar.rb +++ b/lib/gitlab/contributions_calendar.rb @@ -7,7 +7,11 @@ class ContributionsCalendar def initialize(contributor, current_user = nil) @contributor = contributor @current_user = current_user - @projects = ContributedProjectsFinder.new(contributor).execute(current_user) + @projects = if @contributor.include_private_contributions? + ContributedProjectsFinder.new(@contributor).execute(@contributor) + else + ContributedProjectsFinder.new(contributor).execute(current_user) + end end def activity_dates @@ -36,13 +40,9 @@ def activity_dates def events_by_date(date) return Event.none unless can_read_cross_project? - events = Event.contributions.where(author_id: contributor.id) + Event.contributions.where(author_id: contributor.id) .where(created_at: date.beginning_of_day..date.end_of_day) .where(project_id: projects) - - # Use visible_to_user? instead of the complicated logic in activity_dates - # because we're only viewing the events for a single day. - events.select { |event| event.visible_to_user?(current_user) } end def starting_year diff --git a/lib/gitlab/diff/file_collection/base.rb b/lib/gitlab/diff/file_collection/base.rb index c79d8d3cb215f0e51c49e9dfcbaf0442ba9e7dbf..2acb0e43b69239853da2ece0fad93078dc7ac625 100644 --- a/lib/gitlab/diff/file_collection/base.rb +++ b/lib/gitlab/diff/file_collection/base.rb @@ -2,7 +2,7 @@ module Gitlab module Diff module FileCollection class Base - attr_reader :project, :diff_options, :diff_refs, :fallback_diff_refs + attr_reader :project, :diff_options, :diff_refs, :fallback_diff_refs, :diffable delegate :count, :size, :real_size, to: :diff_files @@ -33,6 +33,14 @@ def diff_file_with_new_path(new_path) diff_files.find { |diff_file| diff_file.new_path == new_path } end + def clear_cache + # No-op + end + + def write_cache + # No-op + end + private def decorate_diff!(diff) diff --git a/lib/gitlab/diff/file_collection/merge_request_diff.rb b/lib/gitlab/diff/file_collection/merge_request_diff.rb index be25e1bab21be6ed0db578eca32fc1ec5d04dd64..0dd073a3a8e3923e0452ca45fff90566e7a9ddaf 100644 --- a/lib/gitlab/diff/file_collection/merge_request_diff.rb +++ b/lib/gitlab/diff/file_collection/merge_request_diff.rb @@ -2,6 +2,8 @@ module Gitlab module Diff module FileCollection class MergeRequestDiff < Base + extend ::Gitlab::Utils::Override + def initialize(merge_request_diff, diff_options:) @merge_request_diff = merge_request_diff @@ -13,70 +15,35 @@ def initialize(merge_request_diff, diff_options:) end def diff_files - # Make sure to _not_ send any method call to Gitlab::Diff::File - # _before_ all of them were collected (`super`). Premature method calls will - # trigger N+1 RPCs to Gitaly through BatchLoader records (Blob.lazy). - # diff_files = super - diff_files.each { |diff_file| cache_highlight!(diff_file) if cacheable?(diff_file) } - store_highlight_cache + diff_files.each { |diff_file| cache.decorate(diff_file) } diff_files end - def real_size - @merge_request_diff.real_size + override :write_cache + def write_cache + cache.write_if_empty end - def clear_cache! - Rails.cache.delete(cache_key) + override :clear_cache + def clear_cache + cache.clear end def cache_key - [@merge_request_diff, 'highlighted-diff-files', Gitlab::Diff::Line::SERIALIZE_KEYS, diff_options] - end - - private - - def highlight_diff_file_from_cache!(diff_file, cache_diff_lines) - diff_file.highlighted_diff_lines = cache_diff_lines.map do |line| - Gitlab::Diff::Line.init_from_hash(line) - end + cache.key end - # - # If we find the highlighted diff files lines on the cache we replace existing diff_files lines (no highlighted) - # for the highlighted ones, so we just skip their execution. - # If the highlighted diff files lines are not cached we calculate and cache them. - # - # The content of the cache is a Hash where the key identifies the file and the values are Arrays of - # hashes that represent serialized diff lines. - # - def cache_highlight!(diff_file) - item_key = diff_file.file_identifier - - if highlight_cache[item_key] - highlight_diff_file_from_cache!(diff_file, highlight_cache[item_key]) - else - highlight_cache[item_key] = diff_file.highlighted_diff_lines.map(&:to_hash) - end - end - - def highlight_cache - return @highlight_cache if defined?(@highlight_cache) - - @highlight_cache = Rails.cache.read(cache_key) || {} - @highlight_cache_was_empty = @highlight_cache.empty? - @highlight_cache + def real_size + @merge_request_diff.real_size end - def store_highlight_cache - Rails.cache.write(cache_key, highlight_cache, expires_in: 1.week) if @highlight_cache_was_empty - end + private - def cacheable?(diff_file) - @merge_request_diff.present? && diff_file.text? && diff_file.diffable? + def cache + @cache ||= Gitlab::Diff::HighlightCache.new(self) end end end diff --git a/lib/gitlab/diff/highlight_cache.rb b/lib/gitlab/diff/highlight_cache.rb new file mode 100644 index 0000000000000000000000000000000000000000..e4390771db2c54cc12e320e4472db57f4a487f81 --- /dev/null +++ b/lib/gitlab/diff/highlight_cache.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true +# +module Gitlab + module Diff + class HighlightCache + delegate :diffable, to: :@diff_collection + delegate :diff_options, to: :@diff_collection + + def initialize(diff_collection, backend: Rails.cache) + @backend = backend + @diff_collection = diff_collection + end + + # - Reads from cache + # - Assigns DiffFile#highlighted_diff_lines for cached files + def decorate(diff_file) + if content = read_file(diff_file) + diff_file.highlighted_diff_lines = content.map do |line| + Gitlab::Diff::Line.init_from_hash(line) + end + end + end + + # It populates a Hash in order to submit a single write to the memory + # cache. This avoids excessive IO generated by N+1's (1 writing for + # each highlighted line or file). + def write_if_empty + return if cached_content.present? + + @diff_collection.diff_files.each do |diff_file| + next unless cacheable?(diff_file) + + diff_file_id = diff_file.file_identifier + + cached_content[diff_file_id] = diff_file.highlighted_diff_lines.map(&:to_hash) + end + + cache.write(key, cached_content, expires_in: 1.week) + end + + def clear + cache.delete(key) + end + + def key + [diffable, 'highlighted-diff-files', Gitlab::Diff::Line::SERIALIZE_KEYS, diff_options] + end + + private + + def read_file(diff_file) + cached_content[diff_file.file_identifier] + end + + def cache + @backend + end + + def cached_content + @cached_content ||= cache.read(key) || {} + end + + def cacheable?(diff_file) + diffable.present? && diff_file.text? && diff_file.diffable? + end + end + end +end diff --git a/lib/gitlab/git/diff_collection.rb b/lib/gitlab/git/diff_collection.rb index 219c69893ad3514d710faf64661d1b1812e268be..20dce8d0e06df9f18cddc43ccaf012a69be7b9c6 100644 --- a/lib/gitlab/git/diff_collection.rb +++ b/lib/gitlab/git/diff_collection.rb @@ -11,7 +11,7 @@ class DiffCollection delegate :max_files, :max_lines, :max_bytes, :safe_max_files, :safe_max_lines, :safe_max_bytes, to: :limits - def self.collection_limits(options = {}) + def self.limits(options = {}) limits = {} limits[:max_files] = options.fetch(:max_files, DEFAULT_LIMITS[:max_files]) limits[:max_lines] = options.fetch(:max_lines, DEFAULT_LIMITS[:max_lines]) @@ -19,13 +19,14 @@ def self.collection_limits(options = {}) limits[:safe_max_files] = [limits[:max_files], DEFAULT_LIMITS[:max_files]].min limits[:safe_max_lines] = [limits[:max_lines], DEFAULT_LIMITS[:max_lines]].min limits[:safe_max_bytes] = limits[:safe_max_files] * 5.kilobytes # Average 5 KB per file + limits[:max_patch_bytes] = Gitlab::Git::Diff::SIZE_LIMIT OpenStruct.new(limits) end def initialize(iterator, options = {}) @iterator = iterator - @limits = self.class.collection_limits(options) + @limits = self.class.limits(options) @enforce_limits = !!options.fetch(:limits, true) @expanded = !!options.fetch(:expanded, true) diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index 6a97cd8ed1709e939f83058afe70728345b4b673..aa5b4f940908d5a66314489f736ac4a972c7103b 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -369,7 +369,7 @@ def call_commit_diff(request_params, options = {}) request_params[:ignore_whitespace_change] = options.fetch(:ignore_whitespace_change, false) request_params[:enforce_limits] = options.fetch(:limits, true) request_params[:collapse_diffs] = !options.fetch(:expanded, true) - request_params.merge!(Gitlab::Git::DiffCollection.collection_limits(options).to_h) + request_params.merge!(Gitlab::Git::DiffCollection.limits(options).to_h) request = Gitaly::CommitDiffRequest.new(request_params) response = GitalyClient.call(@repository.storage, :diff_service, :commit_diff, request, timeout: GitalyClient.medium_timeout) diff --git a/lib/gitlab/gitaly_client/remote_service.rb b/lib/gitlab/gitaly_client/remote_service.rb index 6415c64b4e2d81ba03ce1a517597a38b2a63707a..4661448621b8efe8cee4a13837e51324697381e1 100644 --- a/lib/gitlab/gitaly_client/remote_service.rb +++ b/lib/gitlab/gitaly_client/remote_service.rb @@ -1,6 +1,8 @@ module Gitlab module GitalyClient class RemoteService + include Gitlab::EncodingHelper + MAX_MSG_SIZE = 128.kilobytes.freeze def self.exists?(remote_url) @@ -61,7 +63,7 @@ def find_remote_root_ref(remote_name) response = GitalyClient.call(@storage, :remote_service, :find_remote_root_ref, request) - response.ref.presence + encode_utf8(response.ref) end def update_remote_mirror(ref_name, only_branches_matching) diff --git a/lib/gitlab/gitaly_client/storage_service.rb b/lib/gitlab/gitaly_client/storage_service.rb index eb0e910665bdf324bdb67a6fc3091c347eb717f0..3a26dd58ff404e678697c682c280748b24b40dc0 100644 --- a/lib/gitlab/gitaly_client/storage_service.rb +++ b/lib/gitlab/gitaly_client/storage_service.rb @@ -5,6 +5,14 @@ def initialize(storage) @storage = storage end + # Returns all directories in the git storage directory, lexically ordered + def list_directories(depth: 1) + request = Gitaly::ListDirectoriesRequest.new(storage_name: @storage, depth: depth) + + GitalyClient.call(@storage, :storage_service, :list_directories, request) + .flat_map(&:paths) + end + # Delete all repositories in the storage. This is a slow and VERY DESTRUCTIVE operation. def delete_all_repositories request = Gitaly::DeleteAllRepositoriesRequest.new(storage_name: @storage) diff --git a/lib/gitlab/project_service_logger.rb b/lib/gitlab/project_service_logger.rb new file mode 100644 index 0000000000000000000000000000000000000000..e84dca97962b21e7acb8bc7166ab5f06b43c7e9f --- /dev/null +++ b/lib/gitlab/project_service_logger.rb @@ -0,0 +1,7 @@ +module Gitlab + class ProjectServiceLogger < Gitlab::JsonLogger + def self.file_name_noext + 'integrations_json' + end + end +end diff --git a/lib/tasks/gitlab/cleanup.rake b/lib/tasks/gitlab/cleanup.rake index ba0224bdaa3428dd7f621637eaf81d9f731296a4..959361b9aa04db82abebb2e30d3c99da1826d92d 100644 --- a/lib/tasks/gitlab/cleanup.rake +++ b/lib/tasks/gitlab/cleanup.rake @@ -1,40 +1,29 @@ -# Gitaly migration: https://gitlab.com/gitlab-org/gitaly/issues/954 -# +# frozen_string_literal: true +require 'set' + namespace :gitlab do namespace :cleanup do - HASHED_REPOSITORY_NAME = '@hashed'.freeze - desc "GitLab | Cleanup | Clean namespaces" task dirs: :gitlab_environment do - warn_user_is_not_gitlab + namespaces = Set.new(Namespace.pluck(:path)) + namespaces << Storage::HashedProject::ROOT_PATH_PREFIX - namespaces = Namespace.pluck(:path) - namespaces << HASHED_REPOSITORY_NAME # add so that it will be ignored - Gitlab.config.repositories.storages.each do |name, repository_storage| - git_base_path = Gitlab::GitalyClient::StorageSettings.allow_disk_access { repository_storage.legacy_disk_path } - all_dirs = Dir.glob(git_base_path + '/*') + Gitaly::Server.all.each do |server| + all_dirs = Gitlab::GitalyClient::StorageService + .new(server.storage) + .list_directories(depth: 0) + .reject { |dir| dir.ends_with?('.git') || namespaces.include?(File.basename(dir)) } - puts git_base_path.color(:yellow) puts "Looking for directories to remove... " - - all_dirs.reject! do |dir| - # skip if git repo - dir =~ /.git$/ - end - - all_dirs.reject! do |dir| - dir_name = File.basename dir - - # skip if namespace present - namespaces.include?(dir_name) - end - all_dirs.each do |dir_path| if remove? - if FileUtils.rm_rf dir_path - puts "Removed...#{dir_path}".color(:red) - else - puts "Cannot remove #{dir_path}".color(:red) + begin + Gitlab::GitalyClient::NamespaceService.new(server.storage) + .remove(dir_path) + + puts "Removed...#{dir_path}" + rescue StandardError => e + puts "Cannot remove #{dir_path}: #{e.message}".color(:red) end else puts "Can be removed: #{dir_path}".color(:red) @@ -79,29 +68,29 @@ namespace :gitlab do desc "GitLab | Cleanup | Clean repositories" task repos: :gitlab_environment do - warn_user_is_not_gitlab - move_suffix = "+orphaned+#{Time.now.to_i}" - Gitlab.config.repositories.storages.each do |name, repository_storage| - repo_root = Gitlab::GitalyClient::StorageSettings.allow_disk_access { repository_storage.legacy_disk_path } - # Look for global repos (legacy, depth 1) and normal repos (depth 2) - IO.popen(%W(find #{repo_root} -mindepth 1 -maxdepth 2 -name *.git)) do |find| - find.each_line do |path| - path.chomp! - repo_with_namespace = path - .sub(repo_root, '') - .sub(%r{^/*}, '') - .chomp('.git') - .chomp('.wiki') - - # TODO ignoring hashed repositories for now. But revisit to fully support - # possible orphaned hashed repos - next if repo_with_namespace.start_with?("#{HASHED_REPOSITORY_NAME}/") || Project.find_by_full_path(repo_with_namespace) - - new_path = path + move_suffix - puts path.inspect + ' -> ' + new_path.inspect - File.rename(path, new_path) + Gitaly::Server.all.each do |server| + Gitlab::GitalyClient::StorageService + .new(server.storage) + .list_directories + .each do |path| + repo_with_namespace = path.chomp('.git').chomp('.wiki') + + # TODO ignoring hashed repositories for now. But revisit to fully support + # possible orphaned hashed repos + next if repo_with_namespace.start_with?(Storage::HashedProject::ROOT_PATH_PREFIX) + next if Project.find_by_full_path(repo_with_namespace) + + new_path = path + move_suffix + puts path.inspect + ' -> ' + new_path.inspect + + begin + Gitlab::GitalyClient::NamespaceService + .new(server.storage) + .rename(path, new_path) + rescue StandardError => e + puts "Error occured while moving the repository: #{e.message}".color(:red) end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 41b567873384548c39a0ffa54bafe33e12d1ebfa..774eccad495975bc70d8ce882543fdd610048f79 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -1421,6 +1421,12 @@ msgstr "" msgid "Choose a branch/tag (e.g. %{master}) or enter a commit (e.g. %{sha}) to see what's changed or to create a merge request." msgstr "" +msgid "Choose a template..." +msgstr "" + +msgid "Choose a type..." +msgstr "" + msgid "Choose any color." msgstr "" @@ -2202,6 +2208,9 @@ msgstr "" msgid "Contribution guide" msgstr "" +msgid "Contributions for <strong>%{calendar_date}</strong>" +msgstr "" + msgid "Contributions per group member" msgstr "" @@ -2672,9 +2681,21 @@ msgstr "" msgid "Disable group Runners" msgstr "" +msgid "Discard" +msgstr "" + +msgid "Discard all changes" +msgstr "" + +msgid "Discard all unstaged changes?" +msgstr "" + msgid "Discard changes" msgstr "" +msgid "Discard changes to %{path}?" +msgstr "" + msgid "Discard draft" msgstr "" @@ -3173,6 +3194,9 @@ msgstr "" msgid "Fields on this page are now uneditable, you can configure" msgstr "" +msgid "File templates" +msgstr "" + msgid "Files" msgstr "" @@ -3188,6 +3212,9 @@ msgstr "" msgid "Filter by commit message" msgstr "" +msgid "Filter..." +msgstr "" + msgid "Find by path" msgstr "" @@ -4825,9 +4852,6 @@ msgstr "" msgid "More" msgstr "" -msgid "More actions" -msgstr "" - msgid "More info" msgstr "" @@ -4980,6 +5004,9 @@ msgstr "" msgid "No container images stored for this project. Add one by following the instructions above." msgstr "" +msgid "No contributions were found" +msgstr "" + msgid "No due date" msgstr "" @@ -5588,6 +5615,9 @@ msgstr "" msgid "Profiles| You are going to change the username %{currentUsernameBold} to %{newUsernameBold}. Profile and projects will be redirected to the %{newUsername} namespace but this redirect will expire once the %{currentUsername} namespace is registered by another user or group. Please update your Git repository remotes as soon as possible." msgstr "" +msgid "Profiles|%{author_name} made a private contribution" +msgstr "" + msgid "Profiles|Account scheduled for removal." msgstr "" @@ -5597,15 +5627,30 @@ msgstr "" msgid "Profiles|Add status emoji" msgstr "" +msgid "Profiles|Avatar cropper" +msgstr "" + +msgid "Profiles|Avatar will be removed. Are you sure?" +msgstr "" + msgid "Profiles|Change username" msgstr "" +msgid "Profiles|Choose file..." +msgstr "" + +msgid "Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information." +msgstr "" + msgid "Profiles|Clear status" msgstr "" msgid "Profiles|Current path: %{path}" msgstr "" +msgid "Profiles|Current status" +msgstr "" + msgid "Profiles|Delete Account" msgstr "" @@ -5618,39 +5663,108 @@ msgstr "" msgid "Profiles|Deleting an account has the following effects:" msgstr "" +msgid "Profiles|Do not show on profile" +msgstr "" + +msgid "Profiles|Don't display activity-related personal information on your profiles" +msgstr "" + +msgid "Profiles|Edit Profile" +msgstr "" + msgid "Profiles|Invalid password" msgstr "" msgid "Profiles|Invalid username" msgstr "" +msgid "Profiles|Main settings" +msgstr "" + +msgid "Profiles|No file chosen" +msgstr "" + msgid "Profiles|Path" msgstr "" +msgid "Profiles|Position and size your new avatar" +msgstr "" + +msgid "Profiles|Private contributions" +msgstr "" + +msgid "Profiles|Public Avatar" +msgstr "" + +msgid "Profiles|Remove avatar" +msgstr "" + +msgid "Profiles|Set new profile picture" +msgstr "" + +msgid "Profiles|Some options are unavailable for LDAP accounts" +msgstr "" + +msgid "Profiles|Tell us about yourself in fewer than 250 characters." +msgstr "" + +msgid "Profiles|The maximum file size allowed is 200KB." +msgstr "" + msgid "Profiles|This doesn't look like a public SSH key, are you sure you want to add it?" msgstr "" +msgid "Profiles|This email will be displayed on your public profile." +msgstr "" + msgid "Profiles|This emoji and message will appear on your profile and throughout the interface." msgstr "" +msgid "Profiles|This feature is experimental and translations are not complete yet." +msgstr "" + +msgid "Profiles|This information will appear on your profile." +msgstr "" + msgid "Profiles|Type your %{confirmationValue} to confirm:" msgstr "" msgid "Profiles|Typically starts with \"ssh-rsa …\"" msgstr "" +msgid "Profiles|Update profile settings" +msgstr "" + msgid "Profiles|Update username" msgstr "" +msgid "Profiles|Upload new avatar" +msgstr "" + msgid "Profiles|Username change failed - %{message}" msgstr "" msgid "Profiles|Username successfully changed" msgstr "" +msgid "Profiles|Website" +msgstr "" + msgid "Profiles|What's your status?" msgstr "" +msgid "Profiles|You can change your avatar here" +msgstr "" + +msgid "Profiles|You can change your avatar here or remove the current avatar to revert to %{gravatar_link}" +msgstr "" + +msgid "Profiles|You can upload your avatar here" +msgstr "" + +msgid "Profiles|You can upload your avatar here or change it at %{gravatar_link}" +msgstr "" + msgid "Profiles|You don't have access to delete this user." msgstr "" @@ -5660,6 +5774,15 @@ msgstr "" msgid "Profiles|Your account is currently an owner in these groups:" msgstr "" +msgid "Profiles|Your email address was automatically set based on your %{provider_label} account." +msgstr "" + +msgid "Profiles|Your location was automatically set based on your %{provider_label} account." +msgstr "" + +msgid "Profiles|Your name was automatically set based on your %{provider_label} account, so people you know can recognize you." +msgstr "" + msgid "Profiles|Your status" msgstr "" @@ -7266,6 +7389,12 @@ msgstr "" msgid "There are no projects shared with this group yet" msgstr "" +msgid "There are no staged changes" +msgstr "" + +msgid "There are no unstaged changes" +msgstr "" + msgid "There are problems accessing Git storage: " msgstr "" @@ -7786,6 +7915,9 @@ msgstr "" msgid "Unable to sign you in to the group with SAML due to \"%{reason}\"" msgstr "" +msgid "Undo" +msgstr "" + msgid "Unknown" msgstr "" @@ -7801,6 +7933,9 @@ msgstr "" msgid "Unresolve discussion" msgstr "" +msgid "Unstage" +msgstr "" + msgid "Unstage all changes" msgstr "" @@ -7870,9 +8005,6 @@ msgstr "" msgid "Upload file" msgstr "" -msgid "Upload new avatar" -msgstr "" - msgid "UploadLink|click to upload" msgstr "" @@ -7924,9 +8056,6 @@ msgstr "" msgid "Users" msgstr "" -msgid "User|Current status" -msgstr "" - msgid "Variables" msgstr "" @@ -8284,6 +8413,12 @@ msgstr "" msgid "You need permission." msgstr "" +msgid "You will loose all changes you've made to this file. This action cannot be undone." +msgstr "" + +msgid "You will loose all the unstaged changes you've made in this project. This action cannot be undone." +msgstr "" + msgid "You will not get any notifications via email" msgstr "" diff --git a/public/robots.txt b/public/robots.txt index 1f9d42f4adc9862306a232e69fb4e8b27c30a5ab..ea931e1a223b93e87b1774d5b55eecacff9fe6b1 100644 --- a/public/robots.txt +++ b/public/robots.txt @@ -21,6 +21,8 @@ Disallow: /groups/new Disallow: /groups/*/edit Disallow: /users Disallow: /help +# Only specifically allow the Sign In page to avoid very ugly search results +Allow: /users/sign_in # Global snippets User-Agent: * diff --git a/qa/qa.rb b/qa/qa.rb index c21cb3c19294c509f8f51fcb7359e53ab337dd1c..cb3202c8e1ce7cc3520e51db94861fb18a1ef2f1 100644 --- a/qa/qa.rb +++ b/qa/qa.rb @@ -100,7 +100,7 @@ module Integration end module Sanity - autoload :Failing, 'qa/scenario/test/sanity/failing' + autoload :Framework, 'qa/scenario/test/sanity/framework' autoload :Selectors, 'qa/scenario/test/sanity/selectors' end end diff --git a/qa/qa/page/main/login.rb b/qa/qa/page/main/login.rb index afc8b66d8784dd2b7e960d72efa700f4d9193a6f..1ea98b94aeb1878d2da28df7a074ad30f437bc51 100644 --- a/qa/qa/page/main/login.rb +++ b/qa/qa/page/main/login.rb @@ -63,6 +63,14 @@ def self.path '/users/sign_in' end + def sign_in_tab? + page.has_button?('Sign in') + end + + def ldap_tab? + page.has_button?('LDAP') + end + def switch_to_sign_in_tab click_on 'Sign in' end @@ -90,8 +98,8 @@ def sign_in_using_ldap_credentials end def sign_in_using_gitlab_credentials(user) - switch_to_sign_in_tab unless page.has_button?('Sign in') - switch_to_standard_tab if page.has_content?('LDAP') + switch_to_sign_in_tab unless sign_in_tab? + switch_to_standard_tab if ldap_tab? fill_in :user_login, with: user.username fill_in :user_password, with: user.password diff --git a/qa/qa/scenario/test/sanity/failing.rb b/qa/qa/scenario/test/sanity/framework.rb similarity index 51% rename from qa/qa/scenario/test/sanity/failing.rb rename to qa/qa/scenario/test/sanity/framework.rb index 03452f6693d7daaa0624bde0a2c3372bc365cfbc..7835d2564f012bb2133ad951f7d8a3be61dfb139 100644 --- a/qa/qa/scenario/test/sanity/failing.rb +++ b/qa/qa/scenario/test/sanity/framework.rb @@ -5,12 +5,13 @@ module Scenario module Test module Sanity ## - # This scenario exits with a 1 exit code. + # This scenario runs 1 passing example, and 1 failing example, and exits + # with a 1 exit code. # - class Failing < Template + class Framework < Template include Bootable - tags :failing + tags :framework end end end diff --git a/qa/qa/specs/features/sanity/failing_spec.rb b/qa/qa/specs/features/sanity/failing_spec.rb deleted file mode 100644 index 7e0480e90673d995705bff38264f7357282a7831..0000000000000000000000000000000000000000 --- a/qa/qa/specs/features/sanity/failing_spec.rb +++ /dev/null @@ -1,13 +0,0 @@ -# frozen_string_literal: true - -module QA - context 'Sanity checks', :orchestrated, :failing do - describe 'Failing orchestrated example' do - it 'always fails' do - Runtime::Browser.visit(:gitlab, Page::Main::Login) - - expect(page).to have_text("These Aren't the Texts You're Looking For", wait: 1) - end - end - end -end diff --git a/qa/qa/specs/features/sanity/framework_spec.rb b/qa/qa/specs/features/sanity/framework_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..ee9d068eb3a4e14cd02057633c05a8e244940600 --- /dev/null +++ b/qa/qa/specs/features/sanity/framework_spec.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module QA + context 'Framework sanity checks', :orchestrated, :framework do + describe 'Passing orchestrated example' do + it 'succeeds' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + + Page::Main::Login.perform do |main_login| + expect(main_login.sign_in_tab?).to be(true) + end + end + end + + describe 'Failing orchestrated example' do + it 'fails' do + Runtime::Browser.visit(:gitlab, Page::Main::Login) + + expect(page).to have_text("These Aren't the Texts You're Looking For", wait: 1) + end + end + end +end diff --git a/qa/spec/scenario/test/sanity/framework_spec.rb b/qa/spec/scenario/test/sanity/framework_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..44ac780556edc70189a96051c39b0fcdb5e6e1e3 --- /dev/null +++ b/qa/spec/scenario/test/sanity/framework_spec.rb @@ -0,0 +1,5 @@ +describe QA::Scenario::Test::Sanity::Framework do + it_behaves_like 'a QA scenario class' do + let(:tags) { [:framework] } + end +end diff --git a/spec/bin/changelog_spec.rb b/spec/bin/changelog_spec.rb index 9dc4edf97d1f7d34c3feb12b96754a923b13f424..c59add88a82671252da38b848804ec818f136394 100644 --- a/spec/bin/changelog_spec.rb +++ b/spec/bin/changelog_spec.rb @@ -95,6 +95,7 @@ it 'shows error message and exits the program' do allow($stdin).to receive(:getc).and_return(type) + expect do expect { described_class.read_type }.to raise_error( ChangelogHelpers::Abort, diff --git a/spec/controllers/projects/jobs_controller_spec.rb b/spec/controllers/projects/jobs_controller_spec.rb index ca7d30fec831841b0ab9da468d7c3f0eb472a1e2..751919f95017f487eaabe53afc239314c0109b52 100644 --- a/spec/controllers/projects/jobs_controller_spec.rb +++ b/spec/controllers/projects/jobs_controller_spec.rb @@ -166,8 +166,8 @@ def get_index(**extra_params) expect(response).to match_response_schema('job/job_details') expect(json_response['artifact']['download_path']).to match(%r{artifacts/download}) expect(json_response['artifact']['browse_path']).to match(%r{artifacts/browse}) - expect(json_response['artifact']).not_to have_key(:expired) - expect(json_response['artifact']).not_to have_key(:expired_at) + expect(json_response['artifact']).not_to have_key('expired') + expect(json_response['artifact']).not_to have_key('expired_at') end end @@ -177,8 +177,8 @@ def get_index(**extra_params) it 'exposes needed information' do expect(response).to have_gitlab_http_status(:ok) expect(response).to match_response_schema('job/job_details') - expect(json_response['artifact']).not_to have_key(:download_path) - expect(json_response['artifact']).not_to have_key(:browse_path) + expect(json_response['artifact']).not_to have_key('download_path') + expect(json_response['artifact']).not_to have_key('browse_path') expect(json_response['artifact']['expired']).to eq(true) expect(json_response['artifact']['expire_at']).not_to be_empty end diff --git a/spec/features/projects/members/invite_group_spec.rb b/spec/features/projects/members/invite_group_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..db4d41568895e66a64a0edc6151bad23565d19e8 --- /dev/null +++ b/spec/features/projects/members/invite_group_spec.rb @@ -0,0 +1,195 @@ +require 'spec_helper' + +describe 'Project > Members > Invite group', :js do + include Select2Helper + include ActionView::Helpers::DateHelper + + let(:maintainer) { create(:user) } + + describe 'Share with group lock' do + shared_examples 'the project can be shared with groups' do + it 'the "Invite group" tab exists' do + visit project_settings_members_path(project) + expect(page).to have_selector('#invite-group-tab') + end + end + + shared_examples 'the project cannot be shared with groups' do + it 'the "Invite group" tab does not exist' do + visit project_settings_members_path(project) + expect(page).not_to have_selector('#invite-group-tab') + end + end + + context 'for a project in a root group' do + let!(:group_to_share_with) { create(:group) } + let(:project) { create(:project, namespace: create(:group)) } + + before do + project.add_maintainer(maintainer) + sign_in(maintainer) + end + + context 'when the group has "Share group lock" disabled' do + it_behaves_like 'the project can be shared with groups' + + it 'the project can be shared with another group' do + visit project_settings_members_path(project) + + click_on 'invite-group-tab' + + select2 group_to_share_with.id, from: '#link_group_id' + page.find('body').click + find('.btn-create').click + + page.within('.project-members-groups') do + expect(page).to have_content(group_to_share_with.name) + end + end + end + + context 'when the group has "Share group lock" enabled' do + before do + project.namespace.update_column(:share_with_group_lock, true) + end + + it_behaves_like 'the project cannot be shared with groups' + end + end + + context 'for a project in a subgroup', :nested_groups do + let!(:group_to_share_with) { create(:group) } + let(:root_group) { create(:group) } + let(:subgroup) { create(:group, parent: root_group) } + let(:project) { create(:project, namespace: subgroup) } + + before do + project.add_maintainer(maintainer) + sign_in(maintainer) + end + + context 'when the root_group has "Share group lock" disabled' do + context 'when the subgroup has "Share group lock" disabled' do + it_behaves_like 'the project can be shared with groups' + end + + context 'when the subgroup has "Share group lock" enabled' do + before do + subgroup.update_column(:share_with_group_lock, true) + end + + it_behaves_like 'the project cannot be shared with groups' + end + end + + context 'when the root_group has "Share group lock" enabled' do + before do + root_group.update_column(:share_with_group_lock, true) + end + + context 'when the subgroup has "Share group lock" disabled (parent overridden)' do + it_behaves_like 'the project can be shared with groups' + end + + context 'when the subgroup has "Share group lock" enabled' do + before do + subgroup.update_column(:share_with_group_lock, true) + end + + it_behaves_like 'the project cannot be shared with groups' + end + end + end + end + + describe 'setting an expiration date for a group link' do + let(:project) { create(:project) } + let!(:group) { create(:group) } + + around do |example| + Timecop.freeze { example.run } + end + + before do + project.add_maintainer(maintainer) + sign_in(maintainer) + + visit project_settings_members_path(project) + + click_on 'invite-group-tab' + + select2 group.id, from: '#link_group_id' + + fill_in 'expires_at_groups', with: (Time.now + 4.5.days).strftime('%Y-%m-%d') + click_on 'invite-group-tab' + find('.btn-create').click + end + + it 'the group link shows the expiration time with a warning class' do + page.within('.project-members-groups') do + # Using distance_of_time_in_words_to_now because it is not the same as + # subtraction, and this way avoids time zone issues as well + expires_in_text = distance_of_time_in_words_to_now(project.project_group_links.first.expires_at) + expect(page).to have_content(expires_in_text) + expect(page).to have_selector('.text-warning') + end + end + end + + describe 'the groups dropdown' do + context 'with multiple groups to choose from' do + let(:project) { create(:project) } + + before do + project.add_maintainer(maintainer) + sign_in(maintainer) + + create(:group).add_owner(maintainer) + create(:group).add_owner(maintainer) + + visit project_settings_members_path(project) + + click_link 'Invite group' + + find('.ajax-groups-select.select2-container') + + execute_script 'GROUP_SELECT_PER_PAGE = 1;' + open_select2 '#link_group_id' + end + + it 'should infinitely scroll' do + expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 1) + + scroll_select2_to_bottom('.select2-drop .select2-results:visible') + + expect(find('.select2-drop .select2-results')).to have_selector('.select2-result', count: 2) + end + end + + context 'for a project in a nested group' do + let(:group) { create(:group) } + let!(:nested_group) { create(:group, parent: group) } + let!(:group_to_share_with) { create(:group) } + let!(:project) { create(:project, namespace: nested_group) } + + before do + project.add_maintainer(maintainer) + sign_in(maintainer) + group.add_maintainer(maintainer) + group_to_share_with.add_maintainer(maintainer) + end + + it 'the groups dropdown does not show ancestors', :nested_groups do + visit project_settings_members_path(project) + + click_on 'invite-group-tab' + click_link 'Search for a group' + + page.within '.select2-drop' do + expect(page).to have_content(group_to_share_with.name) + expect(page).not_to have_content(group.name) + end + end + end + end +end diff --git a/spec/finders/contributed_projects_finder_spec.rb b/spec/finders/contributed_projects_finder_spec.rb index 9155a8d6fe9870b03eb2b542c5217945d44b4f89..81fb4e3561c8e9e07cdf81259fc7b6c013eb33e7 100644 --- a/spec/finders/contributed_projects_finder_spec.rb +++ b/spec/finders/contributed_projects_finder_spec.rb @@ -8,6 +8,7 @@ let!(:public_project) { create(:project, :public) } let!(:private_project) { create(:project, :private) } + let!(:internal_project) { create(:project, :internal) } before do private_project.add_maintainer(source_user) @@ -16,17 +17,18 @@ create(:push_event, project: public_project, author: source_user) create(:push_event, project: private_project, author: source_user) + create(:push_event, project: internal_project, author: source_user) end - describe 'without a current user' do + describe 'activity without a current user' do subject { finder.execute } - it { is_expected.to eq([public_project]) } + it { is_expected.to match_array([public_project]) } end - describe 'with a current user' do + describe 'activity with a current user' do subject { finder.execute(current_user) } - it { is_expected.to eq([private_project, public_project]) } + it { is_expected.to match_array([private_project, internal_project, public_project]) } end end diff --git a/spec/finders/user_recent_events_finder_spec.rb b/spec/finders/user_recent_events_finder_spec.rb index 58470f4c84d73802921e4bbc072d5b325421c24b..c5fcd68eb4ced2957652fb447ae1f47c0e78c1f6 100644 --- a/spec/finders/user_recent_events_finder_spec.rb +++ b/spec/finders/user_recent_events_finder_spec.rb @@ -13,49 +13,25 @@ subject(:finder) { described_class.new(current_user, project_owner) } describe '#execute' do - context 'current user does not have access to projects' do - it 'returns public and internal events' do - records = finder.execute - - expect(records).to include(public_event, internal_event) - expect(records).not_to include(private_event) + context 'when profile is public' do + it 'returns all the events' do + expect(finder.execute).to include(private_event, internal_event, public_event) end end - context 'when current user has access to the projects' do - before do - private_project.add_developer(current_user) - internal_project.add_developer(current_user) - public_project.add_developer(current_user) - end - - context 'when profile is public' do - it 'returns all the events' do - expect(finder.execute).to include(private_event, internal_event, public_event) - end - end - - context 'when profile is private' do - it 'returns no event' do - allow(Ability).to receive(:allowed?).and_call_original - allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false) - expect(finder.execute).to be_empty - end - end + context 'when profile is private' do + it 'returns no event' do + allow(Ability).to receive(:allowed?).and_call_original + allow(Ability).to receive(:allowed?).with(current_user, :read_user_profile, project_owner).and_return(false) - it 'does not include the events if the user cannot read cross project' do - expect(Ability).to receive(:allowed?).and_call_original - expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false } expect(finder.execute).to be_empty end end - context 'when current user is anonymous' do - let(:current_user) { nil } - - it 'returns public events only' do - expect(finder.execute).to eq([public_event]) - end + it 'does not include the events if the user cannot read cross project' do + expect(Ability).to receive(:allowed?).and_call_original + expect(Ability).to receive(:allowed?).with(current_user, :read_cross_project) { false } + expect(finder.execute).to be_empty end end end diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js index 41d8bfff7e7ef002f3d79f8c5c90c56769545bb1..b45ae5bbb0f9a84bd1a429ff6c101a4826ba9216 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -30,7 +30,7 @@ describe('Multi-file editor commit sidebar list item', () => { }); it('renders file path', () => { - expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent.trim()).toBe(f.path); + expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent).toContain(f.path); }); it('renders actionn button', () => { diff --git a/spec/javascripts/ide/components/commit_sidebar/stage_button_spec.js b/spec/javascripts/ide/components/commit_sidebar/stage_button_spec.js index a5b906da8a1e672a732f63578a3c7515a14c9ab8..e09ccbe2a63d0782529368d053e5ac05aa083f20 100644 --- a/spec/javascripts/ide/components/commit_sidebar/stage_button_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/stage_button_spec.js @@ -29,7 +29,7 @@ describe('IDE stage file button', () => { }); it('renders button to discard & stage', () => { - expect(vm.$el.querySelectorAll('.btn').length).toBe(2); + expect(vm.$el.querySelectorAll('.btn-blank').length).toBe(2); }); it('calls store with stage button', () => { @@ -39,7 +39,7 @@ describe('IDE stage file button', () => { }); it('calls store with discard button', () => { - vm.$el.querySelector('.dropdown-menu button').click(); + vm.$el.querySelector('.btn-danger').click(); expect(vm.discardFileChanges).toHaveBeenCalledWith(f.path); }); diff --git a/spec/javascripts/ide/components/file_templates/bar_spec.js b/spec/javascripts/ide/components/file_templates/bar_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..a688f7f69a61eac220e9ea4795c502ead7b94ae5 --- /dev/null +++ b/spec/javascripts/ide/components/file_templates/bar_spec.js @@ -0,0 +1,117 @@ +import Vue from 'vue'; +import { createStore } from '~/ide/stores'; +import Bar from '~/ide/components/file_templates/bar.vue'; +import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore, file } from '../../helpers'; + +describe('IDE file templates bar component', () => { + let Component; + let vm; + + beforeAll(() => { + Component = Vue.extend(Bar); + }); + + beforeEach(() => { + const store = createStore(); + + store.state.openFiles.push({ + ...file('file'), + opened: true, + active: true, + }); + + vm = mountComponentWithStore(Component, { store }); + }); + + afterEach(() => { + vm.$destroy(); + resetStore(vm.$store); + }); + + describe('template type dropdown', () => { + it('renders dropdown component', () => { + expect(vm.$el.querySelector('.dropdown').textContent).toContain('Choose a type'); + }); + + it('calls setSelectedTemplateType when clicking item', () => { + spyOn(vm, 'setSelectedTemplateType').and.stub(); + + vm.$el.querySelector('.dropdown-content button').click(); + + expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({ + name: '.gitlab-ci.yml', + key: 'gitlab_ci_ymls', + }); + }); + }); + + describe('template dropdown', () => { + beforeEach(done => { + vm.$store.state.fileTemplates.templates = [ + { + name: 'test', + }, + ]; + vm.$store.state.fileTemplates.selectedTemplateType = { + name: '.gitlab-ci.yml', + key: 'gitlab_ci_ymls', + }; + + vm.$nextTick(done); + }); + + it('renders dropdown component', () => { + expect(vm.$el.querySelectorAll('.dropdown')[1].textContent).toContain('Choose a template'); + }); + + it('calls fetchTemplate on click', () => { + spyOn(vm, 'fetchTemplate').and.stub(); + + vm.$el + .querySelectorAll('.dropdown-content')[1] + .querySelector('button') + .click(); + + expect(vm.fetchTemplate).toHaveBeenCalledWith({ + name: 'test', + }); + }); + }); + + it('shows undo button if updateSuccess is true', done => { + vm.$store.state.fileTemplates.updateSuccess = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.btn-default').style.display).not.toBe('none'); + + done(); + }); + }); + + it('calls undoFileTemplate when clicking undo button', () => { + spyOn(vm, 'undoFileTemplate').and.stub(); + + vm.$el.querySelector('.btn-default').click(); + + expect(vm.undoFileTemplate).toHaveBeenCalled(); + }); + + it('calls setSelectedTemplateType if activeFile name matches a template', done => { + const fileName = '.gitlab-ci.yml'; + + spyOn(vm, 'setSelectedTemplateType'); + vm.$store.state.openFiles[0].name = fileName; + + vm.setInitialType(); + + vm.$nextTick(() => { + expect(vm.setSelectedTemplateType).toHaveBeenCalledWith({ + name: fileName, + key: 'gitlab_ci_ymls', + }); + + done(); + }); + }); +}); diff --git a/spec/javascripts/ide/components/file_templates/dropdown_spec.js b/spec/javascripts/ide/components/file_templates/dropdown_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..898796f4fa0f3633cdd313de2f424b54def72668 --- /dev/null +++ b/spec/javascripts/ide/components/file_templates/dropdown_spec.js @@ -0,0 +1,201 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import { createStore } from '~/ide/stores'; +import Dropdown from '~/ide/components/file_templates/dropdown.vue'; +import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; +import { resetStore } from '../../helpers'; + +describe('IDE file templates dropdown component', () => { + let Component; + let vm; + + beforeAll(() => { + Component = Vue.extend(Dropdown); + }); + + beforeEach(() => { + const store = createStore(); + + vm = createComponentWithStore(Component, store, { + label: 'Test', + }).$mount(); + }); + + afterEach(() => { + vm.$destroy(); + resetStore(vm.$store); + }); + + describe('async', () => { + beforeEach(() => { + vm.isAsyncData = true; + }); + + it('calls async store method on Bootstrap dropdown event', () => { + spyOn(vm, 'fetchTemplateTypes').and.stub(); + + $(vm.$el).trigger('show.bs.dropdown'); + + expect(vm.fetchTemplateTypes).toHaveBeenCalled(); + }); + + it('renders templates when async', done => { + vm.$store.state.fileTemplates.templates = [ + { + name: 'test', + }, + ]; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.dropdown-content').textContent).toContain('test'); + + done(); + }); + }); + + it('renders loading icon when isLoading is true', done => { + vm.$store.state.fileTemplates.isLoading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.loading-container')).not.toBe(null); + + done(); + }); + }); + + it('searches template data', () => { + vm.$store.state.fileTemplates.templates = [ + { + name: 'test', + }, + ]; + vm.searchable = true; + vm.search = 'hello'; + + expect(vm.outputData).toEqual([]); + }); + + it('does not filter data is searchable is false', () => { + vm.$store.state.fileTemplates.templates = [ + { + name: 'test', + }, + ]; + vm.search = 'hello'; + + expect(vm.outputData).toEqual([ + { + name: 'test', + }, + ]); + }); + + it('calls clickItem on click', done => { + spyOn(vm, 'clickItem').and.stub(); + + vm.$store.state.fileTemplates.templates = [ + { + name: 'test', + }, + ]; + + vm.$nextTick(() => { + vm.$el.querySelector('.dropdown-content button').click(); + + expect(vm.clickItem).toHaveBeenCalledWith({ + name: 'test', + }); + + done(); + }); + }); + + it('renders input when searchable is true', done => { + vm.searchable = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.dropdown-input')).not.toBe(null); + + done(); + }); + }); + + it('does not render input when searchable is true & showLoading is true', done => { + vm.searchable = true; + vm.$store.state.fileTemplates.isLoading = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.dropdown-input')).toBe(null); + + done(); + }); + }); + }); + + describe('sync', () => { + beforeEach(done => { + vm.data = [ + { + name: 'test sync', + }, + ]; + + vm.$nextTick(done); + }); + + it('renders props data', () => { + expect(vm.$el.querySelector('.dropdown-content').textContent).toContain('test sync'); + }); + + it('renders input when searchable is true', done => { + vm.searchable = true; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.dropdown-input')).not.toBe(null); + + done(); + }); + }); + + it('calls clickItem on click', done => { + spyOn(vm, 'clickItem').and.stub(); + + vm.$nextTick(() => { + vm.$el.querySelector('.dropdown-content button').click(); + + expect(vm.clickItem).toHaveBeenCalledWith({ + name: 'test sync', + }); + + done(); + }); + }); + + it('searches template data', () => { + vm.searchable = true; + vm.search = 'hello'; + + expect(vm.outputData).toEqual([]); + }); + + it('does not filter data is searchable is false', () => { + vm.search = 'hello'; + + expect(vm.outputData).toEqual([ + { + name: 'test sync', + }, + ]); + }); + + it('renders dropdown title', done => { + vm.title = 'Test title'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.dropdown-title').textContent).toContain('Test title'); + + done(); + }); + }); + }); +}); diff --git a/spec/javascripts/ide/components/repo_commit_section_spec.js b/spec/javascripts/ide/components/repo_commit_section_spec.js index 30cd92b2ca485264db4938747c01420b9f1252d8..d09ccd7ac34fded59199d5e6e0a9a9a8f4b3539c 100644 --- a/spec/javascripts/ide/components/repo_commit_section_spec.js +++ b/spec/javascripts/ide/components/repo_commit_section_spec.js @@ -111,7 +111,7 @@ describe('RepoCommitSection', () => { .then(vm.$nextTick) .then(() => { expect(vm.$el.querySelector('.ide-commit-list-container').textContent).toContain( - 'No changes', + 'There are no unstaged changes', ); }) .then(done) @@ -133,7 +133,7 @@ describe('RepoCommitSection', () => { }); it('discards a single file', done => { - vm.$el.querySelector('.multi-file-discard-btn .dropdown-menu button').click(); + vm.$el.querySelector('.multi-file-commit-list li:first-child .js-modal-primary-action').click(); Vue.nextTick(() => { expect(vm.$el.querySelector('.ide-commit-list-container').textContent).not.toContain('file1'); diff --git a/spec/javascripts/ide/helpers.js b/spec/javascripts/ide/helpers.js index c11c482fef8efb25d6fe20bba1864191ac51af79..3ce9c9fcda112ba30df4df70ef75a2559d5bc6e0 100644 --- a/spec/javascripts/ide/helpers.js +++ b/spec/javascripts/ide/helpers.js @@ -5,6 +5,7 @@ import commitState from '~/ide/stores/modules/commit/state'; import mergeRequestsState from '~/ide/stores/modules/merge_requests/state'; import pipelinesState from '~/ide/stores/modules/pipelines/state'; import branchesState from '~/ide/stores/modules/branches/state'; +import fileTemplatesState from '~/ide/stores/modules/file_templates/state'; export const resetStore = store => { const newState = { @@ -13,6 +14,7 @@ export const resetStore = store => { mergeRequests: mergeRequestsState(), pipelines: pipelinesState(), branches: branchesState(), + fileTemplates: fileTemplatesState(), }; store.replaceState(newState); }; diff --git a/spec/javascripts/ide/stores/actions/file_spec.js b/spec/javascripts/ide/stores/actions/file_spec.js index bca2033ff97612271ef52ee31ab32b8d559487cc..1ca811e996b43a3032884e78b6d41426cd500439 100644 --- a/spec/javascripts/ide/stores/actions/file_spec.js +++ b/spec/javascripts/ide/stores/actions/file_spec.js @@ -692,21 +692,6 @@ describe('IDE store file actions', () => { .then(done) .catch(done.fail); }); - - it('calls scrollToTab', done => { - const scrollToTabSpy = jasmine.createSpy('scrollToTab'); - const oldScrollToTab = store._actions.scrollToTab; // eslint-disable-line - store._actions.scrollToTab = [scrollToTabSpy]; // eslint-disable-line - - store - .dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }) - .then(() => { - expect(scrollToTabSpy).toHaveBeenCalled(); - store._actions.scrollToTab = oldScrollToTab; // eslint-disable-line - }) - .then(done) - .catch(done.fail); - }); }); describe('removePendingTab', () => { diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js index d84f1717a6188f7cc65d5f2f4abeb20fe008d614..c9a1158a14ed7da593e3bb823ffe00e12f4bfd48 100644 --- a/spec/javascripts/ide/stores/actions_spec.js +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -305,7 +305,11 @@ describe('Multi-file store actions', () => { describe('stageAllChanges', () => { it('adds all files from changedFiles to stagedFiles', done => { - store.state.changedFiles.push(file(), file('new')); + const openFile = { ...file(), path: 'test' }; + + store.state.openFiles.push(openFile); + store.state.stagedFiles.push(openFile); + store.state.changedFiles.push(openFile, file('new')); testAction( stageAllChanges, @@ -316,7 +320,12 @@ describe('Multi-file store actions', () => { { type: types.STAGE_CHANGE, payload: store.state.changedFiles[0].path }, { type: types.STAGE_CHANGE, payload: store.state.changedFiles[1].path }, ], - [], + [ + { + type: 'openPendingTab', + payload: { file: openFile, keyPrefix: 'staged' }, + }, + ], done, ); }); @@ -324,7 +333,11 @@ describe('Multi-file store actions', () => { describe('unstageAllChanges', () => { it('removes all files from stagedFiles after unstaging', done => { - store.state.stagedFiles.push(file(), file('new')); + const openFile = { ...file(), path: 'test' }; + + store.state.openFiles.push(openFile); + store.state.changedFiles.push(openFile); + store.state.stagedFiles.push(openFile, file('new')); testAction( unstageAllChanges, @@ -334,7 +347,12 @@ describe('Multi-file store actions', () => { { type: types.UNSTAGE_CHANGE, payload: store.state.stagedFiles[0].path }, { type: types.UNSTAGE_CHANGE, payload: store.state.stagedFiles[1].path }, ], - [], + [ + { + type: 'openPendingTab', + payload: { file: openFile, keyPrefix: 'unstaged' }, + }, + ], done, ); }); diff --git a/spec/javascripts/ide/stores/modules/file_templates/actions_spec.js b/spec/javascripts/ide/stores/modules/file_templates/actions_spec.js index f831a9f0a5d17845c1faa07eb128de5d574725a9..c29dd9f0d06958e025cfaf721259d0e45b100298 100644 --- a/spec/javascripts/ide/stores/modules/file_templates/actions_spec.js +++ b/spec/javascripts/ide/stores/modules/file_templates/actions_spec.js @@ -148,14 +148,66 @@ describe('IDE file templates actions', () => { }); describe('setSelectedTemplateType', () => { - it('commits SET_SELECTED_TEMPLATE_TYPE', done => { - testAction( - actions.setSelectedTemplateType, - 'test', - state, - [{ type: types.SET_SELECTED_TEMPLATE_TYPE, payload: 'test' }], - [], - done, + it('commits SET_SELECTED_TEMPLATE_TYPE', () => { + const commit = jasmine.createSpy('commit'); + const options = { + commit, + dispatch() {}, + rootGetters: { + activeFile: { + name: 'test', + prevPath: '', + }, + }, + }; + + actions.setSelectedTemplateType(options, { name: 'test' }); + + expect(commit).toHaveBeenCalledWith(types.SET_SELECTED_TEMPLATE_TYPE, { name: 'test' }); + }); + + it('dispatches discardFileChanges if prevPath matches templates name', () => { + const dispatch = jasmine.createSpy('dispatch'); + const options = { + commit() {}, + dispatch, + rootGetters: { + activeFile: { + name: 'test', + path: 'test', + prevPath: 'test', + }, + }, + }; + + actions.setSelectedTemplateType(options, { name: 'test' }); + + expect(dispatch).toHaveBeenCalledWith('discardFileChanges', 'test', { root: true }); + }); + + it('dispatches renameEntry if file name doesnt match', () => { + const dispatch = jasmine.createSpy('dispatch'); + const options = { + commit() {}, + dispatch, + rootGetters: { + activeFile: { + name: 'oldtest', + path: 'oldtest', + prevPath: '', + }, + }, + }; + + actions.setSelectedTemplateType(options, { name: 'test' }); + + expect(dispatch).toHaveBeenCalledWith( + 'renameEntry', + { + path: 'oldtest', + name: 'test', + }, + { root: true }, ); }); }); @@ -332,5 +384,20 @@ describe('IDE file templates actions', () => { expect(commit).toHaveBeenCalledWith('SET_UPDATE_SUCCESS', false); }); + + it('dispatches discardFileChanges if file has prevPath', () => { + const dispatch = jasmine.createSpy('dispatch'); + const rootGetters = { + activeFile: { path: 'test', prevPath: 'newtest', raw: 'raw content' }, + }; + + actions.undoFileTemplate({ dispatch, commit() {}, rootGetters }); + + expect(dispatch.calls.mostRecent().args).toEqual([ + 'discardFileChanges', + 'test', + { root: true }, + ]); + }); }); }); diff --git a/spec/javascripts/ide/stores/modules/file_templates/getters_spec.js b/spec/javascripts/ide/stores/modules/file_templates/getters_spec.js index e337c3f331b42a42bb01b0b1bd50b88d40ac3652..17cb457881f1e9d06c2f8dd4cc5df306b44743c7 100644 --- a/spec/javascripts/ide/stores/modules/file_templates/getters_spec.js +++ b/spec/javascripts/ide/stores/modules/file_templates/getters_spec.js @@ -1,3 +1,5 @@ +import createState from '~/ide/stores/state'; +import { activityBarViews } from '~/ide/constants'; import * as getters from '~/ide/stores/modules/file_templates/getters'; describe('IDE file templates getters', () => { @@ -8,22 +10,49 @@ describe('IDE file templates getters', () => { }); describe('showFileTemplatesBar', () => { - it('finds template type by name', () => { + let rootState; + + beforeEach(() => { + rootState = createState(); + }); + + it('returns true if template is found and currentActivityView is edit', () => { + rootState.currentActivityView = activityBarViews.edit; + + expect( + getters.showFileTemplatesBar( + null, + { + templateTypes: getters.templateTypes(), + }, + rootState, + )('LICENSE'), + ).toBe(true); + }); + + it('returns false if template is found and currentActivityView is not edit', () => { + rootState.currentActivityView = activityBarViews.commit; + expect( - getters.showFileTemplatesBar(null, { - templateTypes: getters.templateTypes(), - })('LICENSE'), - ).toEqual({ - name: 'LICENSE', - key: 'licenses', - }); + getters.showFileTemplatesBar( + null, + { + templateTypes: getters.templateTypes(), + }, + rootState, + )('LICENSE'), + ).toBe(false); }); it('returns undefined if not found', () => { expect( - getters.showFileTemplatesBar(null, { - templateTypes: getters.templateTypes(), - })('test'), + getters.showFileTemplatesBar( + null, + { + templateTypes: getters.templateTypes(), + }, + rootState, + )('test'), ).toBe(undefined); }); }); diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js index 6ce76aaa03b425b059256f48686dec3248da67c9..41dd3d3c67f8f319c493fa8f36746bbe27c171d7 100644 --- a/spec/javascripts/ide/stores/mutations_spec.js +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -339,5 +339,13 @@ describe('Multi-file store mutations', () => { expect(localState.entries.parentPath.tree.length).toBe(1); }); + + it('adds to openFiles if previously opened', () => { + localState.entries.oldPath.opened = true; + + mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' }); + + expect(localState.openFiles).toEqual([localState.entries.newPath]); + }); }); }); diff --git a/spec/javascripts/monitoring/graph/flag_spec.js b/spec/javascripts/monitoring/graph/flag_spec.js index 91453a367b9262123a3dee03fd11e9b4fbd17018..ab12ed447b2823661b4401391b68f9bedb392c93 100644 --- a/spec/javascripts/monitoring/graph/flag_spec.js +++ b/spec/javascripts/monitoring/graph/flag_spec.js @@ -35,7 +35,7 @@ const defaultValuesComponent = { unitOfDisplay: 'ms', currentDataIndex: 0, legendTitle: 'Average', - currentCoordinates: [], + currentCoordinates: {}, }; const deploymentFlagData = { diff --git a/spec/javascripts/monitoring/graph_spec.js b/spec/javascripts/monitoring/graph_spec.js index a46a387a5345306232bbd5569bebd33bfe6f62f6..990619b4109699f1d5c792d5788d3c34eea84d0a 100644 --- a/spec/javascripts/monitoring/graph_spec.js +++ b/spec/javascripts/monitoring/graph_spec.js @@ -113,6 +113,9 @@ describe('Graph', () => { projectPath, }); + // simulate moving mouse over data series + component.seriesUnderMouse = component.timeSeries; + component.positionFlag(); expect(component.currentData).toBe(component.timeSeries[0].values[10]); }); diff --git a/spec/lib/api/helpers/pagination_spec.rb b/spec/lib/api/helpers/pagination_spec.rb index c73c6023b60c468dd9ffc3fd1fb432aa7a8d6db1..0a7682d906bb5a5e1bed44fb99a51cea4511edf4 100644 --- a/spec/lib/api/helpers/pagination_spec.rb +++ b/spec/lib/api/helpers/pagination_spec.rb @@ -189,9 +189,9 @@ it 'it returns the right link to the next page' do allow(subject).to receive(:params) .and_return({ pagination: 'keyset', ks_prev_id: projects[3].id, ks_prev_name: projects[3].name, per_page: 2 }) + expect_header('X-Per-Page', '2') expect_header('X-Next-Page', "#{value}?ks_prev_id=#{projects[6].id}&ks_prev_name=#{projects[6].name}&pagination=keyset&per_page=2") - expect_header('Link', anything) do |_key, val| expect(val).to include('rel="next"') end diff --git a/spec/lib/banzai/filter/spaced_link_filter_spec.rb b/spec/lib/banzai/filter/spaced_link_filter_spec.rb index 4463c0115228f9b4fbe8cf1a7742ac20b9278a79..1ad7f3ff5675443550c441c6623bc9269a634b80 100644 --- a/spec/lib/banzai/filter/spaced_link_filter_spec.rb +++ b/spec/lib/banzai/filter/spaced_link_filter_spec.rb @@ -3,49 +3,73 @@ describe Banzai::Filter::SpacedLinkFilter do include FilterSpecHelper - let(:link) { '[example](page slug)' } + let(:link) { '[example](page slug)' } + let(:image) { '' } - it 'converts slug with spaces to a link' do - doc = filter("See #{link}") + context 'when a link is detected' do + it 'converts slug with spaces to a link' do + doc = filter("See #{link}") - expect(doc.at_css('a').text).to eq 'example' - expect(doc.at_css('a')['href']).to eq 'page%20slug' - expect(doc.at_css('p')).to eq nil - end + expect(doc.at_css('a').text).to eq 'example' + expect(doc.at_css('a')['href']).to eq 'page%20slug' + expect(doc.at_css('a')['title']).to be_nil + expect(doc.at_css('p')).to be_nil + end - it 'converts slug with spaces and a title to a link' do - link = '[example](page slug "title")' - doc = filter("See #{link}") + it 'converts slug with spaces and a title to a link' do + link = '[example](page slug "title")' + doc = filter("See #{link}") - expect(doc.at_css('a').text).to eq 'example' - expect(doc.at_css('a')['href']).to eq 'page%20slug' - expect(doc.at_css('a')['title']).to eq 'title' - expect(doc.at_css('p')).to eq nil - end + expect(doc.at_css('a').text).to eq 'example' + expect(doc.at_css('a')['href']).to eq 'page%20slug' + expect(doc.at_css('a')['title']).to eq 'title' + expect(doc.at_css('p')).to be_nil + end - it 'does nothing when markdown_engine is redcarpet' do - exp = act = link - expect(filter(act, markdown_engine: :redcarpet).to_html).to eq exp - end + it 'does nothing when markdown_engine is redcarpet' do + exp = act = link + expect(filter(act, markdown_engine: :redcarpet).to_html).to eq exp + end + + it 'does nothing with empty text' do + link = '[](page slug)' + doc = filter("See #{link}") + + expect(doc.at_css('a')).to be_nil + end - it 'does nothing with empty text' do - link = '[](page slug)' - doc = filter("See #{link}") + it 'does nothing with an empty slug' do + link = '[example]()' + doc = filter("See #{link}") - expect(doc.at_css('a')).to eq nil + expect(doc.at_css('a')).to be_nil + end end - it 'does nothing with an empty slug' do - link = '[example]()' - doc = filter("See #{link}") + context 'when an image is detected' do + it 'converts slug with spaces to an iamge' do + doc = filter("See #{image}") + + expect(doc.at_css('img')['src']).to eq 'img%20test.jpg' + expect(doc.at_css('img')['alt']).to eq 'example' + expect(doc.at_css('p')).to be_nil + end + + it 'converts slug with spaces and a title to an image' do + image = '' + doc = filter("See #{image}") - expect(doc.at_css('a')).to eq nil + expect(doc.at_css('img')['src']).to eq 'img%20test.jpg' + expect(doc.at_css('img')['alt']).to eq 'example' + expect(doc.at_css('img')['title']).to eq 'title' + expect(doc.at_css('p')).to be_nil + end end it 'converts multiple URLs' do link1 = '[first](slug one)' link2 = '[second](http://example.com/slug two)' - doc = filter("See #{link1} and #{link2}") + doc = filter("See #{link1} and #{image} and #{link2}") found_links = doc.css('a') @@ -54,6 +78,12 @@ expect(found_links[0]['href']).to eq 'slug%20one' expect(found_links[1].text).to eq 'second' expect(found_links[1]['href']).to eq 'http://example.com/slug%20two' + + found_images = doc.css('img') + + expect(found_images.size).to eq(1) + expect(found_images[0]['src']).to eq 'img%20test.jpg' + expect(found_images[0]['alt']).to eq 'example' end described_class::IGNORE_PARENTS.each do |elem| diff --git a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb index 52b8c9be6470d253802e5cdcf9e9478160bd8db6..64ca3ec345d1d8e8a64512ad62c0284884240c68 100644 --- a/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb +++ b/spec/lib/banzai/pipeline/wiki_pipeline_spec.rb @@ -178,4 +178,25 @@ end end end + + describe 'videos' do + let(:namespace) { create(:namespace, name: "wiki_link_ns") } + let(:project) { create(:project, :public, name: "wiki_link_project", namespace: namespace) } + let(:project_wiki) { ProjectWiki.new(project, double(:user)) } + let(:page) { build(:wiki_page, wiki: project_wiki, page: OpenStruct.new(url_path: 'nested/twice/start-page')) } + + it 'generates video html structure' do + markdown = "" + output = described_class.to_html(markdown, project: project, project_wiki: project_wiki, page_slug: page.slug) + + expect(output).to include('<video src="/wiki_link_ns/wiki_link_project/wikis/nested/twice/video_file_name.mp4"') + end + + it 'rewrites and replaces video links names with white spaces to %20' do + markdown = "" + output = described_class.to_html(markdown, project: project, project_wiki: project_wiki, page_slug: page.slug) + + expect(output).to include('<video src="/wiki_link_ns/wiki_link_project/wikis/nested/twice/video%20file%20name.mp4"') + end + end end diff --git a/spec/lib/forever_spec.rb b/spec/lib/forever_spec.rb index cf40c467c722ae6dea74ae1d76d42be15345a6dc..494c0561975c6e6f1aa2f4b18982e9db611040f4 100644 --- a/spec/lib/forever_spec.rb +++ b/spec/lib/forever_spec.rb @@ -7,6 +7,7 @@ context 'when using PostgreSQL' do it 'should return Postgresql future date' do allow(Gitlab::Database).to receive(:postgresql?).and_return(true) + expect(subject).to eq(described_class::POSTGRESQL_DATE) end end @@ -14,6 +15,7 @@ context 'when using MySQL' do it 'should return MySQL future date' do allow(Gitlab::Database).to receive(:postgresql?).and_return(false) + expect(subject).to eq(described_class::MYSQL_DATE) end end diff --git a/spec/lib/gitlab/contributions_calendar_spec.rb b/spec/lib/gitlab/contributions_calendar_spec.rb index 2c63f3b0455f1f13a643fbacdc5debef8bf9489d..6d29044ffd52e68dc49b8b8af21186af05a254b6 100644 --- a/spec/lib/gitlab/contributions_calendar_spec.rb +++ b/spec/lib/gitlab/contributions_calendar_spec.rb @@ -62,13 +62,16 @@ def create_event(project, day, hour = 0, action = Event::CREATED, target_symbol expect(calendar.activity_dates).to eq(last_week => 2, today => 1) end - it "only shows private events to authorized users" do - create_event(private_project, today) - create_event(feature_project, today) + context "when the user has opted-in for private contributions" do + it "shows private and public events to all users" do + user.update_column(:include_private_contributions, true) + create_event(private_project, today) + create_event(public_project, today) - expect(calendar.activity_dates[today]).to eq(0) - expect(calendar(user).activity_dates[today]).to eq(0) - expect(calendar(contributor).activity_dates[today]).to eq(2) + expect(calendar.activity_dates[today]).to eq(1) + expect(calendar(user).activity_dates[today]).to eq(1) + expect(calendar(contributor).activity_dates[today]).to eq(2) + end end it "counts the diff notes on merge request" do @@ -128,7 +131,7 @@ def create_event(project, day, hour = 0, action = Event::CREATED, target_symbol e3 = create_event(feature_project, today) create_event(public_project, last_week) - expect(calendar.events_by_date(today)).to contain_exactly(e1) + expect(calendar.events_by_date(today)).to contain_exactly(e1, e3) expect(calendar(contributor).events_by_date(today)).to contain_exactly(e1, e2, e3) end diff --git a/spec/lib/gitlab/diff/highlight_cache_spec.rb b/spec/lib/gitlab/diff/highlight_cache_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..bfcfed4231f7a95d139031f12f888cc230ef6e6c --- /dev/null +++ b/spec/lib/gitlab/diff/highlight_cache_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe Gitlab::Diff::HighlightCache do + let(:merge_request) { create(:merge_request_with_diffs) } + + subject(:cache) { described_class.new(merge_request.diffs, backend: backend) } + + describe '#decorate' do + let(:backend) { double('backend').as_null_object } + + # Manually creates a Diff::File object to avoid triggering the cache on + # the FileCollection::MergeRequestDiff + let(:diff_file) do + diffs = merge_request.diffs + raw_diff = diffs.diffable.raw_diffs(diffs.diff_options.merge(paths: ['CHANGELOG'])).first + Gitlab::Diff::File.new(raw_diff, + repository: diffs.project.repository, + diff_refs: diffs.diff_refs, + fallback_diff_refs: diffs.fallback_diff_refs) + end + + it 'does not calculate highlighting when reading from cache' do + cache.write_if_empty + cache.decorate(diff_file) + + expect_any_instance_of(Gitlab::Diff::Highlight).not_to receive(:highlight) + + diff_file.highlighted_diff_lines + end + + it 'assigns highlighted diff lines to the DiffFile' do + cache.write_if_empty + cache.decorate(diff_file) + + expect(diff_file.highlighted_diff_lines.size).to be > 5 + end + + it 'submits a single reading from the cache' do + cache.decorate(diff_file) + cache.decorate(diff_file) + + expect(backend).to have_received(:read).with(cache.key).once + end + end + + describe '#write_if_empty' do + let(:backend) { double('backend', read: {}).as_null_object } + + it 'submits a single writing to the cache' do + cache.write_if_empty + cache.write_if_empty + + expect(backend).to have_received(:write).with(cache.key, + hash_including('CHANGELOG-false-false-false'), + expires_in: 1.week).once + end + end + + describe '#clear' do + let(:backend) { double('backend').as_null_object } + + it 'clears cache' do + cache.clear + + expect(backend).to have_received(:delete).with(cache.key) + end + end +end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 1098a2661405a792cd0adc89be26fb710ed0e255..28c34e234f7f96efed5e2a52aa7d88ca61c7ff7b 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -591,6 +591,10 @@ def new_repository_path expect(repository.find_remote_root_ref('origin')).to eq 'master' end + it 'returns UTF-8' do + expect(repository.find_remote_root_ref('origin')).to be_utf8 + end + it 'returns nil when remote name is nil' do expect_any_instance_of(Gitlab::GitalyClient::RemoteService) .not_to receive(:find_remote_root_ref) diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb index 54f2ea33f90cfdf268ec5363b124d86a4ac9b67d..bcdf12a00a0e50fe1731861db1eb2f835e9596fc 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -19,7 +19,14 @@ right_commit_id: commit.id, collapse_diffs: false, enforce_limits: true, - **Gitlab::Git::DiffCollection.collection_limits.to_h + # Tests limitation parameters explicitly + max_files: 100, + max_lines: 5000, + max_bytes: 512000, + safe_max_files: 100, + safe_max_lines: 5000, + safe_max_bytes: 512000, + max_patch_bytes: 102400 ) expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_diff).with(request, kind_of(Hash)) @@ -37,7 +44,14 @@ right_commit_id: initial_commit.id, collapse_diffs: false, enforce_limits: true, - **Gitlab::Git::DiffCollection.collection_limits.to_h + # Tests limitation parameters explicitly + max_files: 100, + max_lines: 5000, + max_bytes: 512000, + safe_max_files: 100, + safe_max_lines: 5000, + safe_max_bytes: 512000, + max_patch_bytes: 102400 ) expect_any_instance_of(Gitaly::DiffService::Stub).to receive(:commit_diff).with(request, kind_of(Hash)) diff --git a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb index b8831c54aba3865bdf44c00e0c255983a7718467..9030a49983de04e16652dcd3340c5bc02ea469eb 100644 --- a/spec/lib/gitlab/gitaly_client/remote_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/remote_service_spec.rb @@ -54,6 +54,15 @@ expect(client.find_remote_root_ref('origin')).to eq 'master' end + + it 'ensure ref is a valid UTF-8 string' do + expect_any_instance_of(Gitaly::RemoteService::Stub) + .to receive(:find_remote_root_ref) + .with(gitaly_request_with_path(storage_name, relative_path), kind_of(Hash)) + .and_return(double(ref: "an_invalid_ref_\xE5")) + + expect(client.find_remote_root_ref('origin')).to eq "an_invalid_ref_Ã¥" + end end describe '#update_remote_mirror' do diff --git a/spec/migrations/remove_orphaned_label_links_spec.rb b/spec/migrations/remove_orphaned_label_links_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..13b8919343e786bc784dc8c0f6e539674b086482 --- /dev/null +++ b/spec/migrations/remove_orphaned_label_links_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' +require Rails.root.join('db', 'post_migrate', '20180906051323_remove_orphaned_label_links.rb') + +describe RemoveOrphanedLabelLinks, :migration do + let(:label_links) { table(:label_links) } + let(:labels) { table(:labels) } + + let(:project) { create(:project) } # rubocop:disable RSpec/FactoriesInMigrationSpecs + let(:label) { create_label } + + context 'add foreign key on label_id' do + let!(:label_link_with_label) { create_label_link(label_id: label.id) } + let!(:label_link_without_label) { create_label_link(label_id: nil) } + + it 'removes orphaned labels without corresponding label' do + expect { migrate! }.to change { LabelLink.count }.from(2).to(1) + end + + it 'does not remove entries with valid label_id' do + expect { migrate! }.not_to change { label_link_with_label.reload } + end + end + + def create_label(**opts) + labels.create!( + project_id: project.id, + **opts + ) + end + + def create_label_link(**opts) + label_links.create!( + target_id: 1, + target_type: 'Issue', + **opts + ) + end +end diff --git a/spec/models/project_services/jira_service_spec.rb b/spec/models/project_services/jira_service_spec.rb index 54f1a0e38a598db655f559dafad3dec84bc1f0f7..788b3179b018f10b99a94754541980f531df958f 100644 --- a/spec/models/project_services/jira_service_spec.rb +++ b/spec/models/project_services/jira_service_spec.rb @@ -231,12 +231,12 @@ end it 'logs exception when transition id is not valid' do - allow(Rails.logger).to receive(:info) - WebMock.stub_request(:post, @transitions_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password)).and_raise('Bad Request') + allow(@jira_service).to receive(:log_error) + WebMock.stub_request(:post, @transitions_url).with(basic_auth: %w(gitlab_jira_username gitlab_jira_password)).and_raise("Bad Request") @jira_service.close_issue(resource, ExternalIssue.new('JIRA-123', project)) - expect(Rails.logger).to have_received(:info).with('JiraService Issue Transition failed message ERROR: http://jira.example.com - Bad Request') + expect(@jira_service).to have_received(:log_error).with("Issue transition failed", error: "Bad Request", client_url: "http://jira.example.com") end it 'calls the api with jira_issue_transition_id' do diff --git a/spec/models/service_spec.rb b/spec/models/service_spec.rb index 029ad7f3e9f2afbe372bf1e33ac3c0345714ca03..25eecb3f9090f28890b9d23ff1b5062ab465bb0b 100644 --- a/spec/models/service_spec.rb +++ b/spec/models/service_spec.rb @@ -345,4 +345,31 @@ def fields expect(service.api_field_names).to eq(['safe_field']) end end + + context 'logging' do + let(:project) { create(:project) } + let(:service) { create(:service, project: project) } + let(:test_message) { "test message" } + let(:arguments) do + { + service_class: service.class.name, + project_path: project.full_path, + project_id: project.id, + message: test_message, + additional_argument: 'some argument' + } + end + + it 'logs info messages using json logger' do + expect(Gitlab::JsonLogger).to receive(:info).with(arguments) + + service.log_info(test_message, additional_argument: 'some argument') + end + + it 'logs error messages using json logger' do + expect(Gitlab::JsonLogger).to receive(:error).with(arguments) + + service.log_error(test_message, additional_argument: 'some argument') + end + end end diff --git a/spec/services/merge_requests/reload_diffs_service_spec.rb b/spec/services/merge_requests/reload_diffs_service_spec.rb index a0a27d247fcedf8be6599f4a6b10de17625b2054..21f369a3818ec9f915150282261b5129dfe8dd71 100644 --- a/spec/services/merge_requests/reload_diffs_service_spec.rb +++ b/spec/services/merge_requests/reload_diffs_service_spec.rb @@ -57,6 +57,7 @@ expect(Rails.cache).to receive(:delete).with(old_cache_key).and_call_original expect(Rails.cache).to receive(:read).with(new_cache_key).and_call_original expect(Rails.cache).to receive(:write).with(new_cache_key, anything, anything).and_call_original + subject.execute end end diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 0dfd69155270223c10a6e1450c551dc400d85464..da60916428ecc7d924f36152108f302ed209e58a 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -688,7 +688,7 @@ def build_note(old_assignees, new_assignees) let(:jira_tracker) { project.jira_service } let(:commit) { project.commit } let(:comment_url) { jira_api_comment_url(jira_issue.id) } - let(:success_message) { "JiraService SUCCESS: Successfully posted to http://jira.example.net." } + let(:success_message) { "SUCCESS: Successfully posted to http://jira.example.net." } before do stub_jira_urls(jira_issue.id) diff --git a/spec/services/wikis/create_attachment_service_spec.rb b/spec/services/wikis/create_attachment_service_spec.rb index 3f4da873ce44f8fed28353fd08d6c218be4d0c40..f5899f292c898e7be7466f17e6a7844de81491d4 100644 --- a/spec/services/wikis/create_attachment_service_spec.rb +++ b/spec/services/wikis/create_attachment_service_spec.rb @@ -88,8 +88,30 @@ end end - describe 'validations' do + describe '#parse_file_name' do context 'when file_name' do + context 'has white spaces' do + let(:file_name) { 'file with spaces' } + + it "replaces all of them with '_'" do + result = service.execute + + expect(result[:status]).to eq :success + expect(result[:result][:file_name]).to eq 'file_with_spaces' + end + end + + context 'has other invalid characters' do + let(:file_name) { "file\twith\tinvalid chars" } + + it "replaces all of them with '_'" do + result = service.execute + + expect(result[:status]).to eq :success + expect(result[:result][:file_name]).to eq 'file_with_invalid_chars' + end + end + context 'is not present' do let(:file_name) { nil } diff --git a/spec/tasks/gitlab/cleanup_rake_spec.rb b/spec/tasks/gitlab/cleanup_rake_spec.rb index cc2cca10f585dcad6b34327216e5fb8c5363587d..19794227d9f7fd7f1839d2936f8b8d034225af2f 100644 --- a/spec/tasks/gitlab/cleanup_rake_spec.rb +++ b/spec/tasks/gitlab/cleanup_rake_spec.rb @@ -6,6 +6,8 @@ end describe 'cleanup namespaces and repos' do + let(:gitlab_shell) { Gitlab::Shell.new } + let(:storage) { storages.keys.first } let(:storages) do { 'default' => Gitlab::GitalyClient::StorageSettings.new(@default_storage_hash.merge('path' => 'tmp/tests/default_storage')) @@ -17,53 +19,56 @@ end before do - FileUtils.mkdir(Settings.absolute('tmp/tests/default_storage')) allow(Gitlab.config.repositories).to receive(:storages).and_return(storages) end after do - FileUtils.rm_rf(Settings.absolute('tmp/tests/default_storage')) + Gitlab::GitalyClient::StorageService.new(storage).delete_all_repositories end describe 'cleanup:repos' do before do - FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/broken/project.git')) - FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git')) + gitlab_shell.add_namespace(storage, 'broken/project.git') + gitlab_shell.add_namespace(storage, '@hashed/12/34/5678.git') end it 'moves it to an orphaned path' do - run_rake_task('gitlab:cleanup:repos') - repo_list = Dir['tmp/tests/default_storage/broken/*'] + now = Time.now + + Timecop.freeze(now) do + run_rake_task('gitlab:cleanup:repos') + repo_list = Gitlab::GitalyClient::StorageService.new(storage).list_directories(depth: 0) - expect(repo_list.first).to include('+orphaned+') + expect(repo_list.last).to include("broken+orphaned+#{now.to_i}") + end end it 'ignores @hashed repos' do run_rake_task('gitlab:cleanup:repos') - expect(Dir.exist?(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git'))).to be_truthy + expect(gitlab_shell.exists?(storage, '@hashed/12/34/5678.git')).to be(true) end end describe 'cleanup:dirs' do it 'removes missing namespaces' do - FileUtils.mkdir_p(Settings.absolute("tmp/tests/default_storage/namespace_1/project.git")) - FileUtils.mkdir_p(Settings.absolute("tmp/tests/default_storage/namespace_2/project.git")) - allow(Namespace).to receive(:pluck).and_return('namespace_1') + gitlab_shell.add_namespace(storage, "namespace_1/project.git") + gitlab_shell.add_namespace(storage, "namespace_2/project.git") + allow(Namespace).to receive(:pluck).and_return(['namespace_1']) stub_env('REMOVE', 'true') run_rake_task('gitlab:cleanup:dirs') - expect(Dir.exist?(Settings.absolute('tmp/tests/default_storage/namespace_1'))).to be_truthy - expect(Dir.exist?(Settings.absolute('tmp/tests/default_storage/namespace_2'))).to be_falsey + expect(gitlab_shell.exists?(storage, 'namespace_1')).to be(true) + expect(gitlab_shell.exists?(storage, 'namespace_2')).to be(false) end it 'ignores @hashed directory' do - FileUtils.mkdir_p(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git')) + gitlab_shell.add_namespace(storage, '@hashed/12/34/5678.git') run_rake_task('gitlab:cleanup:dirs') - expect(Dir.exist?(Settings.absolute('tmp/tests/default_storage/@hashed/12/34/5678.git'))).to be_truthy + expect(gitlab_shell.exists?(storage, '@hashed/12/34/5678.git')).to be(true) end end end diff --git a/spec/uploaders/namespace_file_uploader_spec.rb b/spec/uploaders/namespace_file_uploader_spec.rb index 71fe2c353c07fc0e4f6b1bdca5ab103f38ac5c79..eafbea07e10cac0a992d28202a39009d692979f1 100644 --- a/spec/uploaders/namespace_file_uploader_spec.rb +++ b/spec/uploaders/namespace_file_uploader_spec.rb @@ -26,6 +26,20 @@ upload_path: IDENTIFIER end + context '.base_dir' do + it 'returns local storage base_dir without store param' do + expect(described_class.base_dir(group)).to eq("uploads/-/system/namespace/#{group.id}") + end + + it 'returns local storage base_dir when store param is Store::LOCAL' do + expect(described_class.base_dir(group, ObjectStorage::Store::LOCAL)).to eq("uploads/-/system/namespace/#{group.id}") + end + + it 'returns remote base_dir when store param is Store::REMOTE' do + expect(described_class.base_dir(group, ObjectStorage::Store::REMOTE)).to eq("namespace/#{group.id}") + end + end + describe "#migrate!" do before do uploader.store!(fixture_file_upload(File.join('spec/fixtures/doc_sample.txt'))) diff --git a/yarn.lock b/yarn.lock index b020332d3695a4293983cc380a6d8f84f2f20550..0a1b964ffef05120ad202105acb726df0f51cbc7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -78,11 +78,7 @@ lodash "^4.2.0" to-fast-properties "^2.0.0" -"@gitlab-org/gitlab-svgs@^1.23.0": - version "1.27.0" - resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.27.0.tgz#638e70399ebd59e503732177316bb9a18bf7a13f" - -"@gitlab-org/gitlab-svgs@^1.29.0": +"@gitlab-org/gitlab-svgs@^1.23.0", "@gitlab-org/gitlab-svgs@^1.29.0": version "1.29.0" resolved "https://registry.yarnpkg.com/@gitlab-org/gitlab-svgs/-/gitlab-svgs-1.29.0.tgz#03b65b513f9099bbda6ecf94d673a2952f8c6c70" @@ -307,11 +303,7 @@ acorn@^3.0.4: version "3.3.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-3.3.0.tgz#45e37fb39e8da3f25baee3ff5369e2bb5f22017a" -acorn@^5.0.0, acorn@^5.3.0, acorn@^5.5.0: - version "5.6.2" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.6.2.tgz#b1da1d7be2ac1b4a327fb9eab851702c5045b4e7" - -acorn@^5.6.2: +acorn@^5.0.0, acorn@^5.3.0, acorn@^5.5.0, acorn@^5.6.2: version "5.7.1" resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.1.tgz#f095829297706a7c9776958c0afc8930a9b9d9d8" @@ -553,13 +545,7 @@ async@1.x, async@^1.4.0, async@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/async/-/async-1.5.2.tgz#ec6a61ae56480c0c3cb241c95618e20892f9672a" -async@^2.0.0, async@^2.1.4: - version "2.6.0" - resolved "https://registry.yarnpkg.com/async/-/async-2.6.0.tgz#61a29abb6fcc026fea77e56d1c6ec53a795951f4" - dependencies: - lodash "^4.14.0" - -async@~2.6.0: +async@^2.0.0, async@^2.1.4, async@~2.6.0: version "2.6.1" resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" dependencies: @@ -1380,13 +1366,9 @@ bootstrap-vue@^2.0.0-rc.11: popper.js "^1.12.9" vue-functional-data-merge "^2.0.5" -bootstrap@^4.1.1: - version "4.1.3" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.3.tgz#0eb371af2c8448e8c210411d0cb824a6409a12be" - -bootstrap@~4.1.1: - version "4.1.1" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.1.tgz#3aec85000fa619085da8d2e4983dfd67cf2114cb" +bootstrap@^4.1.1, bootstrap@~4.1.1: + version "4.1.2" + resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-4.1.2.tgz#aee2a93472e61c471fc79fb475531dcbc87de326" boxen@^1.2.1: version "1.3.0" @@ -1681,25 +1663,7 @@ check-types@^7.3.0: version "7.3.0" resolved "https://registry.yarnpkg.com/check-types/-/check-types-7.3.0.tgz#468f571a4435c24248f5fd0cb0e8d87c3c341e7d" -chokidar@^2.0.0, chokidar@^2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.2.tgz#4dc65139eeb2714977735b6a35d06e97b494dfd7" - dependencies: - anymatch "^2.0.0" - async-each "^1.0.0" - braces "^2.3.0" - glob-parent "^3.1.0" - inherits "^2.0.1" - is-binary-path "^1.0.0" - is-glob "^4.0.0" - normalize-path "^2.1.1" - path-is-absolute "^1.0.0" - readdirp "^2.0.0" - upath "^1.0.0" - optionalDependencies: - fsevents "^1.0.0" - -chokidar@^2.0.3: +chokidar@^2.0.0, chokidar@^2.0.2, chokidar@^2.0.3: version "2.0.4" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.0.4.tgz#356ff4e2b0e8e43e322d18a372460bbcf3accd26" dependencies: @@ -2784,15 +2748,7 @@ engine.io@~3.1.0: optionalDependencies: uws "~9.14.0" -enhanced-resolve@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.0.0.tgz#e34a6eaa790f62fccd71d93959f56b2b432db10a" - dependencies: - graceful-fs "^4.1.2" - memory-fs "^0.4.0" - tapable "^1.0.0" - -enhanced-resolve@^4.1.0: +enhanced-resolve@^4.0.0, enhanced-resolve@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-4.1.0.tgz#41c7e0bfdfe74ac1ffe1e57ad6a5c6c9f3742a7f" dependencies: @@ -2816,13 +2772,7 @@ entities@^1.1.1, entities@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/entities/-/entities-1.1.1.tgz#6e5c2d0a5621b5dadaecef80b90edfb5cd7772f0" -errno@^0.1.3: - version "0.1.4" - resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.4.tgz#b896e23a9e5e8ba33871fc996abd3635fc9a1c7d" - dependencies: - prr "~0.0.0" - -errno@^0.1.4: +errno@^0.1.3, errno@^0.1.4: version "0.1.7" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" dependencies: @@ -3470,7 +3420,7 @@ fs.realpath@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" -fsevents@^1.0.0, fsevents@^1.2.2: +fsevents@^1.2.2: version "1.2.4" resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.4.tgz#f41dcb1af2582af3692da36fc55cbd8e1041c426" dependencies: @@ -4490,11 +4440,7 @@ istanbul-api@^1.1.14: mkdirp "^0.5.1" once "^1.4.0" -istanbul-lib-coverage@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.1.1.tgz#73bfb998885299415c93d38a3e9adf784a77a9da" - -istanbul-lib-coverage@^1.2.0: +istanbul-lib-coverage@^1.1.1, istanbul-lib-coverage@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-1.2.0.tgz#f7d8f2e42b97e37fe796114cb0f9d68b5e3a4341" @@ -4945,7 +4891,7 @@ lodash@4.17.4: version "4.17.4" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.4.tgz#78203a4d1c328ae1d86dca6460e369b57f4055ae" -lodash@^4.0.0, lodash@^4.11.1, lodash@^4.14.0, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.3.0, lodash@^4.5.0: +lodash@^4.0.0, lodash@^4.11.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.0, lodash@^4.3.0, lodash@^4.5.0: version "4.17.10" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.10.tgz#1b7793cf7259ea38fb3661d4d38b3260af8ae4e7" @@ -6003,15 +5949,7 @@ postcss-value-parser@^3.3.0: version "3.3.0" resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz#87f38f9f18f774a4ab4c8a232f5c5ce8872a9d15" -postcss@^6.0.1, postcss@^6.0.14, postcss@^6.0.20: - version "6.0.22" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.22.tgz#e23b78314905c3b90cbd61702121e7a78848f2a3" - dependencies: - chalk "^2.4.1" - source-map "^0.6.1" - supports-color "^5.4.0" - -postcss@^6.0.23: +postcss@^6.0.1, postcss@^6.0.14, postcss@^6.0.20, postcss@^6.0.23: version "6.0.23" resolved "https://registry.yarnpkg.com/postcss/-/postcss-6.0.23.tgz#61c82cc328ac60e677645f979054eb98bc0e3324" dependencies: @@ -6095,10 +6033,6 @@ proxy-from-env@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" -prr@~0.0.0: - version "0.0.0" - resolved "https://registry.yarnpkg.com/prr/-/prr-0.0.0.tgz#1a84b85908325501411853d0081ee3fa86e2926a" - prr@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" @@ -6276,16 +6210,16 @@ read-pkg@^2.0.0: normalize-package-data "^2.3.2" path-type "^2.0.0" -"readable-stream@1 || 2", readable-stream@2, readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.3: - version "2.3.4" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.4.tgz#c946c3f47fa7d8eabc0b6150f4a12f69a4574071" +"readable-stream@1 || 2", readable-stream@2, readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.6, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.6: + version "2.3.6" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" dependencies: core-util-is "~1.0.0" inherits "~2.0.3" isarray "~1.0.0" process-nextick-args "~2.0.0" safe-buffer "~5.1.1" - string_decoder "~1.0.3" + string_decoder "~1.1.1" util-deprecate "~1.0.1" readable-stream@1.1.x, "readable-stream@1.x >=1.1.9": @@ -6297,18 +6231,6 @@ readable-stream@1.1.x, "readable-stream@1.x >=1.1.9": isarray "0.0.1" string_decoder "~0.10.x" -readable-stream@^2.3.6: - version "2.3.6" - resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - readable-stream@~2.0.5, readable-stream@~2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.0.6.tgz#8f90341e68a53ccc928788dacfcd11b36eb9b78e" @@ -7021,14 +6943,10 @@ source-map@^0.4.4: dependencies: amdefine ">=0.0.4" -source-map@^0.5.0, source-map@^0.5.7, source-map@~0.5.6: +source-map@^0.5.0, source-map@^0.5.3, source-map@^0.5.6, source-map@^0.5.7, source-map@~0.5.1, source-map@~0.5.6: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" -source-map@^0.5.3, source-map@^0.5.6, source-map@~0.5.1: - version "0.5.6" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" - source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" @@ -7130,11 +7048,7 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" -"statuses@>= 1.3.1 < 2": - version "1.4.0" - resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" - -"statuses@>= 1.4.0 < 2": +"statuses@>= 1.3.1 < 2", "statuses@>= 1.4.0 < 2": version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" @@ -7218,12 +7132,6 @@ string_decoder@~0.10.x: version "0.10.31" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-0.10.31.tgz#62e203bc41766c6c28c9fc84301dab1c5310fa94" -string_decoder@~1.0.3: - version "1.0.3" - resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.0.3.tgz#0fc67d7c141825de94282dd536bec6b9bce860ab" - dependencies: - safe-buffer "~5.1.0" - stringstream@~0.0.4, stringstream@~0.0.5: version "0.0.5" resolved "https://registry.yarnpkg.com/stringstream/-/stringstream-0.0.5.tgz#4e484cd4de5a0bbbee18e46307710a8a81621878" @@ -7621,10 +7529,6 @@ unzip-response@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" -upath@^1.0.0: - version "1.0.5" - resolved "https://registry.yarnpkg.com/upath/-/upath-1.0.5.tgz#02cab9ecebe95bbec6d5fc2566325725ab6d1a73" - upath@^1.0.5: version "1.1.0" resolved "https://registry.yarnpkg.com/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd" @@ -7744,11 +7648,7 @@ validate-npm-package-license@^3.0.1: spdx-correct "~1.0.0" spdx-expression-parse "~1.0.0" -vary@~1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.1.tgz#67535ebb694c1d52257457984665323f587e8d37" - -vary@~1.1.2: +vary@~1.1.1, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc"